aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 18:08:45 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 18:08:45 +0200
commit8e35d35796d5925ac7a4ee95dd60120a3c6b9539 (patch)
tree88a4c2923a84b7d30326bb3f831affc147ddaf23 /docs/superpowers/plans
parent8cb5cc303f1c83d2c63d5cb972a7faee294e7c23 (diff)
downloadllamachat-8e35d35796d5925ac7a4ee95dd60120a3c6b9539.tar.gz
llamachat-8e35d35796d5925ac7a4ee95dd60120a3c6b9539.zip
docs: spec and plan for skills support
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-08-25-skills.md1435
1 files changed, 1435 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-25-skills.md b/docs/superpowers/plans/2026-08-25-skills.md
new file mode 100644
index 0000000..3ad154d
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-25-skills.md
@@ -0,0 +1,1435 @@
+# Skills Support Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let llamachat read instruction files from a skills directory (`~/.agents/skills/*/SKILL.md`), let the model load them on demand through a `load_skill` tool, and let the user load them by typing `/skill-name` or mentioning a skill name. A loaded skill's text stays in the system prompt for the rest of the session (chat mode) or just the reply (one-shot).
+
+**Architecture:** A new `skills.py` module parses the SKILL.md files (frontmatter `name` + `description`, markdown body) with stdlib only. The existing tool loop in `backend.stream_chat`, currently hardcoded to `web_search`, is generalized to dispatch over `web_search` plus `load_skill` by call name. The UI appends loaded skill bodies to the system prompt, persists the loaded set on the session (chat mode only), and shows removable chips in the window. This mirrors the existing `prompts.py` / web-search patterns throughout.
+
+**Tech Stack:** Python 3.11+ standard library (`re`, `json`, `sqlite3`, `tomllib`), PySide6 for the chips. No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-08-25-skills-design.md`
+
+---
+
+## Conventions for this codebase
+
+Read this before Task 1. It is not optional context.
+
+**Tests are not pytest.** `test_llamachat.py` is a single executable file of plain
+functions. Each test ends with `print("ok <short description>")`. Every test must
+be registered by name in the `if __name__ == "__main__":` block at the bottom of the
+file, in the order it should run. A test that is written but not registered never
+runs, and the suite will still say "all checks passed".
+
+Run the whole suite with:
+
+```bash
+./test_llamachat.py
+```
+
+There is no way to run a single test from the command line. To verify one test
+fails or passes in isolation, run the suite and read that test's line. Adding a
+temporary `if __name__` entry for just the new test is acceptable during a
+red/green cycle but must be restored before committing.
+
+**Every new source file needs the GPL header.** Copy it verbatim from the top of
+`llamachat/config.py`, changing nothing but the docstring on the last line.
+
+**Commits are GPG-signed automatically.** Do not pass `-c commit.gpgsign=false`.
+Two git hooks scan for personal data and secrets; treat a rejection as correct.
+Use `example.org`, fake keys, and generic paths in tests and fixtures. Never put a
+real personal detail anywhere, including a test.
+
+**Style:** comments explain why, not what. Deliberate simplifications get a
+`# ponytail:` comment naming the ceiling and the upgrade path. Match the
+surrounding code's density.
+
+**Test fixture convention:** the tests below write fake skill files into a
+`tempfile.TemporaryDirectory`. The `~/.agents/skills` directory on the developer's
+machine is real and has personal skills in it; never assert on it in a test. Every
+test creates its own skills directory.
+
+---
+
+## File Structure
+
+**New files:**
+
+| File | Responsibility |
+| --- | --- |
+| `llamachat/skills.py` | Parse `*/SKILL.md` files (frontmatter name/description + body), the `load_skill` tool schema, whole-word mention matching, `/name` command parsing, the `role: tool` message builders. No Qt, no HTTP. |
+
+**Modified files:**
+
+| File | Change |
+| --- | --- |
+| `llamachat/config.py` | Add `skills_enabled` / `skills_dir` defaults, `Config` fields, resolution (flag with no dir = off), generated config block. |
+| `llamachat/db.py` | `sessions.skills` column (JSON array of names), migration, `set_skills()`. |
+| `llamachat/backend.py` | `SkillsConfig`, generalize the tool loop to dispatch on call name, `_run_skill` handler. |
+| `llamachat/ui.py` | Worker passes `skills_cfg` and emits `skill_loaded`; loaded-skills state + injection into the system prompt; `/name` + natural-mention loading in `send()`; chips bar; session restore/clear. |
+| `test_llamachat.py` | New tests, each registered in the `__main__` block. |
+| `README.md`, `CHANGELOG.md` | Document the feature. |
+
+**Dependency direction:** `skills.py` depends on nothing in the project. `backend.py`
+imports `skills` and `search`. `ui.py` imports `skills` and `backend`. Nothing imports
+`ui`.
+
+---
+
+## Task 1: skills.py
+
+**Files:**
+- Create: `llamachat/skills.py`
+- Test: `test_llamachat.py`
+
+**Interfaces:**
+- Produces: `Skill` dataclass (`name`, `description`, `text`); `SkillStore` class with `__init__(directory: Path)`, `names() -> list[str]`, `load(name) -> Skill | None`, `directory() -> str`, `tool_schema() -> dict`, `match_mentions(text) -> list[str]`; module functions `parse_commands(text, names) -> tuple[str, list[str]]`, `parse_name(arguments) -> str`, `tool_message(call_id, skill, error="", last=False) -> dict`; constants `LOAD_SKILL_NAME = "load_skill"`, `NO_MORE_TOOLS`, `SKILL_HEADER`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append these functions to `test_llamachat.py` (near the other skill tests will be fine, but placement does not matter since the file registers by name). Also add the fixture dict near the top, after `PRESETS_SAMPLE`:
+
+```python
+SKILLS_SAMPLE = {
+ "firefly-cli/SKILL.md": (
+ "---\n"
+ "name: firefly-cli\n"
+ "description: Operate a Firefly III instance from the command line.\n"
+ "---\n"
+ "\n"
+ "# firefly-cli\n"
+ "Run `firefly auth test` first.\n"
+ ),
+ "test-build-slackbuild/SKILL.md": (
+ "---\n"
+ "name: test-build-slackbuild\n"
+ "description: Drive the sbo-dockerbuild test-build tool.\n"
+ "---\n"
+ "\n"
+ "Load the skill, then test-build.\n"
+ ),
+ "no-frontmatter/SKILL.md": (
+ "# no-frontmatter\n"
+ "Skill with no frontmatter; the name falls back to the directory.\n"
+ ),
+}
+```
+
+```python
+def _write_skills(root: Path, sample: dict) -> None:
+ for rel, content in sample.items():
+ path = root / rel
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+
+
+def test_skills_store():
+ from llamachat import skills
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ _write_skills(root, SKILLS_SAMPLE)
+ store = skills.SkillStore(root)
+
+ assert store.names() == [
+ "firefly-cli", "no-frontmatter", "test-build-slackbuild"
+ ]
+
+ skill = store.load("firefly-cli")
+ assert skill is not None
+ assert skill.name == "firefly-cli"
+ assert skill.description == "Operate a Firefly III instance from the command line."
+ assert skill.text.startswith("# firefly-cli")
+
+ # A name that does not exist, and an absent directory, both yield None.
+ assert store.load("nope") is None
+ assert skills.SkillStore(Path(tmp) / "missing").names() == []
+
+ schema = store.tool_schema()
+ function = schema["function"]
+ assert function["name"] == "load_skill"
+ assert function["parameters"]["required"] == ["name"]
+ assert function["parameters"]["properties"]["name"]["enum"] == store.names()
+ assert "firefly-cli" in function["description"]
+
+ directory = store.directory()
+ assert "firefly-cli" in directory
+ assert "test-build-slackbuild" in directory
+ print("ok skills store")
+
+
+def test_skills_mentions():
+ from llamachat import skills
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ _write_skills(root, {"firefly-cli/SKILL.md": SKILLS_SAMPLE["firefly-cli/SKILL.md"]})
+ store = skills.SkillStore(root)
+
+ assert store.match_mentions("use firefly-cli for this") == ["firefly-cli"]
+ assert store.match_mentions("USE FIREFLY-CLI now") == ["firefly-cli"]
+ # No word boundary, no mention: a substring must not load a skill.
+ assert store.match_mentions("firefly-cli2 rocks") == []
+ assert store.match_mentions("the fireflies are out") == []
+ assert store.match_mentions("nothing here") == []
+ print("ok skills mentions")
+
+
+def test_skills_parse_commands():
+ from llamachat import skills
+
+ known = {"firefly-cli", "handoff"}
+ text, loaded = skills.parse_commands(
+ "/firefly-cli fix my budget /unknown stays", known
+ )
+ assert loaded == ["firefly-cli"]
+ assert text == "fix my budget /unknown stays"
+
+ text, loaded = skills.parse_commands("no commands here", known)
+ assert loaded == []
+ assert text == "no commands here"
+
+ # A lone command leaves nothing behind.
+ text, loaded = skills.parse_commands("/handoff", known)
+ assert loaded == ["handoff"]
+ assert text == ""
+
+ # A name that is not a known skill is kept as plain text.
+ text, loaded = skills.parse_commands("/nope", known)
+ assert loaded == []
+ assert text == "/nope"
+ print("ok skills parse commands")
+
+
+def test_skill_tool_message():
+ from llamachat import skills
+
+ skill = skills.Skill(name="firefly-cli", description="d", text="body text")
+ msg = skills.tool_message("call_1", skill)
+ assert msg["role"] == "tool"
+ assert msg["tool_call_id"] == "call_1"
+ assert "[skill: firefly-cli]" in msg["content"]
+ assert "body text" in msg["content"]
+
+ err = skills.tool_message("call_1", None, error="No such skill")
+ assert "error: No such skill" in err["content"]
+
+ last = skills.tool_message("call_1", skill, last=True)
+ assert skills.NO_MORE_TOOLS in last["content"]
+
+ assert skills.parse_name('{"name": "firefly-cli"}') == "firefly-cli"
+ assert skills.parse_name("not json") == ""
+ assert skills.parse_name('{"query": "x"}') == ""
+ assert skills.parse_name("") == ""
+ print("ok skill tool message")
+```
+
+- [ ] **Step 2: Run the suite to verify the new tests fail**
+
+Run: `./test_llamachat.py`
+Expected: `ImportError: cannot import name 'skills' from 'llamachat'` (the tests import the module that does not exist yet).
+
+- [ ] **Step 3: Write `llamachat/skills.py`**
+
+Create the file with the GPL header copied from `config.py` and this docstring: `"""Skills as instruction files, loaded into the model's context on demand.\n\nOne SKILL.md file per skill inside the skills directory. Each file starts\nwith a YAML frontmatter block carrying name and description, then the\ninstructions. Only those two keys are read; parsing is a line scan because\nadding a YAML dependency for two keys would be absurd.\n"""`
+
+Module body:
+
+```python
+import json
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+LOAD_SKILL_NAME = "load_skill"
+
+# The model is told which skill it just received.
+SKILL_HEADER = "[skill: {name}]"
+
+# Appended to the final round's tool result; see tool_message(). A note
+# must ride on the result because a trailing system message is rejected by
+# the chat template ("System message must be at the beginning").
+NO_MORE_TOOLS = (
+ "This was your last available tool use for this turn and tools are now "
+ "unavailable. Write your answer now using the information above. Do not "
+ "request another tool."
+)
+
+# A description is shortened to this for the model's directory, so listing
+# every skill never costs a real chunk of context per request.
+DIRECTORY_CHARS = 140
+
+
+@dataclass
+class Skill:
+ name: str
+ description: str
+ text: str
+
+
+def _parse(path: Path) -> Skill:
+ """Read one SKILL.md, pulling name/description out of its frontmatter."""
+ text = path.read_text(encoding="utf-8", errors="replace")
+ name = path.parent.name
+ description = ""
+ body = text
+ if text.startswith("---"):
+ # split("---", 2) takes only the opening and closing fences, so a
+ # body that itself contains "---" is left intact.
+ parts = text.split("---", 2)
+ if len(parts) == 3:
+ body = parts[2]
+ for line in parts[1].splitlines():
+ key, sep, value = line.partition(":")
+ if not sep:
+ continue
+ key, value = key.strip(), value.strip()
+ if key == "name" and value:
+ name = value
+ elif key == "description":
+ description = value
+ return Skill(name=name, description=description, text=body.strip())
+
+
+def _short(text: str) -> str:
+ """First line of a description, capped, so the directory stays compact."""
+ stripped = text.strip()
+ first = stripped.splitlines()[0] if stripped else ""
+ return first[:DIRECTORY_CHARS]
+
+
+class SkillStore:
+ """The skills directory: one SKILL.md per skill, read only."""
+
+ def __init__(self, directory: Path):
+ self.dir = Path(directory)
+
+ def _skills(self) -> list[Skill]:
+ if not self.dir.is_dir():
+ return []
+ found = []
+ for path in sorted(self.dir.glob("*/SKILL.md")):
+ try:
+ found.append(_parse(path))
+ except OSError:
+ continue # a broken skill must not take down the chat
+ return found
+
+ def names(self) -> list[str]:
+ return [skill.name for skill in self._skills()]
+
+ def load(self, name: str) -> Skill | None:
+ for skill in self._skills():
+ if skill.name == name:
+ return skill
+ return None
+
+ def directory(self) -> str:
+ """Compact name-description lines for the model to choose from."""
+ return "\n".join(
+ f"- {s.name}: {_short(s.description)}" if s.description else f"- {s.name}"
+ for s in self._skills()
+ )
+
+ def tool_schema(self) -> dict:
+ """The OpenAI `load_skill` function schema for the model to call."""
+ return {
+ "type": "function",
+ "function": {
+ "name": LOAD_SKILL_NAME,
+ "description": (
+ "Load a skill's full instructions into context. Use this "
+ "when the task matches one of these skills.\n\n"
+ f"Available skills:\n{self.directory()}"
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The skill to load.",
+ "enum": self.names(),
+ },
+ },
+ "required": ["name"],
+ },
+ },
+ }
+
+ def match_mentions(self, text: str) -> list[str]:
+ """Skills whose names appear in `text` as whole words, in store order."""
+ found = []
+ for skill in self._skills():
+ if re.search(r"\b" + re.escape(skill.name) + r"\b", text, re.IGNORECASE):
+ found.append(skill.name)
+ return found
+
+
+def parse_commands(text: str, names: set[str]) -> tuple[str, list[str]]:
+ """Split /name tokens off `text`; return (remaining, loaded names).
+
+ Only tokens whose name is a known skill are treated as commands and
+ stripped; any other /word survives as plain text.
+ """
+ loaded: list[str] = []
+ remaining: list[str] = []
+ for token in text.split():
+ if token.startswith("/") and len(token) > 1:
+ candidate = token[1:]
+ if candidate in names:
+ loaded.append(candidate)
+ continue
+ remaining.append(token)
+ return " ".join(remaining), loaded
+
+
+def parse_name(arguments: str) -> str:
+ """The skill name out of a tool call's JSON arguments, or ''."""
+ try:
+ parsed = json.loads(arguments or "{}")
+ except ValueError:
+ return ""
+ if not isinstance(parsed, dict):
+ return ""
+ name = parsed.get("name")
+ return name.strip() if isinstance(name, str) else ""
+
+
+def tool_message(
+ call_id: str, skill: Skill | None, error: str = "", last: bool = False
+) -> dict:
+ """The `role: tool` message carrying a loaded skill back to the model.
+
+ A failure is reported rather than swallowed, for the same reason as
+ search.tool_message: the model asked for the tool and is waiting on it.
+ With `last`, the message also says no further tools are available.
+ """
+ if error:
+ content = f"error: {error}"
+ elif skill is not None:
+ content = f"{SKILL_HEADER.format(name=skill.name)}\n{skill.text}"
+ else:
+ content = "error: no skill"
+ if last:
+ content = f"{content}\n\n{NO_MORE_TOOLS}"
+ return {
+ "role": "tool",
+ "tool_call_id": call_id,
+ "content": content,
+ }
+```
+
+- [ ] **Step 4: Register the four new tests and run the suite**
+
+Add to the `if __name__ == "__main__":` block, before the final `print("\nall checks passed")`:
+
+```python
+ test_skills_store()
+ test_skills_mentions()
+ test_skills_parse_commands()
+ test_skill_tool_message()
+```
+
+Run: `./test_llamachat.py`
+Expected: `ok skills store`, `ok skills mentions`, `ok skills parse commands`, `ok skill tool message`, and `all checks passed`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add llamachat/skills.py test_llamachat.py
+git commit -m "feat: skill store parsing for SKILL.md instruction files"
+```
+
+---
+
+## Task 2: Config
+
+**Files:**
+- Modify: `llamachat/config.py` (DEFAULTS dict, `Config` dataclass, `load()`, `write_default()`)
+- Test: `test_llamachat.py`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `Config.skills_enabled: bool`, `Config.skills_dir: Path`; `config.DEFAULTS["skills_enabled"] is True`; `config.DEFAULTS["skills_dir"] == "~/.agents/skills"`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+def test_skills_config():
+ with tempfile.TemporaryDirectory() as tmp:
+ skills_dir = Path(tmp) / "skills"
+ (skills_dir / "firefly-cli").mkdir(parents=True)
+ (skills_dir / "firefly-cli" / "SKILL.md").write_text(
+ "---\nname: firefly-cli\ndescription: d\n---\n\nbody\n"
+ )
+ path = Path(tmp) / "config.toml"
+ path.write_text(f'skills_enabled = true\nskills_dir = "{skills_dir}"\n')
+ cfg = config.load(path)
+ assert cfg.skills_enabled is True
+ assert cfg.skills_dir == skills_dir
+
+ # A flag whose directory does not exist resolves to off, mirroring
+ # the search rule that a flag with no URL stays off.
+ path.write_text('skills_enabled = true\nskills_dir = "/nonexistent"\n')
+ assert config.load(path).skills_enabled is False
+
+ assert config.DEFAULTS["skills_enabled"] is True
+ assert config.DEFAULTS["skills_dir"] == "~/.agents/skills"
+
+ with tempfile.TemporaryDirectory() as tmp:
+ written = config.write_default(Path(tmp) / "config.toml")
+ text = written.read_text()
+ assert "skills_enabled" in text
+ assert "skills_dir" in text
+ print("ok skills config")
+```
+
+- [ ] **Step 2: Run the suite to verify the test fails**
+
+Run: `./test_llamachat.py`
+Expected: `AttributeError: 'Config' object has no attribute 'skills_enabled'`.
+
+- [ ] **Step 3: Modify `llamachat/config.py`**
+
+In `DEFAULTS`, after the `max_searches` entry:
+
+```python
+ # Skills: instruction files read from a directory, one SKILL.md per
+ # skill, loaded into the model's context on demand. On by default
+ # because the directory already exists; a flag whose directory is
+ # missing resolves to off.
+ "skills_enabled": True,
+ "skills_dir": "~/.agents/skills",
+```
+
+In the `Config` dataclass, after `max_searches: int`:
+
+```python
+ skills_enabled: bool
+ skills_dir: Path
+```
+
+In `load()`, after the `search_enabled` resolution line:
+
+```python
+ skills_dir = Path(str(values["skills_dir"])).expanduser()
+ skills_enabled = bool(values["skills_enabled"]) and skills_dir.is_dir()
+```
+
+In the `Config(...)` return, after `max_searches=int(values["max_searches"]),`:
+
+```python
+ skills_enabled=skills_enabled,
+ skills_dir=skills_dir,
+```
+
+In `write_default()`, after the `max_searches` line and before the external-providers block:
+
+```python
+ '\n'
+ '# Skills: instruction files read from a directory, one SKILL.md per\n'
+ '# skill, loaded into the model\'s context on demand (by the model\n'
+ '# itself, by /skill-name, or by mentioning a skill name). On by\n'
+ '# default; a directory that does not exist resolves to off.\n'
+ f'skills_enabled = {str(DEFAULTS["skills_enabled"]).lower()}\n'
+ f'skills_dir = "{DEFAULTS["skills_dir"]}"\n'
+```
+
+- [ ] **Step 4: Register and run**
+
+Add `test_skills_config()` to the `__main__` block. Run `./test_llamachat.py`.
+Expected: `ok skills config` and all prior checks still pass (check `test_config_defaults` in particular).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add llamachat/config.py test_llamachat.py
+git commit -m "feat: skills config keys, on by default"
+```
+
+---
+
+## Task 3: Database
+
+**Files:**
+- Modify: `llamachat/db.py` (SCHEMA, `_migrate`, new `set_skills`, `import json`)
+- Test: `test_llamachat.py`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `History.set_skills(session_id: int, names: list[str]) -> None`; `sessions.skills` column (TEXT, JSON array, default `''`).
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+def test_skills_db():
+ with tempfile.TemporaryDirectory() as tmp:
+ history = db.History(Path(tmp) / "history.db")
+ sid = history.create_session("chat", "m")
+ history.set_skills(sid, ["firefly-cli", "handoff"])
+ session = history.get_session(sid)
+ assert json.loads(session["skills"]) == ["firefly-cli", "handoff"]
+
+ history.set_skills(sid, [])
+ assert json.loads(history.get_session(sid)["skills"]) == []
+
+ # A fresh session has no skills stored.
+ sid2 = history.create_session("oneshot", "m")
+ assert history.get_session(sid2)["skills"] == ""
+ print("ok skills db")
+
+
+def test_skills_column_migration():
+ # A database created before the column existed gains it on open.
+ import sqlite3 as _sqlite3
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "old.db"
+ conn = _sqlite3.connect(path)
+ conn.execute(
+ "CREATE TABLE sessions (id INTEGER PRIMARY KEY, mode TEXT NOT NULL,"
+ " title TEXT, model TEXT, prompt_name TEXT NOT NULL DEFAULT '',"
+ " prompt_custom TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL,"
+ " updated_at INTEGER NOT NULL)"
+ )
+ conn.commit()
+ conn.close()
+
+ history = db.History(path)
+ sid = history.create_session("chat", "m")
+ history.set_skills(sid, ["firefly-cli"])
+ assert json.loads(history.get_session(sid)["skills"]) == ["firefly-cli"]
+ print("ok skills column migration")
+```
+
+- [ ] **Step 2: Run the suite to verify the tests fail**
+
+Run: `./test_llamachat.py`
+Expected: `sqlite3.OperationalError: no such column: skills`.
+
+- [ ] **Step 3: Modify `llamachat/db.py`**
+
+Add `import json` to the imports (after `import sqlite3`).
+
+In `SCHEMA`, in the `sessions` CREATE TABLE, after the `prompt_custom` line:
+
+```sql
+ skills TEXT NOT NULL DEFAULT '',
+```
+
+In `_migrate`, in the `"sessions"` dict, after `"prompt_custom": "TEXT NOT NULL DEFAULT ''",`:
+
+```python
+ "skills": "TEXT NOT NULL DEFAULT ''",
+```
+
+Add a method after `set_prompt`:
+
+```python
+ def set_skills(self, session_id: int, names: list[str]) -> None:
+ """Record which skills a conversation has loaded, as a JSON array."""
+ self.conn.execute(
+ "UPDATE sessions SET skills = ? WHERE id = ?",
+ (json.dumps(names), session_id),
+ )
+ self.conn.commit()
+```
+
+- [ ] **Step 4: Register and run**
+
+Add both tests to the `__main__` block. Run `./test_llamachat.py`.
+Expected: `ok skills db`, `ok skills column migration`, all prior checks pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add llamachat/db.py test_llamachat.py
+git commit -m "feat: persist loaded skills per session"
+```
+
+---
+
+## Task 4: Generalize the backend tool loop
+
+**Files:**
+- Modify: `llamachat/backend.py` (`SkillsConfig`, `stream_chat`, new `_tool_schemas`, `_run_tool`, `_run_skill`, import `skills`)
+- Test: `test_llamachat.py`
+
+**Interfaces:**
+- Consumes: `skills.SkillStore`, `skills.LOAD_SKILL_NAME`, `skills.parse_name`, `skills.tool_message`, `search.TOOL_SCHEMA`.
+- Produces: `backend.SkillsConfig` dataclass (`enabled: bool = False`, `store: skills.SkillStore | None = None`, `max_searches: int = 2`); `stream_chat(model, messages, search_cfg=None, skills_cfg=None)`; new stream kind `("skill_loaded", name)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+def _skill_round(name: str = "firefly-cli"):
+ """One streamed round that asks to load a skill."""
+ return [
+ (
+ "tool_calls",
+ json.dumps(
+ [
+ {
+ "index": 0,
+ "id": "call_2",
+ "function": {
+ "name": "load_skill",
+ "arguments": json.dumps({"name": name}),
+ },
+ }
+ ]
+ ),
+ ),
+ ("tool_finish", ""),
+ ]
+
+
+def test_skill_tool_round():
+ from llamachat import skills
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ _write_skills(root, {"firefly-cli/SKILL.md": SKILLS_SAMPLE["firefly-cli/SKILL.md"]})
+ store = skills.SkillStore(root)
+
+ client = backend.Client("http://x")
+ seen_messages = []
+ sent_tools = []
+
+ def scripted(model, messages, tools):
+ sent_tools.append(tools)
+ seen_messages.append([dict(m) for m in messages])
+ if len(seen_messages) == 1:
+ yield from _skill_round()
+ else:
+ yield ("content", "answered")
+
+ client._stream_once = scripted
+ cfg = backend.SkillsConfig(enabled=True, store=store, max_searches=1)
+ out = list(client.stream_chat(
+ "m", [{"role": "user", "content": "hi"}], skills_cfg=cfg
+ ))
+
+ # The load is reported and the answer flows through.
+ assert ("skill_loaded", "firefly-cli") in out, out
+ assert ("content", "answered") in out, out
+
+ # The offered schema is the load_skill one; the final round has none.
+ assert sent_tools[0] == [store.tool_schema()], sent_tools[0]
+ assert sent_tools[-1] is None, sent_tools
+
+ # The second request carries the assistant tool call and a tool
+ # reply with the skill body.
+ second = seen_messages[1]
+ assistant = next(
+ m for m in second if m["role"] == "assistant" and m.get("tool_calls")
+ )
+ assert assistant["tool_calls"][0]["function"]["name"] == "load_skill"
+ tool_msg = next(m for m in second if m["role"] == "tool")
+ assert tool_msg["tool_call_id"] == "call_2"
+ assert "run `firefly auth test` first" in tool_msg["content"].lower()
+ assert "[skill: firefly-cli]" in tool_msg["content"]
+ print("ok skill tool round")
+
+
+def test_skill_tool_errors():
+ from llamachat import skills
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ _write_skills(root, {"firefly-cli/SKILL.md": SKILLS_SAMPLE["firefly-cli/SKILL.md"]})
+ store = skills.SkillStore(root)
+ client = backend.Client("http://x")
+ cfg = backend.SkillsConfig(enabled=True, store=store, max_searches=1)
+
+ # Unknown skill name -> error tool message, no skill_loaded yield.
+ rounds = [_skill_round("vault-librarian"), [("content", "done")]]
+ seen = []
+
+ def scripted(model, messages, tools):
+ seen.append(tools)
+ yield from rounds[min(len(seen) - 1, len(rounds) - 1)]
+
+ client._stream_once = scripted
+ out = list(client.stream_chat(
+ "m", [{"role": "user", "content": "hi"}], skills_cfg=cfg
+ ))
+ assert ("content", "done") in out, out
+ assert not any(k == "skill_loaded" for k, _ in out), out
+ tool_msg = next(m for m in seen[1] if m["role"] == "tool")
+ assert "No such skill: vault-librarian" in tool_msg["content"]
+
+ # Malformed arguments are answered rather than left dangling.
+ bad = {"id": "call_9", "name": "load_skill", "arguments": "{not json"}
+ convo: list = []
+ emitted = list(client._run_skill(convo, bad, store))
+ assert emitted == [], emitted
+ assert convo[-1]["role"] == "tool"
+ assert "error" in convo[-1]["content"]
+
+ # A tool call naming a tool that does not exist is answered too.
+ rounds2 = [[
+ ("tool_calls", json.dumps([{
+ "index": 0, "id": "call_7",
+ "function": {"name": "no_such_tool", "arguments": "{}"},
+ }])),
+ ("tool_finish", ""),
+ ], [("content", "done")]]
+ seen2 = []
+
+ def scripted2(model, messages, tools):
+ seen2.append(tools)
+ yield from rounds2[min(len(seen2) - 1, len(rounds2) - 1)]
+
+ client._stream_once = scripted2
+ out2 = list(client.stream_chat(
+ "m", [{"role": "user", "content": "hi"}], skills_cfg=cfg
+ ))
+ assert ("content", "done") in out2, out2
+ unknown = next(m for m in seen2[1] if m["role"] == "tool")
+ assert "no_such_tool" in unknown["content"]
+ print("ok skill tool errors")
+
+
+def test_skill_round_cap():
+ from llamachat import skills
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ _write_skills(root, {"firefly-cli/SKILL.md": SKILLS_SAMPLE["firefly-cli/SKILL.md"]})
+ store = skills.SkillStore(root)
+
+ client = backend.Client("http://x")
+ sent_tools = []
+
+ def always_skill_calls(model, messages, tools):
+ sent_tools.append(tools)
+ yield from _skill_round()
+
+ client._stream_once = always_skill_calls
+ cfg = backend.SkillsConfig(enabled=True, store=store, max_searches=1)
+ list(client.stream_chat(
+ "m", [{"role": "user", "content": "hi"}], skills_cfg=cfg
+ ))
+
+ # max_searches rounds offer the tool, then a final round without it,
+ # even when search is entirely disabled (skills-only loop path).
+ assert len(sent_tools) == 2, sent_tools
+ assert sent_tools[0] == [store.tool_schema()], sent_tools[0]
+ assert sent_tools[-1] is None, "the final round must withdraw the tool"
+ print("ok skill round cap")
+```
+
+- [ ] **Step 2: Run the suite to verify the tests fail**
+
+Run: `./test_llamachat.py`
+Expected: `AttributeError: type object 'backend.Client' has no attribute 'SkillsConfig'`.
+
+- [ ] **Step 3: Modify `llamachat/backend.py`**
+
+Add the import (alphabetical, after `from . import providers as providers_mod`):
+
+```python
+from . import skills
+```
+
+Add after the `SearchConfig` dataclass:
+
+```python
+@dataclass
+class SkillsConfig:
+ """What stream_chat needs to offer and run skill loading."""
+ enabled: bool = False
+ store: skills.SkillStore | None = None
+ max_searches: int = 2 # tool rounds per turn, same cap as search
+```
+
+Replace `stream_chat` with:
+
+```python
+ def stream_chat(
+ self,
+ model: str,
+ messages: list[dict],
+ search_cfg: "SearchConfig | None" = None,
+ skills_cfg: "SkillsConfig | None" = None,
+ ):
+ """Yield (kind, text) pairs as the model produces them.
+
+ `kind` is 'reasoning' for the model's thinking and 'content' for the
+ reply proper. Presets with a reasoning budget emit the former in a
+ separate `reasoning_content` delta field, which is what lets the UI
+ keep the two apart.
+
+ With `search_cfg`, the model is offered a `web_search` tool; with
+ `skills_cfg`, a `load_skill` tool. Either turns this into a loop: a
+ round that ends in a tool call is handled, the result appended, and
+ the request re-sent. Two further kinds are yielded around each search,
+ 'search_start' and 'search_done', and one around each loaded skill,
+ 'skill_loaded'. After the round cap the tools are withdrawn, which
+ forces an answer.
+
+ Loading a different model makes the router unload the previous one,
+ so the first chunk can take several seconds. That wait happens
+ inside the initial `stream()` call.
+ """
+ search_on = search_cfg is not None and search_cfg.enabled
+ skills_on = skills_cfg is not None and skills_cfg.enabled
+ if not (search_on or skills_on):
+ yield from self._stream_once(model, messages, tools=None)
+ return
+
+ # The loop mutates its own copy; the caller's list is history.
+ convo = list(messages)
+ cap = search_cfg.max_searches if search_on else skills_cfg.max_searches
+ for round_number in range(cap + 1):
+ last_round = round_number == cap
+ tools = None if last_round else self._tool_schemas(search_cfg, skills_cfg)
+ calls: dict = {}
+ saw_tool_finish = False
+ reasoning = ""
+
+ for kind, piece in self._stream_once(model, convo, tools=tools):
+ if kind == "tool_calls":
+ search.accumulate(calls, json.loads(piece))
+ elif kind == "tool_finish":
+ saw_tool_finish = True
+ else:
+ if kind == "reasoning":
+ reasoning += piece
+ yield (kind, piece)
+
+ wanted = [call for call in calls.values() if call.get("name")]
+ if not (saw_tool_finish and wanted) or last_round:
+ return
+
+ assistant = {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": call["id"],
+ "type": "function",
+ "function": {
+ "name": call["name"],
+ "arguments": call["arguments"],
+ },
+ }
+ for call in wanted
+ ],
+ }
+ # Interleaved-thinking models (DeepSeek V3.2+/V4, GLM-4.7+ on
+ # SiliconFlow) emit their chain-of-thought as reasoning_content and
+ # require it replayed verbatim on the assistant tool-call message.
+ # Dropping it breaks their multi-step tool flow: the follow-up
+ # round ends with the model thinking and no answer. Local reasoning
+ # models tolerate the extra key, so include it whenever it exists.
+ if reasoning:
+ assistant["reasoning_content"] = reasoning
+ convo.append(assistant)
+ # The round after this one is the last, so its results are the
+ # last the model will get. Saying so is what makes it answer
+ # instead of asking for a tool it can no longer use.
+ final = round_number + 1 == cap
+ for call in wanted:
+ yield from self._run_tool(
+ convo, call, search_cfg, skills_cfg, final
+ )
+```
+
+Add these methods to `Client` (after `_run_search`, before `_stream_once`):
+
+```python
+ def _tool_schemas(self, search_cfg, skills_cfg):
+ """The tools to offer this round, or None when there are none."""
+ tools = []
+ if search_cfg is not None and search_cfg.enabled:
+ tools.append(search.TOOL_SCHEMA)
+ if (
+ skills_cfg is not None and skills_cfg.enabled
+ and skills_cfg.store is not None
+ ):
+ tools.append(skills_cfg.store.tool_schema())
+ return tools or None
+
+ def _run_tool(self, convo, call, search_cfg, skills_cfg, last=False):
+ """Route one tool call to its handler, or answer that it is unknown."""
+ name = call["name"]
+ if (
+ search_cfg is not None and search_cfg.enabled
+ and name == search.TOOL_SCHEMA["function"]["name"]
+ ):
+ yield from self._run_search(convo, call, search_cfg, last)
+ return
+ if (
+ skills_cfg is not None and skills_cfg.enabled
+ and skills_cfg.store is not None
+ and name == skills.LOAD_SKILL_NAME
+ ):
+ yield from self._run_skill(convo, call, skills_cfg.store, last)
+ return
+ # A tool call for a name that exists nowhere still needs an answer,
+ # or the next request is rejected for an unanswered call.
+ convo.append(
+ {
+ "role": "tool",
+ "tool_call_id": call["id"],
+ "content": f"error: unknown tool '{name}'",
+ }
+ )
+
+ def _run_skill(self, convo, call, store, last=False):
+ """Load a skill's text and append it to `convo`.
+
+ Every failure still appends a tool message, so the turn completes
+ either way. Success additionally yields ("skill_loaded", name) so
+ the UI can persist the load for later turns.
+ """
+ name = skills.parse_name(call["arguments"])
+ if not name:
+ convo.append(
+ skills.tool_message(
+ call["id"], None, error="Malformed skill request", last=last
+ )
+ )
+ return
+ skill = store.load(name)
+ if skill is None:
+ convo.append(
+ skills.tool_message(
+ call["id"], None, error=f"No such skill: {name}", last=last
+ )
+ )
+ return
+ convo.append(skills.tool_message(call["id"], skill, last=last))
+ yield ("skill_loaded", name)
+```
+
+- [ ] **Step 4: Register and run**
+
+Add the three tests to the `__main__` block. Run `./test_llamachat.py`.
+Expected: `ok skill tool round`, `ok skill tool errors`, `ok skill round cap`, and every pre-existing search-loop test (`test_search_loop_cap`, `test_search_failure_paths`, `test_reasoning_content_preserved`, `test_tool_call_finish_with_usage`, `test_final_round_note`) still passes. The search loop behavior is unchanged: search-only still offers exactly `[search.TOOL_SCHEMA]` and caps at `max_searches`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add llamachat/backend.py test_llamachat.py
+git commit -m "feat: dispatch the tool loop over web_search and load_skill"
+```
+
+---
+
+## Task 5: UI — state, injection, user path
+
+**Files:**
+- Modify: `llamachat/ui.py`
+- Test: `test_llamachat.py`
+
+**Interfaces:**
+- Consumes: `skills.SkillStore`, `skills.parse_commands`, `skills.match_mentions`, `backend.SkillsConfig`.
+- Produces: module-level helpers `_skill_parts(store, loaded: list[str]) -> list[str]` and `_skills_list(raw: str) -> list[str]`; `ChatWindow` gains `self.skills` (a `SkillStore`), `self.loaded_skills: list[str]`, and methods `_load_skill`, `_unload_skill`, `_persist_skills`, `_load_skills_from_text`, `_skills_config`, `_refresh_skill_chips`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+def test_skill_parts():
+ from llamachat import skills
+ from llamachat.ui import _skill_parts, _skills_list
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ _write_skills(root, {"firefly-cli/SKILL.md": SKILLS_SAMPLE["firefly-cli/SKILL.md"]})
+ store = skills.SkillStore(root)
+
+ parts = _skill_parts(store, ["firefly-cli"])
+ assert len(parts) == 1, parts
+ assert "[skill: firefly-cli]" in parts[0]
+ assert "firefly auth test" in parts[0]
+
+ # A name that no longer exists contributes nothing, and the parts
+ # keep load order.
+ parts2 = _skill_parts(store, ["gone", "firefly-cli"])
+ assert parts2 == ["[skill: firefly-cli]\nRun `firefly auth test` first."]
+
+ assert _skills_list('["a", "b"]') == ["a", "b"]
+ assert _skills_list("") == []
+ assert _skills_list("not json") == []
+ assert _skills_list("[1, 2]") == []
+ print("ok skill parts")
+```
+
+- [ ] **Step 2: Run the suite to verify the test fails**
+
+Run: `./test_llamachat.py`
+Expected: `ImportError: cannot import name '_skill_parts' from 'llamachat.ui'`.
+
+- [ ] **Step 3: Modify `llamachat/ui.py`**
+
+Add `skills` to the project import on the `from . import backend, ...` line:
+
+```python
+from . import backend, models as models_mod, prompts, skills
+```
+
+Add these module-level helpers near the other module helpers (e.g. next to `_date_note` at the bottom of the file):
+
+```python
+def _skill_parts(store, loaded: list[str]) -> list[str]:
+ """The system-prompt chunks for the loaded skills, in load order."""
+ parts = []
+ for name in loaded:
+ skill = store.load(name)
+ if skill is not None:
+ parts.append(f"[skill: {skill.name}]\n{skill.text}")
+ return parts
+
+
+def _skills_list(raw: str) -> list[str]:
+ """Session-stored skills JSON back to a list, tolerating absence/garbage."""
+ try:
+ parsed = json.loads(raw) if raw else []
+ except ValueError:
+ return []
+ if not isinstance(parsed, list):
+ return []
+ return [name for name in parsed if isinstance(name, str)]
+```
+
+In `__init__`, after the `self.prompts.ensure_default()` line:
+
+```python
+ self.skills = skills.SkillStore(cfg.skills_dir)
+ self.loaded_skills: list[str] = []
+```
+
+Change `_system_messages()` to:
+
+```python
+ def _system_messages(self) -> list[dict]:
+ parts = [self.system_prompt_text()]
+ if self.cfg.search_enabled:
+ parts.append(_date_note())
+ parts.extend(_skill_parts(self.skills, self.loaded_skills))
+ text = "\n\n".join(p for p in parts if p)
+ return [{"role": "system", "content": text}] if text else []
+```
+
+Add a `# -- skills ---` section with these methods (place after `_system_messages`, before `update_meter`):
+
+```python
+ def _load_skill(self, name: str) -> bool:
+ """Record a skill as loaded; True when it was not already present.
+
+ Persisted to the session in chat mode so reopening the conversation
+ restores it. One-shot mode keeps the set in memory only; it is
+ cleared when the reply finishes.
+ """
+ if not self.cfg.skills_enabled or name in self.loaded_skills:
+ return False
+ if self.skills.load(name) is None:
+ return False
+ self.loaded_skills.append(name)
+ self._persist_skills()
+ self._refresh_skill_chips()
+ return True
+
+ def _unload_skill(self, name: str) -> None:
+ if name not in self.loaded_skills:
+ return
+ self.loaded_skills.remove(name)
+ self._persist_skills()
+ self._refresh_skill_chips()
+
+ def _persist_skills(self) -> None:
+ if (
+ self.cfg.skills_enabled
+ and self.mode == MODE_CHAT
+ and self.session_id is not None
+ ):
+ self.history.set_skills(self.session_id, self.loaded_skills)
+
+ def _load_skills_from_text(self, text: str) -> str:
+ """Load skills named in the draft; returns the text to actually send.
+
+ /name tokens load and are stripped. A skill name appearing as a
+ whole word loads and stays, since it reads naturally either way.
+ """
+ if not self.cfg.skills_enabled:
+ return text
+ known = set(self.skills.names())
+ text, command_names = skills.parse_commands(text, known)
+ for name in command_names:
+ self._load_skill(name)
+ for name in self.skills.match_mentions(text):
+ self._load_skill(name)
+ return text
+
+ def _skills_config(self) -> backend.SkillsConfig | None:
+ """What the stream worker needs to offer the load_skill tool."""
+ if not self.cfg.skills_enabled or not self.skills.names():
+ return None
+ return backend.SkillsConfig(
+ enabled=True, store=self.skills, max_searches=self.cfg.max_searches
+ )
+
+ def _refresh_skill_chips(self) -> None:
+ """Rebuild the loaded-skill chips row; hidden when nothing is loaded.
+
+ The chips bar is built by `_build_ui`, so this is a no-op until then
+ (it is first called from `new_session` during construction).
+ """
+ if not hasattr(self, "skills_bar"):
+ return # still building the window
+ for chip in self.skill_chips.values():
+ chip.deleteLater()
+ self.skill_chips.clear()
+ for name in self.loaded_skills:
+ chip = QPushButton(f"✕ {name}")
+ chip.setFlat(True)
+ chip.setToolTip(f"Loaded skill. Click to unload {name}.")
+ chip.clicked.connect(
+ lambda _checked=False, n=name: self._unload_skill(n)
+ )
+ self.skills_layout.addWidget(chip)
+ self.skill_chips[name] = chip
+ self.skills_bar.setVisible(bool(self.loaded_skills))
+```
+
+In `send()`, after the existing `if not text and not self.attachments: return` guard and the model check, insert the skill scan and the stripped-empty re-guard:
+
+```python
+ text = self._load_skills_from_text(text)
+ if not text and not self.attachments:
+ return
+```
+
+In `send()`, after the `if self.session_id is None:` block that creates the session, add:
+
+```python
+ self._persist_skills()
+```
+
+In `new_session()`, add these two lines (anywhere in the body, e.g. after `self.searches = []`):
+
+```python
+ self.loaded_skills = []
+ self._refresh_skill_chips()
+```
+
+- [ ] **Step 4: Register and run**
+
+Add `test_skill_parts()` to the `__main__` block. Run `./test_llamachat.py`.
+Expected: `ok skill parts` and all prior checks pass. The GUI tests that construct a `ChatWindow` must still construct, so if `_refresh_skill_chips` is called before the chips bar exists (it is, from `new_session` during construction), it must guard on `hasattr`. Step 5 adds that guard; if the suite fails on construction here, proceed to Step 5 and re-run.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add llamachat/ui.py test_llamachat.py
+git commit -m "feat: inject loaded skills into the system prompt and load from text"
+```
+
+---
+
+## Task 6: UI — worker plumbing and chips bar
+
+**Files:**
+- Modify: `llamachat/ui.py`
+- Test: `test_llamachat.py` (regression only)
+
+**Interfaces:**
+- Consumes: `ChatWindow._load_skill`, `ChatWindow._unload_skill`, `ChatWindow._skills_config`, `self.loaded_skills`.
+- Produces: `StreamWorker.skill_loaded = Signal(str)`; `StreamWorker.__init__(client, model, messages, search_cfg=None, skills_cfg=None)`; `ChatWindow.skills_bar` (a `QWidget`), `ChatWindow.skill_chips` (dict), `ChatWindow._refresh_skill_chips`, `ChatWindow._on_skill_loaded`.
+
+- [ ] **Step 1: Verify the current suite passes**
+
+Run: `./test_llamachat.py`
+Expected: `all checks passed`.
+
+- [ ] **Step 2: Modify `llamachat/ui.py`**
+
+In `StreamWorker`, add a signal next to `search_done`:
+
+```python
+ skill_loaded = Signal(str)
+```
+
+Change the `StreamWorker.__init__` signature to:
+
+```python
+ def __init__(
+ self,
+ client,
+ model: str,
+ messages: list[dict],
+ search_cfg: SearchConfig | None = None,
+ skills_cfg: backend.SkillsConfig | None = None,
+ ):
+```
+
+and add the assignment `self.skills_cfg = skills_cfg` after `self.search_cfg = search_cfg`.
+
+In `StreamWorker.run()`, change the `stream_chat` call to pass the skills config:
+
+```python
+ stream = client.stream_chat(
+ wire, self.messages, self.search_cfg, self.skills_cfg
+ )
+```
+
+In the same loop, add a branch for the new kind, after the `search_done` branch:
+
+```python
+ elif kind == "skill_loaded":
+ self.skill_loaded.emit(piece)
+```
+
+In `_build_ui`, after `outer.addLayout(top)`:
+
+```python
+ # Loaded-skills chips, hidden unless at least one skill is loaded.
+ self.skills_bar = QWidget()
+ skills_layout = QHBoxLayout(self.skills_bar)
+ skills_layout.setContentsMargins(8, 0, 8, 0)
+ skills_layout.setSpacing(6)
+ self.skill_chips: dict[str, QPushButton] = {}
+ self.skills_bar.setVisible(False)
+ outer.addWidget(self.skills_bar)
+```
+
+In `_start_stream`, change the worker construction to:
+
+```python
+ self.worker = StreamWorker(
+ self.client, model, messages,
+ self._search_config(), self._skills_config(),
+ )
+```
+
+and add a connect after the `search_done` connect:
+
+```python
+ self.worker.skill_loaded.connect(self._on_skill_loaded)
+```
+
+Add the handler (place in the skills section from Task 5; `_refresh_skill_chips` and `_load_skill` already live there):
+
+```python
+ @Slot(str)
+ def _on_skill_loaded(self, name: str) -> None:
+ """A skill the model just loaded via its tool, recorded for later."""
+ if self._load_skill(name):
+ self.show_status(f"Loaded skill: {name}")
+```
+
+In `_on_stream_finished`, at the top of the method, drop one-shot loaded skills:
+
+```python
+ # One-shot mode: a skill loaded mid-reply is gone when the reply ends.
+ if self.mode == MODE_ONESHOT:
+ self.loaded_skills = []
+ self._refresh_skill_chips()
+```
+
+In `open_session`, after the prompt-restore block, restore the session's skills:
+
+```python
+ self.loaded_skills = _skills_list(_column(session, "skills"))
+ self._refresh_skill_chips()
+```
+
+- [ ] **Step 3: Run the suite**
+
+Run: `./test_llamachat.py`
+Expected: `all checks passed`. Also run a quick import sanity check that the window constructs with skills enabled:
+
+```bash
+python3 -c "import sys; sys.path.insert(0, '.'); from llamachat import config, db, ui; cfg = config.load(); h = db.History('/tmp/skills-test.db'); print('ok window imports')"
+```
+
+Expected: `ok window imports` (this also exercises `_build_ui` indirectly only if it constructed a window; a failed import is the real signal here).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add llamachat/ui.py
+git commit -m "feat: stream skill_loaded events and show loaded-skill chips"
+```
+
+---
+
+## Task 7: Docs
+
+**Files:**
+- Modify: `README.md`, `CHANGELOG.md`
+
+**Interfaces:**
+- Consumes: nothing.
+
+- [ ] **Step 1: Update README**
+
+1. In the Features list, after the **Web search** bullet, add:
+
+```markdown
+- **Skills.** Instruction files read from `~/.agents/skills/*/SKILL.md`,
+ loaded into the model's context on demand. The model can call a
+ `load_skill` tool when a task matches one; you can type `/skill-name` or
+ mention a skill's name. Loaded skills show as removable chips, stay for
+ the whole session in chat mode, and drop after the reply in one-shot mode.
+```
+
+2. In the **Configuration** section, extend the config example with:
+
+```toml
+# Skills. Instruction files in ~/.agents/skills/*/SKILL.md, loaded into the
+# model's context on demand. On by default; a directory that does not exist
+# stays off.
+skills_enabled = true
+skills_dir = "~/.agents/skills"
+```
+
+3. Add a **### Skills** section after the **### Web search** section, describing: the format (`<name>/SKILL.md`, frontmatter `name` + `description`), the three load paths (model tool, `/name`, natural mention), the persistence rule (chat = session, one-shot = reply only), and that `max_searches` also caps skill-loading rounds.
+
+- [ ] **Step 2: Update CHANGELOG**
+
+In `## [Unreleased]` → `### Added`, add a bullet:
+
+```markdown
+- Skills. Instruction files read from `~/.agents/skills/*/SKILL.md` can be
+ loaded into the model's context on demand: the model calls a `load_skill`
+ tool, or you type `/skill-name` or mention a skill's name. Loaded skills
+ stay for the whole session in chat mode, drop after the reply in one-shot
+ mode, and show as removable chips. The backend tool loop now dispatches
+ between `web_search` and `load_skill`; `max_searches` caps total tool
+ rounds per turn.
+```
+
+- [ ] **Step 3: Verify no personal data**
+
+Run: `git diff -- README.md CHANGELOG.md | git hooks` is not a real command; instead inspect the diff and make sure no real account names, email addresses (other than the license line), or personal identifiers were added. The commit hooks will also enforce this.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add README.md CHANGELOG.md
+git commit -m "docs: document skills support"
+```
+
+---
+
+## Task 8: Final verification
+
+**Files:** none.
+
+- [ ] **Step 1: Full suite**
+
+Run: `./test_llamachat.py`
+Expected: `all checks passed`, including every pre-existing check. The test count reported at the end must include the new `ok` lines: `ok skills store`, `ok skills mentions`, `ok skills parse commands`, `ok skill tool message`, `ok skills config`, `ok skills db`, `ok skills column migration`, `ok skill tool round`, `ok skill tool errors`, `ok skill round cap`, `ok skill parts`.
+
+- [ ] **Step 2: Manual smoke**
+
+Launch the daemon against the real router (or confirm it is already running) and exercise: mention a skill name in a message in chat mode and confirm the chip appears and the reply carries the skill; click the chip to unload; verify reopening the session restores the chips in chat mode and that one-shot replies do not carry skills into the next question.
+
+- [ ] **Step 3: Commit any follow-up fixes**
+
+If the smoke test surfaced a fix, commit it separately with a `fix:` message.