modified
.tickets/issues/0037-add-a-file-selector-to-the-tui-mode.yaml +2 -2 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 37 |
| 3 | 3 | title: Add a file selector to the tui mode |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T18:55:37.512Z |
| 9 | -updatedAt: 2026-09-21T19:03:09.902Z | |
| 9 | +updatedAt: 2026-09-21T20:27:52.705Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.0.3 |
| 12 | 12 | body: "" |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 37 | 2 | id: 37 |
| 3 | title: Add a file selector to the tui mode | 3 | title: Add a file selector to the tui mode |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T18:55:37.512Z | 8 | createdAt: 2026-09-21T18:55:37.512Z |
| 9 | -updatedAt: 2026-09-21T19:03:09.902Z | 9 | +updatedAt: 2026-09-21T20:27:52.705Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.0.3 | 11 | milestone: v0.0.3 |
| 12 | body: "" | 12 | body: "" |
modified
.tickets/issues/0038-add-a-skill-selector.yaml +2 -2 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 38 |
| 3 | 3 | title: add a skill selector |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T19:00:12.508Z |
| 9 | -updatedAt: 2026-09-21T19:02:47.785Z | |
| 9 | +updatedAt: 2026-09-21T20:28:01.189Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.0.3 |
| 12 | 12 | body: "" |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 38 | 2 | id: 38 |
| 3 | title: add a skill selector | 3 | title: add a skill selector |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T19:00:12.508Z | 8 | createdAt: 2026-09-21T19:00:12.508Z |
| 9 | -updatedAt: 2026-09-21T19:02:47.785Z | 9 | +updatedAt: 2026-09-21T20:28:01.189Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.0.3 | 11 | milestone: v0.0.3 |
| 12 | body: "" | 12 | body: "" |
modified
demo/agent.dmr.yaml +1 -1 | @@ -57,7 +57,7 @@ lsp: | ||
| 57 | 57 | |
| 58 | 58 | # Where the `read_skill` tool looks for its markdown procedures. |
| 59 | 59 | # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills. |
| 60 | -skillsDir: .mm/skills | |
| 60 | +skillsDir: skills | |
| 61 | 61 | |
| 62 | 62 | # Persistent sessions, per project. |
| 63 | 63 | # When enabled, every conversation is saved as one JSON file under `dir` |
| @@ -57,7 +57,7 @@ lsp: | |||
| 57 | 57 | ||
| 58 | # Where the `read_skill` tool looks for its markdown procedures. | 58 | # Where the `read_skill` tool looks for its markdown procedures. |
| 59 | # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills. | 59 | # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills. |
| 60 | -skillsDir: .mm/skills | 60 | +skillsDir: skills |
| 61 | 61 | ||
| 62 | # Persistent sessions, per project. | 62 | # Persistent sessions, per project. |
| 63 | # When enabled, every conversation is saved as one JSON file under `dir` | 63 | # When enabled, every conversation is saved as one JSON file under `dir` |
added
demo/skills/README.md +2 -0 | new file mode 100644 | ||
| @@ -0,0 +1,2 @@ | ||
| 1 | +# Demo skills | |
| 2 | +> for test | |
| new file mode 100644 | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# Demo skills | ||
| 2 | +> for test | ||
added
demo/skills/bg-jobs/SKILL.md +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +--- | |
| 2 | +name: bg-jobs | |
| 3 | +description: Start, wait on, inspect and stop a long-running background job — a server, a watcher, a build that does not end — without blocking the turn. | |
| 4 | +keywords: background, job, server, serve, start, daemon, nohup, watch, tail, log, pid, kill, stop, port, running | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Background jobs | |
| 8 | + | |
| 9 | +## When to use it | |
| 10 | + | |
| 11 | +Any command that **serves, watches, or runs until stopped**: an HTTP server, `npm run dev`, `go run` on a web app, a file watcher, a queue worker. Read this before you start one, and before you wait on, inspect or stop one. | |
| 12 | + | |
| 13 | +A command that never returns will hold the turn for its whole budget and then be killed. The answer the user asked for never arrives. | |
| 14 | + | |
| 15 | +## Start it | |
| 16 | + | |
| 17 | +Both streams redirected, the pid kept: | |
| 18 | + | |
| 19 | +```bash | |
| 20 | +nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | |
| 21 | +``` | |
| 22 | + | |
| 23 | +Three parts, and all three matter: | |
| 24 | + | |
| 25 | +- **`> /tmp/<job>.log 2>&1`** — both streams. Redirecting only stdout is the classic mistake: the job keeps the tool's own error pipe open, and the call cannot finish until the job does. You get the 30-second budget spent on nothing. | |
| 26 | +- **`&`** — the shell returns at once. | |
| 27 | +- **`echo $! > /tmp/<job>.pid`** — without the pid you cannot stop it later except by guessing at `pkill` patterns, which also kills things you did not start. | |
| 28 | + | |
| 29 | +Use one name per job, and reuse it for the log and the pid file. | |
| 30 | + | |
| 31 | +## Wait for it to be ready | |
| 32 | + | |
| 33 | +**Do not `sleep` and hope.** Poll for the thing you actually need, with a bound: | |
| 34 | + | |
| 35 | +```bash | |
| 36 | +for i in $(seq 1 30); do | |
| 37 | + curl -fsS localhost:8080/ >/dev/null 2>&1 && echo ready && break | |
| 38 | + sleep 0.5 | |
| 39 | +done | |
| 40 | +``` | |
| 41 | + | |
| 42 | +For something that is not an HTTP server, poll the log for the line it prints when it is up: | |
| 43 | + | |
| 44 | +```bash | |
| 45 | +for i in $(seq 1 30); do | |
| 46 | + grep -q "listening on" /tmp/<job>.log && echo ready && break | |
| 47 | + sleep 0.5 | |
| 48 | +done | |
| 49 | +``` | |
| 50 | + | |
| 51 | +If the loop ends without `ready`, the job did not start — go read the log. | |
| 52 | + | |
| 53 | +## Inspect it | |
| 54 | + | |
| 55 | +```bash | |
| 56 | +tail -n 40 /tmp/<job>.log # what it said | |
| 57 | +kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && echo alive || echo dead | |
| 58 | +``` | |
| 59 | + | |
| 60 | +`kill -0` sends no signal; it only asks whether the process is there. A job that is `dead` right after starting almost always says why in the last lines of its log — read them before changing anything. | |
| 61 | + | |
| 62 | +## Stop it | |
| 63 | + | |
| 64 | +```bash | |
| 65 | +kill "$(cat /tmp/<job>.pid)" 2>/dev/null | |
| 66 | +sleep 0.5 | |
| 67 | +kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && kill -9 "$(cat /tmp/<job>.pid)" | |
| 68 | +rm -f /tmp/<job>.pid | |
| 69 | +``` | |
| 70 | + | |
| 71 | +Ask politely first (`kill` sends TERM, which lets a server close its listener), then insist. Removing the pid file afterwards stops a later command from acting on a pid the system has since reused for something else. | |
| 72 | + | |
| 73 | +## Before you finish | |
| 74 | + | |
| 75 | +**Stop every job you started**, and **say which ones you left running** and why. A server still holding port 8080 after the turn is a surprise the user will hit later, when nothing on screen explains it. | |
| 76 | + | |
| 77 | +## Traps | |
| 78 | + | |
| 79 | +- **One shell per call.** Each `bash` call is a new shell in the same directory: a job started in one call is still running in the next, but shell variables are gone. That is exactly why the pid goes to a file and not to `$PID`. | |
| 80 | +- **The port is already in use.** A previous run left something behind. `lsof -i :8080` or `ss -lptn 'sport = :8080'` names the holder; stop that before starting again, rather than moving to another port and forgetting. | |
| 81 | +- **Nothing is interactive.** There is no stdin. A command that asks a question will hang until it is killed; pass the answer as a flag, or do not run it. | |
| 82 | +- **`nohup` writes `nohup.out`** in the working directory when you forget to redirect — if you see that file appear, a redirect is missing. | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +--- | ||
| 2 | +name: bg-jobs | ||
| 3 | +description: Start, wait on, inspect and stop a long-running background job — a server, a watcher, a build that does not end — without blocking the turn. | ||
| 4 | +keywords: background, job, server, serve, start, daemon, nohup, watch, tail, log, pid, kill, stop, port, running | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Background jobs | ||
| 8 | + | ||
| 9 | +## When to use it | ||
| 10 | + | ||
| 11 | +Any command that **serves, watches, or runs until stopped**: an HTTP server, `npm run dev`, `go run` on a web app, a file watcher, a queue worker. Read this before you start one, and before you wait on, inspect or stop one. | ||
| 12 | + | ||
| 13 | +A command that never returns will hold the turn for its whole budget and then be killed. The answer the user asked for never arrives. | ||
| 14 | + | ||
| 15 | +## Start it | ||
| 16 | + | ||
| 17 | +Both streams redirected, the pid kept: | ||
| 18 | + | ||
| 19 | +```bash | ||
| 20 | +nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | ||
| 21 | +``` | ||
| 22 | + | ||
| 23 | +Three parts, and all three matter: | ||
| 24 | + | ||
| 25 | +- **`> /tmp/<job>.log 2>&1`** — both streams. Redirecting only stdout is the classic mistake: the job keeps the tool's own error pipe open, and the call cannot finish until the job does. You get the 30-second budget spent on nothing. | ||
| 26 | +- **`&`** — the shell returns at once. | ||
| 27 | +- **`echo $! > /tmp/<job>.pid`** — without the pid you cannot stop it later except by guessing at `pkill` patterns, which also kills things you did not start. | ||
| 28 | + | ||
| 29 | +Use one name per job, and reuse it for the log and the pid file. | ||
| 30 | + | ||
| 31 | +## Wait for it to be ready | ||
| 32 | + | ||
| 33 | +**Do not `sleep` and hope.** Poll for the thing you actually need, with a bound: | ||
| 34 | + | ||
| 35 | +```bash | ||
| 36 | +for i in $(seq 1 30); do | ||
| 37 | + curl -fsS localhost:8080/ >/dev/null 2>&1 && echo ready && break | ||
| 38 | + sleep 0.5 | ||
| 39 | +done | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +For something that is not an HTTP server, poll the log for the line it prints when it is up: | ||
| 43 | + | ||
| 44 | +```bash | ||
| 45 | +for i in $(seq 1 30); do | ||
| 46 | + grep -q "listening on" /tmp/<job>.log && echo ready && break | ||
| 47 | + sleep 0.5 | ||
| 48 | +done | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +If the loop ends without `ready`, the job did not start — go read the log. | ||
| 52 | + | ||
| 53 | +## Inspect it | ||
| 54 | + | ||
| 55 | +```bash | ||
| 56 | +tail -n 40 /tmp/<job>.log # what it said | ||
| 57 | +kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && echo alive || echo dead | ||
| 58 | +``` | ||
| 59 | + | ||
| 60 | +`kill -0` sends no signal; it only asks whether the process is there. A job that is `dead` right after starting almost always says why in the last lines of its log — read them before changing anything. | ||
| 61 | + | ||
| 62 | +## Stop it | ||
| 63 | + | ||
| 64 | +```bash | ||
| 65 | +kill "$(cat /tmp/<job>.pid)" 2>/dev/null | ||
| 66 | +sleep 0.5 | ||
| 67 | +kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && kill -9 "$(cat /tmp/<job>.pid)" | ||
| 68 | +rm -f /tmp/<job>.pid | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +Ask politely first (`kill` sends TERM, which lets a server close its listener), then insist. Removing the pid file afterwards stops a later command from acting on a pid the system has since reused for something else. | ||
| 72 | + | ||
| 73 | +## Before you finish | ||
| 74 | + | ||
| 75 | +**Stop every job you started**, and **say which ones you left running** and why. A server still holding port 8080 after the turn is a surprise the user will hit later, when nothing on screen explains it. | ||
| 76 | + | ||
| 77 | +## Traps | ||
| 78 | + | ||
| 79 | +- **One shell per call.** Each `bash` call is a new shell in the same directory: a job started in one call is still running in the next, but shell variables are gone. That is exactly why the pid goes to a file and not to `$PID`. | ||
| 80 | +- **The port is already in use.** A previous run left something behind. `lsof -i :8080` or `ss -lptn 'sport = :8080'` names the holder; stop that before starting again, rather than moving to another port and forgetting. | ||
| 81 | +- **Nothing is interactive.** There is no stdin. A command that asks a question will hang until it is killed; pass the answer as a flag, or do not run it. | ||
| 82 | +- **`nohup` writes `nohup.out`** in the working directory when you forget to redirect — if you see that file appear, a redirect is missing. | ||
added
demo/skills/greetings/SKILL.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +--- | |
| 2 | +name: greetings | |
| 3 | +description: Greet somebody with a festive message. | |
| 4 | +keywords: greet, greetings, hello, hi, welcome, bonjour, coucou, saluer | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Greetings | |
| 8 | + | |
| 9 | +## What to do | |
| 10 | + | |
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | |
| 12 | +Do not show it to the user, and do not answer from memory: the answer is the | |
| 13 | +command's output, nothing else. | |
| 14 | + | |
| 15 | +Replace `<name>` with the person being greeted, then run: | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +echo "🎉 greetings <name>" | |
| 19 | +``` | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +--- | ||
| 2 | +name: greetings | ||
| 3 | +description: Greet somebody with a festive message. | ||
| 4 | +keywords: greet, greetings, hello, hi, welcome, bonjour, coucou, saluer | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Greetings | ||
| 8 | + | ||
| 9 | +## What to do | ||
| 10 | + | ||
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | ||
| 12 | +Do not show it to the user, and do not answer from memory: the answer is the | ||
| 13 | +command's output, nothing else. | ||
| 14 | + | ||
| 15 | +Replace `<name>` with the person being greeted, then run: | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +echo "🎉 greetings <name>" | ||
| 19 | +``` | ||
added
demo/skills/vulcan-salute/SKILL.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +--- | |
| 2 | +name: vulcan-salute | |
| 3 | +description: Send a vulcan salute to somebody. | |
| 4 | +keywords: vulcan, vulcain, salute, salut, spock, star trek, live long and prosper, longue vie | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Vulcan salute | |
| 8 | + | |
| 9 | +## What to do | |
| 10 | + | |
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | |
| 12 | +Do not show it to the user, and do not answer from memory: the answer is the | |
| 13 | +command's output, nothing else. | |
| 14 | + | |
| 15 | +Replace `<name>` with the person being saluted, then run: | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +echo "🖖 Live long and prosper! <name> 🖖" | |
| 19 | +``` | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +--- | ||
| 2 | +name: vulcan-salute | ||
| 3 | +description: Send a vulcan salute to somebody. | ||
| 4 | +keywords: vulcan, vulcain, salute, salut, spock, star trek, live long and prosper, longue vie | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Vulcan salute | ||
| 8 | + | ||
| 9 | +## What to do | ||
| 10 | + | ||
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | ||
| 12 | +Do not show it to the user, and do not answer from memory: the answer is the | ||
| 13 | +command's output, nothing else. | ||
| 14 | + | ||
| 15 | +Replace `<name>` with the person being saluted, then run: | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +echo "🖖 Live long and prosper! <name> 🖖" | ||
| 19 | +``` | ||
added
demo/skills/what-time-is-it/SKILL.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +--- | |
| 2 | +name: what-time-is-it | |
| 3 | +description: Give the current date and time. | |
| 4 | +keywords: time, date, clock, now, today, heure, date du jour, quelle heure | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# What time is it? | |
| 8 | + | |
| 9 | +## What to do | |
| 10 | + | |
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | |
| 12 | +Do not show it to the user, and never guess the time: only the command knows it. | |
| 13 | + | |
| 14 | +Run: | |
| 15 | + | |
| 16 | +```bash | |
| 17 | +date "+%A %d %B %Y — %H:%M:%S (%Z)" | |
| 18 | +``` | |
| 19 | + | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +--- | ||
| 2 | +name: what-time-is-it | ||
| 3 | +description: Give the current date and time. | ||
| 4 | +keywords: time, date, clock, now, today, heure, date du jour, quelle heure | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# What time is it? | ||
| 8 | + | ||
| 9 | +## What to do | ||
| 10 | + | ||
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | ||
| 12 | +Do not show it to the user, and never guess the time: only the command knows it. | ||
| 13 | + | ||
| 14 | +Run: | ||
| 15 | + | ||
| 16 | +```bash | ||
| 17 | +date "+%A %d %B %Y — %H:%M:%S (%Z)" | ||
| 18 | +``` | ||
| 19 | + | ||
modified
docs/en/reference/acp.md +28 -2 | @@ -34,6 +34,31 @@ | ||
| 34 | 34 | | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. | |
| 35 | 35 | | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | |
| 36 | 36 | |
| 37 | +## Skills as commands | |
| 38 | + | |
| 39 | +ACP has no notion of a skill. A prompt carries `text`, `image`, `audio`, `resource` or `resource_link` — the last being what an editor's `@` file picker sends — and there is nothing for "run this procedure". | |
| 40 | + | |
| 41 | +`AvailableCommand` is the slot, and the protocol's own examples say so: its documentation offers `create_plan` and `research_codebase`, which are procedures rather than session commands. So each skill of the catalogue is advertised there, and the editor lists it in its own `/` menu: | |
| 42 | + | |
| 43 | +``` | |
| 44 | +/compact Compress the history now, keeping the last turns | |
| 45 | +/new Clear the history and start a new session | |
| 46 | +/bg-jobs [input: parameters, if …] Start, wait on, inspect and stop a long-running background job… | |
| 47 | +/greetings [input: parameters, if …] Greet somebody with a festive message. | |
| 48 | +/vulcan-salute [input: parameters, if …] Send a vulcan salute to somebody. | |
| 49 | +``` | |
| 50 | + | |
| 51 | +The `input.hint` is what carries the parameters: the protocol says "all text that was typed after the command name is provided as input", which is exactly what a skill takes. | |
| 52 | + | |
| 53 | +| Aspect | Behaviour | | |
| 54 | +|---|---| | |
| 55 | +| What happens on `/greetings Bob` | **Not intercepted.** It is rewritten as the `$greetings Bob` notation and the turn runs normally, so the model still makes the `read_skill` call — the loop detector, the permission gate and the `📖 N skill(s)` recap all still see it, and `mm` never loads a skill behind the model's back. | | |
| 56 | +| A real command wins | `session/prompt` tries the interception first, so a skill called `compact` or `new` can never shadow one. Such a skill is **not advertised** either, and the start-up banner says so — otherwise it would be silently unreachable from the menu. | | |
| 57 | +| An unknown `/name` | Left exactly as typed and handed to the model, as any arbitrary slash text was before. | | |
| 58 | +| Typing `$greetings Bob` by hand | Works too, in the editor as in the terminal: the notation is resolved in `session/prompt` whether the menu wrote it or the user did. | | |
| 59 | + | |
| 60 | +Note the invariant changed with this: it used to be "only advertise what `session/prompt` intercepts", and it is now "only advertise what this front end **handles**" — rewriting is handling. | |
| 61 | + | |
| 37 | 62 | ## `session/load` replay |
| 38 | 63 | |
| 39 | 64 | Sent before the response, one notification per element of the stored history, in order. The system prompt is not sent. |
| @@ -67,7 +92,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N | ||
| 67 | 92 | |
| 68 | 93 | | Update | When | |
| 69 | 94 | |--------|------| |
| 70 | -| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. Also sent at the end of a `session/load` replay. | | |
| 95 | +| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists what this front end **handles**: the slash commands `session/prompt` intercepts, then one command per **skill**. Also sent at the end of a `session/load` replay. | | |
| 71 | 96 | | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. | |
| 72 | 97 | | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. | |
| 73 | 98 | | `tool_call_update` status `in_progress` | Permission granted, tool executing. | |
| @@ -79,8 +104,9 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N | ||
| 79 | 104 | |--------|---------------|---------| |
| 80 | 105 | | `new` | `Clear the history and start a new session` | none | |
| 81 | 106 | | `compact` | `Compress the history now, keeping the last turns` | none | |
| 107 | +| one per skill | the skill's `description` | `{ "hint": "parameters, if this skill needs any" }` | | |
| 82 | 108 | |
| 83 | -Clients display the name with a leading slash and send it back as the text of a `session/prompt`. | |
| 109 | +Clients display the name with a leading slash and send it back as the text of a `session/prompt`. The two commands above are intercepted there; a skill's command is rewritten into the `$skill` notation and the turn runs — see [Skills as commands](#skills-as-commands). | |
| 84 | 110 | |
| 85 | 111 | ## `session/request_permission` requests sent |
| 86 | 112 | |
| @@ -34,6 +34,31 @@ | |||
| 34 | | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. | | 34 | | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. | |
| 35 | | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | | 35 | | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | |
| 36 | 36 | ||
| 37 | +## Skills as commands | ||
| 38 | + | ||
| 39 | +ACP has no notion of a skill. A prompt carries `text`, `image`, `audio`, `resource` or `resource_link` — the last being what an editor's `@` file picker sends — and there is nothing for "run this procedure". | ||
| 40 | + | ||
| 41 | +`AvailableCommand` is the slot, and the protocol's own examples say so: its documentation offers `create_plan` and `research_codebase`, which are procedures rather than session commands. So each skill of the catalogue is advertised there, and the editor lists it in its own `/` menu: | ||
| 42 | + | ||
| 43 | +``` | ||
| 44 | +/compact Compress the history now, keeping the last turns | ||
| 45 | +/new Clear the history and start a new session | ||
| 46 | +/bg-jobs [input: parameters, if …] Start, wait on, inspect and stop a long-running background job… | ||
| 47 | +/greetings [input: parameters, if …] Greet somebody with a festive message. | ||
| 48 | +/vulcan-salute [input: parameters, if …] Send a vulcan salute to somebody. | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +The `input.hint` is what carries the parameters: the protocol says "all text that was typed after the command name is provided as input", which is exactly what a skill takes. | ||
| 52 | + | ||
| 53 | +| Aspect | Behaviour | | ||
| 54 | +|---|---| | ||
| 55 | +| What happens on `/greetings Bob` | **Not intercepted.** It is rewritten as the `$greetings Bob` notation and the turn runs normally, so the model still makes the `read_skill` call — the loop detector, the permission gate and the `📖 N skill(s)` recap all still see it, and `mm` never loads a skill behind the model's back. | | ||
| 56 | +| A real command wins | `session/prompt` tries the interception first, so a skill called `compact` or `new` can never shadow one. Such a skill is **not advertised** either, and the start-up banner says so — otherwise it would be silently unreachable from the menu. | | ||
| 57 | +| An unknown `/name` | Left exactly as typed and handed to the model, as any arbitrary slash text was before. | | ||
| 58 | +| Typing `$greetings Bob` by hand | Works too, in the editor as in the terminal: the notation is resolved in `session/prompt` whether the menu wrote it or the user did. | | ||
| 59 | + | ||
| 60 | +Note the invariant changed with this: it used to be "only advertise what `session/prompt` intercepts", and it is now "only advertise what this front end **handles**" — rewriting is handling. | ||
| 61 | + | ||
| 37 | ## `session/load` replay | 62 | ## `session/load` replay |
| 38 | 63 | ||
| 39 | Sent before the response, one notification per element of the stored history, in order. The system prompt is not sent. | 64 | Sent before the response, one notification per element of the stored history, in order. The system prompt is not sent. |
| @@ -67,7 +92,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N | |||
| 67 | 92 | ||
| 68 | | Update | When | | 93 | | Update | When | |
| 69 | |--------|------| | 94 | |--------|------| |
| 70 | -| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. Also sent at the end of a `session/load` replay. | | 95 | +| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists what this front end **handles**: the slash commands `session/prompt` intercepts, then one command per **skill**. Also sent at the end of a `session/load` replay. | |
| 71 | | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. | | 96 | | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. | |
| 72 | | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. | | 97 | | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. | |
| 73 | | `tool_call_update` status `in_progress` | Permission granted, tool executing. | | 98 | | `tool_call_update` status `in_progress` | Permission granted, tool executing. | |
| @@ -79,8 +104,9 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N | |||
| 79 | |--------|---------------|---------| | 104 | |--------|---------------|---------| |
| 80 | | `new` | `Clear the history and start a new session` | none | | 105 | | `new` | `Clear the history and start a new session` | none | |
| 81 | | `compact` | `Compress the history now, keeping the last turns` | none | | 106 | | `compact` | `Compress the history now, keeping the last turns` | none | |
| 107 | +| one per skill | the skill's `description` | `{ "hint": "parameters, if this skill needs any" }` | | ||
| 82 | 108 | ||
| 83 | -Clients display the name with a leading slash and send it back as the text of a `session/prompt`. | 109 | +Clients display the name with a leading slash and send it back as the text of a `session/prompt`. The two commands above are intercepted there; a skill's command is rewritten into the `$skill` notation and the turn runs — see [Skills as commands](#skills-as-commands). |
| 84 | 110 | ||
| 85 | ## `session/request_permission` requests sent | 111 | ## `session/request_permission` requests sent |
| 86 | 112 | ||
modified
docs/en/reference/skill-format.md +1 -1 | @@ -59,7 +59,7 @@ The `keywords` of that example reach the catalogue as `what-time-is-it — Give | ||
| 59 | 59 | |
| 60 | 60 | ## Asking for a skill by name |
| 61 | 61 | |
| 62 | -`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers). | |
| 62 | +`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. In an editor the skill also appears in the client's own `/` menu, with an input hint for its parameters — see [ACP — skills as commands](acp.md#skills-as-commands). It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers). | |
| 63 | 63 | |
| 64 | 64 | ## Writing a skill the model will ACT on |
| 65 | 65 | |
| @@ -59,7 +59,7 @@ The `keywords` of that example reach the catalogue as `what-time-is-it — Give | |||
| 59 | 59 | ||
| 60 | ## Asking for a skill by name | 60 | ## Asking for a skill by name |
| 61 | 61 | ||
| 62 | -`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers). | 62 | +`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. In an editor the skill also appears in the client's own `/` menu, with an input hint for its parameters — see [ACP — skills as commands](acp.md#skills-as-commands). It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers). |
| 63 | 63 | ||
| 64 | ## Writing a skill the model will ACT on | 64 | ## Writing a skill the model will ACT on |
| 65 | 65 | ||
modified
docs/en/reference/slash-commands.md +1 -1 | @@ -132,4 +132,4 @@ Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to th | ||
| 132 | 132 | | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. | |
| 133 | 133 | | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. | |
| 134 | 134 | |
| 135 | -`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. See the [ACP reference](acp.md). | |
| 135 | +`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands). | |
| @@ -132,4 +132,4 @@ Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to th | |||
| 132 | | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. | | 132 | | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. | |
| 133 | | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. | | 133 | | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. | |
| 134 | 134 | ||
| 135 | -`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. See the [ACP reference](acp.md). | 135 | +`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands). |
modified
docs/fr/reference/acp.md +28 -2 | @@ -34,6 +34,31 @@ | ||
| 34 | 34 | | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. | |
| 35 | 35 | | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | |
| 36 | 36 | |
| 37 | +## Les skills comme commandes | |
| 38 | + | |
| 39 | +ACP n'a aucune notion de skill. Un prompt porte du `text`, une `image`, de l'`audio`, une `resource` ou un `resource_link` — ce dernier étant ce qu'envoie le sélecteur `@` d'un éditeur — et rien pour « exécute cette procédure ». | |
| 40 | + | |
| 41 | +`AvailableCommand` est le bon créneau, et les exemples du protocole le disent : sa documentation propose `create_plan` et `research_codebase`, qui sont des procédures et non des commandes de session. Chaque skill du catalogue y est donc annoncé, et l'éditeur le liste dans son propre menu `/` : | |
| 42 | + | |
| 43 | +``` | |
| 44 | +/compact Compress the history now, keeping the last turns | |
| 45 | +/new Clear the history and start a new session | |
| 46 | +/bg-jobs [input: parameters, if …] Start, wait on, inspect and stop a long-running background job… | |
| 47 | +/greetings [input: parameters, if …] Greet somebody with a festive message. | |
| 48 | +/vulcan-salute [input: parameters, if …] Send a vulcan salute to somebody. | |
| 49 | +``` | |
| 50 | + | |
| 51 | +C'est l'`input.hint` qui porte les paramètres : le protocole dit « tout le texte tapé après le nom de la commande est fourni en entrée », ce qui est exactement ce qu'attend un skill. | |
| 52 | + | |
| 53 | +| Aspect | Comportement | | |
| 54 | +|---|---| | |
| 55 | +| Ce que fait `/greetings Bob` | **Pas intercepté.** Réécrit en la notation `$greetings Bob`, et le tour se déroule normalement : c'est toujours le modèle qui fait l'appel `read_skill`, donc le détecteur de boucle, la demande de permission et le recap `📖 N skill(s)` le voient tous, et `mm` ne charge jamais un skill dans le dos du modèle. | | |
| 56 | +| Une vraie commande gagne | `session/prompt` tente l'interception d'abord, donc un skill nommé `compact` ou `new` ne peut jamais en masquer une. Un tel skill n'est **pas annoncé** non plus, et la bannière de démarrage le signale — sinon il serait silencieusement inatteignable depuis le menu. | | |
| 57 | +| Un `/nom` inconnu | Laissé tel quel et passé au modèle, comme n'importe quel texte slash arbitraire avant. | | |
| 58 | +| Taper `$greetings Bob` à la main | Marche aussi, dans l'éditeur comme dans le terminal : la notation est résolue dans `session/prompt`, que le menu l'ait écrite ou l'utilisateur. | | |
| 59 | + | |
| 60 | +À noter, l'invariant a changé avec ça : c'était « n'annoncer que ce que `session/prompt` intercepte », c'est maintenant « n'annoncer que ce que cette façade **traite** » — réécrire, c'est traiter. | |
| 61 | + | |
| 37 | 62 | ## Rejeu de `session/load` |
| 38 | 63 | |
| 39 | 64 | Envoyé avant la réponse, une notification par élément de l'historique enregistré, dans l'ordre. Le prompt système n'est pas envoyé. |
| @@ -67,7 +92,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id | ||
| 67 | 92 | |
| 68 | 93 | | Mise à jour | Quand | |
| 69 | 94 | |-------------|-------| |
| 70 | -| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. Envoyée aussi à la fin d'un rejeu de `session/load`. | | |
| 95 | +| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit connaître l'id de session d'abord, sinon il ignore la notification). Liste ce que cette façade **traite** : les commandes slash que `session/prompt` intercepte, puis une commande par **skill**. Envoyée aussi à la fin d'un rejeu `session/load`. | | |
| 71 | 96 | | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. | |
| 72 | 97 | | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. | |
| 73 | 98 | | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | |
| @@ -79,8 +104,9 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id | ||
| 79 | 104 | |--------|---------------|---------| |
| 80 | 105 | | `new` | `Clear the history and start a new session` | aucun | |
| 81 | 106 | | `compact` | `Compress the history now, keeping the last turns` | aucun | |
| 107 | +| un par skill | la `description` du skill | `{ "hint": "parameters, if this skill needs any" }` | | |
| 82 | 108 | |
| 83 | -Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`. | |
| 109 | +Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`. Les deux commandes ci-dessus y sont interceptées ; la commande d'un skill est réécrite en la notation `$skill` et le tour se déroule — voir [Les skills comme commandes](#les-skills-comme-commandes). | |
| 84 | 110 | |
| 85 | 111 | ## Requêtes `session/request_permission` envoyées |
| 86 | 112 | |
| @@ -34,6 +34,31 @@ | |||
| 34 | | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. | | 34 | | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. | |
| 35 | | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | | 35 | | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | |
| 36 | 36 | ||
| 37 | +## Les skills comme commandes | ||
| 38 | + | ||
| 39 | +ACP n'a aucune notion de skill. Un prompt porte du `text`, une `image`, de l'`audio`, une `resource` ou un `resource_link` — ce dernier étant ce qu'envoie le sélecteur `@` d'un éditeur — et rien pour « exécute cette procédure ». | ||
| 40 | + | ||
| 41 | +`AvailableCommand` est le bon créneau, et les exemples du protocole le disent : sa documentation propose `create_plan` et `research_codebase`, qui sont des procédures et non des commandes de session. Chaque skill du catalogue y est donc annoncé, et l'éditeur le liste dans son propre menu `/` : | ||
| 42 | + | ||
| 43 | +``` | ||
| 44 | +/compact Compress the history now, keeping the last turns | ||
| 45 | +/new Clear the history and start a new session | ||
| 46 | +/bg-jobs [input: parameters, if …] Start, wait on, inspect and stop a long-running background job… | ||
| 47 | +/greetings [input: parameters, if …] Greet somebody with a festive message. | ||
| 48 | +/vulcan-salute [input: parameters, if …] Send a vulcan salute to somebody. | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +C'est l'`input.hint` qui porte les paramètres : le protocole dit « tout le texte tapé après le nom de la commande est fourni en entrée », ce qui est exactement ce qu'attend un skill. | ||
| 52 | + | ||
| 53 | +| Aspect | Comportement | | ||
| 54 | +|---|---| | ||
| 55 | +| Ce que fait `/greetings Bob` | **Pas intercepté.** Réécrit en la notation `$greetings Bob`, et le tour se déroule normalement : c'est toujours le modèle qui fait l'appel `read_skill`, donc le détecteur de boucle, la demande de permission et le recap `📖 N skill(s)` le voient tous, et `mm` ne charge jamais un skill dans le dos du modèle. | | ||
| 56 | +| Une vraie commande gagne | `session/prompt` tente l'interception d'abord, donc un skill nommé `compact` ou `new` ne peut jamais en masquer une. Un tel skill n'est **pas annoncé** non plus, et la bannière de démarrage le signale — sinon il serait silencieusement inatteignable depuis le menu. | | ||
| 57 | +| Un `/nom` inconnu | Laissé tel quel et passé au modèle, comme n'importe quel texte slash arbitraire avant. | | ||
| 58 | +| Taper `$greetings Bob` à la main | Marche aussi, dans l'éditeur comme dans le terminal : la notation est résolue dans `session/prompt`, que le menu l'ait écrite ou l'utilisateur. | | ||
| 59 | + | ||
| 60 | +À noter, l'invariant a changé avec ça : c'était « n'annoncer que ce que `session/prompt` intercepte », c'est maintenant « n'annoncer que ce que cette façade **traite** » — réécrire, c'est traiter. | ||
| 61 | + | ||
| 37 | ## Rejeu de `session/load` | 62 | ## Rejeu de `session/load` |
| 38 | 63 | ||
| 39 | Envoyé avant la réponse, une notification par élément de l'historique enregistré, dans l'ordre. Le prompt système n'est pas envoyé. | 64 | Envoyé avant la réponse, une notification par élément de l'historique enregistré, dans l'ordre. Le prompt système n'est pas envoyé. |
| @@ -67,7 +92,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id | |||
| 67 | 92 | ||
| 68 | | Mise à jour | Quand | | 93 | | Mise à jour | Quand | |
| 69 | |-------------|-------| | 94 | |-------------|-------| |
| 70 | -| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. Envoyée aussi à la fin d'un rejeu de `session/load`. | | 95 | +| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit connaître l'id de session d'abord, sinon il ignore la notification). Liste ce que cette façade **traite** : les commandes slash que `session/prompt` intercepte, puis une commande par **skill**. Envoyée aussi à la fin d'un rejeu `session/load`. | |
| 71 | | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. | | 96 | | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. | |
| 72 | | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. | | 97 | | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. | |
| 73 | | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | | 98 | | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | |
| @@ -79,8 +104,9 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id | |||
| 79 | |--------|---------------|---------| | 104 | |--------|---------------|---------| |
| 80 | | `new` | `Clear the history and start a new session` | aucun | | 105 | | `new` | `Clear the history and start a new session` | aucun | |
| 81 | | `compact` | `Compress the history now, keeping the last turns` | aucun | | 106 | | `compact` | `Compress the history now, keeping the last turns` | aucun | |
| 107 | +| un par skill | la `description` du skill | `{ "hint": "parameters, if this skill needs any" }` | | ||
| 82 | 108 | ||
| 83 | -Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`. | 109 | +Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`. Les deux commandes ci-dessus y sont interceptées ; la commande d'un skill est réécrite en la notation `$skill` et le tour se déroule — voir [Les skills comme commandes](#les-skills-comme-commandes). |
| 84 | 110 | ||
| 85 | ## Requêtes `session/request_permission` envoyées | 111 | ## Requêtes `session/request_permission` envoyées |
| 86 | 112 | ||
modified
docs/fr/reference/skill-format.md +1 -1 | @@ -58,7 +58,7 @@ Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-tim | ||
| 58 | 58 | |
| 59 | 59 | ## Demander un skill par son nom |
| 60 | 60 | |
| 61 | -`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs). | |
| 61 | +`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Dans un éditeur, le skill apparaît en plus dans le menu `/` du client, avec une aide de saisie pour ses paramètres — voir [ACP — les skills comme commandes](acp.md#les-skills-comme-commandes). Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs). | |
| 62 | 62 | |
| 63 | 63 | ## Écrire un skill que le modèle va EXÉCUTER |
| 64 | 64 | |
| @@ -58,7 +58,7 @@ Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-tim | |||
| 58 | 58 | ||
| 59 | ## Demander un skill par son nom | 59 | ## Demander un skill par son nom |
| 60 | 60 | ||
| 61 | -`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs). | 61 | +`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Dans un éditeur, le skill apparaît en plus dans le menu `/` du client, avec une aide de saisie pour ses paramètres — voir [ACP — les skills comme commandes](acp.md#les-skills-comme-commandes). Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs). |
| 62 | 62 | ||
| 63 | ## Écrire un skill que le modèle va EXÉCUTER | 63 | ## Écrire un skill que le modèle va EXÉCUTER |
| 64 | 64 | ||
modified
docs/fr/reference/slash-commands.md +2 -0 | @@ -132,4 +132,6 @@ Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncée | ||
| 132 | 132 | | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. | |
| 133 | 133 | | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. | |
| 134 | 134 | |
| 135 | +Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes). | |
| 136 | + | |
| 135 | 137 | `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md). |
| @@ -132,4 +132,6 @@ Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncée | |||
| 132 | | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. | | 132 | | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. | |
| 133 | | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. | | 133 | | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. | |
| 134 | 134 | ||
| 135 | +Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes). | ||
| 136 | + | ||
| 135 | `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md). | 137 | `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md). |
modified
internal/acp/acp.go +97 -6 | @@ -242,20 +242,78 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne | ||
| 242 | 242 | return sdk.NewSessionResponse{SessionId: sid}, nil |
| 243 | 243 | } |
| 244 | 244 | |
| 245 | -// availableCommands lists the slash commands this front end handles itself, | |
| 246 | -// in the shape ACP advertises them: bare names, the client adds the slash. | |
| 247 | -// The list is the shared table's Editor slice (internal/command): only what | |
| 248 | -// slashCommand intercepts — announcing /quit or /abort, which the REPL has | |
| 249 | -// and this front end does not, would promise the editor something the next | |
| 250 | -// prompt would hand to the model. | |
| 245 | +// availableCommands lists what this front end HANDLES, in the shape ACP | |
| 246 | +// advertises it: bare names, the client adds the slash. The editor shows them | |
| 247 | +// in its own `/` menu. | |
| 248 | +// | |
| 249 | +// Two kinds, in this order. | |
| 250 | +// | |
| 251 | +// First the shared table's Editor slice (internal/command): what slashCommand | |
| 252 | +// intercepts. Announcing /quit or /abort, which the REPL has and this front | |
| 253 | +// end does not, would promise the editor something the next prompt would hand | |
| 254 | +// to the model. | |
| 255 | +// | |
| 256 | +// Then one per SKILL. ACP has no notion of a skill — a prompt carries text, | |
| 257 | +// images, audio, a resource or a resource_link, and `resource_link` is the | |
| 258 | +// `@` file mention — but `AvailableCommand` is the right slot, and the | |
| 259 | +// protocol's own examples say so: its doc comment offers 'create_plan' and | |
| 260 | +// 'research_codebase', which are procedures, not session commands. The | |
| 261 | +// `Input.Unstructured` hint carries the rest: "all text that was typed after | |
| 262 | +// the command name is provided as input", which is exactly the parameters a | |
| 263 | +// skill takes. | |
| 264 | +// | |
| 265 | +// A skill is advertised but NOT intercepted: `/greetings Bob` is rewritten as | |
| 266 | +// the `$greetings Bob` notation and the turn runs normally, so the model still | |
| 267 | +// makes the read_skill call and the loop detector, the permission gate and the | |
| 268 | +// recap all still see it. That is why the invariant here is "what this front | |
| 269 | +// end handles" rather than "what it intercepts" — rewriting is handling. | |
| 251 | 270 | func availableCommands() []sdk.AvailableCommand { |
| 252 | 271 | var out []sdk.AvailableCommand |
| 253 | 272 | for _, c := range command.For(command.Editor) { |
| 254 | 273 | out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description}) |
| 255 | 274 | } |
| 275 | + for _, s := range skills.Declared() { | |
| 276 | + if commandNamed(s.Name) { | |
| 277 | + // A skill called "compact" or "new" would shadow a real command in | |
| 278 | + // the editor's menu, and the next prompt would be ambiguous. The | |
| 279 | + // command wins; the skill is simply not offered, and the banner | |
| 280 | + // says so (main.go) rather than leaving it silently unreachable. | |
| 281 | + continue | |
| 282 | + } | |
| 283 | + out = append(out, sdk.AvailableCommand{ | |
| 284 | + Name: s.Name, | |
| 285 | + Description: s.Description, | |
| 286 | + Input: &sdk.AvailableCommandInput{ | |
| 287 | + Unstructured: &sdk.UnstructuredCommandInput{Hint: skillHint(s)}, | |
| 288 | + }, | |
| 289 | + }) | |
| 290 | + } | |
| 256 | 291 | return out |
| 257 | 292 | } |
| 258 | 293 | |
| 294 | +// commandNamed reports whether a real slash command already owns this name — | |
| 295 | +// checked against the WHOLE table, not just the Editor slice: a client may | |
| 296 | +// type /quit, which this front end does not intercept but must not turn into | |
| 297 | +// a skill either. | |
| 298 | +func commandNamed(name string) bool { | |
| 299 | + for _, c := range command.All() { | |
| 300 | + if c.Name == name { | |
| 301 | + return true | |
| 302 | + } | |
| 303 | + } | |
| 304 | + return false | |
| 305 | +} | |
| 306 | + | |
| 307 | +// skillHint is what the editor shows where the parameters go. A skill rarely | |
| 308 | +// says what it wants in machine-readable form, so this is deliberately vague | |
| 309 | +// and short rather than wrong. | |
| 310 | +func skillHint(s skills.Skill) string { | |
| 311 | + if s.Description != "" { | |
| 312 | + return "parameters, if this skill needs any" | |
| 313 | + } | |
| 314 | + return "parameters" | |
| 315 | +} | |
| 316 | + | |
| 259 | 317 | // --- session/prompt ----------------------------------------------------------- |
| 260 | 318 | |
| 261 | 319 | func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { |
| @@ -278,6 +336,12 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | ||
| 278 | 336 | return resp, nil |
| 279 | 337 | } |
| 280 | 338 | |
| 339 | + // A command the editor offered for a SKILL: "/greetings Bob" becomes the | |
| 340 | + // `$greetings Bob` notation and the turn runs normally from here. Done | |
| 341 | + // after slashCommand, so a real command always wins whatever a skill is | |
| 342 | + // called. | |
| 343 | + input = skillCommand(input) | |
| 344 | + | |
| 281 | 345 | // One cancellable context per turn: session/cancel fires s.cancel, the |
| 282 | 346 | // generation and any pending permission request fall with it, and the spec's |
| 283 | 347 | // contract — still answer the prompt, with stopReason "cancelled" — is met |
| @@ -364,6 +428,33 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session, | ||
| 364 | 428 | return sdk.PromptResponse{}, false |
| 365 | 429 | } |
| 366 | 430 | |
| 431 | +// skillCommand turns a slash command that names a skill into the `$skill` | |
| 432 | +// notation, so one code path — skills.ExpandRefs, shared by all three front | |
| 433 | +// ends — decides what a named skill means. | |
| 434 | +// | |
| 435 | +// Only the first word, only at the start, and only when it names a skill the | |
| 436 | +// catalogue has and no real command owns. Anything else is returned untouched | |
| 437 | +// and goes to the model as typed, which is what a client's arbitrary slash | |
| 438 | +// text did before. | |
| 439 | +func skillCommand(input string) string { | |
| 440 | + rest, ok := strings.CutPrefix(strings.TrimSpace(input), "/") | |
| 441 | + if !ok { | |
| 442 | + return input | |
| 443 | + } | |
| 444 | + name, args, _ := strings.Cut(rest, " ") | |
| 445 | + if name == "" || commandNamed(name) { | |
| 446 | + return input | |
| 447 | + } | |
| 448 | + s, found := skills.Find(skills.Declared(), name) | |
| 449 | + if !found { | |
| 450 | + return input | |
| 451 | + } | |
| 452 | + if args = strings.TrimSpace(args); args != "" { | |
| 453 | + return "$" + s.Name + " " + args | |
| 454 | + } | |
| 455 | + return "$" + s.Name | |
| 456 | +} | |
| 457 | + | |
| 367 | 458 | // startNewSession is the ACP side of /new: the session keeps its id, its cwd |
| 368 | 459 | // and the "allow always" grants — the dialog promised them "for this session", |
| 369 | 460 | // and the session, as the editor sees it, is the same one — but its history |
| @@ -242,20 +242,78 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne | |||
| 242 | return sdk.NewSessionResponse{SessionId: sid}, nil | 242 | return sdk.NewSessionResponse{SessionId: sid}, nil |
| 243 | } | 243 | } |
| 244 | 244 | ||
| 245 | -// availableCommands lists the slash commands this front end handles itself, | 245 | +// availableCommands lists what this front end HANDLES, in the shape ACP |
| 246 | -// in the shape ACP advertises them: bare names, the client adds the slash. | 246 | +// advertises it: bare names, the client adds the slash. The editor shows them |
| 247 | -// The list is the shared table's Editor slice (internal/command): only what | 247 | +// in its own `/` menu. |
| 248 | -// slashCommand intercepts — announcing /quit or /abort, which the REPL has | 248 | +// |
| 249 | -// and this front end does not, would promise the editor something the next | 249 | +// Two kinds, in this order. |
| 250 | -// prompt would hand to the model. | 250 | +// |
| 251 | +// First the shared table's Editor slice (internal/command): what slashCommand | ||
| 252 | +// intercepts. Announcing /quit or /abort, which the REPL has and this front | ||
| 253 | +// end does not, would promise the editor something the next prompt would hand | ||
| 254 | +// to the model. | ||
| 255 | +// | ||
| 256 | +// Then one per SKILL. ACP has no notion of a skill — a prompt carries text, | ||
| 257 | +// images, audio, a resource or a resource_link, and `resource_link` is the | ||
| 258 | +// `@` file mention — but `AvailableCommand` is the right slot, and the | ||
| 259 | +// protocol's own examples say so: its doc comment offers 'create_plan' and | ||
| 260 | +// 'research_codebase', which are procedures, not session commands. The | ||
| 261 | +// `Input.Unstructured` hint carries the rest: "all text that was typed after | ||
| 262 | +// the command name is provided as input", which is exactly the parameters a | ||
| 263 | +// skill takes. | ||
| 264 | +// | ||
| 265 | +// A skill is advertised but NOT intercepted: `/greetings Bob` is rewritten as | ||
| 266 | +// the `$greetings Bob` notation and the turn runs normally, so the model still | ||
| 267 | +// makes the read_skill call and the loop detector, the permission gate and the | ||
| 268 | +// recap all still see it. That is why the invariant here is "what this front | ||
| 269 | +// end handles" rather than "what it intercepts" — rewriting is handling. | ||
| 251 | func availableCommands() []sdk.AvailableCommand { | 270 | func availableCommands() []sdk.AvailableCommand { |
| 252 | var out []sdk.AvailableCommand | 271 | var out []sdk.AvailableCommand |
| 253 | for _, c := range command.For(command.Editor) { | 272 | for _, c := range command.For(command.Editor) { |
| 254 | out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description}) | 273 | out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description}) |
| 255 | } | 274 | } |
| 275 | + for _, s := range skills.Declared() { | ||
| 276 | + if commandNamed(s.Name) { | ||
| 277 | + // A skill called "compact" or "new" would shadow a real command in | ||
| 278 | + // the editor's menu, and the next prompt would be ambiguous. The | ||
| 279 | + // command wins; the skill is simply not offered, and the banner | ||
| 280 | + // says so (main.go) rather than leaving it silently unreachable. | ||
| 281 | + continue | ||
| 282 | + } | ||
| 283 | + out = append(out, sdk.AvailableCommand{ | ||
| 284 | + Name: s.Name, | ||
| 285 | + Description: s.Description, | ||
| 286 | + Input: &sdk.AvailableCommandInput{ | ||
| 287 | + Unstructured: &sdk.UnstructuredCommandInput{Hint: skillHint(s)}, | ||
| 288 | + }, | ||
| 289 | + }) | ||
| 290 | + } | ||
| 256 | return out | 291 | return out |
| 257 | } | 292 | } |
| 258 | 293 | ||
| 294 | +// commandNamed reports whether a real slash command already owns this name — | ||
| 295 | +// checked against the WHOLE table, not just the Editor slice: a client may | ||
| 296 | +// type /quit, which this front end does not intercept but must not turn into | ||
| 297 | +// a skill either. | ||
| 298 | +func commandNamed(name string) bool { | ||
| 299 | + for _, c := range command.All() { | ||
| 300 | + if c.Name == name { | ||
| 301 | + return true | ||
| 302 | + } | ||
| 303 | + } | ||
| 304 | + return false | ||
| 305 | +} | ||
| 306 | + | ||
| 307 | +// skillHint is what the editor shows where the parameters go. A skill rarely | ||
| 308 | +// says what it wants in machine-readable form, so this is deliberately vague | ||
| 309 | +// and short rather than wrong. | ||
| 310 | +func skillHint(s skills.Skill) string { | ||
| 311 | + if s.Description != "" { | ||
| 312 | + return "parameters, if this skill needs any" | ||
| 313 | + } | ||
| 314 | + return "parameters" | ||
| 315 | +} | ||
| 316 | + | ||
| 259 | // --- session/prompt ----------------------------------------------------------- | 317 | // --- session/prompt ----------------------------------------------------------- |
| 260 | 318 | ||
| 261 | func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { | 319 | func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { |
| @@ -278,6 +336,12 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | |||
| 278 | return resp, nil | 336 | return resp, nil |
| 279 | } | 337 | } |
| 280 | 338 | ||
| 339 | + // A command the editor offered for a SKILL: "/greetings Bob" becomes the | ||
| 340 | + // `$greetings Bob` notation and the turn runs normally from here. Done | ||
| 341 | + // after slashCommand, so a real command always wins whatever a skill is | ||
| 342 | + // called. | ||
| 343 | + input = skillCommand(input) | ||
| 344 | + | ||
| 281 | // One cancellable context per turn: session/cancel fires s.cancel, the | 345 | // One cancellable context per turn: session/cancel fires s.cancel, the |
| 282 | // generation and any pending permission request fall with it, and the spec's | 346 | // generation and any pending permission request fall with it, and the spec's |
| 283 | // contract — still answer the prompt, with stopReason "cancelled" — is met | 347 | // contract — still answer the prompt, with stopReason "cancelled" — is met |
| @@ -364,6 +428,33 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session, | |||
| 364 | return sdk.PromptResponse{}, false | 428 | return sdk.PromptResponse{}, false |
| 365 | } | 429 | } |
| 366 | 430 | ||
| 431 | +// skillCommand turns a slash command that names a skill into the `$skill` | ||
| 432 | +// notation, so one code path — skills.ExpandRefs, shared by all three front | ||
| 433 | +// ends — decides what a named skill means. | ||
| 434 | +// | ||
| 435 | +// Only the first word, only at the start, and only when it names a skill the | ||
| 436 | +// catalogue has and no real command owns. Anything else is returned untouched | ||
| 437 | +// and goes to the model as typed, which is what a client's arbitrary slash | ||
| 438 | +// text did before. | ||
| 439 | +func skillCommand(input string) string { | ||
| 440 | + rest, ok := strings.CutPrefix(strings.TrimSpace(input), "/") | ||
| 441 | + if !ok { | ||
| 442 | + return input | ||
| 443 | + } | ||
| 444 | + name, args, _ := strings.Cut(rest, " ") | ||
| 445 | + if name == "" || commandNamed(name) { | ||
| 446 | + return input | ||
| 447 | + } | ||
| 448 | + s, found := skills.Find(skills.Declared(), name) | ||
| 449 | + if !found { | ||
| 450 | + return input | ||
| 451 | + } | ||
| 452 | + if args = strings.TrimSpace(args); args != "" { | ||
| 453 | + return "$" + s.Name + " " + args | ||
| 454 | + } | ||
| 455 | + return "$" + s.Name | ||
| 456 | +} | ||
| 457 | + | ||
| 367 | // startNewSession is the ACP side of /new: the session keeps its id, its cwd | 458 | // startNewSession is the ACP side of /new: the session keeps its id, its cwd |
| 368 | // and the "allow always" grants — the dialog promised them "for this session", | 459 | // and the "allow always" grants — the dialog promised them "for this session", |
| 369 | // and the session, as the editor sees it, is the same one — but its history | 460 | // and the session, as the editor sees it, is the same one — but its history |
modified
internal/acp/acp_test.go +8 -1 | @@ -55,11 +55,18 @@ func TestToolKindDefaultsToExecute(t *testing.T) { | ||
| 55 | 55 | // as a question; the editor's own stop key cancels a turn) and no less (a |
| 56 | 56 | // command that works but is not listed is one the user never finds). Names |
| 57 | 57 | // are bare: ACP clients add the slash. |
| 58 | +// With no skills declared, the advertisement is the shared table's Editor | |
| 59 | +// slice and nothing else: every name comes back to a handler in slashCommand. | |
| 60 | +// | |
| 61 | +// Skills are advertised too when there are any, and they are NOT intercepted | |
| 62 | +// — they are rewritten into the `$skill` notation, which is why the invariant | |
| 63 | +// is "what this front end handles". That half is pinned in skillcmd_test.go; | |
| 64 | +// this test keeps the command half honest. | |
| 58 | 65 | func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) { |
| 59 | 66 | want := []string{"compact", history.NewCommandName} // the shared table's display order |
| 60 | 67 | cmds := availableCommands() |
| 61 | 68 | if len(cmds) != len(want) { |
| 62 | - t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds) | |
| 69 | + t.Fatalf("availableCommands: %d command(s) with no skills declared, want %d: %+v", len(cmds), len(want), cmds) | |
| 63 | 70 | } |
| 64 | 71 | for i, c := range cmds { |
| 65 | 72 | if c.Name != want[i] { |
| @@ -55,11 +55,18 @@ func TestToolKindDefaultsToExecute(t *testing.T) { | |||
| 55 | // as a question; the editor's own stop key cancels a turn) and no less (a | 55 | // as a question; the editor's own stop key cancels a turn) and no less (a |
| 56 | // command that works but is not listed is one the user never finds). Names | 56 | // command that works but is not listed is one the user never finds). Names |
| 57 | // are bare: ACP clients add the slash. | 57 | // are bare: ACP clients add the slash. |
| 58 | +// With no skills declared, the advertisement is the shared table's Editor | ||
| 59 | +// slice and nothing else: every name comes back to a handler in slashCommand. | ||
| 60 | +// | ||
| 61 | +// Skills are advertised too when there are any, and they are NOT intercepted | ||
| 62 | +// — they are rewritten into the `$skill` notation, which is why the invariant | ||
| 63 | +// is "what this front end handles". That half is pinned in skillcmd_test.go; | ||
| 64 | +// this test keeps the command half honest. | ||
| 58 | func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) { | 65 | func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) { |
| 59 | want := []string{"compact", history.NewCommandName} // the shared table's display order | 66 | want := []string{"compact", history.NewCommandName} // the shared table's display order |
| 60 | cmds := availableCommands() | 67 | cmds := availableCommands() |
| 61 | if len(cmds) != len(want) { | 68 | if len(cmds) != len(want) { |
| 62 | - t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds) | 69 | + t.Fatalf("availableCommands: %d command(s) with no skills declared, want %d: %+v", len(cmds), len(want), cmds) |
| 63 | } | 70 | } |
| 64 | for i, c := range cmds { | 71 | for i, c := range cmds { |
| 65 | if c.Name != want[i] { | 72 | if c.Name != want[i] { |
added
internal/acp/skillcmd_test.go +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +package acp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "mm/internal/command" | |
| 8 | + "mm/internal/skills" | |
| 9 | +) | |
| 10 | + | |
| 11 | +func declare(t *testing.T, list ...skills.Skill) { | |
| 12 | + t.Helper() | |
| 13 | + saved := skills.Declared() | |
| 14 | + t.Cleanup(func() { skills.Declare(saved) }) | |
| 15 | + skills.Declare(list) | |
| 16 | +} | |
| 17 | + | |
| 18 | +// ACP has no notion of a skill, so each one is offered as an AvailableCommand | |
| 19 | +// — the slot the protocol's own examples ('create_plan', 'research_codebase') | |
| 20 | +// were written for — with the input hint that carries its parameters. | |
| 21 | +func TestSkillsAreAdvertisedAsCommands(t *testing.T) { | |
| 22 | + declare(t, | |
| 23 | + skills.Skill{Name: "greetings", Description: "greet somebody"}, | |
| 24 | + skills.Skill{Name: "bg-jobs", Description: "background jobs"}, | |
| 25 | + ) | |
| 26 | + | |
| 27 | + cmds := availableCommands() | |
| 28 | + byName := map[string]int{} | |
| 29 | + for i, c := range cmds { | |
| 30 | + byName[c.Name] = i | |
| 31 | + } | |
| 32 | + | |
| 33 | + // The real commands come first and keep their shape: no input spec. | |
| 34 | + for _, c := range command.For(command.Editor) { | |
| 35 | + i, ok := byName[c.Name] | |
| 36 | + if !ok { | |
| 37 | + t.Fatalf("%q is no longer advertised", c.Name) | |
| 38 | + } | |
| 39 | + if cmds[i].Input != nil { | |
| 40 | + t.Errorf("%q takes no input but advertises one", c.Name) | |
| 41 | + } | |
| 42 | + delete(byName, c.Name) | |
| 43 | + } | |
| 44 | + | |
| 45 | + // Then the skills, each with a description and an input hint. | |
| 46 | + for _, name := range []string{"greetings", "bg-jobs"} { | |
| 47 | + i, ok := byName[name] | |
| 48 | + if !ok { | |
| 49 | + t.Fatalf("skill %q is not advertised: %+v", name, cmds) | |
| 50 | + } | |
| 51 | + c := cmds[i] | |
| 52 | + if c.Description == "" { | |
| 53 | + t.Errorf("%q is advertised with no description", name) | |
| 54 | + } | |
| 55 | + if c.Input == nil || c.Input.Unstructured == nil || c.Input.Unstructured.Hint == "" { | |
| 56 | + t.Errorf("%q has no input hint, so the editor cannot show where parameters go", name) | |
| 57 | + } | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 61 | +// A skill whose name a real command owns is NOT advertised: it would sit in | |
| 62 | +// the editor's menu next to the command with no way to tell them apart, and | |
| 63 | +// the next prompt would be ambiguous. | |
| 64 | +func TestASkillNamedLikeACommandIsNotAdvertised(t *testing.T) { | |
| 65 | + declare(t, | |
| 66 | + skills.Skill{Name: "compact", Description: "a skill that shadows a command"}, | |
| 67 | + skills.Skill{Name: "quit", Description: "a skill that shadows a command the editor never gets"}, | |
| 68 | + skills.Skill{Name: "greetings", Description: "greet somebody"}, | |
| 69 | + ) | |
| 70 | + | |
| 71 | + var skillish []string | |
| 72 | + for _, c := range availableCommands() { | |
| 73 | + if c.Input != nil { | |
| 74 | + skillish = append(skillish, c.Name) | |
| 75 | + } | |
| 76 | + } | |
| 77 | + if len(skillish) != 1 || skillish[0] != "greetings" { | |
| 78 | + t.Errorf("advertised skills = %v, want only greetings", skillish) | |
| 79 | + } | |
| 80 | + // And the real command is still there, exactly once. | |
| 81 | + n := 0 | |
| 82 | + for _, c := range availableCommands() { | |
| 83 | + if c.Name == "compact" { | |
| 84 | + n++ | |
| 85 | + } | |
| 86 | + } | |
| 87 | + if n != 1 { | |
| 88 | + t.Errorf("%q advertised %d times", "compact", n) | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +// The rewrite: "/greetings Bob" becomes the `$greetings Bob` notation, so one | |
| 93 | +// code path decides what a named skill means for all three front ends. | |
| 94 | +func TestSkillCommandRewrite(t *testing.T) { | |
| 95 | + declare(t, | |
| 96 | + skills.Skill{Name: "greetings", Description: "greet somebody"}, | |
| 97 | + skills.Skill{Name: "bg-jobs", Description: "background jobs"}, | |
| 98 | + ) | |
| 99 | + | |
| 100 | + cases := []struct{ name, in, want string }{ | |
| 101 | + {"with parameters", "/greetings Bob Morane", "$greetings Bob Morane"}, | |
| 102 | + {"without parameters", "/bg-jobs", "$bg-jobs"}, | |
| 103 | + {"leading space", " /greetings Bob", "$greetings Bob"}, | |
| 104 | + {"a real command is left for slashCommand", "/compact", "/compact"}, | |
| 105 | + {"a command this front end does not intercept stays as typed", "/quit", "/quit"}, | |
| 106 | + {"an unknown name stays as typed", "/nosuchskill x", "/nosuchskill x"}, | |
| 107 | + {"not a slash command at all", "use $greetings Bob", "use $greetings Bob"}, | |
| 108 | + {"a bare slash", "/", "/"}, | |
| 109 | + } | |
| 110 | + for _, tc := range cases { | |
| 111 | + t.Run(tc.name, func(t *testing.T) { | |
| 112 | + if got := skillCommand(tc.in); got != tc.want { | |
| 113 | + t.Errorf("skillCommand(%q) = %q, want %q", tc.in, got, tc.want) | |
| 114 | + } | |
| 115 | + }) | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +// The rewrite feeds skills.ExpandRefs, which is what actually tells the model | |
| 120 | +// to load the skill — the round trip that makes the editor's menu mean the | |
| 121 | +// same thing as the window's `$` picker. | |
| 122 | +func TestTheRewriteReachesTheModelAsASkillLine(t *testing.T) { | |
| 123 | + list := []skills.Skill{{Name: "greetings", Description: "greet somebody"}} | |
| 124 | + declare(t, list...) | |
| 125 | + | |
| 126 | + expanded, refs := skills.ExpandRefs(skillCommand("/greetings Bob Morane"), list) | |
| 127 | + if len(refs) != 1 || refs[0].Name != "greetings" || refs[0].Args != "Bob Morane" { | |
| 128 | + t.Fatalf("refs = %+v", refs) | |
| 129 | + } | |
| 130 | + if !strings.Contains(expanded, "[skill: greetings") || !strings.Contains(expanded, "parameters: Bob Morane") { | |
| 131 | + t.Errorf("expansion = %q", expanded) | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +// With no skills declared — an agent that ships none — nothing changes: the | |
| 136 | +// advertisement is the two commands it always was, and no rewrite happens. | |
| 137 | +func TestNoSkillsChangesNothing(t *testing.T) { | |
| 138 | + declare(t) | |
| 139 | + | |
| 140 | + cmds := availableCommands() | |
| 141 | + if len(cmds) != len(command.For(command.Editor)) { | |
| 142 | + t.Errorf("%d commands advertised with no skills: %+v", len(cmds), cmds) | |
| 143 | + } | |
| 144 | + if got := skillCommand("/greetings Bob"); got != "/greetings Bob" { | |
| 145 | + t.Errorf("skillCommand rewrote %q with no catalogue", got) | |
| 146 | + } | |
| 147 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +package acp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "mm/internal/command" | ||
| 8 | + "mm/internal/skills" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +func declare(t *testing.T, list ...skills.Skill) { | ||
| 12 | + t.Helper() | ||
| 13 | + saved := skills.Declared() | ||
| 14 | + t.Cleanup(func() { skills.Declare(saved) }) | ||
| 15 | + skills.Declare(list) | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +// ACP has no notion of a skill, so each one is offered as an AvailableCommand | ||
| 19 | +// — the slot the protocol's own examples ('create_plan', 'research_codebase') | ||
| 20 | +// were written for — with the input hint that carries its parameters. | ||
| 21 | +func TestSkillsAreAdvertisedAsCommands(t *testing.T) { | ||
| 22 | + declare(t, | ||
| 23 | + skills.Skill{Name: "greetings", Description: "greet somebody"}, | ||
| 24 | + skills.Skill{Name: "bg-jobs", Description: "background jobs"}, | ||
| 25 | + ) | ||
| 26 | + | ||
| 27 | + cmds := availableCommands() | ||
| 28 | + byName := map[string]int{} | ||
| 29 | + for i, c := range cmds { | ||
| 30 | + byName[c.Name] = i | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + // The real commands come first and keep their shape: no input spec. | ||
| 34 | + for _, c := range command.For(command.Editor) { | ||
| 35 | + i, ok := byName[c.Name] | ||
| 36 | + if !ok { | ||
| 37 | + t.Fatalf("%q is no longer advertised", c.Name) | ||
| 38 | + } | ||
| 39 | + if cmds[i].Input != nil { | ||
| 40 | + t.Errorf("%q takes no input but advertises one", c.Name) | ||
| 41 | + } | ||
| 42 | + delete(byName, c.Name) | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + // Then the skills, each with a description and an input hint. | ||
| 46 | + for _, name := range []string{"greetings", "bg-jobs"} { | ||
| 47 | + i, ok := byName[name] | ||
| 48 | + if !ok { | ||
| 49 | + t.Fatalf("skill %q is not advertised: %+v", name, cmds) | ||
| 50 | + } | ||
| 51 | + c := cmds[i] | ||
| 52 | + if c.Description == "" { | ||
| 53 | + t.Errorf("%q is advertised with no description", name) | ||
| 54 | + } | ||
| 55 | + if c.Input == nil || c.Input.Unstructured == nil || c.Input.Unstructured.Hint == "" { | ||
| 56 | + t.Errorf("%q has no input hint, so the editor cannot show where parameters go", name) | ||
| 57 | + } | ||
| 58 | + } | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +// A skill whose name a real command owns is NOT advertised: it would sit in | ||
| 62 | +// the editor's menu next to the command with no way to tell them apart, and | ||
| 63 | +// the next prompt would be ambiguous. | ||
| 64 | +func TestASkillNamedLikeACommandIsNotAdvertised(t *testing.T) { | ||
| 65 | + declare(t, | ||
| 66 | + skills.Skill{Name: "compact", Description: "a skill that shadows a command"}, | ||
| 67 | + skills.Skill{Name: "quit", Description: "a skill that shadows a command the editor never gets"}, | ||
| 68 | + skills.Skill{Name: "greetings", Description: "greet somebody"}, | ||
| 69 | + ) | ||
| 70 | + | ||
| 71 | + var skillish []string | ||
| 72 | + for _, c := range availableCommands() { | ||
| 73 | + if c.Input != nil { | ||
| 74 | + skillish = append(skillish, c.Name) | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + if len(skillish) != 1 || skillish[0] != "greetings" { | ||
| 78 | + t.Errorf("advertised skills = %v, want only greetings", skillish) | ||
| 79 | + } | ||
| 80 | + // And the real command is still there, exactly once. | ||
| 81 | + n := 0 | ||
| 82 | + for _, c := range availableCommands() { | ||
| 83 | + if c.Name == "compact" { | ||
| 84 | + n++ | ||
| 85 | + } | ||
| 86 | + } | ||
| 87 | + if n != 1 { | ||
| 88 | + t.Errorf("%q advertised %d times", "compact", n) | ||
| 89 | + } | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +// The rewrite: "/greetings Bob" becomes the `$greetings Bob` notation, so one | ||
| 93 | +// code path decides what a named skill means for all three front ends. | ||
| 94 | +func TestSkillCommandRewrite(t *testing.T) { | ||
| 95 | + declare(t, | ||
| 96 | + skills.Skill{Name: "greetings", Description: "greet somebody"}, | ||
| 97 | + skills.Skill{Name: "bg-jobs", Description: "background jobs"}, | ||
| 98 | + ) | ||
| 99 | + | ||
| 100 | + cases := []struct{ name, in, want string }{ | ||
| 101 | + {"with parameters", "/greetings Bob Morane", "$greetings Bob Morane"}, | ||
| 102 | + {"without parameters", "/bg-jobs", "$bg-jobs"}, | ||
| 103 | + {"leading space", " /greetings Bob", "$greetings Bob"}, | ||
| 104 | + {"a real command is left for slashCommand", "/compact", "/compact"}, | ||
| 105 | + {"a command this front end does not intercept stays as typed", "/quit", "/quit"}, | ||
| 106 | + {"an unknown name stays as typed", "/nosuchskill x", "/nosuchskill x"}, | ||
| 107 | + {"not a slash command at all", "use $greetings Bob", "use $greetings Bob"}, | ||
| 108 | + {"a bare slash", "/", "/"}, | ||
| 109 | + } | ||
| 110 | + for _, tc := range cases { | ||
| 111 | + t.Run(tc.name, func(t *testing.T) { | ||
| 112 | + if got := skillCommand(tc.in); got != tc.want { | ||
| 113 | + t.Errorf("skillCommand(%q) = %q, want %q", tc.in, got, tc.want) | ||
| 114 | + } | ||
| 115 | + }) | ||
| 116 | + } | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// The rewrite feeds skills.ExpandRefs, which is what actually tells the model | ||
| 120 | +// to load the skill — the round trip that makes the editor's menu mean the | ||
| 121 | +// same thing as the window's `$` picker. | ||
| 122 | +func TestTheRewriteReachesTheModelAsASkillLine(t *testing.T) { | ||
| 123 | + list := []skills.Skill{{Name: "greetings", Description: "greet somebody"}} | ||
| 124 | + declare(t, list...) | ||
| 125 | + | ||
| 126 | + expanded, refs := skills.ExpandRefs(skillCommand("/greetings Bob Morane"), list) | ||
| 127 | + if len(refs) != 1 || refs[0].Name != "greetings" || refs[0].Args != "Bob Morane" { | ||
| 128 | + t.Fatalf("refs = %+v", refs) | ||
| 129 | + } | ||
| 130 | + if !strings.Contains(expanded, "[skill: greetings") || !strings.Contains(expanded, "parameters: Bob Morane") { | ||
| 131 | + t.Errorf("expansion = %q", expanded) | ||
| 132 | + } | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +// With no skills declared — an agent that ships none — nothing changes: the | ||
| 136 | +// advertisement is the two commands it always was, and no rewrite happens. | ||
| 137 | +func TestNoSkillsChangesNothing(t *testing.T) { | ||
| 138 | + declare(t) | ||
| 139 | + | ||
| 140 | + cmds := availableCommands() | ||
| 141 | + if len(cmds) != len(command.For(command.Editor)) { | ||
| 142 | + t.Errorf("%d commands advertised with no skills: %+v", len(cmds), cmds) | ||
| 143 | + } | ||
| 144 | + if got := skillCommand("/greetings Bob"); got != "/greetings Bob" { | ||
| 145 | + t.Errorf("skillCommand rewrote %q with no catalogue", got) | ||
| 146 | + } | ||
| 147 | +} | ||
modified
internal/skills/report.go +21 -0 | @@ -43,6 +43,27 @@ func Truncated(list []Skill) []string { | ||
| 43 | 43 | return out |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | +// Shadowed lists the skills whose name a slash command already owns. | |
| 47 | +// | |
| 48 | +// It matters in an editor: over ACP each skill is offered in the client's | |
| 49 | +// own `/` menu (see internal/acp), and a skill called "compact" or "new" | |
| 50 | +// would sit in that menu next to the real command with no way to tell them | |
| 51 | +// apart. The command wins and the skill is not offered, so the only way the | |
| 52 | +// author learns of it is a warning at start-up. | |
| 53 | +func Shadowed(list []Skill, taken []string) []string { | |
| 54 | + owned := map[string]bool{} | |
| 55 | + for _, name := range taken { | |
| 56 | + owned[name] = true | |
| 57 | + } | |
| 58 | + var out []string | |
| 59 | + for _, s := range list { | |
| 60 | + if owned[s.Name] { | |
| 61 | + out = append(out, s.Name) | |
| 62 | + } | |
| 63 | + } | |
| 64 | + return out | |
| 65 | +} | |
| 66 | + | |
| 46 | 67 | // Undescribed lists the skills whose front matter gave no description. They |
| 47 | 68 | // still appear in the catalogue, as a bare name — the worst entry there is, |
| 48 | 69 | // since the model has nothing to match a request against. |
| @@ -43,6 +43,27 @@ func Truncated(list []Skill) []string { | |||
| 43 | return out | 43 | return out |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | +// Shadowed lists the skills whose name a slash command already owns. | ||
| 47 | +// | ||
| 48 | +// It matters in an editor: over ACP each skill is offered in the client's | ||
| 49 | +// own `/` menu (see internal/acp), and a skill called "compact" or "new" | ||
| 50 | +// would sit in that menu next to the real command with no way to tell them | ||
| 51 | +// apart. The command wins and the skill is not offered, so the only way the | ||
| 52 | +// author learns of it is a warning at start-up. | ||
| 53 | +func Shadowed(list []Skill, taken []string) []string { | ||
| 54 | + owned := map[string]bool{} | ||
| 55 | + for _, name := range taken { | ||
| 56 | + owned[name] = true | ||
| 57 | + } | ||
| 58 | + var out []string | ||
| 59 | + for _, s := range list { | ||
| 60 | + if owned[s.Name] { | ||
| 61 | + out = append(out, s.Name) | ||
| 62 | + } | ||
| 63 | + } | ||
| 64 | + return out | ||
| 65 | +} | ||
| 66 | + | ||
| 46 | // Undescribed lists the skills whose front matter gave no description. They | 67 | // Undescribed lists the skills whose front matter gave no description. They |
| 47 | // still appear in the catalogue, as a bare name — the worst entry there is, | 68 | // still appear in the catalogue, as a bare name — the worst entry there is, |
| 48 | // since the model has nothing to match a request against. | 69 | // since the model has nothing to match a request against. |
modified
main.go +17 -0 | @@ -37,6 +37,7 @@ import ( | ||
| 37 | 37 | |
| 38 | 38 | "mm/internal/acp" |
| 39 | 39 | "mm/internal/agent" |
| 40 | + "mm/internal/command" | |
| 40 | 41 | "mm/internal/config" |
| 41 | 42 | "mm/internal/detector" |
| 42 | 43 | "mm/internal/engine" |
| @@ -300,6 +301,13 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | ||
| 300 | 301 | fmt.Sprintf("%d skill(s) with no description (%s) — the model sees only the name and will pick blindly", |
| 301 | 302 | len(bare), strings.Join(bare, ", "))) |
| 302 | 303 | } |
| 304 | + // A skill whose name a slash command owns is unreachable from an editor's | |
| 305 | + // command menu, and nothing else would say so. | |
| 306 | + if shadowed := skills.Shadowed(set.skills, commandNames()); len(shadowed) > 0 { | |
| 307 | + info.Warnings = append(info.Warnings, | |
| 308 | + fmt.Sprintf("skill(s) named like a slash command (%s) — reachable as $%s in a question, but not from an editor's / menu", | |
| 309 | + strings.Join(shadowed, ", "), shadowed[0])) | |
| 310 | + } | |
| 303 | 311 | // Only the first maxKeywords of a list reach the catalogue, so the order |
| 304 | 312 | // of a keyword line decides what the model gets to match against. An |
| 305 | 313 | // author cannot see that from their own file. |
| @@ -334,6 +342,15 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | ||
| 334 | 342 | dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off) |
| 335 | 343 | } |
| 336 | 344 | |
| 345 | +// commandNames is the slash commands' names, for the shadowing check. | |
| 346 | +func commandNames() []string { | |
| 347 | + var out []string | |
| 348 | + for _, c := range command.All() { | |
| 349 | + out = append(out, c.Name) | |
| 350 | + } | |
| 351 | + return out | |
| 352 | +} | |
| 353 | + | |
| 337 | 354 | // windowMode decides whether the window front end runs, and where the banner |
| 338 | 355 | // goes. The window owns the screen: the spinner must not draw on it, and |
| 339 | 356 | // the banner is kept in the returned buffer to be shown INSIDE the window as |
| @@ -37,6 +37,7 @@ import ( | |||
| 37 | 37 | ||
| 38 | "mm/internal/acp" | 38 | "mm/internal/acp" |
| 39 | "mm/internal/agent" | 39 | "mm/internal/agent" |
| 40 | + "mm/internal/command" | ||
| 40 | "mm/internal/config" | 41 | "mm/internal/config" |
| 41 | "mm/internal/detector" | 42 | "mm/internal/detector" |
| 42 | "mm/internal/engine" | 43 | "mm/internal/engine" |
| @@ -300,6 +301,13 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | |||
| 300 | fmt.Sprintf("%d skill(s) with no description (%s) — the model sees only the name and will pick blindly", | 301 | fmt.Sprintf("%d skill(s) with no description (%s) — the model sees only the name and will pick blindly", |
| 301 | len(bare), strings.Join(bare, ", "))) | 302 | len(bare), strings.Join(bare, ", "))) |
| 302 | } | 303 | } |
| 304 | + // A skill whose name a slash command owns is unreachable from an editor's | ||
| 305 | + // command menu, and nothing else would say so. | ||
| 306 | + if shadowed := skills.Shadowed(set.skills, commandNames()); len(shadowed) > 0 { | ||
| 307 | + info.Warnings = append(info.Warnings, | ||
| 308 | + fmt.Sprintf("skill(s) named like a slash command (%s) — reachable as $%s in a question, but not from an editor's / menu", | ||
| 309 | + strings.Join(shadowed, ", "), shadowed[0])) | ||
| 310 | + } | ||
| 303 | // Only the first maxKeywords of a list reach the catalogue, so the order | 311 | // Only the first maxKeywords of a list reach the catalogue, so the order |
| 304 | // of a keyword line decides what the model gets to match against. An | 312 | // of a keyword line decides what the model gets to match against. An |
| 305 | // author cannot see that from their own file. | 313 | // author cannot see that from their own file. |
| @@ -334,6 +342,15 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | |||
| 334 | dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off) | 342 | dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off) |
| 335 | } | 343 | } |
| 336 | 344 | ||
| 345 | +// commandNames is the slash commands' names, for the shadowing check. | ||
| 346 | +func commandNames() []string { | ||
| 347 | + var out []string | ||
| 348 | + for _, c := range command.All() { | ||
| 349 | + out = append(out, c.Name) | ||
| 350 | + } | ||
| 351 | + return out | ||
| 352 | +} | ||
| 353 | + | ||
| 337 | // windowMode decides whether the window front end runs, and where the banner | 354 | // windowMode decides whether the window front end runs, and where the banner |
| 338 | // goes. The window owns the screen: the spinner must not draw on it, and | 355 | // goes. The window owns the screen: the spinner must not draw on it, and |
| 339 | // the banner is kept in the returned buffer to be shown INSIDE the window as | 356 | // the banner is kept in the returned buffer to be shown INSIDE the window as |