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

🛟 Updated.

k33g committed 2026-10-21T14:33:20+02:00 Browse files
8d1c5cb parent: e13b1f6
modified .tickets/issues/0061-llmman-provider-download-the-model-automatically.yaml +2 -2
@@ -6,9 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-01T05:48:44.919Z
9-updatedAt: 2026-10-20T07:39:23.051Z
9+updatedAt: 2026-10-21T11:45:30.797Z
1010 labels: []
11-milestone: v0.9.0
11+milestone: v1.0.0
1212 epic: Providers (LLM Engine)
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-10-01T05:48:44.919Z8 createdAt: 2026-10-01T05:48:44.919Z
9-updatedAt: 2026-10-20T07:39:23.051Z9+updatedAt: 2026-10-21T11:45:30.797Z
10 labels: []10 labels: []
11-milestone: v0.9.011+milestone: v1.0.0
12 epic: Providers (LLM Engine)12 epic: Providers (LLM Engine)
13 body: ""13 body: ""
14 tasks: []14 tasks: []
modified .tickets/issues/0075-add-gronk-provider.yaml +2 -2
@@ -6,9 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-06T03:58:37.611Z
9-updatedAt: 2026-10-20T07:48:28.395Z
9+updatedAt: 2026-10-21T11:49:01.282Z
1010 labels: []
11-milestone: v0.9.0
11+milestone: v1.0.0
1212 epic: Providers (LLM Engine)
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-10-06T03:58:37.611Z8 createdAt: 2026-10-06T03:58:37.611Z
9-updatedAt: 2026-10-20T07:48:28.395Z9+updatedAt: 2026-10-21T11:49:01.282Z
10 labels: []10 labels: []
11-milestone: v0.9.011+milestone: v1.0.0
12 epic: Providers (LLM Engine)12 epic: Providers (LLM Engine)
13 body: ""13 body: ""
14 tasks: []14 tasks: []
added .tickets/issues/0097-generate-an-agent-yaml-file-with-the-cli.yaml +275 -0
new file mode 100644
@@ -0,0 +1,275 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 97
3+title: Generate an agent.yaml file with the CLI
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-18T12:31:14.544Z
9+updatedAt: 2026-10-21T23:00:00.000Z
10+labels: []
11+milestone: v0.9.0
12+related:
13+ - 98
14+body: |
15+ generate an agent configuration file from de CLI (`mm`)
16+
17+ - with a wizard using options with kind of model: qwen, gemma, mellum, llama, mistral and ask for the type of usage (developer, translateur, ...) and then generate the appropriate settings
18+tasks:
19+ - id: 1
20+ title: "Agree on the open questions (command name, families, usages, sources of the family table, server probing, prompt language)"
21+ state: closed
22+ priority: none
23+ author:
24+ name: k33g
25+ email: ph.charriere@gmail.com
26+ createdAt: 2026-10-21T16:00:00.000Z
27+ updatedAt: 2026-10-21T16:00:00.000Z
28+ - id: 2
29+ title: "Port model_facts.py's rules to Go: read a model's facts from llmman/Ollama (/api/show) and llama.cpp (/props) — with tests on recorded answers"
30+ state: closed
31+ priority: none
32+ author:
33+ name: k33g
34+ email: ph.charriere@gmail.com
35+ createdAt: 2026-10-21T16:00:00.000Z
36+ updatedAt: 2026-10-21T16:00:00.000Z
37+ - id: 3
38+ title: "Family table (sourced values) + usage profiles + machine rows (from the settings guide) — with tests"
39+ state: closed
40+ priority: none
41+ author:
42+ name: k33g
43+ email: ph.charriere@gmail.com
44+ createdAt: 2026-10-21T16:00:00.000Z
45+ updatedAt: 2026-10-21T16:00:00.000Z
46+ - id: 4
47+ title: "The wizard: questions through internal/elicit in the terminal, flags for every question, never overwrite — with tests"
48+ state: closed
49+ priority: none
50+ author:
51+ name: k33g
52+ email: ph.charriere@gmail.com
53+ createdAt: 2026-10-21T16:00:00.000Z
54+ updatedAt: 2026-10-21T16:00:00.000Z
55+ - id: 5
56+ title: "Generate the commented agent.yaml, load it with config.Load, --check sends one question — with tests and a live check per provider available"
57+ state: closed
58+ priority: none
59+ author:
60+ name: k33g
61+ email: ph.charriere@gmail.com
62+ createdAt: 2026-10-21T16:00:00.000Z
63+ updatedAt: 2026-10-21T16:00:00.000Z
64+ - id: 6
65+ title: "Docs EN+FR (how-to: create an agent with mm init; reference: the flags), README, release notes"
66+ state: closed
67+ priority: none
68+ author:
69+ name: k33g
70+ email: ph.charriere@gmail.com
71+ createdAt: 2026-10-21T16:00:00.000Z
72+ updatedAt: 2026-10-21T16:00:00.000Z
73+comments:
74+ - id: 1
75+ author:
76+ name: k33g
77+ email: ph.charriere@gmail.com
78+ createdAt: 2026-10-21T16:00:00.000Z
79+ body: |
80+ Proposal (2026-10-21) — to confirm before coding.
81+
82+ ## What exists already (checked in the repo)
83+ - agent.creator.yaml + skills/create-agent: an LLM agent (Qwen3.8 27B)
84+ that creates an agent.yaml. Powerful, but needs a 27B model loaded and
85+ minutes per agent.
86+ - skills/create-agent/model_facts.py: reads a model from llmman
87+ (/api/show, /api/tags) — context window, the sampling recommended in the
88+ GGUF, the chat template's reasoning switch and its default, the tool-call
89+ format — and turns them into settings rules.
90+ - guides/agent-settings-for-small-models.md (ticket 98): the "typical
91+ profiles" table (timeouts, maxTurns, maxOutput, context) and the sampling
92+ advice.
93+ - config.samples/ (dmr gemma, gpt-oss, qwen3.6; llamacpp ornith, qwen3.8)
94+ and the demos: tested settings per model.
95+ - internal/elicit: the questions (choice, multi, text, confirm) and their
96+ line-mode answers, used by ask_user in the REPL.
97+
98+ ## Proposal: `mm init` — a wizard with no model to run
99+ A deterministic generator, in Go, in mm itself: questions in the terminal,
100+ then a commented agent.yaml. No LLM involved, so it is instant and works
101+ before any agent exists. agent.creator.yaml stays for the unusual cases.
102+
103+ ### Questions (in this order, each with a default)
104+ 1. Model server: dmr / llmman / ollama / llamacpp — and its URL (the
105+ provider's default; the wizard checks it answers).
106+ 2. Model: picked from the server's list (/v1/models, or llmman's
107+ /api/tags) — or typed.
108+ 3. Model family: detected from the name (qwen, gemma, mellum, llama,
109+ mistral, gpt-oss, granite, minicpm… — "other" when unknown), shown for
110+ confirmation.
111+ 4. Usage: developer (code), translator, writer / documentation, assistant
112+ (questions & answers), reviewer, JSON extractor (structuredOutput),
113+ coordinator (asks other agents) — each a profile, see below.
114+ 5. Machine: small (laptop, ≤ 8B models) / medium / large — picks the row of
115+ the settings guide's profiles table.
116+ 6. Ask before each change (yolo: false)? — default from the usage.
117+ 7. Agent name and file name (default agent.yaml; never overwrite an
118+ existing file, as /save).
119+
120+ ### Where each setting comes from (most reliable first)
121+ 1. The server's facts about THAT model, when it can tell (llmman, Ollama:
122+ /api/show; llama.cpp: /props): the context window, the GGUF's sampling,
123+ the reasoning switch (enable_thinking or reasoning_effort) and its
124+ default — the rules of model_facts.py, ported to Go.
125+ 2. The family table, when the server cannot tell (DMR, or no GGUF
126+ sampling): the authors' published sampling and reasoning switches per
127+ family — each value sourced from a model card or a measured demo, the
128+ source written in a comment of the generated file.
129+ 3. The usage profile: which tools (developer: bash + files + plan;
130+ translator / writer: none, or files only; JSON extractor: none —
131+ structuredOutput forbids tools; coordinator: ask_agent), reasoning on or
132+ off (on for code and multi-step work, off for short tasks — the
133+ create-agent rule), a starting system prompt, maxTurns.
134+ 4. The machine row of the settings guide: firstTokenTimeout,
135+ watchdogTimeout, bashTimeout, maxOutput, context compression.
136+
137+ ### The generated file
138+ Every key commented with WHY it has that value ("from the GGUF", "Qwen's
139+ model card", "settings guide, small machine row"), the way the demos'
140+ agent.yaml are written — so the file teaches as it configures. Then the
141+ wizard loads it with config.Load and, with `--check`, sends one question
142+ ("Say hello in one line.") to prove the model answers.
143+
144+ ### Non-interactive, for scripts and tests
145+ `mm init --provider llmman --model hf.co/… --usage translator --machine
146+ small --name lucy -o agent.yaml`: every question has a flag; with all of
147+ them, no prompt at all.
148+
149+ ### Not in scope of a first version
150+ Skills scaffolding, MCP servers, hooks, mmConfigs agents — the wizard says
151+ where to add them (links to the how-tos) in the file's closing comment.
152+
153+ ## Open questions
154+ - Command name: `mm init`, `mm new`, or a flag `--init`?
155+ - The families and usages above: keep, add, remove?
156+ - Source of the family table: model cards only, or the measured demos too
157+ (which also give tested maxTurns / timeouts)?
158+ - Probe the server for its model list, or only ask for a name?
159+ - The generated system prompts: English only, or in the language the user
160+ picks?
161+ - Ticket 111 (a Claude skill from the settings guide): same knowledge — one
162+ shared table in Go that both use, or separate?
163+ - id: 2
164+ author:
165+ name: k33g
166+ email: ph.charriere@gmail.com
167+ createdAt: 2026-10-21T16:30:00.000Z
168+ body: |
169+ Decided by the user (2026-10-21):
170+ 1. both spellings: `mm init` and `mm --init`;
171+ 2. families and usages: the proposed ones, completed as needed;
172+ 3. the family table draws on both the model cards and the measured
173+ demos (each value with its source);
174+ 4. the wizard queries the server (model list, model facts);
175+ 5. generated system prompts in English only;
176+ 6. one shared table, used by mm init and by the Claude skill of
177+ ticket 111.
178+ - id: 3
179+ author:
180+ name: k33g
181+ email: ph.charriere@gmail.com
182+ createdAt: 2026-10-21T18:00:00.000Z
183+ body: |
184+ Implemented on feature/mm-init (branch created at the user's request).
185+ - internal/agentinit (new): presets.yaml (embedded; THE shared table for
186+ ticket 111: 13 families — qwen-coder, qwen, gemma, mellum-thinking,
187+ mellum, gpt-oss, minicpm, ornith, granite-guardian, menlo, llama,
188+ mistral, other — each with its source: model cards (Qwen, Gemma 4
189+ GGUF, Llama 3.1 generation_config, Mistral Small 3.1 card) and the
190+ measured demos; 9 usages — developer, reviewer, translator, writer,
191+ assistant, researcher, docs, json, coordinator — with tools,
192+ reasoning, precise, maxTurns, yolo and an English system prompt; 3
193+ machine rows from the settings guide); facts.go (model_facts.py
194+ ported: /api/tags, /api/show — native window, general.sampling.*,
195+ Ollama parameters, template reasoning switch and default — llama.cpp
196+ /props, OpenAI /models); decide.go (each value with its reason: server
197+ facts > family > usage > machine); render.go (commented YAML,
198+ consecutive settings sharing one comment); wizard.go (questions through
199+ internal/elicit, legends, Enter = default, every question a flag,
200+ --yes, -o -, never overwrite — checked before any question —,
201+ config.Load after writing, --check runs this mm -p).
202+ - main.go: `mm init`, `mm --init`, `-init` answered before flag.Parse;
203+ `--init` listed in -h.
204+ - Changed from the proposal: context.threshold stays mm's 75 — a
205+ threshold computed from the NATIVE window (the create-agent rule)
206+ would compress almost at once on a server that serves less (Ollama
207+ often 4k–8k), since mm applies it to the served window.
208+ - Tests: presets (every entry sourced; family matching), facts (fake
209+ servers per provider, template switches), decide/render (every usage
210+ × 4 families × 3 machines loads with config.Load; sampling cases;
211+ tools / docs / json / guard note), wizard (flags only, piped answers,
212+ never overwrite, stdout, bad answers), main (isInit; the generated
213+ JSON agent declares no tool — StructuredOutputConflict).
214+ - Live (llmman, Qwen3.5 4B): developer, translator, docs and json
215+ written with --yes --check — each loads and the model answers (the
216+ json agent answered JSON of the example's shape); interactive run with
217+ piped answers on gemma-4-E4B. Ollama, DMR and llama-server were not
218+ running here: their paths are covered by the fake-server tests only.
219+ - Docs EN+FR: how-to create-an-agent, reference/cli (mm init section,
220+ invocation line), both indexes, root README, packages.drawio.
221+ Not done: release notes (v0.9.0 has none yet); ticket 111's skill
222+ itself (it can now read internal/agentinit/presets.yaml).
223+ - id: 4
224+ author:
225+ name: k33g
226+ email: ph.charriere@gmail.com
227+ createdAt: 2026-10-21T20:00:00.000Z
228+ body: |
229+ Updated for ticket 118 (the generic openai provider):
230+ - `openai` is offered as a fifth server, but never probed for the first
231+ question's default (its default address is the OpenAI API: mm init
232+ must not call a remote service nobody asked for);
233+ - its model list is read with OPENAI_API_KEY (Authorization header);
234+ - the file says where the key comes from, that no baseUrl means the
235+ OpenAI API, and that this provider cannot ask for the window
236+ (contextWindow: 0 = unknown → compression counts messages);
237+ - presets.yaml: a new family `openai` (match `gpt-`, after gpt-oss):
238+ temperature 1.0 and max_completion_tokens instead of max_tokens (new
239+ field maxTokensKey), with a note — source: the OpenAI API reference,
240+ not measured here.
241+ - Tests: openai lists models with the key (and other providers do not
242+ send one), the openai family and provider render and load, openai is
243+ not in the probed list. Live: mm init --provider openai on llmman's
244+ OpenAI address with --check — written, loaded, the model answered.
245+ - Docs EN+FR: reference/cli (provider row, facts row), how-to
246+ create-an-agent (server list, prerequisites).
247+ - id: 5
248+ author:
249+ name: k33g
250+ email: ph.charriere@gmail.com
251+ createdAt: 2026-10-21T22:00:00.000Z
252+ body: |
253+ A window "in the style of the TUI" (user's layout choice: steps +
254+ choices + preview):
255+ - internal/agentinit/flow.go: the questions as a UI-agnostic Flow
256+ (Steps, Set/Unset, Preview of the file with a candidate answer,
257+ Result); the line wizard and the window both drive it.
258+ - internal/tui/initwin.go (tui.RunInit): steps on the left (✓ ▸ ·, each
259+ answer beside it), the question with the highlighted choice's
260+ description, and the agent.yaml as it would be written with that
261+ choice, live; a confirmation screen before writing. Keys: ↑↓ Home
262+ End, Enter, y/n, Esc back (skips flag answers), PgUp/PgDn preview,
263+ Tab = preview in place of the question below 100 columns, Ctrl-U,
264+ Ctrl-C = nothing written.
265+ - The window opens when stdin and stdout are terminals; `--line` (new)
266+ or a pipe keeps the line-by-line questions; --yes asks nothing.
267+ The step "Yolo" is labelled "Ask first" (Yolo no read as yes).
268+ - Tests: flow (steps, preview candidate), window on a tcell
269+ SimulationScreen (defaults all the way, preview follows the
270+ selection, back/text/quit, narrow Tab). Live in a pty (130×34,
271+ pyte) against llmman: written and loaded.
272+ - Docs EN+FR: how-to create-an-agent (real capture, keys, line mode),
273+ reference/cli (Window and Line mode rows, --line), the TUI keyboard
274+ shortcuts guide (an mm init section), packages.drawio (tui →
275+ agentinit).
new file mode 100644
@@ -0,0 +1,275 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 97
3+title: Generate an agent.yaml file with the CLI
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-18T12:31:14.544Z
9+updatedAt: 2026-10-21T23:00:00.000Z
10+labels: []
11+milestone: v0.9.0
12+related:
13+ - 98
14+body: |
15+ generate an agent configuration file from de CLI (`mm`)
16+
17+ - with a wizard using options with kind of model: qwen, gemma, mellum, llama, mistral and ask for the type of usage (developer, translateur, ...) and then generate the appropriate settings
18+tasks:
19+ - id: 1
20+ title: "Agree on the open questions (command name, families, usages, sources of the family table, server probing, prompt language)"
21+ state: closed
22+ priority: none
23+ author:
24+ name: k33g
25+ email: ph.charriere@gmail.com
26+ createdAt: 2026-10-21T16:00:00.000Z
27+ updatedAt: 2026-10-21T16:00:00.000Z
28+ - id: 2
29+ title: "Port model_facts.py's rules to Go: read a model's facts from llmman/Ollama (/api/show) and llama.cpp (/props) — with tests on recorded answers"
30+ state: closed
31+ priority: none
32+ author:
33+ name: k33g
34+ email: ph.charriere@gmail.com
35+ createdAt: 2026-10-21T16:00:00.000Z
36+ updatedAt: 2026-10-21T16:00:00.000Z
37+ - id: 3
38+ title: "Family table (sourced values) + usage profiles + machine rows (from the settings guide) — with tests"
39+ state: closed
40+ priority: none
41+ author:
42+ name: k33g
43+ email: ph.charriere@gmail.com
44+ createdAt: 2026-10-21T16:00:00.000Z
45+ updatedAt: 2026-10-21T16:00:00.000Z
46+ - id: 4
47+ title: "The wizard: questions through internal/elicit in the terminal, flags for every question, never overwrite — with tests"
48+ state: closed
49+ priority: none
50+ author:
51+ name: k33g
52+ email: ph.charriere@gmail.com
53+ createdAt: 2026-10-21T16:00:00.000Z
54+ updatedAt: 2026-10-21T16:00:00.000Z
55+ - id: 5
56+ title: "Generate the commented agent.yaml, load it with config.Load, --check sends one question — with tests and a live check per provider available"
57+ state: closed
58+ priority: none
59+ author:
60+ name: k33g
61+ email: ph.charriere@gmail.com
62+ createdAt: 2026-10-21T16:00:00.000Z
63+ updatedAt: 2026-10-21T16:00:00.000Z
64+ - id: 6
65+ title: "Docs EN+FR (how-to: create an agent with mm init; reference: the flags), README, release notes"
66+ state: closed
67+ priority: none
68+ author:
69+ name: k33g
70+ email: ph.charriere@gmail.com
71+ createdAt: 2026-10-21T16:00:00.000Z
72+ updatedAt: 2026-10-21T16:00:00.000Z
73+comments:
74+ - id: 1
75+ author:
76+ name: k33g
77+ email: ph.charriere@gmail.com
78+ createdAt: 2026-10-21T16:00:00.000Z
79+ body: |
80+ Proposal (2026-10-21) — to confirm before coding.
81+
82+ ## What exists already (checked in the repo)
83+ - agent.creator.yaml + skills/create-agent: an LLM agent (Qwen3.8 27B)
84+ that creates an agent.yaml. Powerful, but needs a 27B model loaded and
85+ minutes per agent.
86+ - skills/create-agent/model_facts.py: reads a model from llmman
87+ (/api/show, /api/tags) — context window, the sampling recommended in the
88+ GGUF, the chat template's reasoning switch and its default, the tool-call
89+ format — and turns them into settings rules.
90+ - guides/agent-settings-for-small-models.md (ticket 98): the "typical
91+ profiles" table (timeouts, maxTurns, maxOutput, context) and the sampling
92+ advice.
93+ - config.samples/ (dmr gemma, gpt-oss, qwen3.6; llamacpp ornith, qwen3.8)
94+ and the demos: tested settings per model.
95+ - internal/elicit: the questions (choice, multi, text, confirm) and their
96+ line-mode answers, used by ask_user in the REPL.
97+
98+ ## Proposal: `mm init` — a wizard with no model to run
99+ A deterministic generator, in Go, in mm itself: questions in the terminal,
100+ then a commented agent.yaml. No LLM involved, so it is instant and works
101+ before any agent exists. agent.creator.yaml stays for the unusual cases.
102+
103+ ### Questions (in this order, each with a default)
104+ 1. Model server: dmr / llmman / ollama / llamacpp — and its URL (the
105+ provider's default; the wizard checks it answers).
106+ 2. Model: picked from the server's list (/v1/models, or llmman's
107+ /api/tags) — or typed.
108+ 3. Model family: detected from the name (qwen, gemma, mellum, llama,
109+ mistral, gpt-oss, granite, minicpm… — "other" when unknown), shown for
110+ confirmation.
111+ 4. Usage: developer (code), translator, writer / documentation, assistant
112+ (questions & answers), reviewer, JSON extractor (structuredOutput),
113+ coordinator (asks other agents) — each a profile, see below.
114+ 5. Machine: small (laptop, ≤ 8B models) / medium / large — picks the row of
115+ the settings guide's profiles table.
116+ 6. Ask before each change (yolo: false)? — default from the usage.
117+ 7. Agent name and file name (default agent.yaml; never overwrite an
118+ existing file, as /save).
119+
120+ ### Where each setting comes from (most reliable first)
121+ 1. The server's facts about THAT model, when it can tell (llmman, Ollama:
122+ /api/show; llama.cpp: /props): the context window, the GGUF's sampling,
123+ the reasoning switch (enable_thinking or reasoning_effort) and its
124+ default — the rules of model_facts.py, ported to Go.
125+ 2. The family table, when the server cannot tell (DMR, or no GGUF
126+ sampling): the authors' published sampling and reasoning switches per
127+ family — each value sourced from a model card or a measured demo, the
128+ source written in a comment of the generated file.
129+ 3. The usage profile: which tools (developer: bash + files + plan;
130+ translator / writer: none, or files only; JSON extractor: none —
131+ structuredOutput forbids tools; coordinator: ask_agent), reasoning on or
132+ off (on for code and multi-step work, off for short tasks — the
133+ create-agent rule), a starting system prompt, maxTurns.
134+ 4. The machine row of the settings guide: firstTokenTimeout,
135+ watchdogTimeout, bashTimeout, maxOutput, context compression.
136+
137+ ### The generated file
138+ Every key commented with WHY it has that value ("from the GGUF", "Qwen's
139+ model card", "settings guide, small machine row"), the way the demos'
140+ agent.yaml are written — so the file teaches as it configures. Then the
141+ wizard loads it with config.Load and, with `--check`, sends one question
142+ ("Say hello in one line.") to prove the model answers.
143+
144+ ### Non-interactive, for scripts and tests
145+ `mm init --provider llmman --model hf.co/… --usage translator --machine
146+ small --name lucy -o agent.yaml`: every question has a flag; with all of
147+ them, no prompt at all.
148+
149+ ### Not in scope of a first version
150+ Skills scaffolding, MCP servers, hooks, mmConfigs agents — the wizard says
151+ where to add them (links to the how-tos) in the file's closing comment.
152+
153+ ## Open questions
154+ - Command name: `mm init`, `mm new`, or a flag `--init`?
155+ - The families and usages above: keep, add, remove?
156+ - Source of the family table: model cards only, or the measured demos too
157+ (which also give tested maxTurns / timeouts)?
158+ - Probe the server for its model list, or only ask for a name?
159+ - The generated system prompts: English only, or in the language the user
160+ picks?
161+ - Ticket 111 (a Claude skill from the settings guide): same knowledge — one
162+ shared table in Go that both use, or separate?
163+ - id: 2
164+ author:
165+ name: k33g
166+ email: ph.charriere@gmail.com
167+ createdAt: 2026-10-21T16:30:00.000Z
168+ body: |
169+ Decided by the user (2026-10-21):
170+ 1. both spellings: `mm init` and `mm --init`;
171+ 2. families and usages: the proposed ones, completed as needed;
172+ 3. the family table draws on both the model cards and the measured
173+ demos (each value with its source);
174+ 4. the wizard queries the server (model list, model facts);
175+ 5. generated system prompts in English only;
176+ 6. one shared table, used by mm init and by the Claude skill of
177+ ticket 111.
178+ - id: 3
179+ author:
180+ name: k33g
181+ email: ph.charriere@gmail.com
182+ createdAt: 2026-10-21T18:00:00.000Z
183+ body: |
184+ Implemented on feature/mm-init (branch created at the user's request).
185+ - internal/agentinit (new): presets.yaml (embedded; THE shared table for
186+ ticket 111: 13 families — qwen-coder, qwen, gemma, mellum-thinking,
187+ mellum, gpt-oss, minicpm, ornith, granite-guardian, menlo, llama,
188+ mistral, other — each with its source: model cards (Qwen, Gemma 4
189+ GGUF, Llama 3.1 generation_config, Mistral Small 3.1 card) and the
190+ measured demos; 9 usages — developer, reviewer, translator, writer,
191+ assistant, researcher, docs, json, coordinator — with tools,
192+ reasoning, precise, maxTurns, yolo and an English system prompt; 3
193+ machine rows from the settings guide); facts.go (model_facts.py
194+ ported: /api/tags, /api/show — native window, general.sampling.*,
195+ Ollama parameters, template reasoning switch and default — llama.cpp
196+ /props, OpenAI /models); decide.go (each value with its reason: server
197+ facts > family > usage > machine); render.go (commented YAML,
198+ consecutive settings sharing one comment); wizard.go (questions through
199+ internal/elicit, legends, Enter = default, every question a flag,
200+ --yes, -o -, never overwrite — checked before any question —,
201+ config.Load after writing, --check runs this mm -p).
202+ - main.go: `mm init`, `mm --init`, `-init` answered before flag.Parse;
203+ `--init` listed in -h.
204+ - Changed from the proposal: context.threshold stays mm's 75 — a
205+ threshold computed from the NATIVE window (the create-agent rule)
206+ would compress almost at once on a server that serves less (Ollama
207+ often 4k–8k), since mm applies it to the served window.
208+ - Tests: presets (every entry sourced; family matching), facts (fake
209+ servers per provider, template switches), decide/render (every usage
210+ × 4 families × 3 machines loads with config.Load; sampling cases;
211+ tools / docs / json / guard note), wizard (flags only, piped answers,
212+ never overwrite, stdout, bad answers), main (isInit; the generated
213+ JSON agent declares no tool — StructuredOutputConflict).
214+ - Live (llmman, Qwen3.5 4B): developer, translator, docs and json
215+ written with --yes --check — each loads and the model answers (the
216+ json agent answered JSON of the example's shape); interactive run with
217+ piped answers on gemma-4-E4B. Ollama, DMR and llama-server were not
218+ running here: their paths are covered by the fake-server tests only.
219+ - Docs EN+FR: how-to create-an-agent, reference/cli (mm init section,
220+ invocation line), both indexes, root README, packages.drawio.
221+ Not done: release notes (v0.9.0 has none yet); ticket 111's skill
222+ itself (it can now read internal/agentinit/presets.yaml).
223+ - id: 4
224+ author:
225+ name: k33g
226+ email: ph.charriere@gmail.com
227+ createdAt: 2026-10-21T20:00:00.000Z
228+ body: |
229+ Updated for ticket 118 (the generic openai provider):
230+ - `openai` is offered as a fifth server, but never probed for the first
231+ question's default (its default address is the OpenAI API: mm init
232+ must not call a remote service nobody asked for);
233+ - its model list is read with OPENAI_API_KEY (Authorization header);
234+ - the file says where the key comes from, that no baseUrl means the
235+ OpenAI API, and that this provider cannot ask for the window
236+ (contextWindow: 0 = unknown → compression counts messages);
237+ - presets.yaml: a new family `openai` (match `gpt-`, after gpt-oss):
238+ temperature 1.0 and max_completion_tokens instead of max_tokens (new
239+ field maxTokensKey), with a note — source: the OpenAI API reference,
240+ not measured here.
241+ - Tests: openai lists models with the key (and other providers do not
242+ send one), the openai family and provider render and load, openai is
243+ not in the probed list. Live: mm init --provider openai on llmman's
244+ OpenAI address with --check — written, loaded, the model answered.
245+ - Docs EN+FR: reference/cli (provider row, facts row), how-to
246+ create-an-agent (server list, prerequisites).
247+ - id: 5
248+ author:
249+ name: k33g
250+ email: ph.charriere@gmail.com
251+ createdAt: 2026-10-21T22:00:00.000Z
252+ body: |
253+ A window "in the style of the TUI" (user's layout choice: steps +
254+ choices + preview):
255+ - internal/agentinit/flow.go: the questions as a UI-agnostic Flow
256+ (Steps, Set/Unset, Preview of the file with a candidate answer,
257+ Result); the line wizard and the window both drive it.
258+ - internal/tui/initwin.go (tui.RunInit): steps on the left (✓ ▸ ·, each
259+ answer beside it), the question with the highlighted choice's
260+ description, and the agent.yaml as it would be written with that
261+ choice, live; a confirmation screen before writing. Keys: ↑↓ Home
262+ End, Enter, y/n, Esc back (skips flag answers), PgUp/PgDn preview,
263+ Tab = preview in place of the question below 100 columns, Ctrl-U,
264+ Ctrl-C = nothing written.
265+ - The window opens when stdin and stdout are terminals; `--line` (new)
266+ or a pipe keeps the line-by-line questions; --yes asks nothing.
267+ The step "Yolo" is labelled "Ask first" (Yolo no read as yes).
268+ - Tests: flow (steps, preview candidate), window on a tcell
269+ SimulationScreen (defaults all the way, preview follows the
270+ selection, back/text/quit, narrow Tab). Live in a pty (130×34,
271+ pyte) against llmman: written and loaded.
272+ - Docs EN+FR: how-to create-an-agent (real capture, keys, line mode),
273+ reference/cli (Window and Line mode rows, --line), the TUI keyboard
274+ shortcuts guide (an mm init section), packages.drawio (tui →
275+ agentinit).
modified .tickets/issues/0111-using-the-settings-guide-create-a-claude-skill.yaml +12 -1
@@ -12,4 +12,15 @@ milestone: v1.0.0
1212 body: |
1313 the Claude skill will produce a descriptive doc about the model and a config file (`agent.yaml`)
1414 tasks: []
15-comments: []
15+comments:
16+ - id: 1
17+ author:
18+ name: k33g
19+ email: ph.charriere@gmail.com
20+ createdAt: 2026-10-21T18:00:00.000Z
21+ body: |
22+ User's decision in ticket 97: ONE shared table. It now exists:
23+ internal/agentinit/presets.yaml (families with sourced sampling and
24+ reasoning switches, usages, machine rows from the settings guide),
25+ embedded by `mm init`. The skill of this ticket should read that file
26+ rather than carry its own values.
@@ -12,4 +12,15 @@ milestone: v1.0.0
12 body: |12 body: |
13 the Claude skill will produce a descriptive doc about the model and a config file (`agent.yaml`)13 the Claude skill will produce a descriptive doc about the model and a config file (`agent.yaml`)
14 tasks: []14 tasks: []
15-comments: []15+comments:
16+ - id: 1
17+ author:
18+ name: k33g
19+ email: ph.charriere@gmail.com
20+ createdAt: 2026-10-21T18:00:00.000Z
21+ body: |
22+ User's decision in ticket 97: ONE shared table. It now exists:
23+ internal/agentinit/presets.yaml (families with sourced sampling and
24+ reasoning switches, usages, machine rows from the settings guide),
25+ embedded by `mm init`. The skill of this ticket should read that file
26+ rather than carry its own values.
modified .tickets/issues/0112-add-a-help-window-in-tui-mode-for-the-ui-shortcu.yaml +8 -3
@@ -1,16 +1,21 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 112
33 title: Add a help window in TUI mode for the UI shortcuts
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-21T04:43:03.511Z
9-updatedAt: 2026-10-21T04:43:13.927Z
9+updatedAt: 2026-10-21T11:26:26.119Z
1010 labels:
1111 - kind::feature
12+ - wontfix
1213 milestone: v0.9.0
13-body: ""
14+body: |
15+ Already displayed in the TUI
16+ ```
17+ ── Enter sends · Ctrl-J new line · / commands · @ files · $ skills · Tab scroll ───────────────────────────────
18+ ```
1419 tasks: []
1520 comments:
1621 - id: 1
@@ -1,16 +1,21 @@
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: 1122 id: 112
3 title: Add a help window in TUI mode for the UI shortcuts3 title: Add a help window in TUI mode for the UI shortcuts
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-21T04:43:03.511Z8 createdAt: 2026-10-21T04:43:03.511Z
9-updatedAt: 2026-10-21T04:43:13.927Z9+updatedAt: 2026-10-21T11:26:26.119Z
10 labels:10 labels:
11 - kind::feature11 - kind::feature
12+ - wontfix
12 milestone: v0.9.013 milestone: v0.9.0
13-body: ""14+body: |
15+ Already displayed in the TUI
16+ ```
17+ ── Enter sends · Ctrl-J new line · / commands · @ files · $ skills · Tab scroll ───────────────────────────────
18+ ```
14 tasks: []19 tasks: []
15 comments:20 comments:
16 - id: 121 - id: 1
modified .tickets/issues/0115-uograde-go-version.yaml +2 -2
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 115
33 title: Uograde go version
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-21T11:03:46.198Z
9-updatedAt: 2026-10-21T15:00:00.000Z
9+updatedAt: 2026-10-21T11:23:25.320Z
1010 labels: []
1111 milestone: v0.9.0
1212 body: |
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 1152 id: 115
3 title: Uograde go version3 title: Uograde go version
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-21T11:03:46.198Z8 createdAt: 2026-10-21T11:03:46.198Z
9-updatedAt: 2026-10-21T15:00:00.000Z9+updatedAt: 2026-10-21T11:23:25.320Z
10 labels: []10 labels: []
11 milestone: v0.9.011 milestone: v0.9.0
12 body: |12 body: |
renamed .tickets/issues/0116-how-to-use-mm-for-completion.yaml +4 -6
similarity index 57%
rename from .tickets/issues/0097-generate-an-agent-yaml-file-with-rhe-cli.yaml
rename to .tickets/issues/0116-how-to-use-mm-for-completion.yaml
@@ -1,16 +1,14 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2-id: 97
3-title: Generate an agent.yaml file with rhe CLI
2+id: 116
3+title: How to use mm for completion?
44 state: open
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
8-createdAt: 2026-10-18T12:31:14.544Z
9-updatedAt: 2026-10-20T05:38:52.918Z
8+createdAt: 2026-10-21T11:38:42.420Z
9+updatedAt: 2026-10-21T11:44:57.748Z
1010 labels: []
1111 milestone: v0.9.0
12-related:
13- - 98
1412 body: ""
1513 tasks: []
1614 comments: []
similarity index 57%
rename from .tickets/issues/0097-generate-an-agent-yaml-file-with-rhe-cli.yaml
rename to .tickets/issues/0116-how-to-use-mm-for-completion.yaml
@@ -1,16 +1,14 @@
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: 972+id: 116
3-title: Generate an agent.yaml file with rhe CLI3+title: How to use mm for completion?
4 state: open4 state: open
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8-createdAt: 2026-10-18T12:31:14.544Z8+createdAt: 2026-10-21T11:38:42.420Z
9-updatedAt: 2026-10-20T05:38:52.918Z9+updatedAt: 2026-10-21T11:44:57.748Z
10 labels: []10 labels: []
11 milestone: v0.9.011 milestone: v0.9.0
12-related:
13- - 98
14 body: ""12 body: ""
15 tasks: []13 tasks: []
16 comments: []14 comments: []
added .tickets/issues/0117-make-possible-the-usage-of-mm-tui-like-mm-tui-an.yaml +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 117
3+title: make possible the usage of mm tui like mm --tui and mm acp like mm --acp
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-21T11:39:39.289Z
9+updatedAt: 2026-10-21T11:44:38.778Z
10+labels: []
11+milestone: v0.9.0
12+body: ""
13+tasks: []
14+comments: []
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 117
3+title: make possible the usage of mm tui like mm --tui and mm acp like mm --acp
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-21T11:39:39.289Z
9+updatedAt: 2026-10-21T11:44:38.778Z
10+labels: []
11+milestone: v0.9.0
12+body: ""
13+tasks: []
14+comments: []
added .tickets/issues/0118-add-a-generic-openai-provider.yaml +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 118
3+title: Add a generic OpenAI provider
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-21T11:50:07.819Z
9+updatedAt: 2026-10-21T23:00:00.000Z
10+labels: []
11+milestone: v0.9.0
12+related:
13+ - 97
14+body: |
15+ A generic `openai` provider: any server that speaks the OpenAI
16+ chat-completions protocol and has no dedicated provider in mm — the OpenAI
17+ API itself (default address), vLLM, LM Studio, OpenRouter, a gateway.
18+tasks: []
19+comments:
20+ - id: 1
21+ author:
22+ name: k33g
23+ email: ph.charriere@gmail.com
24+ createdAt: 2026-10-21T20:00:00.000Z
25+ body: |
26+ Implemented on feature/mm-init (with ticket 97, which it is related to).
27+ - internal/engine/openai.go: a sixth registry entry, name `openai`,
28+ baseURL https://api.openai.com/v1, no fallback, apiKeyEnv
29+ OPENAI_API_KEY, key NOT required (dummy key, like the local
30+ providers: a local server checks none, a remote one answers 401). Every
31+ server-specific field left empty: no return_progress (a llama.cpp
32+ field a strict API may refuse), no thinkingOff for summaries, no
33+ unload. probeOpenAI sends the key to GET /models: 401/403 → "refused
34+ the key — export OPENAI_API_KEY" (reachable), 200 → model looked up in
35+ the list, anything else → unreachable (the plain reachable() sends no
36+ key and would call a remote API "down").
37+ - Tests (engine/openai_test.go): the entry assumes no server feature;
38+ Resolve default address and key variable; only standard fields sent
39+ (stream_options, never return_progress); probe with a fake server
40+ (good key, unknown model, refused key, nothing listening). The
41+ registry test lists openai.
42+ - Live: provider openai pointed at llmman's OpenAI address (a real
43+ generic server): mm -p ran a bash tool call and answered; with a wrong
44+ baseUrl the start-up warning gives the openai hint. The OpenAI API
45+ itself was NOT tried (no key here).
46+ - mm --about: "…, and any other OpenAI-compatible one (provider openai)".
47+ - Docs EN+FR: how-to run-with-an-openai-compatible-server (new),
48+ reference/configuration (provider row, defaults table + what openai
49+ sends and does not), explanation/providers, both indexes, README.
50+ Known limits, documented: no prompt-reading progress, no reasoning switch
51+ on summaries, no /unload, no window from the server (set contextWindow);
52+ the OpenAI API's reasoning models need max_completion_tokens and the
53+ default temperature (from the API reference, not measured).
new file mode 100644
@@ -0,0 +1,53 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 118
3+title: Add a generic OpenAI provider
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-21T11:50:07.819Z
9+updatedAt: 2026-10-21T23:00:00.000Z
10+labels: []
11+milestone: v0.9.0
12+related:
13+ - 97
14+body: |
15+ A generic `openai` provider: any server that speaks the OpenAI
16+ chat-completions protocol and has no dedicated provider in mm — the OpenAI
17+ API itself (default address), vLLM, LM Studio, OpenRouter, a gateway.
18+tasks: []
19+comments:
20+ - id: 1
21+ author:
22+ name: k33g
23+ email: ph.charriere@gmail.com
24+ createdAt: 2026-10-21T20:00:00.000Z
25+ body: |
26+ Implemented on feature/mm-init (with ticket 97, which it is related to).
27+ - internal/engine/openai.go: a sixth registry entry, name `openai`,
28+ baseURL https://api.openai.com/v1, no fallback, apiKeyEnv
29+ OPENAI_API_KEY, key NOT required (dummy key, like the local
30+ providers: a local server checks none, a remote one answers 401). Every
31+ server-specific field left empty: no return_progress (a llama.cpp
32+ field a strict API may refuse), no thinkingOff for summaries, no
33+ unload. probeOpenAI sends the key to GET /models: 401/403 → "refused
34+ the key — export OPENAI_API_KEY" (reachable), 200 → model looked up in
35+ the list, anything else → unreachable (the plain reachable() sends no
36+ key and would call a remote API "down").
37+ - Tests (engine/openai_test.go): the entry assumes no server feature;
38+ Resolve default address and key variable; only standard fields sent
39+ (stream_options, never return_progress); probe with a fake server
40+ (good key, unknown model, refused key, nothing listening). The
41+ registry test lists openai.
42+ - Live: provider openai pointed at llmman's OpenAI address (a real
43+ generic server): mm -p ran a bash tool call and answered; with a wrong
44+ baseUrl the start-up warning gives the openai hint. The OpenAI API
45+ itself was NOT tried (no key here).
46+ - mm --about: "…, and any other OpenAI-compatible one (provider openai)".
47+ - Docs EN+FR: how-to run-with-an-openai-compatible-server (new),
48+ reference/configuration (provider row, defaults table + what openai
49+ sends and does not), explanation/providers, both indexes, README.
50+ Known limits, documented: no prompt-reading progress, no reasoning switch
51+ on summaries, no /unload, no window from the server (set contextWindow);
52+ the OpenAI API's reasoning models need max_completion_tokens and the
53+ default temperature (from the API reference, not measured).
modified README.md +2 -1
@@ -20,7 +20,7 @@ What makes Mini Me a bit special is that it was designed from the start to be us
2020
2121 ## Install
2222
23-You need Go 1.27+ and a model server that can emit tool calls, through its OpenAI-compatible API: Docker Model Runner (`provider: dmr`, the default), llama.cpp's `llama-server` (`llamacpp`), [llmman](https://github.com/llmmanorg/llmman) (`llmman`) or Ollama (`ollama`).
23+You need Go 1.27+ and a model server that can emit tool calls, through its OpenAI-compatible API: Docker Model Runner (`provider: dmr`, the default), llama.cpp's `llama-server` (`llamacpp`), [llmman](https://github.com/llmmanorg/llmman) (`llmman`) or Ollama (`ollama`) — or any other OpenAI-compatible server with `provider: openai` (the OpenAI API by default, vLLM, LM Studio…).
2424
2525 ```bash
2626 git clone https://git.rickub.com/bots-garden/mini-me.git
@@ -107,6 +107,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
107107
108108 ## More features
109109
110+- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md).
110111 - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md).
111112 - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md).
112113 - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md).
@@ -20,7 +20,7 @@ What makes Mini Me a bit special is that it was designed from the start to be us
20 20
21 ## Install21 ## Install
22 22
23-You need Go 1.27+ and a model server that can emit tool calls, through its OpenAI-compatible API: Docker Model Runner (`provider: dmr`, the default), llama.cpp's `llama-server` (`llamacpp`), [llmman](https://github.com/llmmanorg/llmman) (`llmman`) or Ollama (`ollama`).23+You need Go 1.27+ and a model server that can emit tool calls, through its OpenAI-compatible API: Docker Model Runner (`provider: dmr`, the default), llama.cpp's `llama-server` (`llamacpp`), [llmman](https://github.com/llmmanorg/llmman) (`llmman`) or Ollama (`ollama`) — or any other OpenAI-compatible server with `provider: openai` (the OpenAI API by default, vLLM, LM Studio…).
24 24
25 ```bash25 ```bash
26 git clone https://git.rickub.com/bots-garden/mini-me.git26 git clone https://git.rickub.com/bots-garden/mini-me.git
@@ -107,6 +107,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
107 107
108 ## More features108 ## More features
109 109
110+- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md).
110 - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md).111 - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md).
111 - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md).112 - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md).
112 - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md).113 - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md).
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), the questions ↑ recalls, /memory and search_memory&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_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" 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><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><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), the questions ↑ recalls, /memory and search_memory&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_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" 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><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><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), the questions ↑ recalls, /memory and search_memory&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_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" 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><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><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), the questions ↑ recalls, /memory and search_memory&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_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" 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><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><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 -0
@@ -10,9 +10,11 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
1010
1111 ## How-to guides — get a task done
1212
13+- [Create an agent with `mm init`](how-to/create-an-agent.md) — a wizard that writes a commented agent.yaml from the model server's facts, a family table, the usage and the machine
1314 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
1415 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/`
1516 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/`
17+- [Use any OpenAI-compatible server](how-to/run-with-an-openai-compatible-server.md) — `provider: openai`: the OpenAI API, vLLM, LM Studio, a gateway
1618 - [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>`
1719 - [See the JSON sent to the model and its answer](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, reading the files with jq
1820 - [Give the agent your project's instructions](how-to/give-project-instructions.md) — `AGENTS.md`, read per conversation, `agentsMd` settings
@@ -10,9 +10,11 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
10 10
11 ## How-to guides — get a task done11 ## How-to guides — get a task done
12 12
13+- [Create an agent with `mm init`](how-to/create-an-agent.md) — a wizard that writes a commented agent.yaml from the model server's facts, a family table, the usage and the machine
13 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)14 - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md)
14 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/`15 - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/`
15 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/`16 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/`
17+- [Use any OpenAI-compatible server](how-to/run-with-an-openai-compatible-server.md) — `provider: openai`: the OpenAI API, vLLM, LM Studio, a gateway
16 - [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>`18 - [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>`
17 - [See the JSON sent to the model and its answer](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, reading the files with jq19 - [See the JSON sent to the model and its answer](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, reading the files with jq
18 - [Give the agent your project's instructions](how-to/give-project-instructions.md) — `AGENTS.md`, read per conversation, `agentsMd` settings20 - [Give the agent your project's instructions](how-to/give-project-instructions.md) — `AGENTS.md`, read per conversation, `agentsMd` settings
modified docs/en/explanation/providers.md +1 -1
@@ -2,7 +2,7 @@
22
33 ## What is this about?
44
5-`mm` talks to a local model server through the OpenAI chat-completions API. Four servers are supported out of the box: Docker Model Runner, llama.cpp's `llama-server`, llmman and Ollama, under the names `dmr`, `llamacpp`, `llmman` and `ollama`. The engine package used to be tied to Docker Model Runner; the provider abstraction is what generalised it.
5+`mm` talks to a local model server through the OpenAI chat-completions API. Four servers are supported out of the box: Docker Model Runner, llama.cpp's `llama-server`, llmman and Ollama, under the names `dmr`, `llamacpp`, `llmman` and `ollama`. A fifth entry, `openai` (ticket 118), takes any other server that speaks the protocol — the OpenAI API by default, vLLM, LM Studio, a gateway — and assumes nothing beyond it: it is the same data-only entry with every server-specific field left empty, and a probe that sends the key. The engine package used to be tied to Docker Model Runner; the provider abstraction is what generalised it.
66
77 ## Why it is designed this way
88
@@ -2,7 +2,7 @@
2 2
3 ## What is this about?3 ## What is this about?
4 4
5-`mm` talks to a local model server through the OpenAI chat-completions API. Four servers are supported out of the box: Docker Model Runner, llama.cpp's `llama-server`, llmman and Ollama, under the names `dmr`, `llamacpp`, `llmman` and `ollama`. The engine package used to be tied to Docker Model Runner; the provider abstraction is what generalised it.5+`mm` talks to a local model server through the OpenAI chat-completions API. Four servers are supported out of the box: Docker Model Runner, llama.cpp's `llama-server`, llmman and Ollama, under the names `dmr`, `llamacpp`, `llmman` and `ollama`. A fifth entry, `openai` (ticket 118), takes any other server that speaks the protocol — the OpenAI API by default, vLLM, LM Studio, a gateway — and assumes nothing beyond it: it is the same data-only entry with every server-specific field left empty, and a probe that sends the key. The engine package used to be tied to Docker Model Runner; the provider abstraction is what generalised it.
6 6
7 ## Why it is designed this way7 ## Why it is designed this way
8 8
added docs/en/how-to/create-an-agent.md +91 -0
new file mode 100644
@@ -0,0 +1,91 @@
1+# How to create an agent with `mm init`
2+
3+This guide shows how to write a new agent.yaml for a model and a job, with mm's wizard: it asks a few questions, reads what the model server says about the model, and writes a commented file — every value with the reason it has that value. No model runs while it does. It assumes a model server is running (llmman, Ollama, Docker Model Runner, llama-server, or any OpenAI-compatible one).
4+
5+## Steps
6+
7+1. In the directory where the agent will work, run:
8+
9+ ```bash
10+ mm init
11+ ```
12+
13+ (`mm --init` is the same.)
14+
15+2. In a terminal, a window opens, in the style of `mm -tui`: the steps on the left, the current question in the middle — with what the highlighted choice means —, and on the right the agent.yaml as it would be written, updated as you move the selection. A real capture (130 × 34, llmman, usage step, `docs` highlighted):
16+
17+ ```text
18+ mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M
19+ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20+ ✓ Server llmman │ What is the agent for? │ # docs — answers from a documentation folder (docs/), with lin…
21+ ✓ Model Qwen3.5-…│ │ #
22+ ✓ Family qwen │ developer (default) │ # Written by `mm init`. Every value says why it has that value…
23+ ▸ Usage │ reviewer │ # them as you watch the agent work (guides/agent-settings-for-…
24+ · Machine │ translator │ #
25+ · Ask first │ writer │ # model family: qwen
26+ · Name │ assistant │ # usage: docs
27+ · File │ researcher │ # machine: small — a laptop, models up to about 8B
28+ │ ▸ docs │ #
29+ │ json │ # mm -config <this file> or mm -tui <this file>
30+ │ coordinator │
31+ │ │ provider: llmman
32+ │ answers from a documentation folder │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
33+ │ (docs/), with links to the passages │
34+ │ │ # 0 = mm asks the server for the window it serves (the model's…
35+ │ │ # is 262144 tokens; the server may serve less)
36+ │ │ contextWindow: 0
37+ │ │ # usage docs's default: tools run without asking
38+ │ │ yolo: true
39+ │ │ # usage docs
40+ │ │ bashTool: false
41+ │ │ editTools: true
42+ │ │ planTool: false
43+ │ │ askUserTool: false
44+ │ │ # no skills: add a skills/ folder and set skillsDir to use som…
45+ │ │ # docs/en/how-to/add-a-skill.md)
46+ │ │ skillsDir: no-skills
47+ │ │ # usage docs
48+ │ │ maxTurns: 8
49+ │ │ # settings guide, row "Small model (≤ 8B), short tasks"
50+ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
51+ ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
52+ ```
53+
54+ `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file) is typed; `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file.
55+
56+ The questions: the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, the agent's name and the file.
57+
58+3. mm writes the file and checks that it loads:
59+
60+ ```text
61+ ✅ wrote agent.yaml
62+ ✅ mm loads it
63+ Next: mm -config agent.yaml (or mm -tui agent.yaml)
64+ ```
65+
66+4. Read the file: each value is preceded by its reason — the GGUF's own sampling, a model card, a measured demo of this repository, the settings guide's row for your machine. Change what does not fit as you watch the agent work.
67+
68+## Variants
69+
70+- **Prove the model answers.** Add `--check`: after writing, mm asks the model "Say hello in one line." with the new file and says whether it answered. The first answer loads the model, which can take a while.
71+- **Line by line.** `--line`, or a run whose input or output is not a terminal (`mm init < answers.txt`), asks the same questions one per line: numbered choices, Enter takes the default.
72+- **No questions, for a script.** Give every answer as a flag, and `--yes` for the rest:
73+
74+ ```bash
75+ mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \
76+ --usage docs --machine small --name rata -o agent.yaml --yes --check
77+ ```
78+
79+- **See the file without writing it.** `-o -` prints it on stdout.
80+- **A server elsewhere.** `--base-url http://192.168.1.20:17434/v1`; it is written into the file as `baseUrl`.
81+- **A server that does not answer.** mm says so and asks for the model's name: the settings then come from the family table only.
82+- **An existing file.** mm never overwrites it, and says so before asking anything: choose another name with `-o`.
83+- **A JSON agent.** `--usage json` writes a `structuredOutput` example (Go types) to replace with yours, and turns every tool off, since a structured answer forbids tools.
84+- **A model mm does not know.** The family `other` gives a generic starting point and a note in the file: check the model card.
85+
86+## See also
87+
88+- [Command line reference — `mm init`](../reference/cli.md#mm-init--the-agentyaml-wizard): every flag, the sources, the exit codes
89+- [Configuration reference](../reference/configuration.md): every key the file holds
90+- [Agent settings for small models](../../../guides/agent-settings-for-small-models.md): what each setting does, the machine rows
91+- `agent.creator.yaml`: an agent that creates agents with a model, for the unusual cases
new file mode 100644
@@ -0,0 +1,91 @@
1+# How to create an agent with `mm init`
2+
3+This guide shows how to write a new agent.yaml for a model and a job, with mm's wizard: it asks a few questions, reads what the model server says about the model, and writes a commented file — every value with the reason it has that value. No model runs while it does. It assumes a model server is running (llmman, Ollama, Docker Model Runner, llama-server, or any OpenAI-compatible one).
4+
5+## Steps
6+
7+1. In the directory where the agent will work, run:
8+
9+ ```bash
10+ mm init
11+ ```
12+
13+ (`mm --init` is the same.)
14+
15+2. In a terminal, a window opens, in the style of `mm -tui`: the steps on the left, the current question in the middle — with what the highlighted choice means —, and on the right the agent.yaml as it would be written, updated as you move the selection. A real capture (130 × 34, llmman, usage step, `docs` highlighted):
16+
17+ ```text
18+ mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M
19+ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20+ ✓ Server llmman │ What is the agent for? │ # docs — answers from a documentation folder (docs/), with lin…
21+ ✓ Model Qwen3.5-…│ │ #
22+ ✓ Family qwen │ developer (default) │ # Written by `mm init`. Every value says why it has that value…
23+ ▸ Usage │ reviewer │ # them as you watch the agent work (guides/agent-settings-for-…
24+ · Machine │ translator │ #
25+ · Ask first │ writer │ # model family: qwen
26+ · Name │ assistant │ # usage: docs
27+ · File │ researcher │ # machine: small — a laptop, models up to about 8B
28+ │ ▸ docs │ #
29+ │ json │ # mm -config <this file> or mm -tui <this file>
30+ │ coordinator │
31+ │ │ provider: llmman
32+ │ answers from a documentation folder │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
33+ │ (docs/), with links to the passages │
34+ │ │ # 0 = mm asks the server for the window it serves (the model's…
35+ │ │ # is 262144 tokens; the server may serve less)
36+ │ │ contextWindow: 0
37+ │ │ # usage docs's default: tools run without asking
38+ │ │ yolo: true
39+ │ │ # usage docs
40+ │ │ bashTool: false
41+ │ │ editTools: true
42+ │ │ planTool: false
43+ │ │ askUserTool: false
44+ │ │ # no skills: add a skills/ folder and set skillsDir to use som…
45+ │ │ # docs/en/how-to/add-a-skill.md)
46+ │ │ skillsDir: no-skills
47+ │ │ # usage docs
48+ │ │ maxTurns: 8
49+ │ │ # settings guide, row "Small model (≤ 8B), short tasks"
50+ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
51+ ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
52+ ```
53+
54+ `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file) is typed; `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file.
55+
56+ The questions: the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, the agent's name and the file.
57+
58+3. mm writes the file and checks that it loads:
59+
60+ ```text
61+ ✅ wrote agent.yaml
62+ ✅ mm loads it
63+ Next: mm -config agent.yaml (or mm -tui agent.yaml)
64+ ```
65+
66+4. Read the file: each value is preceded by its reason — the GGUF's own sampling, a model card, a measured demo of this repository, the settings guide's row for your machine. Change what does not fit as you watch the agent work.
67+
68+## Variants
69+
70+- **Prove the model answers.** Add `--check`: after writing, mm asks the model "Say hello in one line." with the new file and says whether it answered. The first answer loads the model, which can take a while.
71+- **Line by line.** `--line`, or a run whose input or output is not a terminal (`mm init < answers.txt`), asks the same questions one per line: numbered choices, Enter takes the default.
72+- **No questions, for a script.** Give every answer as a flag, and `--yes` for the rest:
73+
74+ ```bash
75+ mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \
76+ --usage docs --machine small --name rata -o agent.yaml --yes --check
77+ ```
78+
79+- **See the file without writing it.** `-o -` prints it on stdout.
80+- **A server elsewhere.** `--base-url http://192.168.1.20:17434/v1`; it is written into the file as `baseUrl`.
81+- **A server that does not answer.** mm says so and asks for the model's name: the settings then come from the family table only.
82+- **An existing file.** mm never overwrites it, and says so before asking anything: choose another name with `-o`.
83+- **A JSON agent.** `--usage json` writes a `structuredOutput` example (Go types) to replace with yours, and turns every tool off, since a structured answer forbids tools.
84+- **A model mm does not know.** The family `other` gives a generic starting point and a note in the file: check the model card.
85+
86+## See also
87+
88+- [Command line reference — `mm init`](../reference/cli.md#mm-init--the-agentyaml-wizard): every flag, the sources, the exit codes
89+- [Configuration reference](../reference/configuration.md): every key the file holds
90+- [Agent settings for small models](../../../guides/agent-settings-for-small-models.md): what each setting does, the machine rows
91+- `agent.creator.yaml`: an agent that creates agents with a model, for the unusual cases
added docs/en/how-to/run-with-an-openai-compatible-server.md +44 -0
new file mode 100644
@@ -0,0 +1,44 @@
1+# How to use any OpenAI-compatible server
2+
3+This guide shows how to run mini-me on a model server that has no dedicated provider — the OpenAI API itself, vLLM, LM Studio, OpenRouter, a company gateway — with the generic `openai` provider. It assumes the server speaks the OpenAI chat-completions protocol and supports tool calls.
4+
5+## Steps
6+
7+1. Point the agent at the server, in its YAML:
8+
9+ ```yaml
10+ provider: openai
11+ model: my-model # as the server names it: GET <baseUrl>/models
12+ baseUrl: http://localhost:8000/v1 # omit it for the OpenAI API
13+ contextWindow: 32768 # the window the server serves: this provider cannot ask for it
14+ ```
15+
16+2. If the server needs a key, export it — it never goes in the YAML:
17+
18+ ```bash
19+ export OPENAI_API_KEY=sk-…
20+ ```
21+
22+ Another variable name: `apiKeyEnv: MY_GATEWAY_KEY`.
23+
24+3. Start mm as usual (`mm agent.yaml`, `mm -tui agent.yaml`, `mm -acp`). Checked with llmman's OpenAI address used as a generic server: the start-up probe lists the models, and a `bash` tool call runs:
25+
26+ ```text
27+ 🛠️ bash: echo hello-from-openai-provider
28+ │ hello-from-openai-provider
29+ 🤖 The command printed: `hello-from-openai-provider`
30+ ```
31+
32+`mm init --provider openai --base-url <address>` writes such a file for you (see [Create an agent with `mm init`](create-an-agent.md)).
33+
34+## Variants
35+
36+- **The OpenAI API.** No `baseUrl`, `OPENAI_API_KEY` exported, a model such as `gpt-5-mini`. Its reasoning models accept only the default temperature and take `max_completion_tokens` instead of `max_tokens`: write those in `sampling`, and remove a key the API refuses. (Not measured in this repository.)
37+- **The key is refused.** The start-up warning says `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`.
38+- **Nothing answers.** The warning says `openai: nothing answers at <baseUrl> — check baseUrl: …`: the address must be the OpenAI-compatible one, usually ending in `/v1`.
39+- **A server mm has a provider for.** Prefer it (`dmr`, `llamacpp`, `llmman`, `ollama`): those know how the server reads a long prompt, turns reasoning off for a summary, unloads a model, and tell the window size. The generic provider does none of that.
40+
41+## See also
42+
43+- [Configuration reference — provider defaults](../reference/configuration.md#provider-defaults): what `openai` sends and does not
44+- [Providers — explanation](../explanation/providers.md)
new file mode 100644
@@ -0,0 +1,44 @@
1+# How to use any OpenAI-compatible server
2+
3+This guide shows how to run mini-me on a model server that has no dedicated provider — the OpenAI API itself, vLLM, LM Studio, OpenRouter, a company gateway — with the generic `openai` provider. It assumes the server speaks the OpenAI chat-completions protocol and supports tool calls.
4+
5+## Steps
6+
7+1. Point the agent at the server, in its YAML:
8+
9+ ```yaml
10+ provider: openai
11+ model: my-model # as the server names it: GET <baseUrl>/models
12+ baseUrl: http://localhost:8000/v1 # omit it for the OpenAI API
13+ contextWindow: 32768 # the window the server serves: this provider cannot ask for it
14+ ```
15+
16+2. If the server needs a key, export it — it never goes in the YAML:
17+
18+ ```bash
19+ export OPENAI_API_KEY=sk-…
20+ ```
21+
22+ Another variable name: `apiKeyEnv: MY_GATEWAY_KEY`.
23+
24+3. Start mm as usual (`mm agent.yaml`, `mm -tui agent.yaml`, `mm -acp`). Checked with llmman's OpenAI address used as a generic server: the start-up probe lists the models, and a `bash` tool call runs:
25+
26+ ```text
27+ 🛠️ bash: echo hello-from-openai-provider
28+ │ hello-from-openai-provider
29+ 🤖 The command printed: `hello-from-openai-provider`
30+ ```
31+
32+`mm init --provider openai --base-url <address>` writes such a file for you (see [Create an agent with `mm init`](create-an-agent.md)).
33+
34+## Variants
35+
36+- **The OpenAI API.** No `baseUrl`, `OPENAI_API_KEY` exported, a model such as `gpt-5-mini`. Its reasoning models accept only the default temperature and take `max_completion_tokens` instead of `max_tokens`: write those in `sampling`, and remove a key the API refuses. (Not measured in this repository.)
37+- **The key is refused.** The start-up warning says `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`.
38+- **Nothing answers.** The warning says `openai: nothing answers at <baseUrl> — check baseUrl: …`: the address must be the OpenAI-compatible one, usually ending in `/v1`.
39+- **A server mm has a provider for.** Prefer it (`dmr`, `llamacpp`, `llmman`, `ollama`): those know how the server reads a long prompt, turns reasoning off for a summary, unloads a model, and tell the window size. The generic provider does none of that.
40+
41+## See also
42+
43+- [Configuration reference — provider defaults](../reference/configuration.md#provider-defaults): what `openai` sends and does not
44+- [Providers — explanation](../explanation/providers.md)
modified docs/en/reference/cli.md +32 -0
@@ -8,6 +8,7 @@
88 mm [-config <path>] [-acp] [-tui] [-prompt <text>|-p <text>] [--version] [--about] [<path>]
99 mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent)
1010 mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin)
11+mm init [flags] (write a new agent.yaml; also mm --init)
1112 ```
1213
1314 | Element | Type | Default | Description |
@@ -148,6 +149,37 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
148149 | Parent process | Watched every 500 ms: when it is gone, the ask is cancelled and the agent stopped. |
149150 | Session | None: neither this process nor the agent writes session files. |
150151
152+## `mm init` — the agent.yaml wizard
153+
154+`mm init` and `mm --init` (also `-init`) write a new, commented agent.yaml. They are answered before any configuration is read. Each question below has a flag; a question whose flag is given is not asked. See [How to create an agent with `mm init`](../how-to/create-an-agent.md).
155+
156+| Flag | Question when absent | Default |
157+|------|----------------------|---------|
158+| `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) |
159+| `--base-url` | — (not asked) | the provider's address, or its fallback when it does not answer |
160+| `--model` | Model: a list of the server's models (`/api/tags` for llmman and Ollama, `/models` for the others), or a name to type when the server does not answer | the first of the list |
161+| `--family` | Model family | detected from the model's name, then from the server's family name |
162+| `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
163+| `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` |
164+| `--yolo yes\|no` | Ask before each command or file change? — only for usages with `bash`, file tools or `fetch_content` | the usage's |
165+| `--name` | Agent name (`acp.name`) | the usage's name |
166+| `-o`, `--output` | File to write (`-` = stdout) | `agent.yaml` |
167+| `--check` | — | off; on: after writing, runs this mm with `-config <file> -p "Say hello in one line."` and reports whether the model answered (5 min at most) |
168+| `--yes` | — | off; on: nothing is asked, every missing answer takes its default |
169+| `--line` | — | off; on: the questions are asked line by line even in a terminal |
170+
171+| Aspect | Behaviour |
172+|--------|-----------|
173+| Window | When stdin and stdout are terminals (and neither `--yes` nor `--line`): a full-screen window — steps (✓ answered, ▸ current, · to come, each answer beside its step), the question with the highlighted choice's description, and the file as it would be written with that choice (preview beside the question from 100 columns, in its place with `Tab` below). Keys: `↑` `↓` `Home` `End` choose, `Enter` answers, `y` / `n` answer the yes-or-no question, `Esc` takes the previous answer back (answers given by flags are skipped), `PgUp` `PgDn` scroll the preview, `Ctrl-U` clears a text answer, `Ctrl-C` quits without writing (`nothing written`, exit 1). A refused answer shows on the bottom rule. Once every question is answered, `Enter` writes the file. |
174+| Line mode | Otherwise: one question per screen line, numbered choices; a number, an option's text or another text answers; an empty line or `/skip` takes the default. Usages and machines are listed with a one-line description first. |
175+| Model facts | llmman and Ollama: `POST /api/show` — the native window, the GGUF's sampling (`general.sampling.*`, Ollama's Modelfile parameters), the chat template's reasoning switch (`enable_thinking`, `reasoning_effort`) and its default. llama-server: `GET /props` — `n_ctx` and the chat template. Docker Model Runner and `openai`: none — the family table decides; `openai` lists its models with `OPENAI_API_KEY`. |
176+| Where each value comes from | The server's facts, then the family table, then the usage, then the machine row; the table is `internal/agentinit/presets.yaml`, each entry with its source. |
177+| Sampling | Reasoning on (the usage asks for it and the model has a switch): the family's thinking sampling, never a temperature under 0.6, `max_tokens: 16384`. Reasoning off: the family's general sampling, or its precise temperature for tool-heavy usages, `max_tokens: 4096`. The GGUF's own sampling replaces the family's. `parallel_tool_calls: false` when any tool is on. |
178+| Written file | Every value preceded by a comment saying why it has that value; `contextWindow: 0` (mm asks the server), with the native window in its comment; `context.threshold: 75`; the usage's English system prompt; for `docs` a `docSearch` block, for `json` a `structuredOutput` example and every tool off, for `coordinator` a commented `mmConfigs` example. |
179+| Existing file | Never overwritten: `⚠️ <file> already exists — nothing was written; choose another name with -o`, exit 1, before any question. |
180+| After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. |
181+| Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. |
182+
151183 ## Exit codes
152184
153185 | Code | Cause |
@@ -8,6 +8,7 @@
8 mm [-config <path>] [-acp] [-tui] [-prompt <text>|-p <text>] [--version] [--about] [<path>]8 mm [-config <path>] [-acp] [-tui] [-prompt <text>|-p <text>] [--version] [--about] [<path>]
9 mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent)9 mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent)
10 mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin)10 mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin)
11+mm init [flags] (write a new agent.yaml; also mm --init)
11 ```12 ```
12 13
13 | Element | Type | Default | Description |14 | Element | Type | Default | Description |
@@ -148,6 +149,37 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
148 | Parent process | Watched every 500 ms: when it is gone, the ask is cancelled and the agent stopped. |149 | Parent process | Watched every 500 ms: when it is gone, the ask is cancelled and the agent stopped. |
149 | Session | None: neither this process nor the agent writes session files. |150 | Session | None: neither this process nor the agent writes session files. |
150 151
152+## `mm init` — the agent.yaml wizard
153+
154+`mm init` and `mm --init` (also `-init`) write a new, commented agent.yaml. They are answered before any configuration is read. Each question below has a flag; a question whose flag is given is not asked. See [How to create an agent with `mm init`](../how-to/create-an-agent.md).
155+
156+| Flag | Question when absent | Default |
157+|------|----------------------|---------|
158+| `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) |
159+| `--base-url` | — (not asked) | the provider's address, or its fallback when it does not answer |
160+| `--model` | Model: a list of the server's models (`/api/tags` for llmman and Ollama, `/models` for the others), or a name to type when the server does not answer | the first of the list |
161+| `--family` | Model family | detected from the model's name, then from the server's family name |
162+| `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
163+| `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` |
164+| `--yolo yes\|no` | Ask before each command or file change? — only for usages with `bash`, file tools or `fetch_content` | the usage's |
165+| `--name` | Agent name (`acp.name`) | the usage's name |
166+| `-o`, `--output` | File to write (`-` = stdout) | `agent.yaml` |
167+| `--check` | — | off; on: after writing, runs this mm with `-config <file> -p "Say hello in one line."` and reports whether the model answered (5 min at most) |
168+| `--yes` | — | off; on: nothing is asked, every missing answer takes its default |
169+| `--line` | — | off; on: the questions are asked line by line even in a terminal |
170+
171+| Aspect | Behaviour |
172+|--------|-----------|
173+| Window | When stdin and stdout are terminals (and neither `--yes` nor `--line`): a full-screen window — steps (✓ answered, ▸ current, · to come, each answer beside its step), the question with the highlighted choice's description, and the file as it would be written with that choice (preview beside the question from 100 columns, in its place with `Tab` below). Keys: `↑` `↓` `Home` `End` choose, `Enter` answers, `y` / `n` answer the yes-or-no question, `Esc` takes the previous answer back (answers given by flags are skipped), `PgUp` `PgDn` scroll the preview, `Ctrl-U` clears a text answer, `Ctrl-C` quits without writing (`nothing written`, exit 1). A refused answer shows on the bottom rule. Once every question is answered, `Enter` writes the file. |
174+| Line mode | Otherwise: one question per screen line, numbered choices; a number, an option's text or another text answers; an empty line or `/skip` takes the default. Usages and machines are listed with a one-line description first. |
175+| Model facts | llmman and Ollama: `POST /api/show` — the native window, the GGUF's sampling (`general.sampling.*`, Ollama's Modelfile parameters), the chat template's reasoning switch (`enable_thinking`, `reasoning_effort`) and its default. llama-server: `GET /props` — `n_ctx` and the chat template. Docker Model Runner and `openai`: none — the family table decides; `openai` lists its models with `OPENAI_API_KEY`. |
176+| Where each value comes from | The server's facts, then the family table, then the usage, then the machine row; the table is `internal/agentinit/presets.yaml`, each entry with its source. |
177+| Sampling | Reasoning on (the usage asks for it and the model has a switch): the family's thinking sampling, never a temperature under 0.6, `max_tokens: 16384`. Reasoning off: the family's general sampling, or its precise temperature for tool-heavy usages, `max_tokens: 4096`. The GGUF's own sampling replaces the family's. `parallel_tool_calls: false` when any tool is on. |
178+| Written file | Every value preceded by a comment saying why it has that value; `contextWindow: 0` (mm asks the server), with the native window in its comment; `context.threshold: 75`; the usage's English system prompt; for `docs` a `docSearch` block, for `json` a `structuredOutput` example and every tool off, for `coordinator` a commented `mmConfigs` example. |
179+| Existing file | Never overwritten: `⚠️ <file> already exists — nothing was written; choose another name with -o`, exit 1, before any question. |
180+| After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. |
181+| Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. |
182+
151 ## Exit codes183 ## Exit codes
152 184
153 | Code | Cause |185 | Code | Cause |
modified docs/en/reference/configuration.md +4 -1
@@ -6,7 +6,7 @@
66
77 | Key | Type | Default | Description |
88 |-----|------|---------|-------------|
9-| `provider` | string | `dmr` | Registry key of the LLM server: `dmr`, `llamacpp`, `llmman` or `ollama`. Overridden by `AGENT_PROVIDER`. |
9+| `provider` | string | `dmr` | Registry key of the LLM server: `dmr`, `llamacpp`, `llmman`, `ollama`, or `openai` for any other OpenAI-compatible server (see [How to use any OpenAI-compatible server](../how-to/run-with-an-openai-compatible-server.md)). Overridden by `AGENT_PROVIDER`. |
1010 | `model` | string | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Model identifier in the provider's naming. Overridden by `AGENT_MODEL`. |
1111 | `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. |
1212 | `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. |
@@ -177,6 +177,9 @@ tools:
177177 | `llamacpp` | `http://127.0.0.1:8080/v1` | none | `LLAMA_API_KEY` | no |
178178 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | no |
179179 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | no |
180+| `openai` | `https://api.openai.com/v1` | none | `OPENAI_API_KEY` | no — a server that needs one answers 401, and the start-up warning names the variable |
181+
182+The `openai` provider sends only standard fields: no `return_progress` (so no prompt-reading progress while a long prompt is read), no reasoning switch on a summary request, no `/unload`. It cannot ask the server for its window: set `contextWindow`, or compression counts messages (`context.maxMessages`). Its start-up probe sends the key to `GET /models`; a 401 or 403 is reported as a refused key, not as a server that does not answer.
180183
181184 ## `structuredOutput`
182185
@@ -6,7 +6,7 @@
6 6
7 | Key | Type | Default | Description |7 | Key | Type | Default | Description |
8 |-----|------|---------|-------------|8 |-----|------|---------|-------------|
9-| `provider` | string | `dmr` | Registry key of the LLM server: `dmr`, `llamacpp`, `llmman` or `ollama`. Overridden by `AGENT_PROVIDER`. |9+| `provider` | string | `dmr` | Registry key of the LLM server: `dmr`, `llamacpp`, `llmman`, `ollama`, or `openai` for any other OpenAI-compatible server (see [How to use any OpenAI-compatible server](../how-to/run-with-an-openai-compatible-server.md)). Overridden by `AGENT_PROVIDER`. |
10 | `model` | string | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Model identifier in the provider's naming. Overridden by `AGENT_MODEL`. |10 | `model` | string | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Model identifier in the provider's naming. Overridden by `AGENT_MODEL`. |
11 | `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. |11 | `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. |
12 | `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. |12 | `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. |
@@ -177,6 +177,9 @@ tools:
177 | `llamacpp` | `http://127.0.0.1:8080/v1` | none | `LLAMA_API_KEY` | no |177 | `llamacpp` | `http://127.0.0.1:8080/v1` | none | `LLAMA_API_KEY` | no |
178 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | no |178 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | no |
179 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | no |179 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | no |
180+| `openai` | `https://api.openai.com/v1` | none | `OPENAI_API_KEY` | no — a server that needs one answers 401, and the start-up warning names the variable |
181+
182+The `openai` provider sends only standard fields: no `return_progress` (so no prompt-reading progress while a long prompt is read), no reasoning switch on a summary request, no `/unload`. It cannot ask the server for its window: set `contextWindow`, or compression counts messages (`context.maxMessages`). Its start-up probe sends the key to `GET /models`; a 401 or 403 is reported as a refused key, not as a server that does not answer.
180 183
181 ## `structuredOutput`184 ## `structuredOutput`
182 185
modified docs/fr/README.md +2 -0
@@ -10,9 +10,11 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
1010
1111 ## Guides pratiques — accomplir une tâche
1212
13+- [Créer un agent avec `mm init`](how-to/create-an-agent.md) — un assistant qui écrit un agent.yaml commenté à partir des faits du serveur, d'une table de familles, de l'usage et de la machine
1314 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
1415 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/`
1516 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/`
17+- [Utiliser n'importe quel serveur compatible OpenAI](how-to/run-with-an-openai-compatible-server.md) — `provider: openai` : l'API d'OpenAI, vLLM, LM Studio, une passerelle
1618 - [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>`
1719 - [Voir le JSON envoyé au modèle et sa réponse](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, lire les fichiers avec jq
1820 - [Donner à l'agent les consignes de votre projet](how-to/give-project-instructions.md) — `AGENTS.md`, lu à chaque conversation, réglages `agentsMd`
@@ -10,9 +10,11 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
10 10
11 ## Guides pratiques — accomplir une tâche11 ## Guides pratiques — accomplir une tâche
12 12
13+- [Créer un agent avec `mm init`](how-to/create-an-agent.md) — un assistant qui écrit un agent.yaml commenté à partir des faits du serveur, d'une table de familles, de l'usage et de la machine
13 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)14 - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md)
14 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/`15 - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/`
15 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/`16 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/`
17+- [Utiliser n'importe quel serveur compatible OpenAI](how-to/run-with-an-openai-compatible-server.md) — `provider: openai` : l'API d'OpenAI, vLLM, LM Studio, une passerelle
16 - [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>`18 - [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>`
17 - [Voir le JSON envoyé au modèle et sa réponse](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, lire les fichiers avec jq19 - [Voir le JSON envoyé au modèle et sa réponse](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, lire les fichiers avec jq
18 - [Donner à l'agent les consignes de votre projet](how-to/give-project-instructions.md) — `AGENTS.md`, lu à chaque conversation, réglages `agentsMd`20 - [Donner à l'agent les consignes de votre projet](how-to/give-project-instructions.md) — `AGENTS.md`, lu à chaque conversation, réglages `agentsMd`
modified docs/fr/explanation/providers.md +1 -1
@@ -2,7 +2,7 @@
22
33 ## De quoi s'agit-il ?
44
5-`mm` dialogue avec un serveur de modèle local via l'API chat-completions d'OpenAI. Quatre serveurs sont pris en charge d'origine : Docker Model Runner, `llama-server` de llama.cpp, llmman et Ollama, sous les noms `dmr`, `llamacpp`, `llmman` et `ollama`. Le paquet moteur était autrefois lié à Docker Model Runner ; l'abstraction de fournisseur est ce qui l'a généralisé.
5+`mm` dialogue avec un serveur de modèle local via l'API chat-completions d'OpenAI. Quatre serveurs sont pris en charge d'origine : Docker Model Runner, `llama-server` de llama.cpp, llmman et Ollama, sous les noms `dmr`, `llamacpp`, `llmman` et `ollama`. Une cinquième entrée, `openai` (ticket 118), prend tout autre serveur qui parle le protocole — l'API d'OpenAI par défaut, vLLM, LM Studio, une passerelle — et ne suppose rien au-delà : c'est la même entrée faite de données, avec tous les champs propres à un serveur laissés vides, et une sonde qui envoie la clé. Le paquet moteur était autrefois lié à Docker Model Runner ; l'abstraction de fournisseur est ce qui l'a généralisé.
66
77 ## Pourquoi c'est conçu ainsi
88
@@ -2,7 +2,7 @@
2 2
3 ## De quoi s'agit-il ?3 ## De quoi s'agit-il ?
4 4
5-`mm` dialogue avec un serveur de modèle local via l'API chat-completions d'OpenAI. Quatre serveurs sont pris en charge d'origine : Docker Model Runner, `llama-server` de llama.cpp, llmman et Ollama, sous les noms `dmr`, `llamacpp`, `llmman` et `ollama`. Le paquet moteur était autrefois lié à Docker Model Runner ; l'abstraction de fournisseur est ce qui l'a généralisé.5+`mm` dialogue avec un serveur de modèle local via l'API chat-completions d'OpenAI. Quatre serveurs sont pris en charge d'origine : Docker Model Runner, `llama-server` de llama.cpp, llmman et Ollama, sous les noms `dmr`, `llamacpp`, `llmman` et `ollama`. Une cinquième entrée, `openai` (ticket 118), prend tout autre serveur qui parle le protocole — l'API d'OpenAI par défaut, vLLM, LM Studio, une passerelle — et ne suppose rien au-delà : c'est la même entrée faite de données, avec tous les champs propres à un serveur laissés vides, et une sonde qui envoie la clé. Le paquet moteur était autrefois lié à Docker Model Runner ; l'abstraction de fournisseur est ce qui l'a généralisé.
6 6
7 ## Pourquoi c'est conçu ainsi7 ## Pourquoi c'est conçu ainsi
8 8
added docs/fr/how-to/create-an-agent.md +91 -0
new file mode 100644
@@ -0,0 +1,91 @@
1+# Créer un agent avec `mm init`
2+
3+Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâche, avec l'assistant de mm : il pose quelques questions, lit ce que le serveur de modèles dit du modèle, et écrit un fichier commenté — chaque valeur avec la raison qui la justifie. Aucun modèle ne tourne pendant ce temps. Il suppose qu'un serveur de modèles tourne (llmman, Ollama, Docker Model Runner, llama-server, ou n'importe quel serveur compatible OpenAI).
4+
5+## Étapes
6+
7+1. Dans le répertoire où l'agent travaillera, lancez :
8+
9+ ```bash
10+ mm init
11+ ```
12+
13+ (`mm --init` fait la même chose.)
14+
15+2. Dans un terminal, une fenêtre s'ouvre, dans le style de `mm -tui` : les étapes à gauche, la question en cours au centre — avec ce que signifie le choix surligné —, et à droite l'agent.yaml tel qu'il serait écrit, mis à jour quand la sélection bouge. Une capture réelle (130 × 34, llmman, étape de l'usage, `docs` surligné) :
16+
17+ ```text
18+ mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M
19+ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20+ ✓ Server llmman │ What is the agent for? │ # docs — answers from a documentation folder (docs/), with lin…
21+ ✓ Model Qwen3.5-…│ │ #
22+ ✓ Family qwen │ developer (default) │ # Written by `mm init`. Every value says why it has that value…
23+ ▸ Usage │ reviewer │ # them as you watch the agent work (guides/agent-settings-for-…
24+ · Machine │ translator │ #
25+ · Ask first │ writer │ # model family: qwen
26+ · Name │ assistant │ # usage: docs
27+ · File │ researcher │ # machine: small — a laptop, models up to about 8B
28+ │ ▸ docs │ #
29+ │ json │ # mm -config <this file> or mm -tui <this file>
30+ │ coordinator │
31+ │ │ provider: llmman
32+ │ answers from a documentation folder │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
33+ │ (docs/), with links to the passages │
34+ │ │ # 0 = mm asks the server for the window it serves (the model's…
35+ │ │ # is 262144 tokens; the server may serve less)
36+ │ │ contextWindow: 0
37+ │ │ # usage docs's default: tools run without asking
38+ │ │ yolo: true
39+ │ │ # usage docs
40+ │ │ bashTool: false
41+ │ │ editTools: true
42+ │ │ planTool: false
43+ │ │ askUserTool: false
44+ │ │ # no skills: add a skills/ folder and set skillsDir to use som…
45+ │ │ # docs/en/how-to/add-a-skill.md)
46+ │ │ skillsDir: no-skills
47+ │ │ # usage docs
48+ │ │ maxTurns: 8
49+ │ │ # settings guide, row "Small model (≤ 8B), short tasks"
50+ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
51+ ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
52+ ```
53+
54+ `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier) se tape ; `Ctrl-U` l'efface. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier.
55+
56+ Les questions : le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, le nom de l'agent et le fichier.
57+
58+3. mm écrit le fichier et vérifie qu'il se charge :
59+
60+ ```text
61+ ✅ wrote agent.yaml
62+ ✅ mm loads it
63+ Next: mm -config agent.yaml (or mm -tui agent.yaml)
64+ ```
65+
66+4. Lisez le fichier : chaque valeur est précédée de sa raison — l'échantillonnage du GGUF lui-même, une carte de modèle, une démo mesurée de ce dépôt, la ligne du guide des réglages pour votre machine. Modifiez ce qui ne convient pas en regardant l'agent travailler.
67+
68+## Variantes
69+
70+- **Vérifier que le modèle répond.** Ajoutez `--check` : après l'écriture, mm pose au modèle la question « Say hello in one line. » avec le nouveau fichier et indique s'il a répondu. La première réponse charge le modèle, ce qui peut prendre un moment.
71+- **Ligne par ligne.** `--line`, ou une exécution dont l'entrée ou la sortie n'est pas un terminal (`mm init < reponses.txt`), pose les mêmes questions une par ligne : choix numérotés, Entrée prend la valeur par défaut.
72+- **Aucune question, pour un script.** Donnez chaque réponse en option, et `--yes` pour le reste :
73+
74+ ```bash
75+ mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \
76+ --usage docs --machine small --name rata -o agent.yaml --yes --check
77+ ```
78+
79+- **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard.
80+- **Un serveur ailleurs.** `--base-url http://192.168.1.20:17434/v1` ; il est écrit dans le fichier comme `baseUrl`.
81+- **Un serveur qui ne répond pas.** mm le dit et demande le nom du modèle : les réglages viennent alors de la seule table des familles.
82+- **Un fichier existant.** mm ne l'écrase jamais, et le dit avant de poser la moindre question : choisissez un autre nom avec `-o`.
83+- **Un agent JSON.** `--usage json` écrit un exemple de `structuredOutput` (des types Go) à remplacer par les vôtres, et désactive tous les outils, puisqu'une réponse structurée interdit les outils.
84+- **Un modèle que mm ne connaît pas.** La famille `other` donne un point de départ générique et une note dans le fichier : vérifiez la carte du modèle.
85+
86+## Voir aussi
87+
88+- [Référence de la ligne de commande — `mm init`](../reference/cli.md#mm-init--lassistant-agentyaml) : chaque option, les sources, les codes de sortie
89+- [Référence de la configuration](../reference/configuration.md) : chaque clé du fichier
90+- [Réglages des agents pour petits modèles](../../../guides/reglages-agents-petits-modeles.md) : ce que fait chaque réglage, les lignes par machine
91+- `agent.creator.yaml` : un agent qui crée des agents avec un modèle, pour les cas qui sortent de l'ordinaire
new file mode 100644
@@ -0,0 +1,91 @@
1+# Créer un agent avec `mm init`
2+
3+Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâche, avec l'assistant de mm : il pose quelques questions, lit ce que le serveur de modèles dit du modèle, et écrit un fichier commenté — chaque valeur avec la raison qui la justifie. Aucun modèle ne tourne pendant ce temps. Il suppose qu'un serveur de modèles tourne (llmman, Ollama, Docker Model Runner, llama-server, ou n'importe quel serveur compatible OpenAI).
4+
5+## Étapes
6+
7+1. Dans le répertoire où l'agent travaillera, lancez :
8+
9+ ```bash
10+ mm init
11+ ```
12+
13+ (`mm --init` fait la même chose.)
14+
15+2. Dans un terminal, une fenêtre s'ouvre, dans le style de `mm -tui` : les étapes à gauche, la question en cours au centre — avec ce que signifie le choix surligné —, et à droite l'agent.yaml tel qu'il serait écrit, mis à jour quand la sélection bouge. Une capture réelle (130 × 34, llmman, étape de l'usage, `docs` surligné) :
16+
17+ ```text
18+ mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M
19+ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20+ ✓ Server llmman │ What is the agent for? │ # docs — answers from a documentation folder (docs/), with lin…
21+ ✓ Model Qwen3.5-…│ │ #
22+ ✓ Family qwen │ developer (default) │ # Written by `mm init`. Every value says why it has that value…
23+ ▸ Usage │ reviewer │ # them as you watch the agent work (guides/agent-settings-for-…
24+ · Machine │ translator │ #
25+ · Ask first │ writer │ # model family: qwen
26+ · Name │ assistant │ # usage: docs
27+ · File │ researcher │ # machine: small — a laptop, models up to about 8B
28+ │ ▸ docs │ #
29+ │ json │ # mm -config <this file> or mm -tui <this file>
30+ │ coordinator │
31+ │ │ provider: llmman
32+ │ answers from a documentation folder │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
33+ │ (docs/), with links to the passages │
34+ │ │ # 0 = mm asks the server for the window it serves (the model's…
35+ │ │ # is 262144 tokens; the server may serve less)
36+ │ │ contextWindow: 0
37+ │ │ # usage docs's default: tools run without asking
38+ │ │ yolo: true
39+ │ │ # usage docs
40+ │ │ bashTool: false
41+ │ │ editTools: true
42+ │ │ planTool: false
43+ │ │ askUserTool: false
44+ │ │ # no skills: add a skills/ folder and set skillsDir to use som…
45+ │ │ # docs/en/how-to/add-a-skill.md)
46+ │ │ skillsDir: no-skills
47+ │ │ # usage docs
48+ │ │ maxTurns: 8
49+ │ │ # settings guide, row "Small model (≤ 8B), short tasks"
50+ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
51+ ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
52+ ```
53+
54+ `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier) se tape ; `Ctrl-U` l'efface. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier.
55+
56+ Les questions : le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, le nom de l'agent et le fichier.
57+
58+3. mm écrit le fichier et vérifie qu'il se charge :
59+
60+ ```text
61+ ✅ wrote agent.yaml
62+ ✅ mm loads it
63+ Next: mm -config agent.yaml (or mm -tui agent.yaml)
64+ ```
65+
66+4. Lisez le fichier : chaque valeur est précédée de sa raison — l'échantillonnage du GGUF lui-même, une carte de modèle, une démo mesurée de ce dépôt, la ligne du guide des réglages pour votre machine. Modifiez ce qui ne convient pas en regardant l'agent travailler.
67+
68+## Variantes
69+
70+- **Vérifier que le modèle répond.** Ajoutez `--check` : après l'écriture, mm pose au modèle la question « Say hello in one line. » avec le nouveau fichier et indique s'il a répondu. La première réponse charge le modèle, ce qui peut prendre un moment.
71+- **Ligne par ligne.** `--line`, ou une exécution dont l'entrée ou la sortie n'est pas un terminal (`mm init < reponses.txt`), pose les mêmes questions une par ligne : choix numérotés, Entrée prend la valeur par défaut.
72+- **Aucune question, pour un script.** Donnez chaque réponse en option, et `--yes` pour le reste :
73+
74+ ```bash
75+ mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \
76+ --usage docs --machine small --name rata -o agent.yaml --yes --check
77+ ```
78+
79+- **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard.
80+- **Un serveur ailleurs.** `--base-url http://192.168.1.20:17434/v1` ; il est écrit dans le fichier comme `baseUrl`.
81+- **Un serveur qui ne répond pas.** mm le dit et demande le nom du modèle : les réglages viennent alors de la seule table des familles.
82+- **Un fichier existant.** mm ne l'écrase jamais, et le dit avant de poser la moindre question : choisissez un autre nom avec `-o`.
83+- **Un agent JSON.** `--usage json` écrit un exemple de `structuredOutput` (des types Go) à remplacer par les vôtres, et désactive tous les outils, puisqu'une réponse structurée interdit les outils.
84+- **Un modèle que mm ne connaît pas.** La famille `other` donne un point de départ générique et une note dans le fichier : vérifiez la carte du modèle.
85+
86+## Voir aussi
87+
88+- [Référence de la ligne de commande — `mm init`](../reference/cli.md#mm-init--lassistant-agentyaml) : chaque option, les sources, les codes de sortie
89+- [Référence de la configuration](../reference/configuration.md) : chaque clé du fichier
90+- [Réglages des agents pour petits modèles](../../../guides/reglages-agents-petits-modeles.md) : ce que fait chaque réglage, les lignes par machine
91+- `agent.creator.yaml` : un agent qui crée des agents avec un modèle, pour les cas qui sortent de l'ordinaire
added docs/fr/how-to/run-with-an-openai-compatible-server.md +44 -0
new file mode 100644
@@ -0,0 +1,44 @@
1+# Utiliser n'importe quel serveur compatible OpenAI
2+
3+Ce guide montre comment faire tourner mini-me sur un serveur de modèles qui n'a pas de fournisseur dédié — l'API d'OpenAI elle-même, vLLM, LM Studio, OpenRouter, une passerelle d'entreprise — avec le fournisseur générique `openai`. Il suppose que le serveur parle le protocole chat-completions d'OpenAI et gère les appels d'outils.
4+
5+## Étapes
6+
7+1. Pointez l'agent vers le serveur, dans son YAML :
8+
9+ ```yaml
10+ provider: openai
11+ model: mon-modele # tel que le serveur le nomme : GET <baseUrl>/models
12+ baseUrl: http://localhost:8000/v1 # à omettre pour l'API d'OpenAI
13+ contextWindow: 32768 # la fenêtre servie : ce fournisseur ne peut pas la demander
14+ ```
15+
16+2. Si le serveur exige une clé, exportez-la — elle ne va jamais dans le YAML :
17+
18+ ```bash
19+ export OPENAI_API_KEY=sk-…
20+ ```
21+
22+ Un autre nom de variable : `apiKeyEnv: MA_CLE_PASSERELLE`.
23+
24+3. Lancez mm comme d'habitude (`mm agent.yaml`, `mm -tui agent.yaml`, `mm -acp`). Vérifié avec l'adresse OpenAI de llmman utilisée comme serveur générique : la sonde de démarrage liste les modèles, et un appel à l'outil `bash` s'exécute :
25+
26+ ```text
27+ 🛠️ bash: echo hello-from-openai-provider
28+ │ hello-from-openai-provider
29+ 🤖 The command printed: `hello-from-openai-provider`
30+ ```
31+
32+`mm init --provider openai --base-url <adresse>` écrit ce fichier pour vous (voir [Créer un agent avec `mm init`](create-an-agent.md)).
33+
34+## Variantes
35+
36+- **L'API d'OpenAI.** Pas de `baseUrl`, `OPENAI_API_KEY` exportée, un modèle comme `gpt-5-mini`. Ses modèles à raisonnement n'acceptent que la température par défaut et prennent `max_completion_tokens` au lieu de `max_tokens` : écrivez-les dans `sampling`, et retirez une clé que l'API refuse. (Non mesuré dans ce dépôt.)
37+- **La clé est refusée.** L'avertissement de démarrage indique `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`.
38+- **Rien ne répond.** L'avertissement indique `openai: nothing answers at <baseUrl> — check baseUrl: …` : l'adresse doit être celle compatible OpenAI, qui finit généralement par `/v1`.
39+- **Un serveur pour lequel mm a un fournisseur.** Préférez-le (`dmr`, `llamacpp`, `llmman`, `ollama`) : ceux-là savent comment le serveur lit un long prompt, coupent le raisonnement pour un résumé, déchargent un modèle et donnent la taille de la fenêtre. Le fournisseur générique ne fait rien de tout cela.
40+
41+## Voir aussi
42+
43+- [Référence de la configuration — valeurs par défaut des fournisseurs](../reference/configuration.md#valeurs-par-défaut-des-fournisseurs) : ce que `openai` envoie et n'envoie pas
44+- [Les fournisseurs — explication](../explanation/providers.md)
new file mode 100644
@@ -0,0 +1,44 @@
1+# Utiliser n'importe quel serveur compatible OpenAI
2+
3+Ce guide montre comment faire tourner mini-me sur un serveur de modèles qui n'a pas de fournisseur dédié — l'API d'OpenAI elle-même, vLLM, LM Studio, OpenRouter, une passerelle d'entreprise — avec le fournisseur générique `openai`. Il suppose que le serveur parle le protocole chat-completions d'OpenAI et gère les appels d'outils.
4+
5+## Étapes
6+
7+1. Pointez l'agent vers le serveur, dans son YAML :
8+
9+ ```yaml
10+ provider: openai
11+ model: mon-modele # tel que le serveur le nomme : GET <baseUrl>/models
12+ baseUrl: http://localhost:8000/v1 # à omettre pour l'API d'OpenAI
13+ contextWindow: 32768 # la fenêtre servie : ce fournisseur ne peut pas la demander
14+ ```
15+
16+2. Si le serveur exige une clé, exportez-la — elle ne va jamais dans le YAML :
17+
18+ ```bash
19+ export OPENAI_API_KEY=sk-…
20+ ```
21+
22+ Un autre nom de variable : `apiKeyEnv: MA_CLE_PASSERELLE`.
23+
24+3. Lancez mm comme d'habitude (`mm agent.yaml`, `mm -tui agent.yaml`, `mm -acp`). Vérifié avec l'adresse OpenAI de llmman utilisée comme serveur générique : la sonde de démarrage liste les modèles, et un appel à l'outil `bash` s'exécute :
25+
26+ ```text
27+ 🛠️ bash: echo hello-from-openai-provider
28+ │ hello-from-openai-provider
29+ 🤖 The command printed: `hello-from-openai-provider`
30+ ```
31+
32+`mm init --provider openai --base-url <adresse>` écrit ce fichier pour vous (voir [Créer un agent avec `mm init`](create-an-agent.md)).
33+
34+## Variantes
35+
36+- **L'API d'OpenAI.** Pas de `baseUrl`, `OPENAI_API_KEY` exportée, un modèle comme `gpt-5-mini`. Ses modèles à raisonnement n'acceptent que la température par défaut et prennent `max_completion_tokens` au lieu de `max_tokens` : écrivez-les dans `sampling`, et retirez une clé que l'API refuse. (Non mesuré dans ce dépôt.)
37+- **La clé est refusée.** L'avertissement de démarrage indique `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`.
38+- **Rien ne répond.** L'avertissement indique `openai: nothing answers at <baseUrl> — check baseUrl: …` : l'adresse doit être celle compatible OpenAI, qui finit généralement par `/v1`.
39+- **Un serveur pour lequel mm a un fournisseur.** Préférez-le (`dmr`, `llamacpp`, `llmman`, `ollama`) : ceux-là savent comment le serveur lit un long prompt, coupent le raisonnement pour un résumé, déchargent un modèle et donnent la taille de la fenêtre. Le fournisseur générique ne fait rien de tout cela.
40+
41+## Voir aussi
42+
43+- [Référence de la configuration — valeurs par défaut des fournisseurs](../reference/configuration.md#valeurs-par-défaut-des-fournisseurs) : ce que `openai` envoie et n'envoie pas
44+- [Les fournisseurs — explication](../explanation/providers.md)
modified docs/fr/reference/cli.md +32 -0
@@ -8,6 +8,7 @@
88 mm [-config <chemin>] [-acp] [-tui] [-prompt <texte>|-p <texte>] [--version] [--about] [<chemin>]
99 mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p)
1010 mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin)
11+mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init)
1112 ```
1213
1314 | Élément | Type | Défaut | Description |
@@ -148,6 +149,37 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
148149 | Processus parent | Surveillé toutes les 500 ms : s'il disparaît, la demande est annulée et l'agent arrêté. |
149150 | Session | Aucune : ni ce processus ni l'agent n'écrivent de fichier de session. |
150151
152+## `mm init` — l'assistant agent.yaml
153+
154+`mm init` et `mm --init` (aussi `-init`) écrivent un nouvel agent.yaml commenté. Ils sont traités avant toute lecture de configuration. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Créer un agent avec `mm init`](../how-to/create-an-agent.md).
155+
156+| Option | Question quand elle est absente | Défaut |
157+|--------|---------------------------------|--------|
158+| `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `openai` | le premier serveur local qui répond, sinon `llmman` (`openai` n'est jamais sondé : son adresse par défaut est l'API d'OpenAI) |
159+| `--base-url` | — (pas posée) | l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas |
160+| `--model` | Modèle : la liste des modèles du serveur (`/api/tags` pour llmman et Ollama, `/models` pour les autres), ou un nom à taper quand le serveur ne répond pas | le premier de la liste |
161+| `--family` | Famille du modèle | détectée d'après le nom du modèle, puis d'après le nom de famille donné par le serveur |
162+| `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
163+| `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` |
164+| `--yolo yes\|no` | Demander avant chaque commande ou modification de fichier ? — seulement pour les usages avec `bash`, les outils de fichiers ou `fetch_content` | celui de l'usage |
165+| `--name` | Nom de l'agent (`acp.name`) | le nom de l'usage |
166+| `-o`, `--output` | Fichier à écrire (`-` = sortie standard) | `agent.yaml` |
167+| `--check` | — | désactivé ; activé : après l'écriture, lance ce mm avec `-config <fichier> -p "Say hello in one line."` et indique si le modèle a répondu (5 min au plus) |
168+| `--yes` | — | désactivé ; activé : rien n'est demandé, chaque réponse manquante prend son défaut |
169+| `--line` | — | désactivé ; activé : les questions sont posées ligne par ligne même dans un terminal |
170+
171+| Aspect | Comportement |
172+|--------|--------------|
173+| Fenêtre | Quand l'entrée et la sortie standard sont des terminaux (et ni `--yes` ni `--line`) : une fenêtre plein écran — les étapes (✓ répondue, ▸ en cours, · à venir, chaque réponse à côté de son étape), la question avec la description du choix surligné, et le fichier tel qu'il serait écrit avec ce choix (aperçu à côté de la question à partir de 100 colonnes, à sa place avec `Tab` en dessous). Touches : `↑` `↓` `Début` `Fin` choisissent, `Entrée` répond, `y` / `n` répondent à la question oui ou non, `Échap` reprend la réponse précédente (les réponses données par option sont sautées), `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-U` efface une réponse de texte, `Ctrl-C` quitte sans rien écrire (`nothing written`, code 1). Une réponse refusée s'affiche sur le trait du bas. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. |
174+| Mode ligne | Sinon : une question par ligne, choix numérotés ; un numéro, le texte d'une option ou un autre texte y répond ; une ligne vide ou `/skip` prend le défaut. Les usages et les machines sont d'abord listés avec une description d'une ligne. |
175+| Faits sur le modèle | llmman et Ollama : `POST /api/show` — la fenêtre native, l'échantillonnage du GGUF (`general.sampling.*`, les paramètres du Modelfile d'Ollama), l'interrupteur de raisonnement du chat template (`enable_thinking`, `reasoning_effort`) et son défaut. llama-server : `GET /props` — `n_ctx` et le chat template. Docker Model Runner et `openai` : rien — la table des familles décide ; `openai` liste ses modèles avec `OPENAI_API_KEY`. |
176+| Origine de chaque valeur | Les faits du serveur, puis la table des familles, puis l'usage, puis la ligne de la machine ; la table est `internal/agentinit/presets.yaml`, chaque entrée avec sa source. |
177+| Échantillonnage | Raisonnement activé (l'usage le demande et le modèle a un interrupteur) : l'échantillonnage « thinking » de la famille, jamais une température sous 0,6, `max_tokens: 16384`. Raisonnement désactivé : l'échantillonnage général de la famille, ou sa température « précise » pour les usages qui utilisent beaucoup d'outils, `max_tokens: 4096`. L'échantillonnage du GGUF remplace celui de la famille. `parallel_tool_calls: false` dès qu'un outil est actif. |
178+| Fichier écrit | Chaque valeur précédée d'un commentaire qui dit pourquoi elle a cette valeur ; `contextWindow: 0` (mm demande au serveur), avec la fenêtre native en commentaire ; `context.threshold: 75` ; le prompt système en anglais de l'usage ; pour `docs` un bloc `docSearch`, pour `json` un exemple de `structuredOutput` et tous les outils désactivés, pour `coordinator` un exemple commenté de `mmConfigs`. |
179+| Fichier existant | Jamais écrasé : `⚠️ <fichier> already exists — nothing was written; choose another name with -o`, code de sortie 1, avant toute question. |
180+| Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. |
181+| Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. |
182+
151183 ## Codes de sortie
152184
153185 | Code | Cause |
@@ -8,6 +8,7 @@
8 mm [-config <chemin>] [-acp] [-tui] [-prompt <texte>|-p <texte>] [--version] [--about] [<chemin>]8 mm [-config <chemin>] [-acp] [-tui] [-prompt <texte>|-p <texte>] [--version] [--about] [<chemin>]
9 mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p)9 mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p)
10 mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin)10 mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin)
11+mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init)
11 ```12 ```
12 13
13 | Élément | Type | Défaut | Description |14 | Élément | Type | Défaut | Description |
@@ -148,6 +149,37 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
148 | Processus parent | Surveillé toutes les 500 ms : s'il disparaît, la demande est annulée et l'agent arrêté. |149 | Processus parent | Surveillé toutes les 500 ms : s'il disparaît, la demande est annulée et l'agent arrêté. |
149 | Session | Aucune : ni ce processus ni l'agent n'écrivent de fichier de session. |150 | Session | Aucune : ni ce processus ni l'agent n'écrivent de fichier de session. |
150 151
152+## `mm init` — l'assistant agent.yaml
153+
154+`mm init` et `mm --init` (aussi `-init`) écrivent un nouvel agent.yaml commenté. Ils sont traités avant toute lecture de configuration. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Créer un agent avec `mm init`](../how-to/create-an-agent.md).
155+
156+| Option | Question quand elle est absente | Défaut |
157+|--------|---------------------------------|--------|
158+| `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `openai` | le premier serveur local qui répond, sinon `llmman` (`openai` n'est jamais sondé : son adresse par défaut est l'API d'OpenAI) |
159+| `--base-url` | — (pas posée) | l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas |
160+| `--model` | Modèle : la liste des modèles du serveur (`/api/tags` pour llmman et Ollama, `/models` pour les autres), ou un nom à taper quand le serveur ne répond pas | le premier de la liste |
161+| `--family` | Famille du modèle | détectée d'après le nom du modèle, puis d'après le nom de famille donné par le serveur |
162+| `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
163+| `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` |
164+| `--yolo yes\|no` | Demander avant chaque commande ou modification de fichier ? — seulement pour les usages avec `bash`, les outils de fichiers ou `fetch_content` | celui de l'usage |
165+| `--name` | Nom de l'agent (`acp.name`) | le nom de l'usage |
166+| `-o`, `--output` | Fichier à écrire (`-` = sortie standard) | `agent.yaml` |
167+| `--check` | — | désactivé ; activé : après l'écriture, lance ce mm avec `-config <fichier> -p "Say hello in one line."` et indique si le modèle a répondu (5 min au plus) |
168+| `--yes` | — | désactivé ; activé : rien n'est demandé, chaque réponse manquante prend son défaut |
169+| `--line` | — | désactivé ; activé : les questions sont posées ligne par ligne même dans un terminal |
170+
171+| Aspect | Comportement |
172+|--------|--------------|
173+| Fenêtre | Quand l'entrée et la sortie standard sont des terminaux (et ni `--yes` ni `--line`) : une fenêtre plein écran — les étapes (✓ répondue, ▸ en cours, · à venir, chaque réponse à côté de son étape), la question avec la description du choix surligné, et le fichier tel qu'il serait écrit avec ce choix (aperçu à côté de la question à partir de 100 colonnes, à sa place avec `Tab` en dessous). Touches : `↑` `↓` `Début` `Fin` choisissent, `Entrée` répond, `y` / `n` répondent à la question oui ou non, `Échap` reprend la réponse précédente (les réponses données par option sont sautées), `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-U` efface une réponse de texte, `Ctrl-C` quitte sans rien écrire (`nothing written`, code 1). Une réponse refusée s'affiche sur le trait du bas. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. |
174+| Mode ligne | Sinon : une question par ligne, choix numérotés ; un numéro, le texte d'une option ou un autre texte y répond ; une ligne vide ou `/skip` prend le défaut. Les usages et les machines sont d'abord listés avec une description d'une ligne. |
175+| Faits sur le modèle | llmman et Ollama : `POST /api/show` — la fenêtre native, l'échantillonnage du GGUF (`general.sampling.*`, les paramètres du Modelfile d'Ollama), l'interrupteur de raisonnement du chat template (`enable_thinking`, `reasoning_effort`) et son défaut. llama-server : `GET /props` — `n_ctx` et le chat template. Docker Model Runner et `openai` : rien — la table des familles décide ; `openai` liste ses modèles avec `OPENAI_API_KEY`. |
176+| Origine de chaque valeur | Les faits du serveur, puis la table des familles, puis l'usage, puis la ligne de la machine ; la table est `internal/agentinit/presets.yaml`, chaque entrée avec sa source. |
177+| Échantillonnage | Raisonnement activé (l'usage le demande et le modèle a un interrupteur) : l'échantillonnage « thinking » de la famille, jamais une température sous 0,6, `max_tokens: 16384`. Raisonnement désactivé : l'échantillonnage général de la famille, ou sa température « précise » pour les usages qui utilisent beaucoup d'outils, `max_tokens: 4096`. L'échantillonnage du GGUF remplace celui de la famille. `parallel_tool_calls: false` dès qu'un outil est actif. |
178+| Fichier écrit | Chaque valeur précédée d'un commentaire qui dit pourquoi elle a cette valeur ; `contextWindow: 0` (mm demande au serveur), avec la fenêtre native en commentaire ; `context.threshold: 75` ; le prompt système en anglais de l'usage ; pour `docs` un bloc `docSearch`, pour `json` un exemple de `structuredOutput` et tous les outils désactivés, pour `coordinator` un exemple commenté de `mmConfigs`. |
179+| Fichier existant | Jamais écrasé : `⚠️ <fichier> already exists — nothing was written; choose another name with -o`, code de sortie 1, avant toute question. |
180+| Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. |
181+| Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. |
182+
151 ## Codes de sortie183 ## Codes de sortie
152 184
153 | Code | Cause |185 | Code | Cause |
modified docs/fr/reference/configuration.md +4 -1
@@ -6,7 +6,7 @@
66
77 | Clé | Type | Défaut | Description |
88 |-----|------|--------|-------------|
9-| `provider` | chaîne | `dmr` | Clé du registre du serveur LLM : `dmr`, `llamacpp`, `llmman` ou `ollama`. Surchargée par `AGENT_PROVIDER`. |
9+| `provider` | chaîne | `dmr` | Clé du registre du serveur LLM : `dmr`, `llamacpp`, `llmman`, `ollama`, ou `openai` pour tout autre serveur compatible OpenAI (voir [Utiliser n'importe quel serveur compatible OpenAI](../how-to/run-with-an-openai-compatible-server.md)). Surchargée par `AGENT_PROVIDER`. |
1010 | `model` | chaîne | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Identifiant du modèle dans la nomenclature du fournisseur. Surchargé par `AGENT_MODEL`. |
1111 | `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. |
1212 | `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. |
@@ -177,6 +177,9 @@ tools:
177177 | `llamacpp` | `http://127.0.0.1:8080/v1` | aucun | `LLAMA_API_KEY` | non |
178178 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | non |
179179 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | non |
180+| `openai` | `https://api.openai.com/v1` | aucun | `OPENAI_API_KEY` | non — un serveur qui en exige une répond 401, et l'avertissement de démarrage nomme la variable |
181+
182+Le fournisseur `openai` n'envoie que des champs standard : pas de `return_progress` (donc pas de progression pendant la lecture d'un long prompt), pas d'interrupteur de raisonnement sur une requête de résumé, pas de `/unload`. Il ne peut pas demander sa fenêtre au serveur : renseignez `contextWindow`, sinon la compression compte les messages (`context.maxMessages`). Sa sonde de démarrage envoie la clé à `GET /models` ; un 401 ou un 403 est signalé comme une clé refusée, pas comme un serveur qui ne répond pas.
180183
181184 ## `structuredOutput`
182185
@@ -6,7 +6,7 @@
6 6
7 | Clé | Type | Défaut | Description |7 | Clé | Type | Défaut | Description |
8 |-----|------|--------|-------------|8 |-----|------|--------|-------------|
9-| `provider` | chaîne | `dmr` | Clé du registre du serveur LLM : `dmr`, `llamacpp`, `llmman` ou `ollama`. Surchargée par `AGENT_PROVIDER`. |9+| `provider` | chaîne | `dmr` | Clé du registre du serveur LLM : `dmr`, `llamacpp`, `llmman`, `ollama`, ou `openai` pour tout autre serveur compatible OpenAI (voir [Utiliser n'importe quel serveur compatible OpenAI](../how-to/run-with-an-openai-compatible-server.md)). Surchargée par `AGENT_PROVIDER`. |
10 | `model` | chaîne | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Identifiant du modèle dans la nomenclature du fournisseur. Surchargé par `AGENT_MODEL`. |10 | `model` | chaîne | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Identifiant du modèle dans la nomenclature du fournisseur. Surchargé par `AGENT_MODEL`. |
11 | `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. |11 | `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. |
12 | `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. |12 | `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. |
@@ -177,6 +177,9 @@ tools:
177 | `llamacpp` | `http://127.0.0.1:8080/v1` | aucun | `LLAMA_API_KEY` | non |177 | `llamacpp` | `http://127.0.0.1:8080/v1` | aucun | `LLAMA_API_KEY` | non |
178 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | non |178 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | non |
179 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | non |179 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | non |
180+| `openai` | `https://api.openai.com/v1` | aucun | `OPENAI_API_KEY` | non — un serveur qui en exige une répond 401, et l'avertissement de démarrage nomme la variable |
181+
182+Le fournisseur `openai` n'envoie que des champs standard : pas de `return_progress` (donc pas de progression pendant la lecture d'un long prompt), pas d'interrupteur de raisonnement sur une requête de résumé, pas de `/unload`. Il ne peut pas demander sa fenêtre au serveur : renseignez `contextWindow`, sinon la compression compte les messages (`context.maxMessages`). Sa sonde de démarrage envoie la clé à `GET /models` ; un 401 ou un 403 est signalé comme une clé refusée, pas comme un serveur qui ne répond pas.
180 183
181 ## `structuredOutput`184 ## `structuredOutput`
182 185
modified guides/raccourcis-clavier-tui.md +15 -0
@@ -97,6 +97,21 @@ Quand l'agent vous pose une question (son outil `ask_user`), un petit formulaire
9797 | `F1` | Les commandes et les principales touches, dans la conversation (comme `/help`). |
9898 | `Ctrl-C` | Ferme mm quand rien ne tourne. `/quit` fait de même. |
9999
100+## La fenêtre de `mm init`
101+
102+`mm init` ouvre sa propre fenêtre, dans le même style, pour écrire un nouvel agent.yaml (voir [Créer un agent avec `mm init`](../docs/fr/how-to/create-an-agent.md)).
103+
104+| Touche | Effet |
105+|---|---|
106+| `↑` `↓` `Début` `Fin` | Se déplacent dans les choix ; l'aperçu à droite suit le choix surligné. |
107+| `Entrée` | Répond et passe à la question suivante ; sur le dernier écran, écrit le fichier. |
108+| `y` `n` | Répondent à la question oui ou non. |
109+| `Échap` | Revient d'une question en arrière, en reprenant sa réponse. |
110+| `PgUp` `PgDn` | Font défiler l'aperçu. |
111+| `Tab` | Sur un écran de moins de 100 colonnes : affiche l'aperçu à la place de la question, et inversement. |
112+| `Ctrl-U` | Efface une réponse de texte (le nom, le fichier). |
113+| `Ctrl-C` | Quitte sans rien écrire. |
114+
100115 ## Bon à savoir
101116
102117 - **Les touches dépendent du terminal.** `Alt-Entrée`, `Début`, `Fin` et les touches de fonction dépendent de la façon dont le terminal les transmet ; sur un portable Mac, il faut souvent `Fn` pour `F1`, `F2`, `Début` et `Fin`. `Ctrl-J` marche toujours pour un saut de ligne.
@@ -97,6 +97,21 @@ Quand l'agent vous pose une question (son outil `ask_user`), un petit formulaire
97 | `F1` | Les commandes et les principales touches, dans la conversation (comme `/help`). |97 | `F1` | Les commandes et les principales touches, dans la conversation (comme `/help`). |
98 | `Ctrl-C` | Ferme mm quand rien ne tourne. `/quit` fait de même. |98 | `Ctrl-C` | Ferme mm quand rien ne tourne. `/quit` fait de même. |
99 99
100+## La fenêtre de `mm init`
101+
102+`mm init` ouvre sa propre fenêtre, dans le même style, pour écrire un nouvel agent.yaml (voir [Créer un agent avec `mm init`](../docs/fr/how-to/create-an-agent.md)).
103+
104+| Touche | Effet |
105+|---|---|
106+| `↑` `↓` `Début` `Fin` | Se déplacent dans les choix ; l'aperçu à droite suit le choix surligné. |
107+| `Entrée` | Répond et passe à la question suivante ; sur le dernier écran, écrit le fichier. |
108+| `y` `n` | Répondent à la question oui ou non. |
109+| `Échap` | Revient d'une question en arrière, en reprenant sa réponse. |
110+| `PgUp` `PgDn` | Font défiler l'aperçu. |
111+| `Tab` | Sur un écran de moins de 100 colonnes : affiche l'aperçu à la place de la question, et inversement. |
112+| `Ctrl-U` | Efface une réponse de texte (le nom, le fichier). |
113+| `Ctrl-C` | Quitte sans rien écrire. |
114+
100 ## Bon à savoir115 ## Bon à savoir
101 116
102 - **Les touches dépendent du terminal.** `Alt-Entrée`, `Début`, `Fin` et les touches de fonction dépendent de la façon dont le terminal les transmet ; sur un portable Mac, il faut souvent `Fn` pour `F1`, `F2`, `Début` et `Fin`. `Ctrl-J` marche toujours pour un saut de ligne.117 - **Les touches dépendent du terminal.** `Alt-Entrée`, `Début`, `Fin` et les touches de fonction dépendent de la façon dont le terminal les transmet ; sur un portable Mac, il faut souvent `Fn` pour `F1`, `F2`, `Début` et `Fin`. `Ctrl-J` marche toujours pour un saut de ligne.
modified guides/tui-keyboard-shortcuts.md +15 -0
@@ -97,6 +97,21 @@ When the agent asks you something (its `ask_user` tool), a small form replaces t
9797 | `F1` | The commands and the main keys, in the conversation (same as `/help`). |
9898 | `Ctrl-C` | Closes mm when nothing is running. `/quit` does the same. |
9999
100+## The `mm init` window
101+
102+`mm init` opens a window of its own, in the same style, to write a new agent.yaml (see [Create an agent with `mm init`](../docs/en/how-to/create-an-agent.md)).
103+
104+| Key | What it does |
105+|---|---|
106+| `↑` `↓` `Home` `End` | Move through the choices; the preview on the right follows the highlighted one. |
107+| `Enter` | Answer and go to the next question; on the last screen, write the file. |
108+| `y` `n` | Answer the yes-or-no question. |
109+| `Esc` | Go back one question, taking its answer back. |
110+| `PgUp` `PgDn` | Scroll the preview. |
111+| `Tab` | On a screen narrower than 100 columns: show the preview in place of the question, and back. |
112+| `Ctrl-U` | Clear a text answer (the name, the file). |
113+| `Ctrl-C` | Quit without writing anything. |
114+
100115 ## Good to know
101116
102117 - **Keys depend on the terminal.** `Alt-Enter`, `Home`, `End` and the function keys depend on how the terminal reports them; on a Mac laptop, `Fn` is often needed for `F1`, `F2`, `Home` and `End`. `Ctrl-J` always works for a new line.
@@ -97,6 +97,21 @@ When the agent asks you something (its `ask_user` tool), a small form replaces t
97 | `F1` | The commands and the main keys, in the conversation (same as `/help`). |97 | `F1` | The commands and the main keys, in the conversation (same as `/help`). |
98 | `Ctrl-C` | Closes mm when nothing is running. `/quit` does the same. |98 | `Ctrl-C` | Closes mm when nothing is running. `/quit` does the same. |
99 99
100+## The `mm init` window
101+
102+`mm init` opens a window of its own, in the same style, to write a new agent.yaml (see [Create an agent with `mm init`](../docs/en/how-to/create-an-agent.md)).
103+
104+| Key | What it does |
105+|---|---|
106+| `↑` `↓` `Home` `End` | Move through the choices; the preview on the right follows the highlighted one. |
107+| `Enter` | Answer and go to the next question; on the last screen, write the file. |
108+| `y` `n` | Answer the yes-or-no question. |
109+| `Esc` | Go back one question, taking its answer back. |
110+| `PgUp` `PgDn` | Scroll the preview. |
111+| `Tab` | On a screen narrower than 100 columns: show the preview in place of the question, and back. |
112+| `Ctrl-U` | Clear a text answer (the name, the file). |
113+| `Ctrl-C` | Quit without writing anything. |
114+
100 ## Good to know115 ## Good to know
101 116
102 - **Keys depend on the terminal.** `Alt-Enter`, `Home`, `End` and the function keys depend on how the terminal reports them; on a Mac laptop, `Fn` is often needed for `F1`, `F2`, `Home` and `End`. `Ctrl-J` always works for a new line.117 - **Keys depend on the terminal.** `Alt-Enter`, `Home`, `End` and the function keys depend on how the terminal reports them; on a Mac laptop, `Fn` is often needed for `F1`, `F2`, `Home` and `End`. `Ctrl-J` always works for a new line.
added init_assemble_test.go +50 -0
new file mode 100644
@@ -0,0 +1,50 @@
1+package main
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "mm/internal/agentinit"
10+ "mm/internal/config"
11+ "mm/internal/detector"
12+ "mm/internal/engine"
13+
14+ "github.com/firebase/genkit/go/genkit"
15+)
16+
17+// `mm init` and `mm --init` both open the wizard; nothing else does.
18+func TestIsInit(t *testing.T) {
19+ for arg, want := range map[string]bool{"init": true, "--init": true, "-init": true, "agent.yaml": false, "-p": false} {
20+ if isInit(arg) != want {
21+ t.Errorf("isInit(%q) = %v", arg, !want)
22+ }
23+ }
24+}
25+
26+// A JSON agent written by `mm init` starts: structuredOutput forbids every
27+// tool, and the generated file turns them all off — main is where that rule
28+// is checked (config.StructuredOutputConflict), not config.Load.
29+func TestGeneratedJSONAgentDeclaresNoTool(t *testing.T) {
30+ saved := config.Cfg
31+ t.Cleanup(func() { config.Cfg = saved })
32+ t.Setenv("MCP_GATEWAY_URL", "")
33+ p, err := agentinit.LoadPresets()
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ text := agentinit.Render(agentinit.Decide(p, agentinit.Choices{Provider: "dmr", Model: "ai/x", Usage: "json", Machine: "small", Name: "extract"}, agentinit.Facts{}))
38+ path := filepath.Join(t.TempDir(), "agent.yaml")
39+ if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
40+ t.Fatal(err)
41+ }
42+ if _, err := config.Load(path); err != nil {
43+ t.Fatal(err)
44+ }
45+ config.Cfg.SkillsDir = t.TempDir()
46+ set := assembleTools(&engine.Engine{G: genkit.Init(context.Background())}, detector.NewLoopDetector(10, 3), nil)
47+ if err := config.StructuredOutputConflict(set.names); err != nil {
48+ t.Errorf("the generated JSON agent would not start: %v", err)
49+ }
50+}
new file mode 100644
@@ -0,0 +1,50 @@
1+package main
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "mm/internal/agentinit"
10+ "mm/internal/config"
11+ "mm/internal/detector"
12+ "mm/internal/engine"
13+
14+ "github.com/firebase/genkit/go/genkit"
15+)
16+
17+// `mm init` and `mm --init` both open the wizard; nothing else does.
18+func TestIsInit(t *testing.T) {
19+ for arg, want := range map[string]bool{"init": true, "--init": true, "-init": true, "agent.yaml": false, "-p": false} {
20+ if isInit(arg) != want {
21+ t.Errorf("isInit(%q) = %v", arg, !want)
22+ }
23+ }
24+}
25+
26+// A JSON agent written by `mm init` starts: structuredOutput forbids every
27+// tool, and the generated file turns them all off — main is where that rule
28+// is checked (config.StructuredOutputConflict), not config.Load.
29+func TestGeneratedJSONAgentDeclaresNoTool(t *testing.T) {
30+ saved := config.Cfg
31+ t.Cleanup(func() { config.Cfg = saved })
32+ t.Setenv("MCP_GATEWAY_URL", "")
33+ p, err := agentinit.LoadPresets()
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ text := agentinit.Render(agentinit.Decide(p, agentinit.Choices{Provider: "dmr", Model: "ai/x", Usage: "json", Machine: "small", Name: "extract"}, agentinit.Facts{}))
38+ path := filepath.Join(t.TempDir(), "agent.yaml")
39+ if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
40+ t.Fatal(err)
41+ }
42+ if _, err := config.Load(path); err != nil {
43+ t.Fatal(err)
44+ }
45+ config.Cfg.SkillsDir = t.TempDir()
46+ set := assembleTools(&engine.Engine{G: genkit.Init(context.Background())}, detector.NewLoopDetector(10, 3), nil)
47+ if err := config.StructuredOutputConflict(set.names); err != nil {
48+ t.Errorf("the generated JSON agent would not start: %v", err)
49+ }
50+}
added internal/agentinit/decide.go +264 -0
new file mode 100644
@@ -0,0 +1,264 @@
1+package agentinit
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// Choices are the wizard's answers.
9+type Choices struct {
10+ Provider string // dmr, llmman, ollama, llamacpp
11+ BaseURL string // "" = the provider's default
12+ Model string
13+ Usage string
14+ Machine string
15+ Family string // "" = detected from the model's name
16+ Name string // acp.name
17+ Yolo *bool // nil = the usage's default
18+}
19+
20+// Setting is one value of the generated file with the reason it has that
21+// value — the comment written above it.
22+type Setting struct {
23+ Key string
24+ Value string // already formatted as YAML
25+ Reason string
26+}
27+
28+// Settings is everything the generated file holds.
29+type Settings struct {
30+ Choices Choices
31+ Family Family
32+ Usage Usage
33+ Machine Machine
34+ Facts Facts
35+ Thinking bool // the agent reasons
36+
37+ Top []Setting // contextWindow, yolo, tools, limits, timeouts — in file order
38+ Sampling []Setting // under sampling:
39+ Kwargs []Setting // under sampling.chat_template_kwargs:
40+ Context []Setting // under context:
41+ Notes []string // the file's closing remarks
42+}
43+
44+// Decide turns the answers and the server's facts into settings, each with
45+// its reason. Order of trust: the server's facts about this model, then the
46+// family table, then the usage profile, then the machine row.
47+//
48+// s := agentinit.Decide(p, choices, facts)
49+// fmt.Print(agentinit.Render(s))
50+func Decide(p Presets, c Choices, f Facts) Settings {
51+ u, _ := p.Usage(c.Usage)
52+ m, _ := p.Machine(c.Machine)
53+ fam := p.FamilyOf(c.Model, f.ServerFamily)
54+ if c.Family != "" {
55+ fam = p.Family(c.Family)
56+ }
57+ s := Settings{Choices: c, Family: fam, Usage: u, Machine: m, Facts: f}
58+ s.Thinking = u.Thinking && reasons(fam, f)
59+
60+ s.decideTop()
61+ s.decideSampling()
62+ s.decideContext()
63+ s.decideNotes()
64+ return s
65+}
66+
67+// reasons reports whether the model has a reasoning switch to turn on: the
68+// template's, else the family table's.
69+func reasons(fam Family, f Facts) bool {
70+ if f.Thinking.Switch != "" {
71+ return true
72+ }
73+ return fam.Reasoning != "none" && fam.Reasoning != ""
74+}
75+
76+func (s *Settings) add(key, value, reason string) {
77+ s.Top = append(s.Top, Setting{key, value, reason})
78+}
79+
80+func (s *Settings) decideTop() {
81+ u, m, f := s.Usage, s.Machine, s.Facts
82+ window := "0"
83+ reason := "0 = mm asks the server for the window it serves"
84+ if s.Choices.Provider == "openai" {
85+ reason = "0 = unknown: the generic openai provider cannot ask the server for its window, so compression counts messages (context.maxMessages) instead — set the window your server serves"
86+ }
87+ if f.ContextLength > 0 {
88+ reason += fmt.Sprintf(" (the model's native window is %d tokens; the server may serve less)", f.ContextLength)
89+ }
90+ s.add("contextWindow", window, reason)
91+
92+ yolo := u.Yolo
93+ yoloReason := fmt.Sprintf("usage %s's default", u.Name)
94+ if s.Choices.Yolo != nil {
95+ yolo, yoloReason = *s.Choices.Yolo, "your answer"
96+ }
97+ s.add("yolo", boolYAML(yolo), yoloReason+": "+map[bool]string{true: "tools run without asking", false: "every command and file change waits for your answer"}[yolo])
98+
99+ for _, key := range []string{"bashTool", "editTools", "planTool", "askUserTool", "fetchContentTool", "saveAnswerTool"} {
100+ if on, set := u.Tools[key]; set {
101+ s.add(key, boolYAML(on), "usage "+u.Name)
102+ }
103+ }
104+ if u.JSON || u.Coordinator {
105+ s.add("unloadModels", "false", "no unload_model tool: this agent "+map[bool]string{true: "answers in JSON, which forbids tools", false: "only hands questions to other agents"}[u.JSON])
106+ }
107+ s.add("skillsDir", "no-skills", "no skills: add a skills/ folder and set skillsDir to use some (see docs/en/how-to/add-a-skill.md)")
108+
109+ s.add("maxTurns", fmt.Sprint(u.MaxTurns), "usage "+u.Name)
110+ s.add("maxOutput", fmt.Sprint(m.MaxOutput), m.Source)
111+ s.add("firstTokenTimeout", m.FirstTokenTimeout, m.Source)
112+ watchdog, watchdogReason := m.WatchdogTimeout, m.Source
113+ if u.Tools["bashTool"] && m.WatchdogTimeout != m.BashTimeout {
114+ watchdog, watchdogReason = m.BashTimeout, "aligned on bashTimeout, as the settings guide says when bash is on (section 3)"
115+ }
116+ s.add("watchdogTimeout", watchdog, watchdogReason)
117+ if u.Tools["bashTool"] {
118+ s.add("bashTimeout", m.BashTimeout, m.Source)
119+ }
120+}
121+
122+func (s *Settings) decideSampling() {
123+ fam, f := s.Family, s.Facts
124+ base, source := fam.General, "family "+fam.Name+": "+fam.Source
125+ if s.Thinking && fam.Thinking.Temperature != nil {
126+ base = fam.Thinking
127+ }
128+ values := map[string]float64{}
129+ put := func(key string, v *float64) {
130+ if v != nil {
131+ values[key] = *v
132+ }
133+ }
134+ put("temperature", base.Temperature)
135+ put("top_p", base.TopP)
136+ put("min_p", base.MinP)
137+ if base.TopK != nil {
138+ values["top_k"] = float64(*base.TopK)
139+ }
140+ reasons := map[string]string{}
141+ for k := range values {
142+ reasons[k] = source
143+ }
144+ if len(f.Sampling) > 0 {
145+ for k, v := range f.Sampling {
146+ values[k], reasons[k] = v, "the GGUF's own sampling, read from the server"
147+ }
148+ }
149+ if !s.Thinking && s.Usage.Precise && fam.Precise != nil {
150+ values["temperature"] = *fam.Precise
151+ reasons["temperature"] = fmt.Sprintf("precise temperature for a %s agent without reasoning (family %s)", s.Usage.Name, fam.Name)
152+ }
153+ if s.Thinking && values["temperature"] < 0.5 {
154+ values["temperature"], reasons["temperature"] = 0.6, "reasoning is on: a thinking model loops at a low temperature"
155+ }
156+ for _, k := range []string{"temperature", "top_p", "top_k", "min_p"} {
157+ if v, ok := values[k]; ok {
158+ s.Sampling = append(s.Sampling, Setting{k, number(k, v), reasons[k]})
159+ }
160+ }
161+ tokens, tokensReason := "4096", "room for an answer or a whole file in one tool call"
162+ if s.Thinking {
163+ tokens, tokensReason = "16384", "reasoning on: room for the thoughts plus a whole tool call"
164+ }
165+ maxKey := "max_tokens"
166+ if s.Family.MaxTokensKey != "" {
167+ maxKey, tokensReason = s.Family.MaxTokensKey, tokensReason+" (family "+s.Family.Name+" names it "+s.Family.MaxTokensKey+")"
168+ }
169+ s.Sampling = append(s.Sampling, Setting{maxKey, tokens, tokensReason})
170+ if s.anyTool() {
171+ s.Sampling = append(s.Sampling, Setting{"parallel_tool_calls", "false", "one tool call per reply: small models run a check before the change it checks has landed otherwise"})
172+ }
173+ s.decideKwargs()
174+}
175+
176+func (s *Settings) decideKwargs() {
177+ sw := s.Facts.Thinking.Switch
178+ source := "read from the chat template"
179+ if sw == "" {
180+ sw, source = s.Family.Reasoning, "family "+s.Family.Name
181+ }
182+ switch sw {
183+ case "enable_thinking":
184+ why := "reasoning off: short, direct answers"
185+ if s.Thinking {
186+ why = "reasoning on: code and multi-step work"
187+ }
188+ if d := s.Facts.Thinking.Default; d != "" {
189+ why += "; the template's default is " + d
190+ }
191+ s.Kwargs = append(s.Kwargs, Setting{"enable_thinking", boolYAML(s.Thinking), why + " (" + source + ")"})
192+ case "reasoning_effort":
193+ values := s.Facts.Thinking.EffortValues
194+ if len(values) == 0 {
195+ values = s.Family.EffortValues
196+ }
197+ effort := "low"
198+ if s.Thinking {
199+ effort = "medium"
200+ }
201+ if len(values) > 0 && !contains(values, effort) {
202+ effort = values[0]
203+ }
204+ s.Kwargs = append(s.Kwargs, Setting{"reasoning_effort", effort, fmt.Sprintf("one of %s (%s); a value the template does not know may make every request fail", strings.Join(values, ", "), source)})
205+ }
206+}
207+
208+func (s *Settings) decideContext() {
209+ s.Context = append(s.Context, Setting{"enabled", "true", "compress the history before the window is full (settings guide, every row)"})
210+ // The percentage applies to the window the server SERVES, which mm
211+ // probes (contextWindow: 0) and which may be far below the model's
212+ // native one (Ollama often serves 4k–8k): a threshold computed from the
213+ // native window would compress almost at once. mm's default it is.
214+ s.Context = append(s.Context, Setting{"threshold", "75", "mm's default: 75 % of the window the server serves, a quarter left for the next question and its tool outputs"})
215+}
216+
217+func (s *Settings) decideNotes() {
218+ if s.Family.Note != "" {
219+ s.Notes = append(s.Notes, "This model is "+s.Family.Note+".")
220+ }
221+ if s.Family.Name == "other" {
222+ s.Notes = append(s.Notes, "The family of this model is not in mm's table: the sampling is a generic starting point — check the model card.")
223+ }
224+ if !s.Facts.Known {
225+ s.Notes = append(s.Notes, "The server said nothing about this model beyond its name: the settings come from the family table.")
226+ }
227+}
228+
229+func (s *Settings) anyTool() bool {
230+ for _, on := range s.Usage.Tools {
231+ if on {
232+ return true
233+ }
234+ }
235+ return s.Usage.Docs
236+}
237+
238+func boolYAML(b bool) string {
239+ if b {
240+ return "true"
241+ }
242+ return "false"
243+}
244+
245+func number(key string, v float64) string {
246+ if key == "top_k" {
247+ return fmt.Sprint(int(v))
248+ }
249+ text := fmt.Sprintf("%.2f", v)
250+ text = strings.TrimRight(strings.TrimRight(text, "0"), ".")
251+ if !strings.Contains(text, ".") {
252+ text += ".0"
253+ }
254+ return text
255+}
256+
257+func contains(list []string, s string) bool {
258+ for _, v := range list {
259+ if v == s {
260+ return true
261+ }
262+ }
263+ return false
264+}
new file mode 100644
@@ -0,0 +1,264 @@
1+package agentinit
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// Choices are the wizard's answers.
9+type Choices struct {
10+ Provider string // dmr, llmman, ollama, llamacpp
11+ BaseURL string // "" = the provider's default
12+ Model string
13+ Usage string
14+ Machine string
15+ Family string // "" = detected from the model's name
16+ Name string // acp.name
17+ Yolo *bool // nil = the usage's default
18+}
19+
20+// Setting is one value of the generated file with the reason it has that
21+// value — the comment written above it.
22+type Setting struct {
23+ Key string
24+ Value string // already formatted as YAML
25+ Reason string
26+}
27+
28+// Settings is everything the generated file holds.
29+type Settings struct {
30+ Choices Choices
31+ Family Family
32+ Usage Usage
33+ Machine Machine
34+ Facts Facts
35+ Thinking bool // the agent reasons
36+
37+ Top []Setting // contextWindow, yolo, tools, limits, timeouts — in file order
38+ Sampling []Setting // under sampling:
39+ Kwargs []Setting // under sampling.chat_template_kwargs:
40+ Context []Setting // under context:
41+ Notes []string // the file's closing remarks
42+}
43+
44+// Decide turns the answers and the server's facts into settings, each with
45+// its reason. Order of trust: the server's facts about this model, then the
46+// family table, then the usage profile, then the machine row.
47+//
48+// s := agentinit.Decide(p, choices, facts)
49+// fmt.Print(agentinit.Render(s))
50+func Decide(p Presets, c Choices, f Facts) Settings {
51+ u, _ := p.Usage(c.Usage)
52+ m, _ := p.Machine(c.Machine)
53+ fam := p.FamilyOf(c.Model, f.ServerFamily)
54+ if c.Family != "" {
55+ fam = p.Family(c.Family)
56+ }
57+ s := Settings{Choices: c, Family: fam, Usage: u, Machine: m, Facts: f}
58+ s.Thinking = u.Thinking && reasons(fam, f)
59+
60+ s.decideTop()
61+ s.decideSampling()
62+ s.decideContext()
63+ s.decideNotes()
64+ return s
65+}
66+
67+// reasons reports whether the model has a reasoning switch to turn on: the
68+// template's, else the family table's.
69+func reasons(fam Family, f Facts) bool {
70+ if f.Thinking.Switch != "" {
71+ return true
72+ }
73+ return fam.Reasoning != "none" && fam.Reasoning != ""
74+}
75+
76+func (s *Settings) add(key, value, reason string) {
77+ s.Top = append(s.Top, Setting{key, value, reason})
78+}
79+
80+func (s *Settings) decideTop() {
81+ u, m, f := s.Usage, s.Machine, s.Facts
82+ window := "0"
83+ reason := "0 = mm asks the server for the window it serves"
84+ if s.Choices.Provider == "openai" {
85+ reason = "0 = unknown: the generic openai provider cannot ask the server for its window, so compression counts messages (context.maxMessages) instead — set the window your server serves"
86+ }
87+ if f.ContextLength > 0 {
88+ reason += fmt.Sprintf(" (the model's native window is %d tokens; the server may serve less)", f.ContextLength)
89+ }
90+ s.add("contextWindow", window, reason)
91+
92+ yolo := u.Yolo
93+ yoloReason := fmt.Sprintf("usage %s's default", u.Name)
94+ if s.Choices.Yolo != nil {
95+ yolo, yoloReason = *s.Choices.Yolo, "your answer"
96+ }
97+ s.add("yolo", boolYAML(yolo), yoloReason+": "+map[bool]string{true: "tools run without asking", false: "every command and file change waits for your answer"}[yolo])
98+
99+ for _, key := range []string{"bashTool", "editTools", "planTool", "askUserTool", "fetchContentTool", "saveAnswerTool"} {
100+ if on, set := u.Tools[key]; set {
101+ s.add(key, boolYAML(on), "usage "+u.Name)
102+ }
103+ }
104+ if u.JSON || u.Coordinator {
105+ s.add("unloadModels", "false", "no unload_model tool: this agent "+map[bool]string{true: "answers in JSON, which forbids tools", false: "only hands questions to other agents"}[u.JSON])
106+ }
107+ s.add("skillsDir", "no-skills", "no skills: add a skills/ folder and set skillsDir to use some (see docs/en/how-to/add-a-skill.md)")
108+
109+ s.add("maxTurns", fmt.Sprint(u.MaxTurns), "usage "+u.Name)
110+ s.add("maxOutput", fmt.Sprint(m.MaxOutput), m.Source)
111+ s.add("firstTokenTimeout", m.FirstTokenTimeout, m.Source)
112+ watchdog, watchdogReason := m.WatchdogTimeout, m.Source
113+ if u.Tools["bashTool"] && m.WatchdogTimeout != m.BashTimeout {
114+ watchdog, watchdogReason = m.BashTimeout, "aligned on bashTimeout, as the settings guide says when bash is on (section 3)"
115+ }
116+ s.add("watchdogTimeout", watchdog, watchdogReason)
117+ if u.Tools["bashTool"] {
118+ s.add("bashTimeout", m.BashTimeout, m.Source)
119+ }
120+}
121+
122+func (s *Settings) decideSampling() {
123+ fam, f := s.Family, s.Facts
124+ base, source := fam.General, "family "+fam.Name+": "+fam.Source
125+ if s.Thinking && fam.Thinking.Temperature != nil {
126+ base = fam.Thinking
127+ }
128+ values := map[string]float64{}
129+ put := func(key string, v *float64) {
130+ if v != nil {
131+ values[key] = *v
132+ }
133+ }
134+ put("temperature", base.Temperature)
135+ put("top_p", base.TopP)
136+ put("min_p", base.MinP)
137+ if base.TopK != nil {
138+ values["top_k"] = float64(*base.TopK)
139+ }
140+ reasons := map[string]string{}
141+ for k := range values {
142+ reasons[k] = source
143+ }
144+ if len(f.Sampling) > 0 {
145+ for k, v := range f.Sampling {
146+ values[k], reasons[k] = v, "the GGUF's own sampling, read from the server"
147+ }
148+ }
149+ if !s.Thinking && s.Usage.Precise && fam.Precise != nil {
150+ values["temperature"] = *fam.Precise
151+ reasons["temperature"] = fmt.Sprintf("precise temperature for a %s agent without reasoning (family %s)", s.Usage.Name, fam.Name)
152+ }
153+ if s.Thinking && values["temperature"] < 0.5 {
154+ values["temperature"], reasons["temperature"] = 0.6, "reasoning is on: a thinking model loops at a low temperature"
155+ }
156+ for _, k := range []string{"temperature", "top_p", "top_k", "min_p"} {
157+ if v, ok := values[k]; ok {
158+ s.Sampling = append(s.Sampling, Setting{k, number(k, v), reasons[k]})
159+ }
160+ }
161+ tokens, tokensReason := "4096", "room for an answer or a whole file in one tool call"
162+ if s.Thinking {
163+ tokens, tokensReason = "16384", "reasoning on: room for the thoughts plus a whole tool call"
164+ }
165+ maxKey := "max_tokens"
166+ if s.Family.MaxTokensKey != "" {
167+ maxKey, tokensReason = s.Family.MaxTokensKey, tokensReason+" (family "+s.Family.Name+" names it "+s.Family.MaxTokensKey+")"
168+ }
169+ s.Sampling = append(s.Sampling, Setting{maxKey, tokens, tokensReason})
170+ if s.anyTool() {
171+ s.Sampling = append(s.Sampling, Setting{"parallel_tool_calls", "false", "one tool call per reply: small models run a check before the change it checks has landed otherwise"})
172+ }
173+ s.decideKwargs()
174+}
175+
176+func (s *Settings) decideKwargs() {
177+ sw := s.Facts.Thinking.Switch
178+ source := "read from the chat template"
179+ if sw == "" {
180+ sw, source = s.Family.Reasoning, "family "+s.Family.Name
181+ }
182+ switch sw {
183+ case "enable_thinking":
184+ why := "reasoning off: short, direct answers"
185+ if s.Thinking {
186+ why = "reasoning on: code and multi-step work"
187+ }
188+ if d := s.Facts.Thinking.Default; d != "" {
189+ why += "; the template's default is " + d
190+ }
191+ s.Kwargs = append(s.Kwargs, Setting{"enable_thinking", boolYAML(s.Thinking), why + " (" + source + ")"})
192+ case "reasoning_effort":
193+ values := s.Facts.Thinking.EffortValues
194+ if len(values) == 0 {
195+ values = s.Family.EffortValues
196+ }
197+ effort := "low"
198+ if s.Thinking {
199+ effort = "medium"
200+ }
201+ if len(values) > 0 && !contains(values, effort) {
202+ effort = values[0]
203+ }
204+ s.Kwargs = append(s.Kwargs, Setting{"reasoning_effort", effort, fmt.Sprintf("one of %s (%s); a value the template does not know may make every request fail", strings.Join(values, ", "), source)})
205+ }
206+}
207+
208+func (s *Settings) decideContext() {
209+ s.Context = append(s.Context, Setting{"enabled", "true", "compress the history before the window is full (settings guide, every row)"})
210+ // The percentage applies to the window the server SERVES, which mm
211+ // probes (contextWindow: 0) and which may be far below the model's
212+ // native one (Ollama often serves 4k–8k): a threshold computed from the
213+ // native window would compress almost at once. mm's default it is.
214+ s.Context = append(s.Context, Setting{"threshold", "75", "mm's default: 75 % of the window the server serves, a quarter left for the next question and its tool outputs"})
215+}
216+
217+func (s *Settings) decideNotes() {
218+ if s.Family.Note != "" {
219+ s.Notes = append(s.Notes, "This model is "+s.Family.Note+".")
220+ }
221+ if s.Family.Name == "other" {
222+ s.Notes = append(s.Notes, "The family of this model is not in mm's table: the sampling is a generic starting point — check the model card.")
223+ }
224+ if !s.Facts.Known {
225+ s.Notes = append(s.Notes, "The server said nothing about this model beyond its name: the settings come from the family table.")
226+ }
227+}
228+
229+func (s *Settings) anyTool() bool {
230+ for _, on := range s.Usage.Tools {
231+ if on {
232+ return true
233+ }
234+ }
235+ return s.Usage.Docs
236+}
237+
238+func boolYAML(b bool) string {
239+ if b {
240+ return "true"
241+ }
242+ return "false"
243+}
244+
245+func number(key string, v float64) string {
246+ if key == "top_k" {
247+ return fmt.Sprint(int(v))
248+ }
249+ text := fmt.Sprintf("%.2f", v)
250+ text = strings.TrimRight(strings.TrimRight(text, "0"), ".")
251+ if !strings.Contains(text, ".") {
252+ text += ".0"
253+ }
254+ return text
255+}
256+
257+func contains(list []string, s string) bool {
258+ for _, v := range list {
259+ if v == s {
260+ return true
261+ }
262+ }
263+ return false
264+}
added internal/agentinit/decide_test.go +155 -0
new file mode 100644
@@ -0,0 +1,155 @@
1+package agentinit
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+)
11+
12+// loadRendered writes the rendered file and loads it as mm would.
13+func loadRendered(t *testing.T, text string) {
14+ t.Helper()
15+ saved := config.Cfg
16+ t.Cleanup(func() { config.Cfg = saved })
17+ path := filepath.Join(t.TempDir(), "agent.yaml")
18+ if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+ if _, err := config.Load(path); err != nil {
22+ t.Fatalf("mm does not load the generated file: %v\n%s", err, text)
23+ }
24+}
25+
26+// Every usage, on models of several families, gives a file mm loads.
27+func TestEveryGeneratedFileLoads(t *testing.T) {
28+ p, _ := LoadPresets()
29+ for _, model := range []string{"hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M", "hf.co/unsloth/gpt-oss-20b-GGUF:Q4_K_M", "llama3.2:3b", "some/unknown"} {
30+ for _, usage := range p.UsageNames() {
31+ for _, machine := range p.MachineNames() {
32+ c := Choices{Provider: "llmman", Model: model, Usage: usage, Machine: machine, Name: "bob"}
33+ loadRendered(t, Render(Decide(p, c, Facts{})))
34+ }
35+ }
36+ }
37+}
38+
39+func setting(list []Setting, key string) (string, bool) {
40+ for _, s := range list {
41+ if s.Key == key {
42+ return s.Value, true
43+ }
44+ }
45+ return "", false
46+}
47+
48+func TestDecideSampling(t *testing.T) {
49+ p, _ := LoadPresets()
50+ qwen := "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
51+ cases := []struct {
52+ name string
53+ c Choices
54+ f Facts
55+ want map[string]string // sampling
56+ kwargs map[string]string
57+ }{
58+ {"developer reasons with the thinking sampling", Choices{Model: qwen, Usage: "developer", Machine: "small"}, Facts{},
59+ map[string]string{"temperature": "0.6", "top_p": "0.95", "top_k": "20", "max_tokens": "16384"}, map[string]string{"enable_thinking": "true"}},
60+ {"docs: precise, no reasoning", Choices{Model: qwen, Usage: "docs", Machine: "small"}, Facts{},
61+ map[string]string{"temperature": "0.0", "max_tokens": "4096", "parallel_tool_calls": "false"}, map[string]string{"enable_thinking": "false"}},
62+ {"assistant: the family's general sampling", Choices{Model: qwen, Usage: "assistant", Machine: "small"}, Facts{},
63+ map[string]string{"temperature": "0.7", "top_p": "0.8"}, map[string]string{"enable_thinking": "false"}},
64+ {"the GGUF's sampling wins over the family's", Choices{Model: qwen, Usage: "writer", Machine: "small"},
65+ Facts{Known: true, Sampling: map[string]float64{"temperature": 0.9, "top_k": 40}},
66+ map[string]string{"temperature": "0.9", "top_k": "40"}, nil},
67+ {"gpt-oss: reasoning_effort, from the template's values", Choices{Model: "gpt-oss-20b", Usage: "developer", Machine: "medium"},
68+ Facts{Known: true, Thinking: Thinking{Switch: "reasoning_effort", EffortValues: []string{"high", "low", "medium"}}},
69+ map[string]string{"temperature": "1.0"}, map[string]string{"reasoning_effort": "medium"}},
70+ {"no switch: no kwargs, no reasoning", Choices{Model: "llama3.2:3b", Usage: "developer", Machine: "small"}, Facts{},
71+ map[string]string{"temperature": "0.2", "max_tokens": "4096"}, map[string]string{}},
72+ }
73+ for _, c := range cases {
74+ s := Decide(p, c.c, c.f)
75+ for k, v := range c.want {
76+ if got, _ := setting(s.Sampling, k); got != v {
77+ t.Errorf("%s: sampling %s = %q, want %q", c.name, k, got, v)
78+ }
79+ }
80+ if c.kwargs != nil {
81+ if len(c.kwargs) == 0 && len(s.Kwargs) > 0 {
82+ t.Errorf("%s: kwargs %+v, want none", c.name, s.Kwargs)
83+ }
84+ for k, v := range c.kwargs {
85+ if got, _ := setting(s.Kwargs, k); got != v {
86+ t.Errorf("%s: kwarg %s = %q, want %q", c.name, k, got, v)
87+ }
88+ }
89+ }
90+ }
91+}
92+
93+// Tools, limits and timeouts come from the usage and the machine; with bash
94+// on, the watchdog is aligned on bashTimeout; the JSON usage writes a
95+// structuredOutput and no tool, the docs usage a docSearch block.
96+func TestDecideAndRenderTheRest(t *testing.T) {
97+ p, _ := LoadPresets()
98+ yes := true
99+ s := Decide(p, Choices{Provider: "llmman", Model: "qwen3", Usage: "developer", Machine: "small", Name: "bob", Yolo: &yes}, Facts{ContextLength: 262144})
100+ for key, want := range map[string]string{"bashTool": "true", "yolo": "true", "maxTurns": "40", "maxOutput": "8000", "watchdogTimeout": "30s", "bashTimeout": "30s", "contextWindow": "0"} {
101+ if got, _ := setting(s.Top, key); got != want {
102+ t.Errorf("%s = %q, want %q", key, got, want)
103+ }
104+ }
105+ text := Render(s)
106+ if !strings.Contains(text, "262144 tokens") || !strings.Contains(text, "threshold: 75") {
107+ t.Errorf("window comment or threshold missing:\n%s", text)
108+ }
109+ if !strings.Contains(text, "# usage developer\nbashTool: true\neditTools: true\nplanTool: true\naskUserTool: true\n") {
110+ t.Errorf("consecutive settings with the same reason should share one comment:\n%s", text)
111+ }
112+
113+ js := Render(Decide(p, Choices{Provider: "dmr", Model: "x", Usage: "json", Machine: "small", Name: "extract"}, Facts{}))
114+ for _, want := range []string{"structuredOutput: |", "var Answer Contact", "bashTool: false", "unloadModels: false", "skillsDir: no-skills"} {
115+ if !strings.Contains(js, want) {
116+ t.Errorf("json usage lacks %q", want)
117+ }
118+ }
119+ docs := Render(Decide(p, Choices{Provider: "llmman", Model: "x", Usage: "docs", Machine: "small", Name: "rata"}, Facts{}))
120+ if !strings.Contains(docs, "docSearch:\n searchDocs: true\n autoSearchDocs: true") {
121+ t.Errorf("docs usage lacks the docSearch block:\n%s", docs)
122+ }
123+ guard := Decide(p, Choices{Provider: "llmman", Model: "granite-guardian-3.2", Usage: "assistant", Machine: "small"}, Facts{})
124+ if len(guard.Notes) == 0 || !strings.Contains(guard.Notes[0], "guard model") {
125+ t.Errorf("guard family note missing: %v", guard.Notes)
126+ }
127+}
128+
129+// A hosted OpenAI model: max_completion_tokens, the default temperature,
130+// a note; the file says where the key comes from, and loads.
131+func TestOpenAIFamilyAndProvider(t *testing.T) {
132+ p, _ := LoadPresets()
133+ s := Decide(p, Choices{Provider: "openai", Model: "gpt-5-mini", Usage: "developer", Machine: "medium", Name: "g"}, Facts{})
134+ if s.Family.Name != "openai" {
135+ t.Fatalf("family %s", s.Family.Name)
136+ }
137+ if _, ok := setting(s.Sampling, "max_completion_tokens"); !ok {
138+ t.Errorf("no max_completion_tokens: %+v", s.Sampling)
139+ }
140+ if _, ok := setting(s.Sampling, "max_tokens"); ok {
141+ t.Error("max_tokens written for the openai family")
142+ }
143+ if got, _ := setting(s.Sampling, "temperature"); got != "1.0" {
144+ t.Errorf("temperature %s", got)
145+ }
146+ text := Render(s)
147+ flat := strings.ReplaceAll(text, "\n# ", " ") // comments are wrapped
148+ if !strings.Contains(flat, "OPENAI_API_KEY") || !strings.Contains(flat, "No baseUrl: the OpenAI API") || !strings.Contains(flat, "cannot ask the server for its window") {
149+ t.Errorf("key / address comment missing:\n%s", text)
150+ }
151+ loadRendered(t, text)
152+ if contains(local, "openai") {
153+ t.Error("openai must not be probed for the first question's default")
154+ }
155+}
new file mode 100644
@@ -0,0 +1,155 @@
1+package agentinit
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+)
11+
12+// loadRendered writes the rendered file and loads it as mm would.
13+func loadRendered(t *testing.T, text string) {
14+ t.Helper()
15+ saved := config.Cfg
16+ t.Cleanup(func() { config.Cfg = saved })
17+ path := filepath.Join(t.TempDir(), "agent.yaml")
18+ if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+ if _, err := config.Load(path); err != nil {
22+ t.Fatalf("mm does not load the generated file: %v\n%s", err, text)
23+ }
24+}
25+
26+// Every usage, on models of several families, gives a file mm loads.
27+func TestEveryGeneratedFileLoads(t *testing.T) {
28+ p, _ := LoadPresets()
29+ for _, model := range []string{"hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M", "hf.co/unsloth/gpt-oss-20b-GGUF:Q4_K_M", "llama3.2:3b", "some/unknown"} {
30+ for _, usage := range p.UsageNames() {
31+ for _, machine := range p.MachineNames() {
32+ c := Choices{Provider: "llmman", Model: model, Usage: usage, Machine: machine, Name: "bob"}
33+ loadRendered(t, Render(Decide(p, c, Facts{})))
34+ }
35+ }
36+ }
37+}
38+
39+func setting(list []Setting, key string) (string, bool) {
40+ for _, s := range list {
41+ if s.Key == key {
42+ return s.Value, true
43+ }
44+ }
45+ return "", false
46+}
47+
48+func TestDecideSampling(t *testing.T) {
49+ p, _ := LoadPresets()
50+ qwen := "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
51+ cases := []struct {
52+ name string
53+ c Choices
54+ f Facts
55+ want map[string]string // sampling
56+ kwargs map[string]string
57+ }{
58+ {"developer reasons with the thinking sampling", Choices{Model: qwen, Usage: "developer", Machine: "small"}, Facts{},
59+ map[string]string{"temperature": "0.6", "top_p": "0.95", "top_k": "20", "max_tokens": "16384"}, map[string]string{"enable_thinking": "true"}},
60+ {"docs: precise, no reasoning", Choices{Model: qwen, Usage: "docs", Machine: "small"}, Facts{},
61+ map[string]string{"temperature": "0.0", "max_tokens": "4096", "parallel_tool_calls": "false"}, map[string]string{"enable_thinking": "false"}},
62+ {"assistant: the family's general sampling", Choices{Model: qwen, Usage: "assistant", Machine: "small"}, Facts{},
63+ map[string]string{"temperature": "0.7", "top_p": "0.8"}, map[string]string{"enable_thinking": "false"}},
64+ {"the GGUF's sampling wins over the family's", Choices{Model: qwen, Usage: "writer", Machine: "small"},
65+ Facts{Known: true, Sampling: map[string]float64{"temperature": 0.9, "top_k": 40}},
66+ map[string]string{"temperature": "0.9", "top_k": "40"}, nil},
67+ {"gpt-oss: reasoning_effort, from the template's values", Choices{Model: "gpt-oss-20b", Usage: "developer", Machine: "medium"},
68+ Facts{Known: true, Thinking: Thinking{Switch: "reasoning_effort", EffortValues: []string{"high", "low", "medium"}}},
69+ map[string]string{"temperature": "1.0"}, map[string]string{"reasoning_effort": "medium"}},
70+ {"no switch: no kwargs, no reasoning", Choices{Model: "llama3.2:3b", Usage: "developer", Machine: "small"}, Facts{},
71+ map[string]string{"temperature": "0.2", "max_tokens": "4096"}, map[string]string{}},
72+ }
73+ for _, c := range cases {
74+ s := Decide(p, c.c, c.f)
75+ for k, v := range c.want {
76+ if got, _ := setting(s.Sampling, k); got != v {
77+ t.Errorf("%s: sampling %s = %q, want %q", c.name, k, got, v)
78+ }
79+ }
80+ if c.kwargs != nil {
81+ if len(c.kwargs) == 0 && len(s.Kwargs) > 0 {
82+ t.Errorf("%s: kwargs %+v, want none", c.name, s.Kwargs)
83+ }
84+ for k, v := range c.kwargs {
85+ if got, _ := setting(s.Kwargs, k); got != v {
86+ t.Errorf("%s: kwarg %s = %q, want %q", c.name, k, got, v)
87+ }
88+ }
89+ }
90+ }
91+}
92+
93+// Tools, limits and timeouts come from the usage and the machine; with bash
94+// on, the watchdog is aligned on bashTimeout; the JSON usage writes a
95+// structuredOutput and no tool, the docs usage a docSearch block.
96+func TestDecideAndRenderTheRest(t *testing.T) {
97+ p, _ := LoadPresets()
98+ yes := true
99+ s := Decide(p, Choices{Provider: "llmman", Model: "qwen3", Usage: "developer", Machine: "small", Name: "bob", Yolo: &yes}, Facts{ContextLength: 262144})
100+ for key, want := range map[string]string{"bashTool": "true", "yolo": "true", "maxTurns": "40", "maxOutput": "8000", "watchdogTimeout": "30s", "bashTimeout": "30s", "contextWindow": "0"} {
101+ if got, _ := setting(s.Top, key); got != want {
102+ t.Errorf("%s = %q, want %q", key, got, want)
103+ }
104+ }
105+ text := Render(s)
106+ if !strings.Contains(text, "262144 tokens") || !strings.Contains(text, "threshold: 75") {
107+ t.Errorf("window comment or threshold missing:\n%s", text)
108+ }
109+ if !strings.Contains(text, "# usage developer\nbashTool: true\neditTools: true\nplanTool: true\naskUserTool: true\n") {
110+ t.Errorf("consecutive settings with the same reason should share one comment:\n%s", text)
111+ }
112+
113+ js := Render(Decide(p, Choices{Provider: "dmr", Model: "x", Usage: "json", Machine: "small", Name: "extract"}, Facts{}))
114+ for _, want := range []string{"structuredOutput: |", "var Answer Contact", "bashTool: false", "unloadModels: false", "skillsDir: no-skills"} {
115+ if !strings.Contains(js, want) {
116+ t.Errorf("json usage lacks %q", want)
117+ }
118+ }
119+ docs := Render(Decide(p, Choices{Provider: "llmman", Model: "x", Usage: "docs", Machine: "small", Name: "rata"}, Facts{}))
120+ if !strings.Contains(docs, "docSearch:\n searchDocs: true\n autoSearchDocs: true") {
121+ t.Errorf("docs usage lacks the docSearch block:\n%s", docs)
122+ }
123+ guard := Decide(p, Choices{Provider: "llmman", Model: "granite-guardian-3.2", Usage: "assistant", Machine: "small"}, Facts{})
124+ if len(guard.Notes) == 0 || !strings.Contains(guard.Notes[0], "guard model") {
125+ t.Errorf("guard family note missing: %v", guard.Notes)
126+ }
127+}
128+
129+// A hosted OpenAI model: max_completion_tokens, the default temperature,
130+// a note; the file says where the key comes from, and loads.
131+func TestOpenAIFamilyAndProvider(t *testing.T) {
132+ p, _ := LoadPresets()
133+ s := Decide(p, Choices{Provider: "openai", Model: "gpt-5-mini", Usage: "developer", Machine: "medium", Name: "g"}, Facts{})
134+ if s.Family.Name != "openai" {
135+ t.Fatalf("family %s", s.Family.Name)
136+ }
137+ if _, ok := setting(s.Sampling, "max_completion_tokens"); !ok {
138+ t.Errorf("no max_completion_tokens: %+v", s.Sampling)
139+ }
140+ if _, ok := setting(s.Sampling, "max_tokens"); ok {
141+ t.Error("max_tokens written for the openai family")
142+ }
143+ if got, _ := setting(s.Sampling, "temperature"); got != "1.0" {
144+ t.Errorf("temperature %s", got)
145+ }
146+ text := Render(s)
147+ flat := strings.ReplaceAll(text, "\n# ", " ") // comments are wrapped
148+ if !strings.Contains(flat, "OPENAI_API_KEY") || !strings.Contains(flat, "No baseUrl: the OpenAI API") || !strings.Contains(flat, "cannot ask the server for its window") {
149+ t.Errorf("key / address comment missing:\n%s", text)
150+ }
151+ loadRendered(t, text)
152+ if contains(local, "openai") {
153+ t.Error("openai must not be probed for the first question's default")
154+ }
155+}
added internal/agentinit/facts.go +242 -0
new file mode 100644
@@ -0,0 +1,242 @@
1+package agentinit
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "net/http"
9+ "os"
10+ "regexp"
11+ "sort"
12+ "strconv"
13+ "strings"
14+ "time"
15+)
16+
17+// Facts is what the model server says about one model — the most specific
18+// source of settings there is. The rules are skills/create-agent's
19+// model_facts.py, ported: the same endpoints, the same template reading.
20+type Facts struct {
21+ Known bool // the server answered about this model
22+ ServerFamily string // llmman / Ollama "details.family", e.g. "qwen35"
23+ ContextLength int // the model's native window, 0 when unknown
24+ Sampling map[string]float64 // the GGUF's recommended sampling: temperature, top_p, top_k, min_p
25+ Thinking Thinking
26+}
27+
28+// Thinking is how the chat template switches reasoning.
29+type Thinking struct {
30+ Switch string // "enable_thinking", "reasoning_effort" or "" (none found)
31+ Default string // "on", "off" or "" (unknown)
32+ EffortValues []string // the reasoning_effort values the template names
33+}
34+
35+// root is a server's address without its OpenAI prefix: llmman's and
36+// Ollama's /api/*, llama.cpp's /props live there.
37+func root(base string) string {
38+ base = strings.TrimRight(base, "/")
39+ for _, suffix := range []string{"/engines/v1", "/v1"} {
40+ if strings.HasSuffix(base, suffix) {
41+ return strings.TrimSuffix(base, suffix)
42+ }
43+ }
44+ return base
45+}
46+
47+var client = &http.Client{}
48+
49+// keyContext carries the key a provider needs to list its models (the
50+// generic `openai` one: a remote API answers 401 to an anonymous request).
51+type keyContext struct{}
52+
53+func withKey(ctx context.Context, key string) context.Context {
54+ return context.WithValue(ctx, keyContext{}, key)
55+}
56+
57+func apiKey(ctx context.Context) string {
58+ key, _ := ctx.Value(keyContext{}).(string)
59+ return key
60+}
61+
62+func call(ctx context.Context, method, url string, body any, timeout time.Duration, out any) error {
63+ ctx, cancel := context.WithTimeout(ctx, timeout)
64+ defer cancel()
65+ var reader *bytes.Reader
66+ if body != nil {
67+ data, _ := json.Marshal(body)
68+ reader = bytes.NewReader(data)
69+ } else {
70+ reader = bytes.NewReader(nil)
71+ }
72+ req, err := http.NewRequestWithContext(ctx, method, url, reader)
73+ if err != nil {
74+ return err
75+ }
76+ req.Header.Set("Content-Type", "application/json")
77+ if key := apiKey(ctx); key != "" {
78+ req.Header.Set("Authorization", "Bearer "+key)
79+ }
80+ resp, err := client.Do(req)
81+ if err != nil {
82+ return err
83+ }
84+ defer resp.Body.Close()
85+ if resp.StatusCode != http.StatusOK {
86+ return fmt.Errorf("%s %s: %s", method, url, resp.Status)
87+ }
88+ return json.NewDecoder(resp.Body).Decode(out)
89+}
90+
91+// Models lists the models a server serves: llmman's and Ollama's /api/tags,
92+// the OpenAI /models of the others. Sorted.
93+//
94+// names, err := agentinit.Models(ctx, "llmman", "http://127.0.0.1:17434/v1")
95+func Models(ctx context.Context, provider, base string) ([]string, error) {
96+ if provider == "openai" {
97+ ctx = withKey(ctx, os.Getenv("OPENAI_API_KEY"))
98+ }
99+ var names []string
100+ switch provider {
101+ case "llmman", "ollama":
102+ var tags struct {
103+ Models []struct {
104+ Name string `json:"name"`
105+ } `json:"models"`
106+ }
107+ if err := call(ctx, http.MethodGet, root(base)+"/api/tags", nil, 5*time.Second, &tags); err != nil {
108+ return nil, err
109+ }
110+ for _, m := range tags.Models {
111+ names = append(names, m.Name)
112+ }
113+ default:
114+ var list struct {
115+ Data []struct {
116+ ID string `json:"id"`
117+ } `json:"data"`
118+ }
119+ if err := call(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/models", nil, 5*time.Second, &list); err != nil {
120+ return nil, err
121+ }
122+ for _, m := range list.Data {
123+ names = append(names, m.ID)
124+ }
125+ }
126+ sort.Strings(names)
127+ return names, nil
128+}
129+
130+// ModelFacts asks the server about one model. llmman and Ollama answer
131+// /api/show (GGUF metadata, chat template); llama-server answers /props
132+// (window, chat template); Docker Model Runner says nothing more than the
133+// model list — Facts.Known is then false and the family table decides.
134+func ModelFacts(ctx context.Context, provider, base, model string) Facts {
135+ switch provider {
136+ case "llmman", "ollama":
137+ return showFacts(ctx, base, model)
138+ case "llamacpp":
139+ return propsFacts(ctx, base)
140+ }
141+ return Facts{}
142+}
143+
144+func showFacts(ctx context.Context, base, model string) Facts {
145+ var show struct {
146+ ModelInfo map[string]any `json:"model_info"`
147+ Template string `json:"template"`
148+ Parameters string `json:"parameters"` // Ollama: "temperature 0.6\ntop_k 20"
149+ Details struct {
150+ Family string `json:"family"`
151+ } `json:"details"`
152+ }
153+ if err := call(ctx, http.MethodPost, root(base)+"/api/show", map[string]string{"model": model}, 30*time.Second, &show); err != nil {
154+ return Facts{}
155+ }
156+ f := Facts{Known: true, ServerFamily: show.Details.Family, Sampling: map[string]float64{}, Thinking: templateThinking(show.Template)}
157+ arch, _ := show.ModelInfo["general.architecture"].(string)
158+ if n, ok := show.ModelInfo[arch+".context_length"].(float64); ok {
159+ f.ContextLength = int(n)
160+ }
161+ for key, value := range show.ModelInfo {
162+ if name, ok := strings.CutPrefix(key, "general.sampling."); ok {
163+ if n, ok := value.(float64); ok {
164+ f.Sampling[samplingKey(name)] = n
165+ }
166+ }
167+ }
168+ for _, line := range strings.Split(show.Parameters, "\n") {
169+ fields := strings.Fields(line)
170+ if len(fields) == 2 {
171+ if n, err := strconv.ParseFloat(fields[1], 64); err == nil && isSamplingKey(fields[0]) {
172+ f.Sampling[samplingKey(fields[0])] = n
173+ }
174+ }
175+ }
176+ return f
177+}
178+
179+func propsFacts(ctx context.Context, base string) Facts {
180+ var props struct {
181+ ChatTemplate string `json:"chat_template"`
182+ Settings struct {
183+ NCtx int `json:"n_ctx"`
184+ } `json:"default_generation_settings"`
185+ }
186+ if err := call(ctx, http.MethodGet, root(base)+"/props", nil, 10*time.Second, &props); err != nil {
187+ return Facts{}
188+ }
189+ return Facts{Known: true, ContextLength: props.Settings.NCtx, Thinking: templateThinking(props.ChatTemplate)}
190+}
191+
192+// samplingKey names a GGUF / Modelfile sampling key as the YAML does.
193+func samplingKey(name string) string {
194+ if name == "temp" {
195+ return "temperature"
196+ }
197+ return name
198+}
199+
200+func isSamplingKey(name string) bool {
201+ switch name {
202+ case "temperature", "top_p", "top_k", "min_p":
203+ return true
204+ }
205+ return false
206+}
207+
208+var (
209+ thinkingDefaultOff = regexp.MustCompile(`enable_thinking\s*\|\s*default\(\s*false|enable_thinking is defined and enable_thinking is true`)
210+ thinkingDefaultOn = regexp.MustCompile(`enable_thinking is defined and enable_thinking is false|enable_thinking is undefined or enable_thinking is true`)
211+ effortValue = regexp.MustCompile(`['"](low|medium|high|xhigh|minimal|none)['"]`)
212+)
213+
214+// templateThinking reads a chat template's reasoning switch and its default
215+// (model_facts.py's thinking()).
216+func templateThinking(template string) Thinking {
217+ var t Thinking
218+ if strings.Contains(template, "enable_thinking") {
219+ t.Switch = "enable_thinking"
220+ switch {
221+ case thinkingDefaultOff.MatchString(template):
222+ t.Default = "off"
223+ case thinkingDefaultOn.MatchString(template):
224+ t.Default = "on"
225+ }
226+ }
227+ if strings.Contains(template, "reasoning_effort") {
228+ if t.Switch == "" {
229+ t.Switch = "reasoning_effort"
230+ t.Default = "on"
231+ }
232+ seen := map[string]bool{}
233+ for _, m := range effortValue.FindAllStringSubmatch(template, -1) {
234+ if !seen[m[1]] {
235+ seen[m[1]] = true
236+ t.EffortValues = append(t.EffortValues, m[1])
237+ }
238+ }
239+ sort.Strings(t.EffortValues)
240+ }
241+ return t
242+}
new file mode 100644
@@ -0,0 +1,242 @@
1+package agentinit
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "net/http"
9+ "os"
10+ "regexp"
11+ "sort"
12+ "strconv"
13+ "strings"
14+ "time"
15+)
16+
17+// Facts is what the model server says about one model — the most specific
18+// source of settings there is. The rules are skills/create-agent's
19+// model_facts.py, ported: the same endpoints, the same template reading.
20+type Facts struct {
21+ Known bool // the server answered about this model
22+ ServerFamily string // llmman / Ollama "details.family", e.g. "qwen35"
23+ ContextLength int // the model's native window, 0 when unknown
24+ Sampling map[string]float64 // the GGUF's recommended sampling: temperature, top_p, top_k, min_p
25+ Thinking Thinking
26+}
27+
28+// Thinking is how the chat template switches reasoning.
29+type Thinking struct {
30+ Switch string // "enable_thinking", "reasoning_effort" or "" (none found)
31+ Default string // "on", "off" or "" (unknown)
32+ EffortValues []string // the reasoning_effort values the template names
33+}
34+
35+// root is a server's address without its OpenAI prefix: llmman's and
36+// Ollama's /api/*, llama.cpp's /props live there.
37+func root(base string) string {
38+ base = strings.TrimRight(base, "/")
39+ for _, suffix := range []string{"/engines/v1", "/v1"} {
40+ if strings.HasSuffix(base, suffix) {
41+ return strings.TrimSuffix(base, suffix)
42+ }
43+ }
44+ return base
45+}
46+
47+var client = &http.Client{}
48+
49+// keyContext carries the key a provider needs to list its models (the
50+// generic `openai` one: a remote API answers 401 to an anonymous request).
51+type keyContext struct{}
52+
53+func withKey(ctx context.Context, key string) context.Context {
54+ return context.WithValue(ctx, keyContext{}, key)
55+}
56+
57+func apiKey(ctx context.Context) string {
58+ key, _ := ctx.Value(keyContext{}).(string)
59+ return key
60+}
61+
62+func call(ctx context.Context, method, url string, body any, timeout time.Duration, out any) error {
63+ ctx, cancel := context.WithTimeout(ctx, timeout)
64+ defer cancel()
65+ var reader *bytes.Reader
66+ if body != nil {
67+ data, _ := json.Marshal(body)
68+ reader = bytes.NewReader(data)
69+ } else {
70+ reader = bytes.NewReader(nil)
71+ }
72+ req, err := http.NewRequestWithContext(ctx, method, url, reader)
73+ if err != nil {
74+ return err
75+ }
76+ req.Header.Set("Content-Type", "application/json")
77+ if key := apiKey(ctx); key != "" {
78+ req.Header.Set("Authorization", "Bearer "+key)
79+ }
80+ resp, err := client.Do(req)
81+ if err != nil {
82+ return err
83+ }
84+ defer resp.Body.Close()
85+ if resp.StatusCode != http.StatusOK {
86+ return fmt.Errorf("%s %s: %s", method, url, resp.Status)
87+ }
88+ return json.NewDecoder(resp.Body).Decode(out)
89+}
90+
91+// Models lists the models a server serves: llmman's and Ollama's /api/tags,
92+// the OpenAI /models of the others. Sorted.
93+//
94+// names, err := agentinit.Models(ctx, "llmman", "http://127.0.0.1:17434/v1")
95+func Models(ctx context.Context, provider, base string) ([]string, error) {
96+ if provider == "openai" {
97+ ctx = withKey(ctx, os.Getenv("OPENAI_API_KEY"))
98+ }
99+ var names []string
100+ switch provider {
101+ case "llmman", "ollama":
102+ var tags struct {
103+ Models []struct {
104+ Name string `json:"name"`
105+ } `json:"models"`
106+ }
107+ if err := call(ctx, http.MethodGet, root(base)+"/api/tags", nil, 5*time.Second, &tags); err != nil {
108+ return nil, err
109+ }
110+ for _, m := range tags.Models {
111+ names = append(names, m.Name)
112+ }
113+ default:
114+ var list struct {
115+ Data []struct {
116+ ID string `json:"id"`
117+ } `json:"data"`
118+ }
119+ if err := call(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/models", nil, 5*time.Second, &list); err != nil {
120+ return nil, err
121+ }
122+ for _, m := range list.Data {
123+ names = append(names, m.ID)
124+ }
125+ }
126+ sort.Strings(names)
127+ return names, nil
128+}
129+
130+// ModelFacts asks the server about one model. llmman and Ollama answer
131+// /api/show (GGUF metadata, chat template); llama-server answers /props
132+// (window, chat template); Docker Model Runner says nothing more than the
133+// model list — Facts.Known is then false and the family table decides.
134+func ModelFacts(ctx context.Context, provider, base, model string) Facts {
135+ switch provider {
136+ case "llmman", "ollama":
137+ return showFacts(ctx, base, model)
138+ case "llamacpp":
139+ return propsFacts(ctx, base)
140+ }
141+ return Facts{}
142+}
143+
144+func showFacts(ctx context.Context, base, model string) Facts {
145+ var show struct {
146+ ModelInfo map[string]any `json:"model_info"`
147+ Template string `json:"template"`
148+ Parameters string `json:"parameters"` // Ollama: "temperature 0.6\ntop_k 20"
149+ Details struct {
150+ Family string `json:"family"`
151+ } `json:"details"`
152+ }
153+ if err := call(ctx, http.MethodPost, root(base)+"/api/show", map[string]string{"model": model}, 30*time.Second, &show); err != nil {
154+ return Facts{}
155+ }
156+ f := Facts{Known: true, ServerFamily: show.Details.Family, Sampling: map[string]float64{}, Thinking: templateThinking(show.Template)}
157+ arch, _ := show.ModelInfo["general.architecture"].(string)
158+ if n, ok := show.ModelInfo[arch+".context_length"].(float64); ok {
159+ f.ContextLength = int(n)
160+ }
161+ for key, value := range show.ModelInfo {
162+ if name, ok := strings.CutPrefix(key, "general.sampling."); ok {
163+ if n, ok := value.(float64); ok {
164+ f.Sampling[samplingKey(name)] = n
165+ }
166+ }
167+ }
168+ for _, line := range strings.Split(show.Parameters, "\n") {
169+ fields := strings.Fields(line)
170+ if len(fields) == 2 {
171+ if n, err := strconv.ParseFloat(fields[1], 64); err == nil && isSamplingKey(fields[0]) {
172+ f.Sampling[samplingKey(fields[0])] = n
173+ }
174+ }
175+ }
176+ return f
177+}
178+
179+func propsFacts(ctx context.Context, base string) Facts {
180+ var props struct {
181+ ChatTemplate string `json:"chat_template"`
182+ Settings struct {
183+ NCtx int `json:"n_ctx"`
184+ } `json:"default_generation_settings"`
185+ }
186+ if err := call(ctx, http.MethodGet, root(base)+"/props", nil, 10*time.Second, &props); err != nil {
187+ return Facts{}
188+ }
189+ return Facts{Known: true, ContextLength: props.Settings.NCtx, Thinking: templateThinking(props.ChatTemplate)}
190+}
191+
192+// samplingKey names a GGUF / Modelfile sampling key as the YAML does.
193+func samplingKey(name string) string {
194+ if name == "temp" {
195+ return "temperature"
196+ }
197+ return name
198+}
199+
200+func isSamplingKey(name string) bool {
201+ switch name {
202+ case "temperature", "top_p", "top_k", "min_p":
203+ return true
204+ }
205+ return false
206+}
207+
208+var (
209+ thinkingDefaultOff = regexp.MustCompile(`enable_thinking\s*\|\s*default\(\s*false|enable_thinking is defined and enable_thinking is true`)
210+ thinkingDefaultOn = regexp.MustCompile(`enable_thinking is defined and enable_thinking is false|enable_thinking is undefined or enable_thinking is true`)
211+ effortValue = regexp.MustCompile(`['"](low|medium|high|xhigh|minimal|none)['"]`)
212+)
213+
214+// templateThinking reads a chat template's reasoning switch and its default
215+// (model_facts.py's thinking()).
216+func templateThinking(template string) Thinking {
217+ var t Thinking
218+ if strings.Contains(template, "enable_thinking") {
219+ t.Switch = "enable_thinking"
220+ switch {
221+ case thinkingDefaultOff.MatchString(template):
222+ t.Default = "off"
223+ case thinkingDefaultOn.MatchString(template):
224+ t.Default = "on"
225+ }
226+ }
227+ if strings.Contains(template, "reasoning_effort") {
228+ if t.Switch == "" {
229+ t.Switch = "reasoning_effort"
230+ t.Default = "on"
231+ }
232+ seen := map[string]bool{}
233+ for _, m := range effortValue.FindAllStringSubmatch(template, -1) {
234+ if !seen[m[1]] {
235+ seen[m[1]] = true
236+ t.EffortValues = append(t.EffortValues, m[1])
237+ }
238+ }
239+ sort.Strings(t.EffortValues)
240+ }
241+ return t
242+}
added internal/agentinit/facts_test.go +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+package agentinit
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "reflect"
9+ "testing"
10+)
11+
12+func fakeServer(t *testing.T, routes map[string]any) *httptest.Server {
13+ t.Helper()
14+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+ body, ok := routes[r.Method+" "+r.URL.Path]
16+ if !ok {
17+ http.NotFound(w, r)
18+ return
19+ }
20+ _ = json.NewEncoder(w).Encode(body)
21+ }))
22+ t.Cleanup(s.Close)
23+ return s
24+}
25+
26+func TestModelsPerProvider(t *testing.T) {
27+ s := fakeServer(t, map[string]any{
28+ "GET /api/tags": map[string]any{"models": []any{map[string]any{"name": "b:latest"}, map[string]any{"name": "a:Q4"}}},
29+ "GET /engines/v1/models": map[string]any{"data": []any{map[string]any{"id": "ai/qwen3"}}},
30+ "GET /v1/models": map[string]any{"data": []any{map[string]any{"id": "local"}}},
31+ })
32+ ctx := context.Background()
33+ if got, err := Models(ctx, "llmman", s.URL+"/v1"); err != nil || !reflect.DeepEqual(got, []string{"a:Q4", "b:latest"}) {
34+ t.Errorf("llmman: %v %v", got, err)
35+ }
36+ if got, err := Models(ctx, "dmr", s.URL+"/engines/v1"); err != nil || !reflect.DeepEqual(got, []string{"ai/qwen3"}) {
37+ t.Errorf("dmr: %v %v", got, err)
38+ }
39+ if got, err := Models(ctx, "llamacpp", s.URL+"/v1"); err != nil || !reflect.DeepEqual(got, []string{"local"}) {
40+ t.Errorf("llamacpp: %v %v", got, err)
41+ }
42+}
43+
44+// llmman / Ollama: the window, the GGUF's sampling (temp → temperature), the
45+// Modelfile parameters, the template's reasoning switch and its default.
46+func TestShowFacts(t *testing.T) {
47+ s := fakeServer(t, map[string]any{"POST /api/show": map[string]any{
48+ "model_info": map[string]any{
49+ "general.architecture": "qwen35",
50+ "qwen35.context_length": 262144,
51+ "general.sampling.temp": 0.6,
52+ "general.sampling.top_k": 20,
53+ "general.sampling.top_p": 0.95,
54+ },
55+ "parameters": "min_p 0\nstop <|im_end|>",
56+ "template": "{%- if enable_thinking is defined and enable_thinking is false %}…",
57+ "details": map[string]any{"family": "qwen35"},
58+ }})
59+ f := ModelFacts(context.Background(), "llmman", s.URL+"/v1", "m")
60+ want := Facts{Known: true, ServerFamily: "qwen35", ContextLength: 262144,
61+ Sampling: map[string]float64{"temperature": 0.6, "top_k": 20, "top_p": 0.95, "min_p": 0},
62+ Thinking: Thinking{Switch: "enable_thinking", Default: "on"}}
63+ if !reflect.DeepEqual(f, want) {
64+ t.Errorf("facts = %+v\nwant %+v", f, want)
65+ }
66+}
67+
68+func TestPropsFactsAndUnknowns(t *testing.T) {
69+ s := fakeServer(t, map[string]any{"GET /props": map[string]any{
70+ "chat_template": "{{ reasoning_effort | default('medium') }} 'low' 'high'",
71+ "default_generation_settings": map[string]any{"n_ctx": 32768},
72+ }})
73+ f := ModelFacts(context.Background(), "llamacpp", s.URL+"/v1", "m")
74+ if !f.Known || f.ContextLength != 32768 || f.Thinking.Switch != "reasoning_effort" || !reflect.DeepEqual(f.Thinking.EffortValues, []string{"high", "low", "medium"}) {
75+ t.Errorf("llama.cpp facts = %+v", f)
76+ }
77+ if f := ModelFacts(context.Background(), "dmr", s.URL+"/engines/v1", "m"); f.Known {
78+ t.Error("dmr has no facts endpoint")
79+ }
80+ if f := ModelFacts(context.Background(), "llmman", "http://127.0.0.1:1/v1", "m"); f.Known {
81+ t.Error("an unreachable server gave facts")
82+ }
83+}
84+
85+func TestTemplateThinking(t *testing.T) {
86+ for template, want := range map[string]Thinking{
87+ "{{ enable_thinking | default(false) }}": {Switch: "enable_thinking", Default: "off"},
88+ "{% if enable_thinking is undefined or enable_thinking is true %}": {Switch: "enable_thinking", Default: "on"},
89+ "{{ enable_thinking }}": {Switch: "enable_thinking"},
90+ "no reasoning here": {},
91+ } {
92+ if got := templateThinking(template); !reflect.DeepEqual(got, want) {
93+ t.Errorf("templateThinking(%q) = %+v, want %+v", template, got, want)
94+ }
95+ }
96+}
97+
98+// The generic provider lists models with OPENAI_API_KEY: a remote API
99+// answers 401 to an anonymous request.
100+func TestModelsOpenAISendsTheKey(t *testing.T) {
101+ t.Setenv("OPENAI_API_KEY", "sk-test")
102+ var auth string
103+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104+ auth = r.Header.Get("Authorization")
105+ _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "gpt-x"}}})
106+ }))
107+ t.Cleanup(s.Close)
108+ if got, err := Models(context.Background(), "openai", s.URL+"/v1"); err != nil || len(got) != 1 || auth != "Bearer sk-test" {
109+ t.Errorf("models %v, err %v, auth %q", got, err, auth)
110+ }
111+ if _, err := Models(context.Background(), "llamacpp", s.URL+"/v1"); err != nil || auth != "" {
112+ t.Errorf("another provider sent a key: %q", auth)
113+ }
114+}
new file mode 100644
@@ -0,0 +1,114 @@
1+package agentinit
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "reflect"
9+ "testing"
10+)
11+
12+func fakeServer(t *testing.T, routes map[string]any) *httptest.Server {
13+ t.Helper()
14+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+ body, ok := routes[r.Method+" "+r.URL.Path]
16+ if !ok {
17+ http.NotFound(w, r)
18+ return
19+ }
20+ _ = json.NewEncoder(w).Encode(body)
21+ }))
22+ t.Cleanup(s.Close)
23+ return s
24+}
25+
26+func TestModelsPerProvider(t *testing.T) {
27+ s := fakeServer(t, map[string]any{
28+ "GET /api/tags": map[string]any{"models": []any{map[string]any{"name": "b:latest"}, map[string]any{"name": "a:Q4"}}},
29+ "GET /engines/v1/models": map[string]any{"data": []any{map[string]any{"id": "ai/qwen3"}}},
30+ "GET /v1/models": map[string]any{"data": []any{map[string]any{"id": "local"}}},
31+ })
32+ ctx := context.Background()
33+ if got, err := Models(ctx, "llmman", s.URL+"/v1"); err != nil || !reflect.DeepEqual(got, []string{"a:Q4", "b:latest"}) {
34+ t.Errorf("llmman: %v %v", got, err)
35+ }
36+ if got, err := Models(ctx, "dmr", s.URL+"/engines/v1"); err != nil || !reflect.DeepEqual(got, []string{"ai/qwen3"}) {
37+ t.Errorf("dmr: %v %v", got, err)
38+ }
39+ if got, err := Models(ctx, "llamacpp", s.URL+"/v1"); err != nil || !reflect.DeepEqual(got, []string{"local"}) {
40+ t.Errorf("llamacpp: %v %v", got, err)
41+ }
42+}
43+
44+// llmman / Ollama: the window, the GGUF's sampling (temp → temperature), the
45+// Modelfile parameters, the template's reasoning switch and its default.
46+func TestShowFacts(t *testing.T) {
47+ s := fakeServer(t, map[string]any{"POST /api/show": map[string]any{
48+ "model_info": map[string]any{
49+ "general.architecture": "qwen35",
50+ "qwen35.context_length": 262144,
51+ "general.sampling.temp": 0.6,
52+ "general.sampling.top_k": 20,
53+ "general.sampling.top_p": 0.95,
54+ },
55+ "parameters": "min_p 0\nstop <|im_end|>",
56+ "template": "{%- if enable_thinking is defined and enable_thinking is false %}…",
57+ "details": map[string]any{"family": "qwen35"},
58+ }})
59+ f := ModelFacts(context.Background(), "llmman", s.URL+"/v1", "m")
60+ want := Facts{Known: true, ServerFamily: "qwen35", ContextLength: 262144,
61+ Sampling: map[string]float64{"temperature": 0.6, "top_k": 20, "top_p": 0.95, "min_p": 0},
62+ Thinking: Thinking{Switch: "enable_thinking", Default: "on"}}
63+ if !reflect.DeepEqual(f, want) {
64+ t.Errorf("facts = %+v\nwant %+v", f, want)
65+ }
66+}
67+
68+func TestPropsFactsAndUnknowns(t *testing.T) {
69+ s := fakeServer(t, map[string]any{"GET /props": map[string]any{
70+ "chat_template": "{{ reasoning_effort | default('medium') }} 'low' 'high'",
71+ "default_generation_settings": map[string]any{"n_ctx": 32768},
72+ }})
73+ f := ModelFacts(context.Background(), "llamacpp", s.URL+"/v1", "m")
74+ if !f.Known || f.ContextLength != 32768 || f.Thinking.Switch != "reasoning_effort" || !reflect.DeepEqual(f.Thinking.EffortValues, []string{"high", "low", "medium"}) {
75+ t.Errorf("llama.cpp facts = %+v", f)
76+ }
77+ if f := ModelFacts(context.Background(), "dmr", s.URL+"/engines/v1", "m"); f.Known {
78+ t.Error("dmr has no facts endpoint")
79+ }
80+ if f := ModelFacts(context.Background(), "llmman", "http://127.0.0.1:1/v1", "m"); f.Known {
81+ t.Error("an unreachable server gave facts")
82+ }
83+}
84+
85+func TestTemplateThinking(t *testing.T) {
86+ for template, want := range map[string]Thinking{
87+ "{{ enable_thinking | default(false) }}": {Switch: "enable_thinking", Default: "off"},
88+ "{% if enable_thinking is undefined or enable_thinking is true %}": {Switch: "enable_thinking", Default: "on"},
89+ "{{ enable_thinking }}": {Switch: "enable_thinking"},
90+ "no reasoning here": {},
91+ } {
92+ if got := templateThinking(template); !reflect.DeepEqual(got, want) {
93+ t.Errorf("templateThinking(%q) = %+v, want %+v", template, got, want)
94+ }
95+ }
96+}
97+
98+// The generic provider lists models with OPENAI_API_KEY: a remote API
99+// answers 401 to an anonymous request.
100+func TestModelsOpenAISendsTheKey(t *testing.T) {
101+ t.Setenv("OPENAI_API_KEY", "sk-test")
102+ var auth string
103+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104+ auth = r.Header.Get("Authorization")
105+ _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "gpt-x"}}})
106+ }))
107+ t.Cleanup(s.Close)
108+ if got, err := Models(context.Background(), "openai", s.URL+"/v1"); err != nil || len(got) != 1 || auth != "Bearer sk-test" {
109+ t.Errorf("models %v, err %v, auth %q", got, err, auth)
110+ }
111+ if _, err := Models(context.Background(), "llamacpp", s.URL+"/v1"); err != nil || auth != "" {
112+ t.Errorf("another provider sent a key: %q", auth)
113+ }
114+}
added internal/agentinit/flow.go +365 -0
new file mode 100644
@@ -0,0 +1,365 @@
1+package agentinit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "mm/internal/elicit"
9+)
10+
11+// Option is one choice of a step: the value stored, the label shown, and a
12+// line that explains it (a usage's description, a family's source…).
13+type Option struct {
14+ Value string
15+ Label string
16+ Detail string
17+}
18+
19+// Step is one question of the wizard, ready to show: the line mode prints
20+// it, the window draws it (internal/tui).
21+type Step struct {
22+ Key string // provider, model, family, usage, machine, yolo, name, file
23+ Title string // the short name in the window's list of steps
24+ Prompt string // the question
25+ Kind elicit.Kind
26+ Options []Option // Choice; empty for Text and Confirm
27+ Default string
28+ Value string // the answer given so far, "" when none
29+ Given bool // answered by a flag: shown, never asked
30+}
31+
32+// stepKeys is the order of the questions.
33+var stepKeys = []string{"provider", "model", "family", "usage", "machine", "yolo", "name", "file"}
34+
35+var stepTitles = map[string]string{
36+ "provider": "Server", "model": "Model", "family": "Family", "usage": "Usage",
37+ "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File",
38+}
39+
40+// Flow is the wizard's state, apart from any way of showing it: the answers
41+// so far, what the server said, and which answers came from flags. The line
42+// mode (wizard.go) and the window (internal/tui) both drive one.
43+//
44+// f := agentinit.NewFlow(ctx, presets, given, output)
45+// for _, st := range f.Pending() { _ = f.Set(st.Key, st.Default) }
46+// fmt.Print(f.Preview("", ""))
47+type Flow struct {
48+ presets Presets
49+ ctx context.Context
50+ c Choices
51+ output string
52+ given map[string]bool
53+ answer map[string]string
54+
55+ base string // the resolved server address, for c.Provider
56+ models []string
57+ modelsErr error
58+ listed string // the provider+base the models were listed for
59+ facts Facts
60+ factsFor string // the model the facts are about
61+ reachable map[string]bool
62+}
63+
64+// NewFlow starts a flow; what the flags gave is marked given and never asked.
65+func NewFlow(ctx context.Context, p Presets, given Choices, output string) *Flow {
66+ f := &Flow{presets: p, ctx: ctx, c: given, output: output, given: map[string]bool{}, answer: map[string]string{}}
67+ for key, value := range map[string]string{"provider": given.Provider, "model": given.Model, "family": given.Family, "usage": given.Usage, "machine": given.Machine, "name": given.Name, "file": output} {
68+ if value != "" {
69+ f.given[key], f.answer[key] = true, value
70+ }
71+ }
72+ if given.Yolo != nil {
73+ f.given["yolo"], f.answer["yolo"] = true, yesNo(*given.Yolo)
74+ }
75+ return f
76+}
77+
78+func yesNo(b bool) string {
79+ if b {
80+ return "yes"
81+ }
82+ return "no"
83+}
84+
85+// Steps is every question in order, answered or not — the window's list.
86+// The yolo question only exists for a usage with tools that change things.
87+func (f *Flow) Steps() []Step {
88+ var out []Step
89+ for _, key := range stepKeys {
90+ if key == "yolo" && !f.asksYolo() {
91+ continue
92+ }
93+ out = append(out, f.Step(key))
94+ }
95+ return out
96+}
97+
98+// Pending is the questions still to answer, in order.
99+func (f *Flow) Pending() []Step {
100+ var out []Step
101+ for _, st := range f.Steps() {
102+ if st.Value == "" {
103+ out = append(out, st)
104+ }
105+ }
106+ return out
107+}
108+
109+func (f *Flow) asksYolo() bool {
110+ u, ok := f.presets.Usage(f.usageOrDefault())
111+ return ok && (u.Tools["bashTool"] || u.Tools["editTools"] || u.Tools["fetchContentTool"])
112+}
113+
114+func (f *Flow) usageOrDefault() string {
115+ if f.c.Usage != "" {
116+ return f.c.Usage
117+ }
118+ return "developer"
119+}
120+
121+// Step builds one question as it stands now: its options may depend on the
122+// answers before it (the model list on the server, the family's default on
123+// the model).
124+func (f *Flow) Step(key string) Step {
125+ st := Step{Key: key, Title: stepTitles[key], Value: f.answer[key], Given: f.given[key], Kind: elicit.Choice}
126+ if st.Value != "" {
127+ // Answered: the list of steps needs its title and value only — the
128+ // options (a probe of every server, the model list) are built when
129+ // the question is asked again.
130+ return st
131+ }
132+ switch key {
133+ case "provider":
134+ st.Prompt = "Model server?"
135+ for _, name := range providers {
136+ detail := "not probed: its default address is the OpenAI API"
137+ if contains(local, name) {
138+ detail = "does not answer"
139+ if f.answers(name) {
140+ detail = "answers"
141+ }
142+ }
143+ st.Options = append(st.Options, Option{Value: name, Label: name, Detail: detail})
144+ }
145+ st.Default = f.firstAnswering()
146+ case "model":
147+ st.Prompt = "Model?"
148+ models := f.modelList()
149+ if len(models) == 0 {
150+ st.Kind, st.Prompt = elicit.Text, "Model (as the server names it)?"
151+ break
152+ }
153+ for _, m := range models {
154+ st.Options = append(st.Options, Option{Value: m, Label: m})
155+ }
156+ st.Default = models[0]
157+ case "family":
158+ st.Prompt = "Model family?"
159+ for _, fam := range f.presets.Families {
160+ st.Options = append(st.Options, Option{Value: fam.Name, Label: fam.Name, Detail: fam.Source})
161+ }
162+ st.Default = f.presets.FamilyOf(f.c.Model, f.modelFacts().ServerFamily).Name
163+ case "usage":
164+ st.Prompt = "What is the agent for?"
165+ for _, u := range f.presets.Usages {
166+ st.Options = append(st.Options, Option{Value: u.Name, Label: u.Name, Detail: u.Description})
167+ }
168+ st.Default = "developer"
169+ case "machine":
170+ st.Prompt = "Which machine runs the model?"
171+ for _, m := range f.presets.Machines {
172+ st.Options = append(st.Options, Option{Value: m.Name, Label: m.Name, Detail: m.Description})
173+ }
174+ st.Default = "small"
175+ case "yolo":
176+ st.Prompt, st.Kind = "Ask before each command or file change?", elicit.Confirm
177+ u, _ := f.presets.Usage(f.usageOrDefault())
178+ st.Default = yesNo(!u.Yolo)
179+ st.Options = []Option{
180+ {Value: "yes", Label: "yes", Detail: "every command and file change waits for your answer (yolo: false)"},
181+ {Value: "no", Label: "no", Detail: "tools run without asking (yolo: true)"},
182+ }
183+ case "name":
184+ st.Prompt, st.Kind, st.Default = "Agent name?", elicit.Text, f.usageOrDefault()
185+ case "file":
186+ st.Prompt, st.Kind, st.Default = "File to write?", elicit.Text, "agent.yaml"
187+ }
188+ return st
189+}
190+
191+// Set answers one question. A new server forgets the model (and the
192+// family, unless a flag gave it); a new model forgets the facts.
193+func (f *Flow) Set(key, value string) error {
194+ value = strings.TrimSpace(value)
195+ if value == "" {
196+ return fmt.Errorf("%s: an answer is needed", key)
197+ }
198+ switch key {
199+ case "provider":
200+ if !contains(providers, value) {
201+ return fmt.Errorf("unknown provider %q (known: %s)", value, strings.Join(providers, ", "))
202+ }
203+ if value != f.c.Provider {
204+ f.c.Provider = value
205+ f.base = ""
206+ f.forget("model")
207+ f.c.Model = ""
208+ }
209+ case "model":
210+ if value != f.c.Model {
211+ f.c.Model = value
212+ f.forget("family")
213+ }
214+ case "family":
215+ if f.presets.Family(value).Name == "" {
216+ return fmt.Errorf("unknown family %q", value)
217+ }
218+ f.c.Family = value
219+ case "usage":
220+ if _, ok := f.presets.Usage(value); !ok {
221+ return fmt.Errorf("unknown usage %q (known: %s)", value, strings.Join(f.presets.UsageNames(), ", "))
222+ }
223+ f.c.Usage = value
224+ case "machine":
225+ if _, ok := f.presets.Machine(value); !ok {
226+ return fmt.Errorf("unknown machine %q (known: %s)", value, strings.Join(f.presets.MachineNames(), ", "))
227+ }
228+ f.c.Machine = value
229+ case "yolo":
230+ ask := value == "yes"
231+ yolo := !ask
232+ f.c.Yolo = &yolo
233+ case "name":
234+ f.c.Name = value
235+ case "file":
236+ f.output = value
237+ default:
238+ return fmt.Errorf("no question %q", key)
239+ }
240+ f.answer[key] = value
241+ return nil
242+}
243+
244+// Unset takes an answer back (the window's Esc), unless a flag gave it.
245+func (f *Flow) Unset(key string) {
246+ if f.given[key] {
247+ return
248+ }
249+ delete(f.answer, key)
250+ switch key {
251+ case "family":
252+ f.c.Family = ""
253+ case "usage":
254+ f.c.Usage = ""
255+ case "machine":
256+ f.c.Machine = ""
257+ case "yolo":
258+ f.c.Yolo = nil
259+ case "name":
260+ f.c.Name = ""
261+ case "file":
262+ f.output = ""
263+ }
264+}
265+
266+func (f *Flow) forget(key string) {
267+ if !f.given[key] {
268+ delete(f.answer, key)
269+ if key == "family" {
270+ f.c.Family = ""
271+ }
272+ }
273+}
274+
275+// Preview is the file as it would be written now — with candidate as the
276+// answer to key, when given, so the window can show the highlighted choice
277+// before it is chosen. Unanswered questions take their defaults.
278+func (f *Flow) Preview(key, candidate string) string {
279+ c := f.c
280+ if key != "" && candidate != "" {
281+ trial := *f
282+ trial.answer = map[string]string{}
283+ trial.given = f.given
284+ if trial.Set(key, candidate) == nil {
285+ c = trial.c
286+ }
287+ }
288+ if c.Usage == "" {
289+ c.Usage = "developer"
290+ }
291+ if c.Machine == "" {
292+ c.Machine = "small"
293+ }
294+ if c.Name == "" {
295+ c.Name = c.Usage
296+ }
297+ if c.Model == "" {
298+ c.Model = "<model>"
299+ }
300+ return Render(Decide(f.presets, c, f.modelFacts()))
301+}
302+
303+// Result is the answers, the facts and the file to write, once every
304+// question is answered.
305+func (f *Flow) Result() (Choices, Facts, string) {
306+ return f.c, f.modelFacts(), f.output
307+}
308+
309+// Base is the resolved server address ("" before a provider is chosen).
310+func (f *Flow) Base() string {
311+ if f.base == "" && f.c.Provider != "" {
312+ f.base = resolveBase(f.c)
313+ }
314+ return f.base
315+}
316+
317+// ModelsError is why the server gave no model list, nil when it did.
318+func (f *Flow) ModelsError() error {
319+ f.modelList()
320+ return f.modelsErr
321+}
322+
323+func (f *Flow) modelList() []string {
324+ if f.c.Provider == "" {
325+ return nil
326+ }
327+ key := f.c.Provider + " " + f.Base()
328+ if f.listed != key {
329+ f.models, f.modelsErr = Models(f.ctx, f.c.Provider, f.Base())
330+ f.listed = key
331+ }
332+ return f.models
333+}
334+
335+func (f *Flow) modelFacts() Facts {
336+ if f.c.Model == "" || f.c.Provider == "" {
337+ return Facts{}
338+ }
339+ if f.factsFor != f.c.Provider+" "+f.c.Model {
340+ f.facts = ModelFacts(f.ctx, f.c.Provider, f.Base(), f.c.Model)
341+ f.factsFor = f.c.Provider + " " + f.c.Model
342+ }
343+ return f.facts
344+}
345+
346+// answers reports, once per provider, whether its server answers.
347+func (f *Flow) answers(name string) bool {
348+ if f.reachable == nil {
349+ f.reachable = map[string]bool{}
350+ for _, p := range local {
351+ _, err := Models(f.ctx, p, resolveBase(Choices{Provider: p}))
352+ f.reachable[p] = err == nil
353+ }
354+ }
355+ return f.reachable[name]
356+}
357+
358+func (f *Flow) firstAnswering() string {
359+ for _, name := range local {
360+ if f.answers(name) {
361+ return name
362+ }
363+ }
364+ return providers[0]
365+}
new file mode 100644
@@ -0,0 +1,365 @@
1+package agentinit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "mm/internal/elicit"
9+)
10+
11+// Option is one choice of a step: the value stored, the label shown, and a
12+// line that explains it (a usage's description, a family's source…).
13+type Option struct {
14+ Value string
15+ Label string
16+ Detail string
17+}
18+
19+// Step is one question of the wizard, ready to show: the line mode prints
20+// it, the window draws it (internal/tui).
21+type Step struct {
22+ Key string // provider, model, family, usage, machine, yolo, name, file
23+ Title string // the short name in the window's list of steps
24+ Prompt string // the question
25+ Kind elicit.Kind
26+ Options []Option // Choice; empty for Text and Confirm
27+ Default string
28+ Value string // the answer given so far, "" when none
29+ Given bool // answered by a flag: shown, never asked
30+}
31+
32+// stepKeys is the order of the questions.
33+var stepKeys = []string{"provider", "model", "family", "usage", "machine", "yolo", "name", "file"}
34+
35+var stepTitles = map[string]string{
36+ "provider": "Server", "model": "Model", "family": "Family", "usage": "Usage",
37+ "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File",
38+}
39+
40+// Flow is the wizard's state, apart from any way of showing it: the answers
41+// so far, what the server said, and which answers came from flags. The line
42+// mode (wizard.go) and the window (internal/tui) both drive one.
43+//
44+// f := agentinit.NewFlow(ctx, presets, given, output)
45+// for _, st := range f.Pending() { _ = f.Set(st.Key, st.Default) }
46+// fmt.Print(f.Preview("", ""))
47+type Flow struct {
48+ presets Presets
49+ ctx context.Context
50+ c Choices
51+ output string
52+ given map[string]bool
53+ answer map[string]string
54+
55+ base string // the resolved server address, for c.Provider
56+ models []string
57+ modelsErr error
58+ listed string // the provider+base the models were listed for
59+ facts Facts
60+ factsFor string // the model the facts are about
61+ reachable map[string]bool
62+}
63+
64+// NewFlow starts a flow; what the flags gave is marked given and never asked.
65+func NewFlow(ctx context.Context, p Presets, given Choices, output string) *Flow {
66+ f := &Flow{presets: p, ctx: ctx, c: given, output: output, given: map[string]bool{}, answer: map[string]string{}}
67+ for key, value := range map[string]string{"provider": given.Provider, "model": given.Model, "family": given.Family, "usage": given.Usage, "machine": given.Machine, "name": given.Name, "file": output} {
68+ if value != "" {
69+ f.given[key], f.answer[key] = true, value
70+ }
71+ }
72+ if given.Yolo != nil {
73+ f.given["yolo"], f.answer["yolo"] = true, yesNo(*given.Yolo)
74+ }
75+ return f
76+}
77+
78+func yesNo(b bool) string {
79+ if b {
80+ return "yes"
81+ }
82+ return "no"
83+}
84+
85+// Steps is every question in order, answered or not — the window's list.
86+// The yolo question only exists for a usage with tools that change things.
87+func (f *Flow) Steps() []Step {
88+ var out []Step
89+ for _, key := range stepKeys {
90+ if key == "yolo" && !f.asksYolo() {
91+ continue
92+ }
93+ out = append(out, f.Step(key))
94+ }
95+ return out
96+}
97+
98+// Pending is the questions still to answer, in order.
99+func (f *Flow) Pending() []Step {
100+ var out []Step
101+ for _, st := range f.Steps() {
102+ if st.Value == "" {
103+ out = append(out, st)
104+ }
105+ }
106+ return out
107+}
108+
109+func (f *Flow) asksYolo() bool {
110+ u, ok := f.presets.Usage(f.usageOrDefault())
111+ return ok && (u.Tools["bashTool"] || u.Tools["editTools"] || u.Tools["fetchContentTool"])
112+}
113+
114+func (f *Flow) usageOrDefault() string {
115+ if f.c.Usage != "" {
116+ return f.c.Usage
117+ }
118+ return "developer"
119+}
120+
121+// Step builds one question as it stands now: its options may depend on the
122+// answers before it (the model list on the server, the family's default on
123+// the model).
124+func (f *Flow) Step(key string) Step {
125+ st := Step{Key: key, Title: stepTitles[key], Value: f.answer[key], Given: f.given[key], Kind: elicit.Choice}
126+ if st.Value != "" {
127+ // Answered: the list of steps needs its title and value only — the
128+ // options (a probe of every server, the model list) are built when
129+ // the question is asked again.
130+ return st
131+ }
132+ switch key {
133+ case "provider":
134+ st.Prompt = "Model server?"
135+ for _, name := range providers {
136+ detail := "not probed: its default address is the OpenAI API"
137+ if contains(local, name) {
138+ detail = "does not answer"
139+ if f.answers(name) {
140+ detail = "answers"
141+ }
142+ }
143+ st.Options = append(st.Options, Option{Value: name, Label: name, Detail: detail})
144+ }
145+ st.Default = f.firstAnswering()
146+ case "model":
147+ st.Prompt = "Model?"
148+ models := f.modelList()
149+ if len(models) == 0 {
150+ st.Kind, st.Prompt = elicit.Text, "Model (as the server names it)?"
151+ break
152+ }
153+ for _, m := range models {
154+ st.Options = append(st.Options, Option{Value: m, Label: m})
155+ }
156+ st.Default = models[0]
157+ case "family":
158+ st.Prompt = "Model family?"
159+ for _, fam := range f.presets.Families {
160+ st.Options = append(st.Options, Option{Value: fam.Name, Label: fam.Name, Detail: fam.Source})
161+ }
162+ st.Default = f.presets.FamilyOf(f.c.Model, f.modelFacts().ServerFamily).Name
163+ case "usage":
164+ st.Prompt = "What is the agent for?"
165+ for _, u := range f.presets.Usages {
166+ st.Options = append(st.Options, Option{Value: u.Name, Label: u.Name, Detail: u.Description})
167+ }
168+ st.Default = "developer"
169+ case "machine":
170+ st.Prompt = "Which machine runs the model?"
171+ for _, m := range f.presets.Machines {
172+ st.Options = append(st.Options, Option{Value: m.Name, Label: m.Name, Detail: m.Description})
173+ }
174+ st.Default = "small"
175+ case "yolo":
176+ st.Prompt, st.Kind = "Ask before each command or file change?", elicit.Confirm
177+ u, _ := f.presets.Usage(f.usageOrDefault())
178+ st.Default = yesNo(!u.Yolo)
179+ st.Options = []Option{
180+ {Value: "yes", Label: "yes", Detail: "every command and file change waits for your answer (yolo: false)"},
181+ {Value: "no", Label: "no", Detail: "tools run without asking (yolo: true)"},
182+ }
183+ case "name":
184+ st.Prompt, st.Kind, st.Default = "Agent name?", elicit.Text, f.usageOrDefault()
185+ case "file":
186+ st.Prompt, st.Kind, st.Default = "File to write?", elicit.Text, "agent.yaml"
187+ }
188+ return st
189+}
190+
191+// Set answers one question. A new server forgets the model (and the
192+// family, unless a flag gave it); a new model forgets the facts.
193+func (f *Flow) Set(key, value string) error {
194+ value = strings.TrimSpace(value)
195+ if value == "" {
196+ return fmt.Errorf("%s: an answer is needed", key)
197+ }
198+ switch key {
199+ case "provider":
200+ if !contains(providers, value) {
201+ return fmt.Errorf("unknown provider %q (known: %s)", value, strings.Join(providers, ", "))
202+ }
203+ if value != f.c.Provider {
204+ f.c.Provider = value
205+ f.base = ""
206+ f.forget("model")
207+ f.c.Model = ""
208+ }
209+ case "model":
210+ if value != f.c.Model {
211+ f.c.Model = value
212+ f.forget("family")
213+ }
214+ case "family":
215+ if f.presets.Family(value).Name == "" {
216+ return fmt.Errorf("unknown family %q", value)
217+ }
218+ f.c.Family = value
219+ case "usage":
220+ if _, ok := f.presets.Usage(value); !ok {
221+ return fmt.Errorf("unknown usage %q (known: %s)", value, strings.Join(f.presets.UsageNames(), ", "))
222+ }
223+ f.c.Usage = value
224+ case "machine":
225+ if _, ok := f.presets.Machine(value); !ok {
226+ return fmt.Errorf("unknown machine %q (known: %s)", value, strings.Join(f.presets.MachineNames(), ", "))
227+ }
228+ f.c.Machine = value
229+ case "yolo":
230+ ask := value == "yes"
231+ yolo := !ask
232+ f.c.Yolo = &yolo
233+ case "name":
234+ f.c.Name = value
235+ case "file":
236+ f.output = value
237+ default:
238+ return fmt.Errorf("no question %q", key)
239+ }
240+ f.answer[key] = value
241+ return nil
242+}
243+
244+// Unset takes an answer back (the window's Esc), unless a flag gave it.
245+func (f *Flow) Unset(key string) {
246+ if f.given[key] {
247+ return
248+ }
249+ delete(f.answer, key)
250+ switch key {
251+ case "family":
252+ f.c.Family = ""
253+ case "usage":
254+ f.c.Usage = ""
255+ case "machine":
256+ f.c.Machine = ""
257+ case "yolo":
258+ f.c.Yolo = nil
259+ case "name":
260+ f.c.Name = ""
261+ case "file":
262+ f.output = ""
263+ }
264+}
265+
266+func (f *Flow) forget(key string) {
267+ if !f.given[key] {
268+ delete(f.answer, key)
269+ if key == "family" {
270+ f.c.Family = ""
271+ }
272+ }
273+}
274+
275+// Preview is the file as it would be written now — with candidate as the
276+// answer to key, when given, so the window can show the highlighted choice
277+// before it is chosen. Unanswered questions take their defaults.
278+func (f *Flow) Preview(key, candidate string) string {
279+ c := f.c
280+ if key != "" && candidate != "" {
281+ trial := *f
282+ trial.answer = map[string]string{}
283+ trial.given = f.given
284+ if trial.Set(key, candidate) == nil {
285+ c = trial.c
286+ }
287+ }
288+ if c.Usage == "" {
289+ c.Usage = "developer"
290+ }
291+ if c.Machine == "" {
292+ c.Machine = "small"
293+ }
294+ if c.Name == "" {
295+ c.Name = c.Usage
296+ }
297+ if c.Model == "" {
298+ c.Model = "<model>"
299+ }
300+ return Render(Decide(f.presets, c, f.modelFacts()))
301+}
302+
303+// Result is the answers, the facts and the file to write, once every
304+// question is answered.
305+func (f *Flow) Result() (Choices, Facts, string) {
306+ return f.c, f.modelFacts(), f.output
307+}
308+
309+// Base is the resolved server address ("" before a provider is chosen).
310+func (f *Flow) Base() string {
311+ if f.base == "" && f.c.Provider != "" {
312+ f.base = resolveBase(f.c)
313+ }
314+ return f.base
315+}
316+
317+// ModelsError is why the server gave no model list, nil when it did.
318+func (f *Flow) ModelsError() error {
319+ f.modelList()
320+ return f.modelsErr
321+}
322+
323+func (f *Flow) modelList() []string {
324+ if f.c.Provider == "" {
325+ return nil
326+ }
327+ key := f.c.Provider + " " + f.Base()
328+ if f.listed != key {
329+ f.models, f.modelsErr = Models(f.ctx, f.c.Provider, f.Base())
330+ f.listed = key
331+ }
332+ return f.models
333+}
334+
335+func (f *Flow) modelFacts() Facts {
336+ if f.c.Model == "" || f.c.Provider == "" {
337+ return Facts{}
338+ }
339+ if f.factsFor != f.c.Provider+" "+f.c.Model {
340+ f.facts = ModelFacts(f.ctx, f.c.Provider, f.Base(), f.c.Model)
341+ f.factsFor = f.c.Provider + " " + f.c.Model
342+ }
343+ return f.facts
344+}
345+
346+// answers reports, once per provider, whether its server answers.
347+func (f *Flow) answers(name string) bool {
348+ if f.reachable == nil {
349+ f.reachable = map[string]bool{}
350+ for _, p := range local {
351+ _, err := Models(f.ctx, p, resolveBase(Choices{Provider: p}))
352+ f.reachable[p] = err == nil
353+ }
354+ }
355+ return f.reachable[name]
356+}
357+
358+func (f *Flow) firstAnswering() string {
359+ for _, name := range local {
360+ if f.answers(name) {
361+ return name
362+ }
363+ }
364+ return providers[0]
365+}
added internal/agentinit/flow_test.go +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+package agentinit
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+)
8+
9+func keys(steps []Step) string {
10+ var out []string
11+ for _, s := range steps {
12+ out = append(out, s.Key)
13+ }
14+ return strings.Join(out, ",")
15+}
16+
17+func TestFlowSteps(t *testing.T) {
18+ keepConfig(t)
19+ p, _ := LoadPresets()
20+ base := llmmanStub(t)
21+ f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: base}, "")
22+ if got := keys(f.Pending()); got != "model,family,usage,machine,yolo,name,file" {
23+ t.Fatalf("pending = %s", got)
24+ }
25+ if st := f.Step("model"); st.Default != "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" || len(st.Options) != 1 {
26+ t.Errorf("model step: %+v", st)
27+ }
28+ for k, v := range map[string]string{"model": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M", "usage": "translator"} {
29+ if err := f.Set(k, v); err != nil {
30+ t.Fatal(err)
31+ }
32+ }
33+ // translator has no tool that changes things: no "ask first" question.
34+ if got := keys(f.Steps()); strings.Contains(got, "yolo") {
35+ t.Errorf("steps for translator = %s", got)
36+ }
37+ if st := f.Step("family"); st.Default != "qwen" {
38+ t.Errorf("family default %q", st.Default)
39+ }
40+ // A new server forgets the model; the given provider cannot be taken back.
41+ if err := f.Set("provider", "dmr"); err != nil {
42+ t.Fatal(err)
43+ }
44+ if c, _, _ := f.Result(); c.Model != "" {
45+ t.Errorf("model kept across servers: %q", c.Model)
46+ }
47+ f.Unset("provider")
48+ if st := f.Step("provider"); st.Value != "dmr" || !st.Given {
49+ t.Errorf("a given answer was taken back: %+v", st)
50+ }
51+ if err := f.Set("usage", "nope"); err == nil {
52+ t.Error("an unknown usage was accepted")
53+ }
54+}
55+
56+// The preview shows the candidate without keeping it.
57+func TestFlowPreviewCandidate(t *testing.T) {
58+ keepConfig(t)
59+ p, _ := LoadPresets()
60+ f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: llmmanStub(t), Model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}, "")
61+ if text := f.Preview("usage", "docs"); !strings.Contains(text, "docSearch:") {
62+ t.Errorf("candidate not previewed")
63+ }
64+ if c, _, _ := f.Result(); c.Usage != "" {
65+ t.Errorf("the candidate was kept: %q", c.Usage)
66+ }
67+ if text := f.Preview("", ""); !strings.Contains(text, "usage: developer") {
68+ t.Errorf("unanswered usage should preview its default")
69+ }
70+}
new file mode 100644
@@ -0,0 +1,70 @@
1+package agentinit
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+)
8+
9+func keys(steps []Step) string {
10+ var out []string
11+ for _, s := range steps {
12+ out = append(out, s.Key)
13+ }
14+ return strings.Join(out, ",")
15+}
16+
17+func TestFlowSteps(t *testing.T) {
18+ keepConfig(t)
19+ p, _ := LoadPresets()
20+ base := llmmanStub(t)
21+ f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: base}, "")
22+ if got := keys(f.Pending()); got != "model,family,usage,machine,yolo,name,file" {
23+ t.Fatalf("pending = %s", got)
24+ }
25+ if st := f.Step("model"); st.Default != "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" || len(st.Options) != 1 {
26+ t.Errorf("model step: %+v", st)
27+ }
28+ for k, v := range map[string]string{"model": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M", "usage": "translator"} {
29+ if err := f.Set(k, v); err != nil {
30+ t.Fatal(err)
31+ }
32+ }
33+ // translator has no tool that changes things: no "ask first" question.
34+ if got := keys(f.Steps()); strings.Contains(got, "yolo") {
35+ t.Errorf("steps for translator = %s", got)
36+ }
37+ if st := f.Step("family"); st.Default != "qwen" {
38+ t.Errorf("family default %q", st.Default)
39+ }
40+ // A new server forgets the model; the given provider cannot be taken back.
41+ if err := f.Set("provider", "dmr"); err != nil {
42+ t.Fatal(err)
43+ }
44+ if c, _, _ := f.Result(); c.Model != "" {
45+ t.Errorf("model kept across servers: %q", c.Model)
46+ }
47+ f.Unset("provider")
48+ if st := f.Step("provider"); st.Value != "dmr" || !st.Given {
49+ t.Errorf("a given answer was taken back: %+v", st)
50+ }
51+ if err := f.Set("usage", "nope"); err == nil {
52+ t.Error("an unknown usage was accepted")
53+ }
54+}
55+
56+// The preview shows the candidate without keeping it.
57+func TestFlowPreviewCandidate(t *testing.T) {
58+ keepConfig(t)
59+ p, _ := LoadPresets()
60+ f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: llmmanStub(t), Model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}, "")
61+ if text := f.Preview("usage", "docs"); !strings.Contains(text, "docSearch:") {
62+ t.Errorf("candidate not previewed")
63+ }
64+ if c, _, _ := f.Result(); c.Usage != "" {
65+ t.Errorf("the candidate was kept: %q", c.Usage)
66+ }
67+ if text := f.Preview("", ""); !strings.Contains(text, "usage: developer") {
68+ t.Errorf("unanswered usage should preview its default")
69+ }
70+}
added internal/agentinit/presets.go +165 -0
new file mode 100644
@@ -0,0 +1,165 @@
1+// Package agentinit is `mm init` (ticket 97): a wizard that writes a
2+// commented agent.yaml for a model and a job, with no model to run. The
3+// settings come, most reliable first, from what the model server says about
4+// the model (facts.go), from the family table, the usage profiles and the
5+// machine rows of presets.yaml (this file), and are written with the reason
6+// for each value (render.go).
7+//
8+// os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout)) // mm init …
9+package agentinit
10+
11+import (
12+ _ "embed"
13+ "fmt"
14+ "strings"
15+
16+ "gopkg.in/yaml.v3"
17+)
18+
19+// presetsYAML is the shared table: the Claude skill of ticket 111 reads the
20+// same file.
21+//
22+//go:embed presets.yaml
23+var presetsYAML []byte
24+
25+// Sampling is a sampling block: the values the YAML writes under `sampling`.
26+type Sampling struct {
27+ Temperature *float64 `yaml:"temperature"`
28+ TopP *float64 `yaml:"top_p"`
29+ TopK *int `yaml:"top_k"`
30+ MinP *float64 `yaml:"min_p"`
31+}
32+
33+// Family is what is known about a family of models.
34+type Family struct {
35+ Name string `yaml:"name"`
36+ Match []string `yaml:"match"`
37+ Reasoning string `yaml:"reasoning"` // "enable_thinking", "reasoning_effort" or "none"
38+ EffortValues []string `yaml:"effortValues"`
39+ General Sampling `yaml:"general"`
40+ Thinking Sampling `yaml:"thinking"`
41+ Precise *float64 `yaml:"precise"`
42+ MaxTokensKey string `yaml:"maxTokensKey"` // "" = max_tokens
43+ Note string `yaml:"note"`
44+ Source string `yaml:"source"`
45+}
46+
47+// Usage is what an agent is for.
48+type Usage struct {
49+ Name string `yaml:"name"`
50+ Description string `yaml:"description"`
51+ Tools map[string]bool `yaml:"tools"`
52+ Thinking bool `yaml:"thinking"`
53+ Precise bool `yaml:"precise"`
54+ Docs bool `yaml:"docs"`
55+ JSON bool `yaml:"json"`
56+ Coordinator bool `yaml:"coordinator"`
57+ MaxTurns int `yaml:"maxTurns"`
58+ Yolo bool `yaml:"yolo"`
59+ System string `yaml:"system"`
60+}
61+
62+// Machine is a row of the settings guide's profiles table.
63+type Machine struct {
64+ Name string `yaml:"name"`
65+ Description string `yaml:"description"`
66+ FirstTokenTimeout string `yaml:"firstTokenTimeout"`
67+ WatchdogTimeout string `yaml:"watchdogTimeout"`
68+ BashTimeout string `yaml:"bashTimeout"`
69+ MaxOutput int `yaml:"maxOutput"`
70+ Source string `yaml:"source"`
71+}
72+
73+// Presets is the whole table.
74+type Presets struct {
75+ Families []Family `yaml:"families"`
76+ Usages []Usage `yaml:"usages"`
77+ Machines []Machine `yaml:"machines"`
78+}
79+
80+// LoadPresets reads the embedded table.
81+//
82+// p, err := agentinit.LoadPresets()
83+// fam := p.FamilyOf("hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M") // qwen
84+func LoadPresets() (Presets, error) {
85+ var p Presets
86+ if err := yaml.Unmarshal(presetsYAML, &p); err != nil {
87+ return Presets{}, fmt.Errorf("presets.yaml: %w", err)
88+ }
89+ return p, nil
90+}
91+
92+// FamilyOf is the first family whose match words all appear in the model id
93+// (lower-cased) — `other` when none does. The server's own family name
94+// (llmman and Ollama say "qwen35", "gemma4"…) is tried too.
95+func (p Presets) FamilyOf(model string, serverFamily ...string) Family {
96+ ids := append([]string{model}, serverFamily...)
97+ for _, f := range p.Families {
98+ if len(f.Match) == 0 {
99+ continue
100+ }
101+ for _, id := range ids {
102+ if matchesAll(strings.ToLower(id), f.Match) {
103+ return f
104+ }
105+ }
106+ }
107+ return p.Family("other")
108+}
109+
110+func matchesAll(id string, words []string) bool {
111+ for _, w := range words {
112+ if !strings.Contains(id, w) {
113+ return false
114+ }
115+ }
116+ return true
117+}
118+
119+// Family is the family of that name, the zero Family when there is none.
120+func (p Presets) Family(name string) Family {
121+ for _, f := range p.Families {
122+ if f.Name == name {
123+ return f
124+ }
125+ }
126+ return Family{}
127+}
128+
129+// Usage is the usage of that name, and whether there is one.
130+func (p Presets) Usage(name string) (Usage, bool) {
131+ for _, u := range p.Usages {
132+ if u.Name == name {
133+ return u, true
134+ }
135+ }
136+ return Usage{}, false
137+}
138+
139+// Machine is the machine row of that name, and whether there is one.
140+func (p Presets) Machine(name string) (Machine, bool) {
141+ for _, m := range p.Machines {
142+ if m.Name == name {
143+ return m, true
144+ }
145+ }
146+ return Machine{}, false
147+}
148+
149+// UsageNames and MachineNames list the choices, in the table's order.
150+func (p Presets) UsageNames() []string {
151+ var out []string
152+ for _, u := range p.Usages {
153+ out = append(out, u.Name)
154+ }
155+ return out
156+}
157+
158+// MachineNames lists the machine rows, in the table's order.
159+func (p Presets) MachineNames() []string {
160+ var out []string
161+ for _, m := range p.Machines {
162+ out = append(out, m.Name)
163+ }
164+ return out
165+}
new file mode 100644
@@ -0,0 +1,165 @@
1+// Package agentinit is `mm init` (ticket 97): a wizard that writes a
2+// commented agent.yaml for a model and a job, with no model to run. The
3+// settings come, most reliable first, from what the model server says about
4+// the model (facts.go), from the family table, the usage profiles and the
5+// machine rows of presets.yaml (this file), and are written with the reason
6+// for each value (render.go).
7+//
8+// os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout)) // mm init …
9+package agentinit
10+
11+import (
12+ _ "embed"
13+ "fmt"
14+ "strings"
15+
16+ "gopkg.in/yaml.v3"
17+)
18+
19+// presetsYAML is the shared table: the Claude skill of ticket 111 reads the
20+// same file.
21+//
22+//go:embed presets.yaml
23+var presetsYAML []byte
24+
25+// Sampling is a sampling block: the values the YAML writes under `sampling`.
26+type Sampling struct {
27+ Temperature *float64 `yaml:"temperature"`
28+ TopP *float64 `yaml:"top_p"`
29+ TopK *int `yaml:"top_k"`
30+ MinP *float64 `yaml:"min_p"`
31+}
32+
33+// Family is what is known about a family of models.
34+type Family struct {
35+ Name string `yaml:"name"`
36+ Match []string `yaml:"match"`
37+ Reasoning string `yaml:"reasoning"` // "enable_thinking", "reasoning_effort" or "none"
38+ EffortValues []string `yaml:"effortValues"`
39+ General Sampling `yaml:"general"`
40+ Thinking Sampling `yaml:"thinking"`
41+ Precise *float64 `yaml:"precise"`
42+ MaxTokensKey string `yaml:"maxTokensKey"` // "" = max_tokens
43+ Note string `yaml:"note"`
44+ Source string `yaml:"source"`
45+}
46+
47+// Usage is what an agent is for.
48+type Usage struct {
49+ Name string `yaml:"name"`
50+ Description string `yaml:"description"`
51+ Tools map[string]bool `yaml:"tools"`
52+ Thinking bool `yaml:"thinking"`
53+ Precise bool `yaml:"precise"`
54+ Docs bool `yaml:"docs"`
55+ JSON bool `yaml:"json"`
56+ Coordinator bool `yaml:"coordinator"`
57+ MaxTurns int `yaml:"maxTurns"`
58+ Yolo bool `yaml:"yolo"`
59+ System string `yaml:"system"`
60+}
61+
62+// Machine is a row of the settings guide's profiles table.
63+type Machine struct {
64+ Name string `yaml:"name"`
65+ Description string `yaml:"description"`
66+ FirstTokenTimeout string `yaml:"firstTokenTimeout"`
67+ WatchdogTimeout string `yaml:"watchdogTimeout"`
68+ BashTimeout string `yaml:"bashTimeout"`
69+ MaxOutput int `yaml:"maxOutput"`
70+ Source string `yaml:"source"`
71+}
72+
73+// Presets is the whole table.
74+type Presets struct {
75+ Families []Family `yaml:"families"`
76+ Usages []Usage `yaml:"usages"`
77+ Machines []Machine `yaml:"machines"`
78+}
79+
80+// LoadPresets reads the embedded table.
81+//
82+// p, err := agentinit.LoadPresets()
83+// fam := p.FamilyOf("hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M") // qwen
84+func LoadPresets() (Presets, error) {
85+ var p Presets
86+ if err := yaml.Unmarshal(presetsYAML, &p); err != nil {
87+ return Presets{}, fmt.Errorf("presets.yaml: %w", err)
88+ }
89+ return p, nil
90+}
91+
92+// FamilyOf is the first family whose match words all appear in the model id
93+// (lower-cased) — `other` when none does. The server's own family name
94+// (llmman and Ollama say "qwen35", "gemma4"…) is tried too.
95+func (p Presets) FamilyOf(model string, serverFamily ...string) Family {
96+ ids := append([]string{model}, serverFamily...)
97+ for _, f := range p.Families {
98+ if len(f.Match) == 0 {
99+ continue
100+ }
101+ for _, id := range ids {
102+ if matchesAll(strings.ToLower(id), f.Match) {
103+ return f
104+ }
105+ }
106+ }
107+ return p.Family("other")
108+}
109+
110+func matchesAll(id string, words []string) bool {
111+ for _, w := range words {
112+ if !strings.Contains(id, w) {
113+ return false
114+ }
115+ }
116+ return true
117+}
118+
119+// Family is the family of that name, the zero Family when there is none.
120+func (p Presets) Family(name string) Family {
121+ for _, f := range p.Families {
122+ if f.Name == name {
123+ return f
124+ }
125+ }
126+ return Family{}
127+}
128+
129+// Usage is the usage of that name, and whether there is one.
130+func (p Presets) Usage(name string) (Usage, bool) {
131+ for _, u := range p.Usages {
132+ if u.Name == name {
133+ return u, true
134+ }
135+ }
136+ return Usage{}, false
137+}
138+
139+// Machine is the machine row of that name, and whether there is one.
140+func (p Presets) Machine(name string) (Machine, bool) {
141+ for _, m := range p.Machines {
142+ if m.Name == name {
143+ return m, true
144+ }
145+ }
146+ return Machine{}, false
147+}
148+
149+// UsageNames and MachineNames list the choices, in the table's order.
150+func (p Presets) UsageNames() []string {
151+ var out []string
152+ for _, u := range p.Usages {
153+ out = append(out, u.Name)
154+ }
155+ return out
156+}
157+
158+// MachineNames lists the machine rows, in the table's order.
159+func (p Presets) MachineNames() []string {
160+ var out []string
161+ for _, m := range p.Machines {
162+ out = append(out, m.Name)
163+ }
164+ return out
165+}
added internal/agentinit/presets.yaml +284 -0
new file mode 100644
@@ -0,0 +1,284 @@
1+# presets.yaml — what `mm init` knows about model families, usages and
2+# machines (ticket 97). ONE table, shared on purpose: `mm init` embeds it, and
3+# the Claude skill of ticket 111 reads this same file. Every value says where
4+# it comes from: a model card, or a measured demo of this repository.
5+#
6+# Families are matched against the lower-cased model id, in order: the first
7+# entry whose `match` words all appear wins, so a specific family (qwen +
8+# coder) comes before its general one (qwen). `other` matches anything.
9+#
10+# Sampling: `general` is the author's recommendation for normal use (thinking
11+# off); `thinking` is used when the agent reasons; `precise` is the
12+# temperature for tool-heavy, thinking-off agents (developer, docs, JSON…).
13+# When the model server tells the GGUF's own sampling (llmman, Ollama), it
14+# replaces `general` and `thinking` — the GGUF is the most specific source.
15+
16+families:
17+ - name: qwen-coder
18+ match: [qwen, coder]
19+ reasoning: none
20+ general: {temperature: 1.0, top_p: 0.95, top_k: 40}
21+ precise: 0.2
22+ source: "demos/qwen-style (Qwen3-Coder-Next, measured: 1.0 / 0.95 / 40 from the GGUF)"
23+
24+ - name: qwen
25+ match: [qwen]
26+ reasoning: enable_thinking
27+ general: {temperature: 0.7, top_p: 0.8, top_k: 20}
28+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
29+ precise: 0.0
30+ source: "Qwen model cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, thinking off (demos/docs-search, memory-search, plans)"
31+
32+ - name: gemma
33+ match: [gemma]
34+ reasoning: enable_thinking
35+ general: {temperature: 1.0, top_p: 0.95, top_k: 64}
36+ thinking: {temperature: 1.0, top_p: 0.95, top_k: 64}
37+ precise: 0.2
38+ source: "Gemma 4 GGUF sampling 1.0 / 0.95 / 64 (demos/gemini-style); precise 0.2 measured with gemma-4-E4B (demos/hooks, guards); enable_thinking is off by default in the template"
39+
40+ - name: mellum-thinking
41+ match: [mellum, thinking]
42+ reasoning: enable_thinking
43+ general: {temperature: 0.6, top_p: 0.95}
44+ thinking: {temperature: 0.6, top_p: 0.95}
45+ precise: 0.2
46+ source: "demos/coding-helpers (Mellum2 12B Thinking: 0.6 / 0.95, thinking on); demos/programming-answers (Mellum2.1: 0.2 / 0.9)"
47+
48+ - name: mellum
49+ match: [mellum]
50+ reasoning: none
51+ general: {temperature: 0.2, top_p: 0.9}
52+ precise: 0.0
53+ source: "demos/little-coder (Mellum2 12B Instruct, measured 0.2 / 0.9); agent.dmr.yaml (0.0 / 0.9)"
54+
55+ - name: gpt-oss
56+ match: [gpt-oss]
57+ reasoning: reasoning_effort
58+ effortValues: [low, medium, high]
59+ general: {temperature: 1.0, top_p: 1.0}
60+ thinking: {temperature: 1.0, top_p: 1.0}
61+ precise: 1.0
62+ source: "demos/codex-style (gpt-oss 20B: 1.0 / 1.0, harmony format, reasoning_effort low | medium | high, default medium) — the model always reasons: never a low temperature"
63+
64+ - name: openai
65+ match: [gpt-]
66+ reasoning: none
67+ maxTokensKey: max_completion_tokens
68+ general: {temperature: 1.0}
69+ precise: 1.0
70+ note: "a hosted OpenAI model: its reasoning models (gpt-5, the o-series) accept only the default temperature and take max_completion_tokens instead of max_tokens — remove a sampling key the API refuses"
71+ source: "OpenAI API reference; not measured in this repository"
72+
73+ - name: minicpm
74+ match: [minicpm]
75+ reasoning: enable_thinking
76+ general: {temperature: 0.3, top_p: 0.95}
77+ thinking: {temperature: 0.6, top_p: 0.95}
78+ precise: 0.3
79+ source: "demos/coding-helpers (MiniCPM5 2B: 0.3 / 0.95, enable_thinking false — its template has no default: always set it)"
80+
81+ - name: ornith
82+ match: [ornith]
83+ reasoning: enable_thinking
84+ general: {temperature: 0.3, top_p: 0.8, top_k: 20}
85+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
86+ precise: 0.3
87+ source: "demos/team (Ornith 1.5 9B: 0.3–0.4 / 0.8 / 20 thinking off, 0.6 / 0.95 / 20 thinking on)"
88+
89+ - name: granite-guardian
90+ match: [granite, guardian]
91+ reasoning: none
92+ general: {temperature: 0.0}
93+ precise: 0.0
94+ note: "a guard model: it answers Yes / No about a risk named in the system message, it is not a general agent — see demos/guard"
95+ source: "demos/guard (Granite Guardian 3.2 3B-A800M: temperature 0.0)"
96+
97+ - name: menlo
98+ match: [menlo]
99+ reasoning: none
100+ general: {temperature: 0.0, top_p: 0.9}
101+ precise: 0.0
102+ source: "demos/llmman (Jan-nano, Lucy: 0.0 / 0.9)"
103+
104+ - name: llama
105+ match: [llama]
106+ reasoning: none
107+ general: {temperature: 0.6, top_p: 0.9}
108+ precise: 0.2
109+ source: "Llama 3.1 8B Instruct generation_config.json (0.6 / 0.9); no measured demo in this repository"
110+
111+ - name: mistral
112+ match: [mistral]
113+ reasoning: none
114+ general: {temperature: 0.15}
115+ precise: 0.15
116+ source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
117+
118+ - name: other
119+ match: []
120+ reasoning: none
121+ general: {temperature: 0.7, top_p: 0.9}
122+ precise: 0.2
123+ source: "skills/create-agent rule when nothing is published: 0.7 / 0.9, 0.2 for a code agent without reasoning"
124+
125+# Usages: what the agent is for. `tools` are the YAML switches; `thinking`
126+# asks for reasoning when the family has a switch; `precise` picks the
127+# family's precise temperature (when not thinking); `docs` turns on the
128+# documentation search; `json` writes a structuredOutput example (which
129+# forbids every tool).
130+usages:
131+ - name: developer
132+ description: "a coding agent: reads and changes the project, runs commands"
133+ tools: {bashTool: true, editTools: true, planTool: true, askUserTool: true}
134+ thinking: true
135+ precise: true
136+ maxTurns: 40
137+ yolo: false
138+ system: |
139+ You are a coding agent working in a terminal, in the user's project.
140+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
141+ plan tool.
142+ Read before you change: look at the files and run the commands that tell
143+ you how the project works. Make the smallest change that does the job,
144+ then run the tests or the build to check it.
145+ Never state the output of a command you did not run in this answer.
146+ When a task has several steps, make a plan and update it as you go.
147+
148+ - name: reviewer
149+ description: "reviews code and changes, suggests fixes, changes nothing unasked"
150+ tools: {bashTool: true, editTools: true, planTool: false, askUserTool: true}
151+ thinking: true
152+ precise: true
153+ maxTurns: 30
154+ yolo: false
155+ system: |
156+ You are a code reviewer working in a terminal, in the user's project.
157+ Read the code and the changes (git diff, git log) with your tools, and
158+ report: bugs first, then risks, then style — each with the file and line.
159+ Do not change any file unless the user asks you to.
160+ Never state the output of a command you did not run in this answer.
161+
162+ - name: translator
163+ description: "translates texts between languages, no tools"
164+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
165+ thinking: false
166+ precise: true
167+ maxTurns: 3
168+ yolo: true
169+ system: |
170+ You are a translator. Translate the text the user gives you into the
171+ language they ask for (English ↔ French when they do not say).
172+ Keep the meaning, the tone and the formatting (Markdown, code blocks,
173+ lists). Do not translate code, file names or product names.
174+ Answer with the translation only, no comment.
175+
176+ - name: writer
177+ description: "writes documents and saves them to files"
178+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: true, saveAnswerTool: true}
179+ thinking: false
180+ precise: false
181+ maxTurns: 10
182+ yolo: false
183+ system: |
184+ You are a technical writer. Write clear, well-structured documents in
185+ Markdown: short paragraphs, headings, examples.
186+ When the user asks you to save a document you just wrote, call
187+ save_answer with the file name; to change an existing file, read it
188+ first, then edit it.
189+
190+ - name: assistant
191+ description: "answers questions, no tools"
192+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true}
193+ thinking: false
194+ precise: false
195+ maxTurns: 5
196+ yolo: true
197+ system: |
198+ You are a helpful assistant. Answer clearly and briefly, in the user's
199+ language. Say when you do not know; never invent facts, names or
200+ figures.
201+
202+ - name: researcher
203+ description: "reads web pages and summarises them"
204+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true, fetchContentTool: true, saveAnswerTool: true}
205+ thinking: false
206+ precise: false
207+ maxTurns: 15
208+ yolo: false
209+ system: |
210+ You are a research assistant. To answer, read the web pages the user
211+ names with fetch_content, then summarise what they say, citing each
212+ page's address. Never state what a page says without having read it in
213+ this answer.
214+
215+ - name: docs
216+ description: "answers from a documentation folder (docs/), with links to the passages"
217+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: false}
218+ docs: true
219+ thinking: false
220+ precise: true
221+ maxTurns: 8
222+ yolo: true
223+ system: |
224+ You answer questions about the project from its documentation, in the
225+ folder docs/. For every question the documentation may cover, call
226+ search_docs first, with the words the documentation is likely to use
227+ (often English, names of commands and types). Answer from the passages
228+ it returns, and end with the link of the passage you used (path:line).
229+ If nothing is found, try other words once, then say the documentation
230+ does not cover it.
231+
232+ - name: json
233+ description: "answers in JSON shaped by Go types (structuredOutput), no tools"
234+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
235+ json: true
236+ thinking: false
237+ precise: true
238+ maxTurns: 3
239+ yolo: true
240+ system: |
241+ You extract facts from the user's text and answer in the JSON shape you
242+ are given. Fill every field from the text; use null when the text does
243+ not say.
244+
245+ - name: coordinator
246+ description: "hands each question to other mm agents (mmConfigs) and relays their answers"
247+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
248+ coordinator: true
249+ thinking: false
250+ precise: true
251+ maxTurns: 4
252+ yolo: true
253+ system: |
254+ You are a coordinator. You do not answer the questions yourself: for
255+ each question, call ask_agent with the agent whose description fits,
256+ then give its answer to the user, in the user's language.
257+ For a greeting or a question about you, answer in one short sentence,
258+ without any tool.
259+
260+# Machines: from guides/agent-settings-for-small-models.md, "Summary:
261+# typical profiles". With bash on, watchdogTimeout is aligned on bashTimeout
262+# (section 3 of the guide).
263+machines:
264+ - name: small
265+ description: "a laptop, models up to about 8B"
266+ firstTokenTimeout: 3m
267+ watchdogTimeout: 20s
268+ bashTimeout: 30s
269+ maxOutput: 8000
270+ source: "settings guide, row \"Small model (≤ 8B), short tasks\""
271+ - name: medium
272+ description: "a workstation, models up to about 30B"
273+ firstTokenTimeout: 5m
274+ watchdogTimeout: 2m
275+ bashTimeout: 2m
276+ maxOutput: 16000
277+ source: "settings guide, row \"Small/medium model, general coding agent\""
278+ - name: large
279+ description: "a big model, or a modest machine running on the CPU"
280+ firstTokenTimeout: 10m
281+ watchdogTimeout: 5m
282+ bashTimeout: 5m
283+ maxOutput: 24000
284+ source: "settings guide, row \"Larger model or modest machine (CPU)\""
new file mode 100644
@@ -0,0 +1,284 @@
1+# presets.yaml — what `mm init` knows about model families, usages and
2+# machines (ticket 97). ONE table, shared on purpose: `mm init` embeds it, and
3+# the Claude skill of ticket 111 reads this same file. Every value says where
4+# it comes from: a model card, or a measured demo of this repository.
5+#
6+# Families are matched against the lower-cased model id, in order: the first
7+# entry whose `match` words all appear wins, so a specific family (qwen +
8+# coder) comes before its general one (qwen). `other` matches anything.
9+#
10+# Sampling: `general` is the author's recommendation for normal use (thinking
11+# off); `thinking` is used when the agent reasons; `precise` is the
12+# temperature for tool-heavy, thinking-off agents (developer, docs, JSON…).
13+# When the model server tells the GGUF's own sampling (llmman, Ollama), it
14+# replaces `general` and `thinking` — the GGUF is the most specific source.
15+
16+families:
17+ - name: qwen-coder
18+ match: [qwen, coder]
19+ reasoning: none
20+ general: {temperature: 1.0, top_p: 0.95, top_k: 40}
21+ precise: 0.2
22+ source: "demos/qwen-style (Qwen3-Coder-Next, measured: 1.0 / 0.95 / 40 from the GGUF)"
23+
24+ - name: qwen
25+ match: [qwen]
26+ reasoning: enable_thinking
27+ general: {temperature: 0.7, top_p: 0.8, top_k: 20}
28+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
29+ precise: 0.0
30+ source: "Qwen model cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, thinking off (demos/docs-search, memory-search, plans)"
31+
32+ - name: gemma
33+ match: [gemma]
34+ reasoning: enable_thinking
35+ general: {temperature: 1.0, top_p: 0.95, top_k: 64}
36+ thinking: {temperature: 1.0, top_p: 0.95, top_k: 64}
37+ precise: 0.2
38+ source: "Gemma 4 GGUF sampling 1.0 / 0.95 / 64 (demos/gemini-style); precise 0.2 measured with gemma-4-E4B (demos/hooks, guards); enable_thinking is off by default in the template"
39+
40+ - name: mellum-thinking
41+ match: [mellum, thinking]
42+ reasoning: enable_thinking
43+ general: {temperature: 0.6, top_p: 0.95}
44+ thinking: {temperature: 0.6, top_p: 0.95}
45+ precise: 0.2
46+ source: "demos/coding-helpers (Mellum2 12B Thinking: 0.6 / 0.95, thinking on); demos/programming-answers (Mellum2.1: 0.2 / 0.9)"
47+
48+ - name: mellum
49+ match: [mellum]
50+ reasoning: none
51+ general: {temperature: 0.2, top_p: 0.9}
52+ precise: 0.0
53+ source: "demos/little-coder (Mellum2 12B Instruct, measured 0.2 / 0.9); agent.dmr.yaml (0.0 / 0.9)"
54+
55+ - name: gpt-oss
56+ match: [gpt-oss]
57+ reasoning: reasoning_effort
58+ effortValues: [low, medium, high]
59+ general: {temperature: 1.0, top_p: 1.0}
60+ thinking: {temperature: 1.0, top_p: 1.0}
61+ precise: 1.0
62+ source: "demos/codex-style (gpt-oss 20B: 1.0 / 1.0, harmony format, reasoning_effort low | medium | high, default medium) — the model always reasons: never a low temperature"
63+
64+ - name: openai
65+ match: [gpt-]
66+ reasoning: none
67+ maxTokensKey: max_completion_tokens
68+ general: {temperature: 1.0}
69+ precise: 1.0
70+ note: "a hosted OpenAI model: its reasoning models (gpt-5, the o-series) accept only the default temperature and take max_completion_tokens instead of max_tokens — remove a sampling key the API refuses"
71+ source: "OpenAI API reference; not measured in this repository"
72+
73+ - name: minicpm
74+ match: [minicpm]
75+ reasoning: enable_thinking
76+ general: {temperature: 0.3, top_p: 0.95}
77+ thinking: {temperature: 0.6, top_p: 0.95}
78+ precise: 0.3
79+ source: "demos/coding-helpers (MiniCPM5 2B: 0.3 / 0.95, enable_thinking false — its template has no default: always set it)"
80+
81+ - name: ornith
82+ match: [ornith]
83+ reasoning: enable_thinking
84+ general: {temperature: 0.3, top_p: 0.8, top_k: 20}
85+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
86+ precise: 0.3
87+ source: "demos/team (Ornith 1.5 9B: 0.3–0.4 / 0.8 / 20 thinking off, 0.6 / 0.95 / 20 thinking on)"
88+
89+ - name: granite-guardian
90+ match: [granite, guardian]
91+ reasoning: none
92+ general: {temperature: 0.0}
93+ precise: 0.0
94+ note: "a guard model: it answers Yes / No about a risk named in the system message, it is not a general agent — see demos/guard"
95+ source: "demos/guard (Granite Guardian 3.2 3B-A800M: temperature 0.0)"
96+
97+ - name: menlo
98+ match: [menlo]
99+ reasoning: none
100+ general: {temperature: 0.0, top_p: 0.9}
101+ precise: 0.0
102+ source: "demos/llmman (Jan-nano, Lucy: 0.0 / 0.9)"
103+
104+ - name: llama
105+ match: [llama]
106+ reasoning: none
107+ general: {temperature: 0.6, top_p: 0.9}
108+ precise: 0.2
109+ source: "Llama 3.1 8B Instruct generation_config.json (0.6 / 0.9); no measured demo in this repository"
110+
111+ - name: mistral
112+ match: [mistral]
113+ reasoning: none
114+ general: {temperature: 0.15}
115+ precise: 0.15
116+ source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
117+
118+ - name: other
119+ match: []
120+ reasoning: none
121+ general: {temperature: 0.7, top_p: 0.9}
122+ precise: 0.2
123+ source: "skills/create-agent rule when nothing is published: 0.7 / 0.9, 0.2 for a code agent without reasoning"
124+
125+# Usages: what the agent is for. `tools` are the YAML switches; `thinking`
126+# asks for reasoning when the family has a switch; `precise` picks the
127+# family's precise temperature (when not thinking); `docs` turns on the
128+# documentation search; `json` writes a structuredOutput example (which
129+# forbids every tool).
130+usages:
131+ - name: developer
132+ description: "a coding agent: reads and changes the project, runs commands"
133+ tools: {bashTool: true, editTools: true, planTool: true, askUserTool: true}
134+ thinking: true
135+ precise: true
136+ maxTurns: 40
137+ yolo: false
138+ system: |
139+ You are a coding agent working in a terminal, in the user's project.
140+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
141+ plan tool.
142+ Read before you change: look at the files and run the commands that tell
143+ you how the project works. Make the smallest change that does the job,
144+ then run the tests or the build to check it.
145+ Never state the output of a command you did not run in this answer.
146+ When a task has several steps, make a plan and update it as you go.
147+
148+ - name: reviewer
149+ description: "reviews code and changes, suggests fixes, changes nothing unasked"
150+ tools: {bashTool: true, editTools: true, planTool: false, askUserTool: true}
151+ thinking: true
152+ precise: true
153+ maxTurns: 30
154+ yolo: false
155+ system: |
156+ You are a code reviewer working in a terminal, in the user's project.
157+ Read the code and the changes (git diff, git log) with your tools, and
158+ report: bugs first, then risks, then style — each with the file and line.
159+ Do not change any file unless the user asks you to.
160+ Never state the output of a command you did not run in this answer.
161+
162+ - name: translator
163+ description: "translates texts between languages, no tools"
164+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
165+ thinking: false
166+ precise: true
167+ maxTurns: 3
168+ yolo: true
169+ system: |
170+ You are a translator. Translate the text the user gives you into the
171+ language they ask for (English ↔ French when they do not say).
172+ Keep the meaning, the tone and the formatting (Markdown, code blocks,
173+ lists). Do not translate code, file names or product names.
174+ Answer with the translation only, no comment.
175+
176+ - name: writer
177+ description: "writes documents and saves them to files"
178+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: true, saveAnswerTool: true}
179+ thinking: false
180+ precise: false
181+ maxTurns: 10
182+ yolo: false
183+ system: |
184+ You are a technical writer. Write clear, well-structured documents in
185+ Markdown: short paragraphs, headings, examples.
186+ When the user asks you to save a document you just wrote, call
187+ save_answer with the file name; to change an existing file, read it
188+ first, then edit it.
189+
190+ - name: assistant
191+ description: "answers questions, no tools"
192+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true}
193+ thinking: false
194+ precise: false
195+ maxTurns: 5
196+ yolo: true
197+ system: |
198+ You are a helpful assistant. Answer clearly and briefly, in the user's
199+ language. Say when you do not know; never invent facts, names or
200+ figures.
201+
202+ - name: researcher
203+ description: "reads web pages and summarises them"
204+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true, fetchContentTool: true, saveAnswerTool: true}
205+ thinking: false
206+ precise: false
207+ maxTurns: 15
208+ yolo: false
209+ system: |
210+ You are a research assistant. To answer, read the web pages the user
211+ names with fetch_content, then summarise what they say, citing each
212+ page's address. Never state what a page says without having read it in
213+ this answer.
214+
215+ - name: docs
216+ description: "answers from a documentation folder (docs/), with links to the passages"
217+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: false}
218+ docs: true
219+ thinking: false
220+ precise: true
221+ maxTurns: 8
222+ yolo: true
223+ system: |
224+ You answer questions about the project from its documentation, in the
225+ folder docs/. For every question the documentation may cover, call
226+ search_docs first, with the words the documentation is likely to use
227+ (often English, names of commands and types). Answer from the passages
228+ it returns, and end with the link of the passage you used (path:line).
229+ If nothing is found, try other words once, then say the documentation
230+ does not cover it.
231+
232+ - name: json
233+ description: "answers in JSON shaped by Go types (structuredOutput), no tools"
234+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
235+ json: true
236+ thinking: false
237+ precise: true
238+ maxTurns: 3
239+ yolo: true
240+ system: |
241+ You extract facts from the user's text and answer in the JSON shape you
242+ are given. Fill every field from the text; use null when the text does
243+ not say.
244+
245+ - name: coordinator
246+ description: "hands each question to other mm agents (mmConfigs) and relays their answers"
247+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
248+ coordinator: true
249+ thinking: false
250+ precise: true
251+ maxTurns: 4
252+ yolo: true
253+ system: |
254+ You are a coordinator. You do not answer the questions yourself: for
255+ each question, call ask_agent with the agent whose description fits,
256+ then give its answer to the user, in the user's language.
257+ For a greeting or a question about you, answer in one short sentence,
258+ without any tool.
259+
260+# Machines: from guides/agent-settings-for-small-models.md, "Summary:
261+# typical profiles". With bash on, watchdogTimeout is aligned on bashTimeout
262+# (section 3 of the guide).
263+machines:
264+ - name: small
265+ description: "a laptop, models up to about 8B"
266+ firstTokenTimeout: 3m
267+ watchdogTimeout: 20s
268+ bashTimeout: 30s
269+ maxOutput: 8000
270+ source: "settings guide, row \"Small model (≤ 8B), short tasks\""
271+ - name: medium
272+ description: "a workstation, models up to about 30B"
273+ firstTokenTimeout: 5m
274+ watchdogTimeout: 2m
275+ bashTimeout: 2m
276+ maxOutput: 16000
277+ source: "settings guide, row \"Small/medium model, general coding agent\""
278+ - name: large
279+ description: "a big model, or a modest machine running on the CPU"
280+ firstTokenTimeout: 10m
281+ watchdogTimeout: 5m
282+ bashTimeout: 5m
283+ maxOutput: 24000
284+ source: "settings guide, row \"Larger model or modest machine (CPU)\""
added internal/agentinit/presets_test.go +59 -0
new file mode 100644
@@ -0,0 +1,59 @@
1+package agentinit
2+
3+import "testing"
4+
5+func TestPresetsLoadAndEveryEntryIsSourced(t *testing.T) {
6+ p, err := LoadPresets()
7+ if err != nil {
8+ t.Fatal(err)
9+ }
10+ if len(p.Families) == 0 || len(p.Usages) == 0 || len(p.Machines) != 3 {
11+ t.Fatalf("presets: %d families, %d usages, %d machines", len(p.Families), len(p.Usages), len(p.Machines))
12+ }
13+ for _, f := range p.Families {
14+ if f.Source == "" || f.General.Temperature == nil || f.Precise == nil {
15+ t.Errorf("family %s: source, general temperature and precise are required", f.Name)
16+ }
17+ if f.Reasoning != "none" && f.Reasoning != "enable_thinking" && f.Reasoning != "reasoning_effort" {
18+ t.Errorf("family %s: reasoning %q", f.Name, f.Reasoning)
19+ }
20+ if f.Reasoning != "none" && f.Thinking.Temperature == nil {
21+ t.Errorf("family %s reasons but has no thinking sampling", f.Name)
22+ }
23+ }
24+ for _, u := range p.Usages {
25+ if u.System == "" || u.MaxTurns <= 0 || u.Description == "" {
26+ t.Errorf("usage %s: system, maxTurns and description are required", u.Name)
27+ }
28+ }
29+ for _, m := range p.Machines {
30+ if m.Source == "" || m.MaxOutput <= 0 {
31+ t.Errorf("machine %s: source and maxOutput are required", m.Name)
32+ }
33+ }
34+ if p.Family("other").Name != "other" {
35+ t.Error("the table has no `other` family")
36+ }
37+}
38+
39+func TestFamilyOf(t *testing.T) {
40+ p, _ := LoadPresets()
41+ for model, want := range map[string]string{
42+ "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M": "qwen",
43+ "hf.co/unsloth/Qwen3-Coder-Next-GGUF:Q4_K_M": "qwen-coder",
44+ "huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4": "mellum-thinking",
45+ "hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q8_0:Q8_0": "mellum",
46+ "hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M": "gemma",
47+ "hf.co/unsloth/gpt-oss-20b-GGUF:Q4_K_M": "gpt-oss",
48+ "llama3.2:3b": "llama",
49+ "mistral-small3.1": "mistral",
50+ "some/unknown-model": "other",
51+ } {
52+ if got := p.FamilyOf(model).Name; got != want {
53+ t.Errorf("FamilyOf(%q) = %s, want %s", model, got, want)
54+ }
55+ }
56+ if got := p.FamilyOf("custom:latest", "qwen35").Name; got != "qwen" {
57+ t.Errorf("the server's family name is not used: %s", got)
58+ }
59+}
new file mode 100644
@@ -0,0 +1,59 @@
1+package agentinit
2+
3+import "testing"
4+
5+func TestPresetsLoadAndEveryEntryIsSourced(t *testing.T) {
6+ p, err := LoadPresets()
7+ if err != nil {
8+ t.Fatal(err)
9+ }
10+ if len(p.Families) == 0 || len(p.Usages) == 0 || len(p.Machines) != 3 {
11+ t.Fatalf("presets: %d families, %d usages, %d machines", len(p.Families), len(p.Usages), len(p.Machines))
12+ }
13+ for _, f := range p.Families {
14+ if f.Source == "" || f.General.Temperature == nil || f.Precise == nil {
15+ t.Errorf("family %s: source, general temperature and precise are required", f.Name)
16+ }
17+ if f.Reasoning != "none" && f.Reasoning != "enable_thinking" && f.Reasoning != "reasoning_effort" {
18+ t.Errorf("family %s: reasoning %q", f.Name, f.Reasoning)
19+ }
20+ if f.Reasoning != "none" && f.Thinking.Temperature == nil {
21+ t.Errorf("family %s reasons but has no thinking sampling", f.Name)
22+ }
23+ }
24+ for _, u := range p.Usages {
25+ if u.System == "" || u.MaxTurns <= 0 || u.Description == "" {
26+ t.Errorf("usage %s: system, maxTurns and description are required", u.Name)
27+ }
28+ }
29+ for _, m := range p.Machines {
30+ if m.Source == "" || m.MaxOutput <= 0 {
31+ t.Errorf("machine %s: source and maxOutput are required", m.Name)
32+ }
33+ }
34+ if p.Family("other").Name != "other" {
35+ t.Error("the table has no `other` family")
36+ }
37+}
38+
39+func TestFamilyOf(t *testing.T) {
40+ p, _ := LoadPresets()
41+ for model, want := range map[string]string{
42+ "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M": "qwen",
43+ "hf.co/unsloth/Qwen3-Coder-Next-GGUF:Q4_K_M": "qwen-coder",
44+ "huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4": "mellum-thinking",
45+ "hf.co/JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q8_0:Q8_0": "mellum",
46+ "hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M": "gemma",
47+ "hf.co/unsloth/gpt-oss-20b-GGUF:Q4_K_M": "gpt-oss",
48+ "llama3.2:3b": "llama",
49+ "mistral-small3.1": "mistral",
50+ "some/unknown-model": "other",
51+ } {
52+ if got := p.FamilyOf(model).Name; got != want {
53+ t.Errorf("FamilyOf(%q) = %s, want %s", model, got, want)
54+ }
55+ }
56+ if got := p.FamilyOf("custom:latest", "qwen35").Name; got != "qwen" {
57+ t.Errorf("the server's family name is not used: %s", got)
58+ }
59+}
added internal/agentinit/render.go +147 -0
new file mode 100644
@@ -0,0 +1,147 @@
1+package agentinit
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+)
8+
9+// Render writes the settings as a commented agent.yaml: every value with the
10+// reason it has that value, the way the demos' files are written, so the
11+// file teaches as it configures.
12+func Render(s Settings) string {
13+ var b strings.Builder
14+ c, u := s.Choices, s.Usage
15+ fmt.Fprintf(&b, "# %s — %s.\n", c.Name, u.Description)
16+ b.WriteString("#\n# Written by `mm init`. Every value says why it has that value; change\n")
17+ b.WriteString("# them as you watch the agent work (guides/agent-settings-for-small-models.md).\n#\n")
18+ fmt.Fprintf(&b, "# model family: %s\n# usage: %s\n# machine: %s — %s\n", s.Family.Name, u.Name, s.Machine.Name, s.Machine.Description)
19+ fmt.Fprintf(&b, "#\n# mm %s or mm -tui %s\n\n", "-config <this file>", "<this file>")
20+
21+ fmt.Fprintf(&b, "provider: %s\n", c.Provider)
22+ fmt.Fprintf(&b, "model: %s\n", quote(c.Model))
23+ if c.BaseURL != "" {
24+ fmt.Fprintf(&b, "baseUrl: %s\n", quote(c.BaseURL))
25+ }
26+ if c.Provider == "openai" {
27+ b.WriteString("# The key, when the server needs one, is read from OPENAI_API_KEY (or the\n# variable apiKeyEnv names): export it — it never goes in this file.\n")
28+ if c.BaseURL == "" {
29+ b.WriteString("# No baseUrl: the OpenAI API. Set one for any other OpenAI-compatible server.\n")
30+ }
31+ }
32+ b.WriteString("\n")
33+
34+ writeSettings(&b, "", s.Top)
35+ b.WriteString("\n")
36+
37+ if u.Docs {
38+ b.WriteString("# Documentation search (usage docs): /docs for you, search_docs for the model.\n")
39+ b.WriteString("docSearch:\n searchDocs: true\n autoSearchDocs: true\n dirs: [docs]\n extensions: [.md, .markdown, .txt, .adoc, .asciidoc, .rst]\n\n")
40+ }
41+ if u.Coordinator {
42+ b.WriteString("# The agents this coordinator hands questions to: one entry per agent,\n")
43+ b.WriteString("# with a description saying when to ask it (docs/en/how-to/let-the-agent-call-other-agents.md).\n")
44+ b.WriteString("# mmConfigs:\n# expert:\n# path: agent.expert.yaml\n# description: Answers programming questions.\n\n")
45+ }
46+
47+ b.WriteString("sampling:\n")
48+ writeSettings(&b, " ", s.Sampling)
49+ if len(s.Kwargs) > 0 {
50+ b.WriteString(" chat_template_kwargs:\n")
51+ writeSettings(&b, " ", s.Kwargs)
52+ }
53+ b.WriteString("\n")
54+
55+ b.WriteString("context:\n")
56+ writeSettings(&b, " ", s.Context)
57+ b.WriteString("\n")
58+
59+ b.WriteString("agentsMd:\n # An AGENTS.md in the project directory is added to the system prompt.\n enabled: true\n\n")
60+ fmt.Fprintf(&b, "acp:\n name: %s\n title: %s\n\n", c.Name, quote(titleOf(c.Name, u)))
61+
62+ if u.JSON {
63+ b.WriteString("# The shape of every answer, as Go types (docs/en/how-to/get-json-answers.md).\n")
64+ b.WriteString("# An EXAMPLE: replace it with your own. It forbids every tool.\n")
65+ b.WriteString("structuredOutput: |\n")
66+ for _, line := range strings.Split(jsonExample, "\n") {
67+ fmt.Fprintf(&b, " %s\n", line)
68+ }
69+ b.WriteString("\n")
70+ }
71+
72+ b.WriteString("system: |\n")
73+ for _, line := range strings.Split(strings.TrimRight(u.System, "\n"), "\n") {
74+ if line == "" {
75+ b.WriteString("\n")
76+ continue
77+ }
78+ fmt.Fprintf(&b, " %s\n", line)
79+ }
80+
81+ if len(s.Notes) > 0 {
82+ b.WriteString("\n")
83+ for _, n := range s.Notes {
84+ for _, line := range wrap(n, 74) {
85+ fmt.Fprintf(&b, "# %s\n", line)
86+ }
87+ }
88+ }
89+ b.WriteString("\n# Next: skills (docs/en/how-to/add-a-skill.md), MCP servers (docs/en/tutorials/mcp-getting-started.md),\n")
90+ b.WriteString("# hooks (docs/en/how-to/guard-the-agent-with-hooks.md), sessions (docs/en/how-to/keep-sessions.md).\n")
91+ return b.String()
92+}
93+
94+const jsonExample = `type Contact struct {
95+ Name string ` + "`json:\"name\"`" + `
96+ Email *string ` + "`json:\"email\"`" + ` // null when the text gives none
97+ City *string ` + "`json:\"city\"`" + `
98+}
99+
100+var Answer Contact`
101+
102+// writeSettings writes a block of settings; consecutive settings with the
103+// same reason share one comment.
104+func writeSettings(b *strings.Builder, indent string, settings []Setting) {
105+ last := ""
106+ for _, st := range settings {
107+ if st.Reason != last {
108+ for _, line := range wrap(st.Reason, 76-len(indent)) {
109+ fmt.Fprintf(b, "%s# %s\n", indent, line)
110+ }
111+ last = st.Reason
112+ }
113+ fmt.Fprintf(b, "%s%s: %s\n", indent, st.Key, st.Value)
114+ }
115+}
116+
117+func titleOf(name string, u Usage) string {
118+ if name == "" {
119+ return u.Description
120+ }
121+ return strings.ToUpper(name[:1]) + name[1:] + " (" + u.Name + ")"
122+}
123+
124+// quote writes a YAML string that never needs thinking about: double-quoted.
125+func quote(s string) string { return strconv.Quote(s) }
126+
127+// wrap cuts a comment at word boundaries.
128+func wrap(text string, width int) []string {
129+ var out []string
130+ line := ""
131+ for _, w := range strings.Fields(text) {
132+ if line != "" && len(line)+1+len(w) > width {
133+ out = append(out, line)
134+ line = w
135+ continue
136+ }
137+ if line == "" {
138+ line = w
139+ } else {
140+ line += " " + w
141+ }
142+ }
143+ if line != "" {
144+ out = append(out, line)
145+ }
146+ return out
147+}
new file mode 100644
@@ -0,0 +1,147 @@
1+package agentinit
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+)
8+
9+// Render writes the settings as a commented agent.yaml: every value with the
10+// reason it has that value, the way the demos' files are written, so the
11+// file teaches as it configures.
12+func Render(s Settings) string {
13+ var b strings.Builder
14+ c, u := s.Choices, s.Usage
15+ fmt.Fprintf(&b, "# %s — %s.\n", c.Name, u.Description)
16+ b.WriteString("#\n# Written by `mm init`. Every value says why it has that value; change\n")
17+ b.WriteString("# them as you watch the agent work (guides/agent-settings-for-small-models.md).\n#\n")
18+ fmt.Fprintf(&b, "# model family: %s\n# usage: %s\n# machine: %s — %s\n", s.Family.Name, u.Name, s.Machine.Name, s.Machine.Description)
19+ fmt.Fprintf(&b, "#\n# mm %s or mm -tui %s\n\n", "-config <this file>", "<this file>")
20+
21+ fmt.Fprintf(&b, "provider: %s\n", c.Provider)
22+ fmt.Fprintf(&b, "model: %s\n", quote(c.Model))
23+ if c.BaseURL != "" {
24+ fmt.Fprintf(&b, "baseUrl: %s\n", quote(c.BaseURL))
25+ }
26+ if c.Provider == "openai" {
27+ b.WriteString("# The key, when the server needs one, is read from OPENAI_API_KEY (or the\n# variable apiKeyEnv names): export it — it never goes in this file.\n")
28+ if c.BaseURL == "" {
29+ b.WriteString("# No baseUrl: the OpenAI API. Set one for any other OpenAI-compatible server.\n")
30+ }
31+ }
32+ b.WriteString("\n")
33+
34+ writeSettings(&b, "", s.Top)
35+ b.WriteString("\n")
36+
37+ if u.Docs {
38+ b.WriteString("# Documentation search (usage docs): /docs for you, search_docs for the model.\n")
39+ b.WriteString("docSearch:\n searchDocs: true\n autoSearchDocs: true\n dirs: [docs]\n extensions: [.md, .markdown, .txt, .adoc, .asciidoc, .rst]\n\n")
40+ }
41+ if u.Coordinator {
42+ b.WriteString("# The agents this coordinator hands questions to: one entry per agent,\n")
43+ b.WriteString("# with a description saying when to ask it (docs/en/how-to/let-the-agent-call-other-agents.md).\n")
44+ b.WriteString("# mmConfigs:\n# expert:\n# path: agent.expert.yaml\n# description: Answers programming questions.\n\n")
45+ }
46+
47+ b.WriteString("sampling:\n")
48+ writeSettings(&b, " ", s.Sampling)
49+ if len(s.Kwargs) > 0 {
50+ b.WriteString(" chat_template_kwargs:\n")
51+ writeSettings(&b, " ", s.Kwargs)
52+ }
53+ b.WriteString("\n")
54+
55+ b.WriteString("context:\n")
56+ writeSettings(&b, " ", s.Context)
57+ b.WriteString("\n")
58+
59+ b.WriteString("agentsMd:\n # An AGENTS.md in the project directory is added to the system prompt.\n enabled: true\n\n")
60+ fmt.Fprintf(&b, "acp:\n name: %s\n title: %s\n\n", c.Name, quote(titleOf(c.Name, u)))
61+
62+ if u.JSON {
63+ b.WriteString("# The shape of every answer, as Go types (docs/en/how-to/get-json-answers.md).\n")
64+ b.WriteString("# An EXAMPLE: replace it with your own. It forbids every tool.\n")
65+ b.WriteString("structuredOutput: |\n")
66+ for _, line := range strings.Split(jsonExample, "\n") {
67+ fmt.Fprintf(&b, " %s\n", line)
68+ }
69+ b.WriteString("\n")
70+ }
71+
72+ b.WriteString("system: |\n")
73+ for _, line := range strings.Split(strings.TrimRight(u.System, "\n"), "\n") {
74+ if line == "" {
75+ b.WriteString("\n")
76+ continue
77+ }
78+ fmt.Fprintf(&b, " %s\n", line)
79+ }
80+
81+ if len(s.Notes) > 0 {
82+ b.WriteString("\n")
83+ for _, n := range s.Notes {
84+ for _, line := range wrap(n, 74) {
85+ fmt.Fprintf(&b, "# %s\n", line)
86+ }
87+ }
88+ }
89+ b.WriteString("\n# Next: skills (docs/en/how-to/add-a-skill.md), MCP servers (docs/en/tutorials/mcp-getting-started.md),\n")
90+ b.WriteString("# hooks (docs/en/how-to/guard-the-agent-with-hooks.md), sessions (docs/en/how-to/keep-sessions.md).\n")
91+ return b.String()
92+}
93+
94+const jsonExample = `type Contact struct {
95+ Name string ` + "`json:\"name\"`" + `
96+ Email *string ` + "`json:\"email\"`" + ` // null when the text gives none
97+ City *string ` + "`json:\"city\"`" + `
98+}
99+
100+var Answer Contact`
101+
102+// writeSettings writes a block of settings; consecutive settings with the
103+// same reason share one comment.
104+func writeSettings(b *strings.Builder, indent string, settings []Setting) {
105+ last := ""
106+ for _, st := range settings {
107+ if st.Reason != last {
108+ for _, line := range wrap(st.Reason, 76-len(indent)) {
109+ fmt.Fprintf(b, "%s# %s\n", indent, line)
110+ }
111+ last = st.Reason
112+ }
113+ fmt.Fprintf(b, "%s%s: %s\n", indent, st.Key, st.Value)
114+ }
115+}
116+
117+func titleOf(name string, u Usage) string {
118+ if name == "" {
119+ return u.Description
120+ }
121+ return strings.ToUpper(name[:1]) + name[1:] + " (" + u.Name + ")"
122+}
123+
124+// quote writes a YAML string that never needs thinking about: double-quoted.
125+func quote(s string) string { return strconv.Quote(s) }
126+
127+// wrap cuts a comment at word boundaries.
128+func wrap(text string, width int) []string {
129+ var out []string
130+ line := ""
131+ for _, w := range strings.Fields(text) {
132+ if line != "" && len(line)+1+len(w) > width {
133+ out = append(out, line)
134+ line = w
135+ continue
136+ }
137+ if line == "" {
138+ line = w
139+ } else {
140+ line += " " + w
141+ }
142+ }
143+ if line != "" {
144+ out = append(out, line)
145+ }
146+ return out
147+}
added internal/agentinit/wizard.go +299 -0
new file mode 100644
@@ -0,0 +1,299 @@
1+package agentinit
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strings"
13+ "time"
14+
15+ "mm/internal/config"
16+ "mm/internal/elicit"
17+ "mm/internal/engine"
18+)
19+
20+// providers are the servers mm speaks to, in the order they are offered.
21+var providers = []string{"llmman", "ollama", "dmr", "llamacpp", "openai"}
22+
23+// local are the providers probed for the first question's default: never
24+// `openai`, whose default address is the OpenAI API — mm init must not call
25+// a remote service nobody asked for.
26+var local = []string{"llmman", "ollama", "dmr", "llamacpp"}
27+
28+// Window asks the flow's questions in a full-screen window (internal/tui's
29+// RunInit): it returns false when the user quit without writing. main hands
30+// it over — internal/tui imports this package, not the other way round.
31+type Window func(ctx context.Context, f *Flow) (bool, error)
32+
33+// Options are what main gives Main besides the arguments.
34+type Options struct {
35+ Window Window // nil: the line mode only
36+ Terminal func() bool // whether stdin and stdout are terminals (the window needs both)
37+}
38+
39+// Main is `mm init` and `mm --init`: it asks what it needs — in a window
40+// when it runs in a terminal, line by line otherwise or with --line — or
41+// reads it from the flags, writes a commented agent.yaml, checks that mm
42+// loads it and, with --check, that the model answers. It returns the exit
43+// code.
44+//
45+// mm init
46+// mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \
47+// --usage docs --machine small --name rata -o agent.yaml --yes
48+func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int {
49+ var o Options
50+ if len(opts) > 0 {
51+ o = opts[0]
52+ }
53+ fs := flag.NewFlagSet("mm init", flag.ContinueOnError)
54+ fs.SetOutput(stdout)
55+ var c Choices
56+ var yolo, output string
57+ var check, yes, line bool
58+ fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", "))
59+ fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's)")
60+ fs.StringVar(&c.Model, "model", "", "the model, as the server names it")
61+ fs.StringVar(&c.Family, "family", "", "the model family (default: detected from the name)")
62+ fs.StringVar(&c.Usage, "usage", "", "what the agent is for (developer, translator, docs, json…)")
63+ fs.StringVar(&c.Machine, "machine", "", "small, medium or large")
64+ fs.StringVar(&c.Name, "name", "", "the agent's name")
65+ fs.StringVar(&yolo, "yolo", "", "yes: tools run without asking; no: every change waits for your answer (default: the usage's)")
66+ fs.StringVar(&output, "o", "", "the file to write (default agent.yaml; - for stdout); never overwritten")
67+ fs.StringVar(&output, "output", "", "same as -o")
68+ fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers")
69+ fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default")
70+ fs.BoolVar(&line, "line", false, "ask line by line even in a terminal (no window)")
71+ if err := fs.Parse(args); err != nil {
72+ if errors.Is(err, flag.ErrHelp) {
73+ return 0
74+ }
75+ return 2
76+ }
77+ presets, err := LoadPresets()
78+ if err != nil {
79+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
80+ return 1
81+ }
82+ if output != "" && output != "-" && exists(output) {
83+ fmt.Fprintf(stdout, "⚠️ %s already exists — nothing was written; choose another name with -o\n", output)
84+ return 1
85+ }
86+ if yolo != "" {
87+ on := yolo == "yes" || yolo == "true"
88+ c.Yolo = &on
89+ }
90+ ctx := context.Background()
91+ flow := NewFlow(ctx, presets, c, output)
92+ if err := flow.validateGiven(); err != nil {
93+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
94+ return 1
95+ }
96+
97+ switch {
98+ case yes:
99+ if err := flow.takeDefaults(); err != nil {
100+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
101+ return 1
102+ }
103+ case o.Window != nil && !line && o.Terminal != nil && o.Terminal():
104+ done, err := o.Window(ctx, flow)
105+ if err != nil {
106+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
107+ return 1
108+ }
109+ if !done {
110+ fmt.Fprintln(stdout, "nothing written")
111+ return 1
112+ }
113+ default:
114+ w := &lineWizard{in: bufio.NewReader(stdin), out: stdout}
115+ if err := w.run(flow); err != nil {
116+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
117+ return 1
118+ }
119+ }
120+ return finish(flow, stdout, check)
121+}
122+
123+// finish writes the file the flow describes, loads it, and checks it.
124+func finish(flow *Flow, stdout io.Writer, check bool) int {
125+ choices, facts, output := flow.Result()
126+ if choices.Model == "" {
127+ fmt.Fprintln(stdout, "⚠️ no model: nothing written")
128+ return 1
129+ }
130+ text := Render(Decide(flow.presets, choices, facts))
131+ if output == "-" {
132+ fmt.Fprint(stdout, text)
133+ return 0
134+ }
135+ if err := writeNew(output, text); err != nil {
136+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
137+ return 1
138+ }
139+ fmt.Fprintf(stdout, "✅ wrote %s\n", output)
140+ if _, err := config.Load(output); err != nil {
141+ fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err)
142+ return 1
143+ }
144+ fmt.Fprintln(stdout, "✅ mm loads it")
145+ if check {
146+ return checkFile(stdout, output)
147+ }
148+ fmt.Fprintf(stdout, "Next: mm -config %s (or mm -tui %s)\n", output, output)
149+ return 0
150+}
151+
152+// validateGiven refuses a flag value no question would accept.
153+func (f *Flow) validateGiven() error {
154+ for _, key := range stepKeys {
155+ if f.given[key] && key != "file" && key != "yolo" {
156+ if err := f.Set(key, f.answer[key]); err != nil {
157+ return err
158+ }
159+ }
160+ }
161+ return nil
162+}
163+
164+// takeDefaults answers every pending question with its default (--yes).
165+func (f *Flow) takeDefaults() error {
166+ for {
167+ pending := f.Pending()
168+ if len(pending) == 0 {
169+ return nil
170+ }
171+ st := pending[0]
172+ if st.Default == "" {
173+ return fmt.Errorf("%s: no default — give it with --%s", strings.ToLower(st.Title), st.Key)
174+ }
175+ if err := f.Set(st.Key, st.Default); err != nil {
176+ return err
177+ }
178+ }
179+}
180+
181+// lineWizard asks the questions one per line (internal/elicit's format).
182+type lineWizard struct {
183+ in *bufio.Reader
184+ out io.Writer
185+}
186+
187+func (w *lineWizard) run(f *Flow) error {
188+ for {
189+ pending := f.Pending()
190+ if len(pending) == 0 {
191+ return nil
192+ }
193+ st := pending[0]
194+ if st.Key == "model" && st.Kind == elicit.Text {
195+ if err := f.ModelsError(); err != nil {
196+ fmt.Fprintf(w.out, "ℹ️ %s does not answer on %s (%v)\n", f.c.Provider, f.Base(), err)
197+ }
198+ }
199+ answer := w.ask(st)
200+ if answer == "" {
201+ return fmt.Errorf("%s: no answer", strings.ToLower(st.Title))
202+ }
203+ if err := f.Set(st.Key, answer); err != nil {
204+ fmt.Fprintln(w.out, "⚠️ "+err.Error())
205+ }
206+ }
207+}
208+
209+// ask asks one step until the line answers it; the end of the input or
210+// /skip take the default.
211+func (w *lineWizard) ask(st Step) string {
212+ q := elicit.Question{Prompt: st.Prompt, Kind: st.Kind, Default: st.Default}
213+ for _, o := range st.Options {
214+ q.Options = append(q.Options, o.Value)
215+ }
216+ if st.Key == "usage" || st.Key == "machine" {
217+ for _, o := range st.Options {
218+ fmt.Fprintf(w.out, " %-12s %s\n", o.Label, o.Detail)
219+ }
220+ }
221+ for {
222+ lines := elicit.Lines(q)
223+ // Here /skip and an empty line take the default; nothing is declined.
224+ lines[len(lines)-1] = strings.Replace(lines[len(lines)-1], "/skip to decline", "Enter = the default", 1)
225+ for _, line := range lines {
226+ fmt.Fprintln(w.out, line)
227+ }
228+ fmt.Fprint(w.out, "> ")
229+ line, err := w.in.ReadString('\n')
230+ if elicit.IsSkip(line) || (err != nil && strings.TrimSpace(line) == "") {
231+ return q.Default
232+ }
233+ if a, ok := elicit.ParseLine(q, line); ok && len(a.Values) > 0 {
234+ return a.Values[0]
235+ }
236+ if err != nil {
237+ return q.Default
238+ }
239+ }
240+}
241+
242+// resolveBase is the server's address: the flag's, else the provider's
243+// default (or its fallback, when the default does not answer).
244+func resolveBase(c Choices) string {
245+ if c.BaseURL != "" {
246+ return c.BaseURL
247+ }
248+ p, err := engine.Lookup(c.Provider)
249+ if err != nil {
250+ return ""
251+ }
252+ b, err := p.Resolve(config.Config{Provider: c.Provider, Model: "probe"})
253+ if err != nil {
254+ return ""
255+ }
256+ return b.BaseURL
257+}
258+
259+func exists(path string) bool {
260+ _, err := os.Stat(path)
261+ return err == nil
262+}
263+
264+// writeNew writes a file that must not exist yet: an existing agent.yaml is
265+// someone's work.
266+func writeNew(path, text string) error {
267+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
268+ if errors.Is(err, os.ErrExist) {
269+ return fmt.Errorf("%s already exists — nothing was written; choose another name with -o", path)
270+ }
271+ if err != nil {
272+ return err
273+ }
274+ if _, err := f.WriteString(text); err != nil {
275+ f.Close()
276+ return err
277+ }
278+ return f.Close()
279+}
280+
281+// checkFile runs this same mm on the new file with one question.
282+func checkFile(stdout io.Writer, path string) int {
283+ self, err := os.Executable()
284+ if err != nil {
285+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
286+ return 1
287+ }
288+ fmt.Fprintln(stdout, "… asking the model one question (the first answer loads the model)")
289+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
290+ defer cancel()
291+ out, err := exec.CommandContext(ctx, self, "-config", path, "-p", "Say hello in one line.").CombinedOutput()
292+ fmt.Fprintln(stdout, strings.TrimSpace(string(out)))
293+ if err != nil {
294+ fmt.Fprintf(stdout, "⚠️ the check failed: %v\n", err)
295+ return 1
296+ }
297+ fmt.Fprintln(stdout, "✅ the model answers")
298+ return 0
299+}
new file mode 100644
@@ -0,0 +1,299 @@
1+package agentinit
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strings"
13+ "time"
14+
15+ "mm/internal/config"
16+ "mm/internal/elicit"
17+ "mm/internal/engine"
18+)
19+
20+// providers are the servers mm speaks to, in the order they are offered.
21+var providers = []string{"llmman", "ollama", "dmr", "llamacpp", "openai"}
22+
23+// local are the providers probed for the first question's default: never
24+// `openai`, whose default address is the OpenAI API — mm init must not call
25+// a remote service nobody asked for.
26+var local = []string{"llmman", "ollama", "dmr", "llamacpp"}
27+
28+// Window asks the flow's questions in a full-screen window (internal/tui's
29+// RunInit): it returns false when the user quit without writing. main hands
30+// it over — internal/tui imports this package, not the other way round.
31+type Window func(ctx context.Context, f *Flow) (bool, error)
32+
33+// Options are what main gives Main besides the arguments.
34+type Options struct {
35+ Window Window // nil: the line mode only
36+ Terminal func() bool // whether stdin and stdout are terminals (the window needs both)
37+}
38+
39+// Main is `mm init` and `mm --init`: it asks what it needs — in a window
40+// when it runs in a terminal, line by line otherwise or with --line — or
41+// reads it from the flags, writes a commented agent.yaml, checks that mm
42+// loads it and, with --check, that the model answers. It returns the exit
43+// code.
44+//
45+// mm init
46+// mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \
47+// --usage docs --machine small --name rata -o agent.yaml --yes
48+func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int {
49+ var o Options
50+ if len(opts) > 0 {
51+ o = opts[0]
52+ }
53+ fs := flag.NewFlagSet("mm init", flag.ContinueOnError)
54+ fs.SetOutput(stdout)
55+ var c Choices
56+ var yolo, output string
57+ var check, yes, line bool
58+ fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", "))
59+ fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's)")
60+ fs.StringVar(&c.Model, "model", "", "the model, as the server names it")
61+ fs.StringVar(&c.Family, "family", "", "the model family (default: detected from the name)")
62+ fs.StringVar(&c.Usage, "usage", "", "what the agent is for (developer, translator, docs, json…)")
63+ fs.StringVar(&c.Machine, "machine", "", "small, medium or large")
64+ fs.StringVar(&c.Name, "name", "", "the agent's name")
65+ fs.StringVar(&yolo, "yolo", "", "yes: tools run without asking; no: every change waits for your answer (default: the usage's)")
66+ fs.StringVar(&output, "o", "", "the file to write (default agent.yaml; - for stdout); never overwritten")
67+ fs.StringVar(&output, "output", "", "same as -o")
68+ fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers")
69+ fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default")
70+ fs.BoolVar(&line, "line", false, "ask line by line even in a terminal (no window)")
71+ if err := fs.Parse(args); err != nil {
72+ if errors.Is(err, flag.ErrHelp) {
73+ return 0
74+ }
75+ return 2
76+ }
77+ presets, err := LoadPresets()
78+ if err != nil {
79+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
80+ return 1
81+ }
82+ if output != "" && output != "-" && exists(output) {
83+ fmt.Fprintf(stdout, "⚠️ %s already exists — nothing was written; choose another name with -o\n", output)
84+ return 1
85+ }
86+ if yolo != "" {
87+ on := yolo == "yes" || yolo == "true"
88+ c.Yolo = &on
89+ }
90+ ctx := context.Background()
91+ flow := NewFlow(ctx, presets, c, output)
92+ if err := flow.validateGiven(); err != nil {
93+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
94+ return 1
95+ }
96+
97+ switch {
98+ case yes:
99+ if err := flow.takeDefaults(); err != nil {
100+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
101+ return 1
102+ }
103+ case o.Window != nil && !line && o.Terminal != nil && o.Terminal():
104+ done, err := o.Window(ctx, flow)
105+ if err != nil {
106+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
107+ return 1
108+ }
109+ if !done {
110+ fmt.Fprintln(stdout, "nothing written")
111+ return 1
112+ }
113+ default:
114+ w := &lineWizard{in: bufio.NewReader(stdin), out: stdout}
115+ if err := w.run(flow); err != nil {
116+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
117+ return 1
118+ }
119+ }
120+ return finish(flow, stdout, check)
121+}
122+
123+// finish writes the file the flow describes, loads it, and checks it.
124+func finish(flow *Flow, stdout io.Writer, check bool) int {
125+ choices, facts, output := flow.Result()
126+ if choices.Model == "" {
127+ fmt.Fprintln(stdout, "⚠️ no model: nothing written")
128+ return 1
129+ }
130+ text := Render(Decide(flow.presets, choices, facts))
131+ if output == "-" {
132+ fmt.Fprint(stdout, text)
133+ return 0
134+ }
135+ if err := writeNew(output, text); err != nil {
136+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
137+ return 1
138+ }
139+ fmt.Fprintf(stdout, "✅ wrote %s\n", output)
140+ if _, err := config.Load(output); err != nil {
141+ fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err)
142+ return 1
143+ }
144+ fmt.Fprintln(stdout, "✅ mm loads it")
145+ if check {
146+ return checkFile(stdout, output)
147+ }
148+ fmt.Fprintf(stdout, "Next: mm -config %s (or mm -tui %s)\n", output, output)
149+ return 0
150+}
151+
152+// validateGiven refuses a flag value no question would accept.
153+func (f *Flow) validateGiven() error {
154+ for _, key := range stepKeys {
155+ if f.given[key] && key != "file" && key != "yolo" {
156+ if err := f.Set(key, f.answer[key]); err != nil {
157+ return err
158+ }
159+ }
160+ }
161+ return nil
162+}
163+
164+// takeDefaults answers every pending question with its default (--yes).
165+func (f *Flow) takeDefaults() error {
166+ for {
167+ pending := f.Pending()
168+ if len(pending) == 0 {
169+ return nil
170+ }
171+ st := pending[0]
172+ if st.Default == "" {
173+ return fmt.Errorf("%s: no default — give it with --%s", strings.ToLower(st.Title), st.Key)
174+ }
175+ if err := f.Set(st.Key, st.Default); err != nil {
176+ return err
177+ }
178+ }
179+}
180+
181+// lineWizard asks the questions one per line (internal/elicit's format).
182+type lineWizard struct {
183+ in *bufio.Reader
184+ out io.Writer
185+}
186+
187+func (w *lineWizard) run(f *Flow) error {
188+ for {
189+ pending := f.Pending()
190+ if len(pending) == 0 {
191+ return nil
192+ }
193+ st := pending[0]
194+ if st.Key == "model" && st.Kind == elicit.Text {
195+ if err := f.ModelsError(); err != nil {
196+ fmt.Fprintf(w.out, "ℹ️ %s does not answer on %s (%v)\n", f.c.Provider, f.Base(), err)
197+ }
198+ }
199+ answer := w.ask(st)
200+ if answer == "" {
201+ return fmt.Errorf("%s: no answer", strings.ToLower(st.Title))
202+ }
203+ if err := f.Set(st.Key, answer); err != nil {
204+ fmt.Fprintln(w.out, "⚠️ "+err.Error())
205+ }
206+ }
207+}
208+
209+// ask asks one step until the line answers it; the end of the input or
210+// /skip take the default.
211+func (w *lineWizard) ask(st Step) string {
212+ q := elicit.Question{Prompt: st.Prompt, Kind: st.Kind, Default: st.Default}
213+ for _, o := range st.Options {
214+ q.Options = append(q.Options, o.Value)
215+ }
216+ if st.Key == "usage" || st.Key == "machine" {
217+ for _, o := range st.Options {
218+ fmt.Fprintf(w.out, " %-12s %s\n", o.Label, o.Detail)
219+ }
220+ }
221+ for {
222+ lines := elicit.Lines(q)
223+ // Here /skip and an empty line take the default; nothing is declined.
224+ lines[len(lines)-1] = strings.Replace(lines[len(lines)-1], "/skip to decline", "Enter = the default", 1)
225+ for _, line := range lines {
226+ fmt.Fprintln(w.out, line)
227+ }
228+ fmt.Fprint(w.out, "> ")
229+ line, err := w.in.ReadString('\n')
230+ if elicit.IsSkip(line) || (err != nil && strings.TrimSpace(line) == "") {
231+ return q.Default
232+ }
233+ if a, ok := elicit.ParseLine(q, line); ok && len(a.Values) > 0 {
234+ return a.Values[0]
235+ }
236+ if err != nil {
237+ return q.Default
238+ }
239+ }
240+}
241+
242+// resolveBase is the server's address: the flag's, else the provider's
243+// default (or its fallback, when the default does not answer).
244+func resolveBase(c Choices) string {
245+ if c.BaseURL != "" {
246+ return c.BaseURL
247+ }
248+ p, err := engine.Lookup(c.Provider)
249+ if err != nil {
250+ return ""
251+ }
252+ b, err := p.Resolve(config.Config{Provider: c.Provider, Model: "probe"})
253+ if err != nil {
254+ return ""
255+ }
256+ return b.BaseURL
257+}
258+
259+func exists(path string) bool {
260+ _, err := os.Stat(path)
261+ return err == nil
262+}
263+
264+// writeNew writes a file that must not exist yet: an existing agent.yaml is
265+// someone's work.
266+func writeNew(path, text string) error {
267+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
268+ if errors.Is(err, os.ErrExist) {
269+ return fmt.Errorf("%s already exists — nothing was written; choose another name with -o", path)
270+ }
271+ if err != nil {
272+ return err
273+ }
274+ if _, err := f.WriteString(text); err != nil {
275+ f.Close()
276+ return err
277+ }
278+ return f.Close()
279+}
280+
281+// checkFile runs this same mm on the new file with one question.
282+func checkFile(stdout io.Writer, path string) int {
283+ self, err := os.Executable()
284+ if err != nil {
285+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
286+ return 1
287+ }
288+ fmt.Fprintln(stdout, "… asking the model one question (the first answer loads the model)")
289+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
290+ defer cancel()
291+ out, err := exec.CommandContext(ctx, self, "-config", path, "-p", "Say hello in one line.").CombinedOutput()
292+ fmt.Fprintln(stdout, strings.TrimSpace(string(out)))
293+ if err != nil {
294+ fmt.Fprintf(stdout, "⚠️ the check failed: %v\n", err)
295+ return 1
296+ }
297+ fmt.Fprintln(stdout, "✅ the model answers")
298+ return 0
299+}
added internal/agentinit/wizard_test.go +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+package agentinit
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/config"
11+)
12+
13+func llmmanStub(t *testing.T) string {
14+ t.Helper()
15+ s := fakeServer(t, map[string]any{
16+ "GET /api/tags": map[string]any{"models": []any{map[string]any{"name": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}}},
17+ "POST /api/show": map[string]any{"model_info": map[string]any{"general.architecture": "qwen35", "qwen35.context_length": 32768}, "template": "{{ enable_thinking | default(false) }}"},
18+ })
19+ return s.URL + "/v1"
20+}
21+
22+func keepConfig(t *testing.T) {
23+ saved := config.Cfg
24+ t.Cleanup(func() { config.Cfg = saved })
25+}
26+
27+// Flags only, --yes: nothing is asked, the file is written, mm loads it.
28+func TestMainWithFlagsOnly(t *testing.T) {
29+ keepConfig(t)
30+ out := filepath.Join(t.TempDir(), "agent.yaml")
31+ var stdout bytes.Buffer
32+ code := Main([]string{"--provider", "llmman", "--base-url", llmmanStub(t), "--usage", "translator", "--machine", "small", "--name", "lucy", "-o", out, "--yes"}, strings.NewReader(""), &stdout)
33+ if code != 0 {
34+ t.Fatalf("exit %d:\n%s", code, stdout.String())
35+ }
36+ data, _ := os.ReadFile(out)
37+ text := string(data)
38+ for _, want := range []string{`model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"`, "name: lucy", "enable_thinking: false", "the template's default is off", "32768 tokens"} {
39+ if !strings.Contains(text, want) {
40+ t.Errorf("generated file lacks %q:\n%s", want, text)
41+ }
42+ }
43+ if !strings.Contains(stdout.String(), "✅ mm loads it") {
44+ t.Errorf("stdout:\n%s", stdout.String())
45+ }
46+}
47+
48+// Questions answered on stdin: the model from the server's list, the usage
49+// and machine by number, yolo, the name, the file.
50+func TestMainAsksTheQuestions(t *testing.T) {
51+ keepConfig(t)
52+ dir := t.TempDir()
53+ out := filepath.Join(dir, "dev.yaml")
54+ answers := strings.Join([]string{
55+ "1", // model: the server's only one
56+ "", // family: the detected one
57+ "1", // usage: developer
58+ "2", // machine: medium
59+ "n", // ask before each change? no → yolo on
60+ "bob", // name
61+ out, // file
62+ }, "\n") + "\n"
63+ var stdout bytes.Buffer
64+ code := Main([]string{"--provider", "llmman", "--base-url", llmmanStub(t)}, strings.NewReader(answers), &stdout)
65+ if code != 0 {
66+ t.Fatalf("exit %d:\n%s", code, stdout.String())
67+ }
68+ data, _ := os.ReadFile(out)
69+ for _, want := range []string{"yolo: true", "# your answer", "name: bob", "watchdogTimeout: 2m", "bashTool: true"} {
70+ if !strings.Contains(string(data), want) {
71+ t.Errorf("generated file lacks %q:\n%s", want, data)
72+ }
73+ }
74+ shown := stdout.String()
75+ if !strings.Contains(shown, "? Model family?") || !strings.Contains(shown, "qwen (default)") {
76+ t.Errorf("the family question did not show the detected default:\n%s", shown)
77+ }
78+ if !strings.Contains(shown, "translator translates texts") || !strings.Contains(shown, "Enter = the default") || strings.Contains(shown, "decline") {
79+ t.Errorf("legend or hint wrong:\n%s", shown)
80+ }
81+}
82+
83+// An existing file is never overwritten, and nothing is asked first.
84+func TestMainNeverOverwrites(t *testing.T) {
85+ keepConfig(t)
86+ out := filepath.Join(t.TempDir(), "agent.yaml")
87+ if err := os.WriteFile(out, []byte("mine"), 0o644); err != nil {
88+ t.Fatal(err)
89+ }
90+ var stdout bytes.Buffer
91+ if code := Main([]string{"-o", out}, strings.NewReader(""), &stdout); code != 1 || !strings.Contains(stdout.String(), "already exists") {
92+ t.Errorf("exit %d:\n%s", code, stdout.String())
93+ }
94+ if data, _ := os.ReadFile(out); string(data) != "mine" {
95+ t.Error("the existing file was changed")
96+ }
97+}
98+
99+func TestMainToStdoutAndBadAnswers(t *testing.T) {
100+ keepConfig(t)
101+ var stdout bytes.Buffer
102+ code := Main([]string{"--provider", "dmr", "--base-url", "http://127.0.0.1:1/engines/v1", "--model", "ai/x", "--usage", "assistant", "--machine", "large", "--name", "a", "-o", "-", "--yes"}, strings.NewReader(""), &stdout)
103+ if code != 0 || !strings.Contains(stdout.String(), "provider: dmr") || !strings.Contains(stdout.String(), "beyond its name") {
104+ t.Errorf("exit %d:\n%s", code, stdout.String())
105+ }
106+ stdout.Reset()
107+ if code := Main([]string{"--provider", "nope", "--yes"}, strings.NewReader(""), &stdout); code != 1 || !strings.Contains(stdout.String(), "unknown provider") {
108+ t.Errorf("bad provider: exit %d:\n%s", code, stdout.String())
109+ }
110+ stdout.Reset()
111+ if code := Main([]string{"--provider", "dmr", "--base-url", "http://127.0.0.1:1/v1", "--model", "x", "--usage", "nope", "--yes"}, strings.NewReader(""), &stdout); code != 1 || !strings.Contains(stdout.String(), "unknown usage") {
112+ t.Errorf("bad usage: exit %d:\n%s", code, stdout.String())
113+ }
114+}
new file mode 100644
@@ -0,0 +1,114 @@
1+package agentinit
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/config"
11+)
12+
13+func llmmanStub(t *testing.T) string {
14+ t.Helper()
15+ s := fakeServer(t, map[string]any{
16+ "GET /api/tags": map[string]any{"models": []any{map[string]any{"name": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}}},
17+ "POST /api/show": map[string]any{"model_info": map[string]any{"general.architecture": "qwen35", "qwen35.context_length": 32768}, "template": "{{ enable_thinking | default(false) }}"},
18+ })
19+ return s.URL + "/v1"
20+}
21+
22+func keepConfig(t *testing.T) {
23+ saved := config.Cfg
24+ t.Cleanup(func() { config.Cfg = saved })
25+}
26+
27+// Flags only, --yes: nothing is asked, the file is written, mm loads it.
28+func TestMainWithFlagsOnly(t *testing.T) {
29+ keepConfig(t)
30+ out := filepath.Join(t.TempDir(), "agent.yaml")
31+ var stdout bytes.Buffer
32+ code := Main([]string{"--provider", "llmman", "--base-url", llmmanStub(t), "--usage", "translator", "--machine", "small", "--name", "lucy", "-o", out, "--yes"}, strings.NewReader(""), &stdout)
33+ if code != 0 {
34+ t.Fatalf("exit %d:\n%s", code, stdout.String())
35+ }
36+ data, _ := os.ReadFile(out)
37+ text := string(data)
38+ for _, want := range []string{`model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"`, "name: lucy", "enable_thinking: false", "the template's default is off", "32768 tokens"} {
39+ if !strings.Contains(text, want) {
40+ t.Errorf("generated file lacks %q:\n%s", want, text)
41+ }
42+ }
43+ if !strings.Contains(stdout.String(), "✅ mm loads it") {
44+ t.Errorf("stdout:\n%s", stdout.String())
45+ }
46+}
47+
48+// Questions answered on stdin: the model from the server's list, the usage
49+// and machine by number, yolo, the name, the file.
50+func TestMainAsksTheQuestions(t *testing.T) {
51+ keepConfig(t)
52+ dir := t.TempDir()
53+ out := filepath.Join(dir, "dev.yaml")
54+ answers := strings.Join([]string{
55+ "1", // model: the server's only one
56+ "", // family: the detected one
57+ "1", // usage: developer
58+ "2", // machine: medium
59+ "n", // ask before each change? no → yolo on
60+ "bob", // name
61+ out, // file
62+ }, "\n") + "\n"
63+ var stdout bytes.Buffer
64+ code := Main([]string{"--provider", "llmman", "--base-url", llmmanStub(t)}, strings.NewReader(answers), &stdout)
65+ if code != 0 {
66+ t.Fatalf("exit %d:\n%s", code, stdout.String())
67+ }
68+ data, _ := os.ReadFile(out)
69+ for _, want := range []string{"yolo: true", "# your answer", "name: bob", "watchdogTimeout: 2m", "bashTool: true"} {
70+ if !strings.Contains(string(data), want) {
71+ t.Errorf("generated file lacks %q:\n%s", want, data)
72+ }
73+ }
74+ shown := stdout.String()
75+ if !strings.Contains(shown, "? Model family?") || !strings.Contains(shown, "qwen (default)") {
76+ t.Errorf("the family question did not show the detected default:\n%s", shown)
77+ }
78+ if !strings.Contains(shown, "translator translates texts") || !strings.Contains(shown, "Enter = the default") || strings.Contains(shown, "decline") {
79+ t.Errorf("legend or hint wrong:\n%s", shown)
80+ }
81+}
82+
83+// An existing file is never overwritten, and nothing is asked first.
84+func TestMainNeverOverwrites(t *testing.T) {
85+ keepConfig(t)
86+ out := filepath.Join(t.TempDir(), "agent.yaml")
87+ if err := os.WriteFile(out, []byte("mine"), 0o644); err != nil {
88+ t.Fatal(err)
89+ }
90+ var stdout bytes.Buffer
91+ if code := Main([]string{"-o", out}, strings.NewReader(""), &stdout); code != 1 || !strings.Contains(stdout.String(), "already exists") {
92+ t.Errorf("exit %d:\n%s", code, stdout.String())
93+ }
94+ if data, _ := os.ReadFile(out); string(data) != "mine" {
95+ t.Error("the existing file was changed")
96+ }
97+}
98+
99+func TestMainToStdoutAndBadAnswers(t *testing.T) {
100+ keepConfig(t)
101+ var stdout bytes.Buffer
102+ code := Main([]string{"--provider", "dmr", "--base-url", "http://127.0.0.1:1/engines/v1", "--model", "ai/x", "--usage", "assistant", "--machine", "large", "--name", "a", "-o", "-", "--yes"}, strings.NewReader(""), &stdout)
103+ if code != 0 || !strings.Contains(stdout.String(), "provider: dmr") || !strings.Contains(stdout.String(), "beyond its name") {
104+ t.Errorf("exit %d:\n%s", code, stdout.String())
105+ }
106+ stdout.Reset()
107+ if code := Main([]string{"--provider", "nope", "--yes"}, strings.NewReader(""), &stdout); code != 1 || !strings.Contains(stdout.String(), "unknown provider") {
108+ t.Errorf("bad provider: exit %d:\n%s", code, stdout.String())
109+ }
110+ stdout.Reset()
111+ if code := Main([]string{"--provider", "dmr", "--base-url", "http://127.0.0.1:1/v1", "--model", "x", "--usage", "nope", "--yes"}, strings.NewReader(""), &stdout); code != 1 || !strings.Contains(stdout.String(), "unknown usage") {
112+ t.Errorf("bad usage: exit %d:\n%s", code, stdout.String())
113+ }
114+}
added internal/engine/openai.go +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+)
9+
10+// The generic `openai` provider (ticket 118): any server that speaks the
11+// OpenAI chat-completions protocol and that mm has no dedicated entry for —
12+// the OpenAI API itself (the default address), vLLM, LM Studio, OpenRouter,
13+// a gateway. It assumes nothing beyond the protocol: no return_progress (a
14+// llama.cpp field a strict API may refuse), no known way to turn reasoning
15+// off for a summary, no unloading. The key is read from OPENAI_API_KEY (or
16+// the YAML's apiKeyEnv) and is not required: a local server usually checks
17+// none, and the API's own 401 says when one is missing.
18+func init() {
19+ register(&openaiCompat{
20+ name: "openai",
21+ baseURL: "https://api.openai.com/v1",
22+ apiKeyEnv: "OPENAI_API_KEY",
23+ dummyKey: "not-needed",
24+ startHint: "check baseUrl: the server's OpenAI-compatible address, usually ending in /v1 (default: the OpenAI API)",
25+ notFoundHint: "the server has no model %s — GET <baseUrl>/models lists the ones it has",
26+ probe: probeOpenAI,
27+ })
28+}
29+
30+// probeOpenAI asks GET /models WITH the key: a remote API answers 401 to an
31+// anonymous request, which the plain reachability check would report as
32+// "nothing answers". 401 / 403 = the server is there and refused the key;
33+// 200 = there, and the model is looked for in its list.
34+func probeOpenAI(ctx context.Context, b Backend) Info {
35+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.BaseURL+"/models", nil)
36+ if err != nil {
37+ return Info{}
38+ }
39+ req.Header.Set("Authorization", "Bearer "+b.APIKey)
40+ resp, err := probeClient.Do(req)
41+ if err != nil {
42+ return Info{}
43+ }
44+ defer func() { _ = resp.Body.Close() }()
45+ switch resp.StatusCode {
46+ case http.StatusUnauthorized, http.StatusForbidden:
47+ return Info{Reachable: true, Warnings: []string{fmt.Sprintf("%s: %s refused the key (HTTP %d) — export %s", b.Provider, b.BaseURL, resp.StatusCode, keyVariable(b))}}
48+ case http.StatusOK:
49+ default:
50+ return Info{}
51+ }
52+ var list struct {
53+ Data []struct {
54+ ID string `json:"id"`
55+ } `json:"data"`
56+ }
57+ info := Info{Reachable: true}
58+ if json.NewDecoder(resp.Body).Decode(&list) != nil || len(list.Data) == 0 {
59+ return info
60+ }
61+ for _, m := range list.Data {
62+ if m.ID == b.Model {
63+ return info
64+ }
65+ }
66+ info.Warnings = append(info.Warnings, fmt.Sprintf("%s: model %q is not in the server's list (GET %s/models)", b.Provider, b.Model, b.BaseURL))
67+ return info
68+}
69+
70+// keyVariable names the variable the key is read from, for a message.
71+func keyVariable(b Backend) string {
72+ if b.APIKeyEnv != "" {
73+ return b.APIKeyEnv
74+ }
75+ return "OPENAI_API_KEY"
76+}
new file mode 100644
@@ -0,0 +1,76 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+)
9+
10+// The generic `openai` provider (ticket 118): any server that speaks the
11+// OpenAI chat-completions protocol and that mm has no dedicated entry for —
12+// the OpenAI API itself (the default address), vLLM, LM Studio, OpenRouter,
13+// a gateway. It assumes nothing beyond the protocol: no return_progress (a
14+// llama.cpp field a strict API may refuse), no known way to turn reasoning
15+// off for a summary, no unloading. The key is read from OPENAI_API_KEY (or
16+// the YAML's apiKeyEnv) and is not required: a local server usually checks
17+// none, and the API's own 401 says when one is missing.
18+func init() {
19+ register(&openaiCompat{
20+ name: "openai",
21+ baseURL: "https://api.openai.com/v1",
22+ apiKeyEnv: "OPENAI_API_KEY",
23+ dummyKey: "not-needed",
24+ startHint: "check baseUrl: the server's OpenAI-compatible address, usually ending in /v1 (default: the OpenAI API)",
25+ notFoundHint: "the server has no model %s — GET <baseUrl>/models lists the ones it has",
26+ probe: probeOpenAI,
27+ })
28+}
29+
30+// probeOpenAI asks GET /models WITH the key: a remote API answers 401 to an
31+// anonymous request, which the plain reachability check would report as
32+// "nothing answers". 401 / 403 = the server is there and refused the key;
33+// 200 = there, and the model is looked for in its list.
34+func probeOpenAI(ctx context.Context, b Backend) Info {
35+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.BaseURL+"/models", nil)
36+ if err != nil {
37+ return Info{}
38+ }
39+ req.Header.Set("Authorization", "Bearer "+b.APIKey)
40+ resp, err := probeClient.Do(req)
41+ if err != nil {
42+ return Info{}
43+ }
44+ defer func() { _ = resp.Body.Close() }()
45+ switch resp.StatusCode {
46+ case http.StatusUnauthorized, http.StatusForbidden:
47+ return Info{Reachable: true, Warnings: []string{fmt.Sprintf("%s: %s refused the key (HTTP %d) — export %s", b.Provider, b.BaseURL, resp.StatusCode, keyVariable(b))}}
48+ case http.StatusOK:
49+ default:
50+ return Info{}
51+ }
52+ var list struct {
53+ Data []struct {
54+ ID string `json:"id"`
55+ } `json:"data"`
56+ }
57+ info := Info{Reachable: true}
58+ if json.NewDecoder(resp.Body).Decode(&list) != nil || len(list.Data) == 0 {
59+ return info
60+ }
61+ for _, m := range list.Data {
62+ if m.ID == b.Model {
63+ return info
64+ }
65+ }
66+ info.Warnings = append(info.Warnings, fmt.Sprintf("%s: model %q is not in the server's list (GET %s/models)", b.Provider, b.Model, b.BaseURL))
67+ return info
68+}
69+
70+// keyVariable names the variable the key is read from, for a message.
71+func keyVariable(b Backend) string {
72+ if b.APIKeyEnv != "" {
73+ return b.APIKeyEnv
74+ }
75+ return "OPENAI_API_KEY"
76+}
added internal/engine/openai_test.go +85 -0
new file mode 100644
@@ -0,0 +1,85 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+
12+ "mm/internal/config"
13+)
14+
15+// The generic provider assumes nothing beyond the protocol: no llama.cpp
16+// return_progress, no summary reasoning switch, no unloading, no required
17+// key; the OpenAI API by default, OPENAI_API_KEY for the key.
18+func TestOpenAIProviderEntry(t *testing.T) {
19+ clearEnv(t)
20+ p, err := Lookup("openai")
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ o := p.(*openaiCompat)
25+ if o.progress || o.thinkingOff != 0 || o.unload != nil || o.keyRequired || o.fallback != "" {
26+ t.Errorf("openai entry assumes a server feature: %+v", o)
27+ }
28+ b, err := p.Resolve(config.Config{Provider: "openai", Model: "gpt-x"})
29+ if err != nil || b.BaseURL != "https://api.openai.com/v1" || b.APIKey != "not-needed" || b.APIKeyEnv != "OPENAI_API_KEY" {
30+ t.Errorf("resolve, no key: %+v %v", b, err)
31+ }
32+ t.Setenv("OPENAI_API_KEY", "sk-test")
33+ if b, _ := p.Resolve(config.Config{Provider: "openai", Model: "m", BaseURL: "http://127.0.0.1:1/v1"}); b.APIKey != "sk-test" || b.BaseURL != "http://127.0.0.1:1/v1" {
34+ t.Errorf("resolve, key and baseUrl: %+v", b)
35+ }
36+}
37+
38+// What the provider sends: stream_options for the usage, never
39+// return_progress — a strict API refuses unknown fields.
40+func TestOpenAIProviderSendsOnlyStandardFields(t *testing.T) {
41+ o, _ := Lookup("openai")
42+ req, _ := http.NewRequest(http.MethodPost, "http://x/v1/chat/completions", strings.NewReader(`{"stream":true}`))
43+ var seen string
44+ _, _ = streamOptions(o.(*openaiCompat).progress, o.(*openaiCompat).thinkingOff)(req, func(r *http.Request) (*http.Response, error) {
45+ b, _ := io.ReadAll(r.Body)
46+ seen = string(b)
47+ return &http.Response{StatusCode: 200, Body: http.NoBody}, nil
48+ })
49+ if strings.Contains(seen, "return_progress") || !strings.Contains(seen, `"include_usage":true`) {
50+ t.Errorf("sent: %s", seen)
51+ }
52+}
53+
54+// The probe sends the key: 401 is "there, key refused", not "down"; 200
55+// looks the model up in the list.
56+func TestProbeOpenAI(t *testing.T) {
57+ var gotAuth string
58+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+ gotAuth = r.Header.Get("Authorization")
60+ if gotAuth != "Bearer sk-good" {
61+ w.WriteHeader(http.StatusUnauthorized)
62+ return
63+ }
64+ _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "gpt-x"}}})
65+ }))
66+ t.Cleanup(srv.Close)
67+ ctx := context.Background()
68+ b := Backend{Provider: "openai", BaseURL: srv.URL + "/v1", Model: "gpt-x", APIKey: "sk-good", APIKeyEnv: "OPENAI_API_KEY"}
69+
70+ if info := probeOpenAI(ctx, b); !info.Reachable || len(info.Warnings) != 0 || gotAuth != "Bearer sk-good" {
71+ t.Errorf("good key: %+v (auth %q)", info, gotAuth)
72+ }
73+ b.Model = "other"
74+ if info := probeOpenAI(ctx, b); len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], `model "other" is not in the server's list`) {
75+ t.Errorf("unknown model: %+v", info)
76+ }
77+ b.APIKey = "not-needed"
78+ if info := probeOpenAI(ctx, b); !info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "refused the key (HTTP 401) — export OPENAI_API_KEY") {
79+ t.Errorf("refused key: %+v", info)
80+ }
81+ b.BaseURL = "http://127.0.0.1:1/v1"
82+ if info := probeOpenAI(ctx, b); info.Reachable {
83+ t.Errorf("nothing listening: %+v", info)
84+ }
85+}
new file mode 100644
@@ -0,0 +1,85 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+
12+ "mm/internal/config"
13+)
14+
15+// The generic provider assumes nothing beyond the protocol: no llama.cpp
16+// return_progress, no summary reasoning switch, no unloading, no required
17+// key; the OpenAI API by default, OPENAI_API_KEY for the key.
18+func TestOpenAIProviderEntry(t *testing.T) {
19+ clearEnv(t)
20+ p, err := Lookup("openai")
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ o := p.(*openaiCompat)
25+ if o.progress || o.thinkingOff != 0 || o.unload != nil || o.keyRequired || o.fallback != "" {
26+ t.Errorf("openai entry assumes a server feature: %+v", o)
27+ }
28+ b, err := p.Resolve(config.Config{Provider: "openai", Model: "gpt-x"})
29+ if err != nil || b.BaseURL != "https://api.openai.com/v1" || b.APIKey != "not-needed" || b.APIKeyEnv != "OPENAI_API_KEY" {
30+ t.Errorf("resolve, no key: %+v %v", b, err)
31+ }
32+ t.Setenv("OPENAI_API_KEY", "sk-test")
33+ if b, _ := p.Resolve(config.Config{Provider: "openai", Model: "m", BaseURL: "http://127.0.0.1:1/v1"}); b.APIKey != "sk-test" || b.BaseURL != "http://127.0.0.1:1/v1" {
34+ t.Errorf("resolve, key and baseUrl: %+v", b)
35+ }
36+}
37+
38+// What the provider sends: stream_options for the usage, never
39+// return_progress — a strict API refuses unknown fields.
40+func TestOpenAIProviderSendsOnlyStandardFields(t *testing.T) {
41+ o, _ := Lookup("openai")
42+ req, _ := http.NewRequest(http.MethodPost, "http://x/v1/chat/completions", strings.NewReader(`{"stream":true}`))
43+ var seen string
44+ _, _ = streamOptions(o.(*openaiCompat).progress, o.(*openaiCompat).thinkingOff)(req, func(r *http.Request) (*http.Response, error) {
45+ b, _ := io.ReadAll(r.Body)
46+ seen = string(b)
47+ return &http.Response{StatusCode: 200, Body: http.NoBody}, nil
48+ })
49+ if strings.Contains(seen, "return_progress") || !strings.Contains(seen, `"include_usage":true`) {
50+ t.Errorf("sent: %s", seen)
51+ }
52+}
53+
54+// The probe sends the key: 401 is "there, key refused", not "down"; 200
55+// looks the model up in the list.
56+func TestProbeOpenAI(t *testing.T) {
57+ var gotAuth string
58+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+ gotAuth = r.Header.Get("Authorization")
60+ if gotAuth != "Bearer sk-good" {
61+ w.WriteHeader(http.StatusUnauthorized)
62+ return
63+ }
64+ _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "gpt-x"}}})
65+ }))
66+ t.Cleanup(srv.Close)
67+ ctx := context.Background()
68+ b := Backend{Provider: "openai", BaseURL: srv.URL + "/v1", Model: "gpt-x", APIKey: "sk-good", APIKeyEnv: "OPENAI_API_KEY"}
69+
70+ if info := probeOpenAI(ctx, b); !info.Reachable || len(info.Warnings) != 0 || gotAuth != "Bearer sk-good" {
71+ t.Errorf("good key: %+v (auth %q)", info, gotAuth)
72+ }
73+ b.Model = "other"
74+ if info := probeOpenAI(ctx, b); len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], `model "other" is not in the server's list`) {
75+ t.Errorf("unknown model: %+v", info)
76+ }
77+ b.APIKey = "not-needed"
78+ if info := probeOpenAI(ctx, b); !info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "refused the key (HTTP 401) — export OPENAI_API_KEY") {
79+ t.Errorf("refused key: %+v", info)
80+ }
81+ b.BaseURL = "http://127.0.0.1:1/v1"
82+ if info := probeOpenAI(ctx, b); info.Reachable {
83+ t.Errorf("nothing listening: %+v", info)
84+ }
85+}
modified internal/engine/provider_test.go +2 -2
@@ -75,14 +75,14 @@ func ptr(s string) *string { return &s }
7575 // where DMR_BASE_URL is often exported.
7676 func clearEnv(t *testing.T) {
7777 t.Helper()
78- for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "LLMMAN_API_KEY", "OLLAMA_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} {
78+ for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "LLMMAN_API_KEY", "OLLAMA_API_KEY", "OPENAI_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} {
7979 t.Setenv(k, "")
8080 }
8181 }
8282
8383 func TestLookupListsKnownProviders(t *testing.T) {
8484 _, err := Lookup("vllm")
85- if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp, llmman, ollama") {
85+ if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp, llmman, ollama, openai") {
8686 t.Fatalf("Lookup(vllm) = %v, want an error naming the known providers", err)
8787 }
8888 }
@@ -75,14 +75,14 @@ func ptr(s string) *string { return &s }
75 // where DMR_BASE_URL is often exported.75 // where DMR_BASE_URL is often exported.
76 func clearEnv(t *testing.T) {76 func clearEnv(t *testing.T) {
77 t.Helper()77 t.Helper()
78- for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "LLMMAN_API_KEY", "OLLAMA_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} {78+ for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "LLMMAN_API_KEY", "OLLAMA_API_KEY", "OPENAI_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} {
79 t.Setenv(k, "")79 t.Setenv(k, "")
80 }80 }
81 }81 }
82 82
83 func TestLookupListsKnownProviders(t *testing.T) {83 func TestLookupListsKnownProviders(t *testing.T) {
84 _, err := Lookup("vllm")84 _, err := Lookup("vllm")
85- if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp, llmman, ollama") {85+ if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp, llmman, ollama, openai") {
86 t.Fatalf("Lookup(vllm) = %v, want an error naming the known providers", err)86 t.Fatalf("Lookup(vllm) = %v, want an error naming the known providers", err)
87 }87 }
88 }88 }
added internal/tui/initwin.go +384 -0
new file mode 100644
@@ -0,0 +1,384 @@
1+package tui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "mm/internal/agentinit"
9+ "mm/internal/elicit"
10+
11+ "github.com/gdamore/tcell/v2"
12+)
13+
14+// RunInit is `mm init` in a window (ticket 97): the steps on the left, the
15+// question in the middle with what the highlighted choice means, and on the
16+// right the agent.yaml as it would be written — updated as the selection
17+// moves. It returns true when every question is answered and the user asked
18+// to write the file; false when they quit. agentinit writes the file.
19+//
20+// agentinit.Main(args, os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal})
21+func RunInit(_ context.Context, flow *agentinit.Flow) (bool, error) {
22+ screen, err := tcell.NewScreen()
23+ if err != nil {
24+ return false, err
25+ }
26+ if err := screen.Init(); err != nil {
27+ return false, err
28+ }
29+ defer screen.Fini()
30+ return runInit(screen, flow), nil
31+}
32+
33+// initWin is the wizard's window.
34+type initWin struct {
35+ screen tcell.Screen
36+ flow *agentinit.Flow
37+ cursor int // the step being answered, in flow.Steps()
38+ sel map[string]int // the highlighted option, per step
39+ text map[string][]rune
40+ scroll int // preview lines hidden above
41+ preview bool // narrow screens: the preview replaces the question (Tab)
42+ confirm bool // every question answered: "write the file?"
43+ problem string
44+ result int // 0 running, 1 write, -1 quit
45+}
46+
47+// previewMinWidth is the width from which the preview sits beside the
48+// question; below, Tab switches between the two.
49+const previewMinWidth = 100
50+
51+func runInit(screen tcell.Screen, flow *agentinit.Flow) bool {
52+ w := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string][]rune{}}
53+ w.toFirstPending()
54+ for w.result == 0 {
55+ w.draw()
56+ ev := screen.PollEvent()
57+ if ev == nil {
58+ return false
59+ }
60+ switch e := ev.(type) {
61+ case *tcell.EventKey:
62+ w.handleKey(e)
63+ case *tcell.EventResize:
64+ screen.Sync()
65+ }
66+ }
67+ return w.result == 1
68+}
69+
70+// steps is the flow's steps as they stand now (a choice may add or remove
71+// one: the yolo question exists only for usages with tools).
72+func (w *initWin) steps() []agentinit.Step { return w.flow.Steps() }
73+
74+func (w *initWin) current() agentinit.Step {
75+ steps := w.steps()
76+ w.cursor = min(max(w.cursor, 0), len(steps)-1)
77+ return steps[w.cursor]
78+}
79+
80+// toFirstPending puts the cursor on the first unanswered question, or opens
81+// the confirmation when there is none.
82+func (w *initWin) toFirstPending() {
83+ for i, st := range w.steps() {
84+ if st.Value == "" {
85+ w.cursor, w.confirm = i, false
86+ w.initSelection(st)
87+ return
88+ }
89+ }
90+ w.confirm = true
91+}
92+
93+// initSelection highlights the step's answer, else its default.
94+func (w *initWin) initSelection(st agentinit.Step) {
95+ want := st.Value
96+ if want == "" {
97+ want = st.Default
98+ }
99+ w.sel[st.Key] = 0
100+ for i, o := range st.Options {
101+ if o.Value == want {
102+ w.sel[st.Key] = i
103+ }
104+ }
105+ if st.Kind == elicit.Text {
106+ if _, ok := w.text[st.Key]; !ok {
107+ w.text[st.Key] = []rune(want)
108+ }
109+ }
110+}
111+
112+// candidate is what the step would be answered with now.
113+func (w *initWin) candidate(st agentinit.Step) string {
114+ if st.Kind == elicit.Text {
115+ return strings.TrimSpace(string(w.text[st.Key]))
116+ }
117+ if len(st.Options) == 0 {
118+ return ""
119+ }
120+ return st.Options[min(w.sel[st.Key], len(st.Options)-1)].Value
121+}
122+
123+func (w *initWin) handleKey(ev *tcell.EventKey) {
124+ w.problem = ""
125+ switch ev.Key() {
126+ case tcell.KeyCtrlC:
127+ w.result = -1
128+ return
129+ case tcell.KeyTab:
130+ w.preview = !w.preview
131+ return
132+ case tcell.KeyPgUp:
133+ w.scroll = max(w.scroll-10, 0)
134+ return
135+ case tcell.KeyPgDn:
136+ w.scroll += 10
137+ return
138+ case tcell.KeyEscape:
139+ w.back()
140+ return
141+ }
142+ if w.confirm {
143+ if ev.Key() == tcell.KeyEnter {
144+ w.result = 1
145+ }
146+ return
147+ }
148+ st := w.current()
149+ if st.Kind == elicit.Text {
150+ w.textKey(st, ev)
151+ return
152+ }
153+ switch ev.Key() {
154+ case tcell.KeyUp:
155+ w.sel[st.Key] = (w.sel[st.Key] + len(st.Options) - 1) % len(st.Options)
156+ case tcell.KeyDown:
157+ w.sel[st.Key] = (w.sel[st.Key] + 1) % len(st.Options)
158+ case tcell.KeyHome:
159+ w.sel[st.Key] = 0
160+ case tcell.KeyEnd:
161+ w.sel[st.Key] = len(st.Options) - 1
162+ case tcell.KeyEnter:
163+ w.answer(st)
164+ case tcell.KeyRune:
165+ if st.Kind == elicit.Confirm && (ev.Rune() == 'y' || ev.Rune() == 'n') {
166+ w.sel[st.Key] = map[rune]int{'y': 0, 'n': 1}[ev.Rune()]
167+ w.answer(st)
168+ }
169+ }
170+}
171+
172+func (w *initWin) textKey(st agentinit.Step, ev *tcell.EventKey) {
173+ t := w.text[st.Key]
174+ switch ev.Key() {
175+ case tcell.KeyRune:
176+ w.text[st.Key] = append(t, ev.Rune())
177+ case tcell.KeyBackspace, tcell.KeyBackspace2:
178+ if len(t) > 0 {
179+ w.text[st.Key] = t[:len(t)-1]
180+ }
181+ case tcell.KeyCtrlU:
182+ w.text[st.Key] = nil
183+ case tcell.KeyEnter:
184+ w.answer(st)
185+ }
186+}
187+
188+// answer records the highlighted choice and moves to the next question.
189+func (w *initWin) answer(st agentinit.Step) {
190+ value := w.candidate(st)
191+ if value == "" {
192+ value = st.Default
193+ }
194+ if err := w.flow.Set(st.Key, value); err != nil {
195+ w.problem = err.Error()
196+ return
197+ }
198+ w.scroll = 0
199+ w.toFirstPending()
200+}
201+
202+// back is Esc: the previous question the user answered becomes the current
203+// one again — its answer taken back; questions answered by flags are
204+// skipped.
205+func (w *initWin) back() {
206+ steps := w.steps()
207+ from := w.cursor
208+ if w.confirm {
209+ from = len(steps)
210+ }
211+ for i := from - 1; i >= 0; i-- {
212+ if !steps[i].Given {
213+ w.flow.Unset(steps[i].Key)
214+ w.cursor, w.confirm = i, false
215+ w.initSelection(w.flow.Step(steps[i].Key))
216+ return
217+ }
218+ }
219+}
220+
221+// --- drawing ----------------------------------------------------------------
222+
223+func (w *initWin) draw() {
224+ width, height := w.screen.Size()
225+ if width < 1 || height < 4 {
226+ return
227+ }
228+ w.screen.Clear()
229+ p := painter{screen: w.screen, area: rect{w: width, h: height}}
230+ w.drawHeader(p.sub(rect{x: 0, y: 0, w: width, h: 1}))
231+ p.sub(rect{x: 0, y: 1, w: width, h: 1}).hline(0, 0, width, '─', stRule)
232+ body := rect{x: 0, y: 2, w: width, h: height - 4}
233+ w.drawBody(p.sub(body))
234+ rule := p.sub(rect{x: 0, y: height - 2, w: width, h: 1})
235+ rule.hline(0, 0, width, '─', stRule)
236+ if w.problem != "" {
237+ rule.textLimited(1, 0, width-2, " ⚠️ "+w.problem+" ", stError)
238+ }
239+ w.drawStatus(p.sub(rect{x: 0, y: height - 1, w: width, h: 1}))
240+ w.screen.Show()
241+}
242+
243+func (w *initWin) drawHeader(p painter) {
244+ p.fill(' ', stBar)
245+ title := " mm init — new agent"
246+ c, _, _ := w.flow.Result()
247+ if c.Provider != "" {
248+ title += " · " + c.Provider
249+ }
250+ if c.Model != "" {
251+ title += " · " + shortModel(c.Model)
252+ }
253+ p.textLimited(0, 0, p.width(), title, stBar)
254+}
255+
256+func (w *initWin) drawStatus(p painter) {
257+ p.fill(' ', stBar)
258+ keys := " ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit "
259+ switch {
260+ case w.confirm:
261+ keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit "
262+ case w.current().Kind == elicit.Text:
263+ keys = " type · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit "
264+ }
265+ if p.width() < previewMinWidth {
266+ keys = strings.Replace(keys, " · Ctrl-C", " · Tab preview · Ctrl-C", 1)
267+ }
268+ p.textLimited(0, 0, p.width(), keys, stBar)
269+}
270+
271+// drawBody lays out the three panes: steps | question | preview.
272+func (w *initWin) drawBody(p painter) {
273+ stepsW := 22
274+ w.drawSteps(p.sub(rect{x: 0, y: 0, w: stepsW, h: p.height()}))
275+ for y := range p.height() {
276+ p.set(stepsW, y, '│', stRule)
277+ }
278+ rest := rect{x: stepsW + 1, y: 0, w: p.width() - stepsW - 1, h: p.height()}
279+ if p.width() < previewMinWidth {
280+ if w.preview {
281+ w.drawPreview(p.sub(rest))
282+ } else {
283+ w.drawQuestion(p.sub(rest))
284+ }
285+ return
286+ }
287+ qw := rest.w * 2 / 5
288+ w.drawQuestion(p.sub(rect{x: rest.x, y: 0, w: qw, h: rest.h}))
289+ for y := range p.height() {
290+ p.set(rest.x+qw, y, '│', stRule)
291+ }
292+ w.drawPreview(p.sub(rect{x: rest.x + qw + 1, y: 0, w: rest.w - qw - 1, h: rest.h}))
293+}
294+
295+func (w *initWin) drawSteps(p painter) {
296+ for i, st := range w.steps() {
297+ mark, style := "·", stNotice
298+ switch {
299+ case !w.confirm && i == w.cursor:
300+ mark, style = "▸", stPrompt
301+ case st.Value != "":
302+ mark, style = "✓", stBody
303+ }
304+ line := fmt.Sprintf(" %s %-9s", mark, st.Title)
305+ x := p.text(0, i, line, style)
306+ if st.Value != "" && (w.confirm || i != w.cursor) {
307+ p.textLimited(x+1, i, p.width()-x-1, shortModel(st.Value), stNotice)
308+ }
309+ }
310+}
311+
312+func (w *initWin) drawQuestion(p painter) {
313+ if w.confirm {
314+ _, _, output := w.flow.Result()
315+ p.textLimited(1, 0, p.width()-2, "Ready to write "+output, stLabel)
316+ p.textLimited(1, 2, p.width()-2, "Every question is answered: the file", stBody)
317+ p.textLimited(1, 3, p.width()-2, "on the right is what will be written.", stBody)
318+ p.textLimited(1, 5, p.width()-2, "Enter writes it; Esc goes back.", stNotice)
319+ return
320+ }
321+ st := w.current()
322+ p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel)
323+ if st.Kind == elicit.Text {
324+ t := string(w.text[st.Key])
325+ x := p.text(1, 2, "› "+t, stBody)
326+ p.showCursor(x, 2)
327+ if st.Default != "" {
328+ p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice)
329+ }
330+ return
331+ }
332+ p.showCursor(-1, -1)
333+ listH := max(p.height()-6, 3)
334+ sel := w.sel[st.Key]
335+ top := min(max(sel-listH/2, 0), max(len(st.Options)-listH, 0))
336+ for row := 0; row < listH && top+row < len(st.Options); row++ {
337+ o := st.Options[top+row]
338+ label := " " + o.Label
339+ if o.Value == st.Default {
340+ label += " (default)"
341+ }
342+ style := stBody
343+ if top+row == sel {
344+ style = stPick
345+ label = "▸" + label[1:]
346+ }
347+ p.textLimited(1, 2+row, p.width()-2, padTo(label, p.width()-2), style)
348+ }
349+ if d := st.Options[sel].Detail; d != "" {
350+ y := 3 + min(listH, len(st.Options))
351+ for i, line := range wrap(d, p.width()-2) {
352+ if y+i < p.height() {
353+ p.textLimited(1, y+i, p.width()-2, line, stNotice)
354+ }
355+ }
356+ }
357+}
358+
359+func padTo(s string, width int) string {
360+ if n := len([]rune(s)); n < width {
361+ return s + strings.Repeat(" ", width-n)
362+ }
363+ return s
364+}
365+
366+// drawPreview shows the file as it would be written with the highlighted
367+// choice; comments dim, keys plain.
368+func (w *initWin) drawPreview(p painter) {
369+ key, candidate := "", ""
370+ if !w.confirm {
371+ st := w.current()
372+ key, candidate = st.Key, w.candidate(st)
373+ }
374+ lines := strings.Split(w.flow.Preview(key, candidate), "\n")
375+ w.scroll = min(w.scroll, max(len(lines)-p.height(), 0))
376+ for y := 0; y < p.height() && w.scroll+y < len(lines); y++ {
377+ line := lines[w.scroll+y]
378+ style := stBody
379+ if strings.HasPrefix(strings.TrimSpace(line), "#") {
380+ style = stNotice
381+ }
382+ p.textLimited(1, y, p.width()-1, line, style)
383+ }
384+}
new file mode 100644
@@ -0,0 +1,384 @@
1+package tui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "mm/internal/agentinit"
9+ "mm/internal/elicit"
10+
11+ "github.com/gdamore/tcell/v2"
12+)
13+
14+// RunInit is `mm init` in a window (ticket 97): the steps on the left, the
15+// question in the middle with what the highlighted choice means, and on the
16+// right the agent.yaml as it would be written — updated as the selection
17+// moves. It returns true when every question is answered and the user asked
18+// to write the file; false when they quit. agentinit writes the file.
19+//
20+// agentinit.Main(args, os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal})
21+func RunInit(_ context.Context, flow *agentinit.Flow) (bool, error) {
22+ screen, err := tcell.NewScreen()
23+ if err != nil {
24+ return false, err
25+ }
26+ if err := screen.Init(); err != nil {
27+ return false, err
28+ }
29+ defer screen.Fini()
30+ return runInit(screen, flow), nil
31+}
32+
33+// initWin is the wizard's window.
34+type initWin struct {
35+ screen tcell.Screen
36+ flow *agentinit.Flow
37+ cursor int // the step being answered, in flow.Steps()
38+ sel map[string]int // the highlighted option, per step
39+ text map[string][]rune
40+ scroll int // preview lines hidden above
41+ preview bool // narrow screens: the preview replaces the question (Tab)
42+ confirm bool // every question answered: "write the file?"
43+ problem string
44+ result int // 0 running, 1 write, -1 quit
45+}
46+
47+// previewMinWidth is the width from which the preview sits beside the
48+// question; below, Tab switches between the two.
49+const previewMinWidth = 100
50+
51+func runInit(screen tcell.Screen, flow *agentinit.Flow) bool {
52+ w := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string][]rune{}}
53+ w.toFirstPending()
54+ for w.result == 0 {
55+ w.draw()
56+ ev := screen.PollEvent()
57+ if ev == nil {
58+ return false
59+ }
60+ switch e := ev.(type) {
61+ case *tcell.EventKey:
62+ w.handleKey(e)
63+ case *tcell.EventResize:
64+ screen.Sync()
65+ }
66+ }
67+ return w.result == 1
68+}
69+
70+// steps is the flow's steps as they stand now (a choice may add or remove
71+// one: the yolo question exists only for usages with tools).
72+func (w *initWin) steps() []agentinit.Step { return w.flow.Steps() }
73+
74+func (w *initWin) current() agentinit.Step {
75+ steps := w.steps()
76+ w.cursor = min(max(w.cursor, 0), len(steps)-1)
77+ return steps[w.cursor]
78+}
79+
80+// toFirstPending puts the cursor on the first unanswered question, or opens
81+// the confirmation when there is none.
82+func (w *initWin) toFirstPending() {
83+ for i, st := range w.steps() {
84+ if st.Value == "" {
85+ w.cursor, w.confirm = i, false
86+ w.initSelection(st)
87+ return
88+ }
89+ }
90+ w.confirm = true
91+}
92+
93+// initSelection highlights the step's answer, else its default.
94+func (w *initWin) initSelection(st agentinit.Step) {
95+ want := st.Value
96+ if want == "" {
97+ want = st.Default
98+ }
99+ w.sel[st.Key] = 0
100+ for i, o := range st.Options {
101+ if o.Value == want {
102+ w.sel[st.Key] = i
103+ }
104+ }
105+ if st.Kind == elicit.Text {
106+ if _, ok := w.text[st.Key]; !ok {
107+ w.text[st.Key] = []rune(want)
108+ }
109+ }
110+}
111+
112+// candidate is what the step would be answered with now.
113+func (w *initWin) candidate(st agentinit.Step) string {
114+ if st.Kind == elicit.Text {
115+ return strings.TrimSpace(string(w.text[st.Key]))
116+ }
117+ if len(st.Options) == 0 {
118+ return ""
119+ }
120+ return st.Options[min(w.sel[st.Key], len(st.Options)-1)].Value
121+}
122+
123+func (w *initWin) handleKey(ev *tcell.EventKey) {
124+ w.problem = ""
125+ switch ev.Key() {
126+ case tcell.KeyCtrlC:
127+ w.result = -1
128+ return
129+ case tcell.KeyTab:
130+ w.preview = !w.preview
131+ return
132+ case tcell.KeyPgUp:
133+ w.scroll = max(w.scroll-10, 0)
134+ return
135+ case tcell.KeyPgDn:
136+ w.scroll += 10
137+ return
138+ case tcell.KeyEscape:
139+ w.back()
140+ return
141+ }
142+ if w.confirm {
143+ if ev.Key() == tcell.KeyEnter {
144+ w.result = 1
145+ }
146+ return
147+ }
148+ st := w.current()
149+ if st.Kind == elicit.Text {
150+ w.textKey(st, ev)
151+ return
152+ }
153+ switch ev.Key() {
154+ case tcell.KeyUp:
155+ w.sel[st.Key] = (w.sel[st.Key] + len(st.Options) - 1) % len(st.Options)
156+ case tcell.KeyDown:
157+ w.sel[st.Key] = (w.sel[st.Key] + 1) % len(st.Options)
158+ case tcell.KeyHome:
159+ w.sel[st.Key] = 0
160+ case tcell.KeyEnd:
161+ w.sel[st.Key] = len(st.Options) - 1
162+ case tcell.KeyEnter:
163+ w.answer(st)
164+ case tcell.KeyRune:
165+ if st.Kind == elicit.Confirm && (ev.Rune() == 'y' || ev.Rune() == 'n') {
166+ w.sel[st.Key] = map[rune]int{'y': 0, 'n': 1}[ev.Rune()]
167+ w.answer(st)
168+ }
169+ }
170+}
171+
172+func (w *initWin) textKey(st agentinit.Step, ev *tcell.EventKey) {
173+ t := w.text[st.Key]
174+ switch ev.Key() {
175+ case tcell.KeyRune:
176+ w.text[st.Key] = append(t, ev.Rune())
177+ case tcell.KeyBackspace, tcell.KeyBackspace2:
178+ if len(t) > 0 {
179+ w.text[st.Key] = t[:len(t)-1]
180+ }
181+ case tcell.KeyCtrlU:
182+ w.text[st.Key] = nil
183+ case tcell.KeyEnter:
184+ w.answer(st)
185+ }
186+}
187+
188+// answer records the highlighted choice and moves to the next question.
189+func (w *initWin) answer(st agentinit.Step) {
190+ value := w.candidate(st)
191+ if value == "" {
192+ value = st.Default
193+ }
194+ if err := w.flow.Set(st.Key, value); err != nil {
195+ w.problem = err.Error()
196+ return
197+ }
198+ w.scroll = 0
199+ w.toFirstPending()
200+}
201+
202+// back is Esc: the previous question the user answered becomes the current
203+// one again — its answer taken back; questions answered by flags are
204+// skipped.
205+func (w *initWin) back() {
206+ steps := w.steps()
207+ from := w.cursor
208+ if w.confirm {
209+ from = len(steps)
210+ }
211+ for i := from - 1; i >= 0; i-- {
212+ if !steps[i].Given {
213+ w.flow.Unset(steps[i].Key)
214+ w.cursor, w.confirm = i, false
215+ w.initSelection(w.flow.Step(steps[i].Key))
216+ return
217+ }
218+ }
219+}
220+
221+// --- drawing ----------------------------------------------------------------
222+
223+func (w *initWin) draw() {
224+ width, height := w.screen.Size()
225+ if width < 1 || height < 4 {
226+ return
227+ }
228+ w.screen.Clear()
229+ p := painter{screen: w.screen, area: rect{w: width, h: height}}
230+ w.drawHeader(p.sub(rect{x: 0, y: 0, w: width, h: 1}))
231+ p.sub(rect{x: 0, y: 1, w: width, h: 1}).hline(0, 0, width, '─', stRule)
232+ body := rect{x: 0, y: 2, w: width, h: height - 4}
233+ w.drawBody(p.sub(body))
234+ rule := p.sub(rect{x: 0, y: height - 2, w: width, h: 1})
235+ rule.hline(0, 0, width, '─', stRule)
236+ if w.problem != "" {
237+ rule.textLimited(1, 0, width-2, " ⚠️ "+w.problem+" ", stError)
238+ }
239+ w.drawStatus(p.sub(rect{x: 0, y: height - 1, w: width, h: 1}))
240+ w.screen.Show()
241+}
242+
243+func (w *initWin) drawHeader(p painter) {
244+ p.fill(' ', stBar)
245+ title := " mm init — new agent"
246+ c, _, _ := w.flow.Result()
247+ if c.Provider != "" {
248+ title += " · " + c.Provider
249+ }
250+ if c.Model != "" {
251+ title += " · " + shortModel(c.Model)
252+ }
253+ p.textLimited(0, 0, p.width(), title, stBar)
254+}
255+
256+func (w *initWin) drawStatus(p painter) {
257+ p.fill(' ', stBar)
258+ keys := " ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit "
259+ switch {
260+ case w.confirm:
261+ keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit "
262+ case w.current().Kind == elicit.Text:
263+ keys = " type · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit "
264+ }
265+ if p.width() < previewMinWidth {
266+ keys = strings.Replace(keys, " · Ctrl-C", " · Tab preview · Ctrl-C", 1)
267+ }
268+ p.textLimited(0, 0, p.width(), keys, stBar)
269+}
270+
271+// drawBody lays out the three panes: steps | question | preview.
272+func (w *initWin) drawBody(p painter) {
273+ stepsW := 22
274+ w.drawSteps(p.sub(rect{x: 0, y: 0, w: stepsW, h: p.height()}))
275+ for y := range p.height() {
276+ p.set(stepsW, y, '│', stRule)
277+ }
278+ rest := rect{x: stepsW + 1, y: 0, w: p.width() - stepsW - 1, h: p.height()}
279+ if p.width() < previewMinWidth {
280+ if w.preview {
281+ w.drawPreview(p.sub(rest))
282+ } else {
283+ w.drawQuestion(p.sub(rest))
284+ }
285+ return
286+ }
287+ qw := rest.w * 2 / 5
288+ w.drawQuestion(p.sub(rect{x: rest.x, y: 0, w: qw, h: rest.h}))
289+ for y := range p.height() {
290+ p.set(rest.x+qw, y, '│', stRule)
291+ }
292+ w.drawPreview(p.sub(rect{x: rest.x + qw + 1, y: 0, w: rest.w - qw - 1, h: rest.h}))
293+}
294+
295+func (w *initWin) drawSteps(p painter) {
296+ for i, st := range w.steps() {
297+ mark, style := "·", stNotice
298+ switch {
299+ case !w.confirm && i == w.cursor:
300+ mark, style = "▸", stPrompt
301+ case st.Value != "":
302+ mark, style = "✓", stBody
303+ }
304+ line := fmt.Sprintf(" %s %-9s", mark, st.Title)
305+ x := p.text(0, i, line, style)
306+ if st.Value != "" && (w.confirm || i != w.cursor) {
307+ p.textLimited(x+1, i, p.width()-x-1, shortModel(st.Value), stNotice)
308+ }
309+ }
310+}
311+
312+func (w *initWin) drawQuestion(p painter) {
313+ if w.confirm {
314+ _, _, output := w.flow.Result()
315+ p.textLimited(1, 0, p.width()-2, "Ready to write "+output, stLabel)
316+ p.textLimited(1, 2, p.width()-2, "Every question is answered: the file", stBody)
317+ p.textLimited(1, 3, p.width()-2, "on the right is what will be written.", stBody)
318+ p.textLimited(1, 5, p.width()-2, "Enter writes it; Esc goes back.", stNotice)
319+ return
320+ }
321+ st := w.current()
322+ p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel)
323+ if st.Kind == elicit.Text {
324+ t := string(w.text[st.Key])
325+ x := p.text(1, 2, "› "+t, stBody)
326+ p.showCursor(x, 2)
327+ if st.Default != "" {
328+ p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice)
329+ }
330+ return
331+ }
332+ p.showCursor(-1, -1)
333+ listH := max(p.height()-6, 3)
334+ sel := w.sel[st.Key]
335+ top := min(max(sel-listH/2, 0), max(len(st.Options)-listH, 0))
336+ for row := 0; row < listH && top+row < len(st.Options); row++ {
337+ o := st.Options[top+row]
338+ label := " " + o.Label
339+ if o.Value == st.Default {
340+ label += " (default)"
341+ }
342+ style := stBody
343+ if top+row == sel {
344+ style = stPick
345+ label = "▸" + label[1:]
346+ }
347+ p.textLimited(1, 2+row, p.width()-2, padTo(label, p.width()-2), style)
348+ }
349+ if d := st.Options[sel].Detail; d != "" {
350+ y := 3 + min(listH, len(st.Options))
351+ for i, line := range wrap(d, p.width()-2) {
352+ if y+i < p.height() {
353+ p.textLimited(1, y+i, p.width()-2, line, stNotice)
354+ }
355+ }
356+ }
357+}
358+
359+func padTo(s string, width int) string {
360+ if n := len([]rune(s)); n < width {
361+ return s + strings.Repeat(" ", width-n)
362+ }
363+ return s
364+}
365+
366+// drawPreview shows the file as it would be written with the highlighted
367+// choice; comments dim, keys plain.
368+func (w *initWin) drawPreview(p painter) {
369+ key, candidate := "", ""
370+ if !w.confirm {
371+ st := w.current()
372+ key, candidate = st.Key, w.candidate(st)
373+ }
374+ lines := strings.Split(w.flow.Preview(key, candidate), "\n")
375+ w.scroll = min(w.scroll, max(len(lines)-p.height(), 0))
376+ for y := 0; y < p.height() && w.scroll+y < len(lines); y++ {
377+ line := lines[w.scroll+y]
378+ style := stBody
379+ if strings.HasPrefix(strings.TrimSpace(line), "#") {
380+ style = stNotice
381+ }
382+ p.textLimited(1, y, p.width()-1, line, style)
383+ }
384+}
added internal/tui/initwin_test.go +164 -0
new file mode 100644
@@ -0,0 +1,164 @@
1+package tui
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "mm/internal/agentinit"
12+
13+ "github.com/gdamore/tcell/v2"
14+)
15+
16+// initFlow is a flow on a fake llmman: provider and address given, so no
17+// real server is probed; the model list has two models.
18+func initFlow(t *testing.T, output string) *agentinit.Flow {
19+ t.Helper()
20+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+ switch r.URL.Path {
22+ case "/api/tags":
23+ _ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}, map[string]any{"name": "hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"}}})
24+ case "/api/show":
25+ _ = json.NewEncoder(w).Encode(map[string]any{"model_info": map[string]any{}, "template": ""})
26+ default:
27+ http.NotFound(w, r)
28+ }
29+ }))
30+ t.Cleanup(srv.Close)
31+ p, err := agentinit.LoadPresets()
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ return agentinit.NewFlow(context.Background(), p, agentinit.Choices{Provider: "llmman", BaseURL: srv.URL + "/v1"}, output)
36+}
37+
38+func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.SimulationScreen) {
39+ t.Helper()
40+ screen := tcell.NewSimulationScreen("UTF-8")
41+ if err := screen.Init(); err != nil {
42+ t.Fatal(err)
43+ }
44+ screen.SetSize(w, h)
45+ t.Cleanup(screen.Fini)
46+ win := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string][]rune{}}
47+ win.toFirstPending()
48+ return win, screen
49+}
50+
51+func screenOf(win *initWin, screen tcell.SimulationScreen) string {
52+ win.draw()
53+ cells, w, h := screen.GetContents()
54+ var b strings.Builder
55+ for y := 0; y < h; y++ {
56+ for x := 0; x < w; x++ {
57+ if r := cells[y*w+x].Runes; len(r) > 0 {
58+ b.WriteString(string(r))
59+ } else {
60+ b.WriteByte(' ')
61+ }
62+ }
63+ b.WriteByte('\n')
64+ }
65+ return b.String()
66+}
67+
68+func key(win *initWin, k tcell.Key) { win.handleKey(tcell.NewEventKey(k, 0, tcell.ModNone)) }
69+func typeText(win *initWin, s string) {
70+ for _, r := range s {
71+ win.handleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
72+ }
73+}
74+
75+// Enter on every default goes to the confirmation, and Enter there writes:
76+// the flow holds the answers the defaults gave.
77+func TestInitWindowDefaultsAllTheWay(t *testing.T) {
78+ flow := initFlow(t, "out.yaml")
79+ screen := tcell.NewSimulationScreen("UTF-8")
80+ if err := screen.Init(); err != nil {
81+ t.Fatal(err)
82+ }
83+ screen.SetSize(120, 30)
84+ // Six questions (model, family, usage, machine, yolo, name) and the
85+ // confirmation; the simulation's event queue is bounded, so no more.
86+ for range 8 {
87+ screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
88+ }
89+ if !runInit(screen, flow) {
90+ t.Fatal("the window did not finish")
91+ }
92+ c, _, out := flow.Result()
93+ if c.Model != "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" || c.Usage != "developer" || c.Machine != "small" || c.Name != "developer" || out != "out.yaml" || c.Yolo == nil || *c.Yolo {
94+ t.Errorf("answers %+v, file %q", c, out)
95+ }
96+}
97+
98+// The preview follows the highlighted choice before it is chosen; the
99+// steps list marks what is answered; the detail of the highlighted option
100+// is shown.
101+func TestInitWindowPreviewFollowsTheSelection(t *testing.T) {
102+ win, screen := newInitWin(t, initFlow(t, "out.yaml"), 130, 40)
103+ key(win, tcell.KeyEnter) // model: the first
104+ key(win, tcell.KeyEnter) // family: detected (qwen)
105+ shown := screenOf(win, screen)
106+ if !strings.Contains(shown, "What is the agent for?") || !strings.Contains(shown, "✓ Model") || !strings.Contains(shown, "▸ Usage") {
107+ t.Fatalf("usage step not shown:\n%s", shown)
108+ }
109+ for win.candidate(win.current()) != "docs" {
110+ key(win, tcell.KeyDown)
111+ }
112+ shown = screenOf(win, screen)
113+ if !strings.Contains(shown, "docSearch:") || !strings.Contains(shown, "answers from a documentation folder") {
114+ t.Errorf("preview or detail does not follow the selection:\n%s", shown)
115+ }
116+ if strings.Contains(shown, "bashTool: true") {
117+ t.Errorf("preview still shows the developer's bash:\n%s", shown)
118+ }
119+}
120+
121+// Esc takes the last answer back; text questions are typed; Ctrl-C quits
122+// without writing.
123+func TestInitWindowBackTextAndQuit(t *testing.T) {
124+ flow := initFlow(t, "")
125+ win, _ := newInitWin(t, flow, 130, 40)
126+ key(win, tcell.KeyEnter) // model
127+ key(win, tcell.KeyEnter) // family
128+ key(win, tcell.KeyEnter) // usage: developer
129+ if win.current().Key != "machine" {
130+ t.Fatalf("at %s, want machine", win.current().Key)
131+ }
132+ key(win, tcell.KeyEscape)
133+ if st := win.current(); st.Key != "usage" || st.Value != "" {
134+ t.Fatalf("after Esc: %+v", st)
135+ }
136+ key(win, tcell.KeyEnter) // usage again
137+ key(win, tcell.KeyEnter) // machine
138+ key(win, tcell.KeyEnter) // yolo: default
139+ if win.current().Key != "name" {
140+ t.Fatalf("at %s, want name", win.current().Key)
141+ }
142+ key(win, tcell.KeyCtrlU)
143+ typeText(win, "bob")
144+ key(win, tcell.KeyEnter)
145+ if c, _, _ := flow.Result(); c.Name != "bob" {
146+ t.Errorf("name %q", c.Name)
147+ }
148+ key(win, tcell.KeyCtrlC)
149+ if win.result != -1 {
150+ t.Errorf("Ctrl-C: result %d", win.result)
151+ }
152+}
153+
154+// Narrow: Tab shows the preview in place of the question.
155+func TestInitWindowNarrowTabShowsThePreview(t *testing.T) {
156+ win, screen := newInitWin(t, initFlow(t, "out.yaml"), 80, 30)
157+ if shown := screenOf(win, screen); !strings.Contains(shown, "Model?") || strings.Contains(shown, "provider: llmman") || !strings.Contains(shown, "Tab preview") {
158+ t.Fatalf("narrow, question first:\n%s", shown)
159+ }
160+ key(win, tcell.KeyTab)
161+ if shown := screenOf(win, screen); !strings.Contains(shown, "provider: llmman") {
162+ t.Errorf("Tab did not show the preview:\n%s", shown)
163+ }
164+}
new file mode 100644
@@ -0,0 +1,164 @@
1+package tui
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "mm/internal/agentinit"
12+
13+ "github.com/gdamore/tcell/v2"
14+)
15+
16+// initFlow is a flow on a fake llmman: provider and address given, so no
17+// real server is probed; the model list has two models.
18+func initFlow(t *testing.T, output string) *agentinit.Flow {
19+ t.Helper()
20+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+ switch r.URL.Path {
22+ case "/api/tags":
23+ _ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}, map[string]any{"name": "hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"}}})
24+ case "/api/show":
25+ _ = json.NewEncoder(w).Encode(map[string]any{"model_info": map[string]any{}, "template": ""})
26+ default:
27+ http.NotFound(w, r)
28+ }
29+ }))
30+ t.Cleanup(srv.Close)
31+ p, err := agentinit.LoadPresets()
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ return agentinit.NewFlow(context.Background(), p, agentinit.Choices{Provider: "llmman", BaseURL: srv.URL + "/v1"}, output)
36+}
37+
38+func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.SimulationScreen) {
39+ t.Helper()
40+ screen := tcell.NewSimulationScreen("UTF-8")
41+ if err := screen.Init(); err != nil {
42+ t.Fatal(err)
43+ }
44+ screen.SetSize(w, h)
45+ t.Cleanup(screen.Fini)
46+ win := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string][]rune{}}
47+ win.toFirstPending()
48+ return win, screen
49+}
50+
51+func screenOf(win *initWin, screen tcell.SimulationScreen) string {
52+ win.draw()
53+ cells, w, h := screen.GetContents()
54+ var b strings.Builder
55+ for y := 0; y < h; y++ {
56+ for x := 0; x < w; x++ {
57+ if r := cells[y*w+x].Runes; len(r) > 0 {
58+ b.WriteString(string(r))
59+ } else {
60+ b.WriteByte(' ')
61+ }
62+ }
63+ b.WriteByte('\n')
64+ }
65+ return b.String()
66+}
67+
68+func key(win *initWin, k tcell.Key) { win.handleKey(tcell.NewEventKey(k, 0, tcell.ModNone)) }
69+func typeText(win *initWin, s string) {
70+ for _, r := range s {
71+ win.handleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
72+ }
73+}
74+
75+// Enter on every default goes to the confirmation, and Enter there writes:
76+// the flow holds the answers the defaults gave.
77+func TestInitWindowDefaultsAllTheWay(t *testing.T) {
78+ flow := initFlow(t, "out.yaml")
79+ screen := tcell.NewSimulationScreen("UTF-8")
80+ if err := screen.Init(); err != nil {
81+ t.Fatal(err)
82+ }
83+ screen.SetSize(120, 30)
84+ // Six questions (model, family, usage, machine, yolo, name) and the
85+ // confirmation; the simulation's event queue is bounded, so no more.
86+ for range 8 {
87+ screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
88+ }
89+ if !runInit(screen, flow) {
90+ t.Fatal("the window did not finish")
91+ }
92+ c, _, out := flow.Result()
93+ if c.Model != "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" || c.Usage != "developer" || c.Machine != "small" || c.Name != "developer" || out != "out.yaml" || c.Yolo == nil || *c.Yolo {
94+ t.Errorf("answers %+v, file %q", c, out)
95+ }
96+}
97+
98+// The preview follows the highlighted choice before it is chosen; the
99+// steps list marks what is answered; the detail of the highlighted option
100+// is shown.
101+func TestInitWindowPreviewFollowsTheSelection(t *testing.T) {
102+ win, screen := newInitWin(t, initFlow(t, "out.yaml"), 130, 40)
103+ key(win, tcell.KeyEnter) // model: the first
104+ key(win, tcell.KeyEnter) // family: detected (qwen)
105+ shown := screenOf(win, screen)
106+ if !strings.Contains(shown, "What is the agent for?") || !strings.Contains(shown, "✓ Model") || !strings.Contains(shown, "▸ Usage") {
107+ t.Fatalf("usage step not shown:\n%s", shown)
108+ }
109+ for win.candidate(win.current()) != "docs" {
110+ key(win, tcell.KeyDown)
111+ }
112+ shown = screenOf(win, screen)
113+ if !strings.Contains(shown, "docSearch:") || !strings.Contains(shown, "answers from a documentation folder") {
114+ t.Errorf("preview or detail does not follow the selection:\n%s", shown)
115+ }
116+ if strings.Contains(shown, "bashTool: true") {
117+ t.Errorf("preview still shows the developer's bash:\n%s", shown)
118+ }
119+}
120+
121+// Esc takes the last answer back; text questions are typed; Ctrl-C quits
122+// without writing.
123+func TestInitWindowBackTextAndQuit(t *testing.T) {
124+ flow := initFlow(t, "")
125+ win, _ := newInitWin(t, flow, 130, 40)
126+ key(win, tcell.KeyEnter) // model
127+ key(win, tcell.KeyEnter) // family
128+ key(win, tcell.KeyEnter) // usage: developer
129+ if win.current().Key != "machine" {
130+ t.Fatalf("at %s, want machine", win.current().Key)
131+ }
132+ key(win, tcell.KeyEscape)
133+ if st := win.current(); st.Key != "usage" || st.Value != "" {
134+ t.Fatalf("after Esc: %+v", st)
135+ }
136+ key(win, tcell.KeyEnter) // usage again
137+ key(win, tcell.KeyEnter) // machine
138+ key(win, tcell.KeyEnter) // yolo: default
139+ if win.current().Key != "name" {
140+ t.Fatalf("at %s, want name", win.current().Key)
141+ }
142+ key(win, tcell.KeyCtrlU)
143+ typeText(win, "bob")
144+ key(win, tcell.KeyEnter)
145+ if c, _, _ := flow.Result(); c.Name != "bob" {
146+ t.Errorf("name %q", c.Name)
147+ }
148+ key(win, tcell.KeyCtrlC)
149+ if win.result != -1 {
150+ t.Errorf("Ctrl-C: result %d", win.result)
151+ }
152+}
153+
154+// Narrow: Tab shows the preview in place of the question.
155+func TestInitWindowNarrowTabShowsThePreview(t *testing.T) {
156+ win, screen := newInitWin(t, initFlow(t, "out.yaml"), 80, 30)
157+ if shown := screenOf(win, screen); !strings.Contains(shown, "Model?") || strings.Contains(shown, "provider: llmman") || !strings.Contains(shown, "Tab preview") {
158+ t.Fatalf("narrow, question first:\n%s", shown)
159+ }
160+ key(win, tcell.KeyTab)
161+ if shown := screenOf(win, screen); !strings.Contains(shown, "provider: llmman") {
162+ t.Errorf("Tab did not show the preview:\n%s", shown)
163+ }
164+}
modified internal/version/about.go +1 -1
@@ -29,7 +29,7 @@ func About() string {
2929 b.WriteString("Built-in tools: bash, read_file, write_file, edit_file, read_skill, plan, ask_user, unload_model;\n")
3030 b.WriteString(" when the config turns them on: lsp, fetch_content, ask_agent, save_answer, search_memory, search_docs.\n")
3131 b.WriteString(" Plus the agent file's own tools (tools:) and MCP servers' tools.\n")
32- b.WriteString("Model servers: Docker Model Runner, llmman, Ollama, llama.cpp — anything speaking the OpenAI API.\n\n")
32+ b.WriteString("Model servers: Docker Model Runner, llmman, Ollama, llama.cpp, and any other OpenAI-compatible one (provider openai).\n\n")
3333 fmt.Fprintf(&b, "%s\n", Home)
3434 b.WriteString("Docs: docs/en (English), docs/fr (français) · mm -h for the flags")
3535 return b.String()
@@ -29,7 +29,7 @@ func About() string {
29 b.WriteString("Built-in tools: bash, read_file, write_file, edit_file, read_skill, plan, ask_user, unload_model;\n")29 b.WriteString("Built-in tools: bash, read_file, write_file, edit_file, read_skill, plan, ask_user, unload_model;\n")
30 b.WriteString(" when the config turns them on: lsp, fetch_content, ask_agent, save_answer, search_memory, search_docs.\n")30 b.WriteString(" when the config turns them on: lsp, fetch_content, ask_agent, save_answer, search_memory, search_docs.\n")
31 b.WriteString(" Plus the agent file's own tools (tools:) and MCP servers' tools.\n")31 b.WriteString(" Plus the agent file's own tools (tools:) and MCP servers' tools.\n")
32- b.WriteString("Model servers: Docker Model Runner, llmman, Ollama, llama.cpp — anything speaking the OpenAI API.\n\n")32+ b.WriteString("Model servers: Docker Model Runner, llmman, Ollama, llama.cpp, and any other OpenAI-compatible one (provider openai).\n\n")
33 fmt.Fprintf(&b, "%s\n", Home)33 fmt.Fprintf(&b, "%s\n", Home)
34 b.WriteString("Docs: docs/en (English), docs/fr (français) · mm -h for the flags")34 b.WriteString("Docs: docs/en (English), docs/fr (français) · mm -h for the flags")
35 return b.String()35 return b.String()
modified main.go +15 -0
@@ -42,6 +42,7 @@ import (
4242
4343 "mm/internal/acp"
4444 "mm/internal/agent"
45+ "mm/internal/agentinit"
4546 "mm/internal/ask"
4647 "mm/internal/command"
4748 "mm/internal/config"
@@ -74,6 +75,12 @@ func main() {
7475 // The flag package gives us "-h" for free; a second argument is a typo, and
7576 // saying so is better than ignoring it.
7677
78+ // `mm init` and `mm --init` (ticket 97): the agent.yaml wizard, with its
79+ // own flags, before anything reads a configuration — there is none yet.
80+ if len(os.Args) > 1 && isInit(os.Args[1]) {
81+ os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal}))
82+ }
83+
7784 // Handle --prompt/-p flag which can appear after the config file argument
7885 // We manually extract it before flag.Parse() so both orders work:
7986 // mm --prompt "hello" agent.yaml AND mm agent.yaml --prompt "hello"
@@ -111,6 +118,8 @@ func main() {
111118 flag.StringVar(promptMode, "p", "", "shorthand for --prompt")
112119 showVersion := flag.Bool("version", false, "print the version and exit")
113120 showAbout := flag.Bool("about", false, "print what mm is, and exit")
121+ // Listed for -h only: `mm --init` is answered before flag.Parse.
122+ flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)")
114123 askAgent := flag.String("ask", "", "ask the agent `name` (a key of mmConfigs) the -p prompt, or stdin; print only its answer on stdout, and exit")
115124 askWithContext := flag.String("ask-with-context", "", "same as --ask, with the conversation read from stdin (question with -p, or after a \"---\" line)")
116125 flag.Parse()
@@ -809,3 +818,9 @@ func runAsk(ctx context.Context, name string, withContext bool, prompt string) i
809818 }
810819 return 0
811820 }
821+
822+// isInit reports whether the first argument asks for the wizard: `mm init`,
823+// `mm --init` or `mm -init`.
824+func isInit(arg string) bool {
825+ return arg == "init" || arg == "--init" || arg == "-init"
826+}
@@ -42,6 +42,7 @@ import (
42 42
43 "mm/internal/acp"43 "mm/internal/acp"
44 "mm/internal/agent"44 "mm/internal/agent"
45+ "mm/internal/agentinit"
45 "mm/internal/ask"46 "mm/internal/ask"
46 "mm/internal/command"47 "mm/internal/command"
47 "mm/internal/config"48 "mm/internal/config"
@@ -74,6 +75,12 @@ func main() {
74 // The flag package gives us "-h" for free; a second argument is a typo, and75 // The flag package gives us "-h" for free; a second argument is a typo, and
75 // saying so is better than ignoring it.76 // saying so is better than ignoring it.
76 77
78+ // `mm init` and `mm --init` (ticket 97): the agent.yaml wizard, with its
79+ // own flags, before anything reads a configuration — there is none yet.
80+ if len(os.Args) > 1 && isInit(os.Args[1]) {
81+ os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal}))
82+ }
83+
77 // Handle --prompt/-p flag which can appear after the config file argument84 // Handle --prompt/-p flag which can appear after the config file argument
78 // We manually extract it before flag.Parse() so both orders work:85 // We manually extract it before flag.Parse() so both orders work:
79 // mm --prompt "hello" agent.yaml AND mm agent.yaml --prompt "hello"86 // mm --prompt "hello" agent.yaml AND mm agent.yaml --prompt "hello"
@@ -111,6 +118,8 @@ func main() {
111 flag.StringVar(promptMode, "p", "", "shorthand for --prompt")118 flag.StringVar(promptMode, "p", "", "shorthand for --prompt")
112 showVersion := flag.Bool("version", false, "print the version and exit")119 showVersion := flag.Bool("version", false, "print the version and exit")
113 showAbout := flag.Bool("about", false, "print what mm is, and exit")120 showAbout := flag.Bool("about", false, "print what mm is, and exit")
121+ // Listed for -h only: `mm --init` is answered before flag.Parse.
122+ flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)")
114 askAgent := flag.String("ask", "", "ask the agent `name` (a key of mmConfigs) the -p prompt, or stdin; print only its answer on stdout, and exit")123 askAgent := flag.String("ask", "", "ask the agent `name` (a key of mmConfigs) the -p prompt, or stdin; print only its answer on stdout, and exit")
115 askWithContext := flag.String("ask-with-context", "", "same as --ask, with the conversation read from stdin (question with -p, or after a \"---\" line)")124 askWithContext := flag.String("ask-with-context", "", "same as --ask, with the conversation read from stdin (question with -p, or after a \"---\" line)")
116 flag.Parse()125 flag.Parse()
@@ -809,3 +818,9 @@ func runAsk(ctx context.Context, name string, withContext bool, prompt string) i
809 }818 }
810 return 0819 return 0
811 }820 }
821+
822+// isInit reports whether the first argument asks for the wizard: `mm init`,
823+// `mm --init` or `mm -init`.
824+func isInit(arg string) bool {
825+ return arg == "init" || arg == "--init" || arg == "-init"
826+}