modified
.tickets/issues/0061-llmman-provider-download-the-model-automatically.yaml +2 -2 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-01T05:48:44.919Z |
| 9 | -updatedAt: 2026-10-20T07:39:23.051Z | |
| 9 | +updatedAt: 2026-10-21T11:45:30.797Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v0.9.0 | |
| 11 | +milestone: v1.0.0 | |
| 12 | 12 | epic: Providers (LLM Engine) |
| 13 | 13 | body: "" |
| 14 | 14 | tasks: [] |
| @@ -6,9 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-01T05:48:44.919Z | 8 | createdAt: 2026-10-01T05:48:44.919Z |
| 9 | -updatedAt: 2026-10-20T07:39:23.051Z | 9 | +updatedAt: 2026-10-21T11:45:30.797Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v0.9.0 | 11 | +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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-06T03:58:37.611Z |
| 9 | -updatedAt: 2026-10-20T07:48:28.395Z | |
| 9 | +updatedAt: 2026-10-21T11:49:01.282Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v0.9.0 | |
| 11 | +milestone: v1.0.0 | |
| 12 | 12 | epic: Providers (LLM Engine) |
| 13 | 13 | body: "" |
| 14 | 14 | tasks: [] |
| @@ -6,9 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-06T03:58:37.611Z | 8 | createdAt: 2026-10-06T03:58:37.611Z |
| 9 | -updatedAt: 2026-10-20T07:48:28.395Z | 9 | +updatedAt: 2026-10-21T11:49:01.282Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v0.9.0 | 11 | +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 | ||
| 12 | 12 | body: | |
| 13 | 13 | the Claude skill will produce a descriptive doc about the model and a config file (`agent.yaml`) |
| 14 | 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. | |
| @@ -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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 112 |
| 3 | 3 | title: Add a help window in TUI mode for the UI shortcuts |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-21T04:43:03.511Z |
| 9 | -updatedAt: 2026-10-21T04:43:13.927Z | |
| 9 | +updatedAt: 2026-10-21T11:26:26.119Z | |
| 10 | 10 | labels: |
| 11 | 11 | - kind::feature |
| 12 | + - wontfix | |
| 12 | 13 | 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 | 19 | tasks: [] |
| 15 | 20 | comments: |
| 16 | 21 | - 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: 112 | 2 | id: 112 |
| 3 | title: Add a help window in TUI mode for the UI shortcuts | 3 | title: Add a help window in TUI mode for the UI shortcuts |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-21T04:43:03.511Z | 8 | createdAt: 2026-10-21T04:43:03.511Z |
| 9 | -updatedAt: 2026-10-21T04:43:13.927Z | 9 | +updatedAt: 2026-10-21T11:26:26.119Z |
| 10 | labels: | 10 | labels: |
| 11 | - kind::feature | 11 | - kind::feature |
| 12 | + - wontfix | ||
| 12 | milestone: v0.9.0 | 13 | 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: 1 | 21 | - id: 1 |
modified
.tickets/issues/0115-uograde-go-version.yaml +2 -2 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 115 |
| 3 | 3 | title: Uograde go version |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-21T11:03:46.198Z |
| 9 | -updatedAt: 2026-10-21T15:00:00.000Z | |
| 9 | +updatedAt: 2026-10-21T11:23:25.320Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.9.0 |
| 12 | 12 | body: | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 115 | 2 | id: 115 |
| 3 | title: Uograde go version | 3 | title: Uograde go version |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-21T11:03:46.198Z | 8 | createdAt: 2026-10-21T11:03:46.198Z |
| 9 | -updatedAt: 2026-10-21T15:00:00.000Z | 9 | +updatedAt: 2026-10-21T11:23:25.320Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.9.0 | 11 | 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 @@ | ||
| 1 | 1 | # 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? | |
| 4 | 4 | state: open |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | 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 | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.9.0 |
| 12 | -related: | |
| 13 | - - 98 | |
| 14 | 12 | body: "" |
| 15 | 13 | tasks: [] |
| 16 | 14 | 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: 97 | 2 | +id: 116 |
| 3 | -title: Generate an agent.yaml file with rhe CLI | 3 | +title: How to use mm for completion? |
| 4 | state: open | 4 | state: open |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | -createdAt: 2026-10-18T12:31:14.544Z | 8 | +createdAt: 2026-10-21T11:38:42.420Z |
| 9 | -updatedAt: 2026-10-20T05:38:52.918Z | 9 | +updatedAt: 2026-10-21T11:44:57.748Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.9.0 | 11 | 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 | ||
| 20 | 20 | |
| 21 | 21 | ## 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 | 25 | ```bash |
| 26 | 26 | 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 | 108 | ## 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 | 111 | - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md). |
| 111 | 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 | 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). |
| @@ -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 | ## Install | 21 | ## 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 | ```bash | 25 | ```bash |
| 26 | git clone https://git.rickub.com/bots-garden/mini-me.git | 26 | 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 features | 108 | ## 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 @@ | ||
| 1 | 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" 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="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" 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="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" 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="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" 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="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" 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="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" 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="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" 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="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" 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="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" 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="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" 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="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" 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="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" 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="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" 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="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" 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="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" 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="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" 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="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" 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 | 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" 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="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" 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="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" 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="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" 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="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" 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="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" 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="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" 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="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" 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="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" 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="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." 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="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" 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="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" 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="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" 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="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" 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="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" 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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" 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="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" 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="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" 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="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" 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="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" 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="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" 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="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" 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="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" 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="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" 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="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" 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="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" 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="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" 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="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" 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="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." 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="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" 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="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" 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="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" 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="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" 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="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" 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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" 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="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" 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="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" 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="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" 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="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" 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="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" 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="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" 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="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" 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="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" 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="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" 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="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" 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="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" 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="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" 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="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" 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="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" 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="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" 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="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" 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="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" 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="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" 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="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" 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="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" 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="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" 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="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" 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="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" 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="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" 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="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" 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="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" 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="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" 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="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" 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="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" 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="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" 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="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" 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="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" 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="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" 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="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" 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="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" 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="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" 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="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" 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="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" 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="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" 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="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" 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="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" 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="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" 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="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" 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="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" 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="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" 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="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." 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="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" 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="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" 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="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" 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="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" 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="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" 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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" 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="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" 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="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" 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="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" 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="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" 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="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" 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="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" 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="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" 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="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" 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="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" 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="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" 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="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" 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="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" 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="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." 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="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" 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="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" 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="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" 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="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" 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="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" 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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" 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="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" 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="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" 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="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" 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="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" 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="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" 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) | ||
| 10 | 10 | |
| 11 | 11 | ## 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 | 14 | - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 14 | 15 | - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/` |
| 15 | 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 | 18 | - [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>` |
| 17 | 19 | - [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 | 20 | - [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 done | 11 | ## 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 jq | 19 | - [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` settings | 20 | - [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 @@ | ||
| 2 | 2 | |
| 3 | 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 | 7 | ## Why it is designed this way |
| 8 | 8 | |
| @@ -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 way | 7 | ## 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 @@ | ||
| 8 | 8 | mm [-config <path>] [-acp] [-tui] [-prompt <text>|-p <text>] [--version] [--about] [<path>] |
| 9 | 9 | mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent) |
| 10 | 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 | 14 | | Element | Type | Default | Description | |
| @@ -148,6 +149,37 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 148 | 149 | | Parent process | Watched every 500 ms: when it is gone, the ask is cancelled and the agent stopped. | |
| 149 | 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 | 183 | ## Exit codes |
| 152 | 184 | |
| 153 | 185 | | 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 codes | 183 | ## Exit codes |
| 152 | 184 | ||
| 153 | | Code | Cause | | 185 | | Code | Cause | |
modified
docs/en/reference/configuration.md +4 -1 | @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 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 | 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 | 11 | | `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. | |
| 12 | 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 | 177 | | `llamacpp` | `http://127.0.0.1:8080/v1` | none | `LLAMA_API_KEY` | no | |
| 178 | 178 | | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | no | |
| 179 | 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 | 184 | ## `structuredOutput` |
| 182 | 185 | |
| @@ -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: | ||
| 10 | 10 | |
| 11 | 11 | ## 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 | 14 | - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 14 | 15 | - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/` |
| 15 | 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 | 18 | - [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>` |
| 17 | 19 | - [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 | 20 | - [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âche | 11 | ## 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 jq | 19 | - [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 @@ | ||
| 2 | 2 | |
| 3 | 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 | 7 | ## Pourquoi c'est conçu ainsi |
| 8 | 8 | |
| @@ -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 ainsi | 7 | ## 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 @@ | ||
| 8 | 8 | mm [-config <chemin>] [-acp] [-tui] [-prompt <texte>|-p <texte>] [--version] [--about] [<chemin>] |
| 9 | 9 | mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p) |
| 10 | 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 | 14 | | Élément | Type | Défaut | Description | |
| @@ -148,6 +149,37 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 148 | 149 | | Processus parent | Surveillé toutes les 500 ms : s'il disparaît, la demande est annulée et l'agent arrêté. | |
| 149 | 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 | 183 | ## Codes de sortie |
| 152 | 184 | |
| 153 | 185 | | 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 sortie | 183 | ## Codes de sortie |
| 152 | 184 | ||
| 153 | | Code | Cause | | 185 | | Code | Cause | |
modified
docs/fr/reference/configuration.md +4 -1 | @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 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 | 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 | 11 | | `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. | |
| 12 | 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 | 177 | | `llamacpp` | `http://127.0.0.1:8080/v1` | aucun | `LLAMA_API_KEY` | non | |
| 178 | 178 | | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | non | |
| 179 | 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 | 184 | ## `structuredOutput` |
| 182 | 185 | |
| @@ -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 | ||
| 97 | 97 | | `F1` | Les commandes et les principales touches, dans la conversation (comme `/help`). | |
| 98 | 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 | 115 | ## Bon à savoir |
| 101 | 116 | |
| 102 | 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. |
| @@ -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 à savoir | 115 | ## 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 | ||
| 97 | 97 | | `F1` | The commands and the main keys, in the conversation (same as `/help`). | |
| 98 | 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 | 115 | ## Good to know |
| 101 | 116 | |
| 102 | 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. |
| @@ -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 know | 115 | ## 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 } | ||
| 75 | 75 | // where DMR_BASE_URL is often exported. |
| 76 | 76 | func clearEnv(t *testing.T) { |
| 77 | 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 | 79 | t.Setenv(k, "") |
| 80 | 80 | } |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | 83 | func TestLookupListsKnownProviders(t *testing.T) { |
| 84 | 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 | 86 | t.Fatalf("Lookup(vllm) = %v, want an error naming the known providers", err) |
| 87 | 87 | } |
| 88 | 88 | } |
| @@ -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 { | ||
| 29 | 29 | b.WriteString("Built-in tools: bash, read_file, write_file, edit_file, read_skill, plan, ask_user, unload_model;\n") |
| 30 | 30 | b.WriteString(" when the config turns them on: lsp, fetch_content, ask_agent, save_answer, search_memory, search_docs.\n") |
| 31 | 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 | 33 | fmt.Fprintf(&b, "%s\n", Home) |
| 34 | 34 | b.WriteString("Docs: docs/en (English), docs/fr (français) · mm -h for the flags") |
| 35 | 35 | 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 ( | ||
| 42 | 42 | |
| 43 | 43 | "mm/internal/acp" |
| 44 | 44 | "mm/internal/agent" |
| 45 | + "mm/internal/agentinit" | |
| 45 | 46 | "mm/internal/ask" |
| 46 | 47 | "mm/internal/command" |
| 47 | 48 | "mm/internal/config" |
| @@ -74,6 +75,12 @@ func main() { | ||
| 74 | 75 | // The flag package gives us "-h" for free; a second argument is a typo, and |
| 75 | 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 | 84 | // Handle --prompt/-p flag which can appear after the config file argument |
| 78 | 85 | // We manually extract it before flag.Parse() so both orders work: |
| 79 | 86 | // mm --prompt "hello" agent.yaml AND mm agent.yaml --prompt "hello" |
| @@ -111,6 +118,8 @@ func main() { | ||
| 111 | 118 | flag.StringVar(promptMode, "p", "", "shorthand for --prompt") |
| 112 | 119 | showVersion := flag.Bool("version", false, "print the version and exit") |
| 113 | 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 | 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 | 124 | askWithContext := flag.String("ask-with-context", "", "same as --ask, with the conversation read from stdin (question with -p, or after a \"---\" line)") |
| 116 | 125 | flag.Parse() |
| @@ -809,3 +818,9 @@ func runAsk(ctx context.Context, name string, withContext bool, prompt string) i | ||
| 809 | 818 | } |
| 810 | 819 | 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 | +} | |
| @@ -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, and | 75 | // 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 argument | 84 | // 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 0 | 819 | 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 | +} | ||