bots-garden/mini-mepublic Fork
⑂ d722711
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

💾 Saved.

k33g committed 2026-09-16T02:13:19+02:00 Browse files
d722711 parent: 79fc998
added .gitignore +4 -0
new file mode 100644
@@ -0,0 +1,4 @@
1+kits
2+notes.txt
3+tmp
4+mm
new file mode 100644
@@ -0,0 +1,4 @@
1+kits
2+notes.txt
3+tmp
4+mm
added .memory/README.md +11 -0
new file mode 100644
@@ -0,0 +1,11 @@
1+# .memory — project record for whoever continues building mini-me
2+
3+Read this directory before doing anything else in a session.
4+
5+| File | What it is |
6+|------|------------|
7+| `summary.md` | Snapshot of the project as it is now: architecture, decisions in force, real commands. Edited in place, never regenerated. |
8+| `history.md` | One dated entry per session, appended at the end, never rewritten. |
9+| `handoffs/YYYY-MM-DD-<slug>.md` | Where a session stopped: work in flight, next steps, traps. |
10+
11+`docs/` is for people who use `mm`; this directory is for people who build it.
new file mode 100644
@@ -0,0 +1,11 @@
1+# .memory — project record for whoever continues building mini-me
2+
3+Read this directory before doing anything else in a session.
4+
5+| File | What it is |
6+|------|------------|
7+| `summary.md` | Snapshot of the project as it is now: architecture, decisions in force, real commands. Edited in place, never regenerated. |
8+| `history.md` | One dated entry per session, appended at the end, never rewritten. |
9+| `handoffs/YYYY-MM-DD-<slug>.md` | Where a session stopped: work in flight, next steps, traps. |
10+
11+`docs/` is for people who use `mm`; this directory is for people who build it.
added .memory/handoffs/2026-09-15-diataxis-docs.md +50 -0
new file mode 100644
@@ -0,0 +1,50 @@
1+# Handoff — 2026-09-15 — Diátaxis documentation bootstrap (agent "riker")
2+
3+## Where it stopped
4+
5+`docs/` exists with a complete bilingual four-quadrant structure (`docs/en/`, `docs/fr/`, identical file names). Every page is "complete, not finished": usable now, open to growth. All relative links resolve (checked with a shell loop over `](…)` targets). Nothing committed — the whole repository is untracked apart from two old commits.
6+
7+## Filing plan that was applied (compass)
8+
9+| Topic | Quadrant | File (both languages) |
10+|-------|----------|-----------------------|
11+| First session with DMR | Tutorial | `tutorials/getting-started.md` |
12+| Run on llama.cpp | How-to | `how-to/run-with-llama-cpp.md` |
13+| Host in an editor (ACP/Zed) | How-to | `how-to/use-from-an-editor.md` |
14+| Add a skill | How-to | `how-to/add-a-skill.md` |
15+| Long session inside the window | How-to | `how-to/manage-the-context-window.md` |
16+| Start a new session (`/new`) | How-to | `how-to/start-a-new-session.md` — written by the other agent (bob) |
17+| CLI flags, env vars, banner, exit codes | Reference | `reference/cli.md` |
18+| Slash commands and keys | Reference | `reference/slash-commands.md` — bob edited the `/new` row after me |
19+| YAML keys, defaults, validation errors | Reference | `reference/configuration.md` |
20+| Built-in tools | Reference | `reference/tools.md` |
21+| Skill file format | Reference | `reference/skill-format.md` |
22+| ACP surface | Reference | `reference/acp.md` |
23+| One loop, two front ends | Explanation | `explanation/architecture.md` |
24+| Providers | Explanation | `explanation/providers.md` |
25+| Context compression | Explanation | `explanation/context-compression.md` |
26+
27+The skill's mandatory language question was not asked (user unavailable); "Both" was taken from the methodical-dev convention. If the user wants a single language, delete the other tree and the selector in `docs/README.md`.
28+
29+## Traps found (documented, not fixed — no Go changes were in my scope)
30+
31+1. **Shipped skills are invisible.** `skills.List` globs `<skillsDir>/*.md`; the examples are `skills/<name>/SKILL.md`. Banner shows `skills: 0`, `read_skill` is never declared. Either flatten the files or teach the loader to read `*/SKILL.md`. The docs describe the current behaviour and tell the user to flatten.
32+2. **`go test ./...` fails** in `mm/internal/skills`: `TestShippedSkillsHaveDescriptions` hard-codes `../../../demo/skills`, a path from the project this code was extracted from. Fix the path (and the layout above) or the quality gate will never be green.
33+3. The tutorial's banner line was captured verbatim from a real run; it now includes `"/new" to start a new session.` because bob changed `agent.go`. If the banner text changes again, update both tutorials.
34+4. `agent.yaml` calls the agent "Bob", ACP `agentInfo.name` is `bob` / version `0.11.0`, the binary is `mm`. The docs use `mm` for the binary and quote the ACP values as-is.
35+
36+## Not verified
37+
38+- No model server in the sandbox: step 3 of the tutorial (a real generation) was written from the code paths, not observed. The banner and `-h` output were observed.
39+- `docs/diagrams/packages.drawio` was created by bob; I linked it from both indexes and both architecture pages without validating its content.
40+
41+## Suggested next increments
42+
43+- A how-to for `editTools: false` (bash-only A/B mode) once someone has actually run it.
44+- A reference page for the engine's runtime messages (`[watchdog: …]`, `Retrying without streaming`) — currently only mentioned in passing.
45+- Explanation of the loop detector once its thresholds (history 10, threshold 3, set in `main.go`) are considered stable.
46+- bob updated `reference/slash-commands.md` and `reference/acp.md` in both languages for `/new` (row, ACP section, `available_commands_update`) after I wrote them; at the end of my session they were consistent with the code. Re-check only if `/new` changes again.
47+
48+## Addendum (same day, orchestrator)
49+
50+The Zed snippet in `how-to/use-from-an-editor.md` (EN + FR) was missing `"type": "custom"`; the user confirmed Zed needs it. Fixed in both pages. Any future editor how-to must include that key.
new file mode 100644
@@ -0,0 +1,50 @@
1+# Handoff — 2026-09-15 — Diátaxis documentation bootstrap (agent "riker")
2+
3+## Where it stopped
4+
5+`docs/` exists with a complete bilingual four-quadrant structure (`docs/en/`, `docs/fr/`, identical file names). Every page is "complete, not finished": usable now, open to growth. All relative links resolve (checked with a shell loop over `](…)` targets). Nothing committed — the whole repository is untracked apart from two old commits.
6+
7+## Filing plan that was applied (compass)
8+
9+| Topic | Quadrant | File (both languages) |
10+|-------|----------|-----------------------|
11+| First session with DMR | Tutorial | `tutorials/getting-started.md` |
12+| Run on llama.cpp | How-to | `how-to/run-with-llama-cpp.md` |
13+| Host in an editor (ACP/Zed) | How-to | `how-to/use-from-an-editor.md` |
14+| Add a skill | How-to | `how-to/add-a-skill.md` |
15+| Long session inside the window | How-to | `how-to/manage-the-context-window.md` |
16+| Start a new session (`/new`) | How-to | `how-to/start-a-new-session.md` — written by the other agent (bob) |
17+| CLI flags, env vars, banner, exit codes | Reference | `reference/cli.md` |
18+| Slash commands and keys | Reference | `reference/slash-commands.md` — bob edited the `/new` row after me |
19+| YAML keys, defaults, validation errors | Reference | `reference/configuration.md` |
20+| Built-in tools | Reference | `reference/tools.md` |
21+| Skill file format | Reference | `reference/skill-format.md` |
22+| ACP surface | Reference | `reference/acp.md` |
23+| One loop, two front ends | Explanation | `explanation/architecture.md` |
24+| Providers | Explanation | `explanation/providers.md` |
25+| Context compression | Explanation | `explanation/context-compression.md` |
26+
27+The skill's mandatory language question was not asked (user unavailable); "Both" was taken from the methodical-dev convention. If the user wants a single language, delete the other tree and the selector in `docs/README.md`.
28+
29+## Traps found (documented, not fixed — no Go changes were in my scope)
30+
31+1. **Shipped skills are invisible.** `skills.List` globs `<skillsDir>/*.md`; the examples are `skills/<name>/SKILL.md`. Banner shows `skills: 0`, `read_skill` is never declared. Either flatten the files or teach the loader to read `*/SKILL.md`. The docs describe the current behaviour and tell the user to flatten.
32+2. **`go test ./...` fails** in `mm/internal/skills`: `TestShippedSkillsHaveDescriptions` hard-codes `../../../demo/skills`, a path from the project this code was extracted from. Fix the path (and the layout above) or the quality gate will never be green.
33+3. The tutorial's banner line was captured verbatim from a real run; it now includes `"/new" to start a new session.` because bob changed `agent.go`. If the banner text changes again, update both tutorials.
34+4. `agent.yaml` calls the agent "Bob", ACP `agentInfo.name` is `bob` / version `0.11.0`, the binary is `mm`. The docs use `mm` for the binary and quote the ACP values as-is.
35+
36+## Not verified
37+
38+- No model server in the sandbox: step 3 of the tutorial (a real generation) was written from the code paths, not observed. The banner and `-h` output were observed.
39+- `docs/diagrams/packages.drawio` was created by bob; I linked it from both indexes and both architecture pages without validating its content.
40+
41+## Suggested next increments
42+
43+- A how-to for `editTools: false` (bash-only A/B mode) once someone has actually run it.
44+- A reference page for the engine's runtime messages (`[watchdog: …]`, `Retrying without streaming`) — currently only mentioned in passing.
45+- Explanation of the loop detector once its thresholds (history 10, threshold 3, set in `main.go`) are considered stable.
46+- bob updated `reference/slash-commands.md` and `reference/acp.md` in both languages for `/new` (row, ACP section, `available_commands_update`) after I wrote them; at the end of my session they were consistent with the code. Re-check only if `/new` changes again.
47+
48+## Addendum (same day, orchestrator)
49+
50+The Zed snippet in `how-to/use-from-an-editor.md` (EN + FR) was missing `"type": "custom"`; the user confirmed Zed needs it. Fixed in both pages. Any future editor how-to must include that key.
added .memory/handoffs/2026-09-15-new-command.md +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+# Handoff — 2026-09-15 — `/new` slash command
2+
3+## State
4+
5+`/new` works in both front ends and is documented in EN and FR. `go build`, `go vet` and every test pass except one pre-existing failure (below). The quality gate is configured (`.qlty/qlty.toml`, exclusions `kits/**` and `tmp/**`) and FAILS on pre-existing debt only. `.memory/` was created by this session. Nothing is committed: all of `internal/`, `main.go`, `go.mod`, `docs/`, `.qlty/`, `.quality/`, `.memory/` are untracked (only `README.md`, `git.sh`, `.tickets/*.yaml`, `.vscode/` are tracked).
6+
7+## In flight
8+
9+Nothing on the `/new` feature itself. Agent "riker" was writing `docs/` at the same time; the pages this session edited in place (`docs/{en,fr}/reference/slash-commands.md`, `acp.md`, both `README.md`, both `explanation/architecture.md`) may be rewritten by riker — check that the `/new` rows, the "ACP mode" paragraph, the "Available commands" section and the `packages.drawio` link survived.
10+
11+## Next steps
12+
13+1. Decide the pre-existing test: `internal/skills/skills_test.go` `TestShippedSkillsHaveDescriptions` looks for `../../../demo/skills`; this repo has `./skills`. Changing the path to `../../skills` is a one-line fix, left to the user.
14+2. Decide the quality debt: bump `go.mod` `go 1.25.0` → `1.26` (clears 47 of 55 osv findings), `go get -u` the indirect deps flagged (`jsonparser`, `otel`, `x/sys`), fix the 12 `errcheck` sites, and refactor `agent.Run` (complexity 121) — the last one needs tests around the REPL first.
15+3. Verify in a real Zed that the slash menu shows `/new` (the notification is sent before the `session/new` response).
16+4. Commit: propose one commit for code + tests + docs + `.qlty/` + `.quality/` + `.memory/` (see history entry). Not done — the skill forbids committing without an explicit request.
17+
18+## Open questions / blockers
19+
20+- Should `/compact` also be exposed over ACP? Not asked; the code deliberately advertises only what `session/prompt` intercepts.
21+- Should `/new` over ACP also drop "allow always" grants? This session chose to keep them (same session id, same promise to the user).
22+
23+## Watch out for
24+
25+- `qlty init` only looks at git-tracked files: it detected no Go here and enabled no Go plugin. The Go plugins were added by hand in `.qlty/qlty.toml`; do not re-run `qlty init`.
26+- qlty's default `exclude_patterns` contains `**/config/**`, which would silently hide `internal/config`. It was removed from the list; do not paste the defaults back.
27+- Inside `internal/acp`, `mm/internal/session` is imported as `history` because the package has its own `session` struct.
28+- Piped stdin to the REPL loses lines after the first (the `bufio.Reader` is re-created per iteration). Use the scripted JSON-RPC client approach (python `subprocess`, one JSON object per line) to smoke-test ACP, and expect only the first line of a piped REPL session to be read.
29+- Run Go through `bash -l -c "..."` when `go` is not on `PATH`.
30+
31+## Addendum (same day, orchestrator) — step 3 done, root cause found
32+
33+The user tried it in Zed: "Available commands for mini-me: none". Cause: the notification went out before the `session/new` response, and Zed drops updates for a session id it does not know yet. Fixed by a `responseWriter` wrapper around the SDK's output that fires the notification after the response line is written (see `internal/acp/acp.go`, history entry of the same day). Verified on the wire with the real binary and with a pipe-driven test; still to be confirmed by the user inside Zed. If Zed still shows no commands, the next suspect is the `initialize` exchange (Zed may require `protocolVersion` ≥ 1 and reads `agentCapabilities`), not the ordering.
34+
35+## Addendum 2 (same day, orchestrator) — `/compact` and `/abort` over ACP
36+
37+User confirmed `/new` appears in Zed after the ordering fix. `/compact` and `/abort` were added the same way (history entry of the same day). Left for the user to confirm in Zed: the two new menu entries, and `/abort` while a generation runs — Zed may not send a prompt during a running turn, in which case `/abort` will only ever say "No generation in progress" and the stop button remains the way to cancel. If so, the honest move is to drop `/abort` from `availableCommands()` rather than advertise a no-op.
38+
39+## Addendum 3 — trap: sandbox builds clobber the Mac binary
40+
41+`go build -o mm .` in the sandbox writes a linux/arm64 ELF at the repo's `./mm`, the same path the user's `install.sh` builds and copies from. Do not run `install.sh` from the sandbox, and tell the user to rebuild natively after sandbox builds. Separately, `install.sh` copies over the installed binary in place; on Apple Silicon that can yield `Killed: 9` until the target is removed first — fix pending the user's go-ahead.
new file mode 100644
@@ -0,0 +1,41 @@
1+# Handoff — 2026-09-15 — `/new` slash command
2+
3+## State
4+
5+`/new` works in both front ends and is documented in EN and FR. `go build`, `go vet` and every test pass except one pre-existing failure (below). The quality gate is configured (`.qlty/qlty.toml`, exclusions `kits/**` and `tmp/**`) and FAILS on pre-existing debt only. `.memory/` was created by this session. Nothing is committed: all of `internal/`, `main.go`, `go.mod`, `docs/`, `.qlty/`, `.quality/`, `.memory/` are untracked (only `README.md`, `git.sh`, `.tickets/*.yaml`, `.vscode/` are tracked).
6+
7+## In flight
8+
9+Nothing on the `/new` feature itself. Agent "riker" was writing `docs/` at the same time; the pages this session edited in place (`docs/{en,fr}/reference/slash-commands.md`, `acp.md`, both `README.md`, both `explanation/architecture.md`) may be rewritten by riker — check that the `/new` rows, the "ACP mode" paragraph, the "Available commands" section and the `packages.drawio` link survived.
10+
11+## Next steps
12+
13+1. Decide the pre-existing test: `internal/skills/skills_test.go` `TestShippedSkillsHaveDescriptions` looks for `../../../demo/skills`; this repo has `./skills`. Changing the path to `../../skills` is a one-line fix, left to the user.
14+2. Decide the quality debt: bump `go.mod` `go 1.25.0` → `1.26` (clears 47 of 55 osv findings), `go get -u` the indirect deps flagged (`jsonparser`, `otel`, `x/sys`), fix the 12 `errcheck` sites, and refactor `agent.Run` (complexity 121) — the last one needs tests around the REPL first.
15+3. Verify in a real Zed that the slash menu shows `/new` (the notification is sent before the `session/new` response).
16+4. Commit: propose one commit for code + tests + docs + `.qlty/` + `.quality/` + `.memory/` (see history entry). Not done — the skill forbids committing without an explicit request.
17+
18+## Open questions / blockers
19+
20+- Should `/compact` also be exposed over ACP? Not asked; the code deliberately advertises only what `session/prompt` intercepts.
21+- Should `/new` over ACP also drop "allow always" grants? This session chose to keep them (same session id, same promise to the user).
22+
23+## Watch out for
24+
25+- `qlty init` only looks at git-tracked files: it detected no Go here and enabled no Go plugin. The Go plugins were added by hand in `.qlty/qlty.toml`; do not re-run `qlty init`.
26+- qlty's default `exclude_patterns` contains `**/config/**`, which would silently hide `internal/config`. It was removed from the list; do not paste the defaults back.
27+- Inside `internal/acp`, `mm/internal/session` is imported as `history` because the package has its own `session` struct.
28+- Piped stdin to the REPL loses lines after the first (the `bufio.Reader` is re-created per iteration). Use the scripted JSON-RPC client approach (python `subprocess`, one JSON object per line) to smoke-test ACP, and expect only the first line of a piped REPL session to be read.
29+- Run Go through `bash -l -c "..."` when `go` is not on `PATH`.
30+
31+## Addendum (same day, orchestrator) — step 3 done, root cause found
32+
33+The user tried it in Zed: "Available commands for mini-me: none". Cause: the notification went out before the `session/new` response, and Zed drops updates for a session id it does not know yet. Fixed by a `responseWriter` wrapper around the SDK's output that fires the notification after the response line is written (see `internal/acp/acp.go`, history entry of the same day). Verified on the wire with the real binary and with a pipe-driven test; still to be confirmed by the user inside Zed. If Zed still shows no commands, the next suspect is the `initialize` exchange (Zed may require `protocolVersion` ≥ 1 and reads `agentCapabilities`), not the ordering.
34+
35+## Addendum 2 (same day, orchestrator) — `/compact` and `/abort` over ACP
36+
37+User confirmed `/new` appears in Zed after the ordering fix. `/compact` and `/abort` were added the same way (history entry of the same day). Left for the user to confirm in Zed: the two new menu entries, and `/abort` while a generation runs — Zed may not send a prompt during a running turn, in which case `/abort` will only ever say "No generation in progress" and the stop button remains the way to cancel. If so, the honest move is to drop `/abort` from `availableCommands()` rather than advertise a no-op.
38+
39+## Addendum 3 — trap: sandbox builds clobber the Mac binary
40+
41+`go build -o mm .` in the sandbox writes a linux/arm64 ELF at the repo's `./mm`, the same path the user's `install.sh` builds and copies from. Do not run `install.sh` from the sandbox, and tell the user to rebuild natively after sandbox builds. Separately, `install.sh` copies over the installed binary in place; on Apple Silicon that can yield `Killed: 9` until the target is removed first — fix pending the user's go-ahead.
added .memory/handoffs/2026-09-15-skills-layout.md +25 -0
new file mode 100644
@@ -0,0 +1,25 @@
1+# Handoff — 2026-09-15 — skills layout and skillsDir resolution
2+
3+## Where it stopped
4+
5+Done and green: the loader accepts `<skillsDir>/<name>.md` and `<skillsDir>/<name>/SKILL.md`; a relative `skillsDir` is resolved from the directory `mm` is started in (terminal) or next to the config file (ACP with a config); the banner warns with the absolute path when nothing is found. `/abort` no longer exists over ACP (user's call: Escape in Zed already cancels). Nothing committed.
6+
7+## For the user to do on the Mac
8+
9+- `sudo rm /usr/local/bin/mm && ./install.sh` — remove-then-copy avoids the `Killed: 9` of an in-place `cp` on Apple Silicon (see history entry "mm killed").
10+- Start `mm` from the repository root: the banner must say `skills: 3` and list `read_skill`.
11+- `agent.yaml` was renamed `agent.dmr.yaml`: a Zed `AGENT_CONFIG` still pointing at `agent.yaml` names a missing file, which is a hard config error. Point it at `agent.dmr.yaml` or `agent.llamacpp.yaml` (absolute path).
12+
13+## Traps
14+
15+- Never `go build -o mm .` from the sandbox: it clobbers the user's Mach-O at the same path. Build to `/tmp/…` for checks.
16+- `install.sh` still copies in place; making it `rm` first (or use `install`) is proposed, not applied.
17+
18+## Next increments (suggested)
19+
20+- In ACP mode without a config file, `skillsDir` follows the process cwd; using each session's `cwd` from `session/new` would let one `mm` serve several projects' skills.
21+- `install.sh`: remove the target before copying.
22+
23+## Addendum — `@path` notation (same day)
24+
25+Implemented in `internal/mention`, wired in both front ends, documented EN+FR (`how-to/attach-a-file.md`). Not verified: a real model turn reading the attached file (no server in the sandbox). Possible next steps if asked: quoted mentions for paths with spaces (`@"my file.md"`), a `@dir/` listing hint, or inlining small files when `editTools` is off.
new file mode 100644
@@ -0,0 +1,25 @@
1+# Handoff — 2026-09-15 — skills layout and skillsDir resolution
2+
3+## Where it stopped
4+
5+Done and green: the loader accepts `<skillsDir>/<name>.md` and `<skillsDir>/<name>/SKILL.md`; a relative `skillsDir` is resolved from the directory `mm` is started in (terminal) or next to the config file (ACP with a config); the banner warns with the absolute path when nothing is found. `/abort` no longer exists over ACP (user's call: Escape in Zed already cancels). Nothing committed.
6+
7+## For the user to do on the Mac
8+
9+- `sudo rm /usr/local/bin/mm && ./install.sh` — remove-then-copy avoids the `Killed: 9` of an in-place `cp` on Apple Silicon (see history entry "mm killed").
10+- Start `mm` from the repository root: the banner must say `skills: 3` and list `read_skill`.
11+- `agent.yaml` was renamed `agent.dmr.yaml`: a Zed `AGENT_CONFIG` still pointing at `agent.yaml` names a missing file, which is a hard config error. Point it at `agent.dmr.yaml` or `agent.llamacpp.yaml` (absolute path).
12+
13+## Traps
14+
15+- Never `go build -o mm .` from the sandbox: it clobbers the user's Mach-O at the same path. Build to `/tmp/…` for checks.
16+- `install.sh` still copies in place; making it `rm` first (or use `install`) is proposed, not applied.
17+
18+## Next increments (suggested)
19+
20+- In ACP mode without a config file, `skillsDir` follows the process cwd; using each session's `cwd` from `session/new` would let one `mm` serve several projects' skills.
21+- `install.sh`: remove the target before copying.
22+
23+## Addendum — `@path` notation (same day)
24+
25+Implemented in `internal/mention`, wired in both front ends, documented EN+FR (`how-to/attach-a-file.md`). Not verified: a real model turn reading the attached file (no server in the sandbox). Possible next steps if asked: quoted mentions for paths with spaces (`@"my file.md"`), a `@dir/` listing hint, or inlining small files when `editTools` is off.
added .memory/history.md +69 -0
new file mode 100644
@@ -0,0 +1,69 @@
1+# History
2+
3+One entry per session, appended, never rewritten.
4+
5+## 2026-09-15 — Diátaxis documentation bootstrap (agent "riker")
6+
7+- **Asked:** `/diataxis-doc` — start documenting the project, excluding `./kits` and `./tmp`. Ran in parallel with another agent adding `/new`.
8+- **Changed:** created `docs/` (language selector) with full four-quadrant trees in `docs/en/` and `docs/fr/`, identical file names: 1 tutorial, 4 how-to guides, 6 reference pages, 3 explanations per language, plus both indexes. Linked the `/new` how-to page and `docs/diagrams/packages.drawio` written by the other agent. No Go code touched.
9+- **Decisions:** language "Both" taken from the methodical-dev convention (user unavailable, skill's mandatory question skipped and recorded here). Shipped skill layout defect documented in how-to/reference rather than fixed.
10+- **Verified:** `go build` OK; banner output for both YAML files captured verbatim; `go test ./...` fails on `mm/internal/skills` (test points at `../../../demo/skills`). All relative links in `docs/` resolve.
11+- **Docs:** everything under `docs/`. Created `.memory/` skeleton.
12+
13+## 2026-09-15 — `/new` slash command (terminal + ACP), quality gate set-up, first `.memory/`
14+
15+- **Goal**: add `/new` to the `mm` agent to clear the history and start a fresh session, in the terminal REPL and over ACP; exclude `./kits` and `./tmp` from the quality measurement. Run by agent "bob" via `methodical-dev`, autonomously (the user was not available for the skill's approval gates), while agent "riker" wrote the rest of `docs/` in parallel.
16+- **Changes**: new package `internal/session` (`NewCommand`, `NewCommandName`, `IsNewCommand`, `Fresh`, `Forgotten`) + tests; `detector.Reset()` + first tests for `internal/detector`; `internal/agent/agent.go` (`/new` handling, `startNewSession`, banner, `if/else` → `switch` for staticcheck QF1003, both front ends now start from `session.Fresh`); `internal/acp/acp.go` (`availableCommands`, `available_commands_update` in `NewSession`, `/new` interception in `Prompt`, `startNewSession`, `resetSession`) + tests; `main.go` package list comment; `.qlty/qlty.toml` (created); `.quality/` (2 runs); docs (below); `.memory/` created.
17+- **Decisions**: `/new` resets history + engine token count + loop detector (REPL); ACP keeps session id / cwd / allow-always; only `/new` advertised over ACP; notification sent inside `NewSession` before the response; the `session` import is aliased `history` inside `internal/acp` because that package already has a `session` type. Rejected: reusing `compactHistory`'s "keep the detector" rule for `/new` (different situation: the task changes); creating a git worktree (all files untracked); fixing the pre-existing `demo/skills` test path (layout decision for the user).
18+- **Tests**: `go test ./...` — all packages pass except the pre-existing `internal/skills` failure (`TestShippedSkillsHaveDescriptions`, path `../../../demo/skills`). `/new` smoke-tested end to end: piped REPL prints `🆕 new session: 0 message(s) forgotten`; scripted JSON-RPC client sees `available_commands_update` then the `session/new` response, and `/new` answered by an `agent_message_chunk` + `end_turn`.
19+- **Quality**: gate FAIL, 2 iterations: warnings 69 → 67, smells 18 → 18, complexity 620 → 618. Fixed: `ineffassign` in the new test, staticcheck QF1003 in `agent.go`. Remaining findings are all pre-existing (osv on `go.mod`, errcheck, complexity of `agent.Run` etc.) and out of this change's scope. Nothing was excluded beyond `kits/**` and `tmp/**`; no threshold changed.
20+- **Docs**: `docs/{en,fr}/how-to/start-a-new-session.md` (new); `/new` rows and ACP paragraphs in `docs/{en,fr}/reference/slash-commands.md` and `acp.md` (riker's pages, edited in place); links in both `README.md` indexes; `docs/diagrams/packages.drawio` (new, from `go list`) referenced from both `explanation/architecture.md`.
21+
22+## 2026-09-15 — Zed config fix in the editor how-to (orchestrator)
23+
24+- **Asked**: the user reported that the Zed `agent_servers` snippet in the docs is insufficient — Zed requires `"type": "custom"` in the entry.
25+- **Changes**: added `"type": "custom"` to the JSON block in `docs/en/how-to/use-from-an-editor.md` and `docs/fr/how-to/use-from-an-editor.md`; recorded the requirement under "Decisions in force" in `summary.md`.
26+- **Tests / quality**: docs-only change, none run.
27+
28+## 2026-09-15 — ACP: `/new` was invisible in Zed's slash menu (orchestrator)
29+
30+- **Asked**: the user reported that Zed lists commands for other ACP agents (docker agent) but shows "Available commands for mini-me: none" and rejects `/new` as unknown.
31+- **Cause**: `available_commands_update` was sent inside `NewSession`, i.e. before the SDK wrote the `session/new` response. Zed keys sessions on the id in that response and drops updates for an unknown session id.
32+- **Changes**: `internal/acp/acp.go` — `Run` now builds the front via `newFront(e, system, tools, w, r)`; the SDK writes to a `responseWriter` that runs a per-session hook once the response line carrying that session id has been written; `NewSession` registers the hook (`context.WithoutCancel(ctx)`, the request context dies with the method) instead of calling `SessionUpdate` directly. `acp_test.go` — `TestResponseSessionIDMatchesOnlySessionResponses`, `TestAvailableCommandsAreSentAfterTheSessionNewResponse` (drives the real SDK over pipes, reads the raw lines back). Docs: `available_commands_update` row in `docs/{en,fr}/reference/acp.md`; `summary.md` decision updated, the "Zed slash menu" unknown removed.
33+- **Rejected**: a goroutine with a `time.Sleep` after `NewSession` returns (a race, just an unlikely one); sending the list on the first `session/prompt` (too late for the menu before the first message).
34+- **Tests**: `go test -race ./internal/acp -count=5` ok; `go build` ok; real binary over stdin shows the response line before the notification line.
35+- **Not verified**: the menu in a real Zed (no Zed in the sandbox) — the user should retry.
36+
37+## 2026-09-15 — ACP: `/compact` and `/abort` alongside `/new` (orchestrator)
38+
39+- **Asked**: the user confirmed `/new` now shows in Zed and asked for the same for `/compact` and `/abort`.
40+- **Changes**: `internal/acp/acp.go` — `compactCommand`/`abortCommand` constants, `isCommand`, three advertised commands, `/abort` intercepted in `Prompt` before `turnMu` (`abortTurn` → shared `cancelTurn`, also used by `Cancel`), `/compact` after `turnMu` (`compactSession` → `compactHistory(ctx, s, cfg, summarize, explain) (line, compressed)`, engine-free and testable). `internal/compact/compact.go` — `Result.Report()` plus `kilo`/`seconds` moved here from `internal/agent/agent.go`, which now prints `res.Report()`. Tests: `TestAvailableCommandsMatchWhatPromptIntercepts`, `TestIsCommandTrimsAndRequiresExactMatch`, `TestCompactHistoryOutcomes` (3 sub-tests), `TestCancelTurnFiresOnlyWhenATurnIsInFlight`; the wire-order test now compares against `availableCommands()`. Docs EN+FR: `reference/slash-commands.md` (ACP section became a table), `reference/acp.md` (`session/new` row, three `session/prompt` rows, commands table), `how-to/use-from-an-editor.md` (cancel + compress/restart bullets), `how-to/manage-the-context-window.md`.
41+- **Decisions**: `/quit` stays terminal-only; `/compact` over ACP is always forced (no automatic compaction exists in the ACP front end, unchanged); `/abort` answers `⚠️ No generation in progress.` when idle rather than erroring. Rejected: handling `/abort` under `turnMu` (it could never reach a running turn).
42+- **Tests**: `go test -race` on acp/compact ok; full `go test ./...` still fails only on the pre-existing `internal/skills` path test. Real binary driven by a small JSON-RPC client: commands list `new, compact, abort`; `/abort` idle → `⚠️ No generation in progress.`; ` /compact\n` on an empty history → `🗜️ nothing to compact: 1 message(s), no turn older than the last 3`; both `end_turn`.
43+- **Not verified**: `/abort` against a real running generation (no model server in the sandbox) and the two new entries in Zed's menu.
44+
45+## 2026-09-15 — `mm` killed on the user's Mac (orchestrator, diagnosis only)
46+
47+- **Asked**: running `mm` on the Mac prints `[1] 55918 killed mm`.
48+- **Findings**: the repo's `./mm` is a Mach-O (magic `CF FA ED FE`), so the user rebuilt natively after the sandbox builds (the sandbox produced linux/arm64 ELF binaries at the same path in between — `mm` is gitignored, fine). SIGKILL of a freshly built Go binary on macOS is the invalid-signature kill; most likely cause is `install.sh` overwriting `/usr/local/bin/mm` IN PLACE with `cp` (the kernel keeps the old inode's signature cached → `Killed: 9`), possibly compounded by the repo living in a kDrive-synced folder. Not verifiable from the sandbox.
49+- **Proposed, not applied**: `sudo rm /usr/local/bin/mm && ./install.sh`; make `install.sh` remove the target (or use `install`) before copying; `codesign -vv /usr/local/bin/mm` to confirm.
50+
51+## 2026-09-15 — "the agent does not see the skills" (orchestrator, diagnosis only)
52+
53+- **Cause confirmed in code**: `skills.List` globs `<skillsDir>/*.md` and `skills.Read` opens `<skillsDir>/<name>.md`, while the shipped skills live in `skills/<name>/SKILL.md` — so `skills: 0` and `read_skill` is never declared. Two aggravating factors: in terminal mode a relative `skillsDir` resolves against the current directory, and `agent.yaml` has been renamed `agent.dmr.yaml` by the user (no `./agent.yaml` → built-in defaults; a Zed `AGENT_CONFIG` still pointing to `agent.yaml` names a missing file, which `config.Load` treats as an error).
54+- **Proposed, not applied**: make `List`/`Read` accept both layouts (`*.md` and `*/SKILL.md`, name = directory), fix the test path `../../../demo/skills` → `../../skills`.
55+
56+## 2026-09-15 — Skills layout fix, skillsDir resolution, `/abort` removed from ACP (orchestrator)
57+
58+- **Asked**: fix the skills loader; make an installed `mm` (`/usr/local/bin`) load the skills of the directory it is started from; drop `/abort` from ACP because Escape already cancels in Zed.
59+- **Changes**: `internal/skills/skills.go` — `List` globs both `<dir>/*.md` and `<dir>/*/SKILL.md`, `defaultName` uses the directory name for a `SKILL.md`, `Read` tries `<name>.md` then `<name>/SKILL.md` (flat wins); tests `TestListAcceptsFlatAndNestedLayouts`, `TestReadAcceptsFlatAndNestedLayouts`, shipped-skills test path → `../../skills`. `main.go` — `resolveSkillsDir(dir, configPath, acpMode)` makes a relative `skillsDir` absolute from the current directory (terminal, or ACP without config) or next to the config file (ACP with one); a `[warning: no skills found in <abs path> (expected <name>.md or <name>/SKILL.md there)]` line when the count is 0. `internal/acp/acp.go` — `/abort` handling, `abortTurn`, `cancelTurn` removed; `Cancel` back to its inline form; `availableCommands()` = `new`, `compact`; tests adjusted. Docs EN+FR: `how-to/add-a-skill.md`, `reference/skill-format.md`, `reference/tools.md`, `reference/configuration.md`, `reference/cli.md`, `how-to/use-from-an-editor.md`, `reference/slash-commands.md`, `reference/acp.md`. `summary.md`: known defects closed, skills decision added, ACP commands decision updated.
60+- **Decisions**: both layouts accepted rather than moving the shipped files (the `<name>/SKILL.md` form is the Agent Skills convention); flat file wins when both exist; the warning is printed whenever 0 skills are found, with the resolved absolute path, because "skills: 0" alone was the user's whole problem.
61+- **Tests**: `go test -race ./...` all green (first time `internal/skills` passes). Linux build to `/tmp/mm-linux` (NOT `./mm`, which is the user's Mach-O): from the repo root the banner lists `read_skill`; from `/tmp` it warns `no skills found in /tmp/skills`.
62+- **Not verified**: the Mac binary — the user must rebuild natively (`sudo rm /usr/local/bin/mm && ./install.sh`).
63+
64+## 2026-09-15 — `@path` notation in questions (orchestrator)
65+
66+- **Asked**: mini-me must understand `@path/file` in a question, without a picker.
67+- **Changes**: new `internal/mention` (`Expand(input, cwd) (string, []Attachment)`, `Attachment.Line()`): mentions start the input or follow a space/bracket/quote, end at a space, lose trailing punctuation; resolved against cwd, absolute, or `~/`; only existing paths attach, once each, as `[attached file: …]` / `[attached directory: …]` lines appended to the text (same shape as ACP `resource_link`). Wired in `internal/agent/agent.go` (cwd captured once in `Run`; `📎 <abs>` dim line per attachment) and `internal/acp/acp.go` (after `flatten`, against `s.cwd`; `[acp] session <id>: attached <path>` on stderr). Tests: 4 in `mention_test.go`. Docs EN+FR: new `how-to/attach-a-file.md`, README indexes, `reference/slash-commands.md` (new `@path` section), `reference/acp.md` (prompt content row), `how-to/use-from-an-editor.md`, `explanation/architecture.md` package table, `docs/diagrams/packages.drawio` (node + edges from agent and acp).
68+- **Decisions**: keep the sentence as typed and append the marker lines rather than rewriting the `@` token (mirrors what Zed sends: text + resource_link); no inlining of content (bash-first agent reads it); paths with spaces unsupported (documented); non-existing paths silently left alone rather than warned (a `@handle` in a sentence is normal text).
69+- **Tests**: `go test -race ./...` all green. Linux build in `/tmp`: typing `explain @main.go and @internal, not @nothing.go` prints two `📎` lines and none for the missing path.
new file mode 100644
@@ -0,0 +1,69 @@
1+# History
2+
3+One entry per session, appended, never rewritten.
4+
5+## 2026-09-15 — Diátaxis documentation bootstrap (agent "riker")
6+
7+- **Asked:** `/diataxis-doc` — start documenting the project, excluding `./kits` and `./tmp`. Ran in parallel with another agent adding `/new`.
8+- **Changed:** created `docs/` (language selector) with full four-quadrant trees in `docs/en/` and `docs/fr/`, identical file names: 1 tutorial, 4 how-to guides, 6 reference pages, 3 explanations per language, plus both indexes. Linked the `/new` how-to page and `docs/diagrams/packages.drawio` written by the other agent. No Go code touched.
9+- **Decisions:** language "Both" taken from the methodical-dev convention (user unavailable, skill's mandatory question skipped and recorded here). Shipped skill layout defect documented in how-to/reference rather than fixed.
10+- **Verified:** `go build` OK; banner output for both YAML files captured verbatim; `go test ./...` fails on `mm/internal/skills` (test points at `../../../demo/skills`). All relative links in `docs/` resolve.
11+- **Docs:** everything under `docs/`. Created `.memory/` skeleton.
12+
13+## 2026-09-15 — `/new` slash command (terminal + ACP), quality gate set-up, first `.memory/`
14+
15+- **Goal**: add `/new` to the `mm` agent to clear the history and start a fresh session, in the terminal REPL and over ACP; exclude `./kits` and `./tmp` from the quality measurement. Run by agent "bob" via `methodical-dev`, autonomously (the user was not available for the skill's approval gates), while agent "riker" wrote the rest of `docs/` in parallel.
16+- **Changes**: new package `internal/session` (`NewCommand`, `NewCommandName`, `IsNewCommand`, `Fresh`, `Forgotten`) + tests; `detector.Reset()` + first tests for `internal/detector`; `internal/agent/agent.go` (`/new` handling, `startNewSession`, banner, `if/else` → `switch` for staticcheck QF1003, both front ends now start from `session.Fresh`); `internal/acp/acp.go` (`availableCommands`, `available_commands_update` in `NewSession`, `/new` interception in `Prompt`, `startNewSession`, `resetSession`) + tests; `main.go` package list comment; `.qlty/qlty.toml` (created); `.quality/` (2 runs); docs (below); `.memory/` created.
17+- **Decisions**: `/new` resets history + engine token count + loop detector (REPL); ACP keeps session id / cwd / allow-always; only `/new` advertised over ACP; notification sent inside `NewSession` before the response; the `session` import is aliased `history` inside `internal/acp` because that package already has a `session` type. Rejected: reusing `compactHistory`'s "keep the detector" rule for `/new` (different situation: the task changes); creating a git worktree (all files untracked); fixing the pre-existing `demo/skills` test path (layout decision for the user).
18+- **Tests**: `go test ./...` — all packages pass except the pre-existing `internal/skills` failure (`TestShippedSkillsHaveDescriptions`, path `../../../demo/skills`). `/new` smoke-tested end to end: piped REPL prints `🆕 new session: 0 message(s) forgotten`; scripted JSON-RPC client sees `available_commands_update` then the `session/new` response, and `/new` answered by an `agent_message_chunk` + `end_turn`.
19+- **Quality**: gate FAIL, 2 iterations: warnings 69 → 67, smells 18 → 18, complexity 620 → 618. Fixed: `ineffassign` in the new test, staticcheck QF1003 in `agent.go`. Remaining findings are all pre-existing (osv on `go.mod`, errcheck, complexity of `agent.Run` etc.) and out of this change's scope. Nothing was excluded beyond `kits/**` and `tmp/**`; no threshold changed.
20+- **Docs**: `docs/{en,fr}/how-to/start-a-new-session.md` (new); `/new` rows and ACP paragraphs in `docs/{en,fr}/reference/slash-commands.md` and `acp.md` (riker's pages, edited in place); links in both `README.md` indexes; `docs/diagrams/packages.drawio` (new, from `go list`) referenced from both `explanation/architecture.md`.
21+
22+## 2026-09-15 — Zed config fix in the editor how-to (orchestrator)
23+
24+- **Asked**: the user reported that the Zed `agent_servers` snippet in the docs is insufficient — Zed requires `"type": "custom"` in the entry.
25+- **Changes**: added `"type": "custom"` to the JSON block in `docs/en/how-to/use-from-an-editor.md` and `docs/fr/how-to/use-from-an-editor.md`; recorded the requirement under "Decisions in force" in `summary.md`.
26+- **Tests / quality**: docs-only change, none run.
27+
28+## 2026-09-15 — ACP: `/new` was invisible in Zed's slash menu (orchestrator)
29+
30+- **Asked**: the user reported that Zed lists commands for other ACP agents (docker agent) but shows "Available commands for mini-me: none" and rejects `/new` as unknown.
31+- **Cause**: `available_commands_update` was sent inside `NewSession`, i.e. before the SDK wrote the `session/new` response. Zed keys sessions on the id in that response and drops updates for an unknown session id.
32+- **Changes**: `internal/acp/acp.go` — `Run` now builds the front via `newFront(e, system, tools, w, r)`; the SDK writes to a `responseWriter` that runs a per-session hook once the response line carrying that session id has been written; `NewSession` registers the hook (`context.WithoutCancel(ctx)`, the request context dies with the method) instead of calling `SessionUpdate` directly. `acp_test.go` — `TestResponseSessionIDMatchesOnlySessionResponses`, `TestAvailableCommandsAreSentAfterTheSessionNewResponse` (drives the real SDK over pipes, reads the raw lines back). Docs: `available_commands_update` row in `docs/{en,fr}/reference/acp.md`; `summary.md` decision updated, the "Zed slash menu" unknown removed.
33+- **Rejected**: a goroutine with a `time.Sleep` after `NewSession` returns (a race, just an unlikely one); sending the list on the first `session/prompt` (too late for the menu before the first message).
34+- **Tests**: `go test -race ./internal/acp -count=5` ok; `go build` ok; real binary over stdin shows the response line before the notification line.
35+- **Not verified**: the menu in a real Zed (no Zed in the sandbox) — the user should retry.
36+
37+## 2026-09-15 — ACP: `/compact` and `/abort` alongside `/new` (orchestrator)
38+
39+- **Asked**: the user confirmed `/new` now shows in Zed and asked for the same for `/compact` and `/abort`.
40+- **Changes**: `internal/acp/acp.go` — `compactCommand`/`abortCommand` constants, `isCommand`, three advertised commands, `/abort` intercepted in `Prompt` before `turnMu` (`abortTurn` → shared `cancelTurn`, also used by `Cancel`), `/compact` after `turnMu` (`compactSession` → `compactHistory(ctx, s, cfg, summarize, explain) (line, compressed)`, engine-free and testable). `internal/compact/compact.go` — `Result.Report()` plus `kilo`/`seconds` moved here from `internal/agent/agent.go`, which now prints `res.Report()`. Tests: `TestAvailableCommandsMatchWhatPromptIntercepts`, `TestIsCommandTrimsAndRequiresExactMatch`, `TestCompactHistoryOutcomes` (3 sub-tests), `TestCancelTurnFiresOnlyWhenATurnIsInFlight`; the wire-order test now compares against `availableCommands()`. Docs EN+FR: `reference/slash-commands.md` (ACP section became a table), `reference/acp.md` (`session/new` row, three `session/prompt` rows, commands table), `how-to/use-from-an-editor.md` (cancel + compress/restart bullets), `how-to/manage-the-context-window.md`.
41+- **Decisions**: `/quit` stays terminal-only; `/compact` over ACP is always forced (no automatic compaction exists in the ACP front end, unchanged); `/abort` answers `⚠️ No generation in progress.` when idle rather than erroring. Rejected: handling `/abort` under `turnMu` (it could never reach a running turn).
42+- **Tests**: `go test -race` on acp/compact ok; full `go test ./...` still fails only on the pre-existing `internal/skills` path test. Real binary driven by a small JSON-RPC client: commands list `new, compact, abort`; `/abort` idle → `⚠️ No generation in progress.`; ` /compact\n` on an empty history → `🗜️ nothing to compact: 1 message(s), no turn older than the last 3`; both `end_turn`.
43+- **Not verified**: `/abort` against a real running generation (no model server in the sandbox) and the two new entries in Zed's menu.
44+
45+## 2026-09-15 — `mm` killed on the user's Mac (orchestrator, diagnosis only)
46+
47+- **Asked**: running `mm` on the Mac prints `[1] 55918 killed mm`.
48+- **Findings**: the repo's `./mm` is a Mach-O (magic `CF FA ED FE`), so the user rebuilt natively after the sandbox builds (the sandbox produced linux/arm64 ELF binaries at the same path in between — `mm` is gitignored, fine). SIGKILL of a freshly built Go binary on macOS is the invalid-signature kill; most likely cause is `install.sh` overwriting `/usr/local/bin/mm` IN PLACE with `cp` (the kernel keeps the old inode's signature cached → `Killed: 9`), possibly compounded by the repo living in a kDrive-synced folder. Not verifiable from the sandbox.
49+- **Proposed, not applied**: `sudo rm /usr/local/bin/mm && ./install.sh`; make `install.sh` remove the target (or use `install`) before copying; `codesign -vv /usr/local/bin/mm` to confirm.
50+
51+## 2026-09-15 — "the agent does not see the skills" (orchestrator, diagnosis only)
52+
53+- **Cause confirmed in code**: `skills.List` globs `<skillsDir>/*.md` and `skills.Read` opens `<skillsDir>/<name>.md`, while the shipped skills live in `skills/<name>/SKILL.md` — so `skills: 0` and `read_skill` is never declared. Two aggravating factors: in terminal mode a relative `skillsDir` resolves against the current directory, and `agent.yaml` has been renamed `agent.dmr.yaml` by the user (no `./agent.yaml` → built-in defaults; a Zed `AGENT_CONFIG` still pointing to `agent.yaml` names a missing file, which `config.Load` treats as an error).
54+- **Proposed, not applied**: make `List`/`Read` accept both layouts (`*.md` and `*/SKILL.md`, name = directory), fix the test path `../../../demo/skills` → `../../skills`.
55+
56+## 2026-09-15 — Skills layout fix, skillsDir resolution, `/abort` removed from ACP (orchestrator)
57+
58+- **Asked**: fix the skills loader; make an installed `mm` (`/usr/local/bin`) load the skills of the directory it is started from; drop `/abort` from ACP because Escape already cancels in Zed.
59+- **Changes**: `internal/skills/skills.go` — `List` globs both `<dir>/*.md` and `<dir>/*/SKILL.md`, `defaultName` uses the directory name for a `SKILL.md`, `Read` tries `<name>.md` then `<name>/SKILL.md` (flat wins); tests `TestListAcceptsFlatAndNestedLayouts`, `TestReadAcceptsFlatAndNestedLayouts`, shipped-skills test path → `../../skills`. `main.go` — `resolveSkillsDir(dir, configPath, acpMode)` makes a relative `skillsDir` absolute from the current directory (terminal, or ACP without config) or next to the config file (ACP with one); a `[warning: no skills found in <abs path> (expected <name>.md or <name>/SKILL.md there)]` line when the count is 0. `internal/acp/acp.go` — `/abort` handling, `abortTurn`, `cancelTurn` removed; `Cancel` back to its inline form; `availableCommands()` = `new`, `compact`; tests adjusted. Docs EN+FR: `how-to/add-a-skill.md`, `reference/skill-format.md`, `reference/tools.md`, `reference/configuration.md`, `reference/cli.md`, `how-to/use-from-an-editor.md`, `reference/slash-commands.md`, `reference/acp.md`. `summary.md`: known defects closed, skills decision added, ACP commands decision updated.
60+- **Decisions**: both layouts accepted rather than moving the shipped files (the `<name>/SKILL.md` form is the Agent Skills convention); flat file wins when both exist; the warning is printed whenever 0 skills are found, with the resolved absolute path, because "skills: 0" alone was the user's whole problem.
61+- **Tests**: `go test -race ./...` all green (first time `internal/skills` passes). Linux build to `/tmp/mm-linux` (NOT `./mm`, which is the user's Mach-O): from the repo root the banner lists `read_skill`; from `/tmp` it warns `no skills found in /tmp/skills`.
62+- **Not verified**: the Mac binary — the user must rebuild natively (`sudo rm /usr/local/bin/mm && ./install.sh`).
63+
64+## 2026-09-15 — `@path` notation in questions (orchestrator)
65+
66+- **Asked**: mini-me must understand `@path/file` in a question, without a picker.
67+- **Changes**: new `internal/mention` (`Expand(input, cwd) (string, []Attachment)`, `Attachment.Line()`): mentions start the input or follow a space/bracket/quote, end at a space, lose trailing punctuation; resolved against cwd, absolute, or `~/`; only existing paths attach, once each, as `[attached file: …]` / `[attached directory: …]` lines appended to the text (same shape as ACP `resource_link`). Wired in `internal/agent/agent.go` (cwd captured once in `Run`; `📎 <abs>` dim line per attachment) and `internal/acp/acp.go` (after `flatten`, against `s.cwd`; `[acp] session <id>: attached <path>` on stderr). Tests: 4 in `mention_test.go`. Docs EN+FR: new `how-to/attach-a-file.md`, README indexes, `reference/slash-commands.md` (new `@path` section), `reference/acp.md` (prompt content row), `how-to/use-from-an-editor.md`, `explanation/architecture.md` package table, `docs/diagrams/packages.drawio` (node + edges from agent and acp).
68+- **Decisions**: keep the sentence as typed and append the marker lines rather than rewriting the `@` token (mirrors what Zed sends: text + resource_link); no inlining of content (bash-first agent reads it); paths with spaces unsupported (documented); non-existing paths silently left alone rather than warned (a `@handle` in a sentence is normal text).
69+- **Tests**: `go test -race ./...` all green. Linux build in `/tmp`: typing `explain @main.go and @internal, not @nothing.go` prints two `📎` lines and none for the missing path.
added .memory/summary.md +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+# mini-me (mm) — project summary
2+
3+## What it is
4+
5+A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26.5) that drives a local LLM through the OpenAI-compatible chat-completions API with Genkit (`github.com/firebase/genkit/go` v1.10.0). Two front ends share one loop: a terminal REPL (`internal/agent`) and an Agent Client Protocol façade (`internal/acp`, `github.com/coder/acp-go-sdk` v0.13.5) selected with `mm -acp`.
6+
7+## Architecture (verified 2026-09-15)
8+
9+`main.go` does the wiring only. Packages under `internal/`: `config` (defaults + YAML + `AGENT_*` env overrides), `engine` (provider registry `dmr`/`llamacpp`, streaming, watchdog, non-streaming retry, `Summarize`), `tools` (`bash`, `read_skill`, `read_file`, `write_file`, `edit_file`), `fileedit` (exact-replacement editing), `skills` (catalogue from `<skillsDir>/*.md` front matter), `detector` (repeated-action detection), `compact` (history compression), `spinner`, `ui` (`Out` writer + `Sink` event interface the ACP façade implements), `agent`, `acp`, `session` (added 2026-09-15 by the `/new` work — see history).
10+
11+## Commands
12+
13+| Purpose | Command |
14+|---------|---------|
15+| Build | `go build -o mm .` |
16+| Install | `./install.sh` (builds, copies to `/usr/local/bin`) |
17+| Test | `go test ./...` |
18+| Run (DMR) | `./mm` (reads `./agent.yaml`) |
19+| Run (llama.cpp) | `./mm agent.llamacpp.yaml` |
20+| Editor | `mm -acp` with `AGENT_CONFIG=<absolute path>` |
21+| Quality gate | `python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .` (report under `.quality/`, exit 0 = pass) |
22+
23+Use `bash -l -c "…"` in this sandbox so `go` is on the PATH.
24+
25+## Decisions in force
26+
27+- Tools return failures as text, never as Go errors (the model must read them).
28+- Full history is kept after a failed/cancelled turn; only an unanswered question is dropped.
29+- One provider type per wire protocol (`openaiCompat`); `dmr` and `llamacpp` are registry entries.
30+- API keys are named by environment variable in YAML, never stored in it.
31+- `contextWindow` is one top-level key shared by the banner and the compression trigger.
32+- Documentation follows Diátaxis, bilingual: `docs/en/` and `docs/fr/` with identical file names; `kits/` and `tmp/` are out of scope.
33+- Slash commands: terminal `/quit`, `/abort`, `/compact`, `/new`; ACP `/new`, `/compact` only (no `/quit`: the editor owns the process; no `/abort`: Escape in Zed cancels through `session/cancel` — user's decision 2026-09-15, an earlier `/abort` was removed), advertised through `available_commands_update` sent right AFTER the `session/new` response is written (a hook on the writer handed to the SDK, `responseWriter` in `internal/acp/acp.go`, fires when the response line carrying the session id goes out). Sending it before the response does not work: Zed drops updates for a session id it has not received yet (user report 2026-09-15; fix confirmed working in Zed by the user). Only what `session/prompt` intercepts is advertised. `/new` and `/compact` are intercepted under `turnMu`, so the history never moves under Genkit. The compaction report line is `compact.Result.Report()`, shared by both front ends.
34+- `/new` resets the history (system prompt alone), the engine's last input-token count, and — terminal only — the loop detector (`detector.Reset`): a new session is a new task, unlike `/compact` which keeps the detector. Over ACP it keeps the session id, `cwd` and "allow always" grants. Shared logic lives in `internal/session`, imported as `history` inside `internal/acp` (name clash with its `session` struct).
35+- Quality gate scope: `.qlty/qlty.toml` excludes `kits/**` and `tmp/**` (user's request, 2026-09-15), drops qlty's default `**/config/**` exclusion so `internal/config` is measured, and enables `gofmt`, `golangci-lint`, `osv-scanner` by hand (`qlty init` only saw tracked files, hence no Go). Do not re-run `qlty init`.
36+- Zed `agent_servers` entry for mm must carry `"type": "custom"` alongside `command`/`args`/`env`; without it Zed rejects the entry (user report, 2026-09-15). Both `how-to/use-from-an-editor.md` pages show the full block.
37+- Skills: two layouts, flat `<name>.md` and `<name>/SKILL.md` (Agent Skills convention, the shipped one); `Read` prefers the flat file. A relative `skillsDir` is made absolute in `main.go` (`resolveSkillsDir`): from the current directory (terminal, or ACP without a config file) or next to the config file (ACP with one); the banner warns `no skills found in <abs path>` when the count is 0.
38+- `@path` mentions (`internal/mention`, `Expand(input, cwd)`): a typed `@path` that exists (relative to the start directory / session cwd, absolute, or `~/`) appends `[attached file: <abs>]` / `[attached directory: <abs>]` to the message — the exact shape of an ACP `resource_link` — and is echoed (`📎` line in the terminal, `[acp] … attached` on stderr). No picker, no completion, content never inlined; a non-existing path or a `@` glued to a word is left alone. Used by both front ends.
39+- The package import graph is kept in `docs/diagrams/packages.drawio` (generated from `go list`); update it when a package is added, removed or re-wired.
40+
41+## Known defects (verified 2026-09-15, not fixed)
42+
43+- None open. (The skills-layout bug and the `demo/skills` test path were fixed on 2026-09-15; `go test ./...` passes.)
44+
45+## Quality gate state (2026-09-15, 2 runs in `.quality/history.jsonl`)
46+
47+FAIL on pre-existing debt only: 67 warnings (55 `osv-scanner` on `go.mod` — 47 against `stdlib@1.25.0`, i.e. the `go` directive, 8 against indirect deps `jsonparser`, `otel`, `x/sys`; 12 `golangci-lint:errcheck` in `main.go`, `internal/engine`, `internal/fileedit`) and 18 smells (complexity: `agent.Run` = 121, `fileedit/diff.go`, `compact.Valid`, `engine.Generate`, `config.validate`, `engine.Explain`). No finding in the `/new` code.
48+
49+## Repository oddities
50+
51+- Almost everything is untracked in git (only two commits, "Updated."). `.gitignore` exists.
52+- `.tickets/` (IssueSpec) holds three open issues: add a help command, add an about command, improve TUI.
53+- `agent.yaml` names the agent "Bob" in its system prompt; ACP `agentInfo.name` is also `bob`, version `0.11.0`. The binary is `mm`.
54+
55+## Not yet established
56+
57+- Whether a real DMR or llama-server run works end to end from this sandbox (no model server here; only the banner and `-h` were exercised).
58+- Whether the project wants `go.mod`'s `go 1.25.0` bumped to 1.26 and the flagged indirect deps upgraded to clear the osv-scanner findings.
new file mode 100644
@@ -0,0 +1,58 @@
1+# mini-me (mm) — project summary
2+
3+## What it is
4+
5+A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26.5) that drives a local LLM through the OpenAI-compatible chat-completions API with Genkit (`github.com/firebase/genkit/go` v1.10.0). Two front ends share one loop: a terminal REPL (`internal/agent`) and an Agent Client Protocol façade (`internal/acp`, `github.com/coder/acp-go-sdk` v0.13.5) selected with `mm -acp`.
6+
7+## Architecture (verified 2026-09-15)
8+
9+`main.go` does the wiring only. Packages under `internal/`: `config` (defaults + YAML + `AGENT_*` env overrides), `engine` (provider registry `dmr`/`llamacpp`, streaming, watchdog, non-streaming retry, `Summarize`), `tools` (`bash`, `read_skill`, `read_file`, `write_file`, `edit_file`), `fileedit` (exact-replacement editing), `skills` (catalogue from `<skillsDir>/*.md` front matter), `detector` (repeated-action detection), `compact` (history compression), `spinner`, `ui` (`Out` writer + `Sink` event interface the ACP façade implements), `agent`, `acp`, `session` (added 2026-09-15 by the `/new` work — see history).
10+
11+## Commands
12+
13+| Purpose | Command |
14+|---------|---------|
15+| Build | `go build -o mm .` |
16+| Install | `./install.sh` (builds, copies to `/usr/local/bin`) |
17+| Test | `go test ./...` |
18+| Run (DMR) | `./mm` (reads `./agent.yaml`) |
19+| Run (llama.cpp) | `./mm agent.llamacpp.yaml` |
20+| Editor | `mm -acp` with `AGENT_CONFIG=<absolute path>` |
21+| Quality gate | `python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .` (report under `.quality/`, exit 0 = pass) |
22+
23+Use `bash -l -c "…"` in this sandbox so `go` is on the PATH.
24+
25+## Decisions in force
26+
27+- Tools return failures as text, never as Go errors (the model must read them).
28+- Full history is kept after a failed/cancelled turn; only an unanswered question is dropped.
29+- One provider type per wire protocol (`openaiCompat`); `dmr` and `llamacpp` are registry entries.
30+- API keys are named by environment variable in YAML, never stored in it.
31+- `contextWindow` is one top-level key shared by the banner and the compression trigger.
32+- Documentation follows Diátaxis, bilingual: `docs/en/` and `docs/fr/` with identical file names; `kits/` and `tmp/` are out of scope.
33+- Slash commands: terminal `/quit`, `/abort`, `/compact`, `/new`; ACP `/new`, `/compact` only (no `/quit`: the editor owns the process; no `/abort`: Escape in Zed cancels through `session/cancel` — user's decision 2026-09-15, an earlier `/abort` was removed), advertised through `available_commands_update` sent right AFTER the `session/new` response is written (a hook on the writer handed to the SDK, `responseWriter` in `internal/acp/acp.go`, fires when the response line carrying the session id goes out). Sending it before the response does not work: Zed drops updates for a session id it has not received yet (user report 2026-09-15; fix confirmed working in Zed by the user). Only what `session/prompt` intercepts is advertised. `/new` and `/compact` are intercepted under `turnMu`, so the history never moves under Genkit. The compaction report line is `compact.Result.Report()`, shared by both front ends.
34+- `/new` resets the history (system prompt alone), the engine's last input-token count, and — terminal only — the loop detector (`detector.Reset`): a new session is a new task, unlike `/compact` which keeps the detector. Over ACP it keeps the session id, `cwd` and "allow always" grants. Shared logic lives in `internal/session`, imported as `history` inside `internal/acp` (name clash with its `session` struct).
35+- Quality gate scope: `.qlty/qlty.toml` excludes `kits/**` and `tmp/**` (user's request, 2026-09-15), drops qlty's default `**/config/**` exclusion so `internal/config` is measured, and enables `gofmt`, `golangci-lint`, `osv-scanner` by hand (`qlty init` only saw tracked files, hence no Go). Do not re-run `qlty init`.
36+- Zed `agent_servers` entry for mm must carry `"type": "custom"` alongside `command`/`args`/`env`; without it Zed rejects the entry (user report, 2026-09-15). Both `how-to/use-from-an-editor.md` pages show the full block.
37+- Skills: two layouts, flat `<name>.md` and `<name>/SKILL.md` (Agent Skills convention, the shipped one); `Read` prefers the flat file. A relative `skillsDir` is made absolute in `main.go` (`resolveSkillsDir`): from the current directory (terminal, or ACP without a config file) or next to the config file (ACP with one); the banner warns `no skills found in <abs path>` when the count is 0.
38+- `@path` mentions (`internal/mention`, `Expand(input, cwd)`): a typed `@path` that exists (relative to the start directory / session cwd, absolute, or `~/`) appends `[attached file: <abs>]` / `[attached directory: <abs>]` to the message — the exact shape of an ACP `resource_link` — and is echoed (`📎` line in the terminal, `[acp] … attached` on stderr). No picker, no completion, content never inlined; a non-existing path or a `@` glued to a word is left alone. Used by both front ends.
39+- The package import graph is kept in `docs/diagrams/packages.drawio` (generated from `go list`); update it when a package is added, removed or re-wired.
40+
41+## Known defects (verified 2026-09-15, not fixed)
42+
43+- None open. (The skills-layout bug and the `demo/skills` test path were fixed on 2026-09-15; `go test ./...` passes.)
44+
45+## Quality gate state (2026-09-15, 2 runs in `.quality/history.jsonl`)
46+
47+FAIL on pre-existing debt only: 67 warnings (55 `osv-scanner` on `go.mod` — 47 against `stdlib@1.25.0`, i.e. the `go` directive, 8 against indirect deps `jsonparser`, `otel`, `x/sys`; 12 `golangci-lint:errcheck` in `main.go`, `internal/engine`, `internal/fileedit`) and 18 smells (complexity: `agent.Run` = 121, `fileedit/diff.go`, `compact.Valid`, `engine.Generate`, `config.validate`, `engine.Explain`). No finding in the `/new` code.
48+
49+## Repository oddities
50+
51+- Almost everything is untracked in git (only two commits, "Updated."). `.gitignore` exists.
52+- `.tickets/` (IssueSpec) holds three open issues: add a help command, add an about command, improve TUI.
53+- `agent.yaml` names the agent "Bob" in its system prompt; ACP `agentInfo.name` is also `bob`, version `0.11.0`. The binary is `mm`.
54+
55+## Not yet established
56+
57+- Whether a real DMR or llama-server run works end to end from this sandbox (no model server here; only the banner and `-h` were exercised).
58+- Whether the project wants `go.mod`'s `go 1.25.0` bumped to 1.26 and the flagged indirect deps upgraded to clear the osv-scanner findings.
added .qlty/.gitignore +7 -0
new file mode 100644
@@ -0,0 +1,7 @@
1+*
2+!configs
3+!configs/**
4+!hooks
5+!hooks/**
6+!qlty.toml
7+!.gitignore
new file mode 100644
@@ -0,0 +1,7 @@
1+*
2+!configs
3+!configs/**
4+!hooks
5+!hooks/**
6+!qlty.toml
7+!.gitignore
added .qlty/configs/.shellcheckrc +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+source-path=SCRIPTDIR
\ No newline at end of file
new file mode 100644
@@ -0,0 +1 @@
1+source-path=SCRIPTDIR
\ No newline at end of file\ No newline at end of file
added .qlty/qlty.toml +94 -0
new file mode 100644
@@ -0,0 +1,94 @@
1+# This file was automatically generated by `qlty init`.
2+# You can modify it to suit your needs.
3+# We recommend you to commit this file to your repository.
4+#
5+# This configuration is used by both Qlty CLI and Qlty Cloud.
6+#
7+# Qlty CLI -- Code quality toolkit for developers
8+# Qlty Cloud -- Fully automated Code Health Platform
9+#
10+# Try Qlty Cloud: https://qlty.sh
11+#
12+# For a guide to configuration, visit https://qlty.sh/d/config
13+# Or for a full reference, visit https://qlty.sh/d/qlty-toml
14+config_version = "0"
15+
16+exclude_patterns = [
17+ "*_min.*",
18+ "*-min.*",
19+ "*.min.*",
20+ "**/.yarn/**",
21+ "**/*.d.ts",
22+ "**/assets/**",
23+ "**/bower_components/**",
24+ "**/build/**",
25+ "**/cache/**",
26+ # "**/config/**" is a qlty default, removed here: internal/config is project
27+ # code (the YAML settings loader) and must be measured like the rest.
28+ "**/db/**",
29+ "**/deps/**",
30+ "**/dist/**",
31+ "**/extern/**",
32+ "**/external/**",
33+ "**/generated/**",
34+ "**/Godeps/**",
35+ "**/gradlew/**",
36+ "**/mvnw/**",
37+ "**/node_modules/**",
38+ "**/protos/**",
39+ "**/seed/**",
40+ "**/target/**",
41+ "**/templates/**",
42+ "**/testdata/**",
43+ "**/vendor/**",
44+ # Excluded at the user's request (2026-09-15):
45+ # kits/ is the vendored dev-toolkit kit — the sources installed into
46+ # ~/.claude/, including the quality skill's own quality_report.py, so leaving
47+ # it in scope would measure the instrument with itself (its subprocess use is
48+ # flagged by every Python security linter and cannot be fixed).
49+ "kits/**",
50+ # tmp/ holds a draft agent (tmp/draft-agent) and a cross-compile script
51+ # (tmp/build.sh) that are not part of the mm binary or its tests.
52+ "tmp/**",
53+]
54+
55+test_patterns = [
56+ "**/test/**",
57+ "**/spec/**",
58+ "**/*.test.*",
59+ "**/*.spec.*",
60+ "**/*_test.*",
61+ "**/*_spec.*",
62+ "**/test_*.*",
63+ "**/spec_*.*",
64+]
65+
66+[smells]
67+mode = "comment"
68+
69+[[source]]
70+name = "default"
71+default = true
72+
73+
74+[[plugin]]
75+name = "ripgrep"
76+mode = "comment"
77+
78+[[plugin]]
79+name = "shellcheck"
80+
81+[[plugin]]
82+name = "trufflehog"
83+
84+# `qlty init` only looks at files git tracks, and at init time every Go file of
85+# this project was still untracked, so it enabled none of the Go plugins. These
86+# are the ones it enables when it does see Go code.
87+[[plugin]]
88+name = "gofmt"
89+
90+[[plugin]]
91+name = "golangci-lint"
92+
93+[[plugin]]
94+name = "osv-scanner"
new file mode 100644
@@ -0,0 +1,94 @@
1+# This file was automatically generated by `qlty init`.
2+# You can modify it to suit your needs.
3+# We recommend you to commit this file to your repository.
4+#
5+# This configuration is used by both Qlty CLI and Qlty Cloud.
6+#
7+# Qlty CLI -- Code quality toolkit for developers
8+# Qlty Cloud -- Fully automated Code Health Platform
9+#
10+# Try Qlty Cloud: https://qlty.sh
11+#
12+# For a guide to configuration, visit https://qlty.sh/d/config
13+# Or for a full reference, visit https://qlty.sh/d/qlty-toml
14+config_version = "0"
15+
16+exclude_patterns = [
17+ "*_min.*",
18+ "*-min.*",
19+ "*.min.*",
20+ "**/.yarn/**",
21+ "**/*.d.ts",
22+ "**/assets/**",
23+ "**/bower_components/**",
24+ "**/build/**",
25+ "**/cache/**",
26+ # "**/config/**" is a qlty default, removed here: internal/config is project
27+ # code (the YAML settings loader) and must be measured like the rest.
28+ "**/db/**",
29+ "**/deps/**",
30+ "**/dist/**",
31+ "**/extern/**",
32+ "**/external/**",
33+ "**/generated/**",
34+ "**/Godeps/**",
35+ "**/gradlew/**",
36+ "**/mvnw/**",
37+ "**/node_modules/**",
38+ "**/protos/**",
39+ "**/seed/**",
40+ "**/target/**",
41+ "**/templates/**",
42+ "**/testdata/**",
43+ "**/vendor/**",
44+ # Excluded at the user's request (2026-09-15):
45+ # kits/ is the vendored dev-toolkit kit — the sources installed into
46+ # ~/.claude/, including the quality skill's own quality_report.py, so leaving
47+ # it in scope would measure the instrument with itself (its subprocess use is
48+ # flagged by every Python security linter and cannot be fixed).
49+ "kits/**",
50+ # tmp/ holds a draft agent (tmp/draft-agent) and a cross-compile script
51+ # (tmp/build.sh) that are not part of the mm binary or its tests.
52+ "tmp/**",
53+]
54+
55+test_patterns = [
56+ "**/test/**",
57+ "**/spec/**",
58+ "**/*.test.*",
59+ "**/*.spec.*",
60+ "**/*_test.*",
61+ "**/*_spec.*",
62+ "**/test_*.*",
63+ "**/spec_*.*",
64+]
65+
66+[smells]
67+mode = "comment"
68+
69+[[source]]
70+name = "default"
71+default = true
72+
73+
74+[[plugin]]
75+name = "ripgrep"
76+mode = "comment"
77+
78+[[plugin]]
79+name = "shellcheck"
80+
81+[[plugin]]
82+name = "trufflehog"
83+
84+# `qlty init` only looks at files git tracks, and at init time every Go file of
85+# this project was still untracked, so it enabled none of the Go plugins. These
86+# are the ones it enables when it does see Go code.
87+[[plugin]]
88+name = "gofmt"
89+
90+[[plugin]]
91+name = "golangci-lint"
92+
93+[[plugin]]
94+name = "osv-scanner"
added .quality/history.jsonl +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+{"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 620, "cyclo": 994, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4718, "loc": 2954}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 1, "smells": 18, "timestamp": "2026-09-15T22:32:19Z"}
2+{"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 618, "cyclo": 992, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4719, "loc": 2956}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 2, "smells": 18, "timestamp": "2026-09-15T22:33:07Z"}
new file mode 100644
@@ -0,0 +1,2 @@
1+{"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 620, "cyclo": 994, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4718, "loc": 2954}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 1, "smells": 18, "timestamp": "2026-09-15T22:32:19Z"}
2+{"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 618, "cyclo": 992, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4719, "loc": 2956}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 2, "smells": 18, "timestamp": "2026-09-15T22:33:07Z"}
added .quality/report-20260915T223219Z.md +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+# Quality report — 2026-09-15T22:32:19Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `79fc998` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #1 (first recorded run)
7+
8+## Gate violations
9+
10+- warning-level issues: 69 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 69 | — |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/agent/agent.go | 1 |
49+| internal/engine/engine.go | 1 |
50+| internal/session/session_test.go | 1 |
51+
52+## Code smells (`qlty smells`)
53+
54+Total: **18** (vs previous: —)
55+
56+| smell | file | line | detail |
57+|---|---|---|---|
58+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
59+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
60+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
61+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
62+| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) |
63+| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 278 | Deeply nested control flow (level = 5) |
65+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 133) |
66+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 121): Run |
67+| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate |
68+| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact |
69+| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid |
70+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) |
71+| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid |
72+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
73+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
74+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
75+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
76+
77+## Metrics (`qlty metrics`)
78+
79+| metric | total | vs previous |
80+|---|---|---|
81+| funcs | 156 | — |
82+| classes | 38 | — |
83+| fields | 149 | — |
84+| cyclo | 994 | — |
85+| complex | 620 | — |
86+| lcom | 0 | — |
87+| lines | 4718 | — |
88+| loc | 2954 | — |
89+
90+### Most complex files
91+
92+| file | complex | cyclo | loc |
93+|---|---|---|---|
94+| internal/agent/agent.go | 133 | 99 | 268 |
95+| internal/engine/engine.go | 91 | 121 | 347 |
96+| internal/fileedit/diff.go | 89 | 177 | 271 |
97+| internal/compact/compact.go | 50 | 92 | 175 |
98+| internal/tools/files.go | 32 | 50 | 238 |
99+| internal/engine/openai_compat.go | 29 | 51 | 176 |
100+| internal/tools/tools.go | 28 | 47 | 191 |
101+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
102+| internal/skills/skills.go | 20 | 28 | 79 |
103+| internal/fileedit/textfile.go | 15 | 27 | 76 |
104+| main.go | 15 | 31 | 92 |
105+| internal/fileedit/edits.go | 14 | 38 | 88 |
106+| internal/engine/explain.go | 13 | 31 | 58 |
107+| internal/spinner/spinner.go | 13 | 27 | 102 |
108+| internal/acp/acp.go | 11 | 28 | 183 |
109+
110+## Trend
111+
112+| run | timestamp | error | warning | smells | complex | gate |
113+|---|---|---|---|---|---|---|
114+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
new file mode 100644
@@ -0,0 +1,114 @@
1+# Quality report — 2026-09-15T22:32:19Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `79fc998` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #1 (first recorded run)
7+
8+## Gate violations
9+
10+- warning-level issues: 69 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 69 | — |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/agent/agent.go | 1 |
49+| internal/engine/engine.go | 1 |
50+| internal/session/session_test.go | 1 |
51+
52+## Code smells (`qlty smells`)
53+
54+Total: **18** (vs previous: —)
55+
56+| smell | file | line | detail |
57+|---|---|---|---|
58+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
59+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
60+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
61+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
62+| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) |
63+| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 278 | Deeply nested control flow (level = 5) |
65+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 133) |
66+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 121): Run |
67+| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate |
68+| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact |
69+| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid |
70+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) |
71+| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid |
72+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
73+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
74+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
75+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
76+
77+## Metrics (`qlty metrics`)
78+
79+| metric | total | vs previous |
80+|---|---|---|
81+| funcs | 156 | — |
82+| classes | 38 | — |
83+| fields | 149 | — |
84+| cyclo | 994 | — |
85+| complex | 620 | — |
86+| lcom | 0 | — |
87+| lines | 4718 | — |
88+| loc | 2954 | — |
89+
90+### Most complex files
91+
92+| file | complex | cyclo | loc |
93+|---|---|---|---|
94+| internal/agent/agent.go | 133 | 99 | 268 |
95+| internal/engine/engine.go | 91 | 121 | 347 |
96+| internal/fileedit/diff.go | 89 | 177 | 271 |
97+| internal/compact/compact.go | 50 | 92 | 175 |
98+| internal/tools/files.go | 32 | 50 | 238 |
99+| internal/engine/openai_compat.go | 29 | 51 | 176 |
100+| internal/tools/tools.go | 28 | 47 | 191 |
101+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
102+| internal/skills/skills.go | 20 | 28 | 79 |
103+| internal/fileedit/textfile.go | 15 | 27 | 76 |
104+| main.go | 15 | 31 | 92 |
105+| internal/fileedit/edits.go | 14 | 38 | 88 |
106+| internal/engine/explain.go | 13 | 31 | 58 |
107+| internal/spinner/spinner.go | 13 | 27 | 102 |
108+| internal/acp/acp.go | 11 | 28 | 183 |
109+
110+## Trend
111+
112+| run | timestamp | error | warning | smells | complex | gate |
113+|---|---|---|---|---|---|---|
114+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
added .quality/report-20260915T223307Z.md +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+# Quality report — 2026-09-15T22:33:07Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `79fc998` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #2 (previous: 2026-09-15T22:32:19Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 67 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 67 | -2 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/engine/engine.go | 1 |
49+
50+## Code smells (`qlty smells`)
51+
52+Total: **18** (vs previous: ±0)
53+
54+| smell | file | line | detail |
55+|---|---|---|---|
56+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
60+| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) |
62+| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) |
63+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) |
64+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run |
65+| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate |
66+| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) |
69+| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+
75+## Metrics (`qlty metrics`)
76+
77+| metric | total | vs previous |
78+|---|---|---|
79+| funcs | 156 | ±0 |
80+| classes | 38 | ±0 |
81+| fields | 149 | ±0 |
82+| cyclo | 992 | -2 |
83+| complex | 618 | -2 |
84+| lcom | 0 | ±0 |
85+| lines | 4719 | +1 |
86+| loc | 2956 | +2 |
87+
88+### Most complex files
89+
90+| file | complex | cyclo | loc |
91+|---|---|---|---|
92+| internal/agent/agent.go | 131 | 97 | 270 |
93+| internal/engine/engine.go | 91 | 121 | 347 |
94+| internal/fileedit/diff.go | 89 | 177 | 271 |
95+| internal/compact/compact.go | 50 | 92 | 175 |
96+| internal/tools/files.go | 32 | 50 | 238 |
97+| internal/engine/openai_compat.go | 29 | 51 | 176 |
98+| internal/tools/tools.go | 28 | 47 | 191 |
99+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
100+| internal/skills/skills.go | 20 | 28 | 79 |
101+| internal/fileedit/textfile.go | 15 | 27 | 76 |
102+| main.go | 15 | 31 | 92 |
103+| internal/fileedit/edits.go | 14 | 38 | 88 |
104+| internal/engine/explain.go | 13 | 31 | 58 |
105+| internal/spinner/spinner.go | 13 | 27 | 102 |
106+| internal/acp/acp.go | 11 | 28 | 183 |
107+
108+## Trend
109+
110+| run | timestamp | error | warning | smells | complex | gate |
111+|---|---|---|---|---|---|---|
112+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
113+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
new file mode 100644
@@ -0,0 +1,113 @@
1+# Quality report — 2026-09-15T22:33:07Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `79fc998` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #2 (previous: 2026-09-15T22:32:19Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 67 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 67 | -2 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/engine/engine.go | 1 |
49+
50+## Code smells (`qlty smells`)
51+
52+Total: **18** (vs previous: ±0)
53+
54+| smell | file | line | detail |
55+|---|---|---|---|
56+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
60+| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) |
62+| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) |
63+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) |
64+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run |
65+| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate |
66+| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) |
69+| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+
75+## Metrics (`qlty metrics`)
76+
77+| metric | total | vs previous |
78+|---|---|---|
79+| funcs | 156 | ±0 |
80+| classes | 38 | ±0 |
81+| fields | 149 | ±0 |
82+| cyclo | 992 | -2 |
83+| complex | 618 | -2 |
84+| lcom | 0 | ±0 |
85+| lines | 4719 | +1 |
86+| loc | 2956 | +2 |
87+
88+### Most complex files
89+
90+| file | complex | cyclo | loc |
91+|---|---|---|---|
92+| internal/agent/agent.go | 131 | 97 | 270 |
93+| internal/engine/engine.go | 91 | 121 | 347 |
94+| internal/fileedit/diff.go | 89 | 177 | 271 |
95+| internal/compact/compact.go | 50 | 92 | 175 |
96+| internal/tools/files.go | 32 | 50 | 238 |
97+| internal/engine/openai_compat.go | 29 | 51 | 176 |
98+| internal/tools/tools.go | 28 | 47 | 191 |
99+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
100+| internal/skills/skills.go | 20 | 28 | 79 |
101+| internal/fileedit/textfile.go | 15 | 27 | 76 |
102+| main.go | 15 | 31 | 92 |
103+| internal/fileedit/edits.go | 14 | 38 | 88 |
104+| internal/engine/explain.go | 13 | 31 | 58 |
105+| internal/spinner/spinner.go | 13 | 27 | 102 |
106+| internal/acp/acp.go | 11 | 28 | 183 |
107+
108+## Trend
109+
110+| run | timestamp | error | warning | smells | complex | gate |
111+|---|---|---|---|---|---|---|
112+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
113+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
added .quality/report-latest.md +113 -0
new file mode 100644
@@ -0,0 +1,113 @@
1+# Quality report — 2026-09-15T22:33:07Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `79fc998` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #2 (previous: 2026-09-15T22:32:19Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 67 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 67 | -2 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/engine/engine.go | 1 |
49+
50+## Code smells (`qlty smells`)
51+
52+Total: **18** (vs previous: ±0)
53+
54+| smell | file | line | detail |
55+|---|---|---|---|
56+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
60+| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) |
62+| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) |
63+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) |
64+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run |
65+| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate |
66+| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) |
69+| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+
75+## Metrics (`qlty metrics`)
76+
77+| metric | total | vs previous |
78+|---|---|---|
79+| funcs | 156 | ±0 |
80+| classes | 38 | ±0 |
81+| fields | 149 | ±0 |
82+| cyclo | 992 | -2 |
83+| complex | 618 | -2 |
84+| lcom | 0 | ±0 |
85+| lines | 4719 | +1 |
86+| loc | 2956 | +2 |
87+
88+### Most complex files
89+
90+| file | complex | cyclo | loc |
91+|---|---|---|---|
92+| internal/agent/agent.go | 131 | 97 | 270 |
93+| internal/engine/engine.go | 91 | 121 | 347 |
94+| internal/fileedit/diff.go | 89 | 177 | 271 |
95+| internal/compact/compact.go | 50 | 92 | 175 |
96+| internal/tools/files.go | 32 | 50 | 238 |
97+| internal/engine/openai_compat.go | 29 | 51 | 176 |
98+| internal/tools/tools.go | 28 | 47 | 191 |
99+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
100+| internal/skills/skills.go | 20 | 28 | 79 |
101+| internal/fileedit/textfile.go | 15 | 27 | 76 |
102+| main.go | 15 | 31 | 92 |
103+| internal/fileedit/edits.go | 14 | 38 | 88 |
104+| internal/engine/explain.go | 13 | 31 | 58 |
105+| internal/spinner/spinner.go | 13 | 27 | 102 |
106+| internal/acp/acp.go | 11 | 28 | 183 |
107+
108+## Trend
109+
110+| run | timestamp | error | warning | smells | complex | gate |
111+|---|---|---|---|---|---|---|
112+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
113+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
new file mode 100644
@@ -0,0 +1,113 @@
1+# Quality report — 2026-09-15T22:33:07Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `79fc998` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #2 (previous: 2026-09-15T22:32:19Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 67 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 67 | -2 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/engine/engine.go | 1 |
49+
50+## Code smells (`qlty smells`)
51+
52+Total: **18** (vs previous: ±0)
53+
54+| smell | file | line | detail |
55+|---|---|---|---|
56+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
60+| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) |
62+| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) |
63+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) |
64+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run |
65+| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate |
66+| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) |
69+| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+
75+## Metrics (`qlty metrics`)
76+
77+| metric | total | vs previous |
78+|---|---|---|
79+| funcs | 156 | ±0 |
80+| classes | 38 | ±0 |
81+| fields | 149 | ±0 |
82+| cyclo | 992 | -2 |
83+| complex | 618 | -2 |
84+| lcom | 0 | ±0 |
85+| lines | 4719 | +1 |
86+| loc | 2956 | +2 |
87+
88+### Most complex files
89+
90+| file | complex | cyclo | loc |
91+|---|---|---|---|
92+| internal/agent/agent.go | 131 | 97 | 270 |
93+| internal/engine/engine.go | 91 | 121 | 347 |
94+| internal/fileedit/diff.go | 89 | 177 | 271 |
95+| internal/compact/compact.go | 50 | 92 | 175 |
96+| internal/tools/files.go | 32 | 50 | 238 |
97+| internal/engine/openai_compat.go | 29 | 51 | 176 |
98+| internal/tools/tools.go | 28 | 47 | 191 |
99+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
100+| internal/skills/skills.go | 20 | 28 | 79 |
101+| internal/fileedit/textfile.go | 15 | 27 | 76 |
102+| main.go | 15 | 31 | 92 |
103+| internal/fileedit/edits.go | 14 | 38 | 88 |
104+| internal/engine/explain.go | 13 | 31 | 58 |
105+| internal/spinner/spinner.go | 13 | 27 | 102 |
106+| internal/acp/acp.go | 11 | 28 | 183 |
107+
108+## Trend
109+
110+| run | timestamp | error | warning | smells | complex | gate |
111+|---|---|---|---|---|---|---|
112+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
113+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
added .tickets/issues/0001-add-a-help-command.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 1
3+title: Add a help command
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T22:22:54.491Z
9+updatedAt: 2026-09-15T22:22:54.491Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 1
3+title: Add a help command
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T22:22:54.491Z
9+updatedAt: 2026-09-15T22:22:54.491Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
added .tickets/issues/0002-add-a-about-command.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 2
3+title: Add a about command
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T22:23:07.065Z
9+updatedAt: 2026-09-15T22:23:07.065Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 2
3+title: Add a about command
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T22:23:07.065Z
9+updatedAt: 2026-09-15T22:23:07.065Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
added .tickets/issues/0003-improve-tui.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 3
3+title: Improve TUI
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T22:25:16.449Z
9+updatedAt: 2026-09-15T22:25:16.449Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 3
3+title: Improve TUI
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T22:25:16.449Z
9+updatedAt: 2026-09-15T22:25:16.449Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
added .tickets/issues/0004-do-we-need-mcp.yaml +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 4
3+title: Do we need MCP?
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T22:38:45.129Z
9+updatedAt: 2026-09-15T22:39:03.084Z
10+labels: []
11+body: |
12+ I would say no - use a CLI
13+tasks: []
14+comments: []
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 4
3+title: Do we need MCP?
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T22:38:45.129Z
9+updatedAt: 2026-09-15T22:39:03.084Z
10+labels: []
11+body: |
12+ I would say no - use a CLI
13+tasks: []
14+comments: []
added .tickets/issues/0005-follow-token-consumption.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 5
3+title: Follow token consumption
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:04:05.027Z
9+updatedAt: 2026-09-15T23:04:05.027Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 5
3+title: Follow token consumption
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:04:05.027Z
9+updatedAt: 2026-09-15T23:04:05.027Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
added .tickets/issues/0006-add-acp-command-for-token-consumption.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 6
3+title: Add ACP command for token consumption
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:04:31.606Z
9+updatedAt: 2026-09-15T23:04:31.606Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 6
3+title: Add ACP command for token consumption
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:04:31.606Z
9+updatedAt: 2026-09-15T23:04:31.606Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
added .tickets/issues/0007-skills-are-read-not-executed.yaml +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 7
3+title: Skills are read, not executed
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:32:00.224Z
9+updatedAt: 2026-09-15T23:32:11.894Z
10+labels:
11+ - priority::high
12+ - kind::bug
13+body: ""
14+tasks: []
15+comments: []
new file mode 100644
@@ -0,0 +1,15 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 7
3+title: Skills are read, not executed
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:32:00.224Z
9+updatedAt: 2026-09-15T23:32:11.894Z
10+labels:
11+ - priority::high
12+ - kind::bug
13+body: ""
14+tasks: []
15+comments: []
added .tickets/issues/0008-why-in-zed-i-cannot-relaoad-the-agent.yaml +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 8
3+title: Why in Zed I cannot relaoad the agent?
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:34:42.762Z
9+updatedAt: 2026-09-15T23:34:54.131Z
10+labels:
11+ - priority::medium
12+ - kind::bug
13+body: ""
14+tasks: []
15+comments: []
new file mode 100644
@@ -0,0 +1,15 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 8
3+title: Why in Zed I cannot relaoad the agent?
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:34:42.762Z
9+updatedAt: 2026-09-15T23:34:54.131Z
10+labels:
11+ - priority::medium
12+ - kind::bug
13+body: ""
14+tasks: []
15+comments: []
added .tickets/issues/0009-create-release-process.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 9
3+title: Create release process
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:51:39.464Z
9+updatedAt: 2026-09-15T23:51:39.464Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 9
3+title: Create release process
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-15T23:51:39.464Z
9+updatedAt: 2026-09-15T23:51:39.464Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
modified README.md +3 -0
@@ -1 +1,4 @@
11 # Mini Me
2+
3+## ACP with Mini Me and Zed
4+
@@ -1 +1,4 @@
1 # Mini Me1 # Mini Me
2+
3+## ACP with Mini Me and Zed
4+
added agent.dmr.yaml +208 -0
new file mode 100644
@@ -0,0 +1,208 @@
1+# Agent settings.
2+#
3+# Every key is optional: what you leave out keeps its built-in default
4+# (see internal/config/config.go). Nothing here requires recompiling —
5+# `go run .` picks the file up at startup.
6+#
7+# Use another file with: AGENT_CONFIG=./fast.yaml go run .
8+
9+# Which server is behind the URL. Left out, it means `dmr`: every agent.yaml
10+# written before this key existed keeps working unchanged. `llamacpp` speaks
11+# the same OpenAI-compatible protocol, but with its own port, its own way of
12+# refusing tool calls (no --jinja) and its own words in error messages — the
13+# provider is what turns those into one useful line. See agent.llamacpp.yaml.
14+# AGENT_PROVIDER overrides this key for one run.
15+provider: dmr
16+
17+# The chat model, in the provider's own naming (DMR: `ai/…`, `hf.co/…`). It
18+# MUST be able to emit tool_calls, otherwise the agent never runs a single
19+# command. AGENT_MODEL overrides it.
20+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
21+
22+# Endpoint. `baseUrl` is tried first; if it does not answer, `fallback` is used
23+# — handy from a container or a sandbox, where the host is reachable as
24+# host.docker.internal. Both are LEFT OUT here on purpose: each provider has its
25+# defaults, and an explicit value is always respected — so with DMR's URL
26+# written in this file, `AGENT_PROVIDER=llamacpp ./bob agent.yaml` still talked
27+# to port 12434. Leaving them out keeps the file provider-neutral. These are the
28+# values the `dmr` provider fills in:
29+# baseUrl: http://localhost:12434/engines/v1
30+# fallback: http://host.docker.internal:12434/engines/v1
31+# AGENT_BASE_URL — or, for dmr, the older DMR_BASE_URL — overrides both without
32+# probing. `fallback: ""` (an explicit empty string) disables the fallback.
33+
34+# The NAME of the environment variable holding the API key — never the key
35+# itself, so this file can be committed and shown on screen. DMR ignores the
36+# key, so nothing is needed here; llama-server checks one only when started
37+# with --api-key (default variable: LLAMA_API_KEY).
38+# apiKeyEnv: LLAMA_API_KEY
39+
40+# How many tokens the server actually serves. The model does not know this:
41+# it is what the operator started the server with (`llama-server -c 32768`,
42+# `docker model configure`). Shown at start-up next to its origin, because a
43+# number without an origin never gets corrected. 0 = ask the server
44+# (llama-server tells its n_ctx on /props), else "unknown".
45+contextWindow: 0
46+
47+# Max number of characters a tool returns to the model. Context safeguard:
48+# beyond that, the output is truncated (beginning + end kept).
49+maxOutput: 16000
50+
51+# Max number of model ↔ tools round trips for a single question. Raise it for
52+# tasks that need long chains of commands.
53+maxTurns: 40
54+
55+# The built-in file tools: read_file, write_file, edit_file. This is the switch
56+# this part exists for. `true`: the model edits files through tools it can SEE
57+# in its tool list. `false`: the agent is part 09 again — bash and read_skill —
58+# and edits files through the `edit` CLI if it is on the PATH. Same binary,
59+# same prompts, two set-ups: measured side by side, that is the comparison
60+# (in part 07, `read_skill` as a tool was loaded 3/3 times where a catalogue
61+# in the prompt plus `cat` managed 1/11). Paths are relative to the current
62+# directory and are not confined to it.
63+editTools: true
64+
65+# Where the `read_skill` tool looks for its markdown procedures. The path is
66+# relative to the CURRENT DIRECTORY, so run the agent from the directory that
67+# holds skills/ — not from src/. When the directory has no *.md file, the tool
68+# is not declared at all.
69+skillsDir: skills
70+
71+# How many lines of a command's output are echoed to the terminal. "Show me
72+# that file" means show it: running `cat` is not displaying it. 0 disables it.
73+previewLines: 20
74+
75+# The system prompt: what the agent is, and what it is allowed to do.
76+# This is the knob to play with — it is the shortest path to changing behaviour.
77+system: |
78+ Your name is Bob.
79+ You are a coding agent working in a terminal.
80+ You have a "bash" tool to run shell commands.
81+ Use it to explore files, run tests, inspect the repository, etc.
82+ Chain several commands if needed, then answer clearly in English.
83+
84+ A request often mixes things you answer from yourself ("say hello") with
85+ things only a command can answer ("list the files"). Handle every part, in
86+ the order asked, and run a command for each part that needs one.
87+ Never state the contents of a file, the output of a command, or the state of
88+ the repository unless a command in THIS answer returned it. What you did not
89+ read, you do not know: run the command instead of recalling it.
90+
91+ SKILLS
92+ You have a second tool, `read_skill`. Its description lists the procedures
93+ available for this project — one per kind of task.
94+
95+ Any request to DO something to a Go project is a skill, not a shell command
96+ you invent. Match the request against that list, call `read_skill` FIRST,
97+ before any bash command, and then follow what it says step by step.
98+
99+ FILE EDITING
100+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
101+ They are how a file gets read and changed here: each change is exact,
102+ checked before it is written, and comes back as a diff with line numbers.
103+ bash is for running things — building, testing, listing, searching.
104+
105+ - Read before you write: call `read_file` on the file (numbered=true when
106+ you need line numbers). You cannot target text you have not seen; never
107+ rely on what you think you remember about a file.
108+ - To change an existing file, call `edit_file` with one or more {old, new}
109+ pairs. `old` is copied from the file character for character — same
110+ spaces, same indentation, same line breaks — and appears exactly once:
111+ add the surrounding lines until it is unique. Several pairs are applied
112+ together, against the original file. An empty `new` deletes the text.
113+ - Call `write_file` only to create a file, or to rewrite one entirely and
114+ on purpose. On an existing file it replaces everything, including what
115+ you did not intend to touch.
116+ - Read the diff the tool returns: it says exactly what changed and on which
117+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
118+ edits — read the file again and fix `old`. Do not fall back to
119+ `write_file` to force the change through.
120+ - After editing code, run the narrowest check with bash: the formatter, the
121+ compiler, or the test covering that file.
122+
123+ RULES
124+ - Keep everything the file already does, unless the user asked to remove it.
125+ - Touch only the files the request is about. Do not add tests, files or
126+ features that were not asked for.
127+ - Never run a git command unless the user says git, commit or push.
128+ - Never move, rename or delete a file unless the user asked for it.
129+ - Then answer in English, in a few lines.
130+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
131+ to understand the options, then run the command.
132+
133+ BACKGROUND JOBS
134+ Never let a command block the answer. Anything that serves, watches or runs
135+ long goes to the background, with BOTH streams redirected and its pid kept:
136+
137+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
138+
139+ Redirecting only stdout still blocks until the process exits. Read the
140+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
141+ rule you cannot guess. Stop every job you started before you finish, and say
142+ which ones you left running.
143+
144+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
145+# we want precise and reproducible answers, not creativity.
146+sampling:
147+ temperature: 0.0
148+ # Une seule commande par tour. Sans ça, le modèle peut en demander
149+ # plusieurs d'un coup : Genkit les exécute EN PARALLÈLE, dans le même
150+ # répertoire, et on ne sait plus quelle sortie appartient à quelle
151+ # commande. Pour le détecteur de boucle, c'est pire : deux appels du même
152+ # tour s'enregistrent dans un ordre indéterminé, et « la même action deux
153+ # fois de suite » perd son sens. Mettre `true` pour retrouver le
154+ # comportement par défaut de l'API.
155+ parallel_tool_calls: false
156+ top_p: 0.9
157+ max_tokens: 4096
158+
159+watchdogTimeout: 30s
160+
161+# Context compression, carried over from part 08 (its CONTEXT_WINDOW.md has the
162+# reasoning). OFF by default: with `enabled: false` the agent behaves exactly
163+# as before. Why it exists: nothing in the agent ever shortens the history —
164+# measured with the fake engine of part 03, the messages sent to the model grew
165+# 2 → 5 → 7 → 9 over four requests, and every `bash` output (up to maxOutput
166+# characters) and every skill read stays until /quit. On a local model the
167+# window is fixed at load time and the prefill runs on the laptop, so a long
168+# session first shows up as a false "[watchdog: connection hang]", then as an
169+# error from the server.
170+#
171+# The window it measures against is the top-level `contextWindow` above — ONE
172+# key for the banner and for the trigger, so the two cannot disagree. When
173+# that key is 0, the value the provider's probe learned from the server is
174+# used (llama-server tells its n_ctx on /props; DMR tells nothing). When both
175+# are unknown, only `maxMessages` can trigger, and the agent says so at
176+# start-up.
177+context:
178+ enabled: false
179+
180+ # Compress when the history reaches this share of the window. 75 leaves a
181+ # quarter for the next question, the outputs of its turns and the answer —
182+ # a single `bash` output can be maxOutput characters, about 4-5k tokens.
183+ threshold: 75
184+
185+ # Fallback on the message count, for when the window is unknown or the
186+ # estimate is off. One command costs 2 messages (call + response), so 80 is
187+ # roughly 30 commands of history.
188+ maxMessages: 80
189+
190+ # Question turns kept raw at the end. The recent turns are where the model
191+ # works; summarising them makes it re-run what it just did. Three covers
192+ # "do X", "now fix it", "and test it" — the usual shape of a demo.
193+ keepLastTurns: 3
194+
195+ # max_tokens of the summary request. Seven sections of one line per item
196+ # fit in far less; the cap stops a runaway model from filling the window it
197+ # was asked to empty.
198+ summaryMaxTokens: 1200
199+
200+ # Replaces the built-in summary prompt (08-context-compression/CONTEXT_WINDOW.md
201+ # § 4.1) when set. Empty = built-in.
202+ prompt: ""
203+
204+ # One line after each compression: what was replaced, what was kept,
205+ # tokens before → after. Worth showing on a screen, noise in a log.
206+ showStats: true
207+
208+displayCommands: true
new file mode 100644
@@ -0,0 +1,208 @@
1+# Agent settings.
2+#
3+# Every key is optional: what you leave out keeps its built-in default
4+# (see internal/config/config.go). Nothing here requires recompiling —
5+# `go run .` picks the file up at startup.
6+#
7+# Use another file with: AGENT_CONFIG=./fast.yaml go run .
8+
9+# Which server is behind the URL. Left out, it means `dmr`: every agent.yaml
10+# written before this key existed keeps working unchanged. `llamacpp` speaks
11+# the same OpenAI-compatible protocol, but with its own port, its own way of
12+# refusing tool calls (no --jinja) and its own words in error messages — the
13+# provider is what turns those into one useful line. See agent.llamacpp.yaml.
14+# AGENT_PROVIDER overrides this key for one run.
15+provider: dmr
16+
17+# The chat model, in the provider's own naming (DMR: `ai/…`, `hf.co/…`). It
18+# MUST be able to emit tool_calls, otherwise the agent never runs a single
19+# command. AGENT_MODEL overrides it.
20+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
21+
22+# Endpoint. `baseUrl` is tried first; if it does not answer, `fallback` is used
23+# — handy from a container or a sandbox, where the host is reachable as
24+# host.docker.internal. Both are LEFT OUT here on purpose: each provider has its
25+# defaults, and an explicit value is always respected — so with DMR's URL
26+# written in this file, `AGENT_PROVIDER=llamacpp ./bob agent.yaml` still talked
27+# to port 12434. Leaving them out keeps the file provider-neutral. These are the
28+# values the `dmr` provider fills in:
29+# baseUrl: http://localhost:12434/engines/v1
30+# fallback: http://host.docker.internal:12434/engines/v1
31+# AGENT_BASE_URL — or, for dmr, the older DMR_BASE_URL — overrides both without
32+# probing. `fallback: ""` (an explicit empty string) disables the fallback.
33+
34+# The NAME of the environment variable holding the API key — never the key
35+# itself, so this file can be committed and shown on screen. DMR ignores the
36+# key, so nothing is needed here; llama-server checks one only when started
37+# with --api-key (default variable: LLAMA_API_KEY).
38+# apiKeyEnv: LLAMA_API_KEY
39+
40+# How many tokens the server actually serves. The model does not know this:
41+# it is what the operator started the server with (`llama-server -c 32768`,
42+# `docker model configure`). Shown at start-up next to its origin, because a
43+# number without an origin never gets corrected. 0 = ask the server
44+# (llama-server tells its n_ctx on /props), else "unknown".
45+contextWindow: 0
46+
47+# Max number of characters a tool returns to the model. Context safeguard:
48+# beyond that, the output is truncated (beginning + end kept).
49+maxOutput: 16000
50+
51+# Max number of model ↔ tools round trips for a single question. Raise it for
52+# tasks that need long chains of commands.
53+maxTurns: 40
54+
55+# The built-in file tools: read_file, write_file, edit_file. This is the switch
56+# this part exists for. `true`: the model edits files through tools it can SEE
57+# in its tool list. `false`: the agent is part 09 again — bash and read_skill —
58+# and edits files through the `edit` CLI if it is on the PATH. Same binary,
59+# same prompts, two set-ups: measured side by side, that is the comparison
60+# (in part 07, `read_skill` as a tool was loaded 3/3 times where a catalogue
61+# in the prompt plus `cat` managed 1/11). Paths are relative to the current
62+# directory and are not confined to it.
63+editTools: true
64+
65+# Where the `read_skill` tool looks for its markdown procedures. The path is
66+# relative to the CURRENT DIRECTORY, so run the agent from the directory that
67+# holds skills/ — not from src/. When the directory has no *.md file, the tool
68+# is not declared at all.
69+skillsDir: skills
70+
71+# How many lines of a command's output are echoed to the terminal. "Show me
72+# that file" means show it: running `cat` is not displaying it. 0 disables it.
73+previewLines: 20
74+
75+# The system prompt: what the agent is, and what it is allowed to do.
76+# This is the knob to play with — it is the shortest path to changing behaviour.
77+system: |
78+ Your name is Bob.
79+ You are a coding agent working in a terminal.
80+ You have a "bash" tool to run shell commands.
81+ Use it to explore files, run tests, inspect the repository, etc.
82+ Chain several commands if needed, then answer clearly in English.
83+
84+ A request often mixes things you answer from yourself ("say hello") with
85+ things only a command can answer ("list the files"). Handle every part, in
86+ the order asked, and run a command for each part that needs one.
87+ Never state the contents of a file, the output of a command, or the state of
88+ the repository unless a command in THIS answer returned it. What you did not
89+ read, you do not know: run the command instead of recalling it.
90+
91+ SKILLS
92+ You have a second tool, `read_skill`. Its description lists the procedures
93+ available for this project — one per kind of task.
94+
95+ Any request to DO something to a Go project is a skill, not a shell command
96+ you invent. Match the request against that list, call `read_skill` FIRST,
97+ before any bash command, and then follow what it says step by step.
98+
99+ FILE EDITING
100+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
101+ They are how a file gets read and changed here: each change is exact,
102+ checked before it is written, and comes back as a diff with line numbers.
103+ bash is for running things — building, testing, listing, searching.
104+
105+ - Read before you write: call `read_file` on the file (numbered=true when
106+ you need line numbers). You cannot target text you have not seen; never
107+ rely on what you think you remember about a file.
108+ - To change an existing file, call `edit_file` with one or more {old, new}
109+ pairs. `old` is copied from the file character for character — same
110+ spaces, same indentation, same line breaks — and appears exactly once:
111+ add the surrounding lines until it is unique. Several pairs are applied
112+ together, against the original file. An empty `new` deletes the text.
113+ - Call `write_file` only to create a file, or to rewrite one entirely and
114+ on purpose. On an existing file it replaces everything, including what
115+ you did not intend to touch.
116+ - Read the diff the tool returns: it says exactly what changed and on which
117+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
118+ edits — read the file again and fix `old`. Do not fall back to
119+ `write_file` to force the change through.
120+ - After editing code, run the narrowest check with bash: the formatter, the
121+ compiler, or the test covering that file.
122+
123+ RULES
124+ - Keep everything the file already does, unless the user asked to remove it.
125+ - Touch only the files the request is about. Do not add tests, files or
126+ features that were not asked for.
127+ - Never run a git command unless the user says git, commit or push.
128+ - Never move, rename or delete a file unless the user asked for it.
129+ - Then answer in English, in a few lines.
130+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
131+ to understand the options, then run the command.
132+
133+ BACKGROUND JOBS
134+ Never let a command block the answer. Anything that serves, watches or runs
135+ long goes to the background, with BOTH streams redirected and its pid kept:
136+
137+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
138+
139+ Redirecting only stdout still blocks until the process exits. Read the
140+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
141+ rule you cannot guess. Stop every job you started before you finish, and say
142+ which ones you left running.
143+
144+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
145+# we want precise and reproducible answers, not creativity.
146+sampling:
147+ temperature: 0.0
148+ # Une seule commande par tour. Sans ça, le modèle peut en demander
149+ # plusieurs d'un coup : Genkit les exécute EN PARALLÈLE, dans le même
150+ # répertoire, et on ne sait plus quelle sortie appartient à quelle
151+ # commande. Pour le détecteur de boucle, c'est pire : deux appels du même
152+ # tour s'enregistrent dans un ordre indéterminé, et « la même action deux
153+ # fois de suite » perd son sens. Mettre `true` pour retrouver le
154+ # comportement par défaut de l'API.
155+ parallel_tool_calls: false
156+ top_p: 0.9
157+ max_tokens: 4096
158+
159+watchdogTimeout: 30s
160+
161+# Context compression, carried over from part 08 (its CONTEXT_WINDOW.md has the
162+# reasoning). OFF by default: with `enabled: false` the agent behaves exactly
163+# as before. Why it exists: nothing in the agent ever shortens the history —
164+# measured with the fake engine of part 03, the messages sent to the model grew
165+# 2 → 5 → 7 → 9 over four requests, and every `bash` output (up to maxOutput
166+# characters) and every skill read stays until /quit. On a local model the
167+# window is fixed at load time and the prefill runs on the laptop, so a long
168+# session first shows up as a false "[watchdog: connection hang]", then as an
169+# error from the server.
170+#
171+# The window it measures against is the top-level `contextWindow` above — ONE
172+# key for the banner and for the trigger, so the two cannot disagree. When
173+# that key is 0, the value the provider's probe learned from the server is
174+# used (llama-server tells its n_ctx on /props; DMR tells nothing). When both
175+# are unknown, only `maxMessages` can trigger, and the agent says so at
176+# start-up.
177+context:
178+ enabled: false
179+
180+ # Compress when the history reaches this share of the window. 75 leaves a
181+ # quarter for the next question, the outputs of its turns and the answer —
182+ # a single `bash` output can be maxOutput characters, about 4-5k tokens.
183+ threshold: 75
184+
185+ # Fallback on the message count, for when the window is unknown or the
186+ # estimate is off. One command costs 2 messages (call + response), so 80 is
187+ # roughly 30 commands of history.
188+ maxMessages: 80
189+
190+ # Question turns kept raw at the end. The recent turns are where the model
191+ # works; summarising them makes it re-run what it just did. Three covers
192+ # "do X", "now fix it", "and test it" — the usual shape of a demo.
193+ keepLastTurns: 3
194+
195+ # max_tokens of the summary request. Seven sections of one line per item
196+ # fit in far less; the cap stops a runaway model from filling the window it
197+ # was asked to empty.
198+ summaryMaxTokens: 1200
199+
200+ # Replaces the built-in summary prompt (08-context-compression/CONTEXT_WINDOW.md
201+ # § 4.1) when set. Empty = built-in.
202+ prompt: ""
203+
204+ # One line after each compression: what was replaced, what was kept,
205+ # tokens before → after. Worth showing on a screen, noise in a log.
206+ showStats: true
207+
208+displayCommands: true
added agent.llamacpp.yaml +155 -0
new file mode 100644
@@ -0,0 +1,155 @@
1+# Same agent, served by llama.cpp's `llama-server` instead of Docker Model Runner.
2+#
3+# llama-server -hf poolside/Laguna-XS-2.1-GGUF:Q4_K_M \
4+# -a poolside/Laguna-XS-2.1-GGUF:Q4_K_M --jinja -c 32768 --port 8080
5+# ./bob agent.llamacpp.yaml # from demo/, so that skills/ is found
6+#
7+# `-hf <user>/<repo>:<quant>` downloads the GGUF from Hugging Face (the quant tag
8+# is optional and defaults to Q4_K_M; here the repo has exactly one such file,
9+# Laguna-XS-2.1-Q4_K_M.gguf, 20.3 GB). `-a` gives the served model the SAME name
10+# as the `model:` key below: llama-server routes requests on the `model` field,
11+# and without an alias it exposes the file's name on /v1/models, not the repo's.
12+# `--jinja` is not optional: without it llama-server refuses the `tools`
13+# parameter, and this agent is nothing but tool calls. The agent recognises that
14+# refusal and says so in one line instead of printing the server's stack.
15+# `-c` is the context the server SERVES (the model accepts up to 262,144); it is
16+# what shows up under `ctx:` at start-up, read from GET /props.
17+# Laguna XS 2.1 is a 33B MoE with 3B active parameters, built for coding; its
18+# card says the chat template does tool calling under --jinja, and that it
19+# needs a recent llama.cpp (the card points at PR #25165 — check it is merged
20+# in your build, or build that branch).
21+
22+provider: llamacpp
23+
24+# Must match what GET /v1/models returns — the `-a` alias above. llama-server
25+# routes on this field; a name it does not serve is an error, not a fallback.
26+#model: poolside/Laguna-XS-2.1-GGUF:Q4_K_M
27+#model: unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M
28+model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
29+
30+# llama-server's default is 127.0.0.1:8080; the OpenAI routes sit under /v1.
31+# Leave it out to get exactly this value.
32+baseUrl: http://127.0.0.1:8080/v1
33+
34+# No fallback here: llama-server is wherever you started it.
35+fallback: ""
36+
37+# Only when llama-server was started with --api-key; unset otherwise.
38+# apiKeyEnv: LLAMA_API_KEY
39+
40+# The built-in file tools: read_file, write_file, edit_file. This is the switch
41+# this part exists for. `true`: the model edits files through tools it can SEE
42+# in its tool list. `false`: the agent is part 09 again — bash and read_skill —
43+# and edits files through the `edit` CLI if it is on the PATH. Same binary,
44+# same prompts, two set-ups: measured side by side, that is the comparison
45+# (in part 07, `read_skill` as a tool was loaded 3/3 times where a catalogue
46+# in the prompt plus `cat` managed 1/11). Paths are relative to the current
47+# directory and are not confined to it.
48+editTools: true
49+
50+# 0 = read the served size from /props. Set it when the server hides /props
51+# (a reverse proxy) or when you know better.
52+contextWindow: 0
53+
54+maxOutput: 16000
55+maxTurns: 40
56+skillsDir: skills
57+previewLines: 20
58+displayCommands: true
59+
60+system: |
61+ Your name is Bob.
62+ You are a coding agent working in a terminal.
63+ You have a "bash" tool to run shell commands.
64+ Use it to explore files, run tests, inspect the repository, etc.
65+ Chain several commands if needed, then answer clearly in English.
66+
67+ A request often mixes things you answer from yourself ("say hello") with
68+ things only a command can answer ("list the files"). Handle every part, in
69+ the order asked, and run a command for each part that needs one.
70+ Never state the contents of a file, the output of a command, or the state of
71+ the repository unless a command in THIS answer returned it. What you did not
72+ read, you do not know: run the command instead of recalling it.
73+
74+ SKILLS
75+ You have a second tool, `read_skill`. Its description lists the procedures
76+ available for this project — one per kind of task.
77+
78+ Any request to DO something to a Go project is a skill, not a shell command
79+ you invent. Match the request against that list, call `read_skill` FIRST,
80+ before any bash command, and then follow what it says step by step.
81+
82+ FILE EDITING
83+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
84+ They are how a file gets read and changed here: each change is exact,
85+ checked before it is written, and comes back as a diff with line numbers.
86+ bash is for running things — building, testing, listing, searching.
87+
88+ - Read before you write: call `read_file` on the file (numbered=true when
89+ you need line numbers). You cannot target text you have not seen; never
90+ rely on what you think you remember about a file.
91+ - To change an existing file, call `edit_file` with one or more {old, new}
92+ pairs. `old` is copied from the file character for character — same
93+ spaces, same indentation, same line breaks — and appears exactly once:
94+ add the surrounding lines until it is unique. Several pairs are applied
95+ together, against the original file. An empty `new` deletes the text.
96+ - Call `write_file` only to create a file, or to rewrite one entirely and
97+ on purpose. On an existing file it replaces everything, including what
98+ you did not intend to touch.
99+ - Read the diff the tool returns: it says exactly what changed and on which
100+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
101+ edits — read the file again and fix `old`. Do not fall back to
102+ `write_file` to force the change through.
103+ - After editing code, run the narrowest check with bash: the formatter, the
104+ compiler, or the test covering that file.
105+
106+ RULES
107+ - Keep everything the file already does, unless the user asked to remove it.
108+ - Touch only the files the request is about. Do not add tests, files or
109+ features that were not asked for.
110+ - Never run a git command unless the user says git, commit or push.
111+ - Never move, rename or delete a file unless the user asked for it.
112+ - Then answer in English, in a few lines.
113+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
114+ to understand the options, then run the command.
115+
116+ BACKGROUND JOBS
117+ Never let a command block the answer. Anything that serves, watches or runs
118+ long goes to the background, with BOTH streams redirected and its pid kept:
119+
120+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
121+
122+ Redirecting only stdout still blocks until the process exits. Read the
123+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
124+ rule you cannot guess. Stop every job you started before you finish, and say
125+ which ones you left running.
126+
127+# Same sampling as the DMR file. llama-server honours `parallel_tool_calls`
128+# (off by default on its side too) and `max_tokens`.
129+sampling:
130+ temperature: 0.0
131+ parallel_tool_calls: false
132+ top_p: 0.9
133+ max_tokens: 4096
134+
135+# llama-server processes the whole prompt before the first token; on a laptop a
136+# 32k context can take a while. Same watchdog as DMR, raise it if it fires.
137+watchdogTimeout: 30s
138+
139+# Context compression (from part 08). ON here, because this is the one set-up
140+# where the agent knows the window without being told: `contextWindow: 0`
141+# above means "read n_ctx from /props", and that number is what `threshold`
142+# applies to — so the banner's `ctx: 32768 (/props)` and the trigger agree by
143+# construction. With a 32k window and a 33B MoE, the whole history is
144+# re-read at every turn; compressing at 75 % keeps the prefill — and the
145+# watchdog — inside the 30 s above. Set `enabled: false` to get the exact
146+# part-07 behaviour back; `/compact` still works.
147+context:
148+ enabled: true
149+ threshold: 75
150+ # Kept as a net for a reverse proxy that hides /props: then the window is
151+ # unknown and only this can trigger.
152+ maxMessages: 80
153+ keepLastTurns: 3
154+ summaryMaxTokens: 1200
155+ showStats: true
new file mode 100644
@@ -0,0 +1,155 @@
1+# Same agent, served by llama.cpp's `llama-server` instead of Docker Model Runner.
2+#
3+# llama-server -hf poolside/Laguna-XS-2.1-GGUF:Q4_K_M \
4+# -a poolside/Laguna-XS-2.1-GGUF:Q4_K_M --jinja -c 32768 --port 8080
5+# ./bob agent.llamacpp.yaml # from demo/, so that skills/ is found
6+#
7+# `-hf <user>/<repo>:<quant>` downloads the GGUF from Hugging Face (the quant tag
8+# is optional and defaults to Q4_K_M; here the repo has exactly one such file,
9+# Laguna-XS-2.1-Q4_K_M.gguf, 20.3 GB). `-a` gives the served model the SAME name
10+# as the `model:` key below: llama-server routes requests on the `model` field,
11+# and without an alias it exposes the file's name on /v1/models, not the repo's.
12+# `--jinja` is not optional: without it llama-server refuses the `tools`
13+# parameter, and this agent is nothing but tool calls. The agent recognises that
14+# refusal and says so in one line instead of printing the server's stack.
15+# `-c` is the context the server SERVES (the model accepts up to 262,144); it is
16+# what shows up under `ctx:` at start-up, read from GET /props.
17+# Laguna XS 2.1 is a 33B MoE with 3B active parameters, built for coding; its
18+# card says the chat template does tool calling under --jinja, and that it
19+# needs a recent llama.cpp (the card points at PR #25165 — check it is merged
20+# in your build, or build that branch).
21+
22+provider: llamacpp
23+
24+# Must match what GET /v1/models returns — the `-a` alias above. llama-server
25+# routes on this field; a name it does not serve is an error, not a fallback.
26+#model: poolside/Laguna-XS-2.1-GGUF:Q4_K_M
27+#model: unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M
28+model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
29+
30+# llama-server's default is 127.0.0.1:8080; the OpenAI routes sit under /v1.
31+# Leave it out to get exactly this value.
32+baseUrl: http://127.0.0.1:8080/v1
33+
34+# No fallback here: llama-server is wherever you started it.
35+fallback: ""
36+
37+# Only when llama-server was started with --api-key; unset otherwise.
38+# apiKeyEnv: LLAMA_API_KEY
39+
40+# The built-in file tools: read_file, write_file, edit_file. This is the switch
41+# this part exists for. `true`: the model edits files through tools it can SEE
42+# in its tool list. `false`: the agent is part 09 again — bash and read_skill —
43+# and edits files through the `edit` CLI if it is on the PATH. Same binary,
44+# same prompts, two set-ups: measured side by side, that is the comparison
45+# (in part 07, `read_skill` as a tool was loaded 3/3 times where a catalogue
46+# in the prompt plus `cat` managed 1/11). Paths are relative to the current
47+# directory and are not confined to it.
48+editTools: true
49+
50+# 0 = read the served size from /props. Set it when the server hides /props
51+# (a reverse proxy) or when you know better.
52+contextWindow: 0
53+
54+maxOutput: 16000
55+maxTurns: 40
56+skillsDir: skills
57+previewLines: 20
58+displayCommands: true
59+
60+system: |
61+ Your name is Bob.
62+ You are a coding agent working in a terminal.
63+ You have a "bash" tool to run shell commands.
64+ Use it to explore files, run tests, inspect the repository, etc.
65+ Chain several commands if needed, then answer clearly in English.
66+
67+ A request often mixes things you answer from yourself ("say hello") with
68+ things only a command can answer ("list the files"). Handle every part, in
69+ the order asked, and run a command for each part that needs one.
70+ Never state the contents of a file, the output of a command, or the state of
71+ the repository unless a command in THIS answer returned it. What you did not
72+ read, you do not know: run the command instead of recalling it.
73+
74+ SKILLS
75+ You have a second tool, `read_skill`. Its description lists the procedures
76+ available for this project — one per kind of task.
77+
78+ Any request to DO something to a Go project is a skill, not a shell command
79+ you invent. Match the request against that list, call `read_skill` FIRST,
80+ before any bash command, and then follow what it says step by step.
81+
82+ FILE EDITING
83+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
84+ They are how a file gets read and changed here: each change is exact,
85+ checked before it is written, and comes back as a diff with line numbers.
86+ bash is for running things — building, testing, listing, searching.
87+
88+ - Read before you write: call `read_file` on the file (numbered=true when
89+ you need line numbers). You cannot target text you have not seen; never
90+ rely on what you think you remember about a file.
91+ - To change an existing file, call `edit_file` with one or more {old, new}
92+ pairs. `old` is copied from the file character for character — same
93+ spaces, same indentation, same line breaks — and appears exactly once:
94+ add the surrounding lines until it is unique. Several pairs are applied
95+ together, against the original file. An empty `new` deletes the text.
96+ - Call `write_file` only to create a file, or to rewrite one entirely and
97+ on purpose. On an existing file it replaces everything, including what
98+ you did not intend to touch.
99+ - Read the diff the tool returns: it says exactly what changed and on which
100+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
101+ edits — read the file again and fix `old`. Do not fall back to
102+ `write_file` to force the change through.
103+ - After editing code, run the narrowest check with bash: the formatter, the
104+ compiler, or the test covering that file.
105+
106+ RULES
107+ - Keep everything the file already does, unless the user asked to remove it.
108+ - Touch only the files the request is about. Do not add tests, files or
109+ features that were not asked for.
110+ - Never run a git command unless the user says git, commit or push.
111+ - Never move, rename or delete a file unless the user asked for it.
112+ - Then answer in English, in a few lines.
113+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
114+ to understand the options, then run the command.
115+
116+ BACKGROUND JOBS
117+ Never let a command block the answer. Anything that serves, watches or runs
118+ long goes to the background, with BOTH streams redirected and its pid kept:
119+
120+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
121+
122+ Redirecting only stdout still blocks until the process exits. Read the
123+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
124+ rule you cannot guess. Stop every job you started before you finish, and say
125+ which ones you left running.
126+
127+# Same sampling as the DMR file. llama-server honours `parallel_tool_calls`
128+# (off by default on its side too) and `max_tokens`.
129+sampling:
130+ temperature: 0.0
131+ parallel_tool_calls: false
132+ top_p: 0.9
133+ max_tokens: 4096
134+
135+# llama-server processes the whole prompt before the first token; on a laptop a
136+# 32k context can take a while. Same watchdog as DMR, raise it if it fires.
137+watchdogTimeout: 30s
138+
139+# Context compression (from part 08). ON here, because this is the one set-up
140+# where the agent knows the window without being told: `contextWindow: 0`
141+# above means "read n_ctx from /props", and that number is what `threshold`
142+# applies to — so the banner's `ctx: 32768 (/props)` and the trigger agree by
143+# construction. With a 32k window and a 33B MoE, the whole history is
144+# re-read at every turn; compressing at 75 % keeps the prefill — and the
145+# watchdog — inside the 30 s above. Set `enabled: false` to get the exact
146+# part-07 behaviour back; `/compact` still works.
147+context:
148+ enabled: true
149+ threshold: 75
150+ # Kept as a net for a reverse proxy that hides /props: then the window is
151+ # unknown and only this can trigger.
152+ maxMessages: 80
153+ keepLastTurns: 3
154+ summaryMaxTokens: 1200
155+ showStats: true
added docs/README.md +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+# mini-me documentation
2+
3+Documentation of **mini-me** (`mm`), a minimal terminal coding agent written in Go that drives a local LLM through the OpenAI-compatible API.
4+
5+Choose a language:
6+
7+- [English](en/README.md)
8+- [Français](fr/README.md)
9+
10+Both trees follow the [Diátaxis](https://diataxis.fr) layout: `tutorials/` to learn, `how-to/` to get a task done, `reference/` to look something up, `explanation/` to understand.
new file mode 100644
@@ -0,0 +1,10 @@
1+# mini-me documentation
2+
3+Documentation of **mini-me** (`mm`), a minimal terminal coding agent written in Go that drives a local LLM through the OpenAI-compatible API.
4+
5+Choose a language:
6+
7+- [English](en/README.md)
8+- [Français](fr/README.md)
9+
10+Both trees follow the [Diátaxis](https://diataxis.fr) layout: `tutorials/` to learn, `how-to/` to get a task done, `reference/` to look something up, `explanation/` to understand.
added docs/diagrams/packages.drawio +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
new file mode 100644
@@ -0,0 +1 @@
1+<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
added docs/en/README.md +38 -0
new file mode 100644
@@ -0,0 +1,38 @@
1+# mini-me — documentation (English)
2+
3+`mm` is a coding agent that runs in your terminal, or inside an editor through the Agent Client Protocol. It talks to a local model server (Docker Model Runner or llama.cpp's `llama-server`) and works with a handful of built-in tools: `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
4+
5+This documentation is organised along the four [Diátaxis](https://diataxis.fr) quadrants. Pick the one that matches what you need right now.
6+
7+## Tutorials — learn by doing
8+
9+- [Your first session with mini-me](tutorials/getting-started.md)
10+
11+## How-to guides — get a task done
12+
13+- [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
14+- [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
15+- [Add a skill](how-to/add-a-skill.md)
16+- [Keep a long session inside the context window](how-to/manage-the-context-window.md)
17+- [Start a new session without restarting](how-to/start-a-new-session.md)
18+- [Point the model at a file with `@path`](how-to/attach-a-file.md)
19+
20+## Reference — look it up
21+
22+- [Command line](reference/cli.md) — flags, environment variables, start-up banner
23+- [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, keyboard shortcuts
24+- [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
25+- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`
26+- [Skill file format](reference/skill-format.md)
27+- [Agent Client Protocol surface](reference/acp.md)
28+
29+## Explanation — understand
30+
31+- [Architecture: one loop, two front ends](explanation/architecture.md)
32+- [Providers: one implementation per protocol](explanation/providers.md)
33+- [Context compression](explanation/context-compression.md)
34+- Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
35+
36+## Scope
37+
38+This documentation covers the `mm` binary and the packages under `internal/`. The `kits/` and `tmp/` directories at the repository root are not part of the project and are not documented here.
new file mode 100644
@@ -0,0 +1,38 @@
1+# mini-me — documentation (English)
2+
3+`mm` is a coding agent that runs in your terminal, or inside an editor through the Agent Client Protocol. It talks to a local model server (Docker Model Runner or llama.cpp's `llama-server`) and works with a handful of built-in tools: `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
4+
5+This documentation is organised along the four [Diátaxis](https://diataxis.fr) quadrants. Pick the one that matches what you need right now.
6+
7+## Tutorials — learn by doing
8+
9+- [Your first session with mini-me](tutorials/getting-started.md)
10+
11+## How-to guides — get a task done
12+
13+- [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
14+- [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
15+- [Add a skill](how-to/add-a-skill.md)
16+- [Keep a long session inside the context window](how-to/manage-the-context-window.md)
17+- [Start a new session without restarting](how-to/start-a-new-session.md)
18+- [Point the model at a file with `@path`](how-to/attach-a-file.md)
19+
20+## Reference — look it up
21+
22+- [Command line](reference/cli.md) — flags, environment variables, start-up banner
23+- [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, keyboard shortcuts
24+- [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
25+- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`
26+- [Skill file format](reference/skill-format.md)
27+- [Agent Client Protocol surface](reference/acp.md)
28+
29+## Explanation — understand
30+
31+- [Architecture: one loop, two front ends](explanation/architecture.md)
32+- [Providers: one implementation per protocol](explanation/providers.md)
33+- [Context compression](explanation/context-compression.md)
34+- Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
35+
36+## Scope
37+
38+This documentation covers the `mm` binary and the packages under `internal/`. The `kits/` and `tmp/` directories at the repository root are not part of the project and are not documented here.
added docs/en/explanation/architecture.md +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+# Architecture: one loop, two front ends — explanation
2+
3+## What is this about?
4+
5+`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal and from an editor without duplicating anything.
6+
7+The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of two front ends. Each concern lives in its own package under `internal/`:
8+
9+| Package | Responsibility |
10+|---------|----------------|
11+| `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. |
12+| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. |
13+| `tools` | The built-in tools the model can call. |
14+| `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
15+| `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
16+| `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |
17+| `detector` | Detection of the same action repeated with the same result. |
18+| `compact` | Compression of the history into a model-written summary. |
19+| `spinner` | The "still working" indicator on a terminal. |
20+| `ui` | Where human-facing output goes, and the event sink the two front ends share. |
21+| `agent` | The terminal REPL. |
22+| `acp` | The Agent Client Protocol façade. |
23+
24+A draw.io diagram of these packages and their dependencies is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).
25+
26+## Why it is designed this way
27+
28+**Tools report failures as text, never as errors.** A command that exits non-zero, a file that does not exist, an edit that is ambiguous: all of it goes back to the model as the tool's output. The model is the one that must react, so it is the one that must read the message. A Go error would end the turn instead.
29+
30+**The full history is kept even when a turn fails.** Genkit only returns the last message; the engine returns the whole conversation including tool calls and results. The front ends keep that history after an abort, a watchdog cut or a `maxTurns` overrun, because the commands that already ran are facts the model must not re-run or invent on the next question. Only a question that got no answer at all is dropped.
31+
32+**Output is shown before the model sees it.** In the terminal, `bash` echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing.
33+
34+**One loop, two front ends.** The terminal REPL and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests.
35+
36+**Permission lives in the sink, not in the tools.** A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session.
37+
38+**Turns are serialised in ACP mode.** The active sink is process-global and the engine was never built for concurrent generations, so a second `session/prompt` waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway.
39+
40+**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new.
41+
42+## Rejected alternatives
43+
44+- **One binary per front end.** Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in `main.go` is what makes that visible.
45+- **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout.
46+- **Returning Go errors from tools.** Rejected because it ends the turn instead of letting the model recover.
47+
48+## How it relates to the rest
49+
50+- The provider registry that the engine uses is described in [providers](providers.md).
51+- Why and how the history is shortened is in [context compression](context-compression.md).
52+- The exact protocol surface is in the [ACP reference](../reference/acp.md); the tools' contract is in the [tools reference](../reference/tools.md).
53+
54+## Package map
55+
56+The import graph of every package, with a one-line purpose on each, is kept as a draw.io diagram at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (opens in diagrams.net or the VS Code Draw.io extension). It is regenerated from `go list` whenever a package is added, removed or re-wired.
new file mode 100644
@@ -0,0 +1,56 @@
1+# Architecture: one loop, two front ends — explanation
2+
3+## What is this about?
4+
5+`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal and from an editor without duplicating anything.
6+
7+The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of two front ends. Each concern lives in its own package under `internal/`:
8+
9+| Package | Responsibility |
10+|---------|----------------|
11+| `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. |
12+| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. |
13+| `tools` | The built-in tools the model can call. |
14+| `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
15+| `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
16+| `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |
17+| `detector` | Detection of the same action repeated with the same result. |
18+| `compact` | Compression of the history into a model-written summary. |
19+| `spinner` | The "still working" indicator on a terminal. |
20+| `ui` | Where human-facing output goes, and the event sink the two front ends share. |
21+| `agent` | The terminal REPL. |
22+| `acp` | The Agent Client Protocol façade. |
23+
24+A draw.io diagram of these packages and their dependencies is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).
25+
26+## Why it is designed this way
27+
28+**Tools report failures as text, never as errors.** A command that exits non-zero, a file that does not exist, an edit that is ambiguous: all of it goes back to the model as the tool's output. The model is the one that must react, so it is the one that must read the message. A Go error would end the turn instead.
29+
30+**The full history is kept even when a turn fails.** Genkit only returns the last message; the engine returns the whole conversation including tool calls and results. The front ends keep that history after an abort, a watchdog cut or a `maxTurns` overrun, because the commands that already ran are facts the model must not re-run or invent on the next question. Only a question that got no answer at all is dropped.
31+
32+**Output is shown before the model sees it.** In the terminal, `bash` echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing.
33+
34+**One loop, two front ends.** The terminal REPL and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests.
35+
36+**Permission lives in the sink, not in the tools.** A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session.
37+
38+**Turns are serialised in ACP mode.** The active sink is process-global and the engine was never built for concurrent generations, so a second `session/prompt` waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway.
39+
40+**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new.
41+
42+## Rejected alternatives
43+
44+- **One binary per front end.** Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in `main.go` is what makes that visible.
45+- **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout.
46+- **Returning Go errors from tools.** Rejected because it ends the turn instead of letting the model recover.
47+
48+## How it relates to the rest
49+
50+- The provider registry that the engine uses is described in [providers](providers.md).
51+- Why and how the history is shortened is in [context compression](context-compression.md).
52+- The exact protocol surface is in the [ACP reference](../reference/acp.md); the tools' contract is in the [tools reference](../reference/tools.md).
53+
54+## Package map
55+
56+The import graph of every package, with a one-line purpose on each, is kept as a draw.io diagram at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (opens in diagrams.net or the VS Code Draw.io extension). It is regenerated from `go list` whenever a package is added, removed or re-wired.
added docs/en/explanation/context-compression.md +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+# Context compression — explanation
2+
3+## What is this about?
4+
5+Nothing in an agent loop ever shortens the history. Measured against a fake engine, the messages sent to the model grew 2 → 5 → 7 → 9 over four requests, and every `bash` output, up to `maxOutput` characters, and every loaded skill stay until `/quit`. On a local model the context window is fixed when the server loads, and the whole history is re-read at every turn on the laptop's hardware. A long session therefore first shows up as a false watchdog stall, then as an error from the server.
6+
7+The `compact` package replaces the old question turns by one summary written by the model itself, and keeps the last few turns raw.
8+
9+## Why it is designed this way
10+
11+**A hybrid: summary plus raw recent turns.** The recent turns are where the model is working; summarising them makes it re-run what it just did. The default keeps three question turns, enough for "do X", "now fix it", "and test it". Everything older becomes a pair of messages inserted right after the system prompt: a user message carrying the notes, and a short model acknowledgement.
12+
13+**The cut falls on a turn boundary.** A tool call and its result must never be split; reusing whole turns guarantees it.
14+
15+**Two triggers, because the window is not always known.** The primary trigger is a share of the context window, 75 % by default, leaving a quarter for the next question, its tool outputs and the answer. The server's own input-token count from the last call is preferred to the local estimate when it is larger, because it sees the tools' JSON and the chat template. When the window is unknown, Docker Model Runner tells nothing and `contextWindow` may be `0`, only the message-count trigger can fire, and the agent says so at start-up.
16+
17+**One key for the window.** The banner and the trigger read the same `contextWindow`; two keys for the same number would drift apart.
18+
19+**Earlier summaries are merged, not re-summarised.** The two inserted messages carry a marker; a later compression recognises them and folds the new old turns into the existing summary, because summarising a summary loses detail exponentially.
20+
21+**A fixed-shape summary.** The built-in prompt asks for seven sections with exact paths, command lines and error messages, and forbids quoting file contents: a file may have changed since it was read, and the system prompt already forbids stating what a command in this answer did not return. The summary request replaces the agent's own system prompt with a note-taker's, because "you are a coding agent, run commands" sent with a history full of tool calls invites the model to keep acting.
22+
23+**Failure costs nothing.** If the summary request fails, server down, watchdog, empty result, the history is left untouched and the next question goes out as before. The compression exists to keep the next question possible; it must never cost one.
24+
25+**The watchdog is twice as patient here.** The summary is the longest prefill of the session and nothing streams while it is written.
26+
27+**`/compact` skips the threshold, not the boundary.** It still keeps the last turns and says so when nothing is older. Compression only ever runs between two generations, because Genkit holds its own copy of the conversation while a turn runs.
28+
29+## Rejected alternatives
30+
31+- **Truncating the oldest messages.** Rejected: it would drop tool results whose calls remain, and lose the goal of the session.
32+- **Summarising everything, recent turns included.** Rejected: the model then redoes its last steps.
33+- **A separate `context.contextWindow` key.** Rejected in favour of the single top-level key.
34+
35+## How it relates to the rest
36+
37+- Turning it on and reading the report: [how to keep a long session inside the context window](../how-to/manage-the-context-window.md).
38+- The `context.*` keys: [configuration reference](../reference/configuration.md).
39+- Where the window comes from: [providers](providers.md).
new file mode 100644
@@ -0,0 +1,39 @@
1+# Context compression — explanation
2+
3+## What is this about?
4+
5+Nothing in an agent loop ever shortens the history. Measured against a fake engine, the messages sent to the model grew 2 → 5 → 7 → 9 over four requests, and every `bash` output, up to `maxOutput` characters, and every loaded skill stay until `/quit`. On a local model the context window is fixed when the server loads, and the whole history is re-read at every turn on the laptop's hardware. A long session therefore first shows up as a false watchdog stall, then as an error from the server.
6+
7+The `compact` package replaces the old question turns by one summary written by the model itself, and keeps the last few turns raw.
8+
9+## Why it is designed this way
10+
11+**A hybrid: summary plus raw recent turns.** The recent turns are where the model is working; summarising them makes it re-run what it just did. The default keeps three question turns, enough for "do X", "now fix it", "and test it". Everything older becomes a pair of messages inserted right after the system prompt: a user message carrying the notes, and a short model acknowledgement.
12+
13+**The cut falls on a turn boundary.** A tool call and its result must never be split; reusing whole turns guarantees it.
14+
15+**Two triggers, because the window is not always known.** The primary trigger is a share of the context window, 75 % by default, leaving a quarter for the next question, its tool outputs and the answer. The server's own input-token count from the last call is preferred to the local estimate when it is larger, because it sees the tools' JSON and the chat template. When the window is unknown, Docker Model Runner tells nothing and `contextWindow` may be `0`, only the message-count trigger can fire, and the agent says so at start-up.
16+
17+**One key for the window.** The banner and the trigger read the same `contextWindow`; two keys for the same number would drift apart.
18+
19+**Earlier summaries are merged, not re-summarised.** The two inserted messages carry a marker; a later compression recognises them and folds the new old turns into the existing summary, because summarising a summary loses detail exponentially.
20+
21+**A fixed-shape summary.** The built-in prompt asks for seven sections with exact paths, command lines and error messages, and forbids quoting file contents: a file may have changed since it was read, and the system prompt already forbids stating what a command in this answer did not return. The summary request replaces the agent's own system prompt with a note-taker's, because "you are a coding agent, run commands" sent with a history full of tool calls invites the model to keep acting.
22+
23+**Failure costs nothing.** If the summary request fails, server down, watchdog, empty result, the history is left untouched and the next question goes out as before. The compression exists to keep the next question possible; it must never cost one.
24+
25+**The watchdog is twice as patient here.** The summary is the longest prefill of the session and nothing streams while it is written.
26+
27+**`/compact` skips the threshold, not the boundary.** It still keeps the last turns and says so when nothing is older. Compression only ever runs between two generations, because Genkit holds its own copy of the conversation while a turn runs.
28+
29+## Rejected alternatives
30+
31+- **Truncating the oldest messages.** Rejected: it would drop tool results whose calls remain, and lose the goal of the session.
32+- **Summarising everything, recent turns included.** Rejected: the model then redoes its last steps.
33+- **A separate `context.contextWindow` key.** Rejected in favour of the single top-level key.
34+
35+## How it relates to the rest
36+
37+- Turning it on and reading the report: [how to keep a long session inside the context window](../how-to/manage-the-context-window.md).
38+- The `context.*` keys: [configuration reference](../reference/configuration.md).
39+- Where the window comes from: [providers](providers.md).
added docs/en/explanation/providers.md +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+# Providers: one implementation per protocol — explanation
2+
3+## What is this about?
4+
5+`mm` talks to a local model server through the OpenAI chat-completions API. Two servers are supported out of the box, Docker Model Runner and llama.cpp's `llama-server`, under the names `dmr` and `llamacpp`. The engine package used to be tied to Docker Model Runner; the provider abstraction is what generalised it.
6+
7+## Why it is designed this way
8+
9+**One provider type per wire protocol, not per vendor.** When the engine was split out, exactly one function and two string constants were specific to Docker Model Runner. Everything else, the streaming, the watchdog, the retry, the command counter, behaved the same against a fake engine that is not DMR either. So `dmr` and `llamacpp` are two data entries of a single `openaiCompat` type: a default base URL, an optional fallback URL, a key policy, and the words used in error messages. Adding another OpenAI-compatible server is a registry entry; adding a different protocol would be a new type.
10+
11+**A provider owns four things.** It resolves the configuration into a concrete backend (URL after environment overrides and fallback probe, key read from the environment, model name); it opens Genkit with its plugin; it probes the server before the first question; and it explains a transport error in one line.
12+
13+**The fallback exists for containers.** On the host, Docker Model Runner listens on `localhost:12434`; from inside a container or a sandbox the same server is reachable as `host.docker.internal`. If the primary URL does not answer `/models` within two seconds, the fallback is used. An explicit environment override skips the probe: whoever set it knows where the server is. An explicit empty `fallback: ""` disables it, which is what a test configuration needs to stay pinned to one URL.
14+
15+**The key never goes in the YAML.** The file names the environment variable that holds the key, so it can be committed and shown on screen. Servers that ignore the header get a placeholder, because the OpenAI client wants something there. A 401 is then explained by naming the variable the provider actually read, or, when none is configured, by pointing at the URL.
16+
17+**The probe is advice, never a failure.** On a demo machine the server is often started after the agent. The probe checks reachability, warns when the configured model is not in DMR's list, and on llama.cpp reads the served context size from `/props`, one level above `/v1`, because that endpoint reports what the server serves, not what the model was trained with. When the window is still unknown and compression is on, the REPL probes again before the first question.
18+
19+**Errors are rewritten in the server's own words.** A stack of wrapped messages is noise; "tool calls need llama-server started with --jinja" is the whole diagnosis. The provider recognises connection refusals, the missing `--jinja` flag, 401/403, 404 with a pull or alias hint, 429 and 5xx. Unknown errors come back unchanged: a raw message beats a wrong hint.
20+
21+## Rejected alternatives
22+
23+- **A provider per vendor with duplicated engine code.** Rejected: measured, the vendor-specific part was three identifiers.
24+- **Putting the API key in the configuration file.** Rejected because the file is meant to be committed and shown.
25+- **Failing at start-up when the server is down.** Rejected because starting the server second is the normal demo situation.
26+
27+## How it relates to the rest
28+
29+- The two configuration files that parametrise each provider are listed in the [configuration reference](../reference/configuration.md).
30+- The window the probe learns is what [context compression](context-compression.md) measures against.
31+- Switching servers for one run is covered in [how to run on llama.cpp](../how-to/run-with-llama-cpp.md).
new file mode 100644
@@ -0,0 +1,31 @@
1+# Providers: one implementation per protocol — explanation
2+
3+## What is this about?
4+
5+`mm` talks to a local model server through the OpenAI chat-completions API. Two servers are supported out of the box, Docker Model Runner and llama.cpp's `llama-server`, under the names `dmr` and `llamacpp`. The engine package used to be tied to Docker Model Runner; the provider abstraction is what generalised it.
6+
7+## Why it is designed this way
8+
9+**One provider type per wire protocol, not per vendor.** When the engine was split out, exactly one function and two string constants were specific to Docker Model Runner. Everything else, the streaming, the watchdog, the retry, the command counter, behaved the same against a fake engine that is not DMR either. So `dmr` and `llamacpp` are two data entries of a single `openaiCompat` type: a default base URL, an optional fallback URL, a key policy, and the words used in error messages. Adding another OpenAI-compatible server is a registry entry; adding a different protocol would be a new type.
10+
11+**A provider owns four things.** It resolves the configuration into a concrete backend (URL after environment overrides and fallback probe, key read from the environment, model name); it opens Genkit with its plugin; it probes the server before the first question; and it explains a transport error in one line.
12+
13+**The fallback exists for containers.** On the host, Docker Model Runner listens on `localhost:12434`; from inside a container or a sandbox the same server is reachable as `host.docker.internal`. If the primary URL does not answer `/models` within two seconds, the fallback is used. An explicit environment override skips the probe: whoever set it knows where the server is. An explicit empty `fallback: ""` disables it, which is what a test configuration needs to stay pinned to one URL.
14+
15+**The key never goes in the YAML.** The file names the environment variable that holds the key, so it can be committed and shown on screen. Servers that ignore the header get a placeholder, because the OpenAI client wants something there. A 401 is then explained by naming the variable the provider actually read, or, when none is configured, by pointing at the URL.
16+
17+**The probe is advice, never a failure.** On a demo machine the server is often started after the agent. The probe checks reachability, warns when the configured model is not in DMR's list, and on llama.cpp reads the served context size from `/props`, one level above `/v1`, because that endpoint reports what the server serves, not what the model was trained with. When the window is still unknown and compression is on, the REPL probes again before the first question.
18+
19+**Errors are rewritten in the server's own words.** A stack of wrapped messages is noise; "tool calls need llama-server started with --jinja" is the whole diagnosis. The provider recognises connection refusals, the missing `--jinja` flag, 401/403, 404 with a pull or alias hint, 429 and 5xx. Unknown errors come back unchanged: a raw message beats a wrong hint.
20+
21+## Rejected alternatives
22+
23+- **A provider per vendor with duplicated engine code.** Rejected: measured, the vendor-specific part was three identifiers.
24+- **Putting the API key in the configuration file.** Rejected because the file is meant to be committed and shown.
25+- **Failing at start-up when the server is down.** Rejected because starting the server second is the normal demo situation.
26+
27+## How it relates to the rest
28+
29+- The two configuration files that parametrise each provider are listed in the [configuration reference](../reference/configuration.md).
30+- The window the probe learns is what [context compression](context-compression.md) measures against.
31+- Switching servers for one run is covered in [how to run on llama.cpp](../how-to/run-with-llama-cpp.md).
added docs/en/how-to/add-a-skill.md +36 -0
new file mode 100644
@@ -0,0 +1,36 @@
1+# How to add a skill
2+
3+This guide shows how to give the agent a new markdown procedure that the model can load with `read_skill`. It assumes you know what task the procedure should cover.
4+
5+## Steps
6+
7+1. Create the skill in the skills directory (`skills/` by default, see `skillsDir`), in either layout: a flat file `skills/<name>.md`, or a directory per skill `skills/<name>/SKILL.md` (the layout of the shipped examples). The two can be mixed; any other file in a skill's directory is ignored.
8+
9+ ```bash
10+ cat > skills/go-test.md <<'EOF2'
11+ ---
12+ name: go-test
13+ description: run the Go tests of the current module and report failures
14+ ---
15+ # Run the Go tests
16+
17+ Run `go test ./...` with your `bash` tool, then report the failing packages, if any.
18+ EOF2
19+ ```
20+
21+2. Give it a `name:` and a one-line `description:` in the front matter. The description is what the model reads in the `read_skill` tool description to pick a skill; without one it chooses blindly.
22+
23+3. Restart `mm`. The catalogue is built once at start-up. The banner's `skills: N` counter goes up by one, and `read_skill` appears in the `tools:` list if it was absent.
24+
25+4. Ask for the task in the terminal. The system prompt in `agent.yaml` tells the model to call `read_skill` first for any request that matches the catalogue; a `📖 read_skill: go-test` line confirms it did.
26+
27+## Variants
28+
29+- **Another directory.** Set `skillsDir` in the YAML. In the terminal a relative path is resolved from the directory you start `mm` in, so an installed `mm` run inside a project loads that project's skills; in ACP mode with a config file it is resolved next to that file. The banner prints `[warning: no skills found in <absolute path> …]` when the resolved directory yields nothing.
30+- **No skills at all.** Leave the directory empty or absent: `read_skill` is then not declared, and the model never sees it.
31+- **The shipped examples.** The repository ships `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` and `skills/what-time-is-it/SKILL.md`. Start `mm` from the repository root and the banner shows `skills: 3`.
32+
33+## See also
34+
35+- The fields the front matter accepts: [skill file format](../reference/skill-format.md)
36+- What `read_skill` returns to the model: [built-in tools](../reference/tools.md)
new file mode 100644
@@ -0,0 +1,36 @@
1+# How to add a skill
2+
3+This guide shows how to give the agent a new markdown procedure that the model can load with `read_skill`. It assumes you know what task the procedure should cover.
4+
5+## Steps
6+
7+1. Create the skill in the skills directory (`skills/` by default, see `skillsDir`), in either layout: a flat file `skills/<name>.md`, or a directory per skill `skills/<name>/SKILL.md` (the layout of the shipped examples). The two can be mixed; any other file in a skill's directory is ignored.
8+
9+ ```bash
10+ cat > skills/go-test.md <<'EOF2'
11+ ---
12+ name: go-test
13+ description: run the Go tests of the current module and report failures
14+ ---
15+ # Run the Go tests
16+
17+ Run `go test ./...` with your `bash` tool, then report the failing packages, if any.
18+ EOF2
19+ ```
20+
21+2. Give it a `name:` and a one-line `description:` in the front matter. The description is what the model reads in the `read_skill` tool description to pick a skill; without one it chooses blindly.
22+
23+3. Restart `mm`. The catalogue is built once at start-up. The banner's `skills: N` counter goes up by one, and `read_skill` appears in the `tools:` list if it was absent.
24+
25+4. Ask for the task in the terminal. The system prompt in `agent.yaml` tells the model to call `read_skill` first for any request that matches the catalogue; a `📖 read_skill: go-test` line confirms it did.
26+
27+## Variants
28+
29+- **Another directory.** Set `skillsDir` in the YAML. In the terminal a relative path is resolved from the directory you start `mm` in, so an installed `mm` run inside a project loads that project's skills; in ACP mode with a config file it is resolved next to that file. The banner prints `[warning: no skills found in <absolute path> …]` when the resolved directory yields nothing.
30+- **No skills at all.** Leave the directory empty or absent: `read_skill` is then not declared, and the model never sees it.
31+- **The shipped examples.** The repository ships `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` and `skills/what-time-is-it/SKILL.md`. Start `mm` from the repository root and the banner shows `skills: 3`.
32+
33+## See also
34+
35+- The fields the front matter accepts: [skill file format](../reference/skill-format.md)
36+- What `read_skill` returns to the model: [built-in tools](../reference/tools.md)
added docs/en/how-to/attach-a-file.md +44 -0
new file mode 100644
@@ -0,0 +1,44 @@
1+# Point the model at a file with `@path`
2+
3+Name a file or a directory in your question with `@` followed by its path. mini-me checks that the path exists, tells the model where it is, and the model reads it with its tools. There is no picker or completion: you type the path.
4+
5+## Steps
6+
7+1. Start `mm` from the project directory. Relative paths are resolved from there.
8+2. Write the path in the question, glued to `@`:
9+
10+ ```
11+ > explain what @internal/acp/acp.go does with @internal/session
12+ 📎 /work/mini-me/internal/acp/acp.go
13+ 📎 /work/mini-me/internal/session
14+ ```
15+
16+ One `📎` line per attached path confirms the notation was understood. The model receives your sentence as typed, followed by one line per path:
17+
18+ ```
19+ [attached file: /work/mini-me/internal/acp/acp.go]
20+ [attached directory: /work/mini-me/internal/session]
21+ ```
22+
23+3. Ask away. The content is not copied into the prompt: the model runs `cat`, `read_file` or `ls` on the path when it needs to.
24+
25+## Rules
26+
27+| Case | Result |
28+|------|--------|
29+| `@internal/acp/acp.go` | Relative to the directory `mm` was started in (the session's `cwd` in ACP mode). |
30+| `@/abs/path`, `@~/file` | Absolute paths and `~` work. |
31+| `@main.go,` `(@main.go)` `"@main.go"` | Sentence punctuation and brackets around the mention are ignored. |
32+| `@bob`, `@does/not/exist` | Nothing on disk: left in the sentence as plain text, no attachment. |
33+| `k33g@example.com` | A `@` glued to a word is not a mention. |
34+| Same path twice | Attached once. |
35+| Path with spaces | Not supported: a mention ends at the first space. |
36+
37+## In an editor (ACP)
38+
39+The editor's own `@` picker sends the file as a `resource_link`, which mini-me turns into the same `[attached file: …]` line. A path typed by hand, without the picker, is understood the same way, relative to the project the editor opened. Attached paths are logged on stderr as `[acp] session <id>: attached <path>`.
40+
41+## See also
42+
43+- [Built-in tools](../reference/tools.md) — what the model reads the file with
44+- [Use mini-me from an editor](use-from-an-editor.md)
new file mode 100644
@@ -0,0 +1,44 @@
1+# Point the model at a file with `@path`
2+
3+Name a file or a directory in your question with `@` followed by its path. mini-me checks that the path exists, tells the model where it is, and the model reads it with its tools. There is no picker or completion: you type the path.
4+
5+## Steps
6+
7+1. Start `mm` from the project directory. Relative paths are resolved from there.
8+2. Write the path in the question, glued to `@`:
9+
10+ ```
11+ > explain what @internal/acp/acp.go does with @internal/session
12+ 📎 /work/mini-me/internal/acp/acp.go
13+ 📎 /work/mini-me/internal/session
14+ ```
15+
16+ One `📎` line per attached path confirms the notation was understood. The model receives your sentence as typed, followed by one line per path:
17+
18+ ```
19+ [attached file: /work/mini-me/internal/acp/acp.go]
20+ [attached directory: /work/mini-me/internal/session]
21+ ```
22+
23+3. Ask away. The content is not copied into the prompt: the model runs `cat`, `read_file` or `ls` on the path when it needs to.
24+
25+## Rules
26+
27+| Case | Result |
28+|------|--------|
29+| `@internal/acp/acp.go` | Relative to the directory `mm` was started in (the session's `cwd` in ACP mode). |
30+| `@/abs/path`, `@~/file` | Absolute paths and `~` work. |
31+| `@main.go,` `(@main.go)` `"@main.go"` | Sentence punctuation and brackets around the mention are ignored. |
32+| `@bob`, `@does/not/exist` | Nothing on disk: left in the sentence as plain text, no attachment. |
33+| `k33g@example.com` | A `@` glued to a word is not a mention. |
34+| Same path twice | Attached once. |
35+| Path with spaces | Not supported: a mention ends at the first space. |
36+
37+## In an editor (ACP)
38+
39+The editor's own `@` picker sends the file as a `resource_link`, which mini-me turns into the same `[attached file: …]` line. A path typed by hand, without the picker, is understood the same way, relative to the project the editor opened. Attached paths are logged on stderr as `[acp] session <id>: attached <path>`.
40+
41+## See also
42+
43+- [Built-in tools](../reference/tools.md) — what the model reads the file with
44+- [Use mini-me from an editor](use-from-an-editor.md)
added docs/en/how-to/manage-the-context-window.md +37 -0
new file mode 100644
@@ -0,0 +1,37 @@
1+# How to keep a long session inside the context window
2+
3+This guide shows how to stop a long session from overflowing the model's context. It assumes you already run `mm` and know which server serves your model.
4+
5+## Steps
6+
7+1. Tell the agent the size of the window. Either set `contextWindow` in the YAML to the value you started the server with, or leave it at `0` on llama.cpp, where the agent reads `n_ctx` from `/props`. The banner shows the result as `ctx: <size> (config)` or `ctx: <size> (/props)`.
8+
9+2. Turn the automatic compression on:
10+
11+ ```yaml
12+ context:
13+ enabled: true
14+ threshold: 75
15+ keepLastTurns: 3
16+ ```
17+
18+ Before each question, when the history reaches 75 % of the window, the old turns are replaced by one model-written summary and the last three question turns are kept as they are.
19+
20+3. Watch the report. With `showStats: true` (the default) each compression prints one dimmed line:
21+
22+ ```
23+ 🗜️ compressed 14 messages → 1 summary + 7 kept (18.2k → 4.1k tokens, 6.3s)
24+ ```
25+
26+## Variants
27+
28+- **The window stays unknown** (Docker Model Runner tells nothing, and `contextWindow` is `0`). Only the `maxMessages` trigger can fire; the agent says so in a start-up warning. Set `maxMessages` to a message count (one command costs two messages) or set `contextWindow`.
29+- **Compress by hand.** Type `/compact` at the prompt, or send it from the editor in ACP mode. It ignores the threshold but still keeps the last `keepLastTurns` turns, and says `🗜️ nothing to compact` when there is nothing older.
30+- **The server was started after the agent.** The window is re-probed before the first question while it is still unknown and compression is on; you do not need to restart.
31+- **Your own summary prompt.** Set `context.prompt` to replace the built-in seven-section prompt.
32+- **A compression that fails** (server down, watchdog) leaves the history unchanged and prints `[compact: failed, history kept: …]`.
33+
34+## See also
35+
36+- Every `context.*` key and its default: [configuration reference](../reference/configuration.md)
37+- Why the summary keeps recent turns raw and merges earlier summaries: [context compression](../explanation/context-compression.md)
new file mode 100644
@@ -0,0 +1,37 @@
1+# How to keep a long session inside the context window
2+
3+This guide shows how to stop a long session from overflowing the model's context. It assumes you already run `mm` and know which server serves your model.
4+
5+## Steps
6+
7+1. Tell the agent the size of the window. Either set `contextWindow` in the YAML to the value you started the server with, or leave it at `0` on llama.cpp, where the agent reads `n_ctx` from `/props`. The banner shows the result as `ctx: <size> (config)` or `ctx: <size> (/props)`.
8+
9+2. Turn the automatic compression on:
10+
11+ ```yaml
12+ context:
13+ enabled: true
14+ threshold: 75
15+ keepLastTurns: 3
16+ ```
17+
18+ Before each question, when the history reaches 75 % of the window, the old turns are replaced by one model-written summary and the last three question turns are kept as they are.
19+
20+3. Watch the report. With `showStats: true` (the default) each compression prints one dimmed line:
21+
22+ ```
23+ 🗜️ compressed 14 messages → 1 summary + 7 kept (18.2k → 4.1k tokens, 6.3s)
24+ ```
25+
26+## Variants
27+
28+- **The window stays unknown** (Docker Model Runner tells nothing, and `contextWindow` is `0`). Only the `maxMessages` trigger can fire; the agent says so in a start-up warning. Set `maxMessages` to a message count (one command costs two messages) or set `contextWindow`.
29+- **Compress by hand.** Type `/compact` at the prompt, or send it from the editor in ACP mode. It ignores the threshold but still keeps the last `keepLastTurns` turns, and says `🗜️ nothing to compact` when there is nothing older.
30+- **The server was started after the agent.** The window is re-probed before the first question while it is still unknown and compression is on; you do not need to restart.
31+- **Your own summary prompt.** Set `context.prompt` to replace the built-in seven-section prompt.
32+- **A compression that fails** (server down, watchdog) leaves the history unchanged and prints `[compact: failed, history kept: …]`.
33+
34+## See also
35+
36+- Every `context.*` key and its default: [configuration reference](../reference/configuration.md)
37+- Why the summary keeps recent turns raw and merges earlier summaries: [context compression](../explanation/context-compression.md)
added docs/en/how-to/run-with-llama-cpp.md +33 -0
new file mode 100644
@@ -0,0 +1,33 @@
1+# How to run mini-me on llama.cpp instead of Docker Model Runner
2+
3+This guide shows how to point `mm` at a `llama-server` you start yourself. It assumes you already know how to build `mm` and how to obtain a GGUF model.
4+
5+## Steps
6+
7+1. Start `llama-server` with a chat template and tool calling enabled. `--jinja` is mandatory: without it the server refuses the `tools` parameter and the agent cannot run a single command. Give the served model an alias with `-a`, because `llama-server` routes requests on the `model` field:
8+
9+ ```bash
10+ llama-server -hf jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \
11+ -a jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \
12+ --jinja -c 32768 --port 8080
13+ ```
14+
15+2. Use the shipped llama.cpp configuration file. Its `model:` key must match the `-a` alias above:
16+
17+ ```bash
18+ ./mm agent.llamacpp.yaml
19+ ```
20+
21+3. Check the banner. `provider: llamacpp` confirms the provider, and `ctx: 32768 (/props)` confirms the agent read the served context size from the server's `/props` endpoint.
22+
23+## Variants
24+
25+- **Different port or host.** Set `baseUrl` in the YAML to `http://<host>:<port>/v1`, or override it for one run with `AGENT_BASE_URL=http://<host>:<port>/v1 ./mm agent.llamacpp.yaml`. An environment override is taken as-is, without the fallback probe.
26+- **Server started with `--api-key`.** Export the key in `LLAMA_API_KEY` (the provider's default variable) or name another variable under `apiKeyEnv`. The key itself never goes in the YAML.
27+- **Keep `agent.yaml` and switch provider for one run.** `AGENT_PROVIDER=llamacpp ./mm` works as long as `agent.yaml` leaves `baseUrl` empty; an explicit DMR URL in the file would still be honoured and point at port 12434.
28+- **The server hides `/props`** (a reverse proxy, for instance). Set `contextWindow` to the value you started the server with, otherwise the banner shows `ctx: unknown`.
29+
30+## See also
31+
32+- Every key of the file: [configuration reference](../reference/configuration.md)
33+- Why one provider implementation serves both servers: [providers](../explanation/providers.md)
new file mode 100644
@@ -0,0 +1,33 @@
1+# How to run mini-me on llama.cpp instead of Docker Model Runner
2+
3+This guide shows how to point `mm` at a `llama-server` you start yourself. It assumes you already know how to build `mm` and how to obtain a GGUF model.
4+
5+## Steps
6+
7+1. Start `llama-server` with a chat template and tool calling enabled. `--jinja` is mandatory: without it the server refuses the `tools` parameter and the agent cannot run a single command. Give the served model an alias with `-a`, because `llama-server` routes requests on the `model` field:
8+
9+ ```bash
10+ llama-server -hf jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \
11+ -a jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \
12+ --jinja -c 32768 --port 8080
13+ ```
14+
15+2. Use the shipped llama.cpp configuration file. Its `model:` key must match the `-a` alias above:
16+
17+ ```bash
18+ ./mm agent.llamacpp.yaml
19+ ```
20+
21+3. Check the banner. `provider: llamacpp` confirms the provider, and `ctx: 32768 (/props)` confirms the agent read the served context size from the server's `/props` endpoint.
22+
23+## Variants
24+
25+- **Different port or host.** Set `baseUrl` in the YAML to `http://<host>:<port>/v1`, or override it for one run with `AGENT_BASE_URL=http://<host>:<port>/v1 ./mm agent.llamacpp.yaml`. An environment override is taken as-is, without the fallback probe.
26+- **Server started with `--api-key`.** Export the key in `LLAMA_API_KEY` (the provider's default variable) or name another variable under `apiKeyEnv`. The key itself never goes in the YAML.
27+- **Keep `agent.yaml` and switch provider for one run.** `AGENT_PROVIDER=llamacpp ./mm` works as long as `agent.yaml` leaves `baseUrl` empty; an explicit DMR URL in the file would still be honoured and point at port 12434.
28+- **The server hides `/props`** (a reverse proxy, for instance). Set `contextWindow` to the value you started the server with, otherwise the banner shows `ctx: unknown`.
29+
30+## See also
31+
32+- Every key of the file: [configuration reference](../reference/configuration.md)
33+- Why one provider implementation serves both servers: [providers](../explanation/providers.md)
added docs/en/how-to/start-a-new-session.md +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+# How to start a new session without restarting `mm`
2+
3+This guide shows how to wipe the conversation and begin again from a clean history, in the terminal and from an editor. It assumes `mm` is running and has already answered at least one question.
4+
5+## In the terminal
6+
7+1. Wait for the prompt (`> `). The command only acts between two generations; typed while the model is working, it first cancels that generation, then applies.
8+
9+2. Type the command:
10+
11+ ```text
12+ > /new
13+ 🆕 new session: 12 message(s) forgotten
14+ ```
15+
16+ The count is every message except the system prompt: your questions, the model's answers and the tool turns in between. On an already-empty session it reads `0 message(s) forgotten`.
17+
18+3. Ask your next question. The model only sees the system prompt and that question; nothing from before `/new` is sent to it.
19+
20+## From an editor (ACP)
21+
22+1. Type `/new` in the agent panel. Editors that list the agent's commands offer it in the slash-command menu with the description *Clear the history and start a new session*; typing it by hand works as well.
23+
24+2. Read the confirmation in the thread: `🆕 New session: 12 message(s) forgotten.` The turn ends there, the model is not consulted.
25+
26+3. Keep working in the same thread. The session keeps its id, its working directory and the tools you already allowed "for this session"; only the history is gone.
27+
28+## What `/new` resets, and what it keeps
29+
30+| Reset | Kept |
31+|-------|------|
32+| The conversation history (back to the system prompt alone). | The configuration, the model, the tools. |
33+| The loop detector's memory of repeated commands (terminal). | The working directory. |
34+| The server's token count of the last context, used to decide automatic compression. | In ACP mode, the session id and the "allow always" permission grants. |
35+
36+## Related
37+
38+- [Slash commands reference](../reference/slash-commands.md) — every command, at the prompt and during a generation.
39+- [ACP reference](../reference/acp.md) — how the command is advertised and handled over the protocol.
40+- [Keep a long session inside the context window](manage-the-context-window.md) — `/compact` shortens the history without forgetting it; `/new` forgets it.
new file mode 100644
@@ -0,0 +1,40 @@
1+# How to start a new session without restarting `mm`
2+
3+This guide shows how to wipe the conversation and begin again from a clean history, in the terminal and from an editor. It assumes `mm` is running and has already answered at least one question.
4+
5+## In the terminal
6+
7+1. Wait for the prompt (`> `). The command only acts between two generations; typed while the model is working, it first cancels that generation, then applies.
8+
9+2. Type the command:
10+
11+ ```text
12+ > /new
13+ 🆕 new session: 12 message(s) forgotten
14+ ```
15+
16+ The count is every message except the system prompt: your questions, the model's answers and the tool turns in between. On an already-empty session it reads `0 message(s) forgotten`.
17+
18+3. Ask your next question. The model only sees the system prompt and that question; nothing from before `/new` is sent to it.
19+
20+## From an editor (ACP)
21+
22+1. Type `/new` in the agent panel. Editors that list the agent's commands offer it in the slash-command menu with the description *Clear the history and start a new session*; typing it by hand works as well.
23+
24+2. Read the confirmation in the thread: `🆕 New session: 12 message(s) forgotten.` The turn ends there, the model is not consulted.
25+
26+3. Keep working in the same thread. The session keeps its id, its working directory and the tools you already allowed "for this session"; only the history is gone.
27+
28+## What `/new` resets, and what it keeps
29+
30+| Reset | Kept |
31+|-------|------|
32+| The conversation history (back to the system prompt alone). | The configuration, the model, the tools. |
33+| The loop detector's memory of repeated commands (terminal). | The working directory. |
34+| The server's token count of the last context, used to decide automatic compression. | In ACP mode, the session id and the "allow always" permission grants. |
35+
36+## Related
37+
38+- [Slash commands reference](../reference/slash-commands.md) — every command, at the prompt and during a generation.
39+- [ACP reference](../reference/acp.md) — how the command is advertised and handled over the protocol.
40+- [Keep a long session inside the context window](manage-the-context-window.md) — `/compact` shortens the history without forgetting it; `/new` forgets it.
added docs/en/how-to/use-from-an-editor.md +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+# How to use mini-me from an editor (ACP)
2+
3+This guide shows how to host `mm` inside an editor that speaks the Agent Client Protocol, such as Zed. It assumes `mm` is built and installed on your `PATH` (`./install.sh` copies it to `/usr/local/bin`), and that your model server is running.
4+
5+## Steps
6+
7+1. Register `mm` as an agent server in the editor, with the `-acp` flag. In Zed, add to `settings.json`:
8+
9+ ```json
10+ {
11+ "agent_servers": {
12+ "mini-me": {
13+ "type": "custom",
14+ "command": "mm",
15+ "args": ["-acp"],
16+ "env": { "AGENT_CONFIG": "/absolute/path/to/agent.yaml" }
17+ }
18+ }
19+ }
20+ ```
21+
22+2. Point `AGENT_CONFIG` at an absolute path. The editor launches `mm` with the project's directory as working directory, so a relative `./agent.yaml` would not be found. In ACP mode a relative `skillsDir` in that file is resolved next to the file, not next to the editor's working directory.
23+
24+3. Open the agent panel and start a conversation. Each `bash`, `read_file`, `write_file` and `edit_file` call appears as a tool call; the editor asks you to allow it before it runs. `read_skill` runs without a dialog.
25+
26+4. Read the logs when something looks wrong. In ACP mode nothing human-readable goes to stdout: the banner, the warnings and a `[acp] …` trail go to stderr, which Zed shows under `dev: open acp logs`.
27+
28+## Variants
29+
30+- **Allow a tool for the whole session.** Pick "Allow bash for this session" in the permission dialog; the choice is remembered per tool until the session ends.
31+- **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP.
32+- **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode).
33+- **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools.
34+- **Run from a terminal to debug the protocol.** `mm -acp` reads JSON-RPC on stdin and writes it on stdout, one message per line.
35+
36+## See also
37+
38+- The exact methods and capabilities exposed: [ACP reference](../reference/acp.md)
39+- Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md)
new file mode 100644
@@ -0,0 +1,39 @@
1+# How to use mini-me from an editor (ACP)
2+
3+This guide shows how to host `mm` inside an editor that speaks the Agent Client Protocol, such as Zed. It assumes `mm` is built and installed on your `PATH` (`./install.sh` copies it to `/usr/local/bin`), and that your model server is running.
4+
5+## Steps
6+
7+1. Register `mm` as an agent server in the editor, with the `-acp` flag. In Zed, add to `settings.json`:
8+
9+ ```json
10+ {
11+ "agent_servers": {
12+ "mini-me": {
13+ "type": "custom",
14+ "command": "mm",
15+ "args": ["-acp"],
16+ "env": { "AGENT_CONFIG": "/absolute/path/to/agent.yaml" }
17+ }
18+ }
19+ }
20+ ```
21+
22+2. Point `AGENT_CONFIG` at an absolute path. The editor launches `mm` with the project's directory as working directory, so a relative `./agent.yaml` would not be found. In ACP mode a relative `skillsDir` in that file is resolved next to the file, not next to the editor's working directory.
23+
24+3. Open the agent panel and start a conversation. Each `bash`, `read_file`, `write_file` and `edit_file` call appears as a tool call; the editor asks you to allow it before it runs. `read_skill` runs without a dialog.
25+
26+4. Read the logs when something looks wrong. In ACP mode nothing human-readable goes to stdout: the banner, the warnings and a `[acp] …` trail go to stderr, which Zed shows under `dev: open acp logs`.
27+
28+## Variants
29+
30+- **Allow a tool for the whole session.** Pick "Allow bash for this session" in the permission dialog; the choice is remembered per tool until the session ends.
31+- **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP.
32+- **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode).
33+- **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools.
34+- **Run from a terminal to debug the protocol.** `mm -acp` reads JSON-RPC on stdin and writes it on stdout, one message per line.
35+
36+## See also
37+
38+- The exact methods and capabilities exposed: [ACP reference](../reference/acp.md)
39+- Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md)
added docs/en/reference/acp.md +92 -0
new file mode 100644
@@ -0,0 +1,92 @@
1+# Reference: Agent Client Protocol surface
2+
3+> Neutral description of what `mm -acp` exposes over JSON-RPC 2.0 on stdio, through `github.com/coder/acp-go-sdk` v0.13.5.
4+
5+## Transport
6+
7+| Aspect | Value |
8+|--------|-------|
9+| Input | stdin, one JSON-RPC message per line. |
10+| Output | stdout, JSON-RPC messages only. |
11+| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger. |
12+| Lifetime | Until the client closes stdin or the process context is cancelled. |
13+
14+## `initialize` response
15+
16+| Field | Value |
17+|-------|-------|
18+| `protocolVersion` | `1` |
19+| `agentCapabilities` | All absent (false): no `loadSession`, no image or audio prompts, no HTTP MCP. |
20+| `agentInfo.name` | `bob` |
21+| `agentInfo.title` | `Bob (bash-first agent)` |
22+| `agentInfo.version` | `0.11.0` |
23+| `authMethods` | `[]` |
24+
25+## Methods
26+
27+| Method | Behaviour |
28+|--------|-----------|
29+| `initialize` | Logs the client name and protocol version on stderr; returns the values above. |
30+| `authenticate` | Returns an empty response. |
31+| `session/new` | Creates a session `bob-<pid>-<n>` with the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). |
32+| `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
33+| `session/cancel` | Cancels the turn in flight for that session. |
34+| `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
35+
36+## `session/prompt`
37+
38+| Aspect | Behaviour |
39+|--------|-----------|
40+| Unknown `sessionId` | `invalid params` error carrying the `sessionId`. |
41+| Prompt content | `text` blocks concatenated; a `resource_link` becomes `\n[attached file: <path without file://>]`; other block types are ignored. Then every `@path` in the text that names an existing file or directory under the session's `cwd` (or an absolute / `~` path) appends `\n[attached file: <absolute path>]` or `\n[attached directory: …]`, once per path, and is logged as `[acp] session <id>: attached <path>`. |
42+| Slash command | When the flattened text, trimmed, is exactly `/new` or `/compact`, no generation runs; the command answers with one `agent_message_chunk` and `stopReason: end_turn`. |
43+| `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
44+| `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |
45+| History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |
46+| Success | `stopReason: end_turn`. |
47+| Cancelled | `stopReason: cancelled`. |
48+| Failure | `internal error` with `error: <one-line explanation>`. |
49+| Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. |
50+
51+## `session/update` notifications sent
52+
53+| Update | When |
54+|--------|------|
55+| `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. |
56+| `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. |
57+| `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
58+| `tool_call_update` status `in_progress` | Permission granted, tool executing. |
59+| `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |
60+
61+## Available commands
62+
63+| `name` | `description` | `input` |
64+|--------|---------------|---------|
65+| `new` | `Clear the history and start a new session` | none |
66+| `compact` | `Compress the history now, keeping the last turns` | none |
67+
68+Clients display the name with a leading slash and send it back as the text of a `session/prompt`.
69+
70+## `session/request_permission` requests sent
71+
72+Sent before `bash`, `read_file`, `write_file` and `edit_file` run; never for `read_skill`.
73+
74+| Option id | Kind | Effect |
75+|-----------|------|--------|
76+| `allow` | `allow_once` | Runs the tool. |
77+| `allow_always` | `allow_always` | Runs the tool and skips the dialog for that tool name for the rest of the session. |
78+| `reject` | `reject_once` | Does not run; the model receives `[command rejected by the user]`. |
79+
80+A transport error or a `cancelled` outcome counts as a rejection.
81+
82+## Tool kinds
83+
84+| `mm` kind | ACP `ToolKind` | Tools |
85+|-----------|----------------|-------|
86+| `execute` | `execute` | `bash` (and any unknown kind) |
87+| `read` | `read` | `read_skill`, `read_file` |
88+| `edit` | `edit` | `write_file`, `edit_file` |
89+
90+## Tool call identifiers
91+
92+`call_1`, `call_2`, … numbered per process and never reset.
new file mode 100644
@@ -0,0 +1,92 @@
1+# Reference: Agent Client Protocol surface
2+
3+> Neutral description of what `mm -acp` exposes over JSON-RPC 2.0 on stdio, through `github.com/coder/acp-go-sdk` v0.13.5.
4+
5+## Transport
6+
7+| Aspect | Value |
8+|--------|-------|
9+| Input | stdin, one JSON-RPC message per line. |
10+| Output | stdout, JSON-RPC messages only. |
11+| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger. |
12+| Lifetime | Until the client closes stdin or the process context is cancelled. |
13+
14+## `initialize` response
15+
16+| Field | Value |
17+|-------|-------|
18+| `protocolVersion` | `1` |
19+| `agentCapabilities` | All absent (false): no `loadSession`, no image or audio prompts, no HTTP MCP. |
20+| `agentInfo.name` | `bob` |
21+| `agentInfo.title` | `Bob (bash-first agent)` |
22+| `agentInfo.version` | `0.11.0` |
23+| `authMethods` | `[]` |
24+
25+## Methods
26+
27+| Method | Behaviour |
28+|--------|-----------|
29+| `initialize` | Logs the client name and protocol version on stderr; returns the values above. |
30+| `authenticate` | Returns an empty response. |
31+| `session/new` | Creates a session `bob-<pid>-<n>` with the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). |
32+| `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
33+| `session/cancel` | Cancels the turn in flight for that session. |
34+| `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
35+
36+## `session/prompt`
37+
38+| Aspect | Behaviour |
39+|--------|-----------|
40+| Unknown `sessionId` | `invalid params` error carrying the `sessionId`. |
41+| Prompt content | `text` blocks concatenated; a `resource_link` becomes `\n[attached file: <path without file://>]`; other block types are ignored. Then every `@path` in the text that names an existing file or directory under the session's `cwd` (or an absolute / `~` path) appends `\n[attached file: <absolute path>]` or `\n[attached directory: …]`, once per path, and is logged as `[acp] session <id>: attached <path>`. |
42+| Slash command | When the flattened text, trimmed, is exactly `/new` or `/compact`, no generation runs; the command answers with one `agent_message_chunk` and `stopReason: end_turn`. |
43+| `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
44+| `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |
45+| History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |
46+| Success | `stopReason: end_turn`. |
47+| Cancelled | `stopReason: cancelled`. |
48+| Failure | `internal error` with `error: <one-line explanation>`. |
49+| Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. |
50+
51+## `session/update` notifications sent
52+
53+| Update | When |
54+|--------|------|
55+| `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. |
56+| `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. |
57+| `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
58+| `tool_call_update` status `in_progress` | Permission granted, tool executing. |
59+| `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |
60+
61+## Available commands
62+
63+| `name` | `description` | `input` |
64+|--------|---------------|---------|
65+| `new` | `Clear the history and start a new session` | none |
66+| `compact` | `Compress the history now, keeping the last turns` | none |
67+
68+Clients display the name with a leading slash and send it back as the text of a `session/prompt`.
69+
70+## `session/request_permission` requests sent
71+
72+Sent before `bash`, `read_file`, `write_file` and `edit_file` run; never for `read_skill`.
73+
74+| Option id | Kind | Effect |
75+|-----------|------|--------|
76+| `allow` | `allow_once` | Runs the tool. |
77+| `allow_always` | `allow_always` | Runs the tool and skips the dialog for that tool name for the rest of the session. |
78+| `reject` | `reject_once` | Does not run; the model receives `[command rejected by the user]`. |
79+
80+A transport error or a `cancelled` outcome counts as a rejection.
81+
82+## Tool kinds
83+
84+| `mm` kind | ACP `ToolKind` | Tools |
85+|-----------|----------------|-------|
86+| `execute` | `execute` | `bash` (and any unknown kind) |
87+| `read` | `read` | `read_skill`, `read_file` |
88+| `edit` | `edit` | `write_file`, `edit_file` |
89+
90+## Tool call identifiers
91+
92+`call_1`, `call_2`, … numbered per process and never reset.
added docs/en/reference/cli.md +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+# Reference: command line
2+
3+> Neutral description of the `mm` executable: flags, positional argument, environment variables, start-up output and exit codes.
4+
5+## Invocation
6+
7+```
8+mm [-config <path>] [-acp] [<path>]
9+```
10+
11+| Element | Type | Default | Description |
12+|---------|------|---------|-------------|
13+| `-config <path>` | string | `""` | Path of the YAML configuration file. |
14+| `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. |
15+| `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. |
16+| `-h` | — | — | Print the flag usage and exit. |
17+
18+## Configuration file resolution
19+
20+The first existing source wins.
21+
22+| Order | Source | Missing file |
23+|-------|--------|--------------|
24+| 1 | `-config` flag or positional argument | Error, exit code 1 |
25+| 2 | `AGENT_CONFIG` environment variable | Error, exit code 1 |
26+| 3 | `./agent.yaml` | Not an error: built-in defaults are used, the banner shows `config: built-in defaults` |
27+
28+## Environment variables
29+
30+| Variable | Effect |
31+|----------|--------|
32+| `AGENT_CONFIG` | Path of the configuration file (see above). |
33+| `AGENT_PROVIDER` | Overrides the `provider` key. |
34+| `AGENT_MODEL` | Overrides the `model` key. |
35+| `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. |
36+| `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. |
37+| Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`. |
38+
39+## Start-up output
40+
41+Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is a terminal.
42+
43+| Line | Content |
44+|------|---------|
45+| `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger. |
46+| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. |
47+| `Agent ready. Commands: …` | Terminal mode only. |
48+| `Shortcut: Ctrl+C …` | Terminal mode only. |
49+
50+### Example
51+
52+```
53+[warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]
54+config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
55+```
56+
57+## ACP mode specifics
58+
59+| Aspect | Behaviour |
60+|--------|-----------|
61+| stdout | JSON-RPC 2.0 messages only, one per line. |
62+| stderr | Banner, warnings, `[acp] …` trail, SDK logs. |
63+| Spinner | Disabled. |
64+| Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |
65+
66+## Exit codes
67+
68+| Code | Cause |
69+|------|-------|
70+| 0 | `/quit`, `Ctrl+C` at the prompt, end of stdin, or the ACP client closed the connection. |
71+| 1 | More than one positional argument; configuration load error; engine initialisation error (unknown provider, required key not set). |
72+
73+## Build and install
74+
75+| Command | Effect |
76+|---------|--------|
77+| `go build -o mm .` | Builds the binary in the current directory. |
78+| `./install.sh` | Builds `mm` and copies it to `/usr/local/bin`, with `sudo` when the directory is not writable. |
79+| `go test ./...` | Runs the unit tests. |
new file mode 100644
@@ -0,0 +1,79 @@
1+# Reference: command line
2+
3+> Neutral description of the `mm` executable: flags, positional argument, environment variables, start-up output and exit codes.
4+
5+## Invocation
6+
7+```
8+mm [-config <path>] [-acp] [<path>]
9+```
10+
11+| Element | Type | Default | Description |
12+|---------|------|---------|-------------|
13+| `-config <path>` | string | `""` | Path of the YAML configuration file. |
14+| `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. |
15+| `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. |
16+| `-h` | — | — | Print the flag usage and exit. |
17+
18+## Configuration file resolution
19+
20+The first existing source wins.
21+
22+| Order | Source | Missing file |
23+|-------|--------|--------------|
24+| 1 | `-config` flag or positional argument | Error, exit code 1 |
25+| 2 | `AGENT_CONFIG` environment variable | Error, exit code 1 |
26+| 3 | `./agent.yaml` | Not an error: built-in defaults are used, the banner shows `config: built-in defaults` |
27+
28+## Environment variables
29+
30+| Variable | Effect |
31+|----------|--------|
32+| `AGENT_CONFIG` | Path of the configuration file (see above). |
33+| `AGENT_PROVIDER` | Overrides the `provider` key. |
34+| `AGENT_MODEL` | Overrides the `model` key. |
35+| `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. |
36+| `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. |
37+| Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`. |
38+
39+## Start-up output
40+
41+Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is a terminal.
42+
43+| Line | Content |
44+|------|---------|
45+| `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger. |
46+| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. |
47+| `Agent ready. Commands: …` | Terminal mode only. |
48+| `Shortcut: Ctrl+C …` | Terminal mode only. |
49+
50+### Example
51+
52+```
53+[warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]
54+config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
55+```
56+
57+## ACP mode specifics
58+
59+| Aspect | Behaviour |
60+|--------|-----------|
61+| stdout | JSON-RPC 2.0 messages only, one per line. |
62+| stderr | Banner, warnings, `[acp] …` trail, SDK logs. |
63+| Spinner | Disabled. |
64+| Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |
65+
66+## Exit codes
67+
68+| Code | Cause |
69+|------|-------|
70+| 0 | `/quit`, `Ctrl+C` at the prompt, end of stdin, or the ACP client closed the connection. |
71+| 1 | More than one positional argument; configuration load error; engine initialisation error (unknown provider, required key not set). |
72+
73+## Build and install
74+
75+| Command | Effect |
76+|---------|--------|
77+| `go build -o mm .` | Builds the binary in the current directory. |
78+| `./install.sh` | Builds `mm` and copies it to `/usr/local/bin`, with `sudo` when the directory is not writable. |
79+| `go test ./...` | Runs the unit tests. |
added docs/en/reference/configuration.md +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+# Reference: configuration file
2+
3+> Neutral, exhaustive description of the YAML file read by `mm`. Every key is optional; a key left out keeps its built-in default. The file is decoded on top of the defaults, so unknown keys are ignored.
4+
5+## Top-level keys
6+
7+| Key | Type | Default | Description |
8+|-----|------|---------|-------------|
9+| `provider` | string | `dmr` | Registry key of the LLM server: `dmr` or `llamacpp`. Overridden by `AGENT_PROVIDER`. |
10+| `model` | string | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Model identifier in the provider's naming. Overridden by `AGENT_MODEL`. |
11+| `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. |
12+| `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. |
13+| `apiKeyEnv` | string | `""` (provider default) | Name of the environment variable holding the API key. |
14+| `contextWindow` | int | `0` | Tokens the server serves. `0` = ask the server, else unknown. Must be ≥ 0. |
15+| `maxOutput` | int | `16000` | Maximum characters a tool returns to the model; longer output keeps its head and up to 2000 characters of tail. Must be > 0. |
16+| `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. |
17+| `previewLines` | int | `20` | Lines of a command's output echoed to the terminal. `0` disables the echo. Must be ≥ 0. |
18+| `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. |
19+| `system` | string | built-in prompt | The system prompt, first message of every history. |
20+| `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |
21+| `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
22+| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |
23+| `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
24+| `context` | map | see below | Automatic history compression. |
25+
26+## `context` keys
27+
28+| Key | Type | Default | Description |
29+|-----|------|---------|-------------|
30+| `enabled` | bool | `false` | Turn the automatic compression on. `/compact` works regardless. |
31+| `threshold` | int | `75` | Percentage of the context window beyond which the history is compressed before the next question. Between 1 and 100. |
32+| `maxMessages` | int | `80` | Message count that triggers a compression when the window is unknown or the estimate is low. `0` disables. Must be ≥ 0. |
33+| `keepLastTurns` | int | `3` | Question turns kept raw at the end of the history. Must be ≥ 1. |
34+| `summaryMaxTokens` | int | `1200` | `max_tokens` of the summary request. Must be > 0. |
35+| `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. |
36+| `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. |
37+
38+## Provider defaults
39+
40+| Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required |
41+|----------|-----------|------------|-------------|--------------|
42+| `dmr` | `http://localhost:12434/engines/v1` | `http://host.docker.internal:12434/engines/v1` | none | no |
43+| `llamacpp` | `http://127.0.0.1:8080/v1` | none | `LLAMA_API_KEY` | no |
44+
45+## Validation errors
46+
47+Reported as `[config error: <path>: <message>]`, exit code 1.
48+
49+| Message | Cause |
50+|---------|-------|
51+| `provider must not be empty` | `provider: ""` |
52+| `model must not be empty` | `model: ""` |
53+| `contextWindow must be >= 0` | negative value |
54+| `maxOutput must be > 0` | zero or negative |
55+| `maxTurns must be > 0` | zero or negative |
56+| `previewLines must be >= 0` | negative value |
57+| `context.threshold must be between 1 and 100` | out of range |
58+| `context.keepLastTurns must be >= 1` | zero or negative |
59+| `context.summaryMaxTokens must be > 0` | zero or negative |
60+| `context.maxMessages must be >= 0` | negative value |
61+| `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. |
62+
63+A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`.
64+
65+## Shipped files
66+
67+| File | Purpose |
68+|------|---------|
69+| `agent.yaml` | Docker Model Runner set-up: `provider: dmr`, `maxTurns: 40`, `displayCommands: true`, compression off, a system prompt named "Bob" that describes the skills and file-editing rules. |
70+| `agent.llamacpp.yaml` | llama.cpp set-up: `provider: llamacpp`, `baseUrl: http://127.0.0.1:8080/v1`, `fallback: ""`, compression on. |
71+
72+### Example
73+
74+```yaml
75+provider: llamacpp
76+model: my-alias
77+baseUrl: http://127.0.0.1:8080/v1
78+fallback: ""
79+maxTurns: 40
80+context:
81+ enabled: true
82+```
new file mode 100644
@@ -0,0 +1,82 @@
1+# Reference: configuration file
2+
3+> Neutral, exhaustive description of the YAML file read by `mm`. Every key is optional; a key left out keeps its built-in default. The file is decoded on top of the defaults, so unknown keys are ignored.
4+
5+## Top-level keys
6+
7+| Key | Type | Default | Description |
8+|-----|------|---------|-------------|
9+| `provider` | string | `dmr` | Registry key of the LLM server: `dmr` or `llamacpp`. Overridden by `AGENT_PROVIDER`. |
10+| `model` | string | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Model identifier in the provider's naming. Overridden by `AGENT_MODEL`. |
11+| `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. |
12+| `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. |
13+| `apiKeyEnv` | string | `""` (provider default) | Name of the environment variable holding the API key. |
14+| `contextWindow` | int | `0` | Tokens the server serves. `0` = ask the server, else unknown. Must be ≥ 0. |
15+| `maxOutput` | int | `16000` | Maximum characters a tool returns to the model; longer output keeps its head and up to 2000 characters of tail. Must be > 0. |
16+| `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. |
17+| `previewLines` | int | `20` | Lines of a command's output echoed to the terminal. `0` disables the echo. Must be ≥ 0. |
18+| `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. |
19+| `system` | string | built-in prompt | The system prompt, first message of every history. |
20+| `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |
21+| `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
22+| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |
23+| `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
24+| `context` | map | see below | Automatic history compression. |
25+
26+## `context` keys
27+
28+| Key | Type | Default | Description |
29+|-----|------|---------|-------------|
30+| `enabled` | bool | `false` | Turn the automatic compression on. `/compact` works regardless. |
31+| `threshold` | int | `75` | Percentage of the context window beyond which the history is compressed before the next question. Between 1 and 100. |
32+| `maxMessages` | int | `80` | Message count that triggers a compression when the window is unknown or the estimate is low. `0` disables. Must be ≥ 0. |
33+| `keepLastTurns` | int | `3` | Question turns kept raw at the end of the history. Must be ≥ 1. |
34+| `summaryMaxTokens` | int | `1200` | `max_tokens` of the summary request. Must be > 0. |
35+| `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. |
36+| `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. |
37+
38+## Provider defaults
39+
40+| Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required |
41+|----------|-----------|------------|-------------|--------------|
42+| `dmr` | `http://localhost:12434/engines/v1` | `http://host.docker.internal:12434/engines/v1` | none | no |
43+| `llamacpp` | `http://127.0.0.1:8080/v1` | none | `LLAMA_API_KEY` | no |
44+
45+## Validation errors
46+
47+Reported as `[config error: <path>: <message>]`, exit code 1.
48+
49+| Message | Cause |
50+|---------|-------|
51+| `provider must not be empty` | `provider: ""` |
52+| `model must not be empty` | `model: ""` |
53+| `contextWindow must be >= 0` | negative value |
54+| `maxOutput must be > 0` | zero or negative |
55+| `maxTurns must be > 0` | zero or negative |
56+| `previewLines must be >= 0` | negative value |
57+| `context.threshold must be between 1 and 100` | out of range |
58+| `context.keepLastTurns must be >= 1` | zero or negative |
59+| `context.summaryMaxTokens must be > 0` | zero or negative |
60+| `context.maxMessages must be >= 0` | negative value |
61+| `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. |
62+
63+A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`.
64+
65+## Shipped files
66+
67+| File | Purpose |
68+|------|---------|
69+| `agent.yaml` | Docker Model Runner set-up: `provider: dmr`, `maxTurns: 40`, `displayCommands: true`, compression off, a system prompt named "Bob" that describes the skills and file-editing rules. |
70+| `agent.llamacpp.yaml` | llama.cpp set-up: `provider: llamacpp`, `baseUrl: http://127.0.0.1:8080/v1`, `fallback: ""`, compression on. |
71+
72+### Example
73+
74+```yaml
75+provider: llamacpp
76+model: my-alias
77+baseUrl: http://127.0.0.1:8080/v1
78+fallback: ""
79+maxTurns: 40
80+context:
81+ enabled: true
82+```
added docs/en/reference/skill-format.md +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+# Reference: skill file format
2+
3+> Neutral description of the markdown files the `read_skill` tool serves.
4+
5+## Location
6+
7+| Rule | Value |
8+|------|-------|
9+| Directory | `skillsDir` (default `skills`). |
10+| Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. |
11+| Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. |
12+| Both forms for one name | `read_skill` returns the flat file. |
13+| Missing directory | Not an error: zero skills, `read_skill` not declared. |
14+
15+## Front matter
16+
17+Optional. When present, the file starts with a line `---` and the header ends at the next `---` line. Only two keys are read; the others are ignored.
18+
19+| Key | Type | Default | Description |
20+|-----|------|---------|-------------|
21+| `name` | string | file name without `.md` | Name shown in the catalogue and accepted by `read_skill`. An empty value keeps the default. |
22+| `description` | string | `""` | One-line summary shown in the catalogue. |
23+
24+A `description:` line in the body, after the closing `---`, is not metadata.
25+
26+## Catalogue rendering
27+
28+The tool description is:
29+
30+```
31+Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:
32+ <name> — <description>
33+ <name>
34+```
35+
36+Entries are sorted by name; the ` — <description>` part is omitted when the description is empty.
37+
38+### Example
39+
40+```markdown
41+---
42+name: what-time-is-it
43+description: Give the current date and time.
44+keywords: time, date, clock
45+---
46+
47+# What time is it?
48+
49+Run:
50+
51+```bash
52+date "+%A %d %B %Y — %H:%M:%S (%Z)"
53+```
54+```
55+
56+The `keywords` key is accepted in the file and ignored by the loader.
57+
58+## Shipped examples
59+
60+| Path | `name` | `description` |
61+|------|--------|---------------|
62+| `skills/greetings/SKILL.md` | `greetings` | Greet somebody with a festive message. |
63+| `skills/vulcan-salute/SKILL.md` | `vulcan-salute` | Send a vulcan salute to somebody. |
64+| `skills/what-time-is-it/SKILL.md` | `what-time-is-it` | Give the current date and time. |
65+
66+They use the directory-per-skill layout; started from the repository root, `mm` reports `skills: 3`.
new file mode 100644
@@ -0,0 +1,66 @@
1+# Reference: skill file format
2+
3+> Neutral description of the markdown files the `read_skill` tool serves.
4+
5+## Location
6+
7+| Rule | Value |
8+|------|-------|
9+| Directory | `skillsDir` (default `skills`). |
10+| Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. |
11+| Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. |
12+| Both forms for one name | `read_skill` returns the flat file. |
13+| Missing directory | Not an error: zero skills, `read_skill` not declared. |
14+
15+## Front matter
16+
17+Optional. When present, the file starts with a line `---` and the header ends at the next `---` line. Only two keys are read; the others are ignored.
18+
19+| Key | Type | Default | Description |
20+|-----|------|---------|-------------|
21+| `name` | string | file name without `.md` | Name shown in the catalogue and accepted by `read_skill`. An empty value keeps the default. |
22+| `description` | string | `""` | One-line summary shown in the catalogue. |
23+
24+A `description:` line in the body, after the closing `---`, is not metadata.
25+
26+## Catalogue rendering
27+
28+The tool description is:
29+
30+```
31+Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:
32+ <name> — <description>
33+ <name>
34+```
35+
36+Entries are sorted by name; the ` — <description>` part is omitted when the description is empty.
37+
38+### Example
39+
40+```markdown
41+---
42+name: what-time-is-it
43+description: Give the current date and time.
44+keywords: time, date, clock
45+---
46+
47+# What time is it?
48+
49+Run:
50+
51+```bash
52+date "+%A %d %B %Y — %H:%M:%S (%Z)"
53+```
54+```
55+
56+The `keywords` key is accepted in the file and ignored by the loader.
57+
58+## Shipped examples
59+
60+| Path | `name` | `description` |
61+|------|--------|---------------|
62+| `skills/greetings/SKILL.md` | `greetings` | Greet somebody with a festive message. |
63+| `skills/vulcan-salute/SKILL.md` | `vulcan-salute` | Send a vulcan salute to somebody. |
64+| `skills/what-time-is-it/SKILL.md` | `what-time-is-it` | Give the current date and time. |
65+
66+They use the directory-per-skill layout; started from the repository root, `mm` reports `skills: 3`.
added docs/en/reference/slash-commands.md +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+# Reference: slash commands and keyboard shortcuts
2+
3+> Neutral description of the commands recognised at the terminal prompt of `mm`. They are matched on the whole trimmed line; anything else is sent to the model.
4+
5+## Commands
6+
7+| Command | At the prompt | During a generation |
8+|---------|---------------|---------------------|
9+| `/quit` | Exits the program. | Cancels the generation, prints `Goodbye.`, exits. |
10+| `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |
11+| `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |
12+| `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |
13+
14+An empty line at the prompt is ignored.
15+
16+## `@path` in a question
17+
18+| Input | Effect |
19+|-------|--------|
20+| `@<path>` at the start or after a space, bracket or quote | If the path exists (relative to the start directory, absolute, or `~/…`), a dim `📎 <absolute path>` line is printed and `[attached file: <absolute path>]` (or `[attached directory: …]`) is appended to the message the model receives. Trailing punctuation is ignored; each path is attached once. |
21+| A `@<path>` that names nothing on disk, or `@` glued to a word | Left as typed. |
22+
23+Details and examples: [Point the model at a file with `@path`](../how-to/attach-a-file.md).
24+
25+## Any other text during a generation
26+
27+| Input | Behaviour |
28+|-------|-----------|
29+| A non-command line | Prints `🔄 New command received. Cancelling current task...`, cancels the current generation, keeps its partial history, then submits the new line as the next question. |
30+
31+## Keyboard shortcuts
32+
33+| Key | At the prompt | During a generation |
34+|-----|---------------|---------------------|
35+| `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |
36+| `Ctrl+D` (end of input) | Exits. | — |
37+
38+## Messages printed at the end of a turn
39+
40+| Message | Meaning |
41+|---------|---------|
42+| `🛑 Generation aborted.` | The turn was cancelled (`/abort`, `Ctrl+C`). The question and any completed tool turns stay in the history. |
43+| `[error: …]` | The provider explained a failure in one line; a question that got no answer is removed from the history. |
44+| `⚙ N command(s)` | Number of `bash` commands that actually ran in the turn. |
45+| `· 📝 N file op(s)` | Appended when file tools ran. |
46+| `· 📖 N skill(s)` | Appended when skills were loaded. |
47+| Numbered green list | The commands and file operations of the turn, one per line, when `displayCommands: true`. |
48+
49+## ACP mode
50+
51+Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to the editor through an `available_commands_update` notification and intercepted by `session/prompt` when the prompt text, trimmed, is exactly the command. Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted.
52+
53+| Command | Over ACP |
54+|---------|----------|
55+| `/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. |
56+| `/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. |
57+
58+`/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).
new file mode 100644
@@ -0,0 +1,58 @@
1+# Reference: slash commands and keyboard shortcuts
2+
3+> Neutral description of the commands recognised at the terminal prompt of `mm`. They are matched on the whole trimmed line; anything else is sent to the model.
4+
5+## Commands
6+
7+| Command | At the prompt | During a generation |
8+|---------|---------------|---------------------|
9+| `/quit` | Exits the program. | Cancels the generation, prints `Goodbye.`, exits. |
10+| `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |
11+| `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |
12+| `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |
13+
14+An empty line at the prompt is ignored.
15+
16+## `@path` in a question
17+
18+| Input | Effect |
19+|-------|--------|
20+| `@<path>` at the start or after a space, bracket or quote | If the path exists (relative to the start directory, absolute, or `~/…`), a dim `📎 <absolute path>` line is printed and `[attached file: <absolute path>]` (or `[attached directory: …]`) is appended to the message the model receives. Trailing punctuation is ignored; each path is attached once. |
21+| A `@<path>` that names nothing on disk, or `@` glued to a word | Left as typed. |
22+
23+Details and examples: [Point the model at a file with `@path`](../how-to/attach-a-file.md).
24+
25+## Any other text during a generation
26+
27+| Input | Behaviour |
28+|-------|-----------|
29+| A non-command line | Prints `🔄 New command received. Cancelling current task...`, cancels the current generation, keeps its partial history, then submits the new line as the next question. |
30+
31+## Keyboard shortcuts
32+
33+| Key | At the prompt | During a generation |
34+|-----|---------------|---------------------|
35+| `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |
36+| `Ctrl+D` (end of input) | Exits. | — |
37+
38+## Messages printed at the end of a turn
39+
40+| Message | Meaning |
41+|---------|---------|
42+| `🛑 Generation aborted.` | The turn was cancelled (`/abort`, `Ctrl+C`). The question and any completed tool turns stay in the history. |
43+| `[error: …]` | The provider explained a failure in one line; a question that got no answer is removed from the history. |
44+| `⚙ N command(s)` | Number of `bash` commands that actually ran in the turn. |
45+| `· 📝 N file op(s)` | Appended when file tools ran. |
46+| `· 📖 N skill(s)` | Appended when skills were loaded. |
47+| Numbered green list | The commands and file operations of the turn, one per line, when `displayCommands: true`. |
48+
49+## ACP mode
50+
51+Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to the editor through an `available_commands_update` notification and intercepted by `session/prompt` when the prompt text, trimmed, is exactly the command. Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted.
52+
53+| Command | Over ACP |
54+|---------|----------|
55+| `/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. |
56+| `/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. |
57+
58+`/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).
added docs/en/reference/tools.md +103 -0
new file mode 100644
@@ -0,0 +1,103 @@
1+# Reference: built-in tools
2+
3+> Neutral description of the tools declared to the model. A tool never returns a Go error for an ordinary failure: the failure text is returned to the model as the tool's output. Every tool records its call in the loop detector; the same tool, input and output three times in a row appends `[LOOP_DETECTED: …]` to the output.
4+
5+## `bash`
6+
7+Always declared.
8+
9+| Field | Type | Description |
10+|-------|------|-------------|
11+| `command` | string | The shell command to run. |
12+
13+| Aspect | Behaviour |
14+|--------|-----------|
15+| Shell | `bash -c <command>`, a new shell per call, no stdin. |
16+| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. |
17+| Timeout | 30 s; the output then ends with `[error: 30s timeout exceeded]`. |
18+| Output | stdout and stderr merged, truncated to `maxOutput` characters. |
19+| Non-zero exit | Output ends with `[exit code: exit status N]`. |
20+| Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. |
21+| ACP | Tool call of kind `execute`; permission requested before running; a refusal returns `[command rejected by the user]` with status `failed`. |
22+
23+## `read_skill`
24+
25+Declared only when `skillsDir` holds at least one skill (`<name>.md` or `<name>/SKILL.md`).
26+
27+| Field | Type | Description |
28+|-------|------|-------------|
29+| `name` | string | Skill name, exactly as listed in the tool description. |
30+
31+| Aspect | Behaviour |
32+|--------|-----------|
33+| Description | Fixed sentence followed by the catalogue: one ` <name> — <description>` line per skill, sorted by name. |
34+| Output | The full markdown of `<skillsDir>/<name>.md`, or of `<skillsDir>/<name>/SKILL.md` when there is no flat file, truncated to `maxOutput`. |
35+| Unknown name | `No skill named "x". Available skills: a, b, c` |
36+| Path safety | Only the base name of `name` is used; no directory traversal. |
37+| Terminal display | `📖 read_skill: <name>`; the content is not echoed. |
38+| ACP | Tool call of kind `read`; no permission dialog. |
39+
40+## `read_file`
41+
42+Declared when `editTools: true`.
43+
44+| Field | Type | Default | Description |
45+|-------|------|---------|-------------|
46+| `path` | string | — | File path, relative to the working directory. |
47+| `start` | int | `0` | First line (1-based); `0` = from the start. |
48+| `end` | int | `0` | Last line (1-based, inclusive); `0` = to the end. |
49+| `numbered` | bool | `false` | Prefix each line with its number. |
50+
51+| Aspect | Behaviour |
52+|--------|-----------|
53+| Missing file | `<path> does not exist. Create it with write_file` |
54+| Output | The lines, truncated to `maxOutput`; the final newline is kept only when the end of the file is shown. |
55+| ACP | Kind `read`, with the absolute path as location. |
56+
57+## `write_file`
58+
59+Declared when `editTools: true`.
60+
61+| Field | Type | Description |
62+|-------|------|-------------|
63+| `path` | string | File path, relative to the working directory; parent directories are created. |
64+| `content` | string | The whole content of the file. |
65+
66+| Aspect | Behaviour |
67+|--------|-----------|
68+| Existing file | Entirely replaced. |
69+| Identical content | Nothing is written; the result says so. |
70+| Output | A one-line headline and the diff against the previous content. |
71+| ACP | Kind `edit`; permission requested; the change travels as a diff (new-file diff when created). |
72+
73+## `edit_file`
74+
75+Declared when `editTools: true`.
76+
77+| Field | Type | Default | Description |
78+|-------|------|---------|-------------|
79+| `path` | string | — | Path of an existing file. |
80+| `edits` | array of `{old, new}` | — | Replacements, all resolved against the original file. |
81+| `edits[].old` | string | — | Exact text to find, present exactly once. |
82+| `edits[].new` | string | — | Replacement; empty deletes the matched text. |
83+| `dry_run` | bool | `false` | Report what would change, write nothing. |
84+
85+| Rule | Consequence when broken |
86+|------|-------------------------|
87+| `old` appears exactly once | Refused: not found, or ambiguous. |
88+| Two edits must not overlap, nest or repeat | Refused. |
89+| Edits are resolved against the original text, never in cascade | — |
90+| Any refused edit | Nothing is written. |
91+
92+| Aspect | Behaviour |
93+|--------|-----------|
94+| Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. |
95+| ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |
96+
97+## Counters shown after a turn
98+
99+| Counter | Counts |
100+|---------|--------|
101+| `⚙ N command(s)` | `bash` tool responses in the turn. |
102+| `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. |
103+| `📖 N skill(s)` | `read_skill` responses. |
new file mode 100644
@@ -0,0 +1,103 @@
1+# Reference: built-in tools
2+
3+> Neutral description of the tools declared to the model. A tool never returns a Go error for an ordinary failure: the failure text is returned to the model as the tool's output. Every tool records its call in the loop detector; the same tool, input and output three times in a row appends `[LOOP_DETECTED: …]` to the output.
4+
5+## `bash`
6+
7+Always declared.
8+
9+| Field | Type | Description |
10+|-------|------|-------------|
11+| `command` | string | The shell command to run. |
12+
13+| Aspect | Behaviour |
14+|--------|-----------|
15+| Shell | `bash -c <command>`, a new shell per call, no stdin. |
16+| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. |
17+| Timeout | 30 s; the output then ends with `[error: 30s timeout exceeded]`. |
18+| Output | stdout and stderr merged, truncated to `maxOutput` characters. |
19+| Non-zero exit | Output ends with `[exit code: exit status N]`. |
20+| Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. |
21+| ACP | Tool call of kind `execute`; permission requested before running; a refusal returns `[command rejected by the user]` with status `failed`. |
22+
23+## `read_skill`
24+
25+Declared only when `skillsDir` holds at least one skill (`<name>.md` or `<name>/SKILL.md`).
26+
27+| Field | Type | Description |
28+|-------|------|-------------|
29+| `name` | string | Skill name, exactly as listed in the tool description. |
30+
31+| Aspect | Behaviour |
32+|--------|-----------|
33+| Description | Fixed sentence followed by the catalogue: one ` <name> — <description>` line per skill, sorted by name. |
34+| Output | The full markdown of `<skillsDir>/<name>.md`, or of `<skillsDir>/<name>/SKILL.md` when there is no flat file, truncated to `maxOutput`. |
35+| Unknown name | `No skill named "x". Available skills: a, b, c` |
36+| Path safety | Only the base name of `name` is used; no directory traversal. |
37+| Terminal display | `📖 read_skill: <name>`; the content is not echoed. |
38+| ACP | Tool call of kind `read`; no permission dialog. |
39+
40+## `read_file`
41+
42+Declared when `editTools: true`.
43+
44+| Field | Type | Default | Description |
45+|-------|------|---------|-------------|
46+| `path` | string | — | File path, relative to the working directory. |
47+| `start` | int | `0` | First line (1-based); `0` = from the start. |
48+| `end` | int | `0` | Last line (1-based, inclusive); `0` = to the end. |
49+| `numbered` | bool | `false` | Prefix each line with its number. |
50+
51+| Aspect | Behaviour |
52+|--------|-----------|
53+| Missing file | `<path> does not exist. Create it with write_file` |
54+| Output | The lines, truncated to `maxOutput`; the final newline is kept only when the end of the file is shown. |
55+| ACP | Kind `read`, with the absolute path as location. |
56+
57+## `write_file`
58+
59+Declared when `editTools: true`.
60+
61+| Field | Type | Description |
62+|-------|------|-------------|
63+| `path` | string | File path, relative to the working directory; parent directories are created. |
64+| `content` | string | The whole content of the file. |
65+
66+| Aspect | Behaviour |
67+|--------|-----------|
68+| Existing file | Entirely replaced. |
69+| Identical content | Nothing is written; the result says so. |
70+| Output | A one-line headline and the diff against the previous content. |
71+| ACP | Kind `edit`; permission requested; the change travels as a diff (new-file diff when created). |
72+
73+## `edit_file`
74+
75+Declared when `editTools: true`.
76+
77+| Field | Type | Default | Description |
78+|-------|------|---------|-------------|
79+| `path` | string | — | Path of an existing file. |
80+| `edits` | array of `{old, new}` | — | Replacements, all resolved against the original file. |
81+| `edits[].old` | string | — | Exact text to find, present exactly once. |
82+| `edits[].new` | string | — | Replacement; empty deletes the matched text. |
83+| `dry_run` | bool | `false` | Report what would change, write nothing. |
84+
85+| Rule | Consequence when broken |
86+|------|-------------------------|
87+| `old` appears exactly once | Refused: not found, or ambiguous. |
88+| Two edits must not overlap, nest or repeat | Refused. |
89+| Edits are resolved against the original text, never in cascade | — |
90+| Any refused edit | Nothing is written. |
91+
92+| Aspect | Behaviour |
93+|--------|-----------|
94+| Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. |
95+| ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |
96+
97+## Counters shown after a turn
98+
99+| Counter | Counts |
100+|---------|--------|
101+| `⚙ N command(s)` | `bash` tool responses in the turn. |
102+| `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. |
103+| `📖 N skill(s)` | `read_skill` responses. |
added docs/en/tutorials/getting-started.md +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+# Tutorial: your first session with mini-me
2+
3+By the end of this tutorial, you will have built the `mm` binary, started it against Docker Model Runner, asked it one question that makes it run a shell command, and left cleanly. No prior knowledge of the project is required.
4+
5+## Prerequisites
6+
7+- Go 1.25 or later on your `PATH` (`go version` answers).
8+- Docker Desktop with Docker Model Runner enabled and reachable on `http://localhost:12434`.
9+- The model named in `agent.yaml` pulled locally. The file shipped with the project names `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`; pull it with `docker model pull <that name>`.
10+- A terminal open at the root of the repository (the directory that holds `main.go` and `agent.yaml`).
11+
12+## Step 1 — Build the binary
13+
14+Type:
15+
16+```bash
17+go build -o mm .
18+```
19+
20+You should see no output at all. A file named `mm` now sits in the current directory.
21+
22+We have just compiled the whole agent into a single executable.
23+
24+## Step 2 — Start the agent
25+
26+Type:
27+
28+```bash
29+./mm
30+```
31+
32+You should see a banner like this one (the model name is the one from `agent.yaml`):
33+
34+```
35+config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
36+Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.
37+Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
38+
39+>
40+```
41+
42+If a dimmed `[warning: dmr: nothing answers at …]` line appears above the banner, Docker Model Runner is not running: start it, then run `./mm` again. The agent starts anyway, but the next step would fail.
43+
44+We have just loaded the configuration, connected to the model server, and declared the tools the model may call.
45+
46+## Step 3 — Ask a question that needs a command
47+
48+At the `>` prompt, type:
49+
50+```
51+list the files in this directory
52+```
53+
54+You should see, in order: a spinner while the model thinks, a line starting with `🛠️ bash:` showing the command the model chose (typically `ls` or `ls -la`), the first lines of that command's output indented and dimmed, then the model's answer in plain text. The turn ends with a dimmed recap such as:
55+
56+```
57+⚙ 1 command(s)
58+```
59+
60+followed by the numbered list of the commands that ran, in green.
61+
62+We have just watched the agent loop once: the model asked for a tool, the tool ran, its output went back to the model, and the model answered.
63+
64+## Step 4 — Leave
65+
66+At the `>` prompt, type:
67+
68+```
69+/quit
70+```
71+
72+The program exits. Pressing `Ctrl+C` at the prompt does the same and prints `Goodbye.` first.
73+
74+## What now?
75+
76+You have built `mm`, run one tool-calling turn, and exited. To go further:
77+
78+- To run it on llama.cpp, host it in an editor, or add skills → see the [how-to guides](../how-to/)
79+- To look up a flag, a YAML key or a slash command → see the [reference](../reference/)
80+- To understand how the loop and the two front ends fit together → see the [explanation](../explanation/)
new file mode 100644
@@ -0,0 +1,80 @@
1+# Tutorial: your first session with mini-me
2+
3+By the end of this tutorial, you will have built the `mm` binary, started it against Docker Model Runner, asked it one question that makes it run a shell command, and left cleanly. No prior knowledge of the project is required.
4+
5+## Prerequisites
6+
7+- Go 1.25 or later on your `PATH` (`go version` answers).
8+- Docker Desktop with Docker Model Runner enabled and reachable on `http://localhost:12434`.
9+- The model named in `agent.yaml` pulled locally. The file shipped with the project names `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`; pull it with `docker model pull <that name>`.
10+- A terminal open at the root of the repository (the directory that holds `main.go` and `agent.yaml`).
11+
12+## Step 1 — Build the binary
13+
14+Type:
15+
16+```bash
17+go build -o mm .
18+```
19+
20+You should see no output at all. A file named `mm` now sits in the current directory.
21+
22+We have just compiled the whole agent into a single executable.
23+
24+## Step 2 — Start the agent
25+
26+Type:
27+
28+```bash
29+./mm
30+```
31+
32+You should see a banner like this one (the model name is the one from `agent.yaml`):
33+
34+```
35+config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
36+Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.
37+Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
38+
39+>
40+```
41+
42+If a dimmed `[warning: dmr: nothing answers at …]` line appears above the banner, Docker Model Runner is not running: start it, then run `./mm` again. The agent starts anyway, but the next step would fail.
43+
44+We have just loaded the configuration, connected to the model server, and declared the tools the model may call.
45+
46+## Step 3 — Ask a question that needs a command
47+
48+At the `>` prompt, type:
49+
50+```
51+list the files in this directory
52+```
53+
54+You should see, in order: a spinner while the model thinks, a line starting with `🛠️ bash:` showing the command the model chose (typically `ls` or `ls -la`), the first lines of that command's output indented and dimmed, then the model's answer in plain text. The turn ends with a dimmed recap such as:
55+
56+```
57+⚙ 1 command(s)
58+```
59+
60+followed by the numbered list of the commands that ran, in green.
61+
62+We have just watched the agent loop once: the model asked for a tool, the tool ran, its output went back to the model, and the model answered.
63+
64+## Step 4 — Leave
65+
66+At the `>` prompt, type:
67+
68+```
69+/quit
70+```
71+
72+The program exits. Pressing `Ctrl+C` at the prompt does the same and prints `Goodbye.` first.
73+
74+## What now?
75+
76+You have built `mm`, run one tool-calling turn, and exited. To go further:
77+
78+- To run it on llama.cpp, host it in an editor, or add skills → see the [how-to guides](../how-to/)
79+- To look up a flag, a YAML key or a slash command → see the [reference](../reference/)
80+- To understand how the loop and the two front ends fit together → see the [explanation](../explanation/)
added docs/fr/README.md +38 -0
new file mode 100644
@@ -0,0 +1,38 @@
1+# mini-me — documentation (français)
2+
3+`mm` est un agent de code qui tourne dans votre terminal, ou dans un éditeur via l'Agent Client Protocol. Il dialogue avec un serveur de modèle local (Docker Model Runner ou `llama-server` de llama.cpp) et travaille avec une poignée d'outils intégrés : `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
4+
5+Cette documentation est organisée selon les quatre quadrants [Diátaxis](https://diataxis.fr). Choisissez celui qui correspond à votre besoin du moment.
6+
7+## Tutoriels — apprendre en faisant
8+
9+- [Votre première session avec mini-me](tutorials/getting-started.md)
10+
11+## Guides pratiques — accomplir une tâche
12+
13+- [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
14+- [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
15+- [Ajouter un skill](how-to/add-a-skill.md)
16+- [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)
17+- [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
18+- [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
19+
20+## Référence — consulter
21+
22+- [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage
23+- [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, raccourcis clavier
24+- [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
25+- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`
26+- [Format d'un fichier de skill](reference/skill-format.md)
27+- [Surface Agent Client Protocol](reference/acp.md)
28+
29+## Explication — comprendre
30+
31+- [Architecture : une boucle, deux façades](explanation/architecture.md)
32+- [Fournisseurs : une implémentation par protocole](explanation/providers.md)
33+- [Compression du contexte](explanation/context-compression.md)
34+- Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
35+
36+## Périmètre
37+
38+Cette documentation couvre le binaire `mm` et les paquets sous `internal/`. Les répertoires `kits/` et `tmp/` à la racine du dépôt ne font pas partie du projet et ne sont pas documentés ici.
new file mode 100644
@@ -0,0 +1,38 @@
1+# mini-me — documentation (français)
2+
3+`mm` est un agent de code qui tourne dans votre terminal, ou dans un éditeur via l'Agent Client Protocol. Il dialogue avec un serveur de modèle local (Docker Model Runner ou `llama-server` de llama.cpp) et travaille avec une poignée d'outils intégrés : `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
4+
5+Cette documentation est organisée selon les quatre quadrants [Diátaxis](https://diataxis.fr). Choisissez celui qui correspond à votre besoin du moment.
6+
7+## Tutoriels — apprendre en faisant
8+
9+- [Votre première session avec mini-me](tutorials/getting-started.md)
10+
11+## Guides pratiques — accomplir une tâche
12+
13+- [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
14+- [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
15+- [Ajouter un skill](how-to/add-a-skill.md)
16+- [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)
17+- [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
18+- [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
19+
20+## Référence — consulter
21+
22+- [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage
23+- [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, raccourcis clavier
24+- [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
25+- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`
26+- [Format d'un fichier de skill](reference/skill-format.md)
27+- [Surface Agent Client Protocol](reference/acp.md)
28+
29+## Explication — comprendre
30+
31+- [Architecture : une boucle, deux façades](explanation/architecture.md)
32+- [Fournisseurs : une implémentation par protocole](explanation/providers.md)
33+- [Compression du contexte](explanation/context-compression.md)
34+- Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
35+
36+## Périmètre
37+
38+Cette documentation couvre le binaire `mm` et les paquets sous `internal/`. Les répertoires `kits/` et `tmp/` à la racine du dépôt ne font pas partie du projet et ne sont pas documentés ici.
added docs/fr/explanation/architecture.md +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+# Architecture : une boucle, deux façades — explication
2+
3+## De quoi s'agit-il ?
4+
5+`mm` est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal et depuis un éditeur sans rien dupliquer.
6+
7+Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des deux façades. Chaque préoccupation vit dans son propre paquet sous `internal/` :
8+
9+| Paquet | Responsabilité |
10+|--------|----------------|
11+| `config` | Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. |
12+| `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, et le registre des fournisseurs. |
13+| `tools` | Les outils intégrés que le modèle peut appeler. |
14+| `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
15+| `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
16+| `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. |
17+| `detector` | La détection d'une même action répétée avec le même résultat. |
18+| `compact` | La compression de l'historique en un résumé écrit par le modèle. |
19+| `spinner` | L'indicateur « toujours en cours » sur un terminal. |
20+| `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les deux façades partagent. |
21+| `agent` | Le REPL du terminal. |
22+| `acp` | La façade Agent Client Protocol. |
23+
24+Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).
25+
26+## Pourquoi c'est conçu ainsi
27+
28+**Les outils rapportent les échecs comme du texte, jamais comme des erreurs.** Une commande qui sort avec un code non nul, un fichier qui n'existe pas, une édition ambiguë : tout revient au modèle comme sortie de l'outil. C'est le modèle qui doit réagir, donc c'est lui qui doit lire le message. Une erreur Go terminerait le tour à sa place.
29+
30+**L'historique complet est conservé même quand un tour échoue.** Genkit ne renvoie que le dernier message ; le moteur renvoie toute la conversation, appels d'outils et résultats compris. Les façades gardent cet historique après une interruption, une coupure du watchdog ou un dépassement de `maxTurns`, parce que les commandes déjà exécutées sont des faits que le modèle ne doit ni rejouer ni inventer à la question suivante. Seule une question restée sans aucune réponse est retirée.
31+
32+**La sortie est montrée avant que le modèle la voie.** Dans le terminal, `bash` affiche les premières lignes de la sortie d'une commande avant de la renvoyer. « Montre-moi ce fichier » veut dire le montrer ; laisser le modèle le résumer a été observé laissant l'utilisateur sans rien.
33+
34+**Une boucle, deux façades.** Le REPL du terminal et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes `🛠️` et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc `main.go` redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un `ui.Sink` à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` au lieu d'afficher. Le paquet `ui` ne sait rien du protocole ; `acp` est le seul endroit qui traduit ces événements en notifications `session/update` et en demandes de permission.
35+
36+**La permission vit dans le puits, pas dans les outils.** Un dialogue de permission est la principale chose qu'un éditeur ajoute et que le terminal n'a pas. Placer la politique dans le puits ACP garde les outils identiques dans les deux modes ; « toujours autoriser » est mémorisé par nom d'outil pour la session.
37+
38+**Les tours sont sérialisés en mode ACP.** Le puits actif est global au processus et le moteur n'a jamais été conçu pour des générations concurrentes, donc un second `session/prompt` attend la fin du premier. Le protocole laisse ce choix à l'agent, et les éditeurs n'envoient de toute façon pas de prompts qui se chevauchent.
39+
40+**Le détecteur de boucle est séparé de l'historique.** Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par `/compact` : oublier une conversation ne rend pas nouvelle une commande répétée.
41+
42+## Alternatives écartées
43+
44+- **Un binaire par façade.** Écarté parce que le but de l'exercice est de prouver que la boucle est la même ; partager le câblage dans `main.go` est ce qui le rend visible.
45+- **Afficher directement depuis la façade ACP.** Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout.
46+- **Renvoyer des erreurs Go depuis les outils.** Écarté parce que cela termine le tour au lieu de laisser le modèle se rattraper.
47+
48+## Liens avec le reste
49+
50+- Le registre de fournisseurs utilisé par le moteur est décrit dans [fournisseurs](providers.md).
51+- Pourquoi et comment l'historique est raccourci est dans [compression du contexte](context-compression.md).
52+- La surface exacte du protocole est dans la [référence ACP](../reference/acp.md) ; le contrat des outils est dans la [référence des outils](../reference/tools.md).
53+
54+## Carte des paquets
55+
56+Le graphe d'import de tous les paquets, avec une ligne de rôle sur chacun, est tenu sous forme de diagramme draw.io dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (s'ouvre dans diagrams.net ou l'extension Draw.io de VS Code). Il est regénéré à partir de `go list` chaque fois qu'un paquet est ajouté, retiré ou recâblé.
new file mode 100644
@@ -0,0 +1,56 @@
1+# Architecture : une boucle, deux façades — explication
2+
3+## De quoi s'agit-il ?
4+
5+`mm` est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal et depuis un éditeur sans rien dupliquer.
6+
7+Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des deux façades. Chaque préoccupation vit dans son propre paquet sous `internal/` :
8+
9+| Paquet | Responsabilité |
10+|--------|----------------|
11+| `config` | Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. |
12+| `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, et le registre des fournisseurs. |
13+| `tools` | Les outils intégrés que le modèle peut appeler. |
14+| `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
15+| `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
16+| `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. |
17+| `detector` | La détection d'une même action répétée avec le même résultat. |
18+| `compact` | La compression de l'historique en un résumé écrit par le modèle. |
19+| `spinner` | L'indicateur « toujours en cours » sur un terminal. |
20+| `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les deux façades partagent. |
21+| `agent` | Le REPL du terminal. |
22+| `acp` | La façade Agent Client Protocol. |
23+
24+Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).
25+
26+## Pourquoi c'est conçu ainsi
27+
28+**Les outils rapportent les échecs comme du texte, jamais comme des erreurs.** Une commande qui sort avec un code non nul, un fichier qui n'existe pas, une édition ambiguë : tout revient au modèle comme sortie de l'outil. C'est le modèle qui doit réagir, donc c'est lui qui doit lire le message. Une erreur Go terminerait le tour à sa place.
29+
30+**L'historique complet est conservé même quand un tour échoue.** Genkit ne renvoie que le dernier message ; le moteur renvoie toute la conversation, appels d'outils et résultats compris. Les façades gardent cet historique après une interruption, une coupure du watchdog ou un dépassement de `maxTurns`, parce que les commandes déjà exécutées sont des faits que le modèle ne doit ni rejouer ni inventer à la question suivante. Seule une question restée sans aucune réponse est retirée.
31+
32+**La sortie est montrée avant que le modèle la voie.** Dans le terminal, `bash` affiche les premières lignes de la sortie d'une commande avant de la renvoyer. « Montre-moi ce fichier » veut dire le montrer ; laisser le modèle le résumer a été observé laissant l'utilisateur sans rien.
33+
34+**Une boucle, deux façades.** Le REPL du terminal et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes `🛠️` et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc `main.go` redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un `ui.Sink` à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` au lieu d'afficher. Le paquet `ui` ne sait rien du protocole ; `acp` est le seul endroit qui traduit ces événements en notifications `session/update` et en demandes de permission.
35+
36+**La permission vit dans le puits, pas dans les outils.** Un dialogue de permission est la principale chose qu'un éditeur ajoute et que le terminal n'a pas. Placer la politique dans le puits ACP garde les outils identiques dans les deux modes ; « toujours autoriser » est mémorisé par nom d'outil pour la session.
37+
38+**Les tours sont sérialisés en mode ACP.** Le puits actif est global au processus et le moteur n'a jamais été conçu pour des générations concurrentes, donc un second `session/prompt` attend la fin du premier. Le protocole laisse ce choix à l'agent, et les éditeurs n'envoient de toute façon pas de prompts qui se chevauchent.
39+
40+**Le détecteur de boucle est séparé de l'historique.** Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par `/compact` : oublier une conversation ne rend pas nouvelle une commande répétée.
41+
42+## Alternatives écartées
43+
44+- **Un binaire par façade.** Écarté parce que le but de l'exercice est de prouver que la boucle est la même ; partager le câblage dans `main.go` est ce qui le rend visible.
45+- **Afficher directement depuis la façade ACP.** Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout.
46+- **Renvoyer des erreurs Go depuis les outils.** Écarté parce que cela termine le tour au lieu de laisser le modèle se rattraper.
47+
48+## Liens avec le reste
49+
50+- Le registre de fournisseurs utilisé par le moteur est décrit dans [fournisseurs](providers.md).
51+- Pourquoi et comment l'historique est raccourci est dans [compression du contexte](context-compression.md).
52+- La surface exacte du protocole est dans la [référence ACP](../reference/acp.md) ; le contrat des outils est dans la [référence des outils](../reference/tools.md).
53+
54+## Carte des paquets
55+
56+Le graphe d'import de tous les paquets, avec une ligne de rôle sur chacun, est tenu sous forme de diagramme draw.io dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (s'ouvre dans diagrams.net ou l'extension Draw.io de VS Code). Il est regénéré à partir de `go list` chaque fois qu'un paquet est ajouté, retiré ou recâblé.
added docs/fr/explanation/context-compression.md +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+# Compression du contexte — explication
2+
3+## De quoi s'agit-il ?
4+
5+Rien dans une boucle d'agent ne raccourcit jamais l'historique. Mesurés face à un faux moteur, les messages envoyés au modèle ont grossi 2 → 5 → 7 → 9 sur quatre requêtes, et chaque sortie de `bash`, jusqu'à `maxOutput` caractères, et chaque skill chargé restent jusqu'au `/quit`. Sur un modèle local, la fenêtre de contexte est fixée au chargement du serveur, et tout l'historique est relu à chaque tour sur le matériel du portable. Une longue session se manifeste donc d'abord par un faux blocage du watchdog, puis par une erreur du serveur.
6+
7+Le paquet `compact` remplace les anciens tours de question par un résumé écrit par le modèle lui-même, et garde les quelques derniers tours bruts.
8+
9+## Pourquoi c'est conçu ainsi
10+
11+**Un hybride : résumé plus tours récents bruts.** Les tours récents sont là où le modèle travaille ; les résumer lui fait rejouer ce qu'il vient de faire. Le défaut garde trois tours de question, assez pour « fais X », « maintenant corrige », « et teste ». Tout ce qui est plus ancien devient une paire de messages insérée juste après le prompt système : un message utilisateur qui porte les notes, et un court accusé de réception du modèle.
12+
13+**La coupure tombe sur une frontière de tour.** Un appel d'outil et son résultat ne doivent jamais être séparés ; réutiliser des tours entiers le garantit.
14+
15+**Deux déclencheurs, parce que la fenêtre n'est pas toujours connue.** Le déclencheur principal est une part de la fenêtre de contexte, 75 % par défaut, qui laisse un quart pour la question suivante, ses sorties d'outils et la réponse. Le compte de tokens d'entrée rapporté par le serveur au dernier appel est préféré à l'estimation locale quand il est plus grand, parce qu'il voit le JSON des outils et le template de chat. Quand la fenêtre est inconnue, Docker Model Runner ne dit rien et `contextWindow` peut valoir `0`, seul le déclencheur sur le nombre de messages peut jouer, et l'agent le dit au démarrage.
16+
17+**Une seule clé pour la fenêtre.** La bannière et le déclencheur lisent le même `contextWindow` ; deux clés pour le même nombre finiraient par diverger.
18+
19+**Les résumés antérieurs sont fusionnés, pas re-résumés.** Les deux messages insérés portent un marqueur ; une compression ultérieure les reconnaît et replie les nouveaux tours anciens dans le résumé existant, parce que résumer un résumé perd du détail de façon exponentielle.
20+
21+**Un résumé de forme fixe.** Le prompt intégré demande sept sections avec chemins, lignes de commande et messages d'erreur exacts, et interdit de citer le contenu des fichiers : un fichier peut avoir changé depuis sa lecture, et le prompt système interdit déjà d'affirmer ce qu'une commande de cette réponse n'a pas renvoyé. La requête de résumé remplace le prompt système de l'agent par celui d'un preneur de notes, parce que « tu es un agent de code, exécute des commandes » envoyé avec un historique plein d'appels d'outils invite le modèle à continuer d'agir.
22+
23+**L'échec ne coûte rien.** Si la requête de résumé échoue, serveur arrêté, watchdog, résultat vide, l'historique est laissé intact et la question suivante part comme avant. La compression existe pour rendre la question suivante possible ; elle ne doit jamais en coûter une.
24+
25+**Le watchdog est deux fois plus patient ici.** Le résumé est le plus long préremplissage de la session et rien ne s'affiche pendant qu'il s'écrit.
26+
27+**`/compact` ignore le seuil, pas la frontière.** La commande garde toujours les derniers tours et le dit quand rien n'est plus ancien. La compression ne s'exécute jamais qu'entre deux générations, parce que Genkit détient sa propre copie de la conversation pendant qu'un tour tourne.
28+
29+## Alternatives écartées
30+
31+- **Tronquer les messages les plus anciens.** Écarté : on perdrait des résultats d'outils dont les appels resteraient, et l'objectif de la session.
32+- **Tout résumer, tours récents compris.** Écarté : le modèle refait alors ses dernières étapes.
33+- **Une clé `context.contextWindow` séparée.** Écartée au profit de la seule clé de premier niveau.
34+
35+## Liens avec le reste
36+
37+- L'activer et lire le rapport : [comment garder une longue session dans la fenêtre de contexte](../how-to/manage-the-context-window.md).
38+- Les clés `context.*` : [référence de la configuration](../reference/configuration.md).
39+- D'où vient la fenêtre : [fournisseurs](providers.md).
new file mode 100644
@@ -0,0 +1,39 @@
1+# Compression du contexte — explication
2+
3+## De quoi s'agit-il ?
4+
5+Rien dans une boucle d'agent ne raccourcit jamais l'historique. Mesurés face à un faux moteur, les messages envoyés au modèle ont grossi 2 → 5 → 7 → 9 sur quatre requêtes, et chaque sortie de `bash`, jusqu'à `maxOutput` caractères, et chaque skill chargé restent jusqu'au `/quit`. Sur un modèle local, la fenêtre de contexte est fixée au chargement du serveur, et tout l'historique est relu à chaque tour sur le matériel du portable. Une longue session se manifeste donc d'abord par un faux blocage du watchdog, puis par une erreur du serveur.
6+
7+Le paquet `compact` remplace les anciens tours de question par un résumé écrit par le modèle lui-même, et garde les quelques derniers tours bruts.
8+
9+## Pourquoi c'est conçu ainsi
10+
11+**Un hybride : résumé plus tours récents bruts.** Les tours récents sont là où le modèle travaille ; les résumer lui fait rejouer ce qu'il vient de faire. Le défaut garde trois tours de question, assez pour « fais X », « maintenant corrige », « et teste ». Tout ce qui est plus ancien devient une paire de messages insérée juste après le prompt système : un message utilisateur qui porte les notes, et un court accusé de réception du modèle.
12+
13+**La coupure tombe sur une frontière de tour.** Un appel d'outil et son résultat ne doivent jamais être séparés ; réutiliser des tours entiers le garantit.
14+
15+**Deux déclencheurs, parce que la fenêtre n'est pas toujours connue.** Le déclencheur principal est une part de la fenêtre de contexte, 75 % par défaut, qui laisse un quart pour la question suivante, ses sorties d'outils et la réponse. Le compte de tokens d'entrée rapporté par le serveur au dernier appel est préféré à l'estimation locale quand il est plus grand, parce qu'il voit le JSON des outils et le template de chat. Quand la fenêtre est inconnue, Docker Model Runner ne dit rien et `contextWindow` peut valoir `0`, seul le déclencheur sur le nombre de messages peut jouer, et l'agent le dit au démarrage.
16+
17+**Une seule clé pour la fenêtre.** La bannière et le déclencheur lisent le même `contextWindow` ; deux clés pour le même nombre finiraient par diverger.
18+
19+**Les résumés antérieurs sont fusionnés, pas re-résumés.** Les deux messages insérés portent un marqueur ; une compression ultérieure les reconnaît et replie les nouveaux tours anciens dans le résumé existant, parce que résumer un résumé perd du détail de façon exponentielle.
20+
21+**Un résumé de forme fixe.** Le prompt intégré demande sept sections avec chemins, lignes de commande et messages d'erreur exacts, et interdit de citer le contenu des fichiers : un fichier peut avoir changé depuis sa lecture, et le prompt système interdit déjà d'affirmer ce qu'une commande de cette réponse n'a pas renvoyé. La requête de résumé remplace le prompt système de l'agent par celui d'un preneur de notes, parce que « tu es un agent de code, exécute des commandes » envoyé avec un historique plein d'appels d'outils invite le modèle à continuer d'agir.
22+
23+**L'échec ne coûte rien.** Si la requête de résumé échoue, serveur arrêté, watchdog, résultat vide, l'historique est laissé intact et la question suivante part comme avant. La compression existe pour rendre la question suivante possible ; elle ne doit jamais en coûter une.
24+
25+**Le watchdog est deux fois plus patient ici.** Le résumé est le plus long préremplissage de la session et rien ne s'affiche pendant qu'il s'écrit.
26+
27+**`/compact` ignore le seuil, pas la frontière.** La commande garde toujours les derniers tours et le dit quand rien n'est plus ancien. La compression ne s'exécute jamais qu'entre deux générations, parce que Genkit détient sa propre copie de la conversation pendant qu'un tour tourne.
28+
29+## Alternatives écartées
30+
31+- **Tronquer les messages les plus anciens.** Écarté : on perdrait des résultats d'outils dont les appels resteraient, et l'objectif de la session.
32+- **Tout résumer, tours récents compris.** Écarté : le modèle refait alors ses dernières étapes.
33+- **Une clé `context.contextWindow` séparée.** Écartée au profit de la seule clé de premier niveau.
34+
35+## Liens avec le reste
36+
37+- L'activer et lire le rapport : [comment garder une longue session dans la fenêtre de contexte](../how-to/manage-the-context-window.md).
38+- Les clés `context.*` : [référence de la configuration](../reference/configuration.md).
39+- D'où vient la fenêtre : [fournisseurs](providers.md).
added docs/fr/explanation/providers.md +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+# Fournisseurs : une implémentation par protocole — explication
2+
3+## De quoi s'agit-il ?
4+
5+`mm` dialogue avec un serveur de modèle local via l'API chat-completions d'OpenAI. Deux serveurs sont pris en charge d'origine, Docker Model Runner et `llama-server` de llama.cpp, sous les noms `dmr` et `llamacpp`. Le paquet moteur était autrefois lié à Docker Model Runner ; l'abstraction de fournisseur est ce qui l'a généralisé.
6+
7+## Pourquoi c'est conçu ainsi
8+
9+**Un type de fournisseur par protocole réseau, pas par éditeur.** Quand le moteur a été extrait, exactement une fonction et deux constantes de chaîne étaient spécifiques à Docker Model Runner. Tout le reste, le streaming, le watchdog, la nouvelle tentative, le compteur de commandes, se comportait de la même façon face à un faux moteur qui n'est pas DMR non plus. Donc `dmr` et `llamacpp` sont deux entrées de données d'un seul type `openaiCompat` : une URL de base par défaut, une URL de repli optionnelle, une politique de clé, et les mots employés dans les messages d'erreur. Ajouter un autre serveur compatible OpenAI est une entrée de registre ; ajouter un protocole différent serait un nouveau type.
10+
11+**Un fournisseur possède quatre choses.** Il résout la configuration en un backend concret (URL après surcharges d'environnement et sonde de repli, clé lue dans l'environnement, nom du modèle) ; il ouvre Genkit avec son plugin ; il sonde le serveur avant la première question ; et il explique une erreur de transport en une ligne.
12+
13+**Le repli existe pour les conteneurs.** Sur l'hôte, Docker Model Runner écoute sur `localhost:12434` ; depuis un conteneur ou une sandbox, le même serveur est joignable en `host.docker.internal`. Si l'URL principale ne répond pas sur `/models` en deux secondes, le repli est utilisé. Une surcharge explicite par l'environnement saute la sonde : qui l'a posée sait où est le serveur. Un `fallback: ""` explicite le désactive, ce dont une configuration de test a besoin pour rester épinglée à une seule URL.
14+
15+**La clé ne va jamais dans le YAML.** Le fichier nomme la variable d'environnement qui contient la clé, ce qui permet de le committer et de le montrer à l'écran. Les serveurs qui ignorent l'en-tête reçoivent une valeur factice, parce que le client OpenAI veut quelque chose à cet endroit. Un 401 est alors expliqué en nommant la variable que le fournisseur a réellement lue, ou, quand aucune n'est configurée, en pointant l'URL.
16+
17+**La sonde est un conseil, jamais un échec.** Sur une machine de démo, le serveur est souvent démarré après l'agent. La sonde vérifie la joignabilité, avertit quand le modèle configuré n'est pas dans la liste de DMR, et sur llama.cpp lit la taille de contexte servie sur `/props`, un niveau au-dessus de `/v1`, parce que ce point d'accès rapporte ce que le serveur sert, pas ce sur quoi le modèle a été entraîné. Quand la fenêtre est encore inconnue et que la compression est activée, le REPL sonde à nouveau avant la première question.
18+
19+**Les erreurs sont réécrites dans les mots du serveur.** Une pile de messages imbriqués est du bruit ; « tool calls need llama-server started with --jinja » est tout le diagnostic. Le fournisseur reconnaît les refus de connexion, l'option `--jinja` manquante, les 401/403, les 404 avec une indication de pull ou d'alias, les 429 et les 5xx. Les erreurs inconnues reviennent inchangées : un message brut vaut mieux qu'une mauvaise piste.
20+
21+## Alternatives écartées
22+
23+- **Un fournisseur par éditeur avec du code moteur dupliqué.** Écarté : mesurée, la partie spécifique à l'éditeur tenait en trois identifiants.
24+- **Mettre la clé d'API dans le fichier de configuration.** Écarté parce que le fichier est fait pour être committé et montré.
25+- **Échouer au démarrage quand le serveur est arrêté.** Écarté parce que démarrer le serveur en second est la situation normale d'une démo.
26+
27+## Liens avec le reste
28+
29+- Les deux fichiers de configuration qui paramètrent chaque fournisseur sont listés dans la [référence de la configuration](../reference/configuration.md).
30+- La fenêtre apprise par la sonde est ce contre quoi la [compression du contexte](context-compression.md) mesure.
31+- Changer de serveur pour une exécution est couvert dans [comment faire tourner sur llama.cpp](../how-to/run-with-llama-cpp.md).
new file mode 100644
@@ -0,0 +1,31 @@
1+# Fournisseurs : une implémentation par protocole — explication
2+
3+## De quoi s'agit-il ?
4+
5+`mm` dialogue avec un serveur de modèle local via l'API chat-completions d'OpenAI. Deux serveurs sont pris en charge d'origine, Docker Model Runner et `llama-server` de llama.cpp, sous les noms `dmr` et `llamacpp`. Le paquet moteur était autrefois lié à Docker Model Runner ; l'abstraction de fournisseur est ce qui l'a généralisé.
6+
7+## Pourquoi c'est conçu ainsi
8+
9+**Un type de fournisseur par protocole réseau, pas par éditeur.** Quand le moteur a été extrait, exactement une fonction et deux constantes de chaîne étaient spécifiques à Docker Model Runner. Tout le reste, le streaming, le watchdog, la nouvelle tentative, le compteur de commandes, se comportait de la même façon face à un faux moteur qui n'est pas DMR non plus. Donc `dmr` et `llamacpp` sont deux entrées de données d'un seul type `openaiCompat` : une URL de base par défaut, une URL de repli optionnelle, une politique de clé, et les mots employés dans les messages d'erreur. Ajouter un autre serveur compatible OpenAI est une entrée de registre ; ajouter un protocole différent serait un nouveau type.
10+
11+**Un fournisseur possède quatre choses.** Il résout la configuration en un backend concret (URL après surcharges d'environnement et sonde de repli, clé lue dans l'environnement, nom du modèle) ; il ouvre Genkit avec son plugin ; il sonde le serveur avant la première question ; et il explique une erreur de transport en une ligne.
12+
13+**Le repli existe pour les conteneurs.** Sur l'hôte, Docker Model Runner écoute sur `localhost:12434` ; depuis un conteneur ou une sandbox, le même serveur est joignable en `host.docker.internal`. Si l'URL principale ne répond pas sur `/models` en deux secondes, le repli est utilisé. Une surcharge explicite par l'environnement saute la sonde : qui l'a posée sait où est le serveur. Un `fallback: ""` explicite le désactive, ce dont une configuration de test a besoin pour rester épinglée à une seule URL.
14+
15+**La clé ne va jamais dans le YAML.** Le fichier nomme la variable d'environnement qui contient la clé, ce qui permet de le committer et de le montrer à l'écran. Les serveurs qui ignorent l'en-tête reçoivent une valeur factice, parce que le client OpenAI veut quelque chose à cet endroit. Un 401 est alors expliqué en nommant la variable que le fournisseur a réellement lue, ou, quand aucune n'est configurée, en pointant l'URL.
16+
17+**La sonde est un conseil, jamais un échec.** Sur une machine de démo, le serveur est souvent démarré après l'agent. La sonde vérifie la joignabilité, avertit quand le modèle configuré n'est pas dans la liste de DMR, et sur llama.cpp lit la taille de contexte servie sur `/props`, un niveau au-dessus de `/v1`, parce que ce point d'accès rapporte ce que le serveur sert, pas ce sur quoi le modèle a été entraîné. Quand la fenêtre est encore inconnue et que la compression est activée, le REPL sonde à nouveau avant la première question.
18+
19+**Les erreurs sont réécrites dans les mots du serveur.** Une pile de messages imbriqués est du bruit ; « tool calls need llama-server started with --jinja » est tout le diagnostic. Le fournisseur reconnaît les refus de connexion, l'option `--jinja` manquante, les 401/403, les 404 avec une indication de pull ou d'alias, les 429 et les 5xx. Les erreurs inconnues reviennent inchangées : un message brut vaut mieux qu'une mauvaise piste.
20+
21+## Alternatives écartées
22+
23+- **Un fournisseur par éditeur avec du code moteur dupliqué.** Écarté : mesurée, la partie spécifique à l'éditeur tenait en trois identifiants.
24+- **Mettre la clé d'API dans le fichier de configuration.** Écarté parce que le fichier est fait pour être committé et montré.
25+- **Échouer au démarrage quand le serveur est arrêté.** Écarté parce que démarrer le serveur en second est la situation normale d'une démo.
26+
27+## Liens avec le reste
28+
29+- Les deux fichiers de configuration qui paramètrent chaque fournisseur sont listés dans la [référence de la configuration](../reference/configuration.md).
30+- La fenêtre apprise par la sonde est ce contre quoi la [compression du contexte](context-compression.md) mesure.
31+- Changer de serveur pour une exécution est couvert dans [comment faire tourner sur llama.cpp](../how-to/run-with-llama-cpp.md).
added docs/fr/how-to/add-a-skill.md +36 -0
new file mode 100644
@@ -0,0 +1,36 @@
1+# Comment ajouter un skill
2+
3+Ce guide montre comment donner à l'agent une nouvelle procédure en markdown que le modèle peut charger avec `read_skill`. Il suppose que vous savez quelle tâche la procédure doit couvrir.
4+
5+## Étapes
6+
7+1. Créez le skill dans le répertoire des skills (`skills/` par défaut, voir `skillsDir`), dans l'une des deux dispositions : un fichier à plat `skills/<nom>.md`, ou un répertoire par skill `skills/<nom>/SKILL.md` (la disposition des exemples livrés). Les deux peuvent se mélanger ; tout autre fichier dans le répertoire d'un skill est ignoré.
8+
9+ ```bash
10+ cat > skills/go-test.md <<'EOF2'
11+ ---
12+ name: go-test
13+ description: run the Go tests of the current module and report failures
14+ ---
15+ # Run the Go tests
16+
17+ Run `go test ./...` with your `bash` tool, then report the failing packages, if any.
18+ EOF2
19+ ```
20+
21+2. Donnez-lui un `name:` et une `description:` d'une ligne dans l'en-tête. La description est ce que le modèle lit dans la description de l'outil `read_skill` pour choisir un skill ; sans elle, il choisit à l'aveugle.
22+
23+3. Relancez `mm`. Le catalogue est construit une fois au démarrage. Le compteur `skills: N` de la bannière augmente de un, et `read_skill` apparaît dans la liste `tools:` s'il en était absent.
24+
25+4. Demandez la tâche dans le terminal. Le prompt système d'`agent.yaml` dit au modèle d'appeler `read_skill` d'abord pour toute demande qui correspond au catalogue ; une ligne `📖 read_skill: go-test` confirme qu'il l'a fait.
26+
27+## Variantes
28+
29+- **Un autre répertoire.** Fixez `skillsDir` dans le YAML. Dans le terminal, un chemin relatif est résolu depuis le répertoire d'où vous lancez `mm`, donc un `mm` installé et lancé dans un projet charge les skills de ce projet ; en mode ACP avec un fichier de configuration, il est résolu à côté de ce fichier. La bannière affiche `[warning: no skills found in <chemin absolu> …]` quand le répertoire résolu ne donne rien.
30+- **Aucun skill.** Laissez le répertoire vide ou absent : `read_skill` n'est alors pas déclaré, et le modèle ne le voit jamais.
31+- **Les exemples livrés.** Le dépôt livre `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` et `skills/what-time-is-it/SKILL.md`. Lancez `mm` depuis la racine du dépôt et la bannière affiche `skills: 3`.
32+
33+## Voir aussi
34+
35+- Les champs acceptés dans l'en-tête : [format d'un fichier de skill](../reference/skill-format.md)
36+- Ce que `read_skill` renvoie au modèle : [outils intégrés](../reference/tools.md)
new file mode 100644
@@ -0,0 +1,36 @@
1+# Comment ajouter un skill
2+
3+Ce guide montre comment donner à l'agent une nouvelle procédure en markdown que le modèle peut charger avec `read_skill`. Il suppose que vous savez quelle tâche la procédure doit couvrir.
4+
5+## Étapes
6+
7+1. Créez le skill dans le répertoire des skills (`skills/` par défaut, voir `skillsDir`), dans l'une des deux dispositions : un fichier à plat `skills/<nom>.md`, ou un répertoire par skill `skills/<nom>/SKILL.md` (la disposition des exemples livrés). Les deux peuvent se mélanger ; tout autre fichier dans le répertoire d'un skill est ignoré.
8+
9+ ```bash
10+ cat > skills/go-test.md <<'EOF2'
11+ ---
12+ name: go-test
13+ description: run the Go tests of the current module and report failures
14+ ---
15+ # Run the Go tests
16+
17+ Run `go test ./...` with your `bash` tool, then report the failing packages, if any.
18+ EOF2
19+ ```
20+
21+2. Donnez-lui un `name:` et une `description:` d'une ligne dans l'en-tête. La description est ce que le modèle lit dans la description de l'outil `read_skill` pour choisir un skill ; sans elle, il choisit à l'aveugle.
22+
23+3. Relancez `mm`. Le catalogue est construit une fois au démarrage. Le compteur `skills: N` de la bannière augmente de un, et `read_skill` apparaît dans la liste `tools:` s'il en était absent.
24+
25+4. Demandez la tâche dans le terminal. Le prompt système d'`agent.yaml` dit au modèle d'appeler `read_skill` d'abord pour toute demande qui correspond au catalogue ; une ligne `📖 read_skill: go-test` confirme qu'il l'a fait.
26+
27+## Variantes
28+
29+- **Un autre répertoire.** Fixez `skillsDir` dans le YAML. Dans le terminal, un chemin relatif est résolu depuis le répertoire d'où vous lancez `mm`, donc un `mm` installé et lancé dans un projet charge les skills de ce projet ; en mode ACP avec un fichier de configuration, il est résolu à côté de ce fichier. La bannière affiche `[warning: no skills found in <chemin absolu> …]` quand le répertoire résolu ne donne rien.
30+- **Aucun skill.** Laissez le répertoire vide ou absent : `read_skill` n'est alors pas déclaré, et le modèle ne le voit jamais.
31+- **Les exemples livrés.** Le dépôt livre `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` et `skills/what-time-is-it/SKILL.md`. Lancez `mm` depuis la racine du dépôt et la bannière affiche `skills: 3`.
32+
33+## Voir aussi
34+
35+- Les champs acceptés dans l'en-tête : [format d'un fichier de skill](../reference/skill-format.md)
36+- Ce que `read_skill` renvoie au modèle : [outils intégrés](../reference/tools.md)
added docs/fr/how-to/attach-a-file.md +44 -0
new file mode 100644
@@ -0,0 +1,44 @@
1+# Désigner un fichier au modèle avec `@chemin`
2+
3+Nommez un fichier ou un répertoire dans votre question avec `@` suivi de son chemin. mini-me vérifie que le chemin existe, indique au modèle où il se trouve, et le modèle le lit avec ses outils. Il n'y a ni sélecteur ni complétion : vous tapez le chemin.
4+
5+## Étapes
6+
7+1. Lancez `mm` depuis le répertoire du projet. Les chemins relatifs sont résolus depuis là.
8+2. Écrivez le chemin dans la question, collé à `@` :
9+
10+ ```
11+ > explique ce que fait @internal/acp/acp.go avec @internal/session
12+ 📎 /work/mini-me/internal/acp/acp.go
13+ 📎 /work/mini-me/internal/session
14+ ```
15+
16+ Une ligne `📎` par chemin attaché confirme que la notation a été comprise. Le modèle reçoit votre phrase telle que tapée, suivie d'une ligne par chemin :
17+
18+ ```
19+ [attached file: /work/mini-me/internal/acp/acp.go]
20+ [attached directory: /work/mini-me/internal/session]
21+ ```
22+
23+3. Posez votre question. Le contenu n'est pas copié dans le prompt : le modèle exécute `cat`, `read_file` ou `ls` sur le chemin quand il en a besoin.
24+
25+## Règles
26+
27+| Cas | Résultat |
28+|-----|----------|
29+| `@internal/acp/acp.go` | Relatif au répertoire de lancement de `mm` (le `cwd` de la session en mode ACP). |
30+| `@/chemin/absolu`, `@~/fichier` | Les chemins absolus et `~` fonctionnent. |
31+| `@main.go,` `(@main.go)` `"@main.go"` | La ponctuation et les parenthèses autour de la mention sont ignorées. |
32+| `@bob`, `@n/existe/pas` | Rien sur le disque : laissé tel quel dans la phrase, aucune pièce jointe. |
33+| `k33g@example.com` | Un `@` collé à un mot n'est pas une mention. |
34+| Même chemin deux fois | Attaché une seule fois. |
35+| Chemin avec espaces | Non pris en charge : une mention s'arrête au premier espace. |
36+
37+## Dans un éditeur (ACP)
38+
39+Le sélecteur `@` de l'éditeur envoie le fichier comme `resource_link`, que mini-me transforme en la même ligne `[attached file: …]`. Un chemin tapé à la main, sans le sélecteur, est compris de la même façon, relativement au projet ouvert dans l'éditeur. Les chemins attachés sont journalisés sur stderr sous la forme `[acp] session <id>: attached <chemin>`.
40+
41+## Voir aussi
42+
43+- [Outils intégrés](../reference/tools.md) — ce avec quoi le modèle lit le fichier
44+- [Utiliser mini-me depuis un éditeur](use-from-an-editor.md)
new file mode 100644
@@ -0,0 +1,44 @@
1+# Désigner un fichier au modèle avec `@chemin`
2+
3+Nommez un fichier ou un répertoire dans votre question avec `@` suivi de son chemin. mini-me vérifie que le chemin existe, indique au modèle où il se trouve, et le modèle le lit avec ses outils. Il n'y a ni sélecteur ni complétion : vous tapez le chemin.
4+
5+## Étapes
6+
7+1. Lancez `mm` depuis le répertoire du projet. Les chemins relatifs sont résolus depuis là.
8+2. Écrivez le chemin dans la question, collé à `@` :
9+
10+ ```
11+ > explique ce que fait @internal/acp/acp.go avec @internal/session
12+ 📎 /work/mini-me/internal/acp/acp.go
13+ 📎 /work/mini-me/internal/session
14+ ```
15+
16+ Une ligne `📎` par chemin attaché confirme que la notation a été comprise. Le modèle reçoit votre phrase telle que tapée, suivie d'une ligne par chemin :
17+
18+ ```
19+ [attached file: /work/mini-me/internal/acp/acp.go]
20+ [attached directory: /work/mini-me/internal/session]
21+ ```
22+
23+3. Posez votre question. Le contenu n'est pas copié dans le prompt : le modèle exécute `cat`, `read_file` ou `ls` sur le chemin quand il en a besoin.
24+
25+## Règles
26+
27+| Cas | Résultat |
28+|-----|----------|
29+| `@internal/acp/acp.go` | Relatif au répertoire de lancement de `mm` (le `cwd` de la session en mode ACP). |
30+| `@/chemin/absolu`, `@~/fichier` | Les chemins absolus et `~` fonctionnent. |
31+| `@main.go,` `(@main.go)` `"@main.go"` | La ponctuation et les parenthèses autour de la mention sont ignorées. |
32+| `@bob`, `@n/existe/pas` | Rien sur le disque : laissé tel quel dans la phrase, aucune pièce jointe. |
33+| `k33g@example.com` | Un `@` collé à un mot n'est pas une mention. |
34+| Même chemin deux fois | Attaché une seule fois. |
35+| Chemin avec espaces | Non pris en charge : une mention s'arrête au premier espace. |
36+
37+## Dans un éditeur (ACP)
38+
39+Le sélecteur `@` de l'éditeur envoie le fichier comme `resource_link`, que mini-me transforme en la même ligne `[attached file: …]`. Un chemin tapé à la main, sans le sélecteur, est compris de la même façon, relativement au projet ouvert dans l'éditeur. Les chemins attachés sont journalisés sur stderr sous la forme `[acp] session <id>: attached <chemin>`.
40+
41+## Voir aussi
42+
43+- [Outils intégrés](../reference/tools.md) — ce avec quoi le modèle lit le fichier
44+- [Utiliser mini-me depuis un éditeur](use-from-an-editor.md)
added docs/fr/how-to/manage-the-context-window.md +37 -0
new file mode 100644
@@ -0,0 +1,37 @@
1+# Comment garder une longue session dans la fenêtre de contexte
2+
3+Ce guide montre comment empêcher une longue session de déborder du contexte du modèle. Il suppose que vous faites déjà tourner `mm` et que vous savez quel serveur sert votre modèle.
4+
5+## Étapes
6+
7+1. Indiquez à l'agent la taille de la fenêtre. Soit vous fixez `contextWindow` dans le YAML à la valeur avec laquelle vous avez démarré le serveur, soit vous le laissez à `0` sur llama.cpp, où l'agent lit `n_ctx` sur `/props`. La bannière affiche le résultat sous la forme `ctx: <taille> (config)` ou `ctx: <taille> (/props)`.
8+
9+2. Activez la compression automatique :
10+
11+ ```yaml
12+ context:
13+ enabled: true
14+ threshold: 75
15+ keepLastTurns: 3
16+ ```
17+
18+ Avant chaque question, quand l'historique atteint 75 % de la fenêtre, les anciens tours sont remplacés par un résumé écrit par le modèle et les trois derniers tours de question sont conservés tels quels.
19+
20+3. Observez le rapport. Avec `showStats: true` (la valeur par défaut), chaque compression affiche une ligne grisée :
21+
22+ ```
23+ 🗜️ compressed 14 messages → 1 summary + 7 kept (18.2k → 4.1k tokens, 6.3s)
24+ ```
25+
26+## Variantes
27+
28+- **La fenêtre reste inconnue** (Docker Model Runner ne dit rien, et `contextWindow` vaut `0`). Seul le déclencheur `maxMessages` peut se déclencher ; l'agent le signale par un avertissement au démarrage. Fixez `maxMessages` à un nombre de messages (une commande coûte deux messages) ou fixez `contextWindow`.
29+- **Compresser à la main.** Tapez `/compact` à l'invite, ou envoyez-la depuis l'éditeur en mode ACP. La commande ignore le seuil mais conserve toujours les `keepLastTurns` derniers tours, et affiche `🗜️ nothing to compact` quand rien n'est plus ancien.
30+- **Le serveur a été démarré après l'agent.** La fenêtre est re-sondée avant la première question tant qu'elle est inconnue et que la compression est activée ; pas besoin de relancer.
31+- **Votre propre prompt de résumé.** Fixez `context.prompt` pour remplacer le prompt intégré en sept sections.
32+- **Une compression qui échoue** (serveur arrêté, watchdog) laisse l'historique inchangé et affiche `[compact: failed, history kept: …]`.
33+
34+## Voir aussi
35+
36+- Chaque clé `context.*` et sa valeur par défaut : [référence de la configuration](../reference/configuration.md)
37+- Pourquoi le résumé garde les tours récents bruts et fusionne les résumés antérieurs : [compression du contexte](../explanation/context-compression.md)
new file mode 100644
@@ -0,0 +1,37 @@
1+# Comment garder une longue session dans la fenêtre de contexte
2+
3+Ce guide montre comment empêcher une longue session de déborder du contexte du modèle. Il suppose que vous faites déjà tourner `mm` et que vous savez quel serveur sert votre modèle.
4+
5+## Étapes
6+
7+1. Indiquez à l'agent la taille de la fenêtre. Soit vous fixez `contextWindow` dans le YAML à la valeur avec laquelle vous avez démarré le serveur, soit vous le laissez à `0` sur llama.cpp, où l'agent lit `n_ctx` sur `/props`. La bannière affiche le résultat sous la forme `ctx: <taille> (config)` ou `ctx: <taille> (/props)`.
8+
9+2. Activez la compression automatique :
10+
11+ ```yaml
12+ context:
13+ enabled: true
14+ threshold: 75
15+ keepLastTurns: 3
16+ ```
17+
18+ Avant chaque question, quand l'historique atteint 75 % de la fenêtre, les anciens tours sont remplacés par un résumé écrit par le modèle et les trois derniers tours de question sont conservés tels quels.
19+
20+3. Observez le rapport. Avec `showStats: true` (la valeur par défaut), chaque compression affiche une ligne grisée :
21+
22+ ```
23+ 🗜️ compressed 14 messages → 1 summary + 7 kept (18.2k → 4.1k tokens, 6.3s)
24+ ```
25+
26+## Variantes
27+
28+- **La fenêtre reste inconnue** (Docker Model Runner ne dit rien, et `contextWindow` vaut `0`). Seul le déclencheur `maxMessages` peut se déclencher ; l'agent le signale par un avertissement au démarrage. Fixez `maxMessages` à un nombre de messages (une commande coûte deux messages) ou fixez `contextWindow`.
29+- **Compresser à la main.** Tapez `/compact` à l'invite, ou envoyez-la depuis l'éditeur en mode ACP. La commande ignore le seuil mais conserve toujours les `keepLastTurns` derniers tours, et affiche `🗜️ nothing to compact` quand rien n'est plus ancien.
30+- **Le serveur a été démarré après l'agent.** La fenêtre est re-sondée avant la première question tant qu'elle est inconnue et que la compression est activée ; pas besoin de relancer.
31+- **Votre propre prompt de résumé.** Fixez `context.prompt` pour remplacer le prompt intégré en sept sections.
32+- **Une compression qui échoue** (serveur arrêté, watchdog) laisse l'historique inchangé et affiche `[compact: failed, history kept: …]`.
33+
34+## Voir aussi
35+
36+- Chaque clé `context.*` et sa valeur par défaut : [référence de la configuration](../reference/configuration.md)
37+- Pourquoi le résumé garde les tours récents bruts et fusionne les résumés antérieurs : [compression du contexte](../explanation/context-compression.md)
added docs/fr/how-to/run-with-llama-cpp.md +33 -0
new file mode 100644
@@ -0,0 +1,33 @@
1+# Comment faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner
2+
3+Ce guide montre comment pointer `mm` vers un `llama-server` que vous démarrez vous-même. Il suppose que vous savez déjà compiler `mm` et obtenir un modèle GGUF.
4+
5+## Étapes
6+
7+1. Démarrez `llama-server` avec un template de chat et l'appel d'outils activé. `--jinja` est obligatoire : sans lui, le serveur refuse le paramètre `tools` et l'agent ne peut exécuter aucune commande. Donnez au modèle servi un alias avec `-a`, car `llama-server` route les requêtes sur le champ `model` :
8+
9+ ```bash
10+ llama-server -hf jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \
11+ -a jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \
12+ --jinja -c 32768 --port 8080
13+ ```
14+
15+2. Utilisez le fichier de configuration llama.cpp livré. Sa clé `model:` doit correspondre à l'alias `-a` ci-dessus :
16+
17+ ```bash
18+ ./mm agent.llamacpp.yaml
19+ ```
20+
21+3. Vérifiez la bannière. `provider: llamacpp` confirme le fournisseur, et `ctx: 32768 (/props)` confirme que l'agent a lu la taille de contexte servie sur le point d'accès `/props` du serveur.
22+
23+## Variantes
24+
25+- **Autre port ou autre hôte.** Mettez `baseUrl` à `http://<hôte>:<port>/v1` dans le YAML, ou surchargez pour une seule exécution avec `AGENT_BASE_URL=http://<hôte>:<port>/v1 ./mm agent.llamacpp.yaml`. Une surcharge par l'environnement est prise telle quelle, sans sonde de repli.
26+- **Serveur démarré avec `--api-key`.** Exportez la clé dans `LLAMA_API_KEY` (la variable par défaut du fournisseur) ou nommez une autre variable dans `apiKeyEnv`. La clé elle-même ne va jamais dans le YAML.
27+- **Garder `agent.yaml` et changer de fournisseur pour une exécution.** `AGENT_PROVIDER=llamacpp ./mm` fonctionne tant qu'`agent.yaml` laisse `baseUrl` vide ; une URL DMR explicite dans le fichier serait respectée et pointerait toujours sur le port 12434.
28+- **Le serveur masque `/props`** (un reverse proxy, par exemple). Fixez `contextWindow` à la valeur avec laquelle vous avez démarré le serveur, sinon la bannière affiche `ctx: unknown`.
29+
30+## Voir aussi
31+
32+- Chaque clé du fichier : [référence de la configuration](../reference/configuration.md)
33+- Pourquoi une seule implémentation sert les deux serveurs : [fournisseurs](../explanation/providers.md)
new file mode 100644
@@ -0,0 +1,33 @@
1+# Comment faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner
2+
3+Ce guide montre comment pointer `mm` vers un `llama-server` que vous démarrez vous-même. Il suppose que vous savez déjà compiler `mm` et obtenir un modèle GGUF.
4+
5+## Étapes
6+
7+1. Démarrez `llama-server` avec un template de chat et l'appel d'outils activé. `--jinja` est obligatoire : sans lui, le serveur refuse le paramètre `tools` et l'agent ne peut exécuter aucune commande. Donnez au modèle servi un alias avec `-a`, car `llama-server` route les requêtes sur le champ `model` :
8+
9+ ```bash
10+ llama-server -hf jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \
11+ -a jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \
12+ --jinja -c 32768 --port 8080
13+ ```
14+
15+2. Utilisez le fichier de configuration llama.cpp livré. Sa clé `model:` doit correspondre à l'alias `-a` ci-dessus :
16+
17+ ```bash
18+ ./mm agent.llamacpp.yaml
19+ ```
20+
21+3. Vérifiez la bannière. `provider: llamacpp` confirme le fournisseur, et `ctx: 32768 (/props)` confirme que l'agent a lu la taille de contexte servie sur le point d'accès `/props` du serveur.
22+
23+## Variantes
24+
25+- **Autre port ou autre hôte.** Mettez `baseUrl` à `http://<hôte>:<port>/v1` dans le YAML, ou surchargez pour une seule exécution avec `AGENT_BASE_URL=http://<hôte>:<port>/v1 ./mm agent.llamacpp.yaml`. Une surcharge par l'environnement est prise telle quelle, sans sonde de repli.
26+- **Serveur démarré avec `--api-key`.** Exportez la clé dans `LLAMA_API_KEY` (la variable par défaut du fournisseur) ou nommez une autre variable dans `apiKeyEnv`. La clé elle-même ne va jamais dans le YAML.
27+- **Garder `agent.yaml` et changer de fournisseur pour une exécution.** `AGENT_PROVIDER=llamacpp ./mm` fonctionne tant qu'`agent.yaml` laisse `baseUrl` vide ; une URL DMR explicite dans le fichier serait respectée et pointerait toujours sur le port 12434.
28+- **Le serveur masque `/props`** (un reverse proxy, par exemple). Fixez `contextWindow` à la valeur avec laquelle vous avez démarré le serveur, sinon la bannière affiche `ctx: unknown`.
29+
30+## Voir aussi
31+
32+- Chaque clé du fichier : [référence de la configuration](../reference/configuration.md)
33+- Pourquoi une seule implémentation sert les deux serveurs : [fournisseurs](../explanation/providers.md)
added docs/fr/how-to/start-a-new-session.md +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+# Comment démarrer une nouvelle session sans relancer `mm`
2+
3+Ce guide montre comment effacer la conversation et repartir d'un historique vide, dans le terminal et depuis un éditeur. Il suppose que `mm` tourne et a déjà répondu à au moins une question.
4+
5+## Dans le terminal
6+
7+1. Attendez l'invite (`> `). La commande n'agit qu'entre deux générations ; tapée pendant que le modèle travaille, elle annule d'abord cette génération, puis s'applique.
8+
9+2. Tapez la commande :
10+
11+ ```text
12+ > /new
13+ 🆕 new session: 12 message(s) forgotten
14+ ```
15+
16+ Le compte porte sur tous les messages sauf le prompt système : vos questions, les réponses du modèle et les tours d'outils entre les deux. Sur une session déjà vide, il affiche `0 message(s) forgotten`.
17+
18+3. Posez votre question suivante. Le modèle ne voit que le prompt système et cette question ; rien d'antérieur à `/new` ne lui est envoyé.
19+
20+## Depuis un éditeur (ACP)
21+
22+1. Tapez `/new` dans le panneau de l'agent. Les éditeurs qui listent les commandes de l'agent la proposent dans le menu des commandes slash avec la description *Clear the history and start a new session* ; la taper à la main fonctionne aussi.
23+
24+2. Lisez la confirmation dans le fil : `🆕 New session: 12 message(s) forgotten.` Le tour s'arrête là, le modèle n'est pas consulté.
25+
26+3. Continuez à travailler dans le même fil. La session garde son identifiant, son répertoire de travail et les outils que vous avez déjà autorisés « pour cette session » ; seul l'historique disparaît.
27+
28+## Ce que `/new` réinitialise, et ce qu'il conserve
29+
30+| Réinitialisé | Conservé |
31+|--------------|----------|
32+| L'historique de la conversation (retour au seul prompt système). | La configuration, le modèle, les outils. |
33+| La mémoire du détecteur de boucles sur les commandes répétées (terminal). | Le répertoire de travail. |
34+| Le compte de tokens du dernier contexte mesuré par le serveur, qui sert à décider la compression automatique. | En mode ACP, l'identifiant de session et les autorisations « toujours autoriser ». |
35+
36+## Voir aussi
37+
38+- [Référence des commandes slash](../reference/slash-commands.md) — chaque commande, à l'invite et pendant une génération.
39+- [Référence ACP](../reference/acp.md) — comment la commande est annoncée et traitée par le protocole.
40+- [Garder une longue session dans la fenêtre de contexte](manage-the-context-window.md) — `/compact` raccourcit l'historique sans l'oublier ; `/new` l'oublie.
new file mode 100644
@@ -0,0 +1,40 @@
1+# Comment démarrer une nouvelle session sans relancer `mm`
2+
3+Ce guide montre comment effacer la conversation et repartir d'un historique vide, dans le terminal et depuis un éditeur. Il suppose que `mm` tourne et a déjà répondu à au moins une question.
4+
5+## Dans le terminal
6+
7+1. Attendez l'invite (`> `). La commande n'agit qu'entre deux générations ; tapée pendant que le modèle travaille, elle annule d'abord cette génération, puis s'applique.
8+
9+2. Tapez la commande :
10+
11+ ```text
12+ > /new
13+ 🆕 new session: 12 message(s) forgotten
14+ ```
15+
16+ Le compte porte sur tous les messages sauf le prompt système : vos questions, les réponses du modèle et les tours d'outils entre les deux. Sur une session déjà vide, il affiche `0 message(s) forgotten`.
17+
18+3. Posez votre question suivante. Le modèle ne voit que le prompt système et cette question ; rien d'antérieur à `/new` ne lui est envoyé.
19+
20+## Depuis un éditeur (ACP)
21+
22+1. Tapez `/new` dans le panneau de l'agent. Les éditeurs qui listent les commandes de l'agent la proposent dans le menu des commandes slash avec la description *Clear the history and start a new session* ; la taper à la main fonctionne aussi.
23+
24+2. Lisez la confirmation dans le fil : `🆕 New session: 12 message(s) forgotten.` Le tour s'arrête là, le modèle n'est pas consulté.
25+
26+3. Continuez à travailler dans le même fil. La session garde son identifiant, son répertoire de travail et les outils que vous avez déjà autorisés « pour cette session » ; seul l'historique disparaît.
27+
28+## Ce que `/new` réinitialise, et ce qu'il conserve
29+
30+| Réinitialisé | Conservé |
31+|--------------|----------|
32+| L'historique de la conversation (retour au seul prompt système). | La configuration, le modèle, les outils. |
33+| La mémoire du détecteur de boucles sur les commandes répétées (terminal). | Le répertoire de travail. |
34+| Le compte de tokens du dernier contexte mesuré par le serveur, qui sert à décider la compression automatique. | En mode ACP, l'identifiant de session et les autorisations « toujours autoriser ». |
35+
36+## Voir aussi
37+
38+- [Référence des commandes slash](../reference/slash-commands.md) — chaque commande, à l'invite et pendant une génération.
39+- [Référence ACP](../reference/acp.md) — comment la commande est annoncée et traitée par le protocole.
40+- [Garder une longue session dans la fenêtre de contexte](manage-the-context-window.md) — `/compact` raccourcit l'historique sans l'oublier ; `/new` l'oublie.
added docs/fr/how-to/use-from-an-editor.md +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+# Comment utiliser mini-me depuis un éditeur (ACP)
2+
3+Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client Protocol, comme Zed. Il suppose que `mm` est compilé et installé dans votre `PATH` (`./install.sh` le copie dans `/usr/local/bin`), et que votre serveur de modèle tourne.
4+
5+## Étapes
6+
7+1. Déclarez `mm` comme serveur d'agent dans l'éditeur, avec l'option `-acp`. Dans Zed, ajoutez à `settings.json` :
8+
9+ ```json
10+ {
11+ "agent_servers": {
12+ "mini-me": {
13+ "type": "custom",
14+ "command": "mm",
15+ "args": ["-acp"],
16+ "env": { "AGENT_CONFIG": "/chemin/absolu/vers/agent.yaml" }
17+ }
18+ }
19+ }
20+ ```
21+
22+2. Donnez à `AGENT_CONFIG` un chemin absolu. L'éditeur lance `mm` avec le répertoire du projet comme répertoire de travail, donc un `./agent.yaml` relatif ne serait pas trouvé. En mode ACP, un `skillsDir` relatif dans ce fichier est résolu à côté du fichier, pas à côté du répertoire de travail de l'éditeur.
23+
24+3. Ouvrez le panneau d'agent et démarrez une conversation. Chaque appel à `bash`, `read_file`, `write_file` et `edit_file` apparaît comme un appel d'outil ; l'éditeur vous demande de l'autoriser avant exécution. `read_skill` s'exécute sans dialogue.
25+
26+4. Lisez les journaux quand quelque chose semble anormal. En mode ACP, rien de lisible par un humain ne va sur stdout : la bannière, les avertissements et une trace `[acp] …` vont sur stderr, que Zed affiche via `dev: open acp logs`.
27+
28+## Variantes
29+
30+- **Autoriser un outil pour toute la session.** Choisissez « Allow bash for this session » dans le dialogue de permission ; le choix est mémorisé par nom d'outil jusqu'à la fin de la session.
31+- **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP.
32+- **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp).
33+- **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils.
34+- **Lancer depuis un terminal pour déboguer le protocole.** `mm -acp` lit du JSON-RPC sur stdin et l'écrit sur stdout, un message par ligne.
35+
36+## Voir aussi
37+
38+- Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md)
39+- Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md)
new file mode 100644
@@ -0,0 +1,39 @@
1+# Comment utiliser mini-me depuis un éditeur (ACP)
2+
3+Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client Protocol, comme Zed. Il suppose que `mm` est compilé et installé dans votre `PATH` (`./install.sh` le copie dans `/usr/local/bin`), et que votre serveur de modèle tourne.
4+
5+## Étapes
6+
7+1. Déclarez `mm` comme serveur d'agent dans l'éditeur, avec l'option `-acp`. Dans Zed, ajoutez à `settings.json` :
8+
9+ ```json
10+ {
11+ "agent_servers": {
12+ "mini-me": {
13+ "type": "custom",
14+ "command": "mm",
15+ "args": ["-acp"],
16+ "env": { "AGENT_CONFIG": "/chemin/absolu/vers/agent.yaml" }
17+ }
18+ }
19+ }
20+ ```
21+
22+2. Donnez à `AGENT_CONFIG` un chemin absolu. L'éditeur lance `mm` avec le répertoire du projet comme répertoire de travail, donc un `./agent.yaml` relatif ne serait pas trouvé. En mode ACP, un `skillsDir` relatif dans ce fichier est résolu à côté du fichier, pas à côté du répertoire de travail de l'éditeur.
23+
24+3. Ouvrez le panneau d'agent et démarrez une conversation. Chaque appel à `bash`, `read_file`, `write_file` et `edit_file` apparaît comme un appel d'outil ; l'éditeur vous demande de l'autoriser avant exécution. `read_skill` s'exécute sans dialogue.
25+
26+4. Lisez les journaux quand quelque chose semble anormal. En mode ACP, rien de lisible par un humain ne va sur stdout : la bannière, les avertissements et une trace `[acp] …` vont sur stderr, que Zed affiche via `dev: open acp logs`.
27+
28+## Variantes
29+
30+- **Autoriser un outil pour toute la session.** Choisissez « Allow bash for this session » dans le dialogue de permission ; le choix est mémorisé par nom d'outil jusqu'à la fin de la session.
31+- **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP.
32+- **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp).
33+- **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils.
34+- **Lancer depuis un terminal pour déboguer le protocole.** `mm -acp` lit du JSON-RPC sur stdin et l'écrit sur stdout, un message par ligne.
35+
36+## Voir aussi
37+
38+- Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md)
39+- Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md)
added docs/fr/reference/acp.md +92 -0
new file mode 100644
@@ -0,0 +1,92 @@
1+# Référence : surface Agent Client Protocol
2+
3+> Description neutre de ce que `mm -acp` expose en JSON-RPC 2.0 sur stdio, via `github.com/coder/acp-go-sdk` v0.13.5.
4+
5+## Transport
6+
7+| Aspect | Valeur |
8+|--------|--------|
9+| Entrée | stdin, un message JSON-RPC par ligne. |
10+| Sortie | stdout, messages JSON-RPC uniquement. |
11+| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK. |
12+| Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. |
13+
14+## Réponse à `initialize`
15+
16+| Champ | Valeur |
17+|-------|--------|
18+| `protocolVersion` | `1` |
19+| `agentCapabilities` | Toutes absentes (false) : pas de `loadSession`, pas de prompts image ou audio, pas de MCP HTTP. |
20+| `agentInfo.name` | `bob` |
21+| `agentInfo.title` | `Bob (bash-first agent)` |
22+| `agentInfo.version` | `0.11.0` |
23+| `authMethods` | `[]` |
24+
25+## Méthodes
26+
27+| Méthode | Comportement |
28+|---------|--------------|
29+| `initialize` | Journalise le nom du client et la version du protocole sur stderr ; renvoie les valeurs ci-dessus. |
30+| `authenticate` | Renvoie une réponse vide. |
31+| `session/new` | Crée une session `bob-<pid>-<n>` avec le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). |
32+| `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |
33+| `session/cancel` | Annule le tour en cours pour cette session. |
34+| `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
35+
36+## `session/prompt`
37+
38+| Aspect | Comportement |
39+|--------|--------------|
40+| `sessionId` inconnu | Erreur `invalid params` portant le `sessionId`. |
41+| Contenu du prompt | Blocs `text` concaténés ; un `resource_link` devient `\n[attached file: <chemin sans file://>]` ; les autres types de blocs sont ignorés. Ensuite, chaque `@chemin` du texte qui désigne un fichier ou un répertoire existant sous le `cwd` de la session (ou un chemin absolu / `~`) ajoute `\n[attached file: <chemin absolu>]` ou `\n[attached directory: …]`, une fois par chemin, et est journalisé comme `[acp] session <id>: attached <chemin>`. |
42+| Commande slash | Quand le texte aplati, débarrassé de ses espaces, est exactement `/new` ou `/compact`, aucune génération n'a lieu ; la commande répond par un `agent_message_chunk` et `stopReason: end_turn`. |
43+| `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
44+| `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |
45+| Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |
46+| Succès | `stopReason: end_turn`. |
47+| Annulé | `stopReason: cancelled`. |
48+| Échec | `internal error` avec `error: <explication en une ligne>`. |
49+| Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. |
50+
51+## Notifications `session/update` envoyées
52+
53+| Mise à jour | Quand |
54+|-------------|-------|
55+| `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. |
56+| `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. |
57+| `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é. |
58+| `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
59+| `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |
60+
61+## Commandes disponibles
62+
63+| `name` | `description` | `input` |
64+|--------|---------------|---------|
65+| `new` | `Clear the history and start a new session` | aucun |
66+| `compact` | `Compress the history now, keeping the last turns` | aucun |
67+
68+Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`.
69+
70+## Requêtes `session/request_permission` envoyées
71+
72+Envoyées avant l'exécution de `bash`, `read_file`, `write_file` et `edit_file` ; jamais pour `read_skill`.
73+
74+| Identifiant d'option | Genre | Effet |
75+|----------------------|-------|-------|
76+| `allow` | `allow_once` | Exécute l'outil. |
77+| `allow_always` | `allow_always` | Exécute l'outil et supprime le dialogue pour ce nom d'outil jusqu'à la fin de la session. |
78+| `reject` | `reject_once` | N'exécute pas ; le modèle reçoit `[command rejected by the user]`. |
79+
80+Une erreur de transport ou un résultat `cancelled` compte comme un refus.
81+
82+## Genres d'outils
83+
84+| Genre `mm` | `ToolKind` ACP | Outils |
85+|------------|----------------|--------|
86+| `execute` | `execute` | `bash` (et tout genre inconnu) |
87+| `read` | `read` | `read_skill`, `read_file` |
88+| `edit` | `edit` | `write_file`, `edit_file` |
89+
90+## Identifiants d'appel d'outil
91+
92+`call_1`, `call_2`, … numérotés par processus et jamais remis à zéro.
new file mode 100644
@@ -0,0 +1,92 @@
1+# Référence : surface Agent Client Protocol
2+
3+> Description neutre de ce que `mm -acp` expose en JSON-RPC 2.0 sur stdio, via `github.com/coder/acp-go-sdk` v0.13.5.
4+
5+## Transport
6+
7+| Aspect | Valeur |
8+|--------|--------|
9+| Entrée | stdin, un message JSON-RPC par ligne. |
10+| Sortie | stdout, messages JSON-RPC uniquement. |
11+| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK. |
12+| Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. |
13+
14+## Réponse à `initialize`
15+
16+| Champ | Valeur |
17+|-------|--------|
18+| `protocolVersion` | `1` |
19+| `agentCapabilities` | Toutes absentes (false) : pas de `loadSession`, pas de prompts image ou audio, pas de MCP HTTP. |
20+| `agentInfo.name` | `bob` |
21+| `agentInfo.title` | `Bob (bash-first agent)` |
22+| `agentInfo.version` | `0.11.0` |
23+| `authMethods` | `[]` |
24+
25+## Méthodes
26+
27+| Méthode | Comportement |
28+|---------|--------------|
29+| `initialize` | Journalise le nom du client et la version du protocole sur stderr ; renvoie les valeurs ci-dessus. |
30+| `authenticate` | Renvoie une réponse vide. |
31+| `session/new` | Crée une session `bob-<pid>-<n>` avec le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). |
32+| `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |
33+| `session/cancel` | Annule le tour en cours pour cette session. |
34+| `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
35+
36+## `session/prompt`
37+
38+| Aspect | Comportement |
39+|--------|--------------|
40+| `sessionId` inconnu | Erreur `invalid params` portant le `sessionId`. |
41+| Contenu du prompt | Blocs `text` concaténés ; un `resource_link` devient `\n[attached file: <chemin sans file://>]` ; les autres types de blocs sont ignorés. Ensuite, chaque `@chemin` du texte qui désigne un fichier ou un répertoire existant sous le `cwd` de la session (ou un chemin absolu / `~`) ajoute `\n[attached file: <chemin absolu>]` ou `\n[attached directory: …]`, une fois par chemin, et est journalisé comme `[acp] session <id>: attached <chemin>`. |
42+| Commande slash | Quand le texte aplati, débarrassé de ses espaces, est exactement `/new` ou `/compact`, aucune génération n'a lieu ; la commande répond par un `agent_message_chunk` et `stopReason: end_turn`. |
43+| `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
44+| `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |
45+| Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |
46+| Succès | `stopReason: end_turn`. |
47+| Annulé | `stopReason: cancelled`. |
48+| Échec | `internal error` avec `error: <explication en une ligne>`. |
49+| Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. |
50+
51+## Notifications `session/update` envoyées
52+
53+| Mise à jour | Quand |
54+|-------------|-------|
55+| `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. |
56+| `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. |
57+| `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é. |
58+| `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
59+| `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |
60+
61+## Commandes disponibles
62+
63+| `name` | `description` | `input` |
64+|--------|---------------|---------|
65+| `new` | `Clear the history and start a new session` | aucun |
66+| `compact` | `Compress the history now, keeping the last turns` | aucun |
67+
68+Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`.
69+
70+## Requêtes `session/request_permission` envoyées
71+
72+Envoyées avant l'exécution de `bash`, `read_file`, `write_file` et `edit_file` ; jamais pour `read_skill`.
73+
74+| Identifiant d'option | Genre | Effet |
75+|----------------------|-------|-------|
76+| `allow` | `allow_once` | Exécute l'outil. |
77+| `allow_always` | `allow_always` | Exécute l'outil et supprime le dialogue pour ce nom d'outil jusqu'à la fin de la session. |
78+| `reject` | `reject_once` | N'exécute pas ; le modèle reçoit `[command rejected by the user]`. |
79+
80+Une erreur de transport ou un résultat `cancelled` compte comme un refus.
81+
82+## Genres d'outils
83+
84+| Genre `mm` | `ToolKind` ACP | Outils |
85+|------------|----------------|--------|
86+| `execute` | `execute` | `bash` (et tout genre inconnu) |
87+| `read` | `read` | `read_skill`, `read_file` |
88+| `edit` | `edit` | `write_file`, `edit_file` |
89+
90+## Identifiants d'appel d'outil
91+
92+`call_1`, `call_2`, … numérotés par processus et jamais remis à zéro.
added docs/fr/reference/cli.md +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+# Référence : ligne de commande
2+
3+> Description neutre de l'exécutable `mm` : options, argument positionnel, variables d'environnement, sortie de démarrage et codes de sortie.
4+
5+## Invocation
6+
7+```
8+mm [-config <chemin>] [-acp] [<chemin>]
9+```
10+
11+| Élément | Type | Défaut | Description |
12+|---------|------|--------|-------------|
13+| `-config <chemin>` | chaîne | `""` | Chemin du fichier de configuration YAML. |
14+| `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. |
15+| `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. |
16+| `-h` | — | — | Affiche l'usage des options et quitte. |
17+
18+## Résolution du fichier de configuration
19+
20+La première source existante gagne.
21+
22+| Ordre | Source | Fichier absent |
23+|-------|--------|----------------|
24+| 1 | Option `-config` ou argument positionnel | Erreur, code de sortie 1 |
25+| 2 | Variable d'environnement `AGENT_CONFIG` | Erreur, code de sortie 1 |
26+| 3 | `./agent.yaml` | Pas une erreur : les valeurs par défaut intégrées sont utilisées, la bannière affiche `config: built-in defaults` |
27+
28+## Variables d'environnement
29+
30+| Variable | Effet |
31+|----------|-------|
32+| `AGENT_CONFIG` | Chemin du fichier de configuration (voir ci-dessus). |
33+| `AGENT_PROVIDER` | Surcharge la clé `provider`. |
34+| `AGENT_MODEL` | Surcharge la clé `model`. |
35+| `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. |
36+| `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. |
37+| Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`. |
38+
39+## Sortie de démarrage
40+
41+Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand stdout est un terminal.
42+
43+| Ligne | Contenu |
44+|-------|---------|
45+| `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher. |
46+| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. |
47+| `Agent ready. Commands: …` | Mode terminal seulement. |
48+| `Shortcut: Ctrl+C …` | Mode terminal seulement. |
49+
50+### Exemple
51+
52+```
53+[warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]
54+config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
55+```
56+
57+## Spécificités du mode ACP
58+
59+| Aspect | Comportement |
60+|--------|--------------|
61+| stdout | Messages JSON-RPC 2.0 uniquement, un par ligne. |
62+| stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. |
63+| Spinner | Désactivé. |
64+| `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |
65+
66+## Codes de sortie
67+
68+| Code | Cause |
69+|------|-------|
70+| 0 | `/quit`, `Ctrl+C` à l'invite, fin de stdin, ou le client ACP a fermé la connexion. |
71+| 1 | Plus d'un argument positionnel ; erreur de chargement de la configuration ; erreur d'initialisation du moteur (fournisseur inconnu, clé requise absente). |
72+
73+## Compilation et installation
74+
75+| Commande | Effet |
76+|----------|-------|
77+| `go build -o mm .` | Compile le binaire dans le répertoire courant. |
78+| `./install.sh` | Compile `mm` et le copie dans `/usr/local/bin`, avec `sudo` si le répertoire n'est pas accessible en écriture. |
79+| `go test ./...` | Lance les tests unitaires. |
new file mode 100644
@@ -0,0 +1,79 @@
1+# Référence : ligne de commande
2+
3+> Description neutre de l'exécutable `mm` : options, argument positionnel, variables d'environnement, sortie de démarrage et codes de sortie.
4+
5+## Invocation
6+
7+```
8+mm [-config <chemin>] [-acp] [<chemin>]
9+```
10+
11+| Élément | Type | Défaut | Description |
12+|---------|------|--------|-------------|
13+| `-config <chemin>` | chaîne | `""` | Chemin du fichier de configuration YAML. |
14+| `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. |
15+| `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. |
16+| `-h` | — | — | Affiche l'usage des options et quitte. |
17+
18+## Résolution du fichier de configuration
19+
20+La première source existante gagne.
21+
22+| Ordre | Source | Fichier absent |
23+|-------|--------|----------------|
24+| 1 | Option `-config` ou argument positionnel | Erreur, code de sortie 1 |
25+| 2 | Variable d'environnement `AGENT_CONFIG` | Erreur, code de sortie 1 |
26+| 3 | `./agent.yaml` | Pas une erreur : les valeurs par défaut intégrées sont utilisées, la bannière affiche `config: built-in defaults` |
27+
28+## Variables d'environnement
29+
30+| Variable | Effet |
31+|----------|-------|
32+| `AGENT_CONFIG` | Chemin du fichier de configuration (voir ci-dessus). |
33+| `AGENT_PROVIDER` | Surcharge la clé `provider`. |
34+| `AGENT_MODEL` | Surcharge la clé `model`. |
35+| `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. |
36+| `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. |
37+| Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`. |
38+
39+## Sortie de démarrage
40+
41+Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand stdout est un terminal.
42+
43+| Ligne | Contenu |
44+|-------|---------|
45+| `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher. |
46+| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. |
47+| `Agent ready. Commands: …` | Mode terminal seulement. |
48+| `Shortcut: Ctrl+C …` | Mode terminal seulement. |
49+
50+### Exemple
51+
52+```
53+[warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]
54+config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
55+```
56+
57+## Spécificités du mode ACP
58+
59+| Aspect | Comportement |
60+|--------|--------------|
61+| stdout | Messages JSON-RPC 2.0 uniquement, un par ligne. |
62+| stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. |
63+| Spinner | Désactivé. |
64+| `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |
65+
66+## Codes de sortie
67+
68+| Code | Cause |
69+|------|-------|
70+| 0 | `/quit`, `Ctrl+C` à l'invite, fin de stdin, ou le client ACP a fermé la connexion. |
71+| 1 | Plus d'un argument positionnel ; erreur de chargement de la configuration ; erreur d'initialisation du moteur (fournisseur inconnu, clé requise absente). |
72+
73+## Compilation et installation
74+
75+| Commande | Effet |
76+|----------|-------|
77+| `go build -o mm .` | Compile le binaire dans le répertoire courant. |
78+| `./install.sh` | Compile `mm` et le copie dans `/usr/local/bin`, avec `sudo` si le répertoire n'est pas accessible en écriture. |
79+| `go test ./...` | Lance les tests unitaires. |
added docs/fr/reference/configuration.md +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+# Référence : fichier de configuration
2+
3+> Description neutre et exhaustive du fichier YAML lu par `mm`. Chaque clé est optionnelle ; une clé omise garde sa valeur par défaut intégrée. Le fichier est décodé par-dessus les valeurs par défaut, donc les clés inconnues sont ignorées.
4+
5+## Clés de premier niveau
6+
7+| Clé | Type | Défaut | Description |
8+|-----|------|--------|-------------|
9+| `provider` | chaîne | `dmr` | Clé du registre du serveur LLM : `dmr` ou `llamacpp`. Surchargée par `AGENT_PROVIDER`. |
10+| `model` | chaîne | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Identifiant du modèle dans la nomenclature du fournisseur. Surchargé par `AGENT_MODEL`. |
11+| `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. |
12+| `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. |
13+| `apiKeyEnv` | chaîne | `""` (défaut du fournisseur) | Nom de la variable d'environnement qui contient la clé d'API. |
14+| `contextWindow` | entier | `0` | Tokens servis par le serveur. `0` = demander au serveur, sinon inconnu. Doit être ≥ 0. |
15+| `maxOutput` | entier | `16000` | Nombre maximal de caractères qu'un outil renvoie au modèle ; au-delà, la sortie garde son début et jusqu'à 2000 caractères de fin. Doit être > 0. |
16+| `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. |
17+| `previewLines` | entier | `20` | Lignes de la sortie d'une commande affichées dans le terminal. `0` désactive l'affichage. Doit être ≥ 0. |
18+| `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. |
19+| `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. |
20+| `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |
21+| `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
22+| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |
23+| `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
24+| `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
25+
26+## Clés de `context`
27+
28+| Clé | Type | Défaut | Description |
29+|-----|------|--------|-------------|
30+| `enabled` | booléen | `false` | Active la compression automatique. `/compact` fonctionne dans tous les cas. |
31+| `threshold` | entier | `75` | Pourcentage de la fenêtre de contexte au-delà duquel l'historique est compressé avant la question suivante. Entre 1 et 100. |
32+| `maxMessages` | entier | `80` | Nombre de messages qui déclenche une compression quand la fenêtre est inconnue ou l'estimation trop basse. `0` désactive. Doit être ≥ 0. |
33+| `keepLastTurns` | entier | `3` | Tours de question conservés bruts en fin d'historique. Doit être ≥ 1. |
34+| `summaryMaxTokens` | entier | `1200` | `max_tokens` de la requête de résumé. Doit être > 0. |
35+| `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. |
36+| `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. |
37+
38+## Valeurs par défaut des fournisseurs
39+
40+| Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise |
41+|-------------|-----------|------------|-------------|-------------|
42+| `dmr` | `http://localhost:12434/engines/v1` | `http://host.docker.internal:12434/engines/v1` | aucune | non |
43+| `llamacpp` | `http://127.0.0.1:8080/v1` | aucun | `LLAMA_API_KEY` | non |
44+
45+## Erreurs de validation
46+
47+Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1.
48+
49+| Message | Cause |
50+|---------|-------|
51+| `provider must not be empty` | `provider: ""` |
52+| `model must not be empty` | `model: ""` |
53+| `contextWindow must be >= 0` | valeur négative |
54+| `maxOutput must be > 0` | zéro ou négatif |
55+| `maxTurns must be > 0` | zéro ou négatif |
56+| `previewLines must be >= 0` | valeur négative |
57+| `context.threshold must be between 1 and 100` | hors intervalle |
58+| `context.keepLastTurns must be >= 1` | zéro ou négatif |
59+| `context.summaryMaxTokens must be > 0` | zéro ou négatif |
60+| `context.maxMessages must be >= 0` | valeur négative |
61+| `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
62+
63+Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`.
64+
65+## Fichiers livrés
66+
67+| Fichier | Rôle |
68+|---------|------|
69+| `agent.yaml` | Configuration Docker Model Runner : `provider: dmr`, `maxTurns: 40`, `displayCommands: true`, compression désactivée, un prompt système nommé « Bob » qui décrit les skills et les règles d'édition de fichiers. |
70+| `agent.llamacpp.yaml` | Configuration llama.cpp : `provider: llamacpp`, `baseUrl: http://127.0.0.1:8080/v1`, `fallback: ""`, compression activée. |
71+
72+### Exemple
73+
74+```yaml
75+provider: llamacpp
76+model: mon-alias
77+baseUrl: http://127.0.0.1:8080/v1
78+fallback: ""
79+maxTurns: 40
80+context:
81+ enabled: true
82+```
new file mode 100644
@@ -0,0 +1,82 @@
1+# Référence : fichier de configuration
2+
3+> Description neutre et exhaustive du fichier YAML lu par `mm`. Chaque clé est optionnelle ; une clé omise garde sa valeur par défaut intégrée. Le fichier est décodé par-dessus les valeurs par défaut, donc les clés inconnues sont ignorées.
4+
5+## Clés de premier niveau
6+
7+| Clé | Type | Défaut | Description |
8+|-----|------|--------|-------------|
9+| `provider` | chaîne | `dmr` | Clé du registre du serveur LLM : `dmr` ou `llamacpp`. Surchargée par `AGENT_PROVIDER`. |
10+| `model` | chaîne | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Identifiant du modèle dans la nomenclature du fournisseur. Surchargé par `AGENT_MODEL`. |
11+| `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. |
12+| `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. |
13+| `apiKeyEnv` | chaîne | `""` (défaut du fournisseur) | Nom de la variable d'environnement qui contient la clé d'API. |
14+| `contextWindow` | entier | `0` | Tokens servis par le serveur. `0` = demander au serveur, sinon inconnu. Doit être ≥ 0. |
15+| `maxOutput` | entier | `16000` | Nombre maximal de caractères qu'un outil renvoie au modèle ; au-delà, la sortie garde son début et jusqu'à 2000 caractères de fin. Doit être > 0. |
16+| `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. |
17+| `previewLines` | entier | `20` | Lignes de la sortie d'une commande affichées dans le terminal. `0` désactive l'affichage. Doit être ≥ 0. |
18+| `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. |
19+| `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. |
20+| `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |
21+| `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
22+| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |
23+| `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
24+| `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
25+
26+## Clés de `context`
27+
28+| Clé | Type | Défaut | Description |
29+|-----|------|--------|-------------|
30+| `enabled` | booléen | `false` | Active la compression automatique. `/compact` fonctionne dans tous les cas. |
31+| `threshold` | entier | `75` | Pourcentage de la fenêtre de contexte au-delà duquel l'historique est compressé avant la question suivante. Entre 1 et 100. |
32+| `maxMessages` | entier | `80` | Nombre de messages qui déclenche une compression quand la fenêtre est inconnue ou l'estimation trop basse. `0` désactive. Doit être ≥ 0. |
33+| `keepLastTurns` | entier | `3` | Tours de question conservés bruts en fin d'historique. Doit être ≥ 1. |
34+| `summaryMaxTokens` | entier | `1200` | `max_tokens` de la requête de résumé. Doit être > 0. |
35+| `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. |
36+| `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. |
37+
38+## Valeurs par défaut des fournisseurs
39+
40+| Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise |
41+|-------------|-----------|------------|-------------|-------------|
42+| `dmr` | `http://localhost:12434/engines/v1` | `http://host.docker.internal:12434/engines/v1` | aucune | non |
43+| `llamacpp` | `http://127.0.0.1:8080/v1` | aucun | `LLAMA_API_KEY` | non |
44+
45+## Erreurs de validation
46+
47+Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1.
48+
49+| Message | Cause |
50+|---------|-------|
51+| `provider must not be empty` | `provider: ""` |
52+| `model must not be empty` | `model: ""` |
53+| `contextWindow must be >= 0` | valeur négative |
54+| `maxOutput must be > 0` | zéro ou négatif |
55+| `maxTurns must be > 0` | zéro ou négatif |
56+| `previewLines must be >= 0` | valeur négative |
57+| `context.threshold must be between 1 and 100` | hors intervalle |
58+| `context.keepLastTurns must be >= 1` | zéro ou négatif |
59+| `context.summaryMaxTokens must be > 0` | zéro ou négatif |
60+| `context.maxMessages must be >= 0` | valeur négative |
61+| `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
62+
63+Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`.
64+
65+## Fichiers livrés
66+
67+| Fichier | Rôle |
68+|---------|------|
69+| `agent.yaml` | Configuration Docker Model Runner : `provider: dmr`, `maxTurns: 40`, `displayCommands: true`, compression désactivée, un prompt système nommé « Bob » qui décrit les skills et les règles d'édition de fichiers. |
70+| `agent.llamacpp.yaml` | Configuration llama.cpp : `provider: llamacpp`, `baseUrl: http://127.0.0.1:8080/v1`, `fallback: ""`, compression activée. |
71+
72+### Exemple
73+
74+```yaml
75+provider: llamacpp
76+model: mon-alias
77+baseUrl: http://127.0.0.1:8080/v1
78+fallback: ""
79+maxTurns: 40
80+context:
81+ enabled: true
82+```
added docs/fr/reference/skill-format.md +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+# Référence : format d'un fichier de skill
2+
3+> Description neutre des fichiers markdown servis par l'outil `read_skill`.
4+
5+## Emplacement
6+
7+| Règle | Valeur |
8+|-------|--------|
9+| Répertoire | `skillsDir` (défaut `skills`). |
10+| Motifs | `<skillsDir>/<nom>.md` (à plat) et `<skillsDir>/<nom>/SKILL.md` (un répertoire par skill), librement mélangés. Les autres fichiers du répertoire d'un skill sont ignorés. |
11+| Nom du skill sur disque | Le nom du fichier sans `.md`, ou le nom du répertoire pour un `SKILL.md`. |
12+| Les deux formes pour un même nom | `read_skill` renvoie le fichier à plat. |
13+| Répertoire absent | Pas une erreur : zéro skill, `read_skill` non déclaré. |
14+
15+## En-tête (front matter)
16+
17+Optionnel. Quand il est présent, le fichier commence par une ligne `---` et l'en-tête se termine à la ligne `---` suivante. Seules deux clés sont lues ; les autres sont ignorées.
18+
19+| Clé | Type | Défaut | Description |
20+|-----|------|--------|-------------|
21+| `name` | chaîne | nom du fichier sans `.md` | Nom affiché dans le catalogue et accepté par `read_skill`. Une valeur vide garde le défaut. |
22+| `description` | chaîne | `""` | Résumé d'une ligne affiché dans le catalogue. |
23+
24+Une ligne `description:` dans le corps, après le `---` de fermeture, n'est pas une métadonnée.
25+
26+## Rendu du catalogue
27+
28+La description de l'outil est :
29+
30+```
31+Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:
32+ <name> — <description>
33+ <name>
34+```
35+
36+Les entrées sont triées par nom ; la partie ` — <description>` est omise quand la description est vide.
37+
38+### Exemple
39+
40+```markdown
41+---
42+name: what-time-is-it
43+description: Give the current date and time.
44+keywords: time, date, clock
45+---
46+
47+# What time is it?
48+
49+Run:
50+
51+```bash
52+date "+%A %d %B %Y — %H:%M:%S (%Z)"
53+```
54+```
55+
56+La clé `keywords` est acceptée dans le fichier et ignorée par le chargeur.
57+
58+## Exemples livrés
59+
60+| Chemin | `name` | `description` |
61+|--------|--------|---------------|
62+| `skills/greetings/SKILL.md` | `greetings` | Greet somebody with a festive message. |
63+| `skills/vulcan-salute/SKILL.md` | `vulcan-salute` | Send a vulcan salute to somebody. |
64+| `skills/what-time-is-it/SKILL.md` | `what-time-is-it` | Give the current date and time. |
65+
66+Ils utilisent la disposition un répertoire par skill ; lancé depuis la racine du dépôt, `mm` indique `skills: 3`.
new file mode 100644
@@ -0,0 +1,66 @@
1+# Référence : format d'un fichier de skill
2+
3+> Description neutre des fichiers markdown servis par l'outil `read_skill`.
4+
5+## Emplacement
6+
7+| Règle | Valeur |
8+|-------|--------|
9+| Répertoire | `skillsDir` (défaut `skills`). |
10+| Motifs | `<skillsDir>/<nom>.md` (à plat) et `<skillsDir>/<nom>/SKILL.md` (un répertoire par skill), librement mélangés. Les autres fichiers du répertoire d'un skill sont ignorés. |
11+| Nom du skill sur disque | Le nom du fichier sans `.md`, ou le nom du répertoire pour un `SKILL.md`. |
12+| Les deux formes pour un même nom | `read_skill` renvoie le fichier à plat. |
13+| Répertoire absent | Pas une erreur : zéro skill, `read_skill` non déclaré. |
14+
15+## En-tête (front matter)
16+
17+Optionnel. Quand il est présent, le fichier commence par une ligne `---` et l'en-tête se termine à la ligne `---` suivante. Seules deux clés sont lues ; les autres sont ignorées.
18+
19+| Clé | Type | Défaut | Description |
20+|-----|------|--------|-------------|
21+| `name` | chaîne | nom du fichier sans `.md` | Nom affiché dans le catalogue et accepté par `read_skill`. Une valeur vide garde le défaut. |
22+| `description` | chaîne | `""` | Résumé d'une ligne affiché dans le catalogue. |
23+
24+Une ligne `description:` dans le corps, après le `---` de fermeture, n'est pas une métadonnée.
25+
26+## Rendu du catalogue
27+
28+La description de l'outil est :
29+
30+```
31+Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills:
32+ <name> — <description>
33+ <name>
34+```
35+
36+Les entrées sont triées par nom ; la partie ` — <description>` est omise quand la description est vide.
37+
38+### Exemple
39+
40+```markdown
41+---
42+name: what-time-is-it
43+description: Give the current date and time.
44+keywords: time, date, clock
45+---
46+
47+# What time is it?
48+
49+Run:
50+
51+```bash
52+date "+%A %d %B %Y — %H:%M:%S (%Z)"
53+```
54+```
55+
56+La clé `keywords` est acceptée dans le fichier et ignorée par le chargeur.
57+
58+## Exemples livrés
59+
60+| Chemin | `name` | `description` |
61+|--------|--------|---------------|
62+| `skills/greetings/SKILL.md` | `greetings` | Greet somebody with a festive message. |
63+| `skills/vulcan-salute/SKILL.md` | `vulcan-salute` | Send a vulcan salute to somebody. |
64+| `skills/what-time-is-it/SKILL.md` | `what-time-is-it` | Give the current date and time. |
65+
66+Ils utilisent la disposition un répertoire par skill ; lancé depuis la racine du dépôt, `mm` indique `skills: 3`.
added docs/fr/reference/slash-commands.md +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+# Référence : commandes slash et raccourcis clavier
2+
3+> Description neutre des commandes reconnues à l'invite du terminal de `mm`. Elles sont comparées à la ligne entière, débarrassée de ses espaces ; tout autre texte est envoyé au modèle.
4+
5+## Commandes
6+
7+| Commande | À l'invite | Pendant une génération |
8+|----------|------------|------------------------|
9+| `/quit` | Quitte le programme. | Annule la génération, affiche `Goodbye.`, quitte. |
10+| `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
11+| `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |
12+| `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |
13+
14+Une ligne vide à l'invite est ignorée.
15+
16+## `@chemin` dans une question
17+
18+| Saisie | Effet |
19+|--------|-------|
20+| `@<chemin>` en début de ligne ou après un espace, une parenthèse ou un guillemet | Si le chemin existe (relatif au répertoire de lancement, absolu, ou `~/…`), une ligne atténuée `📎 <chemin absolu>` est affichée et `[attached file: <chemin absolu>]` (ou `[attached directory: …]`) est ajouté au message que reçoit le modèle. La ponctuation finale est ignorée ; chaque chemin est attaché une fois. |
21+| Un `@<chemin>` qui ne désigne rien sur le disque, ou `@` collé à un mot | Laissé tel quel. |
22+
23+Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-to/attach-a-file.md).
24+
25+## Tout autre texte pendant une génération
26+
27+| Saisie | Comportement |
28+|--------|--------------|
29+| Une ligne qui n'est pas une commande | Affiche `🔄 New command received. Cancelling current task...`, annule la génération en cours, conserve son historique partiel, puis soumet la nouvelle ligne comme question suivante. |
30+
31+## Raccourcis clavier
32+
33+| Touche | À l'invite | Pendant une génération |
34+|--------|------------|------------------------|
35+| `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |
36+| `Ctrl+D` (fin d'entrée) | Quitte. | — |
37+
38+## Messages affichés en fin de tour
39+
40+| Message | Signification |
41+|---------|---------------|
42+| `🛑 Generation aborted.` | Le tour a été annulé (`/abort`, `Ctrl+C`). La question et les tours d'outils terminés restent dans l'historique. |
43+| `[error: …]` | Le fournisseur a expliqué un échec en une ligne ; une question restée sans réponse est retirée de l'historique. |
44+| `⚙ N command(s)` | Nombre de commandes `bash` réellement exécutées dans le tour. |
45+| `· 📝 N file op(s)` | Ajouté quand des outils de fichiers ont tourné. |
46+| `· 📖 N skill(s)` | Ajouté quand des skills ont été chargés. |
47+| Liste numérotée en vert | Les commandes et opérations sur fichiers du tour, une par ligne, quand `displayCommands: true`. |
48+
49+## Mode ACP
50+
51+Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncées à l'éditeur par une notification `available_commands_update` et interceptées par `session/prompt` quand le texte du prompt, débarrassé de ses espaces, est exactement la commande. Chacune répond par un `agent_message_chunk` portant la ligne que le terminal afficherait, puis `stopReason: end_turn` ; le modèle n'est pas consulté.
52+
53+| Commande | En ACP |
54+|----------|--------|
55+| `/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. |
56+| `/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. |
57+
58+`/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).
new file mode 100644
@@ -0,0 +1,58 @@
1+# Référence : commandes slash et raccourcis clavier
2+
3+> Description neutre des commandes reconnues à l'invite du terminal de `mm`. Elles sont comparées à la ligne entière, débarrassée de ses espaces ; tout autre texte est envoyé au modèle.
4+
5+## Commandes
6+
7+| Commande | À l'invite | Pendant une génération |
8+|----------|------------|------------------------|
9+| `/quit` | Quitte le programme. | Annule la génération, affiche `Goodbye.`, quitte. |
10+| `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
11+| `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |
12+| `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |
13+
14+Une ligne vide à l'invite est ignorée.
15+
16+## `@chemin` dans une question
17+
18+| Saisie | Effet |
19+|--------|-------|
20+| `@<chemin>` en début de ligne ou après un espace, une parenthèse ou un guillemet | Si le chemin existe (relatif au répertoire de lancement, absolu, ou `~/…`), une ligne atténuée `📎 <chemin absolu>` est affichée et `[attached file: <chemin absolu>]` (ou `[attached directory: …]`) est ajouté au message que reçoit le modèle. La ponctuation finale est ignorée ; chaque chemin est attaché une fois. |
21+| Un `@<chemin>` qui ne désigne rien sur le disque, ou `@` collé à un mot | Laissé tel quel. |
22+
23+Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-to/attach-a-file.md).
24+
25+## Tout autre texte pendant une génération
26+
27+| Saisie | Comportement |
28+|--------|--------------|
29+| Une ligne qui n'est pas une commande | Affiche `🔄 New command received. Cancelling current task...`, annule la génération en cours, conserve son historique partiel, puis soumet la nouvelle ligne comme question suivante. |
30+
31+## Raccourcis clavier
32+
33+| Touche | À l'invite | Pendant une génération |
34+|--------|------------|------------------------|
35+| `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |
36+| `Ctrl+D` (fin d'entrée) | Quitte. | — |
37+
38+## Messages affichés en fin de tour
39+
40+| Message | Signification |
41+|---------|---------------|
42+| `🛑 Generation aborted.` | Le tour a été annulé (`/abort`, `Ctrl+C`). La question et les tours d'outils terminés restent dans l'historique. |
43+| `[error: …]` | Le fournisseur a expliqué un échec en une ligne ; une question restée sans réponse est retirée de l'historique. |
44+| `⚙ N command(s)` | Nombre de commandes `bash` réellement exécutées dans le tour. |
45+| `· 📝 N file op(s)` | Ajouté quand des outils de fichiers ont tourné. |
46+| `· 📖 N skill(s)` | Ajouté quand des skills ont été chargés. |
47+| Liste numérotée en vert | Les commandes et opérations sur fichiers du tour, une par ligne, quand `displayCommands: true`. |
48+
49+## Mode ACP
50+
51+Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncées à l'éditeur par une notification `available_commands_update` et interceptées par `session/prompt` quand le texte du prompt, débarrassé de ses espaces, est exactement la commande. Chacune répond par un `agent_message_chunk` portant la ligne que le terminal afficherait, puis `stopReason: end_turn` ; le modèle n'est pas consulté.
52+
53+| Commande | En ACP |
54+|----------|--------|
55+| `/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. |
56+| `/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. |
57+
58+`/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).
added docs/fr/reference/tools.md +103 -0
new file mode 100644
@@ -0,0 +1,103 @@
1+# Référence : outils intégrés
2+
3+> Description neutre des outils déclarés au modèle. Un outil ne renvoie jamais d'erreur Go pour un échec ordinaire : le texte de l'échec est renvoyé au modèle comme sortie de l'outil. Chaque outil enregistre son appel dans le détecteur de boucle ; le même outil, la même entrée et la même sortie trois fois de suite ajoutent `[LOOP_DETECTED: …]` à la sortie.
4+
5+## `bash`
6+
7+Toujours déclaré.
8+
9+| Champ | Type | Description |
10+|-------|------|-------------|
11+| `command` | chaîne | La commande shell à exécuter. |
12+
13+| Aspect | Comportement |
14+|--------|--------------|
15+| Shell | `bash -c <command>`, un nouveau shell par appel, pas de stdin. |
16+| Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP. |
17+| Délai | 30 s ; la sortie se termine alors par `[error: 30s timeout exceeded]`. |
18+| Sortie | stdout et stderr fusionnés, tronqués à `maxOutput` caractères. |
19+| Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. |
20+| Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. |
21+| ACP | Appel d'outil de genre `execute` ; permission demandée avant exécution ; un refus renvoie `[command rejected by the user]` avec le statut `failed`. |
22+
23+## `read_skill`
24+
25+Déclaré seulement quand `skillsDir` contient au moins un skill (`<nom>.md` ou `<nom>/SKILL.md`).
26+
27+| Champ | Type | Description |
28+|-------|------|-------------|
29+| `name` | chaîne | Nom du skill, exactement tel que listé dans la description de l'outil. |
30+
31+| Aspect | Comportement |
32+|--------|--------------|
33+| Description | Phrase fixe suivie du catalogue : une ligne ` <name> — <description>` par skill, triées par nom. |
34+| Sortie | Le markdown complet de `<skillsDir>/<name>.md`, ou de `<skillsDir>/<name>/SKILL.md` s'il n'y a pas de fichier à plat, tronqué à `maxOutput`. |
35+| Nom inconnu | `No skill named "x". Available skills: a, b, c` |
36+| Sécurité des chemins | Seul le nom de base de `name` est utilisé ; pas de traversée de répertoire. |
37+| Affichage terminal | `📖 read_skill: <name>` ; le contenu n'est pas affiché. |
38+| ACP | Appel d'outil de genre `read` ; pas de dialogue de permission. |
39+
40+## `read_file`
41+
42+Déclaré quand `editTools: true`.
43+
44+| Champ | Type | Défaut | Description |
45+|-------|------|--------|-------------|
46+| `path` | chaîne | — | Chemin du fichier, relatif au répertoire de travail. |
47+| `start` | entier | `0` | Première ligne (base 1) ; `0` = depuis le début. |
48+| `end` | entier | `0` | Dernière ligne (base 1, incluse) ; `0` = jusqu'à la fin. |
49+| `numbered` | booléen | `false` | Préfixe chaque ligne de son numéro. |
50+
51+| Aspect | Comportement |
52+|--------|--------------|
53+| Fichier absent | `<path> does not exist. Create it with write_file` |
54+| Sortie | Les lignes, tronquées à `maxOutput` ; le saut de ligne final n'est conservé que si la fin du fichier est affichée. |
55+| ACP | Genre `read`, avec le chemin absolu comme emplacement. |
56+
57+## `write_file`
58+
59+Déclaré quand `editTools: true`.
60+
61+| Champ | Type | Description |
62+|-------|------|-------------|
63+| `path` | chaîne | Chemin du fichier, relatif au répertoire de travail ; les répertoires parents sont créés. |
64+| `content` | chaîne | Le contenu entier du fichier. |
65+
66+| Aspect | Comportement |
67+|--------|--------------|
68+| Fichier existant | Entièrement remplacé. |
69+| Contenu identique | Rien n'est écrit ; le résultat le dit. |
70+| Sortie | Un en-tête d'une ligne et le diff par rapport au contenu précédent. |
71+| ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff (diff de nouveau fichier à la création). |
72+
73+## `edit_file`
74+
75+Déclaré quand `editTools: true`.
76+
77+| Champ | Type | Défaut | Description |
78+|-------|------|--------|-------------|
79+| `path` | chaîne | — | Chemin d'un fichier existant. |
80+| `edits` | tableau de `{old, new}` | — | Remplacements, tous résolus par rapport au fichier original. |
81+| `edits[].old` | chaîne | — | Texte exact à trouver, présent exactement une fois. |
82+| `edits[].new` | chaîne | — | Remplacement ; vide supprime le texte trouvé. |
83+| `dry_run` | booléen | `false` | Rapporte ce qui changerait, n'écrit rien. |
84+
85+| Règle | Conséquence si violée |
86+|-------|-----------------------|
87+| `old` apparaît exactement une fois | Refus : introuvable, ou ambigu. |
88+| Deux éditions ne doivent ni se chevaucher, ni s'imbriquer, ni se répéter | Refus. |
89+| Les éditions sont résolues sur le texte original, jamais en cascade | — |
90+| Toute édition refusée | Rien n'est écrit. |
91+
92+| Aspect | Comportement |
93+|--------|--------------|
94+| Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. |
95+| ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. |
96+
97+## Compteurs affichés après un tour
98+
99+| Compteur | Compte |
100+|----------|--------|
101+| `⚙ N command(s)` | Réponses de l'outil `bash` dans le tour. |
102+| `📝 N file op(s)` | Réponses de `read_file`, `write_file`, `edit_file`. |
103+| `📖 N skill(s)` | Réponses de `read_skill`. |
new file mode 100644
@@ -0,0 +1,103 @@
1+# Référence : outils intégrés
2+
3+> Description neutre des outils déclarés au modèle. Un outil ne renvoie jamais d'erreur Go pour un échec ordinaire : le texte de l'échec est renvoyé au modèle comme sortie de l'outil. Chaque outil enregistre son appel dans le détecteur de boucle ; le même outil, la même entrée et la même sortie trois fois de suite ajoutent `[LOOP_DETECTED: …]` à la sortie.
4+
5+## `bash`
6+
7+Toujours déclaré.
8+
9+| Champ | Type | Description |
10+|-------|------|-------------|
11+| `command` | chaîne | La commande shell à exécuter. |
12+
13+| Aspect | Comportement |
14+|--------|--------------|
15+| Shell | `bash -c <command>`, un nouveau shell par appel, pas de stdin. |
16+| Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP. |
17+| Délai | 30 s ; la sortie se termine alors par `[error: 30s timeout exceeded]`. |
18+| Sortie | stdout et stderr fusionnés, tronqués à `maxOutput` caractères. |
19+| Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. |
20+| Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. |
21+| ACP | Appel d'outil de genre `execute` ; permission demandée avant exécution ; un refus renvoie `[command rejected by the user]` avec le statut `failed`. |
22+
23+## `read_skill`
24+
25+Déclaré seulement quand `skillsDir` contient au moins un skill (`<nom>.md` ou `<nom>/SKILL.md`).
26+
27+| Champ | Type | Description |
28+|-------|------|-------------|
29+| `name` | chaîne | Nom du skill, exactement tel que listé dans la description de l'outil. |
30+
31+| Aspect | Comportement |
32+|--------|--------------|
33+| Description | Phrase fixe suivie du catalogue : une ligne ` <name> — <description>` par skill, triées par nom. |
34+| Sortie | Le markdown complet de `<skillsDir>/<name>.md`, ou de `<skillsDir>/<name>/SKILL.md` s'il n'y a pas de fichier à plat, tronqué à `maxOutput`. |
35+| Nom inconnu | `No skill named "x". Available skills: a, b, c` |
36+| Sécurité des chemins | Seul le nom de base de `name` est utilisé ; pas de traversée de répertoire. |
37+| Affichage terminal | `📖 read_skill: <name>` ; le contenu n'est pas affiché. |
38+| ACP | Appel d'outil de genre `read` ; pas de dialogue de permission. |
39+
40+## `read_file`
41+
42+Déclaré quand `editTools: true`.
43+
44+| Champ | Type | Défaut | Description |
45+|-------|------|--------|-------------|
46+| `path` | chaîne | — | Chemin du fichier, relatif au répertoire de travail. |
47+| `start` | entier | `0` | Première ligne (base 1) ; `0` = depuis le début. |
48+| `end` | entier | `0` | Dernière ligne (base 1, incluse) ; `0` = jusqu'à la fin. |
49+| `numbered` | booléen | `false` | Préfixe chaque ligne de son numéro. |
50+
51+| Aspect | Comportement |
52+|--------|--------------|
53+| Fichier absent | `<path> does not exist. Create it with write_file` |
54+| Sortie | Les lignes, tronquées à `maxOutput` ; le saut de ligne final n'est conservé que si la fin du fichier est affichée. |
55+| ACP | Genre `read`, avec le chemin absolu comme emplacement. |
56+
57+## `write_file`
58+
59+Déclaré quand `editTools: true`.
60+
61+| Champ | Type | Description |
62+|-------|------|-------------|
63+| `path` | chaîne | Chemin du fichier, relatif au répertoire de travail ; les répertoires parents sont créés. |
64+| `content` | chaîne | Le contenu entier du fichier. |
65+
66+| Aspect | Comportement |
67+|--------|--------------|
68+| Fichier existant | Entièrement remplacé. |
69+| Contenu identique | Rien n'est écrit ; le résultat le dit. |
70+| Sortie | Un en-tête d'une ligne et le diff par rapport au contenu précédent. |
71+| ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff (diff de nouveau fichier à la création). |
72+
73+## `edit_file`
74+
75+Déclaré quand `editTools: true`.
76+
77+| Champ | Type | Défaut | Description |
78+|-------|------|--------|-------------|
79+| `path` | chaîne | — | Chemin d'un fichier existant. |
80+| `edits` | tableau de `{old, new}` | — | Remplacements, tous résolus par rapport au fichier original. |
81+| `edits[].old` | chaîne | — | Texte exact à trouver, présent exactement une fois. |
82+| `edits[].new` | chaîne | — | Remplacement ; vide supprime le texte trouvé. |
83+| `dry_run` | booléen | `false` | Rapporte ce qui changerait, n'écrit rien. |
84+
85+| Règle | Conséquence si violée |
86+|-------|-----------------------|
87+| `old` apparaît exactement une fois | Refus : introuvable, ou ambigu. |
88+| Deux éditions ne doivent ni se chevaucher, ni s'imbriquer, ni se répéter | Refus. |
89+| Les éditions sont résolues sur le texte original, jamais en cascade | — |
90+| Toute édition refusée | Rien n'est écrit. |
91+
92+| Aspect | Comportement |
93+|--------|--------------|
94+| Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. |
95+| ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. |
96+
97+## Compteurs affichés après un tour
98+
99+| Compteur | Compte |
100+|----------|--------|
101+| `⚙ N command(s)` | Réponses de l'outil `bash` dans le tour. |
102+| `📝 N file op(s)` | Réponses de `read_file`, `write_file`, `edit_file`. |
103+| `📖 N skill(s)` | Réponses de `read_skill`. |
added docs/fr/tutorials/getting-started.md +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+# Tutoriel : votre première session avec mini-me
2+
3+À la fin de ce tutoriel, vous aurez compilé le binaire `mm`, lancé l'agent face à Docker Model Runner, posé une question qui lui fait exécuter une commande shell, puis quitté proprement. Aucune connaissance préalable du projet n'est requise.
4+
5+## Prérequis
6+
7+- Go 1.25 ou plus récent dans votre `PATH` (`go version` répond).
8+- Docker Desktop avec Docker Model Runner activé et joignable sur `http://localhost:12434`.
9+- Le modèle nommé dans `agent.yaml` téléchargé localement. Le fichier livré avec le projet nomme `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` ; récupérez-le avec `docker model pull <ce nom>`.
10+- Un terminal ouvert à la racine du dépôt (le répertoire qui contient `main.go` et `agent.yaml`).
11+
12+## Étape 1 — Compiler le binaire
13+
14+Tapez :
15+
16+```bash
17+go build -o mm .
18+```
19+
20+Vous ne devez voir aucune sortie. Un fichier `mm` se trouve maintenant dans le répertoire courant.
21+
22+Nous venons de compiler tout l'agent en un seul exécutable.
23+
24+## Étape 2 — Lancer l'agent
25+
26+Tapez :
27+
28+```bash
29+./mm
30+```
31+
32+Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`agent.yaml`) :
33+
34+```
35+config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
36+Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.
37+Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
38+
39+>
40+```
41+
42+Si une ligne grisée `[warning: dmr: nothing answers at …]` apparaît au-dessus de la bannière, Docker Model Runner ne tourne pas : démarrez-le, puis relancez `./mm`. L'agent démarre quand même, mais l'étape suivante échouerait.
43+
44+Nous venons de charger la configuration, de nous connecter au serveur de modèle et de déclarer les outils que le modèle peut appeler.
45+
46+## Étape 3 — Poser une question qui demande une commande
47+
48+À l'invite `>`, tapez :
49+
50+```
51+list the files in this directory
52+```
53+
54+Vous devez voir, dans l'ordre : un spinner pendant que le modèle réfléchit, une ligne commençant par `🛠️ bash:` qui montre la commande choisie par le modèle (typiquement `ls` ou `ls -la`), les premières lignes de la sortie de cette commande, indentées et grisées, puis la réponse du modèle en texte. Le tour se termine par un récapitulatif grisé tel que :
55+
56+```
57+⚙ 1 command(s)
58+```
59+
60+suivi de la liste numérotée, en vert, des commandes exécutées.
61+
62+Nous venons d'observer un tour de la boucle de l'agent : le modèle a demandé un outil, l'outil a tourné, sa sortie est revenue au modèle, et le modèle a répondu.
63+
64+## Étape 4 — Quitter
65+
66+À l'invite `>`, tapez :
67+
68+```
69+/quit
70+```
71+
72+Le programme se termine. `Ctrl+C` à l'invite fait la même chose et affiche d'abord `Goodbye.`.
73+
74+## Et ensuite ?
75+
76+Vous avez compilé `mm`, exécuté un tour avec appel d'outil, et quitté. Pour aller plus loin :
77+
78+- Pour le faire tourner sur llama.cpp, l'héberger dans un éditeur ou ajouter des skills → voir les [guides pratiques](../how-to/)
79+- Pour retrouver une option, une clé YAML ou une commande slash → voir la [référence](../reference/)
80+- Pour comprendre comment la boucle et les deux façades s'articulent → voir les [explications](../explanation/)
new file mode 100644
@@ -0,0 +1,80 @@
1+# Tutoriel : votre première session avec mini-me
2+
3+À la fin de ce tutoriel, vous aurez compilé le binaire `mm`, lancé l'agent face à Docker Model Runner, posé une question qui lui fait exécuter une commande shell, puis quitté proprement. Aucune connaissance préalable du projet n'est requise.
4+
5+## Prérequis
6+
7+- Go 1.25 ou plus récent dans votre `PATH` (`go version` répond).
8+- Docker Desktop avec Docker Model Runner activé et joignable sur `http://localhost:12434`.
9+- Le modèle nommé dans `agent.yaml` téléchargé localement. Le fichier livré avec le projet nomme `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` ; récupérez-le avec `docker model pull <ce nom>`.
10+- Un terminal ouvert à la racine du dépôt (le répertoire qui contient `main.go` et `agent.yaml`).
11+
12+## Étape 1 — Compiler le binaire
13+
14+Tapez :
15+
16+```bash
17+go build -o mm .
18+```
19+
20+Vous ne devez voir aucune sortie. Un fichier `mm` se trouve maintenant dans le répertoire courant.
21+
22+Nous venons de compiler tout l'agent en un seul exécutable.
23+
24+## Étape 2 — Lancer l'agent
25+
26+Tapez :
27+
28+```bash
29+./mm
30+```
31+
32+Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`agent.yaml`) :
33+
34+```
35+config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
36+Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.
37+Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
38+
39+>
40+```
41+
42+Si une ligne grisée `[warning: dmr: nothing answers at …]` apparaît au-dessus de la bannière, Docker Model Runner ne tourne pas : démarrez-le, puis relancez `./mm`. L'agent démarre quand même, mais l'étape suivante échouerait.
43+
44+Nous venons de charger la configuration, de nous connecter au serveur de modèle et de déclarer les outils que le modèle peut appeler.
45+
46+## Étape 3 — Poser une question qui demande une commande
47+
48+À l'invite `>`, tapez :
49+
50+```
51+list the files in this directory
52+```
53+
54+Vous devez voir, dans l'ordre : un spinner pendant que le modèle réfléchit, une ligne commençant par `🛠️ bash:` qui montre la commande choisie par le modèle (typiquement `ls` ou `ls -la`), les premières lignes de la sortie de cette commande, indentées et grisées, puis la réponse du modèle en texte. Le tour se termine par un récapitulatif grisé tel que :
55+
56+```
57+⚙ 1 command(s)
58+```
59+
60+suivi de la liste numérotée, en vert, des commandes exécutées.
61+
62+Nous venons d'observer un tour de la boucle de l'agent : le modèle a demandé un outil, l'outil a tourné, sa sortie est revenue au modèle, et le modèle a répondu.
63+
64+## Étape 4 — Quitter
65+
66+À l'invite `>`, tapez :
67+
68+```
69+/quit
70+```
71+
72+Le programme se termine. `Ctrl+C` à l'invite fait la même chose et affiche d'abord `Goodbye.`.
73+
74+## Et ensuite ?
75+
76+Vous avez compilé `mm`, exécuté un tour avec appel d'outil, et quitté. Pour aller plus loin :
77+
78+- Pour le faire tourner sur llama.cpp, l'héberger dans un éditeur ou ajouter des skills → voir les [guides pratiques](../how-to/)
79+- Pour retrouver une option, une clé YAML ou une commande slash → voir la [référence](../reference/)
80+- Pour comprendre comment la boucle et les deux façades s'articulent → voir les [explications](../explanation/)
added go.mod +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+module mm
2+
3+go 1.25.0
4+
5+require (
6+ github.com/firebase/genkit/go v1.10.0
7+ github.com/openai/openai-go v1.8.2
8+ gopkg.in/yaml.v3 v3.0.1
9+)
10+
11+require (
12+ github.com/bahlo/generic-list-go v0.2.0 // indirect
13+ github.com/buger/jsonparser v1.1.1 // indirect
14+ github.com/coder/acp-go-sdk v0.13.5
15+ github.com/coder/websocket v1.8.14 // indirect
16+ github.com/go-logr/logr v1.4.3 // indirect
17+ github.com/go-logr/stdr v1.2.2 // indirect
18+ github.com/goccy/go-yaml v1.17.1 // indirect
19+ github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect
20+ github.com/google/uuid v1.6.0 // indirect
21+ github.com/invopop/jsonschema v0.13.0 // indirect
22+ github.com/mailru/easyjson v0.9.0 // indirect
23+ github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect
24+ github.com/tidwall/gjson v1.18.0 // indirect
25+ github.com/tidwall/match v1.1.1 // indirect
26+ github.com/tidwall/pretty v1.2.1 // indirect
27+ github.com/tidwall/sjson v1.2.5 // indirect
28+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
29+ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
30+ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
31+ github.com/xeipuuv/gojsonschema v1.2.0 // indirect
32+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
33+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
34+ go.opentelemetry.io/otel v1.36.0 // indirect
35+ go.opentelemetry.io/otel/metric v1.36.0 // indirect
36+ go.opentelemetry.io/otel/sdk v1.36.0 // indirect
37+ go.opentelemetry.io/otel/trace v1.36.0 // indirect
38+ golang.org/x/sys v0.34.0 // indirect
39+)
new file mode 100644
@@ -0,0 +1,39 @@
1+module mm
2+
3+go 1.25.0
4+
5+require (
6+ github.com/firebase/genkit/go v1.10.0
7+ github.com/openai/openai-go v1.8.2
8+ gopkg.in/yaml.v3 v3.0.1
9+)
10+
11+require (
12+ github.com/bahlo/generic-list-go v0.2.0 // indirect
13+ github.com/buger/jsonparser v1.1.1 // indirect
14+ github.com/coder/acp-go-sdk v0.13.5
15+ github.com/coder/websocket v1.8.14 // indirect
16+ github.com/go-logr/logr v1.4.3 // indirect
17+ github.com/go-logr/stdr v1.2.2 // indirect
18+ github.com/goccy/go-yaml v1.17.1 // indirect
19+ github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect
20+ github.com/google/uuid v1.6.0 // indirect
21+ github.com/invopop/jsonschema v0.13.0 // indirect
22+ github.com/mailru/easyjson v0.9.0 // indirect
23+ github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect
24+ github.com/tidwall/gjson v1.18.0 // indirect
25+ github.com/tidwall/match v1.1.1 // indirect
26+ github.com/tidwall/pretty v1.2.1 // indirect
27+ github.com/tidwall/sjson v1.2.5 // indirect
28+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
29+ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
30+ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
31+ github.com/xeipuuv/gojsonschema v1.2.0 // indirect
32+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
33+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
34+ go.opentelemetry.io/otel v1.36.0 // indirect
35+ go.opentelemetry.io/otel/metric v1.36.0 // indirect
36+ go.opentelemetry.io/otel/sdk v1.36.0 // indirect
37+ go.opentelemetry.io/otel/trace v1.36.0 // indirect
38+ golang.org/x/sys v0.34.0 // indirect
39+)
added go.sum +86 -0
new file mode 100644
@@ -0,0 +1,86 @@
1+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
2+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
3+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
4+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
5+github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk=
6+github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
7+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
8+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
9+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
11+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12+github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs=
13+github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E=
14+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
15+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
16+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
17+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
18+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
19+github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
20+github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
21+github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 h1:okN800+zMJOGHLJCgry+OGzhhtH6YrjQh1rluHmOacE=
22+github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254/go.mod h1:k8cjJAQWc//ac/bMnzItyOFbfT01tgRTZGgxELCuxEQ=
23+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
24+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
25+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
26+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
27+github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
28+github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
29+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
30+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
31+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
32+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
33+github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
34+github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
35+github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=
36+github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a/go.mod h1:Y6ghKH+ZijXn5d9E7qGGZBmjitx7iitZdQiIW97EpTU=
37+github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP8=
38+github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
39+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
40+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
41+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
42+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
43+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
44+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
45+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
46+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
47+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
48+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
49+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
50+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
51+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
52+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
53+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
54+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
55+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
56+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
57+github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
58+github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
59+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
60+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
61+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
62+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
63+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
64+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
65+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
66+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
67+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
68+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
69+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
70+go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
71+go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
72+go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
73+go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
74+go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
75+go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
76+go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
77+go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
78+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
79+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
80+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
81+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
82+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
83+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
84+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
85+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
86+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
new file mode 100644
@@ -0,0 +1,86 @@
1+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
2+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
3+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
4+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
5+github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk=
6+github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
7+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
8+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
9+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
11+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12+github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs=
13+github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E=
14+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
15+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
16+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
17+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
18+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
19+github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
20+github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
21+github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 h1:okN800+zMJOGHLJCgry+OGzhhtH6YrjQh1rluHmOacE=
22+github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254/go.mod h1:k8cjJAQWc//ac/bMnzItyOFbfT01tgRTZGgxELCuxEQ=
23+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
24+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
25+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
26+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
27+github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
28+github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
29+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
30+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
31+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
32+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
33+github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
34+github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
35+github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=
36+github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a/go.mod h1:Y6ghKH+ZijXn5d9E7qGGZBmjitx7iitZdQiIW97EpTU=
37+github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP8=
38+github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
39+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
40+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
41+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
42+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
43+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
44+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
45+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
46+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
47+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
48+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
49+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
50+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
51+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
52+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
53+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
54+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
55+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
56+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
57+github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
58+github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
59+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
60+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
61+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
62+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
63+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
64+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
65+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
66+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
67+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
68+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
69+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
70+go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
71+go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
72+go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
73+go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
74+go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
75+go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
76+go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
77+go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
78+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
79+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
80+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
81+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
82+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
83+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
84+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
85+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
86+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
added install.sh +17 -0
new file mode 100755
@@ -0,0 +1,17 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+BINARY_NAME="mm"
5+INSTALL_DIR="/usr/local/bin"
6+
7+echo "Building ${BINARY_NAME}..."
8+go build -o "${BINARY_NAME}" .
9+
10+echo "Installing ${BINARY_NAME} to ${INSTALL_DIR}..."
11+if [[ -w "${INSTALL_DIR}" ]]; then
12+ cp "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
13+else
14+ sudo cp "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
15+fi
16+
17+echo "Done. $(which ${BINARY_NAME})"
new file mode 100755
@@ -0,0 +1,17 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+BINARY_NAME="mm"
5+INSTALL_DIR="/usr/local/bin"
6+
7+echo "Building ${BINARY_NAME}..."
8+go build -o "${BINARY_NAME}" .
9+
10+echo "Installing ${BINARY_NAME} to ${INSTALL_DIR}..."
11+if [[ -w "${INSTALL_DIR}" ]]; then
12+ cp "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
13+else
14+ sudo cp "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
15+fi
16+
17+echo "Done. $(which ${BINARY_NAME})"
added internal/acp/acp.go +462 -0
new file mode 100644
@@ -0,0 +1,462 @@
1+// Package acp is bob's second front end: the same engine, the same tools and
2+// the same history rules as the terminal REPL (internal/agent), exposed over
3+// the Agent Client Protocol — JSON-RPC 2.0 on stdio, one message per line —
4+// so that an editor (Zed, IntelliJ, Neovim…) can host the agent.
5+//
6+// The division of labour:
7+//
8+// - the SDK (github.com/coder/acp-go-sdk) does the transport: framing,
9+// routing initialize/session/new/session/prompt to the methods below,
10+// correlating the ids of our own requests to the client (permissions);
11+// - this package translates: a prompt turn becomes one engine.Generate call,
12+// and the events the tools emit through ui.Sink become session/update
13+// notifications — text chunks, tool_call lifecycles, diffs;
14+// - nothing else changes: internal/engine and internal/tools are shared with
15+// the terminal front end, which is the point of the exercise.
16+//
17+// stdout carries JSON-RPC only. Everything human — the banner, the [acp] trail,
18+// the SDK's own diagnostics — goes to stderr, which the spec leaves to logging
19+// and which Zed shows in `dev: open acp logs`.
20+package acp
21+
22+import (
23+ "context"
24+ "encoding/json"
25+ "errors"
26+ "fmt"
27+ "io"
28+ "log/slog"
29+ "os"
30+ "strings"
31+ "sync"
32+
33+ "mm/internal/compact"
34+ "mm/internal/config"
35+ "mm/internal/engine"
36+ "mm/internal/mention"
37+ history "mm/internal/session"
38+ "mm/internal/ui"
39+
40+ sdk "github.com/coder/acp-go-sdk"
41+ "github.com/firebase/genkit/go/ai"
42+)
43+
44+// version is what initialize reports in agentInfo — the part number, so a
45+// trace says which chapter of the talk produced it.
46+const version = "0.11.0"
47+
48+// session is one conversation with the client. Like the REPL, the history is
49+// plain []*ai.Message with the system prompt first; unlike the REPL there can
50+// be several of them, one per session/new.
51+type session struct {
52+ cwd string
53+ messages []*ai.Message
54+ cancel context.CancelFunc // cancels the turn in flight, if any
55+ // allowAlways remembers the tools the user granted for the whole session
56+ // ("allow always" in the permission dialog).
57+ allowAlways map[string]bool
58+}
59+
60+type front struct {
61+ e *engine.Engine
62+ system string
63+ tools []ai.ToolRef
64+ conn *sdk.AgentSideConnection
65+ out *responseWriter // what the SDK writes to; see responseWriter
66+
67+ mu sync.Mutex // guards sessions and nextID
68+ sessions map[sdk.SessionId]*session
69+ nextID int
70+
71+ // turnMu serialises the turns. Two reasons, both structural: the active
72+ // ui.Sink is process-global, and engine.Generate was never designed for two
73+ // concurrent turns. The spec leaves this to the agent, and Zed does not
74+ // send a second prompt while one is running — a queued turn just waits.
75+ turnMu sync.Mutex
76+}
77+
78+var _ sdk.Agent = (*front)(nil)
79+
80+// Run wires the agent side of an ACP connection on stdio and blocks until the
81+// client closes it. Same arguments as agent.Run: main.go's wiring — engine,
82+// system prompt, tool list — is identical for the two front ends.
83+func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) {
84+ f := newFront(e, system, tools, os.Stdout, os.Stdin)
85+ select {
86+ case <-f.conn.Done(): // the editor closed our stdin: we are done
87+ case <-ctx.Done():
88+ }
89+}
90+
91+// newFront wires a front end on an arbitrary pair of streams — stdio in Run,
92+// pipes in the tests. w is where WE write (JSON-RPC to the client), r where
93+// we read the client's messages.
94+func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front {
95+ f := &front{e: e, system: system, tools: tools, sessions: map[sdk.SessionId]*session{}}
96+ f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}}
97+ conn := sdk.NewAgentSideConnection(f, f.out, r)
98+ conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))
99+ f.conn = conn
100+ return f
101+}
102+
103+// responseWriter is the io.Writer handed to the SDK: the real output plus one
104+// hook per session, run once the session/new response carrying that session
105+// id has been written. It exists for available_commands_update. The spec says
106+// to announce the commands right after the session is created, and the client
107+// (Zed, verified 2026-09-15) drops any session/update for a session whose
108+// session/new response it has not received yet — "Available commands: none".
109+// But the SDK writes the response only when NewSession returns, after anything
110+// the method itself sent, and offers no "after the response" callback. Watching
111+// the bytes is the one place where the order is certain.
112+type responseWriter struct {
113+ io.Writer
114+ mu sync.Mutex
115+ hooks map[sdk.SessionId]func()
116+}
117+
118+// afterResponse registers hook to run — once, in its own goroutine — after the
119+// response that carries sid has been written.
120+func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) {
121+ w.mu.Lock()
122+ defer w.mu.Unlock()
123+ w.hooks[sid] = hook
124+}
125+
126+// Write forwards to the real output, then fires the hook of the session whose
127+// response this line is, if any. The hook runs in a goroutine: the SDK holds
128+// its write lock here, and the hook is about to write a notification.
129+func (w *responseWriter) Write(p []byte) (int, error) {
130+ n, err := w.Writer.Write(p)
131+ if err != nil {
132+ return n, err
133+ }
134+ if sid, ok := responseSessionID(p); ok {
135+ w.mu.Lock()
136+ hook := w.hooks[sid]
137+ delete(w.hooks, sid)
138+ w.mu.Unlock()
139+ if hook != nil {
140+ go hook()
141+ }
142+ }
143+ return n, nil
144+}
145+
146+// responseSessionID recognises a JSON-RPC response whose result carries a
147+// sessionId — session/new (and session/load, which this agent does not
148+// offer). Notifications have no id and prompt responses no sessionId, so
149+// neither matches.
150+func responseSessionID(line []byte) (sdk.SessionId, bool) {
151+ var m struct {
152+ ID json.RawMessage `json:"id"`
153+ Result struct {
154+ SessionId sdk.SessionId `json:"sessionId"`
155+ } `json:"result"`
156+ }
157+ if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" {
158+ return "", false
159+ }
160+ return m.Result.SessionId, true
161+}
162+
163+// --- initialize ---------------------------------------------------------------
164+
165+func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) {
166+ fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d\n",
167+ clientName(p.ClientInfo), p.ProtocolVersion)
168+ return sdk.InitializeResponse{
169+ ProtocolVersion: sdk.ProtocolVersionNumber, // 1
170+ // Everything absent is false: no loadSession, no image/audio prompts,
171+ // no MCP over http. Honest capabilities are what lets a client adapt.
172+ AgentCapabilities: sdk.AgentCapabilities{},
173+ AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version},
174+ AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into
175+ }, nil
176+}
177+
178+func clientName(impl *sdk.Implementation) string {
179+ if impl == nil {
180+ return "unknown"
181+ }
182+ return impl.Name
183+}
184+
185+func (f *front) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) {
186+ return sdk.AuthenticateResponse{}, nil
187+}
188+
189+// --- session/new --------------------------------------------------------------
190+
191+func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
192+ f.mu.Lock()
193+ f.nextID++
194+ sid := sdk.SessionId(fmt.Sprintf("bob-%d-%d", os.Getpid(), f.nextID))
195+ f.sessions[sid] = &session{
196+ cwd: p.Cwd, // absolute, per the spec; where bash runs for this session
197+ messages: history.Fresh(f.system),
198+ allowAlways: map[string]bool{},
199+ }
200+ f.mu.Unlock()
201+ // mcpServers is where the client offers extra tools. bob's whole point is
202+ // the opposite — one built-in tool — so they are acknowledged and ignored.
203+ fmt.Fprintf(os.Stderr, "[acp] session %s cwd=%s mcpServers=%d (ignored)\n", sid, p.Cwd, len(p.McpServers))
204+ // The slash commands, announced as the spec asks — right after the session
205+ // is created — so the editor can list and complete them. "Right after"
206+ // means after the response: Zed keys its sessions on the id that response
207+ // carries and drops updates for an id it does not know yet. Hence the hook
208+ // on the writer rather than a SessionUpdate call here (see responseWriter).
209+ // The request context dies with this method; the hook needs one that lives.
210+ f.out.afterResponse(sid, func() {
211+ _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{
212+ SessionId: sid,
213+ Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
214+ })
215+ })
216+ return sdk.NewSessionResponse{SessionId: sid}, nil
217+}
218+
219+// compactCommand is the REPL's /compact, spelled here because ACP has no other
220+// place for it; /new is shared through internal/session. /quit and /abort are
221+// deliberately absent: the editor owns the process and ends it by closing
222+// stdin, and it cancels a turn with its own stop key through session/cancel.
223+const compactCommand = "/compact"
224+
225+// isCommand says whether input, trimmed, is exactly the given command — the
226+// same rule as session.IsNewCommand: "/compact " is the command, "/compact
227+// now" is a question for the model.
228+func isCommand(input, command string) bool {
229+ return strings.TrimSpace(input) == command
230+}
231+
232+// availableCommands lists the slash commands this front end handles itself,
233+// in the shape ACP advertises them: bare names, the client adds the slash.
234+// Only what session/prompt actually intercepts belongs here — announcing
235+// /quit or /abort, which the REPL has and this front end does not, would
236+// promise the editor something the next prompt would hand to the model.
237+func availableCommands() []sdk.AvailableCommand {
238+ return []sdk.AvailableCommand{
239+ {Name: history.NewCommandName, Description: "Clear the history and start a new session"},
240+ {Name: strings.TrimPrefix(compactCommand, "/"), Description: "Compress the history now, keeping the last turns"},
241+ }
242+}
243+
244+// --- session/prompt -----------------------------------------------------------
245+
246+func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
247+ f.mu.Lock()
248+ s, ok := f.sessions[p.SessionId]
249+ f.mu.Unlock()
250+ if !ok {
251+ return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId})
252+ }
253+
254+ input := flatten(p.Prompt)
255+
256+ f.turnMu.Lock()
257+ defer f.turnMu.Unlock()
258+
259+ // A slash command is answered here, without a turn: it is the REPL's "only
260+ // between two generations" rule, met for free — turnMu guarantees no
261+ // generation is running on this session's history while we replace it.
262+ switch {
263+ case history.IsNewCommand(input):
264+ return f.startNewSession(ctx, p.SessionId, s), nil
265+ case isCommand(input, compactCommand):
266+ return f.compactSession(ctx, p.SessionId, s), nil
267+ }
268+
269+ // One cancellable context per turn: session/cancel fires s.cancel, the
270+ // generation and any pending permission request fall with it, and the spec's
271+ // contract — still answer the prompt, with stopReason "cancelled" — is met
272+ // at the bottom of this function.
273+ turnCtx, cancel := context.WithCancel(ctx)
274+ f.mu.Lock()
275+ s.cancel = cancel
276+ f.mu.Unlock()
277+ defer func() {
278+ f.mu.Lock()
279+ s.cancel = nil
280+ f.mu.Unlock()
281+ cancel()
282+ }()
283+
284+ // The sink is what turns the tools' events into session/update messages;
285+ // installing it is what switches engine+tools to "ACP mode" for this turn.
286+ ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s})
287+ defer ui.SetActive(nil)
288+
289+ // A typed "@path" — the editor's picker bypassed, or an editor without
290+ // one — is understood like the resource_link the picker would have sent,
291+ // against the session's cwd, which is the project the editor opened.
292+ input, attached := mention.Expand(input, s.cwd)
293+ for _, a := range attached {
294+ fmt.Fprintf(os.Stderr, "[acp] session %s: attached %s\n", p.SessionId, a.Path)
295+ }
296+ s.messages = append(s.messages, ai.NewUserTextMessage(input))
297+ before := len(s.messages)
298+
299+ resp, history, err := f.e.Generate(turnCtx, s.messages, f.tools)
300+
301+ // Same rule as the REPL, for the same reason: keep the FULL history even
302+ // when the turn failed or was cancelled — the commands that already ran are
303+ // part of the conversation, and forgetting them makes the model re-run or
304+ // invent them on the next turn.
305+ switch {
306+ case len(history) > before:
307+ s.messages = history
308+ case resp != nil && resp.Message != nil:
309+ s.messages = append(s.messages, resp.Message)
310+ case err != nil:
311+ s.messages = s.messages[:before-1] // question without an answer: forget it
312+ }
313+
314+ // The ⚙ recap of the REPL, one line on stderr: the editor already renders
315+ // every tool call, but the trail is what lets `dev: open acp logs` say at a
316+ // glance whether the model worked or improvised.
317+ turn := s.messages[min(before, len(s.messages)):]
318+ fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n",
319+ len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn))
320+
321+ switch {
322+ case turnCtx.Err() != nil:
323+ // The turn was cancelled — and both roads lead here: our Cancel method
324+ // fires s.cancel (the child), AND the SDK cancels the request's own
325+ // context when session/cancel arrives (the parent — measured: with the
326+ // parent-must-be-intact test the REPL uses, a cancelled turn answered
327+ // "Internal error: stream error: context canceled" instead of honouring
328+ // the spec's contract). Either way the answer is the same: respond to
329+ // session/prompt anyway, with stopReason "cancelled".
330+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
331+ case err != nil:
332+ // One line, in the provider's words — the same text the REPL prints
333+ // between brackets, delivered as a JSON-RPC error.
334+ return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": f.e.Explain(err)})
335+ }
336+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
337+}
338+
339+// startNewSession is the ACP side of /new: the session keeps its id, its cwd
340+// and the "allow always" grants — the dialog promised them "for this session",
341+// and the session, as the editor sees it, is the same one — but its history
342+// goes back to the system prompt alone, and the server's token count of the
343+// last context is dropped with it, as after a compression. The one line the
344+// REPL prints travels as an agent message so the editor shows it in the
345+// thread, and the turn ends right there: the model is not consulted.
346+func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
347+ forgotten := resetSession(s, f.system)
348+ f.e.ForgetInputTokens()
349+ fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
350+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
351+ SessionId: sid,
352+ Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),
353+ })
354+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
355+}
356+
357+// compactSession is the ACP side of /compact: the REPL's compactHistory with
358+// the report sent as an agent message instead of printed. Forced, like the
359+// REPL's — the threshold is for the automatic path, which this front end does
360+// not have — so "nothing to compact" is said rather than swallowed. Runs under
361+// turnMu: Genkit is not holding this history.
362+func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
363+ cfg := config.Cfg.Context
364+ line, compressed := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
365+ return f.e.Summarize(ctx, request, cfg.SummaryMaxTokens)
366+ }, f.e.Explain)
367+ if compressed {
368+ f.e.ForgetInputTokens() // the server's measure described the history just replaced
369+ }
370+ fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)
371+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
372+ SessionId: sid,
373+ Update: sdk.UpdateAgentMessageText(line),
374+ })
375+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
376+}
377+
378+// compactHistory compresses the session's history in place when there is
379+// something to compress, and returns the one line to show — the REPL's words,
380+// so the editor and the terminal agree — and whether the history changed.
381+// Kept apart from compactSession, with the summariser and the error explainer
382+// injected, so the three outcomes are testable without an engine or a
383+// connection.
384+func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) {
385+ res, err := compact.Compact(ctx, s.messages, cfg, summarize)
386+ switch {
387+ case errors.Is(err, compact.ErrNothingToCompact):
388+ return fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(s.messages), cfg.KeepLastTurns), false
389+ case err != nil:
390+ return fmt.Sprintf("[compact: failed, history kept: %s]", explain(err)), false
391+ }
392+ s.messages = res.Messages
393+ return "🗜️ " + res.Report(), true
394+}
395+
396+// resetSession replaces the session's history with a fresh one and returns how
397+// many messages were forgotten. Kept apart from startNewSession so the rule
398+// — history reset, permissions kept — is testable without a connection.
399+func resetSession(s *session, system string) int {
400+ forgotten := history.Forgotten(s.messages)
401+ s.messages = history.Fresh(system)
402+ return forgotten
403+}
404+
405+// flatten turns the prompt's content blocks into the plain text bob reads.
406+// Text is taken as-is; a resource_link (a file the user @-mentioned through
407+// the editor's picker) becomes its path, in the same shape mention.Expand
408+// gives a typed @path — the model has tools to read it, that is the whole
409+// idea; anything else (image, audio, embedded resource) was not announced in
410+// our capabilities, so a compliant client never sends it.
411+func flatten(blocks []sdk.ContentBlock) string {
412+ var b strings.Builder
413+ for _, c := range blocks {
414+ switch {
415+ case c.Text != nil:
416+ b.WriteString(c.Text.Text)
417+ case c.ResourceLink != nil:
418+ fmt.Fprintf(&b, "\n[attached file: %s]", strings.TrimPrefix(c.ResourceLink.Uri, "file://"))
419+ }
420+ }
421+ return b.String()
422+}
423+
424+// --- session/cancel -----------------------------------------------------------
425+
426+func (f *front) Cancel(_ context.Context, p sdk.CancelNotification) error {
427+ f.mu.Lock()
428+ defer f.mu.Unlock()
429+ if s, ok := f.sessions[p.SessionId]; ok && s.cancel != nil {
430+ s.cancel()
431+ }
432+ return nil
433+}
434+
435+// --- the rest of sdk.Agent: not supported, and saying so -----------------------
436+//
437+// The SDK requires the full interface; answering "method not found" is the
438+// protocol's way of saying a capability we never announced is indeed absent.
439+
440+func (f *front) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) {
441+ return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode)
442+}
443+
444+func (f *front) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) {
445+ return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption)
446+}
447+
448+func (f *front) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
449+ return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList)
450+}
451+
452+func (f *front) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
453+ return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume)
454+}
455+
456+func (f *front) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) {
457+ return sdk.CloseSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionClose)
458+}
459+
460+func (f *front) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) {
461+ return sdk.LogoutResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodLogout)
462+}
new file mode 100644
@@ -0,0 +1,462 @@
1+// Package acp is bob's second front end: the same engine, the same tools and
2+// the same history rules as the terminal REPL (internal/agent), exposed over
3+// the Agent Client Protocol — JSON-RPC 2.0 on stdio, one message per line —
4+// so that an editor (Zed, IntelliJ, Neovim…) can host the agent.
5+//
6+// The division of labour:
7+//
8+// - the SDK (github.com/coder/acp-go-sdk) does the transport: framing,
9+// routing initialize/session/new/session/prompt to the methods below,
10+// correlating the ids of our own requests to the client (permissions);
11+// - this package translates: a prompt turn becomes one engine.Generate call,
12+// and the events the tools emit through ui.Sink become session/update
13+// notifications — text chunks, tool_call lifecycles, diffs;
14+// - nothing else changes: internal/engine and internal/tools are shared with
15+// the terminal front end, which is the point of the exercise.
16+//
17+// stdout carries JSON-RPC only. Everything human — the banner, the [acp] trail,
18+// the SDK's own diagnostics — goes to stderr, which the spec leaves to logging
19+// and which Zed shows in `dev: open acp logs`.
20+package acp
21+
22+import (
23+ "context"
24+ "encoding/json"
25+ "errors"
26+ "fmt"
27+ "io"
28+ "log/slog"
29+ "os"
30+ "strings"
31+ "sync"
32+
33+ "mm/internal/compact"
34+ "mm/internal/config"
35+ "mm/internal/engine"
36+ "mm/internal/mention"
37+ history "mm/internal/session"
38+ "mm/internal/ui"
39+
40+ sdk "github.com/coder/acp-go-sdk"
41+ "github.com/firebase/genkit/go/ai"
42+)
43+
44+// version is what initialize reports in agentInfo — the part number, so a
45+// trace says which chapter of the talk produced it.
46+const version = "0.11.0"
47+
48+// session is one conversation with the client. Like the REPL, the history is
49+// plain []*ai.Message with the system prompt first; unlike the REPL there can
50+// be several of them, one per session/new.
51+type session struct {
52+ cwd string
53+ messages []*ai.Message
54+ cancel context.CancelFunc // cancels the turn in flight, if any
55+ // allowAlways remembers the tools the user granted for the whole session
56+ // ("allow always" in the permission dialog).
57+ allowAlways map[string]bool
58+}
59+
60+type front struct {
61+ e *engine.Engine
62+ system string
63+ tools []ai.ToolRef
64+ conn *sdk.AgentSideConnection
65+ out *responseWriter // what the SDK writes to; see responseWriter
66+
67+ mu sync.Mutex // guards sessions and nextID
68+ sessions map[sdk.SessionId]*session
69+ nextID int
70+
71+ // turnMu serialises the turns. Two reasons, both structural: the active
72+ // ui.Sink is process-global, and engine.Generate was never designed for two
73+ // concurrent turns. The spec leaves this to the agent, and Zed does not
74+ // send a second prompt while one is running — a queued turn just waits.
75+ turnMu sync.Mutex
76+}
77+
78+var _ sdk.Agent = (*front)(nil)
79+
80+// Run wires the agent side of an ACP connection on stdio and blocks until the
81+// client closes it. Same arguments as agent.Run: main.go's wiring — engine,
82+// system prompt, tool list — is identical for the two front ends.
83+func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) {
84+ f := newFront(e, system, tools, os.Stdout, os.Stdin)
85+ select {
86+ case <-f.conn.Done(): // the editor closed our stdin: we are done
87+ case <-ctx.Done():
88+ }
89+}
90+
91+// newFront wires a front end on an arbitrary pair of streams — stdio in Run,
92+// pipes in the tests. w is where WE write (JSON-RPC to the client), r where
93+// we read the client's messages.
94+func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front {
95+ f := &front{e: e, system: system, tools: tools, sessions: map[sdk.SessionId]*session{}}
96+ f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}}
97+ conn := sdk.NewAgentSideConnection(f, f.out, r)
98+ conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))
99+ f.conn = conn
100+ return f
101+}
102+
103+// responseWriter is the io.Writer handed to the SDK: the real output plus one
104+// hook per session, run once the session/new response carrying that session
105+// id has been written. It exists for available_commands_update. The spec says
106+// to announce the commands right after the session is created, and the client
107+// (Zed, verified 2026-09-15) drops any session/update for a session whose
108+// session/new response it has not received yet — "Available commands: none".
109+// But the SDK writes the response only when NewSession returns, after anything
110+// the method itself sent, and offers no "after the response" callback. Watching
111+// the bytes is the one place where the order is certain.
112+type responseWriter struct {
113+ io.Writer
114+ mu sync.Mutex
115+ hooks map[sdk.SessionId]func()
116+}
117+
118+// afterResponse registers hook to run — once, in its own goroutine — after the
119+// response that carries sid has been written.
120+func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) {
121+ w.mu.Lock()
122+ defer w.mu.Unlock()
123+ w.hooks[sid] = hook
124+}
125+
126+// Write forwards to the real output, then fires the hook of the session whose
127+// response this line is, if any. The hook runs in a goroutine: the SDK holds
128+// its write lock here, and the hook is about to write a notification.
129+func (w *responseWriter) Write(p []byte) (int, error) {
130+ n, err := w.Writer.Write(p)
131+ if err != nil {
132+ return n, err
133+ }
134+ if sid, ok := responseSessionID(p); ok {
135+ w.mu.Lock()
136+ hook := w.hooks[sid]
137+ delete(w.hooks, sid)
138+ w.mu.Unlock()
139+ if hook != nil {
140+ go hook()
141+ }
142+ }
143+ return n, nil
144+}
145+
146+// responseSessionID recognises a JSON-RPC response whose result carries a
147+// sessionId — session/new (and session/load, which this agent does not
148+// offer). Notifications have no id and prompt responses no sessionId, so
149+// neither matches.
150+func responseSessionID(line []byte) (sdk.SessionId, bool) {
151+ var m struct {
152+ ID json.RawMessage `json:"id"`
153+ Result struct {
154+ SessionId sdk.SessionId `json:"sessionId"`
155+ } `json:"result"`
156+ }
157+ if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" {
158+ return "", false
159+ }
160+ return m.Result.SessionId, true
161+}
162+
163+// --- initialize ---------------------------------------------------------------
164+
165+func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) {
166+ fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d\n",
167+ clientName(p.ClientInfo), p.ProtocolVersion)
168+ return sdk.InitializeResponse{
169+ ProtocolVersion: sdk.ProtocolVersionNumber, // 1
170+ // Everything absent is false: no loadSession, no image/audio prompts,
171+ // no MCP over http. Honest capabilities are what lets a client adapt.
172+ AgentCapabilities: sdk.AgentCapabilities{},
173+ AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version},
174+ AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into
175+ }, nil
176+}
177+
178+func clientName(impl *sdk.Implementation) string {
179+ if impl == nil {
180+ return "unknown"
181+ }
182+ return impl.Name
183+}
184+
185+func (f *front) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) {
186+ return sdk.AuthenticateResponse{}, nil
187+}
188+
189+// --- session/new --------------------------------------------------------------
190+
191+func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
192+ f.mu.Lock()
193+ f.nextID++
194+ sid := sdk.SessionId(fmt.Sprintf("bob-%d-%d", os.Getpid(), f.nextID))
195+ f.sessions[sid] = &session{
196+ cwd: p.Cwd, // absolute, per the spec; where bash runs for this session
197+ messages: history.Fresh(f.system),
198+ allowAlways: map[string]bool{},
199+ }
200+ f.mu.Unlock()
201+ // mcpServers is where the client offers extra tools. bob's whole point is
202+ // the opposite — one built-in tool — so they are acknowledged and ignored.
203+ fmt.Fprintf(os.Stderr, "[acp] session %s cwd=%s mcpServers=%d (ignored)\n", sid, p.Cwd, len(p.McpServers))
204+ // The slash commands, announced as the spec asks — right after the session
205+ // is created — so the editor can list and complete them. "Right after"
206+ // means after the response: Zed keys its sessions on the id that response
207+ // carries and drops updates for an id it does not know yet. Hence the hook
208+ // on the writer rather than a SessionUpdate call here (see responseWriter).
209+ // The request context dies with this method; the hook needs one that lives.
210+ f.out.afterResponse(sid, func() {
211+ _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{
212+ SessionId: sid,
213+ Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
214+ })
215+ })
216+ return sdk.NewSessionResponse{SessionId: sid}, nil
217+}
218+
219+// compactCommand is the REPL's /compact, spelled here because ACP has no other
220+// place for it; /new is shared through internal/session. /quit and /abort are
221+// deliberately absent: the editor owns the process and ends it by closing
222+// stdin, and it cancels a turn with its own stop key through session/cancel.
223+const compactCommand = "/compact"
224+
225+// isCommand says whether input, trimmed, is exactly the given command — the
226+// same rule as session.IsNewCommand: "/compact " is the command, "/compact
227+// now" is a question for the model.
228+func isCommand(input, command string) bool {
229+ return strings.TrimSpace(input) == command
230+}
231+
232+// availableCommands lists the slash commands this front end handles itself,
233+// in the shape ACP advertises them: bare names, the client adds the slash.
234+// Only what session/prompt actually intercepts belongs here — announcing
235+// /quit or /abort, which the REPL has and this front end does not, would
236+// promise the editor something the next prompt would hand to the model.
237+func availableCommands() []sdk.AvailableCommand {
238+ return []sdk.AvailableCommand{
239+ {Name: history.NewCommandName, Description: "Clear the history and start a new session"},
240+ {Name: strings.TrimPrefix(compactCommand, "/"), Description: "Compress the history now, keeping the last turns"},
241+ }
242+}
243+
244+// --- session/prompt -----------------------------------------------------------
245+
246+func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
247+ f.mu.Lock()
248+ s, ok := f.sessions[p.SessionId]
249+ f.mu.Unlock()
250+ if !ok {
251+ return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId})
252+ }
253+
254+ input := flatten(p.Prompt)
255+
256+ f.turnMu.Lock()
257+ defer f.turnMu.Unlock()
258+
259+ // A slash command is answered here, without a turn: it is the REPL's "only
260+ // between two generations" rule, met for free — turnMu guarantees no
261+ // generation is running on this session's history while we replace it.
262+ switch {
263+ case history.IsNewCommand(input):
264+ return f.startNewSession(ctx, p.SessionId, s), nil
265+ case isCommand(input, compactCommand):
266+ return f.compactSession(ctx, p.SessionId, s), nil
267+ }
268+
269+ // One cancellable context per turn: session/cancel fires s.cancel, the
270+ // generation and any pending permission request fall with it, and the spec's
271+ // contract — still answer the prompt, with stopReason "cancelled" — is met
272+ // at the bottom of this function.
273+ turnCtx, cancel := context.WithCancel(ctx)
274+ f.mu.Lock()
275+ s.cancel = cancel
276+ f.mu.Unlock()
277+ defer func() {
278+ f.mu.Lock()
279+ s.cancel = nil
280+ f.mu.Unlock()
281+ cancel()
282+ }()
283+
284+ // The sink is what turns the tools' events into session/update messages;
285+ // installing it is what switches engine+tools to "ACP mode" for this turn.
286+ ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s})
287+ defer ui.SetActive(nil)
288+
289+ // A typed "@path" — the editor's picker bypassed, or an editor without
290+ // one — is understood like the resource_link the picker would have sent,
291+ // against the session's cwd, which is the project the editor opened.
292+ input, attached := mention.Expand(input, s.cwd)
293+ for _, a := range attached {
294+ fmt.Fprintf(os.Stderr, "[acp] session %s: attached %s\n", p.SessionId, a.Path)
295+ }
296+ s.messages = append(s.messages, ai.NewUserTextMessage(input))
297+ before := len(s.messages)
298+
299+ resp, history, err := f.e.Generate(turnCtx, s.messages, f.tools)
300+
301+ // Same rule as the REPL, for the same reason: keep the FULL history even
302+ // when the turn failed or was cancelled — the commands that already ran are
303+ // part of the conversation, and forgetting them makes the model re-run or
304+ // invent them on the next turn.
305+ switch {
306+ case len(history) > before:
307+ s.messages = history
308+ case resp != nil && resp.Message != nil:
309+ s.messages = append(s.messages, resp.Message)
310+ case err != nil:
311+ s.messages = s.messages[:before-1] // question without an answer: forget it
312+ }
313+
314+ // The ⚙ recap of the REPL, one line on stderr: the editor already renders
315+ // every tool call, but the trail is what lets `dev: open acp logs` say at a
316+ // glance whether the model worked or improvised.
317+ turn := s.messages[min(before, len(s.messages)):]
318+ fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n",
319+ len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn))
320+
321+ switch {
322+ case turnCtx.Err() != nil:
323+ // The turn was cancelled — and both roads lead here: our Cancel method
324+ // fires s.cancel (the child), AND the SDK cancels the request's own
325+ // context when session/cancel arrives (the parent — measured: with the
326+ // parent-must-be-intact test the REPL uses, a cancelled turn answered
327+ // "Internal error: stream error: context canceled" instead of honouring
328+ // the spec's contract). Either way the answer is the same: respond to
329+ // session/prompt anyway, with stopReason "cancelled".
330+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
331+ case err != nil:
332+ // One line, in the provider's words — the same text the REPL prints
333+ // between brackets, delivered as a JSON-RPC error.
334+ return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": f.e.Explain(err)})
335+ }
336+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
337+}
338+
339+// startNewSession is the ACP side of /new: the session keeps its id, its cwd
340+// and the "allow always" grants — the dialog promised them "for this session",
341+// and the session, as the editor sees it, is the same one — but its history
342+// goes back to the system prompt alone, and the server's token count of the
343+// last context is dropped with it, as after a compression. The one line the
344+// REPL prints travels as an agent message so the editor shows it in the
345+// thread, and the turn ends right there: the model is not consulted.
346+func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
347+ forgotten := resetSession(s, f.system)
348+ f.e.ForgetInputTokens()
349+ fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
350+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
351+ SessionId: sid,
352+ Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),
353+ })
354+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
355+}
356+
357+// compactSession is the ACP side of /compact: the REPL's compactHistory with
358+// the report sent as an agent message instead of printed. Forced, like the
359+// REPL's — the threshold is for the automatic path, which this front end does
360+// not have — so "nothing to compact" is said rather than swallowed. Runs under
361+// turnMu: Genkit is not holding this history.
362+func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
363+ cfg := config.Cfg.Context
364+ line, compressed := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
365+ return f.e.Summarize(ctx, request, cfg.SummaryMaxTokens)
366+ }, f.e.Explain)
367+ if compressed {
368+ f.e.ForgetInputTokens() // the server's measure described the history just replaced
369+ }
370+ fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)
371+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
372+ SessionId: sid,
373+ Update: sdk.UpdateAgentMessageText(line),
374+ })
375+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
376+}
377+
378+// compactHistory compresses the session's history in place when there is
379+// something to compress, and returns the one line to show — the REPL's words,
380+// so the editor and the terminal agree — and whether the history changed.
381+// Kept apart from compactSession, with the summariser and the error explainer
382+// injected, so the three outcomes are testable without an engine or a
383+// connection.
384+func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) {
385+ res, err := compact.Compact(ctx, s.messages, cfg, summarize)
386+ switch {
387+ case errors.Is(err, compact.ErrNothingToCompact):
388+ return fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(s.messages), cfg.KeepLastTurns), false
389+ case err != nil:
390+ return fmt.Sprintf("[compact: failed, history kept: %s]", explain(err)), false
391+ }
392+ s.messages = res.Messages
393+ return "🗜️ " + res.Report(), true
394+}
395+
396+// resetSession replaces the session's history with a fresh one and returns how
397+// many messages were forgotten. Kept apart from startNewSession so the rule
398+// — history reset, permissions kept — is testable without a connection.
399+func resetSession(s *session, system string) int {
400+ forgotten := history.Forgotten(s.messages)
401+ s.messages = history.Fresh(system)
402+ return forgotten
403+}
404+
405+// flatten turns the prompt's content blocks into the plain text bob reads.
406+// Text is taken as-is; a resource_link (a file the user @-mentioned through
407+// the editor's picker) becomes its path, in the same shape mention.Expand
408+// gives a typed @path — the model has tools to read it, that is the whole
409+// idea; anything else (image, audio, embedded resource) was not announced in
410+// our capabilities, so a compliant client never sends it.
411+func flatten(blocks []sdk.ContentBlock) string {
412+ var b strings.Builder
413+ for _, c := range blocks {
414+ switch {
415+ case c.Text != nil:
416+ b.WriteString(c.Text.Text)
417+ case c.ResourceLink != nil:
418+ fmt.Fprintf(&b, "\n[attached file: %s]", strings.TrimPrefix(c.ResourceLink.Uri, "file://"))
419+ }
420+ }
421+ return b.String()
422+}
423+
424+// --- session/cancel -----------------------------------------------------------
425+
426+func (f *front) Cancel(_ context.Context, p sdk.CancelNotification) error {
427+ f.mu.Lock()
428+ defer f.mu.Unlock()
429+ if s, ok := f.sessions[p.SessionId]; ok && s.cancel != nil {
430+ s.cancel()
431+ }
432+ return nil
433+}
434+
435+// --- the rest of sdk.Agent: not supported, and saying so -----------------------
436+//
437+// The SDK requires the full interface; answering "method not found" is the
438+// protocol's way of saying a capability we never announced is indeed absent.
439+
440+func (f *front) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) {
441+ return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode)
442+}
443+
444+func (f *front) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) {
445+ return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption)
446+}
447+
448+func (f *front) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
449+ return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList)
450+}
451+
452+func (f *front) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
453+ return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume)
454+}
455+
456+func (f *front) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) {
457+ return sdk.CloseSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionClose)
458+}
459+
460+func (f *front) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) {
461+ return sdk.LogoutResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodLogout)
462+}
added internal/acp/acp_test.go +308 -0
new file mode 100644
@@ -0,0 +1,308 @@
1+package acp
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "mm/internal/config"
14+ history "mm/internal/session"
15+
16+ sdk "github.com/coder/acp-go-sdk"
17+ "github.com/firebase/genkit/go/ai"
18+)
19+
20+// A prompt mixes text and attached files (resource_link): the text is kept
21+// as-is and the attachment becomes a path the model can read with its tools —
22+// bob has no embeddedContext capability, so the content itself never travels.
23+func TestFlattenTextAndResourceLink(t *testing.T) {
24+ got := flatten([]sdk.ContentBlock{
25+ sdk.TextBlock("fix the greeting"),
26+ sdk.ResourceLinkBlock("main.go", "file:///work/project/hello/main.go"),
27+ })
28+ want := "fix the greeting\n[attached file: /work/project/hello/main.go]"
29+ if got != want {
30+ t.Errorf("flatten:\n got %q\nwant %q", got, want)
31+ }
32+}
33+
34+// Unknown kinds must render, not fail: the kind only drives the client's
35+// display, so "execute" is the safe default.
36+func TestToolKindDefaultsToExecute(t *testing.T) {
37+ cases := map[string]sdk.ToolKind{
38+ "read": sdk.ToolKindRead,
39+ "edit": sdk.ToolKindEdit,
40+ "execute": sdk.ToolKindExecute,
41+ "": sdk.ToolKindExecute,
42+ "exotic": sdk.ToolKindExecute,
43+ }
44+ for in, want := range cases {
45+ if got := toolKind(in); got != want {
46+ t.Errorf("toolKind(%q) = %q, want %q", in, got, want)
47+ }
48+ }
49+}
50+
51+// The list the editor is shown must be exactly what session/prompt
52+// intercepts — no more (a /quit or /abort promised here would reach the model
53+// as a question; the editor's own stop key cancels a turn) and no less (a
54+// command that works but is not listed is one the user never finds). Names
55+// are bare: ACP clients add the slash.
56+func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {
57+ want := []string{history.NewCommandName, "compact"}
58+ cmds := availableCommands()
59+ if len(cmds) != len(want) {
60+ t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds)
61+ }
62+ for i, c := range cmds {
63+ if c.Name != want[i] {
64+ t.Errorf("command %d is %q, want %q", i, c.Name, want[i])
65+ }
66+ if c.Description == "" {
67+ t.Errorf("%q has no description: the editor would show a bare name", c.Name)
68+ }
69+ if c.Input != nil {
70+ t.Errorf("%q takes no input, but an input spec is advertised", c.Name)
71+ }
72+ // Each advertised name must be recognised when it comes back as "/name".
73+ typed := "/" + c.Name
74+ if !history.IsNewCommand(typed) && !isCommand(typed, compactCommand) {
75+ t.Errorf("%q is advertised but session/prompt would hand it to the model", typed)
76+ }
77+ }
78+}
79+
80+// Same rule as /new: the command is the whole line, spaces aside.
81+func TestIsCommandTrimsAndRequiresExactMatch(t *testing.T) {
82+ cases := map[string]bool{
83+ "/compact": true,
84+ " /compact\n": true,
85+ "/compact now": false,
86+ "/compacts": false,
87+ "please /compact": false,
88+ }
89+ for in, want := range cases {
90+ if got := isCommand(in, compactCommand); got != want {
91+ t.Errorf("isCommand(%q) = %v, want %v", in, got, want)
92+ }
93+ }
94+}
95+
96+// resetSession is what /new does to a session: the history goes back to the
97+// system prompt alone, the count says how much was dropped, and everything
98+// that is NOT the history — cwd, the "allow always" grants — survives, because
99+// the editor still sees the same session.
100+func TestResetSessionKeepsIdentityDropsHistory(t *testing.T) {
101+ s := &session{
102+ cwd: "/work/project",
103+ messages: history.Fresh("you are bob"),
104+ allowAlways: map[string]bool{"bash": true},
105+ }
106+ s.messages = append(s.messages, ai.NewUserTextMessage("ls"), ai.NewModelTextMessage("main.go"))
107+
108+ if got := resetSession(s, "you are bob"); got != 2 {
109+ t.Errorf("forgotten = %d, want 2", got)
110+ }
111+ if len(s.messages) != 1 || s.messages[0].Role != ai.RoleSystem || s.messages[0].Text() != "you are bob" {
112+ t.Errorf("history after reset: %+v, want the system prompt alone", s.messages)
113+ }
114+ if s.cwd != "/work/project" {
115+ t.Errorf("cwd changed to %q", s.cwd)
116+ }
117+ if !s.allowAlways["bash"] {
118+ t.Error("the 'allow always' grant for bash was dropped")
119+ }
120+
121+ // A second /new on a fresh session forgets nothing — and says so.
122+ if got := resetSession(s, "you are bob"); got != 0 {
123+ t.Errorf("forgotten on a fresh session = %d, want 0", got)
124+ }
125+}
126+
127+// The command is recognised from a flattened prompt — the editor sends it as
128+// a text block, exactly as typed.
129+func TestNewCommandIsRecognisedFromPromptBlocks(t *testing.T) {
130+ if !history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new")})) {
131+ t.Error("a text block '/new' is not recognised as the command")
132+ }
133+ if history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new"), sdk.ResourceLinkBlock("main.go", "file:///w/main.go")})) {
134+ t.Error("'/new' with an attached file is a question about that file, not the command")
135+ }
136+}
137+
138+// responseSessionID must recognise exactly the line the commands hook waits
139+// for: a response whose result carries a sessionId. A notification (no id)
140+// or a prompt response (no sessionId) must not fire the hook — the first
141+// would announce the commands before the editor knows the session, the
142+// second would never come from session/new at all.
143+func TestResponseSessionIDMatchesOnlySessionResponses(t *testing.T) {
144+ cases := map[string]struct {
145+ line string
146+ want sdk.SessionId
147+ }{
148+ "session/new response": {`{"jsonrpc":"2.0","id":1,"result":{"sessionId":"bob-1-1"}}`, "bob-1-1"},
149+ "string id": {`{"jsonrpc":"2.0","id":"a","result":{"sessionId":"bob-1-2"}}`, "bob-1-2"},
150+ "prompt response": {`{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}`, ""},
151+ "session/update": {`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"bob-1-1","update":{}}}`, ""},
152+ "request from the agent": {`{"jsonrpc":"2.0","id":3,"method":"session/request_permission","params":{"sessionId":"bob-1-1"}}`, ""},
153+ "garbage": {`not json`, ""},
154+ }
155+ for name, c := range cases {
156+ got, ok := responseSessionID([]byte(c.line))
157+ if ok != (c.want != "") || got != c.want {
158+ t.Errorf("%s: responseSessionID = (%q, %v), want (%q, %v)", name, got, ok, c.want, c.want != "")
159+ }
160+ }
161+}
162+
163+// The order on the wire is the whole point: the editor must read the
164+// session/new response — and so learn the session id — before the
165+// available_commands_update that names it. This drives the real SDK over
166+// pipes, playing the client by hand, and reads the raw lines back.
167+func TestAvailableCommandsAreSentAfterTheSessionNewResponse(t *testing.T) {
168+ clientToAgentR, clientToAgentW := io.Pipe()
169+ agentToClientR, agentToClientW := io.Pipe()
170+ t.Cleanup(func() {
171+ _ = clientToAgentW.Close()
172+ _ = agentToClientR.Close()
173+ })
174+ newFront(nil, "system prompt", nil, agentToClientW, clientToAgentR)
175+
176+ go func() {
177+ _, _ = io.WriteString(clientToAgentW, `{"jsonrpc":"2.0","id":7,"method":"session/new","params":{"cwd":"/work","mcpServers":[]}}`+"\n")
178+ }()
179+
180+ lines := make(chan string)
181+ go func() {
182+ sc := bufio.NewScanner(agentToClientR)
183+ for sc.Scan() {
184+ lines <- sc.Text()
185+ }
186+ close(lines)
187+ }()
188+ next := func() string {
189+ select {
190+ case l, ok := <-lines:
191+ if !ok {
192+ t.Fatal("agent closed its output before sending both lines")
193+ }
194+ return l
195+ case <-time.After(5 * time.Second):
196+ t.Fatal("timed out waiting for the agent's next line")
197+ }
198+ return ""
199+ }
200+
201+ first, second := next(), next()
202+
203+ sid, ok := responseSessionID([]byte(first))
204+ if !ok {
205+ t.Fatalf("first line is not the session/new response:\n%s", first)
206+ }
207+ var upd struct {
208+ Method string `json:"method"`
209+ Params struct {
210+ SessionId sdk.SessionId `json:"sessionId"`
211+ Update struct {
212+ SessionUpdate string `json:"sessionUpdate"`
213+ AvailableCommands []sdk.AvailableCommand `json:"availableCommands"`
214+ } `json:"update"`
215+ } `json:"params"`
216+ }
217+ if err := json.Unmarshal([]byte(second), &upd); err != nil {
218+ t.Fatalf("second line is not JSON: %v\n%s", err, second)
219+ }
220+ if upd.Method != "session/update" || upd.Params.Update.SessionUpdate != "available_commands_update" {
221+ t.Fatalf("second line is not an available_commands_update:\n%s", second)
222+ }
223+ if upd.Params.SessionId != sid {
224+ t.Errorf("commands announced for session %q, response created %q", upd.Params.SessionId, sid)
225+ }
226+ want := availableCommands()
227+ if len(upd.Params.Update.AvailableCommands) != len(want) {
228+ t.Fatalf("announced %d command(s), want %d: %+v", len(upd.Params.Update.AvailableCommands), len(want), upd.Params.Update.AvailableCommands)
229+ }
230+ for i, c := range upd.Params.Update.AvailableCommands {
231+ if c.Name != want[i].Name {
232+ t.Errorf("announced command %d is %q, want %q", i, c.Name, want[i].Name)
233+ }
234+ }
235+}
236+
237+// --- /compact and /abort --------------------------------------------------------
238+
239+// oneTurn is a question answered after one bash command: the smallest unit
240+// compaction cuts on.
241+func oneTurn(q string) []*ai.Message {
242+ return []*ai.Message{
243+ ai.NewUserTextMessage(q),
244+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: q, Name: "bash", Input: map[string]any{"command": "echo " + q}})),
245+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: q, Name: "bash", Output: q})),
246+ ai.NewModelMessage(ai.NewTextPart("done " + q)),
247+ }
248+}
249+
250+func sessionWithTurns(qs ...string) *session {
251+ s := &session{cwd: "/work", messages: history.Fresh("You are Bob."), allowAlways: map[string]bool{"bash": true}}
252+ for _, q := range qs {
253+ s.messages = append(s.messages, oneTurn(q)...)
254+ }
255+ return s
256+}
257+
258+// The three outcomes of /compact, in the REPL's words. Only the first one
259+// touches the history; the two others leave it — and the server's token
260+// count, which the caller resets on `compressed` alone — exactly as they were.
261+func TestCompactHistoryOutcomes(t *testing.T) {
262+ cfg := config.ContextConfig{Enabled: true, KeepLastTurns: 1, Threshold: 75, SummaryMaxTokens: 100}
263+ summary := func(context.Context, []*ai.Message) (string, error) { return "the user ran a and b", nil }
264+ explain := func(err error) string { return "explained: " + err.Error() }
265+
266+ t.Run("compresses the turns older than the kept ones", func(t *testing.T) {
267+ s := sessionWithTurns("a", "b", "c")
268+ before := len(s.messages)
269+ line, compressed := compactHistory(context.Background(), s, cfg, summary, explain)
270+ if !compressed {
271+ t.Fatalf("compressed = false, line %q", line)
272+ }
273+ if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") {
274+ t.Errorf("report %q does not open with the REPL's words and the right counts", line)
275+ }
276+ if len(s.messages) >= before {
277+ t.Errorf("history has %d messages after compaction, %d before", len(s.messages), before)
278+ }
279+ if s.messages[0].Role != ai.RoleSystem || s.allowAlways["bash"] != true || s.cwd != "/work" {
280+ t.Error("compaction touched something other than the turns")
281+ }
282+ })
283+
284+ t.Run("nothing to compact is said, not swallowed", func(t *testing.T) {
285+ s := sessionWithTurns("a")
286+ before := len(s.messages)
287+ line, compressed := compactHistory(context.Background(), s, cfg, summary, explain)
288+ if compressed || len(s.messages) != before {
289+ t.Errorf("compressed = %v, %d messages (was %d): a single kept turn must not move", compressed, len(s.messages), before)
290+ }
291+ if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" {
292+ t.Errorf("line %q", line)
293+ }
294+ })
295+
296+ t.Run("a failed summary keeps the history and names the cause", func(t *testing.T) {
297+ s := sessionWithTurns("a", "b", "c")
298+ before := len(s.messages)
299+ failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") }
300+ line, compressed := compactHistory(context.Background(), s, cfg, failing, explain)
301+ if compressed || len(s.messages) != before {
302+ t.Errorf("compressed = %v, %d messages (was %d): a failure must leave the history alone", compressed, len(s.messages), before)
303+ }
304+ if line != "[compact: failed, history kept: explained: server down]" {
305+ t.Errorf("line %q", line)
306+ }
307+ })
308+}
new file mode 100644
@@ -0,0 +1,308 @@
1+package acp
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "mm/internal/config"
14+ history "mm/internal/session"
15+
16+ sdk "github.com/coder/acp-go-sdk"
17+ "github.com/firebase/genkit/go/ai"
18+)
19+
20+// A prompt mixes text and attached files (resource_link): the text is kept
21+// as-is and the attachment becomes a path the model can read with its tools —
22+// bob has no embeddedContext capability, so the content itself never travels.
23+func TestFlattenTextAndResourceLink(t *testing.T) {
24+ got := flatten([]sdk.ContentBlock{
25+ sdk.TextBlock("fix the greeting"),
26+ sdk.ResourceLinkBlock("main.go", "file:///work/project/hello/main.go"),
27+ })
28+ want := "fix the greeting\n[attached file: /work/project/hello/main.go]"
29+ if got != want {
30+ t.Errorf("flatten:\n got %q\nwant %q", got, want)
31+ }
32+}
33+
34+// Unknown kinds must render, not fail: the kind only drives the client's
35+// display, so "execute" is the safe default.
36+func TestToolKindDefaultsToExecute(t *testing.T) {
37+ cases := map[string]sdk.ToolKind{
38+ "read": sdk.ToolKindRead,
39+ "edit": sdk.ToolKindEdit,
40+ "execute": sdk.ToolKindExecute,
41+ "": sdk.ToolKindExecute,
42+ "exotic": sdk.ToolKindExecute,
43+ }
44+ for in, want := range cases {
45+ if got := toolKind(in); got != want {
46+ t.Errorf("toolKind(%q) = %q, want %q", in, got, want)
47+ }
48+ }
49+}
50+
51+// The list the editor is shown must be exactly what session/prompt
52+// intercepts — no more (a /quit or /abort promised here would reach the model
53+// as a question; the editor's own stop key cancels a turn) and no less (a
54+// command that works but is not listed is one the user never finds). Names
55+// are bare: ACP clients add the slash.
56+func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {
57+ want := []string{history.NewCommandName, "compact"}
58+ cmds := availableCommands()
59+ if len(cmds) != len(want) {
60+ t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds)
61+ }
62+ for i, c := range cmds {
63+ if c.Name != want[i] {
64+ t.Errorf("command %d is %q, want %q", i, c.Name, want[i])
65+ }
66+ if c.Description == "" {
67+ t.Errorf("%q has no description: the editor would show a bare name", c.Name)
68+ }
69+ if c.Input != nil {
70+ t.Errorf("%q takes no input, but an input spec is advertised", c.Name)
71+ }
72+ // Each advertised name must be recognised when it comes back as "/name".
73+ typed := "/" + c.Name
74+ if !history.IsNewCommand(typed) && !isCommand(typed, compactCommand) {
75+ t.Errorf("%q is advertised but session/prompt would hand it to the model", typed)
76+ }
77+ }
78+}
79+
80+// Same rule as /new: the command is the whole line, spaces aside.
81+func TestIsCommandTrimsAndRequiresExactMatch(t *testing.T) {
82+ cases := map[string]bool{
83+ "/compact": true,
84+ " /compact\n": true,
85+ "/compact now": false,
86+ "/compacts": false,
87+ "please /compact": false,
88+ }
89+ for in, want := range cases {
90+ if got := isCommand(in, compactCommand); got != want {
91+ t.Errorf("isCommand(%q) = %v, want %v", in, got, want)
92+ }
93+ }
94+}
95+
96+// resetSession is what /new does to a session: the history goes back to the
97+// system prompt alone, the count says how much was dropped, and everything
98+// that is NOT the history — cwd, the "allow always" grants — survives, because
99+// the editor still sees the same session.
100+func TestResetSessionKeepsIdentityDropsHistory(t *testing.T) {
101+ s := &session{
102+ cwd: "/work/project",
103+ messages: history.Fresh("you are bob"),
104+ allowAlways: map[string]bool{"bash": true},
105+ }
106+ s.messages = append(s.messages, ai.NewUserTextMessage("ls"), ai.NewModelTextMessage("main.go"))
107+
108+ if got := resetSession(s, "you are bob"); got != 2 {
109+ t.Errorf("forgotten = %d, want 2", got)
110+ }
111+ if len(s.messages) != 1 || s.messages[0].Role != ai.RoleSystem || s.messages[0].Text() != "you are bob" {
112+ t.Errorf("history after reset: %+v, want the system prompt alone", s.messages)
113+ }
114+ if s.cwd != "/work/project" {
115+ t.Errorf("cwd changed to %q", s.cwd)
116+ }
117+ if !s.allowAlways["bash"] {
118+ t.Error("the 'allow always' grant for bash was dropped")
119+ }
120+
121+ // A second /new on a fresh session forgets nothing — and says so.
122+ if got := resetSession(s, "you are bob"); got != 0 {
123+ t.Errorf("forgotten on a fresh session = %d, want 0", got)
124+ }
125+}
126+
127+// The command is recognised from a flattened prompt — the editor sends it as
128+// a text block, exactly as typed.
129+func TestNewCommandIsRecognisedFromPromptBlocks(t *testing.T) {
130+ if !history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new")})) {
131+ t.Error("a text block '/new' is not recognised as the command")
132+ }
133+ if history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new"), sdk.ResourceLinkBlock("main.go", "file:///w/main.go")})) {
134+ t.Error("'/new' with an attached file is a question about that file, not the command")
135+ }
136+}
137+
138+// responseSessionID must recognise exactly the line the commands hook waits
139+// for: a response whose result carries a sessionId. A notification (no id)
140+// or a prompt response (no sessionId) must not fire the hook — the first
141+// would announce the commands before the editor knows the session, the
142+// second would never come from session/new at all.
143+func TestResponseSessionIDMatchesOnlySessionResponses(t *testing.T) {
144+ cases := map[string]struct {
145+ line string
146+ want sdk.SessionId
147+ }{
148+ "session/new response": {`{"jsonrpc":"2.0","id":1,"result":{"sessionId":"bob-1-1"}}`, "bob-1-1"},
149+ "string id": {`{"jsonrpc":"2.0","id":"a","result":{"sessionId":"bob-1-2"}}`, "bob-1-2"},
150+ "prompt response": {`{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}`, ""},
151+ "session/update": {`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"bob-1-1","update":{}}}`, ""},
152+ "request from the agent": {`{"jsonrpc":"2.0","id":3,"method":"session/request_permission","params":{"sessionId":"bob-1-1"}}`, ""},
153+ "garbage": {`not json`, ""},
154+ }
155+ for name, c := range cases {
156+ got, ok := responseSessionID([]byte(c.line))
157+ if ok != (c.want != "") || got != c.want {
158+ t.Errorf("%s: responseSessionID = (%q, %v), want (%q, %v)", name, got, ok, c.want, c.want != "")
159+ }
160+ }
161+}
162+
163+// The order on the wire is the whole point: the editor must read the
164+// session/new response — and so learn the session id — before the
165+// available_commands_update that names it. This drives the real SDK over
166+// pipes, playing the client by hand, and reads the raw lines back.
167+func TestAvailableCommandsAreSentAfterTheSessionNewResponse(t *testing.T) {
168+ clientToAgentR, clientToAgentW := io.Pipe()
169+ agentToClientR, agentToClientW := io.Pipe()
170+ t.Cleanup(func() {
171+ _ = clientToAgentW.Close()
172+ _ = agentToClientR.Close()
173+ })
174+ newFront(nil, "system prompt", nil, agentToClientW, clientToAgentR)
175+
176+ go func() {
177+ _, _ = io.WriteString(clientToAgentW, `{"jsonrpc":"2.0","id":7,"method":"session/new","params":{"cwd":"/work","mcpServers":[]}}`+"\n")
178+ }()
179+
180+ lines := make(chan string)
181+ go func() {
182+ sc := bufio.NewScanner(agentToClientR)
183+ for sc.Scan() {
184+ lines <- sc.Text()
185+ }
186+ close(lines)
187+ }()
188+ next := func() string {
189+ select {
190+ case l, ok := <-lines:
191+ if !ok {
192+ t.Fatal("agent closed its output before sending both lines")
193+ }
194+ return l
195+ case <-time.After(5 * time.Second):
196+ t.Fatal("timed out waiting for the agent's next line")
197+ }
198+ return ""
199+ }
200+
201+ first, second := next(), next()
202+
203+ sid, ok := responseSessionID([]byte(first))
204+ if !ok {
205+ t.Fatalf("first line is not the session/new response:\n%s", first)
206+ }
207+ var upd struct {
208+ Method string `json:"method"`
209+ Params struct {
210+ SessionId sdk.SessionId `json:"sessionId"`
211+ Update struct {
212+ SessionUpdate string `json:"sessionUpdate"`
213+ AvailableCommands []sdk.AvailableCommand `json:"availableCommands"`
214+ } `json:"update"`
215+ } `json:"params"`
216+ }
217+ if err := json.Unmarshal([]byte(second), &upd); err != nil {
218+ t.Fatalf("second line is not JSON: %v\n%s", err, second)
219+ }
220+ if upd.Method != "session/update" || upd.Params.Update.SessionUpdate != "available_commands_update" {
221+ t.Fatalf("second line is not an available_commands_update:\n%s", second)
222+ }
223+ if upd.Params.SessionId != sid {
224+ t.Errorf("commands announced for session %q, response created %q", upd.Params.SessionId, sid)
225+ }
226+ want := availableCommands()
227+ if len(upd.Params.Update.AvailableCommands) != len(want) {
228+ t.Fatalf("announced %d command(s), want %d: %+v", len(upd.Params.Update.AvailableCommands), len(want), upd.Params.Update.AvailableCommands)
229+ }
230+ for i, c := range upd.Params.Update.AvailableCommands {
231+ if c.Name != want[i].Name {
232+ t.Errorf("announced command %d is %q, want %q", i, c.Name, want[i].Name)
233+ }
234+ }
235+}
236+
237+// --- /compact and /abort --------------------------------------------------------
238+
239+// oneTurn is a question answered after one bash command: the smallest unit
240+// compaction cuts on.
241+func oneTurn(q string) []*ai.Message {
242+ return []*ai.Message{
243+ ai.NewUserTextMessage(q),
244+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: q, Name: "bash", Input: map[string]any{"command": "echo " + q}})),
245+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: q, Name: "bash", Output: q})),
246+ ai.NewModelMessage(ai.NewTextPart("done " + q)),
247+ }
248+}
249+
250+func sessionWithTurns(qs ...string) *session {
251+ s := &session{cwd: "/work", messages: history.Fresh("You are Bob."), allowAlways: map[string]bool{"bash": true}}
252+ for _, q := range qs {
253+ s.messages = append(s.messages, oneTurn(q)...)
254+ }
255+ return s
256+}
257+
258+// The three outcomes of /compact, in the REPL's words. Only the first one
259+// touches the history; the two others leave it — and the server's token
260+// count, which the caller resets on `compressed` alone — exactly as they were.
261+func TestCompactHistoryOutcomes(t *testing.T) {
262+ cfg := config.ContextConfig{Enabled: true, KeepLastTurns: 1, Threshold: 75, SummaryMaxTokens: 100}
263+ summary := func(context.Context, []*ai.Message) (string, error) { return "the user ran a and b", nil }
264+ explain := func(err error) string { return "explained: " + err.Error() }
265+
266+ t.Run("compresses the turns older than the kept ones", func(t *testing.T) {
267+ s := sessionWithTurns("a", "b", "c")
268+ before := len(s.messages)
269+ line, compressed := compactHistory(context.Background(), s, cfg, summary, explain)
270+ if !compressed {
271+ t.Fatalf("compressed = false, line %q", line)
272+ }
273+ if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") {
274+ t.Errorf("report %q does not open with the REPL's words and the right counts", line)
275+ }
276+ if len(s.messages) >= before {
277+ t.Errorf("history has %d messages after compaction, %d before", len(s.messages), before)
278+ }
279+ if s.messages[0].Role != ai.RoleSystem || s.allowAlways["bash"] != true || s.cwd != "/work" {
280+ t.Error("compaction touched something other than the turns")
281+ }
282+ })
283+
284+ t.Run("nothing to compact is said, not swallowed", func(t *testing.T) {
285+ s := sessionWithTurns("a")
286+ before := len(s.messages)
287+ line, compressed := compactHistory(context.Background(), s, cfg, summary, explain)
288+ if compressed || len(s.messages) != before {
289+ t.Errorf("compressed = %v, %d messages (was %d): a single kept turn must not move", compressed, len(s.messages), before)
290+ }
291+ if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" {
292+ t.Errorf("line %q", line)
293+ }
294+ })
295+
296+ t.Run("a failed summary keeps the history and names the cause", func(t *testing.T) {
297+ s := sessionWithTurns("a", "b", "c")
298+ before := len(s.messages)
299+ failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") }
300+ line, compressed := compactHistory(context.Background(), s, cfg, failing, explain)
301+ if compressed || len(s.messages) != before {
302+ t.Errorf("compressed = %v, %d messages (was %d): a failure must leave the history alone", compressed, len(s.messages), before)
303+ }
304+ if line != "[compact: failed, history kept: explained: server down]" {
305+ t.Errorf("line %q", line)
306+ }
307+ })
308+}
added internal/acp/sink.go +142 -0
new file mode 100644
@@ -0,0 +1,142 @@
1+package acp
2+
3+import (
4+ "context"
5+
6+ "mm/internal/ui"
7+
8+ sdk "github.com/coder/acp-go-sdk"
9+)
10+
11+// sink translates bob's tool events (ui.Sink) into ACP messages for one turn.
12+// It is the ACP counterpart of what the terminal front end does with fmt: Text
13+// is the 🤖 stream, ToolStart the 🛠️ line, ToolEnd the grey echo — except the
14+// client renders them, so they travel as structure, not as emojis.
15+type sink struct {
16+ f *front
17+ ctx context.Context // the turn's context: cancelled by session/cancel
18+ sid sdk.SessionId
19+ s *session
20+}
21+
22+var _ ui.Sink = (*sink)(nil)
23+
24+// send pushes one session/update. Errors are dropped on purpose: a
25+// notification that cannot be written means the client is gone, and the turn's
26+// context is about to be cancelled anyway — there is nobody left to tell.
27+func (k *sink) send(u sdk.SessionUpdate) {
28+ _ = k.f.conn.SessionUpdate(k.ctx, sdk.SessionNotification{SessionId: k.sid, Update: u})
29+}
30+
31+// Text streams one chunk of the model's answer. The raw chunk, whitespace
32+// included: the editor renders Markdown and owns the layout, so the terminal's
33+// gap/squeeze machinery (which fights the screen, not the text) stays out.
34+func (k *sink) Text(text string) {
35+ k.send(sdk.UpdateAgentMessageText(text))
36+}
37+
38+// WorkDir is the session's cwd — the project the editor opened. bash runs
39+// there, and relative file paths resolve against it (ui.Resolve).
40+func (k *sink) WorkDir() string { return k.s.cwd }
41+
42+// ToolStart announces the call, status pending, before anything runs — the
43+// same promise as the terminal's 🛠️ line: it prints BEFORE execution, so its
44+// absence proves nothing ran.
45+func (k *sink) ToolStart(ev ui.ToolEvent) {
46+ opts := []sdk.ToolCallStartOpt{
47+ sdk.WithStartKind(toolKind(ev.Kind)),
48+ sdk.WithStartStatus(sdk.ToolCallStatusPending),
49+ sdk.WithStartRawInput(ev.Input),
50+ }
51+ if ev.Path != "" {
52+ // The location is what lets the editor "follow the agent" — jump to
53+ // the file the tool is touching. Absolute, per the spec.
54+ opts = append(opts, sdk.WithStartLocations([]sdk.ToolCallLocation{{Path: ev.Path}}))
55+ }
56+ k.send(sdk.StartToolCall(sdk.ToolCallId(ev.ID), ev.Title, opts...))
57+}
58+
59+// Allow asks the user, through the editor, before the tool runs. This dialog
60+// simply does not exist in the terminal front end — it is the main thing an
61+// editor adds — so the policy lives here, not in the tools: "allow always" is
62+// remembered per tool for the rest of the session.
63+func (k *sink) Allow(ctx context.Context, ev ui.ToolEvent) bool {
64+ if k.s.allowAlways[ev.Tool] {
65+ return true
66+ }
67+ resp, err := k.f.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{
68+ SessionId: k.sid,
69+ ToolCall: sdk.ToolCallUpdate{
70+ ToolCallId: sdk.ToolCallId(ev.ID),
71+ Title: sdk.Ptr(ev.Title),
72+ Kind: sdk.Ptr(toolKind(ev.Kind)),
73+ Status: sdk.Ptr(sdk.ToolCallStatusPending),
74+ RawInput: ev.Input,
75+ },
76+ Options: []sdk.PermissionOption{
77+ {OptionId: "allow", Name: "Allow once", Kind: sdk.PermissionOptionKindAllowOnce},
78+ {OptionId: "allow_always", Name: "Allow " + ev.Tool + " for this session", Kind: sdk.PermissionOptionKindAllowAlways},
79+ {OptionId: "reject", Name: "Reject", Kind: sdk.PermissionOptionKindRejectOnce},
80+ },
81+ })
82+ // An error or a cancelled outcome both mean "do not run": the client hung
83+ // up, or the user hit stop while the dialog was open — the spec requires
84+ // the client to answer "cancelled" in that case.
85+ if err != nil || resp.Outcome.Cancelled != nil {
86+ return false
87+ }
88+ if sel := resp.Outcome.Selected; sel != nil {
89+ switch string(sel.OptionId) {
90+ case "allow":
91+ return true
92+ case "allow_always":
93+ k.s.allowAlways[ev.Tool] = true
94+ return true
95+ }
96+ }
97+ return false
98+}
99+
100+// ToolRunning flips the call to in_progress: permission granted, executing.
101+func (k *sink) ToolRunning(id string) {
102+ k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress)))
103+}
104+
105+// ToolEnd closes the call. A file change travels as ACP diff content — that is
106+// what the editor renders as a real diff; anything else ships its output as
107+// text content, the equivalent of the terminal's grey echo. rawOutput carries
108+// the output either way, for clients that show the raw exchange.
109+func (k *sink) ToolEnd(id string, res ui.ToolResult) {
110+ status := sdk.ToolCallStatusCompleted
111+ if res.Failed {
112+ status = sdk.ToolCallStatusFailed
113+ }
114+ var content []sdk.ToolCallContent
115+ switch {
116+ case res.Diff != nil && res.Diff.Created:
117+ content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText))
118+ case res.Diff != nil:
119+ content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText, res.Diff.OldText))
120+ case res.Output != "":
121+ content = append(content, sdk.ToolContent(sdk.TextBlock(res.Output)))
122+ }
123+ k.send(sdk.UpdateToolCall(sdk.ToolCallId(id),
124+ sdk.WithUpdateStatus(status),
125+ sdk.WithUpdateContent(content),
126+ sdk.WithUpdateRawOutput(map[string]any{"output": res.Output}),
127+ ))
128+}
129+
130+// toolKind maps bob's event kinds onto ACP's tool-call taxonomy. The kind only
131+// drives how a client displays the call (icon, colour), so unknown values
132+// default to execute rather than failing.
133+func toolKind(kind string) sdk.ToolKind {
134+ switch kind {
135+ case "read":
136+ return sdk.ToolKindRead
137+ case "edit":
138+ return sdk.ToolKindEdit
139+ default:
140+ return sdk.ToolKindExecute
141+ }
142+}
new file mode 100644
@@ -0,0 +1,142 @@
1+package acp
2+
3+import (
4+ "context"
5+
6+ "mm/internal/ui"
7+
8+ sdk "github.com/coder/acp-go-sdk"
9+)
10+
11+// sink translates bob's tool events (ui.Sink) into ACP messages for one turn.
12+// It is the ACP counterpart of what the terminal front end does with fmt: Text
13+// is the 🤖 stream, ToolStart the 🛠️ line, ToolEnd the grey echo — except the
14+// client renders them, so they travel as structure, not as emojis.
15+type sink struct {
16+ f *front
17+ ctx context.Context // the turn's context: cancelled by session/cancel
18+ sid sdk.SessionId
19+ s *session
20+}
21+
22+var _ ui.Sink = (*sink)(nil)
23+
24+// send pushes one session/update. Errors are dropped on purpose: a
25+// notification that cannot be written means the client is gone, and the turn's
26+// context is about to be cancelled anyway — there is nobody left to tell.
27+func (k *sink) send(u sdk.SessionUpdate) {
28+ _ = k.f.conn.SessionUpdate(k.ctx, sdk.SessionNotification{SessionId: k.sid, Update: u})
29+}
30+
31+// Text streams one chunk of the model's answer. The raw chunk, whitespace
32+// included: the editor renders Markdown and owns the layout, so the terminal's
33+// gap/squeeze machinery (which fights the screen, not the text) stays out.
34+func (k *sink) Text(text string) {
35+ k.send(sdk.UpdateAgentMessageText(text))
36+}
37+
38+// WorkDir is the session's cwd — the project the editor opened. bash runs
39+// there, and relative file paths resolve against it (ui.Resolve).
40+func (k *sink) WorkDir() string { return k.s.cwd }
41+
42+// ToolStart announces the call, status pending, before anything runs — the
43+// same promise as the terminal's 🛠️ line: it prints BEFORE execution, so its
44+// absence proves nothing ran.
45+func (k *sink) ToolStart(ev ui.ToolEvent) {
46+ opts := []sdk.ToolCallStartOpt{
47+ sdk.WithStartKind(toolKind(ev.Kind)),
48+ sdk.WithStartStatus(sdk.ToolCallStatusPending),
49+ sdk.WithStartRawInput(ev.Input),
50+ }
51+ if ev.Path != "" {
52+ // The location is what lets the editor "follow the agent" — jump to
53+ // the file the tool is touching. Absolute, per the spec.
54+ opts = append(opts, sdk.WithStartLocations([]sdk.ToolCallLocation{{Path: ev.Path}}))
55+ }
56+ k.send(sdk.StartToolCall(sdk.ToolCallId(ev.ID), ev.Title, opts...))
57+}
58+
59+// Allow asks the user, through the editor, before the tool runs. This dialog
60+// simply does not exist in the terminal front end — it is the main thing an
61+// editor adds — so the policy lives here, not in the tools: "allow always" is
62+// remembered per tool for the rest of the session.
63+func (k *sink) Allow(ctx context.Context, ev ui.ToolEvent) bool {
64+ if k.s.allowAlways[ev.Tool] {
65+ return true
66+ }
67+ resp, err := k.f.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{
68+ SessionId: k.sid,
69+ ToolCall: sdk.ToolCallUpdate{
70+ ToolCallId: sdk.ToolCallId(ev.ID),
71+ Title: sdk.Ptr(ev.Title),
72+ Kind: sdk.Ptr(toolKind(ev.Kind)),
73+ Status: sdk.Ptr(sdk.ToolCallStatusPending),
74+ RawInput: ev.Input,
75+ },
76+ Options: []sdk.PermissionOption{
77+ {OptionId: "allow", Name: "Allow once", Kind: sdk.PermissionOptionKindAllowOnce},
78+ {OptionId: "allow_always", Name: "Allow " + ev.Tool + " for this session", Kind: sdk.PermissionOptionKindAllowAlways},
79+ {OptionId: "reject", Name: "Reject", Kind: sdk.PermissionOptionKindRejectOnce},
80+ },
81+ })
82+ // An error or a cancelled outcome both mean "do not run": the client hung
83+ // up, or the user hit stop while the dialog was open — the spec requires
84+ // the client to answer "cancelled" in that case.
85+ if err != nil || resp.Outcome.Cancelled != nil {
86+ return false
87+ }
88+ if sel := resp.Outcome.Selected; sel != nil {
89+ switch string(sel.OptionId) {
90+ case "allow":
91+ return true
92+ case "allow_always":
93+ k.s.allowAlways[ev.Tool] = true
94+ return true
95+ }
96+ }
97+ return false
98+}
99+
100+// ToolRunning flips the call to in_progress: permission granted, executing.
101+func (k *sink) ToolRunning(id string) {
102+ k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress)))
103+}
104+
105+// ToolEnd closes the call. A file change travels as ACP diff content — that is
106+// what the editor renders as a real diff; anything else ships its output as
107+// text content, the equivalent of the terminal's grey echo. rawOutput carries
108+// the output either way, for clients that show the raw exchange.
109+func (k *sink) ToolEnd(id string, res ui.ToolResult) {
110+ status := sdk.ToolCallStatusCompleted
111+ if res.Failed {
112+ status = sdk.ToolCallStatusFailed
113+ }
114+ var content []sdk.ToolCallContent
115+ switch {
116+ case res.Diff != nil && res.Diff.Created:
117+ content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText))
118+ case res.Diff != nil:
119+ content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText, res.Diff.OldText))
120+ case res.Output != "":
121+ content = append(content, sdk.ToolContent(sdk.TextBlock(res.Output)))
122+ }
123+ k.send(sdk.UpdateToolCall(sdk.ToolCallId(id),
124+ sdk.WithUpdateStatus(status),
125+ sdk.WithUpdateContent(content),
126+ sdk.WithUpdateRawOutput(map[string]any{"output": res.Output}),
127+ ))
128+}
129+
130+// toolKind maps bob's event kinds onto ACP's tool-call taxonomy. The kind only
131+// drives how a client displays the call (icon, colour), so unknown values
132+// default to execute rather than failing.
133+func toolKind(kind string) sdk.ToolKind {
134+ switch kind {
135+ case "read":
136+ return sdk.ToolKindRead
137+ case "edit":
138+ return sdk.ToolKindEdit
139+ default:
140+ return sdk.ToolKindExecute
141+ }
142+}
added internal/agent/agent.go +425 -0
new file mode 100644
@@ -0,0 +1,425 @@
1+// Package agent contains the interactive conversation loop (REPL).
2+package agent
3+
4+import (
5+ "bufio"
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/signal"
12+ "strings"
13+
14+ "mm/internal/compact"
15+ "mm/internal/config"
16+ "mm/internal/detector"
17+ "mm/internal/engine"
18+ "mm/internal/mention"
19+ "mm/internal/session"
20+ "mm/internal/spinner"
21+
22+ "github.com/firebase/genkit/go/ai"
23+)
24+
25+type genResult struct {
26+ resp *ai.ModelResponse
27+ // history is the FULL conversation returned by engine.Generate — tool turns
28+ // included. Genkit only hands back the last message; without this field the
29+ // agent forgets the commands it just ran and starts telling stories instead
30+ // of re-reading.
31+ history []*ai.Message
32+ err error
33+}
34+
35+// Run starts the loop: read user input → generate (with automatic tool calls) →
36+// print → start over, keeping the history.
37+func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) {
38+ messages := session.Fresh(system)
39+ ctx, cancelAll := context.WithCancel(ctx)
40+ defer cancelAll()
41+
42+ // @path mentions are relative to where mm was started — the same anchor
43+ // as a relative skillsDir and as the bash tool's own working directory.
44+ cwd, _ := os.Getwd()
45+
46+ // sigCh captures Ctrl+C (SIGINT).
47+ sigCh := make(chan os.Signal, 1)
48+ signal.Notify(sigCh, os.Interrupt)
49+
50+ // inputCh receives commands from the reader goroutine.
51+ inputCh := make(chan string)
52+
53+ // startInputReader launches a goroutine that reads from Stdin.
54+ // It is designed to restart if an interrupt (Ctrl+C) occurs.
55+ startInputReader := func() {
56+ go func() {
57+ for {
58+ reader := bufio.NewReader(os.Stdin)
59+ line, err := reader.ReadString('\n')
60+ if err != nil {
61+ select {
62+ case <-ctx.Done():
63+ return
64+ default:
65+ if err == io.EOF {
66+ close(inputCh)
67+ return
68+ }
69+ // If it's an interrupt, just continue to restart the reader.
70+ continue
71+ }
72+ }
73+ select {
74+ case <-ctx.Done():
75+ return
76+ case inputCh <- strings.TrimSpace(line):
77+ }
78+ }
79+ }()
80+ }
81+
82+ startInputReader()
83+
84+ fmt.Println(`Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.`)
85+ fmt.Println(`Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.`)
86+
87+ generating := false
88+
89+ for {
90+ fmt.Print("\n> ")
91+
92+ // 1. Wait for the next user input OR a quit signal.
93+ var input string
94+ var ok bool
95+
96+ select {
97+ case <-sigCh:
98+ if !generating {
99+ fmt.Println("\nGoodbye.")
100+ return
101+ }
102+ // If generating, we let the generation loop handle the SIGINT.
103+ // We fall through to wait for the next input (which will be the prompt).
104+ case input, ok = <-inputCh:
105+ if !ok {
106+ return
107+ }
108+ }
109+
110+ // 2. Basic Command Handling.
111+ if input == "/quit" {
112+ break
113+ }
114+ if input == "" {
115+ continue
116+ }
117+ if input == "/abort" {
118+ spinner.Stop()
119+ fmt.Println("⚠️ No generation in progress.")
120+ continue
121+ }
122+ // Manual compression. Only reachable here, between two generations:
123+ // Genkit holds its own copy of the conversation while it runs, and a
124+ // tool_call/tool_result pair in flight must not move under it.
125+ if input == "/compact" {
126+ messages = compactHistory(ctx, e, messages, true)
127+ continue
128+ }
129+ // New session. Same place and same reason as /compact: the history
130+ // only moves between two generations. Typed DURING a generation, the
131+ // command goes through the "new command received" branch below, which
132+ // cancels the turn and re-injects it here — so the reset always applies
133+ // to the finished history, never to one Genkit still holds.
134+ if session.IsNewCommand(input) {
135+ messages = startNewSession(e, d, system, messages)
136+ continue
137+ }
138+
139+ // 3. Normal message: Add to history and start generation.
140+ //
141+ // The automatic compression runs BEFORE the question is appended and
142+ // BEFORE `before` is computed: that index is what the rollback
143+ // (`messages[:before-1]`) and the ⚙ line (`res.history[before:]`)
144+ // rely on, and a history that shrinks after it is taken would break
145+ // both. The server's own count of the last context is preferred to
146+ // the estimate when it is larger — it sees the tools' JSON and the
147+ // chat template, which the estimate cannot. The window is the one the
148+ // Engine knows: the yaml's contextWindow, or what the probe learned.
149+ //
150+ // That probe ran once, at start-up. A llama-server started AFTER bob
151+ // left `ctx: unknown` for the whole session — observed — and the
152+ // compression could then only trigger on maxMessages. So, while the
153+ // window is unknown and compression is on, ask the server again
154+ // before deciding; once known, never again. A probe that still
155+ // fails prints nothing: the start-up warning already said so.
156+ if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 {
157+ if w, src := e.EnsureContextWindow(ctx); w > 0 {
158+ dimln(fmt.Sprintf("ctx: %d (%s)", w, src))
159+ }
160+ }
161+ if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact {
162+ messages = compactHistory(ctx, e, messages, false)
163+ }
164+ // "@path" in the question names a file for the model to look at. The
165+ // text stays as typed; each existing path is appended as an
166+ // [attached file: …] line — what an editor's @-mention becomes over
167+ // ACP — and echoed here so the user sees the notation was understood.
168+ input, attached := mention.Expand(input, cwd)
169+ for _, a := range attached {
170+ dimln("📎 " + a.Path)
171+ }
172+ messages = append(messages, ai.NewUserTextMessage(input))
173+ before := len(messages)
174+ fmt.Println()
175+
176+ // Create a cancellable context for this specific generation cycle.
177+ genCtx, cancelGen := context.WithCancel(ctx)
178+ generating = true
179+
180+ // Launch generation in a goroutine.
181+ resultCh := make(chan genResult, 1)
182+ go func() {
183+ resp, history, err := e.Generate(genCtx, messages, tools)
184+ resultCh <- genResult{resp: resp, history: history, err: err}
185+ }()
186+
187+ // 4. The "Waiting Room": Listen for completion, interruption, OR new command.
188+ generationRunning := true
189+ for generationRunning {
190+ select {
191+ case res := <-resultCh:
192+ generationRunning = false
193+ generating = false
194+
195+ // We take the FULL history returned by engine.Generate, not just
196+ // resp.Message — and we take it EVEN when the generation failed
197+ // or was interrupted: on an abort (Ctrl+C, /abort), a maxTurns
198+ // overrun or a silence caught by the watchdog, all the work of
199+ // the turn is in `history`, and Genkit itself returns nothing.
200+ // (resp.History() will not do: empty on the streamed path.)
201+ switch {
202+ case len(res.history) > before:
203+ messages = res.history
204+ case res.resp != nil && res.resp.Message != nil:
205+ messages = append(messages, res.resp.Message)
206+ case res.err != nil:
207+ messages = messages[:before-1] // question sans réponse : on l'oublie
208+ }
209+
210+ if res.err != nil {
211+ if genCtx.Err() == context.Canceled {
212+ fmt.Println("\n🛑 Generation aborted.")
213+ } else {
214+ // One line, in the provider's words: see engine.Explain.
215+ fmt.Println("\n[error: " + e.Explain(res.err) + "]")
216+ }
217+ } else {
218+ // --- Global Loop Detection ---
219+ // We check the tool calls in the model response.
220+ if res.resp != nil && res.resp.Message != nil {
221+ for _, part := range res.resp.Message.Content {
222+ if part.IsToolRequest() && part.ToolRequest != nil {
223+ d.Record(detector.Action{
224+ ToolName: part.ToolRequest.Name,
225+ Input: fmt.Sprintf("%v", part.ToolRequest.Input),
226+ Output: "[tool_call]",
227+ })
228+ }
229+ }
230+ }
231+ fmt.Println()
232+ }
233+
234+ // The command count is the only visible proof that the model
235+ // worked rather than improvised. Zero is normal on a
236+ // conversational question; zero on "read this file" is not.
237+ turn := res.history[min(before, len(res.history)):]
238+ cmds := engine.CommandList(turn)
239+ ops := engine.FileOps(turn)
240+ line := fmt.Sprintf("⚙ %d command(s)", len(cmds))
241+ // The file tools have their own column: the A/B of this part
242+ // is "edits through bash" versus "edits through tools", and
243+ // a single number would add up what it is meant to separate.
244+ if len(ops) > 0 {
245+ line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))
246+ }
247+ if k := engine.Skills(turn); k > 0 {
248+ line += fmt.Sprintf(" · 📖 %d skill(s)", k)
249+ }
250+ if spinner.Styled() {
251+ fmt.Printf("\033[2m%s\033[0m\n", line)
252+ } else {
253+ fmt.Println(line)
254+ }
255+ if config.Cfg.DisplayCommands {
256+ printCommands(cmds)
257+ printCommands(ops)
258+ }
259+
260+ case <-sigCh:
261+ // Handle Ctrl+C during generation.
262+ // Stop() first: the spinner is running during the generation,
263+ // and without it this message lands on its line — the next
264+ // frame would erase it. Same rule as in dmr.emit.
265+ spinner.Stop()
266+ fmt.Println("\n🛑 Interrupted (Ctrl+C).")
267+ cancelGen()
268+ // We don't set generationRunning=false; we wait for resultCh to catch the Canceled error.
269+
270+ case nextInput := <-inputCh:
271+ spinner.Stop() // le spinner tourne : on lui reprend la ligne
272+ switch nextInput {
273+ case "/abort":
274+ cancelGen()
275+ fmt.Println("🛑 Aborting...")
276+ case "/quit":
277+ cancelGen()
278+ fmt.Println("Goodbye.")
279+ return
280+ default:
281+ // A new message arrived! Cancel current task and re-inject the message.
282+ fmt.Println("🔄 New command received. Cancelling current task...")
283+ cancelGen()
284+
285+ // Wait for current goroutine to clean up before re-injecting.
286+ res := <-resultCh
287+
288+ // Same principle as above: the commands already run stay in
289+ // the history, otherwise the next question starts again
290+ // without knowing what was done.
291+ switch {
292+ case len(res.history) > before:
293+ messages = res.history
294+ case res.err != nil:
295+ messages = messages[:before-1]
296+ if genCtx.Err() != context.Canceled {
297+ fmt.Println("[error: " + e.Explain(res.err) + "]")
298+ }
299+ }
300+ generationRunning = false
301+ generating = false
302+
303+ // Re-inject the command into the main loop.
304+ go func(cmd string) { inputCh <- cmd }(nextInput)
305+ }
306+ }
307+ }
308+ // Final cleanup for this cycle.
309+ cancelGen()
310+ }
311+}
312+
313+// printCommands recaps the commands of the turn, in green.
314+//
315+// The count says HOW MANY, the list says WHAT: it reads at a glance once the
316+// answer is written, whereas the 🛠️ lines are scattered through the output of
317+// the commands. Green separates it from both the grey of the tools and the
318+// model's own text — and it only comes out on a terminal, like every other
319+// colour here.
320+func printCommands(cmds []string) {
321+ if len(cmds) == 0 {
322+ return
323+ }
324+ green, off := "", ""
325+ if spinner.Styled() {
326+ green, off = "\033[32m", "\033[0m"
327+ }
328+
329+ var b strings.Builder
330+ for i, c := range cmds {
331+ b.WriteString(green + fmt.Sprintf(" %d. %s", i+1, oneLine(c)) + off + "\n")
332+ }
333+ fmt.Print(b.String())
334+}
335+
336+// maxCommandWidth is the width beyond which a command is cut. A hundred
337+// columns: that fits a demo terminal without overflowing it.
338+const maxCommandWidth = 100
339+
340+// oneLine folds a command onto ONE line and truncates it when needed.
341+//
342+// A command can be long and span several lines — a heredoc, a trailing &&. The
343+// recap is there to scan what the agent did, not to re-read its code: spread
344+// over several lines it would grow longer than the output it summarises, and
345+// the numbering would stop being readable.
346+func oneLine(s string) string {
347+ // Fields splits on ALL whitespace, line breaks included: that is exactly
348+ // the folding we want, heredoc indentation and all.
349+ s = strings.Join(strings.Fields(s), " ")
350+ r := []rune(s)
351+ if len(r) <= maxCommandWidth {
352+ return s
353+ }
354+ return string(r[:maxCommandWidth-1]) + "…"
355+}
356+
357+// compactHistory replaces the old turns of `msgs` by a model-written summary
358+// and says so in one line. On ANY failure — server down, watchdog, empty or
359+// malformed result — it returns `msgs` unchanged: the compression exists to
360+// keep the next question possible, so it must never cost one.
361+//
362+// `forced` is the /compact command: it skips the threshold, not the "is there
363+// anything older than the kept turns" check, and it says so when there is not.
364+// The loop detector is deliberately not touched: it records what the agent
365+// DOES, and forgetting a conversation does not make a repeated command new.
366+func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, forced bool) []*ai.Message {
367+ cfg := config.Cfg.Context
368+
369+ // The summary is the longest prefill of the session and prints nothing
370+ // while it runs: without a label the agent looks hung, exactly the case
371+ // the spinner exists for.
372+ spinner.Start("Compressing")
373+ res, err := compact.Compact(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
374+ return e.Summarize(ctx, request, cfg.SummaryMaxTokens)
375+ })
376+ spinner.Stop()
377+
378+ switch {
379+ case errors.Is(err, compact.ErrNothingToCompact):
380+ if forced {
381+ dimln(fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns))
382+ }
383+ return msgs
384+ case err != nil:
385+ // One line, in the provider's words, like every other error here.
386+ fmt.Printf("[compact: failed, history kept: %s]\n", e.Explain(err))
387+ return msgs
388+ }
389+
390+ // The server's last measure described the history we just replaced.
391+ e.ForgetInputTokens()
392+
393+ if cfg.ShowStats {
394+ dimln("🗜️ " + res.Report())
395+ }
396+ return res.Messages
397+}
398+
399+// startNewSession forgets the conversation and returns the history of a fresh
400+// one: the system prompt alone. Three things make up "the session" here, and
401+// all three are reset — the messages, the server's token count of the last
402+// context (it described a history that no longer exists, same rule as after a
403+// compression), and the loop detector (a new session is a new task: a command
404+// repeated in the old one must not be flagged as a loop in this one — the
405+// opposite of compactHistory's choice, where the task continues).
406+//
407+// It says in one line how much was forgotten, so a /new on an empty session
408+// visibly did nothing rather than silently nothing.
409+func startNewSession(e *engine.Engine, d *detector.LoopDetector, system string, old []*ai.Message) []*ai.Message {
410+ forgotten := session.Forgotten(old)
411+ e.ForgetInputTokens()
412+ d.Reset()
413+ dimln(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten))
414+ return session.Fresh(system)
415+}
416+
417+// dimln prints one line, dimmed on a terminal and plain in a pipe — the same
418+// rule as the ⚙ line: `bob | jq` must not receive escape codes.
419+func dimln(line string) {
420+ if spinner.Styled() {
421+ fmt.Printf("\033[2m%s\033[0m\n", line)
422+ } else {
423+ fmt.Println(line)
424+ }
425+}
new file mode 100644
@@ -0,0 +1,425 @@
1+// Package agent contains the interactive conversation loop (REPL).
2+package agent
3+
4+import (
5+ "bufio"
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/signal"
12+ "strings"
13+
14+ "mm/internal/compact"
15+ "mm/internal/config"
16+ "mm/internal/detector"
17+ "mm/internal/engine"
18+ "mm/internal/mention"
19+ "mm/internal/session"
20+ "mm/internal/spinner"
21+
22+ "github.com/firebase/genkit/go/ai"
23+)
24+
25+type genResult struct {
26+ resp *ai.ModelResponse
27+ // history is the FULL conversation returned by engine.Generate — tool turns
28+ // included. Genkit only hands back the last message; without this field the
29+ // agent forgets the commands it just ran and starts telling stories instead
30+ // of re-reading.
31+ history []*ai.Message
32+ err error
33+}
34+
35+// Run starts the loop: read user input → generate (with automatic tool calls) →
36+// print → start over, keeping the history.
37+func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) {
38+ messages := session.Fresh(system)
39+ ctx, cancelAll := context.WithCancel(ctx)
40+ defer cancelAll()
41+
42+ // @path mentions are relative to where mm was started — the same anchor
43+ // as a relative skillsDir and as the bash tool's own working directory.
44+ cwd, _ := os.Getwd()
45+
46+ // sigCh captures Ctrl+C (SIGINT).
47+ sigCh := make(chan os.Signal, 1)
48+ signal.Notify(sigCh, os.Interrupt)
49+
50+ // inputCh receives commands from the reader goroutine.
51+ inputCh := make(chan string)
52+
53+ // startInputReader launches a goroutine that reads from Stdin.
54+ // It is designed to restart if an interrupt (Ctrl+C) occurs.
55+ startInputReader := func() {
56+ go func() {
57+ for {
58+ reader := bufio.NewReader(os.Stdin)
59+ line, err := reader.ReadString('\n')
60+ if err != nil {
61+ select {
62+ case <-ctx.Done():
63+ return
64+ default:
65+ if err == io.EOF {
66+ close(inputCh)
67+ return
68+ }
69+ // If it's an interrupt, just continue to restart the reader.
70+ continue
71+ }
72+ }
73+ select {
74+ case <-ctx.Done():
75+ return
76+ case inputCh <- strings.TrimSpace(line):
77+ }
78+ }
79+ }()
80+ }
81+
82+ startInputReader()
83+
84+ fmt.Println(`Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.`)
85+ fmt.Println(`Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.`)
86+
87+ generating := false
88+
89+ for {
90+ fmt.Print("\n> ")
91+
92+ // 1. Wait for the next user input OR a quit signal.
93+ var input string
94+ var ok bool
95+
96+ select {
97+ case <-sigCh:
98+ if !generating {
99+ fmt.Println("\nGoodbye.")
100+ return
101+ }
102+ // If generating, we let the generation loop handle the SIGINT.
103+ // We fall through to wait for the next input (which will be the prompt).
104+ case input, ok = <-inputCh:
105+ if !ok {
106+ return
107+ }
108+ }
109+
110+ // 2. Basic Command Handling.
111+ if input == "/quit" {
112+ break
113+ }
114+ if input == "" {
115+ continue
116+ }
117+ if input == "/abort" {
118+ spinner.Stop()
119+ fmt.Println("⚠️ No generation in progress.")
120+ continue
121+ }
122+ // Manual compression. Only reachable here, between two generations:
123+ // Genkit holds its own copy of the conversation while it runs, and a
124+ // tool_call/tool_result pair in flight must not move under it.
125+ if input == "/compact" {
126+ messages = compactHistory(ctx, e, messages, true)
127+ continue
128+ }
129+ // New session. Same place and same reason as /compact: the history
130+ // only moves between two generations. Typed DURING a generation, the
131+ // command goes through the "new command received" branch below, which
132+ // cancels the turn and re-injects it here — so the reset always applies
133+ // to the finished history, never to one Genkit still holds.
134+ if session.IsNewCommand(input) {
135+ messages = startNewSession(e, d, system, messages)
136+ continue
137+ }
138+
139+ // 3. Normal message: Add to history and start generation.
140+ //
141+ // The automatic compression runs BEFORE the question is appended and
142+ // BEFORE `before` is computed: that index is what the rollback
143+ // (`messages[:before-1]`) and the ⚙ line (`res.history[before:]`)
144+ // rely on, and a history that shrinks after it is taken would break
145+ // both. The server's own count of the last context is preferred to
146+ // the estimate when it is larger — it sees the tools' JSON and the
147+ // chat template, which the estimate cannot. The window is the one the
148+ // Engine knows: the yaml's contextWindow, or what the probe learned.
149+ //
150+ // That probe ran once, at start-up. A llama-server started AFTER bob
151+ // left `ctx: unknown` for the whole session — observed — and the
152+ // compression could then only trigger on maxMessages. So, while the
153+ // window is unknown and compression is on, ask the server again
154+ // before deciding; once known, never again. A probe that still
155+ // fails prints nothing: the start-up warning already said so.
156+ if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 {
157+ if w, src := e.EnsureContextWindow(ctx); w > 0 {
158+ dimln(fmt.Sprintf("ctx: %d (%s)", w, src))
159+ }
160+ }
161+ if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact {
162+ messages = compactHistory(ctx, e, messages, false)
163+ }
164+ // "@path" in the question names a file for the model to look at. The
165+ // text stays as typed; each existing path is appended as an
166+ // [attached file: …] line — what an editor's @-mention becomes over
167+ // ACP — and echoed here so the user sees the notation was understood.
168+ input, attached := mention.Expand(input, cwd)
169+ for _, a := range attached {
170+ dimln("📎 " + a.Path)
171+ }
172+ messages = append(messages, ai.NewUserTextMessage(input))
173+ before := len(messages)
174+ fmt.Println()
175+
176+ // Create a cancellable context for this specific generation cycle.
177+ genCtx, cancelGen := context.WithCancel(ctx)
178+ generating = true
179+
180+ // Launch generation in a goroutine.
181+ resultCh := make(chan genResult, 1)
182+ go func() {
183+ resp, history, err := e.Generate(genCtx, messages, tools)
184+ resultCh <- genResult{resp: resp, history: history, err: err}
185+ }()
186+
187+ // 4. The "Waiting Room": Listen for completion, interruption, OR new command.
188+ generationRunning := true
189+ for generationRunning {
190+ select {
191+ case res := <-resultCh:
192+ generationRunning = false
193+ generating = false
194+
195+ // We take the FULL history returned by engine.Generate, not just
196+ // resp.Message — and we take it EVEN when the generation failed
197+ // or was interrupted: on an abort (Ctrl+C, /abort), a maxTurns
198+ // overrun or a silence caught by the watchdog, all the work of
199+ // the turn is in `history`, and Genkit itself returns nothing.
200+ // (resp.History() will not do: empty on the streamed path.)
201+ switch {
202+ case len(res.history) > before:
203+ messages = res.history
204+ case res.resp != nil && res.resp.Message != nil:
205+ messages = append(messages, res.resp.Message)
206+ case res.err != nil:
207+ messages = messages[:before-1] // question sans réponse : on l'oublie
208+ }
209+
210+ if res.err != nil {
211+ if genCtx.Err() == context.Canceled {
212+ fmt.Println("\n🛑 Generation aborted.")
213+ } else {
214+ // One line, in the provider's words: see engine.Explain.
215+ fmt.Println("\n[error: " + e.Explain(res.err) + "]")
216+ }
217+ } else {
218+ // --- Global Loop Detection ---
219+ // We check the tool calls in the model response.
220+ if res.resp != nil && res.resp.Message != nil {
221+ for _, part := range res.resp.Message.Content {
222+ if part.IsToolRequest() && part.ToolRequest != nil {
223+ d.Record(detector.Action{
224+ ToolName: part.ToolRequest.Name,
225+ Input: fmt.Sprintf("%v", part.ToolRequest.Input),
226+ Output: "[tool_call]",
227+ })
228+ }
229+ }
230+ }
231+ fmt.Println()
232+ }
233+
234+ // The command count is the only visible proof that the model
235+ // worked rather than improvised. Zero is normal on a
236+ // conversational question; zero on "read this file" is not.
237+ turn := res.history[min(before, len(res.history)):]
238+ cmds := engine.CommandList(turn)
239+ ops := engine.FileOps(turn)
240+ line := fmt.Sprintf("⚙ %d command(s)", len(cmds))
241+ // The file tools have their own column: the A/B of this part
242+ // is "edits through bash" versus "edits through tools", and
243+ // a single number would add up what it is meant to separate.
244+ if len(ops) > 0 {
245+ line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))
246+ }
247+ if k := engine.Skills(turn); k > 0 {
248+ line += fmt.Sprintf(" · 📖 %d skill(s)", k)
249+ }
250+ if spinner.Styled() {
251+ fmt.Printf("\033[2m%s\033[0m\n", line)
252+ } else {
253+ fmt.Println(line)
254+ }
255+ if config.Cfg.DisplayCommands {
256+ printCommands(cmds)
257+ printCommands(ops)
258+ }
259+
260+ case <-sigCh:
261+ // Handle Ctrl+C during generation.
262+ // Stop() first: the spinner is running during the generation,
263+ // and without it this message lands on its line — the next
264+ // frame would erase it. Same rule as in dmr.emit.
265+ spinner.Stop()
266+ fmt.Println("\n🛑 Interrupted (Ctrl+C).")
267+ cancelGen()
268+ // We don't set generationRunning=false; we wait for resultCh to catch the Canceled error.
269+
270+ case nextInput := <-inputCh:
271+ spinner.Stop() // le spinner tourne : on lui reprend la ligne
272+ switch nextInput {
273+ case "/abort":
274+ cancelGen()
275+ fmt.Println("🛑 Aborting...")
276+ case "/quit":
277+ cancelGen()
278+ fmt.Println("Goodbye.")
279+ return
280+ default:
281+ // A new message arrived! Cancel current task and re-inject the message.
282+ fmt.Println("🔄 New command received. Cancelling current task...")
283+ cancelGen()
284+
285+ // Wait for current goroutine to clean up before re-injecting.
286+ res := <-resultCh
287+
288+ // Same principle as above: the commands already run stay in
289+ // the history, otherwise the next question starts again
290+ // without knowing what was done.
291+ switch {
292+ case len(res.history) > before:
293+ messages = res.history
294+ case res.err != nil:
295+ messages = messages[:before-1]
296+ if genCtx.Err() != context.Canceled {
297+ fmt.Println("[error: " + e.Explain(res.err) + "]")
298+ }
299+ }
300+ generationRunning = false
301+ generating = false
302+
303+ // Re-inject the command into the main loop.
304+ go func(cmd string) { inputCh <- cmd }(nextInput)
305+ }
306+ }
307+ }
308+ // Final cleanup for this cycle.
309+ cancelGen()
310+ }
311+}
312+
313+// printCommands recaps the commands of the turn, in green.
314+//
315+// The count says HOW MANY, the list says WHAT: it reads at a glance once the
316+// answer is written, whereas the 🛠️ lines are scattered through the output of
317+// the commands. Green separates it from both the grey of the tools and the
318+// model's own text — and it only comes out on a terminal, like every other
319+// colour here.
320+func printCommands(cmds []string) {
321+ if len(cmds) == 0 {
322+ return
323+ }
324+ green, off := "", ""
325+ if spinner.Styled() {
326+ green, off = "\033[32m", "\033[0m"
327+ }
328+
329+ var b strings.Builder
330+ for i, c := range cmds {
331+ b.WriteString(green + fmt.Sprintf(" %d. %s", i+1, oneLine(c)) + off + "\n")
332+ }
333+ fmt.Print(b.String())
334+}
335+
336+// maxCommandWidth is the width beyond which a command is cut. A hundred
337+// columns: that fits a demo terminal without overflowing it.
338+const maxCommandWidth = 100
339+
340+// oneLine folds a command onto ONE line and truncates it when needed.
341+//
342+// A command can be long and span several lines — a heredoc, a trailing &&. The
343+// recap is there to scan what the agent did, not to re-read its code: spread
344+// over several lines it would grow longer than the output it summarises, and
345+// the numbering would stop being readable.
346+func oneLine(s string) string {
347+ // Fields splits on ALL whitespace, line breaks included: that is exactly
348+ // the folding we want, heredoc indentation and all.
349+ s = strings.Join(strings.Fields(s), " ")
350+ r := []rune(s)
351+ if len(r) <= maxCommandWidth {
352+ return s
353+ }
354+ return string(r[:maxCommandWidth-1]) + "…"
355+}
356+
357+// compactHistory replaces the old turns of `msgs` by a model-written summary
358+// and says so in one line. On ANY failure — server down, watchdog, empty or
359+// malformed result — it returns `msgs` unchanged: the compression exists to
360+// keep the next question possible, so it must never cost one.
361+//
362+// `forced` is the /compact command: it skips the threshold, not the "is there
363+// anything older than the kept turns" check, and it says so when there is not.
364+// The loop detector is deliberately not touched: it records what the agent
365+// DOES, and forgetting a conversation does not make a repeated command new.
366+func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, forced bool) []*ai.Message {
367+ cfg := config.Cfg.Context
368+
369+ // The summary is the longest prefill of the session and prints nothing
370+ // while it runs: without a label the agent looks hung, exactly the case
371+ // the spinner exists for.
372+ spinner.Start("Compressing")
373+ res, err := compact.Compact(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
374+ return e.Summarize(ctx, request, cfg.SummaryMaxTokens)
375+ })
376+ spinner.Stop()
377+
378+ switch {
379+ case errors.Is(err, compact.ErrNothingToCompact):
380+ if forced {
381+ dimln(fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns))
382+ }
383+ return msgs
384+ case err != nil:
385+ // One line, in the provider's words, like every other error here.
386+ fmt.Printf("[compact: failed, history kept: %s]\n", e.Explain(err))
387+ return msgs
388+ }
389+
390+ // The server's last measure described the history we just replaced.
391+ e.ForgetInputTokens()
392+
393+ if cfg.ShowStats {
394+ dimln("🗜️ " + res.Report())
395+ }
396+ return res.Messages
397+}
398+
399+// startNewSession forgets the conversation and returns the history of a fresh
400+// one: the system prompt alone. Three things make up "the session" here, and
401+// all three are reset — the messages, the server's token count of the last
402+// context (it described a history that no longer exists, same rule as after a
403+// compression), and the loop detector (a new session is a new task: a command
404+// repeated in the old one must not be flagged as a loop in this one — the
405+// opposite of compactHistory's choice, where the task continues).
406+//
407+// It says in one line how much was forgotten, so a /new on an empty session
408+// visibly did nothing rather than silently nothing.
409+func startNewSession(e *engine.Engine, d *detector.LoopDetector, system string, old []*ai.Message) []*ai.Message {
410+ forgotten := session.Forgotten(old)
411+ e.ForgetInputTokens()
412+ d.Reset()
413+ dimln(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten))
414+ return session.Fresh(system)
415+}
416+
417+// dimln prints one line, dimmed on a terminal and plain in a pipe — the same
418+// rule as the ⚙ line: `bob | jq` must not receive escape codes.
419+func dimln(line string) {
420+ if spinner.Styled() {
421+ fmt.Printf("\033[2m%s\033[0m\n", line)
422+ } else {
423+ fmt.Println(line)
424+ }
425+}
added internal/compact/compact.go +307 -0
new file mode 100644
@@ -0,0 +1,307 @@
1+// Package compact keeps the conversation history inside the model's context
2+// window. When the history grows past a threshold, the OLD question turns are
3+// replaced by ONE summary written by the model itself, and the last N turns
4+// stay raw — the hybrid of CONTEXT_WINDOW.md § 2.e.
5+//
6+// Why it exists: nothing in the agent ever shortens `messages`. Measured with
7+// the fake engine of part 03, the history sent to the model grew 2 → 5 → 7 → 9
8+// messages over four requests, and every `bash` output (up to maxOutput
9+// characters) stays until /quit. On a local model the window is fixed at load
10+// time, so a long session first shows up as a false watchdog stall, then as a
11+// 400 from the engine.
12+//
13+// The package never calls the model directly: it builds the summary request
14+// and hands it to a Summarizer (engine.Summarize in production, a function
15+// returning a fixed text in tests). It knows nothing about the screen either —
16+// the report line is the agent's job.
17+package compact
18+
19+import (
20+ "context"
21+ "encoding/json"
22+ "errors"
23+ "fmt"
24+ "slices"
25+ "strings"
26+ "time"
27+
28+ "mm/internal/config"
29+
30+ "github.com/firebase/genkit/go/ai"
31+)
32+
33+// Summarizer answers a request built by this package — a system message, the
34+// old turns, then the prompt as a user message — with plain text.
35+type Summarizer func(ctx context.Context, request []*ai.Message) (string, error)
36+
37+// Result describes one compression, for the one-line report.
38+type Result struct {
39+ Messages []*ai.Message
40+ Compressed int // messages replaced by the summary pair
41+ Kept int // raw messages kept after the summary (system excluded)
42+ Before int // estimated tokens before
43+ After int // estimated tokens after
44+ Elapsed time.Duration
45+}
46+
47+// Report is the one-line account of a compression, shared by the two front
48+// ends so the terminal and the editor say the same thing:
49+//
50+// compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s)
51+func (r Result) Report() string {
52+ return fmt.Sprintf("compressed %d messages → 1 summary + %d kept (%s → %s tokens, %s)",
53+ r.Compressed, r.Kept, kilo(r.Before), kilo(r.After), seconds(r.Elapsed))
54+}
55+
56+// kilo prints a token count the way the banner does: 18.2k, not 18234.
57+func kilo(n int) string {
58+ if n < 1000 {
59+ return fmt.Sprintf("%d", n)
60+ }
61+ return fmt.Sprintf("%.1fk", float64(n)/1000)
62+}
63+
64+// seconds prints a duration with one decimal under ten seconds, none above.
65+func seconds(d time.Duration) string {
66+ if d < 10*time.Second {
67+ return fmt.Sprintf("%.1fs", d.Seconds())
68+ }
69+ return fmt.Sprintf("%ds", int(d.Seconds()))
70+}
71+
72+// ErrNothingToCompact is returned when the history holds no turn older than
73+// the ones to keep. It is not a failure: the threshold can be reached inside a
74+// single long turn, and there is then nothing to summarise yet.
75+var ErrNothingToCompact = errors.New("nothing to compact")
76+
77+// metaKey marks the two messages a compression inserts, so that a later
78+// compression recognises an earlier summary (and merges it instead of
79+// summarising it a second time — detail decays exponentially otherwise).
80+const metaKey = "compact"
81+
82+// Decision says whether to compress before the next question, and on what
83+// grounds. Tokens is the figure that was compared to the threshold; it is
84+// reported even when nothing triggers, for the /context-style displays.
85+type Decision struct {
86+ Compact bool
87+ Tokens int
88+ Reason string // "tokens", "messages", or "" when nothing triggered
89+}
90+
91+// Decide applies the automatic trigger.
92+//
93+// `measured` is the input-token count the engine reported for the last call
94+// to the model (0 when it reported nothing, as the fake engine does). The
95+// larger of it and the local estimate is used: the measure sees what the
96+// estimate cannot (the tools' JSON, the chat template), but it lags one call
97+// behind and dies with the compression that made it stale — the caller
98+// forgets it after a successful compression.
99+//
100+// `window` is the served context size as far as the agent knows it: the
101+// yaml's `contextWindow`, or what the provider's probe learned from the server
102+// (llama-server's /props) — see engine.Engine.ContextWindow. 0 = unknown, and
103+// then only MaxMessages can trigger.
104+func Decide(msgs []*ai.Message, measured, window int, cfg config.ContextConfig) Decision {
105+ d := Decision{Tokens: max(Estimate(msgs), measured)}
106+ if !cfg.Enabled {
107+ return d
108+ }
109+ switch {
110+ // Cross-multiplied to stay in integers: tokens ≥ window × threshold / 100.
111+ case window > 0 && d.Tokens*100 >= window*cfg.Threshold:
112+ d.Compact, d.Reason = true, "tokens"
113+ case cfg.MaxMessages > 0 && len(msgs) >= cfg.MaxMessages:
114+ d.Compact, d.Reason = true, "messages"
115+ }
116+ return d
117+}
118+
119+// Compact replaces every turn but the last cfg.KeepLastTurns by a summary pair
120+// (a user message carrying the notes, a short model acknowledgement), inserted
121+// right after the system prompt. The kept turns are reused as they are, so the
122+// tool_call/tool_result pairs inside them cannot be split; the cut itself falls
123+// on a turn boundary, never on a message count.
124+//
125+// On any error the caller's history is left untouched: the returned Result is
126+// empty and `msgs` was never modified.
127+func Compact(ctx context.Context, msgs []*ai.Message, cfg config.ContextConfig, summarize Summarizer) (Result, error) {
128+ start := time.Now()
129+
130+ head, turns := Split(msgs)
131+ keep := max(cfg.KeepLastTurns, 1)
132+ if len(turns) <= keep {
133+ return Result{}, ErrNothingToCompact
134+ }
135+ old, recent := turns[:len(turns)-keep], turns[len(turns)-keep:]
136+
137+ // An earlier summary that is the only old turn: re-summarising a summary
138+ // loses detail and gains nothing.
139+ merge := IsSummary(old[0][0])
140+ if merge && len(old) == 1 {
141+ return Result{}, ErrNothingToCompact
142+ }
143+
144+ oldMsgs := flatten(old)
145+ text, err := summarize(ctx, Request(oldMsgs, cfg.Prompt, merge))
146+ if err != nil {
147+ return Result{}, err
148+ }
149+ if strings.TrimSpace(text) == "" {
150+ return Result{}, errors.New("empty summary")
151+ }
152+
153+ questions := len(old)
154+ if merge {
155+ questions-- // the summary pair is not a question the user asked
156+ }
157+ pair := SummaryPair(text, len(oldMsgs), questions, commands(oldMsgs))
158+
159+ out := make([]*ai.Message, 0, len(head)+len(pair)+len(msgs))
160+ out = append(out, head...)
161+ out = append(out, pair...)
162+ kept := flatten(recent)
163+ out = append(out, kept...)
164+
165+ // The safety net: the cut above cannot split a pair, but the check is
166+ // cheap and the failure it guards against is a 400 on the next question.
167+ if err := Valid(out); err != nil {
168+ return Result{}, fmt.Errorf("compressed history is invalid: %w", err)
169+ }
170+
171+ return Result{
172+ Messages: out,
173+ Compressed: len(oldMsgs),
174+ Kept: len(kept),
175+ Before: Estimate(msgs),
176+ After: Estimate(out),
177+ Elapsed: time.Since(start),
178+ }, nil
179+}
180+
181+// Split separates the leading system message(s) from the question turns. A
182+// turn starts at a user message and runs until the next one, so it holds the
183+// whole model ↔ tools exchange the question caused; an earlier summary pair is
184+// a turn of its own, since it starts with a user message too.
185+//
186+// Every strategy of CONTEXT_WINDOW.md cuts on these boundaries: cutting on a
187+// message count can leave a `tool` message without the `assistant` that asked
188+// for it, which the OpenAI API refuses outright.
189+func Split(msgs []*ai.Message) (head []*ai.Message, turns [][]*ai.Message) {
190+ i := 0
191+ for i < len(msgs) && msgs[i].Role == ai.RoleSystem {
192+ i++
193+ }
194+ head = msgs[:i]
195+ for _, m := range msgs[i:] {
196+ if m.Role == ai.RoleUser || len(turns) == 0 {
197+ turns = append(turns, nil)
198+ }
199+ turns[len(turns)-1] = append(turns[len(turns)-1], m)
200+ }
201+ return head, turns
202+}
203+
204+// Valid checks what an OpenAI-compatible server checks before answering: the
205+// history starts with the system prompt, every tool request sits in a model
206+// message, and every tool response answers a request seen earlier — paired by
207+// Ref when the server set one, by tool name otherwise (same rule as
208+// engine.executed).
209+func Valid(msgs []*ai.Message) error {
210+ if len(msgs) == 0 || msgs[0].Role != ai.RoleSystem {
211+ return errors.New("history does not start with the system prompt")
212+ }
213+ type call struct{ ref, name string }
214+ var pending []call
215+ for i, m := range msgs {
216+ for _, p := range m.Content {
217+ switch {
218+ case p.IsToolRequest() && p.ToolRequest != nil:
219+ if m.Role != ai.RoleModel {
220+ return fmt.Errorf("message %d: tool request in a %s message", i, m.Role)
221+ }
222+ pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name})
223+ case p.IsToolResponse() && p.ToolResponse != nil:
224+ r := p.ToolResponse
225+ j := slices.IndexFunc(pending, func(c call) bool {
226+ if r.Ref != "" {
227+ return c.ref == r.Ref
228+ }
229+ return c.name == r.Name
230+ })
231+ if j < 0 {
232+ return fmt.Errorf("message %d: tool response %q answers no pending request", i, r.Name)
233+ }
234+ pending = slices.Delete(pending, j, j+1)
235+ }
236+ }
237+ }
238+ return nil
239+}
240+
241+// Estimate approximates the token count of a history without asking the
242+// engine: characters divided by 3.5, plus a few tokens of framing per message.
243+//
244+// 4 characters per token holds for English prose; code, JSON, paths and French
245+// tokenise closer to 3. Taking 3.5 over-estimates prose a little, which is the
246+// safe side for a threshold. The engine's own count, when it reports one, is
247+// preferred by Decide — this is the fallback, and the number the report shows
248+// for a history that was never sent.
249+func Estimate(msgs []*ai.Message) int {
250+ chars := 0
251+ for _, m := range msgs {
252+ for _, p := range m.Content {
253+ switch {
254+ case p.IsText():
255+ chars += len(p.Text)
256+ case p.IsToolRequest() && p.ToolRequest != nil:
257+ chars += len(p.ToolRequest.Name) + len(jsonOf(p.ToolRequest.Input))
258+ case p.IsToolResponse() && p.ToolResponse != nil:
259+ chars += len(p.ToolResponse.Name) + len(jsonOf(p.ToolResponse.Output))
260+ }
261+ }
262+ }
263+ return (chars*2+6)/7 + 4*len(msgs)
264+}
265+
266+// jsonOf renders a tool payload the way the request will carry it. A string
267+// output is taken as-is: marshalling it would count the escaping twice.
268+func jsonOf(v any) string {
269+ if s, ok := v.(string); ok {
270+ return s
271+ }
272+ b, err := json.Marshal(v)
273+ if err != nil {
274+ return fmt.Sprint(v)
275+ }
276+ return string(b)
277+}
278+
279+// IsSummary reports whether a message is the notes half of a summary pair.
280+func IsSummary(m *ai.Message) bool {
281+ return m != nil && m.Role == ai.RoleUser && m.Metadata[metaKey] == "summary"
282+}
283+
284+// flatten joins turns back into one message list.
285+func flatten(turns [][]*ai.Message) []*ai.Message {
286+ var out []*ai.Message
287+ for _, t := range turns {
288+ out = append(out, t...)
289+ }
290+ return out
291+}
292+
293+// commands counts the bash commands that actually ran in a history — one per
294+// response of the `bash` tool, the same measure as engine.Commands. It is not
295+// imported from engine so that this package stays free of Genkit's wiring and
296+// testable with a plain function.
297+func commands(msgs []*ai.Message) int {
298+ n := 0
299+ for _, m := range msgs {
300+ for _, p := range m.Content {
301+ if p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == "bash" {
302+ n++
303+ }
304+ }
305+ }
306+ return n
307+}
new file mode 100644
@@ -0,0 +1,307 @@
1+// Package compact keeps the conversation history inside the model's context
2+// window. When the history grows past a threshold, the OLD question turns are
3+// replaced by ONE summary written by the model itself, and the last N turns
4+// stay raw — the hybrid of CONTEXT_WINDOW.md § 2.e.
5+//
6+// Why it exists: nothing in the agent ever shortens `messages`. Measured with
7+// the fake engine of part 03, the history sent to the model grew 2 → 5 → 7 → 9
8+// messages over four requests, and every `bash` output (up to maxOutput
9+// characters) stays until /quit. On a local model the window is fixed at load
10+// time, so a long session first shows up as a false watchdog stall, then as a
11+// 400 from the engine.
12+//
13+// The package never calls the model directly: it builds the summary request
14+// and hands it to a Summarizer (engine.Summarize in production, a function
15+// returning a fixed text in tests). It knows nothing about the screen either —
16+// the report line is the agent's job.
17+package compact
18+
19+import (
20+ "context"
21+ "encoding/json"
22+ "errors"
23+ "fmt"
24+ "slices"
25+ "strings"
26+ "time"
27+
28+ "mm/internal/config"
29+
30+ "github.com/firebase/genkit/go/ai"
31+)
32+
33+// Summarizer answers a request built by this package — a system message, the
34+// old turns, then the prompt as a user message — with plain text.
35+type Summarizer func(ctx context.Context, request []*ai.Message) (string, error)
36+
37+// Result describes one compression, for the one-line report.
38+type Result struct {
39+ Messages []*ai.Message
40+ Compressed int // messages replaced by the summary pair
41+ Kept int // raw messages kept after the summary (system excluded)
42+ Before int // estimated tokens before
43+ After int // estimated tokens after
44+ Elapsed time.Duration
45+}
46+
47+// Report is the one-line account of a compression, shared by the two front
48+// ends so the terminal and the editor say the same thing:
49+//
50+// compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s)
51+func (r Result) Report() string {
52+ return fmt.Sprintf("compressed %d messages → 1 summary + %d kept (%s → %s tokens, %s)",
53+ r.Compressed, r.Kept, kilo(r.Before), kilo(r.After), seconds(r.Elapsed))
54+}
55+
56+// kilo prints a token count the way the banner does: 18.2k, not 18234.
57+func kilo(n int) string {
58+ if n < 1000 {
59+ return fmt.Sprintf("%d", n)
60+ }
61+ return fmt.Sprintf("%.1fk", float64(n)/1000)
62+}
63+
64+// seconds prints a duration with one decimal under ten seconds, none above.
65+func seconds(d time.Duration) string {
66+ if d < 10*time.Second {
67+ return fmt.Sprintf("%.1fs", d.Seconds())
68+ }
69+ return fmt.Sprintf("%ds", int(d.Seconds()))
70+}
71+
72+// ErrNothingToCompact is returned when the history holds no turn older than
73+// the ones to keep. It is not a failure: the threshold can be reached inside a
74+// single long turn, and there is then nothing to summarise yet.
75+var ErrNothingToCompact = errors.New("nothing to compact")
76+
77+// metaKey marks the two messages a compression inserts, so that a later
78+// compression recognises an earlier summary (and merges it instead of
79+// summarising it a second time — detail decays exponentially otherwise).
80+const metaKey = "compact"
81+
82+// Decision says whether to compress before the next question, and on what
83+// grounds. Tokens is the figure that was compared to the threshold; it is
84+// reported even when nothing triggers, for the /context-style displays.
85+type Decision struct {
86+ Compact bool
87+ Tokens int
88+ Reason string // "tokens", "messages", or "" when nothing triggered
89+}
90+
91+// Decide applies the automatic trigger.
92+//
93+// `measured` is the input-token count the engine reported for the last call
94+// to the model (0 when it reported nothing, as the fake engine does). The
95+// larger of it and the local estimate is used: the measure sees what the
96+// estimate cannot (the tools' JSON, the chat template), but it lags one call
97+// behind and dies with the compression that made it stale — the caller
98+// forgets it after a successful compression.
99+//
100+// `window` is the served context size as far as the agent knows it: the
101+// yaml's `contextWindow`, or what the provider's probe learned from the server
102+// (llama-server's /props) — see engine.Engine.ContextWindow. 0 = unknown, and
103+// then only MaxMessages can trigger.
104+func Decide(msgs []*ai.Message, measured, window int, cfg config.ContextConfig) Decision {
105+ d := Decision{Tokens: max(Estimate(msgs), measured)}
106+ if !cfg.Enabled {
107+ return d
108+ }
109+ switch {
110+ // Cross-multiplied to stay in integers: tokens ≥ window × threshold / 100.
111+ case window > 0 && d.Tokens*100 >= window*cfg.Threshold:
112+ d.Compact, d.Reason = true, "tokens"
113+ case cfg.MaxMessages > 0 && len(msgs) >= cfg.MaxMessages:
114+ d.Compact, d.Reason = true, "messages"
115+ }
116+ return d
117+}
118+
119+// Compact replaces every turn but the last cfg.KeepLastTurns by a summary pair
120+// (a user message carrying the notes, a short model acknowledgement), inserted
121+// right after the system prompt. The kept turns are reused as they are, so the
122+// tool_call/tool_result pairs inside them cannot be split; the cut itself falls
123+// on a turn boundary, never on a message count.
124+//
125+// On any error the caller's history is left untouched: the returned Result is
126+// empty and `msgs` was never modified.
127+func Compact(ctx context.Context, msgs []*ai.Message, cfg config.ContextConfig, summarize Summarizer) (Result, error) {
128+ start := time.Now()
129+
130+ head, turns := Split(msgs)
131+ keep := max(cfg.KeepLastTurns, 1)
132+ if len(turns) <= keep {
133+ return Result{}, ErrNothingToCompact
134+ }
135+ old, recent := turns[:len(turns)-keep], turns[len(turns)-keep:]
136+
137+ // An earlier summary that is the only old turn: re-summarising a summary
138+ // loses detail and gains nothing.
139+ merge := IsSummary(old[0][0])
140+ if merge && len(old) == 1 {
141+ return Result{}, ErrNothingToCompact
142+ }
143+
144+ oldMsgs := flatten(old)
145+ text, err := summarize(ctx, Request(oldMsgs, cfg.Prompt, merge))
146+ if err != nil {
147+ return Result{}, err
148+ }
149+ if strings.TrimSpace(text) == "" {
150+ return Result{}, errors.New("empty summary")
151+ }
152+
153+ questions := len(old)
154+ if merge {
155+ questions-- // the summary pair is not a question the user asked
156+ }
157+ pair := SummaryPair(text, len(oldMsgs), questions, commands(oldMsgs))
158+
159+ out := make([]*ai.Message, 0, len(head)+len(pair)+len(msgs))
160+ out = append(out, head...)
161+ out = append(out, pair...)
162+ kept := flatten(recent)
163+ out = append(out, kept...)
164+
165+ // The safety net: the cut above cannot split a pair, but the check is
166+ // cheap and the failure it guards against is a 400 on the next question.
167+ if err := Valid(out); err != nil {
168+ return Result{}, fmt.Errorf("compressed history is invalid: %w", err)
169+ }
170+
171+ return Result{
172+ Messages: out,
173+ Compressed: len(oldMsgs),
174+ Kept: len(kept),
175+ Before: Estimate(msgs),
176+ After: Estimate(out),
177+ Elapsed: time.Since(start),
178+ }, nil
179+}
180+
181+// Split separates the leading system message(s) from the question turns. A
182+// turn starts at a user message and runs until the next one, so it holds the
183+// whole model ↔ tools exchange the question caused; an earlier summary pair is
184+// a turn of its own, since it starts with a user message too.
185+//
186+// Every strategy of CONTEXT_WINDOW.md cuts on these boundaries: cutting on a
187+// message count can leave a `tool` message without the `assistant` that asked
188+// for it, which the OpenAI API refuses outright.
189+func Split(msgs []*ai.Message) (head []*ai.Message, turns [][]*ai.Message) {
190+ i := 0
191+ for i < len(msgs) && msgs[i].Role == ai.RoleSystem {
192+ i++
193+ }
194+ head = msgs[:i]
195+ for _, m := range msgs[i:] {
196+ if m.Role == ai.RoleUser || len(turns) == 0 {
197+ turns = append(turns, nil)
198+ }
199+ turns[len(turns)-1] = append(turns[len(turns)-1], m)
200+ }
201+ return head, turns
202+}
203+
204+// Valid checks what an OpenAI-compatible server checks before answering: the
205+// history starts with the system prompt, every tool request sits in a model
206+// message, and every tool response answers a request seen earlier — paired by
207+// Ref when the server set one, by tool name otherwise (same rule as
208+// engine.executed).
209+func Valid(msgs []*ai.Message) error {
210+ if len(msgs) == 0 || msgs[0].Role != ai.RoleSystem {
211+ return errors.New("history does not start with the system prompt")
212+ }
213+ type call struct{ ref, name string }
214+ var pending []call
215+ for i, m := range msgs {
216+ for _, p := range m.Content {
217+ switch {
218+ case p.IsToolRequest() && p.ToolRequest != nil:
219+ if m.Role != ai.RoleModel {
220+ return fmt.Errorf("message %d: tool request in a %s message", i, m.Role)
221+ }
222+ pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name})
223+ case p.IsToolResponse() && p.ToolResponse != nil:
224+ r := p.ToolResponse
225+ j := slices.IndexFunc(pending, func(c call) bool {
226+ if r.Ref != "" {
227+ return c.ref == r.Ref
228+ }
229+ return c.name == r.Name
230+ })
231+ if j < 0 {
232+ return fmt.Errorf("message %d: tool response %q answers no pending request", i, r.Name)
233+ }
234+ pending = slices.Delete(pending, j, j+1)
235+ }
236+ }
237+ }
238+ return nil
239+}
240+
241+// Estimate approximates the token count of a history without asking the
242+// engine: characters divided by 3.5, plus a few tokens of framing per message.
243+//
244+// 4 characters per token holds for English prose; code, JSON, paths and French
245+// tokenise closer to 3. Taking 3.5 over-estimates prose a little, which is the
246+// safe side for a threshold. The engine's own count, when it reports one, is
247+// preferred by Decide — this is the fallback, and the number the report shows
248+// for a history that was never sent.
249+func Estimate(msgs []*ai.Message) int {
250+ chars := 0
251+ for _, m := range msgs {
252+ for _, p := range m.Content {
253+ switch {
254+ case p.IsText():
255+ chars += len(p.Text)
256+ case p.IsToolRequest() && p.ToolRequest != nil:
257+ chars += len(p.ToolRequest.Name) + len(jsonOf(p.ToolRequest.Input))
258+ case p.IsToolResponse() && p.ToolResponse != nil:
259+ chars += len(p.ToolResponse.Name) + len(jsonOf(p.ToolResponse.Output))
260+ }
261+ }
262+ }
263+ return (chars*2+6)/7 + 4*len(msgs)
264+}
265+
266+// jsonOf renders a tool payload the way the request will carry it. A string
267+// output is taken as-is: marshalling it would count the escaping twice.
268+func jsonOf(v any) string {
269+ if s, ok := v.(string); ok {
270+ return s
271+ }
272+ b, err := json.Marshal(v)
273+ if err != nil {
274+ return fmt.Sprint(v)
275+ }
276+ return string(b)
277+}
278+
279+// IsSummary reports whether a message is the notes half of a summary pair.
280+func IsSummary(m *ai.Message) bool {
281+ return m != nil && m.Role == ai.RoleUser && m.Metadata[metaKey] == "summary"
282+}
283+
284+// flatten joins turns back into one message list.
285+func flatten(turns [][]*ai.Message) []*ai.Message {
286+ var out []*ai.Message
287+ for _, t := range turns {
288+ out = append(out, t...)
289+ }
290+ return out
291+}
292+
293+// commands counts the bash commands that actually ran in a history — one per
294+// response of the `bash` tool, the same measure as engine.Commands. It is not
295+// imported from engine so that this package stays free of Genkit's wiring and
296+// testable with a plain function.
297+func commands(msgs []*ai.Message) int {
298+ n := 0
299+ for _, m := range msgs {
300+ for _, p := range m.Content {
301+ if p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == "bash" {
302+ n++
303+ }
304+ }
305+ }
306+ return n
307+}
added internal/compact/compact_test.go +366 -0
new file mode 100644
@@ -0,0 +1,366 @@
1+// The tests build histories by hand, the way TestCommandList does in
2+// internal/engine, and check the one property a compression must never break:
3+// the next request is still a valid conversation — system prompt first, every
4+// tool response under the request that asked for it, the recent turns intact.
5+//
6+// No network and no model: the Summarizer is a function here. The last test
7+// goes through engine.Summarize with a fake Genkit model, so the wiring the
8+// agent uses is exercised too.
9+package compact
10+
11+import (
12+ "context"
13+ "errors"
14+ "strings"
15+ "testing"
16+
17+ "mm/internal/config"
18+ "mm/internal/engine"
19+
20+ "github.com/firebase/genkit/go/ai"
21+ "github.com/firebase/genkit/go/genkit"
22+)
23+
24+// --- fixtures ---------------------------------------------------------------
25+
26+func req(ref, cmd string) *ai.Part {
27+ return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: "bash", Input: map[string]any{"command": cmd}})
28+}
29+
30+func resp(ref, out string) *ai.Part {
31+ return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: "bash", Output: out})
32+}
33+
34+// turn builds one question turn with n commands: user, then n × (model call,
35+// tool response), then the model's final text.
36+func turn(question string, n int, output string) []*ai.Message {
37+ msgs := []*ai.Message{ai.NewUserTextMessage(question)}
38+ for i := 0; i < n; i++ {
39+ ref := question + "-" + string(rune('a'+i))
40+ msgs = append(msgs,
41+ ai.NewModelMessage(req(ref, "echo "+ref)),
42+ ai.NewMessage(ai.RoleTool, nil, resp(ref, output)),
43+ )
44+ }
45+ return append(msgs, ai.NewModelMessage(ai.NewTextPart("done "+question)))
46+}
47+
48+// history is a system prompt followed by the given turns.
49+func history(turns ...[]*ai.Message) []*ai.Message {
50+ msgs := []*ai.Message{ai.NewSystemTextMessage("You are Bob.")}
51+ for _, t := range turns {
52+ msgs = append(msgs, t...)
53+ }
54+ return msgs
55+}
56+
57+func cfg(keep int) config.ContextConfig {
58+ return config.ContextConfig{Enabled: true, KeepLastTurns: keep, Threshold: 75, MaxMessages: 0, SummaryMaxTokens: 100}
59+}
60+
61+// fixed is a Summarizer that returns a constant and records the request.
62+func fixed(text string) (Summarizer, *[]*ai.Message) {
63+ var seen []*ai.Message
64+ return func(_ context.Context, request []*ai.Message) (string, error) {
65+ seen = request
66+ return text, nil
67+ }, &seen
68+}
69+
70+// --- Split / Valid / Estimate -----------------------------------------------
71+
72+func TestSplitCutsOnUserMessages(t *testing.T) {
73+ msgs := history(turn("q1", 2, "x"), turn("q2", 0, "x"), turn("q3", 1, "x"))
74+ head, turns := Split(msgs)
75+ if len(head) != 1 || head[0].Role != ai.RoleSystem {
76+ t.Fatalf("head = %d message(s), want the system prompt alone", len(head))
77+ }
78+ if len(turns) != 3 {
79+ t.Fatalf("turns = %d, want 3", len(turns))
80+ }
81+ // q1: user + 2×(call, response) + text = 6 ; q2: 2 ; q3: 4.
82+ for i, want := range []int{6, 2, 4} {
83+ if len(turns[i]) != want {
84+ t.Errorf("turn %d has %d messages, want %d", i, len(turns[i]), want)
85+ }
86+ if turns[i][0].Role != ai.RoleUser {
87+ t.Errorf("turn %d does not start with a user message", i)
88+ }
89+ }
90+}
91+
92+func TestSplitTreatsSummaryPairAsATurn(t *testing.T) {
93+ pair := SummaryPair("notes", 10, 2, 3)
94+ msgs := history(pair, turn("q4", 1, "x"))
95+ _, turns := Split(msgs)
96+ if len(turns) != 2 || !IsSummary(turns[0][0]) || len(turns[0]) != 2 {
97+ t.Fatalf("the summary pair should be its own two-message turn, got %d turn(s)", len(turns))
98+ }
99+}
100+
101+func TestValid(t *testing.T) {
102+ ok := history(turn("q1", 2, "x"))
103+ if err := Valid(ok); err != nil {
104+ t.Errorf("a well-formed history is refused: %v", err)
105+ }
106+
107+ // A `tool` message whose request was cut away: the classic broken window.
108+ orphan := []*ai.Message{
109+ ai.NewSystemTextMessage("s"),
110+ ai.NewMessage(ai.RoleTool, nil, resp("z", "out")),
111+ }
112+ if err := Valid(orphan); err == nil {
113+ t.Error("an orphan tool response is accepted")
114+ }
115+
116+ noSystem := turn("q1", 0, "x")
117+ if err := Valid(noSystem); err == nil {
118+ t.Error("a history without the system prompt is accepted")
119+ }
120+
121+ // No Ref on either side: pairing by name must still work.
122+ byName := []*ai.Message{
123+ ai.NewSystemTextMessage("s"),
124+ ai.NewUserTextMessage("q"),
125+ ai.NewModelMessage(req("", "ls")),
126+ ai.NewMessage(ai.RoleTool, nil, resp("", "out")),
127+ }
128+ if err := Valid(byName); err != nil {
129+ t.Errorf("pairing by name fails: %v", err)
130+ }
131+}
132+
133+func TestEstimateGrowsWithToolOutput(t *testing.T) {
134+ small := history(turn("q1", 1, "x"))
135+ big := history(turn("q1", 1, strings.Repeat("0123456789\n", 350))) // 3 850 chars ≈ 1 100 tokens
136+ s, b := Estimate(small), Estimate(big)
137+ if b <= s {
138+ t.Fatalf("Estimate(big) = %d is not above Estimate(small) = %d", b, s)
139+ }
140+ // 3.5 characters per token: 3 850 chars of output add about 1 100 tokens.
141+ if delta := b - s; delta < 1000 || delta > 1200 {
142+ t.Errorf("3 850 characters of output add %d tokens, want ~1 100", delta)
143+ }
144+}
145+
146+// --- Decide -----------------------------------------------------------------
147+
148+func TestDecide(t *testing.T) {
149+ msgs := history(turn("q1", 1, strings.Repeat("x", 3500))) // ≈ 1 000 tokens estimated
150+
151+ off := cfg(1)
152+ off.Enabled = false
153+ if d := Decide(msgs, 0, 1000, off); d.Compact {
154+ t.Error("disabled config still triggers")
155+ }
156+
157+ // 1 000 tokens ≥ 75 % of a 1 000-token window: triggers on tokens.
158+ if d := Decide(msgs, 0, 1000, cfg(1)); !d.Compact || d.Reason != "tokens" {
159+ t.Errorf("tokens trigger: got %+v", d)
160+ }
161+
162+ // A window ten times wider: the estimate is far below the threshold…
163+ if d := Decide(msgs, 0, 10000, cfg(1)); d.Compact {
164+ t.Errorf("estimate below threshold still triggers: %+v", d)
165+ }
166+ // …but the engine's own measure wins when it is larger.
167+ if d := Decide(msgs, 9000, 10000, cfg(1)); !d.Compact || d.Tokens != 9000 {
168+ t.Errorf("measured tokens should win: %+v", d)
169+ }
170+
171+ // Unknown window (neither the yaml nor the probe gave one): only the
172+ // message count can trigger.
173+ byCount := cfg(1)
174+ byCount.MaxMessages = 4
175+ if d := Decide(msgs, 0, 0, byCount); !d.Compact || d.Reason != "messages" {
176+ t.Errorf("messages fallback: got %+v", d)
177+ }
178+ byCount.MaxMessages = 100
179+ if d := Decide(msgs, 0, 0, byCount); d.Compact {
180+ t.Errorf("unknown window and few messages still triggers: %+v", d)
181+ }
182+}
183+
184+// --- Compact ----------------------------------------------------------------
185+
186+func TestCompactKeepsLastTurnsAndPairs(t *testing.T) {
187+ // Realistic outputs: a `cat` or a `go test -v` runs to hundreds of lines,
188+ // and that is the weight the summary has to beat — with three-character
189+ // outputs the wrapper alone would be heavier than what it replaces.
190+ big := strings.Repeat("line of tool output\n", 60)
191+ q1, q2, q3, q4 := turn("q1", 2, big), turn("q2", 1, big), turn("q3", 1, big), turn("q4", 0, "")
192+ msgs := history(q1, q2, q3, q4)
193+ summarize, seen := fixed("## Goal\nsummary of q1 and q2")
194+
195+ res, err := Compact(context.Background(), msgs, cfg(2), summarize)
196+ if err != nil {
197+ t.Fatalf("Compact: %v", err)
198+ }
199+ out := res.Messages
200+
201+ // The request the model saw: note-taker system, the two old turns, prompt.
202+ if len(*seen) != 1+len(q1)+len(q2)+1 {
203+ t.Errorf("summary request has %d messages, want %d", len(*seen), 1+len(q1)+len(q2)+1)
204+ }
205+ if (*seen)[0].Role != ai.RoleSystem || (*seen)[0].Text() == msgs[0].Text() {
206+ t.Error("the summary request must use the note-taker's system prompt, not the agent's")
207+ }
208+ if last := (*seen)[len(*seen)-1]; last.Role != ai.RoleUser || !strings.Contains(last.Text(), "## Files touched") {
209+ t.Error("the summary request must end with the prompt")
210+ }
211+ if strings.Contains((*seen)[len(*seen)-1].Text(), "earlier summary") {
212+ t.Error("no earlier summary here: the merge line must not be added")
213+ }
214+
215+ // The shape: system (same pointer), summary pair, then q3 and q4 untouched.
216+ if out[0] != msgs[0] {
217+ t.Error("the system prompt must be kept as-is, in first position")
218+ }
219+ if !IsSummary(out[1]) || !strings.Contains(out[1].Text(), "[Context summary — the 10 earlier messages") {
220+ t.Errorf("message 1 should be the summary with its wrapper, got %q", out[1].Text())
221+ }
222+ if !strings.Contains(out[1].Text(), "(2 question(s), 3 command(s))") {
223+ t.Errorf("wrapper counts are off: %q", strings.SplitN(out[1].Text(), "\n", 2)[0])
224+ }
225+ if out[2].Role != ai.RoleModel {
226+ t.Error("message 2 should be the model's acknowledgement")
227+ }
228+ rest := out[3:]
229+ want := append(append([]*ai.Message{}, q3...), q4...)
230+ if len(rest) != len(want) {
231+ t.Fatalf("kept %d messages, want %d", len(rest), len(want))
232+ }
233+ for i := range want {
234+ if rest[i] != want[i] {
235+ t.Errorf("kept message %d is not the original pointer", i)
236+ }
237+ }
238+
239+ if err := Valid(out); err != nil {
240+ t.Errorf("compressed history invalid: %v", err)
241+ }
242+ if res.Compressed != len(q1)+len(q2) || res.Kept != len(q3)+len(q4) {
243+ t.Errorf("counts: compressed %d kept %d, want %d and %d", res.Compressed, res.Kept, len(q1)+len(q2), len(q3)+len(q4))
244+ }
245+ if res.After >= res.Before {
246+ t.Errorf("tokens did not go down: %d → %d", res.Before, res.After)
247+ }
248+ // The input must not have been modified: same length, same pointers.
249+ if len(msgs) != 1+len(q1)+len(q2)+len(q3)+len(q4) || msgs[1] != q1[0] {
250+ t.Error("Compact modified its input")
251+ }
252+}
253+
254+func TestCompactNothingToDo(t *testing.T) {
255+ msgs := history(turn("q1", 3, "x"))
256+ called := false
257+ _, err := Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) {
258+ called = true
259+ return "should not be asked", nil
260+ })
261+ if !errors.Is(err, ErrNothingToCompact) {
262+ t.Fatalf("err = %v, want ErrNothingToCompact", err)
263+ }
264+ if called {
265+ t.Error("the model was asked for a summary with nothing to summarise")
266+ }
267+}
268+
269+func TestCompactMergesAnEarlierSummary(t *testing.T) {
270+ pair := SummaryPair("old notes", 12, 3, 5)
271+ msgs := history(pair, turn("q4", 1, "x"), turn("q5", 0, ""))
272+ summarize, seen := fixed("merged notes")
273+
274+ res, err := Compact(context.Background(), msgs, cfg(1), summarize)
275+ if err != nil {
276+ t.Fatalf("Compact: %v", err)
277+ }
278+ prompt := (*seen)[len(*seen)-1].Text()
279+ if !strings.Contains(prompt, "earlier summary") {
280+ t.Error("the merge line is missing from the prompt")
281+ }
282+ // The old summary is IN the request (to be merged), not summarised away.
283+ if !IsSummary((*seen)[1]) {
284+ t.Error("the earlier summary should be the first old message of the request")
285+ }
286+ // Exactly one summary pair remains, and the wrapper counts q4 alone.
287+ if n := countSummaries(res.Messages); n != 1 {
288+ t.Errorf("%d summary messages after merge, want 1", n)
289+ }
290+ if !strings.Contains(res.Messages[1].Text(), "(1 question(s), 1 command(s))") {
291+ t.Errorf("wrapper should count the real question only: %q", strings.SplitN(res.Messages[1].Text(), "\n", 2)[0])
292+ }
293+
294+ // A summary that is the only old turn is not worth a model call.
295+ onlySummary := history(pair, turn("q4", 1, "x"))
296+ if _, err := Compact(context.Background(), onlySummary, cfg(1), summarize); !errors.Is(err, ErrNothingToCompact) {
297+ t.Errorf("re-summarising a lone summary: err = %v, want ErrNothingToCompact", err)
298+ }
299+}
300+
301+func TestCompactFailureLeavesNoResult(t *testing.T) {
302+ msgs := history(turn("q1", 1, "x"), turn("q2", 0, ""))
303+ boom := errors.New("engine down")
304+ res, err := Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) {
305+ return "", boom
306+ })
307+ if !errors.Is(err, boom) || res.Messages != nil {
308+ t.Errorf("a failing summary must surface the error and no messages, got err=%v res=%+v", err, res)
309+ }
310+ _, err = Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) {
311+ return " \n", nil
312+ })
313+ if err == nil {
314+ t.Error("an empty summary must be an error — an empty history is worse than a long one")
315+ }
316+}
317+
318+func countSummaries(msgs []*ai.Message) int {
319+ n := 0
320+ for _, m := range msgs {
321+ if IsSummary(m) {
322+ n++
323+ }
324+ }
325+ return n
326+}
327+
328+// --- through engine.Summarize -----------------------------------------------
329+
330+// TestCompactThroughEngineSummarize wires the real Summarizer the agent uses
331+// onto a fake Genkit model, the same approach as internal/engine's tests: the
332+// request the model receives must carry no tools, and the text it returns must
333+// land in the summary message. The Engine is built the way testEngine does it
334+// there — the model reference is all Summarize reads from it.
335+func TestCompactThroughEngineSummarize(t *testing.T) {
336+ g := genkit.Init(context.Background())
337+ var sawTools int
338+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
339+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
340+ func(_ context.Context, r *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
341+ sawTools = len(r.Tools)
342+ return &ai.ModelResponse{
343+ Message: ai.NewModelMessage(ai.NewTextPart("## Goal\nfrom the fake model")),
344+ FinishReason: ai.FinishReasonStop,
345+ }, nil
346+ })
347+ e := &engine.Engine{G: g, Model: "dmr/" + config.Cfg.Model}
348+
349+ msgs := history(turn("q1", 2, "x"), turn("q2", 1, "y"))
350+ c := cfg(1)
351+ res, err := Compact(context.Background(), msgs, c, func(ctx context.Context, request []*ai.Message) (string, error) {
352+ return e.Summarize(ctx, request, c.SummaryMaxTokens)
353+ })
354+ if err != nil {
355+ t.Fatalf("Compact via engine.Summarize: %v", err)
356+ }
357+ if sawTools != 0 {
358+ t.Errorf("the summary request declared %d tool(s), want none", sawTools)
359+ }
360+ if !strings.Contains(res.Messages[1].Text(), "from the fake model") {
361+ t.Errorf("the model's text is not in the summary: %q", res.Messages[1].Text())
362+ }
363+ if err := Valid(res.Messages); err != nil {
364+ t.Errorf("invalid history: %v", err)
365+ }
366+}
new file mode 100644
@@ -0,0 +1,366 @@
1+// The tests build histories by hand, the way TestCommandList does in
2+// internal/engine, and check the one property a compression must never break:
3+// the next request is still a valid conversation — system prompt first, every
4+// tool response under the request that asked for it, the recent turns intact.
5+//
6+// No network and no model: the Summarizer is a function here. The last test
7+// goes through engine.Summarize with a fake Genkit model, so the wiring the
8+// agent uses is exercised too.
9+package compact
10+
11+import (
12+ "context"
13+ "errors"
14+ "strings"
15+ "testing"
16+
17+ "mm/internal/config"
18+ "mm/internal/engine"
19+
20+ "github.com/firebase/genkit/go/ai"
21+ "github.com/firebase/genkit/go/genkit"
22+)
23+
24+// --- fixtures ---------------------------------------------------------------
25+
26+func req(ref, cmd string) *ai.Part {
27+ return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: "bash", Input: map[string]any{"command": cmd}})
28+}
29+
30+func resp(ref, out string) *ai.Part {
31+ return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: "bash", Output: out})
32+}
33+
34+// turn builds one question turn with n commands: user, then n × (model call,
35+// tool response), then the model's final text.
36+func turn(question string, n int, output string) []*ai.Message {
37+ msgs := []*ai.Message{ai.NewUserTextMessage(question)}
38+ for i := 0; i < n; i++ {
39+ ref := question + "-" + string(rune('a'+i))
40+ msgs = append(msgs,
41+ ai.NewModelMessage(req(ref, "echo "+ref)),
42+ ai.NewMessage(ai.RoleTool, nil, resp(ref, output)),
43+ )
44+ }
45+ return append(msgs, ai.NewModelMessage(ai.NewTextPart("done "+question)))
46+}
47+
48+// history is a system prompt followed by the given turns.
49+func history(turns ...[]*ai.Message) []*ai.Message {
50+ msgs := []*ai.Message{ai.NewSystemTextMessage("You are Bob.")}
51+ for _, t := range turns {
52+ msgs = append(msgs, t...)
53+ }
54+ return msgs
55+}
56+
57+func cfg(keep int) config.ContextConfig {
58+ return config.ContextConfig{Enabled: true, KeepLastTurns: keep, Threshold: 75, MaxMessages: 0, SummaryMaxTokens: 100}
59+}
60+
61+// fixed is a Summarizer that returns a constant and records the request.
62+func fixed(text string) (Summarizer, *[]*ai.Message) {
63+ var seen []*ai.Message
64+ return func(_ context.Context, request []*ai.Message) (string, error) {
65+ seen = request
66+ return text, nil
67+ }, &seen
68+}
69+
70+// --- Split / Valid / Estimate -----------------------------------------------
71+
72+func TestSplitCutsOnUserMessages(t *testing.T) {
73+ msgs := history(turn("q1", 2, "x"), turn("q2", 0, "x"), turn("q3", 1, "x"))
74+ head, turns := Split(msgs)
75+ if len(head) != 1 || head[0].Role != ai.RoleSystem {
76+ t.Fatalf("head = %d message(s), want the system prompt alone", len(head))
77+ }
78+ if len(turns) != 3 {
79+ t.Fatalf("turns = %d, want 3", len(turns))
80+ }
81+ // q1: user + 2×(call, response) + text = 6 ; q2: 2 ; q3: 4.
82+ for i, want := range []int{6, 2, 4} {
83+ if len(turns[i]) != want {
84+ t.Errorf("turn %d has %d messages, want %d", i, len(turns[i]), want)
85+ }
86+ if turns[i][0].Role != ai.RoleUser {
87+ t.Errorf("turn %d does not start with a user message", i)
88+ }
89+ }
90+}
91+
92+func TestSplitTreatsSummaryPairAsATurn(t *testing.T) {
93+ pair := SummaryPair("notes", 10, 2, 3)
94+ msgs := history(pair, turn("q4", 1, "x"))
95+ _, turns := Split(msgs)
96+ if len(turns) != 2 || !IsSummary(turns[0][0]) || len(turns[0]) != 2 {
97+ t.Fatalf("the summary pair should be its own two-message turn, got %d turn(s)", len(turns))
98+ }
99+}
100+
101+func TestValid(t *testing.T) {
102+ ok := history(turn("q1", 2, "x"))
103+ if err := Valid(ok); err != nil {
104+ t.Errorf("a well-formed history is refused: %v", err)
105+ }
106+
107+ // A `tool` message whose request was cut away: the classic broken window.
108+ orphan := []*ai.Message{
109+ ai.NewSystemTextMessage("s"),
110+ ai.NewMessage(ai.RoleTool, nil, resp("z", "out")),
111+ }
112+ if err := Valid(orphan); err == nil {
113+ t.Error("an orphan tool response is accepted")
114+ }
115+
116+ noSystem := turn("q1", 0, "x")
117+ if err := Valid(noSystem); err == nil {
118+ t.Error("a history without the system prompt is accepted")
119+ }
120+
121+ // No Ref on either side: pairing by name must still work.
122+ byName := []*ai.Message{
123+ ai.NewSystemTextMessage("s"),
124+ ai.NewUserTextMessage("q"),
125+ ai.NewModelMessage(req("", "ls")),
126+ ai.NewMessage(ai.RoleTool, nil, resp("", "out")),
127+ }
128+ if err := Valid(byName); err != nil {
129+ t.Errorf("pairing by name fails: %v", err)
130+ }
131+}
132+
133+func TestEstimateGrowsWithToolOutput(t *testing.T) {
134+ small := history(turn("q1", 1, "x"))
135+ big := history(turn("q1", 1, strings.Repeat("0123456789\n", 350))) // 3 850 chars ≈ 1 100 tokens
136+ s, b := Estimate(small), Estimate(big)
137+ if b <= s {
138+ t.Fatalf("Estimate(big) = %d is not above Estimate(small) = %d", b, s)
139+ }
140+ // 3.5 characters per token: 3 850 chars of output add about 1 100 tokens.
141+ if delta := b - s; delta < 1000 || delta > 1200 {
142+ t.Errorf("3 850 characters of output add %d tokens, want ~1 100", delta)
143+ }
144+}
145+
146+// --- Decide -----------------------------------------------------------------
147+
148+func TestDecide(t *testing.T) {
149+ msgs := history(turn("q1", 1, strings.Repeat("x", 3500))) // ≈ 1 000 tokens estimated
150+
151+ off := cfg(1)
152+ off.Enabled = false
153+ if d := Decide(msgs, 0, 1000, off); d.Compact {
154+ t.Error("disabled config still triggers")
155+ }
156+
157+ // 1 000 tokens ≥ 75 % of a 1 000-token window: triggers on tokens.
158+ if d := Decide(msgs, 0, 1000, cfg(1)); !d.Compact || d.Reason != "tokens" {
159+ t.Errorf("tokens trigger: got %+v", d)
160+ }
161+
162+ // A window ten times wider: the estimate is far below the threshold…
163+ if d := Decide(msgs, 0, 10000, cfg(1)); d.Compact {
164+ t.Errorf("estimate below threshold still triggers: %+v", d)
165+ }
166+ // …but the engine's own measure wins when it is larger.
167+ if d := Decide(msgs, 9000, 10000, cfg(1)); !d.Compact || d.Tokens != 9000 {
168+ t.Errorf("measured tokens should win: %+v", d)
169+ }
170+
171+ // Unknown window (neither the yaml nor the probe gave one): only the
172+ // message count can trigger.
173+ byCount := cfg(1)
174+ byCount.MaxMessages = 4
175+ if d := Decide(msgs, 0, 0, byCount); !d.Compact || d.Reason != "messages" {
176+ t.Errorf("messages fallback: got %+v", d)
177+ }
178+ byCount.MaxMessages = 100
179+ if d := Decide(msgs, 0, 0, byCount); d.Compact {
180+ t.Errorf("unknown window and few messages still triggers: %+v", d)
181+ }
182+}
183+
184+// --- Compact ----------------------------------------------------------------
185+
186+func TestCompactKeepsLastTurnsAndPairs(t *testing.T) {
187+ // Realistic outputs: a `cat` or a `go test -v` runs to hundreds of lines,
188+ // and that is the weight the summary has to beat — with three-character
189+ // outputs the wrapper alone would be heavier than what it replaces.
190+ big := strings.Repeat("line of tool output\n", 60)
191+ q1, q2, q3, q4 := turn("q1", 2, big), turn("q2", 1, big), turn("q3", 1, big), turn("q4", 0, "")
192+ msgs := history(q1, q2, q3, q4)
193+ summarize, seen := fixed("## Goal\nsummary of q1 and q2")
194+
195+ res, err := Compact(context.Background(), msgs, cfg(2), summarize)
196+ if err != nil {
197+ t.Fatalf("Compact: %v", err)
198+ }
199+ out := res.Messages
200+
201+ // The request the model saw: note-taker system, the two old turns, prompt.
202+ if len(*seen) != 1+len(q1)+len(q2)+1 {
203+ t.Errorf("summary request has %d messages, want %d", len(*seen), 1+len(q1)+len(q2)+1)
204+ }
205+ if (*seen)[0].Role != ai.RoleSystem || (*seen)[0].Text() == msgs[0].Text() {
206+ t.Error("the summary request must use the note-taker's system prompt, not the agent's")
207+ }
208+ if last := (*seen)[len(*seen)-1]; last.Role != ai.RoleUser || !strings.Contains(last.Text(), "## Files touched") {
209+ t.Error("the summary request must end with the prompt")
210+ }
211+ if strings.Contains((*seen)[len(*seen)-1].Text(), "earlier summary") {
212+ t.Error("no earlier summary here: the merge line must not be added")
213+ }
214+
215+ // The shape: system (same pointer), summary pair, then q3 and q4 untouched.
216+ if out[0] != msgs[0] {
217+ t.Error("the system prompt must be kept as-is, in first position")
218+ }
219+ if !IsSummary(out[1]) || !strings.Contains(out[1].Text(), "[Context summary — the 10 earlier messages") {
220+ t.Errorf("message 1 should be the summary with its wrapper, got %q", out[1].Text())
221+ }
222+ if !strings.Contains(out[1].Text(), "(2 question(s), 3 command(s))") {
223+ t.Errorf("wrapper counts are off: %q", strings.SplitN(out[1].Text(), "\n", 2)[0])
224+ }
225+ if out[2].Role != ai.RoleModel {
226+ t.Error("message 2 should be the model's acknowledgement")
227+ }
228+ rest := out[3:]
229+ want := append(append([]*ai.Message{}, q3...), q4...)
230+ if len(rest) != len(want) {
231+ t.Fatalf("kept %d messages, want %d", len(rest), len(want))
232+ }
233+ for i := range want {
234+ if rest[i] != want[i] {
235+ t.Errorf("kept message %d is not the original pointer", i)
236+ }
237+ }
238+
239+ if err := Valid(out); err != nil {
240+ t.Errorf("compressed history invalid: %v", err)
241+ }
242+ if res.Compressed != len(q1)+len(q2) || res.Kept != len(q3)+len(q4) {
243+ t.Errorf("counts: compressed %d kept %d, want %d and %d", res.Compressed, res.Kept, len(q1)+len(q2), len(q3)+len(q4))
244+ }
245+ if res.After >= res.Before {
246+ t.Errorf("tokens did not go down: %d → %d", res.Before, res.After)
247+ }
248+ // The input must not have been modified: same length, same pointers.
249+ if len(msgs) != 1+len(q1)+len(q2)+len(q3)+len(q4) || msgs[1] != q1[0] {
250+ t.Error("Compact modified its input")
251+ }
252+}
253+
254+func TestCompactNothingToDo(t *testing.T) {
255+ msgs := history(turn("q1", 3, "x"))
256+ called := false
257+ _, err := Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) {
258+ called = true
259+ return "should not be asked", nil
260+ })
261+ if !errors.Is(err, ErrNothingToCompact) {
262+ t.Fatalf("err = %v, want ErrNothingToCompact", err)
263+ }
264+ if called {
265+ t.Error("the model was asked for a summary with nothing to summarise")
266+ }
267+}
268+
269+func TestCompactMergesAnEarlierSummary(t *testing.T) {
270+ pair := SummaryPair("old notes", 12, 3, 5)
271+ msgs := history(pair, turn("q4", 1, "x"), turn("q5", 0, ""))
272+ summarize, seen := fixed("merged notes")
273+
274+ res, err := Compact(context.Background(), msgs, cfg(1), summarize)
275+ if err != nil {
276+ t.Fatalf("Compact: %v", err)
277+ }
278+ prompt := (*seen)[len(*seen)-1].Text()
279+ if !strings.Contains(prompt, "earlier summary") {
280+ t.Error("the merge line is missing from the prompt")
281+ }
282+ // The old summary is IN the request (to be merged), not summarised away.
283+ if !IsSummary((*seen)[1]) {
284+ t.Error("the earlier summary should be the first old message of the request")
285+ }
286+ // Exactly one summary pair remains, and the wrapper counts q4 alone.
287+ if n := countSummaries(res.Messages); n != 1 {
288+ t.Errorf("%d summary messages after merge, want 1", n)
289+ }
290+ if !strings.Contains(res.Messages[1].Text(), "(1 question(s), 1 command(s))") {
291+ t.Errorf("wrapper should count the real question only: %q", strings.SplitN(res.Messages[1].Text(), "\n", 2)[0])
292+ }
293+
294+ // A summary that is the only old turn is not worth a model call.
295+ onlySummary := history(pair, turn("q4", 1, "x"))
296+ if _, err := Compact(context.Background(), onlySummary, cfg(1), summarize); !errors.Is(err, ErrNothingToCompact) {
297+ t.Errorf("re-summarising a lone summary: err = %v, want ErrNothingToCompact", err)
298+ }
299+}
300+
301+func TestCompactFailureLeavesNoResult(t *testing.T) {
302+ msgs := history(turn("q1", 1, "x"), turn("q2", 0, ""))
303+ boom := errors.New("engine down")
304+ res, err := Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) {
305+ return "", boom
306+ })
307+ if !errors.Is(err, boom) || res.Messages != nil {
308+ t.Errorf("a failing summary must surface the error and no messages, got err=%v res=%+v", err, res)
309+ }
310+ _, err = Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) {
311+ return " \n", nil
312+ })
313+ if err == nil {
314+ t.Error("an empty summary must be an error — an empty history is worse than a long one")
315+ }
316+}
317+
318+func countSummaries(msgs []*ai.Message) int {
319+ n := 0
320+ for _, m := range msgs {
321+ if IsSummary(m) {
322+ n++
323+ }
324+ }
325+ return n
326+}
327+
328+// --- through engine.Summarize -----------------------------------------------
329+
330+// TestCompactThroughEngineSummarize wires the real Summarizer the agent uses
331+// onto a fake Genkit model, the same approach as internal/engine's tests: the
332+// request the model receives must carry no tools, and the text it returns must
333+// land in the summary message. The Engine is built the way testEngine does it
334+// there — the model reference is all Summarize reads from it.
335+func TestCompactThroughEngineSummarize(t *testing.T) {
336+ g := genkit.Init(context.Background())
337+ var sawTools int
338+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
339+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
340+ func(_ context.Context, r *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
341+ sawTools = len(r.Tools)
342+ return &ai.ModelResponse{
343+ Message: ai.NewModelMessage(ai.NewTextPart("## Goal\nfrom the fake model")),
344+ FinishReason: ai.FinishReasonStop,
345+ }, nil
346+ })
347+ e := &engine.Engine{G: g, Model: "dmr/" + config.Cfg.Model}
348+
349+ msgs := history(turn("q1", 2, "x"), turn("q2", 1, "y"))
350+ c := cfg(1)
351+ res, err := Compact(context.Background(), msgs, c, func(ctx context.Context, request []*ai.Message) (string, error) {
352+ return e.Summarize(ctx, request, c.SummaryMaxTokens)
353+ })
354+ if err != nil {
355+ t.Fatalf("Compact via engine.Summarize: %v", err)
356+ }
357+ if sawTools != 0 {
358+ t.Errorf("the summary request declared %d tool(s), want none", sawTools)
359+ }
360+ if !strings.Contains(res.Messages[1].Text(), "from the fake model") {
361+ t.Errorf("the model's text is not in the summary: %q", res.Messages[1].Text())
362+ }
363+ if err := Valid(res.Messages); err != nil {
364+ t.Errorf("invalid history: %v", err)
365+ }
366+}
added internal/compact/prompt.go +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+package compact
2+
3+import (
4+ "fmt"
5+
6+ "github.com/firebase/genkit/go/ai"
7+)
8+
9+// summarySystem replaces the agent's own system prompt in the summary request.
10+// The agent's prompt says "you are a coding agent, run commands": sent along
11+// with a history full of tool calls and no tools declared, it invites the model
12+// to keep acting. The note-taker's prompt asks for the one thing wanted here.
13+const summarySystem = `You are the note-taker of a coding agent. You write precise, structured notes about a coding session, in plain markdown, and you never invent details that are not in the conversation.`
14+
15+// DefaultPrompt is the summary request (CONTEXT_WINDOW.md § 4.1). The seven
16+// fixed sections are the contract: what the model must preserve, and a shape
17+// the agent can rely on. The ban on quoting file contents is deliberate — a
18+// file may have changed since it was read, and the agent's system prompt
19+// already forbids stating what a command in THIS answer did not return.
20+const DefaultPrompt = `You are compressing the history of a coding session so that the same agent
21+can continue the work with less context. Everything above this message is
22+that history. Write a summary, in English, using EXACTLY the sections below,
23+markdown headings included. Be concrete: exact file paths, exact command
24+lines, exact error messages. Do not invent anything that is not in the
25+history. Do not quote file contents — files may have changed since; say what
26+was done to them instead.
27+
28+## Goal
29+What the user is trying to achieve, in one or two sentences, in their words.
30+
31+## Files touched
32+One line per file: path — created | modified | deleted | read-only — what
33+changed. Nothing else.
34+
35+## Decisions
36+Choices made and their reason (libraries, structure, names, things the user
37+refused). One line each.
38+
39+## Errors and fixes
40+Errors met, and what fixed them. One line each. Skip errors that are not
41+relevant any more.
42+
43+## Done
44+The tasks completed, one line each.
45+
46+## In progress / next steps
47+What was being done when the history ends, and what remains. Be explicit
48+about anything half-finished.
49+
50+## Useful commands
51+Commands to rebuild, test or run the project, and any background job left
52+running (with its pid file). One per line.
53+
54+Keep the whole summary under 40 lines. If a section is empty, write "none".`
55+
56+// mergeLine is appended to the prompt when the history to summarise already
57+// starts with an earlier summary.
58+const mergeLine = "\n\nAn earlier summary is at the top of the history; merge it into the new one, do not repeat it as-is."
59+
60+// acknowledgement is the model half of the summary pair. It keeps the
61+// user/assistant alternation that chat templates expect after a user message,
62+// and it restates the one rule the summary must not erode.
63+const acknowledgement = "Understood — I will re-check files before editing them."
64+
65+// Request builds the summary request: the note-taker's system message, the
66+// old turns as they were sent to the model, then the prompt. `prompt` empty
67+// means DefaultPrompt.
68+func Request(old []*ai.Message, prompt string, merge bool) []*ai.Message {
69+ if prompt == "" {
70+ prompt = DefaultPrompt
71+ }
72+ if merge {
73+ prompt += mergeLine
74+ }
75+ req := make([]*ai.Message, 0, len(old)+2)
76+ req = append(req, ai.NewSystemTextMessage(summarySystem))
77+ req = append(req, old...)
78+ req = append(req, ai.NewUserTextMessage(prompt))
79+ return req
80+}
81+
82+// SummaryPair wraps the model's notes into the two messages that replace the
83+// old turns. The wrapper says what the notes are and what they are not: the
84+// model must treat them as notes to verify, not as observations — otherwise a
85+// wrong line in the summary becomes a fact for the rest of the session.
86+func SummaryPair(summary string, messages, questions, commands int) []*ai.Message {
87+ meta := map[string]any{metaKey: "summary"}
88+ notes := fmt.Sprintf(`[Context summary — the %d earlier messages of this session (%d question(s), %d command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.]
89+
90+%s`, messages, questions, commands, summary)
91+ return []*ai.Message{
92+ ai.NewMessage(ai.RoleUser, meta, ai.NewTextPart(notes)),
93+ ai.NewMessage(ai.RoleModel, meta, ai.NewTextPart(acknowledgement)),
94+ }
95+}
new file mode 100644
@@ -0,0 +1,95 @@
1+package compact
2+
3+import (
4+ "fmt"
5+
6+ "github.com/firebase/genkit/go/ai"
7+)
8+
9+// summarySystem replaces the agent's own system prompt in the summary request.
10+// The agent's prompt says "you are a coding agent, run commands": sent along
11+// with a history full of tool calls and no tools declared, it invites the model
12+// to keep acting. The note-taker's prompt asks for the one thing wanted here.
13+const summarySystem = `You are the note-taker of a coding agent. You write precise, structured notes about a coding session, in plain markdown, and you never invent details that are not in the conversation.`
14+
15+// DefaultPrompt is the summary request (CONTEXT_WINDOW.md § 4.1). The seven
16+// fixed sections are the contract: what the model must preserve, and a shape
17+// the agent can rely on. The ban on quoting file contents is deliberate — a
18+// file may have changed since it was read, and the agent's system prompt
19+// already forbids stating what a command in THIS answer did not return.
20+const DefaultPrompt = `You are compressing the history of a coding session so that the same agent
21+can continue the work with less context. Everything above this message is
22+that history. Write a summary, in English, using EXACTLY the sections below,
23+markdown headings included. Be concrete: exact file paths, exact command
24+lines, exact error messages. Do not invent anything that is not in the
25+history. Do not quote file contents — files may have changed since; say what
26+was done to them instead.
27+
28+## Goal
29+What the user is trying to achieve, in one or two sentences, in their words.
30+
31+## Files touched
32+One line per file: path — created | modified | deleted | read-only — what
33+changed. Nothing else.
34+
35+## Decisions
36+Choices made and their reason (libraries, structure, names, things the user
37+refused). One line each.
38+
39+## Errors and fixes
40+Errors met, and what fixed them. One line each. Skip errors that are not
41+relevant any more.
42+
43+## Done
44+The tasks completed, one line each.
45+
46+## In progress / next steps
47+What was being done when the history ends, and what remains. Be explicit
48+about anything half-finished.
49+
50+## Useful commands
51+Commands to rebuild, test or run the project, and any background job left
52+running (with its pid file). One per line.
53+
54+Keep the whole summary under 40 lines. If a section is empty, write "none".`
55+
56+// mergeLine is appended to the prompt when the history to summarise already
57+// starts with an earlier summary.
58+const mergeLine = "\n\nAn earlier summary is at the top of the history; merge it into the new one, do not repeat it as-is."
59+
60+// acknowledgement is the model half of the summary pair. It keeps the
61+// user/assistant alternation that chat templates expect after a user message,
62+// and it restates the one rule the summary must not erode.
63+const acknowledgement = "Understood — I will re-check files before editing them."
64+
65+// Request builds the summary request: the note-taker's system message, the
66+// old turns as they were sent to the model, then the prompt. `prompt` empty
67+// means DefaultPrompt.
68+func Request(old []*ai.Message, prompt string, merge bool) []*ai.Message {
69+ if prompt == "" {
70+ prompt = DefaultPrompt
71+ }
72+ if merge {
73+ prompt += mergeLine
74+ }
75+ req := make([]*ai.Message, 0, len(old)+2)
76+ req = append(req, ai.NewSystemTextMessage(summarySystem))
77+ req = append(req, old...)
78+ req = append(req, ai.NewUserTextMessage(prompt))
79+ return req
80+}
81+
82+// SummaryPair wraps the model's notes into the two messages that replace the
83+// old turns. The wrapper says what the notes are and what they are not: the
84+// model must treat them as notes to verify, not as observations — otherwise a
85+// wrong line in the summary becomes a fact for the rest of the session.
86+func SummaryPair(summary string, messages, questions, commands int) []*ai.Message {
87+ meta := map[string]any{metaKey: "summary"}
88+ notes := fmt.Sprintf(`[Context summary — the %d earlier messages of this session (%d question(s), %d command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.]
89+
90+%s`, messages, questions, commands, summary)
91+ return []*ai.Message{
92+ ai.NewMessage(ai.RoleUser, meta, ai.NewTextPart(notes)),
93+ ai.NewMessage(ai.RoleModel, meta, ai.NewTextPart(acknowledgement)),
94+ }
95+}
added internal/config/config.go +281 -0
new file mode 100644
@@ -0,0 +1,281 @@
1+// Package config holds every setting of the agent in ONE place.
2+//
3+// The values below are the built-in defaults; a YAML file overrides the keys it
4+// mentions and leaves the others alone. That way the agent still runs with no
5+// config file at all, and a single `agent.yaml` is enough to change the model,
6+// the system prompt or the sampling — no recompilation.
7+package config
8+
9+import (
10+ "errors"
11+ "fmt"
12+ "io/fs"
13+ "os"
14+ "time"
15+
16+ "gopkg.in/yaml.v3"
17+)
18+
19+// Config: everything that can be tuned without touching the code.
20+type Config struct {
21+ // Provider: which LLM server is behind BaseURL — a key of the engine
22+ // registry ("dmr", "llamacpp"). Defaults to "dmr" so that every agent.yaml
23+ // written before this key existed keeps its meaning.
24+ Provider string `yaml:"provider"`
25+
26+ // Model: identifier of the chat model, in the provider's own naming
27+ // (DMR: "ai/qwen2.5-coder", "hf.co/…"; llama-server: the alias it serves).
28+ Model string `yaml:"model"`
29+
30+ // BaseURL: the OpenAI-compatible endpoint, tried first. Empty = the
31+ // provider's default, so a llama.cpp config does not inherit DMR's port.
32+ BaseURL string `yaml:"baseUrl"`
33+
34+ // Fallback: used when BaseURL does not answer — on the host DMR sits on
35+ // localhost, but from inside a container or a sandbox it is reachable as
36+ // host.docker.internal. A pointer, because "absent" and "" differ: absent
37+ // means the provider's default, "" means no fallback at all (the fake
38+ // engine's probe.yaml relies on that to stay pinned to one URL).
39+ Fallback *string `yaml:"fallback"`
40+
41+ // APIKeyEnv: the NAME of the environment variable holding the key — never
42+ // the key itself, so agent.yaml can be committed. Empty = the provider's
43+ // default variable; servers that ignore the key need none.
44+ APIKeyEnv string `yaml:"apiKeyEnv"`
45+
46+ // ContextWindow: how many tokens the server actually serves, when the
47+ // operator knows it (llama-server -c, `docker model configure`). 0 = ask the
48+ // server, then unknown. Displayed at start-up; the context-compression work
49+ // reads it.
50+ ContextWindow int `yaml:"contextWindow"`
51+
52+ // MaxOutput: max number of characters a tool returns to the model
53+ // (context safeguard: large outputs are truncated).
54+ MaxOutput int `yaml:"maxOutput"`
55+
56+ // MaxTurns: max number of model ↔ tools round trips for one question.
57+ MaxTurns int `yaml:"maxTurns"`
58+
59+ // PreviewLines: how many lines of a command's output are echoed to the
60+ // terminal. The user asked to SEE something; running the command is not
61+ // showing it. 0 disables the echo.
62+ PreviewLines int `yaml:"previewLines"`
63+
64+ // DisplayCommands: recap, after the answer, the list of commands the agent
65+ // actually ran. The count alone says HOW MUCH it worked; the list says
66+ // WHAT it did — worth showing on a screen, noise in a log.
67+ DisplayCommands bool `yaml:"displayCommands"`
68+
69+ // System: the system prompt — what the agent is, and what it may do.
70+ System string `yaml:"system"`
71+
72+ // SkillsDir: directory of markdown procedures exposed by the `read_skill`
73+ // tool. When it holds no *.md file, the tool is not declared at all.
74+ SkillsDir string `yaml:"skillsDir"`
75+
76+ // EditTools: declare the built-in file tools (read_file, write_file,
77+ // edit_file). Off, the agent has bash and read_skill only, exactly like
78+ // part 09 — and edits files through the `edit` CLI if it is on the PATH.
79+ // One binary, two set-ups: that is what lets the two be compared.
80+ EditTools bool `yaml:"editTools"`
81+
82+ // Sampling: generation settings (OpenAI API keys; the plugin converts them.)
83+ Sampling map[string]any `yaml:"sampling"`
84+
85+ // WatchdogTimeout: how long to wait for new tokens before assuming the connection has hung.
86+ WatchdogTimeout time.Duration `yaml:"watchdogTimeout"`
87+
88+ // Context: when and how the conversation history is compressed
89+ // (internal/compact). Off unless `context.enabled: true`. The window it
90+ // measures against is the top-level ContextWindow above — or, when that is
91+ // 0, what the provider's probe learned from the server.
92+ Context ContextConfig `yaml:"context"`
93+}
94+
95+// ContextConfig drives the compression of the history (see
96+// 08-context-compression/CONTEXT_WINDOW.md; the code is the same as 08's).
97+//
98+// Off by default: with `enabled: false` this part behaves exactly as before.
99+// Nothing in the agent shortens the history otherwise — measured with the fake
100+// engine of part 03, it grew 2 → 5 → 7 → 9 messages over four requests, and on
101+// a local model the window is fixed at load time.
102+//
103+// Unlike 08 there is no window here: `contextWindow` is ONE key, at the top
104+// level, shared by the banner and the trigger — two keys for the same number
105+// would drift apart.
106+type ContextConfig struct {
107+ // Enabled turns the automatic compression on. `/compact` works regardless.
108+ Enabled bool `yaml:"enabled"`
109+
110+ // Threshold: share of the context window, in percent, beyond which the
111+ // history is compressed before the next question is sent.
112+ Threshold int `yaml:"threshold"`
113+
114+ // MaxMessages: fallback trigger on the message count, for when the window
115+ // is unknown (no contextWindow, no /props) or the estimate is off. 0
116+ // disables it.
117+ MaxMessages int `yaml:"maxMessages"`
118+
119+ // KeepLastTurns: question turns kept raw at the end of the history.
120+ KeepLastTurns int `yaml:"keepLastTurns"`
121+
122+ // SummaryMaxTokens: max_tokens of the summary request.
123+ SummaryMaxTokens int `yaml:"summaryMaxTokens"`
124+
125+ // Prompt replaces the built-in summary prompt when not empty.
126+ Prompt string `yaml:"prompt"`
127+
128+ // ShowStats: print the one-line 🗜️ report after each compression.
129+ ShowStats bool `yaml:"showStats"`
130+}
131+
132+// DefaultPath: the config file looked up when neither the command line nor
133+// AGENT_CONFIG says otherwise.
134+const DefaultPath = "agent.yaml"
135+
136+// Cfg: the live settings, pre-filled with the built-in defaults.
137+//
138+// Defaults suited to a CODING AGENT — low temperature for precise and
139+// reproducible answers.
140+var Cfg = Config{
141+ Provider: "dmr",
142+ Model: "huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M",
143+ // BaseURL and Fallback are left empty on purpose: the provider fills them
144+ // in (for "dmr": localhost:12434 and host.docker.internal:12434, exactly the
145+ // values that used to be here).
146+ MaxOutput: 16000,
147+ MaxTurns: 10,
148+ PreviewLines: 20,
149+ // Off by default: the recap is a demo/debug aid, not something every run
150+ // needs. `displayCommands: true` in the YAML turns it on.
151+ DisplayCommands: false,
152+ SkillsDir: "skills",
153+ EditTools: true,
154+ System: `You are a coding agent working in a terminal.
155+You have a "bash" tool to run shell commands.
156+Use it to explore files, run tests, inspect the repository, etc.
157+Chain several commands if needed, then answer clearly in English.
158+
159+A request often mixes things you answer from yourself ("say hello") with things
160+only a command can answer ("list the files"). Handle every part, in the order
161+asked, and run a command for each part that needs one.
162+Never state the contents of a file, the output of a command, or the state of the
163+repository unless a command in THIS answer returned it. What you did not read,
164+you do not know: run the command instead of recalling it.
165+
166+BACKGROUND JOBS
167+Never let a command block the answer. Anything that serves, watches or runs
168+long goes to the background, with BOTH streams redirected and its pid kept:
169+
170+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
171+
172+Redirecting only stdout still blocks until the process exits. Read the
173+"bg-jobs" skill before you wait on, inspect or stop such a job — each has a
174+rule you cannot guess. Stop every job you started before you finish, and say
175+which ones you left running.`,
176+ Sampling: map[string]any{
177+ "temperature": 0.0,
178+ "top_p": 0.9,
179+ "max_tokens": 4096,
180+ },
181+ WatchdogTimeout: 20 * time.Second,
182+ Context: ContextConfig{
183+ Enabled: false,
184+ // 75 % leaves a quarter of the window for the next question, the tool
185+ // outputs of its turns and the answer: one `bash` output alone can be
186+ // maxOutput characters, about 4-5k tokens.
187+ Threshold: 75,
188+ // One command costs 2 messages (call + response): 80 is roughly 30
189+ // commands of history, the point where a 12B local model slows down.
190+ MaxMessages: 80,
191+ // The recent turns are where the model works; summarising them makes
192+ // it re-run what it just did.
193+ KeepLastTurns: 3,
194+ // Seven sections of one line per item fit in far less; the cap stops
195+ // a runaway model from filling the window it was asked to empty.
196+ SummaryMaxTokens: 1200,
197+ ShowStats: true,
198+ },
199+}
200+
201+// Load reads the YAML file on top of the defaults and returns the path actually
202+// used ("" when no file was found).
203+//
204+// Three places are tried, first one wins: the command line (cliPath, what main
205+// read from -config or from a lone argument), then AGENT_CONFIG, then
206+// ./agent.yaml. The command line wins over the environment because it is the
207+// more local of the two — closer to the run you are making right now.
208+//
209+// A path asked for EXPLICITLY (command line or AGENT_CONFIG) that does not
210+// exist is an error: you named a file, it should be there. A missing
211+// ./agent.yaml is not an error — the built-in defaults are enough to run.
212+func Load(cliPath string) (string, error) {
213+ path, explicit := cliPath, true
214+ if path == "" {
215+ path = os.Getenv("AGENT_CONFIG")
216+ }
217+ if path == "" {
218+ path, explicit = DefaultPath, false
219+ }
220+
221+ data, err := os.ReadFile(path)
222+ if errors.Is(err, fs.ErrNotExist) && !explicit {
223+ return "", nil // no file: the defaults stand
224+ }
225+ if err != nil {
226+ return "", err
227+ }
228+
229+ // Unmarshalling INTO Cfg: keys absent from the file keep their default.
230+ if err := yaml.Unmarshal(data, &Cfg); err != nil {
231+ return "", fmt.Errorf("%s: %w", path, err)
232+ }
233+ applyEnv()
234+ if err := Cfg.validate(); err != nil {
235+ return "", fmt.Errorf("%s: %w", path, err)
236+ }
237+ return path, nil
238+}
239+
240+// applyEnv lets three variables override the file: AGENT_PROVIDER, AGENT_MODEL,
241+// and — resolved by the provider, see engine.Resolve — AGENT_BASE_URL. Same
242+// reasoning as for AGENT_CONFIG: switching engine for one run should not mean
243+// editing a file that is shown on screen.
244+func applyEnv() {
245+ if v := os.Getenv("AGENT_PROVIDER"); v != "" {
246+ Cfg.Provider = v
247+ }
248+ if v := os.Getenv("AGENT_MODEL"); v != "" {
249+ Cfg.Model = v
250+ }
251+}
252+
253+// validate catches the settings that would break the agent at runtime.
254+func (c Config) validate() error {
255+ switch {
256+ case c.Provider == "":
257+ return errors.New("provider must not be empty")
258+ case c.Model == "":
259+ return errors.New("model must not be empty")
260+ case c.ContextWindow < 0:
261+ return errors.New("contextWindow must be >= 0")
262+ case c.MaxOutput <= 0:
263+ return errors.New("maxOutput must be > 0")
264+ case c.MaxTurns <= 0:
265+ return errors.New("maxTurns must be > 0")
266+ case c.PreviewLines < 0:
267+ return errors.New("previewLines must be >= 0")
268+ case c.Context.Threshold < 1 || c.Context.Threshold > 100:
269+ return errors.New("context.threshold must be between 1 and 100")
270+ case c.Context.KeepLastTurns < 1:
271+ return errors.New("context.keepLastTurns must be >= 1")
272+ case c.Context.SummaryMaxTokens <= 0:
273+ return errors.New("context.summaryMaxTokens must be > 0")
274+ case c.Context.MaxMessages < 0:
275+ return errors.New("context.maxMessages must be >= 0")
276+ }
277+ // `enabled: true` with no window and no maxMessages would never trigger —
278+ // but the window may still come from the server's probe at start-up, so
279+ // that case is a warning in main, not an error here.
280+ return nil
281+}
new file mode 100644
@@ -0,0 +1,281 @@
1+// Package config holds every setting of the agent in ONE place.
2+//
3+// The values below are the built-in defaults; a YAML file overrides the keys it
4+// mentions and leaves the others alone. That way the agent still runs with no
5+// config file at all, and a single `agent.yaml` is enough to change the model,
6+// the system prompt or the sampling — no recompilation.
7+package config
8+
9+import (
10+ "errors"
11+ "fmt"
12+ "io/fs"
13+ "os"
14+ "time"
15+
16+ "gopkg.in/yaml.v3"
17+)
18+
19+// Config: everything that can be tuned without touching the code.
20+type Config struct {
21+ // Provider: which LLM server is behind BaseURL — a key of the engine
22+ // registry ("dmr", "llamacpp"). Defaults to "dmr" so that every agent.yaml
23+ // written before this key existed keeps its meaning.
24+ Provider string `yaml:"provider"`
25+
26+ // Model: identifier of the chat model, in the provider's own naming
27+ // (DMR: "ai/qwen2.5-coder", "hf.co/…"; llama-server: the alias it serves).
28+ Model string `yaml:"model"`
29+
30+ // BaseURL: the OpenAI-compatible endpoint, tried first. Empty = the
31+ // provider's default, so a llama.cpp config does not inherit DMR's port.
32+ BaseURL string `yaml:"baseUrl"`
33+
34+ // Fallback: used when BaseURL does not answer — on the host DMR sits on
35+ // localhost, but from inside a container or a sandbox it is reachable as
36+ // host.docker.internal. A pointer, because "absent" and "" differ: absent
37+ // means the provider's default, "" means no fallback at all (the fake
38+ // engine's probe.yaml relies on that to stay pinned to one URL).
39+ Fallback *string `yaml:"fallback"`
40+
41+ // APIKeyEnv: the NAME of the environment variable holding the key — never
42+ // the key itself, so agent.yaml can be committed. Empty = the provider's
43+ // default variable; servers that ignore the key need none.
44+ APIKeyEnv string `yaml:"apiKeyEnv"`
45+
46+ // ContextWindow: how many tokens the server actually serves, when the
47+ // operator knows it (llama-server -c, `docker model configure`). 0 = ask the
48+ // server, then unknown. Displayed at start-up; the context-compression work
49+ // reads it.
50+ ContextWindow int `yaml:"contextWindow"`
51+
52+ // MaxOutput: max number of characters a tool returns to the model
53+ // (context safeguard: large outputs are truncated).
54+ MaxOutput int `yaml:"maxOutput"`
55+
56+ // MaxTurns: max number of model ↔ tools round trips for one question.
57+ MaxTurns int `yaml:"maxTurns"`
58+
59+ // PreviewLines: how many lines of a command's output are echoed to the
60+ // terminal. The user asked to SEE something; running the command is not
61+ // showing it. 0 disables the echo.
62+ PreviewLines int `yaml:"previewLines"`
63+
64+ // DisplayCommands: recap, after the answer, the list of commands the agent
65+ // actually ran. The count alone says HOW MUCH it worked; the list says
66+ // WHAT it did — worth showing on a screen, noise in a log.
67+ DisplayCommands bool `yaml:"displayCommands"`
68+
69+ // System: the system prompt — what the agent is, and what it may do.
70+ System string `yaml:"system"`
71+
72+ // SkillsDir: directory of markdown procedures exposed by the `read_skill`
73+ // tool. When it holds no *.md file, the tool is not declared at all.
74+ SkillsDir string `yaml:"skillsDir"`
75+
76+ // EditTools: declare the built-in file tools (read_file, write_file,
77+ // edit_file). Off, the agent has bash and read_skill only, exactly like
78+ // part 09 — and edits files through the `edit` CLI if it is on the PATH.
79+ // One binary, two set-ups: that is what lets the two be compared.
80+ EditTools bool `yaml:"editTools"`
81+
82+ // Sampling: generation settings (OpenAI API keys; the plugin converts them.)
83+ Sampling map[string]any `yaml:"sampling"`
84+
85+ // WatchdogTimeout: how long to wait for new tokens before assuming the connection has hung.
86+ WatchdogTimeout time.Duration `yaml:"watchdogTimeout"`
87+
88+ // Context: when and how the conversation history is compressed
89+ // (internal/compact). Off unless `context.enabled: true`. The window it
90+ // measures against is the top-level ContextWindow above — or, when that is
91+ // 0, what the provider's probe learned from the server.
92+ Context ContextConfig `yaml:"context"`
93+}
94+
95+// ContextConfig drives the compression of the history (see
96+// 08-context-compression/CONTEXT_WINDOW.md; the code is the same as 08's).
97+//
98+// Off by default: with `enabled: false` this part behaves exactly as before.
99+// Nothing in the agent shortens the history otherwise — measured with the fake
100+// engine of part 03, it grew 2 → 5 → 7 → 9 messages over four requests, and on
101+// a local model the window is fixed at load time.
102+//
103+// Unlike 08 there is no window here: `contextWindow` is ONE key, at the top
104+// level, shared by the banner and the trigger — two keys for the same number
105+// would drift apart.
106+type ContextConfig struct {
107+ // Enabled turns the automatic compression on. `/compact` works regardless.
108+ Enabled bool `yaml:"enabled"`
109+
110+ // Threshold: share of the context window, in percent, beyond which the
111+ // history is compressed before the next question is sent.
112+ Threshold int `yaml:"threshold"`
113+
114+ // MaxMessages: fallback trigger on the message count, for when the window
115+ // is unknown (no contextWindow, no /props) or the estimate is off. 0
116+ // disables it.
117+ MaxMessages int `yaml:"maxMessages"`
118+
119+ // KeepLastTurns: question turns kept raw at the end of the history.
120+ KeepLastTurns int `yaml:"keepLastTurns"`
121+
122+ // SummaryMaxTokens: max_tokens of the summary request.
123+ SummaryMaxTokens int `yaml:"summaryMaxTokens"`
124+
125+ // Prompt replaces the built-in summary prompt when not empty.
126+ Prompt string `yaml:"prompt"`
127+
128+ // ShowStats: print the one-line 🗜️ report after each compression.
129+ ShowStats bool `yaml:"showStats"`
130+}
131+
132+// DefaultPath: the config file looked up when neither the command line nor
133+// AGENT_CONFIG says otherwise.
134+const DefaultPath = "agent.yaml"
135+
136+// Cfg: the live settings, pre-filled with the built-in defaults.
137+//
138+// Defaults suited to a CODING AGENT — low temperature for precise and
139+// reproducible answers.
140+var Cfg = Config{
141+ Provider: "dmr",
142+ Model: "huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M",
143+ // BaseURL and Fallback are left empty on purpose: the provider fills them
144+ // in (for "dmr": localhost:12434 and host.docker.internal:12434, exactly the
145+ // values that used to be here).
146+ MaxOutput: 16000,
147+ MaxTurns: 10,
148+ PreviewLines: 20,
149+ // Off by default: the recap is a demo/debug aid, not something every run
150+ // needs. `displayCommands: true` in the YAML turns it on.
151+ DisplayCommands: false,
152+ SkillsDir: "skills",
153+ EditTools: true,
154+ System: `You are a coding agent working in a terminal.
155+You have a "bash" tool to run shell commands.
156+Use it to explore files, run tests, inspect the repository, etc.
157+Chain several commands if needed, then answer clearly in English.
158+
159+A request often mixes things you answer from yourself ("say hello") with things
160+only a command can answer ("list the files"). Handle every part, in the order
161+asked, and run a command for each part that needs one.
162+Never state the contents of a file, the output of a command, or the state of the
163+repository unless a command in THIS answer returned it. What you did not read,
164+you do not know: run the command instead of recalling it.
165+
166+BACKGROUND JOBS
167+Never let a command block the answer. Anything that serves, watches or runs
168+long goes to the background, with BOTH streams redirected and its pid kept:
169+
170+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
171+
172+Redirecting only stdout still blocks until the process exits. Read the
173+"bg-jobs" skill before you wait on, inspect or stop such a job — each has a
174+rule you cannot guess. Stop every job you started before you finish, and say
175+which ones you left running.`,
176+ Sampling: map[string]any{
177+ "temperature": 0.0,
178+ "top_p": 0.9,
179+ "max_tokens": 4096,
180+ },
181+ WatchdogTimeout: 20 * time.Second,
182+ Context: ContextConfig{
183+ Enabled: false,
184+ // 75 % leaves a quarter of the window for the next question, the tool
185+ // outputs of its turns and the answer: one `bash` output alone can be
186+ // maxOutput characters, about 4-5k tokens.
187+ Threshold: 75,
188+ // One command costs 2 messages (call + response): 80 is roughly 30
189+ // commands of history, the point where a 12B local model slows down.
190+ MaxMessages: 80,
191+ // The recent turns are where the model works; summarising them makes
192+ // it re-run what it just did.
193+ KeepLastTurns: 3,
194+ // Seven sections of one line per item fit in far less; the cap stops
195+ // a runaway model from filling the window it was asked to empty.
196+ SummaryMaxTokens: 1200,
197+ ShowStats: true,
198+ },
199+}
200+
201+// Load reads the YAML file on top of the defaults and returns the path actually
202+// used ("" when no file was found).
203+//
204+// Three places are tried, first one wins: the command line (cliPath, what main
205+// read from -config or from a lone argument), then AGENT_CONFIG, then
206+// ./agent.yaml. The command line wins over the environment because it is the
207+// more local of the two — closer to the run you are making right now.
208+//
209+// A path asked for EXPLICITLY (command line or AGENT_CONFIG) that does not
210+// exist is an error: you named a file, it should be there. A missing
211+// ./agent.yaml is not an error — the built-in defaults are enough to run.
212+func Load(cliPath string) (string, error) {
213+ path, explicit := cliPath, true
214+ if path == "" {
215+ path = os.Getenv("AGENT_CONFIG")
216+ }
217+ if path == "" {
218+ path, explicit = DefaultPath, false
219+ }
220+
221+ data, err := os.ReadFile(path)
222+ if errors.Is(err, fs.ErrNotExist) && !explicit {
223+ return "", nil // no file: the defaults stand
224+ }
225+ if err != nil {
226+ return "", err
227+ }
228+
229+ // Unmarshalling INTO Cfg: keys absent from the file keep their default.
230+ if err := yaml.Unmarshal(data, &Cfg); err != nil {
231+ return "", fmt.Errorf("%s: %w", path, err)
232+ }
233+ applyEnv()
234+ if err := Cfg.validate(); err != nil {
235+ return "", fmt.Errorf("%s: %w", path, err)
236+ }
237+ return path, nil
238+}
239+
240+// applyEnv lets three variables override the file: AGENT_PROVIDER, AGENT_MODEL,
241+// and — resolved by the provider, see engine.Resolve — AGENT_BASE_URL. Same
242+// reasoning as for AGENT_CONFIG: switching engine for one run should not mean
243+// editing a file that is shown on screen.
244+func applyEnv() {
245+ if v := os.Getenv("AGENT_PROVIDER"); v != "" {
246+ Cfg.Provider = v
247+ }
248+ if v := os.Getenv("AGENT_MODEL"); v != "" {
249+ Cfg.Model = v
250+ }
251+}
252+
253+// validate catches the settings that would break the agent at runtime.
254+func (c Config) validate() error {
255+ switch {
256+ case c.Provider == "":
257+ return errors.New("provider must not be empty")
258+ case c.Model == "":
259+ return errors.New("model must not be empty")
260+ case c.ContextWindow < 0:
261+ return errors.New("contextWindow must be >= 0")
262+ case c.MaxOutput <= 0:
263+ return errors.New("maxOutput must be > 0")
264+ case c.MaxTurns <= 0:
265+ return errors.New("maxTurns must be > 0")
266+ case c.PreviewLines < 0:
267+ return errors.New("previewLines must be >= 0")
268+ case c.Context.Threshold < 1 || c.Context.Threshold > 100:
269+ return errors.New("context.threshold must be between 1 and 100")
270+ case c.Context.KeepLastTurns < 1:
271+ return errors.New("context.keepLastTurns must be >= 1")
272+ case c.Context.SummaryMaxTokens <= 0:
273+ return errors.New("context.summaryMaxTokens must be > 0")
274+ case c.Context.MaxMessages < 0:
275+ return errors.New("context.maxMessages must be >= 0")
276+ }
277+ // `enabled: true` with no window and no maxMessages would never trigger —
278+ // but the window may still come from the server's probe at start-up, so
279+ // that case is a warning in main, not an error here.
280+ return nil
281+}
added internal/detector/detector.go +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+// Package detector provides a mechanism to detect repetitive patterns in agent actions.
2+package detector
3+
4+import (
5+ "sync"
6+)
7+
8+// Action represents a single step taken by the agent.
9+type Action struct {
10+ ToolName string
11+ Input string
12+ Output string
13+}
14+
15+// LoopDetector tracks the history of actions to detect repetitive cycles.
16+type LoopDetector struct {
17+ history []Action
18+ historyMu sync.Mutex
19+ maxHistory int
20+ threshold int
21+}
22+
23+// NewLoopDetector creates a new detector with a given history limit and threshold.
24+func NewLoopDetector(maxHistory, threshold int) *LoopDetector {
25+ return &LoopDetector{
26+ history: make([]Action, 0),
27+ maxHistory: maxHistory,
28+ threshold: threshold,
29+ }
30+}
31+
32+// Record adds an action to the history and returns true if a loop is detected.
33+func (d *LoopDetector) Record(action Action) bool {
34+ d.historyMu.Lock()
35+ defer d.historyMu.Unlock()
36+
37+ // Check if the current action (tool + input + output) has occurred consecutively.
38+ count := 0
39+ for i := len(d.history) - 1; i >= 0; i-- {
40+ if d.history[i].ToolName == action.ToolName &&
41+ d.history[i].Input == action.Input &&
42+ d.history[i].Output == action.Output {
43+ count++
44+ if count >= d.threshold {
45+ return true
46+ }
47+ } else {
48+ break
49+ }
50+ }
51+
52+ // Append the new action.
53+ d.history = append(d.history, action)
54+ if len(d.history) > d.maxHistory {
55+ d.history = d.history[1:]
56+ }
57+
58+ return false
59+}
60+
61+// Reset forgets every recorded action and keeps the settings. It is what the
62+// /new command calls: a new session is a new task, and a command the model
63+// repeated in the previous one must not be flagged as a loop in this one.
64+//
65+// d := detector.NewLoopDetector(10, 3)
66+// // ... a session's worth of Record calls ...
67+// d.Reset() // start the count again
68+func (d *LoopDetector) Reset() {
69+ d.historyMu.Lock()
70+ defer d.historyMu.Unlock()
71+ d.history = d.history[:0]
72+}
73+
74+// LoopError returns a standardized error message when a loop is detected.
75+func (d *LoopDetector) LoopError() string {
76+ return "[LOOP_DETECTED: You have attempted the same action multiple times with the same result. Please try a different approach or provide more information.]"
77+}
new file mode 100644
@@ -0,0 +1,77 @@
1+// Package detector provides a mechanism to detect repetitive patterns in agent actions.
2+package detector
3+
4+import (
5+ "sync"
6+)
7+
8+// Action represents a single step taken by the agent.
9+type Action struct {
10+ ToolName string
11+ Input string
12+ Output string
13+}
14+
15+// LoopDetector tracks the history of actions to detect repetitive cycles.
16+type LoopDetector struct {
17+ history []Action
18+ historyMu sync.Mutex
19+ maxHistory int
20+ threshold int
21+}
22+
23+// NewLoopDetector creates a new detector with a given history limit and threshold.
24+func NewLoopDetector(maxHistory, threshold int) *LoopDetector {
25+ return &LoopDetector{
26+ history: make([]Action, 0),
27+ maxHistory: maxHistory,
28+ threshold: threshold,
29+ }
30+}
31+
32+// Record adds an action to the history and returns true if a loop is detected.
33+func (d *LoopDetector) Record(action Action) bool {
34+ d.historyMu.Lock()
35+ defer d.historyMu.Unlock()
36+
37+ // Check if the current action (tool + input + output) has occurred consecutively.
38+ count := 0
39+ for i := len(d.history) - 1; i >= 0; i-- {
40+ if d.history[i].ToolName == action.ToolName &&
41+ d.history[i].Input == action.Input &&
42+ d.history[i].Output == action.Output {
43+ count++
44+ if count >= d.threshold {
45+ return true
46+ }
47+ } else {
48+ break
49+ }
50+ }
51+
52+ // Append the new action.
53+ d.history = append(d.history, action)
54+ if len(d.history) > d.maxHistory {
55+ d.history = d.history[1:]
56+ }
57+
58+ return false
59+}
60+
61+// Reset forgets every recorded action and keeps the settings. It is what the
62+// /new command calls: a new session is a new task, and a command the model
63+// repeated in the previous one must not be flagged as a loop in this one.
64+//
65+// d := detector.NewLoopDetector(10, 3)
66+// // ... a session's worth of Record calls ...
67+// d.Reset() // start the count again
68+func (d *LoopDetector) Reset() {
69+ d.historyMu.Lock()
70+ defer d.historyMu.Unlock()
71+ d.history = d.history[:0]
72+}
73+
74+// LoopError returns a standardized error message when a loop is detected.
75+func (d *LoopDetector) LoopError() string {
76+ return "[LOOP_DETECTED: You have attempted the same action multiple times with the same result. Please try a different approach or provide more information.]"
77+}
added internal/detector/detector_test.go +57 -0
new file mode 100644
@@ -0,0 +1,57 @@
1+package detector
2+
3+import (
4+ "fmt"
5+ "testing"
6+)
7+
8+func same() Action {
9+ return Action{ToolName: "bash", Input: "ls", Output: "[tool_call]"}
10+}
11+
12+// Reset forgets what the detector saw: a command repeated up to the threshold
13+// is flagged, and after /new the same command counts from zero again — the
14+// new session is a new task, and the old repetitions belong to the old one.
15+func TestResetForgetsTheRecordedActions(t *testing.T) {
16+ d := NewLoopDetector(10, 3)
17+ for i := 0; i < 3; i++ {
18+ if d.Record(same()) {
19+ t.Fatalf("loop flagged after %d identical action(s), threshold is 3", i+1)
20+ }
21+ }
22+ if !d.Record(same()) {
23+ t.Fatal("4th identical action not flagged as a loop")
24+ }
25+
26+ d.Reset()
27+
28+ for i := 0; i < 3; i++ {
29+ if d.Record(same()) {
30+ t.Fatalf("after Reset, loop flagged after %d identical action(s)", i+1)
31+ }
32+ }
33+}
34+
35+// Reset keeps the detector's settings: the threshold still applies afterwards.
36+func TestResetKeepsThreshold(t *testing.T) {
37+ d := NewLoopDetector(10, 2)
38+ d.Reset()
39+ d.Record(same())
40+ d.Record(same())
41+ if !d.Record(same()) {
42+ t.Fatal("after Reset, the threshold of 2 no longer applies")
43+ }
44+}
45+
46+func ExampleLoopDetector_Reset() {
47+ d := NewLoopDetector(10, 2)
48+ a := Action{ToolName: "bash", Input: "ls", Output: "[tool_call]"}
49+ d.Record(a)
50+ d.Record(a)
51+ fmt.Println(d.Record(a)) // third time in a row: a loop
52+ d.Reset()
53+ fmt.Println(d.Record(a)) // a new session: counted from zero
54+ // Output:
55+ // true
56+ // false
57+}
new file mode 100644
@@ -0,0 +1,57 @@
1+package detector
2+
3+import (
4+ "fmt"
5+ "testing"
6+)
7+
8+func same() Action {
9+ return Action{ToolName: "bash", Input: "ls", Output: "[tool_call]"}
10+}
11+
12+// Reset forgets what the detector saw: a command repeated up to the threshold
13+// is flagged, and after /new the same command counts from zero again — the
14+// new session is a new task, and the old repetitions belong to the old one.
15+func TestResetForgetsTheRecordedActions(t *testing.T) {
16+ d := NewLoopDetector(10, 3)
17+ for i := 0; i < 3; i++ {
18+ if d.Record(same()) {
19+ t.Fatalf("loop flagged after %d identical action(s), threshold is 3", i+1)
20+ }
21+ }
22+ if !d.Record(same()) {
23+ t.Fatal("4th identical action not flagged as a loop")
24+ }
25+
26+ d.Reset()
27+
28+ for i := 0; i < 3; i++ {
29+ if d.Record(same()) {
30+ t.Fatalf("after Reset, loop flagged after %d identical action(s)", i+1)
31+ }
32+ }
33+}
34+
35+// Reset keeps the detector's settings: the threshold still applies afterwards.
36+func TestResetKeepsThreshold(t *testing.T) {
37+ d := NewLoopDetector(10, 2)
38+ d.Reset()
39+ d.Record(same())
40+ d.Record(same())
41+ if !d.Record(same()) {
42+ t.Fatal("after Reset, the threshold of 2 no longer applies")
43+ }
44+}
45+
46+func ExampleLoopDetector_Reset() {
47+ d := NewLoopDetector(10, 2)
48+ a := Action{ToolName: "bash", Input: "ls", Output: "[tool_call]"}
49+ d.Record(a)
50+ d.Record(a)
51+ fmt.Println(d.Record(a)) // third time in a row: a loop
52+ d.Reset()
53+ fmt.Println(d.Record(a)) // a new session: counted from zero
54+ // Output:
55+ // true
56+ // false
57+}
added internal/engine/engine.go +622 -0
new file mode 100644
@@ -0,0 +1,622 @@
1+// Package engine wraps the connection to the LLM engine — Docker Model Runner,
2+// llama-server, or any other Provider of the registry — and the generation of
3+// answers. Everything here is engine-agnostic: the only lines that know WHICH
4+// server is behind the URL live in the Provider implementations (see
5+// provider.go and openai_compat.go). This package used to be called `dmr`;
6+// measured before the split, exactly one function (Init) and two string
7+// constants were specific to Docker Model Runner. The rest — the whitespace
8+// gap, the watchdog, the retry, the command counter — already behaved the same
9+// against the fake engine, which is not DMR either.
10+package engine
11+
12+import (
13+ "context"
14+ "encoding/json"
15+ "fmt"
16+ "maps"
17+ "slices"
18+ "strings"
19+ "sync"
20+ "sync/atomic"
21+ "time"
22+
23+ "mm/internal/config"
24+ "mm/internal/spinner"
25+ "mm/internal/ui"
26+
27+ "github.com/firebase/genkit/go/ai"
28+ "github.com/firebase/genkit/go/genkit"
29+)
30+
31+// Engine is what main.go hands to the agent and to the tools: an initialised
32+// Genkit, the FULL model reference Genkit expects ("dmr/<id>",
33+// "llamacpp/<id>"), and the provider that built it — kept around for the one
34+// thing that stays provider-specific after start-up: explaining an error in one
35+// line (see Explain).
36+type Engine struct {
37+ G *genkit.Genkit
38+ Model string
39+ Provider Provider
40+ Backend Backend
41+
42+ // ContextWindow is the served context size as far as the agent knows it:
43+ // the config's hint, or what Probe learned from the server (llama-server's
44+ // /props). 0 = unknown. The context compression (internal/compact) reads
45+ // it; the banner shows it with its origin, because a number without an
46+ // origin never gets corrected.
47+ ContextWindow int
48+
49+ // lastInput is the input-token count the server reported on the most
50+ // recent call to the model, captured in Generate's WrapModel hook. It is
51+ // the exact figure the compression trigger prefers over its estimate.
52+ lastInput atomic.Int64
53+}
54+
55+// New reads the provider named in the config, resolves its backend (URL, key,
56+// model) and opens it. Unknown providers are refused HERE, with the list of
57+// known names: a typo in `provider:` must not turn into a "model not found"
58+// twenty seconds later, once the model has tried to load.
59+func New(ctx context.Context) (*Engine, error) {
60+ p, err := Lookup(config.Cfg.Provider)
61+ if err != nil {
62+ return nil, err
63+ }
64+ b, err := p.Resolve(config.Cfg)
65+ if err != nil {
66+ return nil, err
67+ }
68+ g, model, err := p.Open(ctx, b)
69+ if err != nil {
70+ return nil, err
71+ }
72+ return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow}, nil
73+}
74+
75+// Probe asks the provider what it can learn about the server BEFORE the first
76+// question: reachability, the context window it serves, a model that is not
77+// pulled. Never fatal — the user may well start the server after the agent.
78+//
79+// The context window it learns is remembered on the Engine. The provider has
80+// already applied the precedence — the config's value wins over the server's,
81+// because the operator knows what they started the server with — so what is
82+// stored here is the one number the whole agent reasons with.
83+func (e *Engine) Probe(ctx context.Context) Info {
84+ info := e.Provider.Probe(ctx, e.Backend)
85+ if info.ContextWindow > 0 {
86+ e.ContextWindow = info.ContextWindow
87+ }
88+ return info
89+}
90+
91+// EnsureContextWindow re-runs the provider's probe when the window is still
92+// unknown, and reports what it learned: the window and its origin ("/props"),
93+// or 0 and "" when the server still tells nothing.
94+//
95+// Why: the start-up probe runs once (main.go), and on a demo machine the
96+// server is often started AFTER the agent — observed: bob started before
97+// llama-server left `ctx: unknown` for the whole session, so the compression
98+// could only ever trigger on maxMessages. Asking again, lazily, fixes that
99+// without a background poller. Cheap by construction: the caller only asks
100+// while the window is unknown, and a known window is returned as-is, without
101+// a request — a `/props` hit per question would be noise the server does not
102+// deserve. Warnings are deliberately dropped here: the start-up probe already
103+// printed them, and repeating "nothing answers" at every question is nagging.
104+func (e *Engine) EnsureContextWindow(ctx context.Context) (window int, source string) {
105+ if e.ContextWindow > 0 {
106+ return e.ContextWindow, ""
107+ }
108+ info := e.Provider.Probe(ctx, e.Backend)
109+ if info.ContextWindow > 0 {
110+ e.ContextWindow = info.ContextWindow
111+ return info.ContextWindow, info.ContextSource
112+ }
113+ return 0, ""
114+}
115+
116+// LastInputTokens returns the server's own count of the context it last read,
117+// or 0 when it reports no usage (the fake engine of part 03, some proxies).
118+func (e *Engine) LastInputTokens() int { return int(e.lastInput.Load()) }
119+
120+// ForgetInputTokens clears that count. Called after a compression: the measure
121+// described the history that was just replaced, and keeping it would trigger a
122+// second compression on the very next question.
123+func (e *Engine) ForgetInputTokens() { e.lastInput.Store(0) }
124+
125+// Explain turns a transport error into the one-line, actionable message the
126+// REPL prints between brackets. See Provider.Explain for the rationale.
127+func (e *Engine) Explain(err error) string {
128+ return e.Provider.Explain(err, e.Backend)
129+}
130+
131+// blanks is the set of characters the model adds around its text without any of
132+// it showing on screen.
133+const blanks = " \t\r\n"
134+
135+// gap holds back the TRAILING whitespace of the streamed text: it is only
136+// printed if visible text follows. Without that, the line breaks the model puts
137+// after its sentence are printed as-is, and the screen hollows out.
138+//
139+// The model produces MORE AND MORE of it as the turns go by — it imitates its
140+// own messages, which it re-reads in the history every turn. Measured: 2 blank
141+// lines before the first 🛠️, 4 before the second, 7, then 11. Holding the
142+// whitespace back is enough to remove all of it, since that whitespace is never
143+// followed by text: it is a tool that writes next.
144+type gap struct {
145+ // The `bash` tool may call drop() from SEVERAL goroutines — Genkit runs the
146+ // calls of a single turn in parallel (see printMu in tools).
147+ mu sync.Mutex
148+ held string // whitespace waiting to be followed by text
149+ started bool // visible text has already been printed
150+}
151+
152+// next returns what should be printed for this chunk, "" when there is nothing
153+// to print right now.
154+func (g *gap) next(text string) string {
155+ g.mu.Lock()
156+ defer g.mu.Unlock()
157+
158+ body := strings.TrimRight(text, blanks)
159+ if body == "" { // an all-whitespace chunk: set aside, not printed
160+ if g.started {
161+ g.held += text
162+ }
163+ return ""
164+ }
165+
166+ // The chunk splits in three: leading whitespace, text, trailing whitespace.
167+ tail := text[len(body):]
168+ visible := strings.TrimLeft(body, blanks)
169+ lead := body[:len(body)-len(visible)]
170+
171+ out := ""
172+ if g.started { // before the first word, all whitespace is dropped
173+ out = squeeze(g.held + lead)
174+ }
175+ g.held, g.started = tail, true
176+ return out + visible
177+}
178+
179+// drop forgets the pending whitespace. Called when someone ELSE is about to
180+// write — a tool announcing its command: that whitespace belonged to the text
181+// before it, and printing it now would reopen the hole we just closed.
182+func (g *gap) drop() {
183+ g.mu.Lock()
184+ g.held = ""
185+ g.mu.Unlock()
186+}
187+
188+// squeeze brings any run of line breaks down to two, that is ONE blank line at
189+// most. Spaces are left alone: they carry the indentation of a code block, and
190+// cutting it would shift the displayed code.
191+func squeeze(s string) string {
192+ var b strings.Builder
193+ run := 0
194+ for _, r := range s {
195+ if r == '\n' {
196+ run++
197+ if run > 2 {
198+ continue
199+ }
200+ } else {
201+ run = 0
202+ }
203+ b.WriteRune(r)
204+ }
205+ return b.String()
206+}
207+
208+// Generate queries the model, printing the answer as it comes (streaming). Some
209+// OpenAI-compatible servers sometimes cut the SSE stream abruptly ("unexpected
210+// end of JSON input"): if nothing has been printed AND no command has run yet,
211+// we retry once WITHOUT streaming — which is more robust. The "no command"
212+// condition matters: see the comment on the retry below.
213+//
214+// It returns the FULL conversation alongside the response — every intermediate
215+// turn included — because Genkit only hands back the last message. The caller
216+// needs the rest, or the agent forgets the commands it just ran.
217+//
218+// A spinner covers every moment where the program is waiting rather than
219+// printing. On a local engine the first of those is long — the model may still
220+// be loading, and the whole prompt has to be processed before a single token
221+// comes back — and without it the agent looks hung.
222+//
223+// Watchdog: if no tokens arrive for a certain duration, we assume the connection
224+// is hung and we cancel the context. This is the "silent loop" of
225+// STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one.
226+func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
227+ // Genkit only hands us back the LAST message. The intermediate turns — "I
228+ // am calling bash", "here is the output" — exist only in the request it
229+ // builds for the next turn. This middleware captures that request on its way
230+ // through: the last turn seen therefore holds the whole conversation.
231+ //
232+ // The `WrapModel` hook wraps every call to the model — one per turn — so the
233+ // last request seen is the most complete one. `MiddlewareFunc` adapts a
234+ // closure to the `ai.Middleware` contract without going through a plugin:
235+ // `New` is called once per `Generate`, which keeps `full` captured at the
236+ // right level.
237+ var full []*ai.Message
238+ var gaps gap
239+ capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) {
240+ return &ai.Hooks{
241+ WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) {
242+ full = slices.Clone(params.Request.Messages)
243+ resp, err := next(ctx, params)
244+ // The server's own count of what it just read: the one exact
245+ // measure of the context size, kept for the compression
246+ // trigger. Zero means the server reported nothing.
247+ if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 {
248+ e.lastInput.Store(int64(resp.Usage.InputTokens))
249+ }
250+ return resp, err
251+ },
252+ // The tool is about to write to the screen: we forget the
253+ // whitespace the previous text left pending. The hook lands at
254+ // exactly the right moment — just before the tool prints its 🛠️
255+ // line.
256+ WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {
257+ gaps.drop()
258+ return next(ctx, params)
259+ },
260+ }, nil
261+ })
262+
263+ opts := []ai.GenerateOption{
264+ ai.WithModelName(e.Model),
265+ ai.WithMessages(messages...),
266+ ai.WithTools(tools...),
267+ ai.WithMaxTurns(config.Cfg.MaxTurns), // max model ↔ tools round trips
268+ ai.WithConfig(config.Cfg.Sampling),
269+ ai.WithUse(capture),
270+ }
271+
272+ // The prefix is printed by the first token rather than up front, so the
273+ // spinner has a line to itself and vanishes without taking anything with it.
274+ printed := false
275+ emit := func(text string) {
276+ // An event-consuming front end (ACP) takes the chunk raw: the editor
277+ // renders Markdown and owns the layout, so the gap/squeeze machinery —
278+ // which fights the terminal screen, not the text — stays out of the
279+ // way. `printed` is still maintained: the no-streaming retry below must
280+ // know whether the client already saw part of an answer.
281+ if s := ui.ActiveSink(); s != nil {
282+ if text == "" {
283+ return
284+ }
285+ printed = true
286+ s.Text(text)
287+ return
288+ }
289+ // Trailing whitespace is held back, not printed: see the gap type.
290+ out := gaps.next(text)
291+ if out == "" {
292+ return
293+ }
294+ // Stop BEFORE every print, not only the first one.
295+ //
296+ // A question can take several turns, and the tool restarts the spinner
297+ // on its way back to the model. The text of the next turn then landed on
298+ // the spinner's line, which erased it on the following frame
299+ // (`\r\033[2K`): "Hello 👋 to Sam" became "⠦ Thinking… 0s 👋 to Sam".
300+ // Worse, the last sentence of an answer vanished entirely, erased by the
301+ // final Stop().
302+ //
303+ // Stop() is idempotent and returns immediately when nothing is running,
304+ // so calling it on every chunk costs nothing.
305+ spinner.Stop()
306+ if !printed {
307+ fmt.Print("🤖 ")
308+ printed = true
309+ }
310+ fmt.Print(out)
311+ }
312+
313+ // --- Watchdog ---
314+ // A sub-context the watchdog cancels when no token has arrived for
315+ // WatchdogTimeout; the streaming callback beats it on every chunk.
316+ genCtx, beat, cancelGen := watchdog(ctx, config.Cfg.WatchdogTimeout)
317+ defer cancelGen()
318+
319+ spinner.Start("Thinking")
320+ defer spinner.Stop()
321+
322+ resp, err := genkit.Generate(genCtx, e.G, append(opts,
323+ ai.WithStreaming(func(_ context.Context, chunk *ai.ModelResponseChunk) error {
324+ beat()
325+ emit(chunk.Text())
326+ return nil
327+ }),
328+ )...)
329+
330+ // `full` grows as soon as a tool turn has happened: it is the witness that
331+ // says whether commands ran, and it is used twice below.
332+ toolsRan := len(full) > len(messages)
333+
334+ // The watchdog cut `genCtx` while the PARENT context is intact: this is a
335+ // silence of the connection, not a cancellation by the user.
336+ //
337+ // We question the contexts rather than the error. Testing
338+ // `err == context.Canceled` does not work: Genkit wraps the error, the
339+ // comparison fails, and the turn then went off into the non-streamed retry —
340+ // that is, it called the MUTE engine again. Measured: 5 s of watchdog then
341+ // 20 s of "Retrying without streaming…" against a silent server, and the
342+ // "stalled" message never shown.
343+ stalled := genCtx.Err() != nil && ctx.Err() == nil
344+ if err != nil && stalled {
345+ // We return `full` despite the failure: if commands ran before the
346+ // silence, their work must not be lost.
347+ return nil, full, fmt.Errorf("generation timed out (stalled)")
348+ }
349+
350+ // The retry is only worth it for an SSE stream cut before the first byte.
351+ // Any other error (maxTurns exceeded, a failing tool, invalid arguments)
352+ // happens AFTER commands have run, and replaying the turn would replay them:
353+ // measured, an `echo >>` wrote itself twice. `full` grows as soon as a tool
354+ // turn has happened, which is enough to tell the two apart.
355+ if err != nil && !printed && !toolsRan {
356+ spinner.Start("Retrying without streaming")
357+ if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {
358+ emit(resp.Text())
359+ }
360+ }
361+
362+ // `full` stops at the last call to the model; its answer is appended after.
363+ if resp != nil && resp.Message != nil {
364+ full = append(full, resp.Message)
365+ }
366+ return resp, full, err
367+}
368+
369+// watchdog returns a child of ctx that is cancelled when beat() has not been
370+// called for `timeout`. This is the "silent loop" of STALL_DETECTION_PROBLEM.md
371+// — a liveness failure, not a behavioural one — and it is shared by Generate
372+// and Summarize so that both fail the same way.
373+//
374+// The timestamp is atomic because beat() runs on the streaming goroutine while
375+// the ticker reads it from another.
376+func watchdog(ctx context.Context, timeout time.Duration) (genCtx context.Context, beat func(), cancel context.CancelFunc) {
377+ var last atomic.Int64 // UnixNano
378+ last.Store(time.Now().UnixNano())
379+ genCtx, cancel = context.WithCancel(ctx)
380+
381+ go func() {
382+ ticker := time.NewTicker(1 * time.Second)
383+ defer ticker.Stop()
384+ for {
385+ select {
386+ case <-genCtx.Done():
387+ return
388+ case <-ticker.C:
389+ if time.Since(time.Unix(0, last.Load())) > timeout {
390+ // Stop() before printing, like everything else: without it
391+ // the watchdog message lands on the spinner's line and the
392+ // next frame erases it — the diagnosis would vanish at the
393+ // very moment it is needed.
394+ spinner.Stop()
395+ // ui.Out, not stdout: in ACP mode this diagnosis belongs to
396+ // the logs (stderr), never to the JSON-RPC stream.
397+ fmt.Fprintf(ui.Out, "\n[watchdog: connection hang detected after %v]\n", timeout)
398+ cancel()
399+ return
400+ }
401+ }
402+ }
403+ }()
404+
405+ return genCtx, func() { last.Store(time.Now().UnixNano()) }, cancel
406+}
407+
408+// Summarize asks the model for a plain-text answer to `messages` — no tools,
409+// no screen output. It exists for the context compression (internal/compact):
410+// the summary must not stream onto the screen as if it were an answer, and a
411+// request that offers tools invites the model to call them instead of writing.
412+// It goes through the same Genkit and the same model reference as Generate,
413+// so it lands on the same provider and the same server.
414+//
415+// The watchdog is kept, with twice the usual patience: this is the longest
416+// prefill of the session (the whole old history at once) and nothing streams
417+// until it is done. Streaming stays on so that the summary's own tokens beat
418+// the watchdog while it is being written.
419+func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error) {
420+ // The agent's sampling, minus what does not apply here: `parallel_tool_calls`
421+ // without tools is a 400 on the OpenAI API, and `max_tokens` is the summary's
422+ // budget, not the answer's.
423+ sampling := maps.Clone(config.Cfg.Sampling)
424+ if sampling == nil {
425+ sampling = map[string]any{}
426+ }
427+ delete(sampling, "parallel_tool_calls")
428+ sampling["max_tokens"] = maxTokens
429+
430+ genCtx, beat, cancel := watchdog(ctx, 2*config.Cfg.WatchdogTimeout)
431+ defer cancel()
432+
433+ resp, err := genkit.Generate(genCtx, e.G,
434+ ai.WithModelName(e.Model),
435+ ai.WithMessages(messages...),
436+ ai.WithConfig(sampling),
437+ ai.WithStreaming(func(_ context.Context, _ *ai.ModelResponseChunk) error {
438+ beat()
439+ return nil
440+ }),
441+ )
442+ if err != nil {
443+ // Same test as in Generate: the contexts, not the wrapped error.
444+ if genCtx.Err() != nil && ctx.Err() == nil {
445+ return "", fmt.Errorf("summary timed out (stalled)")
446+ }
447+ return "", err
448+ }
449+ text := strings.TrimSpace(resp.Text())
450+ if text == "" {
451+ return "", fmt.Errorf("the model returned an empty summary")
452+ }
453+ return text, nil
454+}
455+
456+// Names of the tools whose responses we count. They live here rather than
457+// scattered through the display code: the counter is a measurement, and a
458+// measurement needs a single definition.
459+const (
460+ toolBash = "bash"
461+ toolSkill = "read_skill"
462+ toolRead = "read_file"
463+ toolWrite = "write_file"
464+ toolEdit = "edit_file"
465+)
466+
467+// executed returns, in order, the text of the calls to a given tool that
468+// ACTUALLY ran.
469+//
470+// We start from the RESPONSES: a call the model asked for but which never ran
471+// (interrupted turn, maxTurns exceeded) produces none. The text of the call, on
472+// the other hand, is ONLY in the request — hence the pairing by Ref, the
473+// identifier the server gives to each call.
474+func executed(history []*ai.Message, name string) []string {
475+ // Calls with no Ref (some servers do not set one) are paired in order, BUT
476+ // only against the same tool name: a turn mixes `bash` and `read_skill`, and
477+ // the first pending call is not necessarily for the right tool.
478+ type call struct{ ref, name, text string }
479+ var pending []call
480+ var out []string
481+
482+ for _, m := range history {
483+ for _, p := range m.Content {
484+ switch {
485+ case p.IsToolRequest():
486+ pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name, commandText(p.ToolRequest)})
487+
488+ case p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == name:
489+ ref := p.ToolResponse.Ref
490+ i := slices.IndexFunc(pending, func(c call) bool {
491+ if ref != "" {
492+ return c.ref == ref
493+ }
494+ return c.name == name
495+ })
496+ if i < 0 { // lost call: the name beats nothing
497+ out = append(out, name)
498+ continue
499+ }
500+ out = append(out, pending[i].text)
501+ pending = slices.Delete(pending, i, i+1)
502+ }
503+ }
504+ }
505+ return out
506+}
507+
508+// commandText describes a tool call in one line. For `bash` it is the command
509+// itself — the rest is only wrapping; for any other tool, its name followed by
510+// its arguments.
511+func commandText(r *ai.ToolRequest) string {
512+ if in, ok := r.Input.(map[string]any); ok {
513+ if c, ok := in["command"].(string); ok && strings.TrimSpace(c) != "" {
514+ return strings.TrimSpace(c)
515+ }
516+ }
517+ args, err := json.Marshal(r.Input)
518+ if err != nil || string(args) == "null" {
519+ return r.Name
520+ }
521+ return r.Name + " " + string(args)
522+}
523+
524+// responses counts the responses of a given tool in a history.
525+func responses(history []*ai.Message, name string) int {
526+ return len(executed(history, name))
527+}
528+
529+// Commands counts the commands actually run in a history — one per response of
530+// the `bash` tool. It is the only reliable measure of the work done: a model
531+// that tells stories without acting produces a fine answer and a count of zero.
532+//
533+// The other tools do NOT count. Loading a skill runs nothing, and counting those
534+// reads would make the number say the opposite of what it is for: measured
535+// before the fix, a turn with two reads and a single command displayed
536+// "⚙ 3 command(s)".
537+func Commands(history []*ai.Message) int {
538+ return responses(history, toolBash)
539+}
540+
541+// CommandList returns, in order, the text of the commands Commands counts — the
542+// one shown in the recap under the count when `displayCommands` is on. The count
543+// says HOW MANY, the list says WHAT.
544+func CommandList(history []*ai.Message) []string {
545+ return executed(history, toolBash)
546+}
547+
548+// FileOps returns, in order, the file-tool calls that ran — read_file,
549+// write_file, edit_file — as one line each ("edit_file hello/main.go (2 edits)").
550+//
551+// They are counted APART from the commands, not folded into them: this part
552+// compares editing through the `edit` CLI (one bash command per edit) with
553+// editing through built-in tools, and a recap that counted both as "commands"
554+// would hide the very difference being measured. `⚙ 3 command(s) · 📝 2 file
555+// op(s)` keeps the two columns readable side by side.
556+func FileOps(history []*ai.Message) []string {
557+ return executedAny(history, toolRead, toolWrite, toolEdit)
558+}
559+
560+// executedAny is executed() across several tool names, keeping the order in
561+// which the responses appear — a per-name pass would lose the interleaving of
562+// a read followed by an edit.
563+func executedAny(history []*ai.Message, names ...string) []string {
564+ type call struct{ ref, name, text string }
565+ var pending []call
566+ var out []string
567+ isOurs := func(n string) bool { return slices.Contains(names, n) }
568+
569+ for _, m := range history {
570+ for _, p := range m.Content {
571+ switch {
572+ case p.IsToolRequest():
573+ pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name, fileOpText(p.ToolRequest)})
574+ case p.IsToolResponse() && p.ToolResponse != nil && isOurs(p.ToolResponse.Name):
575+ ref, name := p.ToolResponse.Ref, p.ToolResponse.Name
576+ i := slices.IndexFunc(pending, func(c call) bool {
577+ if ref != "" {
578+ return c.ref == ref
579+ }
580+ return c.name == name
581+ })
582+ if i < 0 {
583+ out = append(out, name)
584+ continue
585+ }
586+ out = append(out, pending[i].text)
587+ pending = slices.Delete(pending, i, i+1)
588+ }
589+ }
590+ }
591+ return out
592+}
593+
594+// fileOpText describes a file-tool call in one line: the tool, the path, and
595+// for edit_file the number of edits — never the texts themselves, which can
596+// run to dozens of lines and belong in the diff, not in the recap.
597+func fileOpText(r *ai.ToolRequest) string {
598+ in, _ := r.Input.(map[string]any)
599+ path, _ := in["path"].(string)
600+ switch r.Name {
601+ case toolEdit:
602+ if edits, ok := in["edits"].([]any); ok {
603+ return fmt.Sprintf("%s %s (%d edit(s))", r.Name, path, len(edits))
604+ }
605+ case toolRead:
606+ if s, ok := in["start"].(float64); ok && s > 0 {
607+ e, _ := in["end"].(float64)
608+ return fmt.Sprintf("%s %s %d-%d", r.Name, path, int(s), int(e))
609+ }
610+ }
611+ if path == "" {
612+ return commandText(r)
613+ }
614+ return r.Name + " " + path
615+}
616+
617+// Skills counts the skills loaded — one per response of `read_skill`. Shown
618+// next to the command count: seeing which procedure the agent chose to follow is
619+// half the point of this part.
620+func Skills(history []*ai.Message) int {
621+ return responses(history, toolSkill)
622+}
new file mode 100644
@@ -0,0 +1,622 @@
1+// Package engine wraps the connection to the LLM engine — Docker Model Runner,
2+// llama-server, or any other Provider of the registry — and the generation of
3+// answers. Everything here is engine-agnostic: the only lines that know WHICH
4+// server is behind the URL live in the Provider implementations (see
5+// provider.go and openai_compat.go). This package used to be called `dmr`;
6+// measured before the split, exactly one function (Init) and two string
7+// constants were specific to Docker Model Runner. The rest — the whitespace
8+// gap, the watchdog, the retry, the command counter — already behaved the same
9+// against the fake engine, which is not DMR either.
10+package engine
11+
12+import (
13+ "context"
14+ "encoding/json"
15+ "fmt"
16+ "maps"
17+ "slices"
18+ "strings"
19+ "sync"
20+ "sync/atomic"
21+ "time"
22+
23+ "mm/internal/config"
24+ "mm/internal/spinner"
25+ "mm/internal/ui"
26+
27+ "github.com/firebase/genkit/go/ai"
28+ "github.com/firebase/genkit/go/genkit"
29+)
30+
31+// Engine is what main.go hands to the agent and to the tools: an initialised
32+// Genkit, the FULL model reference Genkit expects ("dmr/<id>",
33+// "llamacpp/<id>"), and the provider that built it — kept around for the one
34+// thing that stays provider-specific after start-up: explaining an error in one
35+// line (see Explain).
36+type Engine struct {
37+ G *genkit.Genkit
38+ Model string
39+ Provider Provider
40+ Backend Backend
41+
42+ // ContextWindow is the served context size as far as the agent knows it:
43+ // the config's hint, or what Probe learned from the server (llama-server's
44+ // /props). 0 = unknown. The context compression (internal/compact) reads
45+ // it; the banner shows it with its origin, because a number without an
46+ // origin never gets corrected.
47+ ContextWindow int
48+
49+ // lastInput is the input-token count the server reported on the most
50+ // recent call to the model, captured in Generate's WrapModel hook. It is
51+ // the exact figure the compression trigger prefers over its estimate.
52+ lastInput atomic.Int64
53+}
54+
55+// New reads the provider named in the config, resolves its backend (URL, key,
56+// model) and opens it. Unknown providers are refused HERE, with the list of
57+// known names: a typo in `provider:` must not turn into a "model not found"
58+// twenty seconds later, once the model has tried to load.
59+func New(ctx context.Context) (*Engine, error) {
60+ p, err := Lookup(config.Cfg.Provider)
61+ if err != nil {
62+ return nil, err
63+ }
64+ b, err := p.Resolve(config.Cfg)
65+ if err != nil {
66+ return nil, err
67+ }
68+ g, model, err := p.Open(ctx, b)
69+ if err != nil {
70+ return nil, err
71+ }
72+ return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow}, nil
73+}
74+
75+// Probe asks the provider what it can learn about the server BEFORE the first
76+// question: reachability, the context window it serves, a model that is not
77+// pulled. Never fatal — the user may well start the server after the agent.
78+//
79+// The context window it learns is remembered on the Engine. The provider has
80+// already applied the precedence — the config's value wins over the server's,
81+// because the operator knows what they started the server with — so what is
82+// stored here is the one number the whole agent reasons with.
83+func (e *Engine) Probe(ctx context.Context) Info {
84+ info := e.Provider.Probe(ctx, e.Backend)
85+ if info.ContextWindow > 0 {
86+ e.ContextWindow = info.ContextWindow
87+ }
88+ return info
89+}
90+
91+// EnsureContextWindow re-runs the provider's probe when the window is still
92+// unknown, and reports what it learned: the window and its origin ("/props"),
93+// or 0 and "" when the server still tells nothing.
94+//
95+// Why: the start-up probe runs once (main.go), and on a demo machine the
96+// server is often started AFTER the agent — observed: bob started before
97+// llama-server left `ctx: unknown` for the whole session, so the compression
98+// could only ever trigger on maxMessages. Asking again, lazily, fixes that
99+// without a background poller. Cheap by construction: the caller only asks
100+// while the window is unknown, and a known window is returned as-is, without
101+// a request — a `/props` hit per question would be noise the server does not
102+// deserve. Warnings are deliberately dropped here: the start-up probe already
103+// printed them, and repeating "nothing answers" at every question is nagging.
104+func (e *Engine) EnsureContextWindow(ctx context.Context) (window int, source string) {
105+ if e.ContextWindow > 0 {
106+ return e.ContextWindow, ""
107+ }
108+ info := e.Provider.Probe(ctx, e.Backend)
109+ if info.ContextWindow > 0 {
110+ e.ContextWindow = info.ContextWindow
111+ return info.ContextWindow, info.ContextSource
112+ }
113+ return 0, ""
114+}
115+
116+// LastInputTokens returns the server's own count of the context it last read,
117+// or 0 when it reports no usage (the fake engine of part 03, some proxies).
118+func (e *Engine) LastInputTokens() int { return int(e.lastInput.Load()) }
119+
120+// ForgetInputTokens clears that count. Called after a compression: the measure
121+// described the history that was just replaced, and keeping it would trigger a
122+// second compression on the very next question.
123+func (e *Engine) ForgetInputTokens() { e.lastInput.Store(0) }
124+
125+// Explain turns a transport error into the one-line, actionable message the
126+// REPL prints between brackets. See Provider.Explain for the rationale.
127+func (e *Engine) Explain(err error) string {
128+ return e.Provider.Explain(err, e.Backend)
129+}
130+
131+// blanks is the set of characters the model adds around its text without any of
132+// it showing on screen.
133+const blanks = " \t\r\n"
134+
135+// gap holds back the TRAILING whitespace of the streamed text: it is only
136+// printed if visible text follows. Without that, the line breaks the model puts
137+// after its sentence are printed as-is, and the screen hollows out.
138+//
139+// The model produces MORE AND MORE of it as the turns go by — it imitates its
140+// own messages, which it re-reads in the history every turn. Measured: 2 blank
141+// lines before the first 🛠️, 4 before the second, 7, then 11. Holding the
142+// whitespace back is enough to remove all of it, since that whitespace is never
143+// followed by text: it is a tool that writes next.
144+type gap struct {
145+ // The `bash` tool may call drop() from SEVERAL goroutines — Genkit runs the
146+ // calls of a single turn in parallel (see printMu in tools).
147+ mu sync.Mutex
148+ held string // whitespace waiting to be followed by text
149+ started bool // visible text has already been printed
150+}
151+
152+// next returns what should be printed for this chunk, "" when there is nothing
153+// to print right now.
154+func (g *gap) next(text string) string {
155+ g.mu.Lock()
156+ defer g.mu.Unlock()
157+
158+ body := strings.TrimRight(text, blanks)
159+ if body == "" { // an all-whitespace chunk: set aside, not printed
160+ if g.started {
161+ g.held += text
162+ }
163+ return ""
164+ }
165+
166+ // The chunk splits in three: leading whitespace, text, trailing whitespace.
167+ tail := text[len(body):]
168+ visible := strings.TrimLeft(body, blanks)
169+ lead := body[:len(body)-len(visible)]
170+
171+ out := ""
172+ if g.started { // before the first word, all whitespace is dropped
173+ out = squeeze(g.held + lead)
174+ }
175+ g.held, g.started = tail, true
176+ return out + visible
177+}
178+
179+// drop forgets the pending whitespace. Called when someone ELSE is about to
180+// write — a tool announcing its command: that whitespace belonged to the text
181+// before it, and printing it now would reopen the hole we just closed.
182+func (g *gap) drop() {
183+ g.mu.Lock()
184+ g.held = ""
185+ g.mu.Unlock()
186+}
187+
188+// squeeze brings any run of line breaks down to two, that is ONE blank line at
189+// most. Spaces are left alone: they carry the indentation of a code block, and
190+// cutting it would shift the displayed code.
191+func squeeze(s string) string {
192+ var b strings.Builder
193+ run := 0
194+ for _, r := range s {
195+ if r == '\n' {
196+ run++
197+ if run > 2 {
198+ continue
199+ }
200+ } else {
201+ run = 0
202+ }
203+ b.WriteRune(r)
204+ }
205+ return b.String()
206+}
207+
208+// Generate queries the model, printing the answer as it comes (streaming). Some
209+// OpenAI-compatible servers sometimes cut the SSE stream abruptly ("unexpected
210+// end of JSON input"): if nothing has been printed AND no command has run yet,
211+// we retry once WITHOUT streaming — which is more robust. The "no command"
212+// condition matters: see the comment on the retry below.
213+//
214+// It returns the FULL conversation alongside the response — every intermediate
215+// turn included — because Genkit only hands back the last message. The caller
216+// needs the rest, or the agent forgets the commands it just ran.
217+//
218+// A spinner covers every moment where the program is waiting rather than
219+// printing. On a local engine the first of those is long — the model may still
220+// be loading, and the whole prompt has to be processed before a single token
221+// comes back — and without it the agent looks hung.
222+//
223+// Watchdog: if no tokens arrive for a certain duration, we assume the connection
224+// is hung and we cancel the context. This is the "silent loop" of
225+// STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one.
226+func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
227+ // Genkit only hands us back the LAST message. The intermediate turns — "I
228+ // am calling bash", "here is the output" — exist only in the request it
229+ // builds for the next turn. This middleware captures that request on its way
230+ // through: the last turn seen therefore holds the whole conversation.
231+ //
232+ // The `WrapModel` hook wraps every call to the model — one per turn — so the
233+ // last request seen is the most complete one. `MiddlewareFunc` adapts a
234+ // closure to the `ai.Middleware` contract without going through a plugin:
235+ // `New` is called once per `Generate`, which keeps `full` captured at the
236+ // right level.
237+ var full []*ai.Message
238+ var gaps gap
239+ capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) {
240+ return &ai.Hooks{
241+ WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) {
242+ full = slices.Clone(params.Request.Messages)
243+ resp, err := next(ctx, params)
244+ // The server's own count of what it just read: the one exact
245+ // measure of the context size, kept for the compression
246+ // trigger. Zero means the server reported nothing.
247+ if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 {
248+ e.lastInput.Store(int64(resp.Usage.InputTokens))
249+ }
250+ return resp, err
251+ },
252+ // The tool is about to write to the screen: we forget the
253+ // whitespace the previous text left pending. The hook lands at
254+ // exactly the right moment — just before the tool prints its 🛠️
255+ // line.
256+ WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {
257+ gaps.drop()
258+ return next(ctx, params)
259+ },
260+ }, nil
261+ })
262+
263+ opts := []ai.GenerateOption{
264+ ai.WithModelName(e.Model),
265+ ai.WithMessages(messages...),
266+ ai.WithTools(tools...),
267+ ai.WithMaxTurns(config.Cfg.MaxTurns), // max model ↔ tools round trips
268+ ai.WithConfig(config.Cfg.Sampling),
269+ ai.WithUse(capture),
270+ }
271+
272+ // The prefix is printed by the first token rather than up front, so the
273+ // spinner has a line to itself and vanishes without taking anything with it.
274+ printed := false
275+ emit := func(text string) {
276+ // An event-consuming front end (ACP) takes the chunk raw: the editor
277+ // renders Markdown and owns the layout, so the gap/squeeze machinery —
278+ // which fights the terminal screen, not the text — stays out of the
279+ // way. `printed` is still maintained: the no-streaming retry below must
280+ // know whether the client already saw part of an answer.
281+ if s := ui.ActiveSink(); s != nil {
282+ if text == "" {
283+ return
284+ }
285+ printed = true
286+ s.Text(text)
287+ return
288+ }
289+ // Trailing whitespace is held back, not printed: see the gap type.
290+ out := gaps.next(text)
291+ if out == "" {
292+ return
293+ }
294+ // Stop BEFORE every print, not only the first one.
295+ //
296+ // A question can take several turns, and the tool restarts the spinner
297+ // on its way back to the model. The text of the next turn then landed on
298+ // the spinner's line, which erased it on the following frame
299+ // (`\r\033[2K`): "Hello 👋 to Sam" became "⠦ Thinking… 0s 👋 to Sam".
300+ // Worse, the last sentence of an answer vanished entirely, erased by the
301+ // final Stop().
302+ //
303+ // Stop() is idempotent and returns immediately when nothing is running,
304+ // so calling it on every chunk costs nothing.
305+ spinner.Stop()
306+ if !printed {
307+ fmt.Print("🤖 ")
308+ printed = true
309+ }
310+ fmt.Print(out)
311+ }
312+
313+ // --- Watchdog ---
314+ // A sub-context the watchdog cancels when no token has arrived for
315+ // WatchdogTimeout; the streaming callback beats it on every chunk.
316+ genCtx, beat, cancelGen := watchdog(ctx, config.Cfg.WatchdogTimeout)
317+ defer cancelGen()
318+
319+ spinner.Start("Thinking")
320+ defer spinner.Stop()
321+
322+ resp, err := genkit.Generate(genCtx, e.G, append(opts,
323+ ai.WithStreaming(func(_ context.Context, chunk *ai.ModelResponseChunk) error {
324+ beat()
325+ emit(chunk.Text())
326+ return nil
327+ }),
328+ )...)
329+
330+ // `full` grows as soon as a tool turn has happened: it is the witness that
331+ // says whether commands ran, and it is used twice below.
332+ toolsRan := len(full) > len(messages)
333+
334+ // The watchdog cut `genCtx` while the PARENT context is intact: this is a
335+ // silence of the connection, not a cancellation by the user.
336+ //
337+ // We question the contexts rather than the error. Testing
338+ // `err == context.Canceled` does not work: Genkit wraps the error, the
339+ // comparison fails, and the turn then went off into the non-streamed retry —
340+ // that is, it called the MUTE engine again. Measured: 5 s of watchdog then
341+ // 20 s of "Retrying without streaming…" against a silent server, and the
342+ // "stalled" message never shown.
343+ stalled := genCtx.Err() != nil && ctx.Err() == nil
344+ if err != nil && stalled {
345+ // We return `full` despite the failure: if commands ran before the
346+ // silence, their work must not be lost.
347+ return nil, full, fmt.Errorf("generation timed out (stalled)")
348+ }
349+
350+ // The retry is only worth it for an SSE stream cut before the first byte.
351+ // Any other error (maxTurns exceeded, a failing tool, invalid arguments)
352+ // happens AFTER commands have run, and replaying the turn would replay them:
353+ // measured, an `echo >>` wrote itself twice. `full` grows as soon as a tool
354+ // turn has happened, which is enough to tell the two apart.
355+ if err != nil && !printed && !toolsRan {
356+ spinner.Start("Retrying without streaming")
357+ if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {
358+ emit(resp.Text())
359+ }
360+ }
361+
362+ // `full` stops at the last call to the model; its answer is appended after.
363+ if resp != nil && resp.Message != nil {
364+ full = append(full, resp.Message)
365+ }
366+ return resp, full, err
367+}
368+
369+// watchdog returns a child of ctx that is cancelled when beat() has not been
370+// called for `timeout`. This is the "silent loop" of STALL_DETECTION_PROBLEM.md
371+// — a liveness failure, not a behavioural one — and it is shared by Generate
372+// and Summarize so that both fail the same way.
373+//
374+// The timestamp is atomic because beat() runs on the streaming goroutine while
375+// the ticker reads it from another.
376+func watchdog(ctx context.Context, timeout time.Duration) (genCtx context.Context, beat func(), cancel context.CancelFunc) {
377+ var last atomic.Int64 // UnixNano
378+ last.Store(time.Now().UnixNano())
379+ genCtx, cancel = context.WithCancel(ctx)
380+
381+ go func() {
382+ ticker := time.NewTicker(1 * time.Second)
383+ defer ticker.Stop()
384+ for {
385+ select {
386+ case <-genCtx.Done():
387+ return
388+ case <-ticker.C:
389+ if time.Since(time.Unix(0, last.Load())) > timeout {
390+ // Stop() before printing, like everything else: without it
391+ // the watchdog message lands on the spinner's line and the
392+ // next frame erases it — the diagnosis would vanish at the
393+ // very moment it is needed.
394+ spinner.Stop()
395+ // ui.Out, not stdout: in ACP mode this diagnosis belongs to
396+ // the logs (stderr), never to the JSON-RPC stream.
397+ fmt.Fprintf(ui.Out, "\n[watchdog: connection hang detected after %v]\n", timeout)
398+ cancel()
399+ return
400+ }
401+ }
402+ }
403+ }()
404+
405+ return genCtx, func() { last.Store(time.Now().UnixNano()) }, cancel
406+}
407+
408+// Summarize asks the model for a plain-text answer to `messages` — no tools,
409+// no screen output. It exists for the context compression (internal/compact):
410+// the summary must not stream onto the screen as if it were an answer, and a
411+// request that offers tools invites the model to call them instead of writing.
412+// It goes through the same Genkit and the same model reference as Generate,
413+// so it lands on the same provider and the same server.
414+//
415+// The watchdog is kept, with twice the usual patience: this is the longest
416+// prefill of the session (the whole old history at once) and nothing streams
417+// until it is done. Streaming stays on so that the summary's own tokens beat
418+// the watchdog while it is being written.
419+func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error) {
420+ // The agent's sampling, minus what does not apply here: `parallel_tool_calls`
421+ // without tools is a 400 on the OpenAI API, and `max_tokens` is the summary's
422+ // budget, not the answer's.
423+ sampling := maps.Clone(config.Cfg.Sampling)
424+ if sampling == nil {
425+ sampling = map[string]any{}
426+ }
427+ delete(sampling, "parallel_tool_calls")
428+ sampling["max_tokens"] = maxTokens
429+
430+ genCtx, beat, cancel := watchdog(ctx, 2*config.Cfg.WatchdogTimeout)
431+ defer cancel()
432+
433+ resp, err := genkit.Generate(genCtx, e.G,
434+ ai.WithModelName(e.Model),
435+ ai.WithMessages(messages...),
436+ ai.WithConfig(sampling),
437+ ai.WithStreaming(func(_ context.Context, _ *ai.ModelResponseChunk) error {
438+ beat()
439+ return nil
440+ }),
441+ )
442+ if err != nil {
443+ // Same test as in Generate: the contexts, not the wrapped error.
444+ if genCtx.Err() != nil && ctx.Err() == nil {
445+ return "", fmt.Errorf("summary timed out (stalled)")
446+ }
447+ return "", err
448+ }
449+ text := strings.TrimSpace(resp.Text())
450+ if text == "" {
451+ return "", fmt.Errorf("the model returned an empty summary")
452+ }
453+ return text, nil
454+}
455+
456+// Names of the tools whose responses we count. They live here rather than
457+// scattered through the display code: the counter is a measurement, and a
458+// measurement needs a single definition.
459+const (
460+ toolBash = "bash"
461+ toolSkill = "read_skill"
462+ toolRead = "read_file"
463+ toolWrite = "write_file"
464+ toolEdit = "edit_file"
465+)
466+
467+// executed returns, in order, the text of the calls to a given tool that
468+// ACTUALLY ran.
469+//
470+// We start from the RESPONSES: a call the model asked for but which never ran
471+// (interrupted turn, maxTurns exceeded) produces none. The text of the call, on
472+// the other hand, is ONLY in the request — hence the pairing by Ref, the
473+// identifier the server gives to each call.
474+func executed(history []*ai.Message, name string) []string {
475+ // Calls with no Ref (some servers do not set one) are paired in order, BUT
476+ // only against the same tool name: a turn mixes `bash` and `read_skill`, and
477+ // the first pending call is not necessarily for the right tool.
478+ type call struct{ ref, name, text string }
479+ var pending []call
480+ var out []string
481+
482+ for _, m := range history {
483+ for _, p := range m.Content {
484+ switch {
485+ case p.IsToolRequest():
486+ pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name, commandText(p.ToolRequest)})
487+
488+ case p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == name:
489+ ref := p.ToolResponse.Ref
490+ i := slices.IndexFunc(pending, func(c call) bool {
491+ if ref != "" {
492+ return c.ref == ref
493+ }
494+ return c.name == name
495+ })
496+ if i < 0 { // lost call: the name beats nothing
497+ out = append(out, name)
498+ continue
499+ }
500+ out = append(out, pending[i].text)
501+ pending = slices.Delete(pending, i, i+1)
502+ }
503+ }
504+ }
505+ return out
506+}
507+
508+// commandText describes a tool call in one line. For `bash` it is the command
509+// itself — the rest is only wrapping; for any other tool, its name followed by
510+// its arguments.
511+func commandText(r *ai.ToolRequest) string {
512+ if in, ok := r.Input.(map[string]any); ok {
513+ if c, ok := in["command"].(string); ok && strings.TrimSpace(c) != "" {
514+ return strings.TrimSpace(c)
515+ }
516+ }
517+ args, err := json.Marshal(r.Input)
518+ if err != nil || string(args) == "null" {
519+ return r.Name
520+ }
521+ return r.Name + " " + string(args)
522+}
523+
524+// responses counts the responses of a given tool in a history.
525+func responses(history []*ai.Message, name string) int {
526+ return len(executed(history, name))
527+}
528+
529+// Commands counts the commands actually run in a history — one per response of
530+// the `bash` tool. It is the only reliable measure of the work done: a model
531+// that tells stories without acting produces a fine answer and a count of zero.
532+//
533+// The other tools do NOT count. Loading a skill runs nothing, and counting those
534+// reads would make the number say the opposite of what it is for: measured
535+// before the fix, a turn with two reads and a single command displayed
536+// "⚙ 3 command(s)".
537+func Commands(history []*ai.Message) int {
538+ return responses(history, toolBash)
539+}
540+
541+// CommandList returns, in order, the text of the commands Commands counts — the
542+// one shown in the recap under the count when `displayCommands` is on. The count
543+// says HOW MANY, the list says WHAT.
544+func CommandList(history []*ai.Message) []string {
545+ return executed(history, toolBash)
546+}
547+
548+// FileOps returns, in order, the file-tool calls that ran — read_file,
549+// write_file, edit_file — as one line each ("edit_file hello/main.go (2 edits)").
550+//
551+// They are counted APART from the commands, not folded into them: this part
552+// compares editing through the `edit` CLI (one bash command per edit) with
553+// editing through built-in tools, and a recap that counted both as "commands"
554+// would hide the very difference being measured. `⚙ 3 command(s) · 📝 2 file
555+// op(s)` keeps the two columns readable side by side.
556+func FileOps(history []*ai.Message) []string {
557+ return executedAny(history, toolRead, toolWrite, toolEdit)
558+}
559+
560+// executedAny is executed() across several tool names, keeping the order in
561+// which the responses appear — a per-name pass would lose the interleaving of
562+// a read followed by an edit.
563+func executedAny(history []*ai.Message, names ...string) []string {
564+ type call struct{ ref, name, text string }
565+ var pending []call
566+ var out []string
567+ isOurs := func(n string) bool { return slices.Contains(names, n) }
568+
569+ for _, m := range history {
570+ for _, p := range m.Content {
571+ switch {
572+ case p.IsToolRequest():
573+ pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name, fileOpText(p.ToolRequest)})
574+ case p.IsToolResponse() && p.ToolResponse != nil && isOurs(p.ToolResponse.Name):
575+ ref, name := p.ToolResponse.Ref, p.ToolResponse.Name
576+ i := slices.IndexFunc(pending, func(c call) bool {
577+ if ref != "" {
578+ return c.ref == ref
579+ }
580+ return c.name == name
581+ })
582+ if i < 0 {
583+ out = append(out, name)
584+ continue
585+ }
586+ out = append(out, pending[i].text)
587+ pending = slices.Delete(pending, i, i+1)
588+ }
589+ }
590+ }
591+ return out
592+}
593+
594+// fileOpText describes a file-tool call in one line: the tool, the path, and
595+// for edit_file the number of edits — never the texts themselves, which can
596+// run to dozens of lines and belong in the diff, not in the recap.
597+func fileOpText(r *ai.ToolRequest) string {
598+ in, _ := r.Input.(map[string]any)
599+ path, _ := in["path"].(string)
600+ switch r.Name {
601+ case toolEdit:
602+ if edits, ok := in["edits"].([]any); ok {
603+ return fmt.Sprintf("%s %s (%d edit(s))", r.Name, path, len(edits))
604+ }
605+ case toolRead:
606+ if s, ok := in["start"].(float64); ok && s > 0 {
607+ e, _ := in["end"].(float64)
608+ return fmt.Sprintf("%s %s %d-%d", r.Name, path, int(s), int(e))
609+ }
610+ }
611+ if path == "" {
612+ return commandText(r)
613+ }
614+ return r.Name + " " + path
615+}
616+
617+// Skills counts the skills loaded — one per response of `read_skill`. Shown
618+// next to the command count: seeing which procedure the agent chose to follow is
619+// half the point of this part.
620+func Skills(history []*ai.Message) int {
621+ return responses(history, toolSkill)
622+}
added internal/engine/engine_test.go +362 -0
new file mode 100644
@@ -0,0 +1,362 @@
1+// The test that matters here is the one about CAPTURE: Genkit only hands back
2+// the last message, and the whole point of `Generate` is to hand back the
3+// intermediate turns too. That is the one thing the `WithMiddleware` → `WithUse`
4+// migration could have broken silently — the code compiles just as well with a
5+// hook that never sees anything.
6+//
7+// No real model is needed: a fake model registered in Genkit plays one tool turn
8+// then answers, which is exactly the shape the history must keep a trace of.
9+//
10+// The watchdog of this version runs during these tests: since the fake model
11+// answers instantly it never fires — and if it did, the tests would fail, which
12+// is the intended behaviour.
13+package engine
14+
15+import (
16+ "context"
17+ "testing"
18+
19+ "mm/internal/config"
20+
21+ "github.com/firebase/genkit/go/ai"
22+ "github.com/firebase/genkit/go/genkit"
23+)
24+
25+// fakeGenkit registers a "dmr/<Cfg.Model>" model whose every call is served by
26+// the next answer in `turns`, and a `bash` tool returning a fixed output.
27+// `calls` counts the calls to the model.
28+func fakeGenkit(t *testing.T, turns ...*ai.ModelResponse) (*genkit.Genkit, *int) {
29+ t.Helper()
30+ g := genkit.Init(context.Background())
31+
32+ calls := 0
33+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
34+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
35+ func(_ context.Context, _ *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
36+ if calls >= len(turns) {
37+ t.Errorf("the model was called %d times for %d planned turns", calls+1, len(turns))
38+ return turns[len(turns)-1], nil
39+ }
40+ resp := turns[calls]
41+ calls++
42+ return resp, nil
43+ })
44+
45+ genkit.DefineTool(g, "bash", "run a shell command",
46+ func(_ *ai.ToolContext, in struct {
47+ Command string `json:"command"`
48+ }) (string, error) {
49+ return "hello from " + in.Command, nil
50+ })
51+
52+ genkit.DefineTool(g, "read_skill", "load a markdown procedure",
53+ func(_ *ai.ToolContext, in struct {
54+ Name string `json:"name"`
55+ }) (string, error) {
56+ return "# procedure " + in.Name, nil
57+ })
58+
59+ return g, &calls
60+}
61+
62+// testEngine wraps a fake Genkit the way New would: the model reference is the
63+// only thing Generate reads from the Engine, so no provider is needed here.
64+func testEngine(g *genkit.Genkit) *Engine {
65+ return &Engine{G: g, Model: "dmr/" + config.Cfg.Model}
66+}
67+
68+func modelTurn(parts ...*ai.Part) *ai.ModelResponse {
69+ return &ai.ModelResponse{
70+ Message: ai.NewModelMessage(parts...),
71+ FinishReason: ai.FinishReasonStop,
72+ }
73+}
74+
75+// TestGenerateReturnsFullConversation is the regression test for the capture: on
76+// a tool turn, the history handed back must contain the question, the tool
77+// request, its response AND the final text — not just that last one.
78+func TestGenerateReturnsFullConversation(t *testing.T) {
79+ toolReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{
80+ Name: "bash",
81+ Input: map[string]any{"command": "echo hi"},
82+ }))
83+ final := modelTurn(ai.NewTextPart("done"))
84+
85+ g, calls := fakeGenkit(t, toolReq, final)
86+
87+ resp, full, err := testEngine(g).Generate(context.Background(),
88+ []*ai.Message{ai.NewUserTextMessage("run echo hi")},
89+ []ai.ToolRef{ai.ToolName("bash")})
90+ if err != nil {
91+ t.Fatalf("Generate: %v", err)
92+ }
93+ if *calls != 2 {
94+ t.Errorf("calls to the model = %d, want 2 (tool, then answer)", *calls)
95+ }
96+ if got := resp.Text(); got != "done" {
97+ t.Errorf("resp.Text() = %q, want %q", got, "done")
98+ }
99+
100+ // The full history: without the capture, Genkit only hands back the last
101+ // message and `full` would have length 1.
102+ if len(full) < 4 {
103+ t.Fatalf("history of %d messages, want at least 4 (question, call, tool response, text): %+v", len(full), full)
104+ }
105+ if full[len(full)-1] != final.Message {
106+ t.Errorf("the last message of the history is not the final answer")
107+ }
108+
109+ // The two markers that exist ONLY in the intermediate turns.
110+ var sawRequest, sawResponse bool
111+ for _, m := range full {
112+ for _, p := range m.Content {
113+ if p.IsToolRequest() {
114+ sawRequest = true
115+ }
116+ if p.IsToolResponse() {
117+ sawResponse = true
118+ }
119+ }
120+ }
121+ if !sawRequest {
122+ t.Error("the history contains no tool request")
123+ }
124+ if !sawResponse {
125+ t.Error("the history contains no tool response")
126+ }
127+
128+ // Commands reads the same trace: one command run, exactly one.
129+ if n := Commands(full); n != 1 {
130+ t.Errorf("Commands(full) = %d, want 1", n)
131+ }
132+ // And CommandList hands back its TEXT, which exists only in the call — that
133+ // is what the green recap displays.
134+ if got := CommandList(full); len(got) != 1 || got[0] != "echo hi" {
135+ t.Errorf("CommandList(full) = %q, want [\"echo hi\"]", got)
136+ }
137+}
138+
139+// TestCommandsCountsBashOnly guards the bug introduced by adding `read_skill`:
140+// `Commands` counted EVERY tool response, so loading two skills and running a
141+// single command displayed "⚙ 3 command(s)". The number then said the opposite
142+// of what it is for — that the model acted.
143+func TestCommandsCountsBashOnly(t *testing.T) {
144+ skillReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{
145+ Name: "read_skill",
146+ Input: map[string]any{"name": "go-rename"},
147+ }))
148+ bashReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{
149+ Name: "bash",
150+ Input: map[string]any{"command": "echo hi"},
151+ }))
152+ final := modelTurn(ai.NewTextPart("done"))
153+
154+ g, calls := fakeGenkit(t, skillReq, bashReq, final)
155+
156+ _, full, err := testEngine(g).Generate(context.Background(),
157+ []*ai.Message{ai.NewUserTextMessage("rename Greet")},
158+ []ai.ToolRef{ai.ToolName("bash"), ai.ToolName("read_skill")})
159+ if err != nil {
160+ t.Fatalf("Generate: %v", err)
161+ }
162+ if *calls != 3 {
163+ t.Errorf("calls to the model = %d, want 3", *calls)
164+ }
165+ if n := Commands(full); n != 1 {
166+ t.Errorf("Commands = %d, want 1 — only the bash command counts", n)
167+ }
168+ if k := Skills(full); k != 1 {
169+ t.Errorf("Skills = %d, want 1", k)
170+ }
171+ // The list follows the same rule as the count: the skill read is not in it.
172+ if got := CommandList(full); len(got) != 1 || got[0] != "echo hi" {
173+ t.Errorf("CommandList = %q, want [\"echo hi\"] — a skill is not a command", got)
174+ }
175+}
176+
177+// TestCommandList covers the shapes the call↔response pairing has to take:
178+// several commands in order, a Ref that does not follow the order they were
179+// issued in, a call left without a response (nothing ran: nothing to show)
180+// and a turn mixing `bash` and `read_skill` with no Ref.
181+func TestCommandList(t *testing.T) {
182+ req := func(ref, name string, in any) *ai.Part {
183+ return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: name, Input: in})
184+ }
185+ resp := func(ref, name string) *ai.Part {
186+ return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: name, Output: "ok"})
187+ }
188+ bash := func(cmd string) map[string]any { return map[string]any{"command": cmd} }
189+
190+ cases := []struct {
191+ name string
192+ history []*ai.Message
193+ want []string
194+ }{
195+ {
196+ name: "two commands, in the order they ran",
197+ history: []*ai.Message{
198+ ai.NewModelMessage(req("a", "bash", bash("ls")), req("b", "bash", bash("pwd"))),
199+ ai.NewMessage(ai.RoleTool, nil, resp("a", "bash"), resp("b", "bash")),
200+ },
201+ want: []string{"ls", "pwd"},
202+ },
203+ {
204+ name: "responses out of order: the Ref decides",
205+ history: []*ai.Message{
206+ ai.NewModelMessage(req("a", "bash", bash("ls")), req("b", "bash", bash("pwd"))),
207+ ai.NewMessage(ai.RoleTool, nil, resp("b", "bash"), resp("a", "bash")),
208+ },
209+ want: []string{"pwd", "ls"},
210+ },
211+ {
212+ name: "no Ref, mixed tools: paired against the same name",
213+ history: []*ai.Message{
214+ ai.NewModelMessage(
215+ req("", "read_skill", map[string]any{"name": "go-rename"}),
216+ req("", "bash", bash("ls")),
217+ ),
218+ ai.NewMessage(ai.RoleTool, nil, resp("", "read_skill"), resp("", "bash")),
219+ },
220+ want: []string{"ls"},
221+ },
222+ {
223+ name: "call with no response: the command never ran",
224+ history: []*ai.Message{
225+ ai.NewModelMessage(req("a", "bash", bash("ls"))),
226+ },
227+ want: nil,
228+ },
229+ }
230+
231+ for _, tc := range cases {
232+ t.Run(tc.name, func(t *testing.T) {
233+ got := CommandList(tc.history)
234+ if len(got) != len(tc.want) {
235+ t.Fatalf("CommandList = %q, want %q", got, tc.want)
236+ }
237+ for i := range got {
238+ if got[i] != tc.want[i] {
239+ t.Errorf("CommandList[%d] = %q, want %q", i, got[i], tc.want[i])
240+ }
241+ }
242+ })
243+ }
244+}
245+
246+// TestGenerateNoToolCall checks the bare case: with no tool called, the history
247+// stays the question plus the answer, and Commands counts zero — a model that
248+// tells stories without acting must not be credited with a command.
249+func TestGenerateNoToolCall(t *testing.T) {
250+ final := modelTurn(ai.NewTextPart("hello"))
251+ g, calls := fakeGenkit(t, final)
252+
253+ _, full, err := testEngine(g).Generate(context.Background(),
254+ []*ai.Message{ai.NewUserTextMessage("say hello")},
255+ nil)
256+ if err != nil {
257+ t.Fatalf("Generate: %v", err)
258+ }
259+ if *calls != 1 {
260+ t.Errorf("calls to the model = %d, want 1", *calls)
261+ }
262+ if len(full) != 2 {
263+ t.Fatalf("history of %d messages, want 2 (question, answer): %+v", len(full), full)
264+ }
265+ if n := Commands(full); n != 0 {
266+ t.Errorf("Commands(full) = %d, want 0", n)
267+ }
268+}
269+
270+// TestGenerateCapturesInputTokens: the compression trigger prefers the server's
271+// own count of the context to its estimate, and that count only exists inside
272+// the WrapModel hook. A fake response carrying Usage must surface through
273+// LastInputTokens; ForgetInputTokens must clear it.
274+func TestGenerateCapturesInputTokens(t *testing.T) {
275+ withUsage := modelTurn(ai.NewTextPart("ok"))
276+ withUsage.Usage = &ai.GenerationUsage{InputTokens: 1234, OutputTokens: 5}
277+
278+ g, _ := fakeGenkit(t, withUsage)
279+ e := testEngine(g)
280+ if _, _, err := e.Generate(context.Background(),
281+ []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil {
282+ t.Fatalf("Generate: %v", err)
283+ }
284+ if got := e.LastInputTokens(); got != 1234 {
285+ t.Errorf("LastInputTokens() = %d, want 1234", got)
286+ }
287+ e.ForgetInputTokens()
288+ if got := e.LastInputTokens(); got != 0 {
289+ t.Errorf("after ForgetInputTokens, LastInputTokens() = %d, want 0", got)
290+ }
291+}
292+
293+// TestSummarize: the summary request declares no tools (the model must write,
294+// not act), goes to the Engine's own model reference, and the model's text
295+// comes back trimmed. An empty answer is an error — replacing the history with
296+// nothing is worse than keeping it.
297+func TestSummarize(t *testing.T) {
298+ g := genkit.Init(context.Background())
299+ var sawTools int
300+ answers := []string{" ## Goal\nnotes\n", ""}
301+ call := 0
302+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
303+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
304+ func(_ context.Context, r *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
305+ sawTools = len(r.Tools)
306+ a := answers[call]
307+ call++
308+ return modelTurn(ai.NewTextPart(a)), nil
309+ })
310+ e := testEngine(g)
311+
312+ req := []*ai.Message{ai.NewSystemTextMessage("note-taker"), ai.NewUserTextMessage("summarise")}
313+ text, err := e.Summarize(context.Background(), req, 100)
314+ if err != nil {
315+ t.Fatalf("Summarize: %v", err)
316+ }
317+ if text != "## Goal\nnotes" {
318+ t.Errorf("text = %q, want the trimmed answer", text)
319+ }
320+ if sawTools != 0 {
321+ t.Errorf("the summary request declared %d tool(s), want none", sawTools)
322+ }
323+ if _, err := e.Summarize(context.Background(), req, 100); err == nil {
324+ t.Error("an empty answer must be an error")
325+ }
326+}
327+
328+// TestFileOpsKeepsTheOrder: the recap lists file operations in the order they
329+// ran, across the three tools — a read then an edit must not come out as "all
330+// reads, then all edits", which a per-tool pass would produce. Commands stay
331+// bash-only: the file tools have their own column.
332+func TestFileOpsKeepsTheOrder(t *testing.T) {
333+ req := func(ref, name string, in map[string]any) *ai.Part {
334+ return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: name, Input: in})
335+ }
336+ resp := func(ref, name string) *ai.Part {
337+ return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: name, Output: "ok"})
338+ }
339+ history := []*ai.Message{
340+ ai.NewModelMessage(req("a", "read_file", map[string]any{"path": "f.go", "start": 1.0, "end": 9.0})),
341+ ai.NewMessage(ai.RoleTool, nil, resp("a", "read_file")),
342+ ai.NewModelMessage(req("b", "bash", map[string]any{"command": "go vet ./..."})),
343+ ai.NewMessage(ai.RoleTool, nil, resp("b", "bash")),
344+ ai.NewModelMessage(req("c", "edit_file", map[string]any{"path": "f.go", "edits": []any{map[string]any{"old": "a", "new": "b"}, map[string]any{"old": "c", "new": "d"}}})),
345+ ai.NewMessage(ai.RoleTool, nil, resp("c", "edit_file")),
346+ ai.NewModelMessage(req("d", "write_file", map[string]any{"path": "notes.md", "content": "x"})),
347+ ai.NewMessage(ai.RoleTool, nil, resp("d", "write_file")),
348+ }
349+ want := []string{"read_file f.go 1-9", "edit_file f.go (2 edit(s))", "write_file notes.md"}
350+ got := FileOps(history)
351+ if len(got) != len(want) {
352+ t.Fatalf("FileOps = %q, want %q", got, want)
353+ }
354+ for i := range want {
355+ if got[i] != want[i] {
356+ t.Errorf("FileOps[%d] = %q, want %q", i, got[i], want[i])
357+ }
358+ }
359+ if n := Commands(history); n != 1 {
360+ t.Errorf("Commands = %d, want 1 — file ops are not commands", n)
361+ }
362+}
new file mode 100644
@@ -0,0 +1,362 @@
1+// The test that matters here is the one about CAPTURE: Genkit only hands back
2+// the last message, and the whole point of `Generate` is to hand back the
3+// intermediate turns too. That is the one thing the `WithMiddleware` → `WithUse`
4+// migration could have broken silently — the code compiles just as well with a
5+// hook that never sees anything.
6+//
7+// No real model is needed: a fake model registered in Genkit plays one tool turn
8+// then answers, which is exactly the shape the history must keep a trace of.
9+//
10+// The watchdog of this version runs during these tests: since the fake model
11+// answers instantly it never fires — and if it did, the tests would fail, which
12+// is the intended behaviour.
13+package engine
14+
15+import (
16+ "context"
17+ "testing"
18+
19+ "mm/internal/config"
20+
21+ "github.com/firebase/genkit/go/ai"
22+ "github.com/firebase/genkit/go/genkit"
23+)
24+
25+// fakeGenkit registers a "dmr/<Cfg.Model>" model whose every call is served by
26+// the next answer in `turns`, and a `bash` tool returning a fixed output.
27+// `calls` counts the calls to the model.
28+func fakeGenkit(t *testing.T, turns ...*ai.ModelResponse) (*genkit.Genkit, *int) {
29+ t.Helper()
30+ g := genkit.Init(context.Background())
31+
32+ calls := 0
33+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
34+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
35+ func(_ context.Context, _ *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
36+ if calls >= len(turns) {
37+ t.Errorf("the model was called %d times for %d planned turns", calls+1, len(turns))
38+ return turns[len(turns)-1], nil
39+ }
40+ resp := turns[calls]
41+ calls++
42+ return resp, nil
43+ })
44+
45+ genkit.DefineTool(g, "bash", "run a shell command",
46+ func(_ *ai.ToolContext, in struct {
47+ Command string `json:"command"`
48+ }) (string, error) {
49+ return "hello from " + in.Command, nil
50+ })
51+
52+ genkit.DefineTool(g, "read_skill", "load a markdown procedure",
53+ func(_ *ai.ToolContext, in struct {
54+ Name string `json:"name"`
55+ }) (string, error) {
56+ return "# procedure " + in.Name, nil
57+ })
58+
59+ return g, &calls
60+}
61+
62+// testEngine wraps a fake Genkit the way New would: the model reference is the
63+// only thing Generate reads from the Engine, so no provider is needed here.
64+func testEngine(g *genkit.Genkit) *Engine {
65+ return &Engine{G: g, Model: "dmr/" + config.Cfg.Model}
66+}
67+
68+func modelTurn(parts ...*ai.Part) *ai.ModelResponse {
69+ return &ai.ModelResponse{
70+ Message: ai.NewModelMessage(parts...),
71+ FinishReason: ai.FinishReasonStop,
72+ }
73+}
74+
75+// TestGenerateReturnsFullConversation is the regression test for the capture: on
76+// a tool turn, the history handed back must contain the question, the tool
77+// request, its response AND the final text — not just that last one.
78+func TestGenerateReturnsFullConversation(t *testing.T) {
79+ toolReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{
80+ Name: "bash",
81+ Input: map[string]any{"command": "echo hi"},
82+ }))
83+ final := modelTurn(ai.NewTextPart("done"))
84+
85+ g, calls := fakeGenkit(t, toolReq, final)
86+
87+ resp, full, err := testEngine(g).Generate(context.Background(),
88+ []*ai.Message{ai.NewUserTextMessage("run echo hi")},
89+ []ai.ToolRef{ai.ToolName("bash")})
90+ if err != nil {
91+ t.Fatalf("Generate: %v", err)
92+ }
93+ if *calls != 2 {
94+ t.Errorf("calls to the model = %d, want 2 (tool, then answer)", *calls)
95+ }
96+ if got := resp.Text(); got != "done" {
97+ t.Errorf("resp.Text() = %q, want %q", got, "done")
98+ }
99+
100+ // The full history: without the capture, Genkit only hands back the last
101+ // message and `full` would have length 1.
102+ if len(full) < 4 {
103+ t.Fatalf("history of %d messages, want at least 4 (question, call, tool response, text): %+v", len(full), full)
104+ }
105+ if full[len(full)-1] != final.Message {
106+ t.Errorf("the last message of the history is not the final answer")
107+ }
108+
109+ // The two markers that exist ONLY in the intermediate turns.
110+ var sawRequest, sawResponse bool
111+ for _, m := range full {
112+ for _, p := range m.Content {
113+ if p.IsToolRequest() {
114+ sawRequest = true
115+ }
116+ if p.IsToolResponse() {
117+ sawResponse = true
118+ }
119+ }
120+ }
121+ if !sawRequest {
122+ t.Error("the history contains no tool request")
123+ }
124+ if !sawResponse {
125+ t.Error("the history contains no tool response")
126+ }
127+
128+ // Commands reads the same trace: one command run, exactly one.
129+ if n := Commands(full); n != 1 {
130+ t.Errorf("Commands(full) = %d, want 1", n)
131+ }
132+ // And CommandList hands back its TEXT, which exists only in the call — that
133+ // is what the green recap displays.
134+ if got := CommandList(full); len(got) != 1 || got[0] != "echo hi" {
135+ t.Errorf("CommandList(full) = %q, want [\"echo hi\"]", got)
136+ }
137+}
138+
139+// TestCommandsCountsBashOnly guards the bug introduced by adding `read_skill`:
140+// `Commands` counted EVERY tool response, so loading two skills and running a
141+// single command displayed "⚙ 3 command(s)". The number then said the opposite
142+// of what it is for — that the model acted.
143+func TestCommandsCountsBashOnly(t *testing.T) {
144+ skillReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{
145+ Name: "read_skill",
146+ Input: map[string]any{"name": "go-rename"},
147+ }))
148+ bashReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{
149+ Name: "bash",
150+ Input: map[string]any{"command": "echo hi"},
151+ }))
152+ final := modelTurn(ai.NewTextPart("done"))
153+
154+ g, calls := fakeGenkit(t, skillReq, bashReq, final)
155+
156+ _, full, err := testEngine(g).Generate(context.Background(),
157+ []*ai.Message{ai.NewUserTextMessage("rename Greet")},
158+ []ai.ToolRef{ai.ToolName("bash"), ai.ToolName("read_skill")})
159+ if err != nil {
160+ t.Fatalf("Generate: %v", err)
161+ }
162+ if *calls != 3 {
163+ t.Errorf("calls to the model = %d, want 3", *calls)
164+ }
165+ if n := Commands(full); n != 1 {
166+ t.Errorf("Commands = %d, want 1 — only the bash command counts", n)
167+ }
168+ if k := Skills(full); k != 1 {
169+ t.Errorf("Skills = %d, want 1", k)
170+ }
171+ // The list follows the same rule as the count: the skill read is not in it.
172+ if got := CommandList(full); len(got) != 1 || got[0] != "echo hi" {
173+ t.Errorf("CommandList = %q, want [\"echo hi\"] — a skill is not a command", got)
174+ }
175+}
176+
177+// TestCommandList covers the shapes the call↔response pairing has to take:
178+// several commands in order, a Ref that does not follow the order they were
179+// issued in, a call left without a response (nothing ran: nothing to show)
180+// and a turn mixing `bash` and `read_skill` with no Ref.
181+func TestCommandList(t *testing.T) {
182+ req := func(ref, name string, in any) *ai.Part {
183+ return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: name, Input: in})
184+ }
185+ resp := func(ref, name string) *ai.Part {
186+ return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: name, Output: "ok"})
187+ }
188+ bash := func(cmd string) map[string]any { return map[string]any{"command": cmd} }
189+
190+ cases := []struct {
191+ name string
192+ history []*ai.Message
193+ want []string
194+ }{
195+ {
196+ name: "two commands, in the order they ran",
197+ history: []*ai.Message{
198+ ai.NewModelMessage(req("a", "bash", bash("ls")), req("b", "bash", bash("pwd"))),
199+ ai.NewMessage(ai.RoleTool, nil, resp("a", "bash"), resp("b", "bash")),
200+ },
201+ want: []string{"ls", "pwd"},
202+ },
203+ {
204+ name: "responses out of order: the Ref decides",
205+ history: []*ai.Message{
206+ ai.NewModelMessage(req("a", "bash", bash("ls")), req("b", "bash", bash("pwd"))),
207+ ai.NewMessage(ai.RoleTool, nil, resp("b", "bash"), resp("a", "bash")),
208+ },
209+ want: []string{"pwd", "ls"},
210+ },
211+ {
212+ name: "no Ref, mixed tools: paired against the same name",
213+ history: []*ai.Message{
214+ ai.NewModelMessage(
215+ req("", "read_skill", map[string]any{"name": "go-rename"}),
216+ req("", "bash", bash("ls")),
217+ ),
218+ ai.NewMessage(ai.RoleTool, nil, resp("", "read_skill"), resp("", "bash")),
219+ },
220+ want: []string{"ls"},
221+ },
222+ {
223+ name: "call with no response: the command never ran",
224+ history: []*ai.Message{
225+ ai.NewModelMessage(req("a", "bash", bash("ls"))),
226+ },
227+ want: nil,
228+ },
229+ }
230+
231+ for _, tc := range cases {
232+ t.Run(tc.name, func(t *testing.T) {
233+ got := CommandList(tc.history)
234+ if len(got) != len(tc.want) {
235+ t.Fatalf("CommandList = %q, want %q", got, tc.want)
236+ }
237+ for i := range got {
238+ if got[i] != tc.want[i] {
239+ t.Errorf("CommandList[%d] = %q, want %q", i, got[i], tc.want[i])
240+ }
241+ }
242+ })
243+ }
244+}
245+
246+// TestGenerateNoToolCall checks the bare case: with no tool called, the history
247+// stays the question plus the answer, and Commands counts zero — a model that
248+// tells stories without acting must not be credited with a command.
249+func TestGenerateNoToolCall(t *testing.T) {
250+ final := modelTurn(ai.NewTextPart("hello"))
251+ g, calls := fakeGenkit(t, final)
252+
253+ _, full, err := testEngine(g).Generate(context.Background(),
254+ []*ai.Message{ai.NewUserTextMessage("say hello")},
255+ nil)
256+ if err != nil {
257+ t.Fatalf("Generate: %v", err)
258+ }
259+ if *calls != 1 {
260+ t.Errorf("calls to the model = %d, want 1", *calls)
261+ }
262+ if len(full) != 2 {
263+ t.Fatalf("history of %d messages, want 2 (question, answer): %+v", len(full), full)
264+ }
265+ if n := Commands(full); n != 0 {
266+ t.Errorf("Commands(full) = %d, want 0", n)
267+ }
268+}
269+
270+// TestGenerateCapturesInputTokens: the compression trigger prefers the server's
271+// own count of the context to its estimate, and that count only exists inside
272+// the WrapModel hook. A fake response carrying Usage must surface through
273+// LastInputTokens; ForgetInputTokens must clear it.
274+func TestGenerateCapturesInputTokens(t *testing.T) {
275+ withUsage := modelTurn(ai.NewTextPart("ok"))
276+ withUsage.Usage = &ai.GenerationUsage{InputTokens: 1234, OutputTokens: 5}
277+
278+ g, _ := fakeGenkit(t, withUsage)
279+ e := testEngine(g)
280+ if _, _, err := e.Generate(context.Background(),
281+ []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil {
282+ t.Fatalf("Generate: %v", err)
283+ }
284+ if got := e.LastInputTokens(); got != 1234 {
285+ t.Errorf("LastInputTokens() = %d, want 1234", got)
286+ }
287+ e.ForgetInputTokens()
288+ if got := e.LastInputTokens(); got != 0 {
289+ t.Errorf("after ForgetInputTokens, LastInputTokens() = %d, want 0", got)
290+ }
291+}
292+
293+// TestSummarize: the summary request declares no tools (the model must write,
294+// not act), goes to the Engine's own model reference, and the model's text
295+// comes back trimmed. An empty answer is an error — replacing the history with
296+// nothing is worse than keeping it.
297+func TestSummarize(t *testing.T) {
298+ g := genkit.Init(context.Background())
299+ var sawTools int
300+ answers := []string{" ## Goal\nnotes\n", ""}
301+ call := 0
302+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
303+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
304+ func(_ context.Context, r *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
305+ sawTools = len(r.Tools)
306+ a := answers[call]
307+ call++
308+ return modelTurn(ai.NewTextPart(a)), nil
309+ })
310+ e := testEngine(g)
311+
312+ req := []*ai.Message{ai.NewSystemTextMessage("note-taker"), ai.NewUserTextMessage("summarise")}
313+ text, err := e.Summarize(context.Background(), req, 100)
314+ if err != nil {
315+ t.Fatalf("Summarize: %v", err)
316+ }
317+ if text != "## Goal\nnotes" {
318+ t.Errorf("text = %q, want the trimmed answer", text)
319+ }
320+ if sawTools != 0 {
321+ t.Errorf("the summary request declared %d tool(s), want none", sawTools)
322+ }
323+ if _, err := e.Summarize(context.Background(), req, 100); err == nil {
324+ t.Error("an empty answer must be an error")
325+ }
326+}
327+
328+// TestFileOpsKeepsTheOrder: the recap lists file operations in the order they
329+// ran, across the three tools — a read then an edit must not come out as "all
330+// reads, then all edits", which a per-tool pass would produce. Commands stay
331+// bash-only: the file tools have their own column.
332+func TestFileOpsKeepsTheOrder(t *testing.T) {
333+ req := func(ref, name string, in map[string]any) *ai.Part {
334+ return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: name, Input: in})
335+ }
336+ resp := func(ref, name string) *ai.Part {
337+ return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: name, Output: "ok"})
338+ }
339+ history := []*ai.Message{
340+ ai.NewModelMessage(req("a", "read_file", map[string]any{"path": "f.go", "start": 1.0, "end": 9.0})),
341+ ai.NewMessage(ai.RoleTool, nil, resp("a", "read_file")),
342+ ai.NewModelMessage(req("b", "bash", map[string]any{"command": "go vet ./..."})),
343+ ai.NewMessage(ai.RoleTool, nil, resp("b", "bash")),
344+ ai.NewModelMessage(req("c", "edit_file", map[string]any{"path": "f.go", "edits": []any{map[string]any{"old": "a", "new": "b"}, map[string]any{"old": "c", "new": "d"}}})),
345+ ai.NewMessage(ai.RoleTool, nil, resp("c", "edit_file")),
346+ ai.NewModelMessage(req("d", "write_file", map[string]any{"path": "notes.md", "content": "x"})),
347+ ai.NewMessage(ai.RoleTool, nil, resp("d", "write_file")),
348+ }
349+ want := []string{"read_file f.go 1-9", "edit_file f.go (2 edit(s))", "write_file notes.md"}
350+ got := FileOps(history)
351+ if len(got) != len(want) {
352+ t.Fatalf("FileOps = %q, want %q", got, want)
353+ }
354+ for i := range want {
355+ if got[i] != want[i] {
356+ t.Errorf("FileOps[%d] = %q, want %q", i, got[i], want[i])
357+ }
358+ }
359+ if n := Commands(history); n != 1 {
360+ t.Errorf("Commands = %d, want 1 — file ops are not commands", n)
361+ }
362+}
added internal/engine/explain.go +89 -0
new file mode 100644
@@ -0,0 +1,89 @@
1+package engine
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net"
7+ "regexp"
8+ "strconv"
9+ "strings"
10+
11+ "github.com/openai/openai-go"
12+)
13+
14+// statusInText recovers the HTTP status from the OpenAI client's error text
15+// (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors
16+// with %v instead of %w, and the typed error is then out of reach of errors.As;
17+// the text still carries the number.
18+var statusInText = regexp.MustCompile(`": (\d{3}) `)
19+
20+// Explain maps a failed generation to one line the user can act on. The REPL
21+// prints `[error: …]` and moves on; a stack of wrapped messages there is noise
22+// on stage, while "start llama-server with --jinja" is the whole diagnosis.
23+// Unknown errors come back unchanged: better a raw message than a wrong hint.
24+func (p *openaiCompat) Explain(err error, b Backend) string {
25+ if err == nil {
26+ return ""
27+ }
28+ msg := err.Error()
29+
30+ status := 0
31+ var apiErr *openai.Error
32+ if errors.As(err, &apiErr) && apiErr.Response != nil {
33+ status = apiErr.Response.StatusCode
34+ } else if m := statusInText.FindStringSubmatch(msg); m != nil {
35+ status, _ = strconv.Atoi(m[1])
36+ }
37+
38+ switch {
39+ case connectionRefused(err, msg):
40+ return fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint)
41+
42+ // llama-server without --jinja refuses `tools` with a message naming the
43+ // flag. It is the one llama.cpp error a first-time user hits, so it gets
44+ // its own line rather than the generic "server error".
45+ case strings.Contains(msg, "jinja"):
46+ return fmt.Sprintf("%s: tool calls need llama-server started with --jinja", p.name)
47+
48+ // The variable named here is the one Resolve actually read (yaml `apiKeyEnv`,
49+ // else the provider's default). When there is none — DMR ignores keys — a
50+ // 401 means the URL is not the server we think it is, so that is the hint.
51+ case status == 401 || status == 403:
52+ if b.APIKeyEnv == "" {
53+ return fmt.Sprintf("%s: authentication failed at %s — this server should not need a key; check baseUrl", p.name, b.BaseURL)
54+ }
55+ return fmt.Sprintf("%s: authentication failed at %s — is $%s set and valid?", p.name, b.BaseURL, b.APIKeyEnv)
56+
57+ case status == 404:
58+ return fmt.Sprintf("%s: %q not found at %s — %s", p.name, b.Model, b.BaseURL, fmt.Sprintf(p.notFoundHint, b.Model))
59+
60+ case status == 429:
61+ return fmt.Sprintf("%s: rate limited — wait, or lower the pace", p.name)
62+
63+ case status >= 500:
64+ return fmt.Sprintf("%s: server error %d — %s", p.name, status, firstLine(msg))
65+ }
66+ return msg
67+}
68+
69+// connectionRefused: a dial that failed, whatever the wrapping. The typed check
70+// covers the errors.As path; the text check covers Genkit's %v rebuilds.
71+func connectionRefused(err error, msg string) bool {
72+ var opErr *net.OpError
73+ if errors.As(err, &opErr) && opErr.Op == "dial" {
74+ return true
75+ }
76+ return strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host")
77+}
78+
79+// firstLine keeps a server's message to its first line, cut at 120 columns —
80+// the same one-line-per-item rule as the command recap.
81+func firstLine(s string) string {
82+ if i := strings.IndexByte(s, '\n'); i >= 0 {
83+ s = s[:i]
84+ }
85+ if r := []rune(s); len(r) > 120 {
86+ return string(r[:119]) + "…"
87+ }
88+ return s
89+}
new file mode 100644
@@ -0,0 +1,89 @@
1+package engine
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net"
7+ "regexp"
8+ "strconv"
9+ "strings"
10+
11+ "github.com/openai/openai-go"
12+)
13+
14+// statusInText recovers the HTTP status from the OpenAI client's error text
15+// (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors
16+// with %v instead of %w, and the typed error is then out of reach of errors.As;
17+// the text still carries the number.
18+var statusInText = regexp.MustCompile(`": (\d{3}) `)
19+
20+// Explain maps a failed generation to one line the user can act on. The REPL
21+// prints `[error: …]` and moves on; a stack of wrapped messages there is noise
22+// on stage, while "start llama-server with --jinja" is the whole diagnosis.
23+// Unknown errors come back unchanged: better a raw message than a wrong hint.
24+func (p *openaiCompat) Explain(err error, b Backend) string {
25+ if err == nil {
26+ return ""
27+ }
28+ msg := err.Error()
29+
30+ status := 0
31+ var apiErr *openai.Error
32+ if errors.As(err, &apiErr) && apiErr.Response != nil {
33+ status = apiErr.Response.StatusCode
34+ } else if m := statusInText.FindStringSubmatch(msg); m != nil {
35+ status, _ = strconv.Atoi(m[1])
36+ }
37+
38+ switch {
39+ case connectionRefused(err, msg):
40+ return fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint)
41+
42+ // llama-server without --jinja refuses `tools` with a message naming the
43+ // flag. It is the one llama.cpp error a first-time user hits, so it gets
44+ // its own line rather than the generic "server error".
45+ case strings.Contains(msg, "jinja"):
46+ return fmt.Sprintf("%s: tool calls need llama-server started with --jinja", p.name)
47+
48+ // The variable named here is the one Resolve actually read (yaml `apiKeyEnv`,
49+ // else the provider's default). When there is none — DMR ignores keys — a
50+ // 401 means the URL is not the server we think it is, so that is the hint.
51+ case status == 401 || status == 403:
52+ if b.APIKeyEnv == "" {
53+ return fmt.Sprintf("%s: authentication failed at %s — this server should not need a key; check baseUrl", p.name, b.BaseURL)
54+ }
55+ return fmt.Sprintf("%s: authentication failed at %s — is $%s set and valid?", p.name, b.BaseURL, b.APIKeyEnv)
56+
57+ case status == 404:
58+ return fmt.Sprintf("%s: %q not found at %s — %s", p.name, b.Model, b.BaseURL, fmt.Sprintf(p.notFoundHint, b.Model))
59+
60+ case status == 429:
61+ return fmt.Sprintf("%s: rate limited — wait, or lower the pace", p.name)
62+
63+ case status >= 500:
64+ return fmt.Sprintf("%s: server error %d — %s", p.name, status, firstLine(msg))
65+ }
66+ return msg
67+}
68+
69+// connectionRefused: a dial that failed, whatever the wrapping. The typed check
70+// covers the errors.As path; the text check covers Genkit's %v rebuilds.
71+func connectionRefused(err error, msg string) bool {
72+ var opErr *net.OpError
73+ if errors.As(err, &opErr) && opErr.Op == "dial" {
74+ return true
75+ }
76+ return strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host")
77+}
78+
79+// firstLine keeps a server's message to its first line, cut at 120 columns —
80+// the same one-line-per-item rule as the command recap.
81+func firstLine(s string) string {
82+ if i := strings.IndexByte(s, '\n'); i >= 0 {
83+ s = s[:i]
84+ }
85+ if r := []rune(s); len(r) > 120 {
86+ return string(r[:119]) + "…"
87+ }
88+ return s
89+}
added internal/engine/gap_test.go +130 -0
new file mode 100644
@@ -0,0 +1,130 @@
1+// Tests for the spacing of the streamed text. The bug they guard: the model
2+// ends its sentence with line breaks — more and more of them as the turns go by
3+// — and the screen hollowed out by as much. Reproducible end to end with
4+// `SCENARIO=padding python3 ../03-one-agent-one-tool/docs/fake-engine.py`.
5+package engine
6+
7+import "testing"
8+
9+// next is called once per streamed chunk; we check what gets printed for each
10+// of them, in order.
11+func TestGapHoldsTrailingBlanks(t *testing.T) {
12+ cases := []struct {
13+ name string
14+ fragments []string
15+ want []string
16+ }{{
17+ name: "plain text",
18+ fragments: []string{"Hello"},
19+ want: []string{"Hello"},
20+ }, {
21+ name: "trailing whitespace is not printed",
22+ fragments: []string{"Let me look:\n\n"},
23+ want: []string{"Let me look:"},
24+ }, {
25+ name: "it is printed when text follows, down to one blank line",
26+ fragments: []string{"para one\n\n\n\n\n", "para two"},
27+ want: []string{"para one", "\n\npara two"},
28+ }, {
29+ name: "leading whitespace is dropped before the first word",
30+ fragments: []string{"\n\n", "Hello"},
31+ want: []string{"", "Hello"},
32+ }, {
33+ name: "all-whitespace chunks: accumulated, then capped",
34+ fragments: []string{"a", "\n", "\n", "\n", "\n", "b"},
35+ want: []string{"a", "", "", "", "", "\n\nb"},
36+ }, {
37+ name: "the indentation of a code block survives",
38+ fragments: []string{"code:", "\n fmt.Println()"},
39+ want: []string{"code:", "\n fmt.Println()"},
40+ }, {
41+ name: "a single break between two lines stays single",
42+ fragments: []string{"line one\n", "line two"},
43+ want: []string{"line one", "\nline two"},
44+ }, {
45+ name: "empty chunk",
46+ fragments: []string{"a", "", "b"},
47+ want: []string{"a", "", "b"},
48+ }}
49+
50+ for _, c := range cases {
51+ t.Run(c.name, func(t *testing.T) {
52+ var g gap
53+ for i, frag := range c.fragments {
54+ if got := g.next(frag); got != c.want[i] {
55+ t.Errorf("chunk %d (%q): printed %q, want %q",
56+ i, frag, got, c.want[i])
57+ }
58+ }
59+ })
60+ }
61+}
62+
63+// drop is called when a tool is about to write: the whitespace held back
64+// belonged to the text before it, and printing it after the 🛠️ line would
65+// reopen the hole.
66+func TestGapDropForgetsHeldBlanks(t *testing.T) {
67+ var g gap
68+ if got := g.next("Let me look:\n\n\n"); got != "Let me look:" {
69+ t.Fatalf("printed %q", got)
70+ }
71+ g.drop() // 🛠️ bash: … gets printed here
72+ if got := g.next("Now let me check:"); got != "Now let me check:" {
73+ t.Errorf("after drop: printed %q, want %q with no whitespace in front",
74+ got, "Now let me check:")
75+ }
76+}
77+
78+// The full scenario of the bug: four turns whose trailing whitespace grows, a
79+// tool between each. None of that whitespace may be printed.
80+func TestGapPaddingScenario(t *testing.T) {
81+ var g gap
82+ turns := []string{
83+ "Let me look at the file:" + nl(2),
84+ "Now let me check something else:" + nl(4),
85+ "Let me try another approach:" + nl(7),
86+ "Let me rebuild the program:" + nl(11),
87+ }
88+ for i, turn := range turns {
89+ out := g.next(turn)
90+ if hasBlank(out) {
91+ t.Errorf("turn %d: %q contains trailing whitespace", i, out)
92+ }
93+ g.drop() // the tool writes its 🛠️ line
94+ }
95+}
96+
97+func TestSqueeze(t *testing.T) {
98+ cases := map[string]string{
99+ "": "",
100+ "\n": "\n",
101+ "\n\n": "\n\n",
102+ "\n\n\n": "\n\n",
103+ "\n\n\n\n\n\n\n\n": "\n\n",
104+ "\n ": "\n ", // indentation preserved
105+ "\n\n\n ": "\n\n ", // capped, indentation kept
106+ "a\n\n\n\nb": "a\n\nb", // in the middle of the text too
107+ " ": " ", // spaces are left alone
108+ }
109+ for in, want := range cases {
110+ if got := squeeze(in); got != want {
111+ t.Errorf("squeeze(%q) = %q, want %q", in, got, want)
112+ }
113+ }
114+}
115+
116+func nl(n int) string {
117+ s := ""
118+ for range n {
119+ s += "\n"
120+ }
121+ return s
122+}
123+
124+func hasBlank(s string) bool {
125+ if s == "" {
126+ return false
127+ }
128+ last := s[len(s)-1]
129+ return last == '\n' || last == ' ' || last == '\t' || last == '\r'
130+}
new file mode 100644
@@ -0,0 +1,130 @@
1+// Tests for the spacing of the streamed text. The bug they guard: the model
2+// ends its sentence with line breaks — more and more of them as the turns go by
3+// — and the screen hollowed out by as much. Reproducible end to end with
4+// `SCENARIO=padding python3 ../03-one-agent-one-tool/docs/fake-engine.py`.
5+package engine
6+
7+import "testing"
8+
9+// next is called once per streamed chunk; we check what gets printed for each
10+// of them, in order.
11+func TestGapHoldsTrailingBlanks(t *testing.T) {
12+ cases := []struct {
13+ name string
14+ fragments []string
15+ want []string
16+ }{{
17+ name: "plain text",
18+ fragments: []string{"Hello"},
19+ want: []string{"Hello"},
20+ }, {
21+ name: "trailing whitespace is not printed",
22+ fragments: []string{"Let me look:\n\n"},
23+ want: []string{"Let me look:"},
24+ }, {
25+ name: "it is printed when text follows, down to one blank line",
26+ fragments: []string{"para one\n\n\n\n\n", "para two"},
27+ want: []string{"para one", "\n\npara two"},
28+ }, {
29+ name: "leading whitespace is dropped before the first word",
30+ fragments: []string{"\n\n", "Hello"},
31+ want: []string{"", "Hello"},
32+ }, {
33+ name: "all-whitespace chunks: accumulated, then capped",
34+ fragments: []string{"a", "\n", "\n", "\n", "\n", "b"},
35+ want: []string{"a", "", "", "", "", "\n\nb"},
36+ }, {
37+ name: "the indentation of a code block survives",
38+ fragments: []string{"code:", "\n fmt.Println()"},
39+ want: []string{"code:", "\n fmt.Println()"},
40+ }, {
41+ name: "a single break between two lines stays single",
42+ fragments: []string{"line one\n", "line two"},
43+ want: []string{"line one", "\nline two"},
44+ }, {
45+ name: "empty chunk",
46+ fragments: []string{"a", "", "b"},
47+ want: []string{"a", "", "b"},
48+ }}
49+
50+ for _, c := range cases {
51+ t.Run(c.name, func(t *testing.T) {
52+ var g gap
53+ for i, frag := range c.fragments {
54+ if got := g.next(frag); got != c.want[i] {
55+ t.Errorf("chunk %d (%q): printed %q, want %q",
56+ i, frag, got, c.want[i])
57+ }
58+ }
59+ })
60+ }
61+}
62+
63+// drop is called when a tool is about to write: the whitespace held back
64+// belonged to the text before it, and printing it after the 🛠️ line would
65+// reopen the hole.
66+func TestGapDropForgetsHeldBlanks(t *testing.T) {
67+ var g gap
68+ if got := g.next("Let me look:\n\n\n"); got != "Let me look:" {
69+ t.Fatalf("printed %q", got)
70+ }
71+ g.drop() // 🛠️ bash: … gets printed here
72+ if got := g.next("Now let me check:"); got != "Now let me check:" {
73+ t.Errorf("after drop: printed %q, want %q with no whitespace in front",
74+ got, "Now let me check:")
75+ }
76+}
77+
78+// The full scenario of the bug: four turns whose trailing whitespace grows, a
79+// tool between each. None of that whitespace may be printed.
80+func TestGapPaddingScenario(t *testing.T) {
81+ var g gap
82+ turns := []string{
83+ "Let me look at the file:" + nl(2),
84+ "Now let me check something else:" + nl(4),
85+ "Let me try another approach:" + nl(7),
86+ "Let me rebuild the program:" + nl(11),
87+ }
88+ for i, turn := range turns {
89+ out := g.next(turn)
90+ if hasBlank(out) {
91+ t.Errorf("turn %d: %q contains trailing whitespace", i, out)
92+ }
93+ g.drop() // the tool writes its 🛠️ line
94+ }
95+}
96+
97+func TestSqueeze(t *testing.T) {
98+ cases := map[string]string{
99+ "": "",
100+ "\n": "\n",
101+ "\n\n": "\n\n",
102+ "\n\n\n": "\n\n",
103+ "\n\n\n\n\n\n\n\n": "\n\n",
104+ "\n ": "\n ", // indentation preserved
105+ "\n\n\n ": "\n\n ", // capped, indentation kept
106+ "a\n\n\n\nb": "a\n\nb", // in the middle of the text too
107+ " ": " ", // spaces are left alone
108+ }
109+ for in, want := range cases {
110+ if got := squeeze(in); got != want {
111+ t.Errorf("squeeze(%q) = %q, want %q", in, got, want)
112+ }
113+ }
114+}
115+
116+func nl(n int) string {
117+ s := ""
118+ for range n {
119+ s += "\n"
120+ }
121+ return s
122+}
123+
124+func hasBlank(s string) bool {
125+ if s == "" {
126+ return false
127+ }
128+ last := s[len(s)-1]
129+ return last == '\n' || last == ' ' || last == '\t' || last == '\r'
130+}
added internal/engine/openai_compat.go +240 -0
new file mode 100644
@@ -0,0 +1,240 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "os"
9+ "strings"
10+ "time"
11+
12+ "mm/internal/config"
13+
14+ "github.com/firebase/genkit/go/ai"
15+ "github.com/firebase/genkit/go/genkit"
16+ "github.com/firebase/genkit/go/plugins/compat_oai"
17+)
18+
19+// openaiCompat is the Provider for every server that speaks the OpenAI
20+// chat-completions protocol. Docker Model Runner and llama-server are two
21+// parametrisations of it; Ollama's /v1, vLLM and the remote APIs would be more
22+// entries in init() below — the point of keeping the struct data-only.
23+type openaiCompat struct {
24+ name string
25+
26+ baseURL string // used when the config gives none
27+ fallback string // tried when baseURL does not answer; "" = no fallback
28+
29+ // legacyBaseURLEnv keeps DMR_BASE_URL working for the `dmr` provider: the
30+ // variable is in every README and in the sandbox start scripts. The
31+ // provider-neutral AGENT_BASE_URL wins over it.
32+ legacyBaseURLEnv string
33+
34+ // apiKeyEnv names the variable holding the key; keyRequired says whether an
35+ // empty one is an error (a paid API) or just means "this server does not
36+ // check" (llama-server without --api-key). dummyKey is sent in that case
37+ // because the OpenAI client wants SOMETHING in the header.
38+ apiKeyEnv string
39+ keyRequired bool
40+ dummyKey string
41+
42+ // Words for Explain: how to start the server, how to get the model.
43+ startHint string
44+ notFoundHint string // %s = model
45+
46+ // probe is the server-specific part of Probe; nil = reachability only.
47+ probe func(ctx context.Context, b Backend) Info
48+}
49+
50+func init() {
51+ register(&openaiCompat{
52+ name: "dmr",
53+ baseURL: "http://localhost:12434/engines/v1",
54+ fallback: "http://host.docker.internal:12434/engines/v1",
55+ legacyBaseURLEnv: "DMR_BASE_URL",
56+ dummyKey: "not-needed", // DMR ignores the Authorization header
57+ startHint: "is Docker Model Runner running? (docker model status)",
58+ notFoundHint: "docker model pull %s",
59+ probe: probeModels,
60+ })
61+ register(&openaiCompat{
62+ name: "llamacpp",
63+ baseURL: "http://127.0.0.1:8080/v1", // llama-server's default host:port, plus the OpenAI prefix
64+ // llama-server only checks a key when started with --api-key; most
65+ // local set-ups are not, so an unset LLAMA_API_KEY is normal.
66+ apiKeyEnv: "LLAMA_API_KEY",
67+ dummyKey: "not-needed",
68+ startHint: "start it: llama-server -m <model.gguf> --jinja --port 8080",
69+ notFoundHint: "llama-server serves one model; check baseUrl ends with /v1 (model: %s)",
70+ probe: probeLlamaCpp,
71+ })
72+}
73+
74+func (p *openaiCompat) Name() string { return p.name }
75+
76+// Resolve applies the same precedence the `dmr` package used for its URL —
77+// environment, then config, then fallback if the first does not answer — and
78+// extends it to the key and the model.
79+func (p *openaiCompat) Resolve(cfg config.Config) (Backend, error) {
80+ b := Backend{Provider: p.name, Model: cfg.Model, ContextWindow: cfg.ContextWindow}
81+
82+ // An explicit environment override is taken as-is, without the fallback
83+ // probe: whoever set it knows where the server is.
84+ env := os.Getenv("AGENT_BASE_URL")
85+ if env == "" && p.legacyBaseURLEnv != "" {
86+ env = os.Getenv(p.legacyBaseURLEnv)
87+ }
88+ switch {
89+ case env != "":
90+ b.BaseURL = env
91+ default:
92+ base := cfg.BaseURL
93+ if base == "" {
94+ base = p.baseURL
95+ }
96+ // nil = the key is absent from the file, use the provider's fallback;
97+ // an explicit "" disables it (probe.yaml does that to pin the fake
98+ // engine). The distinction needs the pointer.
99+ fallback := p.fallback
100+ if cfg.Fallback != nil {
101+ fallback = *cfg.Fallback
102+ }
103+ b.BaseURL = base
104+ if fallback != "" && !reachable(base) {
105+ b.BaseURL = fallback
106+ }
107+ }
108+
109+ keyEnv := cfg.APIKeyEnv
110+ if keyEnv == "" {
111+ keyEnv = p.apiKeyEnv
112+ }
113+ b.APIKeyEnv = keyEnv
114+ if keyEnv != "" {
115+ b.APIKey = os.Getenv(keyEnv)
116+ }
117+ if b.APIKey == "" {
118+ if p.keyRequired {
119+ return b, fmt.Errorf("%s: %s is not set (the key never goes in the YAML: export it)", p.name, keyEnv)
120+ }
121+ b.APIKey = p.dummyKey
122+ }
123+ return b, nil
124+}
125+
126+// Open is the former dmr.Init with the constants replaced by the Backend.
127+func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, string, error) {
128+ client := &compat_oai.OpenAICompatible{
129+ Provider: p.name, // model prefix: "<provider>/<id>"
130+ BaseURL: b.BaseURL,
131+ APIKey: b.APIKey,
132+ }
133+ g := genkit.Init(ctx, genkit.WithPlugins(client))
134+ client.DefineModel(p.name, b.Model, ai.ModelOptions{
135+ Label: b.Model,
136+ Supports: &compat_oai.Multimodal, // enables Tools, Multiturn, SystemRole…
137+ })
138+ return g, p.name + "/" + b.Model, nil
139+}
140+
141+// Probe: reachability on /models — the one endpoint every OpenAI-compatible
142+// server has, and the one the fallback logic already relied on — then whatever
143+// the server-specific probe adds. The config's context window always wins over
144+// the server's: the operator knows what they started it with.
145+func (p *openaiCompat) Probe(ctx context.Context, b Backend) Info {
146+ var info Info
147+ if p.probe != nil {
148+ info = p.probe(ctx, b)
149+ } else {
150+ info.Reachable = reachable(b.BaseURL)
151+ }
152+ if !info.Reachable {
153+ info.Warnings = append(info.Warnings,
154+ fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint))
155+ }
156+ if b.ContextWindow > 0 {
157+ info.ContextWindow, info.ContextSource = b.ContextWindow, "config"
158+ }
159+ return info
160+}
161+
162+// probeClient: two seconds, like the old reachable(). A local server answers in
163+// milliseconds; a longer wait only delays the prompt when it is down.
164+var probeClient = &http.Client{Timeout: 2 * time.Second}
165+
166+// reachable reports whether an OpenAI-compatible endpoint answers on /models.
167+func reachable(baseURL string) bool {
168+ resp, err := probeClient.Get(baseURL + "/models")
169+ if err != nil {
170+ return false
171+ }
172+ defer resp.Body.Close()
173+ return resp.StatusCode == http.StatusOK
174+}
175+
176+// getJSON fetches and decodes one endpoint; false when anything failed. Probes
177+// do not distinguish "down" from "not that kind of server": both mean "no info".
178+func getJSON(url string, out any) bool {
179+ resp, err := probeClient.Get(url)
180+ if err != nil {
181+ return false
182+ }
183+ defer resp.Body.Close()
184+ if resp.StatusCode != http.StatusOK {
185+ return false
186+ }
187+ return json.NewDecoder(resp.Body).Decode(out) == nil
188+}
189+
190+// probeModels (DMR): lists /models and warns when the configured model is not
191+// among them. Measured on DMR: asking for a model that is not pulled returns an
192+// error only once the request is sent — after the spinner has run a while.
193+func probeModels(_ context.Context, b Backend) Info {
194+ var list struct {
195+ Data []struct {
196+ ID string `json:"id"`
197+ } `json:"data"`
198+ }
199+ info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)}
200+ if !info.Reachable || len(list.Data) == 0 {
201+ return info
202+ }
203+ for _, m := range list.Data {
204+ if m.ID == b.Model {
205+ return info
206+ }
207+ }
208+ info.Warnings = append(info.Warnings,
209+ fmt.Sprintf("%s: model %q is not in the server's list — docker model pull %s", b.Provider, b.Model, b.Model))
210+ return info
211+}
212+
213+// probeLlamaCpp: llama-server exposes GET /props, one level above /v1, with the
214+// context size it actually serves (`default_generation_settings.n_ctx`) — not
215+// the one the model was trained with, which is what /v1/models reports. The
216+// served size is the number the context-compression work needs.
217+func probeLlamaCpp(_ context.Context, b Backend) Info {
218+ var props struct {
219+ Settings struct {
220+ NCtx int `json:"n_ctx"`
221+ } `json:"default_generation_settings"`
222+ ChatTemplate string `json:"chat_template"`
223+ }
224+ root := strings.TrimSuffix(strings.TrimSuffix(b.BaseURL, "/"), "/v1")
225+ info := Info{Reachable: getJSON(root+"/props", &props)}
226+ if !info.Reachable {
227+ // /props missing but /models answering: some other OpenAI-compatible
228+ // server is on that port. Reachable, but nothing llama.cpp to report.
229+ info.Reachable = reachable(b.BaseURL)
230+ return info
231+ }
232+ if props.Settings.NCtx > 0 {
233+ info.ContextWindow, info.ContextSource = props.Settings.NCtx, "/props"
234+ }
235+ if props.ChatTemplate == "" {
236+ info.Warnings = append(info.Warnings,
237+ "llamacpp: the server reports no chat template; tool calls need one (and --jinja)")
238+ }
239+ return info
240+}
new file mode 100644
@@ -0,0 +1,240 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "os"
9+ "strings"
10+ "time"
11+
12+ "mm/internal/config"
13+
14+ "github.com/firebase/genkit/go/ai"
15+ "github.com/firebase/genkit/go/genkit"
16+ "github.com/firebase/genkit/go/plugins/compat_oai"
17+)
18+
19+// openaiCompat is the Provider for every server that speaks the OpenAI
20+// chat-completions protocol. Docker Model Runner and llama-server are two
21+// parametrisations of it; Ollama's /v1, vLLM and the remote APIs would be more
22+// entries in init() below — the point of keeping the struct data-only.
23+type openaiCompat struct {
24+ name string
25+
26+ baseURL string // used when the config gives none
27+ fallback string // tried when baseURL does not answer; "" = no fallback
28+
29+ // legacyBaseURLEnv keeps DMR_BASE_URL working for the `dmr` provider: the
30+ // variable is in every README and in the sandbox start scripts. The
31+ // provider-neutral AGENT_BASE_URL wins over it.
32+ legacyBaseURLEnv string
33+
34+ // apiKeyEnv names the variable holding the key; keyRequired says whether an
35+ // empty one is an error (a paid API) or just means "this server does not
36+ // check" (llama-server without --api-key). dummyKey is sent in that case
37+ // because the OpenAI client wants SOMETHING in the header.
38+ apiKeyEnv string
39+ keyRequired bool
40+ dummyKey string
41+
42+ // Words for Explain: how to start the server, how to get the model.
43+ startHint string
44+ notFoundHint string // %s = model
45+
46+ // probe is the server-specific part of Probe; nil = reachability only.
47+ probe func(ctx context.Context, b Backend) Info
48+}
49+
50+func init() {
51+ register(&openaiCompat{
52+ name: "dmr",
53+ baseURL: "http://localhost:12434/engines/v1",
54+ fallback: "http://host.docker.internal:12434/engines/v1",
55+ legacyBaseURLEnv: "DMR_BASE_URL",
56+ dummyKey: "not-needed", // DMR ignores the Authorization header
57+ startHint: "is Docker Model Runner running? (docker model status)",
58+ notFoundHint: "docker model pull %s",
59+ probe: probeModels,
60+ })
61+ register(&openaiCompat{
62+ name: "llamacpp",
63+ baseURL: "http://127.0.0.1:8080/v1", // llama-server's default host:port, plus the OpenAI prefix
64+ // llama-server only checks a key when started with --api-key; most
65+ // local set-ups are not, so an unset LLAMA_API_KEY is normal.
66+ apiKeyEnv: "LLAMA_API_KEY",
67+ dummyKey: "not-needed",
68+ startHint: "start it: llama-server -m <model.gguf> --jinja --port 8080",
69+ notFoundHint: "llama-server serves one model; check baseUrl ends with /v1 (model: %s)",
70+ probe: probeLlamaCpp,
71+ })
72+}
73+
74+func (p *openaiCompat) Name() string { return p.name }
75+
76+// Resolve applies the same precedence the `dmr` package used for its URL —
77+// environment, then config, then fallback if the first does not answer — and
78+// extends it to the key and the model.
79+func (p *openaiCompat) Resolve(cfg config.Config) (Backend, error) {
80+ b := Backend{Provider: p.name, Model: cfg.Model, ContextWindow: cfg.ContextWindow}
81+
82+ // An explicit environment override is taken as-is, without the fallback
83+ // probe: whoever set it knows where the server is.
84+ env := os.Getenv("AGENT_BASE_URL")
85+ if env == "" && p.legacyBaseURLEnv != "" {
86+ env = os.Getenv(p.legacyBaseURLEnv)
87+ }
88+ switch {
89+ case env != "":
90+ b.BaseURL = env
91+ default:
92+ base := cfg.BaseURL
93+ if base == "" {
94+ base = p.baseURL
95+ }
96+ // nil = the key is absent from the file, use the provider's fallback;
97+ // an explicit "" disables it (probe.yaml does that to pin the fake
98+ // engine). The distinction needs the pointer.
99+ fallback := p.fallback
100+ if cfg.Fallback != nil {
101+ fallback = *cfg.Fallback
102+ }
103+ b.BaseURL = base
104+ if fallback != "" && !reachable(base) {
105+ b.BaseURL = fallback
106+ }
107+ }
108+
109+ keyEnv := cfg.APIKeyEnv
110+ if keyEnv == "" {
111+ keyEnv = p.apiKeyEnv
112+ }
113+ b.APIKeyEnv = keyEnv
114+ if keyEnv != "" {
115+ b.APIKey = os.Getenv(keyEnv)
116+ }
117+ if b.APIKey == "" {
118+ if p.keyRequired {
119+ return b, fmt.Errorf("%s: %s is not set (the key never goes in the YAML: export it)", p.name, keyEnv)
120+ }
121+ b.APIKey = p.dummyKey
122+ }
123+ return b, nil
124+}
125+
126+// Open is the former dmr.Init with the constants replaced by the Backend.
127+func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, string, error) {
128+ client := &compat_oai.OpenAICompatible{
129+ Provider: p.name, // model prefix: "<provider>/<id>"
130+ BaseURL: b.BaseURL,
131+ APIKey: b.APIKey,
132+ }
133+ g := genkit.Init(ctx, genkit.WithPlugins(client))
134+ client.DefineModel(p.name, b.Model, ai.ModelOptions{
135+ Label: b.Model,
136+ Supports: &compat_oai.Multimodal, // enables Tools, Multiturn, SystemRole…
137+ })
138+ return g, p.name + "/" + b.Model, nil
139+}
140+
141+// Probe: reachability on /models — the one endpoint every OpenAI-compatible
142+// server has, and the one the fallback logic already relied on — then whatever
143+// the server-specific probe adds. The config's context window always wins over
144+// the server's: the operator knows what they started it with.
145+func (p *openaiCompat) Probe(ctx context.Context, b Backend) Info {
146+ var info Info
147+ if p.probe != nil {
148+ info = p.probe(ctx, b)
149+ } else {
150+ info.Reachable = reachable(b.BaseURL)
151+ }
152+ if !info.Reachable {
153+ info.Warnings = append(info.Warnings,
154+ fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint))
155+ }
156+ if b.ContextWindow > 0 {
157+ info.ContextWindow, info.ContextSource = b.ContextWindow, "config"
158+ }
159+ return info
160+}
161+
162+// probeClient: two seconds, like the old reachable(). A local server answers in
163+// milliseconds; a longer wait only delays the prompt when it is down.
164+var probeClient = &http.Client{Timeout: 2 * time.Second}
165+
166+// reachable reports whether an OpenAI-compatible endpoint answers on /models.
167+func reachable(baseURL string) bool {
168+ resp, err := probeClient.Get(baseURL + "/models")
169+ if err != nil {
170+ return false
171+ }
172+ defer resp.Body.Close()
173+ return resp.StatusCode == http.StatusOK
174+}
175+
176+// getJSON fetches and decodes one endpoint; false when anything failed. Probes
177+// do not distinguish "down" from "not that kind of server": both mean "no info".
178+func getJSON(url string, out any) bool {
179+ resp, err := probeClient.Get(url)
180+ if err != nil {
181+ return false
182+ }
183+ defer resp.Body.Close()
184+ if resp.StatusCode != http.StatusOK {
185+ return false
186+ }
187+ return json.NewDecoder(resp.Body).Decode(out) == nil
188+}
189+
190+// probeModels (DMR): lists /models and warns when the configured model is not
191+// among them. Measured on DMR: asking for a model that is not pulled returns an
192+// error only once the request is sent — after the spinner has run a while.
193+func probeModels(_ context.Context, b Backend) Info {
194+ var list struct {
195+ Data []struct {
196+ ID string `json:"id"`
197+ } `json:"data"`
198+ }
199+ info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)}
200+ if !info.Reachable || len(list.Data) == 0 {
201+ return info
202+ }
203+ for _, m := range list.Data {
204+ if m.ID == b.Model {
205+ return info
206+ }
207+ }
208+ info.Warnings = append(info.Warnings,
209+ fmt.Sprintf("%s: model %q is not in the server's list — docker model pull %s", b.Provider, b.Model, b.Model))
210+ return info
211+}
212+
213+// probeLlamaCpp: llama-server exposes GET /props, one level above /v1, with the
214+// context size it actually serves (`default_generation_settings.n_ctx`) — not
215+// the one the model was trained with, which is what /v1/models reports. The
216+// served size is the number the context-compression work needs.
217+func probeLlamaCpp(_ context.Context, b Backend) Info {
218+ var props struct {
219+ Settings struct {
220+ NCtx int `json:"n_ctx"`
221+ } `json:"default_generation_settings"`
222+ ChatTemplate string `json:"chat_template"`
223+ }
224+ root := strings.TrimSuffix(strings.TrimSuffix(b.BaseURL, "/"), "/v1")
225+ info := Info{Reachable: getJSON(root+"/props", &props)}
226+ if !info.Reachable {
227+ // /props missing but /models answering: some other OpenAI-compatible
228+ // server is on that port. Reachable, but nothing llama.cpp to report.
229+ info.Reachable = reachable(b.BaseURL)
230+ return info
231+ }
232+ if props.Settings.NCtx > 0 {
233+ info.ContextWindow, info.ContextSource = props.Settings.NCtx, "/props"
234+ }
235+ if props.ChatTemplate == "" {
236+ info.Warnings = append(info.Warnings,
237+ "llamacpp: the server reports no chat template; tool calls need one (and --jinja)")
238+ }
239+ return info
240+}
added internal/engine/provider.go +97 -0
new file mode 100644
@@ -0,0 +1,97 @@
1+package engine
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strings"
8+
9+ "mm/internal/config"
10+
11+ "github.com/firebase/genkit/go/genkit"
12+)
13+
14+// Provider is what changes from one LLM server to the next. There is ONE
15+// implementation per wire protocol, not one per vendor: Docker Model Runner and
16+// llama-server both speak the OpenAI chat-completions format, and differ only
17+// in a base URL, a key policy and the words of their error messages. Adding a
18+// server that speaks the same protocol is a registry entry (see openai_compat.go);
19+// adding a protocol (Anthropic's Messages API, Ollama's /api/chat) is a new type.
20+type Provider interface {
21+ // Name is the registry key AND the Genkit plugin name, hence the prefix of
22+ // the model reference Generate uses ("dmr/<id>").
23+ Name() string
24+
25+ // Resolve turns the loaded config into a concrete Backend: the URL after
26+ // environment overrides and the fallback probe, the key read from the
27+ // environment, the model in the provider's own naming.
28+ Resolve(cfg config.Config) (Backend, error)
29+
30+ // Open initialises Genkit with this provider's plugin, declares the model
31+ // (stating that it knows how to call tools) and returns the full model
32+ // reference. One Genkit per Engine for now; several backends in one Genkit
33+ // is the seam for a `/model` command later.
34+ Open(ctx context.Context, b Backend) (*genkit.Genkit, string, error)
35+
36+ // Probe learns what it can about the server before the first question.
37+ // Best effort, never fatal: a server started AFTER the agent is a normal
38+ // demo situation, not an error.
39+ Probe(ctx context.Context, b Backend) Info
40+
41+ // Explain rewrites a transport error as ONE actionable line, in the words of
42+ // this server: "start llama-server with --jinja" means nothing to a DMR
43+ // user, and "docker model pull" nothing to a llama.cpp user.
44+ Explain(err error, b Backend) string
45+}
46+
47+// Backend is a resolved target: everything Open and Explain need, and nothing
48+// that still depends on the environment.
49+type Backend struct {
50+ Provider string
51+ BaseURL string
52+ Model string // in the provider's naming, no Genkit prefix
53+ APIKey string // the resolved value; a placeholder when the server ignores it
54+ // APIKeyEnv is the variable the key was read from — the yaml's `apiKeyEnv`,
55+ // else the provider's default — or "" when neither names one. Explain cites
56+ // it on a 401: the first version named a variable nothing read, and a
57+ // message that sends the user to `export` the wrong name is worse than none.
58+ APIKeyEnv string
59+ // ContextWindow is the hint from the config, 0 when absent. The probe may
60+ // fill it from the server (llama-server tells its n_ctx); the config wins,
61+ // because the operator knows what they started the server with.
62+ ContextWindow int
63+}
64+
65+// Info is what Probe found out. Warnings are printed one per line at start-up,
66+// dimmed like the config line; they are advice, not failures.
67+type Info struct {
68+ Reachable bool
69+ ContextWindow int // 0 = unknown
70+ ContextSource string // "config", "/props", … — a number without an origin never gets corrected
71+ Warnings []string
72+}
73+
74+// registry: the providers this build knows. Keys are the values `provider:`
75+// accepts in agent.yaml.
76+var registry = map[string]Provider{}
77+
78+func register(p Provider) { registry[p.Name()] = p }
79+
80+// Lookup finds a provider by its config name. The error lists the known names:
81+// the user typed one, the fix is to pick another.
82+func Lookup(name string) (Provider, error) {
83+ if p, ok := registry[name]; ok {
84+ return p, nil
85+ }
86+ return nil, fmt.Errorf("unknown provider %q (known: %s)", name, strings.Join(Names(), ", "))
87+}
88+
89+// Names lists the registered providers, sorted for stable messages.
90+func Names() []string {
91+ names := make([]string, 0, len(registry))
92+ for n := range registry {
93+ names = append(names, n)
94+ }
95+ sort.Strings(names)
96+ return names
97+}
new file mode 100644
@@ -0,0 +1,97 @@
1+package engine
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+ "strings"
8+
9+ "mm/internal/config"
10+
11+ "github.com/firebase/genkit/go/genkit"
12+)
13+
14+// Provider is what changes from one LLM server to the next. There is ONE
15+// implementation per wire protocol, not one per vendor: Docker Model Runner and
16+// llama-server both speak the OpenAI chat-completions format, and differ only
17+// in a base URL, a key policy and the words of their error messages. Adding a
18+// server that speaks the same protocol is a registry entry (see openai_compat.go);
19+// adding a protocol (Anthropic's Messages API, Ollama's /api/chat) is a new type.
20+type Provider interface {
21+ // Name is the registry key AND the Genkit plugin name, hence the prefix of
22+ // the model reference Generate uses ("dmr/<id>").
23+ Name() string
24+
25+ // Resolve turns the loaded config into a concrete Backend: the URL after
26+ // environment overrides and the fallback probe, the key read from the
27+ // environment, the model in the provider's own naming.
28+ Resolve(cfg config.Config) (Backend, error)
29+
30+ // Open initialises Genkit with this provider's plugin, declares the model
31+ // (stating that it knows how to call tools) and returns the full model
32+ // reference. One Genkit per Engine for now; several backends in one Genkit
33+ // is the seam for a `/model` command later.
34+ Open(ctx context.Context, b Backend) (*genkit.Genkit, string, error)
35+
36+ // Probe learns what it can about the server before the first question.
37+ // Best effort, never fatal: a server started AFTER the agent is a normal
38+ // demo situation, not an error.
39+ Probe(ctx context.Context, b Backend) Info
40+
41+ // Explain rewrites a transport error as ONE actionable line, in the words of
42+ // this server: "start llama-server with --jinja" means nothing to a DMR
43+ // user, and "docker model pull" nothing to a llama.cpp user.
44+ Explain(err error, b Backend) string
45+}
46+
47+// Backend is a resolved target: everything Open and Explain need, and nothing
48+// that still depends on the environment.
49+type Backend struct {
50+ Provider string
51+ BaseURL string
52+ Model string // in the provider's naming, no Genkit prefix
53+ APIKey string // the resolved value; a placeholder when the server ignores it
54+ // APIKeyEnv is the variable the key was read from — the yaml's `apiKeyEnv`,
55+ // else the provider's default — or "" when neither names one. Explain cites
56+ // it on a 401: the first version named a variable nothing read, and a
57+ // message that sends the user to `export` the wrong name is worse than none.
58+ APIKeyEnv string
59+ // ContextWindow is the hint from the config, 0 when absent. The probe may
60+ // fill it from the server (llama-server tells its n_ctx); the config wins,
61+ // because the operator knows what they started the server with.
62+ ContextWindow int
63+}
64+
65+// Info is what Probe found out. Warnings are printed one per line at start-up,
66+// dimmed like the config line; they are advice, not failures.
67+type Info struct {
68+ Reachable bool
69+ ContextWindow int // 0 = unknown
70+ ContextSource string // "config", "/props", … — a number without an origin never gets corrected
71+ Warnings []string
72+}
73+
74+// registry: the providers this build knows. Keys are the values `provider:`
75+// accepts in agent.yaml.
76+var registry = map[string]Provider{}
77+
78+func register(p Provider) { registry[p.Name()] = p }
79+
80+// Lookup finds a provider by its config name. The error lists the known names:
81+// the user typed one, the fix is to pick another.
82+func Lookup(name string) (Provider, error) {
83+ if p, ok := registry[name]; ok {
84+ return p, nil
85+ }
86+ return nil, fmt.Errorf("unknown provider %q (known: %s)", name, strings.Join(Names(), ", "))
87+}
88+
89+// Names lists the registered providers, sorted for stable messages.
90+func Names() []string {
91+ names := make([]string, 0, len(registry))
92+ for n := range registry {
93+ names = append(names, n)
94+ }
95+ sort.Strings(names)
96+ return names
97+}
added internal/engine/provider_test.go +450 -0
new file mode 100644
@@ -0,0 +1,450 @@
1+// Tests for the provider layer, against httptest servers: the fake engine of
2+// the backup docs speaks plain OpenAI and cannot say "I am llama-server", nor
3+// fail on demand. What these tests guard: the URL precedence the `dmr` package
4+// had (env, then file, then fallback when the first does not answer), the
5+// llama.cpp probe, and the one-line error mapping — each line of the table in
6+// PROVIDERS.md §5.7 that this step implements.
7+package engine
8+
9+import (
10+ "context"
11+ "encoding/json"
12+ "net/http"
13+ "net/http/httptest"
14+ "os"
15+ "path/filepath"
16+ "strings"
17+ "testing"
18+
19+ "mm/internal/config"
20+
21+ "github.com/firebase/genkit/go/ai"
22+)
23+
24+// openaiServer is a minimal OpenAI-compatible server: /models lists `models`,
25+// every POST answers `status` with `body`. Enough to drive Resolve, Probe and
26+// Explain; Generate's happy path is covered by engine_test.go's fake model.
27+func openaiServer(t *testing.T, status int, body string, models ...string) *httptest.Server {
28+ t.Helper()
29+ mux := http.NewServeMux()
30+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
31+ if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") {
32+ var data []map[string]string
33+ for _, m := range models {
34+ data = append(data, map[string]string{"id": m, "object": "model"})
35+ }
36+ _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
37+ return
38+ }
39+ if r.Method == http.MethodPost {
40+ w.Header().Set("Content-Type", "application/json")
41+ w.WriteHeader(status)
42+ _, _ = w.Write([]byte(body))
43+ return
44+ }
45+ http.NotFound(w, r)
46+ })
47+ srv := httptest.NewServer(mux)
48+ t.Cleanup(srv.Close)
49+ return srv
50+}
51+
52+// closedURL returns a URL nothing listens on: a server started and stopped, so
53+// the port was free a moment ago.
54+func closedURL(t *testing.T) string {
55+ t.Helper()
56+ srv := httptest.NewServer(http.NotFoundHandler())
57+ url := srv.URL
58+ srv.Close()
59+ return url
60+}
61+
62+func ptr(s string) *string { return &s }
63+
64+// clearEnv: the precedence tests must not depend on the developer's shell,
65+// where DMR_BASE_URL is often exported.
66+func clearEnv(t *testing.T) {
67+ t.Helper()
68+ for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} {
69+ t.Setenv(k, "")
70+ }
71+}
72+
73+func TestLookupListsKnownProviders(t *testing.T) {
74+ _, err := Lookup("ollama")
75+ if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp") {
76+ t.Fatalf("Lookup(ollama) = %v, want an error naming the known providers", err)
77+ }
78+}
79+
80+// TestResolveURLPrecedence replays the rules of the former config.DMRBaseURL —
81+// env wins without probing, the fallback is taken only when baseUrl does not
82+// answer — and checks that llamacpp neither inherits DMR's URL nor its env var.
83+func TestResolveURLPrecedence(t *testing.T) {
84+ clearEnv(t)
85+ up := openaiServer(t, 200, "{}", "fake-model")
86+ down := closedURL(t)
87+
88+ cases := []struct {
89+ name string
90+ provider string
91+ cfg config.Config
92+ env map[string]string
93+ wantURL string
94+ wantKey string
95+ }{
96+ {"dmr keeps a reachable baseUrl", "dmr", config.Config{BaseURL: up.URL}, nil, up.URL, "not-needed"},
97+ {"dmr falls back when baseUrl is down", "dmr", config.Config{BaseURL: down, Fallback: ptr(up.URL)}, nil, up.URL, "not-needed"},
98+ {"an explicit empty fallback disables it", "dmr", config.Config{BaseURL: down, Fallback: ptr("")}, nil, down, "not-needed"},
99+ {"AGENT_BASE_URL wins without probing", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"AGENT_BASE_URL": down}, down, "not-needed"},
100+ {"DMR_BASE_URL still works for dmr", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"DMR_BASE_URL": down}, down, "not-needed"},
101+ {"llamacpp default is llama-server's port", "llamacpp", config.Config{}, nil, "http://127.0.0.1:8080/v1", "not-needed"},
102+ {"llamacpp ignores DMR_BASE_URL", "llamacpp", config.Config{}, map[string]string{"DMR_BASE_URL": down}, "http://127.0.0.1:8080/v1", "not-needed"},
103+ {"llamacpp reads LLAMA_API_KEY when set", "llamacpp", config.Config{}, map[string]string{"LLAMA_API_KEY": "secret"}, "http://127.0.0.1:8080/v1", "secret"},
104+ {"apiKeyEnv names another variable", "llamacpp", config.Config{APIKeyEnv: "MY_KEY"}, map[string]string{"MY_KEY": "k2"}, "http://127.0.0.1:8080/v1", "k2"},
105+ }
106+ for _, tc := range cases {
107+ t.Run(tc.name, func(t *testing.T) {
108+ clearEnv(t)
109+ for k, v := range tc.env {
110+ t.Setenv(k, v)
111+ }
112+ p, _ := Lookup(tc.provider)
113+ tc.cfg.Model = "fake-model"
114+ b, err := p.Resolve(tc.cfg)
115+ if err != nil {
116+ t.Fatalf("Resolve: %v", err)
117+ }
118+ if b.BaseURL != tc.wantURL {
119+ t.Errorf("BaseURL = %q, want %q", b.BaseURL, tc.wantURL)
120+ }
121+ if b.APIKey != tc.wantKey {
122+ t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey)
123+ }
124+ })
125+ }
126+}
127+
128+// TestProbeLlamaCpp: /props sits one level above /v1 and carries the SERVED
129+// context size; the config's value, when given, wins over it.
130+func TestProbeLlamaCpp(t *testing.T) {
131+ mux := http.NewServeMux()
132+ mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) {
133+ _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`))
134+ })
135+ mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
136+ _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"qwen2.5-coder","object":"model"}]}`))
137+ })
138+ srv := httptest.NewServer(mux)
139+ defer srv.Close()
140+
141+ p, _ := Lookup("llamacpp")
142+ info := p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder"})
143+ if !info.Reachable || info.ContextWindow != 32768 || info.ContextSource != "/props" {
144+ t.Errorf("Probe = %+v, want reachable, 32768 from /props", info)
145+ }
146+ if len(info.Warnings) != 0 {
147+ t.Errorf("unexpected warnings: %q", info.Warnings)
148+ }
149+
150+ info = p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder", ContextWindow: 4096})
151+ if info.ContextWindow != 4096 || info.ContextSource != "config" {
152+ t.Errorf("with a config hint: %+v, want 4096 from config", info)
153+ }
154+}
155+
156+// TestEnsureContextWindowReprobes: the start-up probe runs once, and a
157+// llama-server started AFTER the agent left `ctx: unknown` for the whole
158+// session. The lazy re-probe must turn 0 into n_ctx once /props answers — and
159+// must stop asking as soon as the window is known, or when the config gave
160+// it: the hit counter on /props is the measure.
161+func TestEnsureContextWindowReprobes(t *testing.T) {
162+ var propsHits int
163+ up := false // false = llama-server not ready yet: /props answers 503
164+ mux := http.NewServeMux()
165+ mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) {
166+ propsHits++
167+ if !up {
168+ w.WriteHeader(http.StatusServiceUnavailable)
169+ return
170+ }
171+ _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`))
172+ })
173+ mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
174+ _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"m","object":"model"}]}`))
175+ })
176+ srv := httptest.NewServer(mux)
177+ defer srv.Close()
178+
179+ p, _ := Lookup("llamacpp")
180+ e := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m"}}
181+
182+ // Server not ready: still unknown, nothing learned, but the probe was made.
183+ if w, src := e.EnsureContextWindow(context.Background()); w != 0 || src != "" || e.ContextWindow != 0 {
184+ t.Errorf("before the server is up: got (%d, %q), ContextWindow=%d, want (0, \"\"), 0", w, src, e.ContextWindow)
185+ }
186+ if propsHits != 1 {
187+ t.Fatalf("/props hits = %d after the first re-probe, want 1", propsHits)
188+ }
189+
190+ // Server up: the window is learned, remembered, and its origin is named.
191+ up = true
192+ if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "/props" || e.ContextWindow != 32768 {
193+ t.Errorf("after the server is up: got (%d, %q), ContextWindow=%d, want (32768, \"/props\"), 32768", w, src, e.ContextWindow)
194+ }
195+ if propsHits != 2 {
196+ t.Fatalf("/props hits = %d after the second re-probe, want 2", propsHits)
197+ }
198+
199+ // Known window: answered from memory, no request, no origin to announce.
200+ if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "" {
201+ t.Errorf("known window: got (%d, %q), want (32768, \"\")", w, src)
202+ }
203+ if propsHits != 2 {
204+ t.Errorf("/props hits = %d after asking with a known window, want still 2", propsHits)
205+ }
206+
207+ // A window from the config is known from the start: never probed at all.
208+ propsHits = 0
209+ known := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m", ContextWindow: 4096}, ContextWindow: 4096}
210+ if w, src := known.EnsureContextWindow(context.Background()); w != 4096 || src != "" || propsHits != 0 {
211+ t.Errorf("config window: got (%d, %q) with %d /props hit(s), want (4096, \"\") and 0", w, src, propsHits)
212+ }
213+}
214+
215+// TestProbeUnreachableWarns: a server that is down is a warning with the start
216+// command, not a failure — the demo often starts the server second.
217+func TestProbeUnreachableWarns(t *testing.T) {
218+ for _, name := range []string{"dmr", "llamacpp"} {
219+ p, _ := Lookup(name)
220+ info := p.Probe(context.Background(), Backend{Provider: name, BaseURL: closedURL(t) + "/v1", Model: "m"})
221+ if info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "nothing answers") {
222+ t.Errorf("%s: Probe on a closed port = %+v", name, info)
223+ }
224+ }
225+}
226+
227+// TestProbeModelsWarnsOnMissingModel: DMR lists what is pulled; asking for
228+// something else fails only once the request is sent, after a long spinner.
229+func TestProbeModelsWarnsOnMissingModel(t *testing.T) {
230+ srv := openaiServer(t, 200, "{}", "ai/other")
231+ p, _ := Lookup("dmr")
232+ info := p.Probe(context.Background(), Backend{Provider: "dmr", BaseURL: srv.URL, Model: "ai/qwen2.5-coder"})
233+ if !info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "docker model pull ai/qwen2.5-coder") {
234+ t.Errorf("Probe = %+v, want the pull hint", info)
235+ }
236+}
237+
238+// TestExplainThroughTheStack drives a real Open + Generate against servers that
239+// fail on purpose, and checks the ONE line the REPL would print. Going through
240+// Genkit matters: it is what wraps the OpenAI client's error, and the mapping
241+// has to survive that wrapping.
242+func TestExplainThroughTheStack(t *testing.T) {
243+ cases := []struct {
244+ name string
245+ provider string
246+ status int
247+ body string
248+ want string
249+ }{
250+ {"llama-server without --jinja", "llamacpp", 500,
251+ `{"error":{"code":500,"message":"tools param requires --jinja flag","type":"server_error"}}`,
252+ "tool calls need llama-server started with --jinja"},
253+ {"401", "llamacpp", 401, `{"error":{"message":"Invalid API Key","type":"authentication_error"}}`,
254+ "authentication failed"},
255+ {"404 on dmr names the pull command", "dmr", 404, `{"error":{"message":"model not found","type":"not_found"}}`,
256+ "docker model pull fake-model"},
257+ }
258+ for _, tc := range cases {
259+ t.Run(tc.name, func(t *testing.T) {
260+ srv := openaiServer(t, tc.status, tc.body, "fake-model")
261+ got := explainAgainst(t, tc.provider, srv.URL)
262+ if !strings.Contains(got, tc.want) {
263+ t.Errorf("Explain = %q, want it to contain %q", got, tc.want)
264+ }
265+ })
266+ }
267+
268+ t.Run("connection refused", func(t *testing.T) {
269+ got := explainAgainst(t, "llamacpp", closedURL(t))
270+ if !strings.Contains(got, "nothing answers") || !strings.Contains(got, "--jinja --port 8080") {
271+ t.Errorf("Explain = %q, want the start hint", got)
272+ }
273+ })
274+}
275+
276+// TestExplain401CitesTheResolvedVariable guards the phantom variable: the first
277+// Explain named $AGENT_API_KEY on a 401, a variable nothing read. The message
278+// must name the variable Resolve used — the yaml's `apiKeyEnv` when set — and
279+// name none when the provider has none, rather than invent one.
280+func TestExplain401CitesTheResolvedVariable(t *testing.T) {
281+ body := `{"error":{"message":"Invalid API Key","type":"authentication_error"}}`
282+
283+ t.Run("yaml apiKeyEnv is the name cited", func(t *testing.T) {
284+ clearEnv(t)
285+ t.Setenv("MY_KEY", "wrong")
286+ srv := openaiServer(t, 401, body, "fake-model")
287+ p, _ := Lookup("llamacpp")
288+ b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr(""), APIKeyEnv: "MY_KEY"})
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ got := explainWith(t, p, b)
293+ if !strings.Contains(got, "$MY_KEY") || strings.Contains(got, "LLAMA_API_KEY") {
294+ t.Errorf("Explain = %q, want it to cite $MY_KEY and nothing else", got)
295+ }
296+ })
297+
298+ t.Run("dmr has no variable: none is invented", func(t *testing.T) {
299+ clearEnv(t)
300+ srv := openaiServer(t, 401, body, "fake-model")
301+ p, _ := Lookup("dmr")
302+ b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr("")})
303+ if err != nil {
304+ t.Fatal(err)
305+ }
306+ got := explainWith(t, p, b)
307+ if strings.Contains(got, "$") || !strings.Contains(got, "should not need a key") {
308+ t.Errorf("Explain = %q, want no $VARIABLE and the check-baseUrl hint", got)
309+ }
310+ })
311+}
312+
313+// TestResolveKeyPolicy exercises the keyRequired branch with a throwaway
314+// provider built directly — not registered, so the global registry stays as the
315+// other tests expect it. Neither shipped provider requires a key, so without
316+// this the branch a paid API will rely on has never run.
317+func TestResolveKeyPolicy(t *testing.T) {
318+ clearEnv(t)
319+ paid := &openaiCompat{name: "paid", baseURL: "https://paid.example/v1", apiKeyEnv: "PAID_API_KEY", keyRequired: true, dummyKey: "must-not-appear"}
320+ free := &openaiCompat{name: "free", baseURL: "http://127.0.0.1:1/v1", apiKeyEnv: "FREE_API_KEY", dummyKey: "not-needed"}
321+
322+ cases := []struct {
323+ name string
324+ p *openaiCompat
325+ cfg config.Config
326+ env map[string]string
327+ wantErr string // "" = success
328+ wantKey string
329+ wantEnv string
330+ }{
331+ {"required and unset: the error names the variable", paid, config.Config{}, nil,
332+ "paid: PAID_API_KEY is not set", "", "PAID_API_KEY"},
333+ {"required and set: the value, not the dummy", paid, config.Config{}, map[string]string{"PAID_API_KEY": "sk-live"},
334+ "", "sk-live", "PAID_API_KEY"},
335+ {"required, yaml apiKeyEnv redirects to another variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, map[string]string{"OTHER_KEY": "sk-other"},
336+ "", "sk-other", "OTHER_KEY"},
337+ {"required, yaml apiKeyEnv unset: names THAT variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, nil,
338+ "paid: OTHER_KEY is not set", "", "OTHER_KEY"},
339+ {"not required and unset: the dummy key", free, config.Config{}, nil,
340+ "", "not-needed", "FREE_API_KEY"},
341+ {"not required and set: the value", free, config.Config{}, map[string]string{"FREE_API_KEY": "abc"},
342+ "", "abc", "FREE_API_KEY"},
343+ }
344+ for _, tc := range cases {
345+ t.Run(tc.name, func(t *testing.T) {
346+ for _, k := range []string{"PAID_API_KEY", "FREE_API_KEY", "OTHER_KEY"} {
347+ t.Setenv(k, "")
348+ }
349+ for k, v := range tc.env {
350+ t.Setenv(k, v)
351+ }
352+ tc.cfg.Model = "m"
353+ tc.cfg.Fallback = ptr("") // no probe: the URLs here do not exist
354+ b, err := tc.p.Resolve(tc.cfg)
355+ switch {
356+ case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)):
357+ t.Fatalf("Resolve error = %v, want it to contain %q", err, tc.wantErr)
358+ case tc.wantErr == "" && err != nil:
359+ t.Fatalf("Resolve: %v", err)
360+ }
361+ if b.APIKey != tc.wantKey {
362+ t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey)
363+ }
364+ if b.APIKeyEnv != tc.wantEnv {
365+ t.Errorf("APIKeyEnv = %q, want %q", b.APIKeyEnv, tc.wantEnv)
366+ }
367+ })
368+ }
369+}
370+
371+// explainAgainst opens the provider on baseURL, asks one question and returns
372+// the explained error. The spinner and the retry print to stdout: harmless.
373+func explainAgainst(t *testing.T, provider, baseURL string) string {
374+ t.Helper()
375+ p, _ := Lookup(provider)
376+ return explainWith(t, p, Backend{Provider: provider, BaseURL: baseURL, Model: "fake-model", APIKey: "not-needed"})
377+}
378+
379+// explainWith is explainAgainst for a Backend built by Resolve — needed when
380+// the message under test depends on what Resolve recorded (APIKeyEnv).
381+func explainWith(t *testing.T, p Provider, b Backend) string {
382+ t.Helper()
383+ g, model, err := p.Open(context.Background(), b)
384+ if err != nil {
385+ t.Fatalf("Open: %v", err)
386+ }
387+ e := &Engine{G: g, Model: model, Provider: p, Backend: b}
388+ _, _, err = e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil)
389+ if err == nil {
390+ t.Fatal("Generate succeeded against a failing server")
391+ }
392+ return e.Explain(err)
393+}
394+
395+// TestConfigCompat: an agent.yaml written for part 07 — no `provider`, an
396+// explicit `fallback: ""` — must load with the same meaning; and a llamacpp
397+// file with no baseUrl must land on llama-server's port, not DMR's.
398+func TestConfigCompat(t *testing.T) {
399+ clearEnv(t)
400+ saved := config.Cfg
401+ defer func() { config.Cfg = saved }()
402+
403+ dir := t.TempDir()
404+ write := func(name, body string) string {
405+ path := filepath.Join(dir, name)
406+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
407+ t.Fatal(err)
408+ }
409+ return path
410+ }
411+
412+ old := write("probe.yaml", "model: fake-model\nbaseUrl: http://127.0.0.1:18234/engines/v1\nfallback: \"\"\nmaxOutput: 16000\nmaxTurns: 25\nsystem: |\n Probe.\nsampling:\n temperature: 0.0\n")
413+ if _, err := config.Load(old); err != nil {
414+ t.Fatalf("Load(07 config): %v", err)
415+ }
416+ if config.Cfg.Provider != "dmr" {
417+ t.Errorf("Provider = %q, want dmr by default", config.Cfg.Provider)
418+ }
419+ p, _ := Lookup(config.Cfg.Provider)
420+ b, err := p.Resolve(config.Cfg)
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ if b.BaseURL != "http://127.0.0.1:18234/engines/v1" {
425+ t.Errorf("BaseURL = %q: an explicit empty fallback must keep the file's URL even when it is down", b.BaseURL)
426+ }
427+
428+ config.Cfg = saved
429+ llama := write("llama.yaml", "provider: llamacpp\nmodel: qwen2.5-coder\ncontextWindow: 16384\n")
430+ if _, err := config.Load(llama); err != nil {
431+ t.Fatalf("Load(llamacpp config): %v", err)
432+ }
433+ p, _ = Lookup(config.Cfg.Provider)
434+ b, err = p.Resolve(config.Cfg)
435+ if err != nil {
436+ t.Fatal(err)
437+ }
438+ if b.BaseURL != "http://127.0.0.1:8080/v1" || b.ContextWindow != 16384 {
439+ t.Errorf("Backend = %+v, want llama-server's default URL and the 16384 hint", b)
440+ }
441+
442+ config.Cfg = saved
443+ bad := write("bad.yaml", "provider: ollama\nmodel: m\n")
444+ if _, err := config.Load(bad); err != nil {
445+ t.Fatalf("Load must accept an unknown provider (engine.New refuses it): %v", err)
446+ }
447+ if _, err := Lookup(config.Cfg.Provider); err == nil {
448+ t.Error("Lookup(ollama) must fail")
449+ }
450+}
new file mode 100644
@@ -0,0 +1,450 @@
1+// Tests for the provider layer, against httptest servers: the fake engine of
2+// the backup docs speaks plain OpenAI and cannot say "I am llama-server", nor
3+// fail on demand. What these tests guard: the URL precedence the `dmr` package
4+// had (env, then file, then fallback when the first does not answer), the
5+// llama.cpp probe, and the one-line error mapping — each line of the table in
6+// PROVIDERS.md §5.7 that this step implements.
7+package engine
8+
9+import (
10+ "context"
11+ "encoding/json"
12+ "net/http"
13+ "net/http/httptest"
14+ "os"
15+ "path/filepath"
16+ "strings"
17+ "testing"
18+
19+ "mm/internal/config"
20+
21+ "github.com/firebase/genkit/go/ai"
22+)
23+
24+// openaiServer is a minimal OpenAI-compatible server: /models lists `models`,
25+// every POST answers `status` with `body`. Enough to drive Resolve, Probe and
26+// Explain; Generate's happy path is covered by engine_test.go's fake model.
27+func openaiServer(t *testing.T, status int, body string, models ...string) *httptest.Server {
28+ t.Helper()
29+ mux := http.NewServeMux()
30+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
31+ if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") {
32+ var data []map[string]string
33+ for _, m := range models {
34+ data = append(data, map[string]string{"id": m, "object": "model"})
35+ }
36+ _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
37+ return
38+ }
39+ if r.Method == http.MethodPost {
40+ w.Header().Set("Content-Type", "application/json")
41+ w.WriteHeader(status)
42+ _, _ = w.Write([]byte(body))
43+ return
44+ }
45+ http.NotFound(w, r)
46+ })
47+ srv := httptest.NewServer(mux)
48+ t.Cleanup(srv.Close)
49+ return srv
50+}
51+
52+// closedURL returns a URL nothing listens on: a server started and stopped, so
53+// the port was free a moment ago.
54+func closedURL(t *testing.T) string {
55+ t.Helper()
56+ srv := httptest.NewServer(http.NotFoundHandler())
57+ url := srv.URL
58+ srv.Close()
59+ return url
60+}
61+
62+func ptr(s string) *string { return &s }
63+
64+// clearEnv: the precedence tests must not depend on the developer's shell,
65+// where DMR_BASE_URL is often exported.
66+func clearEnv(t *testing.T) {
67+ t.Helper()
68+ for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} {
69+ t.Setenv(k, "")
70+ }
71+}
72+
73+func TestLookupListsKnownProviders(t *testing.T) {
74+ _, err := Lookup("ollama")
75+ if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp") {
76+ t.Fatalf("Lookup(ollama) = %v, want an error naming the known providers", err)
77+ }
78+}
79+
80+// TestResolveURLPrecedence replays the rules of the former config.DMRBaseURL —
81+// env wins without probing, the fallback is taken only when baseUrl does not
82+// answer — and checks that llamacpp neither inherits DMR's URL nor its env var.
83+func TestResolveURLPrecedence(t *testing.T) {
84+ clearEnv(t)
85+ up := openaiServer(t, 200, "{}", "fake-model")
86+ down := closedURL(t)
87+
88+ cases := []struct {
89+ name string
90+ provider string
91+ cfg config.Config
92+ env map[string]string
93+ wantURL string
94+ wantKey string
95+ }{
96+ {"dmr keeps a reachable baseUrl", "dmr", config.Config{BaseURL: up.URL}, nil, up.URL, "not-needed"},
97+ {"dmr falls back when baseUrl is down", "dmr", config.Config{BaseURL: down, Fallback: ptr(up.URL)}, nil, up.URL, "not-needed"},
98+ {"an explicit empty fallback disables it", "dmr", config.Config{BaseURL: down, Fallback: ptr("")}, nil, down, "not-needed"},
99+ {"AGENT_BASE_URL wins without probing", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"AGENT_BASE_URL": down}, down, "not-needed"},
100+ {"DMR_BASE_URL still works for dmr", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"DMR_BASE_URL": down}, down, "not-needed"},
101+ {"llamacpp default is llama-server's port", "llamacpp", config.Config{}, nil, "http://127.0.0.1:8080/v1", "not-needed"},
102+ {"llamacpp ignores DMR_BASE_URL", "llamacpp", config.Config{}, map[string]string{"DMR_BASE_URL": down}, "http://127.0.0.1:8080/v1", "not-needed"},
103+ {"llamacpp reads LLAMA_API_KEY when set", "llamacpp", config.Config{}, map[string]string{"LLAMA_API_KEY": "secret"}, "http://127.0.0.1:8080/v1", "secret"},
104+ {"apiKeyEnv names another variable", "llamacpp", config.Config{APIKeyEnv: "MY_KEY"}, map[string]string{"MY_KEY": "k2"}, "http://127.0.0.1:8080/v1", "k2"},
105+ }
106+ for _, tc := range cases {
107+ t.Run(tc.name, func(t *testing.T) {
108+ clearEnv(t)
109+ for k, v := range tc.env {
110+ t.Setenv(k, v)
111+ }
112+ p, _ := Lookup(tc.provider)
113+ tc.cfg.Model = "fake-model"
114+ b, err := p.Resolve(tc.cfg)
115+ if err != nil {
116+ t.Fatalf("Resolve: %v", err)
117+ }
118+ if b.BaseURL != tc.wantURL {
119+ t.Errorf("BaseURL = %q, want %q", b.BaseURL, tc.wantURL)
120+ }
121+ if b.APIKey != tc.wantKey {
122+ t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey)
123+ }
124+ })
125+ }
126+}
127+
128+// TestProbeLlamaCpp: /props sits one level above /v1 and carries the SERVED
129+// context size; the config's value, when given, wins over it.
130+func TestProbeLlamaCpp(t *testing.T) {
131+ mux := http.NewServeMux()
132+ mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) {
133+ _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`))
134+ })
135+ mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
136+ _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"qwen2.5-coder","object":"model"}]}`))
137+ })
138+ srv := httptest.NewServer(mux)
139+ defer srv.Close()
140+
141+ p, _ := Lookup("llamacpp")
142+ info := p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder"})
143+ if !info.Reachable || info.ContextWindow != 32768 || info.ContextSource != "/props" {
144+ t.Errorf("Probe = %+v, want reachable, 32768 from /props", info)
145+ }
146+ if len(info.Warnings) != 0 {
147+ t.Errorf("unexpected warnings: %q", info.Warnings)
148+ }
149+
150+ info = p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder", ContextWindow: 4096})
151+ if info.ContextWindow != 4096 || info.ContextSource != "config" {
152+ t.Errorf("with a config hint: %+v, want 4096 from config", info)
153+ }
154+}
155+
156+// TestEnsureContextWindowReprobes: the start-up probe runs once, and a
157+// llama-server started AFTER the agent left `ctx: unknown` for the whole
158+// session. The lazy re-probe must turn 0 into n_ctx once /props answers — and
159+// must stop asking as soon as the window is known, or when the config gave
160+// it: the hit counter on /props is the measure.
161+func TestEnsureContextWindowReprobes(t *testing.T) {
162+ var propsHits int
163+ up := false // false = llama-server not ready yet: /props answers 503
164+ mux := http.NewServeMux()
165+ mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) {
166+ propsHits++
167+ if !up {
168+ w.WriteHeader(http.StatusServiceUnavailable)
169+ return
170+ }
171+ _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`))
172+ })
173+ mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
174+ _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"m","object":"model"}]}`))
175+ })
176+ srv := httptest.NewServer(mux)
177+ defer srv.Close()
178+
179+ p, _ := Lookup("llamacpp")
180+ e := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m"}}
181+
182+ // Server not ready: still unknown, nothing learned, but the probe was made.
183+ if w, src := e.EnsureContextWindow(context.Background()); w != 0 || src != "" || e.ContextWindow != 0 {
184+ t.Errorf("before the server is up: got (%d, %q), ContextWindow=%d, want (0, \"\"), 0", w, src, e.ContextWindow)
185+ }
186+ if propsHits != 1 {
187+ t.Fatalf("/props hits = %d after the first re-probe, want 1", propsHits)
188+ }
189+
190+ // Server up: the window is learned, remembered, and its origin is named.
191+ up = true
192+ if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "/props" || e.ContextWindow != 32768 {
193+ t.Errorf("after the server is up: got (%d, %q), ContextWindow=%d, want (32768, \"/props\"), 32768", w, src, e.ContextWindow)
194+ }
195+ if propsHits != 2 {
196+ t.Fatalf("/props hits = %d after the second re-probe, want 2", propsHits)
197+ }
198+
199+ // Known window: answered from memory, no request, no origin to announce.
200+ if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "" {
201+ t.Errorf("known window: got (%d, %q), want (32768, \"\")", w, src)
202+ }
203+ if propsHits != 2 {
204+ t.Errorf("/props hits = %d after asking with a known window, want still 2", propsHits)
205+ }
206+
207+ // A window from the config is known from the start: never probed at all.
208+ propsHits = 0
209+ known := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m", ContextWindow: 4096}, ContextWindow: 4096}
210+ if w, src := known.EnsureContextWindow(context.Background()); w != 4096 || src != "" || propsHits != 0 {
211+ t.Errorf("config window: got (%d, %q) with %d /props hit(s), want (4096, \"\") and 0", w, src, propsHits)
212+ }
213+}
214+
215+// TestProbeUnreachableWarns: a server that is down is a warning with the start
216+// command, not a failure — the demo often starts the server second.
217+func TestProbeUnreachableWarns(t *testing.T) {
218+ for _, name := range []string{"dmr", "llamacpp"} {
219+ p, _ := Lookup(name)
220+ info := p.Probe(context.Background(), Backend{Provider: name, BaseURL: closedURL(t) + "/v1", Model: "m"})
221+ if info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "nothing answers") {
222+ t.Errorf("%s: Probe on a closed port = %+v", name, info)
223+ }
224+ }
225+}
226+
227+// TestProbeModelsWarnsOnMissingModel: DMR lists what is pulled; asking for
228+// something else fails only once the request is sent, after a long spinner.
229+func TestProbeModelsWarnsOnMissingModel(t *testing.T) {
230+ srv := openaiServer(t, 200, "{}", "ai/other")
231+ p, _ := Lookup("dmr")
232+ info := p.Probe(context.Background(), Backend{Provider: "dmr", BaseURL: srv.URL, Model: "ai/qwen2.5-coder"})
233+ if !info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "docker model pull ai/qwen2.5-coder") {
234+ t.Errorf("Probe = %+v, want the pull hint", info)
235+ }
236+}
237+
238+// TestExplainThroughTheStack drives a real Open + Generate against servers that
239+// fail on purpose, and checks the ONE line the REPL would print. Going through
240+// Genkit matters: it is what wraps the OpenAI client's error, and the mapping
241+// has to survive that wrapping.
242+func TestExplainThroughTheStack(t *testing.T) {
243+ cases := []struct {
244+ name string
245+ provider string
246+ status int
247+ body string
248+ want string
249+ }{
250+ {"llama-server without --jinja", "llamacpp", 500,
251+ `{"error":{"code":500,"message":"tools param requires --jinja flag","type":"server_error"}}`,
252+ "tool calls need llama-server started with --jinja"},
253+ {"401", "llamacpp", 401, `{"error":{"message":"Invalid API Key","type":"authentication_error"}}`,
254+ "authentication failed"},
255+ {"404 on dmr names the pull command", "dmr", 404, `{"error":{"message":"model not found","type":"not_found"}}`,
256+ "docker model pull fake-model"},
257+ }
258+ for _, tc := range cases {
259+ t.Run(tc.name, func(t *testing.T) {
260+ srv := openaiServer(t, tc.status, tc.body, "fake-model")
261+ got := explainAgainst(t, tc.provider, srv.URL)
262+ if !strings.Contains(got, tc.want) {
263+ t.Errorf("Explain = %q, want it to contain %q", got, tc.want)
264+ }
265+ })
266+ }
267+
268+ t.Run("connection refused", func(t *testing.T) {
269+ got := explainAgainst(t, "llamacpp", closedURL(t))
270+ if !strings.Contains(got, "nothing answers") || !strings.Contains(got, "--jinja --port 8080") {
271+ t.Errorf("Explain = %q, want the start hint", got)
272+ }
273+ })
274+}
275+
276+// TestExplain401CitesTheResolvedVariable guards the phantom variable: the first
277+// Explain named $AGENT_API_KEY on a 401, a variable nothing read. The message
278+// must name the variable Resolve used — the yaml's `apiKeyEnv` when set — and
279+// name none when the provider has none, rather than invent one.
280+func TestExplain401CitesTheResolvedVariable(t *testing.T) {
281+ body := `{"error":{"message":"Invalid API Key","type":"authentication_error"}}`
282+
283+ t.Run("yaml apiKeyEnv is the name cited", func(t *testing.T) {
284+ clearEnv(t)
285+ t.Setenv("MY_KEY", "wrong")
286+ srv := openaiServer(t, 401, body, "fake-model")
287+ p, _ := Lookup("llamacpp")
288+ b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr(""), APIKeyEnv: "MY_KEY"})
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ got := explainWith(t, p, b)
293+ if !strings.Contains(got, "$MY_KEY") || strings.Contains(got, "LLAMA_API_KEY") {
294+ t.Errorf("Explain = %q, want it to cite $MY_KEY and nothing else", got)
295+ }
296+ })
297+
298+ t.Run("dmr has no variable: none is invented", func(t *testing.T) {
299+ clearEnv(t)
300+ srv := openaiServer(t, 401, body, "fake-model")
301+ p, _ := Lookup("dmr")
302+ b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr("")})
303+ if err != nil {
304+ t.Fatal(err)
305+ }
306+ got := explainWith(t, p, b)
307+ if strings.Contains(got, "$") || !strings.Contains(got, "should not need a key") {
308+ t.Errorf("Explain = %q, want no $VARIABLE and the check-baseUrl hint", got)
309+ }
310+ })
311+}
312+
313+// TestResolveKeyPolicy exercises the keyRequired branch with a throwaway
314+// provider built directly — not registered, so the global registry stays as the
315+// other tests expect it. Neither shipped provider requires a key, so without
316+// this the branch a paid API will rely on has never run.
317+func TestResolveKeyPolicy(t *testing.T) {
318+ clearEnv(t)
319+ paid := &openaiCompat{name: "paid", baseURL: "https://paid.example/v1", apiKeyEnv: "PAID_API_KEY", keyRequired: true, dummyKey: "must-not-appear"}
320+ free := &openaiCompat{name: "free", baseURL: "http://127.0.0.1:1/v1", apiKeyEnv: "FREE_API_KEY", dummyKey: "not-needed"}
321+
322+ cases := []struct {
323+ name string
324+ p *openaiCompat
325+ cfg config.Config
326+ env map[string]string
327+ wantErr string // "" = success
328+ wantKey string
329+ wantEnv string
330+ }{
331+ {"required and unset: the error names the variable", paid, config.Config{}, nil,
332+ "paid: PAID_API_KEY is not set", "", "PAID_API_KEY"},
333+ {"required and set: the value, not the dummy", paid, config.Config{}, map[string]string{"PAID_API_KEY": "sk-live"},
334+ "", "sk-live", "PAID_API_KEY"},
335+ {"required, yaml apiKeyEnv redirects to another variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, map[string]string{"OTHER_KEY": "sk-other"},
336+ "", "sk-other", "OTHER_KEY"},
337+ {"required, yaml apiKeyEnv unset: names THAT variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, nil,
338+ "paid: OTHER_KEY is not set", "", "OTHER_KEY"},
339+ {"not required and unset: the dummy key", free, config.Config{}, nil,
340+ "", "not-needed", "FREE_API_KEY"},
341+ {"not required and set: the value", free, config.Config{}, map[string]string{"FREE_API_KEY": "abc"},
342+ "", "abc", "FREE_API_KEY"},
343+ }
344+ for _, tc := range cases {
345+ t.Run(tc.name, func(t *testing.T) {
346+ for _, k := range []string{"PAID_API_KEY", "FREE_API_KEY", "OTHER_KEY"} {
347+ t.Setenv(k, "")
348+ }
349+ for k, v := range tc.env {
350+ t.Setenv(k, v)
351+ }
352+ tc.cfg.Model = "m"
353+ tc.cfg.Fallback = ptr("") // no probe: the URLs here do not exist
354+ b, err := tc.p.Resolve(tc.cfg)
355+ switch {
356+ case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)):
357+ t.Fatalf("Resolve error = %v, want it to contain %q", err, tc.wantErr)
358+ case tc.wantErr == "" && err != nil:
359+ t.Fatalf("Resolve: %v", err)
360+ }
361+ if b.APIKey != tc.wantKey {
362+ t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey)
363+ }
364+ if b.APIKeyEnv != tc.wantEnv {
365+ t.Errorf("APIKeyEnv = %q, want %q", b.APIKeyEnv, tc.wantEnv)
366+ }
367+ })
368+ }
369+}
370+
371+// explainAgainst opens the provider on baseURL, asks one question and returns
372+// the explained error. The spinner and the retry print to stdout: harmless.
373+func explainAgainst(t *testing.T, provider, baseURL string) string {
374+ t.Helper()
375+ p, _ := Lookup(provider)
376+ return explainWith(t, p, Backend{Provider: provider, BaseURL: baseURL, Model: "fake-model", APIKey: "not-needed"})
377+}
378+
379+// explainWith is explainAgainst for a Backend built by Resolve — needed when
380+// the message under test depends on what Resolve recorded (APIKeyEnv).
381+func explainWith(t *testing.T, p Provider, b Backend) string {
382+ t.Helper()
383+ g, model, err := p.Open(context.Background(), b)
384+ if err != nil {
385+ t.Fatalf("Open: %v", err)
386+ }
387+ e := &Engine{G: g, Model: model, Provider: p, Backend: b}
388+ _, _, err = e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil)
389+ if err == nil {
390+ t.Fatal("Generate succeeded against a failing server")
391+ }
392+ return e.Explain(err)
393+}
394+
395+// TestConfigCompat: an agent.yaml written for part 07 — no `provider`, an
396+// explicit `fallback: ""` — must load with the same meaning; and a llamacpp
397+// file with no baseUrl must land on llama-server's port, not DMR's.
398+func TestConfigCompat(t *testing.T) {
399+ clearEnv(t)
400+ saved := config.Cfg
401+ defer func() { config.Cfg = saved }()
402+
403+ dir := t.TempDir()
404+ write := func(name, body string) string {
405+ path := filepath.Join(dir, name)
406+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
407+ t.Fatal(err)
408+ }
409+ return path
410+ }
411+
412+ old := write("probe.yaml", "model: fake-model\nbaseUrl: http://127.0.0.1:18234/engines/v1\nfallback: \"\"\nmaxOutput: 16000\nmaxTurns: 25\nsystem: |\n Probe.\nsampling:\n temperature: 0.0\n")
413+ if _, err := config.Load(old); err != nil {
414+ t.Fatalf("Load(07 config): %v", err)
415+ }
416+ if config.Cfg.Provider != "dmr" {
417+ t.Errorf("Provider = %q, want dmr by default", config.Cfg.Provider)
418+ }
419+ p, _ := Lookup(config.Cfg.Provider)
420+ b, err := p.Resolve(config.Cfg)
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ if b.BaseURL != "http://127.0.0.1:18234/engines/v1" {
425+ t.Errorf("BaseURL = %q: an explicit empty fallback must keep the file's URL even when it is down", b.BaseURL)
426+ }
427+
428+ config.Cfg = saved
429+ llama := write("llama.yaml", "provider: llamacpp\nmodel: qwen2.5-coder\ncontextWindow: 16384\n")
430+ if _, err := config.Load(llama); err != nil {
431+ t.Fatalf("Load(llamacpp config): %v", err)
432+ }
433+ p, _ = Lookup(config.Cfg.Provider)
434+ b, err = p.Resolve(config.Cfg)
435+ if err != nil {
436+ t.Fatal(err)
437+ }
438+ if b.BaseURL != "http://127.0.0.1:8080/v1" || b.ContextWindow != 16384 {
439+ t.Errorf("Backend = %+v, want llama-server's default URL and the 16384 hint", b)
440+ }
441+
442+ config.Cfg = saved
443+ bad := write("bad.yaml", "provider: ollama\nmodel: m\n")
444+ if _, err := config.Load(bad); err != nil {
445+ t.Fatalf("Load must accept an unknown provider (engine.New refuses it): %v", err)
446+ }
447+ if _, err := Lookup(config.Cfg.Provider); err == nil {
448+ t.Error("Lookup(ollama) must fail")
449+ }
450+}
added internal/fileedit/diff.go +367 -0
new file mode 100644
@@ -0,0 +1,367 @@
1+package fileedit
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// op is what happened to a line. The values are the characters of the unified
9+// format, which saves a conversion table.
10+type op byte
11+
12+const (
13+ keep op = ' '
14+ del op = '-'
15+ add op = '+'
16+)
17+
18+// line is one line of the diff. oldNo and newNo are 1-based, 0 when the line
19+// does not exist on that side.
20+type line struct {
21+ op op
22+ text string
23+ oldNo int
24+ newNo int
25+}
26+
27+// comparison is a diff ready to render: the lines, and what splitting into
28+// lines cannot say — whether each side ended with a newline. Two files that
29+// differ only there are two different files, and `git apply` refuses a patch
30+// that ignores it.
31+type comparison struct {
32+ lines []line
33+ oldNL, newNL bool
34+}
35+
36+// compare splits, compares, and returns what is needed to display and to patch.
37+func compare(old, new string) comparison {
38+ oldLines, oldNL := splitLines(old)
39+ newLines, newNL := splitLines(new)
40+ r := comparison{lines: diffLines(oldLines, newLines), oldNL: oldNL, newNL: newNL}
41+
42+ // Edge case: identical line by line, only the final newline moved. Nothing
43+ // is marked changed and the patch would be empty while the file changes.
44+ // The last line is replayed — deleted then added — which is exactly what
45+ // `git diff` prints.
46+ if oldNL != newNL && !changed(r.lines) && len(r.lines) > 0 {
47+ last := r.lines[len(r.lines)-1]
48+ r.lines = append(r.lines[:len(r.lines)-1],
49+ line{del, last.text, last.oldNo, 0},
50+ line{add, last.text, 0, last.newNo},
51+ )
52+ }
53+ return r
54+}
55+
56+func (r comparison) changed() bool { return changed(r.lines) }
57+
58+// firstChanged is the number, in the NEW version, of the first touched line —
59+// 0 when nothing changed. Enough to open the file at the right place, or to
60+// say "it starts at line 42" rather than "done".
61+func (r comparison) firstChanged() int {
62+ for _, l := range r.lines {
63+ switch l.op {
64+ case add:
65+ return l.newNo
66+ case del:
67+ // A deletion has no line in the new version: point at where it
68+ // happened.
69+ return max(1, newNoBefore(r.lines, l))
70+ }
71+ }
72+ return 0
73+}
74+
75+func (r comparison) stat() (added, deleted int) {
76+ for _, l := range r.lines {
77+ switch l.op {
78+ case add:
79+ added++
80+ case del:
81+ deleted++
82+ }
83+ }
84+ return added, deleted
85+}
86+
87+// splitLines splits a body into lines and says whether it ended with a
88+// newline. Both matter: a file without a final newline is a different file,
89+// and the unified patch has to say so.
90+func splitLines(body string) (lines []string, finalNewline bool) {
91+ if body == "" {
92+ return nil, true
93+ }
94+ finalNewline = strings.HasSuffix(body, "\n")
95+ if finalNewline {
96+ body = body[:len(body)-1]
97+ }
98+ return strings.Split(body, "\n"), finalNewline
99+}
100+
101+// maxCells caps the LCS table. Past it, a line-by-line comparison would cost
102+// hundreds of megabytes for a diff nobody will read: the middle block is then
103+// rendered as a block (all deleted, all added), still exact — only coarser.
104+const maxCells = 4 << 20
105+
106+// diffLines compares two sequences of lines.
107+//
108+// Common prefix and suffix are removed BEFORE the LCS: a targeted edit touches
109+// a few lines in the middle of a file, and without that cut the table would be
110+// the size of the file squared.
111+func diffLines(old, new []string) []line {
112+ var out []line
113+
114+ head := 0
115+ for head < len(old) && head < len(new) && old[head] == new[head] {
116+ out = append(out, line{keep, old[head], head + 1, head + 1})
117+ head++
118+ }
119+ tail := 0
120+ for tail < len(old)-head && tail < len(new)-head &&
121+ old[len(old)-1-tail] == new[len(new)-1-tail] {
122+ tail++
123+ }
124+
125+ midOld, midNew := old[head:len(old)-tail], new[head:len(new)-tail]
126+ out = append(out, middle(midOld, midNew, head)...)
127+
128+ for i := len(old) - tail; i < len(old); i++ {
129+ out = append(out, line{keep, old[i], i + 1, i + 1 - len(old) + len(new)})
130+ }
131+ return out
132+}
133+
134+// middle compares what remains after trimming the edges. `off` is the number
135+// of lines already consumed, to number correctly.
136+func middle(old, new []string, off int) []line {
137+ if len(old) == 0 && len(new) == 0 {
138+ return nil
139+ }
140+ if len(old)*len(new) > maxCells {
141+ out := make([]line, 0, len(old)+len(new))
142+ for i, l := range old {
143+ out = append(out, line{del, l, off + i + 1, 0})
144+ }
145+ for j, l := range new {
146+ out = append(out, line{add, l, 0, off + j + 1})
147+ }
148+ return out
149+ }
150+
151+ // Classic LCS: length table, then walk. Files edited by an agent fit in it
152+ // comfortably.
153+ lcs := make([][]int, len(old)+1)
154+ for i := range lcs {
155+ lcs[i] = make([]int, len(new)+1)
156+ }
157+ for i := len(old) - 1; i >= 0; i-- {
158+ for j := len(new) - 1; j >= 0; j-- {
159+ if old[i] == new[j] {
160+ lcs[i][j] = lcs[i+1][j+1] + 1
161+ } else {
162+ lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1])
163+ }
164+ }
165+ }
166+
167+ var out []line
168+ i, j := 0, 0
169+ for i < len(old) && j < len(new) {
170+ switch {
171+ case old[i] == new[j]:
172+ out = append(out, line{keep, old[i], off + i + 1, off + j + 1})
173+ i, j = i+1, j+1
174+ case lcs[i+1][j] >= lcs[i][j+1]:
175+ // Deletion first on a tie: a replacement then reads "- old" then
176+ // "+ new", the order one expects.
177+ out = append(out, line{del, old[i], off + i + 1, 0})
178+ i++
179+ default:
180+ out = append(out, line{add, new[j], 0, off + j + 1})
181+ j++
182+ }
183+ }
184+ for ; i < len(old); i++ {
185+ out = append(out, line{del, old[i], off + i + 1, 0})
186+ }
187+ for ; j < len(new); j++ {
188+ out = append(out, line{add, new[j], 0, off + j + 1})
189+ }
190+ return out
191+}
192+
193+// newNoBefore finds the number of the kept line that precedes `target`.
194+func newNoBefore(lines []line, target line) int {
195+ last := 0
196+ for _, l := range lines {
197+ if l == target {
198+ break
199+ }
200+ if l.newNo > 0 {
201+ last = l.newNo
202+ }
203+ }
204+ return last + 1
205+}
206+
207+func changed(lines []line) bool {
208+ for _, l := range lines {
209+ if l.op != keep {
210+ return true
211+ }
212+ }
213+ return false
214+}
215+
216+// noNewline is the line the unified format requires when a file does not end
217+// with a newline. `git apply` reads it; without it, it adds a newline nobody
218+// asked for.
219+const noNewline = `\ No newline at end of file`
220+
221+// contextLines is how many unchanged lines are kept around each change. Three
222+// is the value of `diff -u` and of git.
223+const contextLines = 3
224+
225+// unified renders a unified patch, applicable as-is by `patch -p1` or
226+// `git apply`. Paths are prefixed a/ and b/ like git does.
227+func (r comparison) unified(path string) string {
228+ if !changed(r.lines) {
229+ return ""
230+ }
231+ var b strings.Builder
232+ fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path)
233+
234+ lastOld, lastNew := 0, 0
235+ for _, l := range r.lines {
236+ lastOld, lastNew = max(lastOld, l.oldNo), max(lastNew, l.newNo)
237+ }
238+ for _, h := range hunks(r.lines) {
239+ oldStart, oldCount, newStart, newCount := h.ranges()
240+ fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)
241+ for _, l := range h {
242+ b.WriteByte(byte(l.op))
243+ b.WriteString(l.text)
244+ b.WriteByte('\n')
245+ // The marker follows the LAST line of each version, and a kept
246+ // line belongs to both: it may carry it for both at once.
247+ if (!r.oldNL && l.oldNo == lastOld && l.op != add) ||
248+ (!r.newNL && l.newNo == lastNew && l.op != del) {
249+ b.WriteString(noNewline + "\n")
250+ }
251+ }
252+ }
253+ return b.String()
254+}
255+
256+// hunk is a group of contiguous lines shown together.
257+type hunk []line
258+
259+// hunks cuts the diff into pieces: each change plus `contextLines` on both
260+// sides. Touching pieces are merged — otherwise two neighbouring changes would
261+// produce overlapping @@ headers, and the patch would be refused.
262+func hunks(lines []line) []hunk {
263+ var out []hunk
264+ i := 0
265+ for i < len(lines) {
266+ if lines[i].op == keep {
267+ i++
268+ continue
269+ }
270+ start := max(0, i-contextLines)
271+ if n := len(out); n > 0 {
272+ if prevEnd := indexAfter(lines, out[n-1]); prevEnd >= start {
273+ out[n-1] = append(out[n-1], lines[prevEnd:hunkEnd(lines, i)]...)
274+ i = hunkEnd(lines, i)
275+ continue
276+ }
277+ }
278+ out = append(out, append(hunk(nil), lines[start:hunkEnd(lines, i)]...))
279+ i = hunkEnd(lines, i)
280+ }
281+ return out
282+}
283+
284+// hunkEnd finds the end of the piece opened at `i`: advance while a change
285+// occurs within `contextLines` lines, then keep the trailing context.
286+func hunkEnd(lines []line, i int) int {
287+ last := i
288+ for j := i; j < len(lines); j++ {
289+ if lines[j].op != keep {
290+ last = j
291+ continue
292+ }
293+ if j-last > contextLines {
294+ break
295+ }
296+ }
297+ return min(len(lines), last+contextLines+1)
298+}
299+
300+// indexAfter returns the position, in `lines`, of the line following the last
301+// element of `h`.
302+func indexAfter(lines []line, h hunk) int {
303+ if len(h) == 0 {
304+ return 0
305+ }
306+ target := h[len(h)-1]
307+ for i, l := range lines {
308+ if l == target {
309+ return i + 1
310+ }
311+ }
312+ return 0
313+}
314+
315+// ranges computes the @@ header of the piece.
316+func (h hunk) ranges() (oldStart, oldCount, newStart, newCount int) {
317+ for _, l := range h {
318+ if l.op != add {
319+ if oldStart == 0 {
320+ oldStart = l.oldNo
321+ }
322+ oldCount++
323+ }
324+ if l.op != del {
325+ if newStart == 0 {
326+ newStart = l.newNo
327+ }
328+ newCount++
329+ }
330+ }
331+ if oldCount == 0 {
332+ oldStart = 0
333+ }
334+ if newCount == 0 {
335+ newStart = 0
336+ }
337+ return oldStart, oldCount, newStart, newCount
338+}
339+
340+// render formats the diff for the screen and for the model: a line number, a
341+// sign, the text. No colours here at all — the same text goes back to the
342+// model, and ANSI codes in a tool result only get in its way; the screen gets
343+// its grey from the caller's preview mechanism.
344+//
345+// The number shown is ALWAYS the new version's: the one to look at after the
346+// edit. A deleted line no longer exists there, so it gets the number of the
347+// place where it vanished — a replacement shows "-" and "+" on the SAME number
348+// and the column never goes backwards.
349+func (r comparison) render() string {
350+ if !changed(r.lines) {
351+ return ""
352+ }
353+ var b strings.Builder
354+ for _, h := range hunks(r.lines) {
355+ current := 0
356+ for _, l := range h {
357+ no := l.newNo
358+ if no > 0 {
359+ current = no
360+ } else {
361+ no = current + 1
362+ }
363+ fmt.Fprintf(&b, "%4d %c %s\n", no, l.op, l.text)
364+ }
365+ }
366+ return b.String()
367+}
new file mode 100644
@@ -0,0 +1,367 @@
1+package fileedit
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// op is what happened to a line. The values are the characters of the unified
9+// format, which saves a conversion table.
10+type op byte
11+
12+const (
13+ keep op = ' '
14+ del op = '-'
15+ add op = '+'
16+)
17+
18+// line is one line of the diff. oldNo and newNo are 1-based, 0 when the line
19+// does not exist on that side.
20+type line struct {
21+ op op
22+ text string
23+ oldNo int
24+ newNo int
25+}
26+
27+// comparison is a diff ready to render: the lines, and what splitting into
28+// lines cannot say — whether each side ended with a newline. Two files that
29+// differ only there are two different files, and `git apply` refuses a patch
30+// that ignores it.
31+type comparison struct {
32+ lines []line
33+ oldNL, newNL bool
34+}
35+
36+// compare splits, compares, and returns what is needed to display and to patch.
37+func compare(old, new string) comparison {
38+ oldLines, oldNL := splitLines(old)
39+ newLines, newNL := splitLines(new)
40+ r := comparison{lines: diffLines(oldLines, newLines), oldNL: oldNL, newNL: newNL}
41+
42+ // Edge case: identical line by line, only the final newline moved. Nothing
43+ // is marked changed and the patch would be empty while the file changes.
44+ // The last line is replayed — deleted then added — which is exactly what
45+ // `git diff` prints.
46+ if oldNL != newNL && !changed(r.lines) && len(r.lines) > 0 {
47+ last := r.lines[len(r.lines)-1]
48+ r.lines = append(r.lines[:len(r.lines)-1],
49+ line{del, last.text, last.oldNo, 0},
50+ line{add, last.text, 0, last.newNo},
51+ )
52+ }
53+ return r
54+}
55+
56+func (r comparison) changed() bool { return changed(r.lines) }
57+
58+// firstChanged is the number, in the NEW version, of the first touched line —
59+// 0 when nothing changed. Enough to open the file at the right place, or to
60+// say "it starts at line 42" rather than "done".
61+func (r comparison) firstChanged() int {
62+ for _, l := range r.lines {
63+ switch l.op {
64+ case add:
65+ return l.newNo
66+ case del:
67+ // A deletion has no line in the new version: point at where it
68+ // happened.
69+ return max(1, newNoBefore(r.lines, l))
70+ }
71+ }
72+ return 0
73+}
74+
75+func (r comparison) stat() (added, deleted int) {
76+ for _, l := range r.lines {
77+ switch l.op {
78+ case add:
79+ added++
80+ case del:
81+ deleted++
82+ }
83+ }
84+ return added, deleted
85+}
86+
87+// splitLines splits a body into lines and says whether it ended with a
88+// newline. Both matter: a file without a final newline is a different file,
89+// and the unified patch has to say so.
90+func splitLines(body string) (lines []string, finalNewline bool) {
91+ if body == "" {
92+ return nil, true
93+ }
94+ finalNewline = strings.HasSuffix(body, "\n")
95+ if finalNewline {
96+ body = body[:len(body)-1]
97+ }
98+ return strings.Split(body, "\n"), finalNewline
99+}
100+
101+// maxCells caps the LCS table. Past it, a line-by-line comparison would cost
102+// hundreds of megabytes for a diff nobody will read: the middle block is then
103+// rendered as a block (all deleted, all added), still exact — only coarser.
104+const maxCells = 4 << 20
105+
106+// diffLines compares two sequences of lines.
107+//
108+// Common prefix and suffix are removed BEFORE the LCS: a targeted edit touches
109+// a few lines in the middle of a file, and without that cut the table would be
110+// the size of the file squared.
111+func diffLines(old, new []string) []line {
112+ var out []line
113+
114+ head := 0
115+ for head < len(old) && head < len(new) && old[head] == new[head] {
116+ out = append(out, line{keep, old[head], head + 1, head + 1})
117+ head++
118+ }
119+ tail := 0
120+ for tail < len(old)-head && tail < len(new)-head &&
121+ old[len(old)-1-tail] == new[len(new)-1-tail] {
122+ tail++
123+ }
124+
125+ midOld, midNew := old[head:len(old)-tail], new[head:len(new)-tail]
126+ out = append(out, middle(midOld, midNew, head)...)
127+
128+ for i := len(old) - tail; i < len(old); i++ {
129+ out = append(out, line{keep, old[i], i + 1, i + 1 - len(old) + len(new)})
130+ }
131+ return out
132+}
133+
134+// middle compares what remains after trimming the edges. `off` is the number
135+// of lines already consumed, to number correctly.
136+func middle(old, new []string, off int) []line {
137+ if len(old) == 0 && len(new) == 0 {
138+ return nil
139+ }
140+ if len(old)*len(new) > maxCells {
141+ out := make([]line, 0, len(old)+len(new))
142+ for i, l := range old {
143+ out = append(out, line{del, l, off + i + 1, 0})
144+ }
145+ for j, l := range new {
146+ out = append(out, line{add, l, 0, off + j + 1})
147+ }
148+ return out
149+ }
150+
151+ // Classic LCS: length table, then walk. Files edited by an agent fit in it
152+ // comfortably.
153+ lcs := make([][]int, len(old)+1)
154+ for i := range lcs {
155+ lcs[i] = make([]int, len(new)+1)
156+ }
157+ for i := len(old) - 1; i >= 0; i-- {
158+ for j := len(new) - 1; j >= 0; j-- {
159+ if old[i] == new[j] {
160+ lcs[i][j] = lcs[i+1][j+1] + 1
161+ } else {
162+ lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1])
163+ }
164+ }
165+ }
166+
167+ var out []line
168+ i, j := 0, 0
169+ for i < len(old) && j < len(new) {
170+ switch {
171+ case old[i] == new[j]:
172+ out = append(out, line{keep, old[i], off + i + 1, off + j + 1})
173+ i, j = i+1, j+1
174+ case lcs[i+1][j] >= lcs[i][j+1]:
175+ // Deletion first on a tie: a replacement then reads "- old" then
176+ // "+ new", the order one expects.
177+ out = append(out, line{del, old[i], off + i + 1, 0})
178+ i++
179+ default:
180+ out = append(out, line{add, new[j], 0, off + j + 1})
181+ j++
182+ }
183+ }
184+ for ; i < len(old); i++ {
185+ out = append(out, line{del, old[i], off + i + 1, 0})
186+ }
187+ for ; j < len(new); j++ {
188+ out = append(out, line{add, new[j], 0, off + j + 1})
189+ }
190+ return out
191+}
192+
193+// newNoBefore finds the number of the kept line that precedes `target`.
194+func newNoBefore(lines []line, target line) int {
195+ last := 0
196+ for _, l := range lines {
197+ if l == target {
198+ break
199+ }
200+ if l.newNo > 0 {
201+ last = l.newNo
202+ }
203+ }
204+ return last + 1
205+}
206+
207+func changed(lines []line) bool {
208+ for _, l := range lines {
209+ if l.op != keep {
210+ return true
211+ }
212+ }
213+ return false
214+}
215+
216+// noNewline is the line the unified format requires when a file does not end
217+// with a newline. `git apply` reads it; without it, it adds a newline nobody
218+// asked for.
219+const noNewline = `\ No newline at end of file`
220+
221+// contextLines is how many unchanged lines are kept around each change. Three
222+// is the value of `diff -u` and of git.
223+const contextLines = 3
224+
225+// unified renders a unified patch, applicable as-is by `patch -p1` or
226+// `git apply`. Paths are prefixed a/ and b/ like git does.
227+func (r comparison) unified(path string) string {
228+ if !changed(r.lines) {
229+ return ""
230+ }
231+ var b strings.Builder
232+ fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path)
233+
234+ lastOld, lastNew := 0, 0
235+ for _, l := range r.lines {
236+ lastOld, lastNew = max(lastOld, l.oldNo), max(lastNew, l.newNo)
237+ }
238+ for _, h := range hunks(r.lines) {
239+ oldStart, oldCount, newStart, newCount := h.ranges()
240+ fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)
241+ for _, l := range h {
242+ b.WriteByte(byte(l.op))
243+ b.WriteString(l.text)
244+ b.WriteByte('\n')
245+ // The marker follows the LAST line of each version, and a kept
246+ // line belongs to both: it may carry it for both at once.
247+ if (!r.oldNL && l.oldNo == lastOld && l.op != add) ||
248+ (!r.newNL && l.newNo == lastNew && l.op != del) {
249+ b.WriteString(noNewline + "\n")
250+ }
251+ }
252+ }
253+ return b.String()
254+}
255+
256+// hunk is a group of contiguous lines shown together.
257+type hunk []line
258+
259+// hunks cuts the diff into pieces: each change plus `contextLines` on both
260+// sides. Touching pieces are merged — otherwise two neighbouring changes would
261+// produce overlapping @@ headers, and the patch would be refused.
262+func hunks(lines []line) []hunk {
263+ var out []hunk
264+ i := 0
265+ for i < len(lines) {
266+ if lines[i].op == keep {
267+ i++
268+ continue
269+ }
270+ start := max(0, i-contextLines)
271+ if n := len(out); n > 0 {
272+ if prevEnd := indexAfter(lines, out[n-1]); prevEnd >= start {
273+ out[n-1] = append(out[n-1], lines[prevEnd:hunkEnd(lines, i)]...)
274+ i = hunkEnd(lines, i)
275+ continue
276+ }
277+ }
278+ out = append(out, append(hunk(nil), lines[start:hunkEnd(lines, i)]...))
279+ i = hunkEnd(lines, i)
280+ }
281+ return out
282+}
283+
284+// hunkEnd finds the end of the piece opened at `i`: advance while a change
285+// occurs within `contextLines` lines, then keep the trailing context.
286+func hunkEnd(lines []line, i int) int {
287+ last := i
288+ for j := i; j < len(lines); j++ {
289+ if lines[j].op != keep {
290+ last = j
291+ continue
292+ }
293+ if j-last > contextLines {
294+ break
295+ }
296+ }
297+ return min(len(lines), last+contextLines+1)
298+}
299+
300+// indexAfter returns the position, in `lines`, of the line following the last
301+// element of `h`.
302+func indexAfter(lines []line, h hunk) int {
303+ if len(h) == 0 {
304+ return 0
305+ }
306+ target := h[len(h)-1]
307+ for i, l := range lines {
308+ if l == target {
309+ return i + 1
310+ }
311+ }
312+ return 0
313+}
314+
315+// ranges computes the @@ header of the piece.
316+func (h hunk) ranges() (oldStart, oldCount, newStart, newCount int) {
317+ for _, l := range h {
318+ if l.op != add {
319+ if oldStart == 0 {
320+ oldStart = l.oldNo
321+ }
322+ oldCount++
323+ }
324+ if l.op != del {
325+ if newStart == 0 {
326+ newStart = l.newNo
327+ }
328+ newCount++
329+ }
330+ }
331+ if oldCount == 0 {
332+ oldStart = 0
333+ }
334+ if newCount == 0 {
335+ newStart = 0
336+ }
337+ return oldStart, oldCount, newStart, newCount
338+}
339+
340+// render formats the diff for the screen and for the model: a line number, a
341+// sign, the text. No colours here at all — the same text goes back to the
342+// model, and ANSI codes in a tool result only get in its way; the screen gets
343+// its grey from the caller's preview mechanism.
344+//
345+// The number shown is ALWAYS the new version's: the one to look at after the
346+// edit. A deleted line no longer exists there, so it gets the number of the
347+// place where it vanished — a replacement shows "-" and "+" on the SAME number
348+// and the column never goes backwards.
349+func (r comparison) render() string {
350+ if !changed(r.lines) {
351+ return ""
352+ }
353+ var b strings.Builder
354+ for _, h := range hunks(r.lines) {
355+ current := 0
356+ for _, l := range h {
357+ no := l.newNo
358+ if no > 0 {
359+ current = no
360+ } else {
361+ no = current + 1
362+ }
363+ fmt.Fprintf(&b, "%4d %c %s\n", no, l.op, l.text)
364+ }
365+ }
366+ return b.String()
367+}
added internal/fileedit/edits.go +122 -0
new file mode 100644
@@ -0,0 +1,122 @@
1+package fileedit
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+)
8+
9+// Edit is one replacement: a text to find, a text to put in its place. The
10+// JSON names are the ones the model sees in the edit_file schema.
11+type Edit struct {
12+ Old string `json:"old" jsonschema_description:"Exact text to find: copied from the file character for character, and present exactly once"`
13+ New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"`
14+}
15+
16+// span is the region an edit occupies in the ORIGINAL body.
17+type span struct {
18+ start, end int
19+ index int // rank of the edit, for error messages
20+}
21+
22+// apply applies every edit to the body and returns the result.
23+//
24+// The rules of the `edit` CLI, kept to the letter because they are what makes
25+// an edit verifiable: each `old` appears once and only once; edits are resolved
26+// against the ORIGINAL body, never in cascade; two edits must not touch, nest
27+// or repeat; an empty `new` deletes. An ambiguous edit — two occurrences, two
28+// overlapping regions — would give a result that depends on application order,
29+// hence a file nobody has read. Nothing is written when one edit fails: a
30+// half-edited file is the worst outcome, because it looks like a success.
31+func apply(body string, list []Edit) (string, error) {
32+ if len(list) == 0 {
33+ return "", fmt.Errorf("no edit given: each edit needs an old and a new text")
34+ }
35+
36+ spans := make([]span, 0, len(list))
37+ for i, e := range list {
38+ s, err := locate(body, e, i)
39+ if err != nil {
40+ return "", err
41+ }
42+ spans = append(spans, s)
43+ }
44+
45+ // Sorted by position: the overlap check and the rebuild both walk the file
46+ // in its order, not in the order of the request.
47+ sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
48+ for i := 1; i < len(spans); i++ {
49+ if spans[i].start < spans[i-1].end {
50+ a, b := spans[i-1], spans[i]
51+ return "", fmt.Errorf(
52+ "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair",
53+ a.index+1, b.index+1,
54+ lineOf(body, a.start), lineOf(body, a.end),
55+ lineOf(body, b.start), lineOf(body, b.end))
56+ }
57+ }
58+
59+ var b strings.Builder
60+ at := 0
61+ for _, s := range spans {
62+ b.WriteString(body[at:s.start])
63+ b.WriteString(list[s.index].New)
64+ at = s.end
65+ }
66+ b.WriteString(body[at:])
67+ return b.String(), nil
68+}
69+
70+// locate finds the single occurrence of an `old` text.
71+//
72+// The messages are written FOR A MODEL: they say what is wrong and the move
73+// that fixes it, because a model is what will retry the call.
74+func locate(body string, e Edit, i int) (span, error) {
75+ if e.Old == "" {
76+ return span{}, fmt.Errorf(
77+ "edit #%d has an empty old text: it would match everywhere — use write_file to replace the whole file",
78+ i+1)
79+ }
80+ if e.Old == e.New {
81+ return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1)
82+ }
83+
84+ switch n := strings.Count(body, e.Old); {
85+ case n == 0:
86+ return span{}, fmt.Errorf(
87+ "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s",
88+ i+1, quote(e.Old))
89+ case n > 1:
90+ return span{}, fmt.Errorf(
91+ "edit #%d: old text appears %d times — add the surrounding lines until it is unique:\n%s",
92+ i+1, n, quote(e.Old))
93+ }
94+
95+ start := strings.Index(body, e.Old)
96+ return span{start: start, end: start + len(e.Old), index: i}, nil
97+}
98+
99+// lineOf returns the 1-based line number of an offset in the body.
100+func lineOf(body string, offset int) int {
101+ return 1 + strings.Count(body[:offset], "\n")
102+}
103+
104+// quote shows the searched text, indented, capped to its first lines: past
105+// that the error would drown the output and teach nothing more.
106+func quote(s string) string {
107+ const maxLines = 6
108+
109+ lines := strings.Split(s, "\n")
110+ shown := lines
111+ if len(lines) > maxLines {
112+ shown = lines[:maxLines]
113+ }
114+ var b strings.Builder
115+ for _, l := range shown {
116+ b.WriteString(" │ " + l + "\n")
117+ }
118+ if len(lines) > len(shown) {
119+ fmt.Fprintf(&b, " └ … %d more line(s)\n", len(lines)-len(shown))
120+ }
121+ return strings.TrimRight(b.String(), "\n")
122+}
new file mode 100644
@@ -0,0 +1,122 @@
1+package fileedit
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+)
8+
9+// Edit is one replacement: a text to find, a text to put in its place. The
10+// JSON names are the ones the model sees in the edit_file schema.
11+type Edit struct {
12+ Old string `json:"old" jsonschema_description:"Exact text to find: copied from the file character for character, and present exactly once"`
13+ New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"`
14+}
15+
16+// span is the region an edit occupies in the ORIGINAL body.
17+type span struct {
18+ start, end int
19+ index int // rank of the edit, for error messages
20+}
21+
22+// apply applies every edit to the body and returns the result.
23+//
24+// The rules of the `edit` CLI, kept to the letter because they are what makes
25+// an edit verifiable: each `old` appears once and only once; edits are resolved
26+// against the ORIGINAL body, never in cascade; two edits must not touch, nest
27+// or repeat; an empty `new` deletes. An ambiguous edit — two occurrences, two
28+// overlapping regions — would give a result that depends on application order,
29+// hence a file nobody has read. Nothing is written when one edit fails: a
30+// half-edited file is the worst outcome, because it looks like a success.
31+func apply(body string, list []Edit) (string, error) {
32+ if len(list) == 0 {
33+ return "", fmt.Errorf("no edit given: each edit needs an old and a new text")
34+ }
35+
36+ spans := make([]span, 0, len(list))
37+ for i, e := range list {
38+ s, err := locate(body, e, i)
39+ if err != nil {
40+ return "", err
41+ }
42+ spans = append(spans, s)
43+ }
44+
45+ // Sorted by position: the overlap check and the rebuild both walk the file
46+ // in its order, not in the order of the request.
47+ sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
48+ for i := 1; i < len(spans); i++ {
49+ if spans[i].start < spans[i-1].end {
50+ a, b := spans[i-1], spans[i]
51+ return "", fmt.Errorf(
52+ "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair",
53+ a.index+1, b.index+1,
54+ lineOf(body, a.start), lineOf(body, a.end),
55+ lineOf(body, b.start), lineOf(body, b.end))
56+ }
57+ }
58+
59+ var b strings.Builder
60+ at := 0
61+ for _, s := range spans {
62+ b.WriteString(body[at:s.start])
63+ b.WriteString(list[s.index].New)
64+ at = s.end
65+ }
66+ b.WriteString(body[at:])
67+ return b.String(), nil
68+}
69+
70+// locate finds the single occurrence of an `old` text.
71+//
72+// The messages are written FOR A MODEL: they say what is wrong and the move
73+// that fixes it, because a model is what will retry the call.
74+func locate(body string, e Edit, i int) (span, error) {
75+ if e.Old == "" {
76+ return span{}, fmt.Errorf(
77+ "edit #%d has an empty old text: it would match everywhere — use write_file to replace the whole file",
78+ i+1)
79+ }
80+ if e.Old == e.New {
81+ return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1)
82+ }
83+
84+ switch n := strings.Count(body, e.Old); {
85+ case n == 0:
86+ return span{}, fmt.Errorf(
87+ "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s",
88+ i+1, quote(e.Old))
89+ case n > 1:
90+ return span{}, fmt.Errorf(
91+ "edit #%d: old text appears %d times — add the surrounding lines until it is unique:\n%s",
92+ i+1, n, quote(e.Old))
93+ }
94+
95+ start := strings.Index(body, e.Old)
96+ return span{start: start, end: start + len(e.Old), index: i}, nil
97+}
98+
99+// lineOf returns the 1-based line number of an offset in the body.
100+func lineOf(body string, offset int) int {
101+ return 1 + strings.Count(body[:offset], "\n")
102+}
103+
104+// quote shows the searched text, indented, capped to its first lines: past
105+// that the error would drown the output and teach nothing more.
106+func quote(s string) string {
107+ const maxLines = 6
108+
109+ lines := strings.Split(s, "\n")
110+ shown := lines
111+ if len(lines) > maxLines {
112+ shown = lines[:maxLines]
113+ }
114+ var b strings.Builder
115+ for _, l := range shown {
116+ b.WriteString(" │ " + l + "\n")
117+ }
118+ if len(lines) > len(shown) {
119+ fmt.Fprintf(&b, " └ … %d more line(s)\n", len(lines)-len(shown))
120+ }
121+ return strings.TrimRight(b.String(), "\n")
122+}
added internal/fileedit/fileedit.go +166 -0
new file mode 100644
@@ -0,0 +1,166 @@
1+package fileedit
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// Result is what a write or a replace reports — the fields the `edit` CLI
9+// prints and the Pi tool returns: a headline, the readable diff, the unified
10+// patch, the first changed line. Headline + Diff is what goes back to the
11+// model; the caller decides what the screen shows.
12+type Result struct {
13+ Path string
14+ Created bool
15+ Changed bool
16+ DryRun bool
17+ Edits int
18+ Added, Deleted int
19+ FirstChangedLine int
20+ Headline string
21+ Diff string
22+ Patch string
23+}
24+
25+// Read returns the file, or a slice of it (1-based, inclusive; 0 = from the
26+// start / to the end). It is the mandatory companion of Replace: an exact
27+// replacement assumes the exact text was READ, not remembered.
28+func Read(path string, start, end int, numbered bool) (string, error) {
29+ f, err := load(path)
30+ if err != nil {
31+ return "", fmt.Errorf("cannot read %s: %v", path, err)
32+ }
33+ if f.isNew {
34+ return "", fmt.Errorf("%s does not exist. Create it with write_file", path)
35+ }
36+
37+ lines, finalNewline := splitLines(f.body)
38+ from, to, err := bounds(start, end, len(lines))
39+ if err != nil {
40+ return "", fmt.Errorf("%s: %v", path, err)
41+ }
42+ slice := lines[from-1 : to]
43+
44+ if numbered {
45+ var b strings.Builder
46+ for i, l := range slice {
47+ fmt.Fprintf(&b, "%6d %s\n", from+i, l)
48+ }
49+ return b.String(), nil
50+ }
51+ body := strings.Join(slice, "\n")
52+ // The final newline is only put back if it existed AND the end of the file
53+ // is shown: otherwise a slice would invent a line the file does not have.
54+ if finalNewline || to < len(lines) {
55+ body += "\n"
56+ }
57+ return body, nil
58+}
59+
60+// bounds turns start/end into a valid slice, or says why it is not one.
61+func bounds(start, end, total int) (int, int, error) {
62+ if total == 0 {
63+ return 1, 0, nil // empty file: nothing to show, not an error
64+ }
65+ if start == 0 {
66+ start = 1
67+ }
68+ if end == 0 {
69+ end = total
70+ }
71+ if start < 1 || end < 1 {
72+ return 0, 0, fmt.Errorf("start and end are 1-based line numbers")
73+ }
74+ if start > total {
75+ return 0, 0, fmt.Errorf("start %d is past the end of the file (%d lines)", start, total)
76+ }
77+ if end > total {
78+ end = total
79+ }
80+ if start > end {
81+ return 0, 0, fmt.Errorf("start %d comes after end %d", start, end)
82+ }
83+ return start, end, nil
84+}
85+
86+// Write writes the WHOLE file. The tool for a new file or a deliberate
87+// rewrite; to change three lines of an existing file, Replace is safer — it
88+// fails when the targeted text is not what one thought, where Write overwrites
89+// without checking.
90+func Write(path, content string) (Result, error) {
91+ f, err := load(path)
92+ if err != nil {
93+ return Result{}, fmt.Errorf("cannot read %s: %v", path, err)
94+ }
95+ // A content without a final newline would give a file without one: on a
96+ // text file that is almost always an accident, and every diff tool then
97+ // flags it.
98+ if content != "" && !strings.HasSuffix(content, "\n") {
99+ content += "\n"
100+ }
101+ return finish(Result{Path: path, Created: f.isNew}, f, content)
102+}
103+
104+// Replace applies exact replacements to an existing file — the operation that
105+// matters: it changes a file without rewriting it, and refuses anything
106+// ambiguous. With dryRun the diff is computed and nothing is written.
107+func Replace(path string, edits []Edit, dryRun bool) (Result, error) {
108+ f, err := load(path)
109+ if err != nil {
110+ return Result{}, fmt.Errorf("cannot read %s: %v", path, err)
111+ }
112+ if f.isNew {
113+ return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path)
114+ }
115+ body, err := apply(f.body, edits)
116+ if err != nil {
117+ // The error carries the file name: it is read out of context, in a
118+ // tool result among others.
119+ return Result{}, fmt.Errorf("%s: %v", path, err)
120+ }
121+ return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body)
122+}
123+
124+// finish compares, writes if needed, and reports. Write and Replace both end
125+// here: that is what gives them exactly the same output, hence one format for
126+// the model to learn.
127+func finish(r Result, f *file, body string) (Result, error) {
128+ c := compare(f.body, body)
129+ r.Changed = c.changed()
130+ r.Added, r.Deleted = c.stat()
131+ r.FirstChangedLine = c.firstChanged()
132+ r.Diff = c.render()
133+ r.Patch = c.unified(r.Path)
134+
135+ // Nothing to write: SAY so rather than touch the file. An identical
136+ // rewrite would bump the modification time and wake every watcher, for
137+ // nothing.
138+ if r.Changed && !r.DryRun {
139+ if err := f.write(body); err != nil {
140+ return Result{}, fmt.Errorf("cannot write %s: %v", r.Path, err)
141+ }
142+ }
143+ r.Headline = headline(r)
144+ return r, nil
145+}
146+
147+// headline sums the operation up in one line — the one the model re-reads in
148+// its tool result, and the one the screen shows under the display line.
149+func headline(r Result) string {
150+ switch {
151+ case !r.Changed:
152+ return fmt.Sprintf("%s: unchanged", r.Path)
153+ case r.Created && r.DryRun:
154+ return fmt.Sprintf("%s: would be created, %d line(s)", r.Path, r.Added)
155+ case r.Created:
156+ return fmt.Sprintf("%s: created, %d line(s)", r.Path, r.Added)
157+ }
158+ what := fmt.Sprintf("+%d -%d, first change at line %d", r.Added, r.Deleted, r.FirstChangedLine)
159+ if r.Edits > 0 {
160+ what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what)
161+ }
162+ if r.DryRun {
163+ return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what)
164+ }
165+ return fmt.Sprintf("%s: %s", r.Path, what)
166+}
new file mode 100644
@@ -0,0 +1,166 @@
1+package fileedit
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// Result is what a write or a replace reports — the fields the `edit` CLI
9+// prints and the Pi tool returns: a headline, the readable diff, the unified
10+// patch, the first changed line. Headline + Diff is what goes back to the
11+// model; the caller decides what the screen shows.
12+type Result struct {
13+ Path string
14+ Created bool
15+ Changed bool
16+ DryRun bool
17+ Edits int
18+ Added, Deleted int
19+ FirstChangedLine int
20+ Headline string
21+ Diff string
22+ Patch string
23+}
24+
25+// Read returns the file, or a slice of it (1-based, inclusive; 0 = from the
26+// start / to the end). It is the mandatory companion of Replace: an exact
27+// replacement assumes the exact text was READ, not remembered.
28+func Read(path string, start, end int, numbered bool) (string, error) {
29+ f, err := load(path)
30+ if err != nil {
31+ return "", fmt.Errorf("cannot read %s: %v", path, err)
32+ }
33+ if f.isNew {
34+ return "", fmt.Errorf("%s does not exist. Create it with write_file", path)
35+ }
36+
37+ lines, finalNewline := splitLines(f.body)
38+ from, to, err := bounds(start, end, len(lines))
39+ if err != nil {
40+ return "", fmt.Errorf("%s: %v", path, err)
41+ }
42+ slice := lines[from-1 : to]
43+
44+ if numbered {
45+ var b strings.Builder
46+ for i, l := range slice {
47+ fmt.Fprintf(&b, "%6d %s\n", from+i, l)
48+ }
49+ return b.String(), nil
50+ }
51+ body := strings.Join(slice, "\n")
52+ // The final newline is only put back if it existed AND the end of the file
53+ // is shown: otherwise a slice would invent a line the file does not have.
54+ if finalNewline || to < len(lines) {
55+ body += "\n"
56+ }
57+ return body, nil
58+}
59+
60+// bounds turns start/end into a valid slice, or says why it is not one.
61+func bounds(start, end, total int) (int, int, error) {
62+ if total == 0 {
63+ return 1, 0, nil // empty file: nothing to show, not an error
64+ }
65+ if start == 0 {
66+ start = 1
67+ }
68+ if end == 0 {
69+ end = total
70+ }
71+ if start < 1 || end < 1 {
72+ return 0, 0, fmt.Errorf("start and end are 1-based line numbers")
73+ }
74+ if start > total {
75+ return 0, 0, fmt.Errorf("start %d is past the end of the file (%d lines)", start, total)
76+ }
77+ if end > total {
78+ end = total
79+ }
80+ if start > end {
81+ return 0, 0, fmt.Errorf("start %d comes after end %d", start, end)
82+ }
83+ return start, end, nil
84+}
85+
86+// Write writes the WHOLE file. The tool for a new file or a deliberate
87+// rewrite; to change three lines of an existing file, Replace is safer — it
88+// fails when the targeted text is not what one thought, where Write overwrites
89+// without checking.
90+func Write(path, content string) (Result, error) {
91+ f, err := load(path)
92+ if err != nil {
93+ return Result{}, fmt.Errorf("cannot read %s: %v", path, err)
94+ }
95+ // A content without a final newline would give a file without one: on a
96+ // text file that is almost always an accident, and every diff tool then
97+ // flags it.
98+ if content != "" && !strings.HasSuffix(content, "\n") {
99+ content += "\n"
100+ }
101+ return finish(Result{Path: path, Created: f.isNew}, f, content)
102+}
103+
104+// Replace applies exact replacements to an existing file — the operation that
105+// matters: it changes a file without rewriting it, and refuses anything
106+// ambiguous. With dryRun the diff is computed and nothing is written.
107+func Replace(path string, edits []Edit, dryRun bool) (Result, error) {
108+ f, err := load(path)
109+ if err != nil {
110+ return Result{}, fmt.Errorf("cannot read %s: %v", path, err)
111+ }
112+ if f.isNew {
113+ return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path)
114+ }
115+ body, err := apply(f.body, edits)
116+ if err != nil {
117+ // The error carries the file name: it is read out of context, in a
118+ // tool result among others.
119+ return Result{}, fmt.Errorf("%s: %v", path, err)
120+ }
121+ return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body)
122+}
123+
124+// finish compares, writes if needed, and reports. Write and Replace both end
125+// here: that is what gives them exactly the same output, hence one format for
126+// the model to learn.
127+func finish(r Result, f *file, body string) (Result, error) {
128+ c := compare(f.body, body)
129+ r.Changed = c.changed()
130+ r.Added, r.Deleted = c.stat()
131+ r.FirstChangedLine = c.firstChanged()
132+ r.Diff = c.render()
133+ r.Patch = c.unified(r.Path)
134+
135+ // Nothing to write: SAY so rather than touch the file. An identical
136+ // rewrite would bump the modification time and wake every watcher, for
137+ // nothing.
138+ if r.Changed && !r.DryRun {
139+ if err := f.write(body); err != nil {
140+ return Result{}, fmt.Errorf("cannot write %s: %v", r.Path, err)
141+ }
142+ }
143+ r.Headline = headline(r)
144+ return r, nil
145+}
146+
147+// headline sums the operation up in one line — the one the model re-reads in
148+// its tool result, and the one the screen shows under the display line.
149+func headline(r Result) string {
150+ switch {
151+ case !r.Changed:
152+ return fmt.Sprintf("%s: unchanged", r.Path)
153+ case r.Created && r.DryRun:
154+ return fmt.Sprintf("%s: would be created, %d line(s)", r.Path, r.Added)
155+ case r.Created:
156+ return fmt.Sprintf("%s: created, %d line(s)", r.Path, r.Added)
157+ }
158+ what := fmt.Sprintf("+%d -%d, first change at line %d", r.Added, r.Deleted, r.FirstChangedLine)
159+ if r.Edits > 0 {
160+ what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what)
161+ }
162+ if r.DryRun {
163+ return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what)
164+ }
165+ return fmt.Sprintf("%s: %s", r.Path, what)
166+}
added internal/fileedit/fileedit_test.go +307 -0
new file mode 100644
@@ -0,0 +1,307 @@
1+// The cases below are the `edit` CLI's own tests (tools/edit/internal/*),
2+// ported: what they guard is that the built-in tools refuse exactly what the
3+// CLI refuses, in words a model can act on — otherwise the A/B comparison of
4+// this part would measure two different tools.
5+package fileedit
6+
7+import (
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+)
14+
15+func inDir(t *testing.T, name, content string) string {
16+ t.Helper()
17+ path := filepath.Join(t.TempDir(), name)
18+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+ return path
22+}
23+
24+func fileOf(t *testing.T, path string) string {
25+ t.Helper()
26+ data, err := os.ReadFile(path)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ return string(data)
31+}
32+
33+func TestApply(t *testing.T) {
34+ cases := []struct {
35+ name string
36+ body string
37+ edits []Edit
38+ want string
39+ }{
40+ {"one replacement", "const port = 3000;\n",
41+ []Edit{{"const port = 3000;", "const port = envPort(3000);"}}, "const port = envPort(3000);\n"},
42+ {"two independent edits, resolved against the original", "HOST = \"localhost\"\nPORT = 3000\n",
43+ []Edit{{`HOST = "localhost"`, `HOST = env("HOST", "localhost")`}, {`PORT = 3000`, `PORT = envInt("PORT", 3000)`}},
44+ "HOST = env(\"HOST\", \"localhost\")\nPORT = envInt(\"PORT\", 3000)\n"},
45+ {"the order of the edits does not change the result", "a\nb\nc\n",
46+ []Edit{{"c", "C"}, {"a", "A"}}, "A\nb\nC\n"},
47+ {"an empty new deletes", "keep\nto delete\nkeep too\n",
48+ []Edit{{"to delete\n", ""}}, "keep\nkeep too\n"},
49+ {"the old text spans several lines", "func f() {\n\treturn 1\n}\n",
50+ []Edit{{"func f() {\n\treturn 1\n}", "func f() int {\n\treturn 2\n}"}}, "func f() int {\n\treturn 2\n}\n"},
51+ {"an edit is NOT re-read by the next one", "x\ny\n",
52+ []Edit{{"x", "y"}, {"y", "z"}}, "y\nz\n"},
53+ }
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ got, err := apply(tc.body, tc.edits)
57+ if err != nil {
58+ t.Fatalf("apply: %v", err)
59+ }
60+ if got != tc.want {
61+ t.Errorf("apply = %q, want %q", got, tc.want)
62+ }
63+ })
64+ }
65+}
66+
67+// TestApplyRefuses guards the rules that make an edit verifiable. Each message
68+// is read by a MODEL, which must be able to fix the call without guessing: the
69+// test also checks that the message carries the fix.
70+func TestApplyRefuses(t *testing.T) {
71+ cases := []struct {
72+ name string
73+ body string
74+ edits []Edit
75+ says string
76+ }{
77+ {"text not found", "a\n", []Edit{{"b", "c"}}, "not found"},
78+ {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times — add the surrounding lines until it is unique"},
79+ {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n",
80+ []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}},
81+ "overlap"},
82+ {"empty old", "a\n", []Edit{{"", "b"}}, "empty old text"},
83+ {"an edit that changes nothing", "a\n", []Edit{{"a", "a"}}, "changes nothing"},
84+ {"no edit at all", "a\n", nil, "no edit given"},
85+ }
86+ for _, tc := range cases {
87+ t.Run(tc.name, func(t *testing.T) {
88+ got, err := apply(tc.body, tc.edits)
89+ if err == nil {
90+ t.Fatalf("apply succeeded, want an error — result %q", got)
91+ }
92+ if !strings.Contains(err.Error(), tc.says) {
93+ t.Errorf("error = %q, want it to contain %q", err, tc.says)
94+ }
95+ if got != "" {
96+ t.Errorf("apply returned %q along with the error: nothing must be written", got)
97+ }
98+ })
99+ }
100+}
101+
102+func TestRead(t *testing.T) {
103+ path := inDir(t, "f.txt", "one\ntwo\nthree\n")
104+
105+ got, err := Read(path, 0, 0, false)
106+ if err != nil || got != "one\ntwo\nthree\n" {
107+ t.Errorf("Read = (%q, %v)", got, err)
108+ }
109+ got, err = Read(path, 2, 2, true)
110+ if err != nil || got != " 2 two\n" {
111+ t.Errorf("Read 2-2 numbered = (%q, %v)", got, err)
112+ }
113+ if _, err := Read(filepath.Join(filepath.Dir(path), "nope.txt"), 0, 0, false); err == nil || !strings.Contains(err.Error(), "does not exist") {
114+ t.Errorf("Read of a missing file = %v", err)
115+ }
116+ if _, err := Read(path, 99, 0, false); err == nil || !strings.Contains(err.Error(), "past the end") {
117+ t.Errorf("Read start 99 = %v", err)
118+ }
119+}
120+
121+func TestWriteCreatesAndReports(t *testing.T) {
122+ path := filepath.Join(t.TempDir(), "notes.md")
123+
124+ r, err := Write(path, "# Notes")
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ if got := fileOf(t, path); got != "# Notes\n" {
129+ t.Errorf("file = %q, want %q — the final newline is missing", got, "# Notes\n")
130+ }
131+ if !r.Created || !strings.Contains(r.Headline, "created") {
132+ t.Errorf("Result = %+v, want it to announce the creation", r)
133+ }
134+ // Rewriting the same content must do nothing, and SAY so.
135+ r, err = Write(path, "# Notes")
136+ if err != nil || r.Changed || !strings.Contains(r.Headline, "unchanged") {
137+ t.Errorf("identical Write = (%+v, %v)", r, err)
138+ }
139+}
140+
141+func TestReplace(t *testing.T) {
142+ path := inDir(t, "server.go", "port := 3000\nhost := \"local\"\n")
143+
144+ r, err := Replace(path, []Edit{{"port := 3000", "port := envPort(3000)"}}, false)
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ if got := fileOf(t, path); got != "port := envPort(3000)\nhost := \"local\"\n" {
149+ t.Errorf("file = %q", got)
150+ }
151+ if r.FirstChangedLine != 1 || !strings.Contains(r.Headline, "first change at line 1") || !strings.Contains(r.Diff, "+ port := envPort(3000)") {
152+ t.Errorf("Result = %+v", r)
153+ }
154+}
155+
156+// TestReplaceIsAllOrNothing: the second edit is impossible, so the first must
157+// not have happened. A half-edited file is the worst result — it looks like a
158+// success.
159+func TestReplaceIsAllOrNothing(t *testing.T) {
160+ const before = "a = 1\nb = 2\n"
161+ path := inDir(t, "f.txt", before)
162+ _, err := Replace(path, []Edit{{"a = 1", "a = 10"}, {"absent", "x"}}, false)
163+ if err == nil || !strings.Contains(err.Error(), "not found") {
164+ t.Errorf("err = %v", err)
165+ }
166+ if got := fileOf(t, path); got != before {
167+ t.Errorf("file = %q, want unchanged %q", got, before)
168+ }
169+}
170+
171+func TestReplaceRefusesAMissingFile(t *testing.T) {
172+ _, err := Replace(filepath.Join(t.TempDir(), "nope.txt"), []Edit{{"a", "b"}}, false)
173+ if err == nil || !strings.Contains(err.Error(), "does not exist") {
174+ t.Errorf("err = %v", err)
175+ }
176+}
177+
178+func TestDryRunWritesNothing(t *testing.T) {
179+ const before = "a = 1\n"
180+ path := inDir(t, "f.txt", before)
181+ r, err := Replace(path, []Edit{{"a = 1", "a = 2"}}, true)
182+ if err != nil || !r.DryRun || !strings.Contains(r.Headline, "dry run") || !strings.Contains(r.Diff, "+ a = 2") {
183+ t.Errorf("dry run = (%+v, %v)", r, err)
184+ }
185+ if got := fileOf(t, path); got != before {
186+ t.Errorf("file = %q, want unchanged", got)
187+ }
188+}
189+
190+// TestShapeSurvives: BOM and CRLF are removed for the edit and put back on
191+// write, else every line of a Windows file would show up as changed.
192+func TestShapeSurvives(t *testing.T) {
193+ cases := []struct{ name, before, old, new, after string }{
194+ {"CRLF kept", "a\r\nb\r\n", "b", "B", "a\r\nB\r\n"},
195+ {"BOM kept", "\uFEFFa\n", "a", "A", "\uFEFFA\n"},
196+ {"plain LF", "a\n", "a", "A", "A\n"},
197+ }
198+ for _, tc := range cases {
199+ t.Run(tc.name, func(t *testing.T) {
200+ path := inDir(t, "f.txt", tc.before)
201+ if _, err := Replace(path, []Edit{{tc.old, tc.new}}, false); err != nil {
202+ t.Fatal(err)
203+ }
204+ if got := fileOf(t, path); got != tc.after {
205+ t.Errorf("file = %q, want %q", got, tc.after)
206+ }
207+ })
208+ }
209+}
210+
211+// TestWriteKeepsTheMode: an executable script that gets edited must stay
212+// executable.
213+func TestWriteKeepsTheMode(t *testing.T) {
214+ path := inDir(t, "s.sh", "echo a\n")
215+ if err := os.Chmod(path, 0o755); err != nil {
216+ t.Fatal(err)
217+ }
218+ if _, err := Write(path, "echo b\n"); err != nil {
219+ t.Fatal(err)
220+ }
221+ info, _ := os.Stat(path)
222+ if info.Mode().Perm() != 0o755 {
223+ t.Errorf("mode = %v, want 755", info.Mode().Perm())
224+ }
225+}
226+
227+func TestWriteCreatesParentDirs(t *testing.T) {
228+ path := filepath.Join(t.TempDir(), "a", "b", "notes.md")
229+ if _, err := Write(path, "# Notes\n"); err != nil {
230+ t.Fatalf("Write: %v", err)
231+ }
232+ if got := fileOf(t, path); got != "# Notes\n" {
233+ t.Errorf("file = %q", got)
234+ }
235+}
236+
237+func TestFirstChanged(t *testing.T) {
238+ cases := []struct {
239+ name string
240+ old, new string
241+ want int
242+ }{
243+ {"nothing changed", "a\nb\n", "a\nb\n", 0},
244+ {"the second line", "a\nb\nc\n", "a\nB\nc\n", 2},
245+ {"an addition at the top", "a\n", "zero\na\n", 1},
246+ {"a deletion", "a\nb\nc\n", "a\nc\n", 2},
247+ {"a created file", "", "a\n", 1},
248+ }
249+ for _, tc := range cases {
250+ t.Run(tc.name, func(t *testing.T) {
251+ if got := compare(tc.old, tc.new).firstChanged(); got != tc.want {
252+ t.Errorf("firstChanged = %d, want %d", got, tc.want)
253+ }
254+ })
255+ }
256+}
257+
258+func TestRenderHasNoANSI(t *testing.T) {
259+ out := compare("a\nb\n", "a\nB\n").render()
260+ if strings.Contains(out, "\033") || !strings.Contains(out, "- b") || !strings.Contains(out, "+ B") {
261+ t.Errorf("render = %q", out)
262+ }
263+}
264+
265+// TestUnifiedMatchesGitApply is the test that counts: a patch that "looks
266+// like" a unified patch is useless. git APPLIES it, and refuses anything that
267+// is not exact — headers, line numbers, counts, end-of-file marker.
268+func TestUnifiedMatchesGitApply(t *testing.T) {
269+ if _, err := exec.LookPath("git"); err != nil {
270+ t.Skip("git absent")
271+ }
272+ cases := []struct{ name, old, new string }{
273+ {"one line in the middle", "a\nb\nc\nd\ne\n", "a\nb\nC\nd\ne\n"},
274+ {"two distant areas", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", "1\n2\nTHREE\n4\n5\n6\n7\n8\n9\n10\n11\n12\nTHIRTEEN\n14\n15\n"},
275+ {"two neighbouring areas: one hunk", "1\n2\n3\n4\n5\n6\n7\n8\n", "1\n2\nTHREE\n4\nFIVE\n6\n7\n8\n"},
276+ {"addition at the top", "a\nb\n", "zero\na\nb\n"},
277+ {"deletion at the tail", "a\nb\nc\n", "a\nb\n"},
278+ {"everything replaced", "a\nb\n", "x\ny\nz\n"},
279+ {"no final newline", "a\nb\nc", "a\nB\nc"},
280+ {"final newline added", "a\nb", "a\nb\n"},
281+ {"file emptied", "a\nb\n", ""},
282+ }
283+ for _, tc := range cases {
284+ t.Run(tc.name, func(t *testing.T) {
285+ patch := compare(tc.old, tc.new).unified("f.txt")
286+ if patch == "" {
287+ t.Fatal("empty patch")
288+ }
289+ dir := t.TempDir()
290+ run := func(args ...string) {
291+ t.Helper()
292+ cmd := exec.Command("git", args...)
293+ cmd.Dir = dir
294+ if out, err := cmd.CombinedOutput(); err != nil {
295+ t.Fatalf("git %s: %v\n%s\npatch:\n%s", strings.Join(args, " "), err, out, patch)
296+ }
297+ }
298+ run("init", "-q")
299+ os.WriteFile(filepath.Join(dir, "f.txt"), []byte(tc.old), 0o644)
300+ os.WriteFile(filepath.Join(dir, "p.diff"), []byte(patch), 0o644)
301+ run("apply", "p.diff")
302+ if got := fileOf(t, filepath.Join(dir, "f.txt")); got != tc.new {
303+ t.Errorf("after apply: %q, want %q\npatch:\n%s", got, tc.new, patch)
304+ }
305+ })
306+ }
307+}
new file mode 100644
@@ -0,0 +1,307 @@
1+// The cases below are the `edit` CLI's own tests (tools/edit/internal/*),
2+// ported: what they guard is that the built-in tools refuse exactly what the
3+// CLI refuses, in words a model can act on — otherwise the A/B comparison of
4+// this part would measure two different tools.
5+package fileedit
6+
7+import (
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+)
14+
15+func inDir(t *testing.T, name, content string) string {
16+ t.Helper()
17+ path := filepath.Join(t.TempDir(), name)
18+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+ return path
22+}
23+
24+func fileOf(t *testing.T, path string) string {
25+ t.Helper()
26+ data, err := os.ReadFile(path)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ return string(data)
31+}
32+
33+func TestApply(t *testing.T) {
34+ cases := []struct {
35+ name string
36+ body string
37+ edits []Edit
38+ want string
39+ }{
40+ {"one replacement", "const port = 3000;\n",
41+ []Edit{{"const port = 3000;", "const port = envPort(3000);"}}, "const port = envPort(3000);\n"},
42+ {"two independent edits, resolved against the original", "HOST = \"localhost\"\nPORT = 3000\n",
43+ []Edit{{`HOST = "localhost"`, `HOST = env("HOST", "localhost")`}, {`PORT = 3000`, `PORT = envInt("PORT", 3000)`}},
44+ "HOST = env(\"HOST\", \"localhost\")\nPORT = envInt(\"PORT\", 3000)\n"},
45+ {"the order of the edits does not change the result", "a\nb\nc\n",
46+ []Edit{{"c", "C"}, {"a", "A"}}, "A\nb\nC\n"},
47+ {"an empty new deletes", "keep\nto delete\nkeep too\n",
48+ []Edit{{"to delete\n", ""}}, "keep\nkeep too\n"},
49+ {"the old text spans several lines", "func f() {\n\treturn 1\n}\n",
50+ []Edit{{"func f() {\n\treturn 1\n}", "func f() int {\n\treturn 2\n}"}}, "func f() int {\n\treturn 2\n}\n"},
51+ {"an edit is NOT re-read by the next one", "x\ny\n",
52+ []Edit{{"x", "y"}, {"y", "z"}}, "y\nz\n"},
53+ }
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ got, err := apply(tc.body, tc.edits)
57+ if err != nil {
58+ t.Fatalf("apply: %v", err)
59+ }
60+ if got != tc.want {
61+ t.Errorf("apply = %q, want %q", got, tc.want)
62+ }
63+ })
64+ }
65+}
66+
67+// TestApplyRefuses guards the rules that make an edit verifiable. Each message
68+// is read by a MODEL, which must be able to fix the call without guessing: the
69+// test also checks that the message carries the fix.
70+func TestApplyRefuses(t *testing.T) {
71+ cases := []struct {
72+ name string
73+ body string
74+ edits []Edit
75+ says string
76+ }{
77+ {"text not found", "a\n", []Edit{{"b", "c"}}, "not found"},
78+ {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times — add the surrounding lines until it is unique"},
79+ {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n",
80+ []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}},
81+ "overlap"},
82+ {"empty old", "a\n", []Edit{{"", "b"}}, "empty old text"},
83+ {"an edit that changes nothing", "a\n", []Edit{{"a", "a"}}, "changes nothing"},
84+ {"no edit at all", "a\n", nil, "no edit given"},
85+ }
86+ for _, tc := range cases {
87+ t.Run(tc.name, func(t *testing.T) {
88+ got, err := apply(tc.body, tc.edits)
89+ if err == nil {
90+ t.Fatalf("apply succeeded, want an error — result %q", got)
91+ }
92+ if !strings.Contains(err.Error(), tc.says) {
93+ t.Errorf("error = %q, want it to contain %q", err, tc.says)
94+ }
95+ if got != "" {
96+ t.Errorf("apply returned %q along with the error: nothing must be written", got)
97+ }
98+ })
99+ }
100+}
101+
102+func TestRead(t *testing.T) {
103+ path := inDir(t, "f.txt", "one\ntwo\nthree\n")
104+
105+ got, err := Read(path, 0, 0, false)
106+ if err != nil || got != "one\ntwo\nthree\n" {
107+ t.Errorf("Read = (%q, %v)", got, err)
108+ }
109+ got, err = Read(path, 2, 2, true)
110+ if err != nil || got != " 2 two\n" {
111+ t.Errorf("Read 2-2 numbered = (%q, %v)", got, err)
112+ }
113+ if _, err := Read(filepath.Join(filepath.Dir(path), "nope.txt"), 0, 0, false); err == nil || !strings.Contains(err.Error(), "does not exist") {
114+ t.Errorf("Read of a missing file = %v", err)
115+ }
116+ if _, err := Read(path, 99, 0, false); err == nil || !strings.Contains(err.Error(), "past the end") {
117+ t.Errorf("Read start 99 = %v", err)
118+ }
119+}
120+
121+func TestWriteCreatesAndReports(t *testing.T) {
122+ path := filepath.Join(t.TempDir(), "notes.md")
123+
124+ r, err := Write(path, "# Notes")
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ if got := fileOf(t, path); got != "# Notes\n" {
129+ t.Errorf("file = %q, want %q — the final newline is missing", got, "# Notes\n")
130+ }
131+ if !r.Created || !strings.Contains(r.Headline, "created") {
132+ t.Errorf("Result = %+v, want it to announce the creation", r)
133+ }
134+ // Rewriting the same content must do nothing, and SAY so.
135+ r, err = Write(path, "# Notes")
136+ if err != nil || r.Changed || !strings.Contains(r.Headline, "unchanged") {
137+ t.Errorf("identical Write = (%+v, %v)", r, err)
138+ }
139+}
140+
141+func TestReplace(t *testing.T) {
142+ path := inDir(t, "server.go", "port := 3000\nhost := \"local\"\n")
143+
144+ r, err := Replace(path, []Edit{{"port := 3000", "port := envPort(3000)"}}, false)
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ if got := fileOf(t, path); got != "port := envPort(3000)\nhost := \"local\"\n" {
149+ t.Errorf("file = %q", got)
150+ }
151+ if r.FirstChangedLine != 1 || !strings.Contains(r.Headline, "first change at line 1") || !strings.Contains(r.Diff, "+ port := envPort(3000)") {
152+ t.Errorf("Result = %+v", r)
153+ }
154+}
155+
156+// TestReplaceIsAllOrNothing: the second edit is impossible, so the first must
157+// not have happened. A half-edited file is the worst result — it looks like a
158+// success.
159+func TestReplaceIsAllOrNothing(t *testing.T) {
160+ const before = "a = 1\nb = 2\n"
161+ path := inDir(t, "f.txt", before)
162+ _, err := Replace(path, []Edit{{"a = 1", "a = 10"}, {"absent", "x"}}, false)
163+ if err == nil || !strings.Contains(err.Error(), "not found") {
164+ t.Errorf("err = %v", err)
165+ }
166+ if got := fileOf(t, path); got != before {
167+ t.Errorf("file = %q, want unchanged %q", got, before)
168+ }
169+}
170+
171+func TestReplaceRefusesAMissingFile(t *testing.T) {
172+ _, err := Replace(filepath.Join(t.TempDir(), "nope.txt"), []Edit{{"a", "b"}}, false)
173+ if err == nil || !strings.Contains(err.Error(), "does not exist") {
174+ t.Errorf("err = %v", err)
175+ }
176+}
177+
178+func TestDryRunWritesNothing(t *testing.T) {
179+ const before = "a = 1\n"
180+ path := inDir(t, "f.txt", before)
181+ r, err := Replace(path, []Edit{{"a = 1", "a = 2"}}, true)
182+ if err != nil || !r.DryRun || !strings.Contains(r.Headline, "dry run") || !strings.Contains(r.Diff, "+ a = 2") {
183+ t.Errorf("dry run = (%+v, %v)", r, err)
184+ }
185+ if got := fileOf(t, path); got != before {
186+ t.Errorf("file = %q, want unchanged", got)
187+ }
188+}
189+
190+// TestShapeSurvives: BOM and CRLF are removed for the edit and put back on
191+// write, else every line of a Windows file would show up as changed.
192+func TestShapeSurvives(t *testing.T) {
193+ cases := []struct{ name, before, old, new, after string }{
194+ {"CRLF kept", "a\r\nb\r\n", "b", "B", "a\r\nB\r\n"},
195+ {"BOM kept", "\uFEFFa\n", "a", "A", "\uFEFFA\n"},
196+ {"plain LF", "a\n", "a", "A", "A\n"},
197+ }
198+ for _, tc := range cases {
199+ t.Run(tc.name, func(t *testing.T) {
200+ path := inDir(t, "f.txt", tc.before)
201+ if _, err := Replace(path, []Edit{{tc.old, tc.new}}, false); err != nil {
202+ t.Fatal(err)
203+ }
204+ if got := fileOf(t, path); got != tc.after {
205+ t.Errorf("file = %q, want %q", got, tc.after)
206+ }
207+ })
208+ }
209+}
210+
211+// TestWriteKeepsTheMode: an executable script that gets edited must stay
212+// executable.
213+func TestWriteKeepsTheMode(t *testing.T) {
214+ path := inDir(t, "s.sh", "echo a\n")
215+ if err := os.Chmod(path, 0o755); err != nil {
216+ t.Fatal(err)
217+ }
218+ if _, err := Write(path, "echo b\n"); err != nil {
219+ t.Fatal(err)
220+ }
221+ info, _ := os.Stat(path)
222+ if info.Mode().Perm() != 0o755 {
223+ t.Errorf("mode = %v, want 755", info.Mode().Perm())
224+ }
225+}
226+
227+func TestWriteCreatesParentDirs(t *testing.T) {
228+ path := filepath.Join(t.TempDir(), "a", "b", "notes.md")
229+ if _, err := Write(path, "# Notes\n"); err != nil {
230+ t.Fatalf("Write: %v", err)
231+ }
232+ if got := fileOf(t, path); got != "# Notes\n" {
233+ t.Errorf("file = %q", got)
234+ }
235+}
236+
237+func TestFirstChanged(t *testing.T) {
238+ cases := []struct {
239+ name string
240+ old, new string
241+ want int
242+ }{
243+ {"nothing changed", "a\nb\n", "a\nb\n", 0},
244+ {"the second line", "a\nb\nc\n", "a\nB\nc\n", 2},
245+ {"an addition at the top", "a\n", "zero\na\n", 1},
246+ {"a deletion", "a\nb\nc\n", "a\nc\n", 2},
247+ {"a created file", "", "a\n", 1},
248+ }
249+ for _, tc := range cases {
250+ t.Run(tc.name, func(t *testing.T) {
251+ if got := compare(tc.old, tc.new).firstChanged(); got != tc.want {
252+ t.Errorf("firstChanged = %d, want %d", got, tc.want)
253+ }
254+ })
255+ }
256+}
257+
258+func TestRenderHasNoANSI(t *testing.T) {
259+ out := compare("a\nb\n", "a\nB\n").render()
260+ if strings.Contains(out, "\033") || !strings.Contains(out, "- b") || !strings.Contains(out, "+ B") {
261+ t.Errorf("render = %q", out)
262+ }
263+}
264+
265+// TestUnifiedMatchesGitApply is the test that counts: a patch that "looks
266+// like" a unified patch is useless. git APPLIES it, and refuses anything that
267+// is not exact — headers, line numbers, counts, end-of-file marker.
268+func TestUnifiedMatchesGitApply(t *testing.T) {
269+ if _, err := exec.LookPath("git"); err != nil {
270+ t.Skip("git absent")
271+ }
272+ cases := []struct{ name, old, new string }{
273+ {"one line in the middle", "a\nb\nc\nd\ne\n", "a\nb\nC\nd\ne\n"},
274+ {"two distant areas", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", "1\n2\nTHREE\n4\n5\n6\n7\n8\n9\n10\n11\n12\nTHIRTEEN\n14\n15\n"},
275+ {"two neighbouring areas: one hunk", "1\n2\n3\n4\n5\n6\n7\n8\n", "1\n2\nTHREE\n4\nFIVE\n6\n7\n8\n"},
276+ {"addition at the top", "a\nb\n", "zero\na\nb\n"},
277+ {"deletion at the tail", "a\nb\nc\n", "a\nb\n"},
278+ {"everything replaced", "a\nb\n", "x\ny\nz\n"},
279+ {"no final newline", "a\nb\nc", "a\nB\nc"},
280+ {"final newline added", "a\nb", "a\nb\n"},
281+ {"file emptied", "a\nb\n", ""},
282+ }
283+ for _, tc := range cases {
284+ t.Run(tc.name, func(t *testing.T) {
285+ patch := compare(tc.old, tc.new).unified("f.txt")
286+ if patch == "" {
287+ t.Fatal("empty patch")
288+ }
289+ dir := t.TempDir()
290+ run := func(args ...string) {
291+ t.Helper()
292+ cmd := exec.Command("git", args...)
293+ cmd.Dir = dir
294+ if out, err := cmd.CombinedOutput(); err != nil {
295+ t.Fatalf("git %s: %v\n%s\npatch:\n%s", strings.Join(args, " "), err, out, patch)
296+ }
297+ }
298+ run("init", "-q")
299+ os.WriteFile(filepath.Join(dir, "f.txt"), []byte(tc.old), 0o644)
300+ os.WriteFile(filepath.Join(dir, "p.diff"), []byte(patch), 0o644)
301+ run("apply", "p.diff")
302+ if got := fileOf(t, filepath.Join(dir, "f.txt")); got != tc.new {
303+ t.Errorf("after apply: %q, want %q\npatch:\n%s", got, tc.new, patch)
304+ }
305+ })
306+ }
307+}
added internal/fileedit/textfile.go +120 -0
new file mode 100644
@@ -0,0 +1,120 @@
1+// Package fileedit reads, writes and edits ONE text file by exact replacement.
2+// It is a port of the core of the `edit` CLI (tools/edit: internal/edits,
3+// internal/diff, internal/textfile) with the same rules and, as far as the
4+// tool names allow, the same messages — so that the built-in tools of this part
5+// and the CLI of part 05 refuse the same things, in the same words, and the
6+// A/B comparison between them measures the MODEL, not the tool.
7+//
8+// Copied rather than imported: the CLI keeps its core under internal/, which Go
9+// does not let another module import, and moving it out would change a module
10+// this talk shows on stage. Four hundred lines of stdlib are cheaper than that.
11+package fileedit
12+
13+import (
14+ "fmt"
15+ "io/fs"
16+ "os"
17+ "path/filepath"
18+ "strings"
19+)
20+
21+// bom is the UTF-8 byte order mark, as it appears at the head of a file.
22+const bom = "\uFEFF"
23+
24+// file is a text file loaded in memory, ready to be edited.
25+//
26+// Two details invisible on screen would not survive a naive read/replace/write
27+// round trip: the UTF-8 BOM some Windows editors put first — left in the body
28+// it sticks to the first word and defeats an `old` that starts at the top of
29+// the file — and CRLF line endings, which would defeat EVERY multi-line `old`
30+// written with \n, that is, every one a model produces. Both are removed on
31+// read and restored on write.
32+type file struct {
33+ path string
34+ // body is the NORMALISED content: no BOM, \n line endings. Searches and
35+ // diffs work on it.
36+ body string
37+ mode fs.FileMode
38+ // isNew says the file did not exist yet.
39+ isNew bool
40+ hadBOM bool
41+ crlf bool
42+}
43+
44+// load reads a file. A missing file is not an error: it comes back empty and
45+// marked isNew, which lets Write create it and Replace refuse with its own
46+// message.
47+func load(path string) (*file, error) {
48+ f := &file{path: path, mode: 0o644, isNew: true}
49+
50+ data, err := os.ReadFile(path)
51+ if err != nil {
52+ if os.IsNotExist(err) {
53+ return f, nil
54+ }
55+ return nil, err
56+ }
57+ if info, err := os.Stat(path); err == nil {
58+ f.mode = info.Mode().Perm()
59+ }
60+ f.isNew = false
61+
62+ body := string(data)
63+ if strings.HasPrefix(body, bom) {
64+ f.hadBOM = true
65+ body = strings.TrimPrefix(body, bom)
66+ }
67+ // The file's line-ending style is that of its FIRST line ending. A mixed
68+ // file — it happens, usually by accident — is thus rewritten in that style:
69+ // better a consistent file than a diff where every line seems changed.
70+ if i := strings.IndexByte(body, '\n'); i > 0 && body[i-1] == '\r' {
71+ f.crlf = true
72+ }
73+ f.body = strings.ReplaceAll(body, "\r\n", "\n")
74+ return f, nil
75+}
76+
77+// render puts the original shape (BOM, CRLF) back around a normalised body.
78+func (f *file) render(body string) string {
79+ if f.crlf {
80+ body = strings.ReplaceAll(body, "\n", "\r\n")
81+ }
82+ if f.hadBOM {
83+ body = bom + body
84+ }
85+ return body
86+}
87+
88+// write replaces the file with a normalised body.
89+//
90+// It goes through a temporary file in the SAME directory, then a rename: a
91+// full disk or a process killed at the wrong moment leaves the original
92+// intact, where a direct os.WriteFile would have truncated it. The rename is
93+// only atomic on one file system, hence the same directory.
94+func (f *file) write(body string) error {
95+ dir := filepath.Dir(f.path)
96+ if err := os.MkdirAll(dir, 0o755); err != nil {
97+ return err
98+ }
99+ tmp, err := os.CreateTemp(dir, ".edit-*.tmp")
100+ if err != nil {
101+ return err
102+ }
103+ defer os.Remove(tmp.Name()) // no effect once the rename succeeded
104+
105+ if _, err := tmp.WriteString(f.render(body)); err != nil {
106+ tmp.Close()
107+ return err
108+ }
109+ if err := tmp.Close(); err != nil {
110+ return err
111+ }
112+ if err := os.Chmod(tmp.Name(), f.mode); err != nil {
113+ return err
114+ }
115+ if err := os.Rename(tmp.Name(), f.path); err != nil {
116+ return fmt.Errorf("%s: %w", f.path, err)
117+ }
118+ f.body, f.isNew = body, false
119+ return nil
120+}
new file mode 100644
@@ -0,0 +1,120 @@
1+// Package fileedit reads, writes and edits ONE text file by exact replacement.
2+// It is a port of the core of the `edit` CLI (tools/edit: internal/edits,
3+// internal/diff, internal/textfile) with the same rules and, as far as the
4+// tool names allow, the same messages — so that the built-in tools of this part
5+// and the CLI of part 05 refuse the same things, in the same words, and the
6+// A/B comparison between them measures the MODEL, not the tool.
7+//
8+// Copied rather than imported: the CLI keeps its core under internal/, which Go
9+// does not let another module import, and moving it out would change a module
10+// this talk shows on stage. Four hundred lines of stdlib are cheaper than that.
11+package fileedit
12+
13+import (
14+ "fmt"
15+ "io/fs"
16+ "os"
17+ "path/filepath"
18+ "strings"
19+)
20+
21+// bom is the UTF-8 byte order mark, as it appears at the head of a file.
22+const bom = "\uFEFF"
23+
24+// file is a text file loaded in memory, ready to be edited.
25+//
26+// Two details invisible on screen would not survive a naive read/replace/write
27+// round trip: the UTF-8 BOM some Windows editors put first — left in the body
28+// it sticks to the first word and defeats an `old` that starts at the top of
29+// the file — and CRLF line endings, which would defeat EVERY multi-line `old`
30+// written with \n, that is, every one a model produces. Both are removed on
31+// read and restored on write.
32+type file struct {
33+ path string
34+ // body is the NORMALISED content: no BOM, \n line endings. Searches and
35+ // diffs work on it.
36+ body string
37+ mode fs.FileMode
38+ // isNew says the file did not exist yet.
39+ isNew bool
40+ hadBOM bool
41+ crlf bool
42+}
43+
44+// load reads a file. A missing file is not an error: it comes back empty and
45+// marked isNew, which lets Write create it and Replace refuse with its own
46+// message.
47+func load(path string) (*file, error) {
48+ f := &file{path: path, mode: 0o644, isNew: true}
49+
50+ data, err := os.ReadFile(path)
51+ if err != nil {
52+ if os.IsNotExist(err) {
53+ return f, nil
54+ }
55+ return nil, err
56+ }
57+ if info, err := os.Stat(path); err == nil {
58+ f.mode = info.Mode().Perm()
59+ }
60+ f.isNew = false
61+
62+ body := string(data)
63+ if strings.HasPrefix(body, bom) {
64+ f.hadBOM = true
65+ body = strings.TrimPrefix(body, bom)
66+ }
67+ // The file's line-ending style is that of its FIRST line ending. A mixed
68+ // file — it happens, usually by accident — is thus rewritten in that style:
69+ // better a consistent file than a diff where every line seems changed.
70+ if i := strings.IndexByte(body, '\n'); i > 0 && body[i-1] == '\r' {
71+ f.crlf = true
72+ }
73+ f.body = strings.ReplaceAll(body, "\r\n", "\n")
74+ return f, nil
75+}
76+
77+// render puts the original shape (BOM, CRLF) back around a normalised body.
78+func (f *file) render(body string) string {
79+ if f.crlf {
80+ body = strings.ReplaceAll(body, "\n", "\r\n")
81+ }
82+ if f.hadBOM {
83+ body = bom + body
84+ }
85+ return body
86+}
87+
88+// write replaces the file with a normalised body.
89+//
90+// It goes through a temporary file in the SAME directory, then a rename: a
91+// full disk or a process killed at the wrong moment leaves the original
92+// intact, where a direct os.WriteFile would have truncated it. The rename is
93+// only atomic on one file system, hence the same directory.
94+func (f *file) write(body string) error {
95+ dir := filepath.Dir(f.path)
96+ if err := os.MkdirAll(dir, 0o755); err != nil {
97+ return err
98+ }
99+ tmp, err := os.CreateTemp(dir, ".edit-*.tmp")
100+ if err != nil {
101+ return err
102+ }
103+ defer os.Remove(tmp.Name()) // no effect once the rename succeeded
104+
105+ if _, err := tmp.WriteString(f.render(body)); err != nil {
106+ tmp.Close()
107+ return err
108+ }
109+ if err := tmp.Close(); err != nil {
110+ return err
111+ }
112+ if err := os.Chmod(tmp.Name(), f.mode); err != nil {
113+ return err
114+ }
115+ if err := os.Rename(tmp.Name(), f.path); err != nil {
116+ return fmt.Errorf("%s: %w", f.path, err)
117+ }
118+ f.body, f.isNew = body, false
119+ return nil
120+}
added internal/mention/mention.go +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+// Package mention understands the @path notation of a prompt: "explain
2+// @internal/acp/acp.go" names a file the model should look at. There is no
3+// picker — the user types the path — and the file's content does not travel
4+// in the prompt: as for an editor's @-mention over ACP, the mention becomes an
5+// `[attached file: <absolute path>]` line and the model reads the file with
6+// its tools. Both front ends use it, so a typed @path means the same thing in
7+// the terminal and in an editor whose picker was bypassed.
8+package mention
9+
10+import (
11+ "fmt"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+)
16+
17+// Attachment is one @path mention that named an existing file or directory.
18+type Attachment struct {
19+ Mention string // as typed, without the @ and the trailing punctuation
20+ Path string // absolute, cleaned
21+ Dir bool // a directory rather than a file
22+}
23+
24+// trailing is the sentence punctuation a mention may be glued to — "look at
25+// @main.go, then…" mentions main.go, not "main.go,".
26+const trailing = ",.;:!?)]}'\"`"
27+
28+// Expand finds the @path mentions of input, resolves them against cwd (an
29+// absolute path starts with / or ~/, anything else is relative to cwd) and
30+// returns the input followed by one attachment line per existing path, in
31+// order of first appearance, each path once:
32+//
33+// [attached file: /work/project/main.go]
34+// [attached directory: /work/project/internal]
35+//
36+// A mention that names nothing on disk is left alone: "@bob" in a sentence is
37+// not a file. A @ glued to a word (an e-mail address) is not a mention either.
38+func Expand(input, cwd string) (string, []Attachment) {
39+ var out []Attachment
40+ seen := map[string]bool{}
41+ for _, m := range mentions(input) {
42+ path := resolve(m, cwd)
43+ if seen[path] {
44+ continue
45+ }
46+ info, err := os.Stat(path)
47+ if err != nil {
48+ continue
49+ }
50+ seen[path] = true
51+ out = append(out, Attachment{Mention: m, Path: path, Dir: info.IsDir()})
52+ }
53+ var b strings.Builder
54+ b.WriteString(input)
55+ for _, a := range out {
56+ fmt.Fprintf(&b, "\n%s", a.Line())
57+ }
58+ return b.String(), out
59+}
60+
61+// Line is the attachment as the model sees it — the same shape an ACP
62+// resource_link takes, so the model meets one convention, not two.
63+func (a Attachment) Line() string {
64+ if a.Dir {
65+ return fmt.Sprintf("[attached directory: %s]", a.Path)
66+ }
67+ return fmt.Sprintf("[attached file: %s]", a.Path)
68+}
69+
70+// mentions returns the candidate paths: every "@something" that starts the
71+// input or follows a space, an opening bracket or a quote, stripped of the
72+// punctuation it may end with. Existence is the caller's business.
73+func mentions(input string) []string {
74+ var out []string
75+ for i := 0; i < len(input); i++ {
76+ if input[i] != '@' || !startsMention(input, i) {
77+ continue
78+ }
79+ end := i + 1
80+ for end < len(input) && !isSpace(input[end]) {
81+ end++
82+ }
83+ m := strings.TrimRight(input[i+1:end], trailing)
84+ if m != "" {
85+ out = append(out, m)
86+ }
87+ i = end
88+ }
89+ return out
90+}
91+
92+func startsMention(input string, i int) bool {
93+ if i == 0 {
94+ return true
95+ }
96+ return isSpace(input[i-1]) || strings.IndexByte("([{'\"`", input[i-1]) >= 0
97+}
98+
99+func isSpace(c byte) bool {
100+ return c == ' ' || c == '\t' || c == '\n' || c == '\r'
101+}
102+
103+// resolve turns a mention into an absolute, cleaned path.
104+func resolve(m, cwd string) string {
105+ switch {
106+ case filepath.IsAbs(m):
107+ return filepath.Clean(m)
108+ case m == "~" || strings.HasPrefix(m, "~/"):
109+ if home, err := os.UserHomeDir(); err == nil {
110+ return filepath.Clean(filepath.Join(home, m[1:]))
111+ }
112+ }
113+ return filepath.Clean(filepath.Join(cwd, m))
114+}
new file mode 100644
@@ -0,0 +1,114 @@
1+// Package mention understands the @path notation of a prompt: "explain
2+// @internal/acp/acp.go" names a file the model should look at. There is no
3+// picker — the user types the path — and the file's content does not travel
4+// in the prompt: as for an editor's @-mention over ACP, the mention becomes an
5+// `[attached file: <absolute path>]` line and the model reads the file with
6+// its tools. Both front ends use it, so a typed @path means the same thing in
7+// the terminal and in an editor whose picker was bypassed.
8+package mention
9+
10+import (
11+ "fmt"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+)
16+
17+// Attachment is one @path mention that named an existing file or directory.
18+type Attachment struct {
19+ Mention string // as typed, without the @ and the trailing punctuation
20+ Path string // absolute, cleaned
21+ Dir bool // a directory rather than a file
22+}
23+
24+// trailing is the sentence punctuation a mention may be glued to — "look at
25+// @main.go, then…" mentions main.go, not "main.go,".
26+const trailing = ",.;:!?)]}'\"`"
27+
28+// Expand finds the @path mentions of input, resolves them against cwd (an
29+// absolute path starts with / or ~/, anything else is relative to cwd) and
30+// returns the input followed by one attachment line per existing path, in
31+// order of first appearance, each path once:
32+//
33+// [attached file: /work/project/main.go]
34+// [attached directory: /work/project/internal]
35+//
36+// A mention that names nothing on disk is left alone: "@bob" in a sentence is
37+// not a file. A @ glued to a word (an e-mail address) is not a mention either.
38+func Expand(input, cwd string) (string, []Attachment) {
39+ var out []Attachment
40+ seen := map[string]bool{}
41+ for _, m := range mentions(input) {
42+ path := resolve(m, cwd)
43+ if seen[path] {
44+ continue
45+ }
46+ info, err := os.Stat(path)
47+ if err != nil {
48+ continue
49+ }
50+ seen[path] = true
51+ out = append(out, Attachment{Mention: m, Path: path, Dir: info.IsDir()})
52+ }
53+ var b strings.Builder
54+ b.WriteString(input)
55+ for _, a := range out {
56+ fmt.Fprintf(&b, "\n%s", a.Line())
57+ }
58+ return b.String(), out
59+}
60+
61+// Line is the attachment as the model sees it — the same shape an ACP
62+// resource_link takes, so the model meets one convention, not two.
63+func (a Attachment) Line() string {
64+ if a.Dir {
65+ return fmt.Sprintf("[attached directory: %s]", a.Path)
66+ }
67+ return fmt.Sprintf("[attached file: %s]", a.Path)
68+}
69+
70+// mentions returns the candidate paths: every "@something" that starts the
71+// input or follows a space, an opening bracket or a quote, stripped of the
72+// punctuation it may end with. Existence is the caller's business.
73+func mentions(input string) []string {
74+ var out []string
75+ for i := 0; i < len(input); i++ {
76+ if input[i] != '@' || !startsMention(input, i) {
77+ continue
78+ }
79+ end := i + 1
80+ for end < len(input) && !isSpace(input[end]) {
81+ end++
82+ }
83+ m := strings.TrimRight(input[i+1:end], trailing)
84+ if m != "" {
85+ out = append(out, m)
86+ }
87+ i = end
88+ }
89+ return out
90+}
91+
92+func startsMention(input string, i int) bool {
93+ if i == 0 {
94+ return true
95+ }
96+ return isSpace(input[i-1]) || strings.IndexByte("([{'\"`", input[i-1]) >= 0
97+}
98+
99+func isSpace(c byte) bool {
100+ return c == ' ' || c == '\t' || c == '\n' || c == '\r'
101+}
102+
103+// resolve turns a mention into an absolute, cleaned path.
104+func resolve(m, cwd string) string {
105+ switch {
106+ case filepath.IsAbs(m):
107+ return filepath.Clean(m)
108+ case m == "~" || strings.HasPrefix(m, "~/"):
109+ if home, err := os.UserHomeDir(); err == nil {
110+ return filepath.Clean(filepath.Join(home, m[1:]))
111+ }
112+ }
113+ return filepath.Clean(filepath.Join(cwd, m))
114+}
added internal/mention/mention_test.go +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+package mention
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// A workspace with a file, a nested file and a directory, to mention.
11+func workspace(t *testing.T) string {
12+ t.Helper()
13+ dir := t.TempDir()
14+ for _, p := range []string{"main.go", "internal/acp/acp.go"} {
15+ if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, p)), 0o755); err != nil {
16+ t.Fatal(err)
17+ }
18+ if err := os.WriteFile(filepath.Join(dir, p), []byte("package x"), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+ }
22+ return dir
23+}
24+
25+// The text is kept as typed and each existing path becomes one attachment
26+// line, in order, once — the shape an ACP resource_link takes.
27+func TestExpandAttachesExistingPathsOnce(t *testing.T) {
28+ dir := workspace(t)
29+ in := "compare @main.go with @internal/acp/acp.go, then @main.go again; and @internal too"
30+ got, att := Expand(in, dir)
31+
32+ want := in +
33+ "\n[attached file: " + filepath.Join(dir, "main.go") + "]" +
34+ "\n[attached file: " + filepath.Join(dir, "internal/acp/acp.go") + "]" +
35+ "\n[attached directory: " + filepath.Join(dir, "internal") + "]"
36+ if got != want {
37+ t.Errorf("Expand:\n got %q\nwant %q", got, want)
38+ }
39+ if len(att) != 3 || att[0].Mention != "main.go" || att[2].Dir != true || att[1].Dir {
40+ t.Errorf("attachments %+v", att)
41+ }
42+}
43+
44+// What is NOT a mention: a path that does not exist (a name, a handle), an
45+// e-mail address (@ glued to a word), a lone @, and the input untouched when
46+// nothing matches.
47+func TestExpandLeavesNonPathsAlone(t *testing.T) {
48+ dir := workspace(t)
49+ for _, in := range []string{
50+ "ask @bob about it",
51+ "mail k33g@example.com",
52+ "a lone @ sign",
53+ "@does/not/exist.go",
54+ "",
55+ } {
56+ got, att := Expand(in, dir)
57+ if got != in || len(att) != 0 {
58+ t.Errorf("Expand(%q) = %q, %+v; want the input unchanged and no attachment", in, got, att)
59+ }
60+ }
61+}
62+
63+// Absolute paths, ~ paths, and mentions wrapped in brackets or quotes or
64+// glued to punctuation all resolve to the same clean absolute path.
65+func TestExpandResolvesAbsoluteHomeAndPunctuated(t *testing.T) {
66+ dir := workspace(t)
67+ t.Setenv("HOME", dir)
68+ abs := filepath.Join(dir, "main.go")
69+ for _, in := range []string{
70+ "see @" + abs + ".",
71+ "see @~/main.go!",
72+ "see (@main.go)",
73+ "see \"@./main.go\"",
74+ "see @" + dir + "/internal/../main.go",
75+ "@main.go",
76+ } {
77+ _, att := Expand(in, dir)
78+ if len(att) != 1 || att[0].Path != abs {
79+ t.Errorf("Expand(%q) attachments = %+v, want one at %s", in, att, abs)
80+ }
81+ }
82+}
83+
84+// A mention is resolved against cwd, not the process directory: the ACP
85+// front end passes the session's cwd, the terminal the directory mm runs in.
86+func TestExpandIsRelativeToCwd(t *testing.T) {
87+ dir := workspace(t)
88+ _, att := Expand("@acp.go", filepath.Join(dir, "internal", "acp"))
89+ if len(att) != 1 || !strings.HasSuffix(att[0].Path, filepath.Join("internal", "acp", "acp.go")) {
90+ t.Errorf("attachments %+v", att)
91+ }
92+ if _, att := Expand("@acp.go", dir); len(att) != 0 {
93+ t.Errorf("acp.go does not exist at the root, yet %+v", att)
94+ }
95+}
new file mode 100644
@@ -0,0 +1,95 @@
1+package mention
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// A workspace with a file, a nested file and a directory, to mention.
11+func workspace(t *testing.T) string {
12+ t.Helper()
13+ dir := t.TempDir()
14+ for _, p := range []string{"main.go", "internal/acp/acp.go"} {
15+ if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, p)), 0o755); err != nil {
16+ t.Fatal(err)
17+ }
18+ if err := os.WriteFile(filepath.Join(dir, p), []byte("package x"), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+ }
22+ return dir
23+}
24+
25+// The text is kept as typed and each existing path becomes one attachment
26+// line, in order, once — the shape an ACP resource_link takes.
27+func TestExpandAttachesExistingPathsOnce(t *testing.T) {
28+ dir := workspace(t)
29+ in := "compare @main.go with @internal/acp/acp.go, then @main.go again; and @internal too"
30+ got, att := Expand(in, dir)
31+
32+ want := in +
33+ "\n[attached file: " + filepath.Join(dir, "main.go") + "]" +
34+ "\n[attached file: " + filepath.Join(dir, "internal/acp/acp.go") + "]" +
35+ "\n[attached directory: " + filepath.Join(dir, "internal") + "]"
36+ if got != want {
37+ t.Errorf("Expand:\n got %q\nwant %q", got, want)
38+ }
39+ if len(att) != 3 || att[0].Mention != "main.go" || att[2].Dir != true || att[1].Dir {
40+ t.Errorf("attachments %+v", att)
41+ }
42+}
43+
44+// What is NOT a mention: a path that does not exist (a name, a handle), an
45+// e-mail address (@ glued to a word), a lone @, and the input untouched when
46+// nothing matches.
47+func TestExpandLeavesNonPathsAlone(t *testing.T) {
48+ dir := workspace(t)
49+ for _, in := range []string{
50+ "ask @bob about it",
51+ "mail k33g@example.com",
52+ "a lone @ sign",
53+ "@does/not/exist.go",
54+ "",
55+ } {
56+ got, att := Expand(in, dir)
57+ if got != in || len(att) != 0 {
58+ t.Errorf("Expand(%q) = %q, %+v; want the input unchanged and no attachment", in, got, att)
59+ }
60+ }
61+}
62+
63+// Absolute paths, ~ paths, and mentions wrapped in brackets or quotes or
64+// glued to punctuation all resolve to the same clean absolute path.
65+func TestExpandResolvesAbsoluteHomeAndPunctuated(t *testing.T) {
66+ dir := workspace(t)
67+ t.Setenv("HOME", dir)
68+ abs := filepath.Join(dir, "main.go")
69+ for _, in := range []string{
70+ "see @" + abs + ".",
71+ "see @~/main.go!",
72+ "see (@main.go)",
73+ "see \"@./main.go\"",
74+ "see @" + dir + "/internal/../main.go",
75+ "@main.go",
76+ } {
77+ _, att := Expand(in, dir)
78+ if len(att) != 1 || att[0].Path != abs {
79+ t.Errorf("Expand(%q) attachments = %+v, want one at %s", in, att, abs)
80+ }
81+ }
82+}
83+
84+// A mention is resolved against cwd, not the process directory: the ACP
85+// front end passes the session's cwd, the terminal the directory mm runs in.
86+func TestExpandIsRelativeToCwd(t *testing.T) {
87+ dir := workspace(t)
88+ _, att := Expand("@acp.go", filepath.Join(dir, "internal", "acp"))
89+ if len(att) != 1 || !strings.HasSuffix(att[0].Path, filepath.Join("internal", "acp", "acp.go")) {
90+ t.Errorf("attachments %+v", att)
91+ }
92+ if _, att := Expand("@acp.go", dir); len(att) != 0 {
93+ t.Errorf("acp.go does not exist at the root, yet %+v", att)
94+ }
95+}
added internal/session/session.go +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+// Package session holds what the two front ends — the terminal REPL
2+// (internal/agent) and the editor front end (internal/acp) — agree a
3+// conversation starts from, and how it is reset by the /new command.
4+//
5+// A session, for both of them, is a plain []*ai.Message whose first element is
6+// the system prompt. "Starting a new session" means going back to exactly that:
7+// the system prompt alone, every question, answer and tool turn forgotten.
8+// The package knows nothing about the screen or the protocol: printing the
9+// report line, or sending the session/update, stays each front end's job.
10+package session
11+
12+import (
13+ "strings"
14+
15+ "github.com/firebase/genkit/go/ai"
16+)
17+
18+// NewCommand is the slash command that clears the history and starts a new
19+// session. The same spelling is used at the terminal prompt and in an ACP
20+// prompt; ACP advertises it without the slash (see NewCommandName).
21+const NewCommand = "/new"
22+
23+// NewCommandName is NewCommand as ACP names it: clients list commands by bare
24+// name and send them back as prompt text with the leading slash.
25+const NewCommandName = "new"
26+
27+// IsNewCommand reports whether the user's input is the /new command. The
28+// input is trimmed first: an editor sends the line as typed, spaces and
29+// trailing newline included, and "/new " must not be mistaken for a question.
30+//
31+// session.IsNewCommand("/new") // true
32+// session.IsNewCommand(" /new\n") // true
33+// session.IsNewCommand("/new one") // false — not the command
34+func IsNewCommand(input string) bool {
35+ return strings.TrimSpace(input) == NewCommand
36+}
37+
38+// Fresh returns the history of a brand-new session: the system prompt and
39+// nothing else. Both front ends start from it, and /new returns to it.
40+//
41+// messages := session.Fresh(config.Cfg.System)
42+// // len(messages) == 1, messages[0].Role == ai.RoleSystem
43+func Fresh(system string) []*ai.Message {
44+ return []*ai.Message{ai.NewSystemTextMessage(system)}
45+}
46+
47+// Forgotten counts the messages a reset throws away: everything except the
48+// system prompt. It is the figure the report line shows ("12 message(s)
49+// forgotten"), so the user sees that the command did something — or that
50+// there was nothing to forget.
51+//
52+// session.Forgotten(session.Fresh("you are bob")) // 0
53+func Forgotten(msgs []*ai.Message) int {
54+ n := 0
55+ for _, m := range msgs {
56+ if m.Role != ai.RoleSystem {
57+ n++
58+ }
59+ }
60+ return n
61+}
new file mode 100644
@@ -0,0 +1,61 @@
1+// Package session holds what the two front ends — the terminal REPL
2+// (internal/agent) and the editor front end (internal/acp) — agree a
3+// conversation starts from, and how it is reset by the /new command.
4+//
5+// A session, for both of them, is a plain []*ai.Message whose first element is
6+// the system prompt. "Starting a new session" means going back to exactly that:
7+// the system prompt alone, every question, answer and tool turn forgotten.
8+// The package knows nothing about the screen or the protocol: printing the
9+// report line, or sending the session/update, stays each front end's job.
10+package session
11+
12+import (
13+ "strings"
14+
15+ "github.com/firebase/genkit/go/ai"
16+)
17+
18+// NewCommand is the slash command that clears the history and starts a new
19+// session. The same spelling is used at the terminal prompt and in an ACP
20+// prompt; ACP advertises it without the slash (see NewCommandName).
21+const NewCommand = "/new"
22+
23+// NewCommandName is NewCommand as ACP names it: clients list commands by bare
24+// name and send them back as prompt text with the leading slash.
25+const NewCommandName = "new"
26+
27+// IsNewCommand reports whether the user's input is the /new command. The
28+// input is trimmed first: an editor sends the line as typed, spaces and
29+// trailing newline included, and "/new " must not be mistaken for a question.
30+//
31+// session.IsNewCommand("/new") // true
32+// session.IsNewCommand(" /new\n") // true
33+// session.IsNewCommand("/new one") // false — not the command
34+func IsNewCommand(input string) bool {
35+ return strings.TrimSpace(input) == NewCommand
36+}
37+
38+// Fresh returns the history of a brand-new session: the system prompt and
39+// nothing else. Both front ends start from it, and /new returns to it.
40+//
41+// messages := session.Fresh(config.Cfg.System)
42+// // len(messages) == 1, messages[0].Role == ai.RoleSystem
43+func Fresh(system string) []*ai.Message {
44+ return []*ai.Message{ai.NewSystemTextMessage(system)}
45+}
46+
47+// Forgotten counts the messages a reset throws away: everything except the
48+// system prompt. It is the figure the report line shows ("12 message(s)
49+// forgotten"), so the user sees that the command did something — or that
50+// there was nothing to forget.
51+//
52+// session.Forgotten(session.Fresh("you are bob")) // 0
53+func Forgotten(msgs []*ai.Message) int {
54+ n := 0
55+ for _, m := range msgs {
56+ if m.Role != ai.RoleSystem {
57+ n++
58+ }
59+ }
60+ return n
61+}
added internal/session/session_test.go +89 -0
new file mode 100644
@@ -0,0 +1,89 @@
1+package session
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/firebase/genkit/go/ai"
8+)
9+
10+// The command is recognised as typed at a prompt: surrounding whitespace and
11+// the trailing newline an editor keeps must not hide it, and anything after
12+// the word is a question, not the command.
13+func TestIsNewCommandTrimsButDoesNotGuess(t *testing.T) {
14+ cases := map[string]bool{
15+ "/new": true,
16+ " /new ": true,
17+ "/new\n": true,
18+ "/new please": false,
19+ "/News": false,
20+ "new": false,
21+ "": false,
22+ "/compact": false,
23+ "/new\tthings": false,
24+ }
25+ for in, want := range cases {
26+ if got := IsNewCommand(in); got != want {
27+ t.Errorf("IsNewCommand(%q) = %v, want %v", in, got, want)
28+ }
29+ }
30+}
31+
32+// A fresh session is the system prompt alone, with the text given.
33+func TestFreshIsTheSystemPromptAlone(t *testing.T) {
34+ msgs := Fresh("you are bob")
35+ if len(msgs) != 1 {
36+ t.Fatalf("Fresh: %d message(s), want 1", len(msgs))
37+ }
38+ if msgs[0].Role != ai.RoleSystem {
39+ t.Errorf("Fresh: role %q, want %q", msgs[0].Role, ai.RoleSystem)
40+ }
41+ if got := msgs[0].Text(); got != "you are bob" {
42+ t.Errorf("Fresh: text %q, want %q", got, "you are bob")
43+ }
44+}
45+
46+// Two fresh sessions must not share their backing array: appending to one
47+// would otherwise leak into the other (one process, several ACP sessions).
48+func TestFreshReturnsIndependentSlices(t *testing.T) {
49+ a := Fresh("s")
50+ b := Fresh("s")
51+ a = append(a, ai.NewUserTextMessage("hello"))
52+ if len(a) != 2 {
53+ t.Fatalf("appending to a fresh session: %d message(s), want 2", len(a))
54+ }
55+ if len(b) != 1 {
56+ t.Errorf("appending to one fresh session changed another: %d message(s)", len(b))
57+ }
58+}
59+
60+// Forgotten counts what a reset drops: every message but the system prompt,
61+// tool turns included. A fresh session forgets nothing.
62+func TestForgottenCountsEverythingButTheSystemPrompt(t *testing.T) {
63+ if got := Forgotten(Fresh("s")); got != 0 {
64+ t.Errorf("Forgotten(fresh) = %d, want 0", got)
65+ }
66+ if got := Forgotten(nil); got != 0 {
67+ t.Errorf("Forgotten(nil) = %d, want 0", got)
68+ }
69+ msgs := append(Fresh("s"),
70+ ai.NewUserTextMessage("ls"),
71+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}})),
72+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "main.go"})),
73+ ai.NewModelTextMessage("There is one file."),
74+ )
75+ if got := Forgotten(msgs); got != 4 {
76+ t.Errorf("Forgotten = %d, want 4", got)
77+ }
78+}
79+
80+func ExampleIsNewCommand() {
81+ fmt.Println(IsNewCommand("/new"), IsNewCommand("/new please"))
82+ // Output: true false
83+}
84+
85+func ExampleFresh() {
86+ msgs := Fresh("You are a coding agent.")
87+ fmt.Println(len(msgs), msgs[0].Role, Forgotten(msgs))
88+ // Output: 1 system 0
89+}
new file mode 100644
@@ -0,0 +1,89 @@
1+package session
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/firebase/genkit/go/ai"
8+)
9+
10+// The command is recognised as typed at a prompt: surrounding whitespace and
11+// the trailing newline an editor keeps must not hide it, and anything after
12+// the word is a question, not the command.
13+func TestIsNewCommandTrimsButDoesNotGuess(t *testing.T) {
14+ cases := map[string]bool{
15+ "/new": true,
16+ " /new ": true,
17+ "/new\n": true,
18+ "/new please": false,
19+ "/News": false,
20+ "new": false,
21+ "": false,
22+ "/compact": false,
23+ "/new\tthings": false,
24+ }
25+ for in, want := range cases {
26+ if got := IsNewCommand(in); got != want {
27+ t.Errorf("IsNewCommand(%q) = %v, want %v", in, got, want)
28+ }
29+ }
30+}
31+
32+// A fresh session is the system prompt alone, with the text given.
33+func TestFreshIsTheSystemPromptAlone(t *testing.T) {
34+ msgs := Fresh("you are bob")
35+ if len(msgs) != 1 {
36+ t.Fatalf("Fresh: %d message(s), want 1", len(msgs))
37+ }
38+ if msgs[0].Role != ai.RoleSystem {
39+ t.Errorf("Fresh: role %q, want %q", msgs[0].Role, ai.RoleSystem)
40+ }
41+ if got := msgs[0].Text(); got != "you are bob" {
42+ t.Errorf("Fresh: text %q, want %q", got, "you are bob")
43+ }
44+}
45+
46+// Two fresh sessions must not share their backing array: appending to one
47+// would otherwise leak into the other (one process, several ACP sessions).
48+func TestFreshReturnsIndependentSlices(t *testing.T) {
49+ a := Fresh("s")
50+ b := Fresh("s")
51+ a = append(a, ai.NewUserTextMessage("hello"))
52+ if len(a) != 2 {
53+ t.Fatalf("appending to a fresh session: %d message(s), want 2", len(a))
54+ }
55+ if len(b) != 1 {
56+ t.Errorf("appending to one fresh session changed another: %d message(s)", len(b))
57+ }
58+}
59+
60+// Forgotten counts what a reset drops: every message but the system prompt,
61+// tool turns included. A fresh session forgets nothing.
62+func TestForgottenCountsEverythingButTheSystemPrompt(t *testing.T) {
63+ if got := Forgotten(Fresh("s")); got != 0 {
64+ t.Errorf("Forgotten(fresh) = %d, want 0", got)
65+ }
66+ if got := Forgotten(nil); got != 0 {
67+ t.Errorf("Forgotten(nil) = %d, want 0", got)
68+ }
69+ msgs := append(Fresh("s"),
70+ ai.NewUserTextMessage("ls"),
71+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}})),
72+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "main.go"})),
73+ ai.NewModelTextMessage("There is one file."),
74+ )
75+ if got := Forgotten(msgs); got != 4 {
76+ t.Errorf("Forgotten = %d, want 4", got)
77+ }
78+}
79+
80+func ExampleIsNewCommand() {
81+ fmt.Println(IsNewCommand("/new"), IsNewCommand("/new please"))
82+ // Output: true false
83+}
84+
85+func ExampleFresh() {
86+ msgs := Fresh("You are a coding agent.")
87+ fmt.Println(len(msgs), msgs[0].Role, Forgotten(msgs))
88+ // Output: 1 system 0
89+}
added internal/skills/skills.go +140 -0
new file mode 100644
@@ -0,0 +1,140 @@
1+// Package skills discovers the markdown procedures in ./skills.
2+//
3+// A skill is a plain markdown file with a small front matter:
4+//
5+// ---
6+// name: go-rename
7+// description: rename a Go symbol everywhere it is used
8+// ---
9+// # Rename a Go symbol
10+// ...
11+//
12+// The point of the package is to turn that directory into ONE STRING: the
13+// description of the `read_skill` tool. The model never lists the directory
14+// itself — it reads the catalogue in the tool description, the same way it
15+// reads the description of `bash`.
16+package skills
17+
18+import (
19+ "os"
20+ "path/filepath"
21+ "sort"
22+ "strings"
23+)
24+
25+// Skill is one markdown procedure on disk.
26+type Skill struct {
27+ Name string // from the front matter, falling back to the file name
28+ Description string // the one-liner shown in the catalogue
29+ Path string
30+}
31+
32+// skillFile is the file name of a skill stored in its own directory — the
33+// Agent Skills convention (`<skillsDir>/<name>/SKILL.md`), the layout the
34+// shipped examples use.
35+const skillFile = "SKILL.md"
36+
37+// defaultName is the name a skill gets when its front matter has none: the
38+// file name for a flat `<name>.md`, the directory name for `<name>/SKILL.md`.
39+func defaultName(path string) string {
40+ if filepath.Base(path) == skillFile {
41+ return filepath.Base(filepath.Dir(path))
42+ }
43+ return strings.TrimSuffix(filepath.Base(path), ".md")
44+}
45+
46+// parseHeader reads the `name:` and `description:` lines of the front matter.
47+// It stops at the closing `---`: a `description:` in the body is not metadata.
48+func parseHeader(content, path string) Skill {
49+ s := Skill{Name: defaultName(path), Path: path}
50+ lines := strings.Split(content, "\n")
51+ if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
52+ return s
53+ }
54+ for _, line := range lines[1:] {
55+ if strings.TrimSpace(line) == "---" {
56+ break
57+ }
58+ key, value, found := strings.Cut(line, ":")
59+ if !found {
60+ continue
61+ }
62+ value = strings.TrimSpace(value)
63+ switch strings.TrimSpace(key) {
64+ case "name":
65+ if value != "" {
66+ s.Name = value
67+ }
68+ case "description":
69+ s.Description = value
70+ }
71+ }
72+ return s
73+}
74+
75+// List returns the skills of dir, sorted by name. Two layouts are accepted,
76+// and may be mixed: a flat `<dir>/<name>.md`, and one directory per skill,
77+// `<dir>/<name>/SKILL.md`. A missing directory is not an error: it just means
78+// this agent has no skills.
79+func List(dir string) []Skill {
80+ flat, err := filepath.Glob(filepath.Join(dir, "*.md"))
81+ if err != nil {
82+ return nil
83+ }
84+ nested, err := filepath.Glob(filepath.Join(dir, "*", skillFile))
85+ if err != nil {
86+ return nil
87+ }
88+ var out []Skill
89+ for _, p := range append(flat, nested...) {
90+ content, err := os.ReadFile(p)
91+ if err != nil {
92+ continue
93+ }
94+ out = append(out, parseHeader(string(content), p))
95+ }
96+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
97+ return out
98+}
99+
100+// Read returns the full markdown of one skill, whichever layout it uses: the
101+// flat `<name>.md` is tried first, then `<name>/SKILL.md`.
102+func Read(dir, name string) (string, error) {
103+ // Keep the name a plain file name: no "../" escaping out of the directory.
104+ name = filepath.Base(name)
105+ content, err := os.ReadFile(filepath.Join(dir, name+".md"))
106+ if err != nil {
107+ if nested, nestedErr := os.ReadFile(filepath.Join(dir, name, skillFile)); nestedErr == nil {
108+ return string(nested), nil
109+ }
110+ }
111+ return string(content), err
112+}
113+
114+// Catalogue renders the list as the tool description the model will read.
115+// This IS the prompt engineering: what the model knows about the available
116+// skills is exactly these lines.
117+func Catalogue(list []Skill) string {
118+ var b strings.Builder
119+ b.WriteString("Load a skill: the step-by-step procedure to follow for this kind of task. " +
120+ "Call it BEFORE doing the work, with the name of the matching skill, and then follow " +
121+ "what it says. Available skills:\n")
122+ for _, s := range list {
123+ b.WriteString(" " + s.Name)
124+ if s.Description != "" {
125+ b.WriteString(" — " + s.Description)
126+ }
127+ b.WriteString("\n")
128+ }
129+ return b.String()
130+}
131+
132+// Names lists the skill names, for an error message that helps the model
133+// recover from a typo.
134+func Names(list []Skill) []string {
135+ out := make([]string, 0, len(list))
136+ for _, s := range list {
137+ out = append(out, s.Name)
138+ }
139+ return out
140+}
new file mode 100644
@@ -0,0 +1,140 @@
1+// Package skills discovers the markdown procedures in ./skills.
2+//
3+// A skill is a plain markdown file with a small front matter:
4+//
5+// ---
6+// name: go-rename
7+// description: rename a Go symbol everywhere it is used
8+// ---
9+// # Rename a Go symbol
10+// ...
11+//
12+// The point of the package is to turn that directory into ONE STRING: the
13+// description of the `read_skill` tool. The model never lists the directory
14+// itself — it reads the catalogue in the tool description, the same way it
15+// reads the description of `bash`.
16+package skills
17+
18+import (
19+ "os"
20+ "path/filepath"
21+ "sort"
22+ "strings"
23+)
24+
25+// Skill is one markdown procedure on disk.
26+type Skill struct {
27+ Name string // from the front matter, falling back to the file name
28+ Description string // the one-liner shown in the catalogue
29+ Path string
30+}
31+
32+// skillFile is the file name of a skill stored in its own directory — the
33+// Agent Skills convention (`<skillsDir>/<name>/SKILL.md`), the layout the
34+// shipped examples use.
35+const skillFile = "SKILL.md"
36+
37+// defaultName is the name a skill gets when its front matter has none: the
38+// file name for a flat `<name>.md`, the directory name for `<name>/SKILL.md`.
39+func defaultName(path string) string {
40+ if filepath.Base(path) == skillFile {
41+ return filepath.Base(filepath.Dir(path))
42+ }
43+ return strings.TrimSuffix(filepath.Base(path), ".md")
44+}
45+
46+// parseHeader reads the `name:` and `description:` lines of the front matter.
47+// It stops at the closing `---`: a `description:` in the body is not metadata.
48+func parseHeader(content, path string) Skill {
49+ s := Skill{Name: defaultName(path), Path: path}
50+ lines := strings.Split(content, "\n")
51+ if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
52+ return s
53+ }
54+ for _, line := range lines[1:] {
55+ if strings.TrimSpace(line) == "---" {
56+ break
57+ }
58+ key, value, found := strings.Cut(line, ":")
59+ if !found {
60+ continue
61+ }
62+ value = strings.TrimSpace(value)
63+ switch strings.TrimSpace(key) {
64+ case "name":
65+ if value != "" {
66+ s.Name = value
67+ }
68+ case "description":
69+ s.Description = value
70+ }
71+ }
72+ return s
73+}
74+
75+// List returns the skills of dir, sorted by name. Two layouts are accepted,
76+// and may be mixed: a flat `<dir>/<name>.md`, and one directory per skill,
77+// `<dir>/<name>/SKILL.md`. A missing directory is not an error: it just means
78+// this agent has no skills.
79+func List(dir string) []Skill {
80+ flat, err := filepath.Glob(filepath.Join(dir, "*.md"))
81+ if err != nil {
82+ return nil
83+ }
84+ nested, err := filepath.Glob(filepath.Join(dir, "*", skillFile))
85+ if err != nil {
86+ return nil
87+ }
88+ var out []Skill
89+ for _, p := range append(flat, nested...) {
90+ content, err := os.ReadFile(p)
91+ if err != nil {
92+ continue
93+ }
94+ out = append(out, parseHeader(string(content), p))
95+ }
96+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
97+ return out
98+}
99+
100+// Read returns the full markdown of one skill, whichever layout it uses: the
101+// flat `<name>.md` is tried first, then `<name>/SKILL.md`.
102+func Read(dir, name string) (string, error) {
103+ // Keep the name a plain file name: no "../" escaping out of the directory.
104+ name = filepath.Base(name)
105+ content, err := os.ReadFile(filepath.Join(dir, name+".md"))
106+ if err != nil {
107+ if nested, nestedErr := os.ReadFile(filepath.Join(dir, name, skillFile)); nestedErr == nil {
108+ return string(nested), nil
109+ }
110+ }
111+ return string(content), err
112+}
113+
114+// Catalogue renders the list as the tool description the model will read.
115+// This IS the prompt engineering: what the model knows about the available
116+// skills is exactly these lines.
117+func Catalogue(list []Skill) string {
118+ var b strings.Builder
119+ b.WriteString("Load a skill: the step-by-step procedure to follow for this kind of task. " +
120+ "Call it BEFORE doing the work, with the name of the matching skill, and then follow " +
121+ "what it says. Available skills:\n")
122+ for _, s := range list {
123+ b.WriteString(" " + s.Name)
124+ if s.Description != "" {
125+ b.WriteString(" — " + s.Description)
126+ }
127+ b.WriteString("\n")
128+ }
129+ return b.String()
130+}
131+
132+// Names lists the skill names, for an error message that helps the model
133+// recover from a typo.
134+func Names(list []Skill) []string {
135+ out := make([]string, 0, len(list))
136+ for _, s := range list {
137+ out = append(out, s.Name)
138+ }
139+ return out
140+}
added internal/skills/skills_test.go +242 -0
new file mode 100644
@@ -0,0 +1,242 @@
1+// Tests for the skills package. What matters here: the catalogue is a TOOL
2+// DESCRIPTION, so what the model knows about the available skills is exactly
3+// what these functions produce. One header read wrong, and the model can no
4+// longer name the skill it wants.
5+package skills
6+
7+import (
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+)
13+
14+// write creates a skill file in a temporary directory.
15+func write(t *testing.T, dir, name, content string) {
16+ t.Helper()
17+ if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
18+ t.Fatal(err)
19+ }
20+}
21+
22+func TestParseHeader(t *testing.T) {
23+ cases := []struct {
24+ name string
25+ content string
26+ wantName string
27+ wantDesc string
28+ }{{
29+ name: "complete header",
30+ content: "---\nname: go-rename\ndescription: rename a symbol\n---\n# Title\n",
31+ wantName: "go-rename",
32+ wantDesc: "rename a symbol",
33+ }, {
34+ name: "no header: the name comes from the file",
35+ content: "# Just some markdown\n",
36+ wantName: "file",
37+ wantDesc: "",
38+ }, {
39+ name: "empty name: the file name is kept",
40+ content: "---\nname:\ndescription: something\n---\n",
41+ wantName: "file",
42+ wantDesc: "something",
43+ }, {
44+ // The case parseHeader's comment announces: a `description:` in the
45+ // BODY is not metadata.
46+ name: "description in the body: ignored",
47+ content: "---\nname: go-test\n---\ndescription: this is just text\n",
48+ wantName: "go-test",
49+ wantDesc: "",
50+ }, {
51+ name: "spaces around the values",
52+ content: "---\n name : go-fmt \n description : format code \n---\n",
53+ wantName: "go-fmt",
54+ wantDesc: "format code",
55+ }, {
56+ name: "a colon inside the description",
57+ content: "---\nname: go-run\ndescription: run: execute a program\n---\n",
58+ wantName: "go-run",
59+ wantDesc: "run: execute a program",
60+ }}
61+
62+ for _, c := range cases {
63+ t.Run(c.name, func(t *testing.T) {
64+ s := parseHeader(c.content, "/tmp/file.md")
65+ if s.Name != c.wantName {
66+ t.Errorf("Name = %q, want %q", s.Name, c.wantName)
67+ }
68+ if s.Description != c.wantDesc {
69+ t.Errorf("Description = %q, want %q", s.Description, c.wantDesc)
70+ }
71+ })
72+ }
73+}
74+
75+func TestListSortsAndReadsHeaders(t *testing.T) {
76+ dir := t.TempDir()
77+ write(t, dir, "zeta.md", "---\nname: zeta\ndescription: the last one\n---\n")
78+ write(t, dir, "alpha.md", "---\nname: alpha\ndescription: the first one\n---\n")
79+ write(t, dir, "notes.txt", "not a skill") // ignored: not *.md
80+
81+ list := List(dir)
82+ if len(list) != 2 {
83+ t.Fatalf("%d skills, want 2: %+v", len(list), list)
84+ }
85+ if list[0].Name != "alpha" || list[1].Name != "zeta" {
86+ t.Errorf("expected a sort by name, got %q then %q", list[0].Name, list[1].Name)
87+ }
88+}
89+
90+// A missing directory is not an error: this agent simply has no skills, and
91+// main.go will not declare the tool.
92+func TestListMissingDirIsEmpty(t *testing.T) {
93+ if list := List(filepath.Join(t.TempDir(), "doesnotexist")); len(list) != 0 {
94+ t.Errorf("%d skills for a missing directory, want 0", len(list))
95+ }
96+}
97+
98+func TestRead(t *testing.T) {
99+ dir := t.TempDir()
100+ write(t, dir, "go-fmt.md", "---\nname: go-fmt\n---\n# Format\n")
101+
102+ content, err := Read(dir, "go-fmt")
103+ if err != nil {
104+ t.Fatalf("Read: %v", err)
105+ }
106+ if !strings.Contains(content, "# Format") {
107+ t.Errorf("unexpected content: %q", content)
108+ }
109+
110+ if _, err := Read(dir, "unknown"); err == nil {
111+ t.Error("an unknown skill must return an error")
112+ }
113+}
114+
115+// filepath.Base in Read is a barrier: the name comes from the MODEL, so it must
116+// not be able to escape the skills directory.
117+func TestReadCannotEscapeDir(t *testing.T) {
118+ dir := t.TempDir()
119+ parent := filepath.Dir(dir)
120+ if err := os.WriteFile(filepath.Join(parent, "secret.md"), []byte("forbidden"), 0o644); err != nil {
121+ t.Fatal(err)
122+ }
123+
124+ for _, name := range []string{"../secret", "../../secret", "/etc/passwd"} {
125+ if content, err := Read(dir, name); err == nil {
126+ t.Errorf("Read(%q) succeeded and returned %q — the barrier is gone", name, content)
127+ }
128+ }
129+}
130+
131+// The catalogue IS the tool description: every skill must appear in it with its
132+// name, otherwise the model cannot ask for it.
133+func TestCatalogue(t *testing.T) {
134+ list := []Skill{
135+ {Name: "go-fmt", Description: "format code"},
136+ {Name: "go-test"}, // no description
137+ }
138+ cat := Catalogue(list)
139+
140+ for _, want := range []string{"go-fmt", "format code", "go-test"} {
141+ if !strings.Contains(cat, want) {
142+ t.Errorf("the catalogue does not contain %q:\n%s", want, cat)
143+ }
144+ }
145+ if !strings.Contains(cat, "go-fmt — format code") {
146+ t.Errorf("name and description must be joined by a dash:\n%s", cat)
147+ }
148+ // A skill with no description must not leave an orphan dash.
149+ if strings.Contains(cat, "go-test —") {
150+ t.Errorf("orphan dash for a skill with no description:\n%s", cat)
151+ }
152+}
153+
154+func TestNames(t *testing.T) {
155+ got := Names([]Skill{{Name: "a"}, {Name: "b"}})
156+ if strings.Join(got, ",") != "a,b" {
157+ t.Errorf("Names = %v", got)
158+ }
159+}
160+
161+// The skills actually shipped in the repository's skills/ must all have a
162+// name AND a description: without a description, the model picks blindly.
163+//
164+// The path is the real layout — skills/ at the repository root, one directory
165+// per skill. A t.Skip() here would mean "I am testing nothing" without saying
166+// so, so a missing directory is an ERROR, not a skip.
167+func TestShippedSkillsHaveDescriptions(t *testing.T) {
168+ dir := filepath.Join("..", "..", "skills")
169+ list := List(dir)
170+ if len(list) == 0 {
171+ t.Fatalf("no skill found in %s — has the layout changed?", dir)
172+ }
173+ for _, s := range list {
174+ if s.Description == "" {
175+ t.Errorf("skill %q has no description", s.Name)
176+ }
177+ if strings.Contains(s.Name, " ") {
178+ t.Errorf("name %q contains a space: the model will not be able to quote it", s.Name)
179+ }
180+ }
181+ t.Logf("%d shipped skills, all described", len(list))
182+}
183+
184+// writeNested stores a skill the Agent Skills way: <dir>/<name>/SKILL.md.
185+func writeNested(t *testing.T, dir, name, content string) {
186+ t.Helper()
187+ if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
188+ t.Fatal(err)
189+ }
190+ if err := os.WriteFile(filepath.Join(dir, name, "SKILL.md"), []byte(content), 0o644); err != nil {
191+ t.Fatal(err)
192+ }
193+}
194+
195+// The two layouts are both found, may be mixed, and sort together. A nested
196+// skill without a name in its front matter is named after its directory, not
197+// "SKILL"; a stray markdown file inside a skill's directory is not a skill.
198+func TestListAcceptsFlatAndNestedLayouts(t *testing.T) {
199+ dir := t.TempDir()
200+ write(t, dir, "zeta.md", "---\nname: zeta\ndescription: flat, named\n---\n")
201+ writeNested(t, dir, "alpha", "---\ndescription: nested, unnamed\n---\n# Alpha\n")
202+ writeNested(t, dir, "mid", "---\nname: renamed\ndescription: nested, named\n---\n")
203+ if err := os.WriteFile(filepath.Join(dir, "alpha", "notes.md"), []byte("not a skill"), 0o644); err != nil {
204+ t.Fatal(err)
205+ }
206+
207+ list := List(dir)
208+ got := make([]string, 0, len(list))
209+ for _, s := range list {
210+ got = append(got, s.Name+"="+s.Description)
211+ }
212+ want := []string{"alpha=nested, unnamed", "renamed=nested, named", "zeta=flat, named"}
213+ if strings.Join(got, "|") != strings.Join(want, "|") {
214+ t.Errorf("List:\n got %v\nwant %v", got, want)
215+ }
216+}
217+
218+// Read finds a skill whichever layout stores it, prefers the flat file when
219+// both exist (it is the more explicit of the two), and still cannot be walked
220+// out of the directory with a nested name: "../escape" is reduced to "escape",
221+// which does not exist inside dir.
222+func TestReadAcceptsFlatAndNestedLayouts(t *testing.T) {
223+ parent := t.TempDir()
224+ dir := filepath.Join(parent, "skills")
225+ writeNested(t, parent, "escape", "# outside")
226+ writeNested(t, dir, "nested-only", "# nested")
227+ write(t, dir, "both.md", "# flat wins")
228+ writeNested(t, dir, "both", "# nested loses")
229+
230+ if got, err := Read(dir, "nested-only"); err != nil || got != "# nested" {
231+ t.Errorf("Read(nested-only) = %q, %v", got, err)
232+ }
233+ if got, err := Read(dir, "both"); err != nil || got != "# flat wins" {
234+ t.Errorf("Read(both) = %q, %v; want the flat file", got, err)
235+ }
236+ if _, err := Read(dir, "missing"); err == nil {
237+ t.Error("Read(missing) returned no error")
238+ }
239+ if _, err := Read(dir, "../escape"); err == nil {
240+ t.Error("Read(../escape) escaped the directory")
241+ }
242+}
new file mode 100644
@@ -0,0 +1,242 @@
1+// Tests for the skills package. What matters here: the catalogue is a TOOL
2+// DESCRIPTION, so what the model knows about the available skills is exactly
3+// what these functions produce. One header read wrong, and the model can no
4+// longer name the skill it wants.
5+package skills
6+
7+import (
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+)
13+
14+// write creates a skill file in a temporary directory.
15+func write(t *testing.T, dir, name, content string) {
16+ t.Helper()
17+ if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
18+ t.Fatal(err)
19+ }
20+}
21+
22+func TestParseHeader(t *testing.T) {
23+ cases := []struct {
24+ name string
25+ content string
26+ wantName string
27+ wantDesc string
28+ }{{
29+ name: "complete header",
30+ content: "---\nname: go-rename\ndescription: rename a symbol\n---\n# Title\n",
31+ wantName: "go-rename",
32+ wantDesc: "rename a symbol",
33+ }, {
34+ name: "no header: the name comes from the file",
35+ content: "# Just some markdown\n",
36+ wantName: "file",
37+ wantDesc: "",
38+ }, {
39+ name: "empty name: the file name is kept",
40+ content: "---\nname:\ndescription: something\n---\n",
41+ wantName: "file",
42+ wantDesc: "something",
43+ }, {
44+ // The case parseHeader's comment announces: a `description:` in the
45+ // BODY is not metadata.
46+ name: "description in the body: ignored",
47+ content: "---\nname: go-test\n---\ndescription: this is just text\n",
48+ wantName: "go-test",
49+ wantDesc: "",
50+ }, {
51+ name: "spaces around the values",
52+ content: "---\n name : go-fmt \n description : format code \n---\n",
53+ wantName: "go-fmt",
54+ wantDesc: "format code",
55+ }, {
56+ name: "a colon inside the description",
57+ content: "---\nname: go-run\ndescription: run: execute a program\n---\n",
58+ wantName: "go-run",
59+ wantDesc: "run: execute a program",
60+ }}
61+
62+ for _, c := range cases {
63+ t.Run(c.name, func(t *testing.T) {
64+ s := parseHeader(c.content, "/tmp/file.md")
65+ if s.Name != c.wantName {
66+ t.Errorf("Name = %q, want %q", s.Name, c.wantName)
67+ }
68+ if s.Description != c.wantDesc {
69+ t.Errorf("Description = %q, want %q", s.Description, c.wantDesc)
70+ }
71+ })
72+ }
73+}
74+
75+func TestListSortsAndReadsHeaders(t *testing.T) {
76+ dir := t.TempDir()
77+ write(t, dir, "zeta.md", "---\nname: zeta\ndescription: the last one\n---\n")
78+ write(t, dir, "alpha.md", "---\nname: alpha\ndescription: the first one\n---\n")
79+ write(t, dir, "notes.txt", "not a skill") // ignored: not *.md
80+
81+ list := List(dir)
82+ if len(list) != 2 {
83+ t.Fatalf("%d skills, want 2: %+v", len(list), list)
84+ }
85+ if list[0].Name != "alpha" || list[1].Name != "zeta" {
86+ t.Errorf("expected a sort by name, got %q then %q", list[0].Name, list[1].Name)
87+ }
88+}
89+
90+// A missing directory is not an error: this agent simply has no skills, and
91+// main.go will not declare the tool.
92+func TestListMissingDirIsEmpty(t *testing.T) {
93+ if list := List(filepath.Join(t.TempDir(), "doesnotexist")); len(list) != 0 {
94+ t.Errorf("%d skills for a missing directory, want 0", len(list))
95+ }
96+}
97+
98+func TestRead(t *testing.T) {
99+ dir := t.TempDir()
100+ write(t, dir, "go-fmt.md", "---\nname: go-fmt\n---\n# Format\n")
101+
102+ content, err := Read(dir, "go-fmt")
103+ if err != nil {
104+ t.Fatalf("Read: %v", err)
105+ }
106+ if !strings.Contains(content, "# Format") {
107+ t.Errorf("unexpected content: %q", content)
108+ }
109+
110+ if _, err := Read(dir, "unknown"); err == nil {
111+ t.Error("an unknown skill must return an error")
112+ }
113+}
114+
115+// filepath.Base in Read is a barrier: the name comes from the MODEL, so it must
116+// not be able to escape the skills directory.
117+func TestReadCannotEscapeDir(t *testing.T) {
118+ dir := t.TempDir()
119+ parent := filepath.Dir(dir)
120+ if err := os.WriteFile(filepath.Join(parent, "secret.md"), []byte("forbidden"), 0o644); err != nil {
121+ t.Fatal(err)
122+ }
123+
124+ for _, name := range []string{"../secret", "../../secret", "/etc/passwd"} {
125+ if content, err := Read(dir, name); err == nil {
126+ t.Errorf("Read(%q) succeeded and returned %q — the barrier is gone", name, content)
127+ }
128+ }
129+}
130+
131+// The catalogue IS the tool description: every skill must appear in it with its
132+// name, otherwise the model cannot ask for it.
133+func TestCatalogue(t *testing.T) {
134+ list := []Skill{
135+ {Name: "go-fmt", Description: "format code"},
136+ {Name: "go-test"}, // no description
137+ }
138+ cat := Catalogue(list)
139+
140+ for _, want := range []string{"go-fmt", "format code", "go-test"} {
141+ if !strings.Contains(cat, want) {
142+ t.Errorf("the catalogue does not contain %q:\n%s", want, cat)
143+ }
144+ }
145+ if !strings.Contains(cat, "go-fmt — format code") {
146+ t.Errorf("name and description must be joined by a dash:\n%s", cat)
147+ }
148+ // A skill with no description must not leave an orphan dash.
149+ if strings.Contains(cat, "go-test —") {
150+ t.Errorf("orphan dash for a skill with no description:\n%s", cat)
151+ }
152+}
153+
154+func TestNames(t *testing.T) {
155+ got := Names([]Skill{{Name: "a"}, {Name: "b"}})
156+ if strings.Join(got, ",") != "a,b" {
157+ t.Errorf("Names = %v", got)
158+ }
159+}
160+
161+// The skills actually shipped in the repository's skills/ must all have a
162+// name AND a description: without a description, the model picks blindly.
163+//
164+// The path is the real layout — skills/ at the repository root, one directory
165+// per skill. A t.Skip() here would mean "I am testing nothing" without saying
166+// so, so a missing directory is an ERROR, not a skip.
167+func TestShippedSkillsHaveDescriptions(t *testing.T) {
168+ dir := filepath.Join("..", "..", "skills")
169+ list := List(dir)
170+ if len(list) == 0 {
171+ t.Fatalf("no skill found in %s — has the layout changed?", dir)
172+ }
173+ for _, s := range list {
174+ if s.Description == "" {
175+ t.Errorf("skill %q has no description", s.Name)
176+ }
177+ if strings.Contains(s.Name, " ") {
178+ t.Errorf("name %q contains a space: the model will not be able to quote it", s.Name)
179+ }
180+ }
181+ t.Logf("%d shipped skills, all described", len(list))
182+}
183+
184+// writeNested stores a skill the Agent Skills way: <dir>/<name>/SKILL.md.
185+func writeNested(t *testing.T, dir, name, content string) {
186+ t.Helper()
187+ if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
188+ t.Fatal(err)
189+ }
190+ if err := os.WriteFile(filepath.Join(dir, name, "SKILL.md"), []byte(content), 0o644); err != nil {
191+ t.Fatal(err)
192+ }
193+}
194+
195+// The two layouts are both found, may be mixed, and sort together. A nested
196+// skill without a name in its front matter is named after its directory, not
197+// "SKILL"; a stray markdown file inside a skill's directory is not a skill.
198+func TestListAcceptsFlatAndNestedLayouts(t *testing.T) {
199+ dir := t.TempDir()
200+ write(t, dir, "zeta.md", "---\nname: zeta\ndescription: flat, named\n---\n")
201+ writeNested(t, dir, "alpha", "---\ndescription: nested, unnamed\n---\n# Alpha\n")
202+ writeNested(t, dir, "mid", "---\nname: renamed\ndescription: nested, named\n---\n")
203+ if err := os.WriteFile(filepath.Join(dir, "alpha", "notes.md"), []byte("not a skill"), 0o644); err != nil {
204+ t.Fatal(err)
205+ }
206+
207+ list := List(dir)
208+ got := make([]string, 0, len(list))
209+ for _, s := range list {
210+ got = append(got, s.Name+"="+s.Description)
211+ }
212+ want := []string{"alpha=nested, unnamed", "renamed=nested, named", "zeta=flat, named"}
213+ if strings.Join(got, "|") != strings.Join(want, "|") {
214+ t.Errorf("List:\n got %v\nwant %v", got, want)
215+ }
216+}
217+
218+// Read finds a skill whichever layout stores it, prefers the flat file when
219+// both exist (it is the more explicit of the two), and still cannot be walked
220+// out of the directory with a nested name: "../escape" is reduced to "escape",
221+// which does not exist inside dir.
222+func TestReadAcceptsFlatAndNestedLayouts(t *testing.T) {
223+ parent := t.TempDir()
224+ dir := filepath.Join(parent, "skills")
225+ writeNested(t, parent, "escape", "# outside")
226+ writeNested(t, dir, "nested-only", "# nested")
227+ write(t, dir, "both.md", "# flat wins")
228+ writeNested(t, dir, "both", "# nested loses")
229+
230+ if got, err := Read(dir, "nested-only"); err != nil || got != "# nested" {
231+ t.Errorf("Read(nested-only) = %q, %v", got, err)
232+ }
233+ if got, err := Read(dir, "both"); err != nil || got != "# flat wins" {
234+ t.Errorf("Read(both) = %q, %v; want the flat file", got, err)
235+ }
236+ if _, err := Read(dir, "missing"); err == nil {
237+ t.Error("Read(missing) returned no error")
238+ }
239+ if _, err := Read(dir, "../escape"); err == nil {
240+ t.Error("Read(../escape) escaped the directory")
241+ }
242+}
added internal/spinner/spinner.go +165 -0
new file mode 100644
@@ -0,0 +1,165 @@
1+// Package spinner shows a "still working" indicator while nothing is being
2+// printed — the gap between sending a question and the first streamed token,
3+// which on a local engine is long enough (model loading, prompt processing) to
4+// look like a freeze.
5+//
6+// There is one spinner for the whole program rather than a value to pass
7+// around: the REPL runs one generation at a time, and the alternative would be
8+// threading a handle through Genkit's tool callbacks, which we do not control.
9+//
10+// The contract is: Start makes the line ours, Stop gives it back. Anything that
11+// prints must Stop first — hence the calls in dmr and tools.
12+package spinner
13+
14+import (
15+ "fmt"
16+ "os"
17+ "strings"
18+ "sync"
19+ "time"
20+)
21+
22+// frames is the braille cycle. Each frame is one rune wide, so the line never
23+// reflows as it animates.
24+var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
25+
26+const interval = 80 * time.Millisecond
27+
28+var (
29+ mu sync.Mutex
30+ stopCh chan struct{} // closed to ask the goroutine to stop
31+ doneCh chan struct{} // closed by the goroutine once the line is clean
32+ label string // read by the goroutine under mu, so Start can retitle
33+ enabled = supported() //nolint:gochecknoglobals // resolved once at startup
34+)
35+
36+// supported reports whether animating is a good idea. Piped or redirected
37+// output is not a terminal, and writing escape sequences into it would corrupt
38+// whatever is reading — a real risk here, since the point of the exercise is an
39+// agent whose output another program may consume.
40+func supported() bool {
41+ if os.Getenv("TERM") == "dumb" || os.Getenv("NO_COLOR") != "" {
42+ return false
43+ }
44+ info, err := os.Stdout.Stat()
45+ return err == nil && info.Mode()&os.ModeCharDevice != 0
46+}
47+
48+// Disable turns the spinner — and Styled — off for the rest of the process.
49+// The ACP front end calls it once at start-up: stdout then belongs to
50+// JSON-RPC, and even on a real terminal (bob --acp run by hand, for a demo)
51+// not one braille frame may land in the protocol stream. supported() cannot
52+// catch that case — a terminal IS a character device.
53+func Disable() {
54+ mu.Lock()
55+ enabled = false
56+ mu.Unlock()
57+}
58+
59+// Styled reports whether stdout may carry escape sequences — same test that
60+// decides whether to animate. Anything that colours its output should ask,
61+// rather than repeat the predicate: piped output is meant to be consumed by
62+// another program, and `bob | jq` should not receive dim codes any more than it
63+// should receive braille frames.
64+func Styled() bool {
65+ return enabled
66+}
67+
68+// Start begins animating with the given label, or just retitles a spinner that
69+// is already running. Safe to call when the previous one was never stopped.
70+func Start(text string) {
71+ if !enabled {
72+ return
73+ }
74+
75+ mu.Lock()
76+ if stopCh != nil { // already spinning: only the wording changes
77+ label = text
78+ mu.Unlock()
79+ return
80+ }
81+
82+ label = text
83+ stop, done := make(chan struct{}), make(chan struct{})
84+ stopCh, doneCh = stop, done
85+ mu.Unlock()
86+
87+ go spin(stop, done)
88+}
89+
90+// Stop erases the line and waits for the goroutine to be gone, so that whatever
91+// the caller prints next cannot interleave with a frame. Idempotent.
92+func Stop() {
93+ mu.Lock()
94+ stop, done := stopCh, doneCh
95+ stopCh, doneCh = nil, nil
96+ mu.Unlock()
97+
98+ if stop == nil {
99+ return
100+ }
101+ close(stop)
102+ <-done
103+}
104+
105+// spin owns the line until it is told to stop. It restores the cursor on the
106+// way out whatever happens, because a hidden cursor left behind outlives the
107+// program and makes the user's shell look broken.
108+func spin(stop <-chan struct{}, done chan<- struct{}) {
109+ started := time.Now()
110+ ticker := time.NewTicker(interval)
111+
112+ defer func() {
113+ ticker.Stop()
114+ fmt.Print("\r\033[2K\033[?25h") // erase line, show cursor
115+ close(done)
116+ }()
117+
118+ fmt.Print("\033[?25l") // hide cursor
119+ for i := 0; ; i++ {
120+ select {
121+ case <-stop:
122+ return
123+ default:
124+ }
125+
126+ mu.Lock()
127+ text := label
128+ mu.Unlock()
129+
130+ // \r + erase-to-end rather than padding with spaces: the label can
131+ // shrink between two frames, and leftovers would stay on screen.
132+ fmt.Printf("\r\033[2K\033[2m%s %s… %s\033[0m",
133+ frames[i%len(frames)], text, elapsed(time.Since(started)))
134+
135+ select {
136+ case <-stop:
137+ return
138+ case <-ticker.C:
139+ }
140+ }
141+}
142+
143+// elapsed formats the wait the way a person reads it: seconds until a minute,
144+// then minutes and seconds. Showing it is the point — it is what distinguishes
145+// "slow" from "hung".
146+func elapsed(d time.Duration) string {
147+ if d < time.Minute {
148+ return fmt.Sprintf("%ds", int(d.Seconds()))
149+ }
150+ return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
151+}
152+
153+// Suspend stops the spinner, runs fn, and starts it again under the same label
154+// — for code that has to print in the middle of a wait.
155+func Suspend(fn func()) {
156+ mu.Lock()
157+ text, running := label, stopCh != nil
158+ mu.Unlock()
159+
160+ Stop()
161+ fn()
162+ if running && strings.TrimSpace(text) != "" {
163+ Start(text)
164+ }
165+}
new file mode 100644
@@ -0,0 +1,165 @@
1+// Package spinner shows a "still working" indicator while nothing is being
2+// printed — the gap between sending a question and the first streamed token,
3+// which on a local engine is long enough (model loading, prompt processing) to
4+// look like a freeze.
5+//
6+// There is one spinner for the whole program rather than a value to pass
7+// around: the REPL runs one generation at a time, and the alternative would be
8+// threading a handle through Genkit's tool callbacks, which we do not control.
9+//
10+// The contract is: Start makes the line ours, Stop gives it back. Anything that
11+// prints must Stop first — hence the calls in dmr and tools.
12+package spinner
13+
14+import (
15+ "fmt"
16+ "os"
17+ "strings"
18+ "sync"
19+ "time"
20+)
21+
22+// frames is the braille cycle. Each frame is one rune wide, so the line never
23+// reflows as it animates.
24+var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
25+
26+const interval = 80 * time.Millisecond
27+
28+var (
29+ mu sync.Mutex
30+ stopCh chan struct{} // closed to ask the goroutine to stop
31+ doneCh chan struct{} // closed by the goroutine once the line is clean
32+ label string // read by the goroutine under mu, so Start can retitle
33+ enabled = supported() //nolint:gochecknoglobals // resolved once at startup
34+)
35+
36+// supported reports whether animating is a good idea. Piped or redirected
37+// output is not a terminal, and writing escape sequences into it would corrupt
38+// whatever is reading — a real risk here, since the point of the exercise is an
39+// agent whose output another program may consume.
40+func supported() bool {
41+ if os.Getenv("TERM") == "dumb" || os.Getenv("NO_COLOR") != "" {
42+ return false
43+ }
44+ info, err := os.Stdout.Stat()
45+ return err == nil && info.Mode()&os.ModeCharDevice != 0
46+}
47+
48+// Disable turns the spinner — and Styled — off for the rest of the process.
49+// The ACP front end calls it once at start-up: stdout then belongs to
50+// JSON-RPC, and even on a real terminal (bob --acp run by hand, for a demo)
51+// not one braille frame may land in the protocol stream. supported() cannot
52+// catch that case — a terminal IS a character device.
53+func Disable() {
54+ mu.Lock()
55+ enabled = false
56+ mu.Unlock()
57+}
58+
59+// Styled reports whether stdout may carry escape sequences — same test that
60+// decides whether to animate. Anything that colours its output should ask,
61+// rather than repeat the predicate: piped output is meant to be consumed by
62+// another program, and `bob | jq` should not receive dim codes any more than it
63+// should receive braille frames.
64+func Styled() bool {
65+ return enabled
66+}
67+
68+// Start begins animating with the given label, or just retitles a spinner that
69+// is already running. Safe to call when the previous one was never stopped.
70+func Start(text string) {
71+ if !enabled {
72+ return
73+ }
74+
75+ mu.Lock()
76+ if stopCh != nil { // already spinning: only the wording changes
77+ label = text
78+ mu.Unlock()
79+ return
80+ }
81+
82+ label = text
83+ stop, done := make(chan struct{}), make(chan struct{})
84+ stopCh, doneCh = stop, done
85+ mu.Unlock()
86+
87+ go spin(stop, done)
88+}
89+
90+// Stop erases the line and waits for the goroutine to be gone, so that whatever
91+// the caller prints next cannot interleave with a frame. Idempotent.
92+func Stop() {
93+ mu.Lock()
94+ stop, done := stopCh, doneCh
95+ stopCh, doneCh = nil, nil
96+ mu.Unlock()
97+
98+ if stop == nil {
99+ return
100+ }
101+ close(stop)
102+ <-done
103+}
104+
105+// spin owns the line until it is told to stop. It restores the cursor on the
106+// way out whatever happens, because a hidden cursor left behind outlives the
107+// program and makes the user's shell look broken.
108+func spin(stop <-chan struct{}, done chan<- struct{}) {
109+ started := time.Now()
110+ ticker := time.NewTicker(interval)
111+
112+ defer func() {
113+ ticker.Stop()
114+ fmt.Print("\r\033[2K\033[?25h") // erase line, show cursor
115+ close(done)
116+ }()
117+
118+ fmt.Print("\033[?25l") // hide cursor
119+ for i := 0; ; i++ {
120+ select {
121+ case <-stop:
122+ return
123+ default:
124+ }
125+
126+ mu.Lock()
127+ text := label
128+ mu.Unlock()
129+
130+ // \r + erase-to-end rather than padding with spaces: the label can
131+ // shrink between two frames, and leftovers would stay on screen.
132+ fmt.Printf("\r\033[2K\033[2m%s %s… %s\033[0m",
133+ frames[i%len(frames)], text, elapsed(time.Since(started)))
134+
135+ select {
136+ case <-stop:
137+ return
138+ case <-ticker.C:
139+ }
140+ }
141+}
142+
143+// elapsed formats the wait the way a person reads it: seconds until a minute,
144+// then minutes and seconds. Showing it is the point — it is what distinguishes
145+// "slow" from "hung".
146+func elapsed(d time.Duration) string {
147+ if d < time.Minute {
148+ return fmt.Sprintf("%ds", int(d.Seconds()))
149+ }
150+ return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
151+}
152+
153+// Suspend stops the spinner, runs fn, and starts it again under the same label
154+// — for code that has to print in the middle of a wait.
155+func Suspend(fn func()) {
156+ mu.Lock()
157+ text, running := label, stopCh != nil
158+ mu.Unlock()
159+
160+ Stop()
161+ fn()
162+ if running && strings.TrimSpace(text) != "" {
163+ Start(text)
164+ }
165+}
added internal/tools/files.go +324 -0
new file mode 100644
@@ -0,0 +1,324 @@
1+package tools
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "mm/internal/detector"
11+ "mm/internal/fileedit"
12+ "mm/internal/spinner"
13+ "mm/internal/ui"
14+
15+ "github.com/firebase/genkit/go/ai"
16+ "github.com/firebase/genkit/go/genkit"
17+)
18+
19+// --- read_file / write_file / edit_file ---------------------------------------
20+//
21+// Three built-in tools with the rules of the `edit` CLI (tools/edit). Part 05
22+// gave the model that CLI through bash; this part gives it the same operations
23+// as tools, so the two can be COMPARED: does a 12B model edit more reliably
24+// when the operation is a tool it can see, or a command it has to spell?
25+//
26+// Same contract as `bash`: a failure is returned AS TEXT, never as a Go error,
27+// so the model reads why and retries; every call prints one scannable line
28+// before running and echoes its result to the user before the model sees it.
29+
30+// maxDisplayWidth is where a display line is cut — the same hundred columns as
31+// the command recap: it fits a demo terminal without wrapping.
32+const maxDisplayWidth = 100
33+
34+// clip folds a display line onto ONE line and truncates it. A path can be
35+// long, an old text longer: the line is there to be scanned, not read.
36+func clip(s string) string {
37+ s = strings.Join(strings.Fields(s), " ")
38+ if r := []rune(s); len(r) > maxDisplayWidth {
39+ return string(r[:maxDisplayWidth-1]) + "…"
40+ }
41+ return s
42+}
43+
44+// announce prints the display line of a file tool, the way `bash` prints its
45+// 🛠️ line: under the spinner's Suspend so the label comes back for the wait
46+// that follows, and under printMu so two tools of one turn do not interleave.
47+func announce(line string) {
48+ spinner.Suspend(func() {
49+ printMu.Lock()
50+ fmt.Printf("\n%s\n", clip(line))
51+ printMu.Unlock()
52+ })
53+}
54+
55+// record feeds the loop detector like `bash` does, and appends its instruction
56+// to the output when a loop is detected: the same edit refused three times in
57+// a row is the model going in circles, exactly like a failing command.
58+func record(d *detector.LoopDetector, tool, input, out string) string {
59+ if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {
60+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError())
61+ }
62+ return out
63+}
64+
65+type readFileInput struct {
66+ Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"`
67+ Start int `json:"start,omitempty" jsonschema_description:"First line to return (1-based); 0 or absent = from the start"`
68+ End int `json:"end,omitempty" jsonschema_description:"Last line to return (1-based, inclusive); 0 or absent = to the end"`
69+ Numbered bool `json:"numbered,omitempty" jsonschema_description:"Prefix each line with its number — useful to pick a range for a second read"`
70+}
71+
72+// ReadFile declares the `read_file` tool.
73+func ReadFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
74+ description := `Read a text file, or a range of its lines.
75+Read before you edit: edit_file needs the EXACT text of the file, and you cannot match text you have not seen. Do not type it from memory.
76+Use start/end for a slice of a long file, and numbered=true to get line numbers. Long content is truncated, and the text says so.`
77+
78+ return genkit.DefineTool(g, "read_file", description,
79+ func(_ *ai.ToolContext, in readFileInput) (string, error) {
80+ where := in.Path
81+ if in.Start > 0 || in.End > 0 {
82+ where += fmt.Sprintf(" %d-%d", in.Start, in.End)
83+ }
84+ if s := ui.ActiveSink(); s != nil {
85+ return readFileEvented(s, d, in, where)
86+ }
87+ announce("📄 read_file: " + where)
88+
89+ content, err := fileedit.Read(in.Path, in.Start, in.End, in.Numbered)
90+ if err != nil {
91+ // Information, not a crash: the message says how to fix the call.
92+ out := err.Error()
93+ spinner.Suspend(func() { echoOutput(out) })
94+ return record(d, "read_file", where, out), nil
95+ }
96+ out := truncate(content)
97+ // Shown to the user like a command's output: "show me that file"
98+ // means show it, whatever tool read it.
99+ spinner.Suspend(func() { echoOutput(out) })
100+ return record(d, "read_file", where, out), nil
101+ })
102+}
103+
104+type writeFileInput struct {
105+ Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory; parent directories are created"`
106+ Content string `json:"content" jsonschema_description:"The WHOLE content of the file"`
107+}
108+
109+// WriteFile declares the `write_file` tool.
110+func WriteFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
111+ description := `Write the WHOLE content of a file: create it, or rewrite it entirely and on purpose.
112+On an existing file it overwrites everything, including what you did not intend to touch: to change part of a file, use edit_file instead.
113+Returns a one-line summary and the diff against the previous content. Writing identical content changes nothing and says so.`
114+
115+ return genkit.DefineTool(g, "write_file", description,
116+ func(tctx *ai.ToolContext, in writeFileInput) (string, error) {
117+ if s := ui.ActiveSink(); s != nil {
118+ return writeFileEvented(tctx, s, d, in)
119+ }
120+ announce(fmt.Sprintf("💾 write_file: %s (%d bytes)", in.Path, len(in.Content)))
121+
122+ r, err := fileedit.Write(in.Path, in.Content)
123+ if err != nil {
124+ out := err.Error()
125+ spinner.Suspend(func() { echoOutput(out) })
126+ return record(d, "write_file", in.Path, out), nil
127+ }
128+ return report(d, "write_file", in.Path, r), nil
129+ })
130+}
131+
132+type editFileInput struct {
133+ Path string `json:"path" jsonschema_description:"Path of an EXISTING file, relative to the working directory"`
134+ Edits []fileedit.Edit `json:"edits" jsonschema_description:"The replacements, all resolved against the ORIGINAL file; two edits must not overlap"`
135+ DryRun bool `json:"dry_run,omitempty" jsonschema_description:"Show what would change and write nothing"`
136+}
137+
138+// EditFile declares the `edit_file` tool.
139+func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
140+ description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it.
141+Each edit has an old text and a new text. The rules, which are what makes an edit verifiable:
142+1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file.
143+2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique.
144+3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote.
145+4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit.
146+5. An empty new deletes the matched text.
147+6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines.
148+Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through.`
149+
150+ return genkit.DefineTool(g, "edit_file", description,
151+ func(tctx *ai.ToolContext, in editFileInput) (string, error) {
152+ what := fmt.Sprintf("%s (%d edit(s))", in.Path, len(in.Edits))
153+ if in.DryRun {
154+ what += " dry-run"
155+ }
156+ if s := ui.ActiveSink(); s != nil {
157+ return editFileEvented(tctx, s, d, in, what)
158+ }
159+ announce("📝 edit_file: " + what)
160+
161+ r, err := fileedit.Replace(in.Path, in.Edits, in.DryRun)
162+ if err != nil {
163+ out := err.Error()
164+ spinner.Suspend(func() { echoOutput(out) })
165+ return record(d, "edit_file", editKey(in), out), nil
166+ }
167+ return report(d, "edit_file", editKey(in), r), nil
168+ })
169+}
170+
171+// editKey is what the loop detector compares for edit_file: the path AND the
172+// edits. Two calls with the same path but different old texts are the model
173+// correcting itself, not looping.
174+func editKey(in editFileInput) string {
175+ var b strings.Builder
176+ b.WriteString(in.Path)
177+ for _, e := range in.Edits {
178+ b.WriteString("\x00" + e.Old + "\x00" + e.New)
179+ }
180+ return b.String()
181+}
182+
183+// --- the same three tools, for an event-consuming front end (ACP) -------------
184+//
185+// The information is the same as the terminal path prints; only the channel
186+// changes. Two things exist here that the terminal never had: write_file and
187+// edit_file ask the client's user for permission before touching a file, and a
188+// successful change travels as a structured diff the editor can render. Paths
189+// resolve against the SESSION's working directory (the project open in the
190+// editor), not the process's — that is ui.Resolve.
191+
192+// absolute is the location a client can jump to: ACP wants absolute paths.
193+func absolute(path string) string {
194+ if abs, err := filepath.Abs(path); err == nil {
195+ return abs
196+ }
197+ return path
198+}
199+
200+func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, where string) (string, error) {
201+ path := ui.Resolve(in.Path)
202+ ev := ui.ToolEvent{
203+ ID: ui.NextCallID(),
204+ Tool: "read_file",
205+ Title: "read_file: " + where,
206+ Kind: "read",
207+ Input: map[string]any{"path": in.Path},
208+ Path: absolute(path),
209+ }
210+ // Reading is not gated: the permission dialog exists to protect the user's
211+ // files from changes and their shell from commands, and asking before every
212+ // read teaches them to stop reading the dialogs.
213+ s.ToolStart(ev)
214+ s.ToolRunning(ev.ID)
215+
216+ content, err := fileedit.Read(path, in.Start, in.End, in.Numbered)
217+ if err != nil {
218+ out := err.Error()
219+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
220+ return record(d, "read_file", where, out), nil
221+ }
222+ out := truncate(content)
223+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
224+ return record(d, "read_file", where, out), nil
225+}
226+
227+func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) {
228+ path := ui.Resolve(in.Path)
229+ ev := ui.ToolEvent{
230+ ID: ui.NextCallID(),
231+ Tool: "write_file",
232+ Title: fmt.Sprintf("write_file: %s (%d bytes)", in.Path, len(in.Content)),
233+ Kind: "edit",
234+ Input: map[string]any{"path": in.Path},
235+ Path: absolute(path),
236+ }
237+ s.ToolStart(ev)
238+ if !s.Allow(ctx, ev) {
239+ out := "[write_file rejected by the user]"
240+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
241+ return record(d, "write_file", in.Path, out), nil
242+ }
243+ s.ToolRunning(ev.ID)
244+
245+ // The old content is read BEFORE the write, for the diff the client
246+ // renders. Best effort: an unreadable file simply means "created".
247+ before, readErr := os.ReadFile(path)
248+
249+ r, err := fileedit.Write(path, in.Content)
250+ if err != nil {
251+ out := err.Error()
252+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
253+ return record(d, "write_file", in.Path, out), nil
254+ }
255+ out := resultText(r)
256+ res := ui.ToolResult{Output: out}
257+ if r.Changed {
258+ res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil}
259+ }
260+ s.ToolEnd(ev.ID, res)
261+ return record(d, "write_file", in.Path, truncate(out)), nil
262+}
263+
264+func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) {
265+ path := ui.Resolve(in.Path)
266+ ev := ui.ToolEvent{
267+ ID: ui.NextCallID(),
268+ Tool: "edit_file",
269+ Title: "edit_file: " + what,
270+ Kind: "edit",
271+ Input: map[string]any{"path": in.Path, "edits": len(in.Edits), "dry_run": in.DryRun},
272+ Path: absolute(path),
273+ }
274+ s.ToolStart(ev)
275+ if !s.Allow(ctx, ev) {
276+ out := "[edit_file rejected by the user]"
277+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
278+ return record(d, "edit_file", editKey(in), out), nil
279+ }
280+ s.ToolRunning(ev.ID)
281+
282+ before, _ := os.ReadFile(path)
283+
284+ r, err := fileedit.Replace(path, in.Edits, in.DryRun)
285+ if err != nil {
286+ // A refused edit (text not found, ambiguous, overlapping) is the
287+ // NORMAL path of this tool: failed status for the client, text for the
288+ // model — which reads the rule and fixes its `old`.
289+ out := err.Error()
290+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
291+ return record(d, "edit_file", editKey(in), out), nil
292+ }
293+ out := resultText(r)
294+ res := ui.ToolResult{Output: out}
295+ if r.Changed && !in.DryRun {
296+ after, readErr := os.ReadFile(path)
297+ if readErr == nil {
298+ res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: string(after)}
299+ }
300+ }
301+ s.ToolEnd(ev.ID, res)
302+ return record(d, "edit_file", editKey(in), truncate(out)), nil
303+}
304+
305+// resultText is what report() builds for the model, without the echo: the
306+// headline, then the numbered diff.
307+func resultText(r fileedit.Result) string {
308+ out := r.Headline
309+ if r.Diff != "" {
310+ out += "\n" + strings.TrimRight(r.Diff, "\n")
311+ }
312+ return out
313+}
314+
315+// report echoes a write/edit result to the user — headline then diff, dimmed
316+// and capped by previewLines like a command's output — and returns the same
317+// text to the model, capped by maxOutput. The user sees the change BEFORE the
318+// model comments on it: what the file became no longer depends on what the
319+// model chooses to say about it.
320+func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string {
321+ out := resultText(r)
322+ spinner.Suspend(func() { echoOutput(out) })
323+ return record(d, tool, key, truncate(out))
324+}
new file mode 100644
@@ -0,0 +1,324 @@
1+package tools
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "mm/internal/detector"
11+ "mm/internal/fileedit"
12+ "mm/internal/spinner"
13+ "mm/internal/ui"
14+
15+ "github.com/firebase/genkit/go/ai"
16+ "github.com/firebase/genkit/go/genkit"
17+)
18+
19+// --- read_file / write_file / edit_file ---------------------------------------
20+//
21+// Three built-in tools with the rules of the `edit` CLI (tools/edit). Part 05
22+// gave the model that CLI through bash; this part gives it the same operations
23+// as tools, so the two can be COMPARED: does a 12B model edit more reliably
24+// when the operation is a tool it can see, or a command it has to spell?
25+//
26+// Same contract as `bash`: a failure is returned AS TEXT, never as a Go error,
27+// so the model reads why and retries; every call prints one scannable line
28+// before running and echoes its result to the user before the model sees it.
29+
30+// maxDisplayWidth is where a display line is cut — the same hundred columns as
31+// the command recap: it fits a demo terminal without wrapping.
32+const maxDisplayWidth = 100
33+
34+// clip folds a display line onto ONE line and truncates it. A path can be
35+// long, an old text longer: the line is there to be scanned, not read.
36+func clip(s string) string {
37+ s = strings.Join(strings.Fields(s), " ")
38+ if r := []rune(s); len(r) > maxDisplayWidth {
39+ return string(r[:maxDisplayWidth-1]) + "…"
40+ }
41+ return s
42+}
43+
44+// announce prints the display line of a file tool, the way `bash` prints its
45+// 🛠️ line: under the spinner's Suspend so the label comes back for the wait
46+// that follows, and under printMu so two tools of one turn do not interleave.
47+func announce(line string) {
48+ spinner.Suspend(func() {
49+ printMu.Lock()
50+ fmt.Printf("\n%s\n", clip(line))
51+ printMu.Unlock()
52+ })
53+}
54+
55+// record feeds the loop detector like `bash` does, and appends its instruction
56+// to the output when a loop is detected: the same edit refused three times in
57+// a row is the model going in circles, exactly like a failing command.
58+func record(d *detector.LoopDetector, tool, input, out string) string {
59+ if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {
60+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError())
61+ }
62+ return out
63+}
64+
65+type readFileInput struct {
66+ Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"`
67+ Start int `json:"start,omitempty" jsonschema_description:"First line to return (1-based); 0 or absent = from the start"`
68+ End int `json:"end,omitempty" jsonschema_description:"Last line to return (1-based, inclusive); 0 or absent = to the end"`
69+ Numbered bool `json:"numbered,omitempty" jsonschema_description:"Prefix each line with its number — useful to pick a range for a second read"`
70+}
71+
72+// ReadFile declares the `read_file` tool.
73+func ReadFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
74+ description := `Read a text file, or a range of its lines.
75+Read before you edit: edit_file needs the EXACT text of the file, and you cannot match text you have not seen. Do not type it from memory.
76+Use start/end for a slice of a long file, and numbered=true to get line numbers. Long content is truncated, and the text says so.`
77+
78+ return genkit.DefineTool(g, "read_file", description,
79+ func(_ *ai.ToolContext, in readFileInput) (string, error) {
80+ where := in.Path
81+ if in.Start > 0 || in.End > 0 {
82+ where += fmt.Sprintf(" %d-%d", in.Start, in.End)
83+ }
84+ if s := ui.ActiveSink(); s != nil {
85+ return readFileEvented(s, d, in, where)
86+ }
87+ announce("📄 read_file: " + where)
88+
89+ content, err := fileedit.Read(in.Path, in.Start, in.End, in.Numbered)
90+ if err != nil {
91+ // Information, not a crash: the message says how to fix the call.
92+ out := err.Error()
93+ spinner.Suspend(func() { echoOutput(out) })
94+ return record(d, "read_file", where, out), nil
95+ }
96+ out := truncate(content)
97+ // Shown to the user like a command's output: "show me that file"
98+ // means show it, whatever tool read it.
99+ spinner.Suspend(func() { echoOutput(out) })
100+ return record(d, "read_file", where, out), nil
101+ })
102+}
103+
104+type writeFileInput struct {
105+ Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory; parent directories are created"`
106+ Content string `json:"content" jsonschema_description:"The WHOLE content of the file"`
107+}
108+
109+// WriteFile declares the `write_file` tool.
110+func WriteFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
111+ description := `Write the WHOLE content of a file: create it, or rewrite it entirely and on purpose.
112+On an existing file it overwrites everything, including what you did not intend to touch: to change part of a file, use edit_file instead.
113+Returns a one-line summary and the diff against the previous content. Writing identical content changes nothing and says so.`
114+
115+ return genkit.DefineTool(g, "write_file", description,
116+ func(tctx *ai.ToolContext, in writeFileInput) (string, error) {
117+ if s := ui.ActiveSink(); s != nil {
118+ return writeFileEvented(tctx, s, d, in)
119+ }
120+ announce(fmt.Sprintf("💾 write_file: %s (%d bytes)", in.Path, len(in.Content)))
121+
122+ r, err := fileedit.Write(in.Path, in.Content)
123+ if err != nil {
124+ out := err.Error()
125+ spinner.Suspend(func() { echoOutput(out) })
126+ return record(d, "write_file", in.Path, out), nil
127+ }
128+ return report(d, "write_file", in.Path, r), nil
129+ })
130+}
131+
132+type editFileInput struct {
133+ Path string `json:"path" jsonschema_description:"Path of an EXISTING file, relative to the working directory"`
134+ Edits []fileedit.Edit `json:"edits" jsonschema_description:"The replacements, all resolved against the ORIGINAL file; two edits must not overlap"`
135+ DryRun bool `json:"dry_run,omitempty" jsonschema_description:"Show what would change and write nothing"`
136+}
137+
138+// EditFile declares the `edit_file` tool.
139+func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
140+ description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it.
141+Each edit has an old text and a new text. The rules, which are what makes an edit verifiable:
142+1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file.
143+2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique.
144+3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote.
145+4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit.
146+5. An empty new deletes the matched text.
147+6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines.
148+Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through.`
149+
150+ return genkit.DefineTool(g, "edit_file", description,
151+ func(tctx *ai.ToolContext, in editFileInput) (string, error) {
152+ what := fmt.Sprintf("%s (%d edit(s))", in.Path, len(in.Edits))
153+ if in.DryRun {
154+ what += " dry-run"
155+ }
156+ if s := ui.ActiveSink(); s != nil {
157+ return editFileEvented(tctx, s, d, in, what)
158+ }
159+ announce("📝 edit_file: " + what)
160+
161+ r, err := fileedit.Replace(in.Path, in.Edits, in.DryRun)
162+ if err != nil {
163+ out := err.Error()
164+ spinner.Suspend(func() { echoOutput(out) })
165+ return record(d, "edit_file", editKey(in), out), nil
166+ }
167+ return report(d, "edit_file", editKey(in), r), nil
168+ })
169+}
170+
171+// editKey is what the loop detector compares for edit_file: the path AND the
172+// edits. Two calls with the same path but different old texts are the model
173+// correcting itself, not looping.
174+func editKey(in editFileInput) string {
175+ var b strings.Builder
176+ b.WriteString(in.Path)
177+ for _, e := range in.Edits {
178+ b.WriteString("\x00" + e.Old + "\x00" + e.New)
179+ }
180+ return b.String()
181+}
182+
183+// --- the same three tools, for an event-consuming front end (ACP) -------------
184+//
185+// The information is the same as the terminal path prints; only the channel
186+// changes. Two things exist here that the terminal never had: write_file and
187+// edit_file ask the client's user for permission before touching a file, and a
188+// successful change travels as a structured diff the editor can render. Paths
189+// resolve against the SESSION's working directory (the project open in the
190+// editor), not the process's — that is ui.Resolve.
191+
192+// absolute is the location a client can jump to: ACP wants absolute paths.
193+func absolute(path string) string {
194+ if abs, err := filepath.Abs(path); err == nil {
195+ return abs
196+ }
197+ return path
198+}
199+
200+func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, where string) (string, error) {
201+ path := ui.Resolve(in.Path)
202+ ev := ui.ToolEvent{
203+ ID: ui.NextCallID(),
204+ Tool: "read_file",
205+ Title: "read_file: " + where,
206+ Kind: "read",
207+ Input: map[string]any{"path": in.Path},
208+ Path: absolute(path),
209+ }
210+ // Reading is not gated: the permission dialog exists to protect the user's
211+ // files from changes and their shell from commands, and asking before every
212+ // read teaches them to stop reading the dialogs.
213+ s.ToolStart(ev)
214+ s.ToolRunning(ev.ID)
215+
216+ content, err := fileedit.Read(path, in.Start, in.End, in.Numbered)
217+ if err != nil {
218+ out := err.Error()
219+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
220+ return record(d, "read_file", where, out), nil
221+ }
222+ out := truncate(content)
223+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
224+ return record(d, "read_file", where, out), nil
225+}
226+
227+func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) {
228+ path := ui.Resolve(in.Path)
229+ ev := ui.ToolEvent{
230+ ID: ui.NextCallID(),
231+ Tool: "write_file",
232+ Title: fmt.Sprintf("write_file: %s (%d bytes)", in.Path, len(in.Content)),
233+ Kind: "edit",
234+ Input: map[string]any{"path": in.Path},
235+ Path: absolute(path),
236+ }
237+ s.ToolStart(ev)
238+ if !s.Allow(ctx, ev) {
239+ out := "[write_file rejected by the user]"
240+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
241+ return record(d, "write_file", in.Path, out), nil
242+ }
243+ s.ToolRunning(ev.ID)
244+
245+ // The old content is read BEFORE the write, for the diff the client
246+ // renders. Best effort: an unreadable file simply means "created".
247+ before, readErr := os.ReadFile(path)
248+
249+ r, err := fileedit.Write(path, in.Content)
250+ if err != nil {
251+ out := err.Error()
252+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
253+ return record(d, "write_file", in.Path, out), nil
254+ }
255+ out := resultText(r)
256+ res := ui.ToolResult{Output: out}
257+ if r.Changed {
258+ res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil}
259+ }
260+ s.ToolEnd(ev.ID, res)
261+ return record(d, "write_file", in.Path, truncate(out)), nil
262+}
263+
264+func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) {
265+ path := ui.Resolve(in.Path)
266+ ev := ui.ToolEvent{
267+ ID: ui.NextCallID(),
268+ Tool: "edit_file",
269+ Title: "edit_file: " + what,
270+ Kind: "edit",
271+ Input: map[string]any{"path": in.Path, "edits": len(in.Edits), "dry_run": in.DryRun},
272+ Path: absolute(path),
273+ }
274+ s.ToolStart(ev)
275+ if !s.Allow(ctx, ev) {
276+ out := "[edit_file rejected by the user]"
277+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
278+ return record(d, "edit_file", editKey(in), out), nil
279+ }
280+ s.ToolRunning(ev.ID)
281+
282+ before, _ := os.ReadFile(path)
283+
284+ r, err := fileedit.Replace(path, in.Edits, in.DryRun)
285+ if err != nil {
286+ // A refused edit (text not found, ambiguous, overlapping) is the
287+ // NORMAL path of this tool: failed status for the client, text for the
288+ // model — which reads the rule and fixes its `old`.
289+ out := err.Error()
290+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
291+ return record(d, "edit_file", editKey(in), out), nil
292+ }
293+ out := resultText(r)
294+ res := ui.ToolResult{Output: out}
295+ if r.Changed && !in.DryRun {
296+ after, readErr := os.ReadFile(path)
297+ if readErr == nil {
298+ res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: string(after)}
299+ }
300+ }
301+ s.ToolEnd(ev.ID, res)
302+ return record(d, "edit_file", editKey(in), truncate(out)), nil
303+}
304+
305+// resultText is what report() builds for the model, without the echo: the
306+// headline, then the numbered diff.
307+func resultText(r fileedit.Result) string {
308+ out := r.Headline
309+ if r.Diff != "" {
310+ out += "\n" + strings.TrimRight(r.Diff, "\n")
311+ }
312+ return out
313+}
314+
315+// report echoes a write/edit result to the user — headline then diff, dimmed
316+// and capped by previewLines like a command's output — and returns the same
317+// text to the model, capped by maxOutput. The user sees the change BEFORE the
318+// model comments on it: what the file became no longer depends on what the
319+// model chooses to say about it.
320+func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string {
321+ out := resultText(r)
322+ spinner.Suspend(func() { echoOutput(out) })
323+ return record(d, tool, key, truncate(out))
324+}
added internal/tools/files_test.go +33 -0
new file mode 100644
@@ -0,0 +1,33 @@
1+package tools
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "mm/internal/fileedit"
8+)
9+
10+// TestClip: a display line is scanned, not read. A hundred columns, one line —
11+// the same rule as the command recap, and the same reason: a multi-line old
12+// text printed whole would be longer than the diff it announces.
13+func TestClip(t *testing.T) {
14+ if got := clip("📝 edit_file: a.go\n (2 edits)"); got != "📝 edit_file: a.go (2 edits)" {
15+ t.Errorf("clip folded to %q", got)
16+ }
17+ long := clip("📄 read_file: " + strings.Repeat("x", 200))
18+ if r := []rune(long); len(r) != maxDisplayWidth || r[len(r)-1] != '…' {
19+ t.Errorf("clip = %d runes ending %q, want %d ending …", len(r), string(r[len(r)-1]), maxDisplayWidth)
20+ }
21+}
22+
23+// TestEditKeyTellsRetriesApart: the loop detector must see a corrected edit as
24+// a NEW action. Keying on the path alone made "same path, different old text"
25+// look like a repeat, and the detector fired on a model that was fixing itself.
26+func TestEditKeyTellsRetriesApart(t *testing.T) {
27+ a := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x", New: "y"}}})
28+ b := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x2", New: "y"}}})
29+ same := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x", New: "y"}}})
30+ if a == b || a != same {
31+ t.Errorf("editKey: a=%q b=%q same=%q", a, b, same)
32+ }
33+}
new file mode 100644
@@ -0,0 +1,33 @@
1+package tools
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "mm/internal/fileedit"
8+)
9+
10+// TestClip: a display line is scanned, not read. A hundred columns, one line —
11+// the same rule as the command recap, and the same reason: a multi-line old
12+// text printed whole would be longer than the diff it announces.
13+func TestClip(t *testing.T) {
14+ if got := clip("📝 edit_file: a.go\n (2 edits)"); got != "📝 edit_file: a.go (2 edits)" {
15+ t.Errorf("clip folded to %q", got)
16+ }
17+ long := clip("📄 read_file: " + strings.Repeat("x", 200))
18+ if r := []rune(long); len(r) != maxDisplayWidth || r[len(r)-1] != '…' {
19+ t.Errorf("clip = %d runes ending %q, want %d ending …", len(r), string(r[len(r)-1]), maxDisplayWidth)
20+ }
21+}
22+
23+// TestEditKeyTellsRetriesApart: the loop detector must see a corrected edit as
24+// a NEW action. Keying on the path alone made "same path, different old text"
25+// look like a repeat, and the detector fired on a model that was fixing itself.
26+func TestEditKeyTellsRetriesApart(t *testing.T) {
27+ a := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x", New: "y"}}})
28+ b := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x2", New: "y"}}})
29+ same := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x", New: "y"}}})
30+ if a == b || a != same {
31+ t.Errorf("editKey: a=%q b=%q same=%q", a, b, same)
32+ }
33+}
added internal/tools/tools.go +316 -0
new file mode 100644
@@ -0,0 +1,316 @@
1+// Package tools contains the built-in tools the model can call.
2+// Each `XxxTool(g)` constructor declares the tool in Genkit and returns it.
3+//
4+// Shared principle: we NEVER return a Go `error` for a "normal" failure
5+// (a command that fails, etc.). The error goes back AS TEXT, so that the model
6+// reads it and reacts.
7+package tools
8+
9+import (
10+ "context"
11+ "fmt"
12+ "os/exec"
13+ "strings"
14+ "sync"
15+ "time"
16+
17+ "mm/internal/config"
18+ "mm/internal/detector"
19+ "mm/internal/skills"
20+ "mm/internal/spinner"
21+ "mm/internal/ui"
22+
23+ "github.com/firebase/genkit/go/ai"
24+ "github.com/firebase/genkit/go/genkit"
25+)
26+
27+// --- Tool Implementation ----------------------------------------------------
28+
29+// truncate caps a large output, keeping the beginning AND the end.
30+func truncate(s string) string {
31+ max := config.Cfg.MaxOutput
32+ r := []rune(s)
33+ if len(r) <= max {
34+ return s
35+ }
36+ // 2000 characters kept at the end — but never more than half the budget,
37+ // since maxOutput comes from the config file and may be small.
38+ tailLen := min(2000, max/2)
39+ headLen := max - tailLen
40+
41+ head := string(r[:headLen])
42+ tail := string(r[len(r)-tailLen:])
43+ omitted := len(r) - headLen - tailLen
44+ return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", head, omitted, tail)
45+}
46+
47+// printMu serialises what the tools write to the screen. Genkit runs the calls
48+// of a single turn IN PARALLEL (one goroutine each), so two commands may want to
49+// print at the same time; without this lock their outputs interleave line by
50+// line.
51+var printMu sync.Mutex
52+
53+// echoOutput shows on screen what the command answered.
54+//
55+// Without it the agent swallows the output: it goes to the model, which
56+// summarises it — or does not. Seen on "display the content of agent.yaml": the
57+// model runs `cat`, considers the request fulfilled, and the user sees nothing.
58+// But "show me" means show.
59+//
60+// Only the first PreviewLines lines are printed: an output can run to thousands
61+// of lines, and drowning the terminal would be as useless as showing none of
62+// it.
63+func echoOutput(out string) {
64+ if config.Cfg.PreviewLines <= 0 {
65+ return
66+ }
67+ out = strings.TrimRight(out, "\n")
68+ if out == "" {
69+ printMu.Lock()
70+ if spinner.Styled() {
71+ fmt.Print("\033[2m (no output)\033[0m\n")
72+ } else {
73+ fmt.Print(" (no output)\n")
74+ }
75+ printMu.Unlock()
76+ return
77+ }
78+
79+ lines := strings.Split(out, "\n")
80+ shown := lines
81+ if len(lines) > config.Cfg.PreviewLines {
82+ shown = lines[:config.Cfg.PreviewLines]
83+ }
84+
85+ // Indented and dimmed: one glance must separate what the machine answered
86+ // from what the model says about it. The grey only comes out on a terminal.
87+ dim, off := "", ""
88+ if spinner.Styled() {
89+ dim, off = "\033[2m", "\033[0m"
90+ }
91+
92+ var b strings.Builder
93+ for _, l := range shown {
94+ b.WriteString(dim + " │ " + l + off + "\n")
95+ }
96+ if len(lines) > len(shown) {
97+ fmt.Fprintf(&b, "%s └ … %d more line(s)%s\n", dim, len(lines)-len(shown), off)
98+ }
99+
100+ printMu.Lock()
101+ fmt.Print(b.String())
102+ printMu.Unlock()
103+}
104+
105+// runBash runs a shell command (stdout+stderr merged, 30s timeout) in `dir` —
106+// "" meaning the process's own directory, which is the terminal behaviour.
107+// `failed` reports a non-zero exit or a timeout; the text already says so, but
108+// a front end that renders statuses (ACP) needs it as a value, not a substring.
109+func runBash(dir, command string) (output string, failed bool) {
110+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
111+ defer cancel()
112+
113+ cmd := exec.CommandContext(ctx, "bash", "-c", command)
114+ cmd.Dir = dir
115+ out, err := cmd.CombinedOutput()
116+ output = truncate(string(out))
117+
118+ if ctx.Err() == context.DeadlineExceeded {
119+ return output + "\n[error: 30s timeout exceeded]", true
120+ }
121+ if err != nil {
122+ return output + fmt.Sprintf("\n[exit code: %v]", err), true
123+ }
124+ return output, false
125+}
126+
127+// Bash declares the `bash` tool.
128+func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
129+
130+ description := `Run a shell command and return its output, plus the exit status when it is not zero.
131+This is a real bash shell: pipes, redirections, && and any installed command are available.
132+Each call starts a NEW shell in the same directory, so a "cd" does not carry over to the next call — chain with && instead.
133+Nothing is interactive: there is no stdin, so never use a command that asks a question.
134+Long output is truncated, and the text says so when it happens.
135+Read before you write: if you do not know a path or the content of a file, run a command to find out rather than guessing.`
136+
137+ return genkit.DefineTool(g, "bash",
138+ description,
139+ func(tctx *ai.ToolContext, in bashInput) (string, error) {
140+ // A front end that consumes events (the ACP façade) gets them
141+ // instead of the prints below; the terminal path is unchanged.
142+ if s := ui.ActiveSink(); s != nil {
143+ return bashEvented(tctx, s, d, in)
144+ }
145+
146+ // Suspend rather than Stop: the model was "Thinking" when it decided
147+ // to call us, and it goes back to thinking as soon as we return the
148+ // output — so the label is restored for the wait that follows.
149+ spinner.Suspend(func() {
150+ printMu.Lock()
151+ fmt.Printf("\n🛠️ bash: %s\n", in.Command)
152+ printMu.Unlock()
153+ })
154+
155+ // The command has its own 30s budget, and a slow one looks exactly
156+ // like a hung agent. Retitle for the duration, then hand the line
157+ // back to the "Thinking" wait Suspend restored.
158+ spinner.Start("Running")
159+ out, _ := runBash("", in.Command)
160+
161+ // The output is shown to the user BEFORE going back to the model:
162+ // what the command answered no longer depends on what the model
163+ // chooses to say about it.
164+ spinner.Suspend(func() {
165+ echoOutput(out)
166+ })
167+
168+ spinner.Start("Thinking")
169+
170+ // The loop detector sees the command AND its output: the same pair
171+ // twice in a row, and it hands the model back an instruction to
172+ // change its approach (see internal/detector).
173+ if d.Record(detector.Action{
174+ ToolName: "bash",
175+ Input: in.Command,
176+ Output: out,
177+ }) {
178+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
179+ }
180+
181+ return out, nil
182+ })
183+}
184+
185+type bashInput struct {
186+ Command string `json:"command" jsonschema_description:"The shell command to run"`
187+}
188+
189+// bashEvented is the `bash` tool when a front end consumes events (ACP): the
190+// 🛠️ line becomes ToolStart, the grey echo becomes ToolEnd's output — and one
191+// thing exists here that the terminal never had: the command does not run until
192+// the client's user allowed it. The refusal goes back to the model AS TEXT,
193+// like every normal failure in this package, so it can propose something else
194+// instead of crashing the turn.
195+func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in bashInput) (string, error) {
196+ ev := ui.ToolEvent{
197+ ID: ui.NextCallID(),
198+ Tool: "bash",
199+ Title: "bash: " + in.Command,
200+ Kind: "execute",
201+ Input: map[string]any{"command": in.Command},
202+ }
203+ s.ToolStart(ev)
204+
205+ if !s.Allow(ctx, ev) {
206+ out := "[command rejected by the user]"
207+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
208+ // Recorded like any other outcome: a model that keeps retrying a
209+ // command the user keeps rejecting is looping.
210+ return record(d, "bash", in.Command, out), nil
211+ }
212+
213+ s.ToolRunning(ev.ID)
214+ // The session's working directory, not the process's: in an editor, the
215+ // project the user opened is where the commands belong.
216+ out, failed := runBash(s.WorkDir(), in.Command)
217+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
218+
219+ return record(d, "bash", in.Command, out), nil
220+}
221+
222+// --- read_skill --------------------------------------------------------------
223+
224+type readSkillInput struct {
225+ Name string `json:"name" jsonschema_description:"The skill name, exactly as listed in this tool's description (for example \"go-rename\")"`
226+}
227+
228+// ReadSkill declares the `read_skill` tool: it loads one markdown procedure
229+// from the skills directory.
230+//
231+// The whole point is in the DESCRIPTION: it is built from the front matter of
232+// the files on disk, so the catalogue the model sees is always the directory's
233+// real content. Nothing to keep in sync by hand — and, unlike a paragraph in
234+// the system prompt, loading a skill becomes a tool the model can SEE.
235+//
236+// Returns nil when the directory holds no skill: an agent with an empty
237+// catalogue should not advertise the tool at all.
238+func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRef {
239+ list := skills.List(dir)
240+ if len(list) == 0 {
241+ return nil
242+ }
243+
244+ return genkit.DefineTool(g, "read_skill",
245+ skills.Catalogue(list),
246+ func(_ *ai.ToolContext, in readSkillInput) (string, error) {
247+ // Event-consuming front end (ACP): same information, as a tool_call.
248+ if s := ui.ActiveSink(); s != nil {
249+ return readSkillEvented(s, d, dir, list, in)
250+ }
251+
252+ // Same reasoning as `bash`: announce the call without losing the
253+ // "Thinking" wait that resumes the moment the skill is returned.
254+ // Reading a file is fast, so there is no second label here.
255+ spinner.Suspend(func() {
256+ printMu.Lock()
257+ fmt.Printf("\n📖 read_skill: %s\n", in.Name)
258+ printMu.Unlock()
259+ })
260+
261+ content, err := skills.Read(dir, in.Name)
262+ if err != nil {
263+ // Unknown skill: information, not a crash. The list of valid
264+ // names goes back AS TEXT so the model can correct itself.
265+ //
266+ // Recorded in the detector like everything else: a model
267+ // asking four times for the same missing skill is looping,
268+ // exactly as it would on a failing command.
269+ out := fmt.Sprintf("No skill named %q. Available skills: %s",
270+ in.Name, strings.Join(skills.Names(list), ", "))
271+ if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) {
272+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
273+ }
274+ return out, nil
275+ }
276+
277+ // The content is NOT displayed: a skill runs to dozens of lines, it
278+ // is written for the model, and the user already has the useful
279+ // information — which one was loaded. What they want to see are the
280+ // commands that follow from it, and those will show up.
281+ out := truncate(content)
282+ if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) {
283+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
284+ }
285+ return out, nil
286+ })
287+}
288+
289+// readSkillEvented is `read_skill` for an event-consuming front end. No
290+// permission dialog: it reads a procedure the agent ships, nothing more — and a
291+// dialog for every skill would teach the user to click "allow" without reading,
292+// which is worse than no dialog at all. The client still sees the call, kind
293+// "read", with the loaded content as its output.
294+func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []skills.Skill, in readSkillInput) (string, error) {
295+ ev := ui.ToolEvent{
296+ ID: ui.NextCallID(),
297+ Tool: "read_skill",
298+ Title: "read_skill: " + in.Name,
299+ Kind: "read",
300+ Input: map[string]any{"name": in.Name},
301+ }
302+ s.ToolStart(ev)
303+ s.ToolRunning(ev.ID)
304+
305+ content, err := skills.Read(dir, in.Name)
306+ if err != nil {
307+ out := fmt.Sprintf("No skill named %q. Available skills: %s",
308+ in.Name, strings.Join(skills.Names(list), ", "))
309+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
310+ return record(d, "read_skill", in.Name, out), nil
311+ }
312+
313+ out := truncate(content)
314+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
315+ return record(d, "read_skill", in.Name, out), nil
316+}
new file mode 100644
@@ -0,0 +1,316 @@
1+// Package tools contains the built-in tools the model can call.
2+// Each `XxxTool(g)` constructor declares the tool in Genkit and returns it.
3+//
4+// Shared principle: we NEVER return a Go `error` for a "normal" failure
5+// (a command that fails, etc.). The error goes back AS TEXT, so that the model
6+// reads it and reacts.
7+package tools
8+
9+import (
10+ "context"
11+ "fmt"
12+ "os/exec"
13+ "strings"
14+ "sync"
15+ "time"
16+
17+ "mm/internal/config"
18+ "mm/internal/detector"
19+ "mm/internal/skills"
20+ "mm/internal/spinner"
21+ "mm/internal/ui"
22+
23+ "github.com/firebase/genkit/go/ai"
24+ "github.com/firebase/genkit/go/genkit"
25+)
26+
27+// --- Tool Implementation ----------------------------------------------------
28+
29+// truncate caps a large output, keeping the beginning AND the end.
30+func truncate(s string) string {
31+ max := config.Cfg.MaxOutput
32+ r := []rune(s)
33+ if len(r) <= max {
34+ return s
35+ }
36+ // 2000 characters kept at the end — but never more than half the budget,
37+ // since maxOutput comes from the config file and may be small.
38+ tailLen := min(2000, max/2)
39+ headLen := max - tailLen
40+
41+ head := string(r[:headLen])
42+ tail := string(r[len(r)-tailLen:])
43+ omitted := len(r) - headLen - tailLen
44+ return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", head, omitted, tail)
45+}
46+
47+// printMu serialises what the tools write to the screen. Genkit runs the calls
48+// of a single turn IN PARALLEL (one goroutine each), so two commands may want to
49+// print at the same time; without this lock their outputs interleave line by
50+// line.
51+var printMu sync.Mutex
52+
53+// echoOutput shows on screen what the command answered.
54+//
55+// Without it the agent swallows the output: it goes to the model, which
56+// summarises it — or does not. Seen on "display the content of agent.yaml": the
57+// model runs `cat`, considers the request fulfilled, and the user sees nothing.
58+// But "show me" means show.
59+//
60+// Only the first PreviewLines lines are printed: an output can run to thousands
61+// of lines, and drowning the terminal would be as useless as showing none of
62+// it.
63+func echoOutput(out string) {
64+ if config.Cfg.PreviewLines <= 0 {
65+ return
66+ }
67+ out = strings.TrimRight(out, "\n")
68+ if out == "" {
69+ printMu.Lock()
70+ if spinner.Styled() {
71+ fmt.Print("\033[2m (no output)\033[0m\n")
72+ } else {
73+ fmt.Print(" (no output)\n")
74+ }
75+ printMu.Unlock()
76+ return
77+ }
78+
79+ lines := strings.Split(out, "\n")
80+ shown := lines
81+ if len(lines) > config.Cfg.PreviewLines {
82+ shown = lines[:config.Cfg.PreviewLines]
83+ }
84+
85+ // Indented and dimmed: one glance must separate what the machine answered
86+ // from what the model says about it. The grey only comes out on a terminal.
87+ dim, off := "", ""
88+ if spinner.Styled() {
89+ dim, off = "\033[2m", "\033[0m"
90+ }
91+
92+ var b strings.Builder
93+ for _, l := range shown {
94+ b.WriteString(dim + " │ " + l + off + "\n")
95+ }
96+ if len(lines) > len(shown) {
97+ fmt.Fprintf(&b, "%s └ … %d more line(s)%s\n", dim, len(lines)-len(shown), off)
98+ }
99+
100+ printMu.Lock()
101+ fmt.Print(b.String())
102+ printMu.Unlock()
103+}
104+
105+// runBash runs a shell command (stdout+stderr merged, 30s timeout) in `dir` —
106+// "" meaning the process's own directory, which is the terminal behaviour.
107+// `failed` reports a non-zero exit or a timeout; the text already says so, but
108+// a front end that renders statuses (ACP) needs it as a value, not a substring.
109+func runBash(dir, command string) (output string, failed bool) {
110+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
111+ defer cancel()
112+
113+ cmd := exec.CommandContext(ctx, "bash", "-c", command)
114+ cmd.Dir = dir
115+ out, err := cmd.CombinedOutput()
116+ output = truncate(string(out))
117+
118+ if ctx.Err() == context.DeadlineExceeded {
119+ return output + "\n[error: 30s timeout exceeded]", true
120+ }
121+ if err != nil {
122+ return output + fmt.Sprintf("\n[exit code: %v]", err), true
123+ }
124+ return output, false
125+}
126+
127+// Bash declares the `bash` tool.
128+func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
129+
130+ description := `Run a shell command and return its output, plus the exit status when it is not zero.
131+This is a real bash shell: pipes, redirections, && and any installed command are available.
132+Each call starts a NEW shell in the same directory, so a "cd" does not carry over to the next call — chain with && instead.
133+Nothing is interactive: there is no stdin, so never use a command that asks a question.
134+Long output is truncated, and the text says so when it happens.
135+Read before you write: if you do not know a path or the content of a file, run a command to find out rather than guessing.`
136+
137+ return genkit.DefineTool(g, "bash",
138+ description,
139+ func(tctx *ai.ToolContext, in bashInput) (string, error) {
140+ // A front end that consumes events (the ACP façade) gets them
141+ // instead of the prints below; the terminal path is unchanged.
142+ if s := ui.ActiveSink(); s != nil {
143+ return bashEvented(tctx, s, d, in)
144+ }
145+
146+ // Suspend rather than Stop: the model was "Thinking" when it decided
147+ // to call us, and it goes back to thinking as soon as we return the
148+ // output — so the label is restored for the wait that follows.
149+ spinner.Suspend(func() {
150+ printMu.Lock()
151+ fmt.Printf("\n🛠️ bash: %s\n", in.Command)
152+ printMu.Unlock()
153+ })
154+
155+ // The command has its own 30s budget, and a slow one looks exactly
156+ // like a hung agent. Retitle for the duration, then hand the line
157+ // back to the "Thinking" wait Suspend restored.
158+ spinner.Start("Running")
159+ out, _ := runBash("", in.Command)
160+
161+ // The output is shown to the user BEFORE going back to the model:
162+ // what the command answered no longer depends on what the model
163+ // chooses to say about it.
164+ spinner.Suspend(func() {
165+ echoOutput(out)
166+ })
167+
168+ spinner.Start("Thinking")
169+
170+ // The loop detector sees the command AND its output: the same pair
171+ // twice in a row, and it hands the model back an instruction to
172+ // change its approach (see internal/detector).
173+ if d.Record(detector.Action{
174+ ToolName: "bash",
175+ Input: in.Command,
176+ Output: out,
177+ }) {
178+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
179+ }
180+
181+ return out, nil
182+ })
183+}
184+
185+type bashInput struct {
186+ Command string `json:"command" jsonschema_description:"The shell command to run"`
187+}
188+
189+// bashEvented is the `bash` tool when a front end consumes events (ACP): the
190+// 🛠️ line becomes ToolStart, the grey echo becomes ToolEnd's output — and one
191+// thing exists here that the terminal never had: the command does not run until
192+// the client's user allowed it. The refusal goes back to the model AS TEXT,
193+// like every normal failure in this package, so it can propose something else
194+// instead of crashing the turn.
195+func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in bashInput) (string, error) {
196+ ev := ui.ToolEvent{
197+ ID: ui.NextCallID(),
198+ Tool: "bash",
199+ Title: "bash: " + in.Command,
200+ Kind: "execute",
201+ Input: map[string]any{"command": in.Command},
202+ }
203+ s.ToolStart(ev)
204+
205+ if !s.Allow(ctx, ev) {
206+ out := "[command rejected by the user]"
207+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
208+ // Recorded like any other outcome: a model that keeps retrying a
209+ // command the user keeps rejecting is looping.
210+ return record(d, "bash", in.Command, out), nil
211+ }
212+
213+ s.ToolRunning(ev.ID)
214+ // The session's working directory, not the process's: in an editor, the
215+ // project the user opened is where the commands belong.
216+ out, failed := runBash(s.WorkDir(), in.Command)
217+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
218+
219+ return record(d, "bash", in.Command, out), nil
220+}
221+
222+// --- read_skill --------------------------------------------------------------
223+
224+type readSkillInput struct {
225+ Name string `json:"name" jsonschema_description:"The skill name, exactly as listed in this tool's description (for example \"go-rename\")"`
226+}
227+
228+// ReadSkill declares the `read_skill` tool: it loads one markdown procedure
229+// from the skills directory.
230+//
231+// The whole point is in the DESCRIPTION: it is built from the front matter of
232+// the files on disk, so the catalogue the model sees is always the directory's
233+// real content. Nothing to keep in sync by hand — and, unlike a paragraph in
234+// the system prompt, loading a skill becomes a tool the model can SEE.
235+//
236+// Returns nil when the directory holds no skill: an agent with an empty
237+// catalogue should not advertise the tool at all.
238+func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRef {
239+ list := skills.List(dir)
240+ if len(list) == 0 {
241+ return nil
242+ }
243+
244+ return genkit.DefineTool(g, "read_skill",
245+ skills.Catalogue(list),
246+ func(_ *ai.ToolContext, in readSkillInput) (string, error) {
247+ // Event-consuming front end (ACP): same information, as a tool_call.
248+ if s := ui.ActiveSink(); s != nil {
249+ return readSkillEvented(s, d, dir, list, in)
250+ }
251+
252+ // Same reasoning as `bash`: announce the call without losing the
253+ // "Thinking" wait that resumes the moment the skill is returned.
254+ // Reading a file is fast, so there is no second label here.
255+ spinner.Suspend(func() {
256+ printMu.Lock()
257+ fmt.Printf("\n📖 read_skill: %s\n", in.Name)
258+ printMu.Unlock()
259+ })
260+
261+ content, err := skills.Read(dir, in.Name)
262+ if err != nil {
263+ // Unknown skill: information, not a crash. The list of valid
264+ // names goes back AS TEXT so the model can correct itself.
265+ //
266+ // Recorded in the detector like everything else: a model
267+ // asking four times for the same missing skill is looping,
268+ // exactly as it would on a failing command.
269+ out := fmt.Sprintf("No skill named %q. Available skills: %s",
270+ in.Name, strings.Join(skills.Names(list), ", "))
271+ if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) {
272+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
273+ }
274+ return out, nil
275+ }
276+
277+ // The content is NOT displayed: a skill runs to dozens of lines, it
278+ // is written for the model, and the user already has the useful
279+ // information — which one was loaded. What they want to see are the
280+ // commands that follow from it, and those will show up.
281+ out := truncate(content)
282+ if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) {
283+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
284+ }
285+ return out, nil
286+ })
287+}
288+
289+// readSkillEvented is `read_skill` for an event-consuming front end. No
290+// permission dialog: it reads a procedure the agent ships, nothing more — and a
291+// dialog for every skill would teach the user to click "allow" without reading,
292+// which is worse than no dialog at all. The client still sees the call, kind
293+// "read", with the loaded content as its output.
294+func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []skills.Skill, in readSkillInput) (string, error) {
295+ ev := ui.ToolEvent{
296+ ID: ui.NextCallID(),
297+ Tool: "read_skill",
298+ Title: "read_skill: " + in.Name,
299+ Kind: "read",
300+ Input: map[string]any{"name": in.Name},
301+ }
302+ s.ToolStart(ev)
303+ s.ToolRunning(ev.ID)
304+
305+ content, err := skills.Read(dir, in.Name)
306+ if err != nil {
307+ out := fmt.Sprintf("No skill named %q. Available skills: %s",
308+ in.Name, strings.Join(skills.Names(list), ", "))
309+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
310+ return record(d, "read_skill", in.Name, out), nil
311+ }
312+
313+ out := truncate(content)
314+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
315+ return record(d, "read_skill", in.Name, out), nil
316+}
added internal/ui/ui.go +120 -0
new file mode 100644
@@ -0,0 +1,120 @@
1+// Package ui decides where the agent's human-facing output goes, and — when a
2+// front end wants events rather than text — carries those events as a Sink.
3+//
4+// Two front ends share the same engine and the same tools:
5+//
6+// - the terminal REPL (internal/agent): Sink stays nil, Out is os.Stdout,
7+// and the tools print their own 🛠️/📄/📝 lines exactly as in part 10;
8+// - the ACP façade (internal/acp): stdout belongs to JSON-RPC — the spec says
9+// the agent "MUST NOT write anything to its stdout that is not a valid ACP
10+// message" — so main.go moves Out to os.Stderr, and the façade installs a
11+// Sink that turns every event into a session/update notification.
12+//
13+// The package holds no protocol knowledge: the event names below are bob's
14+// (a tool starts, a tool ends, may I run this?), and internal/acp is the one
15+// place that translates them into ACP messages.
16+package ui
17+
18+import (
19+ "context"
20+ "fmt"
21+ "io"
22+ "os"
23+ "path/filepath"
24+ "sync"
25+ "sync/atomic"
26+)
27+
28+// Out is where human-facing text goes when no Sink is active: the banner, the
29+// warnings, the watchdog message. os.Stdout for the terminal REPL; main.go
30+// moves it to os.Stderr in ACP mode, so everything already written for the
31+// terminal stays readable — in the editor's agent logs instead of the screen.
32+var Out io.Writer = os.Stdout
33+
34+// ToolEvent describes one tool call, in bob's vocabulary.
35+type ToolEvent struct {
36+ ID string // "call_1" — unique within the process, see NextCallID
37+ Tool string // the tool's name: "bash", "read_file", …
38+ Title string // one line for the client's UI: "bash: ls -1"
39+ Kind string // how a client should render it: "execute", "read", "edit"
40+ Input map[string]any // the raw input, for the client to display
41+ Path string // the file touched (absolute), "" when none
42+}
43+
44+// ToolResult is what a tool call ended with.
45+type ToolResult struct {
46+ Output string // what goes back to the model (already truncated)
47+ Failed bool // non-zero exit, refused edit, rejected permission…
48+ Diff *Diff // the file change, when there is one a client can render
49+}
50+
51+// Diff carries a file change for clients able to show a real diff (ACP has a
52+// dedicated content type for it — that is how Zed renders edits).
53+type Diff struct {
54+ Path string // absolute
55+ OldText string // ignored when Created
56+ NewText string
57+ Created bool // the file did not exist before
58+}
59+
60+// Sink receives the events of a turn. nil (the default) means the terminal
61+// front end: the tools print themselves and nobody asks for permission.
62+type Sink interface {
63+ // Text is a chunk of the model's streamed answer.
64+ Text(text string)
65+ // ToolStart announces a call before anything runs (ACP: status "pending").
66+ ToolStart(ev ToolEvent)
67+ // Allow asks whether the call may run. The terminal front end has no
68+ // gatekeeper and would always say yes; a client shows the user a dialog.
69+ // ctx is the turn's context: a cancelled turn abandons the question.
70+ Allow(ctx context.Context, ev ToolEvent) bool
71+ // ToolRunning reports that the call was allowed and is executing.
72+ ToolRunning(id string)
73+ // ToolEnd reports the outcome, output included.
74+ ToolEnd(id string, res ToolResult)
75+ // WorkDir is the session's working directory ("" = the process's own):
76+ // where bash runs and what relative file paths resolve against.
77+ WorkDir() string
78+}
79+
80+// The active sink is set once per turn by the ACP façade and read by the tools
81+// from Genkit's goroutines — hence the lock, not a plain variable.
82+var (
83+ mu sync.RWMutex
84+ active Sink
85+)
86+
87+// SetActive installs (or, with nil, removes) the sink for the current turn.
88+func SetActive(s Sink) {
89+ mu.Lock()
90+ active = s
91+ mu.Unlock()
92+}
93+
94+// ActiveSink returns the sink of the current turn, nil for the terminal.
95+func ActiveSink() Sink {
96+ mu.RLock()
97+ defer mu.RUnlock()
98+ return active
99+}
100+
101+// callID numbers the tool calls of the process. Never reset: a client
102+// correlates tool_call and tool_call_update by this ID, and reusing one across
103+// sessions would merge two calls in its UI.
104+var callID atomic.Int64
105+
106+// NextCallID returns "call_1", "call_2", …
107+func NextCallID() string {
108+ return fmt.Sprintf("call_%d", callID.Add(1))
109+}
110+
111+// Resolve makes a relative path absolute against the active session's working
112+// directory. With no sink — the terminal REPL — the path is returned as-is:
113+// part 10's behaviour, relative to the process's directory, unchanged.
114+func Resolve(path string) string {
115+ s := ActiveSink()
116+ if s == nil || filepath.IsAbs(path) || s.WorkDir() == "" {
117+ return path
118+ }
119+ return filepath.Join(s.WorkDir(), path)
120+}
new file mode 100644
@@ -0,0 +1,120 @@
1+// Package ui decides where the agent's human-facing output goes, and — when a
2+// front end wants events rather than text — carries those events as a Sink.
3+//
4+// Two front ends share the same engine and the same tools:
5+//
6+// - the terminal REPL (internal/agent): Sink stays nil, Out is os.Stdout,
7+// and the tools print their own 🛠️/📄/📝 lines exactly as in part 10;
8+// - the ACP façade (internal/acp): stdout belongs to JSON-RPC — the spec says
9+// the agent "MUST NOT write anything to its stdout that is not a valid ACP
10+// message" — so main.go moves Out to os.Stderr, and the façade installs a
11+// Sink that turns every event into a session/update notification.
12+//
13+// The package holds no protocol knowledge: the event names below are bob's
14+// (a tool starts, a tool ends, may I run this?), and internal/acp is the one
15+// place that translates them into ACP messages.
16+package ui
17+
18+import (
19+ "context"
20+ "fmt"
21+ "io"
22+ "os"
23+ "path/filepath"
24+ "sync"
25+ "sync/atomic"
26+)
27+
28+// Out is where human-facing text goes when no Sink is active: the banner, the
29+// warnings, the watchdog message. os.Stdout for the terminal REPL; main.go
30+// moves it to os.Stderr in ACP mode, so everything already written for the
31+// terminal stays readable — in the editor's agent logs instead of the screen.
32+var Out io.Writer = os.Stdout
33+
34+// ToolEvent describes one tool call, in bob's vocabulary.
35+type ToolEvent struct {
36+ ID string // "call_1" — unique within the process, see NextCallID
37+ Tool string // the tool's name: "bash", "read_file", …
38+ Title string // one line for the client's UI: "bash: ls -1"
39+ Kind string // how a client should render it: "execute", "read", "edit"
40+ Input map[string]any // the raw input, for the client to display
41+ Path string // the file touched (absolute), "" when none
42+}
43+
44+// ToolResult is what a tool call ended with.
45+type ToolResult struct {
46+ Output string // what goes back to the model (already truncated)
47+ Failed bool // non-zero exit, refused edit, rejected permission…
48+ Diff *Diff // the file change, when there is one a client can render
49+}
50+
51+// Diff carries a file change for clients able to show a real diff (ACP has a
52+// dedicated content type for it — that is how Zed renders edits).
53+type Diff struct {
54+ Path string // absolute
55+ OldText string // ignored when Created
56+ NewText string
57+ Created bool // the file did not exist before
58+}
59+
60+// Sink receives the events of a turn. nil (the default) means the terminal
61+// front end: the tools print themselves and nobody asks for permission.
62+type Sink interface {
63+ // Text is a chunk of the model's streamed answer.
64+ Text(text string)
65+ // ToolStart announces a call before anything runs (ACP: status "pending").
66+ ToolStart(ev ToolEvent)
67+ // Allow asks whether the call may run. The terminal front end has no
68+ // gatekeeper and would always say yes; a client shows the user a dialog.
69+ // ctx is the turn's context: a cancelled turn abandons the question.
70+ Allow(ctx context.Context, ev ToolEvent) bool
71+ // ToolRunning reports that the call was allowed and is executing.
72+ ToolRunning(id string)
73+ // ToolEnd reports the outcome, output included.
74+ ToolEnd(id string, res ToolResult)
75+ // WorkDir is the session's working directory ("" = the process's own):
76+ // where bash runs and what relative file paths resolve against.
77+ WorkDir() string
78+}
79+
80+// The active sink is set once per turn by the ACP façade and read by the tools
81+// from Genkit's goroutines — hence the lock, not a plain variable.
82+var (
83+ mu sync.RWMutex
84+ active Sink
85+)
86+
87+// SetActive installs (or, with nil, removes) the sink for the current turn.
88+func SetActive(s Sink) {
89+ mu.Lock()
90+ active = s
91+ mu.Unlock()
92+}
93+
94+// ActiveSink returns the sink of the current turn, nil for the terminal.
95+func ActiveSink() Sink {
96+ mu.RLock()
97+ defer mu.RUnlock()
98+ return active
99+}
100+
101+// callID numbers the tool calls of the process. Never reset: a client
102+// correlates tool_call and tool_call_update by this ID, and reusing one across
103+// sessions would merge two calls in its UI.
104+var callID atomic.Int64
105+
106+// NextCallID returns "call_1", "call_2", …
107+func NextCallID() string {
108+ return fmt.Sprintf("call_%d", callID.Add(1))
109+}
110+
111+// Resolve makes a relative path absolute against the active session's working
112+// directory. With no sink — the terminal REPL — the path is returned as-is:
113+// part 10's behaviour, relative to the process's directory, unchanged.
114+func Resolve(path string) string {
115+ s := ActiveSink()
116+ if s == nil || filepath.IsAbs(path) || s.WorkDir() == "" {
117+ return path
118+ }
119+ return filepath.Join(s.WorkDir(), path)
120+}
added internal/ui/ui_test.go +54 -0
new file mode 100644
@@ -0,0 +1,54 @@
1+package ui
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+)
8+
9+// stubSink is the smallest Sink that can carry a working directory.
10+type stubSink struct{ cwd string }
11+
12+func (stubSink) Text(string) {}
13+func (stubSink) ToolStart(ToolEvent) {}
14+func (stubSink) Allow(context.Context, ToolEvent) bool { return true }
15+func (stubSink) ToolRunning(string) {}
16+func (stubSink) ToolEnd(string, ToolResult) {}
17+func (s stubSink) WorkDir() string { return s.cwd }
18+
19+// With no sink — the terminal front end — Resolve must be the identity: part
20+// 10's behaviour, paths relative to the process, unchanged.
21+func TestResolveWithoutSinkIsIdentity(t *testing.T) {
22+ SetActive(nil)
23+ for _, p := range []string{"hello/main.go", "../x", "/abs/path"} {
24+ if got := Resolve(p); got != p {
25+ t.Errorf("Resolve(%q) = %q, want unchanged", p, got)
26+ }
27+ }
28+}
29+
30+// With a sink, a relative path follows the SESSION's working directory — the
31+// project open in the editor — and an absolute one is left alone.
32+func TestResolveWithSinkFollowsTheSession(t *testing.T) {
33+ SetActive(stubSink{cwd: "/work/project"})
34+ defer SetActive(nil)
35+
36+ if got := Resolve("hello/main.go"); got != "/work/project/hello/main.go" {
37+ t.Errorf("relative path: got %q", got)
38+ }
39+ if got := Resolve("/etc/hosts"); got != "/etc/hosts" {
40+ t.Errorf("absolute path must not move: got %q", got)
41+ }
42+}
43+
44+// Call IDs must never repeat: an ACP client correlates tool_call and
45+// tool_call_update by them, and a duplicate would merge two calls in its UI.
46+func TestNextCallIDNeverRepeats(t *testing.T) {
47+ a, b := NextCallID(), NextCallID()
48+ if a == b {
49+ t.Fatalf("two calls, same id: %q", a)
50+ }
51+ if !strings.HasPrefix(a, "call_") {
52+ t.Fatalf("unexpected shape: %q", a)
53+ }
54+}
new file mode 100644
@@ -0,0 +1,54 @@
1+package ui
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+)
8+
9+// stubSink is the smallest Sink that can carry a working directory.
10+type stubSink struct{ cwd string }
11+
12+func (stubSink) Text(string) {}
13+func (stubSink) ToolStart(ToolEvent) {}
14+func (stubSink) Allow(context.Context, ToolEvent) bool { return true }
15+func (stubSink) ToolRunning(string) {}
16+func (stubSink) ToolEnd(string, ToolResult) {}
17+func (s stubSink) WorkDir() string { return s.cwd }
18+
19+// With no sink — the terminal front end — Resolve must be the identity: part
20+// 10's behaviour, paths relative to the process, unchanged.
21+func TestResolveWithoutSinkIsIdentity(t *testing.T) {
22+ SetActive(nil)
23+ for _, p := range []string{"hello/main.go", "../x", "/abs/path"} {
24+ if got := Resolve(p); got != p {
25+ t.Errorf("Resolve(%q) = %q, want unchanged", p, got)
26+ }
27+ }
28+}
29+
30+// With a sink, a relative path follows the SESSION's working directory — the
31+// project open in the editor — and an absolute one is left alone.
32+func TestResolveWithSinkFollowsTheSession(t *testing.T) {
33+ SetActive(stubSink{cwd: "/work/project"})
34+ defer SetActive(nil)
35+
36+ if got := Resolve("hello/main.go"); got != "/work/project/hello/main.go" {
37+ t.Errorf("relative path: got %q", got)
38+ }
39+ if got := Resolve("/etc/hosts"); got != "/etc/hosts" {
40+ t.Errorf("absolute path must not move: got %q", got)
41+ }
42+}
43+
44+// Call IDs must never repeat: an ACP client correlates tool_call and
45+// tool_call_update by them, and a duplicate would merge two calls in its UI.
46+func TestNextCallIDNeverRepeats(t *testing.T) {
47+ a, b := NextCallID(), NextCallID()
48+ if a == b {
49+ t.Fatalf("two calls, same id: %q", a)
50+ }
51+ if !strings.HasPrefix(a, "call_") {
52+ t.Fatalf("unexpected shape: %q", a)
53+ }
54+}
added main.go +195 -0
new file mode 100644
@@ -0,0 +1,195 @@
1+// Minimal coding agent: an agent loop with built-in tools — `bash`,
2+// `read_skill`, and (switchable) `read_file`, `write_file`, `edit_file`.
3+//
4+// LLM engine: any provider of internal/engine — Docker Model Runner by default,
5+// llama-server with `provider: llamacpp` — all through the OpenAI-compatible API.
6+//
7+// The code is split into packages (internal/ directory):
8+// - config: every setting, with YAML overrides (see agent.yaml);
9+// - detector: repeated-action detection (the logical loop);
10+// - engine: connection to the LLM engine + generation (streaming + fallback),
11+// with one Provider per kind of server;
12+// - skills: the markdown procedures of ./skills, and their catalogue;
13+// - mention: the @path notation of a question, shared by both front ends;
14+// - fileedit: exact-replacement file editing, the rules of the `edit` CLI;
15+// - tools: the built-in tools (`bash`, `read_skill`, the file tools);
16+// - ui: where the human-facing output goes, and the event sink the
17+// two front ends share;
18+// - session: what a conversation starts from, and the /new command that
19+// takes it back there — shared by the two front ends;
20+// - agent: the terminal front end (the REPL);
21+// - acp: the editor front end — the same loop behind the Agent Client
22+// Protocol (`bob --acp`), for Zed, JetBrains, Neovim…
23+//
24+// main() only does the WIRING: load the settings, engine init, tool list, then
25+// starting the loop.
26+package main
27+
28+import (
29+ "context"
30+ "flag"
31+ "fmt"
32+ "os"
33+ "path/filepath"
34+ "strings"
35+
36+ "mm/internal/acp"
37+ "mm/internal/agent"
38+ "mm/internal/config"
39+ "mm/internal/detector"
40+ "mm/internal/engine"
41+ "mm/internal/skills"
42+ "mm/internal/spinner"
43+ "mm/internal/tools"
44+ "mm/internal/ui"
45+
46+ "github.com/firebase/genkit/go/ai"
47+)
48+
49+func main() {
50+ ctx := context.Background()
51+
52+ // Where the config file is. Same value, three ways to give it, from the most
53+ // local to the most ambient:
54+ //
55+ // go run . -config ./fast.yaml (the flag)
56+ // go run . ./fast.yaml (a lone argument, handy in a demo)
57+ // AGENT_CONFIG=./fast.yaml go run .
58+ //
59+ // The flag package gives us "-h" for free; a second argument is a typo, and
60+ // saying so is better than ignoring it.
61+ configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
62+ acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")
63+ flag.Parse()
64+ if *configPath == "" && flag.NArg() > 0 {
65+ *configPath = flag.Arg(0)
66+ }
67+ if flag.NArg() > 1 {
68+ fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]")
69+ os.Exit(1)
70+ }
71+
72+ // In ACP mode, stdout belongs to JSON-RPC — the spec forbids anything else
73+ // on it. Everything below that prints (banner, warnings, errors) goes
74+ // through ui.Out, so ONE move sends it all to stderr, where the editor's
75+ // agent logs pick it up. The spinner is silenced outright: supported()
76+ // cannot tell "a terminal" from "a terminal used as a protocol pipe".
77+ if *acpMode {
78+ ui.Out = os.Stderr
79+ spinner.Disable()
80+ }
81+
82+ // Settings: built-in defaults, overridden by the YAML file if there is one.
83+ path, err := config.Load(*configPath)
84+ if err != nil {
85+ fmt.Fprintln(ui.Out, "[config error:", err, "]")
86+ os.Exit(1)
87+ }
88+
89+ // Where a relative skillsDir points depends on who started mm. From a
90+ // terminal, it is the directory mm was started from — an installed mm
91+ // (/usr/local/bin) run inside a project loads THAT project's skills. The
92+ // editor launches mm with ITS working directory, so in ACP mode with a
93+ // config file the anchor is that file — the one path the user actually
94+ // named (env AGENT_CONFIG in the editor's settings). Either way the path
95+ // is made absolute here, so the banner can say exactly where it looked.
96+ config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)
97+
98+ e, err := engine.New(ctx)
99+ if err != nil {
100+ fmt.Fprintln(ui.Out, "[engine error:", err, "]")
101+ os.Exit(1)
102+ }
103+
104+ // Initialize the global loop detector.
105+ loopDetector := detector.NewLoopDetector(10, 3)
106+
107+ agentTools := []ai.ToolRef{
108+ tools.Bash(e.G, loopDetector),
109+ }
110+
111+ // `read_skill` only exists when there is something to read. Its description
112+ // carries the catalogue, so declaring it with an empty directory would
113+ // advertise a tool that can do nothing.
114+ skillCount := len(skills.List(config.Cfg.SkillsDir))
115+ toolNames := []string{"bash"}
116+ if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil {
117+ agentTools = append(agentTools, skillTool)
118+ toolNames = append(toolNames, "read_skill")
119+ }
120+
121+ // The file tools are a switch, not a given: the same binary must run as
122+ // part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so
123+ // the two can be compared on the same prompts.
124+ if config.Cfg.EditTools {
125+ agentTools = append(agentTools,
126+ tools.ReadFile(e.G, loopDetector),
127+ tools.WriteFile(e.G, loopDetector),
128+ tools.EditFile(e.G, loopDetector))
129+ toolNames = append(toolNames, "read_file", "write_file", "edit_file")
130+ }
131+
132+ if path == "" {
133+ path = "built-in defaults"
134+ }
135+
136+ // The probe runs before the banner so its warnings sit right under the
137+ // prompt they explain. It never stops the agent: on a demo machine the
138+ // server is often started AFTER the agent.
139+ info := e.Probe(ctx)
140+ ctxCol := "unknown"
141+ if info.ContextWindow > 0 {
142+ ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource)
143+ }
144+ // Dimmed on a terminal, plain in a pipe: the agent's output is meant to be
145+ // consumed by another program as much as it is meant to be read.
146+ dim, off := "", ""
147+ if spinner.Styled() {
148+ dim, off = "\033[2m", "\033[0m"
149+ }
150+ // The compression's own warning belongs with the provider's: it is the
151+ // same kind of advice, and it depends on what the probe just learned.
152+ if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 && c.MaxMessages == 0 {
153+ info.Warnings = append(info.Warnings,
154+ "context compression is on, but the context window is unknown and context.maxMessages is 0 — it will never trigger (set contextWindow, or maxMessages)")
155+ }
156+ // "skills: 0" alone sends people hunting; the resolved path says at once
157+ // whether mm looked in the wrong place or found the right one empty.
158+ if skillCount == 0 {
159+ info.Warnings = append(info.Warnings,
160+ fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
161+ }
162+ for _, w := range info.Warnings {
163+ fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off)
164+ }
165+ fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n",
166+ dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off)
167+
168+ // Two front ends, one wiring: everything above — config, engine, tools,
169+ // detector — is strictly identical whichever loop runs below.
170+ if *acpMode {
171+ acp.Run(ctx, e, config.Cfg.System, agentTools)
172+ return
173+ }
174+ agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)
175+}
176+
177+// resolveSkillsDir makes the skills directory absolute. An absolute dir is
178+// kept as-is. A relative one follows the config file in ACP mode when there
179+// is one (the editor's working directory is not the project's), and the
180+// current directory otherwise — for the terminal, that is where the user
181+// started mm.
182+func resolveSkillsDir(dir, configPath string, acpMode bool) string {
183+ if filepath.IsAbs(dir) {
184+ return dir
185+ }
186+ anchor := "."
187+ if acpMode && configPath != "" {
188+ anchor = filepath.Dir(configPath)
189+ }
190+ abs, err := filepath.Abs(filepath.Join(anchor, dir))
191+ if err != nil {
192+ return filepath.Join(anchor, dir)
193+ }
194+ return abs
195+}
new file mode 100644
@@ -0,0 +1,195 @@
1+// Minimal coding agent: an agent loop with built-in tools — `bash`,
2+// `read_skill`, and (switchable) `read_file`, `write_file`, `edit_file`.
3+//
4+// LLM engine: any provider of internal/engine — Docker Model Runner by default,
5+// llama-server with `provider: llamacpp` — all through the OpenAI-compatible API.
6+//
7+// The code is split into packages (internal/ directory):
8+// - config: every setting, with YAML overrides (see agent.yaml);
9+// - detector: repeated-action detection (the logical loop);
10+// - engine: connection to the LLM engine + generation (streaming + fallback),
11+// with one Provider per kind of server;
12+// - skills: the markdown procedures of ./skills, and their catalogue;
13+// - mention: the @path notation of a question, shared by both front ends;
14+// - fileedit: exact-replacement file editing, the rules of the `edit` CLI;
15+// - tools: the built-in tools (`bash`, `read_skill`, the file tools);
16+// - ui: where the human-facing output goes, and the event sink the
17+// two front ends share;
18+// - session: what a conversation starts from, and the /new command that
19+// takes it back there — shared by the two front ends;
20+// - agent: the terminal front end (the REPL);
21+// - acp: the editor front end — the same loop behind the Agent Client
22+// Protocol (`bob --acp`), for Zed, JetBrains, Neovim…
23+//
24+// main() only does the WIRING: load the settings, engine init, tool list, then
25+// starting the loop.
26+package main
27+
28+import (
29+ "context"
30+ "flag"
31+ "fmt"
32+ "os"
33+ "path/filepath"
34+ "strings"
35+
36+ "mm/internal/acp"
37+ "mm/internal/agent"
38+ "mm/internal/config"
39+ "mm/internal/detector"
40+ "mm/internal/engine"
41+ "mm/internal/skills"
42+ "mm/internal/spinner"
43+ "mm/internal/tools"
44+ "mm/internal/ui"
45+
46+ "github.com/firebase/genkit/go/ai"
47+)
48+
49+func main() {
50+ ctx := context.Background()
51+
52+ // Where the config file is. Same value, three ways to give it, from the most
53+ // local to the most ambient:
54+ //
55+ // go run . -config ./fast.yaml (the flag)
56+ // go run . ./fast.yaml (a lone argument, handy in a demo)
57+ // AGENT_CONFIG=./fast.yaml go run .
58+ //
59+ // The flag package gives us "-h" for free; a second argument is a typo, and
60+ // saying so is better than ignoring it.
61+ configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
62+ acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")
63+ flag.Parse()
64+ if *configPath == "" && flag.NArg() > 0 {
65+ *configPath = flag.Arg(0)
66+ }
67+ if flag.NArg() > 1 {
68+ fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]")
69+ os.Exit(1)
70+ }
71+
72+ // In ACP mode, stdout belongs to JSON-RPC — the spec forbids anything else
73+ // on it. Everything below that prints (banner, warnings, errors) goes
74+ // through ui.Out, so ONE move sends it all to stderr, where the editor's
75+ // agent logs pick it up. The spinner is silenced outright: supported()
76+ // cannot tell "a terminal" from "a terminal used as a protocol pipe".
77+ if *acpMode {
78+ ui.Out = os.Stderr
79+ spinner.Disable()
80+ }
81+
82+ // Settings: built-in defaults, overridden by the YAML file if there is one.
83+ path, err := config.Load(*configPath)
84+ if err != nil {
85+ fmt.Fprintln(ui.Out, "[config error:", err, "]")
86+ os.Exit(1)
87+ }
88+
89+ // Where a relative skillsDir points depends on who started mm. From a
90+ // terminal, it is the directory mm was started from — an installed mm
91+ // (/usr/local/bin) run inside a project loads THAT project's skills. The
92+ // editor launches mm with ITS working directory, so in ACP mode with a
93+ // config file the anchor is that file — the one path the user actually
94+ // named (env AGENT_CONFIG in the editor's settings). Either way the path
95+ // is made absolute here, so the banner can say exactly where it looked.
96+ config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)
97+
98+ e, err := engine.New(ctx)
99+ if err != nil {
100+ fmt.Fprintln(ui.Out, "[engine error:", err, "]")
101+ os.Exit(1)
102+ }
103+
104+ // Initialize the global loop detector.
105+ loopDetector := detector.NewLoopDetector(10, 3)
106+
107+ agentTools := []ai.ToolRef{
108+ tools.Bash(e.G, loopDetector),
109+ }
110+
111+ // `read_skill` only exists when there is something to read. Its description
112+ // carries the catalogue, so declaring it with an empty directory would
113+ // advertise a tool that can do nothing.
114+ skillCount := len(skills.List(config.Cfg.SkillsDir))
115+ toolNames := []string{"bash"}
116+ if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil {
117+ agentTools = append(agentTools, skillTool)
118+ toolNames = append(toolNames, "read_skill")
119+ }
120+
121+ // The file tools are a switch, not a given: the same binary must run as
122+ // part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so
123+ // the two can be compared on the same prompts.
124+ if config.Cfg.EditTools {
125+ agentTools = append(agentTools,
126+ tools.ReadFile(e.G, loopDetector),
127+ tools.WriteFile(e.G, loopDetector),
128+ tools.EditFile(e.G, loopDetector))
129+ toolNames = append(toolNames, "read_file", "write_file", "edit_file")
130+ }
131+
132+ if path == "" {
133+ path = "built-in defaults"
134+ }
135+
136+ // The probe runs before the banner so its warnings sit right under the
137+ // prompt they explain. It never stops the agent: on a demo machine the
138+ // server is often started AFTER the agent.
139+ info := e.Probe(ctx)
140+ ctxCol := "unknown"
141+ if info.ContextWindow > 0 {
142+ ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource)
143+ }
144+ // Dimmed on a terminal, plain in a pipe: the agent's output is meant to be
145+ // consumed by another program as much as it is meant to be read.
146+ dim, off := "", ""
147+ if spinner.Styled() {
148+ dim, off = "\033[2m", "\033[0m"
149+ }
150+ // The compression's own warning belongs with the provider's: it is the
151+ // same kind of advice, and it depends on what the probe just learned.
152+ if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 && c.MaxMessages == 0 {
153+ info.Warnings = append(info.Warnings,
154+ "context compression is on, but the context window is unknown and context.maxMessages is 0 — it will never trigger (set contextWindow, or maxMessages)")
155+ }
156+ // "skills: 0" alone sends people hunting; the resolved path says at once
157+ // whether mm looked in the wrong place or found the right one empty.
158+ if skillCount == 0 {
159+ info.Warnings = append(info.Warnings,
160+ fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
161+ }
162+ for _, w := range info.Warnings {
163+ fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off)
164+ }
165+ fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n",
166+ dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off)
167+
168+ // Two front ends, one wiring: everything above — config, engine, tools,
169+ // detector — is strictly identical whichever loop runs below.
170+ if *acpMode {
171+ acp.Run(ctx, e, config.Cfg.System, agentTools)
172+ return
173+ }
174+ agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)
175+}
176+
177+// resolveSkillsDir makes the skills directory absolute. An absolute dir is
178+// kept as-is. A relative one follows the config file in ACP mode when there
179+// is one (the editor's working directory is not the project's), and the
180+// current directory otherwise — for the terminal, that is where the user
181+// started mm.
182+func resolveSkillsDir(dir, configPath string, acpMode bool) string {
183+ if filepath.IsAbs(dir) {
184+ return dir
185+ }
186+ anchor := "."
187+ if acpMode && configPath != "" {
188+ anchor = filepath.Dir(configPath)
189+ }
190+ abs, err := filepath.Abs(filepath.Join(anchor, dir))
191+ if err != nil {
192+ return filepath.Join(anchor, dir)
193+ }
194+ return abs
195+}
added 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 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, the 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, the run:
16+
17+```bash
18+echo "🖖 Live long and prosper! <name> 🖖"
19+```
added 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+