aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-08-25-skills-design.md
blob: 99baee61a3cd26d71122f2819b49de0721a9a459 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# 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. One-shot mode keeps it
in memory (never in history) until New or the chip is clicked, so a skill
loaded in a scratch window stays active for the rest of it.

## 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.