diff options
Diffstat (limited to 'docs/superpowers/specs')
| -rw-r--r-- | docs/superpowers/specs/2026-08-25-skills-design.md | 188 |
1 files changed, 188 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-25-skills-design.md b/docs/superpowers/specs/2026-08-25-skills-design.md new file mode 100644 index 0000000..20a3c0f --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-skills-design.md @@ -0,0 +1,188 @@ +# Skills support + +Status: design approved, pending user review + +## Problem + +llamachat can already do web search through a single hardcoded tool. It has +no way to hand the model a body of reusable instructions, the "skills" a user +of this app already keeps for other agents. Those skills live in +`~/.agents/skills/<name>/SKILL.md` and are plain markdown: a YAML frontmatter +with `name` and `description`, then the instructions. + +This adds a skills subsystem: llamachat reads that directory, lets the model +load a skill on demand through a tool call, and lets the user load one by +typing `/name` or mentioning the name naturally. A loaded skill's text stays +in context for the whole conversation in chat mode, and for the single reply +in one-shot mode. + +## Skills directory and format + +Skills are read from a single directory, default `~/.agents/skills`. Each +skill is a `SKILL.md` file inside its own subdirectory: + +``` +~/.agents/skills/ + firefly-cli/SKILL.md + test-build-slackbuild/SKILL.md + ... +``` + +A file starts with a YAML frontmatter block between `---` lines: + +``` +--- +name: firefly-cli +description: Operate a Firefly III instance ... +--- + +# firefly-cli + +... instructions ... +``` + +The `name` is the skill's identity, `description` the one-line summary shown +to the model. Only these two keys are read. If the frontmatter is absent or +has no `name`, the directory name is used. Parsing is a small line scanner in +`skills.py`, no YAML dependency. A skill whose file cannot be read is skipped +rather than failing startup. + +## Configuration + +Two keys, both on by default because the directory already exists and is +populated: + +```toml +skills_enabled = true +skills_dir = "~/.agents/skills" +``` + +`skills_dir` is expanded and resolved at load. A flag whose directory does +not exist resolves to off, so an upgrade never starts injecting tools or +reading files that are not there. This mirrors the web search rule (a flag +with no URL stays off). + +## New module: skills.py + +`SkillStore` mirrors `prompts.PromptStore`: + +- `__init__(dir)` +- `names()` — skill names, sorted +- `load(name)` — the `Skill` (name, description, text), or `None` +- `directory()` — compact `name — description` lines for the tool schema +- `match_mentions(text)` — the skill names that appear in `text` as whole + words, case-insensitive +- `tool_schema()` — the OpenAI tool schema for `load_skill` + +Descriptions are truncated to one sentence in `directory()` so listing nine +skills does not bloat every request. + +## Backend: generalizing the tool loop + +`backend.Client.stream_chat` currently offers only `web_search` and its loop +is written around that one tool. It becomes a dispatch over a small set of +tools. + +New signature: + +``` +stream_chat(model, messages, search_cfg=None, skills_cfg=None) +``` + +`SkillsConfig` carries `enabled: bool` and a `SkillStore`. The tools offered +in a round are `web_search` (when search is on) plus `load_skill` (when +skills are on). The `wanted` filter stops matching the one search name and +instead accepts every tool call the model made, routing each by name: + +- `web_search` -> `_run_search` (unchanged) +- `load_skill` -> `_run_skill` +- anything else -> a tool message saying the tool does not exist + +`_run_skill` loads the named skill and appends its text as the tool result. +When it succeeds it also yields `("skill_loaded", name)` so the UI can record +the load. The full text is prefixed with a short header (`[skill: <name>]`) +so the model sees which skill it now holds. + +The `max_searches` cap still governs the loop, now as "tool rounds per turn", +so a model cannot load skills forever. The final round's "this was your last +search" note is generalised to a tool-neutral "this was your last tool use" +message, phrased per tool. + +## UI + +### Injection + +`ui._system_messages()` already assembles the system prompt from the chosen +prompt plus the search date note. It gains a third part: the body of every +loaded skill, appended under a header, in load order: + +``` +<system prompt> +<date note, when search on> +[skill: firefly-cli] +<firefly-cli text> +[skill: test-build-slackbuild] +<...> +``` + +A skill whose file has since been deleted or renamed contributes nothing. + +### Loaded-skill state + +- Chat mode: the set of loaded skill names is stored on the session and + restored on reopen, exactly like `prompt_name`. +- One-shot mode: the set lives in memory only and is cleared when the reply + finishes. + +The UI learns a skill was loaded two ways: the `skill_loaded` stream event +from a tool call, or the user path below. Either way the set updates, the +chips refresh, and (chat mode) the session is persisted. + +### User path + +Before a message is sent, the typed text is scanned: + +- `/name` tokens load that skill and are stripped from the message before it + is stored and sent. +- A skill name appearing as a whole word, case-insensitive, loads it and the + word is left in place. + +Both go through the same load routine, so `/firefly-cli` and a sentence that +naturally mentions `firefly-cli` behave identically. + +### Chips + +A small row of removable chips shows the loaded skills, visible only when at +least one is loaded. Clicking a chip unloads that skill. Placement is in the +top bar area, using the existing widget patterns. + +## Database + +`sessions` gains one column: + +```sql +skills TEXT NOT NULL DEFAULT '' +``` + +It holds a JSON array of loaded skill names. Added to the `CREATE TABLE` and +to the existing `_migrate` loop, so old databases gain it on open. A +`set_skills(session_id, names)` method writes it; reopening reads it back via +the existing `get_session`. + +## Testing + +Extend `test_llamachat.py` in its existing assert style: + +- `skills.py`: frontmatter parsing with and without `name`, directory-name + fallback, whole-word matching (including hyphenated names), description + truncation. +- `backend.py`: a `load_skill` tool round returns the body and yields + `skill_loaded`; an unknown tool name returns an error tool message; the + final-round withdrawal still forces an answer when only skills are offered. + +## Out of scope + +- Executable skills (shell, file access) — this is instruction text only, + not new capabilities the model can act on. +- RAG / embedding over skill contents. +- Skills outside the one configured directory. |
