bots-garden/mini-mepublic Fork
⑂ 7e53bfe
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

📦 Mini Me

k33g committed 2026-10-17T18:34:49+02:00 Browse files
7e53bfe parent: 2341940
modified .tickets/issues/0036-hooks.yaml +2 -1
@@ -6,8 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T06:32:48.566Z
9-updatedAt: 2026-10-14T22:00:49.000Z
9+updatedAt: 2026-10-16T14:19:50.441Z
1010 labels: []
11+milestone: v0.5.0
1112 epic: Loop
1213 body: |
1314 ## Overview
@@ -6,8 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T06:32:48.566Z8 createdAt: 2026-09-21T06:32:48.566Z
9-updatedAt: 2026-10-14T22:00:49.000Z9+updatedAt: 2026-10-16T14:19:50.441Z
10 labels: []10 labels: []
11+milestone: v0.5.0
11 epic: Loop12 epic: Loop
12 body: |13 body: |
13 ## Overview14 ## Overview
modified .tickets/issues/0048-add-a-builtin-tool-to-search-into-the-mm-session.yaml +2 -2
@@ -6,9 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-27T03:45:48.121Z
9-updatedAt: 2026-10-16T11:48:27.806Z
9+updatedAt: 2026-10-17T16:33:44.187Z
1010 labels: []
11-milestone: v0.6.0
11+milestone: v0.7.0
1212 epic: Agent Memory
1313 body: ""
1414 tasks: []
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-27T03:45:48.121Z8 createdAt: 2026-09-27T03:45:48.121Z
9-updatedAt: 2026-10-16T11:48:27.806Z9+updatedAt: 2026-10-17T16:33:44.187Z
10 labels: []10 labels: []
11-milestone: v0.6.011+milestone: v0.7.0
12 epic: Agent Memory12 epic: Agent Memory
13 body: ""13 body: ""
14 tasks: []14 tasks: []
modified .tickets/issues/0049-recreate-the-readme.yaml +2 -2
@@ -6,9 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-28T18:44:44.821Z
9-updatedAt: 2026-10-16T11:48:41.005Z
9+updatedAt: 2026-10-17T16:34:31.420Z
1010 labels: []
11-milestone: v0.6.0
11+milestone: v0.7.0
1212 epic: ToDo
1313 body: ""
1414 tasks: []
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-28T18:44:44.821Z8 createdAt: 2026-09-28T18:44:44.821Z
9-updatedAt: 2026-10-16T11:48:41.005Z9+updatedAt: 2026-10-17T16:34:31.420Z
10 labels: []10 labels: []
11-milestone: v0.6.011+milestone: v0.7.0
12 epic: ToDo12 epic: ToDo
13 body: ""13 body: ""
14 tasks: []14 tasks: []
modified .tickets/issues/0088-custom-tools-declared-in-the-agent-file-command.yaml +4 -5
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 88
33 title: Custom tools declared in the agent file (command tools)
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-16T12:00:00.000Z
9-updatedAt: 2026-10-16T17:00:00.000Z
9+updatedAt: 2026-10-16T14:16:43.681Z
1010 labels:
1111 - kind::feature
1212 - builtin tool
@@ -89,7 +89,6 @@ body: |
8989
9090 Measured once built (same bench, condition D = the real feature): **mellum2-12b 30/30 (1.3 s avg), qwen3.5-4b 30/30 (5.8 s)**, every run in ONE call, no invalid call. The French release request that failed 5/5 through the MCP stand-in succeeded 5/5; which difference matters (no `proj_` prefix, no "MCP tool from server" text, the "Give each parameter as its own field." line) was not isolated.
9191 Demo: `demos/declared-tools/` (ops desk, four declared tools, every parameter type, mellum2 on DMR): 24/24 action requests in one call. Found there: an `enum` does not stop the model from silently swapping an unknown value for an allowed one ("alpha" → `beta`, 3/3) — fixed by the demo's system prompt.
92-
9392 tasks:
9493 - id: 1
9594 title: "Measure: MCP stand-in vs skill + bash, 2 models, 5 runs per case"
@@ -112,7 +111,7 @@ tasks:
112111 updatedAt: 2026-10-16T17:00:00.000Z
113112 closedAt: 2026-10-16T17:00:00.000Z
114113 - id: 3
115- title: "Implement declared tools (config, tools, main) with tests"
114+ title: Implement declared tools (config, tools, main) with tests
116115 state: closed
117116 priority: high
118117 author:
@@ -132,7 +131,7 @@ tasks:
132131 updatedAt: 2026-10-16T17:00:00.000Z
133132 closedAt: 2026-10-16T17:00:00.000Z
134133 - id: 5
135- title: "Quality gate (qlty not installed in the 2026-10-16 sandbox — to run)"
134+ title: Quality gate (qlty not installed in the 2026-10-16 sandbox — to run)
136135 state: open
137136 priority: medium
138137 author:
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 882 id: 88
3 title: Custom tools declared in the agent file (command tools)3 title: Custom tools declared in the agent file (command tools)
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-16T12:00:00.000Z8 createdAt: 2026-10-16T12:00:00.000Z
9-updatedAt: 2026-10-16T17:00:00.000Z9+updatedAt: 2026-10-16T14:16:43.681Z
10 labels:10 labels:
11 - kind::feature11 - kind::feature
12 - builtin tool12 - builtin tool
@@ -89,7 +89,6 @@ body: |
89 89
90 Measured once built (same bench, condition D = the real feature): **mellum2-12b 30/30 (1.3 s avg), qwen3.5-4b 30/30 (5.8 s)**, every run in ONE call, no invalid call. The French release request that failed 5/5 through the MCP stand-in succeeded 5/5; which difference matters (no `proj_` prefix, no "MCP tool from server" text, the "Give each parameter as its own field." line) was not isolated.90 Measured once built (same bench, condition D = the real feature): **mellum2-12b 30/30 (1.3 s avg), qwen3.5-4b 30/30 (5.8 s)**, every run in ONE call, no invalid call. The French release request that failed 5/5 through the MCP stand-in succeeded 5/5; which difference matters (no `proj_` prefix, no "MCP tool from server" text, the "Give each parameter as its own field." line) was not isolated.
91 Demo: `demos/declared-tools/` (ops desk, four declared tools, every parameter type, mellum2 on DMR): 24/24 action requests in one call. Found there: an `enum` does not stop the model from silently swapping an unknown value for an allowed one ("alpha" → `beta`, 3/3) — fixed by the demo's system prompt.91 Demo: `demos/declared-tools/` (ops desk, four declared tools, every parameter type, mellum2 on DMR): 24/24 action requests in one call. Found there: an `enum` does not stop the model from silently swapping an unknown value for an allowed one ("alpha" → `beta`, 3/3) — fixed by the demo's system prompt.
92-
93 tasks:92 tasks:
94 - id: 193 - id: 1
95 title: "Measure: MCP stand-in vs skill + bash, 2 models, 5 runs per case"94 title: "Measure: MCP stand-in vs skill + bash, 2 models, 5 runs per case"
@@ -112,7 +111,7 @@ tasks:
112 updatedAt: 2026-10-16T17:00:00.000Z111 updatedAt: 2026-10-16T17:00:00.000Z
113 closedAt: 2026-10-16T17:00:00.000Z112 closedAt: 2026-10-16T17:00:00.000Z
114 - id: 3113 - id: 3
115- title: "Implement declared tools (config, tools, main) with tests"114+ title: Implement declared tools (config, tools, main) with tests
116 state: closed115 state: closed
117 priority: high116 priority: high
118 author:117 author:
@@ -132,7 +131,7 @@ tasks:
132 updatedAt: 2026-10-16T17:00:00.000Z131 updatedAt: 2026-10-16T17:00:00.000Z
133 closedAt: 2026-10-16T17:00:00.000Z132 closedAt: 2026-10-16T17:00:00.000Z
134 - id: 5133 - id: 5
135- title: "Quality gate (qlty not installed in the 2026-10-16 sandbox — to run)"134+ title: Quality gate (qlty not installed in the 2026-10-16 sandbox — to run)
136 state: open135 state: open
137 priority: medium136 priority: medium
138 author:137 author:
added .tickets/issues/0089-autonomous-agent.yaml +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 89
3+title: Autonomous Agent?
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-16T15:36:08.133Z
9+updatedAt: 2026-10-16T15:38:22.791Z
10+labels: []
11+milestone: v0.7.0
12+related:
13+ - 90
14+body: |
15+ Without Human interaction
16+
17+ And then, what about orchestration
18+tasks: []
19+comments: []
new file mode 100644
@@ -0,0 +1,19 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 89
3+title: Autonomous Agent?
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-16T15:36:08.133Z
9+updatedAt: 2026-10-16T15:38:22.791Z
10+labels: []
11+milestone: v0.7.0
12+related:
13+ - 90
14+body: |
15+ Without Human interaction
16+
17+ And then, what about orchestration
18+tasks: []
19+comments: []
added .tickets/issues/0090-agent-identity.yaml +16 -0
new file mode 100644
@@ -0,0 +1,16 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 90
3+title: Agent identity
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-16T15:37:32.062Z
9+updatedAt: 2026-10-16T15:41:47.728Z
10+labels: []
11+milestone: v0.7.0
12+body: |
13+ Des idées pour gérer cela ?
14+ avec l'existant (est ce que un seul useur peut avoir plusieurs identités sur GitHub par exemple)
15+tasks: []
16+comments: []
new file mode 100644
@@ -0,0 +1,16 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 90
3+title: Agent identity
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-16T15:37:32.062Z
9+updatedAt: 2026-10-16T15:41:47.728Z
10+labels: []
11+milestone: v0.7.0
12+body: |
13+ Des idées pour gérer cela ?
14+ avec l'existant (est ce que un seul useur peut avoir plusieurs identités sur GitHub par exemple)
15+tasks: []
16+comments: []
added .tickets/issues/0091-plan-update-without-status-loops-the-model.yaml +72 -0
new file mode 100644
@@ -0,0 +1,72 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 91
3+title: plan update without status loops the model
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-17T12:00:00.000Z
9+updatedAt: 2026-10-17T16:33:17.797Z
10+labels:
11+ - kind::bug
12+ - builtin tool
13+milestone: v0.6.0
14+epic: Builtin tools
15+body: |
16+ ## What happened (2026-10-17)
17+ Tuning `wip/agent.yaml` for Mellum2.1-12B-A2.5B-Thinking on llmman, the request "Écris un tutoriel complet sur Nu shell dans le fichier nushell-tutorial.md" failed 1 run out of 5: after one `plan create`, the model sent `plan update` about **30 times** until `maxTurns: 40` stopped the turn (9 min 20, `exceeded maximum tool call iterations (40)`). Every call looked like this (from the trace):
18+ ```json
19+ {"action":"update","name":"user-request-1","taskId":1,
20+ "tasks":[{"description":"Write introduction in French about Nu shell","id":1}, …]}
21+ ```
22+ The model was trying to **edit the plan's tasks**. `update` only changes one task's status, and `tasks` is only read by `create`.
23+
24+ ## Why the model could not get out
25+ - The answer was `[error: invalid status ''. Must be one of: open, in_progress, done, failed]` (`PlanStore.UpdateTask`, `internal/tools/plan.go`). It does not say that the `status` FIELD is missing, nor that `tasks` is ignored, nor how to change a plan's tasks (there is no such action: delete + create).
26+ - The loop detector never fired: the `tasks` list grew a little on most calls, so no three calls in a row were identical. Only `maxTurns` ended it.
27+
28+ ## What was changed
29+ - `internal/tools/plan_actions.go`: `planUpdate` checks the status against `planStatuses` BEFORE the store, and `invalidUpdate` answers instead, e.g.:
30+ ```
31+ [error: plan update changes the status of ONE task, and "status" is missing. You sent: {"action":"update","name":"user-request-1","tasks":[…],"taskId":1}. "status" must be one of: open, in_progress, done, failed. "tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again. A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}]
32+ ```
33+ An unknown status (`"finished"`) gets the same shape (`"finished" is not a status`); the sentence about `tasks` only when tasks were sent; the example uses the call's name and taskId, or `user-request-1` / `1` when absent. The plan is not modified. Same principle as the declared tools' refusals (ticket 88): what was sent, what is missing, a call that works.
34+ - Tests: `TestPlanUpdateWithoutStatusExplainsTheCall` (the traced call, plan left untouched), `TestPlanUpdateWithAnUnknownStatus`.
35+ - Docs EN+FR: `how-to/manage-plans-and-tasks.md`, troubleshooting entry "The agent keeps calling `plan update`".
36+ - `wip/agent.yaml` keeps `planTool: false` (writing a document needs no plan).
37+
38+ ## Not done
39+ - Not re-measured live with Mellum2.1 and `planTool: true`: the new message is unit-tested, not proven to stop the loop on the model.
40+ - The loop detector still ignores near-identical calls; that is a separate question.
41+ - No way to edit a plan's tasks was added.
42+tasks:
43+ - id: 1
44+ title: Explicit refusal for update without a valid status, with tests
45+ state: closed
46+ priority: high
47+ author:
48+ name: k33g
49+ email: ph.charriere@gmail.com
50+ createdAt: 2026-10-17T12:00:00.000Z
51+ updatedAt: 2026-10-17T12:00:00.000Z
52+ closedAt: 2026-10-17T12:00:00.000Z
53+ - id: 2
54+ title: "Docs EN+FR: troubleshooting entry in manage-plans-and-tasks"
55+ state: closed
56+ priority: medium
57+ author:
58+ name: k33g
59+ email: ph.charriere@gmail.com
60+ createdAt: 2026-10-17T12:00:00.000Z
61+ updatedAt: 2026-10-17T12:00:00.000Z
62+ closedAt: 2026-10-17T12:00:00.000Z
63+ - id: 3
64+ title: "Re-measure Mellum2.1 with planTool: true on the tutorial request"
65+ state: open
66+ priority: medium
67+ author:
68+ name: k33g
69+ email: ph.charriere@gmail.com
70+ createdAt: 2026-10-17T12:00:00.000Z
71+ updatedAt: 2026-10-17T12:00:00.000Z
72+comments: []
new file mode 100644
@@ -0,0 +1,72 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 91
3+title: plan update without status loops the model
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-17T12:00:00.000Z
9+updatedAt: 2026-10-17T16:33:17.797Z
10+labels:
11+ - kind::bug
12+ - builtin tool
13+milestone: v0.6.0
14+epic: Builtin tools
15+body: |
16+ ## What happened (2026-10-17)
17+ Tuning `wip/agent.yaml` for Mellum2.1-12B-A2.5B-Thinking on llmman, the request "Écris un tutoriel complet sur Nu shell dans le fichier nushell-tutorial.md" failed 1 run out of 5: after one `plan create`, the model sent `plan update` about **30 times** until `maxTurns: 40` stopped the turn (9 min 20, `exceeded maximum tool call iterations (40)`). Every call looked like this (from the trace):
18+ ```json
19+ {"action":"update","name":"user-request-1","taskId":1,
20+ "tasks":[{"description":"Write introduction in French about Nu shell","id":1}, …]}
21+ ```
22+ The model was trying to **edit the plan's tasks**. `update` only changes one task's status, and `tasks` is only read by `create`.
23+
24+ ## Why the model could not get out
25+ - The answer was `[error: invalid status ''. Must be one of: open, in_progress, done, failed]` (`PlanStore.UpdateTask`, `internal/tools/plan.go`). It does not say that the `status` FIELD is missing, nor that `tasks` is ignored, nor how to change a plan's tasks (there is no such action: delete + create).
26+ - The loop detector never fired: the `tasks` list grew a little on most calls, so no three calls in a row were identical. Only `maxTurns` ended it.
27+
28+ ## What was changed
29+ - `internal/tools/plan_actions.go`: `planUpdate` checks the status against `planStatuses` BEFORE the store, and `invalidUpdate` answers instead, e.g.:
30+ ```
31+ [error: plan update changes the status of ONE task, and "status" is missing. You sent: {"action":"update","name":"user-request-1","tasks":[…],"taskId":1}. "status" must be one of: open, in_progress, done, failed. "tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again. A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}]
32+ ```
33+ An unknown status (`"finished"`) gets the same shape (`"finished" is not a status`); the sentence about `tasks` only when tasks were sent; the example uses the call's name and taskId, or `user-request-1` / `1` when absent. The plan is not modified. Same principle as the declared tools' refusals (ticket 88): what was sent, what is missing, a call that works.
34+ - Tests: `TestPlanUpdateWithoutStatusExplainsTheCall` (the traced call, plan left untouched), `TestPlanUpdateWithAnUnknownStatus`.
35+ - Docs EN+FR: `how-to/manage-plans-and-tasks.md`, troubleshooting entry "The agent keeps calling `plan update`".
36+ - `wip/agent.yaml` keeps `planTool: false` (writing a document needs no plan).
37+
38+ ## Not done
39+ - Not re-measured live with Mellum2.1 and `planTool: true`: the new message is unit-tested, not proven to stop the loop on the model.
40+ - The loop detector still ignores near-identical calls; that is a separate question.
41+ - No way to edit a plan's tasks was added.
42+tasks:
43+ - id: 1
44+ title: Explicit refusal for update without a valid status, with tests
45+ state: closed
46+ priority: high
47+ author:
48+ name: k33g
49+ email: ph.charriere@gmail.com
50+ createdAt: 2026-10-17T12:00:00.000Z
51+ updatedAt: 2026-10-17T12:00:00.000Z
52+ closedAt: 2026-10-17T12:00:00.000Z
53+ - id: 2
54+ title: "Docs EN+FR: troubleshooting entry in manage-plans-and-tasks"
55+ state: closed
56+ priority: medium
57+ author:
58+ name: k33g
59+ email: ph.charriere@gmail.com
60+ createdAt: 2026-10-17T12:00:00.000Z
61+ updatedAt: 2026-10-17T12:00:00.000Z
62+ closedAt: 2026-10-17T12:00:00.000Z
63+ - id: 3
64+ title: "Re-measure Mellum2.1 with planTool: true on the tutorial request"
65+ state: open
66+ priority: medium
67+ author:
68+ name: k33g
69+ email: ph.charriere@gmail.com
70+ createdAt: 2026-10-17T12:00:00.000Z
71+ updatedAt: 2026-10-17T12:00:00.000Z
72+comments: []
added .tickets/issues/0092-how-to-unload-a-model.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: 92
3+title: How to unload a model
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-17T14:24:18.694Z
9+updatedAt: 2026-10-17T14:24:34.591Z
10+labels:
11+ - priority::urgent
12+milestone: v0.7.0
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: 92
3+title: How to unload a model
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-17T14:24:18.694Z
9+updatedAt: 2026-10-17T14:24:34.591Z
10+labels:
11+ - priority::urgent
12+milestone: v0.7.0
13+body: ""
14+tasks: []
15+comments: []
added .tickets/issues/0093-save-an-answer-without-regenerating-it.yaml +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 93
3+title: Save an answer to a file without regenerating it (/save, save_answer)
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-17T16:00:00.000Z
9+updatedAt: 2026-10-17T16:00:00.000Z
10+labels:
11+ - kind::feature
12+ - builtin tool
13+epic: Builtin tools
14+body: |
15+ ## Why
16+ For an agent that answers questions with mini tutorials (user's idea, 2026-10-17: "comment faire mon 1er script nushell", "créer une lib en Go", "apprends-moi le pattern matching en Kotlin"…), the answer lives in the conversation and goes to a Markdown file ONLY when the user asks.
17+ Saving through `write_file` makes the model generate the whole text a second time. Measured on Mellum2.1-12B-A2.5B-Thinking (llmman, `wip/` bench): the `write_file` call of a ~1,000-word tutorial cost 10,000–16,000 generated tokens, 5 to 8 minutes — the model drafts the text in its reasoning, then writes it again. The same request answered in the conversation took 1 min 45.
18+
19+ ## User's decisions (2026-10-17)
20+ - Both mechanisms: a `/save` command AND a `save_answer` tool.
21+ - Never overwrite an existing file: refuse and say so.
22+ - Directly on `main`.
23+
24+ ## What was built
25+ - `internal/saved` (new package): `Write` (creates parent dirs, adds the final newline, `O_EXCL` — `ErrExists` on an existing file), `ParseArg` (`<file> [n]`), `Pick`, `Command` (the one-line result), `Answers` (the model's text answers of a history — moved from `agent.modelAnswers`, now shared by /copy, /save and the tool).
26+ - `/save <file> [n]` in the terminal and the window (`internal/command`, `agent.go`, `tui/commands.go` + `tui/copy.go` `saveAnswer`). Not offered in ACP, like /copy. The model is not called.
27+ - `save_answer(path)` tool (`internal/tools/saveanswer.go`), behind `saveAnswerTool` (default `false`, so existing agents' tool lists do not change). It saves the text of the model message that made the call when it has text, else the last answer of the history at the start of the turn. Consent like `write_file` (yolo). Result quotes the first line saved.
28+ - `internal/engine/reply.go`: `Reply(ctx)` — Generate's WrapTool hook puts the model message that made the call in the tool's context (the existing `Conversation(ctx)` only holds the history at the start of the turn).
29+ - `save_answer` added to `config.BuiltinToolNames`.
30+ - Tests: `internal/saved` (3), `tools/saveanswer_test.go` (4), `engine` `TestToolsSeeTheReplyThatCalledThem`, `tui` `TestRunnerSave`; `command` and `tui` picker tests updated for the 9th command.
31+ - Docs EN+FR: `how-to/save-an-answer.md` (new), `reference/slash-commands.md`, `reference/configuration.md` (`saveAnswerTool`, reserved names), `reference/tools.md` (`save_answer`), both READMEs; `docs/diagrams/packages.drawio` (internal/saved node, edges from agent, tui, tools).
32+
33+ ## Known limit
34+ When the model writes a short sentence beside the call ("I'll save it."), that sentence is what gets saved. The tool description tells the model to call it alone to save a previous answer; to be measured with the mini-tutorial agent (`wip2/`).
35+ ## Measured with the mini-tutorial agent (wip2/, 2026-10-17)
36+ 8 cases × 2 runs, three configs (Mellum2.1 thinking 13/16, Mellum2.1 no thinking 11/16, Qwen3.5-9B 14/16). **"save it" after the answer: 6/6** — the file is the exact answer, in seconds. **"explain X and save it" in one request: 0/6**: Mellum2.1 thinking wrote the call as TEXT with wrong tags (`<tools>{…}</tools>`), Qwen3.5-9B never called the tool, Mellum2.1 without thinking called it FIRST ("no answer yet"), then once claimed it was saved, once saved its own 1,536-word deliberation + tutorial. Open: a way to save the answer being written (see wip2/README).
37+
38+tasks:
39+ - id: 1
40+ title: "internal/saved + /save command, with tests"
41+ state: closed
42+ priority: high
43+ author:
44+ name: k33g
45+ email: ph.charriere@gmail.com
46+ createdAt: 2026-10-17T16:00:00.000Z
47+ updatedAt: 2026-10-17T16:00:00.000Z
48+ closedAt: 2026-10-17T16:00:00.000Z
49+ - id: 2
50+ title: "save_answer tool + engine.Reply, with tests"
51+ state: closed
52+ priority: high
53+ author:
54+ name: k33g
55+ email: ph.charriere@gmail.com
56+ createdAt: 2026-10-17T16:00:00.000Z
57+ updatedAt: 2026-10-17T16:00:00.000Z
58+ closedAt: 2026-10-17T16:00:00.000Z
59+ - id: 3
60+ title: "Docs EN+FR, diagram"
61+ state: closed
62+ priority: medium
63+ author:
64+ name: k33g
65+ email: ph.charriere@gmail.com
66+ createdAt: 2026-10-17T16:00:00.000Z
67+ updatedAt: 2026-10-17T16:00:00.000Z
68+ closedAt: 2026-10-17T16:00:00.000Z
69+ - id: 4
70+ title: "Measure save_answer live with the mini-tutorial agent (wip2/)"
71+ state: closed
72+ priority: medium
73+ author:
74+ name: k33g
75+ email: ph.charriere@gmail.com
76+ createdAt: 2026-10-17T16:00:00.000Z
77+ updatedAt: 2026-10-17T18:00:00.000Z
78+ closedAt: 2026-10-17T18:00:00.000Z
79+comments: []
new file mode 100644
@@ -0,0 +1,79 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 93
3+title: Save an answer to a file without regenerating it (/save, save_answer)
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-17T16:00:00.000Z
9+updatedAt: 2026-10-17T16:00:00.000Z
10+labels:
11+ - kind::feature
12+ - builtin tool
13+epic: Builtin tools
14+body: |
15+ ## Why
16+ For an agent that answers questions with mini tutorials (user's idea, 2026-10-17: "comment faire mon 1er script nushell", "créer une lib en Go", "apprends-moi le pattern matching en Kotlin"…), the answer lives in the conversation and goes to a Markdown file ONLY when the user asks.
17+ Saving through `write_file` makes the model generate the whole text a second time. Measured on Mellum2.1-12B-A2.5B-Thinking (llmman, `wip/` bench): the `write_file` call of a ~1,000-word tutorial cost 10,000–16,000 generated tokens, 5 to 8 minutes — the model drafts the text in its reasoning, then writes it again. The same request answered in the conversation took 1 min 45.
18+
19+ ## User's decisions (2026-10-17)
20+ - Both mechanisms: a `/save` command AND a `save_answer` tool.
21+ - Never overwrite an existing file: refuse and say so.
22+ - Directly on `main`.
23+
24+ ## What was built
25+ - `internal/saved` (new package): `Write` (creates parent dirs, adds the final newline, `O_EXCL` — `ErrExists` on an existing file), `ParseArg` (`<file> [n]`), `Pick`, `Command` (the one-line result), `Answers` (the model's text answers of a history — moved from `agent.modelAnswers`, now shared by /copy, /save and the tool).
26+ - `/save <file> [n]` in the terminal and the window (`internal/command`, `agent.go`, `tui/commands.go` + `tui/copy.go` `saveAnswer`). Not offered in ACP, like /copy. The model is not called.
27+ - `save_answer(path)` tool (`internal/tools/saveanswer.go`), behind `saveAnswerTool` (default `false`, so existing agents' tool lists do not change). It saves the text of the model message that made the call when it has text, else the last answer of the history at the start of the turn. Consent like `write_file` (yolo). Result quotes the first line saved.
28+ - `internal/engine/reply.go`: `Reply(ctx)` — Generate's WrapTool hook puts the model message that made the call in the tool's context (the existing `Conversation(ctx)` only holds the history at the start of the turn).
29+ - `save_answer` added to `config.BuiltinToolNames`.
30+ - Tests: `internal/saved` (3), `tools/saveanswer_test.go` (4), `engine` `TestToolsSeeTheReplyThatCalledThem`, `tui` `TestRunnerSave`; `command` and `tui` picker tests updated for the 9th command.
31+ - Docs EN+FR: `how-to/save-an-answer.md` (new), `reference/slash-commands.md`, `reference/configuration.md` (`saveAnswerTool`, reserved names), `reference/tools.md` (`save_answer`), both READMEs; `docs/diagrams/packages.drawio` (internal/saved node, edges from agent, tui, tools).
32+
33+ ## Known limit
34+ When the model writes a short sentence beside the call ("I'll save it."), that sentence is what gets saved. The tool description tells the model to call it alone to save a previous answer; to be measured with the mini-tutorial agent (`wip2/`).
35+ ## Measured with the mini-tutorial agent (wip2/, 2026-10-17)
36+ 8 cases × 2 runs, three configs (Mellum2.1 thinking 13/16, Mellum2.1 no thinking 11/16, Qwen3.5-9B 14/16). **"save it" after the answer: 6/6** — the file is the exact answer, in seconds. **"explain X and save it" in one request: 0/6**: Mellum2.1 thinking wrote the call as TEXT with wrong tags (`<tools>{…}</tools>`), Qwen3.5-9B never called the tool, Mellum2.1 without thinking called it FIRST ("no answer yet"), then once claimed it was saved, once saved its own 1,536-word deliberation + tutorial. Open: a way to save the answer being written (see wip2/README).
37+
38+tasks:
39+ - id: 1
40+ title: "internal/saved + /save command, with tests"
41+ state: closed
42+ priority: high
43+ author:
44+ name: k33g
45+ email: ph.charriere@gmail.com
46+ createdAt: 2026-10-17T16:00:00.000Z
47+ updatedAt: 2026-10-17T16:00:00.000Z
48+ closedAt: 2026-10-17T16:00:00.000Z
49+ - id: 2
50+ title: "save_answer tool + engine.Reply, with tests"
51+ state: closed
52+ priority: high
53+ author:
54+ name: k33g
55+ email: ph.charriere@gmail.com
56+ createdAt: 2026-10-17T16:00:00.000Z
57+ updatedAt: 2026-10-17T16:00:00.000Z
58+ closedAt: 2026-10-17T16:00:00.000Z
59+ - id: 3
60+ title: "Docs EN+FR, diagram"
61+ state: closed
62+ priority: medium
63+ author:
64+ name: k33g
65+ email: ph.charriere@gmail.com
66+ createdAt: 2026-10-17T16:00:00.000Z
67+ updatedAt: 2026-10-17T16:00:00.000Z
68+ closedAt: 2026-10-17T16:00:00.000Z
69+ - id: 4
70+ title: "Measure save_answer live with the mini-tutorial agent (wip2/)"
71+ state: closed
72+ priority: medium
73+ author:
74+ name: k33g
75+ email: ph.charriere@gmail.com
76+ createdAt: 2026-10-17T16:00:00.000Z
77+ updatedAt: 2026-10-17T18:00:00.000Z
78+ closedAt: 2026-10-17T18:00:00.000Z
79+comments: []
modified .tickets/milestones.yaml +2 -0
@@ -24,3 +24,5 @@ milestones:
2424 - title: v0.6.0
2525 state: open
2626 dueOn: 2026-10-28
27+ - title: v0.7.0
28+ state: open
@@ -24,3 +24,5 @@ milestones:
24 - title: v0.6.024 - title: v0.6.0
25 state: open25 state: open
26 dueOn: 2026-10-2826 dueOn: 2026-10-28
27+ - title: v0.7.0
28+ state: open
added demos/programming-answers-2/README.en.md +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+# Tuto — an agent that answers with mini tutorials
2+
3+*[Version française](README.fr.md)*
4+
5+"How do I write my first Nushell script?", "how do I create a Go library?", "teach me pattern matching in Kotlin": Tuto answers with a **mini tutorial in the conversation**, and saves it to a Markdown file **only when asked**. Model: **Mellum2.1-12B-A2.5B-Thinking** (`hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M`) on **llmman**.
6+
7+## Run
8+
9+```bash
10+cd wip2
11+mm # the terminal; mm -tui for the window
12+```
13+
14+Then, in the conversation:
15+
16+```
17+> How do I write my first Nushell script?
18+> and in bash?
19+> save it in first-script.md
20+> /save first-nushell-script.md 2
21+```
22+
23+## How it is built
24+
25+- **The answer stays in the conversation.** In `wip/`, writing a tutorial to a file cost 10,000–16,000 tokens and 5 to 8 minutes, because the model drafts the text in its reasoning and then writes it again in the `write_file` call. Answering directly costs a single draft.
26+- **Saving regenerates nothing.** `save_answer` (tool, `saveAnswerTool: true`) receives only a file name, and mm writes the answer already given itself. `/save <file> [n]` does the same without the model. Neither overwrites an existing file.
27+- **Docs only when needed.** The prompt asks for one documentation page (`fetch_content`) for a recent or niche tool, a specific version, or when in doubt, and none for well-known topics.
28+- **A fixed format.** A title, a goal sentence, prerequisites, 3 to 6 steps with code and expected output, pitfalls, "to go further". Between 300 and 600 words, in the language of the question.
29+- **Nothing else.** No `bash`, file tools, `plan`, `ask_user` or skills: on this model, every unneeded tool has been a chance to wander (see `wip/`).
30+- **The rest comes from `wip/`.** Reasoning on, no adjustable effort, `max_tokens: 16384`.
31+
32+## Measured results (2026-10-17)
33+
34+Eight cases, 2 runs each, in the REPL driven through a pseudo-terminal, on the development machine's llmman. The checks are scripted from the session file: complete answer, no file created unasked, 200 to 1,000 words, language of the question, an expected keyword (`cargo new`, `go mod init`, `when`, `json.load`…), syntax of complete Go blocks (`gofmt -e`). For saves: the file exists, has more than 150 words, and is identical to the answer.
35+
36+| Case | Mellum2.1, reasoning on (`agent.yaml`) | Mellum2.1, reasoning off | Qwen3.5-9B |
37+|-----|-----|-----|-----|
38+| first Nushell script | 2/2 (reads 3 to 6 pages) | 1/2 (76 words, no docs) | 2/2, **without reading the docs** |
39+| Rust application | 1/2 (192 words) | 2/2 | 2/2 |
40+| Go library | 2/2 | 2/2 | 2/2 |
41+| Kotlin pattern matching | 2/2 | 1/2 (answer in English) | 2/2 |
42+| JSON in Python (question in English) | 2/2 | 2/2 | 2/2 |
43+| follow-up ("and in Go?") | 2/2 | 1/2 | 2/2 |
44+| **"explain X and save it"** | **0/2** | **0/2** | **0/2** |
45+| "save it" after the answer | 2/2 | 2/2 | 2/2 |
46+| **Total** | **13/16** | 11/16 | 14/16 |
47+| Average duration of a conversation | 97 s | 50 s | 112 s |
48+| Answer length | 192–344 words | 76–935 words | 348–606 words |
49+
50+What stands out:
51+
52+- **Saving afterwards works everywhere** (6/6): the file is the exact answer, in a few seconds.
53+- **"Explain and save" in one request fails everywhere** (0/6), in three different ways:
54+ - Mellum2.1 with reasoning writes the tutorial, then the call **as text**, with the wrong tags (`<tools>{"name": "save_answer", …}</tools>`): it is not a tool call;
55+ - Qwen3.5-9B never calls the tool;
56+ - Mellum2.1 without reasoning calls `save_answer` **before** writing anything and gets "no answer yet". In one run it then claims it was saved, which is false. In the other it calls the tool again after writing, in its reply, its whole deliberation ("I need to explain them… I can only make one tool call per turn…") followed by the tutorial: those 1,536 words are what got saved.
57+
58+ This is the trap already known with small models: writing a text, then calling a tool, in the same reply.
59+- **Reasoning keeps the format.** With it on, Mellum2.1 follows the short format, and it is the only one to read the docs for Nushell (2/2). With it off, it is twice as fast but uneven: 76 to 935 words, an answer in English to a question in French.
60+- **Qwen3.5-9B is the most regular** (14/16), but it is the slowest and **never read the Nushell docs**. Its answers reach the top of the requested range (348–606 words).
61+- **No complete Go block had a syntax error.** The other languages were not checked: no Rust, Kotlin or Nushell in the measurement sandbox. Nor was the correctness of the Nushell commands checked.
62+
63+Two runs per case: an order of magnitude, not a guarantee.
64+
65+## Known limits
66+
67+- **"Explain and save" does not work** (see above). Meanwhile: ask the question, then ask "save it in x.md", or type `/save x.md`.
68+- `save_answer` saves all the text written beside the call: a short sentence ("I'll save it."), or a deliberation (seen 1 time out of 6, reasoning off).
69+- The prompt partly contradicts itself: "call it alone" for a previous answer, but "in that same reply" for "explain and save". The model without reasoning deliberated at length over that contradiction.
70+- **Not measured:** the window (`-tui`), the editor (`-acp`), long conversations (compression).
new file mode 100644
@@ -0,0 +1,70 @@
1+# Tuto — an agent that answers with mini tutorials
2+
3+*[Version française](README.fr.md)*
4+
5+"How do I write my first Nushell script?", "how do I create a Go library?", "teach me pattern matching in Kotlin": Tuto answers with a **mini tutorial in the conversation**, and saves it to a Markdown file **only when asked**. Model: **Mellum2.1-12B-A2.5B-Thinking** (`hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M`) on **llmman**.
6+
7+## Run
8+
9+```bash
10+cd wip2
11+mm # the terminal; mm -tui for the window
12+```
13+
14+Then, in the conversation:
15+
16+```
17+> How do I write my first Nushell script?
18+> and in bash?
19+> save it in first-script.md
20+> /save first-nushell-script.md 2
21+```
22+
23+## How it is built
24+
25+- **The answer stays in the conversation.** In `wip/`, writing a tutorial to a file cost 10,000–16,000 tokens and 5 to 8 minutes, because the model drafts the text in its reasoning and then writes it again in the `write_file` call. Answering directly costs a single draft.
26+- **Saving regenerates nothing.** `save_answer` (tool, `saveAnswerTool: true`) receives only a file name, and mm writes the answer already given itself. `/save <file> [n]` does the same without the model. Neither overwrites an existing file.
27+- **Docs only when needed.** The prompt asks for one documentation page (`fetch_content`) for a recent or niche tool, a specific version, or when in doubt, and none for well-known topics.
28+- **A fixed format.** A title, a goal sentence, prerequisites, 3 to 6 steps with code and expected output, pitfalls, "to go further". Between 300 and 600 words, in the language of the question.
29+- **Nothing else.** No `bash`, file tools, `plan`, `ask_user` or skills: on this model, every unneeded tool has been a chance to wander (see `wip/`).
30+- **The rest comes from `wip/`.** Reasoning on, no adjustable effort, `max_tokens: 16384`.
31+
32+## Measured results (2026-10-17)
33+
34+Eight cases, 2 runs each, in the REPL driven through a pseudo-terminal, on the development machine's llmman. The checks are scripted from the session file: complete answer, no file created unasked, 200 to 1,000 words, language of the question, an expected keyword (`cargo new`, `go mod init`, `when`, `json.load`…), syntax of complete Go blocks (`gofmt -e`). For saves: the file exists, has more than 150 words, and is identical to the answer.
35+
36+| Case | Mellum2.1, reasoning on (`agent.yaml`) | Mellum2.1, reasoning off | Qwen3.5-9B |
37+|-----|-----|-----|-----|
38+| first Nushell script | 2/2 (reads 3 to 6 pages) | 1/2 (76 words, no docs) | 2/2, **without reading the docs** |
39+| Rust application | 1/2 (192 words) | 2/2 | 2/2 |
40+| Go library | 2/2 | 2/2 | 2/2 |
41+| Kotlin pattern matching | 2/2 | 1/2 (answer in English) | 2/2 |
42+| JSON in Python (question in English) | 2/2 | 2/2 | 2/2 |
43+| follow-up ("and in Go?") | 2/2 | 1/2 | 2/2 |
44+| **"explain X and save it"** | **0/2** | **0/2** | **0/2** |
45+| "save it" after the answer | 2/2 | 2/2 | 2/2 |
46+| **Total** | **13/16** | 11/16 | 14/16 |
47+| Average duration of a conversation | 97 s | 50 s | 112 s |
48+| Answer length | 192–344 words | 76–935 words | 348–606 words |
49+
50+What stands out:
51+
52+- **Saving afterwards works everywhere** (6/6): the file is the exact answer, in a few seconds.
53+- **"Explain and save" in one request fails everywhere** (0/6), in three different ways:
54+ - Mellum2.1 with reasoning writes the tutorial, then the call **as text**, with the wrong tags (`<tools>{"name": "save_answer", …}</tools>`): it is not a tool call;
55+ - Qwen3.5-9B never calls the tool;
56+ - Mellum2.1 without reasoning calls `save_answer` **before** writing anything and gets "no answer yet". In one run it then claims it was saved, which is false. In the other it calls the tool again after writing, in its reply, its whole deliberation ("I need to explain them… I can only make one tool call per turn…") followed by the tutorial: those 1,536 words are what got saved.
57+
58+ This is the trap already known with small models: writing a text, then calling a tool, in the same reply.
59+- **Reasoning keeps the format.** With it on, Mellum2.1 follows the short format, and it is the only one to read the docs for Nushell (2/2). With it off, it is twice as fast but uneven: 76 to 935 words, an answer in English to a question in French.
60+- **Qwen3.5-9B is the most regular** (14/16), but it is the slowest and **never read the Nushell docs**. Its answers reach the top of the requested range (348–606 words).
61+- **No complete Go block had a syntax error.** The other languages were not checked: no Rust, Kotlin or Nushell in the measurement sandbox. Nor was the correctness of the Nushell commands checked.
62+
63+Two runs per case: an order of magnitude, not a guarantee.
64+
65+## Known limits
66+
67+- **"Explain and save" does not work** (see above). Meanwhile: ask the question, then ask "save it in x.md", or type `/save x.md`.
68+- `save_answer` saves all the text written beside the call: a short sentence ("I'll save it."), or a deliberation (seen 1 time out of 6, reasoning off).
69+- The prompt partly contradicts itself: "call it alone" for a previous answer, but "in that same reply" for "explain and save". The model without reasoning deliberated at length over that contradiction.
70+- **Not measured:** the window (`-tui`), the editor (`-acp`), long conversations (compression).
added demos/programming-answers-2/README.fr.md +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+# Tuto — un agent qui répond par des mini-tutoriels
2+
3+*[English version](README.en.md)*
4+
5+« Comment faire mon premier script Nushell ? », « comment créer une librairie en Go ? », « apprends-moi le pattern matching en Kotlin » : Tuto répond par un **mini-tutoriel dans la conversation**, et le sauvegarde dans un fichier Markdown **seulement si on le demande**. Modèle : **Mellum2.1-12B-A2.5B-Thinking** (`hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M`) sur **llmman**.
6+
7+## Lancer
8+
9+```bash
10+cd wip2
11+mm # le terminal ; mm -tui pour la fenêtre
12+```
13+
14+Puis, dans la conversation :
15+
16+```
17+> Comment faire mon premier script Nushell ?
18+> et en bash ?
19+> sauvegarde-le dans premier-script.md
20+> /save premier-script-nushell.md 2
21+```
22+
23+## Comment il est construit
24+
25+- **La réponse reste dans la conversation.** Dans `wip/`, écrire un tutoriel dans un fichier coûtait 10 000 à 16 000 tokens et 5 à 8 minutes, parce que le modèle rédige le texte dans son raisonnement puis le réécrit dans l'appel `write_file`. Répondre directement coûte une seule rédaction.
26+- **Sauvegarder ne régénère rien.** `save_answer` (tool, `saveAnswerTool: true`) ne reçoit qu'un nom de fichier, et mm écrit lui-même la réponse déjà donnée. `/save <fichier> [n]` fait la même chose sans le modèle. Aucun des deux n'écrase un fichier existant.
27+- **La doc seulement si nécessaire.** Le prompt demande une page de documentation (`fetch_content`) pour un outil récent ou de niche, une version précise ou en cas de doute, et rien pour les sujets connus.
28+- **Un format fixe.** Un titre, une phrase d'objectif, les prérequis, 3 à 6 étapes avec du code et la sortie attendue, les pièges, « pour aller plus loin ». Entre 300 et 600 mots, dans la langue de la question.
29+- **Rien d'autre.** Pas de `bash`, d'outils de fichiers, de `plan`, d'`ask_user` ni de skills : sur ce modèle, chaque outil inutile a été une occasion de dériver (voir `wip/`).
30+- **Le reste vient de `wip/`.** Raisonnement activé, aucun effort réglable, `max_tokens: 16384`.
31+
32+## Résultats mesurés (2026-10-17)
33+
34+Huit cas, 2 runs chacun, dans le REPL piloté par un pseudo-terminal, sur le llmman de la machine de développement. Les contrôles sont scriptés depuis le fichier de session : réponse complète, aucun fichier créé sans demande, 200 à 1 000 mots, langue de la question, un mot-clé attendu (`cargo new`, `go mod init`, `when`, `json.load`…), syntaxe des blocs Go complets (`gofmt -e`). Pour les sauvegardes : le fichier existe, il fait plus de 150 mots, et il est identique à la réponse.
35+
36+| Cas | Mellum2.1, raisonnement activé (`agent.yaml`) | Mellum2.1, raisonnement désactivé | Qwen3.5-9B |
37+|-----|-----|-----|-----|
38+| premier script Nushell | 2/2 (lit 3 à 6 pages) | 1/2 (76 mots, sans doc) | 2/2, **sans lire la doc** |
39+| application Rust | 1/2 (192 mots) | 2/2 | 2/2 |
40+| librairie Go | 2/2 | 2/2 | 2/2 |
41+| pattern matching Kotlin | 2/2 | 1/2 (réponse en anglais) | 2/2 |
42+| JSON en Python (question en anglais) | 2/2 | 2/2 | 2/2 |
43+| question de suite (« et en Go ? ») | 2/2 | 1/2 | 2/2 |
44+| **« explique X et sauvegarde-le »** | **0/2** | **0/2** | **0/2** |
45+| « sauvegarde-le » après la réponse | 2/2 | 2/2 | 2/2 |
46+| **Total** | **13/16** | 11/16 | 14/16 |
47+| Durée moyenne d'une conversation | 97 s | 50 s | 112 s |
48+| Longueur des réponses | 192 à 344 mots | 76 à 935 mots | 348 à 606 mots |
49+
50+Ce qui ressort :
51+
52+- **Sauvegarder après coup marche partout** (6/6) : le fichier est la réponse exacte, en quelques secondes.
53+- **« Explique et sauvegarde » dans une même demande échoue partout** (0/6), de trois façons différentes :
54+ - Mellum2.1 avec raisonnement écrit le tutoriel, puis l'appel **sous forme de texte**, avec de mauvaises balises (`<tools>{"name": "save_answer", …}</tools>`) : ce n'est pas un appel d'outil ;
55+ - Qwen3.5-9B n'appelle jamais le tool ;
56+ - Mellum2.1 sans raisonnement appelle `save_answer` **avant** d'avoir écrit et reçoit « no answer yet ». Dans un run, il affirme ensuite que c'est sauvegardé, ce qui est faux. Dans l'autre, il rappelle le tool après avoir écrit, dans sa réponse, toute sa délibération (« I need to explain them… I can only make one tool call per turn… ») suivie du tutoriel : c'est cet ensemble de 1 536 mots qui est enregistré.
57+
58+ C'est le piège déjà connu avec les petits modèles : écrire un texte, puis appeler un outil dans la même réponse.
59+- **Le raisonnement tient le format.** Activé, Mellum2.1 suit le format court, et c'est le seul à lire la doc pour Nushell (2/2). Désactivé, il est deux fois plus rapide mais irrégulier : de 76 à 935 mots, une réponse en anglais à une question en français.
60+- **Qwen3.5-9B est le plus régulier** (14/16), mais il est le plus lent et **n'a jamais lu la doc de Nushell**. Ses réponses montent jusqu'au haut de la fourchette demandée (348 à 606 mots).
61+- **Aucun bloc Go complet n'avait d'erreur de syntaxe.** Les autres langages n'ont pas été vérifiés : pas de Rust, Kotlin ni Nushell dans le sandbox de mesure. L'exactitude des commandes Nushell n'a pas non plus été vérifiée.
62+
63+Deux runs par cas, c'est un ordre de grandeur, pas une garantie.
64+
65+## Limites connues
66+
67+- **« Explique et sauvegarde » ne fonctionne pas** (voir ci-dessus). En attendant : posez la question, puis demandez « sauvegarde-le dans x.md », ou tapez `/save x.md`.
68+- `save_answer` enregistre tout le texte écrit à côté de l'appel : une courte phrase (« Je le sauvegarde. »), ou une délibération (vu 1 fois sur 6, sans raisonnement).
69+- Le prompt se contredit en partie : « appelle-le seul » pour une réponse précédente, mais « dans la même réponse » pour « explique et sauvegarde ». Le modèle sans raisonnement a délibéré longuement sur cette contradiction.
70+- **Non mesuré :** la fenêtre (`-tui`), l'éditeur (`-acp`), les conversations longues (compression).
new file mode 100644
@@ -0,0 +1,70 @@
1+# Tuto — un agent qui répond par des mini-tutoriels
2+
3+*[English version](README.en.md)*
4+
5+« Comment faire mon premier script Nushell ? », « comment créer une librairie en Go ? », « apprends-moi le pattern matching en Kotlin » : Tuto répond par un **mini-tutoriel dans la conversation**, et le sauvegarde dans un fichier Markdown **seulement si on le demande**. Modèle : **Mellum2.1-12B-A2.5B-Thinking** (`hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M`) sur **llmman**.
6+
7+## Lancer
8+
9+```bash
10+cd wip2
11+mm # le terminal ; mm -tui pour la fenêtre
12+```
13+
14+Puis, dans la conversation :
15+
16+```
17+> Comment faire mon premier script Nushell ?
18+> et en bash ?
19+> sauvegarde-le dans premier-script.md
20+> /save premier-script-nushell.md 2
21+```
22+
23+## Comment il est construit
24+
25+- **La réponse reste dans la conversation.** Dans `wip/`, écrire un tutoriel dans un fichier coûtait 10 000 à 16 000 tokens et 5 à 8 minutes, parce que le modèle rédige le texte dans son raisonnement puis le réécrit dans l'appel `write_file`. Répondre directement coûte une seule rédaction.
26+- **Sauvegarder ne régénère rien.** `save_answer` (tool, `saveAnswerTool: true`) ne reçoit qu'un nom de fichier, et mm écrit lui-même la réponse déjà donnée. `/save <fichier> [n]` fait la même chose sans le modèle. Aucun des deux n'écrase un fichier existant.
27+- **La doc seulement si nécessaire.** Le prompt demande une page de documentation (`fetch_content`) pour un outil récent ou de niche, une version précise ou en cas de doute, et rien pour les sujets connus.
28+- **Un format fixe.** Un titre, une phrase d'objectif, les prérequis, 3 à 6 étapes avec du code et la sortie attendue, les pièges, « pour aller plus loin ». Entre 300 et 600 mots, dans la langue de la question.
29+- **Rien d'autre.** Pas de `bash`, d'outils de fichiers, de `plan`, d'`ask_user` ni de skills : sur ce modèle, chaque outil inutile a été une occasion de dériver (voir `wip/`).
30+- **Le reste vient de `wip/`.** Raisonnement activé, aucun effort réglable, `max_tokens: 16384`.
31+
32+## Résultats mesurés (2026-10-17)
33+
34+Huit cas, 2 runs chacun, dans le REPL piloté par un pseudo-terminal, sur le llmman de la machine de développement. Les contrôles sont scriptés depuis le fichier de session : réponse complète, aucun fichier créé sans demande, 200 à 1 000 mots, langue de la question, un mot-clé attendu (`cargo new`, `go mod init`, `when`, `json.load`…), syntaxe des blocs Go complets (`gofmt -e`). Pour les sauvegardes : le fichier existe, il fait plus de 150 mots, et il est identique à la réponse.
35+
36+| Cas | Mellum2.1, raisonnement activé (`agent.yaml`) | Mellum2.1, raisonnement désactivé | Qwen3.5-9B |
37+|-----|-----|-----|-----|
38+| premier script Nushell | 2/2 (lit 3 à 6 pages) | 1/2 (76 mots, sans doc) | 2/2, **sans lire la doc** |
39+| application Rust | 1/2 (192 mots) | 2/2 | 2/2 |
40+| librairie Go | 2/2 | 2/2 | 2/2 |
41+| pattern matching Kotlin | 2/2 | 1/2 (réponse en anglais) | 2/2 |
42+| JSON en Python (question en anglais) | 2/2 | 2/2 | 2/2 |
43+| question de suite (« et en Go ? ») | 2/2 | 1/2 | 2/2 |
44+| **« explique X et sauvegarde-le »** | **0/2** | **0/2** | **0/2** |
45+| « sauvegarde-le » après la réponse | 2/2 | 2/2 | 2/2 |
46+| **Total** | **13/16** | 11/16 | 14/16 |
47+| Durée moyenne d'une conversation | 97 s | 50 s | 112 s |
48+| Longueur des réponses | 192 à 344 mots | 76 à 935 mots | 348 à 606 mots |
49+
50+Ce qui ressort :
51+
52+- **Sauvegarder après coup marche partout** (6/6) : le fichier est la réponse exacte, en quelques secondes.
53+- **« Explique et sauvegarde » dans une même demande échoue partout** (0/6), de trois façons différentes :
54+ - Mellum2.1 avec raisonnement écrit le tutoriel, puis l'appel **sous forme de texte**, avec de mauvaises balises (`<tools>{"name": "save_answer", …}</tools>`) : ce n'est pas un appel d'outil ;
55+ - Qwen3.5-9B n'appelle jamais le tool ;
56+ - Mellum2.1 sans raisonnement appelle `save_answer` **avant** d'avoir écrit et reçoit « no answer yet ». Dans un run, il affirme ensuite que c'est sauvegardé, ce qui est faux. Dans l'autre, il rappelle le tool après avoir écrit, dans sa réponse, toute sa délibération (« I need to explain them… I can only make one tool call per turn… ») suivie du tutoriel : c'est cet ensemble de 1 536 mots qui est enregistré.
57+
58+ C'est le piège déjà connu avec les petits modèles : écrire un texte, puis appeler un outil dans la même réponse.
59+- **Le raisonnement tient le format.** Activé, Mellum2.1 suit le format court, et c'est le seul à lire la doc pour Nushell (2/2). Désactivé, il est deux fois plus rapide mais irrégulier : de 76 à 935 mots, une réponse en anglais à une question en français.
60+- **Qwen3.5-9B est le plus régulier** (14/16), mais il est le plus lent et **n'a jamais lu la doc de Nushell**. Ses réponses montent jusqu'au haut de la fourchette demandée (348 à 606 mots).
61+- **Aucun bloc Go complet n'avait d'erreur de syntaxe.** Les autres langages n'ont pas été vérifiés : pas de Rust, Kotlin ni Nushell dans le sandbox de mesure. L'exactitude des commandes Nushell n'a pas non plus été vérifiée.
62+
63+Deux runs par cas, c'est un ordre de grandeur, pas une garantie.
64+
65+## Limites connues
66+
67+- **« Explique et sauvegarde » ne fonctionne pas** (voir ci-dessus). En attendant : posez la question, puis demandez « sauvegarde-le dans x.md », ou tapez `/save x.md`.
68+- `save_answer` enregistre tout le texte écrit à côté de l'appel : une courte phrase (« Je le sauvegarde. »), ou une délibération (vu 1 fois sur 6, sans raisonnement).
69+- Le prompt se contredit en partie : « appelle-le seul » pour une réponse précédente, mais « dans la même réponse » pour « explique et sauvegarde ». Le modèle sans raisonnement a délibéré longuement sur cette contradiction.
70+- **Non mesuré :** la fenêtre (`-tui`), l'éditeur (`-acp`), les conversations longues (compression).
added demos/programming-answers-2/agent.yaml +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+# Tuto — an agent that answers with mini tutorials ("how do I write my first
2+# Nushell script", "create a Go library", "teach me pattern matching in
3+# Kotlin"…). The answer stays in the conversation; it goes to a Markdown file
4+# only when the user asks (save_answer, or the /save command).
5+# Mellum2.1 12B-A2.5B Thinking on llmman. See README.en.md / README.fr.md.
6+provider: llmman
7+model: hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M
8+baseUrl: http://127.0.0.1:17434/v1
9+fallback: http://host.docker.internal:17434/v1
10+
11+# mm's budget for compression; llmman served 131072 during the measurements.
12+contextWindow: 32768
13+
14+sampling:
15+ # Explanations, not code edits: a little more freedom than a coder's 0.2,
16+ # never 0.0 with reasoning on (thinking models loop when greedy).
17+ temperature: 0.3
18+ top_p: 0.9
19+ # Measured in wip/: 16384 holds the reasoning plus a long answer; 24576 made
20+ # things worse (the model reasons longer).
21+ max_tokens: 16384
22+ parallel_tool_calls: false
23+ # The template's only reasoning switch (no effort level — see wip/README).
24+ chat_template_kwargs:
25+ enable_thinking: true
26+
27+showThinking: true
28+firstTokenTimeout: 3m
29+watchdogTimeout: 2m
30+
31+# A mini tutorial is one answer, maybe after one fetch, maybe one save.
32+maxTurns: 8
33+maxOutput: 8000
34+previewLines: 10
35+
36+# ─── Tools: the documentation, and saving on request ─────────────────────────
37+yolo: true
38+bashTool: false
39+editTools: false
40+planTool: false
41+askUserTool: false
42+fetchContentTool: true
43+saveAnswerTool: true
44+skillsDir: no-skills
45+
46+agentsMd:
47+ enabled: false
48+
49+sessions:
50+ enabled: true
51+ dir: .mm/sessions
52+
53+context:
54+ enabled: true
55+ threshold: 60
56+ keepLastTurns: 2
57+ summaryMaxTokens: 1200
58+
59+acp:
60+ name: tuto
61+ title: 📘 Tuto
62+
63+system: |
64+ You are Tuto. You answer questions about programming languages, shells and
65+ developer tools with a MINI TUTORIAL, in the language of the question.
66+
67+ HOW TO ANSWER
68+ 1. Decide whether you need the documentation. Fetch ONE page with
69+ fetch_content only when the subject is a recent or niche tool (for
70+ example Nushell, a young framework), when the user names a version, or
71+ when you are not sure a command or an option exists. For well-known
72+ topics (Go, Rust, Python, Kotlin, Java basics) answer from what you know.
73+ 2. Write the mini tutorial directly in your reply, in Markdown:
74+ - a title (#)
75+ - one sentence: what the reader will be able to do
76+ - Prerequisites: a short list
77+ - 3 to 6 numbered steps, each with a code block and, when it prints
78+ something, the expected output
79+ - Pitfalls: 2 or 3 short points
80+ - To go further: one or two lines
81+ Between 300 and 600 words. Short snippets; a complete program only when
82+ the user asks for one.
83+ 3. A follow-up question ("and in Go?") refers to your previous answer:
84+ answer the same need for the new language or tool.
85+
86+ SAVING TO A FILE
87+ - Never save unless the user asks to save, write or export to a file.
88+ - When asked, call save_answer with the file name the user gave (or a short
89+ name made from the subject, ending in .md). Do NOT write the tutorial
90+ again: save_answer saves your previous answer by itself — call it alone,
91+ with no text in your reply.
92+ - When the request is "explain X and save it", write the tutorial in your
93+ reply AND call save_answer in that same reply.
94+ - If save_answer says the file exists, tell the user and ask for another
95+ name.
new file mode 100644
@@ -0,0 +1,95 @@
1+# Tuto — an agent that answers with mini tutorials ("how do I write my first
2+# Nushell script", "create a Go library", "teach me pattern matching in
3+# Kotlin"…). The answer stays in the conversation; it goes to a Markdown file
4+# only when the user asks (save_answer, or the /save command).
5+# Mellum2.1 12B-A2.5B Thinking on llmman. See README.en.md / README.fr.md.
6+provider: llmman
7+model: hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M
8+baseUrl: http://127.0.0.1:17434/v1
9+fallback: http://host.docker.internal:17434/v1
10+
11+# mm's budget for compression; llmman served 131072 during the measurements.
12+contextWindow: 32768
13+
14+sampling:
15+ # Explanations, not code edits: a little more freedom than a coder's 0.2,
16+ # never 0.0 with reasoning on (thinking models loop when greedy).
17+ temperature: 0.3
18+ top_p: 0.9
19+ # Measured in wip/: 16384 holds the reasoning plus a long answer; 24576 made
20+ # things worse (the model reasons longer).
21+ max_tokens: 16384
22+ parallel_tool_calls: false
23+ # The template's only reasoning switch (no effort level — see wip/README).
24+ chat_template_kwargs:
25+ enable_thinking: true
26+
27+showThinking: true
28+firstTokenTimeout: 3m
29+watchdogTimeout: 2m
30+
31+# A mini tutorial is one answer, maybe after one fetch, maybe one save.
32+maxTurns: 8
33+maxOutput: 8000
34+previewLines: 10
35+
36+# ─── Tools: the documentation, and saving on request ─────────────────────────
37+yolo: true
38+bashTool: false
39+editTools: false
40+planTool: false
41+askUserTool: false
42+fetchContentTool: true
43+saveAnswerTool: true
44+skillsDir: no-skills
45+
46+agentsMd:
47+ enabled: false
48+
49+sessions:
50+ enabled: true
51+ dir: .mm/sessions
52+
53+context:
54+ enabled: true
55+ threshold: 60
56+ keepLastTurns: 2
57+ summaryMaxTokens: 1200
58+
59+acp:
60+ name: tuto
61+ title: 📘 Tuto
62+
63+system: |
64+ You are Tuto. You answer questions about programming languages, shells and
65+ developer tools with a MINI TUTORIAL, in the language of the question.
66+
67+ HOW TO ANSWER
68+ 1. Decide whether you need the documentation. Fetch ONE page with
69+ fetch_content only when the subject is a recent or niche tool (for
70+ example Nushell, a young framework), when the user names a version, or
71+ when you are not sure a command or an option exists. For well-known
72+ topics (Go, Rust, Python, Kotlin, Java basics) answer from what you know.
73+ 2. Write the mini tutorial directly in your reply, in Markdown:
74+ - a title (#)
75+ - one sentence: what the reader will be able to do
76+ - Prerequisites: a short list
77+ - 3 to 6 numbered steps, each with a code block and, when it prints
78+ something, the expected output
79+ - Pitfalls: 2 or 3 short points
80+ - To go further: one or two lines
81+ Between 300 and 600 words. Short snippets; a complete program only when
82+ the user asks for one.
83+ 3. A follow-up question ("and in Go?") refers to your previous answer:
84+ answer the same need for the new language or tool.
85+
86+ SAVING TO A FILE
87+ - Never save unless the user asks to save, write or export to a file.
88+ - When asked, call save_answer with the file name the user gave (or a short
89+ name made from the subject, ending in .md). Do NOT write the tutorial
90+ again: save_answer saves your previous answer by itself — call it alone,
91+ with no text in your reply.
92+ - When the request is "explain X and save it", write the tutorial in your
93+ reply AND call save_answer in that same reply.
94+ - If save_answer says the file exists, tell the user and ask for another
95+ name.
added demos/programming-answers/AGENTS.md +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+# Little Coder workspace
2+
new file mode 100644
@@ -0,0 +1,2 @@
1+# Little Coder workspace
2+
added demos/programming-answers/README.en.md +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+# Little Coder on Mellum2.1 Thinking — settings and choices
2+
3+*[Version française](README.fr.md)*
4+
5+`agent.yaml` tunes mm for **Mellum2.1-12B-A2.5B-Thinking** (`hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M`) served by **llmman**. The case it was tuned on: "Écris-moi un tutoriel sur Nu shell" (write me a Nu shell tutorial), with or without a file name. The original configuration is kept as `agent.orig.yaml`.
6+
7+## Run
8+
9+```bash
10+cd wip
11+mm # the terminal
12+mm -p "Écris-moi un tutoriel sur Nu shell"
13+```
14+
15+## What the model says (llmman `/api/show`)
16+
17+- 12.1 billion parameters, mixture of experts (64, 8 active), 8.07 GB in Q4_K_M.
18+- Native window of 131,072 tokens; llmman served 131,072 during the measurements.
19+- Chat template: one reasoning switch, `enable_thinking`, **on by default**. No effort variable.
20+- Tool calls in Hermes JSON (`<tool_call>{…}</tool_call>`).
21+- No sampling settings in the GGUF.
22+
23+## The settings, and why
24+
25+| Setting | Before | Now | Measured reason |
26+|---------|--------|-----|-----------------|
27+| `planTool` | `true` | `false` | 1 run out of 5 sent `plan update` about 30 times without its `status` field, until `maxTurns` (9 min 20). Writing a document needs no plan. |
28+| `sampling.chat_template_kwargs.reasoning_effort` | `low` | removed | The template has no such variable: with the same seed, the answer does not change in the expected direction. See "Reasoning effort" below. |
29+| `sampling.chat_template_kwargs.enable_thinking` | `true` | `true` | With reasoning off, 4 runs out of 6 saved the "installation" page as is instead of writing a tutorial, and one run went on for 30 minutes. |
30+| `sampling.max_tokens` | `32768` | `16384` | 32,768 is the whole window mm budgets for. A ~1,000-word tutorial costs 10,000–16,000 tokens in the `write_file` call (almost all of it reasoning). 16,384 was hit once out of 6, and mm's retry recovered the call. |
31+| `maxTurns` | `40` | `20` | A tutorial takes 4–8 page reads plus one write. 20 turns stop a looping model in a few minutes rather than 9. |
32+| `system` | `You are Little Coder.` | steps (below) | With one line: ~2 KB tutorials called "complete", 2 runs out of 5 written from memory, with inventions ("extensions written in Lua", `brew install nu`). |
33+
34+The other settings (`temperature: 0.2`, `parallel_tool_calls: false`, `contextWindow`, the timeouts, `agentsMd`, `context`) are unchanged. No compression was triggered during the measurements: the prompt reached about 22,000 tokens at write time.
35+
36+## The system prompt
37+
38+Three steps for a tutorial or a guide:
39+
40+1. **Research first** with `fetch_content`: the official site, then 2 to 4 documentation pages. The model is told why: its knowledge is months old and it invents commands when it writes from memory.
41+2. **Write the whole tutorial in ONE `write_file`**, in the language of the request, with a list of sections: installation, first steps, one section per concept with at least two examples each followed by a sentence saying what it prints, an exercise with its solution, the links read. Target: 1,000 words or more.
42+3. **Answer in two or three sentences**: the file name and its sections.
43+
44+Two rules: do not read the file back after writing it, and never put the tutorial in the reply instead of in `write_file`.
45+
46+## Reasoning effort
47+
48+There is none for this model. Tested directly on llmman 0.1.478 (`/v1/chat/completions`, same question, same seed, 2 runs per variant):
49+
50+| Variant | Effect |
51+|---------|--------|
52+| `enable_thinking: false` | reasoning removed (about 90 tokens instead of 500–650) |
53+| `reasoning_effort: low` in `chat_template_kwargs` | ignored by the template |
54+| `reasoning_effort: low` at the top level | ignored (same figures as the baseline) |
55+| `reasoning_budget: 0` or `200` | ignored (same figures as the baseline) |
56+| "Reasoning: low" system prompt | no effect |
57+| `thinking_budget_tokens: 200` | **applied, but it cuts**: the reasoning stops dead, and the model keeps thinking in its answer ("Wait need to ensure correct syntax…") |
58+
59+In the agent, `thinking_budget_tokens: 4096` dropped success from 6/6 to 4/6. Cut mid-way, the model spilled its draft into the reply, and twice the tutorial was not written to a file. So only two choices remain, reasoning on or off, and it is on.
60+
61+## Measured results (2026-10-17)
62+
63+Two requests, "Écris-moi un tutoriel sur Nu shell" and "Écris un tutoriel complet sur Nu shell dans le fichier nushell-tutorial.md", in `mm -p`, on the development machine's llmman.
64+
65+| Configuration | Runs | Finishes | Reads the docs | In French | Tutorial size | Duration |
66+|---------------|------|----------|----------------|-----------|---------------|----------|
67+| `agent.orig.yaml` | 5 | 4 | 2 | — | ~2 KB | 1 min 45 to 9 min 20 |
68+| no `plan`, step prompt | 6 | 6 | 6 | 3 | 2–3 KB, 5 sections | 2 min 40 to 4 min 15 |
69+| **+ language and section list (`agent.yaml`)** | 6 | **6** | 6 | 5 | **500–950 words, 6–11 sections** | 8 to 17 min |
70+| the same, softer source rule ("build the tutorial on the commands shown in the pages you fetched, and never invent a package name, a command or an option") | 6 | 4 | 6 | 4 (of the 4 written) | 385–630 words, 7–11 sections | 5 min 30 to 17 min |
71+| the same, `max_tokens: 24576` | 6 | 4 | 6 | 5 (of the 5 written) | 347–781 words, 5–11 sections | 10 min 30 to over 21 min |
72+| the same, reasoning off | 6 | 2 | — | — | the raw "installation" page 4 times | 2 to 30 min |
73+| the same, `thinking_budget_tokens: 4096` | 6 | 4 | 6 | — | 330–880 words | 5 min 40 to 15 min |
74+
75+"Finishes" means: a tutorial is written to a file and the run ends without an error. Six runs per configuration on a single request: an order of magnitude, not a guarantee.
76+
77+## Known limits
78+
79+- **Slowness.** Most of the time is spent in the `write_file` call, 10,000–16,000 tokens at about 30 tokens/s, because the model drafts the whole tutorial in its reasoning before writing it.
80+- **The rule "every command must come from a page you fetched" costs time.** In a slow run the model deliberated at length over whether `let` and `echo` appeared in the pages it had read. A softer wording was measured and **not kept**: 4 runs out of 6 instead of 6, shorter tutorials, a barely lower average duration (10 min 30 against 12 min 25). Both failures hit `max_tokens` (16,384) in the final reply, without calling `write_file`.
81+- **`max_tokens: 16384` is a real limit.** Hit 1 time out of 6 with v2 (in a `write_file`, which mm retries) and 2 times out of 6 with the softer rule (in the final reply, which mm does not retry). Raising it to 24,576 does not help (measured, 6 runs): the model simply reasons longer. Two runs reached the 24,576 tokens: one never wrote its file, the other ran past 20 minutes. Result: 4 out of 6 and slower runs.
82+- **One tutorial out of six stayed in English** despite the language rule.
83+- **Not measured:** the window mode (`-tui`), the editor (`-acp`), and coding requests, even though they are Little Coder's first job. The settings above were measured on writing a tutorial only.
new file mode 100644
@@ -0,0 +1,83 @@
1+# Little Coder on Mellum2.1 Thinking — settings and choices
2+
3+*[Version française](README.fr.md)*
4+
5+`agent.yaml` tunes mm for **Mellum2.1-12B-A2.5B-Thinking** (`hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M`) served by **llmman**. The case it was tuned on: "Écris-moi un tutoriel sur Nu shell" (write me a Nu shell tutorial), with or without a file name. The original configuration is kept as `agent.orig.yaml`.
6+
7+## Run
8+
9+```bash
10+cd wip
11+mm # the terminal
12+mm -p "Écris-moi un tutoriel sur Nu shell"
13+```
14+
15+## What the model says (llmman `/api/show`)
16+
17+- 12.1 billion parameters, mixture of experts (64, 8 active), 8.07 GB in Q4_K_M.
18+- Native window of 131,072 tokens; llmman served 131,072 during the measurements.
19+- Chat template: one reasoning switch, `enable_thinking`, **on by default**. No effort variable.
20+- Tool calls in Hermes JSON (`<tool_call>{…}</tool_call>`).
21+- No sampling settings in the GGUF.
22+
23+## The settings, and why
24+
25+| Setting | Before | Now | Measured reason |
26+|---------|--------|-----|-----------------|
27+| `planTool` | `true` | `false` | 1 run out of 5 sent `plan update` about 30 times without its `status` field, until `maxTurns` (9 min 20). Writing a document needs no plan. |
28+| `sampling.chat_template_kwargs.reasoning_effort` | `low` | removed | The template has no such variable: with the same seed, the answer does not change in the expected direction. See "Reasoning effort" below. |
29+| `sampling.chat_template_kwargs.enable_thinking` | `true` | `true` | With reasoning off, 4 runs out of 6 saved the "installation" page as is instead of writing a tutorial, and one run went on for 30 minutes. |
30+| `sampling.max_tokens` | `32768` | `16384` | 32,768 is the whole window mm budgets for. A ~1,000-word tutorial costs 10,000–16,000 tokens in the `write_file` call (almost all of it reasoning). 16,384 was hit once out of 6, and mm's retry recovered the call. |
31+| `maxTurns` | `40` | `20` | A tutorial takes 4–8 page reads plus one write. 20 turns stop a looping model in a few minutes rather than 9. |
32+| `system` | `You are Little Coder.` | steps (below) | With one line: ~2 KB tutorials called "complete", 2 runs out of 5 written from memory, with inventions ("extensions written in Lua", `brew install nu`). |
33+
34+The other settings (`temperature: 0.2`, `parallel_tool_calls: false`, `contextWindow`, the timeouts, `agentsMd`, `context`) are unchanged. No compression was triggered during the measurements: the prompt reached about 22,000 tokens at write time.
35+
36+## The system prompt
37+
38+Three steps for a tutorial or a guide:
39+
40+1. **Research first** with `fetch_content`: the official site, then 2 to 4 documentation pages. The model is told why: its knowledge is months old and it invents commands when it writes from memory.
41+2. **Write the whole tutorial in ONE `write_file`**, in the language of the request, with a list of sections: installation, first steps, one section per concept with at least two examples each followed by a sentence saying what it prints, an exercise with its solution, the links read. Target: 1,000 words or more.
42+3. **Answer in two or three sentences**: the file name and its sections.
43+
44+Two rules: do not read the file back after writing it, and never put the tutorial in the reply instead of in `write_file`.
45+
46+## Reasoning effort
47+
48+There is none for this model. Tested directly on llmman 0.1.478 (`/v1/chat/completions`, same question, same seed, 2 runs per variant):
49+
50+| Variant | Effect |
51+|---------|--------|
52+| `enable_thinking: false` | reasoning removed (about 90 tokens instead of 500–650) |
53+| `reasoning_effort: low` in `chat_template_kwargs` | ignored by the template |
54+| `reasoning_effort: low` at the top level | ignored (same figures as the baseline) |
55+| `reasoning_budget: 0` or `200` | ignored (same figures as the baseline) |
56+| "Reasoning: low" system prompt | no effect |
57+| `thinking_budget_tokens: 200` | **applied, but it cuts**: the reasoning stops dead, and the model keeps thinking in its answer ("Wait need to ensure correct syntax…") |
58+
59+In the agent, `thinking_budget_tokens: 4096` dropped success from 6/6 to 4/6. Cut mid-way, the model spilled its draft into the reply, and twice the tutorial was not written to a file. So only two choices remain, reasoning on or off, and it is on.
60+
61+## Measured results (2026-10-17)
62+
63+Two requests, "Écris-moi un tutoriel sur Nu shell" and "Écris un tutoriel complet sur Nu shell dans le fichier nushell-tutorial.md", in `mm -p`, on the development machine's llmman.
64+
65+| Configuration | Runs | Finishes | Reads the docs | In French | Tutorial size | Duration |
66+|---------------|------|----------|----------------|-----------|---------------|----------|
67+| `agent.orig.yaml` | 5 | 4 | 2 | — | ~2 KB | 1 min 45 to 9 min 20 |
68+| no `plan`, step prompt | 6 | 6 | 6 | 3 | 2–3 KB, 5 sections | 2 min 40 to 4 min 15 |
69+| **+ language and section list (`agent.yaml`)** | 6 | **6** | 6 | 5 | **500–950 words, 6–11 sections** | 8 to 17 min |
70+| the same, softer source rule ("build the tutorial on the commands shown in the pages you fetched, and never invent a package name, a command or an option") | 6 | 4 | 6 | 4 (of the 4 written) | 385–630 words, 7–11 sections | 5 min 30 to 17 min |
71+| the same, `max_tokens: 24576` | 6 | 4 | 6 | 5 (of the 5 written) | 347–781 words, 5–11 sections | 10 min 30 to over 21 min |
72+| the same, reasoning off | 6 | 2 | — | — | the raw "installation" page 4 times | 2 to 30 min |
73+| the same, `thinking_budget_tokens: 4096` | 6 | 4 | 6 | — | 330–880 words | 5 min 40 to 15 min |
74+
75+"Finishes" means: a tutorial is written to a file and the run ends without an error. Six runs per configuration on a single request: an order of magnitude, not a guarantee.
76+
77+## Known limits
78+
79+- **Slowness.** Most of the time is spent in the `write_file` call, 10,000–16,000 tokens at about 30 tokens/s, because the model drafts the whole tutorial in its reasoning before writing it.
80+- **The rule "every command must come from a page you fetched" costs time.** In a slow run the model deliberated at length over whether `let` and `echo` appeared in the pages it had read. A softer wording was measured and **not kept**: 4 runs out of 6 instead of 6, shorter tutorials, a barely lower average duration (10 min 30 against 12 min 25). Both failures hit `max_tokens` (16,384) in the final reply, without calling `write_file`.
81+- **`max_tokens: 16384` is a real limit.** Hit 1 time out of 6 with v2 (in a `write_file`, which mm retries) and 2 times out of 6 with the softer rule (in the final reply, which mm does not retry). Raising it to 24,576 does not help (measured, 6 runs): the model simply reasons longer. Two runs reached the 24,576 tokens: one never wrote its file, the other ran past 20 minutes. Result: 4 out of 6 and slower runs.
82+- **One tutorial out of six stayed in English** despite the language rule.
83+- **Not measured:** the window mode (`-tui`), the editor (`-acp`), and coding requests, even though they are Little Coder's first job. The settings above were measured on writing a tutorial only.
added demos/programming-answers/README.fr.md +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+# Little Coder sur Mellum2.1 Thinking — réglages et choix
2+
3+*[English version](README.en.md)*
4+
5+`agent.yaml` règle mm pour **Mellum2.1-12B-A2.5B-Thinking** (`hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M`) servi par **llmman**. Le cas qui a servi à le régler : « Écris-moi un tutoriel sur Nu shell », avec ou sans nom de fichier. La configuration d'origine est conservée dans `agent.orig.yaml`.
6+
7+## Lancer
8+
9+```bash
10+cd wip
11+mm # le terminal
12+mm -p "Écris-moi un tutoriel sur Nu shell"
13+```
14+
15+## Ce que dit le modèle (llmman `/api/show`)
16+
17+- 12,1 milliards de paramètres, mixture d'experts (64, dont 8 actifs), 8,07 Go en Q4_K_M.
18+- Fenêtre native de 131 072 tokens ; llmman servait 131 072 pendant les mesures.
19+- Template de chat : un seul interrupteur de raisonnement, `enable_thinking`, **activé par défaut**. Aucune variable d'effort.
20+- Appels d'outils au format Hermes JSON (`<tool_call>{…}</tool_call>`).
21+- Aucun réglage d'échantillonnage dans le GGUF.
22+
23+## Les réglages, et pourquoi
24+
25+| Réglage | Avant | Maintenant | Raison mesurée |
26+|---------|-------|------------|----------------|
27+| `planTool` | `true` | `false` | 1 run sur 5 a envoyé environ 30 fois `plan update` sans le champ `status`, jusqu'à `maxTurns` (9 min 20). Écrire un document n'a pas besoin de plan. |
28+| `sampling.chat_template_kwargs.reasoning_effort` | `low` | retiré | Le template n'a pas cette variable : à seed égale, la réponse ne change pas dans le sens attendu. Voir « L'effort de raisonnement » ci-dessous. |
29+| `sampling.chat_template_kwargs.enable_thinking` | `true` | `true` | Sans raisonnement, 4 runs sur 6 ont enregistré la page « installation » telle quelle au lieu d'écrire un tutoriel, et un run a tourné 30 minutes. |
30+| `sampling.max_tokens` | `32768` | `16384` | 32 768, c'est toute la fenêtre budgétée par mm. Un tutoriel d'environ 1 000 mots coûte 10 000 à 16 000 tokens dans l'appel `write_file` (presque tout en raisonnement). 16 384 a été atteint 1 fois sur 6, et la relance de mm a récupéré l'appel. |
31+| `maxTurns` | `40` | `20` | Un tutoriel prend 4 à 8 lectures de pages plus une écriture. 20 tours arrêtent un modèle qui boucle en quelques minutes plutôt qu'en 9. |
32+| `system` | `You are Little Coder.` | des étapes (ci-dessous) | Avec une seule ligne : des tutoriels d'environ 2 Ko dits « complets », 2 runs sur 5 écrits de mémoire avec des inventions (« extensions écrites en Lua », `brew install nu`). |
33+
34+Les autres réglages (`temperature: 0.2`, `parallel_tool_calls: false`, `contextWindow`, les délais, `agentsMd`, `context`) n'ont pas changé. Aucune compression ne s'est déclenchée pendant les mesures : le prompt atteignait environ 22 000 tokens au moment d'écrire.
35+
36+## Le prompt système
37+
38+Trois étapes pour un tutoriel ou un guide :
39+
40+1. **Se documenter d'abord** avec `fetch_content` : le site officiel, puis 2 à 4 pages de documentation. La raison est donnée au modèle : ses connaissances datent de plusieurs mois et il invente des commandes quand il écrit de mémoire.
41+2. **Écrire tout le tutoriel en un seul `write_file`**, dans la langue de la demande, avec une liste de sections : installation, premiers pas, une section par concept avec au moins deux exemples suivis d'une phrase qui dit ce qu'ils affichent, un exercice avec sa solution, les liens lus. Objectif : 1 000 mots ou plus.
42+3. **Répondre en deux ou trois phrases** : le nom du fichier et ses sections.
43+
44+Deux règles : ne pas relire le fichier après l'avoir écrit, et ne jamais mettre le tutoriel dans la réponse au lieu de `write_file`.
45+
46+## L'effort de raisonnement
47+
48+Il n'existe pas pour ce modèle. Testé directement sur llmman 0.1.478 (`/v1/chat/completions`, même question, même seed, 2 runs par variante) :
49+
50+| Variante | Effet |
51+|----------|-------|
52+| `enable_thinking: false` | raisonnement supprimé (environ 90 tokens au lieu de 500 à 650) |
53+| `reasoning_effort: low` dans `chat_template_kwargs` | ignoré par le template |
54+| `reasoning_effort: low` au premier niveau | ignoré (mêmes chiffres que la référence) |
55+| `reasoning_budget: 0` ou `200` | ignoré (mêmes chiffres que la référence) |
56+| prompt système « Reasoning: low » | aucun effet |
57+| `thinking_budget_tokens: 200` | **appliqué, mais il coupe** : le raisonnement s'arrête net, et le modèle continue de réfléchir dans sa réponse (« Wait need to ensure correct syntax… ») |
58+
59+Dans l'agent, `thinking_budget_tokens: 4096` a fait tomber la réussite de 6/6 à 4/6. Coupé au milieu, le modèle a déversé son brouillon dans la réponse, et deux fois le tutoriel n'a pas été écrit dans un fichier. Il ne reste donc que deux choix, raisonnement activé ou désactivé, et c'est activé.
60+
61+## Résultats mesurés (2026-10-17)
62+
63+Deux demandes, « Écris-moi un tutoriel sur Nu shell » et « Écris un tutoriel complet sur Nu shell dans le fichier nushell-tutorial.md », en `mm -p`, sur le llmman de la machine de développement.
64+
65+| Configuration | Runs | Va au bout | Lit la doc | En français | Taille du tutoriel | Durée |
66+|---------------|------|------------|------------|-------------|--------------------|-------|
67+| `agent.orig.yaml` | 5 | 4 | 2 | — | ~2 Ko | 1 min 45 à 9 min 20 |
68+| sans `plan`, prompt en étapes | 6 | 6 | 6 | 3 | 2 à 3 Ko, 5 sections | 2 min 40 à 4 min 15 |
69+| **+ langue et liste de sections (`agent.yaml`)** | 6 | **6** | 6 | 5 | **500 à 950 mots, 6 à 11 sections** | 8 à 17 min |
70+| la même, règle de sources assouplie (« build the tutorial on the commands shown in the pages you fetched, and never invent a package name, a command or an option ») | 6 | 4 | 6 | 4 (sur 4 écrits) | 385 à 630 mots, 7 à 11 sections | 5 min 30 à 17 min |
71+| la même, `max_tokens: 24576` | 6 | 4 | 6 | 5 (sur 5 écrits) | 347 à 781 mots, 5 à 11 sections | 10 min 30 à plus de 21 min |
72+| la même, raisonnement désactivé | 6 | 2 | — | — | 4 fois la page « installation » brute | 2 à 30 min |
73+| la même, `thinking_budget_tokens: 4096` | 6 | 4 | 6 | — | 330 à 880 mots | 5 min 40 à 15 min |
74+
75+« Va au bout » signifie : un tutoriel est écrit dans un fichier et le run se termine sans erreur. Ce ne sont que six runs par configuration, sur une seule demande : c'est un ordre de grandeur, pas une garantie.
76+
77+## Limites connues
78+
79+- **La lenteur.** L'essentiel du temps est passé dans l'appel `write_file`, entre 10 000 et 16 000 tokens à environ 30 tokens/s, parce que le modèle rédige tout le tutoriel dans son raisonnement avant de l'écrire.
80+- **La règle « chaque commande doit venir d'une page lue » coûte du temps.** Dans un run lent, le modèle a longuement délibéré pour savoir si `let` et `echo` figuraient dans les pages lues. Une version assouplie a été mesurée et **n'a pas été retenue** : 4 runs sur 6 au lieu de 6, des tutoriels plus courts, une durée moyenne à peine plus faible (10 min 30 contre 12 min 25). Les deux échecs ont atteint `max_tokens` (16 384) dans la réponse finale, sans appeler `write_file`.
81+- **`max_tokens: 16384` est une vraie limite.** Atteint 1 fois sur 6 avec la v2 (dans un `write_file`, que mm relance) et 2 fois sur 6 avec la règle assouplie (dans la réponse finale, que mm ne relance pas). Monter à 24 576 n'aide pas (mesuré, 6 runs) : le modèle raisonne simplement plus longtemps. Deux runs ont atteint les 24 576 tokens : l'un n'a jamais écrit son fichier, l'autre a dépassé 20 minutes. Résultat : 4 sur 6 et des runs plus lents.
82+- **Un tutoriel sur six est resté en anglais** malgré la règle de langue.
83+- **Non mesuré :** le mode fenêtre (`-tui`), l'éditeur (`-acp`), et les demandes de code, pourtant le premier métier de « Little Coder ». Les réglages ci-dessus n'ont été mesurés que sur l'écriture d'un tutoriel.
new file mode 100644
@@ -0,0 +1,83 @@
1+# Little Coder sur Mellum2.1 Thinking — réglages et choix
2+
3+*[English version](README.en.md)*
4+
5+`agent.yaml` règle mm pour **Mellum2.1-12B-A2.5B-Thinking** (`hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M`) servi par **llmman**. Le cas qui a servi à le régler : « Écris-moi un tutoriel sur Nu shell », avec ou sans nom de fichier. La configuration d'origine est conservée dans `agent.orig.yaml`.
6+
7+## Lancer
8+
9+```bash
10+cd wip
11+mm # le terminal
12+mm -p "Écris-moi un tutoriel sur Nu shell"
13+```
14+
15+## Ce que dit le modèle (llmman `/api/show`)
16+
17+- 12,1 milliards de paramètres, mixture d'experts (64, dont 8 actifs), 8,07 Go en Q4_K_M.
18+- Fenêtre native de 131 072 tokens ; llmman servait 131 072 pendant les mesures.
19+- Template de chat : un seul interrupteur de raisonnement, `enable_thinking`, **activé par défaut**. Aucune variable d'effort.
20+- Appels d'outils au format Hermes JSON (`<tool_call>{…}</tool_call>`).
21+- Aucun réglage d'échantillonnage dans le GGUF.
22+
23+## Les réglages, et pourquoi
24+
25+| Réglage | Avant | Maintenant | Raison mesurée |
26+|---------|-------|------------|----------------|
27+| `planTool` | `true` | `false` | 1 run sur 5 a envoyé environ 30 fois `plan update` sans le champ `status`, jusqu'à `maxTurns` (9 min 20). Écrire un document n'a pas besoin de plan. |
28+| `sampling.chat_template_kwargs.reasoning_effort` | `low` | retiré | Le template n'a pas cette variable : à seed égale, la réponse ne change pas dans le sens attendu. Voir « L'effort de raisonnement » ci-dessous. |
29+| `sampling.chat_template_kwargs.enable_thinking` | `true` | `true` | Sans raisonnement, 4 runs sur 6 ont enregistré la page « installation » telle quelle au lieu d'écrire un tutoriel, et un run a tourné 30 minutes. |
30+| `sampling.max_tokens` | `32768` | `16384` | 32 768, c'est toute la fenêtre budgétée par mm. Un tutoriel d'environ 1 000 mots coûte 10 000 à 16 000 tokens dans l'appel `write_file` (presque tout en raisonnement). 16 384 a été atteint 1 fois sur 6, et la relance de mm a récupéré l'appel. |
31+| `maxTurns` | `40` | `20` | Un tutoriel prend 4 à 8 lectures de pages plus une écriture. 20 tours arrêtent un modèle qui boucle en quelques minutes plutôt qu'en 9. |
32+| `system` | `You are Little Coder.` | des étapes (ci-dessous) | Avec une seule ligne : des tutoriels d'environ 2 Ko dits « complets », 2 runs sur 5 écrits de mémoire avec des inventions (« extensions écrites en Lua », `brew install nu`). |
33+
34+Les autres réglages (`temperature: 0.2`, `parallel_tool_calls: false`, `contextWindow`, les délais, `agentsMd`, `context`) n'ont pas changé. Aucune compression ne s'est déclenchée pendant les mesures : le prompt atteignait environ 22 000 tokens au moment d'écrire.
35+
36+## Le prompt système
37+
38+Trois étapes pour un tutoriel ou un guide :
39+
40+1. **Se documenter d'abord** avec `fetch_content` : le site officiel, puis 2 à 4 pages de documentation. La raison est donnée au modèle : ses connaissances datent de plusieurs mois et il invente des commandes quand il écrit de mémoire.
41+2. **Écrire tout le tutoriel en un seul `write_file`**, dans la langue de la demande, avec une liste de sections : installation, premiers pas, une section par concept avec au moins deux exemples suivis d'une phrase qui dit ce qu'ils affichent, un exercice avec sa solution, les liens lus. Objectif : 1 000 mots ou plus.
42+3. **Répondre en deux ou trois phrases** : le nom du fichier et ses sections.
43+
44+Deux règles : ne pas relire le fichier après l'avoir écrit, et ne jamais mettre le tutoriel dans la réponse au lieu de `write_file`.
45+
46+## L'effort de raisonnement
47+
48+Il n'existe pas pour ce modèle. Testé directement sur llmman 0.1.478 (`/v1/chat/completions`, même question, même seed, 2 runs par variante) :
49+
50+| Variante | Effet |
51+|----------|-------|
52+| `enable_thinking: false` | raisonnement supprimé (environ 90 tokens au lieu de 500 à 650) |
53+| `reasoning_effort: low` dans `chat_template_kwargs` | ignoré par le template |
54+| `reasoning_effort: low` au premier niveau | ignoré (mêmes chiffres que la référence) |
55+| `reasoning_budget: 0` ou `200` | ignoré (mêmes chiffres que la référence) |
56+| prompt système « Reasoning: low » | aucun effet |
57+| `thinking_budget_tokens: 200` | **appliqué, mais il coupe** : le raisonnement s'arrête net, et le modèle continue de réfléchir dans sa réponse (« Wait need to ensure correct syntax… ») |
58+
59+Dans l'agent, `thinking_budget_tokens: 4096` a fait tomber la réussite de 6/6 à 4/6. Coupé au milieu, le modèle a déversé son brouillon dans la réponse, et deux fois le tutoriel n'a pas été écrit dans un fichier. Il ne reste donc que deux choix, raisonnement activé ou désactivé, et c'est activé.
60+
61+## Résultats mesurés (2026-10-17)
62+
63+Deux demandes, « Écris-moi un tutoriel sur Nu shell » et « Écris un tutoriel complet sur Nu shell dans le fichier nushell-tutorial.md », en `mm -p`, sur le llmman de la machine de développement.
64+
65+| Configuration | Runs | Va au bout | Lit la doc | En français | Taille du tutoriel | Durée |
66+|---------------|------|------------|------------|-------------|--------------------|-------|
67+| `agent.orig.yaml` | 5 | 4 | 2 | — | ~2 Ko | 1 min 45 à 9 min 20 |
68+| sans `plan`, prompt en étapes | 6 | 6 | 6 | 3 | 2 à 3 Ko, 5 sections | 2 min 40 à 4 min 15 |
69+| **+ langue et liste de sections (`agent.yaml`)** | 6 | **6** | 6 | 5 | **500 à 950 mots, 6 à 11 sections** | 8 à 17 min |
70+| la même, règle de sources assouplie (« build the tutorial on the commands shown in the pages you fetched, and never invent a package name, a command or an option ») | 6 | 4 | 6 | 4 (sur 4 écrits) | 385 à 630 mots, 7 à 11 sections | 5 min 30 à 17 min |
71+| la même, `max_tokens: 24576` | 6 | 4 | 6 | 5 (sur 5 écrits) | 347 à 781 mots, 5 à 11 sections | 10 min 30 à plus de 21 min |
72+| la même, raisonnement désactivé | 6 | 2 | — | — | 4 fois la page « installation » brute | 2 à 30 min |
73+| la même, `thinking_budget_tokens: 4096` | 6 | 4 | 6 | — | 330 à 880 mots | 5 min 40 à 15 min |
74+
75+« Va au bout » signifie : un tutoriel est écrit dans un fichier et le run se termine sans erreur. Ce ne sont que six runs par configuration, sur une seule demande : c'est un ordre de grandeur, pas une garantie.
76+
77+## Limites connues
78+
79+- **La lenteur.** L'essentiel du temps est passé dans l'appel `write_file`, entre 10 000 et 16 000 tokens à environ 30 tokens/s, parce que le modèle rédige tout le tutoriel dans son raisonnement avant de l'écrire.
80+- **La règle « chaque commande doit venir d'une page lue » coûte du temps.** Dans un run lent, le modèle a longuement délibéré pour savoir si `let` et `echo` figuraient dans les pages lues. Une version assouplie a été mesurée et **n'a pas été retenue** : 4 runs sur 6 au lieu de 6, des tutoriels plus courts, une durée moyenne à peine plus faible (10 min 30 contre 12 min 25). Les deux échecs ont atteint `max_tokens` (16 384) dans la réponse finale, sans appeler `write_file`.
81+- **`max_tokens: 16384` est une vraie limite.** Atteint 1 fois sur 6 avec la v2 (dans un `write_file`, que mm relance) et 2 fois sur 6 avec la règle assouplie (dans la réponse finale, que mm ne relance pas). Monter à 24 576 n'aide pas (mesuré, 6 runs) : le modèle raisonne simplement plus longtemps. Deux runs ont atteint les 24 576 tokens : l'un n'a jamais écrit son fichier, l'autre a dépassé 20 minutes. Résultat : 4 sur 6 et des runs plus lents.
82+- **Un tutoriel sur six est resté en anglais** malgré la règle de langue.
83+- **Non mesuré :** le mode fenêtre (`-tui`), l'éditeur (`-acp`), et les demandes de code, pourtant le premier métier de « Little Coder ». Les réglages ci-dessus n'ont été mesurés que sur l'écriture d'un tutoriel.
added demos/programming-answers/agent.orig.yaml +105 -0
new file mode 100644
@@ -0,0 +1,105 @@
1+provider: llmman
2+model: hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M
3+
4+baseUrl: http://127.0.0.1:17434/v1
5+fallback: http://host.docker.internal:17434/v1
6+
7+# The window mm budgets for, whatever llmman serves: compression starts at
8+# `context.threshold` % of it. 32k matches LLMMAN_CONTEXT_LENGTH above; if
9+# llmman serves more, mm simply compresses earlier — never set it higher than
10+# what llmman serves.
11+contextWindow: 32768
12+
13+# ─── Generation ──────────────────────────────────────────────────────────────
14+
15+sampling:
16+ # Code wants precision. Not 0.0: greedy decoding is what sends a small
17+ # model into repetition loops on long tool calls.
18+ temperature: 0.2
19+ top_p: 0.9
20+
21+ # ONE reply = one tool call. A whole file in one write_file (main.go,
22+ # a template) is 1–3k tokens; 8192 leaves margin without letting a runaway
23+ # reply fill the window.
24+ max_tokens: 32768
25+
26+ # One file, one command at a time.
27+ parallel_tool_calls: false
28+
29+ # chat_template_kwargs:
30+ # enable_thinking: false
31+
32+ chat_template_kwargs:
33+ enable_thinking: true
34+ reasoning_effort: low
35+
36+showThinking: true
37+
38+# ─── Timeouts ────────────────────────────────────────────────────────────────
39+
40+# Loading ~7 GB on the first question, then prompts of 10–20k tokens.
41+firstTokenTimeout: 3m
42+
43+# A first `go build` / `go test` downloads nothing (standard library only)
44+# but compiles net/http: under a minute on an M-series Air.
45+bashTimeout: 2m
46+
47+# Counts while a bash command runs: at least bashTimeout.
48+watchdogTimeout: 2m
49+
50+# ─── Agent loop ──────────────────────────────────────────────────────────────
51+
52+# A sample app is ~6 files + build + test + run + check + fixes.
53+maxTurns: 40
54+
55+# go vet / go test failures are short; 8000 characters keep a whole report
56+# without filling a 32k window with logs.
57+maxOutput: 8000
58+
59+previewLines: 15
60+displayCommands: true
61+
62+# ─── Tools ───────────────────────────────────────────────────────────────────
63+
64+# true: no question before each command or file write — generating an app is
65+# a dozen of them. Set false (or AGENT_YOLO=false) to approve each one.
66+yolo: true
67+
68+bashTool: true
69+editTools: true
70+
71+planTool: true
72+fetchContentTool: true
73+
74+#skillsDir: skills
75+
76+# The rules of this workspace (Go version, stack, layout), appended to the
77+# system prompt. Kept short: it is sent with every request.
78+agentsMd:
79+ enabled: true
80+ path: AGENTS.md
81+ # Guard rail: a file grown past ~1,000 tokens is skipped, not sent.
82+ maxChars: 4000
83+
84+sessions:
85+ enabled: true
86+ dir: .mm/sessions
87+
88+context:
89+ enabled: true
90+ # 60 % of 32k ≈ 19.6k tokens: compress before the prompt gets long enough
91+ # for the model to lose track of the files it wrote.
92+ threshold: 60
93+ maxMessages: 60
94+ keepLastTurns: 2
95+ summaryMaxTokens: 1200
96+ showStats: true
97+
98+# ─── Identity ────────────────────────────────────────────────────────────────
99+acp:
100+ name: little-coder
101+ title: 🤓 Little Coder
102+
103+system: |
104+ You are Little Coder.
105+
new file mode 100644
@@ -0,0 +1,105 @@
1+provider: llmman
2+model: hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M
3+
4+baseUrl: http://127.0.0.1:17434/v1
5+fallback: http://host.docker.internal:17434/v1
6+
7+# The window mm budgets for, whatever llmman serves: compression starts at
8+# `context.threshold` % of it. 32k matches LLMMAN_CONTEXT_LENGTH above; if
9+# llmman serves more, mm simply compresses earlier — never set it higher than
10+# what llmman serves.
11+contextWindow: 32768
12+
13+# ─── Generation ──────────────────────────────────────────────────────────────
14+
15+sampling:
16+ # Code wants precision. Not 0.0: greedy decoding is what sends a small
17+ # model into repetition loops on long tool calls.
18+ temperature: 0.2
19+ top_p: 0.9
20+
21+ # ONE reply = one tool call. A whole file in one write_file (main.go,
22+ # a template) is 1–3k tokens; 8192 leaves margin without letting a runaway
23+ # reply fill the window.
24+ max_tokens: 32768
25+
26+ # One file, one command at a time.
27+ parallel_tool_calls: false
28+
29+ # chat_template_kwargs:
30+ # enable_thinking: false
31+
32+ chat_template_kwargs:
33+ enable_thinking: true
34+ reasoning_effort: low
35+
36+showThinking: true
37+
38+# ─── Timeouts ────────────────────────────────────────────────────────────────
39+
40+# Loading ~7 GB on the first question, then prompts of 10–20k tokens.
41+firstTokenTimeout: 3m
42+
43+# A first `go build` / `go test` downloads nothing (standard library only)
44+# but compiles net/http: under a minute on an M-series Air.
45+bashTimeout: 2m
46+
47+# Counts while a bash command runs: at least bashTimeout.
48+watchdogTimeout: 2m
49+
50+# ─── Agent loop ──────────────────────────────────────────────────────────────
51+
52+# A sample app is ~6 files + build + test + run + check + fixes.
53+maxTurns: 40
54+
55+# go vet / go test failures are short; 8000 characters keep a whole report
56+# without filling a 32k window with logs.
57+maxOutput: 8000
58+
59+previewLines: 15
60+displayCommands: true
61+
62+# ─── Tools ───────────────────────────────────────────────────────────────────
63+
64+# true: no question before each command or file write — generating an app is
65+# a dozen of them. Set false (or AGENT_YOLO=false) to approve each one.
66+yolo: true
67+
68+bashTool: true
69+editTools: true
70+
71+planTool: true
72+fetchContentTool: true
73+
74+#skillsDir: skills
75+
76+# The rules of this workspace (Go version, stack, layout), appended to the
77+# system prompt. Kept short: it is sent with every request.
78+agentsMd:
79+ enabled: true
80+ path: AGENTS.md
81+ # Guard rail: a file grown past ~1,000 tokens is skipped, not sent.
82+ maxChars: 4000
83+
84+sessions:
85+ enabled: true
86+ dir: .mm/sessions
87+
88+context:
89+ enabled: true
90+ # 60 % of 32k ≈ 19.6k tokens: compress before the prompt gets long enough
91+ # for the model to lose track of the files it wrote.
92+ threshold: 60
93+ maxMessages: 60
94+ keepLastTurns: 2
95+ summaryMaxTokens: 1200
96+ showStats: true
97+
98+# ─── Identity ────────────────────────────────────────────────────────────────
99+acp:
100+ name: little-coder
101+ title: 🤓 Little Coder
102+
103+system: |
104+ You are Little Coder.
105+
added demos/programming-answers/agent.yaml +142 -0
new file mode 100644
@@ -0,0 +1,142 @@
1+provider: llmman
2+model: hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M
3+
4+baseUrl: http://127.0.0.1:17434/v1
5+fallback: http://host.docker.internal:17434/v1
6+
7+# The window mm budgets for, whatever llmman serves: compression starts at
8+# `context.threshold` % of it. 32k matches LLMMAN_CONTEXT_LENGTH above; if
9+# llmman serves more, mm simply compresses earlier — never set it higher than
10+# what llmman serves.
11+contextWindow: 32768
12+
13+# ─── Generation ──────────────────────────────────────────────────────────────
14+
15+sampling:
16+ # Code wants precision. Not 0.0: greedy decoding is what sends a small
17+ # model into repetition loops on long tool calls.
18+ temperature: 0.2
19+ top_p: 0.9
20+
21+ # ONE reply = one tool call, reasoning included. Measured 2026-10-17: the
22+ # write_file of a ~1k-word tutorial costs 10–16k tokens, almost all of it
23+ # reasoning (the model drafts the whole text in its thinking first); 16384
24+ # was hit once out of six and mm's retry recovered. 24576 measured worse
25+ # (4/6): the model just reasons longer, twice up to the new limit.
26+ max_tokens: 16384
27+
28+ # One file, one command at a time.
29+ parallel_tool_calls: false
30+
31+ # chat_template_kwargs:
32+ # enable_thinking: false
33+
34+ # Mellum2.1's template has ONE switch, enable_thinking — no effort level
35+ # (reasoning_effort is ignored, measured). Off: the agent saved a fetched
36+ # page instead of writing the tutorial (4/6) and once ran 30 min. Capping
37+ # it with thinking_budget_tokens: 4096 cut the reasoning mid-way and the
38+ # draft spilled into the reply, the tutorial missing its file (2/6).
39+ chat_template_kwargs:
40+ enable_thinking: true
41+
42+showThinking: true
43+
44+# ─── Timeouts ────────────────────────────────────────────────────────────────
45+
46+# Loading ~7 GB on the first question, then prompts of 10–20k tokens.
47+firstTokenTimeout: 3m
48+
49+# A first `go build` / `go test` downloads nothing (standard library only)
50+# but compiles net/http: under a minute on an M-series Air.
51+bashTimeout: 2m
52+
53+# Counts while a bash command runs: at least bashTimeout.
54+watchdogTimeout: 2m
55+
56+# ─── Agent loop ──────────────────────────────────────────────────────────────
57+
58+# A tutorial takes 4–8 fetches + 1 write. 20 also stops a looping model
59+# in minutes instead of the 9 minutes 40 turns took (plan update loop).
60+maxTurns: 20
61+
62+# go vet / go test failures are short; 8000 characters keep a whole report
63+# without filling a 32k window with logs.
64+maxOutput: 8000
65+
66+previewLines: 15
67+displayCommands: true
68+
69+# ─── Tools ───────────────────────────────────────────────────────────────────
70+
71+# true: no question before each command or file write — generating an app is
72+# a dozen of them. Set false (or AGENT_YOLO=false) to approve each one.
73+yolo: true
74+
75+bashTool: true
76+editTools: true
77+
78+# Off: Mellum2.1 looped on `plan update` without its `status` field until
79+# maxTurns (1 run out of 5); writing a document needs no plan.
80+planTool: false
81+fetchContentTool: true
82+
83+#skillsDir: skills
84+
85+# The rules of this workspace (Go version, stack, layout), appended to the
86+# system prompt. Kept short: it is sent with every request.
87+agentsMd:
88+ enabled: true
89+ path: AGENTS.md
90+ # Guard rail: a file grown past ~1,000 tokens is skipped, not sent.
91+ maxChars: 4000
92+
93+sessions:
94+ enabled: true
95+ dir: .mm/sessions
96+
97+context:
98+ enabled: true
99+ # 60 % of 32k ≈ 19.6k tokens: compress before the prompt gets long enough
100+ # for the model to lose track of the files it wrote.
101+ threshold: 60
102+ maxMessages: 60
103+ keepLastTurns: 2
104+ summaryMaxTokens: 1200
105+ showStats: true
106+
107+# ─── Identity ────────────────────────────────────────────────────────────────
108+acp:
109+ name: little-coder
110+ title: 🤓 Little Coder
111+
112+system: |
113+ You are Little Coder, a coding and documentation assistant. Answer in the
114+ user's language.
115+
116+ WRITING A TUTORIAL OR A GUIDE
117+ 1. Research first, with fetch_content: the project's official site, then
118+ 2 to 4 pages of its documentation (getting started, the main concepts).
119+ Your knowledge is months old and you invent commands when you write
120+ from memory; every command in the tutorial must come from a page you
121+ fetched.
122+ 2. Write the whole tutorial in ONE write_file call (a .md file named after
123+ the subject when the user gives no name), in the language of the
124+ user's request: a request in French gets a tutorial in French.
125+ A complete tutorial has these sections:
126+ - Installation (the commands from the fetched page)
127+ - First steps
128+ - one section per core concept found in the pages (for a shell:
129+ structured data and tables, pipelines, filtering and sorting,
130+ opening JSON/CSV files, variables, custom commands, configuration),
131+ each with at least two examples and, after each example, one
132+ sentence saying what it prints
133+ - a practical exercise, with its solution
134+ - the links you fetched
135+ Aim for 1,000 words or more.
136+ 3. Answer in two or three sentences: the file name and its sections.
137+ Do not paste the tutorial again.
138+
139+ RULES
140+ - Do not read a file back after writing it: write_file reports success.
141+ - Never put the tutorial in your reply instead of in write_file.
142+
new file mode 100644
@@ -0,0 +1,142 @@
1+provider: llmman
2+model: hf.co/JetBrains/Mellum2.1-12B-A2.5B-Thinking-GGUF:Q4_K_M
3+
4+baseUrl: http://127.0.0.1:17434/v1
5+fallback: http://host.docker.internal:17434/v1
6+
7+# The window mm budgets for, whatever llmman serves: compression starts at
8+# `context.threshold` % of it. 32k matches LLMMAN_CONTEXT_LENGTH above; if
9+# llmman serves more, mm simply compresses earlier — never set it higher than
10+# what llmman serves.
11+contextWindow: 32768
12+
13+# ─── Generation ──────────────────────────────────────────────────────────────
14+
15+sampling:
16+ # Code wants precision. Not 0.0: greedy decoding is what sends a small
17+ # model into repetition loops on long tool calls.
18+ temperature: 0.2
19+ top_p: 0.9
20+
21+ # ONE reply = one tool call, reasoning included. Measured 2026-10-17: the
22+ # write_file of a ~1k-word tutorial costs 10–16k tokens, almost all of it
23+ # reasoning (the model drafts the whole text in its thinking first); 16384
24+ # was hit once out of six and mm's retry recovered. 24576 measured worse
25+ # (4/6): the model just reasons longer, twice up to the new limit.
26+ max_tokens: 16384
27+
28+ # One file, one command at a time.
29+ parallel_tool_calls: false
30+
31+ # chat_template_kwargs:
32+ # enable_thinking: false
33+
34+ # Mellum2.1's template has ONE switch, enable_thinking — no effort level
35+ # (reasoning_effort is ignored, measured). Off: the agent saved a fetched
36+ # page instead of writing the tutorial (4/6) and once ran 30 min. Capping
37+ # it with thinking_budget_tokens: 4096 cut the reasoning mid-way and the
38+ # draft spilled into the reply, the tutorial missing its file (2/6).
39+ chat_template_kwargs:
40+ enable_thinking: true
41+
42+showThinking: true
43+
44+# ─── Timeouts ────────────────────────────────────────────────────────────────
45+
46+# Loading ~7 GB on the first question, then prompts of 10–20k tokens.
47+firstTokenTimeout: 3m
48+
49+# A first `go build` / `go test` downloads nothing (standard library only)
50+# but compiles net/http: under a minute on an M-series Air.
51+bashTimeout: 2m
52+
53+# Counts while a bash command runs: at least bashTimeout.
54+watchdogTimeout: 2m
55+
56+# ─── Agent loop ──────────────────────────────────────────────────────────────
57+
58+# A tutorial takes 4–8 fetches + 1 write. 20 also stops a looping model
59+# in minutes instead of the 9 minutes 40 turns took (plan update loop).
60+maxTurns: 20
61+
62+# go vet / go test failures are short; 8000 characters keep a whole report
63+# without filling a 32k window with logs.
64+maxOutput: 8000
65+
66+previewLines: 15
67+displayCommands: true
68+
69+# ─── Tools ───────────────────────────────────────────────────────────────────
70+
71+# true: no question before each command or file write — generating an app is
72+# a dozen of them. Set false (or AGENT_YOLO=false) to approve each one.
73+yolo: true
74+
75+bashTool: true
76+editTools: true
77+
78+# Off: Mellum2.1 looped on `plan update` without its `status` field until
79+# maxTurns (1 run out of 5); writing a document needs no plan.
80+planTool: false
81+fetchContentTool: true
82+
83+#skillsDir: skills
84+
85+# The rules of this workspace (Go version, stack, layout), appended to the
86+# system prompt. Kept short: it is sent with every request.
87+agentsMd:
88+ enabled: true
89+ path: AGENTS.md
90+ # Guard rail: a file grown past ~1,000 tokens is skipped, not sent.
91+ maxChars: 4000
92+
93+sessions:
94+ enabled: true
95+ dir: .mm/sessions
96+
97+context:
98+ enabled: true
99+ # 60 % of 32k ≈ 19.6k tokens: compress before the prompt gets long enough
100+ # for the model to lose track of the files it wrote.
101+ threshold: 60
102+ maxMessages: 60
103+ keepLastTurns: 2
104+ summaryMaxTokens: 1200
105+ showStats: true
106+
107+# ─── Identity ────────────────────────────────────────────────────────────────
108+acp:
109+ name: little-coder
110+ title: 🤓 Little Coder
111+
112+system: |
113+ You are Little Coder, a coding and documentation assistant. Answer in the
114+ user's language.
115+
116+ WRITING A TUTORIAL OR A GUIDE
117+ 1. Research first, with fetch_content: the project's official site, then
118+ 2 to 4 pages of its documentation (getting started, the main concepts).
119+ Your knowledge is months old and you invent commands when you write
120+ from memory; every command in the tutorial must come from a page you
121+ fetched.
122+ 2. Write the whole tutorial in ONE write_file call (a .md file named after
123+ the subject when the user gives no name), in the language of the
124+ user's request: a request in French gets a tutorial in French.
125+ A complete tutorial has these sections:
126+ - Installation (the commands from the fetched page)
127+ - First steps
128+ - one section per core concept found in the pages (for a shell:
129+ structured data and tables, pipelines, filtering and sorting,
130+ opening JSON/CSV files, variables, custom commands, configuration),
131+ each with at least two examples and, after each example, one
132+ sentence saying what it prints
133+ - a practical exercise, with its solution
134+ - the links you fetched
135+ Aim for 1,000 words or more.
136+ 3. Answer in two or three sentences: the file name and its sections.
137+ Do not paste the tutorial again.
138+
139+ RULES
140+ - Do not read a file back after writing it: write_file reports success.
141+ - Never put the tutorial in your reply instead of in write_file.
142+
modified docs/diagrams/packages.drawio +1 -1
@@ -1,3 +1,3 @@
11 <mxfile host="app.diagrams.net" modified="2026-10-06T00: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="1060"><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, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;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="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
22 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3- <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="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><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><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
3+ <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="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><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><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
@@ -1,3 +1,3 @@
1 <mxfile host="app.diagrams.net" modified="2026-10-06T00: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="1060"><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, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;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="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>1 <mxfile host="app.diagrams.net" modified="2026-10-06T00: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="1060"><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, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;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="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3- <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="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><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><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
3+ <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="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><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><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/README.md +2 -1
@@ -26,6 +26,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
2626 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor
2727 - [Add a skill](how-to/add-a-skill.md)
2828 - [Declare a custom tool](how-to/declare-a-custom-tool.md)
29+- [Save an answer to a file](how-to/save-an-answer.md)
2930 - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after
3031 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)
3132 - [Start a new session without restarting](how-to/start-a-new-session.md)
@@ -49,7 +50,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
4950 - [Command line](reference/cli.md) — flags, environment variables, start-up banner
5051 - [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, keyboard shortcuts, window keys
5152 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
52-- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, declared tools
53+- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, `save_answer`, declared tools
5354 - [Skill file format](reference/skill-format.md)
5455 - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors
5556 - [Agent Client Protocol surface](reference/acp.md)
@@ -26,6 +26,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
26 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor26 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor
27 - [Add a skill](how-to/add-a-skill.md)27 - [Add a skill](how-to/add-a-skill.md)
28 - [Declare a custom tool](how-to/declare-a-custom-tool.md)28 - [Declare a custom tool](how-to/declare-a-custom-tool.md)
29+- [Save an answer to a file](how-to/save-an-answer.md)
29 - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after30 - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after
30 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)31 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)
31 - [Start a new session without restarting](how-to/start-a-new-session.md)32 - [Start a new session without restarting](how-to/start-a-new-session.md)
@@ -49,7 +50,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
49 - [Command line](reference/cli.md) — flags, environment variables, start-up banner50 - [Command line](reference/cli.md) — flags, environment variables, start-up banner
50 - [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, keyboard shortcuts, window keys51 - [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, keyboard shortcuts, window keys
51 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule52 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
52-- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, declared tools53+- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, `save_answer`, declared tools
53 - [Skill file format](reference/skill-format.md)54 - [Skill file format](reference/skill-format.md)
54 - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors55 - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors
55 - [Agent Client Protocol surface](reference/acp.md)56 - [Agent Client Protocol surface](reference/acp.md)
modified docs/en/how-to/manage-plans-and-tasks.md +10 -0
@@ -392,6 +392,16 @@ If the agent isn't creating plans for your multi-step requests:
392392
393393 3. **Check the skill is loaded** - The agent should automatically load the `plan-manager` skill when it detects a plan-like request
394394
395+### The Agent Keeps Calling `plan update`
396+
397+`update` changes the status of one task; it cannot change a plan's tasks. A model that sends an `update` without a valid `status` — often with a `tasks` list, trying to edit the plan — gets an answer that quotes its call back, names the missing field, says that `tasks` is only read by `create`, and gives a valid call:
398+
399+```
400+[error: plan update changes the status of ONE task, and "status" is missing. You sent: {…}. "status" must be one of: open, in_progress, done, failed. "tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again. A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}]
401+```
402+
403+If a small model still repeats the call, set `planTool: false` for an agent whose job does not need plans (writing a document, answering questions): the model then works without them.
404+
395405 ### Plans Disappearing
396406
397407 In v1, plans are stored in memory and will be lost when:
@@ -392,6 +392,16 @@ If the agent isn't creating plans for your multi-step requests:
392 392
393 3. **Check the skill is loaded** - The agent should automatically load the `plan-manager` skill when it detects a plan-like request393 3. **Check the skill is loaded** - The agent should automatically load the `plan-manager` skill when it detects a plan-like request
394 394
395+### The Agent Keeps Calling `plan update`
396+
397+`update` changes the status of one task; it cannot change a plan's tasks. A model that sends an `update` without a valid `status` — often with a `tasks` list, trying to edit the plan — gets an answer that quotes its call back, names the missing field, says that `tasks` is only read by `create`, and gives a valid call:
398+
399+```
400+[error: plan update changes the status of ONE task, and "status" is missing. You sent: {…}. "status" must be one of: open, in_progress, done, failed. "tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again. A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}]
401+```
402+
403+If a small model still repeats the call, set `planTool: false` for an agent whose job does not need plans (writing a document, answering questions): the model then works without them.
404+
395 ### Plans Disappearing405 ### Plans Disappearing
396 406
397 In v1, plans are stored in memory and will be lost when:407 In v1, plans are stored in memory and will be lost when:
added docs/en/how-to/save-an-answer.md +59 -0
new file mode 100644
@@ -0,0 +1,59 @@
1+# How to save an answer to a file
2+
3+This guide shows how to keep an answer of the agent — a mini tutorial, an explanation, a snippet — in a Markdown file, without the model writing it a second time. It assumes you use mm in the terminal or the window mode (`-tui`).
4+
5+There are two ways. `/save` is a command you type: nothing is asked of the model. `save_answer` is a tool the model calls when you ask in your own words.
6+
7+## Save with the command
8+
9+1. Ask your question and wait for the answer.
10+
11+2. Type:
12+
13+ ```
14+ /save nushell-first-script.md
15+ ```
16+
17+ mm writes the last answer as it is and prints:
18+
19+ ```
20+ 💾 saved the last answer to nushell-first-script.md (2418 bytes)
21+ ```
22+
23+3. To save an earlier answer, add its rank from the end: `/save rust-app.md 2` saves the answer before the last one.
24+
25+## Save by asking the agent
26+
27+1. Turn the tool on in the agent file:
28+
29+ ```yaml
30+ saveAnswerTool: true
31+ ```
32+
33+2. Ask in your own words, after the answer or in the same request:
34+
35+ ```
36+ save it in nushell-first-script.md
37+ ```
38+
39+ ```
40+ teach me pattern matching in Kotlin and save it in kotlin-match.md
41+ ```
42+
43+ The model only sends the file name; mm writes the text the model already gave. The terminal shows:
44+
45+ ```
46+ 💾 save_answer: nushell-first-script.md (the previous answer)
47+ ```
48+
49+## Variants
50+
51+- **The file already exists.** Neither `/save` nor `save_answer` overwrites it: they say so and write nothing. Give another name, or remove the file first.
52+- **A directory that does not exist yet.** `/save notes/rust/app.md` creates `notes/rust/`.
53+- **You want each save confirmed.** With `yolo: false`, `save_answer` asks before writing, like `write_file`. `/save` never asks: you typed it.
54+- **In an editor (ACP).** `/save` is not offered there; `save_answer` works, and writes in the session's directory.
55+
56+## See also
57+
58+- The command: [slash commands](../reference/slash-commands.md)
59+- The tool: [`save_answer`](../reference/tools.md#save_answer) and [`saveAnswerTool`](../reference/configuration.md)
new file mode 100644
@@ -0,0 +1,59 @@
1+# How to save an answer to a file
2+
3+This guide shows how to keep an answer of the agent — a mini tutorial, an explanation, a snippet — in a Markdown file, without the model writing it a second time. It assumes you use mm in the terminal or the window mode (`-tui`).
4+
5+There are two ways. `/save` is a command you type: nothing is asked of the model. `save_answer` is a tool the model calls when you ask in your own words.
6+
7+## Save with the command
8+
9+1. Ask your question and wait for the answer.
10+
11+2. Type:
12+
13+ ```
14+ /save nushell-first-script.md
15+ ```
16+
17+ mm writes the last answer as it is and prints:
18+
19+ ```
20+ 💾 saved the last answer to nushell-first-script.md (2418 bytes)
21+ ```
22+
23+3. To save an earlier answer, add its rank from the end: `/save rust-app.md 2` saves the answer before the last one.
24+
25+## Save by asking the agent
26+
27+1. Turn the tool on in the agent file:
28+
29+ ```yaml
30+ saveAnswerTool: true
31+ ```
32+
33+2. Ask in your own words, after the answer or in the same request:
34+
35+ ```
36+ save it in nushell-first-script.md
37+ ```
38+
39+ ```
40+ teach me pattern matching in Kotlin and save it in kotlin-match.md
41+ ```
42+
43+ The model only sends the file name; mm writes the text the model already gave. The terminal shows:
44+
45+ ```
46+ 💾 save_answer: nushell-first-script.md (the previous answer)
47+ ```
48+
49+## Variants
50+
51+- **The file already exists.** Neither `/save` nor `save_answer` overwrites it: they say so and write nothing. Give another name, or remove the file first.
52+- **A directory that does not exist yet.** `/save notes/rust/app.md` creates `notes/rust/`.
53+- **You want each save confirmed.** With `yolo: false`, `save_answer` asks before writing, like `write_file`. `/save` never asks: you typed it.
54+- **In an editor (ACP).** `/save` is not offered there; `save_answer` works, and writes in the session's directory.
55+
56+## See also
57+
58+- The command: [slash commands](../reference/slash-commands.md)
59+- The tool: [`save_answer`](../reference/tools.md#save_answer) and [`saveAnswerTool`](../reference/configuration.md)
modified docs/en/reference/configuration.md +2 -1
@@ -24,6 +24,7 @@
2424 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
2525 | `fetchContentTool` | bool | `false` | Declare `fetch_content`, which fetches web pages as Markdown. Off by default: it is the only built-in tool that reaches the network. |
2626 | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). |
27+| `saveAnswerTool` | bool | `false` | Declare `save_answer`, which writes an answer the model already gave to a new file: the model sends a file name, not the text again. Off by default. The `/save` command exists either way. See [tools](tools.md#save_answer). |
2728 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |
2829 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
2930 | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). |
@@ -126,7 +127,7 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ
126127 | Key | Type | Default | Description |
127128 |-----|------|---------|-------------|
128129 | `tools` | list | `[]` | One entry per declared tool. |
129-| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `write_file`), even one this agent turns off. |
130+| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `write_file`), even one this agent turns off. |
130131 | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. |
131132 | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. |
132133 | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. |
@@ -24,6 +24,7 @@
24 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |24 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
25 | `fetchContentTool` | bool | `false` | Declare `fetch_content`, which fetches web pages as Markdown. Off by default: it is the only built-in tool that reaches the network. |25 | `fetchContentTool` | bool | `false` | Declare `fetch_content`, which fetches web pages as Markdown. Off by default: it is the only built-in tool that reaches the network. |
26 | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). |26 | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). |
27+| `saveAnswerTool` | bool | `false` | Declare `save_answer`, which writes an answer the model already gave to a new file: the model sends a file name, not the text again. Off by default. The `/save` command exists either way. See [tools](tools.md#save_answer). |
27 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |28 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |
28 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |29 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
29 | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). |30 | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). |
@@ -126,7 +127,7 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ
126 | Key | Type | Default | Description |127 | Key | Type | Default | Description |
127 |-----|------|---------|-------------|128 |-----|------|---------|-------------|
128 | `tools` | list | `[]` | One entry per declared tool. |129 | `tools` | list | `[]` | One entry per declared tool. |
129-| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `write_file`), even one this agent turns off. |130+| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `write_file`), even one this agent turns off. |
130 | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. |131 | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. |
131 | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. |132 | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. |
132 | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. |133 | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. |
modified docs/en/reference/slash-commands.md +1 -0
@@ -14,6 +14,7 @@
1414 | `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"` (plus ` · ⚠️ N error(s)` when the session recorded errors); resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |
1515 | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. |
1616 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |
17+| `/save <file> [n]` | Writes the last answer (`n`: the n-th from the end) as it is to a new file, creating the missing directories, and prints `💾 saved the last answer to <file> (N bytes)`. Never overwrites: an existing file gives `⚠️ <file> already exists — nothing was written; choose another name`. A file name with spaces is not supported. The model is not called. | Sent to the model as a new message. |
1718
1819 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.
1920
@@ -14,6 +14,7 @@
14 | `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"` (plus ` · ⚠️ N error(s)` when the session recorded errors); resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |14 | `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"` (plus ` · ⚠️ N error(s)` when the session recorded errors); resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |
15 | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. |15 | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. |
16 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |16 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |
17+| `/save <file> [n]` | Writes the last answer (`n`: the n-th from the end) as it is to a new file, creating the missing directories, and prints `💾 saved the last answer to <file> (N bytes)`. Never overwrites: an existing file gives `⚠️ <file> already exists — nothing was written; choose another name`. A file name with spaces is not supported. The model is not called. | Sent to the model as a new message. |
17 18
18 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.19 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.
19 20
modified docs/en/reference/tools.md +20 -0
@@ -248,6 +248,26 @@ The declared schema describes a question's fields in words, without `properties`
248248 | Result | Answered: `The user answered:` then one `- <question> → <answer>` line per question (several options joined by `, `). Declined: `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Cancelled: `[the question was cancelled before the user answered]`. Nobody to ask: `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Anything but an answer ends the call as failed in the window and the editor. |
249249 | Errors | `[ask_user: no question — send questions: [{question, type, options}]]`; `[ask_user: N questions — ask at most 4 at a time]`; `[ask_user: question N has no text]`; `[ask_user: question N is a choice question — give it at least two options]` (or `multi`); `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. |
250250
251+## `save_answer`
252+
253+Declared when `saveAnswerTool: true` (default `false`).
254+
255+| Field | Type | Description |
256+|-------|------|-------------|
257+| `path` | string | The file to create, relative to the working directory. It must not exist yet. |
258+
259+| Aspect | Behaviour |
260+|--------|-----------|
261+| Text written | The text of the model message that made the call, when it has any; otherwise the last answer of the conversation as it stood when the turn started. Written as it is, with a trailing newline; the model does not generate it again. |
262+| Existing file | Never overwritten: `[save_answer: <path> already exists — nothing was written. Pick another name, or ask the user]`. |
263+| No answer yet | `[save_answer: there is no answer to save yet — write the answer in your reply first, then call save_answer with the file name]`; nothing is asked. |
264+| Directories | Missing parent directories are created. |
265+| Result | `saved <this answer\|the previous answer> to <path> (N bytes), starting with: <first line>`. |
266+| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode and the window. |
267+| Permission | Requested when `yolo: false`, as for `write_file`; a refusal returns `[save_answer rejected by the user]`. |
268+| Terminal display | `💾 save_answer: <path> (this answer\|the previous answer)`, then the result. |
269+| ACP and window | Tool call of kind `edit`, with the file's absolute path. |
270+
251271 ## Declared tools
252272
253273 Declared from the `tools:` block of the agent file, one tool per entry (see [configuration](configuration.md#tools-keys)). Each has the entry's `name`; its fields are the entry's `parameters`.
@@ -248,6 +248,26 @@ The declared schema describes a question's fields in words, without `properties`
248 | Result | Answered: `The user answered:` then one `- <question> → <answer>` line per question (several options joined by `, `). Declined: `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Cancelled: `[the question was cancelled before the user answered]`. Nobody to ask: `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Anything but an answer ends the call as failed in the window and the editor. |248 | Result | Answered: `The user answered:` then one `- <question> → <answer>` line per question (several options joined by `, `). Declined: `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Cancelled: `[the question was cancelled before the user answered]`. Nobody to ask: `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Anything but an answer ends the call as failed in the window and the editor. |
249 | Errors | `[ask_user: no question — send questions: [{question, type, options}]]`; `[ask_user: N questions — ask at most 4 at a time]`; `[ask_user: question N has no text]`; `[ask_user: question N is a choice question — give it at least two options]` (or `multi`); `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. |249 | Errors | `[ask_user: no question — send questions: [{question, type, options}]]`; `[ask_user: N questions — ask at most 4 at a time]`; `[ask_user: question N has no text]`; `[ask_user: question N is a choice question — give it at least two options]` (or `multi`); `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. |
250 250
251+## `save_answer`
252+
253+Declared when `saveAnswerTool: true` (default `false`).
254+
255+| Field | Type | Description |
256+|-------|------|-------------|
257+| `path` | string | The file to create, relative to the working directory. It must not exist yet. |
258+
259+| Aspect | Behaviour |
260+|--------|-----------|
261+| Text written | The text of the model message that made the call, when it has any; otherwise the last answer of the conversation as it stood when the turn started. Written as it is, with a trailing newline; the model does not generate it again. |
262+| Existing file | Never overwritten: `[save_answer: <path> already exists — nothing was written. Pick another name, or ask the user]`. |
263+| No answer yet | `[save_answer: there is no answer to save yet — write the answer in your reply first, then call save_answer with the file name]`; nothing is asked. |
264+| Directories | Missing parent directories are created. |
265+| Result | `saved <this answer\|the previous answer> to <path> (N bytes), starting with: <first line>`. |
266+| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode and the window. |
267+| Permission | Requested when `yolo: false`, as for `write_file`; a refusal returns `[save_answer rejected by the user]`. |
268+| Terminal display | `💾 save_answer: <path> (this answer\|the previous answer)`, then the result. |
269+| ACP and window | Tool call of kind `edit`, with the file's absolute path. |
270+
251 ## Declared tools271 ## Declared tools
252 272
253 Declared from the `tools:` block of the agent file, one tool per entry (see [configuration](configuration.md#tools-keys)). Each has the entry's `name`; its fields are the entry's `parameters`.273 Declared from the `tools:` block of the agent file, one tool per entry (see [configuration](configuration.md#tools-keys)). Each has the entry's `name`; its fields are the entry's `parameters`.
modified docs/fr/README.md +2 -1
@@ -26,6 +26,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
2626 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur
2727 - [Ajouter un skill](how-to/add-a-skill.md)
2828 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)
29+- [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md)
2930 - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après
3031 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)
3132 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
@@ -49,7 +50,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
4950 - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage
5051 - [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, raccourcis clavier, touches de la fenêtre
5152 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
52-- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, outils déclarés
53+- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, `save_answer`, outils déclarés
5354 - [Format d'un fichier de skill](reference/skill-format.md)
5455 - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation
5556 - [Surface Agent Client Protocol](reference/acp.md)
@@ -26,6 +26,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
26 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur26 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur
27 - [Ajouter un skill](how-to/add-a-skill.md)27 - [Ajouter un skill](how-to/add-a-skill.md)
28 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)28 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)
29+- [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md)
29 - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après30 - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après
30 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)31 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)
31 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)32 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
@@ -49,7 +50,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
49 - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage50 - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage
50 - [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, raccourcis clavier, touches de la fenêtre51 - [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, raccourcis clavier, touches de la fenêtre
51 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation52 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
52-- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, outils déclarés53+- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, `save_answer`, outils déclarés
53 - [Format d'un fichier de skill](reference/skill-format.md)54 - [Format d'un fichier de skill](reference/skill-format.md)
54 - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation55 - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation
55 - [Surface Agent Client Protocol](reference/acp.md)56 - [Surface Agent Client Protocol](reference/acp.md)
modified docs/fr/how-to/manage-plans-and-tasks.md +10 -0
@@ -392,6 +392,16 @@ Si l'agent ne crée pas de plans pour vos demandes multi-étapes :
392392
393393 3. **Vérifiez que la compétence est chargée** - L'agent devrait automatiquement charger la compétence `plan-manager` lorsqu'il détecte une demande de type plan
394394
395+### L'agent appelle `plan update` en boucle
396+
397+`update` change l'état d'une tâche ; il ne peut pas modifier les tâches d'un plan. Un modèle qui envoie un `update` sans `status` valide — souvent avec une liste `tasks`, pour essayer de modifier le plan — reçoit une réponse qui cite son appel, nomme le champ manquant, précise que `tasks` n'est lu que par `create`, et donne un appel valide :
398+
399+```
400+[error: plan update changes the status of ONE task, and "status" is missing. You sent: {…}. "status" must be one of: open, in_progress, done, failed. "tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again. A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}]
401+```
402+
403+Si un petit modèle répète quand même l'appel, mettez `planTool: false` pour un agent dont le métier n'a pas besoin de plans (écrire un document, répondre à des questions) : le modèle travaille alors sans.
404+
395405 ### Les plans disparaissent
396406
397407 En v1, les plans sont stockés en mémoire et seront perdus lorsque :
@@ -392,6 +392,16 @@ Si l'agent ne crée pas de plans pour vos demandes multi-étapes :
392 392
393 3. **Vérifiez que la compétence est chargée** - L'agent devrait automatiquement charger la compétence `plan-manager` lorsqu'il détecte une demande de type plan393 3. **Vérifiez que la compétence est chargée** - L'agent devrait automatiquement charger la compétence `plan-manager` lorsqu'il détecte une demande de type plan
394 394
395+### L'agent appelle `plan update` en boucle
396+
397+`update` change l'état d'une tâche ; il ne peut pas modifier les tâches d'un plan. Un modèle qui envoie un `update` sans `status` valide — souvent avec une liste `tasks`, pour essayer de modifier le plan — reçoit une réponse qui cite son appel, nomme le champ manquant, précise que `tasks` n'est lu que par `create`, et donne un appel valide :
398+
399+```
400+[error: plan update changes the status of ONE task, and "status" is missing. You sent: {…}. "status" must be one of: open, in_progress, done, failed. "tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again. A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}]
401+```
402+
403+Si un petit modèle répète quand même l'appel, mettez `planTool: false` pour un agent dont le métier n'a pas besoin de plans (écrire un document, répondre à des questions) : le modèle travaille alors sans.
404+
395 ### Les plans disparaissent405 ### Les plans disparaissent
396 406
397 En v1, les plans sont stockés en mémoire et seront perdus lorsque :407 En v1, les plans sont stockés en mémoire et seront perdus lorsque :
added docs/fr/how-to/save-an-answer.md +59 -0
new file mode 100644
@@ -0,0 +1,59 @@
1+# Comment sauvegarder une réponse dans un fichier
2+
3+Ce guide montre comment garder une réponse de l'agent — un mini-tutoriel, une explication, un extrait de code — dans un fichier Markdown, sans que le modèle l'écrive une seconde fois. Il suppose que vous utilisez mm dans le terminal ou en mode fenêtre (`-tui`).
4+
5+Il y a deux façons de faire. `/save` est une commande que vous tapez : rien n'est demandé au modèle. `save_answer` est un outil que le modèle appelle quand vous le demandez avec vos propres mots.
6+
7+## Sauvegarder avec la commande
8+
9+1. Posez votre question et attendez la réponse.
10+
11+2. Tapez :
12+
13+ ```
14+ /save premier-script-nushell.md
15+ ```
16+
17+ mm écrit la dernière réponse telle quelle et affiche :
18+
19+ ```
20+ 💾 saved the last answer to premier-script-nushell.md (2418 bytes)
21+ ```
22+
23+3. Pour sauvegarder une réponse plus ancienne, ajoutez son rang depuis la fin : `/save app-rust.md 2` sauvegarde l'avant-dernière réponse.
24+
25+## Sauvegarder en le demandant à l'agent
26+
27+1. Activez l'outil dans le fichier d'agent :
28+
29+ ```yaml
30+ saveAnswerTool: true
31+ ```
32+
33+2. Demandez-le avec vos propres mots, après la réponse ou dans la même demande :
34+
35+ ```
36+ sauvegarde-le dans premier-script-nushell.md
37+ ```
38+
39+ ```
40+ apprends-moi le pattern matching en Kotlin et sauvegarde-le dans kotlin-match.md
41+ ```
42+
43+ Le modèle n'envoie que le nom du fichier ; mm écrit le texte que le modèle a déjà donné. Le terminal affiche :
44+
45+ ```
46+ 💾 save_answer: premier-script-nushell.md (the previous answer)
47+ ```
48+
49+## Variantes
50+
51+- **Le fichier existe déjà.** Ni `/save` ni `save_answer` ne l'écrasent : ils le disent et n'écrivent rien. Donnez un autre nom, ou supprimez d'abord le fichier.
52+- **Un répertoire qui n'existe pas encore.** `/save notes/rust/app.md` crée `notes/rust/`.
53+- **Vous voulez confirmer chaque sauvegarde.** Avec `yolo: false`, `save_answer` demande avant d'écrire, comme `write_file`. `/save` ne demande jamais : c'est vous qui l'avez tapé.
54+- **Dans un éditeur (ACP).** `/save` n'y est pas proposé ; `save_answer` fonctionne, et écrit dans le répertoire de la session.
55+
56+## Voir aussi
57+
58+- La commande : [commandes slash](../reference/slash-commands.md)
59+- L'outil : [`save_answer`](../reference/tools.md#save_answer) et [`saveAnswerTool`](../reference/configuration.md)
new file mode 100644
@@ -0,0 +1,59 @@
1+# Comment sauvegarder une réponse dans un fichier
2+
3+Ce guide montre comment garder une réponse de l'agent — un mini-tutoriel, une explication, un extrait de code — dans un fichier Markdown, sans que le modèle l'écrive une seconde fois. Il suppose que vous utilisez mm dans le terminal ou en mode fenêtre (`-tui`).
4+
5+Il y a deux façons de faire. `/save` est une commande que vous tapez : rien n'est demandé au modèle. `save_answer` est un outil que le modèle appelle quand vous le demandez avec vos propres mots.
6+
7+## Sauvegarder avec la commande
8+
9+1. Posez votre question et attendez la réponse.
10+
11+2. Tapez :
12+
13+ ```
14+ /save premier-script-nushell.md
15+ ```
16+
17+ mm écrit la dernière réponse telle quelle et affiche :
18+
19+ ```
20+ 💾 saved the last answer to premier-script-nushell.md (2418 bytes)
21+ ```
22+
23+3. Pour sauvegarder une réponse plus ancienne, ajoutez son rang depuis la fin : `/save app-rust.md 2` sauvegarde l'avant-dernière réponse.
24+
25+## Sauvegarder en le demandant à l'agent
26+
27+1. Activez l'outil dans le fichier d'agent :
28+
29+ ```yaml
30+ saveAnswerTool: true
31+ ```
32+
33+2. Demandez-le avec vos propres mots, après la réponse ou dans la même demande :
34+
35+ ```
36+ sauvegarde-le dans premier-script-nushell.md
37+ ```
38+
39+ ```
40+ apprends-moi le pattern matching en Kotlin et sauvegarde-le dans kotlin-match.md
41+ ```
42+
43+ Le modèle n'envoie que le nom du fichier ; mm écrit le texte que le modèle a déjà donné. Le terminal affiche :
44+
45+ ```
46+ 💾 save_answer: premier-script-nushell.md (the previous answer)
47+ ```
48+
49+## Variantes
50+
51+- **Le fichier existe déjà.** Ni `/save` ni `save_answer` ne l'écrasent : ils le disent et n'écrivent rien. Donnez un autre nom, ou supprimez d'abord le fichier.
52+- **Un répertoire qui n'existe pas encore.** `/save notes/rust/app.md` crée `notes/rust/`.
53+- **Vous voulez confirmer chaque sauvegarde.** Avec `yolo: false`, `save_answer` demande avant d'écrire, comme `write_file`. `/save` ne demande jamais : c'est vous qui l'avez tapé.
54+- **Dans un éditeur (ACP).** `/save` n'y est pas proposé ; `save_answer` fonctionne, et écrit dans le répertoire de la session.
55+
56+## Voir aussi
57+
58+- La commande : [commandes slash](../reference/slash-commands.md)
59+- L'outil : [`save_answer`](../reference/tools.md#save_answer) et [`saveAnswerTool`](../reference/configuration.md)
modified docs/fr/reference/configuration.md +2 -1
@@ -24,6 +24,7 @@
2424 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
2525 | `fetchContentTool` | booléen | `false` | Déclare `fetch_content`, qui récupère des pages web en Markdown. Désactivé par défaut : c'est le seul outil intégré qui accède au réseau. |
2626 | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). |
27+| `saveAnswerTool` | bool | `false` | Déclare `save_answer`, qui écrit dans un nouveau fichier une réponse que le modèle a déjà donnée : le modèle envoie un nom de fichier, pas le texte une seconde fois. Désactivé par défaut. La commande `/save` existe dans tous les cas. Voir les [outils](tools.md#save_answer). |
2728 | `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. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |
2829 | `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é. |
2930 | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). |
@@ -126,7 +127,7 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl
126127 | Clé | Type | Défaut | Description |
127128 |-----|------|--------|-------------|
128129 | `tools` | liste | `[]` | Une entrée par outil déclaré. |
129-| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `write_file`), même désactivé dans cet agent. |
130+| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `write_file`), même désactivé dans cet agent. |
130131 | `tools[].description` | chaîne | — | Ce que fait l'outil et quand s'en servir, montré au modèle. Obligatoire. Une ligne `Required parameters: …` y est ajoutée. |
131132 | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. |
132133 | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. |
@@ -24,6 +24,7 @@
24 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |24 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
25 | `fetchContentTool` | booléen | `false` | Déclare `fetch_content`, qui récupère des pages web en Markdown. Désactivé par défaut : c'est le seul outil intégré qui accède au réseau. |25 | `fetchContentTool` | booléen | `false` | Déclare `fetch_content`, qui récupère des pages web en Markdown. Désactivé par défaut : c'est le seul outil intégré qui accède au réseau. |
26 | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). |26 | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). |
27+| `saveAnswerTool` | bool | `false` | Déclare `save_answer`, qui écrit dans un nouveau fichier une réponse que le modèle a déjà donnée : le modèle envoie un nom de fichier, pas le texte une seconde fois. Désactivé par défaut. La commande `/save` existe dans tous les cas. Voir les [outils](tools.md#save_answer). |
27 | `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. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |28 | `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. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |
28 | `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é. |29 | `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é. |
29 | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). |30 | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). |
@@ -126,7 +127,7 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl
126 | Clé | Type | Défaut | Description |127 | Clé | Type | Défaut | Description |
127 |-----|------|--------|-------------|128 |-----|------|--------|-------------|
128 | `tools` | liste | `[]` | Une entrée par outil déclaré. |129 | `tools` | liste | `[]` | Une entrée par outil déclaré. |
129-| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `write_file`), même désactivé dans cet agent. |130+| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `write_file`), même désactivé dans cet agent. |
130 | `tools[].description` | chaîne | — | Ce que fait l'outil et quand s'en servir, montré au modèle. Obligatoire. Une ligne `Required parameters: …` y est ajoutée. |131 | `tools[].description` | chaîne | — | Ce que fait l'outil et quand s'en servir, montré au modèle. Obligatoire. Une ligne `Required parameters: …` y est ajoutée. |
131 | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. |132 | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. |
132 | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. |133 | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. |
modified docs/fr/reference/slash-commands.md +1 -0
@@ -13,6 +13,7 @@
1313 | `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question, puis ` · ⚠️ N error(s)` quand la session a enregistré des erreurs ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
1414 | `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` (suivi de ` · ⚠️ N error(s)` quand la session a enregistré des erreurs) ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |
1515 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |
16+| `/save <fichier> [n]` | Écrit la dernière réponse (`n` : la n-ième depuis la fin) telle quelle dans un nouveau fichier, en créant les répertoires manquants, et affiche `💾 saved the last answer to <fichier> (N bytes)`. N'écrase jamais : un fichier existant donne `⚠️ <fichier> already exists — nothing was written; choose another name`. Un nom de fichier avec des espaces n'est pas pris en charge. Le modèle n'est pas appelé. | Envoyée au modèle comme nouveau message. |
1617 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |
1718
1819 Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée.
@@ -13,6 +13,7 @@
13 | `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question, puis ` · ⚠️ N error(s)` quand la session a enregistré des erreurs ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |13 | `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question, puis ` · ⚠️ N error(s)` quand la session a enregistré des erreurs ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
14 | `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` (suivi de ` · ⚠️ N error(s)` quand la session a enregistré des erreurs) ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |14 | `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` (suivi de ` · ⚠️ N error(s)` quand la session a enregistré des erreurs) ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |
15 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |15 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |
16+| `/save <fichier> [n]` | Écrit la dernière réponse (`n` : la n-ième depuis la fin) telle quelle dans un nouveau fichier, en créant les répertoires manquants, et affiche `💾 saved the last answer to <fichier> (N bytes)`. N'écrase jamais : un fichier existant donne `⚠️ <fichier> already exists — nothing was written; choose another name`. Un nom de fichier avec des espaces n'est pas pris en charge. Le modèle n'est pas appelé. | Envoyée au modèle comme nouveau message. |
16 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |17 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |
17 18
18 Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée.19 Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée.
modified docs/fr/reference/tools.md +20 -0
@@ -248,6 +248,26 @@ Le schéma déclaré décrit les champs d'une question en mots, sans `properties
248248 | Résultat | Répondu : `The user answered:` puis une ligne `- <question> → <réponse>` par question (plusieurs options jointes par `, `). Refusé : `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Annulé : `[the question was cancelled before the user answered]`. Personne à interroger : `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Tout ce qui n'est pas une réponse termine l'appel en échec dans la fenêtre et l'éditeur. |
249249 | Erreurs | `[ask_user: no question — send questions: [{question, type, options}]]` ; `[ask_user: N questions — ask at most 4 at a time]` ; `[ask_user: question N has no text]` ; `[ask_user: question N is a choice question — give it at least two options]` (ou `multi`) ; `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. |
250250
251+## `save_answer`
252+
253+Déclaré quand `saveAnswerTool: true` (défaut `false`).
254+
255+| Champ | Type | Description |
256+|-------|------|-------------|
257+| `path` | chaîne | Le fichier à créer, relatif au répertoire de travail. Il ne doit pas encore exister. |
258+
259+| Aspect | Comportement |
260+|--------|--------------|
261+| Texte écrit | Le texte du message du modèle qui a fait l'appel, s'il en a un ; sinon la dernière réponse de la conversation telle qu'elle était au début du tour. Écrit tel quel, avec un saut de ligne final ; le modèle ne le génère pas une seconde fois. |
262+| Fichier existant | Jamais écrasé : `[save_answer: <path> already exists — nothing was written. Pick another name, or ask the user]`. |
263+| Pas encore de réponse | `[save_answer: there is no answer to save yet — write the answer in your reply first, then call save_answer with the file name]` ; rien n'est demandé. |
264+| Répertoires | Les répertoires parents manquants sont créés. |
265+| Résultat | `saved <this answer\|the previous answer> to <path> (N bytes), starting with: <première ligne>`. |
266+| Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP et dans la fenêtre. |
267+| Permission | Demandée quand `yolo: false`, comme pour `write_file` ; un refus renvoie `[save_answer rejected by the user]`. |
268+| Affichage terminal | `💾 save_answer: <path> (this answer\|the previous answer)`, puis le résultat. |
269+| ACP et fenêtre | Appel d'outil de type `edit`, avec le chemin absolu du fichier. |
270+
251271 ## Outils déclarés
252272
253273 Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée (voir la [configuration](configuration.md#clés-de-tools)). Chacun porte le `name` de l'entrée ; ses champs sont les `parameters` de l'entrée.
@@ -248,6 +248,26 @@ Le schéma déclaré décrit les champs d'une question en mots, sans `properties
248 | Résultat | Répondu : `The user answered:` puis une ligne `- <question> → <réponse>` par question (plusieurs options jointes par `, `). Refusé : `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Annulé : `[the question was cancelled before the user answered]`. Personne à interroger : `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Tout ce qui n'est pas une réponse termine l'appel en échec dans la fenêtre et l'éditeur. |248 | Résultat | Répondu : `The user answered:` puis une ligne `- <question> → <réponse>` par question (plusieurs options jointes par `, `). Refusé : `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Annulé : `[the question was cancelled before the user answered]`. Personne à interroger : `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Tout ce qui n'est pas une réponse termine l'appel en échec dans la fenêtre et l'éditeur. |
249 | Erreurs | `[ask_user: no question — send questions: [{question, type, options}]]` ; `[ask_user: N questions — ask at most 4 at a time]` ; `[ask_user: question N has no text]` ; `[ask_user: question N is a choice question — give it at least two options]` (ou `multi`) ; `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. |249 | Erreurs | `[ask_user: no question — send questions: [{question, type, options}]]` ; `[ask_user: N questions — ask at most 4 at a time]` ; `[ask_user: question N has no text]` ; `[ask_user: question N is a choice question — give it at least two options]` (ou `multi`) ; `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. |
250 250
251+## `save_answer`
252+
253+Déclaré quand `saveAnswerTool: true` (défaut `false`).
254+
255+| Champ | Type | Description |
256+|-------|------|-------------|
257+| `path` | chaîne | Le fichier à créer, relatif au répertoire de travail. Il ne doit pas encore exister. |
258+
259+| Aspect | Comportement |
260+|--------|--------------|
261+| Texte écrit | Le texte du message du modèle qui a fait l'appel, s'il en a un ; sinon la dernière réponse de la conversation telle qu'elle était au début du tour. Écrit tel quel, avec un saut de ligne final ; le modèle ne le génère pas une seconde fois. |
262+| Fichier existant | Jamais écrasé : `[save_answer: <path> already exists — nothing was written. Pick another name, or ask the user]`. |
263+| Pas encore de réponse | `[save_answer: there is no answer to save yet — write the answer in your reply first, then call save_answer with the file name]` ; rien n'est demandé. |
264+| Répertoires | Les répertoires parents manquants sont créés. |
265+| Résultat | `saved <this answer\|the previous answer> to <path> (N bytes), starting with: <première ligne>`. |
266+| Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP et dans la fenêtre. |
267+| Permission | Demandée quand `yolo: false`, comme pour `write_file` ; un refus renvoie `[save_answer rejected by the user]`. |
268+| Affichage terminal | `💾 save_answer: <path> (this answer\|the previous answer)`, puis le résultat. |
269+| ACP et fenêtre | Appel d'outil de type `edit`, avec le chemin absolu du fichier. |
270+
251 ## Outils déclarés271 ## Outils déclarés
252 272
253 Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée (voir la [configuration](configuration.md#clés-de-tools)). Chacun porte le `name` de l'entrée ; ses champs sont les `parameters` de l'entrée.273 Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée (voir la [configuration](configuration.md#clés-de-tools)). Chacun porte le `name` de l'entrée ; ses champs sont les `parameters` de l'entrée.
modified install.sh +2 -0
@@ -4,6 +4,8 @@ set -euo pipefail
44 BINARY_NAME="mm"
55 INSTALL_DIR="/usr/local/bin"
66
7+rm "${INSTALL_DIR}/mm"
8+
79 echo "Building ${BINARY_NAME}..."
810 go build -o "${BINARY_NAME}" .
911
@@ -4,6 +4,8 @@ set -euo pipefail
4 BINARY_NAME="mm"4 BINARY_NAME="mm"
5 INSTALL_DIR="/usr/local/bin"5 INSTALL_DIR="/usr/local/bin"
6 6
7+rm "${INSTALL_DIR}/mm"
8+
7 echo "Building ${BINARY_NAME}..."9 echo "Building ${BINARY_NAME}..."
8 go build -o "${BINARY_NAME}" .10 go build -o "${BINARY_NAME}" .
9 11
modified internal/agent/agent.go +3 -0
@@ -21,6 +21,7 @@ import (
2121 "mm/internal/elicit"
2222 "mm/internal/engine"
2323 "mm/internal/mention"
24+ "mm/internal/saved"
2425 "mm/internal/session"
2526 "mm/internal/skills"
2627 "mm/internal/spinner"
@@ -536,6 +537,8 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa
536537 fmt.Println(command.Usage(command.Terminal))
537538 case command.Copy:
538539 fmt.Println(copyAnswer(messages, arg, clipboard.New(terminalOSC52())))
540+ case command.Save:
541+ fmt.Println(saved.Command(modelAnswers(messages), arg))
539542 case command.Resume:
540543 resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, ""))
541544 if err != nil {
@@ -21,6 +21,7 @@ import (
21 "mm/internal/elicit"21 "mm/internal/elicit"
22 "mm/internal/engine"22 "mm/internal/engine"
23 "mm/internal/mention"23 "mm/internal/mention"
24+ "mm/internal/saved"
24 "mm/internal/session"25 "mm/internal/session"
25 "mm/internal/skills"26 "mm/internal/skills"
26 "mm/internal/spinner"27 "mm/internal/spinner"
@@ -536,6 +537,8 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa
536 fmt.Println(command.Usage(command.Terminal))537 fmt.Println(command.Usage(command.Terminal))
537 case command.Copy:538 case command.Copy:
538 fmt.Println(copyAnswer(messages, arg, clipboard.New(terminalOSC52())))539 fmt.Println(copyAnswer(messages, arg, clipboard.New(terminalOSC52())))
540+ case command.Save:
541+ fmt.Println(saved.Command(modelAnswers(messages), arg))
539 case command.Resume:542 case command.Resume:
540 resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, ""))543 resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, ""))
541 if err != nil {544 if err != nil {
modified internal/agent/copy.go +4 -13
@@ -4,9 +4,9 @@ import (
44 "fmt"
55 "os"
66 "strconv"
7- "strings"
87
98 "mm/internal/clipboard"
9+ "mm/internal/saved"
1010
1111 "github.com/firebase/genkit/go/ai"
1212 )
@@ -44,19 +44,10 @@ type copier interface {
4444 Copy(text string) (how string, err error)
4545 }
4646
47-// modelAnswers is the text of every model message that spoke to the user —
48-// tool-call-only messages have none — oldest first.
47+// modelAnswers is the text of every model message that spoke to the user,
48+// oldest first (saved.Answers: /copy and /save count answers alike).
4949 func modelAnswers(messages []*ai.Message) []string {
50- var out []string
51- for _, m := range messages {
52- if m.Role != ai.RoleModel {
53- continue
54- }
55- if text := strings.TrimSpace(m.Text()); text != "" {
56- out = append(out, text)
57- }
58- }
59- return out
50+ return saved.Answers(messages)
6051 }
6152
6253 // terminalOSC52 is the REPL's OSC 52 sink: the sequence goes to stdout when
@@ -4,9 +4,9 @@ import (
4 "fmt"4 "fmt"
5 "os"5 "os"
6 "strconv"6 "strconv"
7- "strings"
8 7
9 "mm/internal/clipboard"8 "mm/internal/clipboard"
9+ "mm/internal/saved"
10 10
11 "github.com/firebase/genkit/go/ai"11 "github.com/firebase/genkit/go/ai"
12 )12 )
@@ -44,19 +44,10 @@ type copier interface {
44 Copy(text string) (how string, err error)44 Copy(text string) (how string, err error)
45 }45 }
46 46
47-// modelAnswers is the text of every model message that spoke to the user —47+// modelAnswers is the text of every model message that spoke to the user,
48-// tool-call-only messages have none — oldest first.48+// oldest first (saved.Answers: /copy and /save count answers alike).
49 func modelAnswers(messages []*ai.Message) []string {49 func modelAnswers(messages []*ai.Message) []string {
50- var out []string50+ return saved.Answers(messages)
51- for _, m := range messages {
52- if m.Role != ai.RoleModel {
53- continue
54- }
55- if text := strings.TrimSpace(m.Text()); text != "" {
56- out = append(out, text)
57- }
58- }
59- return out
60 }51 }
61 52
62 // terminalOSC52 is the REPL's OSC 52 sink: the sequence goes to stdout when53 // terminalOSC52 is the REPL's OSC 52 sink: the sequence goes to stdout when
modified internal/command/command.go +2 -0
@@ -40,6 +40,7 @@ const (
4040 Resume = "resume"
4141 Help = "help"
4242 Copy = "copy"
43+ Save = "save"
4344 )
4445
4546 // Command is one slash command: what it is called, what it does, who offers
@@ -72,6 +73,7 @@ var table = []Command{
7273 {Name: Resume, Description: "Continue a stored session: the latest, or the given id", Fronts: Terminal | Window, TakesArg: true},
7374 {Name: Help, Description: "List the commands", Fronts: Terminal | Window},
7475 {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true},
76+ {Name: Save, Description: "Save the last answer to a file, never overwriting one (/save notes.md 2: the answer before)", Fronts: Terminal | Window, TakesArg: true, Hint: "<file> [n]"},
7577 }
7678
7779 // All returns the whole table, in display order: the fixed commands, then
@@ -40,6 +40,7 @@ const (
40 Resume = "resume"40 Resume = "resume"
41 Help = "help"41 Help = "help"
42 Copy = "copy"42 Copy = "copy"
43+ Save = "save"
43 )44 )
44 45
45 // Command is one slash command: what it is called, what it does, who offers46 // Command is one slash command: what it is called, what it does, who offers
@@ -72,6 +73,7 @@ var table = []Command{
72 {Name: Resume, Description: "Continue a stored session: the latest, or the given id", Fronts: Terminal | Window, TakesArg: true},73 {Name: Resume, Description: "Continue a stored session: the latest, or the given id", Fronts: Terminal | Window, TakesArg: true},
73 {Name: Help, Description: "List the commands", Fronts: Terminal | Window},74 {Name: Help, Description: "List the commands", Fronts: Terminal | Window},
74 {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true},75 {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true},
76+ {Name: Save, Description: "Save the last answer to a file, never overwriting one (/save notes.md 2: the answer before)", Fronts: Terminal | Window, TakesArg: true, Hint: "<file> [n]"},
75 }77 }
76 78
77 // All returns the whole table, in display order: the fixed commands, then79 // All returns the whole table, in display order: the fixed commands, then
modified internal/command/command_test.go +3 -3
@@ -19,15 +19,15 @@ func TestForFiltersByFront(t *testing.T) {
1919 if got := names(For(Editor)); got != "compact new" {
2020 t.Errorf("Editor commands = %q, want %q", got, "compact new")
2121 }
22- want := "quit abort compact new sessions resume help copy"
22+ want := "quit abort compact new sessions resume help copy save"
2323 if got := names(For(Terminal)); got != want {
2424 t.Errorf("Terminal commands = %q, want %q", got, want)
2525 }
2626 if got := names(For(Window)); got != want {
2727 t.Errorf("Window commands = %q, want %q", got, want)
2828 }
29- if len(All()) != 8 {
30- t.Errorf("All() has %d commands, want 8", len(All()))
29+ if len(All()) != 9 {
30+ t.Errorf("All() has %d commands, want 9", len(All()))
3131 }
3232 }
3333
@@ -19,15 +19,15 @@ func TestForFiltersByFront(t *testing.T) {
19 if got := names(For(Editor)); got != "compact new" {19 if got := names(For(Editor)); got != "compact new" {
20 t.Errorf("Editor commands = %q, want %q", got, "compact new")20 t.Errorf("Editor commands = %q, want %q", got, "compact new")
21 }21 }
22- want := "quit abort compact new sessions resume help copy"22+ want := "quit abort compact new sessions resume help copy save"
23 if got := names(For(Terminal)); got != want {23 if got := names(For(Terminal)); got != want {
24 t.Errorf("Terminal commands = %q, want %q", got, want)24 t.Errorf("Terminal commands = %q, want %q", got, want)
25 }25 }
26 if got := names(For(Window)); got != want {26 if got := names(For(Window)); got != want {
27 t.Errorf("Window commands = %q, want %q", got, want)27 t.Errorf("Window commands = %q, want %q", got, want)
28 }28 }
29- if len(All()) != 8 {29+ if len(All()) != 9 {
30- t.Errorf("All() has %d commands, want 8", len(All()))30+ t.Errorf("All() has %d commands, want 9", len(All()))
31 }31 }
32 }32 }
33 33
modified internal/config/config.go +7 -0
@@ -126,6 +126,13 @@ type Config struct {
126126 // Override: AGENT_ASK_USER.
127127 AskUserTool bool `yaml:"askUserTool"`
128128
129+ // SaveAnswerTool: declare the built-in `save_answer` tool, which writes an
130+ // answer the model already gave to a new file — the model sends a file
131+ // name, not the text again (ticket 93). Off by default, so the tool list
132+ // of the existing agents does not change. The /save command exists either
133+ // way.
134+ SaveAnswerTool bool `yaml:"saveAnswerTool"`
135+
129136 // Sampling: generation settings (OpenAI API keys; the plugin converts them.)
130137 Sampling map[string]any `yaml:"sampling"`
131138
@@ -126,6 +126,13 @@ type Config struct {
126 // Override: AGENT_ASK_USER.126 // Override: AGENT_ASK_USER.
127 AskUserTool bool `yaml:"askUserTool"`127 AskUserTool bool `yaml:"askUserTool"`
128 128
129+ // SaveAnswerTool: declare the built-in `save_answer` tool, which writes an
130+ // answer the model already gave to a new file — the model sends a file
131+ // name, not the text again (ticket 93). Off by default, so the tool list
132+ // of the existing agents does not change. The /save command exists either
133+ // way.
134+ SaveAnswerTool bool `yaml:"saveAnswerTool"`
135+
129 // Sampling: generation settings (OpenAI API keys; the plugin converts them.)136 // Sampling: generation settings (OpenAI API keys; the plugin converts them.)
130 Sampling map[string]any `yaml:"sampling"`137 Sampling map[string]any `yaml:"sampling"`
131 138
modified internal/config/tools.go +1 -0
@@ -43,6 +43,7 @@ var BuiltinToolNames = []string{
4343 "plan",
4444 "read_file",
4545 "read_skill",
46+ "save_answer",
4647 "write_file",
4748 }
4849
@@ -43,6 +43,7 @@ var BuiltinToolNames = []string{
43 "plan",43 "plan",
44 "read_file",44 "read_file",
45 "read_skill",45 "read_skill",
46+ "save_answer",
46 "write_file",47 "write_file",
47 }48 }
48 49
modified internal/engine/engine.go +7 -0
@@ -268,6 +268,9 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
268268 // `New` is called once per `Generate`, which keeps `full` captured at the
269269 // right level.
270270 var full []*ai.Message
271+ // reply is the model's latest message — for a tool, the one that called
272+ // it (reply.go).
273+ var reply *ai.Message
271274 // pl is the watchdog's pulse, set below; every model call restarts the
272275 // wait for a first token (progress.go).
273276 var pl *pulse
@@ -349,6 +352,9 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
349352 }
350353 }
351354 dropReasoning(resp)
355+ if resp != nil {
356+ reply = resp.Message
357+ }
352358 return resp, err
353359 },
354360 // The tool is about to write to the screen: we forget the
@@ -359,6 +365,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
359365 // call, built-in and MCP, before the tool asks for consent.
360366 WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {
361367 gaps.drop()
368+ ctx = withReply(ctx, reply)
362369 if !e.hooks.Enabled() {
363370 return next(ctx, params)
364371 }
@@ -268,6 +268,9 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
268 // `New` is called once per `Generate`, which keeps `full` captured at the268 // `New` is called once per `Generate`, which keeps `full` captured at the
269 // right level.269 // right level.
270 var full []*ai.Message270 var full []*ai.Message
271+ // reply is the model's latest message — for a tool, the one that called
272+ // it (reply.go).
273+ var reply *ai.Message
271 // pl is the watchdog's pulse, set below; every model call restarts the274 // pl is the watchdog's pulse, set below; every model call restarts the
272 // wait for a first token (progress.go).275 // wait for a first token (progress.go).
273 var pl *pulse276 var pl *pulse
@@ -349,6 +352,9 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
349 }352 }
350 }353 }
351 dropReasoning(resp)354 dropReasoning(resp)
355+ if resp != nil {
356+ reply = resp.Message
357+ }
352 return resp, err358 return resp, err
353 },359 },
354 // The tool is about to write to the screen: we forget the360 // The tool is about to write to the screen: we forget the
@@ -359,6 +365,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
359 // call, built-in and MCP, before the tool asks for consent.365 // call, built-in and MCP, before the tool asks for consent.
360 WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {366 WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) {
361 gaps.drop()367 gaps.drop()
368+ ctx = withReply(ctx, reply)
362 if !e.hooks.Enabled() {369 if !e.hooks.Enabled() {
363 return next(ctx, params)370 return next(ctx, params)
364 }371 }
modified internal/engine/engine_test.go +25 -0
@@ -437,6 +437,31 @@ func TestToolsSeeTheConversation(t *testing.T) {
437437 }
438438 }
439439
440+// A tool reads the model message that called it — its text included, which
441+// is what save_answer saves when the model writes the answer beside the call
442+// (ticket 93).
443+func TestToolsSeeTheReplyThatCalledThem(t *testing.T) {
444+ call := modelTurn(ai.NewTextPart("# Nushell\nfirst steps"), ai.NewToolRequestPart(&ai.ToolRequest{Name: "peek", Input: map[string]any{}}))
445+ g, _ := fakeGenkit(t, call, modelTurn(ai.NewTextPart("saved")))
446+ var seen *ai.Message
447+ genkit.DefineTool(g, "peek", "look at the reply",
448+ func(tctx *ai.ToolContext, _ struct{}) (string, error) {
449+ seen = Reply(tctx)
450+ return "ok", nil
451+ })
452+ e := testEngine(g)
453+ e.Provider, e.Backend = registry["dmr"], Backend{Provider: "dmr"}
454+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("teach me")}, []ai.ToolRef{ai.ToolName("peek")}); err != nil {
455+ t.Fatal(err)
456+ }
457+ if seen == nil || seen.Text() != "# Nushell\nfirst steps" {
458+ t.Errorf("the tool saw reply %v", seen)
459+ }
460+ if Reply(context.Background()) != nil {
461+ t.Error("Reply outside a tool call is not nil")
462+ }
463+}
464+
440465 // A tool that beats the heartbeat keeps the turn alive past watchdogTimeout —
441466 // ask_agent does it on every event of the agent it asked; a tool that does
442467 // not is still cut (ticket 57).
@@ -437,6 +437,31 @@ func TestToolsSeeTheConversation(t *testing.T) {
437 }437 }
438 }438 }
439 439
440+// A tool reads the model message that called it — its text included, which
441+// is what save_answer saves when the model writes the answer beside the call
442+// (ticket 93).
443+func TestToolsSeeTheReplyThatCalledThem(t *testing.T) {
444+ call := modelTurn(ai.NewTextPart("# Nushell\nfirst steps"), ai.NewToolRequestPart(&ai.ToolRequest{Name: "peek", Input: map[string]any{}}))
445+ g, _ := fakeGenkit(t, call, modelTurn(ai.NewTextPart("saved")))
446+ var seen *ai.Message
447+ genkit.DefineTool(g, "peek", "look at the reply",
448+ func(tctx *ai.ToolContext, _ struct{}) (string, error) {
449+ seen = Reply(tctx)
450+ return "ok", nil
451+ })
452+ e := testEngine(g)
453+ e.Provider, e.Backend = registry["dmr"], Backend{Provider: "dmr"}
454+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("teach me")}, []ai.ToolRef{ai.ToolName("peek")}); err != nil {
455+ t.Fatal(err)
456+ }
457+ if seen == nil || seen.Text() != "# Nushell\nfirst steps" {
458+ t.Errorf("the tool saw reply %v", seen)
459+ }
460+ if Reply(context.Background()) != nil {
461+ t.Error("Reply outside a tool call is not nil")
462+ }
463+}
464+
440 // A tool that beats the heartbeat keeps the turn alive past watchdogTimeout —465 // A tool that beats the heartbeat keeps the turn alive past watchdogTimeout —
441 // ask_agent does it on every event of the agent it asked; a tool that does466 // ask_agent does it on every event of the agent it asked; a tool that does
442 // not is still cut (ticket 57).467 // not is still cut (ticket 57).
added internal/engine/reply.go +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+package engine
2+
3+import (
4+ "context"
5+
6+ "github.com/firebase/genkit/go/ai"
7+)
8+
9+// replyKey is the context key of the model message a tool call came from.
10+type replyKey struct{}
11+
12+// withReply marks a tool call's ctx with the model message that made it.
13+// Generate's WrapTool hook sets it (ticket 93): save_answer saves the text
14+// of that message when the model wrote the answer and the call together.
15+func withReply(ctx context.Context, reply *ai.Message) context.Context {
16+ return context.WithValue(ctx, replyKey{}, reply)
17+}
18+
19+// Reply returns the model message that made the tool call ctx belongs to —
20+// its text, when any, is what the model wrote beside the call. nil outside a
21+// tool call made by Generate. The earlier history is Conversation(ctx).
22+//
23+// if r := engine.Reply(ctx); r != nil && strings.TrimSpace(r.Text()) != "" { … }
24+func Reply(ctx context.Context) *ai.Message {
25+ reply, _ := ctx.Value(replyKey{}).(*ai.Message)
26+ return reply
27+}
new file mode 100644
@@ -0,0 +1,27 @@
1+package engine
2+
3+import (
4+ "context"
5+
6+ "github.com/firebase/genkit/go/ai"
7+)
8+
9+// replyKey is the context key of the model message a tool call came from.
10+type replyKey struct{}
11+
12+// withReply marks a tool call's ctx with the model message that made it.
13+// Generate's WrapTool hook sets it (ticket 93): save_answer saves the text
14+// of that message when the model wrote the answer and the call together.
15+func withReply(ctx context.Context, reply *ai.Message) context.Context {
16+ return context.WithValue(ctx, replyKey{}, reply)
17+}
18+
19+// Reply returns the model message that made the tool call ctx belongs to —
20+// its text, when any, is what the model wrote beside the call. nil outside a
21+// tool call made by Generate. The earlier history is Conversation(ctx).
22+//
23+// if r := engine.Reply(ctx); r != nil && strings.TrimSpace(r.Text()) != "" { … }
24+func Reply(ctx context.Context) *ai.Message {
25+ reply, _ := ctx.Value(replyKey{}).(*ai.Message)
26+ return reply
27+}
added internal/saved/answers.go +25 -0
new file mode 100644
@@ -0,0 +1,25 @@
1+package saved
2+
3+import (
4+ "strings"
5+
6+ "github.com/firebase/genkit/go/ai"
7+)
8+
9+// Answers is the text of every model message that spoke to the user —
10+// tool-call-only messages have none — oldest first. /copy, /save and the
11+// save_answer tool all count answers this way.
12+//
13+// last := saved.Answers(history)[len(saved.Answers(history))-1]
14+func Answers(messages []*ai.Message) []string {
15+ var out []string
16+ for _, m := range messages {
17+ if m == nil || m.Role != ai.RoleModel {
18+ continue
19+ }
20+ if text := strings.TrimSpace(m.Text()); text != "" {
21+ out = append(out, text)
22+ }
23+ }
24+ return out
25+}
new file mode 100644
@@ -0,0 +1,25 @@
1+package saved
2+
3+import (
4+ "strings"
5+
6+ "github.com/firebase/genkit/go/ai"
7+)
8+
9+// Answers is the text of every model message that spoke to the user —
10+// tool-call-only messages have none — oldest first. /copy, /save and the
11+// save_answer tool all count answers this way.
12+//
13+// last := saved.Answers(history)[len(saved.Answers(history))-1]
14+func Answers(messages []*ai.Message) []string {
15+ var out []string
16+ for _, m := range messages {
17+ if m == nil || m.Role != ai.RoleModel {
18+ continue
19+ }
20+ if text := strings.TrimSpace(m.Text()); text != "" {
21+ out = append(out, text)
22+ }
23+ }
24+ return out
25+}
added internal/saved/saved.go +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+// Package saved writes an answer of the model to a file — what the /save
2+// command and the save_answer tool share (ticket 93).
3+//
4+// Both exist so that saving an answer costs nothing: the text the model
5+// already wrote is written as it is, the model never generates it a second
6+// time. Measured on Mellum2.1 12B Thinking (2026-10-17): asking the model to
7+// put a ~1,000-word tutorial in write_file cost 10,000–16,000 generated
8+// tokens, 5 to 8 minutes, because it drafts the whole text in its reasoning
9+// and then writes it again.
10+//
11+// An existing file is never overwritten: the caller says so and the user (or
12+// the model) picks another name.
13+package saved
14+
15+import (
16+ "errors"
17+ "fmt"
18+ "os"
19+ "path/filepath"
20+ "strconv"
21+ "strings"
22+)
23+
24+// ErrExists is returned when the target file is already there.
25+var ErrExists = errors.New("the file already exists")
26+
27+// Write creates path with text, a trailing newline added when missing, and
28+// the missing parent directories. It refuses an empty text and an existing
29+// file (ErrExists), so a saved answer never replaces anything.
30+//
31+// n, err := saved.Write("notes/nushell.md", answer)
32+// // n: bytes written; errors.Is(err, saved.ErrExists) when it was there
33+func Write(path, text string) (int, error) {
34+ if strings.TrimSpace(path) == "" {
35+ return 0, errors.New("no file name given")
36+ }
37+ if strings.TrimSpace(text) == "" {
38+ return 0, errors.New("the answer is empty: nothing to save")
39+ }
40+ if !strings.HasSuffix(text, "\n") {
41+ text += "\n"
42+ }
43+ if dir := filepath.Dir(path); dir != "." {
44+ if err := os.MkdirAll(dir, 0o755); err != nil {
45+ return 0, err
46+ }
47+ }
48+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
49+ if errors.Is(err, os.ErrExist) {
50+ return 0, fmt.Errorf("%s: %w", path, ErrExists)
51+ }
52+ if err != nil {
53+ return 0, err
54+ }
55+ n, err := f.WriteString(text)
56+ if closeErr := f.Close(); err == nil {
57+ err = closeErr
58+ }
59+ return n, err
60+}
61+
62+// ParseArg reads /save's argument: a file name, then optionally which answer
63+// counting from the end (1 = the last one, the default).
64+//
65+// ParseArg("nushell.md") // "nushell.md", 1, nil
66+// ParseArg("nushell.md 2") // "nushell.md", 2, nil
67+func ParseArg(arg string) (path string, n int, err error) {
68+ fields := strings.Fields(arg)
69+ switch len(fields) {
70+ case 0:
71+ return "", 0, errors.New("/save takes a file name: /save notes.md (or /save notes.md 2 for the answer before)")
72+ case 1:
73+ return fields[0], 1, nil
74+ case 2:
75+ n, err := strconv.Atoi(fields[1])
76+ if err != nil || n < 1 {
77+ return "", 0, fmt.Errorf("/save: %q is not an answer number (1 = the last answer)", fields[1])
78+ }
79+ return fields[0], n, nil
80+ }
81+ return "", 0, errors.New("/save takes a file name and, optionally, an answer number — a name with spaces is not supported")
82+}
83+
84+// Pick returns the n-th answer from the end of answers (1 = the last).
85+func Pick(answers []string, n int) (string, error) {
86+ if n > len(answers) {
87+ return "", fmt.Errorf("nothing to save: %d answer(s) in the conversation, %d asked for", len(answers), n)
88+ }
89+ return answers[len(answers)-n], nil
90+}
91+
92+// Command runs /save over a conversation's answers and returns the line to
93+// show: what was written, or why nothing was.
94+//
95+// fmt.Println(saved.Command(answers, "nushell.md"))
96+// // 💾 saved the last answer to nushell.md (4211 bytes)
97+func Command(answers []string, arg string) string {
98+ path, n, err := ParseArg(arg)
99+ if err != nil {
100+ return "⚠️ " + err.Error()
101+ }
102+ text, err := Pick(answers, n)
103+ if err != nil {
104+ return "⚠️ " + err.Error()
105+ }
106+ written, err := Write(path, text)
107+ if errors.Is(err, ErrExists) {
108+ return fmt.Sprintf("⚠️ %s already exists — nothing was written; choose another name", path)
109+ }
110+ if err != nil {
111+ return "⚠️ /save: " + err.Error()
112+ }
113+ which := "the last answer"
114+ if n > 1 {
115+ which = fmt.Sprintf("answer %d from the end", n)
116+ }
117+ return fmt.Sprintf("💾 saved %s to %s (%d bytes)", which, path, written)
118+}
new file mode 100644
@@ -0,0 +1,118 @@
1+// Package saved writes an answer of the model to a file — what the /save
2+// command and the save_answer tool share (ticket 93).
3+//
4+// Both exist so that saving an answer costs nothing: the text the model
5+// already wrote is written as it is, the model never generates it a second
6+// time. Measured on Mellum2.1 12B Thinking (2026-10-17): asking the model to
7+// put a ~1,000-word tutorial in write_file cost 10,000–16,000 generated
8+// tokens, 5 to 8 minutes, because it drafts the whole text in its reasoning
9+// and then writes it again.
10+//
11+// An existing file is never overwritten: the caller says so and the user (or
12+// the model) picks another name.
13+package saved
14+
15+import (
16+ "errors"
17+ "fmt"
18+ "os"
19+ "path/filepath"
20+ "strconv"
21+ "strings"
22+)
23+
24+// ErrExists is returned when the target file is already there.
25+var ErrExists = errors.New("the file already exists")
26+
27+// Write creates path with text, a trailing newline added when missing, and
28+// the missing parent directories. It refuses an empty text and an existing
29+// file (ErrExists), so a saved answer never replaces anything.
30+//
31+// n, err := saved.Write("notes/nushell.md", answer)
32+// // n: bytes written; errors.Is(err, saved.ErrExists) when it was there
33+func Write(path, text string) (int, error) {
34+ if strings.TrimSpace(path) == "" {
35+ return 0, errors.New("no file name given")
36+ }
37+ if strings.TrimSpace(text) == "" {
38+ return 0, errors.New("the answer is empty: nothing to save")
39+ }
40+ if !strings.HasSuffix(text, "\n") {
41+ text += "\n"
42+ }
43+ if dir := filepath.Dir(path); dir != "." {
44+ if err := os.MkdirAll(dir, 0o755); err != nil {
45+ return 0, err
46+ }
47+ }
48+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
49+ if errors.Is(err, os.ErrExist) {
50+ return 0, fmt.Errorf("%s: %w", path, ErrExists)
51+ }
52+ if err != nil {
53+ return 0, err
54+ }
55+ n, err := f.WriteString(text)
56+ if closeErr := f.Close(); err == nil {
57+ err = closeErr
58+ }
59+ return n, err
60+}
61+
62+// ParseArg reads /save's argument: a file name, then optionally which answer
63+// counting from the end (1 = the last one, the default).
64+//
65+// ParseArg("nushell.md") // "nushell.md", 1, nil
66+// ParseArg("nushell.md 2") // "nushell.md", 2, nil
67+func ParseArg(arg string) (path string, n int, err error) {
68+ fields := strings.Fields(arg)
69+ switch len(fields) {
70+ case 0:
71+ return "", 0, errors.New("/save takes a file name: /save notes.md (or /save notes.md 2 for the answer before)")
72+ case 1:
73+ return fields[0], 1, nil
74+ case 2:
75+ n, err := strconv.Atoi(fields[1])
76+ if err != nil || n < 1 {
77+ return "", 0, fmt.Errorf("/save: %q is not an answer number (1 = the last answer)", fields[1])
78+ }
79+ return fields[0], n, nil
80+ }
81+ return "", 0, errors.New("/save takes a file name and, optionally, an answer number — a name with spaces is not supported")
82+}
83+
84+// Pick returns the n-th answer from the end of answers (1 = the last).
85+func Pick(answers []string, n int) (string, error) {
86+ if n > len(answers) {
87+ return "", fmt.Errorf("nothing to save: %d answer(s) in the conversation, %d asked for", len(answers), n)
88+ }
89+ return answers[len(answers)-n], nil
90+}
91+
92+// Command runs /save over a conversation's answers and returns the line to
93+// show: what was written, or why nothing was.
94+//
95+// fmt.Println(saved.Command(answers, "nushell.md"))
96+// // 💾 saved the last answer to nushell.md (4211 bytes)
97+func Command(answers []string, arg string) string {
98+ path, n, err := ParseArg(arg)
99+ if err != nil {
100+ return "⚠️ " + err.Error()
101+ }
102+ text, err := Pick(answers, n)
103+ if err != nil {
104+ return "⚠️ " + err.Error()
105+ }
106+ written, err := Write(path, text)
107+ if errors.Is(err, ErrExists) {
108+ return fmt.Sprintf("⚠️ %s already exists — nothing was written; choose another name", path)
109+ }
110+ if err != nil {
111+ return "⚠️ /save: " + err.Error()
112+ }
113+ which := "the last answer"
114+ if n > 1 {
115+ which = fmt.Sprintf("answer %d from the end", n)
116+ }
117+ return fmt.Sprintf("💾 saved %s to %s (%d bytes)", which, path, written)
118+}
added internal/saved/saved_test.go +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+package saved
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+)
10+
11+// Write creates the file and its directories, adds the final newline, and
12+// never replaces an existing file.
13+func TestWrite(t *testing.T) {
14+ dir := t.TempDir()
15+ path := filepath.Join(dir, "notes", "nu.md")
16+ n, err := Write(path, "# Nu")
17+ if err != nil || n != len("# Nu\n") {
18+ t.Fatalf("Write = %d, %v", n, err)
19+ }
20+ if b, _ := os.ReadFile(path); string(b) != "# Nu\n" {
21+ t.Errorf("file = %q", b)
22+ }
23+ if _, err := Write(path, "other"); !errors.Is(err, ErrExists) {
24+ t.Errorf("second Write = %v, want ErrExists", err)
25+ }
26+ if b, _ := os.ReadFile(path); string(b) != "# Nu\n" {
27+ t.Errorf("the existing file was changed: %q", b)
28+ }
29+ if _, err := Write(filepath.Join(dir, "x.md"), " \n"); err == nil {
30+ t.Error("an empty answer was saved")
31+ }
32+ if _, err := Write(" ", "text"); err == nil {
33+ t.Error("a blank name was accepted")
34+ }
35+}
36+
37+func TestParseArg(t *testing.T) {
38+ cases := []struct {
39+ arg string
40+ path string
41+ n int
42+ ok bool
43+ }{
44+ {"nu.md", "nu.md", 1, true},
45+ {" nu.md 2 ", "nu.md", 2, true},
46+ {"", "", 0, false},
47+ {"nu.md zero", "", 0, false},
48+ {"nu.md 0", "", 0, false},
49+ {"my notes.md 2", "", 0, false},
50+ }
51+ for _, c := range cases {
52+ path, n, err := ParseArg(c.arg)
53+ if (err == nil) != c.ok || path != c.path || n != c.n {
54+ t.Errorf("ParseArg(%q) = %q, %d, %v", c.arg, path, n, err)
55+ }
56+ }
57+}
58+
59+// Command picks the answer, writes it, and says what happened in one line.
60+func TestCommand(t *testing.T) {
61+ dir := t.TempDir()
62+ answers := []string{"first", "second"}
63+ path := filepath.Join(dir, "a.md")
64+
65+ if got := Command(answers, path); !strings.HasPrefix(got, "💾 saved the last answer to ") {
66+ t.Errorf("save last = %q", got)
67+ }
68+ if b, _ := os.ReadFile(path); string(b) != "second\n" {
69+ t.Errorf("file = %q", b)
70+ }
71+ if got := Command(answers, filepath.Join(dir, "b.md")+" 2"); !strings.Contains(got, "answer 2 from the end") {
72+ t.Errorf("save 2 = %q", got)
73+ }
74+ if got := Command(answers, path); !strings.Contains(got, "already exists — nothing was written") {
75+ t.Errorf("existing = %q", got)
76+ }
77+ if got := Command(answers, filepath.Join(dir, "c.md")+" 3"); !strings.Contains(got, "2 answer(s) in the conversation") {
78+ t.Errorf("too far = %q", got)
79+ }
80+ if got := Command(nil, ""); !strings.Contains(got, "/save takes a file name") {
81+ t.Errorf("no arg = %q", got)
82+ }
83+}
new file mode 100644
@@ -0,0 +1,83 @@
1+package saved
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+)
10+
11+// Write creates the file and its directories, adds the final newline, and
12+// never replaces an existing file.
13+func TestWrite(t *testing.T) {
14+ dir := t.TempDir()
15+ path := filepath.Join(dir, "notes", "nu.md")
16+ n, err := Write(path, "# Nu")
17+ if err != nil || n != len("# Nu\n") {
18+ t.Fatalf("Write = %d, %v", n, err)
19+ }
20+ if b, _ := os.ReadFile(path); string(b) != "# Nu\n" {
21+ t.Errorf("file = %q", b)
22+ }
23+ if _, err := Write(path, "other"); !errors.Is(err, ErrExists) {
24+ t.Errorf("second Write = %v, want ErrExists", err)
25+ }
26+ if b, _ := os.ReadFile(path); string(b) != "# Nu\n" {
27+ t.Errorf("the existing file was changed: %q", b)
28+ }
29+ if _, err := Write(filepath.Join(dir, "x.md"), " \n"); err == nil {
30+ t.Error("an empty answer was saved")
31+ }
32+ if _, err := Write(" ", "text"); err == nil {
33+ t.Error("a blank name was accepted")
34+ }
35+}
36+
37+func TestParseArg(t *testing.T) {
38+ cases := []struct {
39+ arg string
40+ path string
41+ n int
42+ ok bool
43+ }{
44+ {"nu.md", "nu.md", 1, true},
45+ {" nu.md 2 ", "nu.md", 2, true},
46+ {"", "", 0, false},
47+ {"nu.md zero", "", 0, false},
48+ {"nu.md 0", "", 0, false},
49+ {"my notes.md 2", "", 0, false},
50+ }
51+ for _, c := range cases {
52+ path, n, err := ParseArg(c.arg)
53+ if (err == nil) != c.ok || path != c.path || n != c.n {
54+ t.Errorf("ParseArg(%q) = %q, %d, %v", c.arg, path, n, err)
55+ }
56+ }
57+}
58+
59+// Command picks the answer, writes it, and says what happened in one line.
60+func TestCommand(t *testing.T) {
61+ dir := t.TempDir()
62+ answers := []string{"first", "second"}
63+ path := filepath.Join(dir, "a.md")
64+
65+ if got := Command(answers, path); !strings.HasPrefix(got, "💾 saved the last answer to ") {
66+ t.Errorf("save last = %q", got)
67+ }
68+ if b, _ := os.ReadFile(path); string(b) != "second\n" {
69+ t.Errorf("file = %q", b)
70+ }
71+ if got := Command(answers, filepath.Join(dir, "b.md")+" 2"); !strings.Contains(got, "answer 2 from the end") {
72+ t.Errorf("save 2 = %q", got)
73+ }
74+ if got := Command(answers, path); !strings.Contains(got, "already exists — nothing was written") {
75+ t.Errorf("existing = %q", got)
76+ }
77+ if got := Command(answers, filepath.Join(dir, "c.md")+" 3"); !strings.Contains(got, "2 answer(s) in the conversation") {
78+ t.Errorf("too far = %q", got)
79+ }
80+ if got := Command(nil, ""); !strings.Contains(got, "/save takes a file name") {
81+ t.Errorf("no arg = %q", got)
82+ }
83+}
modified internal/tools/plan_actions.go +42 -0
@@ -3,6 +3,7 @@ package tools
33 import (
44 "encoding/json"
55 "fmt"
6+ "slices"
67 "strings"
78 )
89
@@ -61,7 +62,14 @@ func planGet(in planInput) string {
6162 return planJSON(PlanOutput{Result: msg, Plan: plan})
6263 }
6364
65+// planStatuses are the values `update` accepts, in the order they are listed
66+// to the model.
67+var planStatuses = []string{"open", "in_progress", "done", "failed"}
68+
6469 func planUpdate(in planInput) string {
70+ if !slices.Contains(planStatuses, in.Status) {
71+ return invalidUpdate(in)
72+ }
6573 _, msg, err := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)
6674 if err != nil {
6775 return planError(err)
@@ -70,6 +78,40 @@ func planUpdate(in planInput) string {
7078 return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
7179 }
7280
81+// invalidUpdate answers an `update` without a valid status (ticket 91).
82+//
83+// The store's own message for a missing status — "invalid status" followed by
84+// an empty quoted value and the four allowed ones — named neither the missing
85+// field nor what `update` is for. Mellum2.1 12B sent
86+// {"action":"update","taskId":1,"tasks":[…]} about thirty times in a row,
87+// trying to edit the plan's tasks, until maxTurns ended the turn; its calls
88+// differed slightly each time, so the loop detector never fired. The answer
89+// therefore quotes the call back, names the field, says that `tasks` is not
90+// read here and how to change tasks, and gives a call that works.
91+func invalidUpdate(in planInput) string {
92+ problem := fmt.Sprintf("%q is not a status", in.Status)
93+ if in.Status == "" {
94+ problem = `"status" is missing`
95+ }
96+ name := in.Name
97+ if name == "" {
98+ name = "user-request-1"
99+ }
100+ taskID := in.TaskID
101+ if taskID <= 0 {
102+ taskID = 1
103+ }
104+ sent, _ := json.Marshal(in)
105+ var b strings.Builder
106+ fmt.Fprintf(&b, "[error: plan update changes the status of ONE task, and %s. You sent: %s. ", problem, sent)
107+ fmt.Fprintf(&b, `"status" must be one of: %s. `, strings.Join(planStatuses, ", "))
108+ if len(in.Tasks) > 0 {
109+ b.WriteString(`"tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again. `)
110+ }
111+ fmt.Fprintf(&b, `A valid call: {"action":"update","name":%q,"taskId":%d,"status":"done"}]`, name, taskID)
112+ return b.String()
113+}
114+
73115 func planNext(in planInput) string {
74116 _, nextTask, msg, err := planStore.NextTask(in.Name)
75117 if err != nil {
@@ -3,6 +3,7 @@ package tools
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5 "fmt"5 "fmt"
6+ "slices"
6 "strings"7 "strings"
7 )8 )
8 9
@@ -61,7 +62,14 @@ func planGet(in planInput) string {
61 return planJSON(PlanOutput{Result: msg, Plan: plan})62 return planJSON(PlanOutput{Result: msg, Plan: plan})
62 }63 }
63 64
65+// planStatuses are the values `update` accepts, in the order they are listed
66+// to the model.
67+var planStatuses = []string{"open", "in_progress", "done", "failed"}
68+
64 func planUpdate(in planInput) string {69 func planUpdate(in planInput) string {
70+ if !slices.Contains(planStatuses, in.Status) {
71+ return invalidUpdate(in)
72+ }
65 _, msg, err := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)73 _, msg, err := planStore.UpdateTask(in.Name, in.TaskID, in.Status, in.Message)
66 if err != nil {74 if err != nil {
67 return planError(err)75 return planError(err)
@@ -70,6 +78,40 @@ func planUpdate(in planInput) string {
70 return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)78 return fmt.Sprintf(`{"result":"%s","status":"%s"}`, msg, statusLine)
71 }79 }
72 80
81+// invalidUpdate answers an `update` without a valid status (ticket 91).
82+//
83+// The store's own message for a missing status — "invalid status" followed by
84+// an empty quoted value and the four allowed ones — named neither the missing
85+// field nor what `update` is for. Mellum2.1 12B sent
86+// {"action":"update","taskId":1,"tasks":[…]} about thirty times in a row,
87+// trying to edit the plan's tasks, until maxTurns ended the turn; its calls
88+// differed slightly each time, so the loop detector never fired. The answer
89+// therefore quotes the call back, names the field, says that `tasks` is not
90+// read here and how to change tasks, and gives a call that works.
91+func invalidUpdate(in planInput) string {
92+ problem := fmt.Sprintf("%q is not a status", in.Status)
93+ if in.Status == "" {
94+ problem = `"status" is missing`
95+ }
96+ name := in.Name
97+ if name == "" {
98+ name = "user-request-1"
99+ }
100+ taskID := in.TaskID
101+ if taskID <= 0 {
102+ taskID = 1
103+ }
104+ sent, _ := json.Marshal(in)
105+ var b strings.Builder
106+ fmt.Fprintf(&b, "[error: plan update changes the status of ONE task, and %s. You sent: %s. ", problem, sent)
107+ fmt.Fprintf(&b, `"status" must be one of: %s. `, strings.Join(planStatuses, ", "))
108+ if len(in.Tasks) > 0 {
109+ b.WriteString(`"tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again. `)
110+ }
111+ fmt.Fprintf(&b, `A valid call: {"action":"update","name":%q,"taskId":%d,"status":"done"}]`, name, taskID)
112+ return b.String()
113+}
114+
73 func planNext(in planInput) string {115 func planNext(in planInput) string {
74 _, nextTask, msg, err := planStore.NextTask(in.Name)116 _, nextTask, msg, err := planStore.NextTask(in.Name)
75 if err != nil {117 if err != nil {
modified internal/tools/plan_actions_test.go +42 -0
@@ -41,3 +41,45 @@ func TestRunPlanAction(t *testing.T) {
4141 }
4242 }
4343 }
44+
45+// The call Mellum2.1 repeated about thirty times (ticket 91): an update with
46+// tasks and no status. The answer names the missing field, quotes the call,
47+// says tasks are not read by update, and gives a call that works — and the
48+// plan is left untouched.
49+func TestPlanUpdateWithoutStatusExplainsTheCall(t *testing.T) {
50+ ClearAllPlansForTesting()
51+ runPlanAction(planInput{Action: "create", Name: "user-request-1", Tasks: []PlanTaskInput{{Description: "Intro"}}}, true)
52+
53+ got := runPlanAction(planInput{
54+ Action: "update", Name: "user-request-1", TaskID: 1,
55+ Tasks: []PlanTaskInput{{ID: 1, Description: "Write introduction in French about Nu shell"}},
56+ }, true)
57+ for _, want := range []string{
58+ `plan update changes the status of ONE task, and "status" is missing`,
59+ `You sent: {"action":"update","name":"user-request-1","tasks":[{"id":1,"description":"Write introduction in French about Nu shell"}],"taskId":1}`,
60+ `"status" must be one of: open, in_progress, done, failed`,
61+ `"tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again`,
62+ `A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}`,
63+ } {
64+ if !strings.Contains(got, want) {
65+ t.Errorf("answer %s\nlacks %s", got, want)
66+ }
67+ }
68+ if status := runPlanAction(planInput{Action: "status", Name: "user-request-1"}, true); !strings.Contains(status, "Task 1: Intro (open)") {
69+ t.Errorf("the refused update changed the plan: %s", status)
70+ }
71+}
72+
73+// A status that is not one of the four is refused the same way, without the
74+// sentence about tasks when none were sent, and with defaults in the example
75+// when the call gave no name or task.
76+func TestPlanUpdateWithAnUnknownStatus(t *testing.T) {
77+ ClearAllPlansForTesting()
78+ got := runPlanAction(planInput{Action: "update", Status: "finished"}, true)
79+ if !strings.Contains(got, `and "finished" is not a status`) || strings.Contains(got, `"tasks" is ignored`) {
80+ t.Errorf("answer %s", got)
81+ }
82+ if !strings.Contains(got, `A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}`) {
83+ t.Errorf("answer %s lacks the default example", got)
84+ }
85+}
@@ -41,3 +41,45 @@ func TestRunPlanAction(t *testing.T) {
41 }41 }
42 }42 }
43 }43 }
44+
45+// The call Mellum2.1 repeated about thirty times (ticket 91): an update with
46+// tasks and no status. The answer names the missing field, quotes the call,
47+// says tasks are not read by update, and gives a call that works — and the
48+// plan is left untouched.
49+func TestPlanUpdateWithoutStatusExplainsTheCall(t *testing.T) {
50+ ClearAllPlansForTesting()
51+ runPlanAction(planInput{Action: "create", Name: "user-request-1", Tasks: []PlanTaskInput{{Description: "Intro"}}}, true)
52+
53+ got := runPlanAction(planInput{
54+ Action: "update", Name: "user-request-1", TaskID: 1,
55+ Tasks: []PlanTaskInput{{ID: 1, Description: "Write introduction in French about Nu shell"}},
56+ }, true)
57+ for _, want := range []string{
58+ `plan update changes the status of ONE task, and "status" is missing`,
59+ `You sent: {"action":"update","name":"user-request-1","tasks":[{"id":1,"description":"Write introduction in French about Nu shell"}],"taskId":1}`,
60+ `"status" must be one of: open, in_progress, done, failed`,
61+ `"tasks" is ignored by update (only create reads it): to change a plan's tasks, delete the plan and create it again`,
62+ `A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}`,
63+ } {
64+ if !strings.Contains(got, want) {
65+ t.Errorf("answer %s\nlacks %s", got, want)
66+ }
67+ }
68+ if status := runPlanAction(planInput{Action: "status", Name: "user-request-1"}, true); !strings.Contains(status, "Task 1: Intro (open)") {
69+ t.Errorf("the refused update changed the plan: %s", status)
70+ }
71+}
72+
73+// A status that is not one of the four is refused the same way, without the
74+// sentence about tasks when none were sent, and with defaults in the example
75+// when the call gave no name or task.
76+func TestPlanUpdateWithAnUnknownStatus(t *testing.T) {
77+ ClearAllPlansForTesting()
78+ got := runPlanAction(planInput{Action: "update", Status: "finished"}, true)
79+ if !strings.Contains(got, `and "finished" is not a status`) || strings.Contains(got, `"tasks" is ignored`) {
80+ t.Errorf("answer %s", got)
81+ }
82+ if !strings.Contains(got, `A valid call: {"action":"update","name":"user-request-1","taskId":1,"status":"done"}`) {
83+ t.Errorf("answer %s lacks the default example", got)
84+ }
85+}
added internal/tools/saveanswer.go +117 -0
new file mode 100644
@@ -0,0 +1,117 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+
9+ "mm/internal/consent"
10+ "mm/internal/detector"
11+ "mm/internal/engine"
12+ "mm/internal/saved"
13+ "mm/internal/spinner"
14+ "mm/internal/ui"
15+
16+ "github.com/firebase/genkit/go/ai"
17+ "github.com/firebase/genkit/go/genkit"
18+)
19+
20+// --- save_answer (ticket 93) -------------------------------------------------
21+//
22+// Writes an answer the model ALREADY gave to a file: the model sends a file
23+// name, mm writes the text. Saving through write_file makes the model
24+// generate the whole text again — measured on Mellum2.1 12B Thinking, 10,000
25+// to 16,000 tokens and 5 to 8 minutes for a ~1,000-word tutorial (see
26+// internal/saved).
27+//
28+// Which answer: the text the model wrote in the same reply as the call, when
29+// there is one ("here is the tutorial … [save_answer]"); otherwise its
30+// previous answer ("save it in nushell.md" after the tutorial was given).
31+// An existing file is never overwritten.
32+
33+type saveAnswerInput struct {
34+ Path string `json:"path" jsonschema_description:"The file to create, relative to the working directory, e.g. nushell-first-script.md. It must not exist yet."`
35+}
36+
37+// SaveAnswer declares the `save_answer` tool (config saveAnswerTool).
38+//
39+// set.tools = append(set.tools, tools.SaveAnswer(e.G, d))
40+func SaveAnswer(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
41+ description := `Save an answer you already gave to a Markdown file, as it is: you only give the file name, you do NOT write the text again.
42+It saves the text of your CURRENT reply when it has one; otherwise your PREVIOUS answer. So, when the user asks to save an answer you gave before, call this tool alone, with no text in your reply.
43+Use it only when the user asks to save, write or export an answer to a file. It never overwrites a file: if the name is taken, it says so — pick another name or ask the user.`
44+
45+ return genkit.DefineTool(g, "save_answer", description,
46+ func(tctx *ai.ToolContext, in saveAnswerInput) (string, error) {
47+ text, which := answerToSave(engine.Reply(tctx), engine.Conversation(tctx))
48+ if s := ui.ActiveSink(); s != nil {
49+ return saveAnswerEvented(tctx, s, d, in, text, which)
50+ }
51+ title := fmt.Sprintf("save_answer: %s (%s)", in.Path, which)
52+ announce("💾 " + title)
53+ if text != "" && !confirmed(tctx, "save_answer", title) {
54+ return refuse(d, "save_answer", in.Path, "[save_answer rejected by the user]")
55+ }
56+ out, _ := saveAnswer(ui.Resolve(in.Path), in.Path, text, which)
57+ spinner.Suspend(func() { echoOutput(out) })
58+ return record(d, "save_answer", in.Path, out)
59+ })
60+}
61+
62+// saveAnswerEvented is save_answer for an event-consuming front end (ACP,
63+// the window): it writes a file, so it asks first when yolo is off.
64+func saveAnswerEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in saveAnswerInput, text, which string) (string, error) {
65+ path := ui.Resolve(in.Path)
66+ ev := ui.ToolEvent{
67+ ID: ui.NextCallID(),
68+ Tool: "save_answer",
69+ Title: fmt.Sprintf("save_answer: %s (%s)", in.Path, which),
70+ Kind: "edit",
71+ Input: map[string]any{"path": in.Path},
72+ Path: absolute(path),
73+ }
74+ s.ToolStart(ev)
75+ if text != "" && !consent.Gate(ctx, s, ev) {
76+ out := "[save_answer rejected by the user]"
77+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
78+ return record(d, "save_answer", in.Path, out)
79+ }
80+ s.ToolRunning(ev.ID)
81+ out, failed := saveAnswer(path, in.Path, text, which)
82+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
83+ return record(d, "save_answer", in.Path, out)
84+}
85+
86+// answerToSave picks the text: the reply carrying the call when it has text,
87+// else the last answer of the history. which names the choice for the
88+// display line and the result.
89+func answerToSave(reply *ai.Message, history []*ai.Message) (text, which string) {
90+ if reply != nil {
91+ if t := strings.TrimSpace(reply.Text()); t != "" {
92+ return t, "this answer"
93+ }
94+ }
95+ answers := saved.Answers(history)
96+ if len(answers) == 0 {
97+ return "", "no answer yet"
98+ }
99+ return answers[len(answers)-1], "the previous answer"
100+}
101+
102+// saveAnswer writes text to path (shown as name) and returns the result the
103+// model reads, and whether it failed.
104+func saveAnswer(path, name, text, which string) (string, bool) {
105+ if text == "" {
106+ return "[save_answer: there is no answer to save yet — write the answer in your reply first, then call save_answer with the file name]", true
107+ }
108+ n, err := saved.Write(path, text)
109+ if errors.Is(err, saved.ErrExists) {
110+ return fmt.Sprintf("[save_answer: %s already exists — nothing was written. Pick another name, or ask the user]", name), true
111+ }
112+ if err != nil {
113+ return fmt.Sprintf("[save_answer: %v]", err), true
114+ }
115+ first, _, _ := strings.Cut(text, "\n")
116+ return fmt.Sprintf("saved %s to %s (%d bytes), starting with: %s", which, name, n, clip(first)), false
117+}
new file mode 100644
@@ -0,0 +1,117 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+
9+ "mm/internal/consent"
10+ "mm/internal/detector"
11+ "mm/internal/engine"
12+ "mm/internal/saved"
13+ "mm/internal/spinner"
14+ "mm/internal/ui"
15+
16+ "github.com/firebase/genkit/go/ai"
17+ "github.com/firebase/genkit/go/genkit"
18+)
19+
20+// --- save_answer (ticket 93) -------------------------------------------------
21+//
22+// Writes an answer the model ALREADY gave to a file: the model sends a file
23+// name, mm writes the text. Saving through write_file makes the model
24+// generate the whole text again — measured on Mellum2.1 12B Thinking, 10,000
25+// to 16,000 tokens and 5 to 8 minutes for a ~1,000-word tutorial (see
26+// internal/saved).
27+//
28+// Which answer: the text the model wrote in the same reply as the call, when
29+// there is one ("here is the tutorial … [save_answer]"); otherwise its
30+// previous answer ("save it in nushell.md" after the tutorial was given).
31+// An existing file is never overwritten.
32+
33+type saveAnswerInput struct {
34+ Path string `json:"path" jsonschema_description:"The file to create, relative to the working directory, e.g. nushell-first-script.md. It must not exist yet."`
35+}
36+
37+// SaveAnswer declares the `save_answer` tool (config saveAnswerTool).
38+//
39+// set.tools = append(set.tools, tools.SaveAnswer(e.G, d))
40+func SaveAnswer(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
41+ description := `Save an answer you already gave to a Markdown file, as it is: you only give the file name, you do NOT write the text again.
42+It saves the text of your CURRENT reply when it has one; otherwise your PREVIOUS answer. So, when the user asks to save an answer you gave before, call this tool alone, with no text in your reply.
43+Use it only when the user asks to save, write or export an answer to a file. It never overwrites a file: if the name is taken, it says so — pick another name or ask the user.`
44+
45+ return genkit.DefineTool(g, "save_answer", description,
46+ func(tctx *ai.ToolContext, in saveAnswerInput) (string, error) {
47+ text, which := answerToSave(engine.Reply(tctx), engine.Conversation(tctx))
48+ if s := ui.ActiveSink(); s != nil {
49+ return saveAnswerEvented(tctx, s, d, in, text, which)
50+ }
51+ title := fmt.Sprintf("save_answer: %s (%s)", in.Path, which)
52+ announce("💾 " + title)
53+ if text != "" && !confirmed(tctx, "save_answer", title) {
54+ return refuse(d, "save_answer", in.Path, "[save_answer rejected by the user]")
55+ }
56+ out, _ := saveAnswer(ui.Resolve(in.Path), in.Path, text, which)
57+ spinner.Suspend(func() { echoOutput(out) })
58+ return record(d, "save_answer", in.Path, out)
59+ })
60+}
61+
62+// saveAnswerEvented is save_answer for an event-consuming front end (ACP,
63+// the window): it writes a file, so it asks first when yolo is off.
64+func saveAnswerEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in saveAnswerInput, text, which string) (string, error) {
65+ path := ui.Resolve(in.Path)
66+ ev := ui.ToolEvent{
67+ ID: ui.NextCallID(),
68+ Tool: "save_answer",
69+ Title: fmt.Sprintf("save_answer: %s (%s)", in.Path, which),
70+ Kind: "edit",
71+ Input: map[string]any{"path": in.Path},
72+ Path: absolute(path),
73+ }
74+ s.ToolStart(ev)
75+ if text != "" && !consent.Gate(ctx, s, ev) {
76+ out := "[save_answer rejected by the user]"
77+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
78+ return record(d, "save_answer", in.Path, out)
79+ }
80+ s.ToolRunning(ev.ID)
81+ out, failed := saveAnswer(path, in.Path, text, which)
82+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
83+ return record(d, "save_answer", in.Path, out)
84+}
85+
86+// answerToSave picks the text: the reply carrying the call when it has text,
87+// else the last answer of the history. which names the choice for the
88+// display line and the result.
89+func answerToSave(reply *ai.Message, history []*ai.Message) (text, which string) {
90+ if reply != nil {
91+ if t := strings.TrimSpace(reply.Text()); t != "" {
92+ return t, "this answer"
93+ }
94+ }
95+ answers := saved.Answers(history)
96+ if len(answers) == 0 {
97+ return "", "no answer yet"
98+ }
99+ return answers[len(answers)-1], "the previous answer"
100+}
101+
102+// saveAnswer writes text to path (shown as name) and returns the result the
103+// model reads, and whether it failed.
104+func saveAnswer(path, name, text, which string) (string, bool) {
105+ if text == "" {
106+ return "[save_answer: there is no answer to save yet — write the answer in your reply first, then call save_answer with the file name]", true
107+ }
108+ n, err := saved.Write(path, text)
109+ if errors.Is(err, saved.ErrExists) {
110+ return fmt.Sprintf("[save_answer: %s already exists — nothing was written. Pick another name, or ask the user]", name), true
111+ }
112+ if err != nil {
113+ return fmt.Sprintf("[save_answer: %v]", err), true
114+ }
115+ first, _, _ := strings.Cut(text, "\n")
116+ return fmt.Sprintf("saved %s to %s (%d bytes), starting with: %s", which, name, n, clip(first)), false
117+}
added internal/tools/saveanswer_test.go +106 -0
new file mode 100644
@@ -0,0 +1,106 @@
1+package tools
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/detector"
11+ "mm/internal/ui"
12+
13+ "github.com/firebase/genkit/go/ai"
14+)
15+
16+// inSessionDir makes sink the active sink for the test: ui.Resolve then
17+// resolves paths in the sink's directory, as in an editor session.
18+func inSessionDir(t *testing.T, sink *recordingSink) *recordingSink {
19+ t.Helper()
20+ ui.SetActive(sink)
21+ t.Cleanup(func() { ui.SetActive(nil) })
22+ return sink
23+}
24+
25+// The text beside the call wins; a call alone saves the previous answer;
26+// with no answer at all there is nothing to save.
27+func TestAnswerToSave(t *testing.T) {
28+ history := []*ai.Message{
29+ ai.NewUserTextMessage("teach me nushell"),
30+ ai.NewModelTextMessage("# Nushell\nfirst steps"),
31+ ai.NewUserTextMessage("save it in nu.md"),
32+ }
33+ if text, which := answerToSave(ai.NewModelTextMessage(" # Here\n "), history); text != "# Here" || which != "this answer" {
34+ t.Errorf("with text in the reply: %q, %q", text, which)
35+ }
36+ if text, which := answerToSave(&ai.Message{Role: ai.RoleModel}, history); text != "# Nushell\nfirst steps" || which != "the previous answer" {
37+ t.Errorf("call alone: %q, %q", text, which)
38+ }
39+ if text, which := answerToSave(nil, history[:1]); text != "" || which != "no answer yet" {
40+ t.Errorf("no answer: %q, %q", text, which)
41+ }
42+}
43+
44+// With yolo on the answer is written at once, in the session's directory;
45+// the result names the choice and quotes the first line.
46+func TestSaveAnswerWritesTheAnswer(t *testing.T) {
47+ dir := t.TempDir()
48+ sink := inSessionDir(t, &recordingSink{dir: dir})
49+ out, err := saveAnswerEvented(context.Background(), sink, detector.NewLoopDetector(10, 3),
50+ saveAnswerInput{Path: "nu.md"}, "# Nushell\nfirst steps", "the previous answer")
51+ if err != nil || sink.asked != 0 || sink.ended.Failed {
52+ t.Fatalf("out %q, err %v, asked %d, ended %+v", out, err, sink.asked, sink.ended)
53+ }
54+ if !strings.HasPrefix(out, "saved the previous answer to nu.md (") || !strings.HasSuffix(out, "starting with: # Nushell") {
55+ t.Errorf("result %q", out)
56+ }
57+ if b, _ := os.ReadFile(filepath.Join(dir, "nu.md")); string(b) != "# Nushell\nfirst steps\n" {
58+ t.Errorf("file %q", b)
59+ }
60+ if sink.started.Kind != "edit" || sink.started.Path != filepath.Join(dir, "nu.md") {
61+ t.Errorf("event %+v", sink.started)
62+ }
63+}
64+
65+// An existing file is refused and left as it was; with no answer nothing is
66+// asked and nothing is written.
67+func TestSaveAnswerRefusals(t *testing.T) {
68+ dir := t.TempDir()
69+ if err := os.WriteFile(filepath.Join(dir, "nu.md"), []byte("keep me"), 0o644); err != nil {
70+ t.Fatal(err)
71+ }
72+ sink := inSessionDir(t, &recordingSink{dir: dir})
73+ d := detector.NewLoopDetector(10, 3)
74+ out, _ := saveAnswerEvented(context.Background(), sink, d, saveAnswerInput{Path: "nu.md"}, "new text", "this answer")
75+ if !strings.Contains(out, "nu.md already exists — nothing was written") || !sink.ended.Failed {
76+ t.Errorf("existing: %q", out)
77+ }
78+ if b, _ := os.ReadFile(filepath.Join(dir, "nu.md")); string(b) != "keep me" {
79+ t.Errorf("the existing file changed: %q", b)
80+ }
81+
82+ yoloOff(t)
83+ sink = inSessionDir(t, &recordingSink{dir: dir, allow: true})
84+ out, _ = saveAnswerEvented(context.Background(), sink, d, saveAnswerInput{Path: "empty.md"}, "", "no answer yet")
85+ if sink.asked != 0 || !strings.Contains(out, "there is no answer to save yet") {
86+ t.Errorf("no answer: asked %d, %q", sink.asked, out)
87+ }
88+ if _, err := os.Stat(filepath.Join(dir, "empty.md")); !os.IsNotExist(err) {
89+ t.Error("a file was written with no answer")
90+ }
91+}
92+
93+// With yolo off the write waits for the user, and a refusal writes nothing.
94+func TestSaveAnswerAsksWhenYoloIsOff(t *testing.T) {
95+ yoloOff(t)
96+ dir := t.TempDir()
97+ sink := inSessionDir(t, &recordingSink{dir: dir, allow: false})
98+ out, _ := saveAnswerEvented(context.Background(), sink, detector.NewLoopDetector(10, 3),
99+ saveAnswerInput{Path: "nu.md"}, "text", "this answer")
100+ if sink.asked != 1 || out != "[save_answer rejected by the user]" {
101+ t.Errorf("asked %d, out %q", sink.asked, out)
102+ }
103+ if _, err := os.Stat(filepath.Join(dir, "nu.md")); !os.IsNotExist(err) {
104+ t.Error("a refused save wrote the file")
105+ }
106+}
new file mode 100644
@@ -0,0 +1,106 @@
1+package tools
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/detector"
11+ "mm/internal/ui"
12+
13+ "github.com/firebase/genkit/go/ai"
14+)
15+
16+// inSessionDir makes sink the active sink for the test: ui.Resolve then
17+// resolves paths in the sink's directory, as in an editor session.
18+func inSessionDir(t *testing.T, sink *recordingSink) *recordingSink {
19+ t.Helper()
20+ ui.SetActive(sink)
21+ t.Cleanup(func() { ui.SetActive(nil) })
22+ return sink
23+}
24+
25+// The text beside the call wins; a call alone saves the previous answer;
26+// with no answer at all there is nothing to save.
27+func TestAnswerToSave(t *testing.T) {
28+ history := []*ai.Message{
29+ ai.NewUserTextMessage("teach me nushell"),
30+ ai.NewModelTextMessage("# Nushell\nfirst steps"),
31+ ai.NewUserTextMessage("save it in nu.md"),
32+ }
33+ if text, which := answerToSave(ai.NewModelTextMessage(" # Here\n "), history); text != "# Here" || which != "this answer" {
34+ t.Errorf("with text in the reply: %q, %q", text, which)
35+ }
36+ if text, which := answerToSave(&ai.Message{Role: ai.RoleModel}, history); text != "# Nushell\nfirst steps" || which != "the previous answer" {
37+ t.Errorf("call alone: %q, %q", text, which)
38+ }
39+ if text, which := answerToSave(nil, history[:1]); text != "" || which != "no answer yet" {
40+ t.Errorf("no answer: %q, %q", text, which)
41+ }
42+}
43+
44+// With yolo on the answer is written at once, in the session's directory;
45+// the result names the choice and quotes the first line.
46+func TestSaveAnswerWritesTheAnswer(t *testing.T) {
47+ dir := t.TempDir()
48+ sink := inSessionDir(t, &recordingSink{dir: dir})
49+ out, err := saveAnswerEvented(context.Background(), sink, detector.NewLoopDetector(10, 3),
50+ saveAnswerInput{Path: "nu.md"}, "# Nushell\nfirst steps", "the previous answer")
51+ if err != nil || sink.asked != 0 || sink.ended.Failed {
52+ t.Fatalf("out %q, err %v, asked %d, ended %+v", out, err, sink.asked, sink.ended)
53+ }
54+ if !strings.HasPrefix(out, "saved the previous answer to nu.md (") || !strings.HasSuffix(out, "starting with: # Nushell") {
55+ t.Errorf("result %q", out)
56+ }
57+ if b, _ := os.ReadFile(filepath.Join(dir, "nu.md")); string(b) != "# Nushell\nfirst steps\n" {
58+ t.Errorf("file %q", b)
59+ }
60+ if sink.started.Kind != "edit" || sink.started.Path != filepath.Join(dir, "nu.md") {
61+ t.Errorf("event %+v", sink.started)
62+ }
63+}
64+
65+// An existing file is refused and left as it was; with no answer nothing is
66+// asked and nothing is written.
67+func TestSaveAnswerRefusals(t *testing.T) {
68+ dir := t.TempDir()
69+ if err := os.WriteFile(filepath.Join(dir, "nu.md"), []byte("keep me"), 0o644); err != nil {
70+ t.Fatal(err)
71+ }
72+ sink := inSessionDir(t, &recordingSink{dir: dir})
73+ d := detector.NewLoopDetector(10, 3)
74+ out, _ := saveAnswerEvented(context.Background(), sink, d, saveAnswerInput{Path: "nu.md"}, "new text", "this answer")
75+ if !strings.Contains(out, "nu.md already exists — nothing was written") || !sink.ended.Failed {
76+ t.Errorf("existing: %q", out)
77+ }
78+ if b, _ := os.ReadFile(filepath.Join(dir, "nu.md")); string(b) != "keep me" {
79+ t.Errorf("the existing file changed: %q", b)
80+ }
81+
82+ yoloOff(t)
83+ sink = inSessionDir(t, &recordingSink{dir: dir, allow: true})
84+ out, _ = saveAnswerEvented(context.Background(), sink, d, saveAnswerInput{Path: "empty.md"}, "", "no answer yet")
85+ if sink.asked != 0 || !strings.Contains(out, "there is no answer to save yet") {
86+ t.Errorf("no answer: asked %d, %q", sink.asked, out)
87+ }
88+ if _, err := os.Stat(filepath.Join(dir, "empty.md")); !os.IsNotExist(err) {
89+ t.Error("a file was written with no answer")
90+ }
91+}
92+
93+// With yolo off the write waits for the user, and a refusal writes nothing.
94+func TestSaveAnswerAsksWhenYoloIsOff(t *testing.T) {
95+ yoloOff(t)
96+ dir := t.TempDir()
97+ sink := inSessionDir(t, &recordingSink{dir: dir, allow: false})
98+ out, _ := saveAnswerEvented(context.Background(), sink, detector.NewLoopDetector(10, 3),
99+ saveAnswerInput{Path: "nu.md"}, "text", "this answer")
100+ if sink.asked != 1 || out != "[save_answer rejected by the user]" {
101+ t.Errorf("asked %d, out %q", sink.asked, out)
102+ }
103+ if _, err := os.Stat(filepath.Join(dir, "nu.md")); !os.IsNotExist(err) {
104+ t.Error("a refused save wrote the file")
105+ }
106+}
modified internal/tui/commands.go +2 -0
@@ -99,6 +99,8 @@ func (r *runner) dispatch(p Prompt) {
9999 r.help()
100100 case command.Copy:
101101 r.copy(arg)
102+ case command.Save:
103+ r.saveAnswer(arg)
102104 }
103105 }
104106
@@ -99,6 +99,8 @@ func (r *runner) dispatch(p Prompt) {
99 r.help()99 r.help()
100 case command.Copy:100 case command.Copy:
101 r.copy(arg)101 r.copy(arg)
102+ case command.Save:
103+ r.saveAnswer(arg)
102 }104 }
103 }105 }
104 106
modified internal/tui/copy.go +13 -0
@@ -3,8 +3,10 @@ package tui
33 import (
44 "fmt"
55 "strconv"
6+ "strings"
67
78 "mm/internal/clipboard"
9+ "mm/internal/saved"
810 )
911
1012 // /copy — the last answer, or the whole conversation, onto the clipboard.
@@ -56,3 +58,14 @@ func copyText(t *Transcript, arg string) (string, error) {
5658 }
5759 return answers[len(answers)-n], nil
5860 }
61+
62+// saveAnswer is /save <file> [n]: the last answer (or the n-th from the end)
63+// written as it is to a new file — never over an existing one.
64+func (r *runner) saveAnswer(arg string) {
65+ line := saved.Command(r.t.Answers(), arg)
66+ if strings.HasPrefix(line, "⚠️") {
67+ r.t.Error(line)
68+ return
69+ }
70+ r.t.Notice(line)
71+}
@@ -3,8 +3,10 @@ package tui
3 import (3 import (
4 "fmt"4 "fmt"
5 "strconv"5 "strconv"
6+ "strings"
6 7
7 "mm/internal/clipboard"8 "mm/internal/clipboard"
9+ "mm/internal/saved"
8 )10 )
9 11
10 // /copy — the last answer, or the whole conversation, onto the clipboard.12 // /copy — the last answer, or the whole conversation, onto the clipboard.
@@ -56,3 +58,14 @@ func copyText(t *Transcript, arg string) (string, error) {
56 }58 }
57 return answers[len(answers)-n], nil59 return answers[len(answers)-n], nil
58 }60 }
61+
62+// saveAnswer is /save <file> [n]: the last answer (or the n-th from the end)
63+// written as it is to a new file — never over an existing one.
64+func (r *runner) saveAnswer(arg string) {
65+ line := saved.Command(r.t.Answers(), arg)
66+ if strings.HasPrefix(line, "⚠️") {
67+ r.t.Error(line)
68+ return
69+ }
70+ r.t.Notice(line)
71+}
modified internal/tui/turn_test.go +21 -0
@@ -369,6 +369,27 @@ func TestRunnerCopy(t *testing.T) {
369369 }
370370 }
371371
372+// /save writes the raw last answer to a new file and says so; an existing
373+// file is left alone, and the refusal is an error notice.
374+func TestRunnerSave(t *testing.T) {
375+ r, tr := newTestRunner(t, &fakeModel{answer: "forty-two"})
376+ r.dispatch(promptOf("what is the answer?"))
377+ waitIdle(t, r)
378+
379+ path := filepath.Join(t.TempDir(), "answer.md")
380+ r.dispatch(promptOf("/save " + path))
381+ if got := notices(tr); !strings.Contains(got, "💾 saved the last answer to "+path) {
382+ t.Fatalf("no confirmation:\n%s", got)
383+ }
384+ if b, _ := os.ReadFile(path); string(b) != "forty-two\n" {
385+ t.Errorf("file = %q, want the raw answer", b)
386+ }
387+ r.dispatch(promptOf("/save " + path))
388+ if got := notices(tr); !strings.Contains(got, "already exists — nothing was written") {
389+ t.Errorf("an existing file was not refused:\n%s", got)
390+ }
391+}
392+
372393 // A long paste reaches the model in full, shows collapsed in the conversation,
373394 // and its @paths are not looked up — only the ones the user typed are.
374395 func TestRunnerTurnWithPaste(t *testing.T) {
@@ -369,6 +369,27 @@ func TestRunnerCopy(t *testing.T) {
369 }369 }
370 }370 }
371 371
372+// /save writes the raw last answer to a new file and says so; an existing
373+// file is left alone, and the refusal is an error notice.
374+func TestRunnerSave(t *testing.T) {
375+ r, tr := newTestRunner(t, &fakeModel{answer: "forty-two"})
376+ r.dispatch(promptOf("what is the answer?"))
377+ waitIdle(t, r)
378+
379+ path := filepath.Join(t.TempDir(), "answer.md")
380+ r.dispatch(promptOf("/save " + path))
381+ if got := notices(tr); !strings.Contains(got, "💾 saved the last answer to "+path) {
382+ t.Fatalf("no confirmation:\n%s", got)
383+ }
384+ if b, _ := os.ReadFile(path); string(b) != "forty-two\n" {
385+ t.Errorf("file = %q, want the raw answer", b)
386+ }
387+ r.dispatch(promptOf("/save " + path))
388+ if got := notices(tr); !strings.Contains(got, "already exists — nothing was written") {
389+ t.Errorf("an existing file was not refused:\n%s", got)
390+ }
391+}
392+
372 // A long paste reaches the model in full, shows collapsed in the conversation,393 // A long paste reaches the model in full, shows collapsed in the conversation,
373 // and its @paths are not looked up — only the ones the user typed are.394 // and its @paths are not looked up — only the ones the user typed are.
374 func TestRunnerTurnWithPaste(t *testing.T) {395 func TestRunnerTurnWithPaste(t *testing.T) {
modified internal/tui/view_test.go +2 -2
@@ -163,8 +163,8 @@ func TestPasteNeverSends(t *testing.T) {
163163 func TestPicker(t *testing.T) {
164164 hs := newHarness(t, 70, 20)
165165 hs.typeText("/")
166- if got := len(hs.v.choices()); got != 8 {
167- t.Fatalf("%d choices on '/', want 8", got)
166+ if got := len(hs.v.choices()); got != 9 {
167+ t.Fatalf("%d choices on '/', want 9", got)
168168 }
169169 if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") {
170170 t.Errorf("picker not drawn:\n%s", text)
@@ -163,8 +163,8 @@ func TestPasteNeverSends(t *testing.T) {
163 func TestPicker(t *testing.T) {163 func TestPicker(t *testing.T) {
164 hs := newHarness(t, 70, 20)164 hs := newHarness(t, 70, 20)
165 hs.typeText("/")165 hs.typeText("/")
166- if got := len(hs.v.choices()); got != 8 {166+ if got := len(hs.v.choices()); got != 9 {
167- t.Fatalf("%d choices on '/', want 8", got)167+ t.Fatalf("%d choices on '/', want 9", got)
168 }168 }
169 if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") {169 if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") {
170 t.Errorf("picker not drawn:\n%s", text)170 t.Errorf("picker not drawn:\n%s", text)
modified main.go +6 -1
@@ -337,7 +337,8 @@ type toolSet struct {
337337 // the PATH — a missing server is a warning, never a refusal to start; and
338338 // `ask_agent` when mmConfigs names at least one agent, over the same pool as
339339 // /ask; `ask_user` behind `askUserTool` (on by default), never with a
340-// structuredOutput; and the agent file's declared `tools:`.
340+// structuredOutput; `save_answer` behind `saveAnswerTool` (off by default);
341+// and the agent file's declared `tools:`.
341342 func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet {
342343 set := toolSet{close: func() {}}
343344 if config.Cfg.BashTool {
@@ -373,6 +374,10 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent
373374 set.tools = append(set.tools, tools.AskUser(e.G, d))
374375 set.names = append(set.names, "ask_user")
375376 }
377+ if config.Cfg.SaveAnswerTool {
378+ set.tools = append(set.tools, tools.SaveAnswer(e.G, d))
379+ set.names = append(set.names, "save_answer")
380+ }
376381 if config.Cfg.FetchContentTool {
377382 // The product token "mm" is what the sites' robots.txt are read for.
378383 fetcher := webmd.NewFetcher("mm/" + version.Short())
@@ -337,7 +337,8 @@ type toolSet struct {
337 // the PATH — a missing server is a warning, never a refusal to start; and337 // the PATH — a missing server is a warning, never a refusal to start; and
338 // `ask_agent` when mmConfigs names at least one agent, over the same pool as338 // `ask_agent` when mmConfigs names at least one agent, over the same pool as
339 // /ask; `ask_user` behind `askUserTool` (on by default), never with a339 // /ask; `ask_user` behind `askUserTool` (on by default), never with a
340-// structuredOutput; and the agent file's declared `tools:`.340+// structuredOutput; `save_answer` behind `saveAnswerTool` (off by default);
341+// and the agent file's declared `tools:`.
341 func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet {342 func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet {
342 set := toolSet{close: func() {}}343 set := toolSet{close: func() {}}
343 if config.Cfg.BashTool {344 if config.Cfg.BashTool {
@@ -373,6 +374,10 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent
373 set.tools = append(set.tools, tools.AskUser(e.G, d))374 set.tools = append(set.tools, tools.AskUser(e.G, d))
374 set.names = append(set.names, "ask_user")375 set.names = append(set.names, "ask_user")
375 }376 }
377+ if config.Cfg.SaveAnswerTool {
378+ set.tools = append(set.tools, tools.SaveAnswer(e.G, d))
379+ set.names = append(set.names, "save_answer")
380+ }
376 if config.Cfg.FetchContentTool {381 if config.Cfg.FetchContentTool {
377 // The product token "mm" is what the sites' robots.txt are read for.382 // The product token "mm" is what the sites' robots.txt are read for.
378 fetcher := webmd.NewFetcher("mm/" + version.Short())383 fetcher := webmd.NewFetcher("mm/" + version.Short())