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

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

Download ZIP

🛟 Updated.

k33g committed 2026-10-20T11:50:47+02:00 Browse files
6072b69 parent: fc4190d
modified .tickets/issues/0106-search-doc-bm25-search-in-text-files.yaml +97 -12
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 106
3-title: search_doc — BM25 search in the text files of a folder (built-in tool)
4-state: open
3+title: search_docs — BM25 search in the text files of folders (/docs and a built-in tool)
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-20T20:00:00.000Z
9-updatedAt: 2026-10-20T20:00:00.000Z
9+updatedAt: 2026-10-20T23:30:00.000Z
1010 labels:
1111 - kind::feature
1212 milestone: v0.8.0
@@ -67,7 +67,7 @@ body: |
6767 tasks:
6868 - id: 1
6969 title: "Agree on the config key, defaults and the open questions below"
70- state: open
70+ state: closed
7171 priority: none
7272 author:
7373 name: k33g
@@ -76,7 +76,7 @@ tasks:
7676 updatedAt: 2026-10-20T20:00:00.000Z
7777 - id: 2
7878 title: "docsearch: chunker — Markdown (# headings, not inside code fences), AsciiDoc (= headings), plain text (blank-line paragraphs); heading path and line kept; long sections split by paragraphs — with tests"
79- state: open
79+ state: closed
8080 priority: none
8181 author:
8282 name: k33g
@@ -85,7 +85,7 @@ tasks:
8585 updatedAt: 2026-10-20T20:00:00.000Z
8686 - id: 3
8787 title: "docsearch: BM25 (k1 1.2, b 0.75) over the chunks, words folded like store.fold (case, accents), heading boost — with tests"
88- state: open
88+ state: closed
8989 priority: none
9090 author:
9191 name: k33g
@@ -94,7 +94,7 @@ tasks:
9494 updatedAt: 2026-10-20T20:00:00.000Z
9595 - id: 4
9696 title: "docsearch: walk the folders (extensions, size cap, skip hidden dirs, .git, node_modules), scanned at each call, no index — with tests"
97- state: open
97+ state: closed
9898 priority: none
9999 author:
100100 name: k33g
@@ -102,8 +102,8 @@ tasks:
102102 createdAt: 2026-10-20T20:00:00.000Z
103103 updatedAt: 2026-10-20T20:00:00.000Z
104104 - id: 5
105- title: "built-in tool search_doc {query, limit?}: hits = path:line, heading path, snippet; declared only when the config enables it; never asks; ACP kind search — with tests"
106- state: open
105+ title: "built-in tool search_docs {query, limit?} and the /docs <words> command: hits = path:line, heading path, snippet; declared only when the config enables it; never asks; ACP kind search — with tests"
106+ state: closed
107107 priority: none
108108 author:
109109 name: k33g
@@ -112,7 +112,7 @@ tasks:
112112 updatedAt: 2026-10-20T20:00:00.000Z
113113 - id: 6
114114 title: "Measure on a small model (llmman) over mm's own docs/ (EN+FR): found when the words are there, not called otherwise; time per call on docs/"
115- state: open
115+ state: closed
116116 priority: none
117117 author:
118118 name: k33g
@@ -121,11 +121,96 @@ tasks:
121121 updatedAt: 2026-10-20T20:00:00.000Z
122122 - id: 7
123123 title: "Docs EN+FR (reference configuration and tools, how-to, explanation: why BM25 and no index), demo if useful, quality gate, .memory"
124- state: open
124+ state: closed
125125 priority: none
126126 author:
127127 name: k33g
128128 email: ph.charriere@gmail.com
129129 createdAt: 2026-10-20T20:00:00.000Z
130130 updatedAt: 2026-10-20T20:00:00.000Z
131-comments: []
131+comments:
132+ - id: 1
133+ author:
134+ name: k33g
135+ email: ph.charriere@gmail.com
136+ createdAt: 2026-10-20T21:00:00.000Z
137+ body: |
138+ Decided by the user (2026-10-20) — the proposal is validated, with:
139+ - tool name: search_docs (plural);
140+ - config block `docSearch`, with TWO switches instead of `enabled`:
141+ `searchDocs: true` → the /docs <words> command (for the user),
142+ `autoSearchDocs: true` → the search_docs tool (for the model);
143+ both default false, independent (as /memory and search_memory);
144+ - /docs: yes;
145+ - prefix matching for words of 4+ letters: yes;
146+ - extensions: only what the configuration lists (no hidden extra);
147+ the default list is the validated one (.md .markdown .txt .adoc
148+ .asciidoc .rst); symlinks not followed;
149+ - key name: docSearch.
150+ Then: demos/docs-search, whose documents are a set of simple
151+ tutorials on using ratatui in Rust (Markdown, generated for the demo).
152+ Branch feature/search-in-doc (created by the user).
153+ - id: 2
154+ author:
155+ name: k33g
156+ email: ph.charriere@gmail.com
157+ createdAt: 2026-10-20T23:00:00.000Z
158+ body: |
159+ Tasks 2-6 done on feature/search-in-doc (go test ./... green, gofmt/vet
160+ clean):
161+ - internal/docsearch (new): passages.go (sections at # / = headings,
162+ fences and listing blocks ignored, heading path, cut at blank lines
163+ past 1,500 runes, never inside a fence), bm25.go (k1 1.2, b 0.75,
164+ heading-path words +2, prefix match for 4+ runes, plural/-ed within
165+ 2 runes, query words < 3 runes dropped beside longer ones), walk.go
166+ (listed extensions only, maxFileSize, hidden dirs + node_modules
167+ skipped, symlinks not followed, NUL = binary, missing dirs
168+ reported), docsearch.go (Search, Result.Format), configured.go
169+ (Configured/Answer from config).
170+ - internal/textfold (new): Fold/Rune moved out of store (shared by
171+ store and docsearch, no duplicate).
172+ - config/docsearch.go: docSearch (searchDocs, autoSearchDocs, dirs
173+ [docs], extensions [.md .markdown .txt .adoc .asciidoc .rst],
174+ maxFileSize 1MB — ByteSize reads 1MB / 512KB / bytes); validated
175+ only when a switch is on; search_docs reserved.
176+ - command/docs.go SetDocs; /docs in the REPL, the window (notice) and
177+ ACP (agent text, input hint); tools/searchdocs.go search_docs
178+ (query, limit? — 5, cap 20), never asks, ACP kind search, dir = the
179+ sink's WorkDir else the process cwd; main.go wiring.
180+ - Speed (best of 10): mm's docs/ 137 files / 1,320 passages ≈ 45 ms;
181+ the demo's 10 files / 44 passages ≈ 1 ms.
182+ demos/docs-search: Rata, Qwen3.5 4B on llmman, 9 Ratatui 0.30
183+ tutorials + index; check-tutorials.sh compiles every complete program
184+ + snippets.rs in a rust container (-Dwarnings): OK, 10 programs,
185+ ratatui 0.30.2. Measured (temp 0, 3 runs each, identical): List →
186+ ListState, 04-lists.md:5 3/3; popup → Clear + centered, NO link 3/3;
187+ Windows double key → right answer (KeyEventKind::Press) 3/3 but cited
188+ code examples instead of 08:60 (ranked first by the search); progress
189+ bar → Gauge + event::poll, 06:9 3/3; two French questions → English
190+ searches, French answers, right links 6/6; "Write src/main.rs …" →
191+ file written 3/3, compiles against ratatui 0.30.2 3/3; greeting and
192+ an off-topic question → no search 6/6. Prompt lesson: the Windows
193+ question was answered without a search (3/3) until the prompt said any
194+ terminal-app question is a Ratatui question — after which the popup
195+ answer stopped citing its link.
196+ Task 7: docs EN+FR done (reference configuration + validation errors,
197+ slash-commands, tools, acp; how-to search-the-docs; explanation
198+ document-search; indexes; root README; packages.drawio). Quality gate
199+ NOT run (qlty absent here) — task 7 stays open for it.
200+ - id: 3
201+ author:
202+ name: k33g
203+ email: ph.charriere@gmail.com
204+ createdAt: 2026-10-20T23:30:00.000Z
205+ body: |
206+ Closed at the user's request. Delivered: internal/docsearch +
207+ internal/textfold, docSearch config (searchDocs / autoSearchDocs),
208+ /docs in the three front ends, the search_docs tool, tests, docs EN+FR
209+ (reference, how-to search-the-docs, explanation document-search),
210+ demos/docs-search (Ratatui tutorials, compile-checked, measured).
211+ Task 7 closed with the qlty quality gate still NOT run (qlty is not
212+ installed in this sandbox); gofmt and go vet clean, go test ./...
213+ green. Known limits left as documented: words, not meanings; in the
214+ demo the popup answer gives no link and the Windows answer cites code
215+ examples rather than the explaining passage.
216+
@@ -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: 1062 id: 106
3-title: search_doc — BM25 search in the text files of a folder (built-in tool)3+title: search_docs — BM25 search in the text files of folders (/docs and a built-in tool)
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-20T20:00:00.000Z8 createdAt: 2026-10-20T20:00:00.000Z
9-updatedAt: 2026-10-20T20:00:00.000Z9+updatedAt: 2026-10-20T23:30:00.000Z
10 labels:10 labels:
11 - kind::feature11 - kind::feature
12 milestone: v0.8.012 milestone: v0.8.0
@@ -67,7 +67,7 @@ body: |
67 tasks:67 tasks:
68 - id: 168 - id: 1
69 title: "Agree on the config key, defaults and the open questions below"69 title: "Agree on the config key, defaults and the open questions below"
70- state: open70+ state: closed
71 priority: none71 priority: none
72 author:72 author:
73 name: k33g73 name: k33g
@@ -76,7 +76,7 @@ tasks:
76 updatedAt: 2026-10-20T20:00:00.000Z76 updatedAt: 2026-10-20T20:00:00.000Z
77 - id: 277 - id: 2
78 title: "docsearch: chunker — Markdown (# headings, not inside code fences), AsciiDoc (= headings), plain text (blank-line paragraphs); heading path and line kept; long sections split by paragraphs — with tests"78 title: "docsearch: chunker — Markdown (# headings, not inside code fences), AsciiDoc (= headings), plain text (blank-line paragraphs); heading path and line kept; long sections split by paragraphs — with tests"
79- state: open79+ state: closed
80 priority: none80 priority: none
81 author:81 author:
82 name: k33g82 name: k33g
@@ -85,7 +85,7 @@ tasks:
85 updatedAt: 2026-10-20T20:00:00.000Z85 updatedAt: 2026-10-20T20:00:00.000Z
86 - id: 386 - id: 3
87 title: "docsearch: BM25 (k1 1.2, b 0.75) over the chunks, words folded like store.fold (case, accents), heading boost — with tests"87 title: "docsearch: BM25 (k1 1.2, b 0.75) over the chunks, words folded like store.fold (case, accents), heading boost — with tests"
88- state: open88+ state: closed
89 priority: none89 priority: none
90 author:90 author:
91 name: k33g91 name: k33g
@@ -94,7 +94,7 @@ tasks:
94 updatedAt: 2026-10-20T20:00:00.000Z94 updatedAt: 2026-10-20T20:00:00.000Z
95 - id: 495 - id: 4
96 title: "docsearch: walk the folders (extensions, size cap, skip hidden dirs, .git, node_modules), scanned at each call, no index — with tests"96 title: "docsearch: walk the folders (extensions, size cap, skip hidden dirs, .git, node_modules), scanned at each call, no index — with tests"
97- state: open97+ state: closed
98 priority: none98 priority: none
99 author:99 author:
100 name: k33g100 name: k33g
@@ -102,8 +102,8 @@ tasks:
102 createdAt: 2026-10-20T20:00:00.000Z102 createdAt: 2026-10-20T20:00:00.000Z
103 updatedAt: 2026-10-20T20:00:00.000Z103 updatedAt: 2026-10-20T20:00:00.000Z
104 - id: 5104 - id: 5
105- title: "built-in tool search_doc {query, limit?}: hits = path:line, heading path, snippet; declared only when the config enables it; never asks; ACP kind search — with tests"105+ title: "built-in tool search_docs {query, limit?} and the /docs <words> command: hits = path:line, heading path, snippet; declared only when the config enables it; never asks; ACP kind search — with tests"
106- state: open106+ state: closed
107 priority: none107 priority: none
108 author:108 author:
109 name: k33g109 name: k33g
@@ -112,7 +112,7 @@ tasks:
112 updatedAt: 2026-10-20T20:00:00.000Z112 updatedAt: 2026-10-20T20:00:00.000Z
113 - id: 6113 - id: 6
114 title: "Measure on a small model (llmman) over mm's own docs/ (EN+FR): found when the words are there, not called otherwise; time per call on docs/"114 title: "Measure on a small model (llmman) over mm's own docs/ (EN+FR): found when the words are there, not called otherwise; time per call on docs/"
115- state: open115+ state: closed
116 priority: none116 priority: none
117 author:117 author:
118 name: k33g118 name: k33g
@@ -121,11 +121,96 @@ tasks:
121 updatedAt: 2026-10-20T20:00:00.000Z121 updatedAt: 2026-10-20T20:00:00.000Z
122 - id: 7122 - id: 7
123 title: "Docs EN+FR (reference configuration and tools, how-to, explanation: why BM25 and no index), demo if useful, quality gate, .memory"123 title: "Docs EN+FR (reference configuration and tools, how-to, explanation: why BM25 and no index), demo if useful, quality gate, .memory"
124- state: open124+ state: closed
125 priority: none125 priority: none
126 author:126 author:
127 name: k33g127 name: k33g
128 email: ph.charriere@gmail.com128 email: ph.charriere@gmail.com
129 createdAt: 2026-10-20T20:00:00.000Z129 createdAt: 2026-10-20T20:00:00.000Z
130 updatedAt: 2026-10-20T20:00:00.000Z130 updatedAt: 2026-10-20T20:00:00.000Z
131-comments: []131+comments:
132+ - id: 1
133+ author:
134+ name: k33g
135+ email: ph.charriere@gmail.com
136+ createdAt: 2026-10-20T21:00:00.000Z
137+ body: |
138+ Decided by the user (2026-10-20) — the proposal is validated, with:
139+ - tool name: search_docs (plural);
140+ - config block `docSearch`, with TWO switches instead of `enabled`:
141+ `searchDocs: true` → the /docs <words> command (for the user),
142+ `autoSearchDocs: true` → the search_docs tool (for the model);
143+ both default false, independent (as /memory and search_memory);
144+ - /docs: yes;
145+ - prefix matching for words of 4+ letters: yes;
146+ - extensions: only what the configuration lists (no hidden extra);
147+ the default list is the validated one (.md .markdown .txt .adoc
148+ .asciidoc .rst); symlinks not followed;
149+ - key name: docSearch.
150+ Then: demos/docs-search, whose documents are a set of simple
151+ tutorials on using ratatui in Rust (Markdown, generated for the demo).
152+ Branch feature/search-in-doc (created by the user).
153+ - id: 2
154+ author:
155+ name: k33g
156+ email: ph.charriere@gmail.com
157+ createdAt: 2026-10-20T23:00:00.000Z
158+ body: |
159+ Tasks 2-6 done on feature/search-in-doc (go test ./... green, gofmt/vet
160+ clean):
161+ - internal/docsearch (new): passages.go (sections at # / = headings,
162+ fences and listing blocks ignored, heading path, cut at blank lines
163+ past 1,500 runes, never inside a fence), bm25.go (k1 1.2, b 0.75,
164+ heading-path words +2, prefix match for 4+ runes, plural/-ed within
165+ 2 runes, query words < 3 runes dropped beside longer ones), walk.go
166+ (listed extensions only, maxFileSize, hidden dirs + node_modules
167+ skipped, symlinks not followed, NUL = binary, missing dirs
168+ reported), docsearch.go (Search, Result.Format), configured.go
169+ (Configured/Answer from config).
170+ - internal/textfold (new): Fold/Rune moved out of store (shared by
171+ store and docsearch, no duplicate).
172+ - config/docsearch.go: docSearch (searchDocs, autoSearchDocs, dirs
173+ [docs], extensions [.md .markdown .txt .adoc .asciidoc .rst],
174+ maxFileSize 1MB — ByteSize reads 1MB / 512KB / bytes); validated
175+ only when a switch is on; search_docs reserved.
176+ - command/docs.go SetDocs; /docs in the REPL, the window (notice) and
177+ ACP (agent text, input hint); tools/searchdocs.go search_docs
178+ (query, limit? — 5, cap 20), never asks, ACP kind search, dir = the
179+ sink's WorkDir else the process cwd; main.go wiring.
180+ - Speed (best of 10): mm's docs/ 137 files / 1,320 passages ≈ 45 ms;
181+ the demo's 10 files / 44 passages ≈ 1 ms.
182+ demos/docs-search: Rata, Qwen3.5 4B on llmman, 9 Ratatui 0.30
183+ tutorials + index; check-tutorials.sh compiles every complete program
184+ + snippets.rs in a rust container (-Dwarnings): OK, 10 programs,
185+ ratatui 0.30.2. Measured (temp 0, 3 runs each, identical): List →
186+ ListState, 04-lists.md:5 3/3; popup → Clear + centered, NO link 3/3;
187+ Windows double key → right answer (KeyEventKind::Press) 3/3 but cited
188+ code examples instead of 08:60 (ranked first by the search); progress
189+ bar → Gauge + event::poll, 06:9 3/3; two French questions → English
190+ searches, French answers, right links 6/6; "Write src/main.rs …" →
191+ file written 3/3, compiles against ratatui 0.30.2 3/3; greeting and
192+ an off-topic question → no search 6/6. Prompt lesson: the Windows
193+ question was answered without a search (3/3) until the prompt said any
194+ terminal-app question is a Ratatui question — after which the popup
195+ answer stopped citing its link.
196+ Task 7: docs EN+FR done (reference configuration + validation errors,
197+ slash-commands, tools, acp; how-to search-the-docs; explanation
198+ document-search; indexes; root README; packages.drawio). Quality gate
199+ NOT run (qlty absent here) — task 7 stays open for it.
200+ - id: 3
201+ author:
202+ name: k33g
203+ email: ph.charriere@gmail.com
204+ createdAt: 2026-10-20T23:30:00.000Z
205+ body: |
206+ Closed at the user's request. Delivered: internal/docsearch +
207+ internal/textfold, docSearch config (searchDocs / autoSearchDocs),
208+ /docs in the three front ends, the search_docs tool, tests, docs EN+FR
209+ (reference, how-to search-the-docs, explanation document-search),
210+ demos/docs-search (Ratatui tutorials, compile-checked, measured).
211+ Task 7 closed with the qlty quality gate still NOT run (qlty is not
212+ installed in this sandbox); gofmt and go vet clean, go test ./...
213+ green. Known limits left as documented: words, not meanings; in the
214+ demo the popup answer gives no link and the Windows answer cites code
215+ examples rather than the explaining passage.
216+
modified README.md +2 -0
@@ -121,6 +121,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
121121 - **Plans**: the built-in `plan` tool lets the agent turn a list of tasks into a tracked plan and show its progress after each answer. See [Manage plans and tasks](docs/en/how-to/manage-plans-and-tasks.md).
122122 - **Save an answer**: `/save` writes the last answer to a Markdown file; with `saveAnswerTool: true`, the model does it itself when you ask in your own words (`save_answer`). Never overwrites a file. See [Save an answer to a file](docs/en/how-to/save-an-answer.md).
123123 - **Unload a model**: `/unload [model]` frees a model from the server's memory — in the terminal, the window and an editor —, and the `unload_model` tool lets the agent do it itself, e.g. on another agent's model once it has answered; llmman, Ollama and llama-server in router mode. `unloadModels: false` turns both off. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/unload-models/`.
124+- **Search a documentation folder**: `/docs <words>` searches the text files of the folders named in `docSearch.dirs` (`.md`, `.adoc`, `.txt`… — only the extensions listed) and returns the best passages with their `path:line` link, ranked with BM25 — no index, no model (`docSearch.searchDocs`); the `search_docs` tool lets the model do it before it answers (`docSearch.autoSearchDocs`); both off by default. See [Search a documentation folder](docs/en/how-to/search-the-docs.md) and `demos/docs-search/`.
124125 - **Search earlier sessions**: with sessions on, `/memory <words>` searches the project's stored conversations (`sessions.searchMemory`), and the `search_memory` tool lets the model do it when a question refers to something decided before (`sessions.autoSearchMemory`); both off by default. The search matches words, not meanings. See [Search earlier sessions](docs/en/how-to/search-earlier-sessions.md) and `demos/memory-search/`.
125126 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).
126127
@@ -155,6 +156,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h
155156 | `demos/guard/` | one guard model (Granite Guardian 3.2 3B-A800M on llmman) answering `Yes`/`No` to "is this question harmful?"; why its `system:` line is a risk name, not a prompt; custom risks |
156157 | `demos/guards/` | an assistant (Gemma 4 E4B) protected by seven guard agents, one per risk: a Node.js `preGeneration` hook asks them in parallel without the history, the assistant refuses or answers according to their verdicts; `displayHooks: true`; measured results |
157158 | `demos/unload-models/` | a coordinator (Qwen3.5 9B) hands programming questions to an expert (Qwen3.5 4B) with `ask_agent`, a `preAsk` hook unloads the coordinator's own model before the expert starts and a `postAsk` hook unloads the expert's once it has answered — the two models are never loaded together; `/unload` and `unload_model` on request; llmman, measured results |
159+| `demos/docs-search/` | an assistant answering from a documentation folder: nine Ratatui tutorials (Rust terminal UIs, each program checked against ratatui 0.30 by `check-tutorials.sh`), `/docs <words>` for you and the `search_docs` tool for the model; Qwen3.5 4B on llmman, measured results |
158160 | `demos/memory-search/` | an assistant that remembers a project's decisions: four earlier sessions (port, storage, logs in French, deployment as a `/compact` summary), `/memory <words>` for you and the `search_memory` tool for the model; Qwen3.5 4B on llmman, measured results — including what a word search cannot find |
159161 | `demos/declared-tools/` | an ops desk with no `bash` and no skills, acting only through four tools declared under `tools:` — release, version bump, service logs, slow requests — covering every parameter type (string, enum, boolean, integer, number), a safe default (`dryRun: true`) and an optional argument; Mellum2 12B on DMR, measured results |
160162
@@ -121,6 +121,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
121 - **Plans**: the built-in `plan` tool lets the agent turn a list of tasks into a tracked plan and show its progress after each answer. See [Manage plans and tasks](docs/en/how-to/manage-plans-and-tasks.md).121 - **Plans**: the built-in `plan` tool lets the agent turn a list of tasks into a tracked plan and show its progress after each answer. See [Manage plans and tasks](docs/en/how-to/manage-plans-and-tasks.md).
122 - **Save an answer**: `/save` writes the last answer to a Markdown file; with `saveAnswerTool: true`, the model does it itself when you ask in your own words (`save_answer`). Never overwrites a file. See [Save an answer to a file](docs/en/how-to/save-an-answer.md).122 - **Save an answer**: `/save` writes the last answer to a Markdown file; with `saveAnswerTool: true`, the model does it itself when you ask in your own words (`save_answer`). Never overwrites a file. See [Save an answer to a file](docs/en/how-to/save-an-answer.md).
123 - **Unload a model**: `/unload [model]` frees a model from the server's memory — in the terminal, the window and an editor —, and the `unload_model` tool lets the agent do it itself, e.g. on another agent's model once it has answered; llmman, Ollama and llama-server in router mode. `unloadModels: false` turns both off. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/unload-models/`.123 - **Unload a model**: `/unload [model]` frees a model from the server's memory — in the terminal, the window and an editor —, and the `unload_model` tool lets the agent do it itself, e.g. on another agent's model once it has answered; llmman, Ollama and llama-server in router mode. `unloadModels: false` turns both off. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/unload-models/`.
124+- **Search a documentation folder**: `/docs <words>` searches the text files of the folders named in `docSearch.dirs` (`.md`, `.adoc`, `.txt`… — only the extensions listed) and returns the best passages with their `path:line` link, ranked with BM25 — no index, no model (`docSearch.searchDocs`); the `search_docs` tool lets the model do it before it answers (`docSearch.autoSearchDocs`); both off by default. See [Search a documentation folder](docs/en/how-to/search-the-docs.md) and `demos/docs-search/`.
124 - **Search earlier sessions**: with sessions on, `/memory <words>` searches the project's stored conversations (`sessions.searchMemory`), and the `search_memory` tool lets the model do it when a question refers to something decided before (`sessions.autoSearchMemory`); both off by default. The search matches words, not meanings. See [Search earlier sessions](docs/en/how-to/search-earlier-sessions.md) and `demos/memory-search/`.125 - **Search earlier sessions**: with sessions on, `/memory <words>` searches the project's stored conversations (`sessions.searchMemory`), and the `search_memory` tool lets the model do it when a question refers to something decided before (`sessions.autoSearchMemory`); both off by default. The search matches words, not meanings. See [Search earlier sessions](docs/en/how-to/search-earlier-sessions.md) and `demos/memory-search/`.
125 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).126 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).
126 127
@@ -155,6 +156,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h
155 | `demos/guard/` | one guard model (Granite Guardian 3.2 3B-A800M on llmman) answering `Yes`/`No` to "is this question harmful?"; why its `system:` line is a risk name, not a prompt; custom risks |156 | `demos/guard/` | one guard model (Granite Guardian 3.2 3B-A800M on llmman) answering `Yes`/`No` to "is this question harmful?"; why its `system:` line is a risk name, not a prompt; custom risks |
156 | `demos/guards/` | an assistant (Gemma 4 E4B) protected by seven guard agents, one per risk: a Node.js `preGeneration` hook asks them in parallel without the history, the assistant refuses or answers according to their verdicts; `displayHooks: true`; measured results |157 | `demos/guards/` | an assistant (Gemma 4 E4B) protected by seven guard agents, one per risk: a Node.js `preGeneration` hook asks them in parallel without the history, the assistant refuses or answers according to their verdicts; `displayHooks: true`; measured results |
157 | `demos/unload-models/` | a coordinator (Qwen3.5 9B) hands programming questions to an expert (Qwen3.5 4B) with `ask_agent`, a `preAsk` hook unloads the coordinator's own model before the expert starts and a `postAsk` hook unloads the expert's once it has answered — the two models are never loaded together; `/unload` and `unload_model` on request; llmman, measured results |158 | `demos/unload-models/` | a coordinator (Qwen3.5 9B) hands programming questions to an expert (Qwen3.5 4B) with `ask_agent`, a `preAsk` hook unloads the coordinator's own model before the expert starts and a `postAsk` hook unloads the expert's once it has answered — the two models are never loaded together; `/unload` and `unload_model` on request; llmman, measured results |
159+| `demos/docs-search/` | an assistant answering from a documentation folder: nine Ratatui tutorials (Rust terminal UIs, each program checked against ratatui 0.30 by `check-tutorials.sh`), `/docs <words>` for you and the `search_docs` tool for the model; Qwen3.5 4B on llmman, measured results |
158 | `demos/memory-search/` | an assistant that remembers a project's decisions: four earlier sessions (port, storage, logs in French, deployment as a `/compact` summary), `/memory <words>` for you and the `search_memory` tool for the model; Qwen3.5 4B on llmman, measured results — including what a word search cannot find |160 | `demos/memory-search/` | an assistant that remembers a project's decisions: four earlier sessions (port, storage, logs in French, deployment as a `/compact` summary), `/memory <words>` for you and the `search_memory` tool for the model; Qwen3.5 4B on llmman, measured results — including what a word search cannot find |
159 | `demos/declared-tools/` | an ops desk with no `bash` and no skills, acting only through four tools declared under `tools:` — release, version bump, service logs, slow requests — covering every parameter type (string, enum, boolean, integer, number), a safe default (`dryRun: true`) and an optional argument; Mellum2 12B on DMR, measured results |161 | `demos/declared-tools/` | an ops desk with no `bash` and no skills, acting only through four tools declared under `tools:` — release, version bump, service logs, slow requests — covering every parameter type (string, enum, boolean, integer, number), a safe default (`dryRun: true`) and an optional argument; Mellum2 12B on DMR, measured results |
160 162
added demos/docs-search/README.en.md +75 -0
new file mode 100644
@@ -0,0 +1,75 @@
1+# mini-me demo: answering from a documentation folder
2+
3+*[Version française](README.fr.md)*
4+
5+Rata helps people build terminal user interfaces in Rust with [Ratatui](https://ratatui.rs). It knows nothing about Ratatui by itself that you should trust: before it answers, it searches `docs/` — nine short tutorials — with the `search_docs` tool, answers from the passages it gets back, and gives their `path:line` link. You can search the same folder yourself with `/docs <words>`. The search is BM25 over the Markdown files: no index, no model. The chat model is Qwen3.5 4B, served by [llmman](https://github.com/llmmanorg/llmman).
6+
7+## What is here
8+
9+| File | Role |
10+|---|---|
11+| `agent.yaml` | Rata: `docSearch` with `searchDocs: true` (the `/docs` command) and `autoSearchDocs: true` (the `search_docs` tool) over `docs/`, `.md` only; `read_file` to open a whole tutorial, `write_file` to write a program; no `bash`. |
12+| `docs/` | Nine Ratatui 0.30 tutorials — getting started, layout, blocks and paragraphs, lists, tables, gauges and ticks, tabs, app state and events, popups — and their index. Each holds a complete program. |
13+| `check-tutorials.sh` | Compiles every complete program of `docs/` (and `snippets.rs`, the expressions quoted in the prose) against the current ratatui 0.30, in a throwaway `rust` container, warnings as errors. Needs Docker only. |
14+| `snippets.rs` | The short expressions the tutorials quote, so that the text cannot drift from the API. |
15+
16+## Run the demo
17+
18+```bash
19+llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+cd demos/docs-search
21+mm -tui agent.yaml
22+```
23+
24+Search by hand:
25+
26+```text
27+/docs liststate
28+```
29+
30+```text
31+🔎 5 passage(s) for "liststate" — 10 file(s) in docs
32+
33+── docs/04-lists.md:5 · Lists and selection › Keep the selection with ListState
34+## Keep the selection with ListState
35+
36+`ListState` remembers which item is selected and how far the list is scrolled. …
37+```
38+
39+Then ask Rata:
40+
41+```text
42+How do I keep the selected item of a List in ratatui?
43+Every key I press acts twice on Windows. Why?
44+Comment afficher un tableau avec une ligne sélectionnée ?
45+Write src/main.rs: a list of three fruits I can move through with the arrow keys, q to quit.
46+```
47+
48+Each search shows as `📚 search_docs: "<words>"` followed by the passages. Check the tutorials still compile with `./check-tutorials.sh` (`OK: 10 programs compile against ratatui 0.30.2` on 2026-10-20).
49+
50+## Measured results (2026-10-20, `mm -p`, llmman)
51+
52+Qwen3.5 4B Q4_K_M, `temperature: 0`, thinking off, this `agent.yaml`, a fresh copy of the demo for every run. Three runs per question; the same search and the same answer each time.
53+
54+| Question | Searched | Answer | Link given | |
55+|---|---|---|---|---|
56+| How do I keep the selected item of a List in ratatui? | `List selected item index` | `ListState`, `render_stateful_widget`, `state.selected()` | `docs/04-lists.md:5` | 3/3 |
57+| How do I show a popup in the middle of the screen? | `popup center middle screen` | `Clear` first, then the dialog in a centered area | none | 3/3 |
58+| Every key I press acts twice on Windows. Why? | `Windows key repeat double press` | Press and Release events; check `KeyEventKind::Press` | `01-getting-started.md:15`, `03-blocks-and-paragraphs.md:11` (code using the check), not the passage that explains it, `08-app-state-and-events.md:60` — which the search ranked first | 3/3 |
59+| How can I animate a progress bar without waiting for a key? | `animate progress bar key event` | `Gauge` + `event::poll` with a timeout, a tick when no event | `docs/06-gauges-and-ticks.md:9` | 3/3 |
60+| Comment découper l'écran en en-tête, corps et pied de page ? | `header footer body layout` | `Layout::vertical` with `Length` / `Min`, in French | `docs/02-layout.md:16` | 3/3 |
61+| Comment afficher un tableau avec une ligne sélectionnée ? | `table selection line` | `Table` + `TableState` + `row_highlight_style`, in French | `docs/05-tables.md:1` | 3/3 |
62+| Write src/main.rs: a list of three fruits I can move through with the arrow keys, q to quit. | `list widget` | `src/main.rs` written — it **compiles** against ratatui 0.30.2 (checked in the `rust` container) | — | 3/3 |
63+| Bonjour, qui es-tu ? | — | one line, in French, no search | — | 3/3 |
64+| What is the capital of Japan? One word. | — | `Tokyo`, no search | — | 3/3 |
65+
66+14 to 38 s per question with a search, under 2 s without; two runs took 164 s and 316 s with the same answer as the others (cause not established — the model server is shared). One search over `docs/` takes about 1 ms.
67+
68+## Good to know
69+
70+- **The model translates, not the search.** The tutorials are in English and the search matches words: French words would find nothing. Told to search "with English words — widget and type names", the model turned both French questions into English searches every time, and answered in French.
71+- **What the prompt had to say.** Without "any question about a terminal app … is a Ratatui question, even when it does not name Ratatui", the Windows question got a generic answer without a search (3/3). With it, the other answers stayed right, but the popup answer stopped giving its link (it did give `02-layout.md:68` and `09-popups.md:9` before) — a small model's citations move with the prompt.
72+- **Links are `path:line`**: an editor opens them, and `read_file` reads the whole tutorial when a passage is not enough.
73+- **Each switch on its own.** `searchDocs` gives you `/docs`; `autoSearchDocs` gives the model `search_docs`. Both are off by default.
74+
75+See also: [How to search a documentation folder](../../docs/en/how-to/search-the-docs.md), [`search_docs`](../../docs/en/reference/tools.md#search_docs), [Why it works this way](../../docs/en/explanation/document-search.md).
new file mode 100644
@@ -0,0 +1,75 @@
1+# mini-me demo: answering from a documentation folder
2+
3+*[Version française](README.fr.md)*
4+
5+Rata helps people build terminal user interfaces in Rust with [Ratatui](https://ratatui.rs). It knows nothing about Ratatui by itself that you should trust: before it answers, it searches `docs/` — nine short tutorials — with the `search_docs` tool, answers from the passages it gets back, and gives their `path:line` link. You can search the same folder yourself with `/docs <words>`. The search is BM25 over the Markdown files: no index, no model. The chat model is Qwen3.5 4B, served by [llmman](https://github.com/llmmanorg/llmman).
6+
7+## What is here
8+
9+| File | Role |
10+|---|---|
11+| `agent.yaml` | Rata: `docSearch` with `searchDocs: true` (the `/docs` command) and `autoSearchDocs: true` (the `search_docs` tool) over `docs/`, `.md` only; `read_file` to open a whole tutorial, `write_file` to write a program; no `bash`. |
12+| `docs/` | Nine Ratatui 0.30 tutorials — getting started, layout, blocks and paragraphs, lists, tables, gauges and ticks, tabs, app state and events, popups — and their index. Each holds a complete program. |
13+| `check-tutorials.sh` | Compiles every complete program of `docs/` (and `snippets.rs`, the expressions quoted in the prose) against the current ratatui 0.30, in a throwaway `rust` container, warnings as errors. Needs Docker only. |
14+| `snippets.rs` | The short expressions the tutorials quote, so that the text cannot drift from the API. |
15+
16+## Run the demo
17+
18+```bash
19+llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+cd demos/docs-search
21+mm -tui agent.yaml
22+```
23+
24+Search by hand:
25+
26+```text
27+/docs liststate
28+```
29+
30+```text
31+🔎 5 passage(s) for "liststate" — 10 file(s) in docs
32+
33+── docs/04-lists.md:5 · Lists and selection › Keep the selection with ListState
34+## Keep the selection with ListState
35+
36+`ListState` remembers which item is selected and how far the list is scrolled. …
37+```
38+
39+Then ask Rata:
40+
41+```text
42+How do I keep the selected item of a List in ratatui?
43+Every key I press acts twice on Windows. Why?
44+Comment afficher un tableau avec une ligne sélectionnée ?
45+Write src/main.rs: a list of three fruits I can move through with the arrow keys, q to quit.
46+```
47+
48+Each search shows as `📚 search_docs: "<words>"` followed by the passages. Check the tutorials still compile with `./check-tutorials.sh` (`OK: 10 programs compile against ratatui 0.30.2` on 2026-10-20).
49+
50+## Measured results (2026-10-20, `mm -p`, llmman)
51+
52+Qwen3.5 4B Q4_K_M, `temperature: 0`, thinking off, this `agent.yaml`, a fresh copy of the demo for every run. Three runs per question; the same search and the same answer each time.
53+
54+| Question | Searched | Answer | Link given | |
55+|---|---|---|---|---|
56+| How do I keep the selected item of a List in ratatui? | `List selected item index` | `ListState`, `render_stateful_widget`, `state.selected()` | `docs/04-lists.md:5` | 3/3 |
57+| How do I show a popup in the middle of the screen? | `popup center middle screen` | `Clear` first, then the dialog in a centered area | none | 3/3 |
58+| Every key I press acts twice on Windows. Why? | `Windows key repeat double press` | Press and Release events; check `KeyEventKind::Press` | `01-getting-started.md:15`, `03-blocks-and-paragraphs.md:11` (code using the check), not the passage that explains it, `08-app-state-and-events.md:60` — which the search ranked first | 3/3 |
59+| How can I animate a progress bar without waiting for a key? | `animate progress bar key event` | `Gauge` + `event::poll` with a timeout, a tick when no event | `docs/06-gauges-and-ticks.md:9` | 3/3 |
60+| Comment découper l'écran en en-tête, corps et pied de page ? | `header footer body layout` | `Layout::vertical` with `Length` / `Min`, in French | `docs/02-layout.md:16` | 3/3 |
61+| Comment afficher un tableau avec une ligne sélectionnée ? | `table selection line` | `Table` + `TableState` + `row_highlight_style`, in French | `docs/05-tables.md:1` | 3/3 |
62+| Write src/main.rs: a list of three fruits I can move through with the arrow keys, q to quit. | `list widget` | `src/main.rs` written — it **compiles** against ratatui 0.30.2 (checked in the `rust` container) | — | 3/3 |
63+| Bonjour, qui es-tu ? | — | one line, in French, no search | — | 3/3 |
64+| What is the capital of Japan? One word. | — | `Tokyo`, no search | — | 3/3 |
65+
66+14 to 38 s per question with a search, under 2 s without; two runs took 164 s and 316 s with the same answer as the others (cause not established — the model server is shared). One search over `docs/` takes about 1 ms.
67+
68+## Good to know
69+
70+- **The model translates, not the search.** The tutorials are in English and the search matches words: French words would find nothing. Told to search "with English words — widget and type names", the model turned both French questions into English searches every time, and answered in French.
71+- **What the prompt had to say.** Without "any question about a terminal app … is a Ratatui question, even when it does not name Ratatui", the Windows question got a generic answer without a search (3/3). With it, the other answers stayed right, but the popup answer stopped giving its link (it did give `02-layout.md:68` and `09-popups.md:9` before) — a small model's citations move with the prompt.
72+- **Links are `path:line`**: an editor opens them, and `read_file` reads the whole tutorial when a passage is not enough.
73+- **Each switch on its own.** `searchDocs` gives you `/docs`; `autoSearchDocs` gives the model `search_docs`. Both are off by default.
74+
75+See also: [How to search a documentation folder](../../docs/en/how-to/search-the-docs.md), [`search_docs`](../../docs/en/reference/tools.md#search_docs), [Why it works this way](../../docs/en/explanation/document-search.md).
added demos/docs-search/README.fr.md +75 -0
new file mode 100644
@@ -0,0 +1,75 @@
1+# Démo mini-me : répondre à partir d'un dossier de documentation
2+
3+*[English version](README.en.md)*
4+
5+Rata aide à construire des interfaces en mode terminal en Rust avec [Ratatui](https://ratatui.rs). Il ne sait rien de Ratatui par lui-même qui mérite confiance : avant de répondre, il cherche dans `docs/` — neuf tutoriels courts — avec l'outil `search_docs`, répond à partir des passages qu'il reçoit et donne leur lien `chemin:ligne`. Vous pouvez chercher vous-même dans le même dossier avec `/docs <mots>`. La recherche est un BM25 sur les fichiers Markdown : ni index, ni modèle. Le modèle de chat est Qwen3.5 4B, servi par [llmman](https://github.com/llmmanorg/llmman).
6+
7+## Contenu
8+
9+| Fichier | Rôle |
10+|---|---|
11+| `agent.yaml` | Rata : `docSearch` avec `searchDocs: true` (la commande `/docs`) et `autoSearchDocs: true` (l'outil `search_docs`) sur `docs/`, `.md` seulement ; `read_file` pour ouvrir un tutoriel entier, `write_file` pour écrire un programme ; pas de `bash`. |
12+| `docs/` | Neuf tutoriels Ratatui 0.30 — premiers pas, layout, blocs et paragraphes, listes, tableaux, jauges et ticks, onglets, état de l'application et événements, popups — et leur index. Chacun contient un programme complet. |
13+| `check-tutorials.sh` | Compile chaque programme complet de `docs/` (et `snippets.rs`, les expressions citées dans le texte) avec la ratatui 0.30 actuelle, dans un conteneur `rust` jetable, avertissements traités comme des erreurs. Ne demande que Docker. |
14+| `snippets.rs` | Les petites expressions que citent les tutoriels, pour que le texte ne puisse pas s'écarter de l'API. |
15+
16+## Lancer la démo
17+
18+```bash
19+llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+cd demos/docs-search
21+mm -tui agent.yaml
22+```
23+
24+Chercher à la main :
25+
26+```text
27+/docs liststate
28+```
29+
30+```text
31+🔎 5 passage(s) for "liststate" — 10 file(s) in docs
32+
33+── docs/04-lists.md:5 · Lists and selection › Keep the selection with ListState
34+## Keep the selection with ListState
35+
36+`ListState` remembers which item is selected and how far the list is scrolled. …
37+```
38+
39+Puis demander à Rata :
40+
41+```text
42+How do I keep the selected item of a List in ratatui?
43+Every key I press acts twice on Windows. Why?
44+Comment afficher un tableau avec une ligne sélectionnée ?
45+Write src/main.rs: a list of three fruits I can move through with the arrow keys, q to quit.
46+```
47+
48+Chaque recherche s'affiche sous la forme `📚 search_docs: "<mots>"` suivie des passages. Vérifiez que les tutoriels compilent toujours avec `./check-tutorials.sh` (`OK: 10 programs compile against ratatui 0.30.2` le 2026-10-20).
49+
50+## Résultats mesurés (2026-10-20, `mm -p`, llmman)
51+
52+Qwen3.5 4B Q4_K_M, `temperature: 0`, réflexion désactivée, cet `agent.yaml`, une copie neuve de la démo à chaque exécution. Trois exécutions par question ; la même recherche et la même réponse à chaque fois.
53+
54+| Question | Recherché | Réponse | Lien donné | |
55+|---|---|---|---|---|
56+| How do I keep the selected item of a List in ratatui? | `List selected item index` | `ListState`, `render_stateful_widget`, `state.selected()` | `docs/04-lists.md:5` | 3/3 |
57+| How do I show a popup in the middle of the screen? | `popup center middle screen` | `Clear` d'abord, puis le dialogue dans une zone centrée | aucun | 3/3 |
58+| Every key I press acts twice on Windows. Why? | `Windows key repeat double press` | événements Press et Release ; tester `KeyEventKind::Press` | `01-getting-started.md:15`, `03-blocks-and-paragraphs.md:11` (du code qui fait ce test), pas le passage qui l'explique, `08-app-state-and-events.md:60` — que la recherche avait classé premier | 3/3 |
59+| How can I animate a progress bar without waiting for a key? | `animate progress bar key event` | `Gauge` + `event::poll` avec délai, un tick quand rien n'arrive | `docs/06-gauges-and-ticks.md:9` | 3/3 |
60+| Comment découper l'écran en en-tête, corps et pied de page ? | `header footer body layout` | `Layout::vertical` avec `Length` / `Min`, en français | `docs/02-layout.md:16` | 3/3 |
61+| Comment afficher un tableau avec une ligne sélectionnée ? | `table selection line` | `Table` + `TableState` + `row_highlight_style`, en français | `docs/05-tables.md:1` | 3/3 |
62+| Write src/main.rs: a list of three fruits I can move through with the arrow keys, q to quit. | `list widget` | `src/main.rs` écrit — il **compile** avec ratatui 0.30.2 (vérifié dans le conteneur `rust`) | — | 3/3 |
63+| Bonjour, qui es-tu ? | — | une ligne, en français, sans recherche | — | 3/3 |
64+| What is the capital of Japan? One word. | — | `Tokyo`, sans recherche | — | 3/3 |
65+
66+De 14 à 38 s par question avec recherche, moins de 2 s sans ; deux exécutions ont pris 164 s et 316 s avec la même réponse que les autres (cause non établie — le serveur de modèles est partagé). Une recherche dans `docs/` prend environ 1 ms.
67+
68+## Bon à savoir
69+
70+- **C'est le modèle qui traduit, pas la recherche.** Les tutoriels sont en anglais et la recherche porte sur les mots : des mots français ne trouveraient rien. Invité à chercher « avec des mots anglais — noms de widgets et de types », le modèle a transformé les deux questions en français en recherches en anglais à chaque fois, et a répondu en français.
71+- **Ce que le prompt a dû dire.** Sans « toute question sur une application terminal … est une question Ratatui, même quand elle ne nomme pas Ratatui », la question sur Windows recevait une réponse générique, sans recherche (3/3). Avec cette phrase, les autres réponses sont restées justes, mais la réponse sur la popup a cessé de donner son lien (elle donnait `02-layout.md:68` et `09-popups.md:9` avant) — les citations d'un petit modèle bougent avec le prompt.
72+- **Les liens sont des `chemin:ligne`** : un éditeur les ouvre, et `read_file` lit le tutoriel entier quand un passage ne suffit pas.
73+- **Chaque interrupteur séparément.** `searchDocs` vous donne `/docs` ; `autoSearchDocs` donne `search_docs` au modèle. Les deux sont désactivés par défaut.
74+
75+Voir aussi : [Chercher dans un dossier de documentation](../../docs/fr/how-to/search-the-docs.md), [`search_docs`](../../docs/fr/reference/tools.md#search_docs), [Pourquoi elle fonctionne ainsi](../../docs/fr/explanation/document-search.md).
new file mode 100644
@@ -0,0 +1,75 @@
1+# Démo mini-me : répondre à partir d'un dossier de documentation
2+
3+*[English version](README.en.md)*
4+
5+Rata aide à construire des interfaces en mode terminal en Rust avec [Ratatui](https://ratatui.rs). Il ne sait rien de Ratatui par lui-même qui mérite confiance : avant de répondre, il cherche dans `docs/` — neuf tutoriels courts — avec l'outil `search_docs`, répond à partir des passages qu'il reçoit et donne leur lien `chemin:ligne`. Vous pouvez chercher vous-même dans le même dossier avec `/docs <mots>`. La recherche est un BM25 sur les fichiers Markdown : ni index, ni modèle. Le modèle de chat est Qwen3.5 4B, servi par [llmman](https://github.com/llmmanorg/llmman).
6+
7+## Contenu
8+
9+| Fichier | Rôle |
10+|---|---|
11+| `agent.yaml` | Rata : `docSearch` avec `searchDocs: true` (la commande `/docs`) et `autoSearchDocs: true` (l'outil `search_docs`) sur `docs/`, `.md` seulement ; `read_file` pour ouvrir un tutoriel entier, `write_file` pour écrire un programme ; pas de `bash`. |
12+| `docs/` | Neuf tutoriels Ratatui 0.30 — premiers pas, layout, blocs et paragraphes, listes, tableaux, jauges et ticks, onglets, état de l'application et événements, popups — et leur index. Chacun contient un programme complet. |
13+| `check-tutorials.sh` | Compile chaque programme complet de `docs/` (et `snippets.rs`, les expressions citées dans le texte) avec la ratatui 0.30 actuelle, dans un conteneur `rust` jetable, avertissements traités comme des erreurs. Ne demande que Docker. |
14+| `snippets.rs` | Les petites expressions que citent les tutoriels, pour que le texte ne puisse pas s'écarter de l'API. |
15+
16+## Lancer la démo
17+
18+```bash
19+llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+cd demos/docs-search
21+mm -tui agent.yaml
22+```
23+
24+Chercher à la main :
25+
26+```text
27+/docs liststate
28+```
29+
30+```text
31+🔎 5 passage(s) for "liststate" — 10 file(s) in docs
32+
33+── docs/04-lists.md:5 · Lists and selection › Keep the selection with ListState
34+## Keep the selection with ListState
35+
36+`ListState` remembers which item is selected and how far the list is scrolled. …
37+```
38+
39+Puis demander à Rata :
40+
41+```text
42+How do I keep the selected item of a List in ratatui?
43+Every key I press acts twice on Windows. Why?
44+Comment afficher un tableau avec une ligne sélectionnée ?
45+Write src/main.rs: a list of three fruits I can move through with the arrow keys, q to quit.
46+```
47+
48+Chaque recherche s'affiche sous la forme `📚 search_docs: "<mots>"` suivie des passages. Vérifiez que les tutoriels compilent toujours avec `./check-tutorials.sh` (`OK: 10 programs compile against ratatui 0.30.2` le 2026-10-20).
49+
50+## Résultats mesurés (2026-10-20, `mm -p`, llmman)
51+
52+Qwen3.5 4B Q4_K_M, `temperature: 0`, réflexion désactivée, cet `agent.yaml`, une copie neuve de la démo à chaque exécution. Trois exécutions par question ; la même recherche et la même réponse à chaque fois.
53+
54+| Question | Recherché | Réponse | Lien donné | |
55+|---|---|---|---|---|
56+| How do I keep the selected item of a List in ratatui? | `List selected item index` | `ListState`, `render_stateful_widget`, `state.selected()` | `docs/04-lists.md:5` | 3/3 |
57+| How do I show a popup in the middle of the screen? | `popup center middle screen` | `Clear` d'abord, puis le dialogue dans une zone centrée | aucun | 3/3 |
58+| Every key I press acts twice on Windows. Why? | `Windows key repeat double press` | événements Press et Release ; tester `KeyEventKind::Press` | `01-getting-started.md:15`, `03-blocks-and-paragraphs.md:11` (du code qui fait ce test), pas le passage qui l'explique, `08-app-state-and-events.md:60` — que la recherche avait classé premier | 3/3 |
59+| How can I animate a progress bar without waiting for a key? | `animate progress bar key event` | `Gauge` + `event::poll` avec délai, un tick quand rien n'arrive | `docs/06-gauges-and-ticks.md:9` | 3/3 |
60+| Comment découper l'écran en en-tête, corps et pied de page ? | `header footer body layout` | `Layout::vertical` avec `Length` / `Min`, en français | `docs/02-layout.md:16` | 3/3 |
61+| Comment afficher un tableau avec une ligne sélectionnée ? | `table selection line` | `Table` + `TableState` + `row_highlight_style`, en français | `docs/05-tables.md:1` | 3/3 |
62+| Write src/main.rs: a list of three fruits I can move through with the arrow keys, q to quit. | `list widget` | `src/main.rs` écrit — il **compile** avec ratatui 0.30.2 (vérifié dans le conteneur `rust`) | — | 3/3 |
63+| Bonjour, qui es-tu ? | — | une ligne, en français, sans recherche | — | 3/3 |
64+| What is the capital of Japan? One word. | — | `Tokyo`, sans recherche | — | 3/3 |
65+
66+De 14 à 38 s par question avec recherche, moins de 2 s sans ; deux exécutions ont pris 164 s et 316 s avec la même réponse que les autres (cause non établie — le serveur de modèles est partagé). Une recherche dans `docs/` prend environ 1 ms.
67+
68+## Bon à savoir
69+
70+- **C'est le modèle qui traduit, pas la recherche.** Les tutoriels sont en anglais et la recherche porte sur les mots : des mots français ne trouveraient rien. Invité à chercher « avec des mots anglais — noms de widgets et de types », le modèle a transformé les deux questions en français en recherches en anglais à chaque fois, et a répondu en français.
71+- **Ce que le prompt a dû dire.** Sans « toute question sur une application terminal … est une question Ratatui, même quand elle ne nomme pas Ratatui », la question sur Windows recevait une réponse générique, sans recherche (3/3). Avec cette phrase, les autres réponses sont restées justes, mais la réponse sur la popup a cessé de donner son lien (elle donnait `02-layout.md:68` et `09-popups.md:9` avant) — les citations d'un petit modèle bougent avec le prompt.
72+- **Les liens sont des `chemin:ligne`** : un éditeur les ouvre, et `read_file` lit le tutoriel entier quand un passage ne suffit pas.
73+- **Chaque interrupteur séparément.** `searchDocs` vous donne `/docs` ; `autoSearchDocs` donne `search_docs` au modèle. Les deux sont désactivés par défaut.
74+
75+Voir aussi : [Chercher dans un dossier de documentation](../../docs/fr/how-to/search-the-docs.md), [`search_docs`](../../docs/fr/reference/tools.md#search_docs), [Pourquoi elle fonctionne ainsi](../../docs/fr/explanation/document-search.md).
added demos/docs-search/agent.yaml +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+# Docs search — an assistant that answers from a documentation folder:
2+#
3+# /docs <words> the user's search through docs/ (docSearch.searchDocs)
4+# search_docs the same search, called by the model before it answers
5+# (docSearch.autoSearchDocs)
6+#
7+# docs/ holds nine short tutorials on Ratatui (Rust terminal UIs), each a
8+# complete program checked by ./check-tutorials.sh against ratatui 0.30.
9+#
10+# llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
11+# llmman serve
12+#
13+# cd demos/docs-search
14+# mm -tui agent.yaml or mm -p "…" agent.yaml
15+
16+provider: llmman
17+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
18+baseUrl: http://127.0.0.1:17434/v1
19+fallback: http://host.docker.internal:17434/v1
20+contextWindow: 16384
21+
22+docSearch:
23+ # /docs <words>, for you.
24+ searchDocs: true
25+ # search_docs, for the model.
26+ autoSearchDocs: true
27+ dirs: [docs]
28+ extensions: [.md]
29+ maxFileSize: 1MB
30+
31+# read_file to open a whole tutorial from a link, write_file to write a
32+# program the user asks for; no bash.
33+yolo: true
34+bashTool: false
35+editTools: true
36+planTool: false
37+askUserTool: false
38+skillsDir: no-skills
39+
40+agentsMd:
41+ enabled: false
42+
43+sampling:
44+ temperature: 0.0
45+ parallel_tool_calls: false
46+ max_tokens: 3072
47+ chat_template_kwargs:
48+ enable_thinking: false
49+
50+maxTurns: 8
51+watchdogTimeout: 2m
52+firstTokenTimeout: 3m
53+displayCommands: true
54+
55+acp:
56+ name: rata
57+ title: 🐀 Rata (Ratatui docs)
58+
59+system: |
60+ Your name is Rata. You help people build terminal user interfaces in Rust
61+ with Ratatui. The folder docs/ holds Ratatui tutorials, written in English.
62+
63+ Any question about a terminal app — widgets, layout, keys, events,
64+ colors, the terminal itself — is a Ratatui question, even when it does
65+ not name Ratatui. For each one, call search_docs first, with English
66+ words — widget and type names work best (List, ListState, Layout, Gauge,
67+ Tabs, Clear, event::poll…). Answer from the passages it returns, and end
68+ with the link of the passage you used (path:line). If a passage is not
69+ enough, read_file the tutorial it comes from. Never invent an API: if
70+ search_docs finds nothing, try other English words once, then say the
71+ tutorials do not cover it.
72+
73+ When the user asks you to write or create a file, call write_file with
74+ the whole content; do not only show the code.
75+
76+ For anything else (a greeting, a question that is not about Ratatui),
77+ answer directly, without search_docs.
78+
79+ Always answer in the language of the user's question.
new file mode 100644
@@ -0,0 +1,79 @@
1+# Docs search — an assistant that answers from a documentation folder:
2+#
3+# /docs <words> the user's search through docs/ (docSearch.searchDocs)
4+# search_docs the same search, called by the model before it answers
5+# (docSearch.autoSearchDocs)
6+#
7+# docs/ holds nine short tutorials on Ratatui (Rust terminal UIs), each a
8+# complete program checked by ./check-tutorials.sh against ratatui 0.30.
9+#
10+# llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
11+# llmman serve
12+#
13+# cd demos/docs-search
14+# mm -tui agent.yaml or mm -p "…" agent.yaml
15+
16+provider: llmman
17+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
18+baseUrl: http://127.0.0.1:17434/v1
19+fallback: http://host.docker.internal:17434/v1
20+contextWindow: 16384
21+
22+docSearch:
23+ # /docs <words>, for you.
24+ searchDocs: true
25+ # search_docs, for the model.
26+ autoSearchDocs: true
27+ dirs: [docs]
28+ extensions: [.md]
29+ maxFileSize: 1MB
30+
31+# read_file to open a whole tutorial from a link, write_file to write a
32+# program the user asks for; no bash.
33+yolo: true
34+bashTool: false
35+editTools: true
36+planTool: false
37+askUserTool: false
38+skillsDir: no-skills
39+
40+agentsMd:
41+ enabled: false
42+
43+sampling:
44+ temperature: 0.0
45+ parallel_tool_calls: false
46+ max_tokens: 3072
47+ chat_template_kwargs:
48+ enable_thinking: false
49+
50+maxTurns: 8
51+watchdogTimeout: 2m
52+firstTokenTimeout: 3m
53+displayCommands: true
54+
55+acp:
56+ name: rata
57+ title: 🐀 Rata (Ratatui docs)
58+
59+system: |
60+ Your name is Rata. You help people build terminal user interfaces in Rust
61+ with Ratatui. The folder docs/ holds Ratatui tutorials, written in English.
62+
63+ Any question about a terminal app — widgets, layout, keys, events,
64+ colors, the terminal itself — is a Ratatui question, even when it does
65+ not name Ratatui. For each one, call search_docs first, with English
66+ words — widget and type names work best (List, ListState, Layout, Gauge,
67+ Tabs, Clear, event::poll…). Answer from the passages it returns, and end
68+ with the link of the passage you used (path:line). If a passage is not
69+ enough, read_file the tutorial it comes from. Never invent an API: if
70+ search_docs finds nothing, try other English words once, then say the
71+ tutorials do not cover it.
72+
73+ When the user asks you to write or create a file, call write_file with
74+ the whole content; do not only show the code.
75+
76+ For anything else (a greeting, a question that is not about Ratatui),
77+ answer directly, without search_docs.
78+
79+ Always answer in the language of the user's question.
added demos/docs-search/check-tutorials.sh +37 -0
new file mode 100755
@@ -0,0 +1,37 @@
1+#!/bin/sh
2+# check-tutorials.sh — compiles every complete program of docs/*.md (the
3+# ```rust blocks that hold a `fn main`) against the current ratatui 0.30, in
4+# a throwaway Rust container: nothing to install but Docker. Also compiles
5+# snippets.rs, the short expressions the tutorials quote in their prose.
6+#
7+# ./check-tutorials.sh → "OK: N programs compile" or cargo's errors
8+set -eu
9+cd "$(dirname "$0")"
10+work=$(mktemp -d)
11+trap 'rm -rf "$work"' EXIT
12+mkdir -p "$work/src/bin"
13+cat > "$work/Cargo.toml" <<'TOML'
14+[package]
15+name = "tutorials"
16+version = "0.1.0"
17+edition = "2021"
18+
19+[dependencies]
20+ratatui = "0.30"
21+TOML
22+n=0
23+for doc in docs/*.md; do
24+ # one file per ```rust block holding fn main (```rust,ignore blocks are skipped)
25+ awk -v out="$work/src/bin/$(basename "$doc" .md)" '
26+ /^```rust$/ { inblock = 1; buf = ""; next }
27+ /^```/ && inblock { inblock = 0; if (buf ~ /fn main/) { k++; f = out "-" k ".rs"; printf "%s", buf > f; close(f) } next }
28+ inblock { buf = buf $0 "\n" }
29+ ' "$doc"
30+done
31+cp snippets.rs "$work/src/bin/snippets.rs"
32+n=$(ls "$work/src/bin" | wc -l | tr -d ' ')
33+# As the calling user (files in $work stay removable), with a cargo home
34+# inside $work since the image's own one belongs to root.
35+docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/work/.cargo -e RUSTFLAGS=-Dwarnings \
36+ -v "$work":/work -w /work rust:1-slim cargo build --quiet --bins
37+echo "OK: $n programs compile against ratatui $(grep -A1 'name = "ratatui"' "$work/Cargo.lock" | sed -n 's/version = "\(.*\)"/\1/p')"
new file mode 100755
@@ -0,0 +1,37 @@
1+#!/bin/sh
2+# check-tutorials.sh — compiles every complete program of docs/*.md (the
3+# ```rust blocks that hold a `fn main`) against the current ratatui 0.30, in
4+# a throwaway Rust container: nothing to install but Docker. Also compiles
5+# snippets.rs, the short expressions the tutorials quote in their prose.
6+#
7+# ./check-tutorials.sh → "OK: N programs compile" or cargo's errors
8+set -eu
9+cd "$(dirname "$0")"
10+work=$(mktemp -d)
11+trap 'rm -rf "$work"' EXIT
12+mkdir -p "$work/src/bin"
13+cat > "$work/Cargo.toml" <<'TOML'
14+[package]
15+name = "tutorials"
16+version = "0.1.0"
17+edition = "2021"
18+
19+[dependencies]
20+ratatui = "0.30"
21+TOML
22+n=0
23+for doc in docs/*.md; do
24+ # one file per ```rust block holding fn main (```rust,ignore blocks are skipped)
25+ awk -v out="$work/src/bin/$(basename "$doc" .md)" '
26+ /^```rust$/ { inblock = 1; buf = ""; next }
27+ /^```/ && inblock { inblock = 0; if (buf ~ /fn main/) { k++; f = out "-" k ".rs"; printf "%s", buf > f; close(f) } next }
28+ inblock { buf = buf $0 "\n" }
29+ ' "$doc"
30+done
31+cp snippets.rs "$work/src/bin/snippets.rs"
32+n=$(ls "$work/src/bin" | wc -l | tr -d ' ')
33+# As the calling user (files in $work stay removable), with a cargo home
34+# inside $work since the image's own one belongs to root.
35+docker run --rm --user "$(id -u):$(id -g)" -e CARGO_HOME=/work/.cargo -e RUSTFLAGS=-Dwarnings \
36+ -v "$work":/work -w /work rust:1-slim cargo build --quiet --bins
37+echo "OK: $n programs compile against ratatui $(grep -A1 'name = "ratatui"' "$work/Cargo.lock" | sed -n 's/version = "\(.*\)"/\1/p')"
added demos/docs-search/docs/01-getting-started.md +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+# Getting started with Ratatui
2+
3+Ratatui is a Rust library for building terminal user interfaces (TUIs). It draws widgets — blocks, paragraphs, lists, tables, gauges — into a buffer, and a backend (Crossterm by default) writes that buffer to the terminal. Ratatui does not read the keyboard itself: events come from the backend, here Crossterm, which Ratatui re-exports as `ratatui::crossterm`.
4+
5+## Create the project
6+
7+```sh
8+cargo new hello-tui
9+cd hello-tui
10+cargo add ratatui
11+```
12+
13+`cargo add ratatui` adds the current version (0.30 at the time of writing). The Crossterm backend is enabled by default, so there is nothing else to add: use `ratatui::crossterm` rather than a separate `crossterm` dependency, and the two versions can never disagree.
14+
15+## The smallest app
16+
17+Replace `src/main.rs` with:
18+
19+```rust
20+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
21+use ratatui::DefaultTerminal;
22+
23+fn main() -> std::io::Result<()> {
24+ let mut terminal = ratatui::init();
25+ let result = run(&mut terminal);
26+ ratatui::restore();
27+ result
28+}
29+
30+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
31+ loop {
32+ terminal.draw(|frame| {
33+ frame.render_widget("Hello, Ratatui! Press q to quit.", frame.area());
34+ })?;
35+ if let Event::Key(key) = event::read()? {
36+ if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
37+ return Ok(());
38+ }
39+ }
40+ }
41+}
42+```
43+
44+Run it with `cargo run`. The text fills the top-left of the terminal; `q` quits.
45+
46+## What init and restore do
47+
48+`ratatui::init()` switches the terminal to raw mode (keys arrive one by one, unechoed) and to the alternate screen (your shell's content comes back when the app ends), and installs a panic hook that restores the terminal if the app panics. `ratatui::restore()` undoes both. Always call `restore` before `main` returns, even on error — that is why the loop lives in its own `run` function whose result is returned after `restore`.
49+
50+## The draw loop
51+
52+`terminal.draw` takes a closure that receives a `Frame`. `frame.area()` is the whole terminal as a `Rect`; `frame.render_widget(widget, area)` draws a widget into a part of it. Ratatui redraws the whole UI every time: it keeps the previous buffer and only sends the cells that changed, so drawing everything each loop is cheap. A `&str` is itself a widget, which is why the smallest app can render a plain string.
53+
54+## Next steps
55+
56+- Split the screen into areas: see the layout tutorial.
57+- Draw borders, titles and wrapped text: see the blocks and paragraphs tutorial.
58+- Keep state and react to keys: see the app state and events tutorial.
new file mode 100644
@@ -0,0 +1,58 @@
1+# Getting started with Ratatui
2+
3+Ratatui is a Rust library for building terminal user interfaces (TUIs). It draws widgets — blocks, paragraphs, lists, tables, gauges — into a buffer, and a backend (Crossterm by default) writes that buffer to the terminal. Ratatui does not read the keyboard itself: events come from the backend, here Crossterm, which Ratatui re-exports as `ratatui::crossterm`.
4+
5+## Create the project
6+
7+```sh
8+cargo new hello-tui
9+cd hello-tui
10+cargo add ratatui
11+```
12+
13+`cargo add ratatui` adds the current version (0.30 at the time of writing). The Crossterm backend is enabled by default, so there is nothing else to add: use `ratatui::crossterm` rather than a separate `crossterm` dependency, and the two versions can never disagree.
14+
15+## The smallest app
16+
17+Replace `src/main.rs` with:
18+
19+```rust
20+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
21+use ratatui::DefaultTerminal;
22+
23+fn main() -> std::io::Result<()> {
24+ let mut terminal = ratatui::init();
25+ let result = run(&mut terminal);
26+ ratatui::restore();
27+ result
28+}
29+
30+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
31+ loop {
32+ terminal.draw(|frame| {
33+ frame.render_widget("Hello, Ratatui! Press q to quit.", frame.area());
34+ })?;
35+ if let Event::Key(key) = event::read()? {
36+ if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
37+ return Ok(());
38+ }
39+ }
40+ }
41+}
42+```
43+
44+Run it with `cargo run`. The text fills the top-left of the terminal; `q` quits.
45+
46+## What init and restore do
47+
48+`ratatui::init()` switches the terminal to raw mode (keys arrive one by one, unechoed) and to the alternate screen (your shell's content comes back when the app ends), and installs a panic hook that restores the terminal if the app panics. `ratatui::restore()` undoes both. Always call `restore` before `main` returns, even on error — that is why the loop lives in its own `run` function whose result is returned after `restore`.
49+
50+## The draw loop
51+
52+`terminal.draw` takes a closure that receives a `Frame`. `frame.area()` is the whole terminal as a `Rect`; `frame.render_widget(widget, area)` draws a widget into a part of it. Ratatui redraws the whole UI every time: it keeps the previous buffer and only sends the cells that changed, so drawing everything each loop is cheap. A `&str` is itself a widget, which is why the smallest app can render a plain string.
53+
54+## Next steps
55+
56+- Split the screen into areas: see the layout tutorial.
57+- Draw borders, titles and wrapped text: see the blocks and paragraphs tutorial.
58+- Keep state and react to keys: see the app state and events tutorial.
added demos/docs-search/docs/02-layout.md +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+# Splitting the screen with Layout
2+
3+A `Layout` cuts a `Rect` into smaller ones, vertically or horizontally, following a list of constraints. Layouts nest: split the screen into rows, then split a row into columns.
4+
5+## Constraints
6+
7+| Constraint | Meaning |
8+|---|---|
9+| `Constraint::Length(n)` | exactly `n` rows (or columns) |
10+| `Constraint::Min(n)` | at least `n`, takes what is left |
11+| `Constraint::Max(n)` | at most `n` |
12+| `Constraint::Percentage(p)` | `p` % of the space |
13+| `Constraint::Ratio(a, b)` | `a / b` of the space |
14+| `Constraint::Fill(w)` | shares the remaining space with the other `Fill`s, by weight `w` |
15+
16+## Header, body, footer
17+
18+`Layout::vertical` stacks areas from top to bottom; `areas` returns them as an array you can destructure, so the number of areas is checked at compile time.
19+
20+```rust
21+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
22+use ratatui::layout::{Constraint, Layout};
23+use ratatui::widgets::{Block, Paragraph};
24+use ratatui::{DefaultTerminal, Frame};
25+
26+fn main() -> std::io::Result<()> {
27+ let mut terminal = ratatui::init();
28+ let result = run(&mut terminal);
29+ ratatui::restore();
30+ result
31+}
32+
33+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
34+ loop {
35+ terminal.draw(draw)?;
36+ if let Event::Key(key) = event::read()? {
37+ if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
38+ return Ok(());
39+ }
40+ }
41+ }
42+}
43+
44+fn draw(frame: &mut Frame) {
45+ let [header, body, footer] = Layout::vertical([
46+ Constraint::Length(3),
47+ Constraint::Min(0),
48+ Constraint::Length(1),
49+ ])
50+ .areas(frame.area());
51+
52+ let [left, right] =
53+ Layout::horizontal([Constraint::Percentage(30), Constraint::Fill(1)]).areas(body);
54+
55+ frame.render_widget(Paragraph::new("My app").block(Block::bordered()), header);
56+ frame.render_widget(Block::bordered().title("Menu"), left);
57+ frame.render_widget(Block::bordered().title("Content"), right);
58+ frame.render_widget("q quit", footer);
59+}
60+```
61+
62+The header is always 3 rows high, the footer 1, and the body takes the rest. The body is then split into a menu (30 % of the width) and a content panel that fills the remaining space.
63+
64+## Spacing and margins
65+
66+`Layout::vertical([...]).spacing(1)` leaves one empty row between areas. `.margin(1)` keeps one cell free all around the area being split. `Block::bordered()` already takes one cell on each side for its border; draw the content in `block.inner(area)` to stay inside it.
67+
68+## Centering an area
69+
70+To center something, give the layout a single constraint and `Flex::Center`:
71+
72+```rust,ignore
73+use ratatui::layout::{Constraint, Flex, Layout, Rect};
74+
75+fn centered(area: Rect, width: u16, height: u16) -> Rect {
76+ let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);
77+ let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area);
78+ area
79+}
80+```
81+
82+The popups tutorial uses this to draw a dialog in the middle of the screen.
new file mode 100644
@@ -0,0 +1,82 @@
1+# Splitting the screen with Layout
2+
3+A `Layout` cuts a `Rect` into smaller ones, vertically or horizontally, following a list of constraints. Layouts nest: split the screen into rows, then split a row into columns.
4+
5+## Constraints
6+
7+| Constraint | Meaning |
8+|---|---|
9+| `Constraint::Length(n)` | exactly `n` rows (or columns) |
10+| `Constraint::Min(n)` | at least `n`, takes what is left |
11+| `Constraint::Max(n)` | at most `n` |
12+| `Constraint::Percentage(p)` | `p` % of the space |
13+| `Constraint::Ratio(a, b)` | `a / b` of the space |
14+| `Constraint::Fill(w)` | shares the remaining space with the other `Fill`s, by weight `w` |
15+
16+## Header, body, footer
17+
18+`Layout::vertical` stacks areas from top to bottom; `areas` returns them as an array you can destructure, so the number of areas is checked at compile time.
19+
20+```rust
21+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
22+use ratatui::layout::{Constraint, Layout};
23+use ratatui::widgets::{Block, Paragraph};
24+use ratatui::{DefaultTerminal, Frame};
25+
26+fn main() -> std::io::Result<()> {
27+ let mut terminal = ratatui::init();
28+ let result = run(&mut terminal);
29+ ratatui::restore();
30+ result
31+}
32+
33+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
34+ loop {
35+ terminal.draw(draw)?;
36+ if let Event::Key(key) = event::read()? {
37+ if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
38+ return Ok(());
39+ }
40+ }
41+ }
42+}
43+
44+fn draw(frame: &mut Frame) {
45+ let [header, body, footer] = Layout::vertical([
46+ Constraint::Length(3),
47+ Constraint::Min(0),
48+ Constraint::Length(1),
49+ ])
50+ .areas(frame.area());
51+
52+ let [left, right] =
53+ Layout::horizontal([Constraint::Percentage(30), Constraint::Fill(1)]).areas(body);
54+
55+ frame.render_widget(Paragraph::new("My app").block(Block::bordered()), header);
56+ frame.render_widget(Block::bordered().title("Menu"), left);
57+ frame.render_widget(Block::bordered().title("Content"), right);
58+ frame.render_widget("q quit", footer);
59+}
60+```
61+
62+The header is always 3 rows high, the footer 1, and the body takes the rest. The body is then split into a menu (30 % of the width) and a content panel that fills the remaining space.
63+
64+## Spacing and margins
65+
66+`Layout::vertical([...]).spacing(1)` leaves one empty row between areas. `.margin(1)` keeps one cell free all around the area being split. `Block::bordered()` already takes one cell on each side for its border; draw the content in `block.inner(area)` to stay inside it.
67+
68+## Centering an area
69+
70+To center something, give the layout a single constraint and `Flex::Center`:
71+
72+```rust,ignore
73+use ratatui::layout::{Constraint, Flex, Layout, Rect};
74+
75+fn centered(area: Rect, width: u16, height: u16) -> Rect {
76+ let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);
77+ let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area);
78+ area
79+}
80+```
81+
82+The popups tutorial uses this to draw a dialog in the middle of the screen.
added demos/docs-search/docs/03-blocks-and-paragraphs.md +63 -0
new file mode 100644
@@ -0,0 +1,63 @@
1+# Blocks, paragraphs and styled text
2+
3+## Block: borders and titles
4+
5+A `Block` draws a border and a title around an area. `Block::bordered()` is a block with all four borders; `.title("…")` sets the title; `.border_type(BorderType::Rounded)` rounds the corners. Most widgets take a block with `.block(...)` and draw inside it.
6+
7+## Paragraph: text that wraps
8+
9+`Paragraph::new(text)` draws text. Without wrapping, a long line is cut at the edge; `.wrap(Wrap { trim: true })` wraps it at word boundaries and trims the leading spaces of wrapped lines. `.scroll((y, 0))` scrolls it down by `y` lines, `.alignment(Alignment::Center)` centers it.
10+
11+## Styled text: Span, Line, Text
12+
13+Text is built from three types:
14+
15+- a `Span` is a piece of text with one style;
16+- a `Line` is a row of spans;
17+- a `Text` is several lines.
18+
19+The `Stylize` trait adds short methods to strings, spans and widgets: `"warning".red().bold()`, `"ok".green()`, `Paragraph::new("x").on_blue()`.
20+
21+```rust
22+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
23+use ratatui::style::Stylize;
24+use ratatui::text::{Line, Text};
25+use ratatui::widgets::{Block, BorderType, Paragraph, Wrap};
26+use ratatui::{DefaultTerminal, Frame};
27+
28+fn main() -> std::io::Result<()> {
29+ let mut terminal = ratatui::init();
30+ let result = run(&mut terminal);
31+ ratatui::restore();
32+ result
33+}
34+
35+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
36+ loop {
37+ terminal.draw(draw)?;
38+ if let Event::Key(key) = event::read()? {
39+ if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
40+ return Ok(());
41+ }
42+ }
43+ }
44+}
45+
46+fn draw(frame: &mut Frame) {
47+ let text = Text::from(vec![
48+ Line::from(vec!["Status: ".into(), "running".green().bold()]),
49+ Line::from(vec!["Errors: ".into(), "2".red()]),
50+ Line::from(""),
51+ Line::from("This long line is wrapped at word boundaries when the window is narrower than the text."),
52+ ]);
53+ let block = Block::bordered()
54+ .border_type(BorderType::Rounded)
55+ .title(" Report ".bold());
56+ let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
57+ frame.render_widget(paragraph, frame.area());
58+}
59+```
60+
61+## Colors
62+
63+`Stylize` covers the named colors (`.red()`, `.on_blue()` for the background, `.light_green()`…) and the modifiers (`.bold()`, `.italic()`, `.underlined()`, `.reversed()`, `.dim()`). For anything else, build a `Style`: `Style::new().fg(Color::Rgb(255, 128, 0)).add_modifier(Modifier::BOLD)`, then `.style(style)` on a widget or `Span::styled("text", style)`.
new file mode 100644
@@ -0,0 +1,63 @@
1+# Blocks, paragraphs and styled text
2+
3+## Block: borders and titles
4+
5+A `Block` draws a border and a title around an area. `Block::bordered()` is a block with all four borders; `.title("…")` sets the title; `.border_type(BorderType::Rounded)` rounds the corners. Most widgets take a block with `.block(...)` and draw inside it.
6+
7+## Paragraph: text that wraps
8+
9+`Paragraph::new(text)` draws text. Without wrapping, a long line is cut at the edge; `.wrap(Wrap { trim: true })` wraps it at word boundaries and trims the leading spaces of wrapped lines. `.scroll((y, 0))` scrolls it down by `y` lines, `.alignment(Alignment::Center)` centers it.
10+
11+## Styled text: Span, Line, Text
12+
13+Text is built from three types:
14+
15+- a `Span` is a piece of text with one style;
16+- a `Line` is a row of spans;
17+- a `Text` is several lines.
18+
19+The `Stylize` trait adds short methods to strings, spans and widgets: `"warning".red().bold()`, `"ok".green()`, `Paragraph::new("x").on_blue()`.
20+
21+```rust
22+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
23+use ratatui::style::Stylize;
24+use ratatui::text::{Line, Text};
25+use ratatui::widgets::{Block, BorderType, Paragraph, Wrap};
26+use ratatui::{DefaultTerminal, Frame};
27+
28+fn main() -> std::io::Result<()> {
29+ let mut terminal = ratatui::init();
30+ let result = run(&mut terminal);
31+ ratatui::restore();
32+ result
33+}
34+
35+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
36+ loop {
37+ terminal.draw(draw)?;
38+ if let Event::Key(key) = event::read()? {
39+ if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
40+ return Ok(());
41+ }
42+ }
43+ }
44+}
45+
46+fn draw(frame: &mut Frame) {
47+ let text = Text::from(vec![
48+ Line::from(vec!["Status: ".into(), "running".green().bold()]),
49+ Line::from(vec!["Errors: ".into(), "2".red()]),
50+ Line::from(""),
51+ Line::from("This long line is wrapped at word boundaries when the window is narrower than the text."),
52+ ]);
53+ let block = Block::bordered()
54+ .border_type(BorderType::Rounded)
55+ .title(" Report ".bold());
56+ let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
57+ frame.render_widget(paragraph, frame.area());
58+}
59+```
60+
61+## Colors
62+
63+`Stylize` covers the named colors (`.red()`, `.on_blue()` for the background, `.light_green()`…) and the modifiers (`.bold()`, `.italic()`, `.underlined()`, `.reversed()`, `.dim()`). For anything else, build a `Style`: `Style::new().fg(Color::Rgb(255, 128, 0)).add_modifier(Modifier::BOLD)`, then `.style(style)` on a widget or `Span::styled("text", style)`.
added demos/docs-search/docs/04-lists.md +60 -0
new file mode 100644
@@ -0,0 +1,60 @@
1+# Lists and selection
2+
3+A `List` shows items, one per row. To let the user move a selection, keep a `ListState` next to the list and render it with `render_stateful_widget`.
4+
5+## Keep the selection with ListState
6+
7+`ListState` remembers which item is selected and how far the list is scrolled. It lives in your app, not in the widget: the widget is rebuilt at each draw, the state survives between draws. `state.select_next()` and `state.select_previous()` move the selection; `state.select(Some(0))` selects the first item; `state.selected()` tells which one is selected.
8+
9+## Highlight the selected item
10+
11+`.highlight_style(...)` styles the selected row and `.highlight_symbol(">> ")` puts a marker before it. `HighlightSpacing::Always` keeps the room for the symbol even when nothing is selected, so the text does not jump.
12+
13+## A list you move through with the arrows
14+
15+```rust
16+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
17+use ratatui::style::Style;
18+use ratatui::widgets::{Block, HighlightSpacing, List, ListState};
19+use ratatui::DefaultTerminal;
20+
21+fn main() -> std::io::Result<()> {
22+ let mut terminal = ratatui::init();
23+ let result = run(&mut terminal);
24+ ratatui::restore();
25+ result
26+}
27+
28+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
29+ let books = ["Dune", "Neuromancer", "Hyperion", "Foundation", "Solaris"];
30+ let mut state = ListState::default();
31+ state.select(Some(0));
32+ loop {
33+ terminal.draw(|frame| {
34+ let list = List::new(books)
35+ .block(Block::bordered().title(" Books — ↑/↓ to move, q to quit "))
36+ .highlight_style(Style::new().black().on_yellow())
37+ .highlight_symbol(">> ")
38+ .highlight_spacing(HighlightSpacing::Always);
39+ frame.render_stateful_widget(list, frame.area(), &mut state);
40+ })?;
41+ if let Event::Key(key) = event::read()? {
42+ if key.kind != KeyEventKind::Press {
43+ continue;
44+ }
45+ match key.code {
46+ KeyCode::Char('q') => return Ok(()),
47+ KeyCode::Down | KeyCode::Char('j') => state.select_next(),
48+ KeyCode::Up | KeyCode::Char('k') => state.select_previous(),
49+ _ => {}
50+ }
51+ }
52+ }
53+}
54+```
55+
56+`select_next` stops at the last item; when the list is taller than the area, `ListState` scrolls it so the selected item stays visible.
57+
58+## Items with several styles
59+
60+An item can be any `Line` or `Text`: `ListItem::new(Line::from(vec!["Dune".bold(), " — Herbert".dim()]))`. `List::new` takes anything that iterates over items, strings included.
new file mode 100644
@@ -0,0 +1,60 @@
1+# Lists and selection
2+
3+A `List` shows items, one per row. To let the user move a selection, keep a `ListState` next to the list and render it with `render_stateful_widget`.
4+
5+## Keep the selection with ListState
6+
7+`ListState` remembers which item is selected and how far the list is scrolled. It lives in your app, not in the widget: the widget is rebuilt at each draw, the state survives between draws. `state.select_next()` and `state.select_previous()` move the selection; `state.select(Some(0))` selects the first item; `state.selected()` tells which one is selected.
8+
9+## Highlight the selected item
10+
11+`.highlight_style(...)` styles the selected row and `.highlight_symbol(">> ")` puts a marker before it. `HighlightSpacing::Always` keeps the room for the symbol even when nothing is selected, so the text does not jump.
12+
13+## A list you move through with the arrows
14+
15+```rust
16+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
17+use ratatui::style::Style;
18+use ratatui::widgets::{Block, HighlightSpacing, List, ListState};
19+use ratatui::DefaultTerminal;
20+
21+fn main() -> std::io::Result<()> {
22+ let mut terminal = ratatui::init();
23+ let result = run(&mut terminal);
24+ ratatui::restore();
25+ result
26+}
27+
28+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
29+ let books = ["Dune", "Neuromancer", "Hyperion", "Foundation", "Solaris"];
30+ let mut state = ListState::default();
31+ state.select(Some(0));
32+ loop {
33+ terminal.draw(|frame| {
34+ let list = List::new(books)
35+ .block(Block::bordered().title(" Books — ↑/↓ to move, q to quit "))
36+ .highlight_style(Style::new().black().on_yellow())
37+ .highlight_symbol(">> ")
38+ .highlight_spacing(HighlightSpacing::Always);
39+ frame.render_stateful_widget(list, frame.area(), &mut state);
40+ })?;
41+ if let Event::Key(key) = event::read()? {
42+ if key.kind != KeyEventKind::Press {
43+ continue;
44+ }
45+ match key.code {
46+ KeyCode::Char('q') => return Ok(()),
47+ KeyCode::Down | KeyCode::Char('j') => state.select_next(),
48+ KeyCode::Up | KeyCode::Char('k') => state.select_previous(),
49+ _ => {}
50+ }
51+ }
52+ }
53+}
54+```
55+
56+`select_next` stops at the last item; when the list is taller than the area, `ListState` scrolls it so the selected item stays visible.
57+
58+## Items with several styles
59+
60+An item can be any `Line` or `Text`: `ListItem::new(Line::from(vec!["Dune".bold(), " — Herbert".dim()]))`. `List::new` takes anything that iterates over items, strings included.
added demos/docs-search/docs/05-tables.md +63 -0
new file mode 100644
@@ -0,0 +1,63 @@
1+# Tables
2+
3+A `Table` shows rows and columns, with an optional header. Like a list, it can keep a selected row in a `TableState`.
4+
5+## Rows, cells and column widths
6+
7+`Row::new(vec!["Dune", "Herbert", "1965"])` is a row of cells. `Table::new(rows, widths)` needs the column widths as constraints, the same ones as `Layout`: `[Constraint::Fill(2), Constraint::Fill(1), Constraint::Length(4)]`. `.header(Row::new(...))` adds a header row, `.column_spacing(2)` the space between columns.
8+
9+## Select a row with TableState
10+
11+`TableState` works like `ListState`: `select_next`, `select_previous`, `selected`. Style the selected row with `.row_highlight_style(...)` and mark it with `.highlight_symbol("> ")`.
12+
13+```rust
14+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
15+use ratatui::layout::Constraint;
16+use ratatui::style::{Style, Stylize};
17+use ratatui::widgets::{Block, Row, Table, TableState};
18+use ratatui::DefaultTerminal;
19+
20+fn main() -> std::io::Result<()> {
21+ let mut terminal = ratatui::init();
22+ let result = run(&mut terminal);
23+ ratatui::restore();
24+ result
25+}
26+
27+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
28+ let books = [
29+ ["Dune", "Frank Herbert", "1965"],
30+ ["Neuromancer", "William Gibson", "1984"],
31+ ["Hyperion", "Dan Simmons", "1989"],
32+ ];
33+ let mut state = TableState::default().with_selected(Some(0));
34+ loop {
35+ terminal.draw(|frame| {
36+ let rows = books.iter().map(|b| Row::new(b.to_vec()));
37+ let widths = [Constraint::Fill(2), Constraint::Fill(2), Constraint::Length(4)];
38+ let table = Table::new(rows, widths)
39+ .header(Row::new(vec!["Title", "Author", "Year"]).bold().underlined())
40+ .block(Block::bordered().title(" Library "))
41+ .row_highlight_style(Style::new().reversed())
42+ .highlight_symbol("> ")
43+ .column_spacing(2);
44+ frame.render_stateful_widget(table, frame.area(), &mut state);
45+ })?;
46+ if let Event::Key(key) = event::read()? {
47+ if key.kind != KeyEventKind::Press {
48+ continue;
49+ }
50+ match key.code {
51+ KeyCode::Char('q') => return Ok(()),
52+ KeyCode::Down => state.select_next(),
53+ KeyCode::Up => state.select_previous(),
54+ _ => {}
55+ }
56+ }
57+ }
58+}
59+```
60+
61+## Cells with their own style
62+
63+A cell can be styled on its own: `Cell::from("overdue").red()`, then `Row::new(vec![Cell::from("Dune"), Cell::from("overdue").red()])`. A whole row too: `Row::new(...).on_dark_gray()`. `.height(2)` gives a row two lines.
new file mode 100644
@@ -0,0 +1,63 @@
1+# Tables
2+
3+A `Table` shows rows and columns, with an optional header. Like a list, it can keep a selected row in a `TableState`.
4+
5+## Rows, cells and column widths
6+
7+`Row::new(vec!["Dune", "Herbert", "1965"])` is a row of cells. `Table::new(rows, widths)` needs the column widths as constraints, the same ones as `Layout`: `[Constraint::Fill(2), Constraint::Fill(1), Constraint::Length(4)]`. `.header(Row::new(...))` adds a header row, `.column_spacing(2)` the space between columns.
8+
9+## Select a row with TableState
10+
11+`TableState` works like `ListState`: `select_next`, `select_previous`, `selected`. Style the selected row with `.row_highlight_style(...)` and mark it with `.highlight_symbol("> ")`.
12+
13+```rust
14+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
15+use ratatui::layout::Constraint;
16+use ratatui::style::{Style, Stylize};
17+use ratatui::widgets::{Block, Row, Table, TableState};
18+use ratatui::DefaultTerminal;
19+
20+fn main() -> std::io::Result<()> {
21+ let mut terminal = ratatui::init();
22+ let result = run(&mut terminal);
23+ ratatui::restore();
24+ result
25+}
26+
27+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
28+ let books = [
29+ ["Dune", "Frank Herbert", "1965"],
30+ ["Neuromancer", "William Gibson", "1984"],
31+ ["Hyperion", "Dan Simmons", "1989"],
32+ ];
33+ let mut state = TableState::default().with_selected(Some(0));
34+ loop {
35+ terminal.draw(|frame| {
36+ let rows = books.iter().map(|b| Row::new(b.to_vec()));
37+ let widths = [Constraint::Fill(2), Constraint::Fill(2), Constraint::Length(4)];
38+ let table = Table::new(rows, widths)
39+ .header(Row::new(vec!["Title", "Author", "Year"]).bold().underlined())
40+ .block(Block::bordered().title(" Library "))
41+ .row_highlight_style(Style::new().reversed())
42+ .highlight_symbol("> ")
43+ .column_spacing(2);
44+ frame.render_stateful_widget(table, frame.area(), &mut state);
45+ })?;
46+ if let Event::Key(key) = event::read()? {
47+ if key.kind != KeyEventKind::Press {
48+ continue;
49+ }
50+ match key.code {
51+ KeyCode::Char('q') => return Ok(()),
52+ KeyCode::Down => state.select_next(),
53+ KeyCode::Up => state.select_previous(),
54+ _ => {}
55+ }
56+ }
57+ }
58+}
59+```
60+
61+## Cells with their own style
62+
63+A cell can be styled on its own: `Cell::from("overdue").red()`, then `Row::new(vec![Cell::from("Dune"), Cell::from("overdue").red()])`. A whole row too: `Row::new(...).on_dark_gray()`. `.height(2)` gives a row two lines.
added demos/docs-search/docs/06-gauges-and-ticks.md +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+# Gauges and a ticking app
2+
3+A `Gauge` draws a progress bar. To animate it, the app must redraw even when no key is pressed: wait for an event with a timeout instead of blocking on `event::read`.
4+
5+## Gauge
6+
7+`Gauge::default().ratio(0.4)` fills 40 % of the bar (`.percent(40)` does the same with an integer). `.gauge_style(...)` colors the filled part, `.label("…")` replaces the default percentage text, `.block(...)` frames it. `LineGauge` is a one-line variant.
8+
9+## Redraw without a key: event::poll
10+
11+`event::poll(timeout)` waits up to `timeout` for an event and returns whether one is ready. Poll with a short timeout (100 ms), and when it returns `false`, advance the app's state — that is a tick — then draw again.
12+
13+```rust
14+use std::time::Duration;
15+
16+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
17+use ratatui::style::{Color, Style};
18+use ratatui::widgets::{Block, Gauge};
19+use ratatui::DefaultTerminal;
20+
21+fn main() -> std::io::Result<()> {
22+ let mut terminal = ratatui::init();
23+ let result = run(&mut terminal);
24+ ratatui::restore();
25+ result
26+}
27+
28+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
29+ let mut progress: f64 = 0.0;
30+ loop {
31+ terminal.draw(|frame| {
32+ let gauge = Gauge::default()
33+ .block(Block::bordered().title(" Download — q to quit "))
34+ .gauge_style(Style::new().fg(Color::Green))
35+ .ratio(progress)
36+ .label(format!("{:.0} %", progress * 100.0));
37+ frame.render_widget(gauge, frame.area());
38+ })?;
39+ if event::poll(Duration::from_millis(100))? {
40+ if let Event::Key(key) = event::read()? {
41+ if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
42+ return Ok(());
43+ }
44+ }
45+ } else {
46+ progress = (progress + 0.01).min(1.0);
47+ }
48+ }
49+}
50+```
51+
52+The bar fills by 1 % every 100 ms while no key is pressed. In a real app the tick would read the real progress — a counter shared with a worker thread, a channel — instead of inventing it.
53+
54+## Ticks and frame rate
55+
56+Keep the poll timeout short enough for the UI to feel alive (50–250 ms) and do no heavy work in the draw closure: compute in the tick, draw from the state. If the work is slow, run it on another thread and send the results to the loop with a channel.
new file mode 100644
@@ -0,0 +1,56 @@
1+# Gauges and a ticking app
2+
3+A `Gauge` draws a progress bar. To animate it, the app must redraw even when no key is pressed: wait for an event with a timeout instead of blocking on `event::read`.
4+
5+## Gauge
6+
7+`Gauge::default().ratio(0.4)` fills 40 % of the bar (`.percent(40)` does the same with an integer). `.gauge_style(...)` colors the filled part, `.label("…")` replaces the default percentage text, `.block(...)` frames it. `LineGauge` is a one-line variant.
8+
9+## Redraw without a key: event::poll
10+
11+`event::poll(timeout)` waits up to `timeout` for an event and returns whether one is ready. Poll with a short timeout (100 ms), and when it returns `false`, advance the app's state — that is a tick — then draw again.
12+
13+```rust
14+use std::time::Duration;
15+
16+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
17+use ratatui::style::{Color, Style};
18+use ratatui::widgets::{Block, Gauge};
19+use ratatui::DefaultTerminal;
20+
21+fn main() -> std::io::Result<()> {
22+ let mut terminal = ratatui::init();
23+ let result = run(&mut terminal);
24+ ratatui::restore();
25+ result
26+}
27+
28+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
29+ let mut progress: f64 = 0.0;
30+ loop {
31+ terminal.draw(|frame| {
32+ let gauge = Gauge::default()
33+ .block(Block::bordered().title(" Download — q to quit "))
34+ .gauge_style(Style::new().fg(Color::Green))
35+ .ratio(progress)
36+ .label(format!("{:.0} %", progress * 100.0));
37+ frame.render_widget(gauge, frame.area());
38+ })?;
39+ if event::poll(Duration::from_millis(100))? {
40+ if let Event::Key(key) = event::read()? {
41+ if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
42+ return Ok(());
43+ }
44+ }
45+ } else {
46+ progress = (progress + 0.01).min(1.0);
47+ }
48+ }
49+}
50+```
51+
52+The bar fills by 1 % every 100 ms while no key is pressed. In a real app the tick would read the real progress — a counter shared with a worker thread, a channel — instead of inventing it.
53+
54+## Ticks and frame rate
55+
56+Keep the poll timeout short enough for the UI to feel alive (50–250 ms) and do no heavy work in the draw closure: compute in the tick, draw from the state. If the work is slow, run it on another thread and send the results to the loop with a channel.
added demos/docs-search/docs/07-tabs.md +64 -0
new file mode 100644
@@ -0,0 +1,64 @@
1+# Tabs
2+
3+`Tabs` draws a row of titles with one highlighted, for apps with several views. The widget only draws the titles: which view is shown under them is up to you.
4+
5+## Draw the tab bar
6+
7+`Tabs::new(vec!["Home", "Books", "Help"])` takes the titles; `.select(index)` highlights one; `.highlight_style(...)` styles it; `.divider(" | ")` sets what goes between titles.
8+
9+## Switch views with the keyboard
10+
11+Keep the selected index in your app, change it on a key, and draw the matching view in the area under the bar.
12+
13+```rust
14+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
15+use ratatui::layout::{Constraint, Layout};
16+use ratatui::style::Style;
17+use ratatui::widgets::{Block, Paragraph, Tabs};
18+use ratatui::{DefaultTerminal, Frame};
19+
20+const TITLES: [&str; 3] = ["Home", "Books", "Help"];
21+
22+fn main() -> std::io::Result<()> {
23+ let mut terminal = ratatui::init();
24+ let result = run(&mut terminal);
25+ ratatui::restore();
26+ result
27+}
28+
29+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
30+ let mut tab = 0;
31+ loop {
32+ terminal.draw(|frame| draw(frame, tab))?;
33+ if let Event::Key(key) = event::read()? {
34+ if key.kind != KeyEventKind::Press {
35+ continue;
36+ }
37+ match key.code {
38+ KeyCode::Char('q') => return Ok(()),
39+ KeyCode::Tab | KeyCode::Right => tab = (tab + 1) % TITLES.len(),
40+ KeyCode::BackTab | KeyCode::Left => tab = (tab + TITLES.len() - 1) % TITLES.len(),
41+ _ => {}
42+ }
43+ }
44+ }
45+}
46+
47+fn draw(frame: &mut Frame, tab: usize) {
48+ let [bar, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(frame.area());
49+ let tabs = Tabs::new(TITLES)
50+ .block(Block::bordered().title(" Tab / ← → to switch, q to quit "))
51+ .select(tab)
52+ .highlight_style(Style::new().yellow().bold())
53+ .divider(" | ");
54+ frame.render_widget(tabs, bar);
55+ let text = match tab {
56+ 0 => "Welcome.",
57+ 1 => "Your books will be listed here.",
58+ _ => "Tab and the arrows switch views; q quits.",
59+ };
60+ frame.render_widget(Paragraph::new(text).block(Block::bordered()), body);
61+}
62+```
63+
64+`KeyCode::BackTab` is Shift+Tab.
new file mode 100644
@@ -0,0 +1,64 @@
1+# Tabs
2+
3+`Tabs` draws a row of titles with one highlighted, for apps with several views. The widget only draws the titles: which view is shown under them is up to you.
4+
5+## Draw the tab bar
6+
7+`Tabs::new(vec!["Home", "Books", "Help"])` takes the titles; `.select(index)` highlights one; `.highlight_style(...)` styles it; `.divider(" | ")` sets what goes between titles.
8+
9+## Switch views with the keyboard
10+
11+Keep the selected index in your app, change it on a key, and draw the matching view in the area under the bar.
12+
13+```rust
14+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
15+use ratatui::layout::{Constraint, Layout};
16+use ratatui::style::Style;
17+use ratatui::widgets::{Block, Paragraph, Tabs};
18+use ratatui::{DefaultTerminal, Frame};
19+
20+const TITLES: [&str; 3] = ["Home", "Books", "Help"];
21+
22+fn main() -> std::io::Result<()> {
23+ let mut terminal = ratatui::init();
24+ let result = run(&mut terminal);
25+ ratatui::restore();
26+ result
27+}
28+
29+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
30+ let mut tab = 0;
31+ loop {
32+ terminal.draw(|frame| draw(frame, tab))?;
33+ if let Event::Key(key) = event::read()? {
34+ if key.kind != KeyEventKind::Press {
35+ continue;
36+ }
37+ match key.code {
38+ KeyCode::Char('q') => return Ok(()),
39+ KeyCode::Tab | KeyCode::Right => tab = (tab + 1) % TITLES.len(),
40+ KeyCode::BackTab | KeyCode::Left => tab = (tab + TITLES.len() - 1) % TITLES.len(),
41+ _ => {}
42+ }
43+ }
44+ }
45+}
46+
47+fn draw(frame: &mut Frame, tab: usize) {
48+ let [bar, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(frame.area());
49+ let tabs = Tabs::new(TITLES)
50+ .block(Block::bordered().title(" Tab / ← → to switch, q to quit "))
51+ .select(tab)
52+ .highlight_style(Style::new().yellow().bold())
53+ .divider(" | ");
54+ frame.render_widget(tabs, bar);
55+ let text = match tab {
56+ 0 => "Welcome.",
57+ 1 => "Your books will be listed here.",
58+ _ => "Tab and the arrows switch views; q quits.",
59+ };
60+ frame.render_widget(Paragraph::new(text).block(Block::bordered()), body);
61+}
62+```
63+
64+`KeyCode::BackTab` is Shift+Tab.
added demos/docs-search/docs/08-app-state-and-events.md +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+# App state and key events
2+
3+As an app grows, keep its state in one struct, and split the loop into three parts: read an event, update the state, draw from the state. Drawing never changes the state; handling a key never draws.
4+
5+## The App struct
6+
7+```rust
8+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
9+use ratatui::widgets::{Block, Paragraph};
10+use ratatui::{DefaultTerminal, Frame};
11+
12+#[derive(Default)]
13+struct App {
14+ counter: i64,
15+ exit: bool,
16+}
17+
18+impl App {
19+ fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
20+ while !self.exit {
21+ terminal.draw(|frame| self.draw(frame))?;
22+ self.handle_events()?;
23+ }
24+ Ok(())
25+ }
26+
27+ fn draw(&self, frame: &mut Frame) {
28+ let text = format!("Counter: {}\n\n← / → to change it, q or Ctrl-C to quit", self.counter);
29+ frame.render_widget(Paragraph::new(text).block(Block::bordered().title(" Counter ")), frame.area());
30+ }
31+
32+ fn handle_events(&mut self) -> std::io::Result<()> {
33+ if let Event::Key(key) = event::read()? {
34+ if key.kind == KeyEventKind::Press {
35+ self.handle_key(key);
36+ }
37+ }
38+ Ok(())
39+ }
40+
41+ fn handle_key(&mut self, key: KeyEvent) {
42+ match key.code {
43+ KeyCode::Char('q') => self.exit = true,
44+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.exit = true,
45+ KeyCode::Left => self.counter -= 1,
46+ KeyCode::Right => self.counter += 1,
47+ _ => {}
48+ }
49+ }
50+}
51+
52+fn main() -> std::io::Result<()> {
53+ let mut terminal = ratatui::init();
54+ let result = App::default().run(&mut terminal);
55+ ratatui::restore();
56+ result
57+}
58+```
59+
60+## Why check KeyEventKind::Press
61+
62+On Windows, and on terminals that report key releases, Crossterm sends a `Press` and a `Release` event for one key stroke. Without the `key.kind == KeyEventKind::Press` check, every key would act twice.
63+
64+## Ctrl-C in raw mode
65+
66+In raw mode Ctrl-C is not a signal any more: it arrives as a key, `KeyCode::Char('c')` with `KeyModifiers::CONTROL`. Handle it, or the user has no way out but closing the terminal.
67+
68+## Test the logic without a terminal
69+
70+Because `handle_key` only changes the state, it can be tested with a plain `#[test]`: build an `App`, call `app.handle_key(KeyEvent::from(KeyCode::Right))`, check `app.counter == 1`. For the drawing itself, Ratatui's `TestBackend` renders into a buffer you can compare.
new file mode 100644
@@ -0,0 +1,70 @@
1+# App state and key events
2+
3+As an app grows, keep its state in one struct, and split the loop into three parts: read an event, update the state, draw from the state. Drawing never changes the state; handling a key never draws.
4+
5+## The App struct
6+
7+```rust
8+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
9+use ratatui::widgets::{Block, Paragraph};
10+use ratatui::{DefaultTerminal, Frame};
11+
12+#[derive(Default)]
13+struct App {
14+ counter: i64,
15+ exit: bool,
16+}
17+
18+impl App {
19+ fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
20+ while !self.exit {
21+ terminal.draw(|frame| self.draw(frame))?;
22+ self.handle_events()?;
23+ }
24+ Ok(())
25+ }
26+
27+ fn draw(&self, frame: &mut Frame) {
28+ let text = format!("Counter: {}\n\n← / → to change it, q or Ctrl-C to quit", self.counter);
29+ frame.render_widget(Paragraph::new(text).block(Block::bordered().title(" Counter ")), frame.area());
30+ }
31+
32+ fn handle_events(&mut self) -> std::io::Result<()> {
33+ if let Event::Key(key) = event::read()? {
34+ if key.kind == KeyEventKind::Press {
35+ self.handle_key(key);
36+ }
37+ }
38+ Ok(())
39+ }
40+
41+ fn handle_key(&mut self, key: KeyEvent) {
42+ match key.code {
43+ KeyCode::Char('q') => self.exit = true,
44+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.exit = true,
45+ KeyCode::Left => self.counter -= 1,
46+ KeyCode::Right => self.counter += 1,
47+ _ => {}
48+ }
49+ }
50+}
51+
52+fn main() -> std::io::Result<()> {
53+ let mut terminal = ratatui::init();
54+ let result = App::default().run(&mut terminal);
55+ ratatui::restore();
56+ result
57+}
58+```
59+
60+## Why check KeyEventKind::Press
61+
62+On Windows, and on terminals that report key releases, Crossterm sends a `Press` and a `Release` event for one key stroke. Without the `key.kind == KeyEventKind::Press` check, every key would act twice.
63+
64+## Ctrl-C in raw mode
65+
66+In raw mode Ctrl-C is not a signal any more: it arrives as a key, `KeyCode::Char('c')` with `KeyModifiers::CONTROL`. Handle it, or the user has no way out but closing the terminal.
67+
68+## Test the logic without a terminal
69+
70+Because `handle_key` only changes the state, it can be tested with a plain `#[test]`: build an `App`, call `app.handle_key(KeyEvent::from(KeyCode::Right))`, check `app.counter == 1`. For the drawing itself, Ratatui's `TestBackend` renders into a buffer you can compare.
added demos/docs-search/docs/09-popups.md +67 -0
new file mode 100644
@@ -0,0 +1,67 @@
1+# Popups and dialogs
2+
3+A popup is drawn over the rest of the UI, in a smaller area, after everything else. Ratatui draws in order: what is rendered last is on top.
4+
5+## Clear the area first
6+
7+Widgets only paint the cells they use, so the UI underneath would show through the popup. Render `Clear` on the popup's area first: it blanks it, then draw the popup's block and content in the same area.
8+
9+## A centered confirmation dialog
10+
11+```rust
12+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
13+use ratatui::layout::{Constraint, Flex, Layout, Rect};
14+use ratatui::style::Stylize;
15+use ratatui::widgets::{Block, Clear, Paragraph};
16+use ratatui::{DefaultTerminal, Frame};
17+
18+fn main() -> std::io::Result<()> {
19+ let mut terminal = ratatui::init();
20+ let result = run(&mut terminal);
21+ ratatui::restore();
22+ result
23+}
24+
25+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
26+ let mut show_popup = false;
27+ loop {
28+ terminal.draw(|frame| draw(frame, show_popup))?;
29+ if let Event::Key(key) = event::read()? {
30+ if key.kind != KeyEventKind::Press {
31+ continue;
32+ }
33+ match (show_popup, key.code) {
34+ (false, KeyCode::Char('q')) => show_popup = true,
35+ (true, KeyCode::Char('y')) => return Ok(()),
36+ (true, KeyCode::Char('n') | KeyCode::Esc) => show_popup = false,
37+ _ => {}
38+ }
39+ }
40+ }
41+}
42+
43+fn draw(frame: &mut Frame, show_popup: bool) {
44+ let main = Paragraph::new("The main screen. Press q to quit.").block(Block::bordered().title(" App "));
45+ frame.render_widget(main, frame.area());
46+ if show_popup {
47+ let area = centered(frame.area(), 40, 5);
48+ frame.render_widget(Clear, area);
49+ let dialog = Paragraph::new("Really quit? y / n")
50+ .centered()
51+ .block(Block::bordered().title(" Confirm ".bold()).on_blue());
52+ frame.render_widget(dialog, area);
53+ }
54+}
55+
56+fn centered(area: Rect, width: u16, height: u16) -> Rect {
57+ let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);
58+ let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area);
59+ area
60+}
61+```
62+
63+`q` opens the dialog instead of quitting at once; `y` quits, `n` or Esc closes the dialog.
64+
65+## Keys go to the popup
66+
67+While a popup is open, it owns the keyboard: match on the popup's state first, as above, so a key meant for the dialog does not also act on the screen behind it.
new file mode 100644
@@ -0,0 +1,67 @@
1+# Popups and dialogs
2+
3+A popup is drawn over the rest of the UI, in a smaller area, after everything else. Ratatui draws in order: what is rendered last is on top.
4+
5+## Clear the area first
6+
7+Widgets only paint the cells they use, so the UI underneath would show through the popup. Render `Clear` on the popup's area first: it blanks it, then draw the popup's block and content in the same area.
8+
9+## A centered confirmation dialog
10+
11+```rust
12+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
13+use ratatui::layout::{Constraint, Flex, Layout, Rect};
14+use ratatui::style::Stylize;
15+use ratatui::widgets::{Block, Clear, Paragraph};
16+use ratatui::{DefaultTerminal, Frame};
17+
18+fn main() -> std::io::Result<()> {
19+ let mut terminal = ratatui::init();
20+ let result = run(&mut terminal);
21+ ratatui::restore();
22+ result
23+}
24+
25+fn run(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
26+ let mut show_popup = false;
27+ loop {
28+ terminal.draw(|frame| draw(frame, show_popup))?;
29+ if let Event::Key(key) = event::read()? {
30+ if key.kind != KeyEventKind::Press {
31+ continue;
32+ }
33+ match (show_popup, key.code) {
34+ (false, KeyCode::Char('q')) => show_popup = true,
35+ (true, KeyCode::Char('y')) => return Ok(()),
36+ (true, KeyCode::Char('n') | KeyCode::Esc) => show_popup = false,
37+ _ => {}
38+ }
39+ }
40+ }
41+}
42+
43+fn draw(frame: &mut Frame, show_popup: bool) {
44+ let main = Paragraph::new("The main screen. Press q to quit.").block(Block::bordered().title(" App "));
45+ frame.render_widget(main, frame.area());
46+ if show_popup {
47+ let area = centered(frame.area(), 40, 5);
48+ frame.render_widget(Clear, area);
49+ let dialog = Paragraph::new("Really quit? y / n")
50+ .centered()
51+ .block(Block::bordered().title(" Confirm ".bold()).on_blue());
52+ frame.render_widget(dialog, area);
53+ }
54+}
55+
56+fn centered(area: Rect, width: u16, height: u16) -> Rect {
57+ let [area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);
58+ let [area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(area);
59+ area
60+}
61+```
62+
63+`q` opens the dialog instead of quitting at once; `y` quits, `n` or Esc closes the dialog.
64+
65+## Keys go to the popup
66+
67+While a popup is open, it owns the keyboard: match on the popup's state first, as above, so a key meant for the dialog does not also act on the screen behind it.
added demos/docs-search/docs/README.md +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Ratatui tutorials
2+
3+Short tutorials on building terminal user interfaces in Rust with [Ratatui](https://ratatui.rs) 0.30. Each one is a complete program you can paste into `src/main.rs` of a project made with `cargo new` and `cargo add ratatui`.
4+
5+1. [Getting started](01-getting-started.md) — the project, the smallest app, `init` / `restore`, the draw loop
6+2. [Layout](02-layout.md) — constraints, header / body / footer, centering an area
7+3. [Blocks, paragraphs and styled text](03-blocks-and-paragraphs.md) — borders, titles, wrapping, `Span` / `Line` / `Text`, colors
8+4. [Lists and selection](04-lists.md) — `List`, `ListState`, highlighting, moving with the arrows
9+5. [Tables](05-tables.md) — rows, column widths, header, `TableState`
10+6. [Gauges and a ticking app](06-gauges-and-ticks.md) — `Gauge`, redrawing with `event::poll`
11+7. [Tabs](07-tabs.md) — a tab bar and switching views
12+8. [App state and key events](08-app-state-and-events.md) — the `App` struct, `KeyEventKind::Press`, Ctrl-C in raw mode, testing
13+9. [Popups and dialogs](09-popups.md) — `Clear`, a centered dialog, keys for the popup
new file mode 100644
@@ -0,0 +1,13 @@
1+# Ratatui tutorials
2+
3+Short tutorials on building terminal user interfaces in Rust with [Ratatui](https://ratatui.rs) 0.30. Each one is a complete program you can paste into `src/main.rs` of a project made with `cargo new` and `cargo add ratatui`.
4+
5+1. [Getting started](01-getting-started.md) — the project, the smallest app, `init` / `restore`, the draw loop
6+2. [Layout](02-layout.md) — constraints, header / body / footer, centering an area
7+3. [Blocks, paragraphs and styled text](03-blocks-and-paragraphs.md) — borders, titles, wrapping, `Span` / `Line` / `Text`, colors
8+4. [Lists and selection](04-lists.md) — `List`, `ListState`, highlighting, moving with the arrows
9+5. [Tables](05-tables.md) — rows, column widths, header, `TableState`
10+6. [Gauges and a ticking app](06-gauges-and-ticks.md) — `Gauge`, redrawing with `event::poll`
11+7. [Tabs](07-tabs.md) — a tab bar and switching views
12+8. [App state and key events](08-app-state-and-events.md) — the `App` struct, `KeyEventKind::Press`, Ctrl-C in raw mode, testing
13+9. [Popups and dialogs](09-popups.md) — `Clear`, a centered dialog, keys for the popup
added demos/docs-search/snippets.rs +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+// The expressions the tutorials quote in their prose, compiled by
2+// check-tutorials.sh so the text cannot drift from the API.
3+use ratatui::crossterm::event::{KeyCode, KeyEvent};
4+use ratatui::layout::{Alignment, Constraint, Layout};
5+use ratatui::style::{Color, Modifier, Style, Stylize};
6+use ratatui::text::{Line, Span};
7+use ratatui::widgets::{Block, Cell, Gauge, LineGauge, ListItem, Paragraph, Row};
8+
9+fn main() {
10+ let _ = Layout::vertical([Constraint::Length(1), Constraint::Ratio(1, 3), Constraint::Max(4)])
11+ .spacing(1)
12+ .margin(1);
13+ let block = Block::bordered();
14+ let _ = block.inner(ratatui::layout::Rect::new(0, 0, 10, 5));
15+ let _ = Paragraph::new("x").scroll((2, 0)).alignment(Alignment::Center).on_blue();
16+ let _: Span = "warning".red().bold();
17+ let _: Span = "ok".green().on_light_green().italic().underlined().reversed().dim();
18+ let style = Style::new().fg(Color::Rgb(255, 128, 0)).add_modifier(Modifier::BOLD);
19+ let _ = Span::styled("text", style);
20+ let _ = Paragraph::new("x").style(style);
21+ let _ = ListItem::new(Line::from(vec!["Dune".bold(), " — Herbert".dim()]));
22+ let _ = Row::new(vec![Cell::from("Dune"), Cell::from("overdue").red()]).on_dark_gray().height(2);
23+ let _ = Gauge::default().percent(40);
24+ let _ = LineGauge::default().ratio(0.4);
25+ let _ = KeyEvent::from(KeyCode::Right);
26+ let _ = ratatui::backend::TestBackend::new(20, 5);
27+}
new file mode 100644
@@ -0,0 +1,27 @@
1+// The expressions the tutorials quote in their prose, compiled by
2+// check-tutorials.sh so the text cannot drift from the API.
3+use ratatui::crossterm::event::{KeyCode, KeyEvent};
4+use ratatui::layout::{Alignment, Constraint, Layout};
5+use ratatui::style::{Color, Modifier, Style, Stylize};
6+use ratatui::text::{Line, Span};
7+use ratatui::widgets::{Block, Cell, Gauge, LineGauge, ListItem, Paragraph, Row};
8+
9+fn main() {
10+ let _ = Layout::vertical([Constraint::Length(1), Constraint::Ratio(1, 3), Constraint::Max(4)])
11+ .spacing(1)
12+ .margin(1);
13+ let block = Block::bordered();
14+ let _ = block.inner(ratatui::layout::Rect::new(0, 0, 10, 5));
15+ let _ = Paragraph::new("x").scroll((2, 0)).alignment(Alignment::Center).on_blue();
16+ let _: Span = "warning".red().bold();
17+ let _: Span = "ok".green().on_light_green().italic().underlined().reversed().dim();
18+ let style = Style::new().fg(Color::Rgb(255, 128, 0)).add_modifier(Modifier::BOLD);
19+ let _ = Span::styled("text", style);
20+ let _ = Paragraph::new("x").style(style);
21+ let _ = ListItem::new(Line::from(vec!["Dune".bold(), " — Herbert".dim()]));
22+ let _ = Row::new(vec![Cell::from("Dune"), Cell::from("overdue").red()]).on_dark_gray().height(2);
23+ let _ = Gauge::default().percent(40);
24+ let _ = LineGauge::default().ratio(0.4);
25+ let _ = KeyEvent::from(KeyCode::Right);
26+ let _ = ratatui::backend::TestBackend::new(20, 5);
27+}
modified docs/diagrams/packages.drawio +1 -1
@@ -1,3 +1,3 @@
11 <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
22 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3- <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
3+ <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
@@ -1,3 +1,3 @@
1 <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>1 <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3- <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
3+ <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/README.md +2 -0
@@ -27,6 +27,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
2727 - [Add a skill](how-to/add-a-skill.md)
2828 - [Declare a custom tool](how-to/declare-a-custom-tool.md)
2929 - [Save an answer to a file](how-to/save-an-answer.md)
30+- [Search a documentation folder](how-to/search-the-docs.md) — `/docs <words>`, the `search_docs` tool, `docSearch` (BM25, no model)
3031 - [Search earlier sessions](how-to/search-earlier-sessions.md) — `/memory <words>`, the `search_memory` tool, `sessions.searchMemory` / `sessions.autoSearchMemory`
3132 - [Free a model's memory](how-to/unload-a-model.md) — `/unload [model]` in every front end, the `unload_model` tool after another agent's answer, `unloadModels`
3233 - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after
@@ -69,6 +70,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
6970 - [Providers: one implementation per protocol](explanation/providers.md)
7071 - [Context compression](explanation/context-compression.md)
7172 - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`
73+- [Documentation search](explanation/document-search.md) — `/docs` and `search_docs`: why BM25 and no model, why no index, passages as sections, prefixes instead of stemming
7274 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission
7375 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits
7476 - [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text
@@ -27,6 +27,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
27 - [Add a skill](how-to/add-a-skill.md)27 - [Add a skill](how-to/add-a-skill.md)
28 - [Declare a custom tool](how-to/declare-a-custom-tool.md)28 - [Declare a custom tool](how-to/declare-a-custom-tool.md)
29 - [Save an answer to a file](how-to/save-an-answer.md)29 - [Save an answer to a file](how-to/save-an-answer.md)
30+- [Search a documentation folder](how-to/search-the-docs.md) — `/docs <words>`, the `search_docs` tool, `docSearch` (BM25, no model)
30 - [Search earlier sessions](how-to/search-earlier-sessions.md) — `/memory <words>`, the `search_memory` tool, `sessions.searchMemory` / `sessions.autoSearchMemory`31 - [Search earlier sessions](how-to/search-earlier-sessions.md) — `/memory <words>`, the `search_memory` tool, `sessions.searchMemory` / `sessions.autoSearchMemory`
31 - [Free a model's memory](how-to/unload-a-model.md) — `/unload [model]` in every front end, the `unload_model` tool after another agent's answer, `unloadModels`32 - [Free a model's memory](how-to/unload-a-model.md) — `/unload [model]` in every front end, the `unload_model` tool after another agent's answer, `unloadModels`
32 - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after33 - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after
@@ -69,6 +70,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
69 - [Providers: one implementation per protocol](explanation/providers.md)70 - [Providers: one implementation per protocol](explanation/providers.md)
70 - [Context compression](explanation/context-compression.md)71 - [Context compression](explanation/context-compression.md)
71 - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`72 - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`
73+- [Documentation search](explanation/document-search.md) — `/docs` and `search_docs`: why BM25 and no model, why no index, passages as sections, prefixes instead of stemming
72 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission74 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission
73 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits75 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits
74 - [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text76 - [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text
added docs/en/explanation/document-search.md +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+# Documentation search — explanation
2+
3+## What is this about?
4+
5+`/docs` and the `search_docs` tool (ticket 106) search the text files of some folders and return passages — a section of a page, with its heading path and its `path:line` link. They answer a different need from [`search_memory`](persistent-sessions.md): the stored sessions hold what was said and decided; a documentation folder holds what is written and maintained. The two searches are separate tools on purpose, with separate engines.
6+
7+## Why BM25, and no model
8+
9+The search ranks passages with BM25, the scoring of most full-text engines: a word counts more when it is rare in the folder and when it appears often in the passage, and long passages are not favoured for being long. It needs no model. The alternatives looked at (ticket 104) — `qmd`'s vector and hybrid modes, embedding-based tools — find passages by meaning, across languages and synonyms, but load one to three extra models (300 MB to 2 GB for `qmd query`) next to the chat model, on machines where memory is what runs out first. `qmd search`, its BM25 mode, is the closest equivalent of what mm does, and it needs Node or Bun, an index to build and keep up to date.
10+
11+BM25 also fixes what made the stored-session search noisy at first: there, every word counted the same, so a short common word matched nearly every message. With BM25 a word found everywhere weighs almost nothing by itself.
12+
13+## Why no index
14+
15+The folders are read at every call. mm's own `docs/` — 137 Markdown files, 1.3 MB, 1,320 passages — is read, cut and scored in about 45 ms; the ten files of the demo (44 passages) in about 1 ms (measured 2026-10-20, best of ten runs). An index would make that faster and add what an index brings: a place to store it, a step to rebuild it, and answers that are wrong between a change and the rebuild. For a documentation folder, read when a question needs it, a scan is fast enough and never stale.
16+
17+## Why passages are sections
18+
19+A hit is meant to be read by the model, so it must be a unit of meaning that fits next to four others: a section — a heading and what follows it — is what the author wrote as one. Its heading path ("Lists and selection › Keep the selection with ListState") says what it is about even when its text does not repeat it, which is why heading words weigh more. A long section is cut at blank lines, never inside a code block: half a program helps no one. The link points at the passage's first line, so the model can `read_file` the whole page when a passage is not enough.
20+
21+## Why prefixes, and no stemming
22+
23+Words of four letters or more also match longer words (`list` → `ListState`) and plurals (`widgets` → `widget`). That is a cheap answer to the commonest misses, for any language. A real stemmer would do better for one language and worse for the others, and would be one more dependency.
24+
25+## What it does not do
26+
27+It matches words, not meanings. "Comment découper l'écran ?" finds nothing in English tutorials. In the demo, a 4B model told to search with the documentation's words translated the question itself — "header footer body layout" — every time (6 of 6 French questions); the search did not have to.
28+
29+## How it relates to the rest
30+
31+`internal/docsearch` holds the engine (passages, BM25, the walk through the folders) and depends only on `internal/config` and `internal/textfold`, the case-and-accent folding it shares with the stored-session search. `/docs` is in the shared command table; the REPL, the window and the ACP façade each answer it from their working directory. `search_docs` is declared in `main.go` beside the other built-in tools.
new file mode 100644
@@ -0,0 +1,31 @@
1+# Documentation search — explanation
2+
3+## What is this about?
4+
5+`/docs` and the `search_docs` tool (ticket 106) search the text files of some folders and return passages — a section of a page, with its heading path and its `path:line` link. They answer a different need from [`search_memory`](persistent-sessions.md): the stored sessions hold what was said and decided; a documentation folder holds what is written and maintained. The two searches are separate tools on purpose, with separate engines.
6+
7+## Why BM25, and no model
8+
9+The search ranks passages with BM25, the scoring of most full-text engines: a word counts more when it is rare in the folder and when it appears often in the passage, and long passages are not favoured for being long. It needs no model. The alternatives looked at (ticket 104) — `qmd`'s vector and hybrid modes, embedding-based tools — find passages by meaning, across languages and synonyms, but load one to three extra models (300 MB to 2 GB for `qmd query`) next to the chat model, on machines where memory is what runs out first. `qmd search`, its BM25 mode, is the closest equivalent of what mm does, and it needs Node or Bun, an index to build and keep up to date.
10+
11+BM25 also fixes what made the stored-session search noisy at first: there, every word counted the same, so a short common word matched nearly every message. With BM25 a word found everywhere weighs almost nothing by itself.
12+
13+## Why no index
14+
15+The folders are read at every call. mm's own `docs/` — 137 Markdown files, 1.3 MB, 1,320 passages — is read, cut and scored in about 45 ms; the ten files of the demo (44 passages) in about 1 ms (measured 2026-10-20, best of ten runs). An index would make that faster and add what an index brings: a place to store it, a step to rebuild it, and answers that are wrong between a change and the rebuild. For a documentation folder, read when a question needs it, a scan is fast enough and never stale.
16+
17+## Why passages are sections
18+
19+A hit is meant to be read by the model, so it must be a unit of meaning that fits next to four others: a section — a heading and what follows it — is what the author wrote as one. Its heading path ("Lists and selection › Keep the selection with ListState") says what it is about even when its text does not repeat it, which is why heading words weigh more. A long section is cut at blank lines, never inside a code block: half a program helps no one. The link points at the passage's first line, so the model can `read_file` the whole page when a passage is not enough.
20+
21+## Why prefixes, and no stemming
22+
23+Words of four letters or more also match longer words (`list` → `ListState`) and plurals (`widgets` → `widget`). That is a cheap answer to the commonest misses, for any language. A real stemmer would do better for one language and worse for the others, and would be one more dependency.
24+
25+## What it does not do
26+
27+It matches words, not meanings. "Comment découper l'écran ?" finds nothing in English tutorials. In the demo, a 4B model told to search with the documentation's words translated the question itself — "header footer body layout" — every time (6 of 6 French questions); the search did not have to.
28+
29+## How it relates to the rest
30+
31+`internal/docsearch` holds the engine (passages, BM25, the walk through the folders) and depends only on `internal/config` and `internal/textfold`, the case-and-accent folding it shares with the stored-session search. `/docs` is in the shared command table; the REPL, the window and the ACP façade each answer it from their working directory. `search_docs` is declared in `main.go` beside the other built-in tools.
added docs/en/how-to/search-the-docs.md +68 -0
new file mode 100644
@@ -0,0 +1,68 @@
1+# How to search a documentation folder
2+
3+This guide shows how to search the text files of a folder — Markdown, AsciiDoc, plain text — by hand with `/docs`, or by letting the model search with its `search_docs` tool before it answers. The search ranks passages with BM25 and loads no model.
4+
5+## Search by hand
6+
7+1. Turn the command on and name the folders, in the agent's YAML:
8+
9+ ```yaml
10+ docSearch:
11+ searchDocs: true
12+ dirs: [docs]
13+ extensions: [.md]
14+ ```
15+
16+2. Start mm — the terminal, the window (`mm -tui`) or an editor — and type the words you are looking for:
17+
18+ ```text
19+ /docs liststate
20+ ```
21+
22+ ```text
23+ 🔎 5 passage(s) for "liststate" — 10 file(s) in docs
24+
25+ ── docs/04-lists.md:5 · Lists and selection › Keep the selection with ListState
26+ ## Keep the selection with ListState
27+
28+ `ListState` remembers which item is selected and how far the list is scrolled. …
29+ ```
30+
31+Each passage starts with its link, `path:line`, which an editor opens and `read_file` reads. The model is not called and the conversation is not changed.
32+
33+## Let the model search
34+
35+1. Declare the tool:
36+
37+ ```yaml
38+ docSearch:
39+ autoSearchDocs: true
40+ dirs: [docs]
41+ extensions: [.md]
42+ ```
43+
44+2. Tell the model, in its `system` prompt, when to call it and how to quote what it finds. What worked on a 4B model:
45+
46+ ```yaml
47+ system: |
48+ For any question the documentation may cover, call search_docs first, with English
49+ words — type and function names work best. Answer from the passages it returns,
50+ and end with the link of the passage you used (path:line).
51+ ```
52+
53+The two switches are independent: `searchDocs` alone gives you the command and the model no tool; `autoSearchDocs` alone the reverse.
54+
55+## Variants
56+
57+- **Several folders, several formats.** `dirs: [docs, guides, /shared/handbook]`, `extensions: [.md, .adoc, .txt]`. Only the listed extensions are read.
58+- **Over ACP.** Relative folders are resolved against the session's `cwd`: the project the editor opened.
59+- **Large files.** Files above `maxFileSize` (default `1MB`) are skipped; raise it, or split the file.
60+- **Questions in another language.** The search matches words, not meanings. Documentation in English searched with French words finds nothing — ask the model to search with the documentation's words (see the prompt above), which a small model did reliably in `demos/docs-search/`.
61+
62+## See also
63+
64+- [Configuration reference](../reference/configuration.md#docsearch-keys) — `docSearch`
65+- [Slash commands reference](../reference/slash-commands.md) — `/docs`
66+- [Tools reference](../reference/tools.md#search_docs) — `search_docs`
67+- [Why the documentation search works this way](../explanation/document-search.md)
68+- `demos/docs-search/` — a measured demo: an assistant answering from nine Ratatui tutorials
new file mode 100644
@@ -0,0 +1,68 @@
1+# How to search a documentation folder
2+
3+This guide shows how to search the text files of a folder — Markdown, AsciiDoc, plain text — by hand with `/docs`, or by letting the model search with its `search_docs` tool before it answers. The search ranks passages with BM25 and loads no model.
4+
5+## Search by hand
6+
7+1. Turn the command on and name the folders, in the agent's YAML:
8+
9+ ```yaml
10+ docSearch:
11+ searchDocs: true
12+ dirs: [docs]
13+ extensions: [.md]
14+ ```
15+
16+2. Start mm — the terminal, the window (`mm -tui`) or an editor — and type the words you are looking for:
17+
18+ ```text
19+ /docs liststate
20+ ```
21+
22+ ```text
23+ 🔎 5 passage(s) for "liststate" — 10 file(s) in docs
24+
25+ ── docs/04-lists.md:5 · Lists and selection › Keep the selection with ListState
26+ ## Keep the selection with ListState
27+
28+ `ListState` remembers which item is selected and how far the list is scrolled. …
29+ ```
30+
31+Each passage starts with its link, `path:line`, which an editor opens and `read_file` reads. The model is not called and the conversation is not changed.
32+
33+## Let the model search
34+
35+1. Declare the tool:
36+
37+ ```yaml
38+ docSearch:
39+ autoSearchDocs: true
40+ dirs: [docs]
41+ extensions: [.md]
42+ ```
43+
44+2. Tell the model, in its `system` prompt, when to call it and how to quote what it finds. What worked on a 4B model:
45+
46+ ```yaml
47+ system: |
48+ For any question the documentation may cover, call search_docs first, with English
49+ words — type and function names work best. Answer from the passages it returns,
50+ and end with the link of the passage you used (path:line).
51+ ```
52+
53+The two switches are independent: `searchDocs` alone gives you the command and the model no tool; `autoSearchDocs` alone the reverse.
54+
55+## Variants
56+
57+- **Several folders, several formats.** `dirs: [docs, guides, /shared/handbook]`, `extensions: [.md, .adoc, .txt]`. Only the listed extensions are read.
58+- **Over ACP.** Relative folders are resolved against the session's `cwd`: the project the editor opened.
59+- **Large files.** Files above `maxFileSize` (default `1MB`) are skipped; raise it, or split the file.
60+- **Questions in another language.** The search matches words, not meanings. Documentation in English searched with French words finds nothing — ask the model to search with the documentation's words (see the prompt above), which a small model did reliably in `demos/docs-search/`.
61+
62+## See also
63+
64+- [Configuration reference](../reference/configuration.md#docsearch-keys) — `docSearch`
65+- [Slash commands reference](../reference/slash-commands.md) — `/docs`
66+- [Tools reference](../reference/tools.md#search_docs) — `search_docs`
67+- [Why the documentation search works this way](../explanation/document-search.md)
68+- `demos/docs-search/` — a measured demo: an assistant answering from nine Ratatui tutorials
modified docs/en/reference/acp.md +1 -1
@@ -159,7 +159,7 @@ When mm is the **client** — an agent asking another one through `/ask`, `ask_a
159159 | `read` | `read` | `read_skill`, `read_file` |
160160 | `edit` | `edit` | `write_file`, `edit_file` |
161161 | `fetch` | `fetch` | `fetch_content` |
162-| `search` | `search` | `search_memory` |
162+| `search` | `search` | `search_memory`, `search_docs` |
163163 | `other` | `other` | `ask_agent`, `ask_user` |
164164
165165 ## Tool call identifiers
@@ -159,7 +159,7 @@ When mm is the **client** — an agent asking another one through `/ask`, `ask_a
159 | `read` | `read` | `read_skill`, `read_file` |159 | `read` | `read` | `read_skill`, `read_file` |
160 | `edit` | `edit` | `write_file`, `edit_file` |160 | `edit` | `edit` | `write_file`, `edit_file` |
161 | `fetch` | `fetch` | `fetch_content` |161 | `fetch` | `fetch` | `fetch_content` |
162-| `search` | `search` | `search_memory` |162+| `search` | `search` | `search_memory`, `search_docs` |
163 | `other` | `other` | `ask_agent`, `ask_user` |163 | `other` | `other` | `ask_agent`, `ask_user` |
164 164
165 ## Tool call identifiers165 ## Tool call identifiers
modified docs/en/reference/configuration.md +20 -1
@@ -33,6 +33,7 @@
3333 | `bashTimeout` | duration | `30s` | How long one `bash` command may run before it is killed; the tool then answers `[error: <bashTimeout> timeout exceeded]`. Must be > 0. The watchdog keeps counting while a command runs (ticket 57): a command longer than `watchdogTimeout` ends the turn, so raise both together. |
3434 | `context` | map | see below | Automatic history compression. |
3535 | `sessions` | map | see below | Persistent, per-project sessions. |
36+| `docSearch` | map | see below | BM25 search through the text files of some folders: the `/docs` command and the `search_docs` tool. |
3637 | `trace` | map | see below | Write every request to the model and every response to files. |
3738 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |
3839 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |
@@ -67,6 +68,18 @@
6768
6869 Either memory key set while `sessions.enabled` is false prints the start-up warning `sessions: searchMemory / autoSearchMemory need sessions.enabled: true — /memory and search_memory are off`.
6970
71+## `docSearch` keys
72+
73+| Key | Type | Default | Description |
74+|-----|------|---------|-------------|
75+| `docSearch.searchDocs` | bool | `false` | The `/docs <words>` command (terminal, window, editor). See [slash commands](slash-commands.md) and [How to search a documentation folder](../how-to/search-the-docs.md). |
76+| `docSearch.autoSearchDocs` | bool | `false` | The [`search_docs`](tools.md#search_docs) tool: the same search, called by the model. Independent of `searchDocs`. |
77+| `docSearch.dirs` | list of strings | `[docs]` | The folders searched, recursively. A relative one is resolved against the working directory — the directory `mm` runs in, the session's `cwd` in ACP mode and the window; an absolute one is used as-is. A folder that does not exist is reported in the result (`⚠️ <dir>: no such folder`). |
78+| `docSearch.extensions` | list of strings | `[.md, .markdown, .txt, .adoc, .asciidoc, .rst]` | The files searched, by extension, compared without case. Only these are read. |
79+| `docSearch.maxFileSize` | size | `1MB` | A bigger file is skipped. A number of bytes, or with `KB` / `MB` / `GB` (powers of 1024): `512KB`. |
80+
81+`dirs`, `extensions` and `maxFileSize` are checked only when `searchDocs` or `autoSearchDocs` is true. Hidden folders (`.git`, `.mm`…) and `node_modules` are skipped, symbolic links are not followed, a file holding a NUL byte is skipped as binary.
82+
7083 ## `trace` keys
7184
7285 | Key | Type | Default | Description |
@@ -133,7 +146,7 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ
133146 | Key | Type | Default | Description |
134147 |-----|------|---------|-------------|
135148 | `tools` | list | `[]` | One entry per declared tool. |
136-| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `search_memory`, `unload_model`, `write_file`), even one this agent turns off. |
149+| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `search_docs`, `search_memory`, `unload_model`, `write_file`), even one this agent turns off. |
137150 | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. |
138151 | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. |
139152 | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. |
@@ -230,6 +243,12 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev
230243 | `context.summaryMaxTokens must be > 0` | zero or negative |
231244 | `context.maxMessages must be >= 0` | negative value |
232245 | `sessions.dir must not be empty` | empty string |
246+| `docSearch.dirs must name at least one folder` | `dirs: []` with a switch on |
247+| `docSearch.dirs: a folder must not be empty` | `dirs: [""]` |
248+| `docSearch.extensions must name at least one extension` | `extensions: []` with a switch on |
249+| `docSearch.extensions: "md" must start with a dot, like ".md"` | an extension without its dot |
250+| `docSearch.maxFileSize must be > 0` | `maxFileSize: 0` with a switch on |
251+| `line N: "huge" is not a size: write 1MB, 512KB or a number of bytes` | `maxFileSize` that is not a size |
233252 | `trace.dir must not be empty` | empty string with the trace on |
234253 | `agentsMd.path must not be empty` | empty string with `agentsMd.enabled` on |
235254 | `agentsMd.maxChars must be >= 0` | negative value |
@@ -33,6 +33,7 @@
33 | `bashTimeout` | duration | `30s` | How long one `bash` command may run before it is killed; the tool then answers `[error: <bashTimeout> timeout exceeded]`. Must be > 0. The watchdog keeps counting while a command runs (ticket 57): a command longer than `watchdogTimeout` ends the turn, so raise both together. |33 | `bashTimeout` | duration | `30s` | How long one `bash` command may run before it is killed; the tool then answers `[error: <bashTimeout> timeout exceeded]`. Must be > 0. The watchdog keeps counting while a command runs (ticket 57): a command longer than `watchdogTimeout` ends the turn, so raise both together. |
34 | `context` | map | see below | Automatic history compression. |34 | `context` | map | see below | Automatic history compression. |
35 | `sessions` | map | see below | Persistent, per-project sessions. |35 | `sessions` | map | see below | Persistent, per-project sessions. |
36+| `docSearch` | map | see below | BM25 search through the text files of some folders: the `/docs` command and the `search_docs` tool. |
36 | `trace` | map | see below | Write every request to the model and every response to files. |37 | `trace` | map | see below | Write every request to the model and every response to files. |
37 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |38 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |
38 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |39 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |
@@ -67,6 +68,18 @@
67 68
68 Either memory key set while `sessions.enabled` is false prints the start-up warning `sessions: searchMemory / autoSearchMemory need sessions.enabled: true — /memory and search_memory are off`.69 Either memory key set while `sessions.enabled` is false prints the start-up warning `sessions: searchMemory / autoSearchMemory need sessions.enabled: true — /memory and search_memory are off`.
69 70
71+## `docSearch` keys
72+
73+| Key | Type | Default | Description |
74+|-----|------|---------|-------------|
75+| `docSearch.searchDocs` | bool | `false` | The `/docs <words>` command (terminal, window, editor). See [slash commands](slash-commands.md) and [How to search a documentation folder](../how-to/search-the-docs.md). |
76+| `docSearch.autoSearchDocs` | bool | `false` | The [`search_docs`](tools.md#search_docs) tool: the same search, called by the model. Independent of `searchDocs`. |
77+| `docSearch.dirs` | list of strings | `[docs]` | The folders searched, recursively. A relative one is resolved against the working directory — the directory `mm` runs in, the session's `cwd` in ACP mode and the window; an absolute one is used as-is. A folder that does not exist is reported in the result (`⚠️ <dir>: no such folder`). |
78+| `docSearch.extensions` | list of strings | `[.md, .markdown, .txt, .adoc, .asciidoc, .rst]` | The files searched, by extension, compared without case. Only these are read. |
79+| `docSearch.maxFileSize` | size | `1MB` | A bigger file is skipped. A number of bytes, or with `KB` / `MB` / `GB` (powers of 1024): `512KB`. |
80+
81+`dirs`, `extensions` and `maxFileSize` are checked only when `searchDocs` or `autoSearchDocs` is true. Hidden folders (`.git`, `.mm`…) and `node_modules` are skipped, symbolic links are not followed, a file holding a NUL byte is skipped as binary.
82+
70 ## `trace` keys83 ## `trace` keys
71 84
72 | Key | Type | Default | Description |85 | Key | Type | Default | Description |
@@ -133,7 +146,7 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ
133 | Key | Type | Default | Description |146 | Key | Type | Default | Description |
134 |-----|------|---------|-------------|147 |-----|------|---------|-------------|
135 | `tools` | list | `[]` | One entry per declared tool. |148 | `tools` | list | `[]` | One entry per declared tool. |
136-| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `search_memory`, `unload_model`, `write_file`), even one this agent turns off. |149+| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `search_docs`, `search_memory`, `unload_model`, `write_file`), even one this agent turns off. |
137 | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. |150 | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. |
138 | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. |151 | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. |
139 | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. |152 | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. |
@@ -230,6 +243,12 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev
230 | `context.summaryMaxTokens must be > 0` | zero or negative |243 | `context.summaryMaxTokens must be > 0` | zero or negative |
231 | `context.maxMessages must be >= 0` | negative value |244 | `context.maxMessages must be >= 0` | negative value |
232 | `sessions.dir must not be empty` | empty string |245 | `sessions.dir must not be empty` | empty string |
246+| `docSearch.dirs must name at least one folder` | `dirs: []` with a switch on |
247+| `docSearch.dirs: a folder must not be empty` | `dirs: [""]` |
248+| `docSearch.extensions must name at least one extension` | `extensions: []` with a switch on |
249+| `docSearch.extensions: "md" must start with a dot, like ".md"` | an extension without its dot |
250+| `docSearch.maxFileSize must be > 0` | `maxFileSize: 0` with a switch on |
251+| `line N: "huge" is not a size: write 1MB, 512KB or a number of bytes` | `maxFileSize` that is not a size |
233 | `trace.dir must not be empty` | empty string with the trace on |252 | `trace.dir must not be empty` | empty string with the trace on |
234 | `agentsMd.path must not be empty` | empty string with `agentsMd.enabled` on |253 | `agentsMd.path must not be empty` | empty string with `agentsMd.enabled` on |
235 | `agentsMd.maxChars must be >= 0` | negative value |254 | `agentsMd.maxChars must be >= 0` | negative value |
modified docs/en/reference/slash-commands.md +3 -1
@@ -16,6 +16,7 @@
1616 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |
1717 | `/save <file> [n]` | Writes the last answer (`n`: the n-th from the end) as it is to a new file, creating the missing directories, and prints `💾 saved the last answer to <file> (N bytes)`. Never overwrites: an existing file gives `⚠️ <file> already exists — nothing was written; choose another name`. A file name with spaces is not supported. The model is not called. | Sent to the model as a new message. |
1818 | `/memory <words>` | With `sessions.enabled` and `sessions.searchMemory`: searches the project's stored sessions and prints `🔎 N hit(s) for "<words>" in <dir>`, then two lines per hit — `<session id> · <date> · turn <n> · question\|answer\|summary` and the text around the words found, plus `→ <answer>` under a question (the answer its turn ended on) — or `🔎 no hit for "<words>" in <dir>`. Case and accents do not count; words of one or two letters are left out beside longer ones; a hit holds at least one of the words; hits are ranked by the number of words held, then newest first; 10 at most. Questions, answers and `/compact` summaries are searched, not tool calls; the conversation in progress is left out. Without words: `⚠️ nothing to search: give one or more words`. The model is not called; the history is not touched. | Sent to the model as a new message. |
19+| `/docs <words>` | With `docSearch.searchDocs`: searches the text files of `docSearch.dirs` with BM25 and prints `🔎 N passage(s) for "<words>" — F file(s) in <dirs>`, then per passage a line `── <path>:<line> · <heading › subheading>` and the passage as written; or `🔎 no passage for "<words>" — F file(s) in <dirs>`. Case and accents do not count; words of 4+ letters also match longer words and plurals; 5 passages at most. Without words: `⚠️ nothing to search: give one or more words`. The model is not called; the history is not touched. | Sent to the model as a new message. |
1920 | `/unload [model]` | With `unloadModels` (default on): unloads a model from the server's memory — this agent's own, or the one named — and prints one line: `🧹 unloaded <model>`, `🧹 <model> was not loaded: nothing to unload`, or `🧹 cannot unload <model>: <reason>` (a provider or a server that has no way to). The history is not touched; the next question reloads the agent's model. | Sent to the model as a new message. |
2021
2122 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.
@@ -171,13 +172,14 @@ The notation works in the line mode and over ACP too, typed by hand: a `$skill`
171172
172173 ## ACP mode
173174
174-Two commands exist in ACP mode, three with `unloadModels` (the default): `/new`, `/compact` and `/unload` — plus `/memory` with `sessions.searchMemory`. They are advertised to the editor through an `available_commands_update` notification and intercepted by `session/prompt` when the prompt text, trimmed, is exactly the command (`/unload` may be followed by a model name, `/memory` by its words). Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted.
175+Two commands exist in ACP mode, three with `unloadModels` (the default): `/new`, `/compact` and `/unload` — plus `/memory` with `sessions.searchMemory` and `/docs` with `docSearch.searchDocs`. They are advertised to the editor through an `available_commands_update` notification and intercepted by `session/prompt` when the prompt text, trimmed, is exactly the command (`/unload` may be followed by a model name, `/memory` and `/docs` by their words). Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted.
175176
176177 | Command | Over ACP |
177178 |---------|----------|
178179 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |
179180 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |
180181 | `/memory <words>` | With `sessions.enabled` and `sessions.searchMemory`, advertised with the input hint `<words>`: same as in the terminal, in the session's project (`sessions.dir` under its `cwd`), as the agent's text; the history is not touched. |
182+| `/docs <words>` | With `docSearch.searchDocs`, advertised with the input hint `<words>`: same as in the terminal, from the session's `cwd`, as the agent's text; the history is not touched. |
181183 | `/unload [model]` | With `unloadModels` (default on), advertised with the input hint `[model]`: same as in the terminal, the `🧹` line as the agent's text; the history is not touched. |
182184
183185 With `mmConfigs`, `/ask-<name>` and `/ask-with-context-<name>` are advertised too, each with the input hint `<prompt>`; the generic `/ask <name>` is not, since an editor's menu completes a command name but not its argument. See [Asking another agent](#asking-another-agent).
@@ -16,6 +16,7 @@
16 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |16 | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |
17 | `/save <file> [n]` | Writes the last answer (`n`: the n-th from the end) as it is to a new file, creating the missing directories, and prints `💾 saved the last answer to <file> (N bytes)`. Never overwrites: an existing file gives `⚠️ <file> already exists — nothing was written; choose another name`. A file name with spaces is not supported. The model is not called. | Sent to the model as a new message. |17 | `/save <file> [n]` | Writes the last answer (`n`: the n-th from the end) as it is to a new file, creating the missing directories, and prints `💾 saved the last answer to <file> (N bytes)`. Never overwrites: an existing file gives `⚠️ <file> already exists — nothing was written; choose another name`. A file name with spaces is not supported. The model is not called. | Sent to the model as a new message. |
18 | `/memory <words>` | With `sessions.enabled` and `sessions.searchMemory`: searches the project's stored sessions and prints `🔎 N hit(s) for "<words>" in <dir>`, then two lines per hit — `<session id> · <date> · turn <n> · question\|answer\|summary` and the text around the words found, plus `→ <answer>` under a question (the answer its turn ended on) — or `🔎 no hit for "<words>" in <dir>`. Case and accents do not count; words of one or two letters are left out beside longer ones; a hit holds at least one of the words; hits are ranked by the number of words held, then newest first; 10 at most. Questions, answers and `/compact` summaries are searched, not tool calls; the conversation in progress is left out. Without words: `⚠️ nothing to search: give one or more words`. The model is not called; the history is not touched. | Sent to the model as a new message. |18 | `/memory <words>` | With `sessions.enabled` and `sessions.searchMemory`: searches the project's stored sessions and prints `🔎 N hit(s) for "<words>" in <dir>`, then two lines per hit — `<session id> · <date> · turn <n> · question\|answer\|summary` and the text around the words found, plus `→ <answer>` under a question (the answer its turn ended on) — or `🔎 no hit for "<words>" in <dir>`. Case and accents do not count; words of one or two letters are left out beside longer ones; a hit holds at least one of the words; hits are ranked by the number of words held, then newest first; 10 at most. Questions, answers and `/compact` summaries are searched, not tool calls; the conversation in progress is left out. Without words: `⚠️ nothing to search: give one or more words`. The model is not called; the history is not touched. | Sent to the model as a new message. |
19+| `/docs <words>` | With `docSearch.searchDocs`: searches the text files of `docSearch.dirs` with BM25 and prints `🔎 N passage(s) for "<words>" — F file(s) in <dirs>`, then per passage a line `── <path>:<line> · <heading › subheading>` and the passage as written; or `🔎 no passage for "<words>" — F file(s) in <dirs>`. Case and accents do not count; words of 4+ letters also match longer words and plurals; 5 passages at most. Without words: `⚠️ nothing to search: give one or more words`. The model is not called; the history is not touched. | Sent to the model as a new message. |
19 | `/unload [model]` | With `unloadModels` (default on): unloads a model from the server's memory — this agent's own, or the one named — and prints one line: `🧹 unloaded <model>`, `🧹 <model> was not loaded: nothing to unload`, or `🧹 cannot unload <model>: <reason>` (a provider or a server that has no way to). The history is not touched; the next question reloads the agent's model. | Sent to the model as a new message. |20 | `/unload [model]` | With `unloadModels` (default on): unloads a model from the server's memory — this agent's own, or the one named — and prints one line: `🧹 unloaded <model>`, `🧹 <model> was not loaded: nothing to unload`, or `🧹 cannot unload <model>: <reason>` (a provider or a server that has no way to). The history is not touched; the next question reloads the agent's model. | Sent to the model as a new message. |
20 21
21 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.22 With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.
@@ -171,13 +172,14 @@ The notation works in the line mode and over ACP too, typed by hand: a `$skill`
171 172
172 ## ACP mode173 ## ACP mode
173 174
174-Two commands exist in ACP mode, three with `unloadModels` (the default): `/new`, `/compact` and `/unload` — plus `/memory` with `sessions.searchMemory`. They are advertised to the editor through an `available_commands_update` notification and intercepted by `session/prompt` when the prompt text, trimmed, is exactly the command (`/unload` may be followed by a model name, `/memory` by its words). Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted.175+Two commands exist in ACP mode, three with `unloadModels` (the default): `/new`, `/compact` and `/unload` — plus `/memory` with `sessions.searchMemory` and `/docs` with `docSearch.searchDocs`. They are advertised to the editor through an `available_commands_update` notification and intercepted by `session/prompt` when the prompt text, trimmed, is exactly the command (`/unload` may be followed by a model name, `/memory` and `/docs` by their words). Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted.
175 176
176 | Command | Over ACP |177 | Command | Over ACP |
177 |---------|----------|178 |---------|----------|
178 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |179 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |
179 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |180 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |
180 | `/memory <words>` | With `sessions.enabled` and `sessions.searchMemory`, advertised with the input hint `<words>`: same as in the terminal, in the session's project (`sessions.dir` under its `cwd`), as the agent's text; the history is not touched. |181 | `/memory <words>` | With `sessions.enabled` and `sessions.searchMemory`, advertised with the input hint `<words>`: same as in the terminal, in the session's project (`sessions.dir` under its `cwd`), as the agent's text; the history is not touched. |
182+| `/docs <words>` | With `docSearch.searchDocs`, advertised with the input hint `<words>`: same as in the terminal, from the session's `cwd`, as the agent's text; the history is not touched. |
181 | `/unload [model]` | With `unloadModels` (default on), advertised with the input hint `[model]`: same as in the terminal, the `🧹` line as the agent's text; the history is not touched. |183 | `/unload [model]` | With `unloadModels` (default on), advertised with the input hint `[model]`: same as in the terminal, the `🧹` line as the agent's text; the history is not touched. |
182 184
183 With `mmConfigs`, `/ask-<name>` and `/ask-with-context-<name>` are advertised too, each with the input hint `<prompt>`; the generic `/ask <name>` is not, since an editor's menu completes a command name but not its argument. See [Asking another agent](#asking-another-agent).185 With `mmConfigs`, `/ask-<name>` and `/ask-with-context-<name>` are advertised too, each with the input hint `<prompt>`; the generic `/ask <name>` is not, since an editor's menu completes a command name but not its argument. See [Asking another agent](#asking-another-agent).
modified docs/en/reference/tools.md +20 -0
@@ -288,6 +288,26 @@ Declared when `unloadModels` is true (the default), except beside a `structuredO
288288 | ACP and window | Tool call of kind `other`; a `cannot unload` result is a failed call. |
289289 | Timeout | 30 s per request to the server. |
290290
291+## `search_docs`
292+
293+Declared when `docSearch.autoSearchDocs` is true (default false). The same search, for the user, is the `/docs <words>` command (`docSearch.searchDocs`, see [slash commands](slash-commands.md)).
294+
295+| Field | Type | Description |
296+|-------|------|-------------|
297+| `query` | string | The words to look for. |
298+| `limit` | integer | How many passages at most. `0` or absent: 5; capped at 20. |
299+
300+| Aspect | Behaviour |
301+|--------|-----------|
302+| Where | The files of `docSearch.dirs` whose extension is in `docSearch.extensions`, recursively, within `docSearch.maxFileSize` (see [configuration](configuration.md#docsearch-keys)). Relative folders are resolved against the working directory: the process directory in terminal mode, the session `cwd` in ACP mode and the window. Read at every call: no index. |
303+| Passages | A Markdown (`.md`, `.markdown`) or AsciiDoc (`.adoc`, `.asciidoc`, `.asc`) file is cut at its headings (`#`… / `=`…), headings inside code fences or listing blocks ignored; a passage keeps its heading path. Any other file is cut at blank lines only. A passage longer than 1,500 characters is cut at blank lines outside fences; a single longer paragraph or code block stays whole. |
304+| Matching | Words are letters and digits, compared without case or accents. A query word of 4 letters or more also matches a longer word it starts (`list` → `ListState`) and a word it extends by one or two letters (`widgets` → `widget`). Query words of 1 or 2 letters are left out when the query has longer ones. |
305+| Ranking | BM25 (k1 1.2, b 0.75) over all the passages of the folders; the words of a passage's heading path count two extra times. Ties: path, then line. Passages holding none of the words are never returned. |
306+| Result | `🔎 N passage(s) for "<query>" — F file(s) in <dirs>`, then per passage `── <path>:<line> · <heading › subheading>` and the passage as written, lines kept. `<path>` is relative to the working directory when the file is inside it, absolute otherwise; `<line>` is the passage's first line. No passage: `🔎 no passage for "<query>" — F file(s) in <dirs>`. An empty query: `⚠️ nothing to search: give one or more words`. A missing folder adds `⚠️ <dir>: no such folder` before the header. Capped by `maxOutput`. |
307+| Permission | Never asked, even with `yolo: false`: the tool only reads. |
308+| Terminal display | `📚 search_docs: "<query>"`, then the result. |
309+| ACP and window | Tool call of kind `search`. |
310+
291311 ## `search_memory`
292312
293313 Declared when `sessions.enabled` and `sessions.autoSearchMemory` are both true (both default to false). The same search, for the user, is the `/memory <words>` command (`sessions.searchMemory`, see [slash commands](slash-commands.md)).
@@ -288,6 +288,26 @@ Declared when `unloadModels` is true (the default), except beside a `structuredO
288 | ACP and window | Tool call of kind `other`; a `cannot unload` result is a failed call. |288 | ACP and window | Tool call of kind `other`; a `cannot unload` result is a failed call. |
289 | Timeout | 30 s per request to the server. |289 | Timeout | 30 s per request to the server. |
290 290
291+## `search_docs`
292+
293+Declared when `docSearch.autoSearchDocs` is true (default false). The same search, for the user, is the `/docs <words>` command (`docSearch.searchDocs`, see [slash commands](slash-commands.md)).
294+
295+| Field | Type | Description |
296+|-------|------|-------------|
297+| `query` | string | The words to look for. |
298+| `limit` | integer | How many passages at most. `0` or absent: 5; capped at 20. |
299+
300+| Aspect | Behaviour |
301+|--------|-----------|
302+| Where | The files of `docSearch.dirs` whose extension is in `docSearch.extensions`, recursively, within `docSearch.maxFileSize` (see [configuration](configuration.md#docsearch-keys)). Relative folders are resolved against the working directory: the process directory in terminal mode, the session `cwd` in ACP mode and the window. Read at every call: no index. |
303+| Passages | A Markdown (`.md`, `.markdown`) or AsciiDoc (`.adoc`, `.asciidoc`, `.asc`) file is cut at its headings (`#`… / `=`…), headings inside code fences or listing blocks ignored; a passage keeps its heading path. Any other file is cut at blank lines only. A passage longer than 1,500 characters is cut at blank lines outside fences; a single longer paragraph or code block stays whole. |
304+| Matching | Words are letters and digits, compared without case or accents. A query word of 4 letters or more also matches a longer word it starts (`list` → `ListState`) and a word it extends by one or two letters (`widgets` → `widget`). Query words of 1 or 2 letters are left out when the query has longer ones. |
305+| Ranking | BM25 (k1 1.2, b 0.75) over all the passages of the folders; the words of a passage's heading path count two extra times. Ties: path, then line. Passages holding none of the words are never returned. |
306+| Result | `🔎 N passage(s) for "<query>" — F file(s) in <dirs>`, then per passage `── <path>:<line> · <heading › subheading>` and the passage as written, lines kept. `<path>` is relative to the working directory when the file is inside it, absolute otherwise; `<line>` is the passage's first line. No passage: `🔎 no passage for "<query>" — F file(s) in <dirs>`. An empty query: `⚠️ nothing to search: give one or more words`. A missing folder adds `⚠️ <dir>: no such folder` before the header. Capped by `maxOutput`. |
307+| Permission | Never asked, even with `yolo: false`: the tool only reads. |
308+| Terminal display | `📚 search_docs: "<query>"`, then the result. |
309+| ACP and window | Tool call of kind `search`. |
310+
291 ## `search_memory`311 ## `search_memory`
292 312
293 Declared when `sessions.enabled` and `sessions.autoSearchMemory` are both true (both default to false). The same search, for the user, is the `/memory <words>` command (`sessions.searchMemory`, see [slash commands](slash-commands.md)).313 Declared when `sessions.enabled` and `sessions.autoSearchMemory` are both true (both default to false). The same search, for the user, is the `/memory <words>` command (`sessions.searchMemory`, see [slash commands](slash-commands.md)).
modified docs/fr/README.md +2 -0
@@ -27,6 +27,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
2727 - [Ajouter un skill](how-to/add-a-skill.md)
2828 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)
2929 - [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md)
30+- [Chercher dans un dossier de documentation](how-to/search-the-docs.md) — `/docs <mots>`, l'outil `search_docs`, `docSearch` (BM25, sans modèle)
3031 - [Chercher dans les sessions précédentes](how-to/search-earlier-sessions.md) — `/memory <mots>`, l'outil `search_memory`, `sessions.searchMemory` / `sessions.autoSearchMemory`
3132 - [Libérer la mémoire d'un modèle](how-to/unload-a-model.md) — `/unload [modèle]` dans toutes les façades, l'outil `unload_model` après la réponse d'un autre agent, `unloadModels`
3233 - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après
@@ -69,6 +70,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
6970 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)
7071 - [Compression du contexte](explanation/context-compression.md)
7172 - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent
73+- [La recherche dans la documentation](explanation/document-search.md) — `/docs` et `search_docs` : pourquoi BM25 et aucun modèle, pourquoi pas d'index, des passages qui sont des sections, des préfixes plutôt qu'une racinisation
7274 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission
7375 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues
7476 - [Les outils déclarés](explanation/declared-tools.md) — pourquoi des outils typés à côté des skills, ce qui a été mesuré, pas de shell, appels invalides expliqués en texte
@@ -27,6 +27,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
27 - [Ajouter un skill](how-to/add-a-skill.md)27 - [Ajouter un skill](how-to/add-a-skill.md)
28 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)28 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)
29 - [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md)29 - [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md)
30+- [Chercher dans un dossier de documentation](how-to/search-the-docs.md) — `/docs <mots>`, l'outil `search_docs`, `docSearch` (BM25, sans modèle)
30 - [Chercher dans les sessions précédentes](how-to/search-earlier-sessions.md) — `/memory <mots>`, l'outil `search_memory`, `sessions.searchMemory` / `sessions.autoSearchMemory`31 - [Chercher dans les sessions précédentes](how-to/search-earlier-sessions.md) — `/memory <mots>`, l'outil `search_memory`, `sessions.searchMemory` / `sessions.autoSearchMemory`
31 - [Libérer la mémoire d'un modèle](how-to/unload-a-model.md) — `/unload [modèle]` dans toutes les façades, l'outil `unload_model` après la réponse d'un autre agent, `unloadModels`32 - [Libérer la mémoire d'un modèle](how-to/unload-a-model.md) — `/unload [modèle]` dans toutes les façades, l'outil `unload_model` après la réponse d'un autre agent, `unloadModels`
32 - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après33 - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après
@@ -69,6 +70,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
69 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)70 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)
70 - [Compression du contexte](explanation/context-compression.md)71 - [Compression du contexte](explanation/context-compression.md)
71 - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent72 - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent
73+- [La recherche dans la documentation](explanation/document-search.md) — `/docs` et `search_docs` : pourquoi BM25 et aucun modèle, pourquoi pas d'index, des passages qui sont des sections, des préfixes plutôt qu'une racinisation
72 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission74 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission
73 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues75 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues
74 - [Les outils déclarés](explanation/declared-tools.md) — pourquoi des outils typés à côté des skills, ce qui a été mesuré, pas de shell, appels invalides expliqués en texte76 - [Les outils déclarés](explanation/declared-tools.md) — pourquoi des outils typés à côté des skills, ce qui a été mesuré, pas de shell, appels invalides expliqués en texte
added docs/fr/explanation/document-search.md +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+# La recherche dans la documentation — explication
2+
3+## De quoi s'agit-il ?
4+
5+`/docs` et l'outil `search_docs` (ticket 106) cherchent dans les fichiers texte de dossiers et renvoient des passages — une section d'une page, avec le chemin de ses titres et son lien `chemin:ligne`. Ils répondent à un autre besoin que [`search_memory`](persistent-sessions.md) : les sessions enregistrées contiennent ce qui a été dit et décidé ; un dossier de documentation contient ce qui est écrit et maintenu. Les deux recherches sont des outils séparés, avec des moteurs séparés, volontairement.
6+
7+## Pourquoi BM25, et aucun modèle
8+
9+La recherche classe les passages avec BM25, le calcul de score de la plupart des moteurs plein texte : un mot compte davantage quand il est rare dans le dossier et fréquent dans le passage, et un passage long n'est pas favorisé parce qu'il est long. Il n'a besoin d'aucun modèle. Les alternatives étudiées (ticket 104) — les modes vectoriel et hybride de `qmd`, les outils à embeddings — trouvent les passages par le sens, entre langues et synonymes, mais chargent un à trois modèles de plus (de 300 Mo à 2 Go pour `qmd query`) à côté du modèle de chat, sur des machines où c'est la mémoire qui manque en premier. `qmd search`, son mode BM25, est l'équivalent le plus proche de ce que fait mm, et il demande Node ou Bun, un index à construire et à tenir à jour.
10+
11+BM25 corrige aussi ce qui rendait au début la recherche dans les sessions bruyante : là, chaque mot comptait autant, si bien qu'un mot court et courant correspondait à presque tous les messages. Avec BM25, un mot présent partout ne pèse presque rien à lui seul.
12+
13+## Pourquoi pas d'index
14+
15+Les dossiers sont lus à chaque appel. Le `docs/` de mm — 137 fichiers Markdown, 1,3 Mo, 1 320 passages — est lu, découpé et noté en 45 ms environ ; les dix fichiers de la démo (44 passages) en 1 ms environ (mesuré le 2026-10-20, meilleur de dix essais). Un index irait plus vite et apporterait ce qu'apporte un index : un endroit où le stocker, une étape pour le reconstruire, et des réponses fausses entre une modification et la reconstruction. Pour un dossier de documentation lu quand une question le demande, un parcours suffit et n'est jamais périmé.
16+
17+## Pourquoi des passages qui sont des sections
18+
19+Un résultat est fait pour être lu par le modèle ; il doit donc être une unité de sens qui tient à côté de quatre autres : une section — un titre et ce qui le suit — est ce que l'auteur a écrit d'un tenant. Le chemin de ses titres (« Lists and selection › Keep the selection with ListState ») dit de quoi elle parle même quand son texte ne le répète pas, d'où le poids supplémentaire des mots de titre. Une section longue est recoupée aux lignes vides, jamais à l'intérieur d'un bloc de code : la moitié d'un programme n'aide personne. Le lien pointe sur la première ligne du passage, pour que le modèle puisse lire la page entière avec `read_file` quand un passage ne suffit pas.
20+
21+## Pourquoi des préfixes, et pas de racinisation
22+
23+Les mots de quatre lettres ou plus trouvent aussi les mots plus longs (`list` → `ListState`) et les pluriels (`widgets` → `widget`). C'est une réponse peu coûteuse aux manques les plus courants, quelle que soit la langue. Un vrai raciniseur ferait mieux pour une langue et moins bien pour les autres, et serait une dépendance de plus.
24+
25+## Ce qu'elle ne fait pas
26+
27+Elle porte sur les mots, pas sur le sens. « Comment découper l'écran ? » ne trouve rien dans des tutoriels en anglais. Dans la démo, un modèle de 4B à qui l'on demande de chercher avec les mots de la documentation a traduit lui-même la question — « header footer body layout » — à chaque fois (6 questions en français sur 6) ; la recherche n'a pas eu à le faire.
28+
29+## Liens avec le reste
30+
31+`internal/docsearch` contient le moteur (passages, BM25, parcours des dossiers) et ne dépend que de `internal/config` et d'`internal/textfold`, le repli casse et accents qu'il partage avec la recherche dans les sessions. `/docs` est dans la table de commandes partagée ; le REPL, la fenêtre et la façade ACP y répondent chacun depuis leur répertoire de travail. `search_docs` est déclaré dans `main.go` à côté des autres outils intégrés.
new file mode 100644
@@ -0,0 +1,31 @@
1+# La recherche dans la documentation — explication
2+
3+## De quoi s'agit-il ?
4+
5+`/docs` et l'outil `search_docs` (ticket 106) cherchent dans les fichiers texte de dossiers et renvoient des passages — une section d'une page, avec le chemin de ses titres et son lien `chemin:ligne`. Ils répondent à un autre besoin que [`search_memory`](persistent-sessions.md) : les sessions enregistrées contiennent ce qui a été dit et décidé ; un dossier de documentation contient ce qui est écrit et maintenu. Les deux recherches sont des outils séparés, avec des moteurs séparés, volontairement.
6+
7+## Pourquoi BM25, et aucun modèle
8+
9+La recherche classe les passages avec BM25, le calcul de score de la plupart des moteurs plein texte : un mot compte davantage quand il est rare dans le dossier et fréquent dans le passage, et un passage long n'est pas favorisé parce qu'il est long. Il n'a besoin d'aucun modèle. Les alternatives étudiées (ticket 104) — les modes vectoriel et hybride de `qmd`, les outils à embeddings — trouvent les passages par le sens, entre langues et synonymes, mais chargent un à trois modèles de plus (de 300 Mo à 2 Go pour `qmd query`) à côté du modèle de chat, sur des machines où c'est la mémoire qui manque en premier. `qmd search`, son mode BM25, est l'équivalent le plus proche de ce que fait mm, et il demande Node ou Bun, un index à construire et à tenir à jour.
10+
11+BM25 corrige aussi ce qui rendait au début la recherche dans les sessions bruyante : là, chaque mot comptait autant, si bien qu'un mot court et courant correspondait à presque tous les messages. Avec BM25, un mot présent partout ne pèse presque rien à lui seul.
12+
13+## Pourquoi pas d'index
14+
15+Les dossiers sont lus à chaque appel. Le `docs/` de mm — 137 fichiers Markdown, 1,3 Mo, 1 320 passages — est lu, découpé et noté en 45 ms environ ; les dix fichiers de la démo (44 passages) en 1 ms environ (mesuré le 2026-10-20, meilleur de dix essais). Un index irait plus vite et apporterait ce qu'apporte un index : un endroit où le stocker, une étape pour le reconstruire, et des réponses fausses entre une modification et la reconstruction. Pour un dossier de documentation lu quand une question le demande, un parcours suffit et n'est jamais périmé.
16+
17+## Pourquoi des passages qui sont des sections
18+
19+Un résultat est fait pour être lu par le modèle ; il doit donc être une unité de sens qui tient à côté de quatre autres : une section — un titre et ce qui le suit — est ce que l'auteur a écrit d'un tenant. Le chemin de ses titres (« Lists and selection › Keep the selection with ListState ») dit de quoi elle parle même quand son texte ne le répète pas, d'où le poids supplémentaire des mots de titre. Une section longue est recoupée aux lignes vides, jamais à l'intérieur d'un bloc de code : la moitié d'un programme n'aide personne. Le lien pointe sur la première ligne du passage, pour que le modèle puisse lire la page entière avec `read_file` quand un passage ne suffit pas.
20+
21+## Pourquoi des préfixes, et pas de racinisation
22+
23+Les mots de quatre lettres ou plus trouvent aussi les mots plus longs (`list` → `ListState`) et les pluriels (`widgets` → `widget`). C'est une réponse peu coûteuse aux manques les plus courants, quelle que soit la langue. Un vrai raciniseur ferait mieux pour une langue et moins bien pour les autres, et serait une dépendance de plus.
24+
25+## Ce qu'elle ne fait pas
26+
27+Elle porte sur les mots, pas sur le sens. « Comment découper l'écran ? » ne trouve rien dans des tutoriels en anglais. Dans la démo, un modèle de 4B à qui l'on demande de chercher avec les mots de la documentation a traduit lui-même la question — « header footer body layout » — à chaque fois (6 questions en français sur 6) ; la recherche n'a pas eu à le faire.
28+
29+## Liens avec le reste
30+
31+`internal/docsearch` contient le moteur (passages, BM25, parcours des dossiers) et ne dépend que de `internal/config` et d'`internal/textfold`, le repli casse et accents qu'il partage avec la recherche dans les sessions. `/docs` est dans la table de commandes partagée ; le REPL, la fenêtre et la façade ACP y répondent chacun depuis leur répertoire de travail. `search_docs` est déclaré dans `main.go` à côté des autres outils intégrés.
added docs/fr/how-to/search-the-docs.md +68 -0
new file mode 100644
@@ -0,0 +1,68 @@
1+# Chercher dans un dossier de documentation
2+
3+Ce guide montre comment chercher dans les fichiers texte d'un dossier — Markdown, AsciiDoc, texte brut — à la main avec `/docs`, ou en laissant le modèle chercher avec son outil `search_docs` avant de répondre. La recherche classe les passages avec BM25 et ne charge aucun modèle.
4+
5+## Chercher à la main
6+
7+1. Activez la commande et nommez les dossiers, dans le YAML de l'agent :
8+
9+ ```yaml
10+ docSearch:
11+ searchDocs: true
12+ dirs: [docs]
13+ extensions: [.md]
14+ ```
15+
16+2. Lancez mm — le terminal, la fenêtre (`mm -tui`) ou un éditeur — et tapez les mots que vous cherchez :
17+
18+ ```text
19+ /docs liststate
20+ ```
21+
22+ ```text
23+ 🔎 5 passage(s) for "liststate" — 10 file(s) in docs
24+
25+ ── docs/04-lists.md:5 · Lists and selection › Keep the selection with ListState
26+ ## Keep the selection with ListState
27+
28+ `ListState` remembers which item is selected and how far the list is scrolled. …
29+ ```
30+
31+Chaque passage commence par son lien, `chemin:ligne`, qu'un éditeur ouvre et que `read_file` lit. Le modèle n'est pas appelé et la conversation n'est pas modifiée.
32+
33+## Laisser le modèle chercher
34+
35+1. Déclarez l'outil :
36+
37+ ```yaml
38+ docSearch:
39+ autoSearchDocs: true
40+ dirs: [docs]
41+ extensions: [.md]
42+ ```
43+
44+2. Dites au modèle, dans son prompt `system`, quand l'appeler et comment citer ce qu'il trouve. Ce qui a marché sur un modèle de 4B :
45+
46+ ```yaml
47+ system: |
48+ For any question the documentation may cover, call search_docs first, with English
49+ words — type and function names work best. Answer from the passages it returns,
50+ and end with the link of the passage you used (path:line).
51+ ```
52+
53+Les deux interrupteurs sont indépendants : `searchDocs` seul vous donne la commande et aucun outil au modèle ; `autoSearchDocs` seul l'inverse.
54+
55+## Variantes
56+
57+- **Plusieurs dossiers, plusieurs formats.** `dirs: [docs, guides, /shared/handbook]`, `extensions: [.md, .adoc, .txt]`. Seules les extensions listées sont lues.
58+- **En ACP.** Les dossiers relatifs sont résolus par rapport au `cwd` de la session : le projet ouvert dans l'éditeur.
59+- **Gros fichiers.** Les fichiers au-delà de `maxFileSize` (`1MB` par défaut) sont ignorés ; augmentez la limite, ou découpez le fichier.
60+- **Questions dans une autre langue.** La recherche porte sur les mots, pas sur le sens. Une documentation en anglais cherchée avec des mots français ne donne rien — demandez au modèle de chercher avec les mots de la documentation (voir le prompt ci-dessus), ce qu'un petit modèle a fait de façon fiable dans `demos/docs-search/`.
61+
62+## Voir aussi
63+
64+- [Référence de la configuration](../reference/configuration.md#clés-de-docsearch) — `docSearch`
65+- [Référence des commandes](../reference/slash-commands.md) — `/docs`
66+- [Référence des outils](../reference/tools.md#search_docs) — `search_docs`
67+- [Pourquoi la recherche dans la documentation fonctionne ainsi](../explanation/document-search.md)
68+- `demos/docs-search/` — une démo mesurée : un assistant qui répond à partir de neuf tutoriels Ratatui
new file mode 100644
@@ -0,0 +1,68 @@
1+# Chercher dans un dossier de documentation
2+
3+Ce guide montre comment chercher dans les fichiers texte d'un dossier — Markdown, AsciiDoc, texte brut — à la main avec `/docs`, ou en laissant le modèle chercher avec son outil `search_docs` avant de répondre. La recherche classe les passages avec BM25 et ne charge aucun modèle.
4+
5+## Chercher à la main
6+
7+1. Activez la commande et nommez les dossiers, dans le YAML de l'agent :
8+
9+ ```yaml
10+ docSearch:
11+ searchDocs: true
12+ dirs: [docs]
13+ extensions: [.md]
14+ ```
15+
16+2. Lancez mm — le terminal, la fenêtre (`mm -tui`) ou un éditeur — et tapez les mots que vous cherchez :
17+
18+ ```text
19+ /docs liststate
20+ ```
21+
22+ ```text
23+ 🔎 5 passage(s) for "liststate" — 10 file(s) in docs
24+
25+ ── docs/04-lists.md:5 · Lists and selection › Keep the selection with ListState
26+ ## Keep the selection with ListState
27+
28+ `ListState` remembers which item is selected and how far the list is scrolled. …
29+ ```
30+
31+Chaque passage commence par son lien, `chemin:ligne`, qu'un éditeur ouvre et que `read_file` lit. Le modèle n'est pas appelé et la conversation n'est pas modifiée.
32+
33+## Laisser le modèle chercher
34+
35+1. Déclarez l'outil :
36+
37+ ```yaml
38+ docSearch:
39+ autoSearchDocs: true
40+ dirs: [docs]
41+ extensions: [.md]
42+ ```
43+
44+2. Dites au modèle, dans son prompt `system`, quand l'appeler et comment citer ce qu'il trouve. Ce qui a marché sur un modèle de 4B :
45+
46+ ```yaml
47+ system: |
48+ For any question the documentation may cover, call search_docs first, with English
49+ words — type and function names work best. Answer from the passages it returns,
50+ and end with the link of the passage you used (path:line).
51+ ```
52+
53+Les deux interrupteurs sont indépendants : `searchDocs` seul vous donne la commande et aucun outil au modèle ; `autoSearchDocs` seul l'inverse.
54+
55+## Variantes
56+
57+- **Plusieurs dossiers, plusieurs formats.** `dirs: [docs, guides, /shared/handbook]`, `extensions: [.md, .adoc, .txt]`. Seules les extensions listées sont lues.
58+- **En ACP.** Les dossiers relatifs sont résolus par rapport au `cwd` de la session : le projet ouvert dans l'éditeur.
59+- **Gros fichiers.** Les fichiers au-delà de `maxFileSize` (`1MB` par défaut) sont ignorés ; augmentez la limite, ou découpez le fichier.
60+- **Questions dans une autre langue.** La recherche porte sur les mots, pas sur le sens. Une documentation en anglais cherchée avec des mots français ne donne rien — demandez au modèle de chercher avec les mots de la documentation (voir le prompt ci-dessus), ce qu'un petit modèle a fait de façon fiable dans `demos/docs-search/`.
61+
62+## Voir aussi
63+
64+- [Référence de la configuration](../reference/configuration.md#clés-de-docsearch) — `docSearch`
65+- [Référence des commandes](../reference/slash-commands.md) — `/docs`
66+- [Référence des outils](../reference/tools.md#search_docs) — `search_docs`
67+- [Pourquoi la recherche dans la documentation fonctionne ainsi](../explanation/document-search.md)
68+- `demos/docs-search/` — une démo mesurée : un assistant qui répond à partir de neuf tutoriels Ratatui
modified docs/fr/reference/acp.md +1 -1
@@ -159,7 +159,7 @@ Quand mm est le **client** — un agent qui en interroge un autre par `/ask`, `a
159159 | `read` | `read` | `read_skill`, `read_file` |
160160 | `edit` | `edit` | `write_file`, `edit_file` |
161161 | `fetch` | `fetch` | `fetch_content` |
162-| `search` | `search` | `search_memory` |
162+| `search` | `search` | `search_memory`, `search_docs` |
163163 | `other` | `other` | `ask_agent`, `ask_user` |
164164
165165 ## Identifiants d'appel d'outil
@@ -159,7 +159,7 @@ Quand mm est le **client** — un agent qui en interroge un autre par `/ask`, `a
159 | `read` | `read` | `read_skill`, `read_file` |159 | `read` | `read` | `read_skill`, `read_file` |
160 | `edit` | `edit` | `write_file`, `edit_file` |160 | `edit` | `edit` | `write_file`, `edit_file` |
161 | `fetch` | `fetch` | `fetch_content` |161 | `fetch` | `fetch` | `fetch_content` |
162-| `search` | `search` | `search_memory` |162+| `search` | `search` | `search_memory`, `search_docs` |
163 | `other` | `other` | `ask_agent`, `ask_user` |163 | `other` | `other` | `ask_agent`, `ask_user` |
164 164
165 ## Identifiants d'appel d'outil165 ## Identifiants d'appel d'outil
modified docs/fr/reference/configuration.md +20 -1
@@ -33,6 +33,7 @@
3333 | `bashTimeout` | durée | `30s` | Durée maximale d'une commande `bash` avant qu'elle soit tuée ; l'outil répond alors `[error: <bashTimeout> timeout exceeded]`. Doit être > 0. Le watchdog continue de compter pendant qu'une commande tourne (ticket 57) : une commande plus longue que `watchdogTimeout` termine le tour, donc relevez les deux ensemble. |
3434 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
3535 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
36+| `docSearch` | dictionnaire | voir ci-dessous | Recherche BM25 dans les fichiers texte de dossiers : la commande `/docs` et l'outil `search_docs`. |
3637 | `agentsMd` | dictionnaire | voir ci-dessous | Ajoute le `AGENTS.md` du projet au prompt système de chaque conversation. Actif par défaut. |
3738 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |
3839 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |
@@ -67,6 +68,18 @@
6768
6869 Une clé de mémoire activée alors que `sessions.enabled` vaut false affiche au démarrage l'avertissement `sessions: searchMemory / autoSearchMemory need sessions.enabled: true — /memory and search_memory are off`.
6970
71+## Clés de `docSearch`
72+
73+| Clé | Type | Défaut | Description |
74+|-----|------|--------|-------------|
75+| `docSearch.searchDocs` | booléen | `false` | La commande `/docs <mots>` (terminal, fenêtre, éditeur). Voir [commandes](slash-commands.md) et [Chercher dans un dossier de documentation](../how-to/search-the-docs.md). |
76+| `docSearch.autoSearchDocs` | booléen | `false` | L'outil [`search_docs`](tools.md#search_docs) : la même recherche, appelée par le modèle. Indépendante de `searchDocs`. |
77+| `docSearch.dirs` | liste de chaînes | `[docs]` | Les dossiers cherchés, récursivement. Un dossier relatif est résolu par rapport au répertoire de travail — celui où `mm` est lancé, le `cwd` de la session en mode ACP et dans la fenêtre ; un dossier absolu est utilisé tel quel. Un dossier qui n'existe pas est signalé dans le résultat (`⚠️ <rép>: no such folder`). |
78+| `docSearch.extensions` | liste de chaînes | `[.md, .markdown, .txt, .adoc, .asciidoc, .rst]` | Les fichiers cherchés, par extension, comparée sans tenir compte de la casse. Seuls ceux-là sont lus. |
79+| `docSearch.maxFileSize` | taille | `1MB` | Un fichier plus gros est ignoré. Un nombre d'octets, ou avec `KB` / `MB` / `GB` (puissances de 1024) : `512KB`. |
80+
81+`dirs`, `extensions` et `maxFileSize` ne sont vérifiées que si `searchDocs` ou `autoSearchDocs` vaut true. Les dossiers cachés (`.git`, `.mm`…) et `node_modules` sont ignorés, les liens symboliques ne sont pas suivis, un fichier contenant un octet NUL est ignoré comme binaire.
82+
7083 ## Clés de `trace`
7184
7285 | Clé | Type | Défaut | Description |
@@ -133,7 +146,7 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl
133146 | Clé | Type | Défaut | Description |
134147 |-----|------|--------|-------------|
135148 | `tools` | liste | `[]` | Une entrée par outil déclaré. |
136-| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `search_memory`, `unload_model`, `write_file`), même désactivé dans cet agent. |
149+| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `search_docs`, `search_memory`, `unload_model`, `write_file`), même désactivé dans cet agent. |
137150 | `tools[].description` | chaîne | — | Ce que fait l'outil et quand s'en servir, montré au modèle. Obligatoire. Une ligne `Required parameters: …` y est ajoutée. |
138151 | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. |
139152 | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. |
@@ -230,6 +243,12 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code
230243 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |
231244 | `context.maxMessages must be >= 0` | valeur négative |
232245 | `sessions.dir must not be empty` | chaîne vide |
246+| `docSearch.dirs must name at least one folder` | `dirs: []` avec un interrupteur activé |
247+| `docSearch.dirs: a folder must not be empty` | `dirs: [""]` |
248+| `docSearch.extensions must name at least one extension` | `extensions: []` avec un interrupteur activé |
249+| `docSearch.extensions: "md" must start with a dot, like ".md"` | une extension sans son point |
250+| `docSearch.maxFileSize must be > 0` | `maxFileSize: 0` avec un interrupteur activé |
251+| `line N: "huge" is not a size: write 1MB, 512KB or a number of bytes` | une `maxFileSize` qui n'est pas une taille |
233252 | `agentsMd.path must not be empty` | chaîne vide avec `agentsMd.enabled` actif |
234253 | `agentsMd.maxChars must be >= 0` | valeur négative |
235254 | `trace.dir must not be empty` | chaîne vide avec la trace active |
@@ -33,6 +33,7 @@
33 | `bashTimeout` | durée | `30s` | Durée maximale d'une commande `bash` avant qu'elle soit tuée ; l'outil répond alors `[error: <bashTimeout> timeout exceeded]`. Doit être > 0. Le watchdog continue de compter pendant qu'une commande tourne (ticket 57) : une commande plus longue que `watchdogTimeout` termine le tour, donc relevez les deux ensemble. |33 | `bashTimeout` | durée | `30s` | Durée maximale d'une commande `bash` avant qu'elle soit tuée ; l'outil répond alors `[error: <bashTimeout> timeout exceeded]`. Doit être > 0. Le watchdog continue de compter pendant qu'une commande tourne (ticket 57) : une commande plus longue que `watchdogTimeout` termine le tour, donc relevez les deux ensemble. |
34 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |34 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
35 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |35 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
36+| `docSearch` | dictionnaire | voir ci-dessous | Recherche BM25 dans les fichiers texte de dossiers : la commande `/docs` et l'outil `search_docs`. |
36 | `agentsMd` | dictionnaire | voir ci-dessous | Ajoute le `AGENTS.md` du projet au prompt système de chaque conversation. Actif par défaut. |37 | `agentsMd` | dictionnaire | voir ci-dessous | Ajoute le `AGENTS.md` du projet au prompt système de chaque conversation. Actif par défaut. |
37 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |38 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |
38 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |39 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |
@@ -67,6 +68,18 @@
67 68
68 Une clé de mémoire activée alors que `sessions.enabled` vaut false affiche au démarrage l'avertissement `sessions: searchMemory / autoSearchMemory need sessions.enabled: true — /memory and search_memory are off`.69 Une clé de mémoire activée alors que `sessions.enabled` vaut false affiche au démarrage l'avertissement `sessions: searchMemory / autoSearchMemory need sessions.enabled: true — /memory and search_memory are off`.
69 70
71+## Clés de `docSearch`
72+
73+| Clé | Type | Défaut | Description |
74+|-----|------|--------|-------------|
75+| `docSearch.searchDocs` | booléen | `false` | La commande `/docs <mots>` (terminal, fenêtre, éditeur). Voir [commandes](slash-commands.md) et [Chercher dans un dossier de documentation](../how-to/search-the-docs.md). |
76+| `docSearch.autoSearchDocs` | booléen | `false` | L'outil [`search_docs`](tools.md#search_docs) : la même recherche, appelée par le modèle. Indépendante de `searchDocs`. |
77+| `docSearch.dirs` | liste de chaînes | `[docs]` | Les dossiers cherchés, récursivement. Un dossier relatif est résolu par rapport au répertoire de travail — celui où `mm` est lancé, le `cwd` de la session en mode ACP et dans la fenêtre ; un dossier absolu est utilisé tel quel. Un dossier qui n'existe pas est signalé dans le résultat (`⚠️ <rép>: no such folder`). |
78+| `docSearch.extensions` | liste de chaînes | `[.md, .markdown, .txt, .adoc, .asciidoc, .rst]` | Les fichiers cherchés, par extension, comparée sans tenir compte de la casse. Seuls ceux-là sont lus. |
79+| `docSearch.maxFileSize` | taille | `1MB` | Un fichier plus gros est ignoré. Un nombre d'octets, ou avec `KB` / `MB` / `GB` (puissances de 1024) : `512KB`. |
80+
81+`dirs`, `extensions` et `maxFileSize` ne sont vérifiées que si `searchDocs` ou `autoSearchDocs` vaut true. Les dossiers cachés (`.git`, `.mm`…) et `node_modules` sont ignorés, les liens symboliques ne sont pas suivis, un fichier contenant un octet NUL est ignoré comme binaire.
82+
70 ## Clés de `trace`83 ## Clés de `trace`
71 84
72 | Clé | Type | Défaut | Description |85 | Clé | Type | Défaut | Description |
@@ -133,7 +146,7 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl
133 | Clé | Type | Défaut | Description |146 | Clé | Type | Défaut | Description |
134 |-----|------|--------|-------------|147 |-----|------|--------|-------------|
135 | `tools` | liste | `[]` | Une entrée par outil déclaré. |148 | `tools` | liste | `[]` | Une entrée par outil déclaré. |
136-| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `search_memory`, `unload_model`, `write_file`), même désactivé dans cet agent. |149+| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `save_answer`, `search_docs`, `search_memory`, `unload_model`, `write_file`), même désactivé dans cet agent. |
137 | `tools[].description` | chaîne | — | Ce que fait l'outil et quand s'en servir, montré au modèle. Obligatoire. Une ligne `Required parameters: …` y est ajoutée. |150 | `tools[].description` | chaîne | — | Ce que fait l'outil et quand s'en servir, montré au modèle. Obligatoire. Une ligne `Required parameters: …` y est ajoutée. |
138 | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. |151 | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. |
139 | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. |152 | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. |
@@ -230,6 +243,12 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code
230 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |243 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |
231 | `context.maxMessages must be >= 0` | valeur négative |244 | `context.maxMessages must be >= 0` | valeur négative |
232 | `sessions.dir must not be empty` | chaîne vide |245 | `sessions.dir must not be empty` | chaîne vide |
246+| `docSearch.dirs must name at least one folder` | `dirs: []` avec un interrupteur activé |
247+| `docSearch.dirs: a folder must not be empty` | `dirs: [""]` |
248+| `docSearch.extensions must name at least one extension` | `extensions: []` avec un interrupteur activé |
249+| `docSearch.extensions: "md" must start with a dot, like ".md"` | une extension sans son point |
250+| `docSearch.maxFileSize must be > 0` | `maxFileSize: 0` avec un interrupteur activé |
251+| `line N: "huge" is not a size: write 1MB, 512KB or a number of bytes` | une `maxFileSize` qui n'est pas une taille |
233 | `agentsMd.path must not be empty` | chaîne vide avec `agentsMd.enabled` actif |252 | `agentsMd.path must not be empty` | chaîne vide avec `agentsMd.enabled` actif |
234 | `agentsMd.maxChars must be >= 0` | valeur négative |253 | `agentsMd.maxChars must be >= 0` | valeur négative |
235 | `trace.dir must not be empty` | chaîne vide avec la trace active |254 | `trace.dir must not be empty` | chaîne vide avec la trace active |
modified docs/fr/reference/slash-commands.md +3 -1
@@ -15,6 +15,7 @@
1515 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |
1616 | `/save <fichier> [n]` | Écrit la dernière réponse (`n` : la n-ième depuis la fin) telle quelle dans un nouveau fichier, en créant les répertoires manquants, et affiche `💾 saved the last answer to <fichier> (N bytes)`. N'écrase jamais : un fichier existant donne `⚠️ <fichier> already exists — nothing was written; choose another name`. Un nom de fichier avec des espaces n'est pas pris en charge. Le modèle n'est pas appelé. | Envoyée au modèle comme nouveau message. |
1717 | `/memory <mots>` | Avec `sessions.enabled` et `sessions.searchMemory` : cherche dans les sessions enregistrées du projet et affiche `🔎 N hit(s) for "<mots>" in <rép>`, puis deux lignes par résultat — `<id de session> · <date> · turn <n> · question\|answer\|summary` et le texte autour des mots trouvés, plus `→ <réponse>` sous une question (la réponse sur laquelle son tour s'est terminé) — ou `🔎 no hit for "<mots>" in <rép>`. La casse et les accents ne comptent pas ; les mots d'une ou deux lettres sont ignorés à côté de mots plus longs ; un résultat contient au moins un des mots ; les résultats sont classés par nombre de mots contenus, puis du plus récent au plus ancien ; 10 au plus. Les questions, les réponses et les résumés de `/compact` sont cherchés, pas les appels d'outils ; la conversation en cours est exclue. Sans mots : `⚠️ nothing to search: give one or more words`. Le modèle n'est pas appelé ; l'historique n'est pas touché. | Envoyée au modèle comme nouveau message. |
18+| `/docs <mots>` | Avec `docSearch.searchDocs` : cherche avec BM25 dans les fichiers texte de `docSearch.dirs` et affiche `🔎 N passage(s) for "<mots>" — F file(s) in <dossiers>`, puis par passage une ligne `── <chemin>:<ligne> · <titre › sous-titre>` et le passage tel qu'il est écrit ; ou `🔎 no passage for "<mots>" — F file(s) in <dossiers>`. La casse et les accents ne comptent pas ; les mots de 4 lettres ou plus trouvent aussi les mots plus longs et les pluriels ; 5 passages au plus. Sans mots : `⚠️ nothing to search: give one or more words`. Le modèle n'est pas appelé ; l'historique n'est pas touché. | Envoyée au modèle comme nouveau message. |
1819 | `/unload [modèle]` | Avec `unloadModels` (activé par défaut) : décharge un modèle de la mémoire du serveur — celui de l'agent, ou celui nommé — et affiche une ligne : `🧹 unloaded <modèle>`, `🧹 <modèle> was not loaded: nothing to unload`, ou `🧹 cannot unload <modèle>: <raison>` (un fournisseur ou un serveur qui n'a aucun moyen de le faire). L'historique n'est pas touché ; la question suivante recharge le modèle de l'agent. | Envoyée au modèle comme nouveau message. |
1920 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |
2021
@@ -171,13 +172,14 @@ La notation marche aussi en mode ligne et en ACP, tapée à la main : un `$skill
171172
172173 ## Mode ACP
173174
174-Deux commandes existent en mode ACP, trois avec `unloadModels` (le défaut) : `/new`, `/compact` et `/unload` — plus `/memory` avec `sessions.searchMemory`. Elles sont annoncées à l'éditeur par une notification `available_commands_update` et interceptées par `session/prompt` quand le texte du prompt, débarrassé de ses espaces, est exactement la commande (`/unload` peut être suivie d'un nom de modèle, `/memory` de ses mots). Chacune répond par un `agent_message_chunk` portant la ligne que le terminal afficherait, puis `stopReason: end_turn` ; le modèle n'est pas consulté.
175+Deux commandes existent en mode ACP, trois avec `unloadModels` (le défaut) : `/new`, `/compact` et `/unload` — plus `/memory` avec `sessions.searchMemory` et `/docs` avec `docSearch.searchDocs`. Elles sont annoncées à l'éditeur par une notification `available_commands_update` et interceptées par `session/prompt` quand le texte du prompt, débarrassé de ses espaces, est exactement la commande (`/unload` peut être suivie d'un nom de modèle, `/memory` et `/docs` de leurs mots). Chacune répond par un `agent_message_chunk` portant la ligne que le terminal afficherait, puis `stopReason: end_turn` ; le modèle n'est pas consulté.
175176
176177 | Commande | En ACP |
177178 |----------|--------|
178179 | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. |
179180 | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. |
180181 | `/memory <mots>` | Avec `sessions.enabled` et `sessions.searchMemory`, annoncée avec l'indication de saisie `<words>` : comme dans le terminal, dans le projet de la session (`sessions.dir` sous son `cwd`), comme texte de l'agent ; l'historique n'est pas touché. |
182+| `/docs <mots>` | Avec `docSearch.searchDocs`, annoncée avec l'indication de saisie `<words>` : comme dans le terminal, depuis le `cwd` de la session, comme texte de l'agent ; l'historique n'est pas touché. |
181183 | `/unload [modèle]` | Avec `unloadModels` (activé par défaut), annoncée avec l'indication de saisie `[model]` : comme dans le terminal, la ligne `🧹` comme texte de l'agent ; l'historique n'est pas touché. |
182184
183185 Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes).
@@ -15,6 +15,7 @@
15 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |15 | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |
16 | `/save <fichier> [n]` | Écrit la dernière réponse (`n` : la n-ième depuis la fin) telle quelle dans un nouveau fichier, en créant les répertoires manquants, et affiche `💾 saved the last answer to <fichier> (N bytes)`. N'écrase jamais : un fichier existant donne `⚠️ <fichier> already exists — nothing was written; choose another name`. Un nom de fichier avec des espaces n'est pas pris en charge. Le modèle n'est pas appelé. | Envoyée au modèle comme nouveau message. |16 | `/save <fichier> [n]` | Écrit la dernière réponse (`n` : la n-ième depuis la fin) telle quelle dans un nouveau fichier, en créant les répertoires manquants, et affiche `💾 saved the last answer to <fichier> (N bytes)`. N'écrase jamais : un fichier existant donne `⚠️ <fichier> already exists — nothing was written; choose another name`. Un nom de fichier avec des espaces n'est pas pris en charge. Le modèle n'est pas appelé. | Envoyée au modèle comme nouveau message. |
17 | `/memory <mots>` | Avec `sessions.enabled` et `sessions.searchMemory` : cherche dans les sessions enregistrées du projet et affiche `🔎 N hit(s) for "<mots>" in <rép>`, puis deux lignes par résultat — `<id de session> · <date> · turn <n> · question\|answer\|summary` et le texte autour des mots trouvés, plus `→ <réponse>` sous une question (la réponse sur laquelle son tour s'est terminé) — ou `🔎 no hit for "<mots>" in <rép>`. La casse et les accents ne comptent pas ; les mots d'une ou deux lettres sont ignorés à côté de mots plus longs ; un résultat contient au moins un des mots ; les résultats sont classés par nombre de mots contenus, puis du plus récent au plus ancien ; 10 au plus. Les questions, les réponses et les résumés de `/compact` sont cherchés, pas les appels d'outils ; la conversation en cours est exclue. Sans mots : `⚠️ nothing to search: give one or more words`. Le modèle n'est pas appelé ; l'historique n'est pas touché. | Envoyée au modèle comme nouveau message. |17 | `/memory <mots>` | Avec `sessions.enabled` et `sessions.searchMemory` : cherche dans les sessions enregistrées du projet et affiche `🔎 N hit(s) for "<mots>" in <rép>`, puis deux lignes par résultat — `<id de session> · <date> · turn <n> · question\|answer\|summary` et le texte autour des mots trouvés, plus `→ <réponse>` sous une question (la réponse sur laquelle son tour s'est terminé) — ou `🔎 no hit for "<mots>" in <rép>`. La casse et les accents ne comptent pas ; les mots d'une ou deux lettres sont ignorés à côté de mots plus longs ; un résultat contient au moins un des mots ; les résultats sont classés par nombre de mots contenus, puis du plus récent au plus ancien ; 10 au plus. Les questions, les réponses et les résumés de `/compact` sont cherchés, pas les appels d'outils ; la conversation en cours est exclue. Sans mots : `⚠️ nothing to search: give one or more words`. Le modèle n'est pas appelé ; l'historique n'est pas touché. | Envoyée au modèle comme nouveau message. |
18+| `/docs <mots>` | Avec `docSearch.searchDocs` : cherche avec BM25 dans les fichiers texte de `docSearch.dirs` et affiche `🔎 N passage(s) for "<mots>" — F file(s) in <dossiers>`, puis par passage une ligne `── <chemin>:<ligne> · <titre › sous-titre>` et le passage tel qu'il est écrit ; ou `🔎 no passage for "<mots>" — F file(s) in <dossiers>`. La casse et les accents ne comptent pas ; les mots de 4 lettres ou plus trouvent aussi les mots plus longs et les pluriels ; 5 passages au plus. Sans mots : `⚠️ nothing to search: give one or more words`. Le modèle n'est pas appelé ; l'historique n'est pas touché. | Envoyée au modèle comme nouveau message. |
18 | `/unload [modèle]` | Avec `unloadModels` (activé par défaut) : décharge un modèle de la mémoire du serveur — celui de l'agent, ou celui nommé — et affiche une ligne : `🧹 unloaded <modèle>`, `🧹 <modèle> was not loaded: nothing to unload`, ou `🧹 cannot unload <modèle>: <raison>` (un fournisseur ou un serveur qui n'a aucun moyen de le faire). L'historique n'est pas touché ; la question suivante recharge le modèle de l'agent. | Envoyée au modèle comme nouveau message. |19 | `/unload [modèle]` | Avec `unloadModels` (activé par défaut) : décharge un modèle de la mémoire du serveur — celui de l'agent, ou celui nommé — et affiche une ligne : `🧹 unloaded <modèle>`, `🧹 <modèle> was not loaded: nothing to unload`, ou `🧹 cannot unload <modèle>: <raison>` (un fournisseur ou un serveur qui n'a aucun moyen de le faire). L'historique n'est pas touché ; la question suivante recharge le modèle de l'agent. | Envoyée au modèle comme nouveau message. |
19 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |20 | `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |
20 21
@@ -171,13 +172,14 @@ La notation marche aussi en mode ligne et en ACP, tapée à la main : un `$skill
171 172
172 ## Mode ACP173 ## Mode ACP
173 174
174-Deux commandes existent en mode ACP, trois avec `unloadModels` (le défaut) : `/new`, `/compact` et `/unload` — plus `/memory` avec `sessions.searchMemory`. Elles sont annoncées à l'éditeur par une notification `available_commands_update` et interceptées par `session/prompt` quand le texte du prompt, débarrassé de ses espaces, est exactement la commande (`/unload` peut être suivie d'un nom de modèle, `/memory` de ses mots). Chacune répond par un `agent_message_chunk` portant la ligne que le terminal afficherait, puis `stopReason: end_turn` ; le modèle n'est pas consulté.175+Deux commandes existent en mode ACP, trois avec `unloadModels` (le défaut) : `/new`, `/compact` et `/unload` — plus `/memory` avec `sessions.searchMemory` et `/docs` avec `docSearch.searchDocs`. Elles sont annoncées à l'éditeur par une notification `available_commands_update` et interceptées par `session/prompt` quand le texte du prompt, débarrassé de ses espaces, est exactement la commande (`/unload` peut être suivie d'un nom de modèle, `/memory` et `/docs` de leurs mots). Chacune répond par un `agent_message_chunk` portant la ligne que le terminal afficherait, puis `stopReason: end_turn` ; le modèle n'est pas consulté.
175 176
176 | Commande | En ACP |177 | Commande | En ACP |
177 |----------|--------|178 |----------|--------|
178 | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. |179 | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. |
179 | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. |180 | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. |
180 | `/memory <mots>` | Avec `sessions.enabled` et `sessions.searchMemory`, annoncée avec l'indication de saisie `<words>` : comme dans le terminal, dans le projet de la session (`sessions.dir` sous son `cwd`), comme texte de l'agent ; l'historique n'est pas touché. |181 | `/memory <mots>` | Avec `sessions.enabled` et `sessions.searchMemory`, annoncée avec l'indication de saisie `<words>` : comme dans le terminal, dans le projet de la session (`sessions.dir` sous son `cwd`), comme texte de l'agent ; l'historique n'est pas touché. |
182+| `/docs <mots>` | Avec `docSearch.searchDocs`, annoncée avec l'indication de saisie `<words>` : comme dans le terminal, depuis le `cwd` de la session, comme texte de l'agent ; l'historique n'est pas touché. |
181 | `/unload [modèle]` | Avec `unloadModels` (activé par défaut), annoncée avec l'indication de saisie `[model]` : comme dans le terminal, la ligne `🧹` comme texte de l'agent ; l'historique n'est pas touché. |183 | `/unload [modèle]` | Avec `unloadModels` (activé par défaut), annoncée avec l'indication de saisie `[model]` : comme dans le terminal, la ligne `🧹` comme texte de l'agent ; l'historique n'est pas touché. |
182 184
183 Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes).185 Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes).
modified docs/fr/reference/tools.md +20 -0
@@ -288,6 +288,26 @@ Déclaré quand `unloadModels` vaut true (le défaut), sauf à côté d'une `str
288288 | ACP et fenêtre | Appel d'outil de type `other` ; un résultat `cannot unload` est un appel en échec. |
289289 | Délai | 30 s par requête au serveur. |
290290
291+## `search_docs`
292+
293+Déclaré quand `docSearch.autoSearchDocs` vaut true (false par défaut). La même recherche, pour l'utilisateur, est la commande `/docs <mots>` (`docSearch.searchDocs`, voir [commandes](slash-commands.md)).
294+
295+| Champ | Type | Description |
296+|-------|------|-------------|
297+| `query` | chaîne | Les mots à chercher. |
298+| `limit` | entier | Nombre maximal de passages. `0` ou absent : 5 ; plafonné à 20. |
299+
300+| Aspect | Comportement |
301+|--------|--------------|
302+| Où | Les fichiers de `docSearch.dirs` dont l'extension figure dans `docSearch.extensions`, récursivement, dans la limite de `docSearch.maxFileSize` (voir [configuration](configuration.md#clés-de-docsearch)). Les dossiers relatifs sont résolus par rapport au répertoire de travail : celui du processus en mode terminal, le `cwd` de la session en mode ACP et dans la fenêtre. Lus à chaque appel : pas d'index. |
303+| Passages | Un fichier Markdown (`.md`, `.markdown`) ou AsciiDoc (`.adoc`, `.asciidoc`, `.asc`) est découpé à ses titres (`#`… / `=`…), les titres dans les blocs de code ou de listing étant ignorés ; un passage garde le chemin de ses titres. Tout autre fichier n'est découpé qu'aux lignes vides. Un passage de plus de 1 500 caractères est recoupé aux lignes vides hors blocs de code ; un paragraphe ou un bloc de code plus long reste entier. |
304+| Correspondance | Les mots sont les lettres et les chiffres, comparés sans tenir compte de la casse ni des accents. Un mot de la requête de 4 lettres ou plus trouve aussi un mot plus long qu'il commence (`list` → `ListState`) et un mot qu'il prolonge d'une ou deux lettres (`widgets` → `widget`). Les mots d'une ou deux lettres sont ignorés quand la requête en contient de plus longs. |
305+| Classement | BM25 (k1 1,2, b 0,75) sur tous les passages des dossiers ; les mots du chemin de titres d'un passage comptent deux fois de plus. Égalités : chemin, puis ligne. Un passage qui ne contient aucun des mots n'est jamais renvoyé. |
306+| Résultat | `🔎 N passage(s) for "<requête>" — F file(s) in <dossiers>`, puis par passage `── <chemin>:<ligne> · <titre › sous-titre>` et le passage tel qu'il est écrit, lignes conservées. `<chemin>` est relatif au répertoire de travail quand le fichier s'y trouve, absolu sinon ; `<ligne>` est la première ligne du passage. Aucun passage : `🔎 no passage for "<requête>" — F file(s) in <dossiers>`. Une requête vide : `⚠️ nothing to search: give one or more words`. Un dossier absent ajoute `⚠️ <rép>: no such folder` avant l'en-tête. Plafonné par `maxOutput`. |
307+| Permission | Jamais demandée, même avec `yolo: false` : l'outil ne fait que lire. |
308+| Affichage terminal | `📚 search_docs: "<requête>"`, puis le résultat. |
309+| ACP et fenêtre | Appel d'outil de type `search`. |
310+
291311 ## `search_memory`
292312
293313 Déclaré quand `sessions.enabled` et `sessions.autoSearchMemory` valent tous deux true (les deux valent false par défaut). La même recherche, pour l'utilisateur, est la commande `/memory <mots>` (`sessions.searchMemory`, voir [commandes](slash-commands.md)).
@@ -288,6 +288,26 @@ Déclaré quand `unloadModels` vaut true (le défaut), sauf à côté d'une `str
288 | ACP et fenêtre | Appel d'outil de type `other` ; un résultat `cannot unload` est un appel en échec. |288 | ACP et fenêtre | Appel d'outil de type `other` ; un résultat `cannot unload` est un appel en échec. |
289 | Délai | 30 s par requête au serveur. |289 | Délai | 30 s par requête au serveur. |
290 290
291+## `search_docs`
292+
293+Déclaré quand `docSearch.autoSearchDocs` vaut true (false par défaut). La même recherche, pour l'utilisateur, est la commande `/docs <mots>` (`docSearch.searchDocs`, voir [commandes](slash-commands.md)).
294+
295+| Champ | Type | Description |
296+|-------|------|-------------|
297+| `query` | chaîne | Les mots à chercher. |
298+| `limit` | entier | Nombre maximal de passages. `0` ou absent : 5 ; plafonné à 20. |
299+
300+| Aspect | Comportement |
301+|--------|--------------|
302+| Où | Les fichiers de `docSearch.dirs` dont l'extension figure dans `docSearch.extensions`, récursivement, dans la limite de `docSearch.maxFileSize` (voir [configuration](configuration.md#clés-de-docsearch)). Les dossiers relatifs sont résolus par rapport au répertoire de travail : celui du processus en mode terminal, le `cwd` de la session en mode ACP et dans la fenêtre. Lus à chaque appel : pas d'index. |
303+| Passages | Un fichier Markdown (`.md`, `.markdown`) ou AsciiDoc (`.adoc`, `.asciidoc`, `.asc`) est découpé à ses titres (`#`… / `=`…), les titres dans les blocs de code ou de listing étant ignorés ; un passage garde le chemin de ses titres. Tout autre fichier n'est découpé qu'aux lignes vides. Un passage de plus de 1 500 caractères est recoupé aux lignes vides hors blocs de code ; un paragraphe ou un bloc de code plus long reste entier. |
304+| Correspondance | Les mots sont les lettres et les chiffres, comparés sans tenir compte de la casse ni des accents. Un mot de la requête de 4 lettres ou plus trouve aussi un mot plus long qu'il commence (`list` → `ListState`) et un mot qu'il prolonge d'une ou deux lettres (`widgets` → `widget`). Les mots d'une ou deux lettres sont ignorés quand la requête en contient de plus longs. |
305+| Classement | BM25 (k1 1,2, b 0,75) sur tous les passages des dossiers ; les mots du chemin de titres d'un passage comptent deux fois de plus. Égalités : chemin, puis ligne. Un passage qui ne contient aucun des mots n'est jamais renvoyé. |
306+| Résultat | `🔎 N passage(s) for "<requête>" — F file(s) in <dossiers>`, puis par passage `── <chemin>:<ligne> · <titre › sous-titre>` et le passage tel qu'il est écrit, lignes conservées. `<chemin>` est relatif au répertoire de travail quand le fichier s'y trouve, absolu sinon ; `<ligne>` est la première ligne du passage. Aucun passage : `🔎 no passage for "<requête>" — F file(s) in <dossiers>`. Une requête vide : `⚠️ nothing to search: give one or more words`. Un dossier absent ajoute `⚠️ <rép>: no such folder` avant l'en-tête. Plafonné par `maxOutput`. |
307+| Permission | Jamais demandée, même avec `yolo: false` : l'outil ne fait que lire. |
308+| Affichage terminal | `📚 search_docs: "<requête>"`, puis le résultat. |
309+| ACP et fenêtre | Appel d'outil de type `search`. |
310+
291 ## `search_memory`311 ## `search_memory`
292 312
293 Déclaré quand `sessions.enabled` et `sessions.autoSearchMemory` valent tous deux true (les deux valent false par défaut). La même recherche, pour l'utilisateur, est la commande `/memory <mots>` (`sessions.searchMemory`, voir [commandes](slash-commands.md)).313 Déclaré quand `sessions.enabled` et `sessions.autoSearchMemory` valent tous deux true (les deux valent false par défaut). La même recherche, pour l'utilisateur, est la commande `/memory <mots>` (`sessions.searchMemory`, voir [commandes](slash-commands.md)).
added docs_assemble_test.go +36 -0
new file mode 100644
@@ -0,0 +1,36 @@
1+package main
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+
8+ "mm/internal/config"
9+ "mm/internal/detector"
10+ "mm/internal/engine"
11+
12+ "github.com/firebase/genkit/go/genkit"
13+)
14+
15+// search_docs is declared only with docSearch.autoSearchDocs; searchDocs alone
16+// (the /docs command) does not declare it.
17+func TestSearchDocsIsDeclaredOnlyWhenAsked(t *testing.T) {
18+ for _, c := range []struct {
19+ name string
20+ docs config.DocSearchConfig
21+ want bool
22+ }{
23+ {"default", config.DocSearchConfig{}, false},
24+ {"command only", config.DocSearchConfig{SearchDocs: true}, false},
25+ {"tool", config.DocSearchConfig{AutoSearchDocs: true}, true},
26+ } {
27+ t.Run(c.name, func(t *testing.T) {
28+ withConfig(t, func(cfg *config.Config) { cfg.DocSearch = c.docs })
29+ t.Setenv("MCP_GATEWAY_URL", "")
30+ set := assembleTools(&engine.Engine{G: genkit.Init(context.Background())}, detector.NewLoopDetector(10, 3), nil)
31+ if got := slices.Contains(set.names, "search_docs"); got != c.want {
32+ t.Errorf("search_docs declared = %v, want %v (names %v)", got, c.want, set.names)
33+ }
34+ })
35+ }
36+}
new file mode 100644
@@ -0,0 +1,36 @@
1+package main
2+
3+import (
4+ "context"
5+ "slices"
6+ "testing"
7+
8+ "mm/internal/config"
9+ "mm/internal/detector"
10+ "mm/internal/engine"
11+
12+ "github.com/firebase/genkit/go/genkit"
13+)
14+
15+// search_docs is declared only with docSearch.autoSearchDocs; searchDocs alone
16+// (the /docs command) does not declare it.
17+func TestSearchDocsIsDeclaredOnlyWhenAsked(t *testing.T) {
18+ for _, c := range []struct {
19+ name string
20+ docs config.DocSearchConfig
21+ want bool
22+ }{
23+ {"default", config.DocSearchConfig{}, false},
24+ {"command only", config.DocSearchConfig{SearchDocs: true}, false},
25+ {"tool", config.DocSearchConfig{AutoSearchDocs: true}, true},
26+ } {
27+ t.Run(c.name, func(t *testing.T) {
28+ withConfig(t, func(cfg *config.Config) { cfg.DocSearch = c.docs })
29+ t.Setenv("MCP_GATEWAY_URL", "")
30+ set := assembleTools(&engine.Engine{G: genkit.Init(context.Background())}, detector.NewLoopDetector(10, 3), nil)
31+ if got := slices.Contains(set.names, "search_docs"); got != c.want {
32+ t.Errorf("search_docs declared = %v, want %v (names %v)", got, c.want, set.names)
33+ }
34+ })
35+ }
36+}
modified internal/acp/acp.go +13 -0
@@ -35,6 +35,7 @@ import (
3535 "mm/internal/compact"
3636 "mm/internal/config"
3737 "mm/internal/consent"
38+ "mm/internal/docsearch"
3839 "mm/internal/engine"
3940 "mm/internal/mention"
4041 history "mm/internal/session"
@@ -498,10 +499,22 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session,
498499 return f.unloadModel(ctx, sid, s, arg), true
499500 case ok && cmd.Name == command.Memory:
500501 return f.searchMemory(ctx, sid, s, arg), true
502+ case ok && cmd.Name == command.Docs:
503+ return f.searchDocs(ctx, sid, s, arg), true
501504 }
502505 return sdk.PromptResponse{}, false
503506 }
504507
508+// searchDocs is /docs <words> over ACP: the passages found in the session's
509+// project (docSearch.dirs under its cwd), sent as the agent's answer; the
510+// history is not touched.
511+func (f *front) searchDocs(ctx context.Context, sid sdk.SessionId, s *session, query string) sdk.PromptResponse {
512+ text := docsearch.Answer(s.cwd, query, 0)
513+ fmt.Fprintf(os.Stderr, "[acp] session %s: /docs %q\n", sid, query)
514+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: sdk.UpdateAgentMessageText(text)})
515+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
516+}
517+
505518 // searchMemory is /memory <words> over ACP: the hits in the session's
506519 // project, sent as the agent's answer; the history is not touched.
507520 func (f *front) searchMemory(ctx context.Context, sid sdk.SessionId, s *session, query string) sdk.PromptResponse {
@@ -35,6 +35,7 @@ import (
35 "mm/internal/compact"35 "mm/internal/compact"
36 "mm/internal/config"36 "mm/internal/config"
37 "mm/internal/consent"37 "mm/internal/consent"
38+ "mm/internal/docsearch"
38 "mm/internal/engine"39 "mm/internal/engine"
39 "mm/internal/mention"40 "mm/internal/mention"
40 history "mm/internal/session"41 history "mm/internal/session"
@@ -498,10 +499,22 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session,
498 return f.unloadModel(ctx, sid, s, arg), true499 return f.unloadModel(ctx, sid, s, arg), true
499 case ok && cmd.Name == command.Memory:500 case ok && cmd.Name == command.Memory:
500 return f.searchMemory(ctx, sid, s, arg), true501 return f.searchMemory(ctx, sid, s, arg), true
502+ case ok && cmd.Name == command.Docs:
503+ return f.searchDocs(ctx, sid, s, arg), true
501 }504 }
502 return sdk.PromptResponse{}, false505 return sdk.PromptResponse{}, false
503 }506 }
504 507
508+// searchDocs is /docs <words> over ACP: the passages found in the session's
509+// project (docSearch.dirs under its cwd), sent as the agent's answer; the
510+// history is not touched.
511+func (f *front) searchDocs(ctx context.Context, sid sdk.SessionId, s *session, query string) sdk.PromptResponse {
512+ text := docsearch.Answer(s.cwd, query, 0)
513+ fmt.Fprintf(os.Stderr, "[acp] session %s: /docs %q\n", sid, query)
514+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: sdk.UpdateAgentMessageText(text)})
515+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
516+}
517+
505 // searchMemory is /memory <words> over ACP: the hits in the session's518 // searchMemory is /memory <words> over ACP: the hits in the session's
506 // project, sent as the agent's answer; the history is not touched.519 // project, sent as the agent's answer; the history is not touched.
507 func (f *front) searchMemory(ctx context.Context, sid sdk.SessionId, s *session, query string) sdk.PromptResponse {520 func (f *front) searchMemory(ctx context.Context, sid sdk.SessionId, s *session, query string) sdk.PromptResponse {
added internal/acp/docs_test.go +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+package acp
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/command"
10+ "mm/internal/config"
11+)
12+
13+// With docSearch.searchDocs on, /docs is advertised with an input hint and
14+// answered from the session's cwd as the agent's text.
15+func TestDocsOverACP(t *testing.T) {
16+ command.SetDocs(true)
17+ t.Cleanup(func() { command.SetDocs(false) })
18+ saved := config.Cfg.DocSearch
19+ t.Cleanup(func() { config.Cfg.DocSearch = saved })
20+ config.Cfg.DocSearch = config.DocSearchConfig{SearchDocs: true, Dirs: []string{"docs"}, Extensions: []string{".md"}, MaxFileSize: 1 << 20}
21+
22+ advertised := false
23+ for _, c := range availableCommands() {
24+ if c.Name == command.Docs {
25+ advertised = c.Input != nil
26+ }
27+ }
28+ if !advertised {
29+ t.Error("/docs is not advertised with an input hint")
30+ }
31+ cwd := t.TempDir()
32+ if err := os.MkdirAll(filepath.Join(cwd, "docs"), 0o755); err != nil {
33+ t.Fatal(err)
34+ }
35+ if err := os.WriteFile(filepath.Join(cwd, "docs", "tabs.md"), []byte("# Tabs\n\nTabs switch views.\n"), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ w := newWire(t, "sys")
39+ w.send(1, "session/new", `{"cwd":"`+cwd+`","mcpServers":[]}`)
40+ _, resp := w.until(1)
41+ sid := resp["result"].(map[string]any)["sessionId"].(string)
42+ w.send(2, "session/prompt", `{"sessionId":"`+sid+`","prompt":[{"type":"text","text":"/docs views"}]}`)
43+ notes, _ := w.until(2)
44+ var text strings.Builder
45+ for _, n := range notes {
46+ if n["sessionUpdate"] == "agent_message_chunk" {
47+ text.WriteString(n["content"].(map[string]any)["text"].(string))
48+ }
49+ }
50+ if got := text.String(); !strings.HasPrefix(got, `🔎 1 passage(s) for "views"`) || !strings.Contains(got, "── docs/tabs.md:1 · Tabs") {
51+ t.Errorf("agent text = %q", got)
52+ }
53+}
new file mode 100644
@@ -0,0 +1,53 @@
1+package acp
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/command"
10+ "mm/internal/config"
11+)
12+
13+// With docSearch.searchDocs on, /docs is advertised with an input hint and
14+// answered from the session's cwd as the agent's text.
15+func TestDocsOverACP(t *testing.T) {
16+ command.SetDocs(true)
17+ t.Cleanup(func() { command.SetDocs(false) })
18+ saved := config.Cfg.DocSearch
19+ t.Cleanup(func() { config.Cfg.DocSearch = saved })
20+ config.Cfg.DocSearch = config.DocSearchConfig{SearchDocs: true, Dirs: []string{"docs"}, Extensions: []string{".md"}, MaxFileSize: 1 << 20}
21+
22+ advertised := false
23+ for _, c := range availableCommands() {
24+ if c.Name == command.Docs {
25+ advertised = c.Input != nil
26+ }
27+ }
28+ if !advertised {
29+ t.Error("/docs is not advertised with an input hint")
30+ }
31+ cwd := t.TempDir()
32+ if err := os.MkdirAll(filepath.Join(cwd, "docs"), 0o755); err != nil {
33+ t.Fatal(err)
34+ }
35+ if err := os.WriteFile(filepath.Join(cwd, "docs", "tabs.md"), []byte("# Tabs\n\nTabs switch views.\n"), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ w := newWire(t, "sys")
39+ w.send(1, "session/new", `{"cwd":"`+cwd+`","mcpServers":[]}`)
40+ _, resp := w.until(1)
41+ sid := resp["result"].(map[string]any)["sessionId"].(string)
42+ w.send(2, "session/prompt", `{"sessionId":"`+sid+`","prompt":[{"type":"text","text":"/docs views"}]}`)
43+ notes, _ := w.until(2)
44+ var text strings.Builder
45+ for _, n := range notes {
46+ if n["sessionUpdate"] == "agent_message_chunk" {
47+ text.WriteString(n["content"].(map[string]any)["text"].(string))
48+ }
49+ }
50+ if got := text.String(); !strings.HasPrefix(got, `🔎 1 passage(s) for "views"`) || !strings.Contains(got, "── docs/tabs.md:1 · Tabs") {
51+ t.Errorf("agent text = %q", got)
52+ }
53+}
modified internal/agent/agent.go +3 -0
@@ -18,6 +18,7 @@ import (
1818 "mm/internal/config"
1919 "mm/internal/consent"
2020 "mm/internal/detector"
21+ "mm/internal/docsearch"
2122 "mm/internal/elicit"
2223 "mm/internal/engine"
2324 "mm/internal/hooks"
@@ -538,6 +539,8 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa
538539 fmt.Println("🧹 " + l.e.Unload(l.ctx, arg))
539540 case command.Memory:
540541 fmt.Println(l.keep.Memory(arg, messages))
542+ case command.Docs:
543+ fmt.Println(docsearch.Answer("", arg, 0))
541544 case command.Resume:
542545 resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, ""))
543546 if err != nil {
@@ -18,6 +18,7 @@ import (
18 "mm/internal/config"18 "mm/internal/config"
19 "mm/internal/consent"19 "mm/internal/consent"
20 "mm/internal/detector"20 "mm/internal/detector"
21+ "mm/internal/docsearch"
21 "mm/internal/elicit"22 "mm/internal/elicit"
22 "mm/internal/engine"23 "mm/internal/engine"
23 "mm/internal/hooks"24 "mm/internal/hooks"
@@ -538,6 +539,8 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa
538 fmt.Println("🧹 " + l.e.Unload(l.ctx, arg))539 fmt.Println("🧹 " + l.e.Unload(l.ctx, arg))
539 case command.Memory:540 case command.Memory:
540 fmt.Println(l.keep.Memory(arg, messages))541 fmt.Println(l.keep.Memory(arg, messages))
542+ case command.Docs:
543+ fmt.Println(docsearch.Answer("", arg, 0))
541 case command.Resume:544 case command.Resume:
542 resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, ""))545 resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, ""))
543 if err != nil {546 if err != nil {
modified internal/agent/memory_test.go +23 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "io"
66 "os"
7+ "path/filepath"
78 "strings"
89 "testing"
910
@@ -54,3 +55,25 @@ func TestMemoryCommandInTheLineMode(t *testing.T) {
5455 t.Errorf("printed:\n%s", out)
5556 }
5657 }
58+
59+// In the line mode /docs prints the passages found from the process's
60+// directory.
61+func TestDocsCommandInTheLineMode(t *testing.T) {
62+ command.SetDocs(true)
63+ t.Cleanup(func() { command.SetDocs(false) })
64+ saved := config.Cfg.DocSearch
65+ t.Cleanup(func() { config.Cfg.DocSearch = saved })
66+ dir := t.TempDir()
67+ config.Cfg.DocSearch = config.DocSearchConfig{SearchDocs: true, Dirs: []string{filepath.Join(dir, "docs")}, Extensions: []string{".md"}, MaxFileSize: 1 << 20}
68+ if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil {
69+ t.Fatal(err)
70+ }
71+ if err := os.WriteFile(filepath.Join(dir, "docs", "gauge.md"), []byte("# Gauge\n\nprogress bar\n"), 0o644); err != nil {
72+ t.Fatal(err)
73+ }
74+ l := &loop{ctx: context.Background()}
75+ out := stdoutOf(t, func() { l.historyCommand("/docs progress", nil) })
76+ if !strings.Contains(out, `🔎 1 passage(s) for "progress"`) || !strings.Contains(out, "gauge.md:1 · Gauge") {
77+ t.Errorf("printed:\n%s", out)
78+ }
79+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "io"5 "io"
6 "os"6 "os"
7+ "path/filepath"
7 "strings"8 "strings"
8 "testing"9 "testing"
9 10
@@ -54,3 +55,25 @@ func TestMemoryCommandInTheLineMode(t *testing.T) {
54 t.Errorf("printed:\n%s", out)55 t.Errorf("printed:\n%s", out)
55 }56 }
56 }57 }
58+
59+// In the line mode /docs prints the passages found from the process's
60+// directory.
61+func TestDocsCommandInTheLineMode(t *testing.T) {
62+ command.SetDocs(true)
63+ t.Cleanup(func() { command.SetDocs(false) })
64+ saved := config.Cfg.DocSearch
65+ t.Cleanup(func() { config.Cfg.DocSearch = saved })
66+ dir := t.TempDir()
67+ config.Cfg.DocSearch = config.DocSearchConfig{SearchDocs: true, Dirs: []string{filepath.Join(dir, "docs")}, Extensions: []string{".md"}, MaxFileSize: 1 << 20}
68+ if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil {
69+ t.Fatal(err)
70+ }
71+ if err := os.WriteFile(filepath.Join(dir, "docs", "gauge.md"), []byte("# Gauge\n\nprogress bar\n"), 0o644); err != nil {
72+ t.Fatal(err)
73+ }
74+ l := &loop{ctx: context.Background()}
75+ out := stdoutOf(t, func() { l.historyCommand("/docs progress", nil) })
76+ if !strings.Contains(out, `🔎 1 passage(s) for "progress"`) || !strings.Contains(out, "gauge.md:1 · Gauge") {
77+ t.Errorf("printed:\n%s", out)
78+ }
79+}
modified internal/command/command.go +2 -1
@@ -43,6 +43,7 @@ const (
4343 Save = "save"
4444 Unload = "unload"
4545 Memory = "memory"
46+ Docs = "docs"
4647 )
4748
4849 // Command is one slash command: what it is called, what it does, who offers
@@ -82,7 +83,7 @@ var table = []Command{
8283 // when the configuration allows it (unload.go), then the /ask family generated
8384 // from the configured agents (ask.go).
8485 func All() []Command {
85- return append(append(append(append([]Command(nil), table...), memoryCommands...), unloadCommands...), askCommands...)
86+ return append(append(append(append(append([]Command(nil), table...), memoryCommands...), docsCommands...), unloadCommands...), askCommands...)
8687 }
8788
8889 // For returns the commands front f offers, in display order.
@@ -43,6 +43,7 @@ const (
43 Save = "save"43 Save = "save"
44 Unload = "unload"44 Unload = "unload"
45 Memory = "memory"45 Memory = "memory"
46+ Docs = "docs"
46 )47 )
47 48
48 // Command is one slash command: what it is called, what it does, who offers49 // Command is one slash command: what it is called, what it does, who offers
@@ -82,7 +83,7 @@ var table = []Command{
82 // when the configuration allows it (unload.go), then the /ask family generated83 // when the configuration allows it (unload.go), then the /ask family generated
83 // from the configured agents (ask.go).84 // from the configured agents (ask.go).
84 func All() []Command {85 func All() []Command {
85- return append(append(append(append([]Command(nil), table...), memoryCommands...), unloadCommands...), askCommands...)86+ return append(append(append(append(append([]Command(nil), table...), memoryCommands...), docsCommands...), unloadCommands...), askCommands...)
86 }87 }
87 88
88 // For returns the commands front f offers, in display order.89 // For returns the commands front f offers, in display order.
added internal/command/docs.go +23 -0
new file mode 100644
@@ -0,0 +1,23 @@
1+package command
2+
3+// docsCommands holds /docs when config `docSearch.searchDocs` is on; empty
4+// otherwise, so /help and the menus do not show a command that is off.
5+var docsCommands []Command
6+
7+// SetDocs adds /docs <words> to every front end, the editor included, or
8+// removes it. Called once by main after the config is loaded.
9+//
10+// command.SetDocs(config.Cfg.DocSearch.DocsCommand())
11+// cmd, arg, _ := command.Parse("/docs list state") // Docs, "list state"
12+func SetDocs(enabled bool) {
13+ docsCommands = nil
14+ if enabled {
15+ docsCommands = []Command{{
16+ Name: Docs,
17+ Description: "Search the documentation folders (docSearch.dirs) for these words",
18+ Fronts: Terminal | Editor | Window,
19+ TakesArg: true,
20+ Hint: "<words>",
21+ }}
22+ }
23+}
new file mode 100644
@@ -0,0 +1,23 @@
1+package command
2+
3+// docsCommands holds /docs when config `docSearch.searchDocs` is on; empty
4+// otherwise, so /help and the menus do not show a command that is off.
5+var docsCommands []Command
6+
7+// SetDocs adds /docs <words> to every front end, the editor included, or
8+// removes it. Called once by main after the config is loaded.
9+//
10+// command.SetDocs(config.Cfg.DocSearch.DocsCommand())
11+// cmd, arg, _ := command.Parse("/docs list state") // Docs, "list state"
12+func SetDocs(enabled bool) {
13+ docsCommands = nil
14+ if enabled {
15+ docsCommands = []Command{{
16+ Name: Docs,
17+ Description: "Search the documentation folders (docSearch.dirs) for these words",
18+ Fronts: Terminal | Editor | Window,
19+ TakesArg: true,
20+ Hint: "<words>",
21+ }}
22+ }
23+}
added internal/command/docs_test.go +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+package command
2+
3+import "testing"
4+
5+func TestSetDocsAddsAndRemovesTheCommand(t *testing.T) {
6+ t.Cleanup(func() { SetDocs(false) })
7+
8+ SetDocs(true)
9+ cmd, arg, ok := Parse("/docs list state")
10+ if !ok || cmd.Name != Docs || arg != "list state" {
11+ t.Fatalf("Parse = %+v %q %v", cmd, arg, ok)
12+ }
13+ for _, f := range []Front{Terminal, Window, Editor} {
14+ found := false
15+ for _, c := range For(f) {
16+ found = found || c.Name == Docs
17+ }
18+ if !found {
19+ t.Errorf("front %d does not offer /docs", f)
20+ }
21+ }
22+
23+ SetDocs(false)
24+ if _, _, ok := Parse("/docs x"); ok {
25+ t.Error("/docs still parsed with the command off")
26+ }
27+}
new file mode 100644
@@ -0,0 +1,27 @@
1+package command
2+
3+import "testing"
4+
5+func TestSetDocsAddsAndRemovesTheCommand(t *testing.T) {
6+ t.Cleanup(func() { SetDocs(false) })
7+
8+ SetDocs(true)
9+ cmd, arg, ok := Parse("/docs list state")
10+ if !ok || cmd.Name != Docs || arg != "list state" {
11+ t.Fatalf("Parse = %+v %q %v", cmd, arg, ok)
12+ }
13+ for _, f := range []Front{Terminal, Window, Editor} {
14+ found := false
15+ for _, c := range For(f) {
16+ found = found || c.Name == Docs
17+ }
18+ if !found {
19+ t.Errorf("front %d does not offer /docs", f)
20+ }
21+ }
22+
23+ SetDocs(false)
24+ if _, _, ok := Parse("/docs x"); ok {
25+ t.Error("/docs still parsed with the command off")
26+ }
27+}
modified internal/config/config.go +13 -0
@@ -178,6 +178,11 @@ type Config struct {
178178 // editor can reload one over ACP (session/load).
179179 Sessions SessionsConfig `yaml:"sessions"`
180180
181+ // DocSearch: BM25 search through the text files of some folders — the
182+ // /docs command and the search_docs tool (internal/docsearch). Both off
183+ // by default.
184+ DocSearch DocSearchConfig `yaml:"docSearch"`
185+
181186 // Trace: write every request sent to the model and every response, as
182187 // they travel on the wire, to files (internal/engine/trace.go). Off
183188 // unless `trace.enabled: true` or AGENT_TRACE=true.
@@ -608,6 +613,11 @@ which ones you left running.`,
608613 Enabled: false,
609614 Dir: ".mm/sessions",
610615 },
616+ DocSearch: DocSearchConfig{
617+ Dirs: []string{"docs"},
618+ Extensions: []string{".md", ".markdown", ".txt", ".adoc", ".asciidoc", ".rst"},
619+ MaxFileSize: 1 << 20,
620+ },
611621 Trace: TraceConfig{
612622 Enabled: false,
613623 Dir: ".mm/traces",
@@ -864,5 +874,8 @@ func (c Config) validate() error {
864874 if err := c.validateTools(); err != nil {
865875 return err
866876 }
877+ if err := c.DocSearch.validate(); err != nil {
878+ return err
879+ }
867880 return c.LSP.validate()
868881 }
@@ -178,6 +178,11 @@ type Config struct {
178 // editor can reload one over ACP (session/load).178 // editor can reload one over ACP (session/load).
179 Sessions SessionsConfig `yaml:"sessions"`179 Sessions SessionsConfig `yaml:"sessions"`
180 180
181+ // DocSearch: BM25 search through the text files of some folders — the
182+ // /docs command and the search_docs tool (internal/docsearch). Both off
183+ // by default.
184+ DocSearch DocSearchConfig `yaml:"docSearch"`
185+
181 // Trace: write every request sent to the model and every response, as186 // Trace: write every request sent to the model and every response, as
182 // they travel on the wire, to files (internal/engine/trace.go). Off187 // they travel on the wire, to files (internal/engine/trace.go). Off
183 // unless `trace.enabled: true` or AGENT_TRACE=true.188 // unless `trace.enabled: true` or AGENT_TRACE=true.
@@ -608,6 +613,11 @@ which ones you left running.`,
608 Enabled: false,613 Enabled: false,
609 Dir: ".mm/sessions",614 Dir: ".mm/sessions",
610 },615 },
616+ DocSearch: DocSearchConfig{
617+ Dirs: []string{"docs"},
618+ Extensions: []string{".md", ".markdown", ".txt", ".adoc", ".asciidoc", ".rst"},
619+ MaxFileSize: 1 << 20,
620+ },
611 Trace: TraceConfig{621 Trace: TraceConfig{
612 Enabled: false,622 Enabled: false,
613 Dir: ".mm/traces",623 Dir: ".mm/traces",
@@ -864,5 +874,8 @@ func (c Config) validate() error {
864 if err := c.validateTools(); err != nil {874 if err := c.validateTools(); err != nil {
865 return err875 return err
866 }876 }
877+ if err := c.DocSearch.validate(); err != nil {
878+ return err
879+ }
867 return c.LSP.validate()880 return c.LSP.validate()
868 }881 }
added internal/config/docsearch.go +110 -0
new file mode 100644
@@ -0,0 +1,110 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+
9+ "gopkg.in/yaml.v3"
10+)
11+
12+// DocSearchConfig is the `docSearch:` block (ticket 106): BM25 search through
13+// the text files of some folders. Two independent switches, both off by
14+// default, as for the stored sessions (searchMemory / autoSearchMemory):
15+//
16+// docSearch:
17+// searchDocs: true # the /docs <words> command, for the user
18+// autoSearchDocs: true # the search_docs tool, for the model
19+// dirs: [docs] # relative to the working directory
20+// extensions: [.md, .txt] # exactly the files searched
21+// maxFileSize: 1MB # bigger files are skipped
22+type DocSearchConfig struct {
23+ // SearchDocs: the /docs <words> command in the terminal, the window and
24+ // an editor.
25+ SearchDocs bool `yaml:"searchDocs"`
26+ // AutoSearchDocs: the built-in `search_docs` tool, the same search in
27+ // the model's hands.
28+ AutoSearchDocs bool `yaml:"autoSearchDocs"`
29+ // Dirs: the folders searched, recursively. A relative one is resolved
30+ // against the working directory — the directory mm runs in, the
31+ // session's cwd over ACP; an absolute one is used as-is.
32+ Dirs []string `yaml:"dirs"`
33+ // Extensions: the files searched, by extension, compared without case.
34+ // Only these: nothing else is ever read.
35+ Extensions []string `yaml:"extensions"`
36+ // MaxFileSize: a file bigger than this is skipped. `1MB`, `512KB`, or a
37+ // number of bytes.
38+ MaxFileSize ByteSize `yaml:"maxFileSize"`
39+}
40+
41+// DocsCommand reports whether /docs is offered.
42+//
43+// command.SetDocs(config.Cfg.DocSearch.DocsCommand())
44+func (d DocSearchConfig) DocsCommand() bool { return d.SearchDocs }
45+
46+// DocsTool reports whether the search_docs tool is declared.
47+func (d DocSearchConfig) DocsTool() bool { return d.AutoSearchDocs }
48+
49+// validate refuses, when either switch is on, a block that could only search
50+// nothing.
51+func (d DocSearchConfig) validate() error {
52+ if !d.SearchDocs && !d.AutoSearchDocs {
53+ return nil
54+ }
55+ if len(d.Dirs) == 0 {
56+ return errors.New("docSearch.dirs must name at least one folder")
57+ }
58+ for _, dir := range d.Dirs {
59+ if strings.TrimSpace(dir) == "" {
60+ return errors.New("docSearch.dirs: a folder must not be empty")
61+ }
62+ }
63+ if len(d.Extensions) == 0 {
64+ return errors.New("docSearch.extensions must name at least one extension")
65+ }
66+ for _, e := range d.Extensions {
67+ if !strings.HasPrefix(e, ".") || len(e) < 2 {
68+ return fmt.Errorf("docSearch.extensions: %q must start with a dot, like \".md\"", e)
69+ }
70+ }
71+ if d.MaxFileSize <= 0 {
72+ return errors.New("docSearch.maxFileSize must be > 0")
73+ }
74+ return nil
75+}
76+
77+// ByteSize is a size in bytes, written in the YAML as a number of bytes or
78+// with a KB / MB / GB suffix (powers of 1024, case ignored): `1MB`, `512KB`.
79+type ByteSize int64
80+
81+// UnmarshalYAML reads `1MB`, `512 kb`, `2048`.
82+func (b *ByteSize) UnmarshalYAML(node *yaml.Node) error {
83+ size, err := ParseByteSize(node.Value)
84+ if err != nil {
85+ return fmt.Errorf("line %d: %w", node.Line, err)
86+ }
87+ *b = size
88+ return nil
89+}
90+
91+// ParseByteSize reads a size as the YAML writes it.
92+//
93+// config.ParseByteSize("1MB") // 1048576
94+// config.ParseByteSize("512kb") // 524288
95+func ParseByteSize(text string) (ByteSize, error) {
96+ value := strings.ToUpper(strings.TrimSpace(text))
97+ unit := int64(1)
98+ for suffix, factor := range map[string]int64{"KB": 1 << 10, "MB": 1 << 20, "GB": 1 << 30} {
99+ if strings.HasSuffix(value, suffix) {
100+ value, unit = strings.TrimSpace(strings.TrimSuffix(value, suffix)), factor
101+ break
102+ }
103+ }
104+ value = strings.TrimSuffix(value, "B")
105+ n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
106+ if err != nil || n < 0 {
107+ return 0, fmt.Errorf("%q is not a size: write 1MB, 512KB or a number of bytes", text)
108+ }
109+ return ByteSize(n * unit), nil
110+}
new file mode 100644
@@ -0,0 +1,110 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+
9+ "gopkg.in/yaml.v3"
10+)
11+
12+// DocSearchConfig is the `docSearch:` block (ticket 106): BM25 search through
13+// the text files of some folders. Two independent switches, both off by
14+// default, as for the stored sessions (searchMemory / autoSearchMemory):
15+//
16+// docSearch:
17+// searchDocs: true # the /docs <words> command, for the user
18+// autoSearchDocs: true # the search_docs tool, for the model
19+// dirs: [docs] # relative to the working directory
20+// extensions: [.md, .txt] # exactly the files searched
21+// maxFileSize: 1MB # bigger files are skipped
22+type DocSearchConfig struct {
23+ // SearchDocs: the /docs <words> command in the terminal, the window and
24+ // an editor.
25+ SearchDocs bool `yaml:"searchDocs"`
26+ // AutoSearchDocs: the built-in `search_docs` tool, the same search in
27+ // the model's hands.
28+ AutoSearchDocs bool `yaml:"autoSearchDocs"`
29+ // Dirs: the folders searched, recursively. A relative one is resolved
30+ // against the working directory — the directory mm runs in, the
31+ // session's cwd over ACP; an absolute one is used as-is.
32+ Dirs []string `yaml:"dirs"`
33+ // Extensions: the files searched, by extension, compared without case.
34+ // Only these: nothing else is ever read.
35+ Extensions []string `yaml:"extensions"`
36+ // MaxFileSize: a file bigger than this is skipped. `1MB`, `512KB`, or a
37+ // number of bytes.
38+ MaxFileSize ByteSize `yaml:"maxFileSize"`
39+}
40+
41+// DocsCommand reports whether /docs is offered.
42+//
43+// command.SetDocs(config.Cfg.DocSearch.DocsCommand())
44+func (d DocSearchConfig) DocsCommand() bool { return d.SearchDocs }
45+
46+// DocsTool reports whether the search_docs tool is declared.
47+func (d DocSearchConfig) DocsTool() bool { return d.AutoSearchDocs }
48+
49+// validate refuses, when either switch is on, a block that could only search
50+// nothing.
51+func (d DocSearchConfig) validate() error {
52+ if !d.SearchDocs && !d.AutoSearchDocs {
53+ return nil
54+ }
55+ if len(d.Dirs) == 0 {
56+ return errors.New("docSearch.dirs must name at least one folder")
57+ }
58+ for _, dir := range d.Dirs {
59+ if strings.TrimSpace(dir) == "" {
60+ return errors.New("docSearch.dirs: a folder must not be empty")
61+ }
62+ }
63+ if len(d.Extensions) == 0 {
64+ return errors.New("docSearch.extensions must name at least one extension")
65+ }
66+ for _, e := range d.Extensions {
67+ if !strings.HasPrefix(e, ".") || len(e) < 2 {
68+ return fmt.Errorf("docSearch.extensions: %q must start with a dot, like \".md\"", e)
69+ }
70+ }
71+ if d.MaxFileSize <= 0 {
72+ return errors.New("docSearch.maxFileSize must be > 0")
73+ }
74+ return nil
75+}
76+
77+// ByteSize is a size in bytes, written in the YAML as a number of bytes or
78+// with a KB / MB / GB suffix (powers of 1024, case ignored): `1MB`, `512KB`.
79+type ByteSize int64
80+
81+// UnmarshalYAML reads `1MB`, `512 kb`, `2048`.
82+func (b *ByteSize) UnmarshalYAML(node *yaml.Node) error {
83+ size, err := ParseByteSize(node.Value)
84+ if err != nil {
85+ return fmt.Errorf("line %d: %w", node.Line, err)
86+ }
87+ *b = size
88+ return nil
89+}
90+
91+// ParseByteSize reads a size as the YAML writes it.
92+//
93+// config.ParseByteSize("1MB") // 1048576
94+// config.ParseByteSize("512kb") // 524288
95+func ParseByteSize(text string) (ByteSize, error) {
96+ value := strings.ToUpper(strings.TrimSpace(text))
97+ unit := int64(1)
98+ for suffix, factor := range map[string]int64{"KB": 1 << 10, "MB": 1 << 20, "GB": 1 << 30} {
99+ if strings.HasSuffix(value, suffix) {
100+ value, unit = strings.TrimSpace(strings.TrimSuffix(value, suffix)), factor
101+ break
102+ }
103+ }
104+ value = strings.TrimSuffix(value, "B")
105+ n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
106+ if err != nil || n < 0 {
107+ return 0, fmt.Errorf("%q is not a size: write 1MB, 512KB or a number of bytes", text)
108+ }
109+ return ByteSize(n * unit), nil
110+}
added internal/config/docsearch_test.go +64 -0
new file mode 100644
@@ -0,0 +1,64 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestParseByteSize(t *testing.T) {
11+ for in, want := range map[string]ByteSize{"1MB": 1 << 20, "512kb": 512 << 10, " 2 GB ": 2 << 30, "2048": 2048, "10B": 10} {
12+ if got, err := ParseByteSize(in); err != nil || got != want {
13+ t.Errorf("ParseByteSize(%q) = %d, %v; want %d", in, got, err, want)
14+ }
15+ }
16+ for _, bad := range []string{"", "MB", "-1", "1TB", "lots"} {
17+ if _, err := ParseByteSize(bad); err == nil {
18+ t.Errorf("ParseByteSize(%q) accepted", bad)
19+ }
20+ }
21+}
22+
23+// Both switches off by default, the defaults ready for either; each switch
24+// on its own; a block that could only search nothing is refused.
25+func TestDocSearchConfig(t *testing.T) {
26+ defaults := Cfg
27+ t.Cleanup(func() { Cfg = defaults })
28+ d := Cfg.DocSearch
29+ if d.DocsCommand() || d.DocsTool() || strings.Join(d.Dirs, ",") != "docs" || len(d.Extensions) != 6 || d.MaxFileSize != 1<<20 {
30+ t.Fatalf("defaults: %+v", d)
31+ }
32+ load := func(yaml string) error {
33+ Cfg = defaults
34+ path := filepath.Join(t.TempDir(), "agent.yaml")
35+ if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ _, err := Load(path)
39+ return err
40+ }
41+ if err := load("docSearch:\n searchDocs: true\n dirs: [guides, notes]\n extensions: [.md]\n maxFileSize: 256KB\n"); err != nil {
42+ t.Fatal(err)
43+ }
44+ if d := Cfg.DocSearch; !d.DocsCommand() || d.DocsTool() || strings.Join(d.Dirs, ",") != "guides,notes" || strings.Join(d.Extensions, ",") != ".md" || d.MaxFileSize != 256<<10 {
45+ t.Errorf("after load: %+v", d)
46+ }
47+ if err := load("docSearch:\n autoSearchDocs: true\n"); err != nil || !Cfg.DocSearch.DocsTool() || Cfg.DocSearch.DocsCommand() {
48+ t.Errorf("tool only: %v %+v", err, Cfg.DocSearch)
49+ }
50+ for yaml, word := range map[string]string{
51+ "docSearch:\n searchDocs: true\n dirs: []\n": "dirs",
52+ "docSearch:\n searchDocs: true\n extensions: [md]\n": "dot",
53+ "docSearch:\n autoSearchDocs: true\n extensions: []\n": "extensions",
54+ "docSearch:\n searchDocs: true\n maxFileSize: 0\n": "maxFileSize",
55+ "docSearch:\n maxFileSize: huge\n": "not a size",
56+ } {
57+ if err := load(yaml); err == nil || !strings.Contains(err.Error(), word) {
58+ t.Errorf("%q: err = %v, want one naming %q", yaml, err, word)
59+ }
60+ }
61+ if err := load("docSearch:\n dirs: []\n"); err != nil {
62+ t.Errorf("both switches off: an empty block is fine, got %v", err)
63+ }
64+}
new file mode 100644
@@ -0,0 +1,64 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestParseByteSize(t *testing.T) {
11+ for in, want := range map[string]ByteSize{"1MB": 1 << 20, "512kb": 512 << 10, " 2 GB ": 2 << 30, "2048": 2048, "10B": 10} {
12+ if got, err := ParseByteSize(in); err != nil || got != want {
13+ t.Errorf("ParseByteSize(%q) = %d, %v; want %d", in, got, err, want)
14+ }
15+ }
16+ for _, bad := range []string{"", "MB", "-1", "1TB", "lots"} {
17+ if _, err := ParseByteSize(bad); err == nil {
18+ t.Errorf("ParseByteSize(%q) accepted", bad)
19+ }
20+ }
21+}
22+
23+// Both switches off by default, the defaults ready for either; each switch
24+// on its own; a block that could only search nothing is refused.
25+func TestDocSearchConfig(t *testing.T) {
26+ defaults := Cfg
27+ t.Cleanup(func() { Cfg = defaults })
28+ d := Cfg.DocSearch
29+ if d.DocsCommand() || d.DocsTool() || strings.Join(d.Dirs, ",") != "docs" || len(d.Extensions) != 6 || d.MaxFileSize != 1<<20 {
30+ t.Fatalf("defaults: %+v", d)
31+ }
32+ load := func(yaml string) error {
33+ Cfg = defaults
34+ path := filepath.Join(t.TempDir(), "agent.yaml")
35+ if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ _, err := Load(path)
39+ return err
40+ }
41+ if err := load("docSearch:\n searchDocs: true\n dirs: [guides, notes]\n extensions: [.md]\n maxFileSize: 256KB\n"); err != nil {
42+ t.Fatal(err)
43+ }
44+ if d := Cfg.DocSearch; !d.DocsCommand() || d.DocsTool() || strings.Join(d.Dirs, ",") != "guides,notes" || strings.Join(d.Extensions, ",") != ".md" || d.MaxFileSize != 256<<10 {
45+ t.Errorf("after load: %+v", d)
46+ }
47+ if err := load("docSearch:\n autoSearchDocs: true\n"); err != nil || !Cfg.DocSearch.DocsTool() || Cfg.DocSearch.DocsCommand() {
48+ t.Errorf("tool only: %v %+v", err, Cfg.DocSearch)
49+ }
50+ for yaml, word := range map[string]string{
51+ "docSearch:\n searchDocs: true\n dirs: []\n": "dirs",
52+ "docSearch:\n searchDocs: true\n extensions: [md]\n": "dot",
53+ "docSearch:\n autoSearchDocs: true\n extensions: []\n": "extensions",
54+ "docSearch:\n searchDocs: true\n maxFileSize: 0\n": "maxFileSize",
55+ "docSearch:\n maxFileSize: huge\n": "not a size",
56+ } {
57+ if err := load(yaml); err == nil || !strings.Contains(err.Error(), word) {
58+ t.Errorf("%q: err = %v, want one naming %q", yaml, err, word)
59+ }
60+ }
61+ if err := load("docSearch:\n dirs: []\n"); err != nil {
62+ t.Errorf("both switches off: an empty block is fine, got %v", err)
63+ }
64+}
modified internal/config/tools.go +1 -0
@@ -44,6 +44,7 @@ var BuiltinToolNames = []string{
4444 "read_file",
4545 "read_skill",
4646 "save_answer",
47+ "search_docs",
4748 "search_memory",
4849 "unload_model",
4950 "write_file",
@@ -44,6 +44,7 @@ var BuiltinToolNames = []string{
44 "read_file",44 "read_file",
45 "read_skill",45 "read_skill",
46 "save_answer",46 "save_answer",
47+ "search_docs",
47 "search_memory",48 "search_memory",
48 "unload_model",49 "unload_model",
49 "write_file",50 "write_file",
added internal/docsearch/bm25.go +146 -0
new file mode 100644
@@ -0,0 +1,146 @@
1+package docsearch
2+
3+import (
4+ "math"
5+ "strconv"
6+ "strings"
7+ "unicode"
8+ "unicode/utf8"
9+
10+ "mm/internal/textfold"
11+)
12+
13+// BM25's two constants, the usual values: k1 how fast a word repeated in a
14+// passage stops adding to its score, b how much a long passage is penalised.
15+const (
16+ k1 = 1.2
17+ b = 0.75
18+)
19+
20+// headingWeight is how many extra times a word of a passage's heading path
21+// counts: a passage under "## Keep the selection with ListState" is about
22+// ListState even if its text says it once.
23+const headingWeight = 2
24+
25+// minQueryRunes: words shorter than this are dropped from a query that has
26+// longer ones — "de", "a", "of" would otherwise match a word in every
27+// passage (the lesson of the memory-search demo, ticket 48).
28+const minQueryRunes = 3
29+
30+// prefixRunes is the shortest word matched by prefix: "migration" finds
31+// "migrations", "render" finds "rendering". Shorter words match exactly.
32+const prefixRunes = 4
33+
34+// words is the folded words of a text: letters and digits, two runes or
35+// more.
36+func words(text string) []string {
37+ return strings.FieldsFunc(textfold.Fold(text), func(r rune) bool {
38+ return !unicode.IsLetter(r) && !unicode.IsDigit(r)
39+ })
40+}
41+
42+// queryWords is a query as distinct folded words, the short ones dropped
43+// beside longer ones.
44+func queryWords(query string) []string {
45+ var long, short []string
46+ seen := map[string]bool{}
47+ for _, w := range words(query) {
48+ if seen[w] {
49+ continue
50+ }
51+ seen[w] = true
52+ if utf8.RuneCountInString(w) < minQueryRunes {
53+ short = append(short, w)
54+ continue
55+ }
56+ long = append(long, w)
57+ }
58+ if len(long) == 0 {
59+ return short
60+ }
61+ return long
62+}
63+
64+// matches reports whether a word of a passage answers a word of the query:
65+// the same word; a longer one it starts (query of 4+ runes: "list" finds
66+// "ListState"); or a shorter one of 4+ runes that it extends by at most two
67+// runes — a plural, "-ed": "widgets" finds "widget".
68+func matches(query, word string) bool {
69+ if word == query {
70+ return true
71+ }
72+ q, w := utf8.RuneCountInString(query), utf8.RuneCountInString(word)
73+ if q >= prefixRunes && strings.HasPrefix(word, query) {
74+ return true
75+ }
76+ return w >= prefixRunes && strings.HasPrefix(query, word) && q-w <= 2
77+}
78+
79+// indexed is a passage ready to be scored: its words counted, the heading
80+// path's words weighted.
81+type indexed struct {
82+ Passage
83+ counts map[string]int
84+ length int
85+}
86+
87+func index(p Passage) indexed {
88+ counts := map[string]int{}
89+ length := 0
90+ for _, w := range words(p.Text) {
91+ counts[w]++
92+ length++
93+ }
94+ for _, w := range words(strings.Join(p.Headings, " ")) {
95+ counts[w] += headingWeight
96+ length += headingWeight
97+ }
98+ return indexed{Passage: p, counts: counts, length: length}
99+}
100+
101+// frequency is how many words of the passage answer the query word.
102+func (x indexed) frequency(query string) int {
103+ n := 0
104+ for w, c := range x.counts {
105+ if matches(query, w) {
106+ n += c
107+ }
108+ }
109+ return n
110+}
111+
112+// score ranks every passage against the query words with BM25 and returns
113+// the scores, in the order of passages. The IDF is computed over these
114+// passages: a word found in every one of them weighs little.
115+func score(passages []indexed, query []string) []float64 {
116+ scores := make([]float64, len(passages))
117+ if len(passages) == 0 {
118+ return scores
119+ }
120+ total := 0
121+ for _, p := range passages {
122+ total += p.length
123+ }
124+ average := float64(total) / float64(len(passages))
125+ n := float64(len(passages))
126+ for _, q := range query {
127+ tf := make([]int, len(passages))
128+ df := 0
129+ for i, p := range passages {
130+ if tf[i] = p.frequency(q); tf[i] > 0 {
131+ df++
132+ }
133+ }
134+ idf := math.Log(1 + (n-float64(df)+0.5)/(float64(df)+0.5))
135+ for i, p := range passages {
136+ if tf[i] == 0 {
137+ continue
138+ }
139+ f := float64(tf[i])
140+ scores[i] += idf * f * (k1 + 1) / (f + k1*(1-b+b*float64(p.length)/average))
141+ }
142+ }
143+ return scores
144+}
145+
146+func itoa(n int) string { return strconv.Itoa(n) }
new file mode 100644
@@ -0,0 +1,146 @@
1+package docsearch
2+
3+import (
4+ "math"
5+ "strconv"
6+ "strings"
7+ "unicode"
8+ "unicode/utf8"
9+
10+ "mm/internal/textfold"
11+)
12+
13+// BM25's two constants, the usual values: k1 how fast a word repeated in a
14+// passage stops adding to its score, b how much a long passage is penalised.
15+const (
16+ k1 = 1.2
17+ b = 0.75
18+)
19+
20+// headingWeight is how many extra times a word of a passage's heading path
21+// counts: a passage under "## Keep the selection with ListState" is about
22+// ListState even if its text says it once.
23+const headingWeight = 2
24+
25+// minQueryRunes: words shorter than this are dropped from a query that has
26+// longer ones — "de", "a", "of" would otherwise match a word in every
27+// passage (the lesson of the memory-search demo, ticket 48).
28+const minQueryRunes = 3
29+
30+// prefixRunes is the shortest word matched by prefix: "migration" finds
31+// "migrations", "render" finds "rendering". Shorter words match exactly.
32+const prefixRunes = 4
33+
34+// words is the folded words of a text: letters and digits, two runes or
35+// more.
36+func words(text string) []string {
37+ return strings.FieldsFunc(textfold.Fold(text), func(r rune) bool {
38+ return !unicode.IsLetter(r) && !unicode.IsDigit(r)
39+ })
40+}
41+
42+// queryWords is a query as distinct folded words, the short ones dropped
43+// beside longer ones.
44+func queryWords(query string) []string {
45+ var long, short []string
46+ seen := map[string]bool{}
47+ for _, w := range words(query) {
48+ if seen[w] {
49+ continue
50+ }
51+ seen[w] = true
52+ if utf8.RuneCountInString(w) < minQueryRunes {
53+ short = append(short, w)
54+ continue
55+ }
56+ long = append(long, w)
57+ }
58+ if len(long) == 0 {
59+ return short
60+ }
61+ return long
62+}
63+
64+// matches reports whether a word of a passage answers a word of the query:
65+// the same word; a longer one it starts (query of 4+ runes: "list" finds
66+// "ListState"); or a shorter one of 4+ runes that it extends by at most two
67+// runes — a plural, "-ed": "widgets" finds "widget".
68+func matches(query, word string) bool {
69+ if word == query {
70+ return true
71+ }
72+ q, w := utf8.RuneCountInString(query), utf8.RuneCountInString(word)
73+ if q >= prefixRunes && strings.HasPrefix(word, query) {
74+ return true
75+ }
76+ return w >= prefixRunes && strings.HasPrefix(query, word) && q-w <= 2
77+}
78+
79+// indexed is a passage ready to be scored: its words counted, the heading
80+// path's words weighted.
81+type indexed struct {
82+ Passage
83+ counts map[string]int
84+ length int
85+}
86+
87+func index(p Passage) indexed {
88+ counts := map[string]int{}
89+ length := 0
90+ for _, w := range words(p.Text) {
91+ counts[w]++
92+ length++
93+ }
94+ for _, w := range words(strings.Join(p.Headings, " ")) {
95+ counts[w] += headingWeight
96+ length += headingWeight
97+ }
98+ return indexed{Passage: p, counts: counts, length: length}
99+}
100+
101+// frequency is how many words of the passage answer the query word.
102+func (x indexed) frequency(query string) int {
103+ n := 0
104+ for w, c := range x.counts {
105+ if matches(query, w) {
106+ n += c
107+ }
108+ }
109+ return n
110+}
111+
112+// score ranks every passage against the query words with BM25 and returns
113+// the scores, in the order of passages. The IDF is computed over these
114+// passages: a word found in every one of them weighs little.
115+func score(passages []indexed, query []string) []float64 {
116+ scores := make([]float64, len(passages))
117+ if len(passages) == 0 {
118+ return scores
119+ }
120+ total := 0
121+ for _, p := range passages {
122+ total += p.length
123+ }
124+ average := float64(total) / float64(len(passages))
125+ n := float64(len(passages))
126+ for _, q := range query {
127+ tf := make([]int, len(passages))
128+ df := 0
129+ for i, p := range passages {
130+ if tf[i] = p.frequency(q); tf[i] > 0 {
131+ df++
132+ }
133+ }
134+ idf := math.Log(1 + (n-float64(df)+0.5)/(float64(df)+0.5))
135+ for i, p := range passages {
136+ if tf[i] == 0 {
137+ continue
138+ }
139+ f := float64(tf[i])
140+ scores[i] += idf * f * (k1 + 1) / (f + k1*(1-b+b*float64(p.length)/average))
141+ }
142+ }
143+ return scores
144+}
145+
146+func itoa(n int) string { return strconv.Itoa(n) }
added internal/docsearch/configured.go +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+package docsearch
2+
3+import (
4+ "os"
5+
6+ "mm/internal/config"
7+)
8+
9+// Configured is the search the `docSearch:` block describes, from base — the
10+// working directory; "" means the process's own.
11+//
12+// opts := docsearch.Configured(cwd)
13+func Configured(base string) Options {
14+ if base == "" {
15+ base, _ = os.Getwd()
16+ }
17+ d := config.Cfg.DocSearch
18+ return Options{Base: base, Dirs: d.Dirs, Extensions: d.Extensions, MaxFileSize: int64(d.MaxFileSize)}
19+}
20+
21+// Answer is what /docs shows and search_docs returns: the configured search
22+// from base, formatted.
23+//
24+// fmt.Println(docsearch.Answer(cwd, "list state", 0))
25+func Answer(base, query string, limit int) string {
26+ return Search(Configured(base), query, limit).Format(query)
27+}
new file mode 100644
@@ -0,0 +1,27 @@
1+package docsearch
2+
3+import (
4+ "os"
5+
6+ "mm/internal/config"
7+)
8+
9+// Configured is the search the `docSearch:` block describes, from base — the
10+// working directory; "" means the process's own.
11+//
12+// opts := docsearch.Configured(cwd)
13+func Configured(base string) Options {
14+ if base == "" {
15+ base, _ = os.Getwd()
16+ }
17+ d := config.Cfg.DocSearch
18+ return Options{Base: base, Dirs: d.Dirs, Extensions: d.Extensions, MaxFileSize: int64(d.MaxFileSize)}
19+}
20+
21+// Answer is what /docs shows and search_docs returns: the configured search
22+// from base, formatted.
23+//
24+// fmt.Println(docsearch.Answer(cwd, "list state", 0))
25+func Answer(base, query string, limit int) string {
26+ return Search(Configured(base), query, limit).Format(query)
27+}
added internal/docsearch/docsearch.go +133 -0
new file mode 100644
@@ -0,0 +1,133 @@
1+// Package docsearch searches the text files of some folders — Markdown,
2+// AsciiDoc, plain text — and returns the passages that answer a query,
3+// ranked with BM25 (ticket 106). It serves the /docs command and the
4+// search_docs tool.
5+//
6+// No index and no model: the folders are read at every search. A project's
7+// documentation is a few megabytes at most (mm's own docs/: 133 Markdown
8+// files, 1.3 MB), which Go reads and scores in milliseconds, and a search
9+// that reads the files is never out of date.
10+//
11+// A passage is a section — a heading and what follows it, up to the next
12+// heading — cut at blank lines when long. Words are compared without case or
13+// accents (internal/textfold); a word of four letters or more also matches
14+// by prefix, which covers plurals ("widget" / "widgets"). What this does not
15+// cover: another language, or another word for the same thing.
16+//
17+// res := docsearch.Search(docsearch.Options{Base: cwd, Dirs: []string{"docs"}, Extensions: []string{".md"}}, "list state", 0)
18+// fmt.Println(res.Format("list state"))
19+package docsearch
20+
21+import (
22+ "fmt"
23+ "sort"
24+ "strings"
25+)
26+
27+// DefaultHits is how many passages a search returns when the caller does not
28+// say; MaxHits caps what a caller may ask for.
29+const (
30+ DefaultHits = 5
31+ MaxHits = 20
32+)
33+
34+// Options says where to search.
35+type Options struct {
36+ Base string // the working directory: relative folders are resolved, and paths shown, against it
37+ Dirs []string // the folders, searched recursively
38+ Extensions []string // the files searched, by extension (".md"); nothing else is read
39+ MaxFileSize int64 // bigger files are skipped; 0 = no limit
40+}
41+
42+// Hit is one passage that answers the query, with its score.
43+type Hit struct {
44+ Passage
45+ Score float64
46+}
47+
48+// Result is what a search found, and over what.
49+type Result struct {
50+ Hits []Hit
51+ Files int // how many files were searched
52+ Passages int // how many passages they made
53+ Missing []string // the folders that do not exist
54+ Dirs []string // the folders, as configured
55+}
56+
57+// Search ranks the passages of the folders against query and returns the
58+// best limit of them (0 = DefaultHits, capped at MaxHits). Passages that
59+// hold none of the words are never returned; ties go to the path, then the
60+// line.
61+func Search(opts Options, query string, limit int) Result {
62+ files, missing := collect(opts)
63+ res := Result{Files: len(files), Missing: missing, Dirs: opts.Dirs}
64+ terms := queryWords(query)
65+ var passages []indexed
66+ for _, f := range files {
67+ for _, p := range split(f.path, f.text) {
68+ passages = append(passages, index(p))
69+ }
70+ }
71+ res.Passages = len(passages)
72+ if len(terms) == 0 {
73+ return res
74+ }
75+ scores := score(passages, terms)
76+ for i, p := range passages {
77+ if scores[i] > 0 {
78+ res.Hits = append(res.Hits, Hit{Passage: p.Passage, Score: scores[i]})
79+ }
80+ }
81+ sort.SliceStable(res.Hits, func(i, j int) bool {
82+ a, b := res.Hits[i], res.Hits[j]
83+ if a.Score != b.Score {
84+ return a.Score > b.Score
85+ }
86+ if a.Path != b.Path {
87+ return a.Path < b.Path
88+ }
89+ return a.Line < b.Line
90+ })
91+ res.Hits = res.Hits[:min(len(res.Hits), hitLimit(limit))]
92+ return res
93+}
94+
95+func hitLimit(limit int) int {
96+ if limit <= 0 {
97+ return DefaultHits
98+ }
99+ return min(limit, MaxHits)
100+}
101+
102+// Format renders a result as /docs shows it and search_docs returns it: a
103+// header line, then per passage its link and heading path and its text.
104+//
105+// 🔎 1 passage(s) for "list state" — 12 file(s) in docs
106+//
107+// ── docs/04-lists.md:18 · Lists › Keep the selection with ListState
108+// ## Keep the selection with ListState
109+// …
110+func (r Result) Format(query string) string {
111+ var b strings.Builder
112+ where := strings.Join(r.Dirs, ", ")
113+ for _, m := range r.Missing {
114+ fmt.Fprintf(&b, "⚠️ %s: no such folder\n", m)
115+ }
116+ switch {
117+ case len(queryWords(query)) == 0:
118+ b.WriteString("⚠️ nothing to search: give one or more words")
119+ return b.String()
120+ case len(r.Hits) == 0:
121+ fmt.Fprintf(&b, "🔎 no passage for %q — %d file(s) in %s", query, r.Files, where)
122+ return b.String()
123+ }
124+ fmt.Fprintf(&b, "🔎 %d passage(s) for %q — %d file(s) in %s", len(r.Hits), query, r.Files, where)
125+ for _, h := range r.Hits {
126+ fmt.Fprintf(&b, "\n\n── %s", h.Link())
127+ if len(h.Headings) > 0 {
128+ fmt.Fprintf(&b, " · %s", strings.Join(h.Headings, " › "))
129+ }
130+ fmt.Fprintf(&b, "\n%s", h.Text)
131+ }
132+ return b.String()
133+}
new file mode 100644
@@ -0,0 +1,133 @@
1+// Package docsearch searches the text files of some folders — Markdown,
2+// AsciiDoc, plain text — and returns the passages that answer a query,
3+// ranked with BM25 (ticket 106). It serves the /docs command and the
4+// search_docs tool.
5+//
6+// No index and no model: the folders are read at every search. A project's
7+// documentation is a few megabytes at most (mm's own docs/: 133 Markdown
8+// files, 1.3 MB), which Go reads and scores in milliseconds, and a search
9+// that reads the files is never out of date.
10+//
11+// A passage is a section — a heading and what follows it, up to the next
12+// heading — cut at blank lines when long. Words are compared without case or
13+// accents (internal/textfold); a word of four letters or more also matches
14+// by prefix, which covers plurals ("widget" / "widgets"). What this does not
15+// cover: another language, or another word for the same thing.
16+//
17+// res := docsearch.Search(docsearch.Options{Base: cwd, Dirs: []string{"docs"}, Extensions: []string{".md"}}, "list state", 0)
18+// fmt.Println(res.Format("list state"))
19+package docsearch
20+
21+import (
22+ "fmt"
23+ "sort"
24+ "strings"
25+)
26+
27+// DefaultHits is how many passages a search returns when the caller does not
28+// say; MaxHits caps what a caller may ask for.
29+const (
30+ DefaultHits = 5
31+ MaxHits = 20
32+)
33+
34+// Options says where to search.
35+type Options struct {
36+ Base string // the working directory: relative folders are resolved, and paths shown, against it
37+ Dirs []string // the folders, searched recursively
38+ Extensions []string // the files searched, by extension (".md"); nothing else is read
39+ MaxFileSize int64 // bigger files are skipped; 0 = no limit
40+}
41+
42+// Hit is one passage that answers the query, with its score.
43+type Hit struct {
44+ Passage
45+ Score float64
46+}
47+
48+// Result is what a search found, and over what.
49+type Result struct {
50+ Hits []Hit
51+ Files int // how many files were searched
52+ Passages int // how many passages they made
53+ Missing []string // the folders that do not exist
54+ Dirs []string // the folders, as configured
55+}
56+
57+// Search ranks the passages of the folders against query and returns the
58+// best limit of them (0 = DefaultHits, capped at MaxHits). Passages that
59+// hold none of the words are never returned; ties go to the path, then the
60+// line.
61+func Search(opts Options, query string, limit int) Result {
62+ files, missing := collect(opts)
63+ res := Result{Files: len(files), Missing: missing, Dirs: opts.Dirs}
64+ terms := queryWords(query)
65+ var passages []indexed
66+ for _, f := range files {
67+ for _, p := range split(f.path, f.text) {
68+ passages = append(passages, index(p))
69+ }
70+ }
71+ res.Passages = len(passages)
72+ if len(terms) == 0 {
73+ return res
74+ }
75+ scores := score(passages, terms)
76+ for i, p := range passages {
77+ if scores[i] > 0 {
78+ res.Hits = append(res.Hits, Hit{Passage: p.Passage, Score: scores[i]})
79+ }
80+ }
81+ sort.SliceStable(res.Hits, func(i, j int) bool {
82+ a, b := res.Hits[i], res.Hits[j]
83+ if a.Score != b.Score {
84+ return a.Score > b.Score
85+ }
86+ if a.Path != b.Path {
87+ return a.Path < b.Path
88+ }
89+ return a.Line < b.Line
90+ })
91+ res.Hits = res.Hits[:min(len(res.Hits), hitLimit(limit))]
92+ return res
93+}
94+
95+func hitLimit(limit int) int {
96+ if limit <= 0 {
97+ return DefaultHits
98+ }
99+ return min(limit, MaxHits)
100+}
101+
102+// Format renders a result as /docs shows it and search_docs returns it: a
103+// header line, then per passage its link and heading path and its text.
104+//
105+// 🔎 1 passage(s) for "list state" — 12 file(s) in docs
106+//
107+// ── docs/04-lists.md:18 · Lists › Keep the selection with ListState
108+// ## Keep the selection with ListState
109+// …
110+func (r Result) Format(query string) string {
111+ var b strings.Builder
112+ where := strings.Join(r.Dirs, ", ")
113+ for _, m := range r.Missing {
114+ fmt.Fprintf(&b, "⚠️ %s: no such folder\n", m)
115+ }
116+ switch {
117+ case len(queryWords(query)) == 0:
118+ b.WriteString("⚠️ nothing to search: give one or more words")
119+ return b.String()
120+ case len(r.Hits) == 0:
121+ fmt.Fprintf(&b, "🔎 no passage for %q — %d file(s) in %s", query, r.Files, where)
122+ return b.String()
123+ }
124+ fmt.Fprintf(&b, "🔎 %d passage(s) for %q — %d file(s) in %s", len(r.Hits), query, r.Files, where)
125+ for _, h := range r.Hits {
126+ fmt.Fprintf(&b, "\n\n── %s", h.Link())
127+ if len(h.Headings) > 0 {
128+ fmt.Fprintf(&b, " · %s", strings.Join(h.Headings, " › "))
129+ }
130+ fmt.Fprintf(&b, "\n%s", h.Text)
131+ }
132+ return b.String()
133+}
added internal/docsearch/docsearch_test.go +196 -0
new file mode 100644
@@ -0,0 +1,196 @@
1+package docsearch
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// Markdown: one passage per heading, with its heading path and first line;
11+// a "#" inside a code fence is not a heading; text before the first heading
12+// is a passage with no heading.
13+func TestSplitMarkdownSections(t *testing.T) {
14+ text := "intro line\n\n# Lists\n\nA list widget.\n\n```sh\n# not a heading\n```\n\n## Keep the selection\n\nUse ListState.\n\n# Tables\n\nRows."
15+ ps := split("docs/a.md", text)
16+ type want struct {
17+ line int
18+ headings string
19+ starts string
20+ }
21+ wants := []want{
22+ {1, "", "intro line"},
23+ {3, "Lists", "# Lists"},
24+ {11, "Lists › Keep the selection", "## Keep the selection"},
25+ {15, "Tables", "# Tables"},
26+ }
27+ if len(ps) != len(wants) {
28+ t.Fatalf("got %d passages: %+v", len(ps), ps)
29+ }
30+ for i, w := range wants {
31+ p := ps[i]
32+ if p.Line != w.line || strings.Join(p.Headings, " › ") != w.headings || !strings.HasPrefix(p.Text, w.starts) {
33+ t.Errorf("passage %d = line %d, headings %q, text %q; want %+v", i, p.Line, p.Headings, p.Text, w)
34+ }
35+ }
36+ if !strings.Contains(ps[1].Text, "# not a heading") {
37+ t.Error("the fenced comment left its section")
38+ }
39+}
40+
41+func TestSplitAsciidocAndPlainText(t *testing.T) {
42+ ps := split("guide.adoc", "= Guide\n\nIntro.\n\n== Install\n\n----\n= not a title\n----\n")
43+ if len(ps) != 2 || strings.Join(ps[1].Headings, " › ") != "Guide › Install" || ps[1].Line != 5 {
44+ t.Fatalf("asciidoc: %+v", ps)
45+ }
46+ ps = split("notes.txt", "# not markdown here\n\nsecond paragraph")
47+ if len(ps) != 1 || len(ps[0].Headings) != 0 {
48+ t.Errorf("plain text: %+v", ps)
49+ }
50+}
51+
52+// A long section is cut at blank lines, never inside a fence, each part
53+// keeping the headings and its own first line.
54+func TestSplitCutsLongSections(t *testing.T) {
55+ para := strings.Repeat("word ", 100) // 500 runes
56+ text := "# Long\n\n" + para + "\n\n" + para + "\n\n```\n" + para + "\n\n" + para + "\n```\n\n" + para
57+ ps := split("long.md", text)
58+ if len(ps) < 2 {
59+ t.Fatalf("not cut: %d passage(s)", len(ps))
60+ }
61+ for _, p := range ps {
62+ if strings.Join(p.Headings, "") != "Long" {
63+ t.Errorf("a part lost its heading: %+v", p.Headings)
64+ }
65+ if strings.Count(p.Text, "```")%2 != 0 {
66+ t.Errorf("a fence was cut at line %d", p.Line)
67+ }
68+ }
69+ lines := strings.Split(text, "\n")
70+ for _, p := range ps {
71+ if first := strings.Split(p.Text, "\n")[0]; strings.TrimRight(lines[p.Line-1], " ") != first {
72+ t.Errorf("line %d is %q, the passage starts with %q", p.Line, lines[p.Line-1], first)
73+ }
74+ }
75+}
76+
77+func TestMatchesAndQueryWords(t *testing.T) {
78+ for _, c := range []struct {
79+ query, word string
80+ want bool
81+ }{
82+ {"list", "list", true},
83+ {"list", "liststate", true}, // prefix, 4+ runes
84+ {"widgets", "widget", true}, // plural
85+ {"rendered", "render", true}, // -ed
86+ {"configuration", "config", false},
87+ {"tab", "table", false}, // under 4 runes: exact only
88+ {"ui", "ui", true},
89+ } {
90+ if got := matches(c.query, c.word); got != c.want {
91+ t.Errorf("matches(%q, %q) = %v", c.query, c.word, got)
92+ }
93+ }
94+ if got := strings.Join(queryWords("Comment créer UNE liste de widgets ?"), ","); got != "comment,creer,une,liste,widgets" {
95+ t.Errorf("queryWords = %q", got)
96+ }
97+ if got := strings.Join(queryWords("go ui"), ","); got != "go,ui" {
98+ t.Errorf("short words alone are kept: %q", got)
99+ }
100+}
101+
102+// BM25: a rare word outweighs a common one; a word in the heading path
103+// lifts its passage; a passage holding none of the words is no hit.
104+func TestScoreRanking(t *testing.T) {
105+ passages := []indexed{
106+ index(Passage{Path: "a.md", Headings: []string{"Intro"}, Text: "ratatui draws widgets. ratatui is fast."}),
107+ index(Passage{Path: "b.md", Headings: []string{"Gauge"}, Text: "ratatui shows progress with a gauge widget."}),
108+ index(Passage{Path: "c.md", Headings: []string{"Layout"}, Text: "ratatui splits the area; a gauge can sit in it."}),
109+ index(Passage{Path: "d.md", Headings: []string{"Other"}, Text: "nothing relevant here."}),
110+ }
111+ scores := score(passages, queryWords("ratatui gauge"))
112+ if !(scores[1] > scores[2] && scores[2] > scores[0]) {
113+ t.Errorf("scores = %v: want the gauge heading first, then the gauge mention, then ratatui alone", scores)
114+ }
115+ if scores[3] != 0 {
116+ t.Errorf("a passage without the words scored %v", scores[3])
117+ }
118+}
119+
120+func writeFile(t *testing.T, path, text string) {
121+ t.Helper()
122+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
123+ t.Fatal(err)
124+ }
125+ if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
126+ t.Fatal(err)
127+ }
128+}
129+
130+// Only the listed extensions, within the size limit, not binary, outside
131+// hidden folders and node_modules, symbolic links not followed; a missing
132+// folder is reported by name.
133+func TestCollect(t *testing.T) {
134+ base := t.TempDir()
135+ writeFile(t, filepath.Join(base, "docs", "a.md"), "# A")
136+ writeFile(t, filepath.Join(base, "docs", "sub", "b.TXT"), "b")
137+ writeFile(t, filepath.Join(base, "docs", "c.html"), "<p>c</p>")
138+ writeFile(t, filepath.Join(base, "docs", ".hidden", "d.md"), "d")
139+ writeFile(t, filepath.Join(base, "docs", "node_modules", "e.md"), "e")
140+ writeFile(t, filepath.Join(base, "docs", "big.md"), strings.Repeat("x", 2000))
141+ writeFile(t, filepath.Join(base, "docs", "bin.md"), "a\x00b")
142+ if err := os.Symlink(filepath.Join(base, "docs", "a.md"), filepath.Join(base, "docs", "link.md")); err != nil {
143+ t.Fatal(err)
144+ }
145+ files, missing := collect(Options{Base: base, Dirs: []string{"docs", "nope"}, Extensions: []string{".md", ".txt"}, MaxFileSize: 1000})
146+ var got []string
147+ for _, f := range files {
148+ got = append(got, f.path)
149+ }
150+ if strings.Join(got, ",") != "docs/a.md,docs/sub/b.TXT" {
151+ t.Errorf("files = %v", got)
152+ }
153+ if strings.Join(missing, ",") != "nope" {
154+ t.Errorf("missing = %v", missing)
155+ }
156+}
157+
158+func TestSearchAndFormat(t *testing.T) {
159+ base := t.TempDir()
160+ writeFile(t, filepath.Join(base, "docs", "lists.md"), "# Lists\n\nA List widget shows items.\n\n## Keep the selection\n\nUse ListState and render_stateful_widget.\n")
161+ writeFile(t, filepath.Join(base, "docs", "gauge.md"), "# Gauge\n\nA progress bar.\n")
162+ opts := Options{Base: base, Dirs: []string{"docs"}, Extensions: []string{".md"}}
163+
164+ res := Search(opts, "selection ListState", 0)
165+ if res.Files != 2 || res.Passages != 3 || len(res.Hits) != 1 {
166+ t.Fatalf("result = %+v", res)
167+ }
168+ out := res.Format("selection ListState")
169+ want := "🔎 1 passage(s) for \"selection ListState\" — 2 file(s) in docs\n\n── docs/lists.md:5 · Lists › Keep the selection\n## Keep the selection\n\nUse ListState and render_stateful_widget."
170+ if out != want {
171+ t.Errorf("Format:\n%s\nwant:\n%s", out, want)
172+ }
173+ if got := Search(opts, "zebra", 0).Format("zebra"); got != "🔎 no passage for \"zebra\" — 2 file(s) in docs" {
174+ t.Errorf("no hit: %q", got)
175+ }
176+ if got := Search(opts, " ", 0).Format(" "); got != "⚠️ nothing to search: give one or more words" {
177+ t.Errorf("empty query: %q", got)
178+ }
179+ missing := Search(Options{Base: base, Dirs: []string{"nope"}, Extensions: []string{".md"}}, "x", 0).Format("x")
180+ if !strings.HasPrefix(missing, "⚠️ nope: no such folder\n") {
181+ t.Errorf("missing folder: %q", missing)
182+ }
183+}
184+
185+func TestSearchLimit(t *testing.T) {
186+ base := t.TempDir()
187+ for i := range MaxHits + 5 {
188+ writeFile(t, filepath.Join(base, "docs", "f"+itoa(i)+".md"), "# Note\n\nratatui")
189+ }
190+ opts := Options{Base: base, Dirs: []string{"docs"}, Extensions: []string{".md"}}
191+ for limit, want := range map[int]int{0: DefaultHits, 3: 3, 1000: MaxHits} {
192+ if n := len(Search(opts, "ratatui", limit).Hits); n != want {
193+ t.Errorf("limit %d gave %d hits, want %d", limit, n, want)
194+ }
195+ }
196+}
new file mode 100644
@@ -0,0 +1,196 @@
1+package docsearch
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// Markdown: one passage per heading, with its heading path and first line;
11+// a "#" inside a code fence is not a heading; text before the first heading
12+// is a passage with no heading.
13+func TestSplitMarkdownSections(t *testing.T) {
14+ text := "intro line\n\n# Lists\n\nA list widget.\n\n```sh\n# not a heading\n```\n\n## Keep the selection\n\nUse ListState.\n\n# Tables\n\nRows."
15+ ps := split("docs/a.md", text)
16+ type want struct {
17+ line int
18+ headings string
19+ starts string
20+ }
21+ wants := []want{
22+ {1, "", "intro line"},
23+ {3, "Lists", "# Lists"},
24+ {11, "Lists › Keep the selection", "## Keep the selection"},
25+ {15, "Tables", "# Tables"},
26+ }
27+ if len(ps) != len(wants) {
28+ t.Fatalf("got %d passages: %+v", len(ps), ps)
29+ }
30+ for i, w := range wants {
31+ p := ps[i]
32+ if p.Line != w.line || strings.Join(p.Headings, " › ") != w.headings || !strings.HasPrefix(p.Text, w.starts) {
33+ t.Errorf("passage %d = line %d, headings %q, text %q; want %+v", i, p.Line, p.Headings, p.Text, w)
34+ }
35+ }
36+ if !strings.Contains(ps[1].Text, "# not a heading") {
37+ t.Error("the fenced comment left its section")
38+ }
39+}
40+
41+func TestSplitAsciidocAndPlainText(t *testing.T) {
42+ ps := split("guide.adoc", "= Guide\n\nIntro.\n\n== Install\n\n----\n= not a title\n----\n")
43+ if len(ps) != 2 || strings.Join(ps[1].Headings, " › ") != "Guide › Install" || ps[1].Line != 5 {
44+ t.Fatalf("asciidoc: %+v", ps)
45+ }
46+ ps = split("notes.txt", "# not markdown here\n\nsecond paragraph")
47+ if len(ps) != 1 || len(ps[0].Headings) != 0 {
48+ t.Errorf("plain text: %+v", ps)
49+ }
50+}
51+
52+// A long section is cut at blank lines, never inside a fence, each part
53+// keeping the headings and its own first line.
54+func TestSplitCutsLongSections(t *testing.T) {
55+ para := strings.Repeat("word ", 100) // 500 runes
56+ text := "# Long\n\n" + para + "\n\n" + para + "\n\n```\n" + para + "\n\n" + para + "\n```\n\n" + para
57+ ps := split("long.md", text)
58+ if len(ps) < 2 {
59+ t.Fatalf("not cut: %d passage(s)", len(ps))
60+ }
61+ for _, p := range ps {
62+ if strings.Join(p.Headings, "") != "Long" {
63+ t.Errorf("a part lost its heading: %+v", p.Headings)
64+ }
65+ if strings.Count(p.Text, "```")%2 != 0 {
66+ t.Errorf("a fence was cut at line %d", p.Line)
67+ }
68+ }
69+ lines := strings.Split(text, "\n")
70+ for _, p := range ps {
71+ if first := strings.Split(p.Text, "\n")[0]; strings.TrimRight(lines[p.Line-1], " ") != first {
72+ t.Errorf("line %d is %q, the passage starts with %q", p.Line, lines[p.Line-1], first)
73+ }
74+ }
75+}
76+
77+func TestMatchesAndQueryWords(t *testing.T) {
78+ for _, c := range []struct {
79+ query, word string
80+ want bool
81+ }{
82+ {"list", "list", true},
83+ {"list", "liststate", true}, // prefix, 4+ runes
84+ {"widgets", "widget", true}, // plural
85+ {"rendered", "render", true}, // -ed
86+ {"configuration", "config", false},
87+ {"tab", "table", false}, // under 4 runes: exact only
88+ {"ui", "ui", true},
89+ } {
90+ if got := matches(c.query, c.word); got != c.want {
91+ t.Errorf("matches(%q, %q) = %v", c.query, c.word, got)
92+ }
93+ }
94+ if got := strings.Join(queryWords("Comment créer UNE liste de widgets ?"), ","); got != "comment,creer,une,liste,widgets" {
95+ t.Errorf("queryWords = %q", got)
96+ }
97+ if got := strings.Join(queryWords("go ui"), ","); got != "go,ui" {
98+ t.Errorf("short words alone are kept: %q", got)
99+ }
100+}
101+
102+// BM25: a rare word outweighs a common one; a word in the heading path
103+// lifts its passage; a passage holding none of the words is no hit.
104+func TestScoreRanking(t *testing.T) {
105+ passages := []indexed{
106+ index(Passage{Path: "a.md", Headings: []string{"Intro"}, Text: "ratatui draws widgets. ratatui is fast."}),
107+ index(Passage{Path: "b.md", Headings: []string{"Gauge"}, Text: "ratatui shows progress with a gauge widget."}),
108+ index(Passage{Path: "c.md", Headings: []string{"Layout"}, Text: "ratatui splits the area; a gauge can sit in it."}),
109+ index(Passage{Path: "d.md", Headings: []string{"Other"}, Text: "nothing relevant here."}),
110+ }
111+ scores := score(passages, queryWords("ratatui gauge"))
112+ if !(scores[1] > scores[2] && scores[2] > scores[0]) {
113+ t.Errorf("scores = %v: want the gauge heading first, then the gauge mention, then ratatui alone", scores)
114+ }
115+ if scores[3] != 0 {
116+ t.Errorf("a passage without the words scored %v", scores[3])
117+ }
118+}
119+
120+func writeFile(t *testing.T, path, text string) {
121+ t.Helper()
122+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
123+ t.Fatal(err)
124+ }
125+ if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
126+ t.Fatal(err)
127+ }
128+}
129+
130+// Only the listed extensions, within the size limit, not binary, outside
131+// hidden folders and node_modules, symbolic links not followed; a missing
132+// folder is reported by name.
133+func TestCollect(t *testing.T) {
134+ base := t.TempDir()
135+ writeFile(t, filepath.Join(base, "docs", "a.md"), "# A")
136+ writeFile(t, filepath.Join(base, "docs", "sub", "b.TXT"), "b")
137+ writeFile(t, filepath.Join(base, "docs", "c.html"), "<p>c</p>")
138+ writeFile(t, filepath.Join(base, "docs", ".hidden", "d.md"), "d")
139+ writeFile(t, filepath.Join(base, "docs", "node_modules", "e.md"), "e")
140+ writeFile(t, filepath.Join(base, "docs", "big.md"), strings.Repeat("x", 2000))
141+ writeFile(t, filepath.Join(base, "docs", "bin.md"), "a\x00b")
142+ if err := os.Symlink(filepath.Join(base, "docs", "a.md"), filepath.Join(base, "docs", "link.md")); err != nil {
143+ t.Fatal(err)
144+ }
145+ files, missing := collect(Options{Base: base, Dirs: []string{"docs", "nope"}, Extensions: []string{".md", ".txt"}, MaxFileSize: 1000})
146+ var got []string
147+ for _, f := range files {
148+ got = append(got, f.path)
149+ }
150+ if strings.Join(got, ",") != "docs/a.md,docs/sub/b.TXT" {
151+ t.Errorf("files = %v", got)
152+ }
153+ if strings.Join(missing, ",") != "nope" {
154+ t.Errorf("missing = %v", missing)
155+ }
156+}
157+
158+func TestSearchAndFormat(t *testing.T) {
159+ base := t.TempDir()
160+ writeFile(t, filepath.Join(base, "docs", "lists.md"), "# Lists\n\nA List widget shows items.\n\n## Keep the selection\n\nUse ListState and render_stateful_widget.\n")
161+ writeFile(t, filepath.Join(base, "docs", "gauge.md"), "# Gauge\n\nA progress bar.\n")
162+ opts := Options{Base: base, Dirs: []string{"docs"}, Extensions: []string{".md"}}
163+
164+ res := Search(opts, "selection ListState", 0)
165+ if res.Files != 2 || res.Passages != 3 || len(res.Hits) != 1 {
166+ t.Fatalf("result = %+v", res)
167+ }
168+ out := res.Format("selection ListState")
169+ want := "🔎 1 passage(s) for \"selection ListState\" — 2 file(s) in docs\n\n── docs/lists.md:5 · Lists › Keep the selection\n## Keep the selection\n\nUse ListState and render_stateful_widget."
170+ if out != want {
171+ t.Errorf("Format:\n%s\nwant:\n%s", out, want)
172+ }
173+ if got := Search(opts, "zebra", 0).Format("zebra"); got != "🔎 no passage for \"zebra\" — 2 file(s) in docs" {
174+ t.Errorf("no hit: %q", got)
175+ }
176+ if got := Search(opts, " ", 0).Format(" "); got != "⚠️ nothing to search: give one or more words" {
177+ t.Errorf("empty query: %q", got)
178+ }
179+ missing := Search(Options{Base: base, Dirs: []string{"nope"}, Extensions: []string{".md"}}, "x", 0).Format("x")
180+ if !strings.HasPrefix(missing, "⚠️ nope: no such folder\n") {
181+ t.Errorf("missing folder: %q", missing)
182+ }
183+}
184+
185+func TestSearchLimit(t *testing.T) {
186+ base := t.TempDir()
187+ for i := range MaxHits + 5 {
188+ writeFile(t, filepath.Join(base, "docs", "f"+itoa(i)+".md"), "# Note\n\nratatui")
189+ }
190+ opts := Options{Base: base, Dirs: []string{"docs"}, Extensions: []string{".md"}}
191+ for limit, want := range map[int]int{0: DefaultHits, 3: 3, 1000: MaxHits} {
192+ if n := len(Search(opts, "ratatui", limit).Hits); n != want {
193+ t.Errorf("limit %d gave %d hits, want %d", limit, n, want)
194+ }
195+ }
196+}
added internal/docsearch/passages.go +157 -0
new file mode 100644
@@ -0,0 +1,157 @@
1+package docsearch
2+
3+import (
4+ "path/filepath"
5+ "regexp"
6+ "strings"
7+)
8+
9+// maxPassageRunes is the size past which a section is cut into several
10+// passages, at blank lines: a passage is what one hit shows the model, and a
11+// whole long page would crowd out the other hits. A single paragraph or code
12+// block longer than this stays whole — cutting code in the middle helps no
13+// one.
14+const maxPassageRunes = 1500
15+
16+// Passage is one searchable piece of a file: a section (a heading and what
17+// follows it, up to the next heading), or part of a long one.
18+type Passage struct {
19+ Path string // the file, as shown: relative to the working directory when inside it
20+ Line int // the passage's first line, from 1
21+ Headings []string // the heading path, outermost first; empty before the first heading
22+ Text string // the passage as written, lines kept
23+}
24+
25+// Link is how a hit names its passage: `path:line`, what an editor and
26+// read_file both take.
27+func (p Passage) Link() string { return p.Path + ":" + itoa(p.Line) }
28+
29+// format is how a file marks its sections.
30+type format int
31+
32+const (
33+ plainText format = iota // no headings: paragraphs only
34+ markdown // ATX headings: # Title … ###### Title
35+ asciidoc // = Title … ====== Title
36+)
37+
38+func formatOf(path string) format {
39+ switch strings.ToLower(filepath.Ext(path)) {
40+ case ".md", ".markdown":
41+ return markdown
42+ case ".adoc", ".asciidoc", ".asc":
43+ return asciidoc
44+ }
45+ return plainText
46+}
47+
48+var (
49+ markdownHeading = regexp.MustCompile(`^(#{1,6})\s+(.*?)\s*#*\s*$`)
50+ asciidocHeading = regexp.MustCompile(`^(={1,6})\s+(.*?)\s*$`)
51+)
52+
53+// heading reads a heading line: its level and title, and whether it is one.
54+func (f format) heading(line string) (int, string, bool) {
55+ var m []string
56+ switch f {
57+ case markdown:
58+ m = markdownHeading.FindStringSubmatch(line)
59+ case asciidoc:
60+ m = asciidocHeading.FindStringSubmatch(line)
61+ }
62+ if m == nil || m[2] == "" {
63+ return 0, "", false
64+ }
65+ return len(m[1]), m[2], true
66+}
67+
68+// isFence reports whether a line opens or closes a block whose content is
69+// not structure: a Markdown code fence, an AsciiDoc listing or literal block.
70+// A "# comment" inside a shell example is not a heading.
71+func (f format) isFence(line string) bool {
72+ trimmed := strings.TrimSpace(line)
73+ switch f {
74+ case markdown:
75+ return strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~")
76+ case asciidoc:
77+ return trimmed == "----" || trimmed == "...." || strings.HasPrefix(trimmed, "```")
78+ }
79+ return false
80+}
81+
82+// split cuts a file's text into passages.
83+//
84+// split("docs/lists.md", "# Lists\n\nA list…\n\n## State\n\n…") // two passages
85+func split(path, text string) []Passage {
86+ f := formatOf(path)
87+ var out []Passage
88+ var headings []string
89+ var lines []string
90+ start, inFence := 1, false
91+ flush := func() {
92+ out = append(out, cutLong(Passage{Path: path, Line: start, Headings: append([]string(nil), headings...), Text: strings.Join(lines, "\n")}, f)...)
93+ lines = nil
94+ }
95+ for i, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
96+ if f.isFence(line) {
97+ inFence = !inFence
98+ }
99+ if level, title, ok := f.heading(line); ok && !inFence {
100+ flush()
101+ headings = append(headings[:min(level-1, len(headings))], title)
102+ start = i + 1
103+ }
104+ lines = append(lines, line)
105+ }
106+ flush()
107+ return dropEmpty(out)
108+}
109+
110+// cutLong cuts a passage longer than maxPassageRunes at blank lines outside
111+// fences, grouping paragraphs up to the limit.
112+func cutLong(p Passage, f format) []Passage {
113+ if len([]rune(p.Text)) <= maxPassageRunes {
114+ return []Passage{p}
115+ }
116+ var out []Passage
117+ current := Passage{Path: p.Path, Line: p.Line, Headings: p.Headings}
118+ var lines []string
119+ size, inFence := 0, false
120+ for i, line := range strings.Split(p.Text, "\n") {
121+ if f.isFence(line) {
122+ inFence = !inFence
123+ }
124+ blank := strings.TrimSpace(line) == "" && !inFence
125+ if blank && size >= maxPassageRunes {
126+ current.Text = strings.Join(lines, "\n")
127+ out = append(out, current)
128+ current = Passage{Path: p.Path, Line: p.Line + i + 1, Headings: p.Headings}
129+ lines, size = nil, 0
130+ continue
131+ }
132+ lines = append(lines, line)
133+ size += len([]rune(line)) + 1
134+ }
135+ current.Text = strings.Join(lines, "\n")
136+ return append(out, current)
137+}
138+
139+// dropEmpty removes the passages holding nothing but blanks, and trims the
140+// blank lines around the others (moving Line to the first kept one).
141+func dropEmpty(ps []Passage) []Passage {
142+ var out []Passage
143+ for _, p := range ps {
144+ lines := strings.Split(p.Text, "\n")
145+ first := 0
146+ for first < len(lines) && strings.TrimSpace(lines[first]) == "" {
147+ first++
148+ }
149+ if first == len(lines) {
150+ continue
151+ }
152+ p.Line += first
153+ p.Text = strings.TrimRight(strings.Join(lines[first:], "\n"), " \t\n")
154+ out = append(out, p)
155+ }
156+ return out
157+}
new file mode 100644
@@ -0,0 +1,157 @@
1+package docsearch
2+
3+import (
4+ "path/filepath"
5+ "regexp"
6+ "strings"
7+)
8+
9+// maxPassageRunes is the size past which a section is cut into several
10+// passages, at blank lines: a passage is what one hit shows the model, and a
11+// whole long page would crowd out the other hits. A single paragraph or code
12+// block longer than this stays whole — cutting code in the middle helps no
13+// one.
14+const maxPassageRunes = 1500
15+
16+// Passage is one searchable piece of a file: a section (a heading and what
17+// follows it, up to the next heading), or part of a long one.
18+type Passage struct {
19+ Path string // the file, as shown: relative to the working directory when inside it
20+ Line int // the passage's first line, from 1
21+ Headings []string // the heading path, outermost first; empty before the first heading
22+ Text string // the passage as written, lines kept
23+}
24+
25+// Link is how a hit names its passage: `path:line`, what an editor and
26+// read_file both take.
27+func (p Passage) Link() string { return p.Path + ":" + itoa(p.Line) }
28+
29+// format is how a file marks its sections.
30+type format int
31+
32+const (
33+ plainText format = iota // no headings: paragraphs only
34+ markdown // ATX headings: # Title … ###### Title
35+ asciidoc // = Title … ====== Title
36+)
37+
38+func formatOf(path string) format {
39+ switch strings.ToLower(filepath.Ext(path)) {
40+ case ".md", ".markdown":
41+ return markdown
42+ case ".adoc", ".asciidoc", ".asc":
43+ return asciidoc
44+ }
45+ return plainText
46+}
47+
48+var (
49+ markdownHeading = regexp.MustCompile(`^(#{1,6})\s+(.*?)\s*#*\s*$`)
50+ asciidocHeading = regexp.MustCompile(`^(={1,6})\s+(.*?)\s*$`)
51+)
52+
53+// heading reads a heading line: its level and title, and whether it is one.
54+func (f format) heading(line string) (int, string, bool) {
55+ var m []string
56+ switch f {
57+ case markdown:
58+ m = markdownHeading.FindStringSubmatch(line)
59+ case asciidoc:
60+ m = asciidocHeading.FindStringSubmatch(line)
61+ }
62+ if m == nil || m[2] == "" {
63+ return 0, "", false
64+ }
65+ return len(m[1]), m[2], true
66+}
67+
68+// isFence reports whether a line opens or closes a block whose content is
69+// not structure: a Markdown code fence, an AsciiDoc listing or literal block.
70+// A "# comment" inside a shell example is not a heading.
71+func (f format) isFence(line string) bool {
72+ trimmed := strings.TrimSpace(line)
73+ switch f {
74+ case markdown:
75+ return strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~")
76+ case asciidoc:
77+ return trimmed == "----" || trimmed == "...." || strings.HasPrefix(trimmed, "```")
78+ }
79+ return false
80+}
81+
82+// split cuts a file's text into passages.
83+//
84+// split("docs/lists.md", "# Lists\n\nA list…\n\n## State\n\n…") // two passages
85+func split(path, text string) []Passage {
86+ f := formatOf(path)
87+ var out []Passage
88+ var headings []string
89+ var lines []string
90+ start, inFence := 1, false
91+ flush := func() {
92+ out = append(out, cutLong(Passage{Path: path, Line: start, Headings: append([]string(nil), headings...), Text: strings.Join(lines, "\n")}, f)...)
93+ lines = nil
94+ }
95+ for i, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
96+ if f.isFence(line) {
97+ inFence = !inFence
98+ }
99+ if level, title, ok := f.heading(line); ok && !inFence {
100+ flush()
101+ headings = append(headings[:min(level-1, len(headings))], title)
102+ start = i + 1
103+ }
104+ lines = append(lines, line)
105+ }
106+ flush()
107+ return dropEmpty(out)
108+}
109+
110+// cutLong cuts a passage longer than maxPassageRunes at blank lines outside
111+// fences, grouping paragraphs up to the limit.
112+func cutLong(p Passage, f format) []Passage {
113+ if len([]rune(p.Text)) <= maxPassageRunes {
114+ return []Passage{p}
115+ }
116+ var out []Passage
117+ current := Passage{Path: p.Path, Line: p.Line, Headings: p.Headings}
118+ var lines []string
119+ size, inFence := 0, false
120+ for i, line := range strings.Split(p.Text, "\n") {
121+ if f.isFence(line) {
122+ inFence = !inFence
123+ }
124+ blank := strings.TrimSpace(line) == "" && !inFence
125+ if blank && size >= maxPassageRunes {
126+ current.Text = strings.Join(lines, "\n")
127+ out = append(out, current)
128+ current = Passage{Path: p.Path, Line: p.Line + i + 1, Headings: p.Headings}
129+ lines, size = nil, 0
130+ continue
131+ }
132+ lines = append(lines, line)
133+ size += len([]rune(line)) + 1
134+ }
135+ current.Text = strings.Join(lines, "\n")
136+ return append(out, current)
137+}
138+
139+// dropEmpty removes the passages holding nothing but blanks, and trims the
140+// blank lines around the others (moving Line to the first kept one).
141+func dropEmpty(ps []Passage) []Passage {
142+ var out []Passage
143+ for _, p := range ps {
144+ lines := strings.Split(p.Text, "\n")
145+ first := 0
146+ for first < len(lines) && strings.TrimSpace(lines[first]) == "" {
147+ first++
148+ }
149+ if first == len(lines) {
150+ continue
151+ }
152+ p.Line += first
153+ p.Text = strings.TrimRight(strings.Join(lines[first:], "\n"), " \t\n")
154+ out = append(out, p)
155+ }
156+ return out
157+}
added internal/docsearch/walk.go +85 -0
new file mode 100644
@@ -0,0 +1,85 @@
1+package docsearch
2+
3+import (
4+ "bytes"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// file is one document read from the folders.
12+type file struct {
13+ path string // as shown
14+ text string
15+}
16+
17+// collect reads the files the search covers: under each folder, recursively,
18+// the files whose extension is listed and whose size is within the limit.
19+// Hidden folders (".git", ".mm"…) and node_modules are skipped, symbolic
20+// links are not followed, and a file holding a NUL byte is taken for binary
21+// and skipped. The folders that do not exist are returned by name.
22+func collect(opts Options) (files []file, missing []string) {
23+ exts := map[string]bool{}
24+ for _, e := range opts.Extensions {
25+ exts[strings.ToLower(e)] = true
26+ }
27+ for _, dir := range opts.Dirs {
28+ root := dir
29+ if !filepath.IsAbs(root) {
30+ root = filepath.Join(opts.Base, root)
31+ }
32+ if info, err := os.Stat(root); err != nil || !info.IsDir() {
33+ missing = append(missing, dir)
34+ continue
35+ }
36+ _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
37+ if err != nil {
38+ return nil // an unreadable folder or file: skip it, keep walking
39+ }
40+ if d.IsDir() {
41+ if path != root && skipFolder(d.Name()) {
42+ return filepath.SkipDir
43+ }
44+ return nil
45+ }
46+ if d.Type()&fs.ModeSymlink != 0 || !exts[strings.ToLower(filepath.Ext(path))] {
47+ return nil
48+ }
49+ if f, ok := readFile(path, opts); ok {
50+ files = append(files, f)
51+ }
52+ return nil
53+ })
54+ }
55+ return files, missing
56+}
57+
58+func skipFolder(name string) bool {
59+ return strings.HasPrefix(name, ".") || name == "node_modules"
60+}
61+
62+// readFile reads one candidate, within the size limit and not binary.
63+func readFile(path string, opts Options) (file, bool) {
64+ info, err := os.Stat(path)
65+ if err != nil || (opts.MaxFileSize > 0 && info.Size() > opts.MaxFileSize) {
66+ return file{}, false
67+ }
68+ data, err := os.ReadFile(path)
69+ if err != nil || bytes.IndexByte(data, 0) >= 0 {
70+ return file{}, false
71+ }
72+ return file{path: shown(path, opts.Base), text: string(data)}, true
73+}
74+
75+// shown is a path relative to base when it is inside it, absolute otherwise.
76+func shown(path, base string) string {
77+ if rel, err := filepath.Rel(base, path); err == nil && !strings.HasPrefix(rel, "..") {
78+ return rel
79+ }
80+ abs, err := filepath.Abs(path)
81+ if err != nil {
82+ return path
83+ }
84+ return abs
85+}
new file mode 100644
@@ -0,0 +1,85 @@
1+package docsearch
2+
3+import (
4+ "bytes"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+)
10+
11+// file is one document read from the folders.
12+type file struct {
13+ path string // as shown
14+ text string
15+}
16+
17+// collect reads the files the search covers: under each folder, recursively,
18+// the files whose extension is listed and whose size is within the limit.
19+// Hidden folders (".git", ".mm"…) and node_modules are skipped, symbolic
20+// links are not followed, and a file holding a NUL byte is taken for binary
21+// and skipped. The folders that do not exist are returned by name.
22+func collect(opts Options) (files []file, missing []string) {
23+ exts := map[string]bool{}
24+ for _, e := range opts.Extensions {
25+ exts[strings.ToLower(e)] = true
26+ }
27+ for _, dir := range opts.Dirs {
28+ root := dir
29+ if !filepath.IsAbs(root) {
30+ root = filepath.Join(opts.Base, root)
31+ }
32+ if info, err := os.Stat(root); err != nil || !info.IsDir() {
33+ missing = append(missing, dir)
34+ continue
35+ }
36+ _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
37+ if err != nil {
38+ return nil // an unreadable folder or file: skip it, keep walking
39+ }
40+ if d.IsDir() {
41+ if path != root && skipFolder(d.Name()) {
42+ return filepath.SkipDir
43+ }
44+ return nil
45+ }
46+ if d.Type()&fs.ModeSymlink != 0 || !exts[strings.ToLower(filepath.Ext(path))] {
47+ return nil
48+ }
49+ if f, ok := readFile(path, opts); ok {
50+ files = append(files, f)
51+ }
52+ return nil
53+ })
54+ }
55+ return files, missing
56+}
57+
58+func skipFolder(name string) bool {
59+ return strings.HasPrefix(name, ".") || name == "node_modules"
60+}
61+
62+// readFile reads one candidate, within the size limit and not binary.
63+func readFile(path string, opts Options) (file, bool) {
64+ info, err := os.Stat(path)
65+ if err != nil || (opts.MaxFileSize > 0 && info.Size() > opts.MaxFileSize) {
66+ return file{}, false
67+ }
68+ data, err := os.ReadFile(path)
69+ if err != nil || bytes.IndexByte(data, 0) >= 0 {
70+ return file{}, false
71+ }
72+ return file{path: shown(path, opts.Base), text: string(data)}, true
73+}
74+
75+// shown is a path relative to base when it is inside it, absolute otherwise.
76+func shown(path, base string) string {
77+ if rel, err := filepath.Rel(base, path); err == nil && !strings.HasPrefix(rel, "..") {
78+ return rel
79+ }
80+ abs, err := filepath.Abs(path)
81+ if err != nil {
82+ return path
83+ }
84+ return abs
85+}
modified internal/store/search.go +4 -31
@@ -6,13 +6,12 @@ import (
66 "sort"
77 "strings"
88 "time"
9- "unicode"
109 "unicode/utf8"
1110
1211 "mm/internal/compact"
12+ "mm/internal/textfold"
1313
1414 "github.com/firebase/genkit/go/ai"
15- "golang.org/x/text/unicode/norm"
1615 )
1716
1817 // Searching the stored sessions (ticket 48): the user's /memory and the
@@ -161,7 +160,7 @@ const minWordRunes = 3
161160 func searchWords(query string) []string {
162161 var words, short []string
163162 seen := map[string]bool{}
164- for _, w := range strings.Fields(fold(query)) {
163+ for _, w := range strings.Fields(textfold.Fold(query)) {
165164 if seen[w] {
166165 continue
167166 }
@@ -291,7 +290,7 @@ func searchable(m *ai.Message) (role, text string, ok bool) {
291290 // matchWords counts the words text holds and returns, in runes, where the
292291 // earliest one found starts and where the latest one found ends.
293292 func matchWords(text string, words []string) (score, first, last int) {
294- folded := fold(text)
293+ folded := textfold.Fold(text)
295294 first = -1
296295 for _, w := range words {
297296 i := strings.Index(folded, w)
@@ -310,7 +309,7 @@ func matchWords(text string, words []string) (score, first, last int) {
310309 }
311310
312311 // snippet is the text from rune first to rune last with some context (see
313-// snippetMax), whitespace collapsed to single spaces. fold keeps one rune
312+// snippetMax), whitespace collapsed to single spaces. textfold.Fold keeps one rune
314313 // per rune, so a position in the folded text is the same position in the
315314 // original.
316315 func snippet(text string, first, last int) string {
@@ -326,29 +325,3 @@ func snippet(text string, first, last int) string {
326325 }
327326 return out
328327 }
329-
330-// fold lower-cases text and drops its accents, rune for rune: "Préférence"
331-// becomes "preference". A rune whose decomposition is not a base letter plus
332-// marks is kept as it is lowered, so the result has exactly as many runes as
333-// the input — what lets snippet cut the original at a folded position.
334-func fold(text string) string {
335- var b strings.Builder
336- for _, r := range text {
337- b.WriteRune(foldRune(r))
338- }
339- return b.String()
340-}
341-
342-func foldRune(r rune) rune {
343- r = unicode.ToLower(r)
344- decomposed := []rune(norm.NFD.String(string(r)))
345- if len(decomposed) == 0 {
346- return r
347- }
348- for _, mark := range decomposed[1:] {
349- if !unicode.Is(unicode.Mn, mark) {
350- return r
351- }
352- }
353- return decomposed[0]
354-}
@@ -6,13 +6,12 @@ import (
6 "sort"6 "sort"
7 "strings"7 "strings"
8 "time"8 "time"
9- "unicode"
10 "unicode/utf8"9 "unicode/utf8"
11 10
12 "mm/internal/compact"11 "mm/internal/compact"
12+ "mm/internal/textfold"
13 13
14 "github.com/firebase/genkit/go/ai"14 "github.com/firebase/genkit/go/ai"
15- "golang.org/x/text/unicode/norm"
16 )15 )
17 16
18 // Searching the stored sessions (ticket 48): the user's /memory and the17 // Searching the stored sessions (ticket 48): the user's /memory and the
@@ -161,7 +160,7 @@ const minWordRunes = 3
161 func searchWords(query string) []string {160 func searchWords(query string) []string {
162 var words, short []string161 var words, short []string
163 seen := map[string]bool{}162 seen := map[string]bool{}
164- for _, w := range strings.Fields(fold(query)) {163+ for _, w := range strings.Fields(textfold.Fold(query)) {
165 if seen[w] {164 if seen[w] {
166 continue165 continue
167 }166 }
@@ -291,7 +290,7 @@ func searchable(m *ai.Message) (role, text string, ok bool) {
291 // matchWords counts the words text holds and returns, in runes, where the290 // matchWords counts the words text holds and returns, in runes, where the
292 // earliest one found starts and where the latest one found ends.291 // earliest one found starts and where the latest one found ends.
293 func matchWords(text string, words []string) (score, first, last int) {292 func matchWords(text string, words []string) (score, first, last int) {
294- folded := fold(text)293+ folded := textfold.Fold(text)
295 first = -1294 first = -1
296 for _, w := range words {295 for _, w := range words {
297 i := strings.Index(folded, w)296 i := strings.Index(folded, w)
@@ -310,7 +309,7 @@ func matchWords(text string, words []string) (score, first, last int) {
310 }309 }
311 310
312 // snippet is the text from rune first to rune last with some context (see311 // snippet is the text from rune first to rune last with some context (see
313-// snippetMax), whitespace collapsed to single spaces. fold keeps one rune312+// snippetMax), whitespace collapsed to single spaces. textfold.Fold keeps one rune
314 // per rune, so a position in the folded text is the same position in the313 // per rune, so a position in the folded text is the same position in the
315 // original.314 // original.
316 func snippet(text string, first, last int) string {315 func snippet(text string, first, last int) string {
@@ -326,29 +325,3 @@ func snippet(text string, first, last int) string {
326 }325 }
327 return out326 return out
328 }327 }
329-
330-// fold lower-cases text and drops its accents, rune for rune: "Préférence"
331-// becomes "preference". A rune whose decomposition is not a base letter plus
332-// marks is kept as it is lowered, so the result has exactly as many runes as
333-// the input — what lets snippet cut the original at a folded position.
334-func fold(text string) string {
335- var b strings.Builder
336- for _, r := range text {
337- b.WriteRune(foldRune(r))
338- }
339- return b.String()
340-}
341-
342-func foldRune(r rune) rune {
343- r = unicode.ToLower(r)
344- decomposed := []rune(norm.NFD.String(string(r)))
345- if len(decomposed) == 0 {
346- return r
347- }
348- for _, mark := range decomposed[1:] {
349- if !unicode.Is(unicode.Mn, mark) {
350- return r
351- }
352- }
353- return decomposed[0]
354-}
modified internal/store/search_test.go +0 -17
@@ -14,23 +14,6 @@ func reply(msgs []*ai.Message, q, a string) []*ai.Message {
1414 return append(msgs, ai.NewUserTextMessage(q), ai.NewModelTextMessage(a))
1515 }
1616
17-func TestFoldDropsCaseAndAccentsRuneForRune(t *testing.T) {
18- for in, want := range map[string]string{
19- "Préférence": "preference",
20- "ÇA MARCHE": "ca marche",
21- "naïve café": "naive cafe",
22- "日本 ok": "日本 ok",
23- } {
24- got := fold(in)
25- if got != want {
26- t.Errorf("fold(%q) = %q, want %q", in, got, want)
27- }
28- if len([]rune(got)) != len([]rune(in)) {
29- t.Errorf("fold(%q) changed the rune count", in)
30- }
31- }
32-}
33-
3417 // Hits are ranked by the number of distinct words they hold, then by the
3518 // session's date; a message of the conversation in progress is never a hit,
3619 // wherever it is stored; the turn is the question's number, the role says
@@ -14,23 +14,6 @@ func reply(msgs []*ai.Message, q, a string) []*ai.Message {
14 return append(msgs, ai.NewUserTextMessage(q), ai.NewModelTextMessage(a))14 return append(msgs, ai.NewUserTextMessage(q), ai.NewModelTextMessage(a))
15 }15 }
16 16
17-func TestFoldDropsCaseAndAccentsRuneForRune(t *testing.T) {
18- for in, want := range map[string]string{
19- "Préférence": "preference",
20- "ÇA MARCHE": "ca marche",
21- "naïve café": "naive cafe",
22- "日本 ok": "日本 ok",
23- } {
24- got := fold(in)
25- if got != want {
26- t.Errorf("fold(%q) = %q, want %q", in, got, want)
27- }
28- if len([]rune(got)) != len([]rune(in)) {
29- t.Errorf("fold(%q) changed the rune count", in)
30- }
31- }
32-}
33-
34 // Hits are ranked by the number of distinct words they hold, then by the17 // Hits are ranked by the number of distinct words they hold, then by the
35 // session's date; a message of the conversation in progress is never a hit,18 // session's date; a message of the conversation in progress is never a hit,
36 // wherever it is stored; the turn is the question's number, the role says19 // wherever it is stored; the turn is the question's number, the role says
added internal/textfold/textfold.go +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+// Package textfold folds text for word search: lower case, accents dropped,
2+// RUNE FOR RUNE — the folded text has exactly as many runes as the original,
3+// so a position found in one is the same position in the other (what lets a
4+// search cut a snippet out of the original at a folded match). Shared by the
5+// stored-session search (internal/store) and the document search
6+// (internal/docsearch).
7+//
8+// textfold.Fold("Préférence") // "preference"
9+package textfold
10+
11+import (
12+ "strings"
13+ "unicode"
14+
15+ "golang.org/x/text/unicode/norm"
16+)
17+
18+// Fold lower-cases text and drops its accents, rune for rune. A rune whose
19+// decomposition is not a base letter plus combining marks is only lowered.
20+//
21+// textfold.Fold("ÇA MARCHE") // "ca marche"
22+func Fold(text string) string {
23+ var b strings.Builder
24+ for _, r := range text {
25+ b.WriteRune(Rune(r))
26+ }
27+ return b.String()
28+}
29+
30+// Rune folds one rune.
31+func Rune(r rune) rune {
32+ r = unicode.ToLower(r)
33+ decomposed := []rune(norm.NFD.String(string(r)))
34+ if len(decomposed) == 0 {
35+ return r
36+ }
37+ for _, mark := range decomposed[1:] {
38+ if !unicode.Is(unicode.Mn, mark) {
39+ return r
40+ }
41+ }
42+ return decomposed[0]
43+}
new file mode 100644
@@ -0,0 +1,43 @@
1+// Package textfold folds text for word search: lower case, accents dropped,
2+// RUNE FOR RUNE — the folded text has exactly as many runes as the original,
3+// so a position found in one is the same position in the other (what lets a
4+// search cut a snippet out of the original at a folded match). Shared by the
5+// stored-session search (internal/store) and the document search
6+// (internal/docsearch).
7+//
8+// textfold.Fold("Préférence") // "preference"
9+package textfold
10+
11+import (
12+ "strings"
13+ "unicode"
14+
15+ "golang.org/x/text/unicode/norm"
16+)
17+
18+// Fold lower-cases text and drops its accents, rune for rune. A rune whose
19+// decomposition is not a base letter plus combining marks is only lowered.
20+//
21+// textfold.Fold("ÇA MARCHE") // "ca marche"
22+func Fold(text string) string {
23+ var b strings.Builder
24+ for _, r := range text {
25+ b.WriteRune(Rune(r))
26+ }
27+ return b.String()
28+}
29+
30+// Rune folds one rune.
31+func Rune(r rune) rune {
32+ r = unicode.ToLower(r)
33+ decomposed := []rune(norm.NFD.String(string(r)))
34+ if len(decomposed) == 0 {
35+ return r
36+ }
37+ for _, mark := range decomposed[1:] {
38+ if !unicode.Is(unicode.Mn, mark) {
39+ return r
40+ }
41+ }
42+ return decomposed[0]
43+}
added internal/textfold/textfold_test.go +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+package textfold
2+
3+import "testing"
4+
5+func TestFoldDropsCaseAndAccentsRuneForRune(t *testing.T) {
6+ for in, want := range map[string]string{
7+ "Préférence": "preference",
8+ "ÇA MARCHE": "ca marche",
9+ "naïve café": "naive cafe",
10+ "日本 ok": "日本 ok",
11+ } {
12+ got := Fold(in)
13+ if got != want {
14+ t.Errorf("Fold(%q) = %q, want %q", in, got, want)
15+ }
16+ if len([]rune(got)) != len([]rune(in)) {
17+ t.Errorf("Fold(%q) changed the rune count", in)
18+ }
19+ }
20+}
new file mode 100644
@@ -0,0 +1,20 @@
1+package textfold
2+
3+import "testing"
4+
5+func TestFoldDropsCaseAndAccentsRuneForRune(t *testing.T) {
6+ for in, want := range map[string]string{
7+ "Préférence": "preference",
8+ "ÇA MARCHE": "ca marche",
9+ "naïve café": "naive cafe",
10+ "日本 ok": "日本 ok",
11+ } {
12+ got := Fold(in)
13+ if got != want {
14+ t.Errorf("Fold(%q) = %q, want %q", in, got, want)
15+ }
16+ if len([]rune(got)) != len([]rune(in)) {
17+ t.Errorf("Fold(%q) changed the rune count", in)
18+ }
19+ }
20+}
added internal/tools/searchdocs.go +67 -0
new file mode 100644
@@ -0,0 +1,67 @@
1+package tools
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "mm/internal/detector"
8+ "mm/internal/docsearch"
9+ "mm/internal/spinner"
10+ "mm/internal/ui"
11+
12+ "github.com/firebase/genkit/go/ai"
13+ "github.com/firebase/genkit/go/genkit"
14+)
15+
16+// --- search_docs (ticket 106) ------------------------------------------------
17+//
18+// The /docs search in the model's hands: BM25 over the text files of the
19+// docSearch folders, the best passages back with their `path:line` link, so
20+// the model can quote them or read_file the whole page. It reads files and
21+// changes nothing, so it is never gated, even with yolo off — like
22+// read_file.
23+
24+type searchDocsInput struct {
25+ Query string `json:"query" jsonschema_description:"The words to look for, e.g. 'list selection state'. Case and accents do not matter; words of 4 letters or more also match longer words (list → ListState) and plurals."`
26+ Limit int `json:"limit,omitempty" jsonschema_description:"How many passages at most; 0 or absent = 5, at most 20."`
27+}
28+
29+// SearchDocs declares the `search_docs` tool (config
30+// docSearch.autoSearchDocs).
31+//
32+// if config.Cfg.DocSearch.DocsTool() {
33+// set.tools = append(set.tools, tools.SearchDocs(e.G, d))
34+// }
35+func SearchDocs(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
36+ description := `Search the project's documentation folders for words and get back the best passages, each with its link (path:line) and its section headings.
37+Use it before answering a question the documentation may cover, and quote or follow what it found: read_file on the link gives the whole page.
38+The search matches words, not meanings: use the words the documentation is likely to use (often English, technical names); if nothing comes back, try other words.`
39+
40+ return genkit.DefineTool(g, "search_docs", description,
41+ func(tctx *ai.ToolContext, in searchDocsInput) (string, error) {
42+ title := fmt.Sprintf("search_docs: %q", in.Query)
43+ run := func() string { return truncate(docsearch.Answer(workDir(), in.Query, in.Limit)) }
44+ if s := ui.ActiveSink(); s != nil {
45+ ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: "search_docs", Title: title, Kind: "search", Input: map[string]any{"query": in.Query, "limit": in.Limit}}
46+ s.ToolStart(ev)
47+ s.ToolRunning(ev.ID)
48+ out := run()
49+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
50+ return record(d, "search_docs", in.Query, out)
51+ }
52+ announce("📚 " + title)
53+ out := run()
54+ spinner.Suspend(func() { echoOutput(out) })
55+ return record(d, "search_docs", in.Query, out)
56+ })
57+}
58+
59+// workDir is the turn's working directory: the session's (ACP, the window),
60+// else the process's.
61+func workDir() string {
62+ if s := ui.ActiveSink(); s != nil && s.WorkDir() != "" {
63+ return s.WorkDir()
64+ }
65+ dir, _ := os.Getwd()
66+ return dir
67+}
new file mode 100644
@@ -0,0 +1,67 @@
1+package tools
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "mm/internal/detector"
8+ "mm/internal/docsearch"
9+ "mm/internal/spinner"
10+ "mm/internal/ui"
11+
12+ "github.com/firebase/genkit/go/ai"
13+ "github.com/firebase/genkit/go/genkit"
14+)
15+
16+// --- search_docs (ticket 106) ------------------------------------------------
17+//
18+// The /docs search in the model's hands: BM25 over the text files of the
19+// docSearch folders, the best passages back with their `path:line` link, so
20+// the model can quote them or read_file the whole page. It reads files and
21+// changes nothing, so it is never gated, even with yolo off — like
22+// read_file.
23+
24+type searchDocsInput struct {
25+ Query string `json:"query" jsonschema_description:"The words to look for, e.g. 'list selection state'. Case and accents do not matter; words of 4 letters or more also match longer words (list → ListState) and plurals."`
26+ Limit int `json:"limit,omitempty" jsonschema_description:"How many passages at most; 0 or absent = 5, at most 20."`
27+}
28+
29+// SearchDocs declares the `search_docs` tool (config
30+// docSearch.autoSearchDocs).
31+//
32+// if config.Cfg.DocSearch.DocsTool() {
33+// set.tools = append(set.tools, tools.SearchDocs(e.G, d))
34+// }
35+func SearchDocs(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
36+ description := `Search the project's documentation folders for words and get back the best passages, each with its link (path:line) and its section headings.
37+Use it before answering a question the documentation may cover, and quote or follow what it found: read_file on the link gives the whole page.
38+The search matches words, not meanings: use the words the documentation is likely to use (often English, technical names); if nothing comes back, try other words.`
39+
40+ return genkit.DefineTool(g, "search_docs", description,
41+ func(tctx *ai.ToolContext, in searchDocsInput) (string, error) {
42+ title := fmt.Sprintf("search_docs: %q", in.Query)
43+ run := func() string { return truncate(docsearch.Answer(workDir(), in.Query, in.Limit)) }
44+ if s := ui.ActiveSink(); s != nil {
45+ ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: "search_docs", Title: title, Kind: "search", Input: map[string]any{"query": in.Query, "limit": in.Limit}}
46+ s.ToolStart(ev)
47+ s.ToolRunning(ev.ID)
48+ out := run()
49+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
50+ return record(d, "search_docs", in.Query, out)
51+ }
52+ announce("📚 " + title)
53+ out := run()
54+ spinner.Suspend(func() { echoOutput(out) })
55+ return record(d, "search_docs", in.Query, out)
56+ })
57+}
58+
59+// workDir is the turn's working directory: the session's (ACP, the window),
60+// else the process's.
61+func workDir() string {
62+ if s := ui.ActiveSink(); s != nil && s.WorkDir() != "" {
63+ return s.WorkDir()
64+ }
65+ dir, _ := os.Getwd()
66+ return dir
67+}
added internal/tools/searchdocs_test.go +49 -0
new file mode 100644
@@ -0,0 +1,49 @@
1+package tools
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/config"
11+ "mm/internal/detector"
12+ "mm/internal/ui"
13+
14+ "github.com/firebase/genkit/go/genkit"
15+)
16+
17+// search_docs reads the docSearch folders of the session's working directory,
18+// returns the passages with their link, shows a search call, and never asks,
19+// even with yolo off.
20+func TestSearchDocsReadsTheConfiguredFolders(t *testing.T) {
21+ yoloOff(t)
22+ saved := config.Cfg.DocSearch
23+ t.Cleanup(func() { config.Cfg.DocSearch = saved })
24+ config.Cfg.DocSearch = config.DocSearchConfig{AutoSearchDocs: true, Dirs: []string{"guides"}, Extensions: []string{".md"}, MaxFileSize: 1 << 20}
25+
26+ project := t.TempDir()
27+ if err := os.MkdirAll(filepath.Join(project, "guides"), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(filepath.Join(project, "guides", "gauge.md"), []byte("# Gauge\n\nA Gauge shows progress.\n"), 0o644); err != nil {
31+ t.Fatal(err)
32+ }
33+ g := genkit.Init(context.Background())
34+ SearchDocs(g, detector.NewLoopDetector(10, 3))
35+ sink := &recordingSink{dir: project}
36+ ui.SetActive(sink)
37+ t.Cleanup(func() { ui.SetActive(nil) })
38+
39+ out, err := genkit.LookupTool(g, "search_docs").RunRaw(context.Background(), map[string]any{"query": "progress"})
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ if text := out.(string); !strings.Contains(text, "── guides/gauge.md:1 · Gauge\n# Gauge") {
44+ t.Errorf("output:\n%s", text)
45+ }
46+ if sink.asked != 0 || sink.started.Kind != "search" || sink.started.Title != `search_docs: "progress"` {
47+ t.Errorf("asked %d, shown as %+v", sink.asked, sink.started)
48+ }
49+}
new file mode 100644
@@ -0,0 +1,49 @@
1+package tools
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/config"
11+ "mm/internal/detector"
12+ "mm/internal/ui"
13+
14+ "github.com/firebase/genkit/go/genkit"
15+)
16+
17+// search_docs reads the docSearch folders of the session's working directory,
18+// returns the passages with their link, shows a search call, and never asks,
19+// even with yolo off.
20+func TestSearchDocsReadsTheConfiguredFolders(t *testing.T) {
21+ yoloOff(t)
22+ saved := config.Cfg.DocSearch
23+ t.Cleanup(func() { config.Cfg.DocSearch = saved })
24+ config.Cfg.DocSearch = config.DocSearchConfig{AutoSearchDocs: true, Dirs: []string{"guides"}, Extensions: []string{".md"}, MaxFileSize: 1 << 20}
25+
26+ project := t.TempDir()
27+ if err := os.MkdirAll(filepath.Join(project, "guides"), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(filepath.Join(project, "guides", "gauge.md"), []byte("# Gauge\n\nA Gauge shows progress.\n"), 0o644); err != nil {
31+ t.Fatal(err)
32+ }
33+ g := genkit.Init(context.Background())
34+ SearchDocs(g, detector.NewLoopDetector(10, 3))
35+ sink := &recordingSink{dir: project}
36+ ui.SetActive(sink)
37+ t.Cleanup(func() { ui.SetActive(nil) })
38+
39+ out, err := genkit.LookupTool(g, "search_docs").RunRaw(context.Background(), map[string]any{"query": "progress"})
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ if text := out.(string); !strings.Contains(text, "── guides/gauge.md:1 · Gauge\n# Gauge") {
44+ t.Errorf("output:\n%s", text)
45+ }
46+ if sink.asked != 0 || sink.started.Kind != "search" || sink.started.Title != `search_docs: "progress"` {
47+ t.Errorf("asked %d, shown as %+v", sink.asked, sink.started)
48+ }
49+}
modified internal/tui/commands.go +3 -0
@@ -10,6 +10,7 @@ import (
1010
1111 "mm/internal/agentsmd"
1212 "mm/internal/command"
13+ "mm/internal/docsearch"
1314 "mm/internal/session"
1415 "mm/internal/store"
1516 )
@@ -106,6 +107,8 @@ func (r *runner) dispatch(p Prompt) {
106107 r.unload(arg)
107108 case command.Memory:
108109 r.t.Notice(r.keep.Memory(arg, r.messages))
110+ case command.Docs:
111+ r.t.Notice(docsearch.Answer(r.opts.Cwd, arg, 0))
109112 }
110113 }
111114
@@ -10,6 +10,7 @@ import (
10 10
11 "mm/internal/agentsmd"11 "mm/internal/agentsmd"
12 "mm/internal/command"12 "mm/internal/command"
13+ "mm/internal/docsearch"
13 "mm/internal/session"14 "mm/internal/session"
14 "mm/internal/store"15 "mm/internal/store"
15 )16 )
@@ -106,6 +107,8 @@ func (r *runner) dispatch(p Prompt) {
106 r.unload(arg)107 r.unload(arg)
107 case command.Memory:108 case command.Memory:
108 r.t.Notice(r.keep.Memory(arg, r.messages))109 r.t.Notice(r.keep.Memory(arg, r.messages))
110+ case command.Docs:
111+ r.t.Notice(docsearch.Answer(r.opts.Cwd, arg, 0))
109 }112 }
110 }113 }
111 114
modified internal/tui/turn_test.go +18 -0
@@ -636,3 +636,21 @@ func TestRunnerMemory(t *testing.T) {
636636 t.Errorf("the history changed: %d → %d messages", before, len(r.messages))
637637 }
638638 }
639+
640+// /docs shows the passages as a notice, from the window's working directory.
641+func TestRunnerDocs(t *testing.T) {
642+ command.SetDocs(true)
643+ t.Cleanup(func() { command.SetDocs(false) })
644+ r, tr := newTestRunner(t, &fakeModel{})
645+ config.Cfg.DocSearch = config.DocSearchConfig{SearchDocs: true, Dirs: []string{"docs"}, Extensions: []string{".md"}, MaxFileSize: 1 << 20}
646+ if err := os.MkdirAll(filepath.Join(r.opts.Cwd, "docs"), 0o755); err != nil {
647+ t.Fatal(err)
648+ }
649+ if err := os.WriteFile(filepath.Join(r.opts.Cwd, "docs", "list.md"), []byte("# List\n\nUse ListState.\n"), 0o644); err != nil {
650+ t.Fatal(err)
651+ }
652+ r.submit(promptOf("/docs liststate"))
653+ if n := notices(tr); !strings.Contains(n, "── docs/list.md:1 · List") {
654+ t.Errorf("notices:\n%s", n)
655+ }
656+}
@@ -636,3 +636,21 @@ func TestRunnerMemory(t *testing.T) {
636 t.Errorf("the history changed: %d → %d messages", before, len(r.messages))636 t.Errorf("the history changed: %d → %d messages", before, len(r.messages))
637 }637 }
638 }638 }
639+
640+// /docs shows the passages as a notice, from the window's working directory.
641+func TestRunnerDocs(t *testing.T) {
642+ command.SetDocs(true)
643+ t.Cleanup(func() { command.SetDocs(false) })
644+ r, tr := newTestRunner(t, &fakeModel{})
645+ config.Cfg.DocSearch = config.DocSearchConfig{SearchDocs: true, Dirs: []string{"docs"}, Extensions: []string{".md"}, MaxFileSize: 1 << 20}
646+ if err := os.MkdirAll(filepath.Join(r.opts.Cwd, "docs"), 0o755); err != nil {
647+ t.Fatal(err)
648+ }
649+ if err := os.WriteFile(filepath.Join(r.opts.Cwd, "docs", "list.md"), []byte("# List\n\nUse ListState.\n"), 0o644); err != nil {
650+ t.Fatal(err)
651+ }
652+ r.submit(promptOf("/docs liststate"))
653+ if n := notices(tr); !strings.Contains(n, "── docs/list.md:1 · List") {
654+ t.Errorf("notices:\n%s", n)
655+ }
656+}
modified main.go +6 -0
@@ -213,6 +213,7 @@ func main() {
213213 command.SetAgents(config.AgentNames())
214214 command.SetUnload(config.Cfg.UnloadModels)
215215 command.SetMemory(config.Cfg.Sessions.MemoryCommand())
216+ command.SetDocs(config.Cfg.DocSearch.DocsCommand())
216217 asker := ask.NewPool()
217218 defer asker.Close()
218219
@@ -356,6 +357,7 @@ type toolSet struct {
356357 // /ask; `ask_user` behind `askUserTool` (on by default), never with a
357358 // structuredOutput; `save_answer` behind `saveAnswerTool` (off by default);
358359 // `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on);
360+// `search_docs` behind `docSearch.autoSearchDocs` (off by default);
359361 // and the agent file's declared `tools:`.
360362 func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet {
361363 set := toolSet{close: func() {}}
@@ -406,6 +408,10 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent
406408 set.tools = append(set.tools, tools.FetchContent(e.G, d, fetcher))
407409 set.names = append(set.names, "fetch_content")
408410 }
411+ if config.Cfg.DocSearch.DocsTool() {
412+ set.tools = append(set.tools, tools.SearchDocs(e.G, d))
413+ set.names = append(set.names, "search_docs")
414+ }
409415 if config.Cfg.Sessions.MemoryTool() {
410416 set.tools = append(set.tools, tools.SearchMemory(e.G, d))
411417 set.names = append(set.names, "search_memory")
@@ -213,6 +213,7 @@ func main() {
213 command.SetAgents(config.AgentNames())213 command.SetAgents(config.AgentNames())
214 command.SetUnload(config.Cfg.UnloadModels)214 command.SetUnload(config.Cfg.UnloadModels)
215 command.SetMemory(config.Cfg.Sessions.MemoryCommand())215 command.SetMemory(config.Cfg.Sessions.MemoryCommand())
216+ command.SetDocs(config.Cfg.DocSearch.DocsCommand())
216 asker := ask.NewPool()217 asker := ask.NewPool()
217 defer asker.Close()218 defer asker.Close()
218 219
@@ -356,6 +357,7 @@ type toolSet struct {
356 // /ask; `ask_user` behind `askUserTool` (on by default), never with a357 // /ask; `ask_user` behind `askUserTool` (on by default), never with a
357 // structuredOutput; `save_answer` behind `saveAnswerTool` (off by default);358 // structuredOutput; `save_answer` behind `saveAnswerTool` (off by default);
358 // `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on);359 // `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on);
360+// `search_docs` behind `docSearch.autoSearchDocs` (off by default);
359 // and the agent file's declared `tools:`.361 // and the agent file's declared `tools:`.
360 func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet {362 func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet {
361 set := toolSet{close: func() {}}363 set := toolSet{close: func() {}}
@@ -406,6 +408,10 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent
406 set.tools = append(set.tools, tools.FetchContent(e.G, d, fetcher))408 set.tools = append(set.tools, tools.FetchContent(e.G, d, fetcher))
407 set.names = append(set.names, "fetch_content")409 set.names = append(set.names, "fetch_content")
408 }410 }
411+ if config.Cfg.DocSearch.DocsTool() {
412+ set.tools = append(set.tools, tools.SearchDocs(e.G, d))
413+ set.names = append(set.names, "search_docs")
414+ }
409 if config.Cfg.Sessions.MemoryTool() {415 if config.Cfg.Sessions.MemoryTool() {
410 set.tools = append(set.tools, tools.SearchMemory(e.G, d))416 set.tools = append(set.tools, tools.SearchMemory(e.G, d))
411 set.names = append(set.names, "search_memory")417 set.names = append(set.names, "search_memory")