aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
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/specs
parent8cb5cc303f1c83d2c63d5cb972a7faee294e7c23 (diff)
downloadllamachat-8e35d35796d5925ac7a4ee95dd60120a3c6b9539.tar.gz
llamachat-8e35d35796d5925ac7a4ee95dd60120a3c6b9539.zip
docs: spec and plan for skills support
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-08-25-skills-design.md188
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.