modified
.tickets/issues/0106-search-doc-bm25-search-in-text-files.yaml +97 -12 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | 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 | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-20T20:00:00.000Z |
| 9 | -updatedAt: 2026-10-20T20:00:00.000Z | |
| 9 | +updatedAt: 2026-10-20T23:30:00.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - kind::feature |
| 12 | 12 | milestone: v0.8.0 |
| @@ -67,7 +67,7 @@ body: | | ||
| 67 | 67 | tasks: |
| 68 | 68 | - id: 1 |
| 69 | 69 | title: "Agree on the config key, defaults and the open questions below" |
| 70 | - state: open | |
| 70 | + state: closed | |
| 71 | 71 | priority: none |
| 72 | 72 | author: |
| 73 | 73 | name: k33g |
| @@ -76,7 +76,7 @@ tasks: | ||
| 76 | 76 | updatedAt: 2026-10-20T20:00:00.000Z |
| 77 | 77 | - id: 2 |
| 78 | 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: open | |
| 79 | + state: closed | |
| 80 | 80 | priority: none |
| 81 | 81 | author: |
| 82 | 82 | name: k33g |
| @@ -85,7 +85,7 @@ tasks: | ||
| 85 | 85 | updatedAt: 2026-10-20T20:00:00.000Z |
| 86 | 86 | - id: 3 |
| 87 | 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: open | |
| 88 | + state: closed | |
| 89 | 89 | priority: none |
| 90 | 90 | author: |
| 91 | 91 | name: k33g |
| @@ -94,7 +94,7 @@ tasks: | ||
| 94 | 94 | updatedAt: 2026-10-20T20:00:00.000Z |
| 95 | 95 | - id: 4 |
| 96 | 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: open | |
| 97 | + state: closed | |
| 98 | 98 | priority: none |
| 99 | 99 | author: |
| 100 | 100 | name: k33g |
| @@ -102,8 +102,8 @@ tasks: | ||
| 102 | 102 | createdAt: 2026-10-20T20:00:00.000Z |
| 103 | 103 | updatedAt: 2026-10-20T20:00:00.000Z |
| 104 | 104 | - 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 | |
| 107 | 107 | priority: none |
| 108 | 108 | author: |
| 109 | 109 | name: k33g |
| @@ -112,7 +112,7 @@ tasks: | ||
| 112 | 112 | updatedAt: 2026-10-20T20:00:00.000Z |
| 113 | 113 | - id: 6 |
| 114 | 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: open | |
| 115 | + state: closed | |
| 116 | 116 | priority: none |
| 117 | 117 | author: |
| 118 | 118 | name: k33g |
| @@ -121,11 +121,96 @@ tasks: | ||
| 121 | 121 | updatedAt: 2026-10-20T20:00:00.000Z |
| 122 | 122 | - id: 7 |
| 123 | 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: open | |
| 124 | + state: closed | |
| 125 | 125 | priority: none |
| 126 | 126 | author: |
| 127 | 127 | name: k33g |
| 128 | 128 | email: ph.charriere@gmail.com |
| 129 | 129 | createdAt: 2026-10-20T20:00:00.000Z |
| 130 | 130 | 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: 106 | 2 | 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: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-20T20:00:00.000Z | 8 | createdAt: 2026-10-20T20:00:00.000Z |
| 9 | -updatedAt: 2026-10-20T20:00:00.000Z | 9 | +updatedAt: 2026-10-20T23:30:00.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - kind::feature | 11 | - kind::feature |
| 12 | milestone: v0.8.0 | 12 | milestone: v0.8.0 |
| @@ -67,7 +67,7 @@ body: | | |||
| 67 | tasks: | 67 | tasks: |
| 68 | - id: 1 | 68 | - 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: open | 70 | + state: closed |
| 71 | priority: none | 71 | priority: none |
| 72 | author: | 72 | author: |
| 73 | name: k33g | 73 | name: k33g |
| @@ -76,7 +76,7 @@ tasks: | |||
| 76 | updatedAt: 2026-10-20T20:00:00.000Z | 76 | updatedAt: 2026-10-20T20:00:00.000Z |
| 77 | - id: 2 | 77 | - 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: open | 79 | + state: closed |
| 80 | priority: none | 80 | priority: none |
| 81 | author: | 81 | author: |
| 82 | name: k33g | 82 | name: k33g |
| @@ -85,7 +85,7 @@ tasks: | |||
| 85 | updatedAt: 2026-10-20T20:00:00.000Z | 85 | updatedAt: 2026-10-20T20:00:00.000Z |
| 86 | - id: 3 | 86 | - 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: open | 88 | + state: closed |
| 89 | priority: none | 89 | priority: none |
| 90 | author: | 90 | author: |
| 91 | name: k33g | 91 | name: k33g |
| @@ -94,7 +94,7 @@ tasks: | |||
| 94 | updatedAt: 2026-10-20T20:00:00.000Z | 94 | updatedAt: 2026-10-20T20:00:00.000Z |
| 95 | - id: 4 | 95 | - 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: open | 97 | + state: closed |
| 98 | priority: none | 98 | priority: none |
| 99 | author: | 99 | author: |
| 100 | name: k33g | 100 | name: k33g |
| @@ -102,8 +102,8 @@ tasks: | |||
| 102 | createdAt: 2026-10-20T20:00:00.000Z | 102 | createdAt: 2026-10-20T20:00:00.000Z |
| 103 | updatedAt: 2026-10-20T20:00:00.000Z | 103 | updatedAt: 2026-10-20T20:00:00.000Z |
| 104 | - id: 5 | 104 | - 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: open | 106 | + state: closed |
| 107 | priority: none | 107 | priority: none |
| 108 | author: | 108 | author: |
| 109 | name: k33g | 109 | name: k33g |
| @@ -112,7 +112,7 @@ tasks: | |||
| 112 | updatedAt: 2026-10-20T20:00:00.000Z | 112 | updatedAt: 2026-10-20T20:00:00.000Z |
| 113 | - id: 6 | 113 | - 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: open | 115 | + state: closed |
| 116 | priority: none | 116 | priority: none |
| 117 | author: | 117 | author: |
| 118 | name: k33g | 118 | name: k33g |
| @@ -121,11 +121,96 @@ tasks: | |||
| 121 | updatedAt: 2026-10-20T20:00:00.000Z | 121 | updatedAt: 2026-10-20T20:00:00.000Z |
| 122 | - id: 7 | 122 | - 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: open | 124 | + state: closed |
| 125 | priority: none | 125 | priority: none |
| 126 | author: | 126 | author: |
| 127 | name: k33g | 127 | name: k33g |
| 128 | email: ph.charriere@gmail.com | 128 | email: ph.charriere@gmail.com |
| 129 | createdAt: 2026-10-20T20:00:00.000Z | 129 | createdAt: 2026-10-20T20:00:00.000Z |
| 130 | updatedAt: 2026-10-20T20:00:00.000Z | 130 | 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 | ||
| 121 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | |
| @@ -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 @@ | ||
| 1 | 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| @@ -1,3 +1,3 @@ | |||
| 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell></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) | ||
| 27 | 27 | - [Add a skill](how-to/add-a-skill.md) |
| 28 | 28 | - [Declare a custom tool](how-to/declare-a-custom-tool.md) |
| 29 | 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 | 31 | - [Search earlier sessions](how-to/search-earlier-sessions.md) — `/memory <words>`, the `search_memory` tool, `sessions.searchMemory` / `sessions.autoSearchMemory` |
| 31 | 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 | 33 | - [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 | 70 | - [Providers: one implementation per protocol](explanation/providers.md) |
| 70 | 71 | - [Context compression](explanation/context-compression.md) |
| 71 | 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 | 74 | - [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 | 75 | - [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 | 76 | - [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-after | 33 | - [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 permission | 74 | - [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 limits | 75 | - [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 text | 76 | - [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 | ||
| 159 | 159 | | `read` | `read` | `read_skill`, `read_file` | |
| 160 | 160 | | `edit` | `edit` | `write_file`, `edit_file` | |
| 161 | 161 | | `fetch` | `fetch` | `fetch_content` | |
| 162 | -| `search` | `search` | `search_memory` | | |
| 162 | +| `search` | `search` | `search_memory`, `search_docs` | | |
| 163 | 163 | | `other` | `other` | `ask_agent`, `ask_user` | |
| 164 | 164 | |
| 165 | 165 | ## 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 identifiers | 165 | ## Tool call identifiers |
modified
docs/en/reference/configuration.md +20 -1 | @@ -33,6 +33,7 @@ | ||
| 33 | 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 | 34 | | `context` | map | see below | Automatic history compression. | |
| 35 | 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 | 37 | | `trace` | map | see below | Write every request to the model and every response to files. | |
| 37 | 38 | | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. | |
| 38 | 39 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | |
| @@ -67,6 +68,18 @@ | ||
| 67 | 68 | |
| 68 | 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 | 83 | ## `trace` keys |
| 71 | 84 | |
| 72 | 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 | 146 | | Key | Type | Default | Description | |
| 134 | 147 | |-----|------|---------|-------------| |
| 135 | 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 | 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 | 151 | | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. | |
| 139 | 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 | 243 | | `context.summaryMaxTokens must be > 0` | zero or negative | |
| 231 | 244 | | `context.maxMessages must be >= 0` | negative value | |
| 232 | 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 | 252 | | `trace.dir must not be empty` | empty string with the trace on | |
| 234 | 253 | | `agentsMd.path must not be empty` | empty string with `agentsMd.enabled` on | |
| 235 | 254 | | `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` keys | 83 | ## `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 @@ | ||
| 16 | 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 | 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 | 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 | 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 | 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 | 173 | ## 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 | 177 | | Command | Over ACP | |
| 177 | 178 | |---------|----------| |
| 178 | 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 | 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 | 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 | 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 | 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). |
| @@ -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 mode | 173 | ## 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 | ||
| 288 | 288 | | ACP and window | Tool call of kind `other`; a `cannot unload` result is a failed call. | |
| 289 | 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 | 311 | ## `search_memory` |
| 292 | 312 | |
| 293 | 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)). |
| @@ -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: | ||
| 27 | 27 | - [Ajouter un skill](how-to/add-a-skill.md) |
| 28 | 28 | - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md) |
| 29 | 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 | 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 | 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 | 33 | - [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 | 70 | - [Fournisseurs : une implémentation par protocole](explanation/providers.md) |
| 70 | 71 | - [Compression du contexte](explanation/context-compression.md) |
| 71 | 72 | - [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 | 74 | - [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 | 75 | - [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 | 76 | - [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ès | 33 | - [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 agent | 72 | - [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 à permission | 74 | - [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 connues | 75 | - [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 texte | 76 | - [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 | ||
| 159 | 159 | | `read` | `read` | `read_skill`, `read_file` | |
| 160 | 160 | | `edit` | `edit` | `write_file`, `edit_file` | |
| 161 | 161 | | `fetch` | `fetch` | `fetch_content` | |
| 162 | -| `search` | `search` | `search_memory` | | |
| 162 | +| `search` | `search` | `search_memory`, `search_docs` | | |
| 163 | 163 | | `other` | `other` | `ask_agent`, `ask_user` | |
| 164 | 164 | |
| 165 | 165 | ## 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'outil | 165 | ## Identifiants d'appel d'outil |
modified
docs/fr/reference/configuration.md +20 -1 | @@ -33,6 +33,7 @@ | ||
| 33 | 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 | 34 | | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. | |
| 35 | 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 | 37 | | `agentsMd` | dictionnaire | voir ci-dessous | Ajoute le `AGENTS.md` du projet au prompt système de chaque conversation. Actif par défaut. | |
| 37 | 38 | | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. | |
| 38 | 39 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | |
| @@ -67,6 +68,18 @@ | ||
| 67 | 68 | |
| 68 | 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 | 83 | ## Clés de `trace` |
| 71 | 84 | |
| 72 | 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 | 146 | | Clé | Type | Défaut | Description | |
| 134 | 147 | |-----|------|--------|-------------| |
| 135 | 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 | 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 | 151 | | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. | |
| 139 | 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 | 243 | | `context.summaryMaxTokens must be > 0` | zéro ou négatif | |
| 231 | 244 | | `context.maxMessages must be >= 0` | valeur négative | |
| 232 | 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 | 252 | | `agentsMd.path must not be empty` | chaîne vide avec `agentsMd.enabled` actif | |
| 234 | 253 | | `agentsMd.maxChars must be >= 0` | valeur négative | |
| 235 | 254 | | `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 @@ | ||
| 15 | 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 | 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 | 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 | 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 | 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 | 173 | ## 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 | 177 | | Commande | En ACP | |
| 177 | 178 | |----------|--------| |
| 178 | 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 | 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 | 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 | 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 | 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). |
| @@ -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 ACP | 173 | ## 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 | ||
| 288 | 288 | | ACP et fenêtre | Appel d'outil de type `other` ; un résultat `cannot unload` est un appel en échec. | |
| 289 | 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 | 311 | ## `search_memory` |
| 292 | 312 | |
| 293 | 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)). |
| @@ -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 ( | ||
| 35 | 35 | "mm/internal/compact" |
| 36 | 36 | "mm/internal/config" |
| 37 | 37 | "mm/internal/consent" |
| 38 | + "mm/internal/docsearch" | |
| 38 | 39 | "mm/internal/engine" |
| 39 | 40 | "mm/internal/mention" |
| 40 | 41 | history "mm/internal/session" |
| @@ -498,10 +499,22 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session, | ||
| 498 | 499 | return f.unloadModel(ctx, sid, s, arg), true |
| 499 | 500 | case ok && cmd.Name == command.Memory: |
| 500 | 501 | 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 | 505 | 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 | 518 | // searchMemory is /memory <words> over ACP: the hits in the session's |
| 506 | 519 | // project, sent as the agent's answer; the history is not touched. |
| 507 | 520 | 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), true | 499 | 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), true | 501 | 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{}, false | 505 | 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's | 518 | // 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 ( | ||
| 18 | 18 | "mm/internal/config" |
| 19 | 19 | "mm/internal/consent" |
| 20 | 20 | "mm/internal/detector" |
| 21 | + "mm/internal/docsearch" | |
| 21 | 22 | "mm/internal/elicit" |
| 22 | 23 | "mm/internal/engine" |
| 23 | 24 | "mm/internal/hooks" |
| @@ -538,6 +539,8 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa | ||
| 538 | 539 | fmt.Println("🧹 " + l.e.Unload(l.ctx, arg)) |
| 539 | 540 | case command.Memory: |
| 540 | 541 | fmt.Println(l.keep.Memory(arg, messages)) |
| 542 | + case command.Docs: | |
| 543 | + fmt.Println(docsearch.Answer("", arg, 0)) | |
| 541 | 544 | case command.Resume: |
| 542 | 545 | resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, "")) |
| 543 | 546 | 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 ( | ||
| 4 | 4 | "context" |
| 5 | 5 | "io" |
| 6 | 6 | "os" |
| 7 | + "path/filepath" | |
| 7 | 8 | "strings" |
| 8 | 9 | "testing" |
| 9 | 10 | |
| @@ -54,3 +55,25 @@ func TestMemoryCommandInTheLineMode(t *testing.T) { | ||
| 54 | 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 | +} | |
| @@ -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 ( | ||
| 43 | 43 | Save = "save" |
| 44 | 44 | Unload = "unload" |
| 45 | 45 | Memory = "memory" |
| 46 | + Docs = "docs" | |
| 46 | 47 | ) |
| 47 | 48 | |
| 48 | 49 | // Command is one slash command: what it is called, what it does, who offers |
| @@ -82,7 +83,7 @@ var table = []Command{ | ||
| 82 | 83 | // when the configuration allows it (unload.go), then the /ask family generated |
| 83 | 84 | // from the configured agents (ask.go). |
| 84 | 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 | 89 | // 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 offers | 49 | // 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 generated | 83 | // 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 { | ||
| 178 | 178 | // editor can reload one over ACP (session/load). |
| 179 | 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 | 186 | // Trace: write every request sent to the model and every response, as |
| 182 | 187 | // they travel on the wire, to files (internal/engine/trace.go). Off |
| 183 | 188 | // unless `trace.enabled: true` or AGENT_TRACE=true. |
| @@ -608,6 +613,11 @@ which ones you left running.`, | ||
| 608 | 613 | Enabled: false, |
| 609 | 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 | 621 | Trace: TraceConfig{ |
| 612 | 622 | Enabled: false, |
| 613 | 623 | Dir: ".mm/traces", |
| @@ -864,5 +874,8 @@ func (c Config) validate() error { | ||
| 864 | 874 | if err := c.validateTools(); err != nil { |
| 865 | 875 | return err |
| 866 | 876 | } |
| 877 | + if err := c.DocSearch.validate(); err != nil { | |
| 878 | + return err | |
| 879 | + } | |
| 867 | 880 | return c.LSP.validate() |
| 868 | 881 | } |
| @@ -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, as | 186 | // Trace: write every request sent to the model and every response, as |
| 182 | // they travel on the wire, to files (internal/engine/trace.go). Off | 187 | // 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 err | 875 | 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{ | ||
| 44 | 44 | "read_file", |
| 45 | 45 | "read_skill", |
| 46 | 46 | "save_answer", |
| 47 | + "search_docs", | |
| 47 | 48 | "search_memory", |
| 48 | 49 | "unload_model", |
| 49 | 50 | "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 ( | ||
| 6 | 6 | "sort" |
| 7 | 7 | "strings" |
| 8 | 8 | "time" |
| 9 | - "unicode" | |
| 10 | 9 | "unicode/utf8" |
| 11 | 10 | |
| 12 | 11 | "mm/internal/compact" |
| 12 | + "mm/internal/textfold" | |
| 13 | 13 | |
| 14 | 14 | "github.com/firebase/genkit/go/ai" |
| 15 | - "golang.org/x/text/unicode/norm" | |
| 16 | 15 | ) |
| 17 | 16 | |
| 18 | 17 | // Searching the stored sessions (ticket 48): the user's /memory and the |
| @@ -161,7 +160,7 @@ const minWordRunes = 3 | ||
| 161 | 160 | func searchWords(query string) []string { |
| 162 | 161 | var words, short []string |
| 163 | 162 | seen := map[string]bool{} |
| 164 | - for _, w := range strings.Fields(fold(query)) { | |
| 163 | + for _, w := range strings.Fields(textfold.Fold(query)) { | |
| 165 | 164 | if seen[w] { |
| 166 | 165 | continue |
| 167 | 166 | } |
| @@ -291,7 +290,7 @@ func searchable(m *ai.Message) (role, text string, ok bool) { | ||
| 291 | 290 | // matchWords counts the words text holds and returns, in runes, where the |
| 292 | 291 | // earliest one found starts and where the latest one found ends. |
| 293 | 292 | func matchWords(text string, words []string) (score, first, last int) { |
| 294 | - folded := fold(text) | |
| 293 | + folded := textfold.Fold(text) | |
| 295 | 294 | first = -1 |
| 296 | 295 | for _, w := range words { |
| 297 | 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 | 311 | // 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 | |
| 314 | 313 | // per rune, so a position in the folded text is the same position in the |
| 315 | 314 | // original. |
| 316 | 315 | func snippet(text string, first, last int) string { |
| @@ -326,29 +325,3 @@ func snippet(text string, first, last int) string { | ||
| 326 | 325 | } |
| 327 | 326 | 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 | -} | |
| @@ -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 the | 17 | // 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 []string | 161 | 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 | continue | 165 | 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 the | 290 | // 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 = -1 | 294 | 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 (see | 311 | // 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 |
| 314 | // per rune, so a position in the folded text is the same position in the | 313 | // 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 out | 326 | 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 { | ||
| 14 | 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 | 17 | // Hits are ranked by the number of distinct words they hold, then by the |
| 35 | 18 | // session's date; a message of the conversation in progress is never a hit, |
| 36 | 19 | // 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 the | 17 | // 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 says | 19 | // 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 ( | ||
| 10 | 10 | |
| 11 | 11 | "mm/internal/agentsmd" |
| 12 | 12 | "mm/internal/command" |
| 13 | + "mm/internal/docsearch" | |
| 13 | 14 | "mm/internal/session" |
| 14 | 15 | "mm/internal/store" |
| 15 | 16 | ) |
| @@ -106,6 +107,8 @@ func (r *runner) dispatch(p Prompt) { | ||
| 106 | 107 | r.unload(arg) |
| 107 | 108 | case command.Memory: |
| 108 | 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 | |
| @@ -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) { | ||
| 636 | 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 | +} | |
| @@ -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() { | ||
| 213 | 213 | command.SetAgents(config.AgentNames()) |
| 214 | 214 | command.SetUnload(config.Cfg.UnloadModels) |
| 215 | 215 | command.SetMemory(config.Cfg.Sessions.MemoryCommand()) |
| 216 | + command.SetDocs(config.Cfg.DocSearch.DocsCommand()) | |
| 216 | 217 | asker := ask.NewPool() |
| 217 | 218 | defer asker.Close() |
| 218 | 219 | |
| @@ -356,6 +357,7 @@ type toolSet struct { | ||
| 356 | 357 | // /ask; `ask_user` behind `askUserTool` (on by default), never with a |
| 357 | 358 | // structuredOutput; `save_answer` behind `saveAnswerTool` (off by default); |
| 358 | 359 | // `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on); |
| 360 | +// `search_docs` behind `docSearch.autoSearchDocs` (off by default); | |
| 359 | 361 | // and the agent file's declared `tools:`. |
| 360 | 362 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { |
| 361 | 363 | set := toolSet{close: func() {}} |
| @@ -406,6 +408,10 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent | ||
| 406 | 408 | set.tools = append(set.tools, tools.FetchContent(e.G, d, fetcher)) |
| 407 | 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 | 415 | if config.Cfg.Sessions.MemoryTool() { |
| 410 | 416 | set.tools = append(set.tools, tools.SearchMemory(e.G, d)) |
| 411 | 417 | 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 a | 357 | // /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") |