🛟 Updated.
05db870 parent: 39ab9bf modified
.tickets/issues/0048-add-a-builtin-tool-to-search-into-the-mm-session.yaml +207 -4 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 48 |
| 3 | 3 | title: Add a builtin tool to search into the mm sessions |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-27T03:45:48.121Z |
| 9 | -updatedAt: 2026-10-20T05:44:21.627Z | |
| 9 | +updatedAt: 2026-10-20T18:00:00.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.8.0 |
| 12 | 12 | epic: Agent Memory |
| @@ -17,5 +17,208 @@ body: | | ||
| 17 | 17 | enabled: true |
| 18 | 18 | ``` |
| 19 | 19 | use a /memory command to search information into the sessions files |
| 20 | -tasks: [] | |
| 21 | -comments: [] | |
| 20 | +tasks: | |
| 21 | + - id: 1 | |
| 22 | + title: "Agree on the command, the config keys and the tool name" | |
| 23 | + state: closed | |
| 24 | + priority: none | |
| 25 | + author: | |
| 26 | + name: k33g | |
| 27 | + email: ph.charriere@gmail.com | |
| 28 | + createdAt: 2026-10-20T12:00:00.000Z | |
| 29 | + updatedAt: 2026-10-20T12:00:00.000Z | |
| 30 | + - id: 2 | |
| 31 | + title: "store: Search(query, limit) — words, case and accents folded, ranked by distinct words then recency, snippets; skips mm notes and the errors array; current session excluded — with tests" | |
| 32 | + state: closed | |
| 33 | + priority: none | |
| 34 | + author: | |
| 35 | + name: k33g | |
| 36 | + email: ph.charriere@gmail.com | |
| 37 | + createdAt: 2026-10-20T12:00:00.000Z | |
| 38 | + updatedAt: 2026-10-20T12:00:00.000Z | |
| 39 | + - id: 3 | |
| 40 | + title: "config: sessions.searchMemory and sessions.autoSearchMemory (both default false), banner — with tests" | |
| 41 | + state: closed | |
| 42 | + priority: none | |
| 43 | + author: | |
| 44 | + name: k33g | |
| 45 | + email: ph.charriere@gmail.com | |
| 46 | + createdAt: 2026-10-20T12:00:00.000Z | |
| 47 | + updatedAt: 2026-10-20T12:00:00.000Z | |
| 48 | + - id: 4 | |
| 49 | + title: "/memory <words> in the command table (Terminal | Window | Editor), offered only when sessions.enabled and sessions.searchMemory — with tests" | |
| 50 | + state: closed | |
| 51 | + priority: none | |
| 52 | + author: | |
| 53 | + name: k33g | |
| 54 | + email: ph.charriere@gmail.com | |
| 55 | + createdAt: 2026-10-20T12:00:00.000Z | |
| 56 | + updatedAt: 2026-10-20T12:00:00.000Z | |
| 57 | + - id: 5 | |
| 58 | + title: "built-in tool search_memory, declared only when sessions.enabled and sessions.autoSearchMemory; read-only, never asks — with tests" | |
| 59 | + state: closed | |
| 60 | + priority: none | |
| 61 | + author: | |
| 62 | + name: k33g | |
| 63 | + email: ph.charriere@gmail.com | |
| 64 | + createdAt: 2026-10-20T12:00:00.000Z | |
| 65 | + updatedAt: 2026-10-20T12:00:00.000Z | |
| 66 | + - id: 6 | |
| 67 | + title: "Measure the tool on a small model (llmman): called when needed, not otherwise" | |
| 68 | + state: closed | |
| 69 | + priority: none | |
| 70 | + author: | |
| 71 | + name: k33g | |
| 72 | + email: ph.charriere@gmail.com | |
| 73 | + createdAt: 2026-10-20T12:00:00.000Z | |
| 74 | + updatedAt: 2026-10-20T12:00:00.000Z | |
| 75 | + - id: 7 | |
| 76 | + title: "Docs EN+FR (reference configuration, slash-commands, tools; how-to), quality gate, .memory" | |
| 77 | + state: closed | |
| 78 | + priority: none | |
| 79 | + author: | |
| 80 | + name: k33g | |
| 81 | + email: ph.charriere@gmail.com | |
| 82 | + createdAt: 2026-10-20T12:00:00.000Z | |
| 83 | + updatedAt: 2026-10-20T12:00:00.000Z | |
| 84 | +comments: | |
| 85 | + - id: 1 | |
| 86 | + author: | |
| 87 | + name: k33g | |
| 88 | + email: ph.charriere@gmail.com | |
| 89 | + createdAt: 2026-10-20T12:00:00.000Z | |
| 90 | + body: | | |
| 91 | + Decided by the user (2026-10-20): | |
| 92 | + - /memory <words>: the user's search, available only when | |
| 93 | + sessions.enabled AND sessions.searchMemory: true (default false); | |
| 94 | + - built-in tool search_memory: usable by the model only when | |
| 95 | + sessions.autoSearchMemory: true (default false) — and sessions on; | |
| 96 | + - tool name: search_memory. | |
| 97 | + Kept from the proposal (not discussed, to confirm while reviewing): | |
| 98 | + the two keys are independent (autoSearchMemory without searchMemory | |
| 99 | + gives the tool and no command); lexical search, no embeddings (ticket | |
| 100 | + 74's ground); /compact summaries searched, mm notes and errors not; | |
| 101 | + current session excluded; v1 searches only — no "read one exchange in | |
| 102 | + full" mode; read-only tool, never asks for permission. | |
| 103 | + - id: 2 | |
| 104 | + author: | |
| 105 | + name: k33g | |
| 106 | + email: ph.charriere@gmail.com | |
| 107 | + createdAt: 2026-10-20T13:00:00.000Z | |
| 108 | + body: | | |
| 109 | + Tasks 2-5 done on feature/memory-search (go test ./... green, gofmt/vet clean): | |
| 110 | + - store/search.go: Store.Search(query, known, limit) / Store.Memory / | |
| 111 | + Keeper.Memory / FormatHits. Words folded (lower case, accents dropped | |
| 112 | + rune for rune with x/text norm, so a snippet cuts the original at the | |
| 113 | + folded position), OR of the words, ranked by distinct words then | |
| 114 | + session date then turn; questions (hook context cut), answers, | |
| 115 | + /compact summaries; not tool calls/outputs, mm notes, errors. | |
| 116 | + Default 10 hits, cap 50; snippet 80 runes before / 160 after. | |
| 117 | + - Change from the proposal: the conversation in progress is left out BY | |
| 118 | + CONTENT (a message with the same role and text as one in the current | |
| 119 | + history is not a hit), not by session id — no front end tells a tool | |
| 120 | + which file it saves to (the REPL has no sink at all), and a resumed or | |
| 121 | + compacted session's file holds exactly the messages in memory. | |
| 122 | + - config: sessions.searchMemory, sessions.autoSearchMemory (default | |
| 123 | + false), MemoryCommand()/MemoryTool()/MemoryWarning() — a key set with | |
| 124 | + sessions off is a banner warning; search_memory reserved. | |
| 125 | + - command/memory.go: SetMemory; /memory <words> in the REPL, the window | |
| 126 | + (notice) and ACP (agent text, advertised with an input hint). | |
| 127 | + - tools/searchmemory.go: search_memory {query, limit?}, never asks, ACP | |
| 128 | + kind "search" (new mapping to ToolKindSearch), sessions dir through | |
| 129 | + ui.Resolve, output capped by maxOutput. | |
| 130 | + - id: 3 | |
| 131 | + author: | |
| 132 | + name: k33g | |
| 133 | + email: ph.charriere@gmail.com | |
| 134 | + createdAt: 2026-10-20T14:00:00.000Z | |
| 135 | + body: | | |
| 136 | + Measured (task 6): mm -p, llmman (host), Qwen3.5-4B Q4_K_M, | |
| 137 | + contextWindow 16384, bash + edit tools + search_memory, a fresh copy of a | |
| 138 | + throwaway project per run with two stored sessions (API port 8417; | |
| 139 | + "Postgres or SQLite for the storage?" → SQLite in data/app.db). | |
| 140 | + - Words present in a session: "What port did we decide for the API | |
| 141 | + server last time?" and "Write config.go with a const Port set to the | |
| 142 | + API port we agreed on before." → search_memory called and right answer | |
| 143 | + 9/9 (warm-up + 3 + 3 + 1 + 1 after the last change); config.go written | |
| 144 | + with `const Port = 8417` 4/4. 5-17 s. | |
| 145 | + - Not needed: "hello", "What is 17 times 3?", "List the files of this | |
| 146 | + directory.", "Write hello.go that prints Hello." → never called, 0/12. | |
| 147 | + - LIMIT: French question on an English session ("Rappelle-moi quelle | |
| 148 | + base de données on avait choisie…"): search "base de données" → no hit; | |
| 149 | + the model then read the JSON files itself and answered right (2/3, the | |
| 150 | + 3rd a server stall: no first token after 3m). Added a no-hit line in | |
| 151 | + the tool's output (try synonyms / English): the model then retried with | |
| 152 | + "database" 3/3 — still no hit, the session says "storage"/"SQLite" — | |
| 153 | + and again read the files (3/3 right). Lexical search cannot bridge a | |
| 154 | + vocabulary gap; an embedding search (embeddinggemma-300m is on the | |
| 155 | + llmman here) would — ticket 74's ground, not done. | |
| 156 | + - /memory checked live in the REPL (piped stdin): listed in /help, 3 | |
| 157 | + hits ranked as expected; the sessions-off warning shows. | |
| 158 | + Docs EN+FR: reference configuration (two keys + warning, reserved name), | |
| 159 | + slash-commands (terminal + ACP rows), tools (search_memory), how-to | |
| 160 | + search-earlier-sessions (new), keep-sessions (variant), explanation | |
| 161 | + persistent-sessions (why words, the measurement, rejected alternative), | |
| 162 | + both indexes; packages.drawio tools → store. | |
| 163 | + Task 7 stays open: qlty quality gate NOT run (qlty absent here). | |
| 164 | + - id: 4 | |
| 165 | + author: | |
| 166 | + name: k33g | |
| 167 | + email: ph.charriere@gmail.com | |
| 168 | + createdAt: 2026-10-20T16:00:00.000Z | |
| 169 | + body: | | |
| 170 | + demos/memory-search/ (user's request): Bob, Qwen3.5 4B on llmman, | |
| 171 | + searchMemory + autoSearchMemory, seed/ = four earlier sessions of a | |
| 172 | + "bookshelf" Go app (port; storage + migrations; logs in FR; deployment | |
| 173 | + as a /compact summary), reset.sh copies them to .mm/sessions (git- | |
| 174 | + ignored). READMEs EN+FR with measured results, root README row + line. | |
| 175 | + Building it found four engine defects, all fixed with tests: | |
| 176 | + 1. a question hit without its answer ("format logs" → only "Quel format | |
| 177 | + pour les logs ?"; the model said nothing was decided) → a matching | |
| 178 | + question now carries its turn's FINAL answer (Hit.Answer, "→" line), | |
| 179 | + the answer is no separate hit, the score counts both; | |
| 180 | + 2. one/two-letter words matched inside words ("de" → Deployment, | |
| 181 | + modernc, Module: the whole history came back, right answer by luck) | |
| 182 | + → dropped beside longer words (minWordRunes 3); | |
| 183 | + 3. snippet cut 160 runes after the FIRST word (summary lost its backups | |
| 184 | + line, wrong answer) → spans first..last word, max 600 runes; | |
| 185 | + 4. the summary's wrapper line and the model's fixed acknowledgement | |
| 186 | + ("Understood — I will re-check files…") were hits → compact. | |
| 187 | + InSummaryPair / SummaryNotes; only the notes are searched. | |
| 188 | + Final measurement (temperature 0, 3 runs each, identical): port 3/3, | |
| 189 | + config.go written with 8417 + data/bookshelf.db 3/3, deploy + backups | |
| 190 | + from the summary 3/3, logs (FR) 3/3, migrations (FR question, EN | |
| 191 | + session) 3/3, "Quelle base de données ?" NOT found 3/3 — honest "I | |
| 192 | + don't know" (the session says storage/SQLite), greeting and a general | |
| 193 | + Go question: no search 6/6. Prompt lessons kept in the README: "do not | |
| 194 | + only show the code" (else config.go printed, 3/3), "how do we | |
| 195 | + handle…" examples (else no search, 3/3), examples in English only | |
| 196 | + (French examples → English question answered in French, 3/3). | |
| 197 | + Not this ticket: the line-mode REPL reads only the FIRST line of piped | |
| 198 | + stdin (a new bufio.Reader per prompt, internal/agent/agent.go:78) — | |
| 199 | + pre-existing, seen with /sessions + /help too. | |
| 200 | + - id: 5 | |
| 201 | + author: | |
| 202 | + name: k33g | |
| 203 | + email: ph.charriere@gmail.com | |
| 204 | + createdAt: 2026-10-20T17:00:00.000Z | |
| 205 | + body: | | |
| 206 | + The piped-stdin defect noted in comment 4 is fixed: ticket 103. | |
| 207 | + - id: 6 | |
| 208 | + author: | |
| 209 | + name: k33g | |
| 210 | + email: ph.charriere@gmail.com | |
| 211 | + createdAt: 2026-10-20T18:00:00.000Z | |
| 212 | + body: | | |
| 213 | + Documentation complete (EN + FR): reference configuration (two keys, | |
| 214 | + warning, reserved name), slash-commands (terminal + ACP), tools | |
| 215 | + (search_memory, with a pointer to /memory), acp (tool kind `search`), | |
| 216 | + how-to search-earlier-sessions, keep-sessions (variant), explanation | |
| 217 | + persistent-sessions (why words, measurements, the demo's four fixes, | |
| 218 | + rejected alternatives), both indexes, root README (feature line + demo | |
| 219 | + row), demos/memory-search READMEs; packages.drawio (store and tools | |
| 220 | + edges). Links of the new pages checked. | |
| 221 | + Closed at the user's request. The qlty quality gate was NOT run (qlty | |
| 222 | + is not installed in this sandbox); gofmt and go vet clean, go test | |
| 223 | + ./... green. | |
| 224 | + | |
| @@ -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: 48 | 2 | id: 48 |
| 3 | title: Add a builtin tool to search into the mm sessions | 3 | title: Add a builtin tool to search into the mm sessions |
| 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-09-27T03:45:48.121Z | 8 | createdAt: 2026-09-27T03:45:48.121Z |
| 9 | -updatedAt: 2026-10-20T05:44:21.627Z | 9 | +updatedAt: 2026-10-20T18:00:00.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.8.0 | 11 | milestone: v0.8.0 |
| 12 | epic: Agent Memory | 12 | epic: Agent Memory |
| @@ -17,5 +17,208 @@ body: | | |||
| 17 | enabled: true | 17 | enabled: true |
| 18 | ``` | 18 | ``` |
| 19 | use a /memory command to search information into the sessions files | 19 | use a /memory command to search information into the sessions files |
| 20 | -tasks: [] | 20 | +tasks: |
| 21 | -comments: [] | 21 | + - id: 1 |
| 22 | + title: "Agree on the command, the config keys and the tool name" | ||
| 23 | + state: closed | ||
| 24 | + priority: none | ||
| 25 | + author: | ||
| 26 | + name: k33g | ||
| 27 | + email: ph.charriere@gmail.com | ||
| 28 | + createdAt: 2026-10-20T12:00:00.000Z | ||
| 29 | + updatedAt: 2026-10-20T12:00:00.000Z | ||
| 30 | + - id: 2 | ||
| 31 | + title: "store: Search(query, limit) — words, case and accents folded, ranked by distinct words then recency, snippets; skips mm notes and the errors array; current session excluded — with tests" | ||
| 32 | + state: closed | ||
| 33 | + priority: none | ||
| 34 | + author: | ||
| 35 | + name: k33g | ||
| 36 | + email: ph.charriere@gmail.com | ||
| 37 | + createdAt: 2026-10-20T12:00:00.000Z | ||
| 38 | + updatedAt: 2026-10-20T12:00:00.000Z | ||
| 39 | + - id: 3 | ||
| 40 | + title: "config: sessions.searchMemory and sessions.autoSearchMemory (both default false), banner — with tests" | ||
| 41 | + state: closed | ||
| 42 | + priority: none | ||
| 43 | + author: | ||
| 44 | + name: k33g | ||
| 45 | + email: ph.charriere@gmail.com | ||
| 46 | + createdAt: 2026-10-20T12:00:00.000Z | ||
| 47 | + updatedAt: 2026-10-20T12:00:00.000Z | ||
| 48 | + - id: 4 | ||
| 49 | + title: "/memory <words> in the command table (Terminal | Window | Editor), offered only when sessions.enabled and sessions.searchMemory — with tests" | ||
| 50 | + state: closed | ||
| 51 | + priority: none | ||
| 52 | + author: | ||
| 53 | + name: k33g | ||
| 54 | + email: ph.charriere@gmail.com | ||
| 55 | + createdAt: 2026-10-20T12:00:00.000Z | ||
| 56 | + updatedAt: 2026-10-20T12:00:00.000Z | ||
| 57 | + - id: 5 | ||
| 58 | + title: "built-in tool search_memory, declared only when sessions.enabled and sessions.autoSearchMemory; read-only, never asks — with tests" | ||
| 59 | + state: closed | ||
| 60 | + priority: none | ||
| 61 | + author: | ||
| 62 | + name: k33g | ||
| 63 | + email: ph.charriere@gmail.com | ||
| 64 | + createdAt: 2026-10-20T12:00:00.000Z | ||
| 65 | + updatedAt: 2026-10-20T12:00:00.000Z | ||
| 66 | + - id: 6 | ||
| 67 | + title: "Measure the tool on a small model (llmman): called when needed, not otherwise" | ||
| 68 | + state: closed | ||
| 69 | + priority: none | ||
| 70 | + author: | ||
| 71 | + name: k33g | ||
| 72 | + email: ph.charriere@gmail.com | ||
| 73 | + createdAt: 2026-10-20T12:00:00.000Z | ||
| 74 | + updatedAt: 2026-10-20T12:00:00.000Z | ||
| 75 | + - id: 7 | ||
| 76 | + title: "Docs EN+FR (reference configuration, slash-commands, tools; how-to), quality gate, .memory" | ||
| 77 | + state: closed | ||
| 78 | + priority: none | ||
| 79 | + author: | ||
| 80 | + name: k33g | ||
| 81 | + email: ph.charriere@gmail.com | ||
| 82 | + createdAt: 2026-10-20T12:00:00.000Z | ||
| 83 | + updatedAt: 2026-10-20T12:00:00.000Z | ||
| 84 | +comments: | ||
| 85 | + - id: 1 | ||
| 86 | + author: | ||
| 87 | + name: k33g | ||
| 88 | + email: ph.charriere@gmail.com | ||
| 89 | + createdAt: 2026-10-20T12:00:00.000Z | ||
| 90 | + body: | | ||
| 91 | + Decided by the user (2026-10-20): | ||
| 92 | + - /memory <words>: the user's search, available only when | ||
| 93 | + sessions.enabled AND sessions.searchMemory: true (default false); | ||
| 94 | + - built-in tool search_memory: usable by the model only when | ||
| 95 | + sessions.autoSearchMemory: true (default false) — and sessions on; | ||
| 96 | + - tool name: search_memory. | ||
| 97 | + Kept from the proposal (not discussed, to confirm while reviewing): | ||
| 98 | + the two keys are independent (autoSearchMemory without searchMemory | ||
| 99 | + gives the tool and no command); lexical search, no embeddings (ticket | ||
| 100 | + 74's ground); /compact summaries searched, mm notes and errors not; | ||
| 101 | + current session excluded; v1 searches only — no "read one exchange in | ||
| 102 | + full" mode; read-only tool, never asks for permission. | ||
| 103 | + - id: 2 | ||
| 104 | + author: | ||
| 105 | + name: k33g | ||
| 106 | + email: ph.charriere@gmail.com | ||
| 107 | + createdAt: 2026-10-20T13:00:00.000Z | ||
| 108 | + body: | | ||
| 109 | + Tasks 2-5 done on feature/memory-search (go test ./... green, gofmt/vet clean): | ||
| 110 | + - store/search.go: Store.Search(query, known, limit) / Store.Memory / | ||
| 111 | + Keeper.Memory / FormatHits. Words folded (lower case, accents dropped | ||
| 112 | + rune for rune with x/text norm, so a snippet cuts the original at the | ||
| 113 | + folded position), OR of the words, ranked by distinct words then | ||
| 114 | + session date then turn; questions (hook context cut), answers, | ||
| 115 | + /compact summaries; not tool calls/outputs, mm notes, errors. | ||
| 116 | + Default 10 hits, cap 50; snippet 80 runes before / 160 after. | ||
| 117 | + - Change from the proposal: the conversation in progress is left out BY | ||
| 118 | + CONTENT (a message with the same role and text as one in the current | ||
| 119 | + history is not a hit), not by session id — no front end tells a tool | ||
| 120 | + which file it saves to (the REPL has no sink at all), and a resumed or | ||
| 121 | + compacted session's file holds exactly the messages in memory. | ||
| 122 | + - config: sessions.searchMemory, sessions.autoSearchMemory (default | ||
| 123 | + false), MemoryCommand()/MemoryTool()/MemoryWarning() — a key set with | ||
| 124 | + sessions off is a banner warning; search_memory reserved. | ||
| 125 | + - command/memory.go: SetMemory; /memory <words> in the REPL, the window | ||
| 126 | + (notice) and ACP (agent text, advertised with an input hint). | ||
| 127 | + - tools/searchmemory.go: search_memory {query, limit?}, never asks, ACP | ||
| 128 | + kind "search" (new mapping to ToolKindSearch), sessions dir through | ||
| 129 | + ui.Resolve, output capped by maxOutput. | ||
| 130 | + - id: 3 | ||
| 131 | + author: | ||
| 132 | + name: k33g | ||
| 133 | + email: ph.charriere@gmail.com | ||
| 134 | + createdAt: 2026-10-20T14:00:00.000Z | ||
| 135 | + body: | | ||
| 136 | + Measured (task 6): mm -p, llmman (host), Qwen3.5-4B Q4_K_M, | ||
| 137 | + contextWindow 16384, bash + edit tools + search_memory, a fresh copy of a | ||
| 138 | + throwaway project per run with two stored sessions (API port 8417; | ||
| 139 | + "Postgres or SQLite for the storage?" → SQLite in data/app.db). | ||
| 140 | + - Words present in a session: "What port did we decide for the API | ||
| 141 | + server last time?" and "Write config.go with a const Port set to the | ||
| 142 | + API port we agreed on before." → search_memory called and right answer | ||
| 143 | + 9/9 (warm-up + 3 + 3 + 1 + 1 after the last change); config.go written | ||
| 144 | + with `const Port = 8417` 4/4. 5-17 s. | ||
| 145 | + - Not needed: "hello", "What is 17 times 3?", "List the files of this | ||
| 146 | + directory.", "Write hello.go that prints Hello." → never called, 0/12. | ||
| 147 | + - LIMIT: French question on an English session ("Rappelle-moi quelle | ||
| 148 | + base de données on avait choisie…"): search "base de données" → no hit; | ||
| 149 | + the model then read the JSON files itself and answered right (2/3, the | ||
| 150 | + 3rd a server stall: no first token after 3m). Added a no-hit line in | ||
| 151 | + the tool's output (try synonyms / English): the model then retried with | ||
| 152 | + "database" 3/3 — still no hit, the session says "storage"/"SQLite" — | ||
| 153 | + and again read the files (3/3 right). Lexical search cannot bridge a | ||
| 154 | + vocabulary gap; an embedding search (embeddinggemma-300m is on the | ||
| 155 | + llmman here) would — ticket 74's ground, not done. | ||
| 156 | + - /memory checked live in the REPL (piped stdin): listed in /help, 3 | ||
| 157 | + hits ranked as expected; the sessions-off warning shows. | ||
| 158 | + Docs EN+FR: reference configuration (two keys + warning, reserved name), | ||
| 159 | + slash-commands (terminal + ACP rows), tools (search_memory), how-to | ||
| 160 | + search-earlier-sessions (new), keep-sessions (variant), explanation | ||
| 161 | + persistent-sessions (why words, the measurement, rejected alternative), | ||
| 162 | + both indexes; packages.drawio tools → store. | ||
| 163 | + Task 7 stays open: qlty quality gate NOT run (qlty absent here). | ||
| 164 | + - id: 4 | ||
| 165 | + author: | ||
| 166 | + name: k33g | ||
| 167 | + email: ph.charriere@gmail.com | ||
| 168 | + createdAt: 2026-10-20T16:00:00.000Z | ||
| 169 | + body: | | ||
| 170 | + demos/memory-search/ (user's request): Bob, Qwen3.5 4B on llmman, | ||
| 171 | + searchMemory + autoSearchMemory, seed/ = four earlier sessions of a | ||
| 172 | + "bookshelf" Go app (port; storage + migrations; logs in FR; deployment | ||
| 173 | + as a /compact summary), reset.sh copies them to .mm/sessions (git- | ||
| 174 | + ignored). READMEs EN+FR with measured results, root README row + line. | ||
| 175 | + Building it found four engine defects, all fixed with tests: | ||
| 176 | + 1. a question hit without its answer ("format logs" → only "Quel format | ||
| 177 | + pour les logs ?"; the model said nothing was decided) → a matching | ||
| 178 | + question now carries its turn's FINAL answer (Hit.Answer, "→" line), | ||
| 179 | + the answer is no separate hit, the score counts both; | ||
| 180 | + 2. one/two-letter words matched inside words ("de" → Deployment, | ||
| 181 | + modernc, Module: the whole history came back, right answer by luck) | ||
| 182 | + → dropped beside longer words (minWordRunes 3); | ||
| 183 | + 3. snippet cut 160 runes after the FIRST word (summary lost its backups | ||
| 184 | + line, wrong answer) → spans first..last word, max 600 runes; | ||
| 185 | + 4. the summary's wrapper line and the model's fixed acknowledgement | ||
| 186 | + ("Understood — I will re-check files…") were hits → compact. | ||
| 187 | + InSummaryPair / SummaryNotes; only the notes are searched. | ||
| 188 | + Final measurement (temperature 0, 3 runs each, identical): port 3/3, | ||
| 189 | + config.go written with 8417 + data/bookshelf.db 3/3, deploy + backups | ||
| 190 | + from the summary 3/3, logs (FR) 3/3, migrations (FR question, EN | ||
| 191 | + session) 3/3, "Quelle base de données ?" NOT found 3/3 — honest "I | ||
| 192 | + don't know" (the session says storage/SQLite), greeting and a general | ||
| 193 | + Go question: no search 6/6. Prompt lessons kept in the README: "do not | ||
| 194 | + only show the code" (else config.go printed, 3/3), "how do we | ||
| 195 | + handle…" examples (else no search, 3/3), examples in English only | ||
| 196 | + (French examples → English question answered in French, 3/3). | ||
| 197 | + Not this ticket: the line-mode REPL reads only the FIRST line of piped | ||
| 198 | + stdin (a new bufio.Reader per prompt, internal/agent/agent.go:78) — | ||
| 199 | + pre-existing, seen with /sessions + /help too. | ||
| 200 | + - id: 5 | ||
| 201 | + author: | ||
| 202 | + name: k33g | ||
| 203 | + email: ph.charriere@gmail.com | ||
| 204 | + createdAt: 2026-10-20T17:00:00.000Z | ||
| 205 | + body: | | ||
| 206 | + The piped-stdin defect noted in comment 4 is fixed: ticket 103. | ||
| 207 | + - id: 6 | ||
| 208 | + author: | ||
| 209 | + name: k33g | ||
| 210 | + email: ph.charriere@gmail.com | ||
| 211 | + createdAt: 2026-10-20T18:00:00.000Z | ||
| 212 | + body: | | ||
| 213 | + Documentation complete (EN + FR): reference configuration (two keys, | ||
| 214 | + warning, reserved name), slash-commands (terminal + ACP), tools | ||
| 215 | + (search_memory, with a pointer to /memory), acp (tool kind `search`), | ||
| 216 | + how-to search-earlier-sessions, keep-sessions (variant), explanation | ||
| 217 | + persistent-sessions (why words, measurements, the demo's four fixes, | ||
| 218 | + rejected alternatives), both indexes, root README (feature line + demo | ||
| 219 | + row), demos/memory-search READMEs; packages.drawio (store and tools | ||
| 220 | + edges). Links of the new pages checked. | ||
| 221 | + Closed at the user's request. The qlty quality gate was NOT run (qlty | ||
| 222 | + is not installed in this sandbox); gofmt and go vet clean, go test | ||
| 223 | + ./... green. | ||
| 224 | + | ||
renamed
.tickets/issues/0093-save-an-answer-to-a-file-without-regenerating-it.yaml +7 -7 | similarity index 94% | ||
| rename from .tickets/issues/0093-save-an-answer-without-regenerating-it.yaml | ||
| rename to .tickets/issues/0093-save-an-answer-to-a-file-without-regenerating-it.yaml | ||
| @@ -1,15 +1,16 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 93 |
| 3 | 3 | title: Save an answer to a file without regenerating it (/save, save_answer) |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-17T16:00:00.000Z |
| 9 | -updatedAt: 2026-10-17T16:00:00.000Z | |
| 9 | +updatedAt: 2026-10-20T06:49:43.311Z | |
| 10 | 10 | labels: |
| 11 | 11 | - kind::feature |
| 12 | 12 | - builtin tool |
| 13 | +milestone: v0.7.0 | |
| 13 | 14 | epic: Builtin tools |
| 14 | 15 | body: | |
| 15 | 16 | ## Why |
| @@ -34,10 +35,9 @@ body: | | ||
| 34 | 35 | When the model writes a short sentence beside the call ("I'll save it."), that sentence is what gets saved. The tool description tells the model to call it alone to save a previous answer; to be measured with the mini-tutorial agent (`wip2/`). |
| 35 | 36 | ## Measured with the mini-tutorial agent (wip2/, 2026-10-17) |
| 36 | 37 | 8 cases × 2 runs, three configs (Mellum2.1 thinking 13/16, Mellum2.1 no thinking 11/16, Qwen3.5-9B 14/16). **"save it" after the answer: 6/6** — the file is the exact answer, in seconds. **"explain X and save it" in one request: 0/6**: Mellum2.1 thinking wrote the call as TEXT with wrong tags (`<tools>{…}</tools>`), Qwen3.5-9B never called the tool, Mellum2.1 without thinking called it FIRST ("no answer yet"), then once claimed it was saved, once saved its own 1,536-word deliberation + tutorial. Open: a way to save the answer being written (see wip2/README). |
| 37 | - | |
| 38 | 38 | tasks: |
| 39 | 39 | - id: 1 |
| 40 | - title: "internal/saved + /save command, with tests" | |
| 40 | + title: internal/saved + /save command, with tests | |
| 41 | 41 | state: closed |
| 42 | 42 | priority: high |
| 43 | 43 | author: |
| @@ -47,7 +47,7 @@ tasks: | ||
| 47 | 47 | updatedAt: 2026-10-17T16:00:00.000Z |
| 48 | 48 | closedAt: 2026-10-17T16:00:00.000Z |
| 49 | 49 | - id: 2 |
| 50 | - title: "save_answer tool + engine.Reply, with tests" | |
| 50 | + title: save_answer tool + engine.Reply, with tests | |
| 51 | 51 | state: closed |
| 52 | 52 | priority: high |
| 53 | 53 | author: |
| @@ -57,7 +57,7 @@ tasks: | ||
| 57 | 57 | updatedAt: 2026-10-17T16:00:00.000Z |
| 58 | 58 | closedAt: 2026-10-17T16:00:00.000Z |
| 59 | 59 | - id: 3 |
| 60 | - title: "Docs EN+FR, diagram" | |
| 60 | + title: Docs EN+FR, diagram | |
| 61 | 61 | state: closed |
| 62 | 62 | priority: medium |
| 63 | 63 | author: |
| @@ -67,7 +67,7 @@ tasks: | ||
| 67 | 67 | updatedAt: 2026-10-17T16:00:00.000Z |
| 68 | 68 | closedAt: 2026-10-17T16:00:00.000Z |
| 69 | 69 | - id: 4 |
| 70 | - title: "Measure save_answer live with the mini-tutorial agent (wip2/)" | |
| 70 | + title: Measure save_answer live with the mini-tutorial agent (wip2/) | |
| 71 | 71 | state: closed |
| 72 | 72 | priority: medium |
| 73 | 73 | author: |
| similarity index 94% | |||
| rename from .tickets/issues/0093-save-an-answer-without-regenerating-it.yaml | |||
| rename to .tickets/issues/0093-save-an-answer-to-a-file-without-regenerating-it.yaml | |||
| @@ -1,15 +1,16 @@ | |||
| 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: 93 | 2 | id: 93 |
| 3 | title: Save an answer to a file without regenerating it (/save, save_answer) | 3 | title: Save an answer to a file without regenerating it (/save, save_answer) |
| 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-17T16:00:00.000Z | 8 | createdAt: 2026-10-17T16:00:00.000Z |
| 9 | -updatedAt: 2026-10-17T16:00:00.000Z | 9 | +updatedAt: 2026-10-20T06:49:43.311Z |
| 10 | labels: | 10 | labels: |
| 11 | - kind::feature | 11 | - kind::feature |
| 12 | - builtin tool | 12 | - builtin tool |
| 13 | +milestone: v0.7.0 | ||
| 13 | epic: Builtin tools | 14 | epic: Builtin tools |
| 14 | body: | | 15 | body: | |
| 15 | ## Why | 16 | ## Why |
| @@ -34,10 +35,9 @@ body: | | |||
| 34 | When the model writes a short sentence beside the call ("I'll save it."), that sentence is what gets saved. The tool description tells the model to call it alone to save a previous answer; to be measured with the mini-tutorial agent (`wip2/`). | 35 | When the model writes a short sentence beside the call ("I'll save it."), that sentence is what gets saved. The tool description tells the model to call it alone to save a previous answer; to be measured with the mini-tutorial agent (`wip2/`). |
| 35 | ## Measured with the mini-tutorial agent (wip2/, 2026-10-17) | 36 | ## Measured with the mini-tutorial agent (wip2/, 2026-10-17) |
| 36 | 8 cases × 2 runs, three configs (Mellum2.1 thinking 13/16, Mellum2.1 no thinking 11/16, Qwen3.5-9B 14/16). **"save it" after the answer: 6/6** — the file is the exact answer, in seconds. **"explain X and save it" in one request: 0/6**: Mellum2.1 thinking wrote the call as TEXT with wrong tags (`<tools>{…}</tools>`), Qwen3.5-9B never called the tool, Mellum2.1 without thinking called it FIRST ("no answer yet"), then once claimed it was saved, once saved its own 1,536-word deliberation + tutorial. Open: a way to save the answer being written (see wip2/README). | 37 | 8 cases × 2 runs, three configs (Mellum2.1 thinking 13/16, Mellum2.1 no thinking 11/16, Qwen3.5-9B 14/16). **"save it" after the answer: 6/6** — the file is the exact answer, in seconds. **"explain X and save it" in one request: 0/6**: Mellum2.1 thinking wrote the call as TEXT with wrong tags (`<tools>{…}</tools>`), Qwen3.5-9B never called the tool, Mellum2.1 without thinking called it FIRST ("no answer yet"), then once claimed it was saved, once saved its own 1,536-word deliberation + tutorial. Open: a way to save the answer being written (see wip2/README). |
| 37 | - | ||
| 38 | tasks: | 38 | tasks: |
| 39 | - id: 1 | 39 | - id: 1 |
| 40 | - title: "internal/saved + /save command, with tests" | 40 | + title: internal/saved + /save command, with tests |
| 41 | state: closed | 41 | state: closed |
| 42 | priority: high | 42 | priority: high |
| 43 | author: | 43 | author: |
| @@ -47,7 +47,7 @@ tasks: | |||
| 47 | updatedAt: 2026-10-17T16:00:00.000Z | 47 | updatedAt: 2026-10-17T16:00:00.000Z |
| 48 | closedAt: 2026-10-17T16:00:00.000Z | 48 | closedAt: 2026-10-17T16:00:00.000Z |
| 49 | - id: 2 | 49 | - id: 2 |
| 50 | - title: "save_answer tool + engine.Reply, with tests" | 50 | + title: save_answer tool + engine.Reply, with tests |
| 51 | state: closed | 51 | state: closed |
| 52 | priority: high | 52 | priority: high |
| 53 | author: | 53 | author: |
| @@ -57,7 +57,7 @@ tasks: | |||
| 57 | updatedAt: 2026-10-17T16:00:00.000Z | 57 | updatedAt: 2026-10-17T16:00:00.000Z |
| 58 | closedAt: 2026-10-17T16:00:00.000Z | 58 | closedAt: 2026-10-17T16:00:00.000Z |
| 59 | - id: 3 | 59 | - id: 3 |
| 60 | - title: "Docs EN+FR, diagram" | 60 | + title: Docs EN+FR, diagram |
| 61 | state: closed | 61 | state: closed |
| 62 | priority: medium | 62 | priority: medium |
| 63 | author: | 63 | author: |
| @@ -67,7 +67,7 @@ tasks: | |||
| 67 | updatedAt: 2026-10-17T16:00:00.000Z | 67 | updatedAt: 2026-10-17T16:00:00.000Z |
| 68 | closedAt: 2026-10-17T16:00:00.000Z | 68 | closedAt: 2026-10-17T16:00:00.000Z |
| 69 | - id: 4 | 69 | - id: 4 |
| 70 | - title: "Measure save_answer live with the mini-tutorial agent (wip2/)" | 70 | + title: Measure save_answer live with the mini-tutorial agent (wip2/) |
| 71 | state: closed | 71 | state: closed |
| 72 | priority: medium | 72 | priority: medium |
| 73 | author: | 73 | author: |
added
.tickets/issues/0102-run-sub-agents-in-background.yaml +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 102 | |
| 3 | +title: Run sub agents in background | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-20T06:48:30.439Z | |
| 9 | +updatedAt: 2026-10-20T06:48:30.439Z | |
| 10 | +labels: [] | |
| 11 | +body: "" | |
| 12 | +tasks: [] | |
| 13 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 102 | ||
| 3 | +title: Run sub agents in background | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-20T06:48:30.439Z | ||
| 9 | +updatedAt: 2026-10-20T06:48:30.439Z | ||
| 10 | +labels: [] | ||
| 11 | +body: "" | ||
| 12 | +tasks: [] | ||
| 13 | +comments: [] | ||
added
.tickets/issues/0103-line-mode-only-the-first-of-several-piped-lines.yaml +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 103 | |
| 3 | +title: Line mode reads only the first of several piped lines | |
| 4 | +state: closed | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-20T17:00:00.000Z | |
| 9 | +updatedAt: 2026-10-20T17:00:00.000Z | |
| 10 | +labels: | |
| 11 | + - kind::bug | |
| 12 | +related: | |
| 13 | + - 48 | |
| 14 | +body: | | |
| 15 | + Found while measuring demos/memory-search (ticket 48). In the line mode | |
| 16 | + (the REPL, no -tui, no -acp), several lines arriving at once are lost but | |
| 17 | + the first: | |
| 18 | + | |
| 19 | + printf '/memory logs\n/memory port\n/quit\n' | mm agent.yaml | |
| 20 | + mm agent.yaml < commands.txt | |
| 21 | + | |
| 22 | + run /memory logs and nothing else. Typing at the keyboard is not affected: | |
| 23 | + the terminal hands over one line per Enter. The window (-tui), ACP and | |
| 24 | + `mm -p` do not use this code. | |
| 25 | + | |
| 26 | + Cause: internal/agent/agent.go made a NEW bufio.Reader for every line. A | |
| 27 | + bufio.Reader reads ahead (up to 4 KB); the first one took all the piped | |
| 28 | + lines, returned the first, and was dropped with the rest in its buffer. | |
| 29 | +tasks: [] | |
| 30 | +comments: | |
| 31 | + - id: 1 | |
| 32 | + author: | |
| 33 | + name: k33g | |
| 34 | + email: ph.charriere@gmail.com | |
| 35 | + createdAt: 2026-10-20T17:00:00.000Z | |
| 36 | + body: | | |
| 37 | + Fixed on feature/memory-search (user's request: "corrige directement"). | |
| 38 | + internal/agent/agent.go: the reader goroutine is now readInput(ctx, r, | |
| 39 | + out) — ONE bufio.Reader for the whole input; also sends a last line | |
| 40 | + that has no newline (it used to be dropped at EOF), then closes the | |
| 41 | + channel as before; other read errors retry as before. | |
| 42 | + Tests (internal/agent/input_test.go): three piped lines all come | |
| 43 | + through; one byte per read (iotest.OneByteReader) + last line without | |
| 44 | + newline; empty input closes; a cancelled context stops the reader. | |
| 45 | + Live: `printf '/memory logs\n/memory port\n/sessions' | mm agent.yaml` | |
| 46 | + runs all three and exits at EOF; through a pty, two typed lines both | |
| 47 | + run and Ctrl-C at the prompt still prints Goodbye and quits. | |
| 48 | + go test ./... green, gofmt/vet clean. | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 103 | ||
| 3 | +title: Line mode reads only the first of several piped lines | ||
| 4 | +state: closed | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-20T17:00:00.000Z | ||
| 9 | +updatedAt: 2026-10-20T17:00:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - kind::bug | ||
| 12 | +related: | ||
| 13 | + - 48 | ||
| 14 | +body: | | ||
| 15 | + Found while measuring demos/memory-search (ticket 48). In the line mode | ||
| 16 | + (the REPL, no -tui, no -acp), several lines arriving at once are lost but | ||
| 17 | + the first: | ||
| 18 | + | ||
| 19 | + printf '/memory logs\n/memory port\n/quit\n' | mm agent.yaml | ||
| 20 | + mm agent.yaml < commands.txt | ||
| 21 | + | ||
| 22 | + run /memory logs and nothing else. Typing at the keyboard is not affected: | ||
| 23 | + the terminal hands over one line per Enter. The window (-tui), ACP and | ||
| 24 | + `mm -p` do not use this code. | ||
| 25 | + | ||
| 26 | + Cause: internal/agent/agent.go made a NEW bufio.Reader for every line. A | ||
| 27 | + bufio.Reader reads ahead (up to 4 KB); the first one took all the piped | ||
| 28 | + lines, returned the first, and was dropped with the rest in its buffer. | ||
| 29 | +tasks: [] | ||
| 30 | +comments: | ||
| 31 | + - id: 1 | ||
| 32 | + author: | ||
| 33 | + name: k33g | ||
| 34 | + email: ph.charriere@gmail.com | ||
| 35 | + createdAt: 2026-10-20T17:00:00.000Z | ||
| 36 | + body: | | ||
| 37 | + Fixed on feature/memory-search (user's request: "corrige directement"). | ||
| 38 | + internal/agent/agent.go: the reader goroutine is now readInput(ctx, r, | ||
| 39 | + out) — ONE bufio.Reader for the whole input; also sends a last line | ||
| 40 | + that has no newline (it used to be dropped at EOF), then closes the | ||
| 41 | + channel as before; other read errors retry as before. | ||
| 42 | + Tests (internal/agent/input_test.go): three piped lines all come | ||
| 43 | + through; one byte per read (iotest.OneByteReader) + last line without | ||
| 44 | + newline; empty input closes; a cancelled context stops the reader. | ||
| 45 | + Live: `printf '/memory logs\n/memory port\n/sessions' | mm agent.yaml` | ||
| 46 | + runs all three and exits at EOF; through a pty, two typed lines both | ||
| 47 | + run and Ctrl-C at the prompt still prints Goodbye and quits. | ||
| 48 | + go test ./... green, gofmt/vet clean. | ||
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 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/`. | |
| 124 | 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). |
| 125 | 126 | |
| 126 | 127 | ## Demos |
| @@ -154,6 +155,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | ||
| 154 | 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 | |
| 155 | 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 | |
| 156 | 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/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 | | |
| 157 | 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 | |
| 158 | 160 | |
| 159 | 161 | ## Learn more |
| @@ -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 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/`. | ||
| 124 | - **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). | 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). |
| 125 | 126 | ||
| 126 | ## Demos | 127 | ## Demos |
| @@ -154,6 +155,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | |||
| 154 | | `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 | | 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 | |
| 155 | | `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 | | 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 | |
| 156 | | `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 | | 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/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 | | ||
| 157 | | `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 | | 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 | |
| 158 | 160 | ||
| 159 | ## Learn more | 161 | ## Learn more |
added
demos/memory-search/README.en.md +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +# mini-me demo: searching earlier sessions | |
| 2 | + | |
| 3 | +*[Version française](README.fr.md)* | |
| 4 | + | |
| 5 | +Bob helps build **bookshelf**, a small Go web app. The project already has four earlier conversations, in which the API port, the storage, the logs and the deployment were decided. Bob does not remember them — each start is a new session — but he can search them: you with `/memory <words>`, Bob himself with the `search_memory` tool. The 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` | Bob: sessions on, `searchMemory: true` (the `/memory` command), `autoSearchMemory: true` (the `search_memory` tool), `write_file` to show the decisions being used. No `bash`. | | |
| 12 | +| `seed/` | The four earlier sessions, as mm writes them: the API port and the module (EN), the storage and the migrations (EN), the logs and the HTTP errors (FR), the deployment as a `/compact` summary (EN). | | |
| 13 | +| `reset.sh` | Copies `seed/` to `.mm/sessions/` (git-ignored) and removes the `*.go` files the agent wrote. Run it before the demo, and again to start over: every run adds its own session. | | |
| 14 | + | |
| 15 | +## Run the demo | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 19 | +cd demos/memory-search | |
| 20 | +./reset.sh | |
| 21 | +mm -tui agent.yaml | |
| 22 | +``` | |
| 23 | + | |
| 24 | +Search by hand: | |
| 25 | + | |
| 26 | +```text | |
| 27 | +/memory storage sqlite | |
| 28 | +``` | |
| 29 | + | |
| 30 | +```text | |
| 31 | +🔎 2 hit(s) for "storage sqlite" in …/demos/memory-search/.mm/sessions | |
| 32 | +20261008-140000-b2c3d4e5 · 2026-10-08 14:35 · turn 1 · question | |
| 33 | + Postgres or SQLite for the storage? | |
| 34 | + → SQLite: one user, nothing to run beside the app. The file is data/bookshelf.db, opened with modernc.org/sqlite (pure Go, no cgo). | |
| 35 | +20261015-163000-d4e5f6a7 · 2026-10-15 17:20 · turn 1 · summary | |
| 36 | + …oxy to 127.0.0.1:8417. - Backups: a nightly cron copies data/bookshelf.db with `sqlite3 .backup` to /var/backups/bookshelf/, 14 days kept. | |
| 37 | +``` | |
| 38 | + | |
| 39 | +Then ask Bob, who searches by himself: | |
| 40 | + | |
| 41 | +```text | |
| 42 | +Which port did we choose for the API? | |
| 43 | +Write config.go with the constants we decided before: the API port and the database file. | |
| 44 | +How do we deploy bookshelf, and where are the backups? | |
| 45 | +Quel format de logs avait-on choisi ? | |
| 46 | +``` | |
| 47 | + | |
| 48 | +In the terminal each search shows as `🔎 search_memory: "<words>"` followed by its hits. | |
| 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 after `./reset.sh` for every run. Three runs per question; identical results each time. | |
| 53 | + | |
| 54 | +| Question | Searched | Result | | | |
| 55 | +|---|---|---|---| | |
| 56 | +| Which port did we choose for the API? | `API port` | 8417, and why (8080 is the admin UI) | 3/3 | | |
| 57 | +| Write config.go with the constants we decided before: the API port and the database file. | `API port database file config` | `config.go` written with `8417` and `data/bookshelf.db` | 3/3 | | |
| 58 | +| How do we deploy bookshelf, and where are the backups? | `deploy bookshelf backups` | binary + scp + systemd + Caddy; nightly backup to `/var/backups/bookshelf/`, 14 days — from the `/compact` summary | 3/3 | | |
| 59 | +| Quel format de logs avait-on choisi ? | `format logs` | slog in JSON on stdout, `BOOKSHELF_DEBUG=1`, answered in French | 3/3 | | |
| 60 | +| Comment gère-t-on les migrations du schéma ? | `migrations schéma` | numbered SQL files in `migrations/`, `schema_version` table — a French question found an English session: `migrations` is the same word, and `schéma` finds `schema_version` (accents do not count) | 3/3 | | |
| 61 | +| Quelle base de données avait-on choisie ? | `base de données choisie`, `database choice`, `database` | "I find nothing about it" — **not found**: the session says *storage* and *SQLite*, never *database*. Nothing invented. | 3/3 | | |
| 62 | +| Bonjour, qui es-tu ? | — | a one-line answer in French, no search | 3/3 | | |
| 63 | +| What does defer do in Go? One sentence. | — | answered directly, no search | 3/3 | | |
| 64 | + | |
| 65 | +1 to 11 s per question, model loaded. | |
| 66 | + | |
| 67 | +## Good to know | |
| 68 | + | |
| 69 | +- **The search matches words, not meanings.** A question in another language than the session, or with other words for the same thing, finds nothing (the database question above). Ask with the words that were written, or search yourself with `/memory` — `/memory sqlite` finds it at once. When the tool finds nothing, its output tells the model to try synonyms or English; here it did (`database`), and then said it did not know, as `agent.yaml` asks. | |
| 70 | +- **A question comes back with its answer.** A hit on a question shows the answer its turn ended on (`→ …`): "Quel format pour les logs ?" alone would say what was asked, not what was decided. | |
| 71 | +- **The conversation in progress is never a hit**: Bob already reads it. | |
| 72 | +- **Each key on its own.** `searchMemory` gives you `/memory`; `autoSearchMemory` gives Bob `search_memory`. Both need `sessions.enabled: true`, both are off by default. | |
| 73 | +- **What the prompt had to say.** Without "do not only show the code", Bob printed `config.go` instead of writing it (3/3). Without examples like "how do we handle…", "Comment gère-t-on les migrations ?" was answered from general knowledge, without a search (3/3). French examples in the prompt made Bob answer an English question in French (3/3): the examples are in English. | |
| 74 | + | |
| 75 | +See also: [How to search earlier sessions](../../docs/en/how-to/search-earlier-sessions.md), [`search_memory`](../../docs/en/reference/tools.md#search_memory). | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +# mini-me demo: searching earlier sessions | ||
| 2 | + | ||
| 3 | +*[Version française](README.fr.md)* | ||
| 4 | + | ||
| 5 | +Bob helps build **bookshelf**, a small Go web app. The project already has four earlier conversations, in which the API port, the storage, the logs and the deployment were decided. Bob does not remember them — each start is a new session — but he can search them: you with `/memory <words>`, Bob himself with the `search_memory` tool. The 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` | Bob: sessions on, `searchMemory: true` (the `/memory` command), `autoSearchMemory: true` (the `search_memory` tool), `write_file` to show the decisions being used. No `bash`. | | ||
| 12 | +| `seed/` | The four earlier sessions, as mm writes them: the API port and the module (EN), the storage and the migrations (EN), the logs and the HTTP errors (FR), the deployment as a `/compact` summary (EN). | | ||
| 13 | +| `reset.sh` | Copies `seed/` to `.mm/sessions/` (git-ignored) and removes the `*.go` files the agent wrote. Run it before the demo, and again to start over: every run adds its own session. | | ||
| 14 | + | ||
| 15 | +## Run the demo | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | ||
| 19 | +cd demos/memory-search | ||
| 20 | +./reset.sh | ||
| 21 | +mm -tui agent.yaml | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +Search by hand: | ||
| 25 | + | ||
| 26 | +```text | ||
| 27 | +/memory storage sqlite | ||
| 28 | +``` | ||
| 29 | + | ||
| 30 | +```text | ||
| 31 | +🔎 2 hit(s) for "storage sqlite" in …/demos/memory-search/.mm/sessions | ||
| 32 | +20261008-140000-b2c3d4e5 · 2026-10-08 14:35 · turn 1 · question | ||
| 33 | + Postgres or SQLite for the storage? | ||
| 34 | + → SQLite: one user, nothing to run beside the app. The file is data/bookshelf.db, opened with modernc.org/sqlite (pure Go, no cgo). | ||
| 35 | +20261015-163000-d4e5f6a7 · 2026-10-15 17:20 · turn 1 · summary | ||
| 36 | + …oxy to 127.0.0.1:8417. - Backups: a nightly cron copies data/bookshelf.db with `sqlite3 .backup` to /var/backups/bookshelf/, 14 days kept. | ||
| 37 | +``` | ||
| 38 | + | ||
| 39 | +Then ask Bob, who searches by himself: | ||
| 40 | + | ||
| 41 | +```text | ||
| 42 | +Which port did we choose for the API? | ||
| 43 | +Write config.go with the constants we decided before: the API port and the database file. | ||
| 44 | +How do we deploy bookshelf, and where are the backups? | ||
| 45 | +Quel format de logs avait-on choisi ? | ||
| 46 | +``` | ||
| 47 | + | ||
| 48 | +In the terminal each search shows as `🔎 search_memory: "<words>"` followed by its hits. | ||
| 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 after `./reset.sh` for every run. Three runs per question; identical results each time. | ||
| 53 | + | ||
| 54 | +| Question | Searched | Result | | | ||
| 55 | +|---|---|---|---| | ||
| 56 | +| Which port did we choose for the API? | `API port` | 8417, and why (8080 is the admin UI) | 3/3 | | ||
| 57 | +| Write config.go with the constants we decided before: the API port and the database file. | `API port database file config` | `config.go` written with `8417` and `data/bookshelf.db` | 3/3 | | ||
| 58 | +| How do we deploy bookshelf, and where are the backups? | `deploy bookshelf backups` | binary + scp + systemd + Caddy; nightly backup to `/var/backups/bookshelf/`, 14 days — from the `/compact` summary | 3/3 | | ||
| 59 | +| Quel format de logs avait-on choisi ? | `format logs` | slog in JSON on stdout, `BOOKSHELF_DEBUG=1`, answered in French | 3/3 | | ||
| 60 | +| Comment gère-t-on les migrations du schéma ? | `migrations schéma` | numbered SQL files in `migrations/`, `schema_version` table — a French question found an English session: `migrations` is the same word, and `schéma` finds `schema_version` (accents do not count) | 3/3 | | ||
| 61 | +| Quelle base de données avait-on choisie ? | `base de données choisie`, `database choice`, `database` | "I find nothing about it" — **not found**: the session says *storage* and *SQLite*, never *database*. Nothing invented. | 3/3 | | ||
| 62 | +| Bonjour, qui es-tu ? | — | a one-line answer in French, no search | 3/3 | | ||
| 63 | +| What does defer do in Go? One sentence. | — | answered directly, no search | 3/3 | | ||
| 64 | + | ||
| 65 | +1 to 11 s per question, model loaded. | ||
| 66 | + | ||
| 67 | +## Good to know | ||
| 68 | + | ||
| 69 | +- **The search matches words, not meanings.** A question in another language than the session, or with other words for the same thing, finds nothing (the database question above). Ask with the words that were written, or search yourself with `/memory` — `/memory sqlite` finds it at once. When the tool finds nothing, its output tells the model to try synonyms or English; here it did (`database`), and then said it did not know, as `agent.yaml` asks. | ||
| 70 | +- **A question comes back with its answer.** A hit on a question shows the answer its turn ended on (`→ …`): "Quel format pour les logs ?" alone would say what was asked, not what was decided. | ||
| 71 | +- **The conversation in progress is never a hit**: Bob already reads it. | ||
| 72 | +- **Each key on its own.** `searchMemory` gives you `/memory`; `autoSearchMemory` gives Bob `search_memory`. Both need `sessions.enabled: true`, both are off by default. | ||
| 73 | +- **What the prompt had to say.** Without "do not only show the code", Bob printed `config.go` instead of writing it (3/3). Without examples like "how do we handle…", "Comment gère-t-on les migrations ?" was answered from general knowledge, without a search (3/3). French examples in the prompt made Bob answer an English question in French (3/3): the examples are in English. | ||
| 74 | + | ||
| 75 | +See also: [How to search earlier sessions](../../docs/en/how-to/search-earlier-sessions.md), [`search_memory`](../../docs/en/reference/tools.md#search_memory). | ||
added
demos/memory-search/README.fr.md +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +# Démo mini-me : chercher dans les sessions précédentes | |
| 2 | + | |
| 3 | +*[English version](README.en.md)* | |
| 4 | + | |
| 5 | +Bob aide à construire **bookshelf**, une petite application web en Go. Le projet compte déjà quatre conversations, dans lesquelles ont été décidés le port de l'API, le stockage, les logs et le déploiement. Bob ne s'en souvient pas — chaque lancement est une nouvelle session — mais il peut les fouiller : vous avec `/memory <mots>`, Bob lui-même avec l'outil `search_memory`. Le modèle est Qwen3.5 4B, servi par [llmman](https://github.com/llmmanorg/llmman). | |
| 6 | + | |
| 7 | +## Contenu | |
| 8 | + | |
| 9 | +| Fichier | Rôle | | |
| 10 | +|---|---| | |
| 11 | +| `agent.yaml` | Bob : sessions activées, `searchMemory: true` (la commande `/memory`), `autoSearchMemory: true` (l'outil `search_memory`), `write_file` pour montrer les décisions en action. Pas de `bash`. | | |
| 12 | +| `seed/` | Les quatre sessions précédentes, telles que mm les écrit : le port de l'API et le module (EN), le stockage et les migrations (EN), les logs et les erreurs HTTP (FR), le déploiement sous forme de résumé `/compact` (EN). | | |
| 13 | +| `reset.sh` | Copie `seed/` dans `.mm/sessions/` (ignoré par git) et supprime les fichiers `*.go` écrits par l'agent. À lancer avant la démo, puis à nouveau pour repartir de zéro : chaque exécution ajoute sa propre session. | | |
| 14 | + | |
| 15 | +## Lancer la démo | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 19 | +cd demos/memory-search | |
| 20 | +./reset.sh | |
| 21 | +mm -tui agent.yaml | |
| 22 | +``` | |
| 23 | + | |
| 24 | +Chercher à la main : | |
| 25 | + | |
| 26 | +```text | |
| 27 | +/memory storage sqlite | |
| 28 | +``` | |
| 29 | + | |
| 30 | +```text | |
| 31 | +🔎 2 hit(s) for "storage sqlite" in …/demos/memory-search/.mm/sessions | |
| 32 | +20261008-140000-b2c3d4e5 · 2026-10-08 14:35 · turn 1 · question | |
| 33 | + Postgres or SQLite for the storage? | |
| 34 | + → SQLite: one user, nothing to run beside the app. The file is data/bookshelf.db, opened with modernc.org/sqlite (pure Go, no cgo). | |
| 35 | +20261015-163000-d4e5f6a7 · 2026-10-15 17:20 · turn 1 · summary | |
| 36 | + …oxy to 127.0.0.1:8417. - Backups: a nightly cron copies data/bookshelf.db with `sqlite3 .backup` to /var/backups/bookshelf/, 14 days kept. | |
| 37 | +``` | |
| 38 | + | |
| 39 | +Puis demander à Bob, qui cherche tout seul : | |
| 40 | + | |
| 41 | +```text | |
| 42 | +Which port did we choose for the API? | |
| 43 | +Write config.go with the constants we decided before: the API port and the database file. | |
| 44 | +How do we deploy bookshelf, and where are the backups? | |
| 45 | +Quel format de logs avait-on choisi ? | |
| 46 | +``` | |
| 47 | + | |
| 48 | +Dans le terminal, chaque recherche s'affiche sous la forme `🔎 search_memory: "<mots>"` suivie de ses résultats. | |
| 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 après `./reset.sh` à chaque exécution. Trois exécutions par question ; résultats identiques à chaque fois. | |
| 53 | + | |
| 54 | +| Question | Recherché | Résultat | | | |
| 55 | +|---|---|---|---| | |
| 56 | +| Which port did we choose for the API? | `API port` | 8417, et pourquoi (8080 est l'interface d'administration) | 3/3 | | |
| 57 | +| Write config.go with the constants we decided before: the API port and the database file. | `API port database file config` | `config.go` écrit avec `8417` et `data/bookshelf.db` | 3/3 | | |
| 58 | +| How do we deploy bookshelf, and where are the backups? | `deploy bookshelf backups` | binaire + scp + systemd + Caddy ; sauvegarde nocturne vers `/var/backups/bookshelf/`, 14 jours — tiré du résumé `/compact` | 3/3 | | |
| 59 | +| Quel format de logs avait-on choisi ? | `format logs` | slog en JSON sur la sortie standard, `BOOKSHELF_DEBUG=1`, réponse en français | 3/3 | | |
| 60 | +| Comment gère-t-on les migrations du schéma ? | `migrations schéma` | fichiers SQL numérotés dans `migrations/`, table `schema_version` — une question en français a trouvé une session en anglais : `migrations` est le même mot, et `schéma` trouve `schema_version` (les accents ne comptent pas) | 3/3 | | |
| 61 | +| Quelle base de données avait-on choisie ? | `base de données choisie`, `database choice`, `database` | « je ne trouve rien » — **pas trouvé** : la session dit *storage* et *SQLite*, jamais *database*. Rien d'inventé. | 3/3 | | |
| 62 | +| Bonjour, qui es-tu ? | — | une réponse d'une ligne en français, sans recherche | 3/3 | | |
| 63 | +| What does defer do in Go? One sentence. | — | réponse directe, sans recherche | 3/3 | | |
| 64 | + | |
| 65 | +De 1 à 11 s par question, modèle chargé. | |
| 66 | + | |
| 67 | +## Bon à savoir | |
| 68 | + | |
| 69 | +- **La recherche porte sur les mots, pas sur le sens.** Une question dans une autre langue que la session, ou avec d'autres mots pour la même chose, ne trouve rien (la question sur la base de données ci-dessus). Posez la question avec les mots qui ont été écrits, ou cherchez vous-même avec `/memory` — `/memory sqlite` la trouve aussitôt. Quand l'outil ne trouve rien, sa sortie invite le modèle à essayer des synonymes ou l'anglais ; ici il l'a fait (`database`), puis a dit qu'il ne savait pas, comme le demande `agent.yaml`. | |
| 70 | +- **Une question revient avec sa réponse.** Un résultat sur une question montre la réponse sur laquelle son tour s'est terminé (`→ …`) : « Quel format pour les logs ? » seule dirait ce qui a été demandé, pas ce qui a été décidé. | |
| 71 | +- **La conversation en cours n'est jamais un résultat** : Bob la lit déjà. | |
| 72 | +- **Chaque clé séparément.** `searchMemory` vous donne `/memory` ; `autoSearchMemory` donne `search_memory` à Bob. Les deux demandent `sessions.enabled: true`, les deux sont désactivées par défaut. | |
| 73 | +- **Ce que le prompt a dû dire.** Sans « do not only show the code », Bob affichait `config.go` au lieu de l'écrire (3/3). Sans exemples comme « how do we handle… », « Comment gère-t-on les migrations ? » recevait une réponse de culture générale, sans recherche (3/3). Des exemples en français dans le prompt faisaient répondre Bob en français à une question en anglais (3/3) : les exemples sont en anglais. | |
| 74 | + | |
| 75 | +Voir aussi : [Chercher dans les sessions précédentes](../../docs/fr/how-to/search-earlier-sessions.md), [`search_memory`](../../docs/fr/reference/tools.md#search_memory). | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +# Démo mini-me : chercher dans les sessions précédentes | ||
| 2 | + | ||
| 3 | +*[English version](README.en.md)* | ||
| 4 | + | ||
| 5 | +Bob aide à construire **bookshelf**, une petite application web en Go. Le projet compte déjà quatre conversations, dans lesquelles ont été décidés le port de l'API, le stockage, les logs et le déploiement. Bob ne s'en souvient pas — chaque lancement est une nouvelle session — mais il peut les fouiller : vous avec `/memory <mots>`, Bob lui-même avec l'outil `search_memory`. Le modèle est Qwen3.5 4B, servi par [llmman](https://github.com/llmmanorg/llmman). | ||
| 6 | + | ||
| 7 | +## Contenu | ||
| 8 | + | ||
| 9 | +| Fichier | Rôle | | ||
| 10 | +|---|---| | ||
| 11 | +| `agent.yaml` | Bob : sessions activées, `searchMemory: true` (la commande `/memory`), `autoSearchMemory: true` (l'outil `search_memory`), `write_file` pour montrer les décisions en action. Pas de `bash`. | | ||
| 12 | +| `seed/` | Les quatre sessions précédentes, telles que mm les écrit : le port de l'API et le module (EN), le stockage et les migrations (EN), les logs et les erreurs HTTP (FR), le déploiement sous forme de résumé `/compact` (EN). | | ||
| 13 | +| `reset.sh` | Copie `seed/` dans `.mm/sessions/` (ignoré par git) et supprime les fichiers `*.go` écrits par l'agent. À lancer avant la démo, puis à nouveau pour repartir de zéro : chaque exécution ajoute sa propre session. | | ||
| 14 | + | ||
| 15 | +## Lancer la démo | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | ||
| 19 | +cd demos/memory-search | ||
| 20 | +./reset.sh | ||
| 21 | +mm -tui agent.yaml | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +Chercher à la main : | ||
| 25 | + | ||
| 26 | +```text | ||
| 27 | +/memory storage sqlite | ||
| 28 | +``` | ||
| 29 | + | ||
| 30 | +```text | ||
| 31 | +🔎 2 hit(s) for "storage sqlite" in …/demos/memory-search/.mm/sessions | ||
| 32 | +20261008-140000-b2c3d4e5 · 2026-10-08 14:35 · turn 1 · question | ||
| 33 | + Postgres or SQLite for the storage? | ||
| 34 | + → SQLite: one user, nothing to run beside the app. The file is data/bookshelf.db, opened with modernc.org/sqlite (pure Go, no cgo). | ||
| 35 | +20261015-163000-d4e5f6a7 · 2026-10-15 17:20 · turn 1 · summary | ||
| 36 | + …oxy to 127.0.0.1:8417. - Backups: a nightly cron copies data/bookshelf.db with `sqlite3 .backup` to /var/backups/bookshelf/, 14 days kept. | ||
| 37 | +``` | ||
| 38 | + | ||
| 39 | +Puis demander à Bob, qui cherche tout seul : | ||
| 40 | + | ||
| 41 | +```text | ||
| 42 | +Which port did we choose for the API? | ||
| 43 | +Write config.go with the constants we decided before: the API port and the database file. | ||
| 44 | +How do we deploy bookshelf, and where are the backups? | ||
| 45 | +Quel format de logs avait-on choisi ? | ||
| 46 | +``` | ||
| 47 | + | ||
| 48 | +Dans le terminal, chaque recherche s'affiche sous la forme `🔎 search_memory: "<mots>"` suivie de ses résultats. | ||
| 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 après `./reset.sh` à chaque exécution. Trois exécutions par question ; résultats identiques à chaque fois. | ||
| 53 | + | ||
| 54 | +| Question | Recherché | Résultat | | | ||
| 55 | +|---|---|---|---| | ||
| 56 | +| Which port did we choose for the API? | `API port` | 8417, et pourquoi (8080 est l'interface d'administration) | 3/3 | | ||
| 57 | +| Write config.go with the constants we decided before: the API port and the database file. | `API port database file config` | `config.go` écrit avec `8417` et `data/bookshelf.db` | 3/3 | | ||
| 58 | +| How do we deploy bookshelf, and where are the backups? | `deploy bookshelf backups` | binaire + scp + systemd + Caddy ; sauvegarde nocturne vers `/var/backups/bookshelf/`, 14 jours — tiré du résumé `/compact` | 3/3 | | ||
| 59 | +| Quel format de logs avait-on choisi ? | `format logs` | slog en JSON sur la sortie standard, `BOOKSHELF_DEBUG=1`, réponse en français | 3/3 | | ||
| 60 | +| Comment gère-t-on les migrations du schéma ? | `migrations schéma` | fichiers SQL numérotés dans `migrations/`, table `schema_version` — une question en français a trouvé une session en anglais : `migrations` est le même mot, et `schéma` trouve `schema_version` (les accents ne comptent pas) | 3/3 | | ||
| 61 | +| Quelle base de données avait-on choisie ? | `base de données choisie`, `database choice`, `database` | « je ne trouve rien » — **pas trouvé** : la session dit *storage* et *SQLite*, jamais *database*. Rien d'inventé. | 3/3 | | ||
| 62 | +| Bonjour, qui es-tu ? | — | une réponse d'une ligne en français, sans recherche | 3/3 | | ||
| 63 | +| What does defer do in Go? One sentence. | — | réponse directe, sans recherche | 3/3 | | ||
| 64 | + | ||
| 65 | +De 1 à 11 s par question, modèle chargé. | ||
| 66 | + | ||
| 67 | +## Bon à savoir | ||
| 68 | + | ||
| 69 | +- **La recherche porte sur les mots, pas sur le sens.** Une question dans une autre langue que la session, ou avec d'autres mots pour la même chose, ne trouve rien (la question sur la base de données ci-dessus). Posez la question avec les mots qui ont été écrits, ou cherchez vous-même avec `/memory` — `/memory sqlite` la trouve aussitôt. Quand l'outil ne trouve rien, sa sortie invite le modèle à essayer des synonymes ou l'anglais ; ici il l'a fait (`database`), puis a dit qu'il ne savait pas, comme le demande `agent.yaml`. | ||
| 70 | +- **Une question revient avec sa réponse.** Un résultat sur une question montre la réponse sur laquelle son tour s'est terminé (`→ …`) : « Quel format pour les logs ? » seule dirait ce qui a été demandé, pas ce qui a été décidé. | ||
| 71 | +- **La conversation en cours n'est jamais un résultat** : Bob la lit déjà. | ||
| 72 | +- **Chaque clé séparément.** `searchMemory` vous donne `/memory` ; `autoSearchMemory` donne `search_memory` à Bob. Les deux demandent `sessions.enabled: true`, les deux sont désactivées par défaut. | ||
| 73 | +- **Ce que le prompt a dû dire.** Sans « do not only show the code », Bob affichait `config.go` au lieu de l'écrire (3/3). Sans exemples comme « how do we handle… », « Comment gère-t-on les migrations ? » recevait une réponse de culture générale, sans recherche (3/3). Des exemples en français dans le prompt faisaient répondre Bob en français à une question en anglais (3/3) : les exemples sont en anglais. | ||
| 74 | + | ||
| 75 | +Voir aussi : [Chercher dans les sessions précédentes](../../docs/fr/how-to/search-earlier-sessions.md), [`search_memory`](../../docs/fr/reference/tools.md#search_memory). | ||
added
demos/memory-search/agent.yaml +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +# Memory search — an assistant that remembers what was decided in the | |
| 2 | +# project's earlier conversations: | |
| 3 | +# | |
| 4 | +# /memory <words> the user's search through the stored sessions | |
| 5 | +# (sessions.searchMemory) | |
| 6 | +# search_memory the same search, called by the model when a question | |
| 7 | +# refers to something decided before | |
| 8 | +# (sessions.autoSearchMemory) | |
| 9 | +# | |
| 10 | +# The project is "bookshelf", a small Go web app; seed/ holds four earlier | |
| 11 | +# sessions (the API port, the storage, the logs — in French —, the | |
| 12 | +# deployment as a /compact summary). reset.sh copies them to .mm/sessions. | |
| 13 | +# | |
| 14 | +# llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 15 | +# llmman serve | |
| 16 | +# | |
| 17 | +# cd demos/memory-search | |
| 18 | +# ./reset.sh | |
| 19 | +# mm -tui agent.yaml or mm -p "…" agent.yaml | |
| 20 | + | |
| 21 | +provider: llmman | |
| 22 | +model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 23 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 24 | +fallback: http://host.docker.internal:17434/v1 | |
| 25 | +contextWindow: 16384 | |
| 26 | + | |
| 27 | +sessions: | |
| 28 | + enabled: true | |
| 29 | + dir: .mm/sessions | |
| 30 | + # /memory <words>, for you. | |
| 31 | + searchMemory: true | |
| 32 | + # search_memory, for the model. | |
| 33 | + autoSearchMemory: true | |
| 34 | + | |
| 35 | +# Writing files (config.go…) is enough to show the decisions being used; no | |
| 36 | +# bash, so the model cannot wander off grepping the session files. | |
| 37 | +yolo: true | |
| 38 | +bashTool: false | |
| 39 | +editTools: true | |
| 40 | +planTool: false | |
| 41 | +askUserTool: false | |
| 42 | +skillsDir: no-skills | |
| 43 | + | |
| 44 | +agentsMd: | |
| 45 | + enabled: false | |
| 46 | + | |
| 47 | +sampling: | |
| 48 | + temperature: 0.0 | |
| 49 | + parallel_tool_calls: false | |
| 50 | + max_tokens: 2048 | |
| 51 | + chat_template_kwargs: | |
| 52 | + enable_thinking: false | |
| 53 | + | |
| 54 | +maxTurns: 8 | |
| 55 | +watchdogTimeout: 2m | |
| 56 | +firstTokenTimeout: 3m | |
| 57 | +displayCommands: true | |
| 58 | + | |
| 59 | +acp: | |
| 60 | + name: bob | |
| 61 | + title: 📚 Bob (bookshelf, with a memory) | |
| 62 | + | |
| 63 | +system: | | |
| 64 | + Your name is Bob. You help build bookshelf, a small Go web app, in this | |
| 65 | + directory. When the user asks you to write or create a file, call | |
| 66 | + write_file with the whole content; do not only show the code. | |
| 67 | + | |
| 68 | + Earlier conversations about this project are stored. When the user refers | |
| 69 | + to something decided or said before ("the port we chose", "like last | |
| 70 | + time", "what did we decide for…"), or asks how THIS project does | |
| 71 | + something ("how do we handle…", "where are…", "which … do we use"), the | |
| 72 | + answer is a choice made for this project (port, storage, schema, logs, | |
| 73 | + errors, deployment…): call search_memory first with the key words, then | |
| 74 | + answer from what it found and name the session it came from. Never | |
| 75 | + invent a decision: if search_memory finds nothing, try other words once, | |
| 76 | + then say you do not know. | |
| 77 | + | |
| 78 | + For anything else (a greeting, a general Go question), answer directly, | |
| 79 | + in the user's language, without search_memory. | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +# Memory search — an assistant that remembers what was decided in the | ||
| 2 | +# project's earlier conversations: | ||
| 3 | +# | ||
| 4 | +# /memory <words> the user's search through the stored sessions | ||
| 5 | +# (sessions.searchMemory) | ||
| 6 | +# search_memory the same search, called by the model when a question | ||
| 7 | +# refers to something decided before | ||
| 8 | +# (sessions.autoSearchMemory) | ||
| 9 | +# | ||
| 10 | +# The project is "bookshelf", a small Go web app; seed/ holds four earlier | ||
| 11 | +# sessions (the API port, the storage, the logs — in French —, the | ||
| 12 | +# deployment as a /compact summary). reset.sh copies them to .mm/sessions. | ||
| 13 | +# | ||
| 14 | +# llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | ||
| 15 | +# llmman serve | ||
| 16 | +# | ||
| 17 | +# cd demos/memory-search | ||
| 18 | +# ./reset.sh | ||
| 19 | +# mm -tui agent.yaml or mm -p "…" agent.yaml | ||
| 20 | + | ||
| 21 | +provider: llmman | ||
| 22 | +model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | ||
| 23 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 24 | +fallback: http://host.docker.internal:17434/v1 | ||
| 25 | +contextWindow: 16384 | ||
| 26 | + | ||
| 27 | +sessions: | ||
| 28 | + enabled: true | ||
| 29 | + dir: .mm/sessions | ||
| 30 | + # /memory <words>, for you. | ||
| 31 | + searchMemory: true | ||
| 32 | + # search_memory, for the model. | ||
| 33 | + autoSearchMemory: true | ||
| 34 | + | ||
| 35 | +# Writing files (config.go…) is enough to show the decisions being used; no | ||
| 36 | +# bash, so the model cannot wander off grepping the session files. | ||
| 37 | +yolo: true | ||
| 38 | +bashTool: false | ||
| 39 | +editTools: true | ||
| 40 | +planTool: false | ||
| 41 | +askUserTool: false | ||
| 42 | +skillsDir: no-skills | ||
| 43 | + | ||
| 44 | +agentsMd: | ||
| 45 | + enabled: false | ||
| 46 | + | ||
| 47 | +sampling: | ||
| 48 | + temperature: 0.0 | ||
| 49 | + parallel_tool_calls: false | ||
| 50 | + max_tokens: 2048 | ||
| 51 | + chat_template_kwargs: | ||
| 52 | + enable_thinking: false | ||
| 53 | + | ||
| 54 | +maxTurns: 8 | ||
| 55 | +watchdogTimeout: 2m | ||
| 56 | +firstTokenTimeout: 3m | ||
| 57 | +displayCommands: true | ||
| 58 | + | ||
| 59 | +acp: | ||
| 60 | + name: bob | ||
| 61 | + title: 📚 Bob (bookshelf, with a memory) | ||
| 62 | + | ||
| 63 | +system: | | ||
| 64 | + Your name is Bob. You help build bookshelf, a small Go web app, in this | ||
| 65 | + directory. When the user asks you to write or create a file, call | ||
| 66 | + write_file with the whole content; do not only show the code. | ||
| 67 | + | ||
| 68 | + Earlier conversations about this project are stored. When the user refers | ||
| 69 | + to something decided or said before ("the port we chose", "like last | ||
| 70 | + time", "what did we decide for…"), or asks how THIS project does | ||
| 71 | + something ("how do we handle…", "where are…", "which … do we use"), the | ||
| 72 | + answer is a choice made for this project (port, storage, schema, logs, | ||
| 73 | + errors, deployment…): call search_memory first with the key words, then | ||
| 74 | + answer from what it found and name the session it came from. Never | ||
| 75 | + invent a decision: if search_memory finds nothing, try other words once, | ||
| 76 | + then say you do not know. | ||
| 77 | + | ||
| 78 | + For anything else (a greeting, a general Go question), answer directly, | ||
| 79 | + in the user's language, without search_memory. | ||
added
demos/memory-search/reset.sh +12 -0 | new file mode 100755 | ||
| @@ -0,0 +1,12 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# reset.sh — puts the demo back as it ships: the four sessions of seed/ as the | |
| 3 | +# project's history, nothing else. Run it before the demo, and again to | |
| 4 | +# start over: every run adds its own session file, and the files the agent | |
| 5 | +# writes (*.go) are removed. | |
| 6 | +set -eu | |
| 7 | +cd "$(dirname "$0")" | |
| 8 | +rm -rf .mm | |
| 9 | +mkdir -p .mm/sessions | |
| 10 | +cp seed/*.json .mm/sessions/ | |
| 11 | +rm -f ./*.go | |
| 12 | +echo "history reset: $(ls .mm/sessions | wc -l | tr -d ' ') stored session(s) in .mm/sessions" | |
| new file mode 100755 | |||
| @@ -0,0 +1,12 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +# reset.sh — puts the demo back as it ships: the four sessions of seed/ as the | ||
| 3 | +# project's history, nothing else. Run it before the demo, and again to | ||
| 4 | +# start over: every run adds its own session file, and the files the agent | ||
| 5 | +# writes (*.go) are removed. | ||
| 6 | +set -eu | ||
| 7 | +cd "$(dirname "$0")" | ||
| 8 | +rm -rf .mm | ||
| 9 | +mkdir -p .mm/sessions | ||
| 10 | +cp seed/*.json .mm/sessions/ | ||
| 11 | +rm -f ./*.go | ||
| 12 | +echo "history reset: $(ls .mm/sessions | wc -l | tr -d ' ') stored session(s) in .mm/sessions" | ||
added
demos/memory-search/seed/20261005-091500-a1b2c3d4.json +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +{ | |
| 2 | + "id": "20261005-091500-a1b2c3d4", | |
| 3 | + "cwd": ".", | |
| 4 | + "createdAt": "2026-10-05T09:15:00Z", | |
| 5 | + "updatedAt": "2026-10-05T09:40:00Z", | |
| 6 | + "messages": [ | |
| 7 | + { | |
| 8 | + "role": "system", | |
| 9 | + "content": [ | |
| 10 | + { | |
| 11 | + "text": "You are Bob, the assistant of the bookshelf project." | |
| 12 | + } | |
| 13 | + ] | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + "role": "user", | |
| 17 | + "content": [ | |
| 18 | + { | |
| 19 | + "text": "We start bookshelf, a small Go web app to track the books I read. Which port should the API server listen on?" | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "role": "model", | |
| 25 | + "content": [ | |
| 26 | + { | |
| 27 | + "text": "Let's use port 8417 for the API server: 8080 is already taken by the admin UI on your machine, and 8417 is free and easy to remember." | |
| 28 | + } | |
| 29 | + ] | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "role": "user", | |
| 33 | + "content": [ | |
| 34 | + { | |
| 35 | + "text": "ok, keep 8417. And the module name?" | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + "role": "model", | |
| 41 | + "content": [ | |
| 42 | + { | |
| 43 | + "text": "Module `github.com/k33g/bookshelf`, Go 1.26, standard library only for the HTTP side (net/http with the 1.22 routing patterns)." | |
| 44 | + } | |
| 45 | + ] | |
| 46 | + } | |
| 47 | + ] | |
| 48 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +{ | ||
| 2 | + "id": "20261005-091500-a1b2c3d4", | ||
| 3 | + "cwd": ".", | ||
| 4 | + "createdAt": "2026-10-05T09:15:00Z", | ||
| 5 | + "updatedAt": "2026-10-05T09:40:00Z", | ||
| 6 | + "messages": [ | ||
| 7 | + { | ||
| 8 | + "role": "system", | ||
| 9 | + "content": [ | ||
| 10 | + { | ||
| 11 | + "text": "You are Bob, the assistant of the bookshelf project." | ||
| 12 | + } | ||
| 13 | + ] | ||
| 14 | + }, | ||
| 15 | + { | ||
| 16 | + "role": "user", | ||
| 17 | + "content": [ | ||
| 18 | + { | ||
| 19 | + "text": "We start bookshelf, a small Go web app to track the books I read. Which port should the API server listen on?" | ||
| 20 | + } | ||
| 21 | + ] | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "role": "model", | ||
| 25 | + "content": [ | ||
| 26 | + { | ||
| 27 | + "text": "Let's use port 8417 for the API server: 8080 is already taken by the admin UI on your machine, and 8417 is free and easy to remember." | ||
| 28 | + } | ||
| 29 | + ] | ||
| 30 | + }, | ||
| 31 | + { | ||
| 32 | + "role": "user", | ||
| 33 | + "content": [ | ||
| 34 | + { | ||
| 35 | + "text": "ok, keep 8417. And the module name?" | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + }, | ||
| 39 | + { | ||
| 40 | + "role": "model", | ||
| 41 | + "content": [ | ||
| 42 | + { | ||
| 43 | + "text": "Module `github.com/k33g/bookshelf`, Go 1.26, standard library only for the HTTP side (net/http with the 1.22 routing patterns)." | ||
| 44 | + } | ||
| 45 | + ] | ||
| 46 | + } | ||
| 47 | + ] | ||
| 48 | +} | ||
added
demos/memory-search/seed/20261008-140000-b2c3d4e5.json +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +{ | |
| 2 | + "id": "20261008-140000-b2c3d4e5", | |
| 3 | + "cwd": ".", | |
| 4 | + "createdAt": "2026-10-08T14:00:00Z", | |
| 5 | + "updatedAt": "2026-10-08T14:35:00Z", | |
| 6 | + "messages": [ | |
| 7 | + { | |
| 8 | + "role": "system", | |
| 9 | + "content": [ | |
| 10 | + { | |
| 11 | + "text": "You are Bob, the assistant of the bookshelf project." | |
| 12 | + } | |
| 13 | + ] | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + "role": "user", | |
| 17 | + "content": [ | |
| 18 | + { | |
| 19 | + "text": "Postgres or SQLite for the storage?" | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "role": "model", | |
| 25 | + "content": [ | |
| 26 | + { | |
| 27 | + "text": "SQLite: one user, nothing to run beside the app. The file is data/bookshelf.db, opened with modernc.org/sqlite (pure Go, no cgo)." | |
| 28 | + } | |
| 29 | + ] | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "role": "user", | |
| 33 | + "content": [ | |
| 34 | + { | |
| 35 | + "text": "How do we handle schema changes?" | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + "role": "model", | |
| 41 | + "content": [ | |
| 42 | + { | |
| 43 | + "text": "Plain SQL files in migrations/, numbered: 001_init.sql, 002_add_rating.sql… applied in order at start-up, the last one applied stored in a schema_version table." | |
| 44 | + } | |
| 45 | + ] | |
| 46 | + } | |
| 47 | + ] | |
| 48 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +{ | ||
| 2 | + "id": "20261008-140000-b2c3d4e5", | ||
| 3 | + "cwd": ".", | ||
| 4 | + "createdAt": "2026-10-08T14:00:00Z", | ||
| 5 | + "updatedAt": "2026-10-08T14:35:00Z", | ||
| 6 | + "messages": [ | ||
| 7 | + { | ||
| 8 | + "role": "system", | ||
| 9 | + "content": [ | ||
| 10 | + { | ||
| 11 | + "text": "You are Bob, the assistant of the bookshelf project." | ||
| 12 | + } | ||
| 13 | + ] | ||
| 14 | + }, | ||
| 15 | + { | ||
| 16 | + "role": "user", | ||
| 17 | + "content": [ | ||
| 18 | + { | ||
| 19 | + "text": "Postgres or SQLite for the storage?" | ||
| 20 | + } | ||
| 21 | + ] | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "role": "model", | ||
| 25 | + "content": [ | ||
| 26 | + { | ||
| 27 | + "text": "SQLite: one user, nothing to run beside the app. The file is data/bookshelf.db, opened with modernc.org/sqlite (pure Go, no cgo)." | ||
| 28 | + } | ||
| 29 | + ] | ||
| 30 | + }, | ||
| 31 | + { | ||
| 32 | + "role": "user", | ||
| 33 | + "content": [ | ||
| 34 | + { | ||
| 35 | + "text": "How do we handle schema changes?" | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + }, | ||
| 39 | + { | ||
| 40 | + "role": "model", | ||
| 41 | + "content": [ | ||
| 42 | + { | ||
| 43 | + "text": "Plain SQL files in migrations/, numbered: 001_init.sql, 002_add_rating.sql… applied in order at start-up, the last one applied stored in a schema_version table." | ||
| 44 | + } | ||
| 45 | + ] | ||
| 46 | + } | ||
| 47 | + ] | ||
| 48 | +} | ||
added
demos/memory-search/seed/20261012-101000-c3d4e5f6.json +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +{ | |
| 2 | + "id": "20261012-101000-c3d4e5f6", | |
| 3 | + "cwd": ".", | |
| 4 | + "createdAt": "2026-10-12T10:10:00Z", | |
| 5 | + "updatedAt": "2026-10-12T10:30:00Z", | |
| 6 | + "messages": [ | |
| 7 | + { | |
| 8 | + "role": "system", | |
| 9 | + "content": [ | |
| 10 | + { | |
| 11 | + "text": "You are Bob, the assistant of the bookshelf project." | |
| 12 | + } | |
| 13 | + ] | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + "role": "user", | |
| 17 | + "content": [ | |
| 18 | + { | |
| 19 | + "text": "Quel format pour les logs ?" | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + "role": "model", | |
| 25 | + "content": [ | |
| 26 | + { | |
| 27 | + "text": "log/slog en JSON sur la sortie standard, niveau info par défaut ; BOOKSHELF_DEBUG=1 passe au niveau debug. Pas de fichier de log : c'est systemd qui les garde." | |
| 28 | + } | |
| 29 | + ] | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "role": "user", | |
| 33 | + "content": [ | |
| 34 | + { | |
| 35 | + "text": "Et les erreurs HTTP ?" | |
| 36 | + } | |
| 37 | + ] | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + "role": "model", | |
| 41 | + "content": [ | |
| 42 | + { | |
| 43 | + "text": "Toujours un corps JSON {\"error\": \"…\"} avec le bon code ; jamais le texte d'une erreur Go interne vers le client — on la journalise, on renvoie un message générique." | |
| 44 | + } | |
| 45 | + ] | |
| 46 | + } | |
| 47 | + ] | |
| 48 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +{ | ||
| 2 | + "id": "20261012-101000-c3d4e5f6", | ||
| 3 | + "cwd": ".", | ||
| 4 | + "createdAt": "2026-10-12T10:10:00Z", | ||
| 5 | + "updatedAt": "2026-10-12T10:30:00Z", | ||
| 6 | + "messages": [ | ||
| 7 | + { | ||
| 8 | + "role": "system", | ||
| 9 | + "content": [ | ||
| 10 | + { | ||
| 11 | + "text": "You are Bob, the assistant of the bookshelf project." | ||
| 12 | + } | ||
| 13 | + ] | ||
| 14 | + }, | ||
| 15 | + { | ||
| 16 | + "role": "user", | ||
| 17 | + "content": [ | ||
| 18 | + { | ||
| 19 | + "text": "Quel format pour les logs ?" | ||
| 20 | + } | ||
| 21 | + ] | ||
| 22 | + }, | ||
| 23 | + { | ||
| 24 | + "role": "model", | ||
| 25 | + "content": [ | ||
| 26 | + { | ||
| 27 | + "text": "log/slog en JSON sur la sortie standard, niveau info par défaut ; BOOKSHELF_DEBUG=1 passe au niveau debug. Pas de fichier de log : c'est systemd qui les garde." | ||
| 28 | + } | ||
| 29 | + ] | ||
| 30 | + }, | ||
| 31 | + { | ||
| 32 | + "role": "user", | ||
| 33 | + "content": [ | ||
| 34 | + { | ||
| 35 | + "text": "Et les erreurs HTTP ?" | ||
| 36 | + } | ||
| 37 | + ] | ||
| 38 | + }, | ||
| 39 | + { | ||
| 40 | + "role": "model", | ||
| 41 | + "content": [ | ||
| 42 | + { | ||
| 43 | + "text": "Toujours un corps JSON {\"error\": \"…\"} avec le bon code ; jamais le texte d'une erreur Go interne vers le client — on la journalise, on renvoie un message générique." | ||
| 44 | + } | ||
| 45 | + ] | ||
| 46 | + } | ||
| 47 | + ] | ||
| 48 | +} | ||
added
demos/memory-search/seed/20261015-163000-d4e5f6a7.json +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +{ | |
| 2 | + "id": "20261015-163000-d4e5f6a7", | |
| 3 | + "cwd": ".", | |
| 4 | + "createdAt": "2026-10-15T16:30:00Z", | |
| 5 | + "updatedAt": "2026-10-15T17:20:00Z", | |
| 6 | + "messages": [ | |
| 7 | + { | |
| 8 | + "role": "system", | |
| 9 | + "content": [ | |
| 10 | + { | |
| 11 | + "text": "You are Bob, the assistant of the bookshelf project." | |
| 12 | + } | |
| 13 | + ] | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + "role": "user", | |
| 17 | + "metadata": { | |
| 18 | + "compact": "summary" | |
| 19 | + }, | |
| 20 | + "content": [ | |
| 21 | + { | |
| 22 | + "text": "[Context summary — the 6 earlier messages of this session (3 question(s), 0 command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.]\n\n- Deployment: one static binary (CGO_ENABLED=0), copied to the VPS with scp, run by the systemd unit bookshelf.service.\n- Caddy in front, TLS automatic, reverse proxy to 127.0.0.1:8417.\n- Backups: a nightly cron copies data/bookshelf.db with `sqlite3 .backup` to /var/backups/bookshelf/, 14 days kept." | |
| 23 | + } | |
| 24 | + ] | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "role": "model", | |
| 28 | + "metadata": { | |
| 29 | + "compact": "summary" | |
| 30 | + }, | |
| 31 | + "content": [ | |
| 32 | + { | |
| 33 | + "text": "Understood — I will re-check files before editing them." | |
| 34 | + } | |
| 35 | + ] | |
| 36 | + }, | |
| 37 | + { | |
| 38 | + "role": "user", | |
| 39 | + "content": [ | |
| 40 | + { | |
| 41 | + "text": "Should the release be tagged?" | |
| 42 | + } | |
| 43 | + ] | |
| 44 | + }, | |
| 45 | + { | |
| 46 | + "role": "model", | |
| 47 | + "content": [ | |
| 48 | + { | |
| 49 | + "text": "Yes: tag vX.Y.Z on main, and the binary's version stamped with -ldflags \"-X main.version=vX.Y.Z\"." | |
| 50 | + } | |
| 51 | + ] | |
| 52 | + } | |
| 53 | + ] | |
| 54 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +{ | ||
| 2 | + "id": "20261015-163000-d4e5f6a7", | ||
| 3 | + "cwd": ".", | ||
| 4 | + "createdAt": "2026-10-15T16:30:00Z", | ||
| 5 | + "updatedAt": "2026-10-15T17:20:00Z", | ||
| 6 | + "messages": [ | ||
| 7 | + { | ||
| 8 | + "role": "system", | ||
| 9 | + "content": [ | ||
| 10 | + { | ||
| 11 | + "text": "You are Bob, the assistant of the bookshelf project." | ||
| 12 | + } | ||
| 13 | + ] | ||
| 14 | + }, | ||
| 15 | + { | ||
| 16 | + "role": "user", | ||
| 17 | + "metadata": { | ||
| 18 | + "compact": "summary" | ||
| 19 | + }, | ||
| 20 | + "content": [ | ||
| 21 | + { | ||
| 22 | + "text": "[Context summary — the 6 earlier messages of this session (3 question(s), 0 command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.]\n\n- Deployment: one static binary (CGO_ENABLED=0), copied to the VPS with scp, run by the systemd unit bookshelf.service.\n- Caddy in front, TLS automatic, reverse proxy to 127.0.0.1:8417.\n- Backups: a nightly cron copies data/bookshelf.db with `sqlite3 .backup` to /var/backups/bookshelf/, 14 days kept." | ||
| 23 | + } | ||
| 24 | + ] | ||
| 25 | + }, | ||
| 26 | + { | ||
| 27 | + "role": "model", | ||
| 28 | + "metadata": { | ||
| 29 | + "compact": "summary" | ||
| 30 | + }, | ||
| 31 | + "content": [ | ||
| 32 | + { | ||
| 33 | + "text": "Understood — I will re-check files before editing them." | ||
| 34 | + } | ||
| 35 | + ] | ||
| 36 | + }, | ||
| 37 | + { | ||
| 38 | + "role": "user", | ||
| 39 | + "content": [ | ||
| 40 | + { | ||
| 41 | + "text": "Should the release be tagged?" | ||
| 42 | + } | ||
| 43 | + ] | ||
| 44 | + }, | ||
| 45 | + { | ||
| 46 | + "role": "model", | ||
| 47 | + "content": [ | ||
| 48 | + { | ||
| 49 | + "text": "Yes: tag vX.Y.Z on main, and the binary's version stamped with -ldflags \"-X main.version=vX.Y.Z\"." | ||
| 50 | + } | ||
| 51 | + ] | ||
| 52 | + } | ||
| 53 | + ] | ||
| 54 | +} | ||
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</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></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></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</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></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></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
docs/en/README.md +1 -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 earlier sessions](how-to/search-earlier-sessions.md) — `/memory <words>`, the `search_memory` tool, `sessions.searchMemory` / `sessions.autoSearchMemory` | |
| 30 | 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` |
| 31 | 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 |
| 32 | 33 | - [Keep a long session inside the context window](how-to/manage-the-context-window.md) |
| @@ -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 earlier sessions](how-to/search-earlier-sessions.md) — `/memory <words>`, the `search_memory` tool, `sessions.searchMemory` / `sessions.autoSearchMemory` | ||
| 30 | - [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` | 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` |
| 31 | - [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 | 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 |
| 32 | - [Keep a long session inside the context window](how-to/manage-the-context-window.md) | 33 | - [Keep a long session inside the context window](how-to/manage-the-context-window.md) |
modified
docs/en/explanation/persistent-sessions.md +11 -0 | @@ -44,10 +44,21 @@ In the window, ↑ on the box's first row recalls the questions already asked (t | ||
| 44 | 44 | |
| 45 | 45 | The price is that a recalled question is what the model read, not exactly what was typed: the `[attached file: …]` lines an `@path` added, a paste in full rather than its token, an `/ask` in its `[asked …]` form; slash commands, never sent to the model, are not there at all. What mm itself wrote as a user message is left out — a `/compact` summary, the note after a broken tool call, the marker of a prompt a hook blocked — and so is the context a `preGeneration` hook appended. One gap the files cannot fill: a turn that fails before answering drops its question from the history, and that is the question one most wants to retry. The window keeps it in memory until the next question, so ↑ still finds it; it is not written anywhere. |
| 46 | 46 | |
| 47 | +## Why the memory search matches words | |
| 48 | + | |
| 49 | +`/memory` and the `search_memory` tool (ticket 48) scan the project's session files on every call: no index, no database, no embedding model. A project's sessions are a few hundred kilobytes — this repository's 43 files make 508 KB — and reading them is instant. An embedding model would find "database" in a session that said "storage", but it is one more model loaded next to the chat model, on machines where memory is the constraint; that kind of memory belongs to the hierarchic-memory work (ticket 74), not to a search. | |
| 50 | + | |
| 51 | +The price was measured, on Qwen3.5 4B through llmman with two stored sessions (2026-10-20). Questions whose words were in a session — the API port, "the port we agreed on before" to write a file — were found 9 times out of 9 (one warm-up run, three of each, then one of each again after the last change), and the tool was never called on a greeting, a sum, a listing or a code request (0 of 12). A French question about a choice written in English found nothing: the model searched "base de données", then, told by the tool's no-hit line to try English, "database" — which the session never said ("storage", "SQLite"). It found the answer by reading the JSON files itself (5 runs of 6; the sixth was lost to a server stall). | |
| 52 | + | |
| 53 | +Building `demos/memory-search/` shaped three more rules, each from a wrong answer it caused. A question that matches comes with the answer its turn ended on: "format logs" found only the question "Quel format pour les logs ?" (the answer says "log/slog"), and the model concluded nothing had been decided. Words of one or two letters are dropped beside longer ones: "de" in "base de données" matched "Deployment", "modernc" and "Module", returned the whole history, and the right answer came by luck. And a snippet runs from the first word found to the last: cut after the first, a summary that named the deployment in its first line lost its backups line, and the model answered beside the question. The summary's wrapper line and the acknowledgement after it, mm's own words, are no longer searched. | |
| 54 | + | |
| 55 | +Two choices follow from the tool being in the model's hands. The conversation in progress is left out by content, not by session id: no front end tells a tool which file it saves to, and a resumed or compacted session's file holds the very messages in memory, so "a message the model already has" is both what to skip and something every front end can compute. And the tool and the command have separate keys, both off by default: a tool in the catalogue of a small model changes what it does, even when unused, so declaring one is the user's decision. | |
| 56 | + | |
| 47 | 57 | ## Rejected alternatives |
| 48 | 58 | |
| 49 | 59 | - **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either. |
| 50 | 60 | - **Append-only log per session.** Rejected: compression shrinks the history, and a log would keep saying what the memory no longer holds. |
| 61 | +- **An index or embeddings for the memory search (ticket 48).** Rejected for v1: the files are small enough to scan, and an embedding model is one more model in memory. The cost — no match across languages or synonyms — is measured above. | |
| 51 | 62 | - **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files. |
| 52 | 63 | - **`session/resume` instead of `session/load`.** Rejected: it relies on the editor having kept the thread, and when the choice was made it was not in the stable spec. Since ticket 95 it is offered beside `session/load`, not instead of it. |
| 53 | 64 | - **A `/resume` slash command over ACP.** Rejected (ticket 95): the editor draws the thread, so an old conversation replayed into an open thread would land after what it already shows, and the file would no longer match the editor's session id. Listing sessions to the editor is the protocol's way. |
| @@ -44,10 +44,21 @@ In the window, ↑ on the box's first row recalls the questions already asked (t | |||
| 44 | 44 | ||
| 45 | The price is that a recalled question is what the model read, not exactly what was typed: the `[attached file: …]` lines an `@path` added, a paste in full rather than its token, an `/ask` in its `[asked …]` form; slash commands, never sent to the model, are not there at all. What mm itself wrote as a user message is left out — a `/compact` summary, the note after a broken tool call, the marker of a prompt a hook blocked — and so is the context a `preGeneration` hook appended. One gap the files cannot fill: a turn that fails before answering drops its question from the history, and that is the question one most wants to retry. The window keeps it in memory until the next question, so ↑ still finds it; it is not written anywhere. | 45 | The price is that a recalled question is what the model read, not exactly what was typed: the `[attached file: …]` lines an `@path` added, a paste in full rather than its token, an `/ask` in its `[asked …]` form; slash commands, never sent to the model, are not there at all. What mm itself wrote as a user message is left out — a `/compact` summary, the note after a broken tool call, the marker of a prompt a hook blocked — and so is the context a `preGeneration` hook appended. One gap the files cannot fill: a turn that fails before answering drops its question from the history, and that is the question one most wants to retry. The window keeps it in memory until the next question, so ↑ still finds it; it is not written anywhere. |
| 46 | 46 | ||
| 47 | +## Why the memory search matches words | ||
| 48 | + | ||
| 49 | +`/memory` and the `search_memory` tool (ticket 48) scan the project's session files on every call: no index, no database, no embedding model. A project's sessions are a few hundred kilobytes — this repository's 43 files make 508 KB — and reading them is instant. An embedding model would find "database" in a session that said "storage", but it is one more model loaded next to the chat model, on machines where memory is the constraint; that kind of memory belongs to the hierarchic-memory work (ticket 74), not to a search. | ||
| 50 | + | ||
| 51 | +The price was measured, on Qwen3.5 4B through llmman with two stored sessions (2026-10-20). Questions whose words were in a session — the API port, "the port we agreed on before" to write a file — were found 9 times out of 9 (one warm-up run, three of each, then one of each again after the last change), and the tool was never called on a greeting, a sum, a listing or a code request (0 of 12). A French question about a choice written in English found nothing: the model searched "base de données", then, told by the tool's no-hit line to try English, "database" — which the session never said ("storage", "SQLite"). It found the answer by reading the JSON files itself (5 runs of 6; the sixth was lost to a server stall). | ||
| 52 | + | ||
| 53 | +Building `demos/memory-search/` shaped three more rules, each from a wrong answer it caused. A question that matches comes with the answer its turn ended on: "format logs" found only the question "Quel format pour les logs ?" (the answer says "log/slog"), and the model concluded nothing had been decided. Words of one or two letters are dropped beside longer ones: "de" in "base de données" matched "Deployment", "modernc" and "Module", returned the whole history, and the right answer came by luck. And a snippet runs from the first word found to the last: cut after the first, a summary that named the deployment in its first line lost its backups line, and the model answered beside the question. The summary's wrapper line and the acknowledgement after it, mm's own words, are no longer searched. | ||
| 54 | + | ||
| 55 | +Two choices follow from the tool being in the model's hands. The conversation in progress is left out by content, not by session id: no front end tells a tool which file it saves to, and a resumed or compacted session's file holds the very messages in memory, so "a message the model already has" is both what to skip and something every front end can compute. And the tool and the command have separate keys, both off by default: a tool in the catalogue of a small model changes what it does, even when unused, so declaring one is the user's decision. | ||
| 56 | + | ||
| 47 | ## Rejected alternatives | 57 | ## Rejected alternatives |
| 48 | 58 | ||
| 49 | - **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either. | 59 | - **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either. |
| 50 | - **Append-only log per session.** Rejected: compression shrinks the history, and a log would keep saying what the memory no longer holds. | 60 | - **Append-only log per session.** Rejected: compression shrinks the history, and a log would keep saying what the memory no longer holds. |
| 61 | +- **An index or embeddings for the memory search (ticket 48).** Rejected for v1: the files are small enough to scan, and an embedding model is one more model in memory. The cost — no match across languages or synonyms — is measured above. | ||
| 51 | - **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files. | 62 | - **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files. |
| 52 | - **`session/resume` instead of `session/load`.** Rejected: it relies on the editor having kept the thread, and when the choice was made it was not in the stable spec. Since ticket 95 it is offered beside `session/load`, not instead of it. | 63 | - **`session/resume` instead of `session/load`.** Rejected: it relies on the editor having kept the thread, and when the choice was made it was not in the stable spec. Since ticket 95 it is offered beside `session/load`, not instead of it. |
| 53 | - **A `/resume` slash command over ACP.** Rejected (ticket 95): the editor draws the thread, so an old conversation replayed into an open thread would land after what it already shows, and the file would no longer match the editor's session id. Listing sessions to the editor is the protocol's way. | 64 | - **A `/resume` slash command over ACP.** Rejected (ticket 95): the editor draws the thread, so an old conversation replayed into an open thread would land after what it already shows, and the file would no longer match the editor's session id. Listing sessions to the editor is the protocol's way. |
modified
docs/en/how-to/keep-sessions.md +1 -0 | @@ -95,6 +95,7 @@ This guide shows how to make `mm` save every conversation of a project and get o | ||
| 95 | 95 | - **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did. The `errors` array is left as it is. |
| 96 | 96 | - **Errors over ACP.** The editor shows the failure in its own way; the record still goes to the thread's file, and `/new` moves it to the archived conversation with the history it belongs to. |
| 97 | 97 | - **Ask an earlier question again (window).** In `mm -tui`, ↑ on the box's first row recalls the questions of this session, then of the earlier ones. See [Recall an earlier question](use-the-window-mode.md#recall-an-earlier-question). |
| 98 | +- **Search what was said before.** `/memory <words>` (with `sessions.searchMemory`) and the model's `search_memory` tool (with `sessions.autoSearchMemory`). See [Search earlier sessions](search-earlier-sessions.md). | |
| 98 | 99 | - **Starting fresh on purpose.** Nothing to do: every start is a new session. The old ones only come back on `/resume` or on an editor reload. |
| 99 | 100 | |
| 100 | 101 | ## See also |
| @@ -95,6 +95,7 @@ This guide shows how to make `mm` save every conversation of a project and get o | |||
| 95 | - **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did. The `errors` array is left as it is. | 95 | - **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did. The `errors` array is left as it is. |
| 96 | - **Errors over ACP.** The editor shows the failure in its own way; the record still goes to the thread's file, and `/new` moves it to the archived conversation with the history it belongs to. | 96 | - **Errors over ACP.** The editor shows the failure in its own way; the record still goes to the thread's file, and `/new` moves it to the archived conversation with the history it belongs to. |
| 97 | - **Ask an earlier question again (window).** In `mm -tui`, ↑ on the box's first row recalls the questions of this session, then of the earlier ones. See [Recall an earlier question](use-the-window-mode.md#recall-an-earlier-question). | 97 | - **Ask an earlier question again (window).** In `mm -tui`, ↑ on the box's first row recalls the questions of this session, then of the earlier ones. See [Recall an earlier question](use-the-window-mode.md#recall-an-earlier-question). |
| 98 | +- **Search what was said before.** `/memory <words>` (with `sessions.searchMemory`) and the model's `search_memory` tool (with `sessions.autoSearchMemory`). See [Search earlier sessions](search-earlier-sessions.md). | ||
| 98 | - **Starting fresh on purpose.** Nothing to do: every start is a new session. The old ones only come back on `/resume` or on an editor reload. | 99 | - **Starting fresh on purpose.** Nothing to do: every start is a new session. The old ones only come back on `/resume` or on an editor reload. |
| 99 | 100 | ||
| 100 | ## See also | 101 | ## See also |
added
docs/en/how-to/search-earlier-sessions.md +59 -0 | new file mode 100644 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +# How to search earlier sessions | |
| 2 | + | |
| 3 | +This guide shows how to find something said in an earlier conversation of a project: by hand with `/memory`, or by letting the model search with its `search_memory` tool. It assumes sessions are kept (see [Keep sessions and resume them](keep-sessions.md)). | |
| 4 | + | |
| 5 | +## Search by hand | |
| 6 | + | |
| 7 | +1. Turn the command on in the agent's YAML: | |
| 8 | + | |
| 9 | + ```yaml | |
| 10 | + sessions: | |
| 11 | + enabled: true | |
| 12 | + searchMemory: true | |
| 13 | + ``` | |
| 14 | + | |
| 15 | +2. Start mm — the terminal, the window (`mm -tui`) or an editor — and type the words you remember: | |
| 16 | + | |
| 17 | + ```text | |
| 18 | + /memory port api | |
| 19 | + ``` | |
| 20 | + | |
| 21 | + ```text | |
| 22 | + 🔎 2 hit(s) for "port api" in /work/project/.mm/sessions | |
| 23 | + 20261010-090000-aaaaaaaa · 2026-10-10 09:30 · turn 1 · question | |
| 24 | + Which port should the API server listen on? | |
| 25 | + → Let's use port 8417 for the API server: 8080 is already taken by the admin UI. | |
| 26 | + … | |
| 27 | + ``` | |
| 28 | + | |
| 29 | +3. To continue that conversation, `/resume 20261010-090000-aaaaaaaa` (terminal and window). | |
| 30 | + | |
| 31 | +The model is not called and the conversation in progress is not changed. A hit holds at least one of the words; the more of them it holds, the higher it comes. A question comes with the answer its turn ended on (`→`). | |
| 32 | + | |
| 33 | +## Let the model search | |
| 34 | + | |
| 35 | +1. Declare the tool: | |
| 36 | + | |
| 37 | + ```yaml | |
| 38 | + sessions: | |
| 39 | + enabled: true | |
| 40 | + autoSearchMemory: true | |
| 41 | + ``` | |
| 42 | + | |
| 43 | +2. Ask as you would a colleague who was there: "What port did we decide for the API server last time?", "Write config.go with the port we agreed on before." The model calls `search_memory`, reads the hits, and answers from them. | |
| 44 | + | |
| 45 | +The two keys are independent: `searchMemory` alone gives you the command and the model no tool; `autoSearchMemory` alone the reverse. | |
| 46 | + | |
| 47 | +## Variants | |
| 48 | + | |
| 49 | +- **Search in the language the sessions were held in.** The search matches words, not meanings: a French question searched in French finds nothing in a session written in English. Use the words that were actually written; when the tool finds nothing, its output tells the model to try synonyms or English. A different word for the same thing — "database" for a session that said "storage" and "SQLite" — is still not found. | |
| 50 | +- **Sessions in a shared directory.** With an absolute `sessions.dir`, the search covers every project that writes there. | |
| 51 | +- **Sessions are off.** Either key then prints a start-up warning and does nothing. | |
| 52 | + | |
| 53 | +## See also | |
| 54 | + | |
| 55 | +- `demos/memory-search/` — a measured demo: an assistant that remembers a project's decisions | |
| 56 | +- [Configuration reference](../reference/configuration.md) — `sessions.searchMemory`, `sessions.autoSearchMemory` | |
| 57 | +- [Slash commands reference](../reference/slash-commands.md) — `/memory` | |
| 58 | +- [Tools reference](../reference/tools.md#search_memory) — `search_memory` | |
| 59 | +- [Why sessions work this way](../explanation/persistent-sessions.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +# How to search earlier sessions | ||
| 2 | + | ||
| 3 | +This guide shows how to find something said in an earlier conversation of a project: by hand with `/memory`, or by letting the model search with its `search_memory` tool. It assumes sessions are kept (see [Keep sessions and resume them](keep-sessions.md)). | ||
| 4 | + | ||
| 5 | +## Search by hand | ||
| 6 | + | ||
| 7 | +1. Turn the command on in the agent's YAML: | ||
| 8 | + | ||
| 9 | + ```yaml | ||
| 10 | + sessions: | ||
| 11 | + enabled: true | ||
| 12 | + searchMemory: true | ||
| 13 | + ``` | ||
| 14 | + | ||
| 15 | +2. Start mm — the terminal, the window (`mm -tui`) or an editor — and type the words you remember: | ||
| 16 | + | ||
| 17 | + ```text | ||
| 18 | + /memory port api | ||
| 19 | + ``` | ||
| 20 | + | ||
| 21 | + ```text | ||
| 22 | + 🔎 2 hit(s) for "port api" in /work/project/.mm/sessions | ||
| 23 | + 20261010-090000-aaaaaaaa · 2026-10-10 09:30 · turn 1 · question | ||
| 24 | + Which port should the API server listen on? | ||
| 25 | + → Let's use port 8417 for the API server: 8080 is already taken by the admin UI. | ||
| 26 | + … | ||
| 27 | + ``` | ||
| 28 | + | ||
| 29 | +3. To continue that conversation, `/resume 20261010-090000-aaaaaaaa` (terminal and window). | ||
| 30 | + | ||
| 31 | +The model is not called and the conversation in progress is not changed. A hit holds at least one of the words; the more of them it holds, the higher it comes. A question comes with the answer its turn ended on (`→`). | ||
| 32 | + | ||
| 33 | +## Let the model search | ||
| 34 | + | ||
| 35 | +1. Declare the tool: | ||
| 36 | + | ||
| 37 | + ```yaml | ||
| 38 | + sessions: | ||
| 39 | + enabled: true | ||
| 40 | + autoSearchMemory: true | ||
| 41 | + ``` | ||
| 42 | + | ||
| 43 | +2. Ask as you would a colleague who was there: "What port did we decide for the API server last time?", "Write config.go with the port we agreed on before." The model calls `search_memory`, reads the hits, and answers from them. | ||
| 44 | + | ||
| 45 | +The two keys are independent: `searchMemory` alone gives you the command and the model no tool; `autoSearchMemory` alone the reverse. | ||
| 46 | + | ||
| 47 | +## Variants | ||
| 48 | + | ||
| 49 | +- **Search in the language the sessions were held in.** The search matches words, not meanings: a French question searched in French finds nothing in a session written in English. Use the words that were actually written; when the tool finds nothing, its output tells the model to try synonyms or English. A different word for the same thing — "database" for a session that said "storage" and "SQLite" — is still not found. | ||
| 50 | +- **Sessions in a shared directory.** With an absolute `sessions.dir`, the search covers every project that writes there. | ||
| 51 | +- **Sessions are off.** Either key then prints a start-up warning and does nothing. | ||
| 52 | + | ||
| 53 | +## See also | ||
| 54 | + | ||
| 55 | +- `demos/memory-search/` — a measured demo: an assistant that remembers a project's decisions | ||
| 56 | +- [Configuration reference](../reference/configuration.md) — `sessions.searchMemory`, `sessions.autoSearchMemory` | ||
| 57 | +- [Slash commands reference](../reference/slash-commands.md) — `/memory` | ||
| 58 | +- [Tools reference](../reference/tools.md#search_memory) — `search_memory` | ||
| 59 | +- [Why sessions work this way](../explanation/persistent-sessions.md) | ||
modified
docs/en/reference/acp.md +1 -0 | @@ -159,6 +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 | 163 | | `other` | `other` | `ask_agent`, `ask_user` | |
| 163 | 164 | |
| 164 | 165 | ## Tool call identifiers |
| @@ -159,6 +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 | | `other` | `other` | `ask_agent`, `ask_user` | | 163 | | `other` | `other` | `ask_agent`, `ask_user` | |
| 163 | 164 | ||
| 164 | ## Tool call identifiers | 165 | ## Tool call identifiers |
modified
docs/en/reference/configuration.md +5 -1 | @@ -62,6 +62,10 @@ | ||
| 62 | 62 | |-----|------|---------|-------------| |
| 63 | 63 | | `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. | |
| 64 | 64 | | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. | |
| 65 | +| `sessions.searchMemory` | bool | `false` | The `/memory <words>` command (terminal, window, editor): searches the stored sessions of the project. Needs `sessions.enabled`. See [slash commands](slash-commands.md) and [How to search earlier sessions](../how-to/search-earlier-sessions.md). | | |
| 66 | +| `sessions.autoSearchMemory` | bool | `false` | The [`search_memory`](tools.md#search_memory) tool: the same search, called by the model. Independent of `searchMemory`. Needs `sessions.enabled`. | | |
| 67 | + | |
| 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`. | |
| 65 | 69 | |
| 66 | 70 | ## `trace` keys |
| 67 | 71 | |
| @@ -129,7 +133,7 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ | ||
| 129 | 133 | | Key | Type | Default | Description | |
| 130 | 134 | |-----|------|---------|-------------| |
| 131 | 135 | | `tools` | list | `[]` | One entry per declared tool. | |
| 132 | -| `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`, `unload_model`, `write_file`), even one this agent turns off. | | |
| 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. | | |
| 133 | 137 | | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. | |
| 134 | 138 | | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. | |
| 135 | 139 | | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. | |
| @@ -62,6 +62,10 @@ | |||
| 62 | |-----|------|---------|-------------| | 62 | |-----|------|---------|-------------| |
| 63 | | `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. | | 63 | | `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. | |
| 64 | | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. | | 64 | | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. | |
| 65 | +| `sessions.searchMemory` | bool | `false` | The `/memory <words>` command (terminal, window, editor): searches the stored sessions of the project. Needs `sessions.enabled`. See [slash commands](slash-commands.md) and [How to search earlier sessions](../how-to/search-earlier-sessions.md). | | ||
| 66 | +| `sessions.autoSearchMemory` | bool | `false` | The [`search_memory`](tools.md#search_memory) tool: the same search, called by the model. Independent of `searchMemory`. Needs `sessions.enabled`. | | ||
| 67 | + | ||
| 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`. | ||
| 65 | 69 | ||
| 66 | ## `trace` keys | 70 | ## `trace` keys |
| 67 | 71 | ||
| @@ -129,7 +133,7 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ | |||
| 129 | | Key | Type | Default | Description | | 133 | | Key | Type | Default | Description | |
| 130 | |-----|------|---------|-------------| | 134 | |-----|------|---------|-------------| |
| 131 | | `tools` | list | `[]` | One entry per declared tool. | | 135 | | `tools` | list | `[]` | One entry per declared tool. | |
| 132 | -| `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`, `unload_model`, `write_file`), even one this agent turns off. | | 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. | |
| 133 | | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. | | 137 | | `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. | |
| 134 | | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. | | 138 | | `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. | |
| 135 | | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. | | 139 | | `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. | |
modified
docs/en/reference/slash-commands.md +3 -1 | @@ -15,6 +15,7 @@ | ||
| 15 | 15 | | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. | |
| 16 | 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 | +| `/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 | 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. | |
| 19 | 20 | |
| 20 | 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. |
| @@ -170,12 +171,13 @@ The notation works in the line mode and over ACP too, typed by hand: a `$skill` | ||
| 170 | 171 | |
| 171 | 172 | ## ACP mode |
| 172 | 173 | |
| 173 | -Two commands exist in ACP mode, three with `unloadModels` (the default): `/new`, `/compact` and `/unload`. 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). Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted. | |
| 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. | |
| 174 | 175 | |
| 175 | 176 | | Command | Over ACP | |
| 176 | 177 | |---------|----------| |
| 177 | 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. | |
| 178 | 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 | +| `/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. | | |
| 179 | 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. | |
| 180 | 182 | |
| 181 | 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). |
| @@ -15,6 +15,7 @@ | |||
| 15 | | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. | | 15 | | `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. | |
| 16 | | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. | | 16 | | `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. | |
| 17 | | `/save <file> [n]` | Writes the last answer (`n`: the n-th from the end) as it is to a new file, creating the missing directories, and prints `💾 saved the last answer to <file> (N bytes)`. Never overwrites: an existing file gives `⚠️ <file> already exists — nothing was written; choose another name`. A file name with spaces is not supported. The model is not called. | Sent to the model as a new message. | | 17 | | `/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 | | `/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. | | 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. | |
| 19 | 20 | ||
| 20 | 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. | 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. |
| @@ -170,12 +171,13 @@ The notation works in the line mode and over ACP too, typed by hand: a `$skill` | |||
| 170 | 171 | ||
| 171 | ## ACP mode | 172 | ## ACP mode |
| 172 | 173 | ||
| 173 | -Two commands exist in ACP mode, three with `unloadModels` (the default): `/new`, `/compact` and `/unload`. 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). Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted. | 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. |
| 174 | 175 | ||
| 175 | | Command | Over ACP | | 176 | | Command | Over ACP | |
| 176 | |---------|----------| | 177 | |---------|----------| |
| 177 | | `/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. | | 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. | |
| 178 | | `/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. | | 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 | +| `/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. | | ||
| 179 | | `/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. | | 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. | |
| 180 | 182 | ||
| 181 | 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). | 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). |
modified
docs/en/reference/tools.md +21 -0 | @@ -288,6 +288,27 @@ 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_memory` | |
| 292 | + | |
| 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)). | |
| 294 | + | |
| 295 | +| Field | Type | Description | | |
| 296 | +|-------|------|-------------| | |
| 297 | +| `query` | string | The words to look for. | | |
| 298 | +| `limit` | integer | How many hits at most. `0` or absent: 10; capped at 50. | | |
| 299 | + | |
| 300 | +| Aspect | Behaviour | | |
| 301 | +|--------|-----------| | |
| 302 | +| Where | The session files under `sessions.dir`, resolved against the working directory: the process directory in terminal mode, the session `cwd` in ACP mode and the window. | | |
| 303 | +| Matching | The query is split into words; case and accents do not count (`preference` finds `Préférence`); a word matches anywhere in a text, also inside a longer word. Words of one or two letters (`de`, `a`, `of`) are left out when the query has longer ones. A message is a hit when it holds at least one word. | | |
| 304 | +| Searched | User questions (without the context a `preGeneration` hook appended), the model's answers, the notes of `/compact` summaries (without their wrapper line). A question that matches carries the answer its turn ended on, which is then not a hit of its own; its score counts the words of both. Not searched: the acknowledgement after a summary, tool calls and outputs, mm's own notes (`[mm: …`, a blocked prompt's marker), the `errors` array. | | |
| 305 | +| Left out | Every message of the conversation in progress, wherever it is stored (same role and text). | | |
| 306 | +| Order | By number of distinct words held, then by the session's last update (newest first), then by turn (latest first). | | |
| 307 | +| Result | `🔎 N hit(s) for "<query>" in <dir>`, then per hit `<session id> · <date> · turn <n> · question\|answer\|summary` and the text from the first word found to the last (80 characters before, at least 160 after the first, 600 at most, on one line, `…` where cut); for a question, a third line `→ <its answer>` — the text around the words, or its beginning. No hit: `🔎 no hit for "<query>" in <dir>` followed by `The search matches words, not meanings: try again with other words — synonyms, or the words in the language the earlier conversation was likely held in (often English).` An empty query: `⚠️ nothing to search: give one or more words`. Capped by `maxOutput`. | | |
| 308 | +| Permission | Never asked, even with `yolo: false`: the tool only reads. | | |
| 309 | +| Terminal display | `🔎 search_memory: "<query>"`, then the result. | | |
| 310 | +| ACP and window | Tool call of kind `search`. | | |
| 311 | + | |
| 291 | 312 | ## Declared tools |
| 292 | 313 | |
| 293 | 314 | Declared from the `tools:` block of the agent file, one tool per entry (see [configuration](configuration.md#tools-keys)). Each has the entry's `name`; its fields are the entry's `parameters`. |
| @@ -288,6 +288,27 @@ 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_memory` | ||
| 292 | + | ||
| 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)). | ||
| 294 | + | ||
| 295 | +| Field | Type | Description | | ||
| 296 | +|-------|------|-------------| | ||
| 297 | +| `query` | string | The words to look for. | | ||
| 298 | +| `limit` | integer | How many hits at most. `0` or absent: 10; capped at 50. | | ||
| 299 | + | ||
| 300 | +| Aspect | Behaviour | | ||
| 301 | +|--------|-----------| | ||
| 302 | +| Where | The session files under `sessions.dir`, resolved against the working directory: the process directory in terminal mode, the session `cwd` in ACP mode and the window. | | ||
| 303 | +| Matching | The query is split into words; case and accents do not count (`preference` finds `Préférence`); a word matches anywhere in a text, also inside a longer word. Words of one or two letters (`de`, `a`, `of`) are left out when the query has longer ones. A message is a hit when it holds at least one word. | | ||
| 304 | +| Searched | User questions (without the context a `preGeneration` hook appended), the model's answers, the notes of `/compact` summaries (without their wrapper line). A question that matches carries the answer its turn ended on, which is then not a hit of its own; its score counts the words of both. Not searched: the acknowledgement after a summary, tool calls and outputs, mm's own notes (`[mm: …`, a blocked prompt's marker), the `errors` array. | | ||
| 305 | +| Left out | Every message of the conversation in progress, wherever it is stored (same role and text). | | ||
| 306 | +| Order | By number of distinct words held, then by the session's last update (newest first), then by turn (latest first). | | ||
| 307 | +| Result | `🔎 N hit(s) for "<query>" in <dir>`, then per hit `<session id> · <date> · turn <n> · question\|answer\|summary` and the text from the first word found to the last (80 characters before, at least 160 after the first, 600 at most, on one line, `…` where cut); for a question, a third line `→ <its answer>` — the text around the words, or its beginning. No hit: `🔎 no hit for "<query>" in <dir>` followed by `The search matches words, not meanings: try again with other words — synonyms, or the words in the language the earlier conversation was likely held in (often English).` An empty query: `⚠️ nothing to search: give one or more words`. Capped by `maxOutput`. | | ||
| 308 | +| Permission | Never asked, even with `yolo: false`: the tool only reads. | | ||
| 309 | +| Terminal display | `🔎 search_memory: "<query>"`, then the result. | | ||
| 310 | +| ACP and window | Tool call of kind `search`. | | ||
| 311 | + | ||
| 291 | ## Declared tools | 312 | ## Declared tools |
| 292 | 313 | ||
| 293 | Declared from the `tools:` block of the agent file, one tool per entry (see [configuration](configuration.md#tools-keys)). Each has the entry's `name`; its fields are the entry's `parameters`. | 314 | Declared from the `tools:` block of the agent file, one tool per entry (see [configuration](configuration.md#tools-keys)). Each has the entry's `name`; its fields are the entry's `parameters`. |
modified
docs/fr/README.md +1 -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 les sessions précédentes](how-to/search-earlier-sessions.md) — `/memory <mots>`, l'outil `search_memory`, `sessions.searchMemory` / `sessions.autoSearchMemory` | |
| 30 | 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` |
| 31 | 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 |
| 32 | 33 | - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) |
| @@ -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 les sessions précédentes](how-to/search-earlier-sessions.md) — `/memory <mots>`, l'outil `search_memory`, `sessions.searchMemory` / `sessions.autoSearchMemory` | ||
| 30 | - [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` | 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` |
| 31 | - [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 | 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 |
| 32 | - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) | 33 | - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) |
modified
docs/fr/explanation/persistent-sessions.md +11 -0 | @@ -44,10 +44,21 @@ Dans la fenêtre, ↑ depuis la première rangée de la zone rappelle les questi | ||
| 44 | 44 | |
| 45 | 45 | La contrepartie : une question rappelée est ce que le modèle a lu, pas exactement ce qui a été tapé — les lignes `[attached file: …]` ajoutées par un `@chemin`, un collage en entier plutôt que son jeton, un `/ask` sous sa forme `[asked …]` ; les commandes `/`, jamais envoyées au modèle, n'y sont pas du tout. Ce que mm a écrit lui-même comme message utilisateur est écarté — un résumé de `/compact`, la note après un appel d'outil cassé, le marqueur d'un prompt bloqué par un hook — de même que le contexte ajouté par un hook `preGeneration`. Un trou que les fichiers ne peuvent pas combler : un tour qui échoue avant de répondre retire sa question de l'historique, et c'est justement celle qu'on veut relancer. La fenêtre la garde en mémoire jusqu'à la question suivante, pour que ↑ la retrouve ; elle n'est écrite nulle part. |
| 46 | 46 | |
| 47 | +## Pourquoi la recherche dans la mémoire porte sur les mots | |
| 48 | + | |
| 49 | +`/memory` et l'outil `search_memory` (ticket 48) parcourent les fichiers de session du projet à chaque appel : pas d'index, pas de base, pas de modèle d'embedding. Les sessions d'un projet pèsent quelques centaines de kilo-octets — les 43 fichiers de ce dépôt font 508 Ko — et les lire est instantané. Un modèle d'embedding trouverait « database » dans une session qui disait « storage », mais c'est un modèle de plus chargé à côté du modèle de chat, sur des machines où la mémoire est la contrainte ; ce genre de mémoire relève du travail sur la mémoire hiérarchique (ticket 74), pas d'une recherche. | |
| 50 | + | |
| 51 | +Le prix a été mesuré, sur Qwen3.5 4B via llmman avec deux sessions enregistrées (2026-10-20). Les questions dont les mots figuraient dans une session — le port de l'API, « le port convenu » pour écrire un fichier — ont été trouvées 9 fois sur 9 (un essai de chauffe, trois de chaque, puis un de chaque à nouveau après le dernier changement), et l'outil n'a jamais été appelé sur une salutation, un calcul, une liste de fichiers ou une demande de code (0 sur 12). Une question en français sur un choix écrit en anglais n'a rien trouvé : le modèle a cherché « base de données », puis, invité par la ligne « no hit » de l'outil à essayer l'anglais, « database » — que la session n'a jamais écrit (« storage », « SQLite »). Il a trouvé la réponse en lisant lui-même les fichiers JSON (5 essais sur 6 ; le sixième a été perdu à cause d'un blocage du serveur). | |
| 52 | + | |
| 53 | +La construction de `demos/memory-search/` a imposé trois règles de plus, chacune née d'une mauvaise réponse. Une question qui correspond vient avec la réponse sur laquelle son tour s'est terminé : « format logs » ne trouvait que la question « Quel format pour les logs ? » (la réponse dit « log/slog »), et le modèle concluait que rien n'avait été décidé. Les mots d'une ou deux lettres sont ignorés à côté de mots plus longs : « de » dans « base de données » trouvait « Deployment », « modernc » et « Module », renvoyait tout l'historique, et la bonne réponse arrivait par chance. Et un extrait va du premier mot trouvé au dernier : coupé après le premier, un résumé qui nommait le déploiement dans sa première ligne perdait sa ligne sur les sauvegardes, et le modèle répondait à côté. La ligne d'en-tête du résumé et l'accusé de réception qui le suit, les mots de mm, ne sont plus cherchés. | |
| 54 | + | |
| 55 | +Deux choix découlent de ce que l'outil est entre les mains du modèle. La conversation en cours est exclue par son contenu, pas par l'identifiant de session : aucune façade ne dit à un outil dans quel fichier elle enregistre, et le fichier d'une session reprise ou compressée contient justement les messages en mémoire ; « un message que le modèle a déjà » est donc à la fois ce qu'il faut écarter et quelque chose que chaque façade sait calculer. Et l'outil et la commande ont des clés séparées, toutes deux désactivées par défaut : un outil dans le catalogue d'un petit modèle change ce qu'il fait, même inutilisé, et le déclarer est la décision de l'utilisateur. | |
| 56 | + | |
| 47 | 57 | ## Alternatives écartées |
| 48 | 58 | |
| 49 | 59 | - **Reprendre automatiquement la dernière session au lancement.** Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus. |
| 50 | 60 | - **Un journal en ajout seul par session.** Écarté : la compression raccourcit l'historique, et un journal continuerait de dire ce que la mémoire ne contient plus. |
| 61 | +- **Un index ou des embeddings pour la recherche dans la mémoire (ticket 48).** Écartés en v1 : les fichiers sont assez petits pour être parcourus, et un modèle d'embedding est un modèle de plus en mémoire. Le coût — aucune correspondance entre langues ni entre synonymes — est mesuré plus haut. | |
| 51 | 62 | - **SQLite, comme docker agent.** Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet. |
| 52 | 63 | - **`session/resume` plutôt que `session/load`.** Écarté : dépendant d'un éditeur qui aurait gardé le fil, et hors de la spécification stable quand le choix a été fait. Depuis le ticket 95, il est proposé à côté de `session/load`, pas à sa place. |
| 53 | 64 | - **Une commande `/resume` en ACP.** Écartée (ticket 95) : c'est l'éditeur qui dessine le fil, donc une ancienne conversation rejouée dans un fil ouvert arriverait après ce qu'il affiche déjà, et le fichier ne correspondrait plus à l'identifiant de session de l'éditeur. Lister les sessions à l'éditeur est la voie du protocole. |
| @@ -44,10 +44,21 @@ Dans la fenêtre, ↑ depuis la première rangée de la zone rappelle les questi | |||
| 44 | 44 | ||
| 45 | La contrepartie : une question rappelée est ce que le modèle a lu, pas exactement ce qui a été tapé — les lignes `[attached file: …]` ajoutées par un `@chemin`, un collage en entier plutôt que son jeton, un `/ask` sous sa forme `[asked …]` ; les commandes `/`, jamais envoyées au modèle, n'y sont pas du tout. Ce que mm a écrit lui-même comme message utilisateur est écarté — un résumé de `/compact`, la note après un appel d'outil cassé, le marqueur d'un prompt bloqué par un hook — de même que le contexte ajouté par un hook `preGeneration`. Un trou que les fichiers ne peuvent pas combler : un tour qui échoue avant de répondre retire sa question de l'historique, et c'est justement celle qu'on veut relancer. La fenêtre la garde en mémoire jusqu'à la question suivante, pour que ↑ la retrouve ; elle n'est écrite nulle part. | 45 | La contrepartie : une question rappelée est ce que le modèle a lu, pas exactement ce qui a été tapé — les lignes `[attached file: …]` ajoutées par un `@chemin`, un collage en entier plutôt que son jeton, un `/ask` sous sa forme `[asked …]` ; les commandes `/`, jamais envoyées au modèle, n'y sont pas du tout. Ce que mm a écrit lui-même comme message utilisateur est écarté — un résumé de `/compact`, la note après un appel d'outil cassé, le marqueur d'un prompt bloqué par un hook — de même que le contexte ajouté par un hook `preGeneration`. Un trou que les fichiers ne peuvent pas combler : un tour qui échoue avant de répondre retire sa question de l'historique, et c'est justement celle qu'on veut relancer. La fenêtre la garde en mémoire jusqu'à la question suivante, pour que ↑ la retrouve ; elle n'est écrite nulle part. |
| 46 | 46 | ||
| 47 | +## Pourquoi la recherche dans la mémoire porte sur les mots | ||
| 48 | + | ||
| 49 | +`/memory` et l'outil `search_memory` (ticket 48) parcourent les fichiers de session du projet à chaque appel : pas d'index, pas de base, pas de modèle d'embedding. Les sessions d'un projet pèsent quelques centaines de kilo-octets — les 43 fichiers de ce dépôt font 508 Ko — et les lire est instantané. Un modèle d'embedding trouverait « database » dans une session qui disait « storage », mais c'est un modèle de plus chargé à côté du modèle de chat, sur des machines où la mémoire est la contrainte ; ce genre de mémoire relève du travail sur la mémoire hiérarchique (ticket 74), pas d'une recherche. | ||
| 50 | + | ||
| 51 | +Le prix a été mesuré, sur Qwen3.5 4B via llmman avec deux sessions enregistrées (2026-10-20). Les questions dont les mots figuraient dans une session — le port de l'API, « le port convenu » pour écrire un fichier — ont été trouvées 9 fois sur 9 (un essai de chauffe, trois de chaque, puis un de chaque à nouveau après le dernier changement), et l'outil n'a jamais été appelé sur une salutation, un calcul, une liste de fichiers ou une demande de code (0 sur 12). Une question en français sur un choix écrit en anglais n'a rien trouvé : le modèle a cherché « base de données », puis, invité par la ligne « no hit » de l'outil à essayer l'anglais, « database » — que la session n'a jamais écrit (« storage », « SQLite »). Il a trouvé la réponse en lisant lui-même les fichiers JSON (5 essais sur 6 ; le sixième a été perdu à cause d'un blocage du serveur). | ||
| 52 | + | ||
| 53 | +La construction de `demos/memory-search/` a imposé trois règles de plus, chacune née d'une mauvaise réponse. Une question qui correspond vient avec la réponse sur laquelle son tour s'est terminé : « format logs » ne trouvait que la question « Quel format pour les logs ? » (la réponse dit « log/slog »), et le modèle concluait que rien n'avait été décidé. Les mots d'une ou deux lettres sont ignorés à côté de mots plus longs : « de » dans « base de données » trouvait « Deployment », « modernc » et « Module », renvoyait tout l'historique, et la bonne réponse arrivait par chance. Et un extrait va du premier mot trouvé au dernier : coupé après le premier, un résumé qui nommait le déploiement dans sa première ligne perdait sa ligne sur les sauvegardes, et le modèle répondait à côté. La ligne d'en-tête du résumé et l'accusé de réception qui le suit, les mots de mm, ne sont plus cherchés. | ||
| 54 | + | ||
| 55 | +Deux choix découlent de ce que l'outil est entre les mains du modèle. La conversation en cours est exclue par son contenu, pas par l'identifiant de session : aucune façade ne dit à un outil dans quel fichier elle enregistre, et le fichier d'une session reprise ou compressée contient justement les messages en mémoire ; « un message que le modèle a déjà » est donc à la fois ce qu'il faut écarter et quelque chose que chaque façade sait calculer. Et l'outil et la commande ont des clés séparées, toutes deux désactivées par défaut : un outil dans le catalogue d'un petit modèle change ce qu'il fait, même inutilisé, et le déclarer est la décision de l'utilisateur. | ||
| 56 | + | ||
| 47 | ## Alternatives écartées | 57 | ## Alternatives écartées |
| 48 | 58 | ||
| 49 | - **Reprendre automatiquement la dernière session au lancement.** Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus. | 59 | - **Reprendre automatiquement la dernière session au lancement.** Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus. |
| 50 | - **Un journal en ajout seul par session.** Écarté : la compression raccourcit l'historique, et un journal continuerait de dire ce que la mémoire ne contient plus. | 60 | - **Un journal en ajout seul par session.** Écarté : la compression raccourcit l'historique, et un journal continuerait de dire ce que la mémoire ne contient plus. |
| 61 | +- **Un index ou des embeddings pour la recherche dans la mémoire (ticket 48).** Écartés en v1 : les fichiers sont assez petits pour être parcourus, et un modèle d'embedding est un modèle de plus en mémoire. Le coût — aucune correspondance entre langues ni entre synonymes — est mesuré plus haut. | ||
| 51 | - **SQLite, comme docker agent.** Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet. | 62 | - **SQLite, comme docker agent.** Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet. |
| 52 | - **`session/resume` plutôt que `session/load`.** Écarté : dépendant d'un éditeur qui aurait gardé le fil, et hors de la spécification stable quand le choix a été fait. Depuis le ticket 95, il est proposé à côté de `session/load`, pas à sa place. | 63 | - **`session/resume` plutôt que `session/load`.** Écarté : dépendant d'un éditeur qui aurait gardé le fil, et hors de la spécification stable quand le choix a été fait. Depuis le ticket 95, il est proposé à côté de `session/load`, pas à sa place. |
| 53 | - **Une commande `/resume` en ACP.** Écartée (ticket 95) : c'est l'éditeur qui dessine le fil, donc une ancienne conversation rejouée dans un fil ouvert arriverait après ce qu'il affiche déjà, et le fichier ne correspondrait plus à l'identifiant de session de l'éditeur. Lister les sessions à l'éditeur est la voie du protocole. | 64 | - **Une commande `/resume` en ACP.** Écartée (ticket 95) : c'est l'éditeur qui dessine le fil, donc une ancienne conversation rejouée dans un fil ouvert arriverait après ce qu'il affiche déjà, et le fichier ne correspondrait plus à l'identifiant de session de l'éditeur. Lister les sessions à l'éditeur est la voie du protocole. |
modified
docs/fr/how-to/keep-sessions.md +1 -0 | @@ -95,6 +95,7 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj | ||
| 95 | 95 | - **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct. Le tableau `errors` reste tel quel. |
| 96 | 96 | - **Erreurs en ACP.** L'éditeur montre l'échec à sa manière ; l'enregistrement va quand même dans le fichier du fil, et `/new` le déplace dans la conversation archivée avec l'historique auquel il appartient. |
| 97 | 97 | - **Reposer une question précédente (fenêtre).** Dans `mm -tui`, ↑ depuis la première rangée de la zone rappelle les questions de cette session, puis des précédentes. Voir [Rappeler une question précédente](use-the-window-mode.md#rappeler-une-question-précédente). |
| 98 | +- **Chercher ce qui a été dit avant.** `/memory <mots>` (avec `sessions.searchMemory`) et l'outil `search_memory` du modèle (avec `sessions.autoSearchMemory`). Voir [Chercher dans les sessions précédentes](search-earlier-sessions.md). | |
| 98 | 99 | - **Repartir de zéro volontairement.** Rien à faire : chaque lancement est une nouvelle session. Les anciennes ne reviennent que sur `/resume` ou sur un rechargement depuis l'éditeur. |
| 99 | 100 | |
| 100 | 101 | ## Voir aussi |
| @@ -95,6 +95,7 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj | |||
| 95 | - **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct. Le tableau `errors` reste tel quel. | 95 | - **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct. Le tableau `errors` reste tel quel. |
| 96 | - **Erreurs en ACP.** L'éditeur montre l'échec à sa manière ; l'enregistrement va quand même dans le fichier du fil, et `/new` le déplace dans la conversation archivée avec l'historique auquel il appartient. | 96 | - **Erreurs en ACP.** L'éditeur montre l'échec à sa manière ; l'enregistrement va quand même dans le fichier du fil, et `/new` le déplace dans la conversation archivée avec l'historique auquel il appartient. |
| 97 | - **Reposer une question précédente (fenêtre).** Dans `mm -tui`, ↑ depuis la première rangée de la zone rappelle les questions de cette session, puis des précédentes. Voir [Rappeler une question précédente](use-the-window-mode.md#rappeler-une-question-précédente). | 97 | - **Reposer une question précédente (fenêtre).** Dans `mm -tui`, ↑ depuis la première rangée de la zone rappelle les questions de cette session, puis des précédentes. Voir [Rappeler une question précédente](use-the-window-mode.md#rappeler-une-question-précédente). |
| 98 | +- **Chercher ce qui a été dit avant.** `/memory <mots>` (avec `sessions.searchMemory`) et l'outil `search_memory` du modèle (avec `sessions.autoSearchMemory`). Voir [Chercher dans les sessions précédentes](search-earlier-sessions.md). | ||
| 98 | - **Repartir de zéro volontairement.** Rien à faire : chaque lancement est une nouvelle session. Les anciennes ne reviennent que sur `/resume` ou sur un rechargement depuis l'éditeur. | 99 | - **Repartir de zéro volontairement.** Rien à faire : chaque lancement est une nouvelle session. Les anciennes ne reviennent que sur `/resume` ou sur un rechargement depuis l'éditeur. |
| 99 | 100 | ||
| 100 | ## Voir aussi | 101 | ## Voir aussi |
added
docs/fr/how-to/search-earlier-sessions.md +59 -0 | new file mode 100644 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +# Chercher dans les sessions précédentes | |
| 2 | + | |
| 3 | +Ce guide montre comment retrouver ce qui a été dit dans une conversation précédente d'un projet : à la main avec `/memory`, ou en laissant le modèle chercher avec son outil `search_memory`. Il suppose que les sessions sont conservées (voir [Conserver les sessions et les reprendre](keep-sessions.md)). | |
| 4 | + | |
| 5 | +## Chercher à la main | |
| 6 | + | |
| 7 | +1. Activez la commande dans le YAML de l'agent : | |
| 8 | + | |
| 9 | + ```yaml | |
| 10 | + sessions: | |
| 11 | + enabled: true | |
| 12 | + searchMemory: true | |
| 13 | + ``` | |
| 14 | + | |
| 15 | +2. Lancez mm — le terminal, la fenêtre (`mm -tui`) ou un éditeur — et tapez les mots dont vous vous souvenez : | |
| 16 | + | |
| 17 | + ```text | |
| 18 | + /memory port api | |
| 19 | + ``` | |
| 20 | + | |
| 21 | + ```text | |
| 22 | + 🔎 2 hit(s) for "port api" in /work/project/.mm/sessions | |
| 23 | + 20261010-090000-aaaaaaaa · 2026-10-10 09:30 · turn 1 · question | |
| 24 | + Which port should the API server listen on? | |
| 25 | + → Let's use port 8417 for the API server: 8080 is already taken by the admin UI. | |
| 26 | + … | |
| 27 | + ``` | |
| 28 | + | |
| 29 | +3. Pour reprendre cette conversation : `/resume 20261010-090000-aaaaaaaa` (terminal et fenêtre). | |
| 30 | + | |
| 31 | +Le modèle n'est pas appelé et la conversation en cours n'est pas modifiée. Un résultat contient au moins un des mots ; plus il en contient, plus il remonte. Une question vient avec la réponse sur laquelle son tour s'est terminé (`→`). | |
| 32 | + | |
| 33 | +## Laisser le modèle chercher | |
| 34 | + | |
| 35 | +1. Déclarez l'outil : | |
| 36 | + | |
| 37 | + ```yaml | |
| 38 | + sessions: | |
| 39 | + enabled: true | |
| 40 | + autoSearchMemory: true | |
| 41 | + ``` | |
| 42 | + | |
| 43 | +2. Demandez comme à un collègue qui était là : « Quel port avait-on décidé pour le serveur d'API ? », « Écris config.go avec le port convenu la dernière fois. » Le modèle appelle `search_memory`, lit les résultats et répond à partir d'eux. | |
| 44 | + | |
| 45 | +Les deux clés sont indépendantes : `searchMemory` seule vous donne la commande et aucun outil au modèle ; `autoSearchMemory` seule l'inverse. | |
| 46 | + | |
| 47 | +## Variantes | |
| 48 | + | |
| 49 | +- **Chercher dans la langue des sessions.** La recherche porte sur les mots, pas sur le sens : une question en français cherchée en français ne trouve rien dans une session écrite en anglais. Utilisez les mots réellement écrits ; quand l'outil ne trouve rien, sa sortie invite le modèle à essayer des synonymes ou l'anglais. Un autre mot pour la même chose — « database » pour une session qui disait « storage » et « SQLite » — n'est toujours pas trouvé. | |
| 50 | +- **Sessions dans un répertoire partagé.** Avec un `sessions.dir` absolu, la recherche couvre tous les projets qui y écrivent. | |
| 51 | +- **Sessions désactivées.** Chaque clé affiche alors un avertissement au démarrage et ne fait rien. | |
| 52 | + | |
| 53 | +## Voir aussi | |
| 54 | + | |
| 55 | +- `demos/memory-search/` — une démo mesurée : un assistant qui se souvient des décisions d'un projet | |
| 56 | +- [Référence de la configuration](../reference/configuration.md) — `sessions.searchMemory`, `sessions.autoSearchMemory` | |
| 57 | +- [Référence des commandes](../reference/slash-commands.md) — `/memory` | |
| 58 | +- [Référence des outils](../reference/tools.md#search_memory) — `search_memory` | |
| 59 | +- [Pourquoi les sessions fonctionnent ainsi](../explanation/persistent-sessions.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +# Chercher dans les sessions précédentes | ||
| 2 | + | ||
| 3 | +Ce guide montre comment retrouver ce qui a été dit dans une conversation précédente d'un projet : à la main avec `/memory`, ou en laissant le modèle chercher avec son outil `search_memory`. Il suppose que les sessions sont conservées (voir [Conserver les sessions et les reprendre](keep-sessions.md)). | ||
| 4 | + | ||
| 5 | +## Chercher à la main | ||
| 6 | + | ||
| 7 | +1. Activez la commande dans le YAML de l'agent : | ||
| 8 | + | ||
| 9 | + ```yaml | ||
| 10 | + sessions: | ||
| 11 | + enabled: true | ||
| 12 | + searchMemory: true | ||
| 13 | + ``` | ||
| 14 | + | ||
| 15 | +2. Lancez mm — le terminal, la fenêtre (`mm -tui`) ou un éditeur — et tapez les mots dont vous vous souvenez : | ||
| 16 | + | ||
| 17 | + ```text | ||
| 18 | + /memory port api | ||
| 19 | + ``` | ||
| 20 | + | ||
| 21 | + ```text | ||
| 22 | + 🔎 2 hit(s) for "port api" in /work/project/.mm/sessions | ||
| 23 | + 20261010-090000-aaaaaaaa · 2026-10-10 09:30 · turn 1 · question | ||
| 24 | + Which port should the API server listen on? | ||
| 25 | + → Let's use port 8417 for the API server: 8080 is already taken by the admin UI. | ||
| 26 | + … | ||
| 27 | + ``` | ||
| 28 | + | ||
| 29 | +3. Pour reprendre cette conversation : `/resume 20261010-090000-aaaaaaaa` (terminal et fenêtre). | ||
| 30 | + | ||
| 31 | +Le modèle n'est pas appelé et la conversation en cours n'est pas modifiée. Un résultat contient au moins un des mots ; plus il en contient, plus il remonte. Une question vient avec la réponse sur laquelle son tour s'est terminé (`→`). | ||
| 32 | + | ||
| 33 | +## Laisser le modèle chercher | ||
| 34 | + | ||
| 35 | +1. Déclarez l'outil : | ||
| 36 | + | ||
| 37 | + ```yaml | ||
| 38 | + sessions: | ||
| 39 | + enabled: true | ||
| 40 | + autoSearchMemory: true | ||
| 41 | + ``` | ||
| 42 | + | ||
| 43 | +2. Demandez comme à un collègue qui était là : « Quel port avait-on décidé pour le serveur d'API ? », « Écris config.go avec le port convenu la dernière fois. » Le modèle appelle `search_memory`, lit les résultats et répond à partir d'eux. | ||
| 44 | + | ||
| 45 | +Les deux clés sont indépendantes : `searchMemory` seule vous donne la commande et aucun outil au modèle ; `autoSearchMemory` seule l'inverse. | ||
| 46 | + | ||
| 47 | +## Variantes | ||
| 48 | + | ||
| 49 | +- **Chercher dans la langue des sessions.** La recherche porte sur les mots, pas sur le sens : une question en français cherchée en français ne trouve rien dans une session écrite en anglais. Utilisez les mots réellement écrits ; quand l'outil ne trouve rien, sa sortie invite le modèle à essayer des synonymes ou l'anglais. Un autre mot pour la même chose — « database » pour une session qui disait « storage » et « SQLite » — n'est toujours pas trouvé. | ||
| 50 | +- **Sessions dans un répertoire partagé.** Avec un `sessions.dir` absolu, la recherche couvre tous les projets qui y écrivent. | ||
| 51 | +- **Sessions désactivées.** Chaque clé affiche alors un avertissement au démarrage et ne fait rien. | ||
| 52 | + | ||
| 53 | +## Voir aussi | ||
| 54 | + | ||
| 55 | +- `demos/memory-search/` — une démo mesurée : un assistant qui se souvient des décisions d'un projet | ||
| 56 | +- [Référence de la configuration](../reference/configuration.md) — `sessions.searchMemory`, `sessions.autoSearchMemory` | ||
| 57 | +- [Référence des commandes](../reference/slash-commands.md) — `/memory` | ||
| 58 | +- [Référence des outils](../reference/tools.md#search_memory) — `search_memory` | ||
| 59 | +- [Pourquoi les sessions fonctionnent ainsi](../explanation/persistent-sessions.md) | ||
modified
docs/fr/reference/acp.md +1 -0 | @@ -159,6 +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 | 163 | | `other` | `other` | `ask_agent`, `ask_user` | |
| 163 | 164 | |
| 164 | 165 | ## Identifiants d'appel d'outil |
| @@ -159,6 +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 | | `other` | `other` | `ask_agent`, `ask_user` | | 163 | | `other` | `other` | `ask_agent`, `ask_user` | |
| 163 | 164 | ||
| 164 | ## Identifiants d'appel d'outil | 165 | ## Identifiants d'appel d'outil |
modified
docs/fr/reference/configuration.md +5 -1 | @@ -62,6 +62,10 @@ | ||
| 62 | 62 | |-----|------|--------|-------------| |
| 63 | 63 | | `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. | |
| 64 | 64 | | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. | |
| 65 | +| `sessions.searchMemory` | booléen | `false` | La commande `/memory <mots>` (terminal, fenêtre, éditeur) : cherche dans les sessions enregistrées du projet. Demande `sessions.enabled`. Voir [commandes](slash-commands.md) et [Chercher dans les sessions précédentes](../how-to/search-earlier-sessions.md). | | |
| 66 | +| `sessions.autoSearchMemory` | booléen | `false` | L'outil [`search_memory`](tools.md#search_memory) : la même recherche, appelée par le modèle. Indépendante de `searchMemory`. Demande `sessions.enabled`. | | |
| 67 | + | |
| 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`. | |
| 65 | 69 | |
| 66 | 70 | ## Clés de `trace` |
| 67 | 71 | |
| @@ -129,7 +133,7 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl | ||
| 129 | 133 | | Clé | Type | Défaut | Description | |
| 130 | 134 | |-----|------|--------|-------------| |
| 131 | 135 | | `tools` | liste | `[]` | Une entrée par outil déclaré. | |
| 132 | -| `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`, `unload_model`, `write_file`), même désactivé dans cet agent. | | |
| 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. | | |
| 133 | 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. | |
| 134 | 138 | | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. | |
| 135 | 139 | | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. | |
| @@ -62,6 +62,10 @@ | |||
| 62 | |-----|------|--------|-------------| | 62 | |-----|------|--------|-------------| |
| 63 | | `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. | | 63 | | `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. | |
| 64 | | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. | | 64 | | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. | |
| 65 | +| `sessions.searchMemory` | booléen | `false` | La commande `/memory <mots>` (terminal, fenêtre, éditeur) : cherche dans les sessions enregistrées du projet. Demande `sessions.enabled`. Voir [commandes](slash-commands.md) et [Chercher dans les sessions précédentes](../how-to/search-earlier-sessions.md). | | ||
| 66 | +| `sessions.autoSearchMemory` | booléen | `false` | L'outil [`search_memory`](tools.md#search_memory) : la même recherche, appelée par le modèle. Indépendante de `searchMemory`. Demande `sessions.enabled`. | | ||
| 67 | + | ||
| 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`. | ||
| 65 | 69 | ||
| 66 | ## Clés de `trace` | 70 | ## Clés de `trace` |
| 67 | 71 | ||
| @@ -129,7 +133,7 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl | |||
| 129 | | Clé | Type | Défaut | Description | | 133 | | Clé | Type | Défaut | Description | |
| 130 | |-----|------|--------|-------------| | 134 | |-----|------|--------|-------------| |
| 131 | | `tools` | liste | `[]` | Une entrée par outil déclaré. | | 135 | | `tools` | liste | `[]` | Une entrée par outil déclaré. | |
| 132 | -| `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`, `unload_model`, `write_file`), même désactivé dans cet agent. | | 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. | |
| 133 | | `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. | | 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. | |
| 134 | | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. | | 138 | | `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. | |
| 135 | | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. | | 139 | | `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. | |
modified
docs/fr/reference/slash-commands.md +3 -1 | @@ -14,6 +14,7 @@ | ||
| 14 | 14 | | `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` (suivi de ` · ⚠️ N error(s)` quand la session a enregistré des erreurs) ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. | |
| 15 | 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 | +| `/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 | 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. | |
| 18 | 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. | |
| 19 | 20 | |
| @@ -170,12 +171,13 @@ La notation marche aussi en mode ligne et en ACP, tapée à la main : un `$skill | ||
| 170 | 171 | |
| 171 | 172 | ## Mode ACP |
| 172 | 173 | |
| 173 | -Deux commandes existent en mode ACP, trois avec `unloadModels` (le défaut) : `/new`, `/compact` et `/unload`. 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). 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é. | |
| 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é. | |
| 174 | 175 | |
| 175 | 176 | | Commande | En ACP | |
| 176 | 177 | |----------|--------| |
| 177 | 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. | |
| 178 | 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 | +| `/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é. | | |
| 179 | 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é. | |
| 180 | 182 | |
| 181 | 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). |
| @@ -14,6 +14,7 @@ | |||
| 14 | | `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` (suivi de ` · ⚠️ N error(s)` quand la session a enregistré des erreurs) ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. | | 14 | | `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` (suivi de ` · ⚠️ N error(s)` quand la session a enregistré des erreurs) ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. | |
| 15 | | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. | | 15 | | `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. | |
| 16 | | `/save <fichier> [n]` | Écrit la dernière réponse (`n` : la n-ième depuis la fin) telle quelle dans un nouveau fichier, en créant les répertoires manquants, et affiche `💾 saved the last answer to <fichier> (N bytes)`. N'écrase jamais : un fichier existant donne `⚠️ <fichier> already exists — nothing was written; choose another name`. Un nom de fichier avec des espaces n'est pas pris en charge. Le modèle n'est pas appelé. | Envoyée au modèle comme nouveau message. | | 16 | | `/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 | | `/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. | | 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. | |
| 18 | | `/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. | | 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. | |
| 19 | 20 | ||
| @@ -170,12 +171,13 @@ La notation marche aussi en mode ligne et en ACP, tapée à la main : un `$skill | |||
| 170 | 171 | ||
| 171 | ## Mode ACP | 172 | ## Mode ACP |
| 172 | 173 | ||
| 173 | -Deux commandes existent en mode ACP, trois avec `unloadModels` (le défaut) : `/new`, `/compact` et `/unload`. 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). 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é. | 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é. |
| 174 | 175 | ||
| 175 | | Commande | En ACP | | 176 | | Commande | En ACP | |
| 176 | |----------|--------| | 177 | |----------|--------| |
| 177 | | `/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. | | 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. | |
| 178 | | `/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. | | 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 | +| `/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é. | | ||
| 179 | | `/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é. | | 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é. | |
| 180 | 182 | ||
| 181 | 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). | 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). |
modified
docs/fr/reference/tools.md +21 -0 | @@ -288,6 +288,27 @@ 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_memory` | |
| 292 | + | |
| 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)). | |
| 294 | + | |
| 295 | +| Champ | Type | Description | | |
| 296 | +|-------|------|-------------| | |
| 297 | +| `query` | chaîne | Les mots à chercher. | | |
| 298 | +| `limit` | entier | Nombre maximal de résultats. `0` ou absent : 10 ; plafonné à 50. | | |
| 299 | + | |
| 300 | +| Aspect | Comportement | | |
| 301 | +|--------|--------------| | |
| 302 | +| Où | Les fichiers de session sous `sessions.dir`, résolu 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. | | |
| 303 | +| Correspondance | La requête est découpée en mots ; la casse et les accents ne comptent pas (`preference` trouve `Préférence`) ; un mot correspond n'importe où dans un texte, y compris à l'intérieur d'un mot plus long. Les mots d'une ou deux lettres (`de`, `a`, `of`) sont ignorés quand la requête en contient de plus longs. Un message est un résultat quand il contient au moins un mot. | | |
| 304 | +| Cherché | Les questions de l'utilisateur (sans le contexte ajouté par un hook `preGeneration`), les réponses du modèle, les notes des résumés de `/compact` (sans leur ligne d'en-tête). Une question qui correspond porte la réponse sur laquelle son tour s'est terminé, qui n'est alors plus un résultat à part ; son score compte les mots des deux. Non cherché : l'accusé de réception après un résumé, les appels et sorties d'outils, les notes de mm (`[mm: …`, le marqueur d'un prompt bloqué), le tableau `errors`. | | |
| 305 | +| Exclu | Tout message de la conversation en cours, où qu'il soit enregistré (même rôle et même texte). | | |
| 306 | +| Ordre | Par nombre de mots distincts contenus, puis par dernière mise à jour de la session (la plus récente d'abord), puis par tour (le plus récent d'abord). | | |
| 307 | +| Résultat | `🔎 N hit(s) for "<requête>" in <rép>`, puis par résultat `<id de session> · <date> · turn <n> · question\|answer\|summary` et le texte du premier au dernier mot trouvé (80 caractères avant, au moins 160 après le premier, 600 au plus, sur une ligne, `…` là où il est coupé) ; pour une question, une troisième ligne `→ <sa réponse>` — le texte autour des mots, ou son début. Aucun résultat : `🔎 no hit for "<requête>" in <rép>` suivi de `The search matches words, not meanings: try again with other words — synonyms, or the words in the language the earlier conversation was likely held in (often English).` Une requête vide : `⚠️ nothing to search: give one or more words`. Plafonné par `maxOutput`. | | |
| 308 | +| Permission | Jamais demandée, même avec `yolo: false` : l'outil ne fait que lire. | | |
| 309 | +| Affichage terminal | `🔎 search_memory: "<requête>"`, puis le résultat. | | |
| 310 | +| ACP et fenêtre | Appel d'outil de type `search`. | | |
| 311 | + | |
| 291 | 312 | ## Outils déclarés |
| 292 | 313 | |
| 293 | 314 | Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée (voir la [configuration](configuration.md#clés-de-tools)). Chacun porte le `name` de l'entrée ; ses champs sont les `parameters` de l'entrée. |
| @@ -288,6 +288,27 @@ 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_memory` | ||
| 292 | + | ||
| 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)). | ||
| 294 | + | ||
| 295 | +| Champ | Type | Description | | ||
| 296 | +|-------|------|-------------| | ||
| 297 | +| `query` | chaîne | Les mots à chercher. | | ||
| 298 | +| `limit` | entier | Nombre maximal de résultats. `0` ou absent : 10 ; plafonné à 50. | | ||
| 299 | + | ||
| 300 | +| Aspect | Comportement | | ||
| 301 | +|--------|--------------| | ||
| 302 | +| Où | Les fichiers de session sous `sessions.dir`, résolu 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. | | ||
| 303 | +| Correspondance | La requête est découpée en mots ; la casse et les accents ne comptent pas (`preference` trouve `Préférence`) ; un mot correspond n'importe où dans un texte, y compris à l'intérieur d'un mot plus long. Les mots d'une ou deux lettres (`de`, `a`, `of`) sont ignorés quand la requête en contient de plus longs. Un message est un résultat quand il contient au moins un mot. | | ||
| 304 | +| Cherché | Les questions de l'utilisateur (sans le contexte ajouté par un hook `preGeneration`), les réponses du modèle, les notes des résumés de `/compact` (sans leur ligne d'en-tête). Une question qui correspond porte la réponse sur laquelle son tour s'est terminé, qui n'est alors plus un résultat à part ; son score compte les mots des deux. Non cherché : l'accusé de réception après un résumé, les appels et sorties d'outils, les notes de mm (`[mm: …`, le marqueur d'un prompt bloqué), le tableau `errors`. | | ||
| 305 | +| Exclu | Tout message de la conversation en cours, où qu'il soit enregistré (même rôle et même texte). | | ||
| 306 | +| Ordre | Par nombre de mots distincts contenus, puis par dernière mise à jour de la session (la plus récente d'abord), puis par tour (le plus récent d'abord). | | ||
| 307 | +| Résultat | `🔎 N hit(s) for "<requête>" in <rép>`, puis par résultat `<id de session> · <date> · turn <n> · question\|answer\|summary` et le texte du premier au dernier mot trouvé (80 caractères avant, au moins 160 après le premier, 600 au plus, sur une ligne, `…` là où il est coupé) ; pour une question, une troisième ligne `→ <sa réponse>` — le texte autour des mots, ou son début. Aucun résultat : `🔎 no hit for "<requête>" in <rép>` suivi de `The search matches words, not meanings: try again with other words — synonyms, or the words in the language the earlier conversation was likely held in (often English).` Une requête vide : `⚠️ nothing to search: give one or more words`. Plafonné par `maxOutput`. | | ||
| 308 | +| Permission | Jamais demandée, même avec `yolo: false` : l'outil ne fait que lire. | | ||
| 309 | +| Affichage terminal | `🔎 search_memory: "<requête>"`, puis le résultat. | | ||
| 310 | +| ACP et fenêtre | Appel d'outil de type `search`. | | ||
| 311 | + | ||
| 291 | ## Outils déclarés | 312 | ## Outils déclarés |
| 292 | 313 | ||
| 293 | Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée (voir la [configuration](configuration.md#clés-de-tools)). Chacun porte le `name` de l'entrée ; ses champs sont les `parameters` de l'entrée. | 314 | Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée (voir la [configuration](configuration.md#clés-de-tools)). Chacun porte le `name` de l'entrée ; ses champs sont les `parameters` de l'entrée. |
modified
internal/acp/acp.go +17 -1 | @@ -492,12 +492,28 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session, | ||
| 492 | 492 | case command.Is(input, command.Compact): |
| 493 | 493 | return f.compactSession(ctx, sid, s), true |
| 494 | 494 | } |
| 495 | - if cmd, arg, ok := command.Parse(input); ok && cmd.Name == command.Unload { | |
| 495 | + cmd, arg, ok := command.Parse(input) | |
| 496 | + switch { | |
| 497 | + case ok && cmd.Name == command.Unload: | |
| 496 | 498 | return f.unloadModel(ctx, sid, s, arg), true |
| 499 | + case ok && cmd.Name == command.Memory: | |
| 500 | + return f.searchMemory(ctx, sid, s, arg), true | |
| 497 | 501 | } |
| 498 | 502 | return sdk.PromptResponse{}, false |
| 499 | 503 | } |
| 500 | 504 | |
| 505 | +// 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. | |
| 507 | +func (f *front) searchMemory(ctx context.Context, sid sdk.SessionId, s *session, query string) sdk.PromptResponse { | |
| 508 | + text := "⚠️ " + store.ErrDisabled.Error() | |
| 509 | + if s.st != nil { | |
| 510 | + text = s.st.Memory(query, s.messages, 0) | |
| 511 | + } | |
| 512 | + fmt.Fprintf(os.Stderr, "[acp] session %s: /memory %q\n", sid, query) | |
| 513 | + _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: sdk.UpdateAgentMessageText(text)}) | |
| 514 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()} | |
| 515 | +} | |
| 516 | + | |
| 501 | 517 | // unloadModel is /unload [model] over ACP: the engine's one line, sent as the |
| 502 | 518 | // agent's answer; the history is not touched. |
| 503 | 519 | func (f *front) unloadModel(ctx context.Context, sid sdk.SessionId, s *session, model string) sdk.PromptResponse { |
| @@ -492,12 +492,28 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session, | |||
| 492 | case command.Is(input, command.Compact): | 492 | case command.Is(input, command.Compact): |
| 493 | return f.compactSession(ctx, sid, s), true | 493 | return f.compactSession(ctx, sid, s), true |
| 494 | } | 494 | } |
| 495 | - if cmd, arg, ok := command.Parse(input); ok && cmd.Name == command.Unload { | 495 | + cmd, arg, ok := command.Parse(input) |
| 496 | + switch { | ||
| 497 | + case ok && cmd.Name == command.Unload: | ||
| 496 | return f.unloadModel(ctx, sid, s, arg), true | 498 | return f.unloadModel(ctx, sid, s, arg), true |
| 499 | + case ok && cmd.Name == command.Memory: | ||
| 500 | + return f.searchMemory(ctx, sid, s, arg), true | ||
| 497 | } | 501 | } |
| 498 | return sdk.PromptResponse{}, false | 502 | return sdk.PromptResponse{}, false |
| 499 | } | 503 | } |
| 500 | 504 | ||
| 505 | +// 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. | ||
| 507 | +func (f *front) searchMemory(ctx context.Context, sid sdk.SessionId, s *session, query string) sdk.PromptResponse { | ||
| 508 | + text := "⚠️ " + store.ErrDisabled.Error() | ||
| 509 | + if s.st != nil { | ||
| 510 | + text = s.st.Memory(query, s.messages, 0) | ||
| 511 | + } | ||
| 512 | + fmt.Fprintf(os.Stderr, "[acp] session %s: /memory %q\n", sid, query) | ||
| 513 | + _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: sdk.UpdateAgentMessageText(text)}) | ||
| 514 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()} | ||
| 515 | +} | ||
| 516 | + | ||
| 501 | // unloadModel is /unload [model] over ACP: the engine's one line, sent as the | 517 | // unloadModel is /unload [model] over ACP: the engine's one line, sent as the |
| 502 | // agent's answer; the history is not touched. | 518 | // agent's answer; the history is not touched. |
| 503 | func (f *front) unloadModel(ctx context.Context, sid sdk.SessionId, s *session, model string) sdk.PromptResponse { | 519 | func (f *front) unloadModel(ctx context.Context, sid sdk.SessionId, s *session, model string) sdk.PromptResponse { |
modified
internal/acp/acp_test.go +1 -0 | @@ -42,6 +42,7 @@ func TestToolKindDefaultsToExecute(t *testing.T) { | ||
| 42 | 42 | "read": sdk.ToolKindRead, |
| 43 | 43 | "edit": sdk.ToolKindEdit, |
| 44 | 44 | "fetch": sdk.ToolKindFetch, |
| 45 | + "search": sdk.ToolKindSearch, | |
| 45 | 46 | "other": sdk.ToolKindOther, |
| 46 | 47 | "execute": sdk.ToolKindExecute, |
| 47 | 48 | "": sdk.ToolKindExecute, |
| @@ -42,6 +42,7 @@ func TestToolKindDefaultsToExecute(t *testing.T) { | |||
| 42 | "read": sdk.ToolKindRead, | 42 | "read": sdk.ToolKindRead, |
| 43 | "edit": sdk.ToolKindEdit, | 43 | "edit": sdk.ToolKindEdit, |
| 44 | "fetch": sdk.ToolKindFetch, | 44 | "fetch": sdk.ToolKindFetch, |
| 45 | + "search": sdk.ToolKindSearch, | ||
| 45 | "other": sdk.ToolKindOther, | 46 | "other": sdk.ToolKindOther, |
| 46 | "execute": sdk.ToolKindExecute, | 47 | "execute": sdk.ToolKindExecute, |
| 47 | "": sdk.ToolKindExecute, | 48 | "": sdk.ToolKindExecute, |
added
internal/acp/memory_test.go +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +package acp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "path/filepath" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | + | |
| 8 | + "mm/internal/command" | |
| 9 | + "mm/internal/store" | |
| 10 | + | |
| 11 | + sdk "github.com/coder/acp-go-sdk" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// With /memory on, the editor is offered it with an input hint, and | |
| 15 | +// session/prompt answers it from the session's project: the hits come back | |
| 16 | +// as the agent's text, the turn ends, and the history is untouched. | |
| 17 | +func TestMemoryOverACP(t *testing.T) { | |
| 18 | + withSessions(t) | |
| 19 | + command.SetMemory(true) | |
| 20 | + t.Cleanup(func() { command.SetMemory(false) }) | |
| 21 | + | |
| 22 | + advertised := false | |
| 23 | + for _, c := range availableCommands() { | |
| 24 | + if c.Name == command.Memory { | |
| 25 | + advertised = c.Input != nil | |
| 26 | + } | |
| 27 | + } | |
| 28 | + if !advertised { | |
| 29 | + t.Error("/memory is not advertised with an input hint") | |
| 30 | + } | |
| 31 | + | |
| 32 | + cwd := t.TempDir() | |
| 33 | + st := store.Open(filepath.Join(cwd, ".mm", "sessions")) | |
| 34 | + if err := st.Save(&store.Session{ID: "20260916-100000-aaaaaaaa", Cwd: cwd, Messages: storedHistory()}); err != nil { | |
| 35 | + t.Fatal(err) | |
| 36 | + } | |
| 37 | + w := newWire(t, "sys") | |
| 38 | + f := frontOf(t, w) | |
| 39 | + w.send(1, "session/new", `{"cwd":"`+cwd+`","mcpServers":[]}`) | |
| 40 | + _, resp := w.until(1) | |
| 41 | + sid := resp["result"].(map[string]any)["sessionId"].(string) | |
| 42 | + | |
| 43 | + w.send(2, "session/prompt", `{"sessionId":"`+sid+`","prompt":[{"type":"text","text":"/memory FILES"}]}`) | |
| 44 | + notes, resp := w.until(2) | |
| 45 | + if stop := resp["result"].(map[string]any)["stopReason"]; stop != "end_turn" { | |
| 46 | + t.Errorf("stopReason = %v", stop) | |
| 47 | + } | |
| 48 | + var text strings.Builder | |
| 49 | + for _, n := range notes { | |
| 50 | + if n["sessionUpdate"] == "agent_message_chunk" { | |
| 51 | + text.WriteString(n["content"].(map[string]any)["text"].(string)) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + got := text.String() | |
| 55 | + if !strings.HasPrefix(got, `🔎 1 hit(s) for "FILES"`) || !strings.Contains(got, "turn 1 · question\n list the files") { | |
| 56 | + t.Errorf("agent text = %q", got) | |
| 57 | + } | |
| 58 | + if msgs := f.sessions[sdk.SessionId(sid)].messages; len(msgs) != 1 { | |
| 59 | + t.Errorf("history: %d messages, want the system prompt alone", len(msgs)) | |
| 60 | + } | |
| 61 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +package acp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "path/filepath" | ||
| 5 | + "strings" | ||
| 6 | + "testing" | ||
| 7 | + | ||
| 8 | + "mm/internal/command" | ||
| 9 | + "mm/internal/store" | ||
| 10 | + | ||
| 11 | + sdk "github.com/coder/acp-go-sdk" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// With /memory on, the editor is offered it with an input hint, and | ||
| 15 | +// session/prompt answers it from the session's project: the hits come back | ||
| 16 | +// as the agent's text, the turn ends, and the history is untouched. | ||
| 17 | +func TestMemoryOverACP(t *testing.T) { | ||
| 18 | + withSessions(t) | ||
| 19 | + command.SetMemory(true) | ||
| 20 | + t.Cleanup(func() { command.SetMemory(false) }) | ||
| 21 | + | ||
| 22 | + advertised := false | ||
| 23 | + for _, c := range availableCommands() { | ||
| 24 | + if c.Name == command.Memory { | ||
| 25 | + advertised = c.Input != nil | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | + if !advertised { | ||
| 29 | + t.Error("/memory is not advertised with an input hint") | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + cwd := t.TempDir() | ||
| 33 | + st := store.Open(filepath.Join(cwd, ".mm", "sessions")) | ||
| 34 | + if err := st.Save(&store.Session{ID: "20260916-100000-aaaaaaaa", Cwd: cwd, Messages: storedHistory()}); err != nil { | ||
| 35 | + t.Fatal(err) | ||
| 36 | + } | ||
| 37 | + w := newWire(t, "sys") | ||
| 38 | + f := frontOf(t, w) | ||
| 39 | + w.send(1, "session/new", `{"cwd":"`+cwd+`","mcpServers":[]}`) | ||
| 40 | + _, resp := w.until(1) | ||
| 41 | + sid := resp["result"].(map[string]any)["sessionId"].(string) | ||
| 42 | + | ||
| 43 | + w.send(2, "session/prompt", `{"sessionId":"`+sid+`","prompt":[{"type":"text","text":"/memory FILES"}]}`) | ||
| 44 | + notes, resp := w.until(2) | ||
| 45 | + if stop := resp["result"].(map[string]any)["stopReason"]; stop != "end_turn" { | ||
| 46 | + t.Errorf("stopReason = %v", stop) | ||
| 47 | + } | ||
| 48 | + var text strings.Builder | ||
| 49 | + for _, n := range notes { | ||
| 50 | + if n["sessionUpdate"] == "agent_message_chunk" { | ||
| 51 | + text.WriteString(n["content"].(map[string]any)["text"].(string)) | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | + got := text.String() | ||
| 55 | + if !strings.HasPrefix(got, `🔎 1 hit(s) for "FILES"`) || !strings.Contains(got, "turn 1 · question\n list the files") { | ||
| 56 | + t.Errorf("agent text = %q", got) | ||
| 57 | + } | ||
| 58 | + if msgs := f.sessions[sdk.SessionId(sid)].messages; len(msgs) != 1 { | ||
| 59 | + t.Errorf("history: %d messages, want the system prompt alone", len(msgs)) | ||
| 60 | + } | ||
| 61 | +} | ||
modified
internal/acp/sink.go +2 -0 | @@ -166,6 +166,8 @@ func toolKind(kind string) sdk.ToolKind { | ||
| 166 | 166 | return sdk.ToolKindEdit |
| 167 | 167 | case "fetch": |
| 168 | 168 | return sdk.ToolKindFetch |
| 169 | + case "search": // search_memory | |
| 170 | + return sdk.ToolKindSearch | |
| 169 | 171 | case "other": // ask_agent, ask_user: a hand-off, a question — not a command |
| 170 | 172 | return sdk.ToolKindOther |
| 171 | 173 | default: |
| @@ -166,6 +166,8 @@ func toolKind(kind string) sdk.ToolKind { | |||
| 166 | return sdk.ToolKindEdit | 166 | return sdk.ToolKindEdit |
| 167 | case "fetch": | 167 | case "fetch": |
| 168 | return sdk.ToolKindFetch | 168 | return sdk.ToolKindFetch |
| 169 | + case "search": // search_memory | ||
| 170 | + return sdk.ToolKindSearch | ||
| 169 | case "other": // ask_agent, ask_user: a hand-off, a question — not a command | 171 | case "other": // ask_agent, ask_user: a hand-off, a question — not a command |
| 170 | return sdk.ToolKindOther | 172 | return sdk.ToolKindOther |
| 171 | default: | 173 | default: |
modified
internal/agent/agent.go +47 -30 | @@ -70,36 +70,8 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | ||
| 70 | 70 | // inputCh receives commands from the reader goroutine. |
| 71 | 71 | inputCh := make(chan string) |
| 72 | 72 | |
| 73 | - // startInputReader launches a goroutine that reads from Stdin. | |
| 74 | - // It is designed to restart if an interrupt (Ctrl+C) occurs. | |
| 75 | - startInputReader := func() { | |
| 76 | - go func() { | |
| 77 | - for { | |
| 78 | - reader := bufio.NewReader(os.Stdin) | |
| 79 | - line, err := reader.ReadString('\n') | |
| 80 | - if err != nil { | |
| 81 | - select { | |
| 82 | - case <-ctx.Done(): | |
| 83 | - return | |
| 84 | - default: | |
| 85 | - if err == io.EOF { | |
| 86 | - close(inputCh) | |
| 87 | - return | |
| 88 | - } | |
| 89 | - // If it's an interrupt, just continue to restart the reader. | |
| 90 | - continue | |
| 91 | - } | |
| 92 | - } | |
| 93 | - select { | |
| 94 | - case <-ctx.Done(): | |
| 95 | - return | |
| 96 | - case inputCh <- strings.TrimSpace(line): | |
| 97 | - } | |
| 98 | - } | |
| 99 | - }() | |
| 100 | - } | |
| 101 | - | |
| 102 | - startInputReader() | |
| 73 | + // The reader goroutine: every line typed — or piped — goes to inputCh. | |
| 74 | + go readInput(ctx, os.Stdin, inputCh) | |
| 103 | 75 | |
| 104 | 76 | // The permission prompt (yolo: false, MCP tools marked "ask"): the tools |
| 105 | 77 | // post their question, the waiting rooms below print it and route the |
| @@ -564,6 +536,8 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa | ||
| 564 | 536 | fmt.Println(saved.Command(modelAnswers(messages), arg)) |
| 565 | 537 | case command.Unload: |
| 566 | 538 | fmt.Println("🧹 " + l.e.Unload(l.ctx, arg)) |
| 539 | + case command.Memory: | |
| 540 | + fmt.Println(l.keep.Memory(arg, messages)) | |
| 567 | 541 | case command.Resume: |
| 568 | 542 | resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, "")) |
| 569 | 543 | if err != nil { |
| @@ -632,3 +606,46 @@ func RunPrompt(ctx context.Context, e *engine.Engine, system string, tools []ai. | ||
| 632 | 606 | fmt.Println() |
| 633 | 607 | } |
| 634 | 608 | } |
| 609 | + | |
| 610 | +// readInput sends each line of r, trimmed, to out, and closes out at the end | |
| 611 | +// of the input — a last line without a newline included. It stops when ctx | |
| 612 | +// is done. | |
| 613 | +// | |
| 614 | +// ONE bufio.Reader for the whole input, on purpose: a bufio.Reader reads | |
| 615 | +// ahead, up to 4 KB at a time. The REPL used to make a new one per line, and | |
| 616 | +// when several lines arrived at once — `printf '/sessions\n/help\n' | mm`, | |
| 617 | +// `mm agent.yaml < commands.txt` — the first reader took them all, handed | |
| 618 | +// back the first line and was dropped with the others in its buffer. A | |
| 619 | +// keyboard sends one line per Enter, so nothing was lost there. | |
| 620 | +// | |
| 621 | +// inputCh := make(chan string) | |
| 622 | +// go readInput(ctx, os.Stdin, inputCh) | |
| 623 | +func readInput(ctx context.Context, r io.Reader, out chan<- string) { | |
| 624 | + reader := bufio.NewReader(r) | |
| 625 | + for { | |
| 626 | + line, err := reader.ReadString('\n') | |
| 627 | + if err != nil && ctx.Err() != nil { | |
| 628 | + return | |
| 629 | + } | |
| 630 | + if err == io.EOF { | |
| 631 | + if last := strings.TrimSpace(line); last != "" { | |
| 632 | + select { | |
| 633 | + case <-ctx.Done(): | |
| 634 | + return | |
| 635 | + case out <- last: | |
| 636 | + } | |
| 637 | + } | |
| 638 | + close(out) | |
| 639 | + return | |
| 640 | + } | |
| 641 | + if err != nil { | |
| 642 | + // Another read error: try again, as the REPL always did. | |
| 643 | + continue | |
| 644 | + } | |
| 645 | + select { | |
| 646 | + case <-ctx.Done(): | |
| 647 | + return | |
| 648 | + case out <- strings.TrimSpace(line): | |
| 649 | + } | |
| 650 | + } | |
| 651 | +} | |
| @@ -70,36 +70,8 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | |||
| 70 | // inputCh receives commands from the reader goroutine. | 70 | // inputCh receives commands from the reader goroutine. |
| 71 | inputCh := make(chan string) | 71 | inputCh := make(chan string) |
| 72 | 72 | ||
| 73 | - // startInputReader launches a goroutine that reads from Stdin. | 73 | + // The reader goroutine: every line typed — or piped — goes to inputCh. |
| 74 | - // It is designed to restart if an interrupt (Ctrl+C) occurs. | 74 | + go readInput(ctx, os.Stdin, inputCh) |
| 75 | - startInputReader := func() { | ||
| 76 | - go func() { | ||
| 77 | - for { | ||
| 78 | - reader := bufio.NewReader(os.Stdin) | ||
| 79 | - line, err := reader.ReadString('\n') | ||
| 80 | - if err != nil { | ||
| 81 | - select { | ||
| 82 | - case <-ctx.Done(): | ||
| 83 | - return | ||
| 84 | - default: | ||
| 85 | - if err == io.EOF { | ||
| 86 | - close(inputCh) | ||
| 87 | - return | ||
| 88 | - } | ||
| 89 | - // If it's an interrupt, just continue to restart the reader. | ||
| 90 | - continue | ||
| 91 | - } | ||
| 92 | - } | ||
| 93 | - select { | ||
| 94 | - case <-ctx.Done(): | ||
| 95 | - return | ||
| 96 | - case inputCh <- strings.TrimSpace(line): | ||
| 97 | - } | ||
| 98 | - } | ||
| 99 | - }() | ||
| 100 | - } | ||
| 101 | - | ||
| 102 | - startInputReader() | ||
| 103 | 75 | ||
| 104 | // The permission prompt (yolo: false, MCP tools marked "ask"): the tools | 76 | // The permission prompt (yolo: false, MCP tools marked "ask"): the tools |
| 105 | // post their question, the waiting rooms below print it and route the | 77 | // post their question, the waiting rooms below print it and route the |
| @@ -564,6 +536,8 @@ func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Messa | |||
| 564 | fmt.Println(saved.Command(modelAnswers(messages), arg)) | 536 | fmt.Println(saved.Command(modelAnswers(messages), arg)) |
| 565 | case command.Unload: | 537 | case command.Unload: |
| 566 | fmt.Println("🧹 " + l.e.Unload(l.ctx, arg)) | 538 | fmt.Println("🧹 " + l.e.Unload(l.ctx, arg)) |
| 539 | + case command.Memory: | ||
| 540 | + fmt.Println(l.keep.Memory(arg, messages)) | ||
| 567 | case command.Resume: | 541 | case command.Resume: |
| 568 | resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, "")) | 542 | resumed, line, err := l.keep.Resume(arg, agentsmd.System(l.system, "")) |
| 569 | if err != nil { | 543 | if err != nil { |
| @@ -632,3 +606,46 @@ func RunPrompt(ctx context.Context, e *engine.Engine, system string, tools []ai. | |||
| 632 | fmt.Println() | 606 | fmt.Println() |
| 633 | } | 607 | } |
| 634 | } | 608 | } |
| 609 | + | ||
| 610 | +// readInput sends each line of r, trimmed, to out, and closes out at the end | ||
| 611 | +// of the input — a last line without a newline included. It stops when ctx | ||
| 612 | +// is done. | ||
| 613 | +// | ||
| 614 | +// ONE bufio.Reader for the whole input, on purpose: a bufio.Reader reads | ||
| 615 | +// ahead, up to 4 KB at a time. The REPL used to make a new one per line, and | ||
| 616 | +// when several lines arrived at once — `printf '/sessions\n/help\n' | mm`, | ||
| 617 | +// `mm agent.yaml < commands.txt` — the first reader took them all, handed | ||
| 618 | +// back the first line and was dropped with the others in its buffer. A | ||
| 619 | +// keyboard sends one line per Enter, so nothing was lost there. | ||
| 620 | +// | ||
| 621 | +// inputCh := make(chan string) | ||
| 622 | +// go readInput(ctx, os.Stdin, inputCh) | ||
| 623 | +func readInput(ctx context.Context, r io.Reader, out chan<- string) { | ||
| 624 | + reader := bufio.NewReader(r) | ||
| 625 | + for { | ||
| 626 | + line, err := reader.ReadString('\n') | ||
| 627 | + if err != nil && ctx.Err() != nil { | ||
| 628 | + return | ||
| 629 | + } | ||
| 630 | + if err == io.EOF { | ||
| 631 | + if last := strings.TrimSpace(line); last != "" { | ||
| 632 | + select { | ||
| 633 | + case <-ctx.Done(): | ||
| 634 | + return | ||
| 635 | + case out <- last: | ||
| 636 | + } | ||
| 637 | + } | ||
| 638 | + close(out) | ||
| 639 | + return | ||
| 640 | + } | ||
| 641 | + if err != nil { | ||
| 642 | + // Another read error: try again, as the REPL always did. | ||
| 643 | + continue | ||
| 644 | + } | ||
| 645 | + select { | ||
| 646 | + case <-ctx.Done(): | ||
| 647 | + return | ||
| 648 | + case out <- strings.TrimSpace(line): | ||
| 649 | + } | ||
| 650 | + } | ||
| 651 | +} | ||
added
internal/agent/input_test.go +55 -0 | new file mode 100644 | ||
| @@ -0,0 +1,55 @@ | ||
| 1 | +package agent | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | + "testing/iotest" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// collect runs readInput over r and returns every line it sent, in order. | |
| 11 | +func collect(t *testing.T, r interface{ Read([]byte) (int, error) }) []string { | |
| 12 | + t.Helper() | |
| 13 | + out := make(chan string) | |
| 14 | + go readInput(context.Background(), r, out) | |
| 15 | + var lines []string | |
| 16 | + for line := range out { | |
| 17 | + lines = append(lines, line) | |
| 18 | + } | |
| 19 | + return lines | |
| 20 | +} | |
| 21 | + | |
| 22 | +// Several lines arriving in one read — a pipe, a redirected file — all come | |
| 23 | +// through; the old one-reader-per-line loop kept only the first. | |
| 24 | +func TestReadInputKeepsEveryPipedLine(t *testing.T) { | |
| 25 | + got := collect(t, strings.NewReader("/memory logs\n/memory port\n /quit \n")) | |
| 26 | + if strings.Join(got, "|") != "/memory logs|/memory port|/quit" { | |
| 27 | + t.Errorf("lines = %q", got) | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +// One byte per read, like a slow terminal: same lines; a last line with no | |
| 32 | +// newline is not lost; an empty input just closes the channel. | |
| 33 | +func TestReadInputOneByteAtATimeAndLastLine(t *testing.T) { | |
| 34 | + got := collect(t, iotest.OneByteReader(strings.NewReader("hello\nbye"))) | |
| 35 | + if strings.Join(got, "|") != "hello|bye" { | |
| 36 | + t.Errorf("lines = %q", got) | |
| 37 | + } | |
| 38 | + if got := collect(t, strings.NewReader("")); len(got) != 0 { | |
| 39 | + t.Errorf("empty input gave %q", got) | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +// A cancelled context stops the reader without closing the channel under | |
| 44 | +// the REPL's feet. | |
| 45 | +func TestReadInputStopsWithTheContext(t *testing.T) { | |
| 46 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 47 | + out := make(chan string) | |
| 48 | + done := make(chan struct{}) | |
| 49 | + go func() { readInput(ctx, strings.NewReader("a\nb\n"), out); close(done) }() | |
| 50 | + if first := <-out; first != "a" { | |
| 51 | + t.Fatalf("first line = %q", first) | |
| 52 | + } | |
| 53 | + cancel() | |
| 54 | + <-done | |
| 55 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,55 @@ | |||
| 1 | +package agent | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "strings" | ||
| 6 | + "testing" | ||
| 7 | + "testing/iotest" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// collect runs readInput over r and returns every line it sent, in order. | ||
| 11 | +func collect(t *testing.T, r interface{ Read([]byte) (int, error) }) []string { | ||
| 12 | + t.Helper() | ||
| 13 | + out := make(chan string) | ||
| 14 | + go readInput(context.Background(), r, out) | ||
| 15 | + var lines []string | ||
| 16 | + for line := range out { | ||
| 17 | + lines = append(lines, line) | ||
| 18 | + } | ||
| 19 | + return lines | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +// Several lines arriving in one read — a pipe, a redirected file — all come | ||
| 23 | +// through; the old one-reader-per-line loop kept only the first. | ||
| 24 | +func TestReadInputKeepsEveryPipedLine(t *testing.T) { | ||
| 25 | + got := collect(t, strings.NewReader("/memory logs\n/memory port\n /quit \n")) | ||
| 26 | + if strings.Join(got, "|") != "/memory logs|/memory port|/quit" { | ||
| 27 | + t.Errorf("lines = %q", got) | ||
| 28 | + } | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +// One byte per read, like a slow terminal: same lines; a last line with no | ||
| 32 | +// newline is not lost; an empty input just closes the channel. | ||
| 33 | +func TestReadInputOneByteAtATimeAndLastLine(t *testing.T) { | ||
| 34 | + got := collect(t, iotest.OneByteReader(strings.NewReader("hello\nbye"))) | ||
| 35 | + if strings.Join(got, "|") != "hello|bye" { | ||
| 36 | + t.Errorf("lines = %q", got) | ||
| 37 | + } | ||
| 38 | + if got := collect(t, strings.NewReader("")); len(got) != 0 { | ||
| 39 | + t.Errorf("empty input gave %q", got) | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +// A cancelled context stops the reader without closing the channel under | ||
| 44 | +// the REPL's feet. | ||
| 45 | +func TestReadInputStopsWithTheContext(t *testing.T) { | ||
| 46 | + ctx, cancel := context.WithCancel(context.Background()) | ||
| 47 | + out := make(chan string) | ||
| 48 | + done := make(chan struct{}) | ||
| 49 | + go func() { readInput(ctx, strings.NewReader("a\nb\n"), out); close(done) }() | ||
| 50 | + if first := <-out; first != "a" { | ||
| 51 | + t.Fatalf("first line = %q", first) | ||
| 52 | + } | ||
| 53 | + cancel() | ||
| 54 | + <-done | ||
| 55 | +} | ||
added
internal/agent/memory_test.go +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +package agent | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "io" | |
| 6 | + "os" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + | |
| 10 | + "mm/internal/command" | |
| 11 | + "mm/internal/config" | |
| 12 | + "mm/internal/store" | |
| 13 | + | |
| 14 | + "github.com/firebase/genkit/go/ai" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// stdoutOf runs fn and returns what it printed. | |
| 18 | +func stdoutOf(t *testing.T, fn func()) string { | |
| 19 | + t.Helper() | |
| 20 | + r, w, err := os.Pipe() | |
| 21 | + if err != nil { | |
| 22 | + t.Fatal(err) | |
| 23 | + } | |
| 24 | + saved := os.Stdout | |
| 25 | + os.Stdout = w | |
| 26 | + fn() | |
| 27 | + os.Stdout = saved | |
| 28 | + _ = w.Close() | |
| 29 | + out, _ := io.ReadAll(r) | |
| 30 | + return string(out) | |
| 31 | +} | |
| 32 | + | |
| 33 | +// In the line mode /memory prints the hits and leaves the history alone. | |
| 34 | +func TestMemoryCommandInTheLineMode(t *testing.T) { | |
| 35 | + command.SetMemory(true) | |
| 36 | + t.Cleanup(func() { command.SetMemory(false) }) | |
| 37 | + saved := config.Cfg.Sessions | |
| 38 | + t.Cleanup(func() { config.Cfg.Sessions = saved }) | |
| 39 | + config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", SearchMemory: true} | |
| 40 | + | |
| 41 | + keep := store.NewKeeper(t.TempDir()) | |
| 42 | + if err := store.Open(keep.Dir()).Save(&store.Session{ID: "20260101-000000-aaaaaaaa", Messages: []*ai.Message{ai.NewUserTextMessage("rename with gopls")}}); err != nil { | |
| 43 | + t.Fatal(err) | |
| 44 | + } | |
| 45 | + l := &loop{ctx: context.Background(), keep: keep} | |
| 46 | + history := []*ai.Message{ai.NewSystemTextMessage("sys")} | |
| 47 | + var got []*ai.Message | |
| 48 | + var handled bool | |
| 49 | + out := stdoutOf(t, func() { got, handled = l.historyCommand("/memory gopls", history) }) | |
| 50 | + if !handled || len(got) != 1 { | |
| 51 | + t.Fatalf("handled %v, history %d messages", handled, len(got)) | |
| 52 | + } | |
| 53 | + if !strings.Contains(out, `🔎 1 hit(s) for "gopls"`) || !strings.Contains(out, "rename with gopls") { | |
| 54 | + t.Errorf("printed:\n%s", out) | |
| 55 | + } | |
| 56 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +package agent | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "io" | ||
| 6 | + "os" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | + | ||
| 10 | + "mm/internal/command" | ||
| 11 | + "mm/internal/config" | ||
| 12 | + "mm/internal/store" | ||
| 13 | + | ||
| 14 | + "github.com/firebase/genkit/go/ai" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// stdoutOf runs fn and returns what it printed. | ||
| 18 | +func stdoutOf(t *testing.T, fn func()) string { | ||
| 19 | + t.Helper() | ||
| 20 | + r, w, err := os.Pipe() | ||
| 21 | + if err != nil { | ||
| 22 | + t.Fatal(err) | ||
| 23 | + } | ||
| 24 | + saved := os.Stdout | ||
| 25 | + os.Stdout = w | ||
| 26 | + fn() | ||
| 27 | + os.Stdout = saved | ||
| 28 | + _ = w.Close() | ||
| 29 | + out, _ := io.ReadAll(r) | ||
| 30 | + return string(out) | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +// In the line mode /memory prints the hits and leaves the history alone. | ||
| 34 | +func TestMemoryCommandInTheLineMode(t *testing.T) { | ||
| 35 | + command.SetMemory(true) | ||
| 36 | + t.Cleanup(func() { command.SetMemory(false) }) | ||
| 37 | + saved := config.Cfg.Sessions | ||
| 38 | + t.Cleanup(func() { config.Cfg.Sessions = saved }) | ||
| 39 | + config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", SearchMemory: true} | ||
| 40 | + | ||
| 41 | + keep := store.NewKeeper(t.TempDir()) | ||
| 42 | + if err := store.Open(keep.Dir()).Save(&store.Session{ID: "20260101-000000-aaaaaaaa", Messages: []*ai.Message{ai.NewUserTextMessage("rename with gopls")}}); err != nil { | ||
| 43 | + t.Fatal(err) | ||
| 44 | + } | ||
| 45 | + l := &loop{ctx: context.Background(), keep: keep} | ||
| 46 | + history := []*ai.Message{ai.NewSystemTextMessage("sys")} | ||
| 47 | + var got []*ai.Message | ||
| 48 | + var handled bool | ||
| 49 | + out := stdoutOf(t, func() { got, handled = l.historyCommand("/memory gopls", history) }) | ||
| 50 | + if !handled || len(got) != 1 { | ||
| 51 | + t.Fatalf("handled %v, history %d messages", handled, len(got)) | ||
| 52 | + } | ||
| 53 | + if !strings.Contains(out, `🔎 1 hit(s) for "gopls"`) || !strings.Contains(out, "rename with gopls") { | ||
| 54 | + t.Errorf("printed:\n%s", out) | ||
| 55 | + } | ||
| 56 | +} | ||
modified
internal/command/command.go +2 -1 | @@ -42,6 +42,7 @@ const ( | ||
| 42 | 42 | Copy = "copy" |
| 43 | 43 | Save = "save" |
| 44 | 44 | Unload = "unload" |
| 45 | + Memory = "memory" | |
| 45 | 46 | ) |
| 46 | 47 | |
| 47 | 48 | // Command is one slash command: what it is called, what it does, who offers |
| @@ -81,7 +82,7 @@ var table = []Command{ | ||
| 81 | 82 | // when the configuration allows it (unload.go), then the /ask family generated |
| 82 | 83 | // from the configured agents (ask.go). |
| 83 | 84 | func All() []Command { |
| 84 | - return append(append(append([]Command(nil), table...), unloadCommands...), askCommands...) | |
| 85 | + return append(append(append(append([]Command(nil), table...), memoryCommands...), unloadCommands...), askCommands...) | |
| 85 | 86 | } |
| 86 | 87 | |
| 87 | 88 | // For returns the commands front f offers, in display order. |
| @@ -42,6 +42,7 @@ const ( | |||
| 42 | Copy = "copy" | 42 | Copy = "copy" |
| 43 | Save = "save" | 43 | Save = "save" |
| 44 | Unload = "unload" | 44 | Unload = "unload" |
| 45 | + Memory = "memory" | ||
| 45 | ) | 46 | ) |
| 46 | 47 | ||
| 47 | // Command is one slash command: what it is called, what it does, who offers | 48 | // Command is one slash command: what it is called, what it does, who offers |
| @@ -81,7 +82,7 @@ var table = []Command{ | |||
| 81 | // when the configuration allows it (unload.go), then the /ask family generated | 82 | // when the configuration allows it (unload.go), then the /ask family generated |
| 82 | // from the configured agents (ask.go). | 83 | // from the configured agents (ask.go). |
| 83 | func All() []Command { | 84 | func All() []Command { |
| 84 | - return append(append(append([]Command(nil), table...), unloadCommands...), askCommands...) | 85 | + return append(append(append(append([]Command(nil), table...), memoryCommands...), unloadCommands...), askCommands...) |
| 85 | } | 86 | } |
| 86 | 87 | ||
| 87 | // For returns the commands front f offers, in display order. | 88 | // For returns the commands front f offers, in display order. |
added
internal/command/memory.go +24 -0 | new file mode 100644 | ||
| @@ -0,0 +1,24 @@ | ||
| 1 | +package command | |
| 2 | + | |
| 3 | +// memoryCommands holds /memory when the config offers it (sessions on and | |
| 4 | +// `sessions.searchMemory`); empty otherwise, so /help and the menus do not | |
| 5 | +// show a command that would only answer "off". | |
| 6 | +var memoryCommands []Command | |
| 7 | + | |
| 8 | +// SetMemory adds /memory <words> to every front end, the editor included, or | |
| 9 | +// removes it. Called once by main after the config is loaded. | |
| 10 | +// | |
| 11 | +// command.SetMemory(config.Cfg.Sessions.MemoryCommand()) | |
| 12 | +// cmd, arg, _ := command.Parse("/memory watchdog timeout") // Memory, "watchdog timeout" | |
| 13 | +func SetMemory(enabled bool) { | |
| 14 | + memoryCommands = nil | |
| 15 | + if enabled { | |
| 16 | + memoryCommands = []Command{{ | |
| 17 | + Name: Memory, | |
| 18 | + Description: "Search the project's stored sessions for these words", | |
| 19 | + Fronts: Terminal | Editor | Window, | |
| 20 | + TakesArg: true, | |
| 21 | + Hint: "<words>", | |
| 22 | + }} | |
| 23 | + } | |
| 24 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,24 @@ | |||
| 1 | +package command | ||
| 2 | + | ||
| 3 | +// memoryCommands holds /memory when the config offers it (sessions on and | ||
| 4 | +// `sessions.searchMemory`); empty otherwise, so /help and the menus do not | ||
| 5 | +// show a command that would only answer "off". | ||
| 6 | +var memoryCommands []Command | ||
| 7 | + | ||
| 8 | +// SetMemory adds /memory <words> to every front end, the editor included, or | ||
| 9 | +// removes it. Called once by main after the config is loaded. | ||
| 10 | +// | ||
| 11 | +// command.SetMemory(config.Cfg.Sessions.MemoryCommand()) | ||
| 12 | +// cmd, arg, _ := command.Parse("/memory watchdog timeout") // Memory, "watchdog timeout" | ||
| 13 | +func SetMemory(enabled bool) { | ||
| 14 | + memoryCommands = nil | ||
| 15 | + if enabled { | ||
| 16 | + memoryCommands = []Command{{ | ||
| 17 | + Name: Memory, | ||
| 18 | + Description: "Search the project's stored sessions for these words", | ||
| 19 | + Fronts: Terminal | Editor | Window, | ||
| 20 | + TakesArg: true, | ||
| 21 | + Hint: "<words>", | ||
| 22 | + }} | ||
| 23 | + } | ||
| 24 | +} | ||
added
internal/command/memory_test.go +27 -0 | new file mode 100644 | ||
| @@ -0,0 +1,27 @@ | ||
| 1 | +package command | |
| 2 | + | |
| 3 | +import "testing" | |
| 4 | + | |
| 5 | +func TestSetMemoryAddsAndRemovesTheCommand(t *testing.T) { | |
| 6 | + t.Cleanup(func() { SetMemory(false) }) | |
| 7 | + | |
| 8 | + SetMemory(true) | |
| 9 | + cmd, arg, ok := Parse("/memory watchdog timeout") | |
| 10 | + if !ok || cmd.Name != Memory || arg != "watchdog timeout" { | |
| 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 == Memory | |
| 17 | + } | |
| 18 | + if !found { | |
| 19 | + t.Errorf("front %d does not offer /memory", f) | |
| 20 | + } | |
| 21 | + } | |
| 22 | + | |
| 23 | + SetMemory(false) | |
| 24 | + if _, _, ok := Parse("/memory x"); ok { | |
| 25 | + t.Error("/memory 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 TestSetMemoryAddsAndRemovesTheCommand(t *testing.T) { | ||
| 6 | + t.Cleanup(func() { SetMemory(false) }) | ||
| 7 | + | ||
| 8 | + SetMemory(true) | ||
| 9 | + cmd, arg, ok := Parse("/memory watchdog timeout") | ||
| 10 | + if !ok || cmd.Name != Memory || arg != "watchdog timeout" { | ||
| 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 == Memory | ||
| 17 | + } | ||
| 18 | + if !found { | ||
| 19 | + t.Errorf("front %d does not offer /memory", f) | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + SetMemory(false) | ||
| 24 | + if _, _, ok := Parse("/memory x"); ok { | ||
| 25 | + t.Error("/memory still parsed with the command off") | ||
| 26 | + } | ||
| 27 | +} | ||
modified
internal/compact/compact.go +20 -0 | @@ -283,6 +283,26 @@ func IsSummary(m *ai.Message) bool { | ||
| 283 | 283 | return m != nil && m.Role == ai.RoleUser && m.Metadata[metaKey] == "summary" |
| 284 | 284 | } |
| 285 | 285 | |
| 286 | +// InSummaryPair reports whether a message is either half of a summary pair: | |
| 287 | +// the notes, or the model's fixed acknowledgement after them. | |
| 288 | +func InSummaryPair(m *ai.Message) bool { | |
| 289 | + return m != nil && m.Metadata[metaKey] == "summary" | |
| 290 | +} | |
| 291 | + | |
| 292 | +// SummaryNotes is the text of a summary's notes without the wrapper line | |
| 293 | +// SummaryPair puts before them — the model's own notes, what is worth | |
| 294 | +// searching or quoting. A message that is not a summary is returned whole. | |
| 295 | +// | |
| 296 | +// notes := compact.SummaryNotes(msgs[1]) // "- Deployment: one static binary…" | |
| 297 | +func SummaryNotes(m *ai.Message) string { | |
| 298 | + text := m.Text() | |
| 299 | + if !IsSummary(m) || !strings.HasPrefix(text, summaryWrapper) { | |
| 300 | + return text | |
| 301 | + } | |
| 302 | + _, notes, _ := strings.Cut(text, "]\n\n") | |
| 303 | + return notes | |
| 304 | +} | |
| 305 | + | |
| 286 | 306 | // flatten joins turns back into one message list. |
| 287 | 307 | func flatten(turns [][]*ai.Message) []*ai.Message { |
| 288 | 308 | var out []*ai.Message |
| @@ -283,6 +283,26 @@ func IsSummary(m *ai.Message) bool { | |||
| 283 | return m != nil && m.Role == ai.RoleUser && m.Metadata[metaKey] == "summary" | 283 | return m != nil && m.Role == ai.RoleUser && m.Metadata[metaKey] == "summary" |
| 284 | } | 284 | } |
| 285 | 285 | ||
| 286 | +// InSummaryPair reports whether a message is either half of a summary pair: | ||
| 287 | +// the notes, or the model's fixed acknowledgement after them. | ||
| 288 | +func InSummaryPair(m *ai.Message) bool { | ||
| 289 | + return m != nil && m.Metadata[metaKey] == "summary" | ||
| 290 | +} | ||
| 291 | + | ||
| 292 | +// SummaryNotes is the text of a summary's notes without the wrapper line | ||
| 293 | +// SummaryPair puts before them — the model's own notes, what is worth | ||
| 294 | +// searching or quoting. A message that is not a summary is returned whole. | ||
| 295 | +// | ||
| 296 | +// notes := compact.SummaryNotes(msgs[1]) // "- Deployment: one static binary…" | ||
| 297 | +func SummaryNotes(m *ai.Message) string { | ||
| 298 | + text := m.Text() | ||
| 299 | + if !IsSummary(m) || !strings.HasPrefix(text, summaryWrapper) { | ||
| 300 | + return text | ||
| 301 | + } | ||
| 302 | + _, notes, _ := strings.Cut(text, "]\n\n") | ||
| 303 | + return notes | ||
| 304 | +} | ||
| 305 | + | ||
| 286 | // flatten joins turns back into one message list. | 306 | // flatten joins turns back into one message list. |
| 287 | func flatten(turns [][]*ai.Message) []*ai.Message { | 307 | func flatten(turns [][]*ai.Message) []*ai.Message { |
| 288 | var out []*ai.Message | 308 | var out []*ai.Message |
modified
internal/compact/compact_test.go +16 -0 | @@ -364,3 +364,19 @@ func TestCompactThroughEngineSummarize(t *testing.T) { | ||
| 364 | 364 | t.Errorf("invalid history: %v", err) |
| 365 | 365 | } |
| 366 | 366 | } |
| 367 | + | |
| 368 | +// The notes of a summary come back without their wrapper line; both halves | |
| 369 | +// of the pair are recognised, and nothing else is. | |
| 370 | +func TestSummaryNotesAndPair(t *testing.T) { | |
| 371 | + pair := SummaryPair("- port 8417\n\n- SQLite", 6, 3, 0) | |
| 372 | + if got := SummaryNotes(pair[0]); got != "- port 8417\n\n- SQLite" { | |
| 373 | + t.Errorf("SummaryNotes = %q", got) | |
| 374 | + } | |
| 375 | + if !InSummaryPair(pair[0]) || !InSummaryPair(pair[1]) || InSummaryPair(ai.NewUserTextMessage("q")) || InSummaryPair(nil) { | |
| 376 | + t.Error("InSummaryPair does not recognise exactly the two halves") | |
| 377 | + } | |
| 378 | + plain := ai.NewUserTextMessage("[Context summary] is what I typed") | |
| 379 | + if SummaryNotes(plain) != plain.Text() { | |
| 380 | + t.Error("a plain message was cut") | |
| 381 | + } | |
| 382 | +} | |
| @@ -364,3 +364,19 @@ func TestCompactThroughEngineSummarize(t *testing.T) { | |||
| 364 | t.Errorf("invalid history: %v", err) | 364 | t.Errorf("invalid history: %v", err) |
| 365 | } | 365 | } |
| 366 | } | 366 | } |
| 367 | + | ||
| 368 | +// The notes of a summary come back without their wrapper line; both halves | ||
| 369 | +// of the pair are recognised, and nothing else is. | ||
| 370 | +func TestSummaryNotesAndPair(t *testing.T) { | ||
| 371 | + pair := SummaryPair("- port 8417\n\n- SQLite", 6, 3, 0) | ||
| 372 | + if got := SummaryNotes(pair[0]); got != "- port 8417\n\n- SQLite" { | ||
| 373 | + t.Errorf("SummaryNotes = %q", got) | ||
| 374 | + } | ||
| 375 | + if !InSummaryPair(pair[0]) || !InSummaryPair(pair[1]) || InSummaryPair(ai.NewUserTextMessage("q")) || InSummaryPair(nil) { | ||
| 376 | + t.Error("InSummaryPair does not recognise exactly the two halves") | ||
| 377 | + } | ||
| 378 | + plain := ai.NewUserTextMessage("[Context summary] is what I typed") | ||
| 379 | + if SummaryNotes(plain) != plain.Text() { | ||
| 380 | + t.Error("a plain message was cut") | ||
| 381 | + } | ||
| 382 | +} | ||
modified
internal/compact/prompt.go +4 -1 | @@ -79,13 +79,16 @@ func Request(old []*ai.Message, prompt string, merge bool) []*ai.Message { | ||
| 79 | 79 | return req |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | +// summaryWrapper opens the line that introduces a summary's notes. | |
| 83 | +const summaryWrapper = "[Context summary" | |
| 84 | + | |
| 82 | 85 | // SummaryPair wraps the model's notes into the two messages that replace the |
| 83 | 86 | // old turns. The wrapper says what the notes are and what they are not: the |
| 84 | 87 | // model must treat them as notes to verify, not as observations — otherwise a |
| 85 | 88 | // wrong line in the summary becomes a fact for the rest of the session. |
| 86 | 89 | func SummaryPair(summary string, messages, questions, commands int) []*ai.Message { |
| 87 | 90 | meta := map[string]any{metaKey: "summary"} |
| 88 | - notes := fmt.Sprintf(`[Context summary — the %d earlier messages of this session (%d question(s), %d command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.] | |
| 91 | + notes := fmt.Sprintf(summaryWrapper+` — the %d earlier messages of this session (%d question(s), %d command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.] | |
| 89 | 92 | |
| 90 | 93 | %s`, messages, questions, commands, summary) |
| 91 | 94 | return []*ai.Message{ |
| @@ -79,13 +79,16 @@ func Request(old []*ai.Message, prompt string, merge bool) []*ai.Message { | |||
| 79 | return req | 79 | return req |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | +// summaryWrapper opens the line that introduces a summary's notes. | ||
| 83 | +const summaryWrapper = "[Context summary" | ||
| 84 | + | ||
| 82 | // SummaryPair wraps the model's notes into the two messages that replace the | 85 | // SummaryPair wraps the model's notes into the two messages that replace the |
| 83 | // old turns. The wrapper says what the notes are and what they are not: the | 86 | // old turns. The wrapper says what the notes are and what they are not: the |
| 84 | // model must treat them as notes to verify, not as observations — otherwise a | 87 | // model must treat them as notes to verify, not as observations — otherwise a |
| 85 | // wrong line in the summary becomes a fact for the rest of the session. | 88 | // wrong line in the summary becomes a fact for the rest of the session. |
| 86 | func SummaryPair(summary string, messages, questions, commands int) []*ai.Message { | 89 | func SummaryPair(summary string, messages, questions, commands int) []*ai.Message { |
| 87 | meta := map[string]any{metaKey: "summary"} | 90 | meta := map[string]any{metaKey: "summary"} |
| 88 | - notes := fmt.Sprintf(`[Context summary — the %d earlier messages of this session (%d question(s), %d command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.] | 91 | + notes := fmt.Sprintf(summaryWrapper+` — the %d earlier messages of this session (%d question(s), %d command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.] |
| 89 | 92 | ||
| 90 | %s`, messages, questions, commands, summary) | 93 | %s`, messages, questions, commands, summary) |
| 91 | return []*ai.Message{ | 94 | return []*ai.Message{ |
modified
internal/config/config.go +31 -0 | @@ -473,6 +473,37 @@ type SessionsConfig struct { | ||
| 473 | 473 | // directory mm is started from in the terminal, the session's cwd in ACP |
| 474 | 474 | // mode. An absolute path is used as-is (one store for every project). |
| 475 | 475 | Dir string `yaml:"dir"` |
| 476 | + | |
| 477 | + // SearchMemory: the /memory <words> command, the user's search through | |
| 478 | + // the project's stored sessions (ticket 48). Off by default; needs | |
| 479 | + // Enabled — there is nothing to search otherwise. | |
| 480 | + SearchMemory bool `yaml:"searchMemory"` | |
| 481 | + | |
| 482 | + // AutoSearchMemory: the built-in `search_memory` tool, the same search | |
| 483 | + // in the model's hands — it decides when to look back. Off by default, | |
| 484 | + // independent of SearchMemory, needs Enabled. Off, the tool list of the | |
| 485 | + // existing agents does not change. | |
| 486 | + AutoSearchMemory bool `yaml:"autoSearchMemory"` | |
| 487 | +} | |
| 488 | + | |
| 489 | +// MemoryCommand reports whether /memory is offered: sessions on and | |
| 490 | +// searchMemory set. | |
| 491 | +// | |
| 492 | +// command.SetMemory(config.Cfg.Sessions.MemoryCommand()) | |
| 493 | +func (s SessionsConfig) MemoryCommand() bool { return s.Enabled && s.SearchMemory } | |
| 494 | + | |
| 495 | +// MemoryTool reports whether the search_memory tool is declared: sessions on | |
| 496 | +// and autoSearchMemory set. | |
| 497 | +func (s SessionsConfig) MemoryTool() bool { return s.Enabled && s.AutoSearchMemory } | |
| 498 | + | |
| 499 | +// MemoryWarning is the banner line for a memory key set while sessions are | |
| 500 | +// off — the setting would otherwise do nothing, silently. "" when there is | |
| 501 | +// nothing to say. | |
| 502 | +func (s SessionsConfig) MemoryWarning() string { | |
| 503 | + if s.Enabled || (!s.SearchMemory && !s.AutoSearchMemory) { | |
| 504 | + return "" | |
| 505 | + } | |
| 506 | + return "sessions: searchMemory / autoSearchMemory need sessions.enabled: true — /memory and search_memory are off" | |
| 476 | 507 | } |
| 477 | 508 | |
| 478 | 509 | // ContextConfig drives the compression of the history (see |
| @@ -473,6 +473,37 @@ type SessionsConfig struct { | |||
| 473 | // directory mm is started from in the terminal, the session's cwd in ACP | 473 | // directory mm is started from in the terminal, the session's cwd in ACP |
| 474 | // mode. An absolute path is used as-is (one store for every project). | 474 | // mode. An absolute path is used as-is (one store for every project). |
| 475 | Dir string `yaml:"dir"` | 475 | Dir string `yaml:"dir"` |
| 476 | + | ||
| 477 | + // SearchMemory: the /memory <words> command, the user's search through | ||
| 478 | + // the project's stored sessions (ticket 48). Off by default; needs | ||
| 479 | + // Enabled — there is nothing to search otherwise. | ||
| 480 | + SearchMemory bool `yaml:"searchMemory"` | ||
| 481 | + | ||
| 482 | + // AutoSearchMemory: the built-in `search_memory` tool, the same search | ||
| 483 | + // in the model's hands — it decides when to look back. Off by default, | ||
| 484 | + // independent of SearchMemory, needs Enabled. Off, the tool list of the | ||
| 485 | + // existing agents does not change. | ||
| 486 | + AutoSearchMemory bool `yaml:"autoSearchMemory"` | ||
| 487 | +} | ||
| 488 | + | ||
| 489 | +// MemoryCommand reports whether /memory is offered: sessions on and | ||
| 490 | +// searchMemory set. | ||
| 491 | +// | ||
| 492 | +// command.SetMemory(config.Cfg.Sessions.MemoryCommand()) | ||
| 493 | +func (s SessionsConfig) MemoryCommand() bool { return s.Enabled && s.SearchMemory } | ||
| 494 | + | ||
| 495 | +// MemoryTool reports whether the search_memory tool is declared: sessions on | ||
| 496 | +// and autoSearchMemory set. | ||
| 497 | +func (s SessionsConfig) MemoryTool() bool { return s.Enabled && s.AutoSearchMemory } | ||
| 498 | + | ||
| 499 | +// MemoryWarning is the banner line for a memory key set while sessions are | ||
| 500 | +// off — the setting would otherwise do nothing, silently. "" when there is | ||
| 501 | +// nothing to say. | ||
| 502 | +func (s SessionsConfig) MemoryWarning() string { | ||
| 503 | + if s.Enabled || (!s.SearchMemory && !s.AutoSearchMemory) { | ||
| 504 | + return "" | ||
| 505 | + } | ||
| 506 | + return "sessions: searchMemory / autoSearchMemory need sessions.enabled: true — /memory and search_memory are off" | ||
| 476 | } | 507 | } |
| 477 | 508 | ||
| 478 | // ContextConfig drives the compression of the history (see | 509 | // ContextConfig drives the compression of the history (see |
added
internal/config/memory_test.go +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "testing" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// Both memory keys are off by default and do nothing without sessions; each | |
| 10 | +// one turns on its own half — /memory, the search_memory tool — and a key | |
| 11 | +// set while sessions are off says so instead of silently doing nothing. | |
| 12 | +func TestMemoryKeys(t *testing.T) { | |
| 13 | + defaults := Cfg | |
| 14 | + t.Cleanup(func() { Cfg = defaults }) | |
| 15 | + if Cfg.Sessions.SearchMemory || Cfg.Sessions.AutoSearchMemory { | |
| 16 | + t.Fatalf("defaults: %+v, want both memory keys off", Cfg.Sessions) | |
| 17 | + } | |
| 18 | + | |
| 19 | + for _, c := range []struct { | |
| 20 | + yaml string | |
| 21 | + command, tool, warn bool | |
| 22 | + }{ | |
| 23 | + {"sessions:\n enabled: true\n", false, false, false}, | |
| 24 | + {"sessions:\n enabled: true\n searchMemory: true\n", true, false, false}, | |
| 25 | + {"sessions:\n enabled: true\n autoSearchMemory: true\n", false, true, false}, | |
| 26 | + {"sessions:\n enabled: true\n searchMemory: true\n autoSearchMemory: true\n", true, true, false}, | |
| 27 | + {"sessions:\n searchMemory: true\n autoSearchMemory: true\n", false, false, true}, | |
| 28 | + } { | |
| 29 | + Cfg = defaults | |
| 30 | + path := filepath.Join(t.TempDir(), "agent.yaml") | |
| 31 | + if err := os.WriteFile(path, []byte(c.yaml), 0o644); err != nil { | |
| 32 | + t.Fatal(err) | |
| 33 | + } | |
| 34 | + if _, err := Load(path); err != nil { | |
| 35 | + t.Fatal(err) | |
| 36 | + } | |
| 37 | + s := Cfg.Sessions | |
| 38 | + if s.MemoryCommand() != c.command || s.MemoryTool() != c.tool || (s.MemoryWarning() != "") != c.warn { | |
| 39 | + t.Errorf("%q: command %v tool %v warning %q", c.yaml, s.MemoryCommand(), s.MemoryTool(), s.MemoryWarning()) | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "testing" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// Both memory keys are off by default and do nothing without sessions; each | ||
| 10 | +// one turns on its own half — /memory, the search_memory tool — and a key | ||
| 11 | +// set while sessions are off says so instead of silently doing nothing. | ||
| 12 | +func TestMemoryKeys(t *testing.T) { | ||
| 13 | + defaults := Cfg | ||
| 14 | + t.Cleanup(func() { Cfg = defaults }) | ||
| 15 | + if Cfg.Sessions.SearchMemory || Cfg.Sessions.AutoSearchMemory { | ||
| 16 | + t.Fatalf("defaults: %+v, want both memory keys off", Cfg.Sessions) | ||
| 17 | + } | ||
| 18 | + | ||
| 19 | + for _, c := range []struct { | ||
| 20 | + yaml string | ||
| 21 | + command, tool, warn bool | ||
| 22 | + }{ | ||
| 23 | + {"sessions:\n enabled: true\n", false, false, false}, | ||
| 24 | + {"sessions:\n enabled: true\n searchMemory: true\n", true, false, false}, | ||
| 25 | + {"sessions:\n enabled: true\n autoSearchMemory: true\n", false, true, false}, | ||
| 26 | + {"sessions:\n enabled: true\n searchMemory: true\n autoSearchMemory: true\n", true, true, false}, | ||
| 27 | + {"sessions:\n searchMemory: true\n autoSearchMemory: true\n", false, false, true}, | ||
| 28 | + } { | ||
| 29 | + Cfg = defaults | ||
| 30 | + path := filepath.Join(t.TempDir(), "agent.yaml") | ||
| 31 | + if err := os.WriteFile(path, []byte(c.yaml), 0o644); err != nil { | ||
| 32 | + t.Fatal(err) | ||
| 33 | + } | ||
| 34 | + if _, err := Load(path); err != nil { | ||
| 35 | + t.Fatal(err) | ||
| 36 | + } | ||
| 37 | + s := Cfg.Sessions | ||
| 38 | + if s.MemoryCommand() != c.command || s.MemoryTool() != c.tool || (s.MemoryWarning() != "") != c.warn { | ||
| 39 | + t.Errorf("%q: command %v tool %v warning %q", c.yaml, s.MemoryCommand(), s.MemoryTool(), s.MemoryWarning()) | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
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_memory", | |
| 47 | 48 | "unload_model", |
| 48 | 49 | "write_file", |
| 49 | 50 | } |
| @@ -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_memory", | ||
| 47 | "unload_model", | 48 | "unload_model", |
| 48 | "write_file", | 49 | "write_file", |
| 49 | } | 50 | } |
added
internal/store/search.go +354 -0 | new file mode 100644 | ||
| @@ -0,0 +1,354 @@ | ||
| 1 | +package store | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "sort" | |
| 7 | + "strings" | |
| 8 | + "time" | |
| 9 | + "unicode" | |
| 10 | + "unicode/utf8" | |
| 11 | + | |
| 12 | + "mm/internal/compact" | |
| 13 | + | |
| 14 | + "github.com/firebase/genkit/go/ai" | |
| 15 | + "golang.org/x/text/unicode/norm" | |
| 16 | +) | |
| 17 | + | |
| 18 | +// Searching the stored sessions (ticket 48): the user's /memory and the | |
| 19 | +// model's search_memory tool read the same hits. The search is lexical on | |
| 20 | +// purpose — a scan of the project's files, no index, no embedding model: a | |
| 21 | +// project's sessions are a few hundred kilobytes, and an embedding model | |
| 22 | +// loaded next to a local chat model is the memory a small machine does not | |
| 23 | +// have (that ground is ticket 74's). | |
| 24 | + | |
| 25 | +// DefaultHits is how many hits a search returns when the caller does not say; | |
| 26 | +// MaxHits caps what a caller may ask for. | |
| 27 | +const ( | |
| 28 | + DefaultHits = 10 | |
| 29 | + MaxHits = 50 | |
| 30 | +) | |
| 31 | + | |
| 32 | +// A hit's snippet runs from snippetBefore runes before the first word found | |
| 33 | +// to snippetBefore runes after the last one, at least snippetAfter runes past | |
| 34 | +// the first, at most snippetMax in all: a summary that names "deploy" in its | |
| 35 | +// first line and "backups" in its last must show both (seen on the | |
| 36 | +// memory-search demo, 2026-10-20: cut after the first word, the backups line | |
| 37 | +// was missing and the model answered beside the question). | |
| 38 | +const ( | |
| 39 | + snippetBefore = 80 | |
| 40 | + snippetAfter = 160 | |
| 41 | + snippetMax = 600 | |
| 42 | +) | |
| 43 | + | |
| 44 | +// Hit is one message that holds at least one of the words searched. | |
| 45 | +type Hit struct { | |
| 46 | + Session string // the session's id, what /resume takes | |
| 47 | + At time.Time // when the session was last written | |
| 48 | + Turn int // the question's number in the session, from 1 | |
| 49 | + Role string // "question", "answer" or "summary" (a /compact's notes) | |
| 50 | + Snippet string // the text around the words found, on one line | |
| 51 | + // Answer is, for a question, the start of the answer the turn ended on — | |
| 52 | + // or the text around the words when the answer holds some. A question | |
| 53 | + // alone says what was asked, not what was decided: seen on the | |
| 54 | + // memory-search demo (2026-10-20), "format logs" found only "Quel format | |
| 55 | + // pour les logs ?" (the answer says "log/slog") and the model concluded | |
| 56 | + // nothing had been decided. | |
| 57 | + Answer string | |
| 58 | + Score int // how many of the words searched the hit holds | |
| 59 | +} | |
| 60 | + | |
| 61 | +// Search looks for the words of query in the stored sessions. A message of | |
| 62 | +// known — the conversation in progress — is never a hit: the model already | |
| 63 | +// reads it, and the user has it on screen. Matching on content rather than on | |
| 64 | +// a session id is what lets the tool work in every front end: none of them | |
| 65 | +// tells a tool which file it is saving to, and a resumed or compacted | |
| 66 | +// session's file holds exactly the messages in memory. Case and accents do | |
| 67 | +// not count: "preference" finds "Préférence". A | |
| 68 | +// message is a hit when it holds at least one word; hits come back by number | |
| 69 | +// of distinct words held, then newest first, at most limit of them (0 = | |
| 70 | +// DefaultHits, capped at MaxHits). Questions, answers and /compact summaries | |
| 71 | +// are searched; tool calls and outputs, mm's own notes and the errors array | |
| 72 | +// are not. | |
| 73 | +// | |
| 74 | +// hits := st.Search("watchdog timeout", messages, 0) | |
| 75 | +// fmt.Println(FormatHits("watchdog timeout", st.Dir(), hits)) | |
| 76 | +func (s *Store) Search(query string, known []*ai.Message, limit int) []Hit { | |
| 77 | + words := searchWords(query) | |
| 78 | + if len(words) == 0 { | |
| 79 | + return nil | |
| 80 | + } | |
| 81 | + seen := knownTexts(known) | |
| 82 | + var hits []Hit | |
| 83 | + for _, sess := range s.all() { | |
| 84 | + hits = append(hits, searchSession(sess, words, seen)...) | |
| 85 | + } | |
| 86 | + sort.SliceStable(hits, func(i, j int) bool { | |
| 87 | + if hits[i].Score != hits[j].Score { | |
| 88 | + return hits[i].Score > hits[j].Score | |
| 89 | + } | |
| 90 | + if !hits[i].At.Equal(hits[j].At) { | |
| 91 | + return hits[i].At.After(hits[j].At) | |
| 92 | + } | |
| 93 | + return hits[i].Turn > hits[j].Turn | |
| 94 | + }) | |
| 95 | + return hits[:min(len(hits), hitLimit(limit))] | |
| 96 | +} | |
| 97 | + | |
| 98 | +// ErrNoWords is a search with nothing to look for. | |
| 99 | +var ErrNoWords = errors.New("nothing to search: give one or more words") | |
| 100 | + | |
| 101 | +// Memory is /memory's answer and search_memory's output: the hits as text, | |
| 102 | +// or the reason there are none to give. current is the conversation in | |
| 103 | +// progress (see Search); limit as in Search. | |
| 104 | +// | |
| 105 | +// fmt.Println(st.Memory("gopls rename", messages, 0)) | |
| 106 | +func (s *Store) Memory(query string, current []*ai.Message, limit int) string { | |
| 107 | + if len(searchWords(query)) == 0 { | |
| 108 | + return "⚠️ " + ErrNoWords.Error() | |
| 109 | + } | |
| 110 | + return FormatHits(query, s.Dir(), s.Search(query, current, limit)) | |
| 111 | +} | |
| 112 | + | |
| 113 | +// Memory is the Keeper's side of Store.Memory. A nil Keeper — sessions off — | |
| 114 | +// says so. | |
| 115 | +// | |
| 116 | +// r.t.Notice(keep.Memory("gopls rename", messages)) | |
| 117 | +func (k *Keeper) Memory(query string, current []*ai.Message) string { | |
| 118 | + if k == nil { | |
| 119 | + return "⚠️ " + ErrDisabled.Error() | |
| 120 | + } | |
| 121 | + return k.st.Memory(query, current, 0) | |
| 122 | +} | |
| 123 | + | |
| 124 | +// FormatHits renders hits as the text /memory shows and search_memory | |
| 125 | +// returns: a header line, then two lines per hit. | |
| 126 | +// | |
| 127 | +// 🔎 1 hit(s) for "watchdog" in /work/.mm/sessions | |
| 128 | +// 20260926-165518-bef7fda6 · 2026-09-26 16:55 · turn 4 · answer | |
| 129 | +// …raise watchdogTimeout to 120s… | |
| 130 | +func FormatHits(query, dir string, hits []Hit) string { | |
| 131 | + if len(hits) == 0 { | |
| 132 | + return fmt.Sprintf("🔎 no hit for %q in %s", query, dir) | |
| 133 | + } | |
| 134 | + var b strings.Builder | |
| 135 | + fmt.Fprintf(&b, "🔎 %d hit(s) for %q in %s", len(hits), query, dir) | |
| 136 | + for _, h := range hits { | |
| 137 | + fmt.Fprintf(&b, "\n%s · %s · turn %d · %s\n %s", h.Session, h.At.Local().Format("2006-01-02 15:04"), h.Turn, h.Role, h.Snippet) | |
| 138 | + if h.Answer != "" { | |
| 139 | + fmt.Fprintf(&b, "\n → %s", h.Answer) | |
| 140 | + } | |
| 141 | + } | |
| 142 | + return b.String() | |
| 143 | +} | |
| 144 | + | |
| 145 | +func hitLimit(limit int) int { | |
| 146 | + if limit <= 0 { | |
| 147 | + return DefaultHits | |
| 148 | + } | |
| 149 | + return min(limit, MaxHits) | |
| 150 | +} | |
| 151 | + | |
| 152 | +// minWordRunes is the shortest word searched when the query has longer ones. | |
| 153 | +// A word matches inside longer words, so "de" or "a" match nearly every | |
| 154 | +// message: seen on the memory-search demo (2026-10-20), "base de données" | |
| 155 | +// returned all six messages of the history through "de" — "Deployment", | |
| 156 | +// "modernc", "Module" — and buried the sessions that held a real answer. | |
| 157 | +const minWordRunes = 3 | |
| 158 | + | |
| 159 | +// searchWords is the query as distinct folded words, the short ones left | |
| 160 | +// out unless there is nothing else to search. | |
| 161 | +func searchWords(query string) []string { | |
| 162 | + var words, short []string | |
| 163 | + seen := map[string]bool{} | |
| 164 | + for _, w := range strings.Fields(fold(query)) { | |
| 165 | + if seen[w] { | |
| 166 | + continue | |
| 167 | + } | |
| 168 | + seen[w] = true | |
| 169 | + if utf8.RuneCountInString(w) < minWordRunes { | |
| 170 | + short = append(short, w) | |
| 171 | + continue | |
| 172 | + } | |
| 173 | + words = append(words, w) | |
| 174 | + } | |
| 175 | + if len(words) == 0 { | |
| 176 | + return short | |
| 177 | + } | |
| 178 | + return words | |
| 179 | +} | |
| 180 | + | |
| 181 | +// knownTexts keys the searchable messages of msgs by role and text. | |
| 182 | +func knownTexts(msgs []*ai.Message) map[string]bool { | |
| 183 | + seen := map[string]bool{} | |
| 184 | + for _, m := range msgs { | |
| 185 | + if role, text, ok := searchable(m); ok { | |
| 186 | + seen[role+"\x00"+text] = true | |
| 187 | + } | |
| 188 | + } | |
| 189 | + return seen | |
| 190 | +} | |
| 191 | + | |
| 192 | +// entry is one searchable message of a session, with its turn. | |
| 193 | +type entry struct { | |
| 194 | + role, text string | |
| 195 | + turn int | |
| 196 | + final bool // the answer its turn ended on | |
| 197 | +} | |
| 198 | + | |
| 199 | +// entries lists the searchable messages of a session, numbered by question, | |
| 200 | +// each turn's last answer marked final. | |
| 201 | +func entries(msgs []*ai.Message) []entry { | |
| 202 | + var out []entry | |
| 203 | + turn := 0 | |
| 204 | + for _, m := range msgs { | |
| 205 | + role, text, ok := searchable(m) | |
| 206 | + if !ok { | |
| 207 | + continue | |
| 208 | + } | |
| 209 | + if role == "question" { | |
| 210 | + turn++ | |
| 211 | + } | |
| 212 | + out = append(out, entry{role: role, text: text, turn: max(turn, 1)}) | |
| 213 | + } | |
| 214 | + for i := range out { | |
| 215 | + next := i + 1 | |
| 216 | + out[i].final = out[i].role == "answer" && (next == len(out) || out[next].role != "answer") | |
| 217 | + } | |
| 218 | + return out | |
| 219 | +} | |
| 220 | + | |
| 221 | +// searchSession is the hits of one session. A question that matches comes | |
| 222 | +// with its turn's final answer (Hit.Answer), which is then not a hit of its | |
| 223 | +// own; a message seen already (the conversation in progress) is skipped. | |
| 224 | +func searchSession(sess *Session, words []string, seen map[string]bool) []Hit { | |
| 225 | + var hits []Hit | |
| 226 | + list := entries(sess.Messages) | |
| 227 | + answered := map[int]bool{} // entries folded into their question's hit | |
| 228 | + for i, e := range list { | |
| 229 | + if answered[i] || seen[e.role+"\x00"+e.text] { | |
| 230 | + continue | |
| 231 | + } | |
| 232 | + score, first, last := matchWords(e.text, words) | |
| 233 | + if score == 0 { | |
| 234 | + continue | |
| 235 | + } | |
| 236 | + hit := Hit{Session: sess.ID, At: sess.UpdatedAt, Turn: e.turn, Role: e.role, Snippet: snippet(e.text, first, last), Score: score} | |
| 237 | + if e.role == "question" { | |
| 238 | + if j := finalAnswer(list, i); j >= 0 { | |
| 239 | + answered[j] = true | |
| 240 | + hit.Answer = answerSnippet(list[j].text, words) | |
| 241 | + hit.Score, _, _ = matchWords(e.text+"\n"+list[j].text, words) | |
| 242 | + } | |
| 243 | + } | |
| 244 | + hits = append(hits, hit) | |
| 245 | + } | |
| 246 | + return hits | |
| 247 | +} | |
| 248 | + | |
| 249 | +// finalAnswer is the index of the answer the turn of question i ended on, | |
| 250 | +// -1 when it has none (the turn failed, or the session stops there). | |
| 251 | +func finalAnswer(list []entry, i int) int { | |
| 252 | + for j := i + 1; j < len(list) && list[j].role != "question"; j++ { | |
| 253 | + if list[j].final { | |
| 254 | + return j | |
| 255 | + } | |
| 256 | + } | |
| 257 | + return -1 | |
| 258 | +} | |
| 259 | + | |
| 260 | +// answerSnippet is what a question's hit shows of its answer: the text | |
| 261 | +// around the words when it holds some, its beginning otherwise. | |
| 262 | +func answerSnippet(text string, words []string) string { | |
| 263 | + score, first, last := matchWords(text, words) | |
| 264 | + if score == 0 { | |
| 265 | + first, last = 0, 0 | |
| 266 | + } | |
| 267 | + return snippet(text, first, last) | |
| 268 | +} | |
| 269 | + | |
| 270 | +// searchable is the role and text of a message worth searching, and whether | |
| 271 | +// it is one: a question as the user typed it (see question), an answer's | |
| 272 | +// text, a /compact summary's notes — without the wrapper line mm puts before | |
| 273 | +// them, and never the fixed acknowledgement that follows them. | |
| 274 | +func searchable(m *ai.Message) (role, text string, ok bool) { | |
| 275 | + switch { | |
| 276 | + case m == nil: | |
| 277 | + return "", "", false | |
| 278 | + case compact.InSummaryPair(m): | |
| 279 | + text = strings.TrimSpace(compact.SummaryNotes(m)) | |
| 280 | + return "summary", text, compact.IsSummary(m) && text != "" | |
| 281 | + case m.Role == ai.RoleUser: | |
| 282 | + text, ok = question(m) | |
| 283 | + return "question", text, ok | |
| 284 | + case m.Role == ai.RoleModel: | |
| 285 | + text = strings.TrimSpace(m.Text()) | |
| 286 | + return "answer", text, text != "" | |
| 287 | + } | |
| 288 | + return "", "", false | |
| 289 | +} | |
| 290 | + | |
| 291 | +// matchWords counts the words text holds and returns, in runes, where the | |
| 292 | +// earliest one found starts and where the latest one found ends. | |
| 293 | +func matchWords(text string, words []string) (score, first, last int) { | |
| 294 | + folded := fold(text) | |
| 295 | + first = -1 | |
| 296 | + for _, w := range words { | |
| 297 | + i := strings.Index(folded, w) | |
| 298 | + if i < 0 { | |
| 299 | + continue | |
| 300 | + } | |
| 301 | + score++ | |
| 302 | + start := utf8.RuneCountInString(folded[:i]) | |
| 303 | + if first < 0 || start < first { | |
| 304 | + first = start | |
| 305 | + } | |
| 306 | + end := utf8.RuneCountInString(folded[:strings.LastIndex(folded, w)]) + utf8.RuneCountInString(w) | |
| 307 | + last = max(last, end) | |
| 308 | + } | |
| 309 | + return score, first, last | |
| 310 | +} | |
| 311 | + | |
| 312 | +// 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 | |
| 314 | +// per rune, so a position in the folded text is the same position in the | |
| 315 | +// original. | |
| 316 | +func snippet(text string, first, last int) string { | |
| 317 | + runes := []rune(text) | |
| 318 | + from := max(first-snippetBefore, 0) | |
| 319 | + to := min(max(first+snippetAfter, last+snippetBefore), from+snippetMax, len(runes)) | |
| 320 | + out := strings.Join(strings.Fields(string(runes[from:to])), " ") | |
| 321 | + if from > 0 { | |
| 322 | + out = "…" + out | |
| 323 | + } | |
| 324 | + if to < len(runes) { | |
| 325 | + out += "…" | |
| 326 | + } | |
| 327 | + return out | |
| 328 | +} | |
| 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 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,354 @@ | |||
| 1 | +package store | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "errors" | ||
| 5 | + "fmt" | ||
| 6 | + "sort" | ||
| 7 | + "strings" | ||
| 8 | + "time" | ||
| 9 | + "unicode" | ||
| 10 | + "unicode/utf8" | ||
| 11 | + | ||
| 12 | + "mm/internal/compact" | ||
| 13 | + | ||
| 14 | + "github.com/firebase/genkit/go/ai" | ||
| 15 | + "golang.org/x/text/unicode/norm" | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +// Searching the stored sessions (ticket 48): the user's /memory and the | ||
| 19 | +// model's search_memory tool read the same hits. The search is lexical on | ||
| 20 | +// purpose — a scan of the project's files, no index, no embedding model: a | ||
| 21 | +// project's sessions are a few hundred kilobytes, and an embedding model | ||
| 22 | +// loaded next to a local chat model is the memory a small machine does not | ||
| 23 | +// have (that ground is ticket 74's). | ||
| 24 | + | ||
| 25 | +// DefaultHits is how many hits a search returns when the caller does not say; | ||
| 26 | +// MaxHits caps what a caller may ask for. | ||
| 27 | +const ( | ||
| 28 | + DefaultHits = 10 | ||
| 29 | + MaxHits = 50 | ||
| 30 | +) | ||
| 31 | + | ||
| 32 | +// A hit's snippet runs from snippetBefore runes before the first word found | ||
| 33 | +// to snippetBefore runes after the last one, at least snippetAfter runes past | ||
| 34 | +// the first, at most snippetMax in all: a summary that names "deploy" in its | ||
| 35 | +// first line and "backups" in its last must show both (seen on the | ||
| 36 | +// memory-search demo, 2026-10-20: cut after the first word, the backups line | ||
| 37 | +// was missing and the model answered beside the question). | ||
| 38 | +const ( | ||
| 39 | + snippetBefore = 80 | ||
| 40 | + snippetAfter = 160 | ||
| 41 | + snippetMax = 600 | ||
| 42 | +) | ||
| 43 | + | ||
| 44 | +// Hit is one message that holds at least one of the words searched. | ||
| 45 | +type Hit struct { | ||
| 46 | + Session string // the session's id, what /resume takes | ||
| 47 | + At time.Time // when the session was last written | ||
| 48 | + Turn int // the question's number in the session, from 1 | ||
| 49 | + Role string // "question", "answer" or "summary" (a /compact's notes) | ||
| 50 | + Snippet string // the text around the words found, on one line | ||
| 51 | + // Answer is, for a question, the start of the answer the turn ended on — | ||
| 52 | + // or the text around the words when the answer holds some. A question | ||
| 53 | + // alone says what was asked, not what was decided: seen on the | ||
| 54 | + // memory-search demo (2026-10-20), "format logs" found only "Quel format | ||
| 55 | + // pour les logs ?" (the answer says "log/slog") and the model concluded | ||
| 56 | + // nothing had been decided. | ||
| 57 | + Answer string | ||
| 58 | + Score int // how many of the words searched the hit holds | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +// Search looks for the words of query in the stored sessions. A message of | ||
| 62 | +// known — the conversation in progress — is never a hit: the model already | ||
| 63 | +// reads it, and the user has it on screen. Matching on content rather than on | ||
| 64 | +// a session id is what lets the tool work in every front end: none of them | ||
| 65 | +// tells a tool which file it is saving to, and a resumed or compacted | ||
| 66 | +// session's file holds exactly the messages in memory. Case and accents do | ||
| 67 | +// not count: "preference" finds "Préférence". A | ||
| 68 | +// message is a hit when it holds at least one word; hits come back by number | ||
| 69 | +// of distinct words held, then newest first, at most limit of them (0 = | ||
| 70 | +// DefaultHits, capped at MaxHits). Questions, answers and /compact summaries | ||
| 71 | +// are searched; tool calls and outputs, mm's own notes and the errors array | ||
| 72 | +// are not. | ||
| 73 | +// | ||
| 74 | +// hits := st.Search("watchdog timeout", messages, 0) | ||
| 75 | +// fmt.Println(FormatHits("watchdog timeout", st.Dir(), hits)) | ||
| 76 | +func (s *Store) Search(query string, known []*ai.Message, limit int) []Hit { | ||
| 77 | + words := searchWords(query) | ||
| 78 | + if len(words) == 0 { | ||
| 79 | + return nil | ||
| 80 | + } | ||
| 81 | + seen := knownTexts(known) | ||
| 82 | + var hits []Hit | ||
| 83 | + for _, sess := range s.all() { | ||
| 84 | + hits = append(hits, searchSession(sess, words, seen)...) | ||
| 85 | + } | ||
| 86 | + sort.SliceStable(hits, func(i, j int) bool { | ||
| 87 | + if hits[i].Score != hits[j].Score { | ||
| 88 | + return hits[i].Score > hits[j].Score | ||
| 89 | + } | ||
| 90 | + if !hits[i].At.Equal(hits[j].At) { | ||
| 91 | + return hits[i].At.After(hits[j].At) | ||
| 92 | + } | ||
| 93 | + return hits[i].Turn > hits[j].Turn | ||
| 94 | + }) | ||
| 95 | + return hits[:min(len(hits), hitLimit(limit))] | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +// ErrNoWords is a search with nothing to look for. | ||
| 99 | +var ErrNoWords = errors.New("nothing to search: give one or more words") | ||
| 100 | + | ||
| 101 | +// Memory is /memory's answer and search_memory's output: the hits as text, | ||
| 102 | +// or the reason there are none to give. current is the conversation in | ||
| 103 | +// progress (see Search); limit as in Search. | ||
| 104 | +// | ||
| 105 | +// fmt.Println(st.Memory("gopls rename", messages, 0)) | ||
| 106 | +func (s *Store) Memory(query string, current []*ai.Message, limit int) string { | ||
| 107 | + if len(searchWords(query)) == 0 { | ||
| 108 | + return "⚠️ " + ErrNoWords.Error() | ||
| 109 | + } | ||
| 110 | + return FormatHits(query, s.Dir(), s.Search(query, current, limit)) | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +// Memory is the Keeper's side of Store.Memory. A nil Keeper — sessions off — | ||
| 114 | +// says so. | ||
| 115 | +// | ||
| 116 | +// r.t.Notice(keep.Memory("gopls rename", messages)) | ||
| 117 | +func (k *Keeper) Memory(query string, current []*ai.Message) string { | ||
| 118 | + if k == nil { | ||
| 119 | + return "⚠️ " + ErrDisabled.Error() | ||
| 120 | + } | ||
| 121 | + return k.st.Memory(query, current, 0) | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +// FormatHits renders hits as the text /memory shows and search_memory | ||
| 125 | +// returns: a header line, then two lines per hit. | ||
| 126 | +// | ||
| 127 | +// 🔎 1 hit(s) for "watchdog" in /work/.mm/sessions | ||
| 128 | +// 20260926-165518-bef7fda6 · 2026-09-26 16:55 · turn 4 · answer | ||
| 129 | +// …raise watchdogTimeout to 120s… | ||
| 130 | +func FormatHits(query, dir string, hits []Hit) string { | ||
| 131 | + if len(hits) == 0 { | ||
| 132 | + return fmt.Sprintf("🔎 no hit for %q in %s", query, dir) | ||
| 133 | + } | ||
| 134 | + var b strings.Builder | ||
| 135 | + fmt.Fprintf(&b, "🔎 %d hit(s) for %q in %s", len(hits), query, dir) | ||
| 136 | + for _, h := range hits { | ||
| 137 | + fmt.Fprintf(&b, "\n%s · %s · turn %d · %s\n %s", h.Session, h.At.Local().Format("2006-01-02 15:04"), h.Turn, h.Role, h.Snippet) | ||
| 138 | + if h.Answer != "" { | ||
| 139 | + fmt.Fprintf(&b, "\n → %s", h.Answer) | ||
| 140 | + } | ||
| 141 | + } | ||
| 142 | + return b.String() | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +func hitLimit(limit int) int { | ||
| 146 | + if limit <= 0 { | ||
| 147 | + return DefaultHits | ||
| 148 | + } | ||
| 149 | + return min(limit, MaxHits) | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +// minWordRunes is the shortest word searched when the query has longer ones. | ||
| 153 | +// A word matches inside longer words, so "de" or "a" match nearly every | ||
| 154 | +// message: seen on the memory-search demo (2026-10-20), "base de données" | ||
| 155 | +// returned all six messages of the history through "de" — "Deployment", | ||
| 156 | +// "modernc", "Module" — and buried the sessions that held a real answer. | ||
| 157 | +const minWordRunes = 3 | ||
| 158 | + | ||
| 159 | +// searchWords is the query as distinct folded words, the short ones left | ||
| 160 | +// out unless there is nothing else to search. | ||
| 161 | +func searchWords(query string) []string { | ||
| 162 | + var words, short []string | ||
| 163 | + seen := map[string]bool{} | ||
| 164 | + for _, w := range strings.Fields(fold(query)) { | ||
| 165 | + if seen[w] { | ||
| 166 | + continue | ||
| 167 | + } | ||
| 168 | + seen[w] = true | ||
| 169 | + if utf8.RuneCountInString(w) < minWordRunes { | ||
| 170 | + short = append(short, w) | ||
| 171 | + continue | ||
| 172 | + } | ||
| 173 | + words = append(words, w) | ||
| 174 | + } | ||
| 175 | + if len(words) == 0 { | ||
| 176 | + return short | ||
| 177 | + } | ||
| 178 | + return words | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +// knownTexts keys the searchable messages of msgs by role and text. | ||
| 182 | +func knownTexts(msgs []*ai.Message) map[string]bool { | ||
| 183 | + seen := map[string]bool{} | ||
| 184 | + for _, m := range msgs { | ||
| 185 | + if role, text, ok := searchable(m); ok { | ||
| 186 | + seen[role+"\x00"+text] = true | ||
| 187 | + } | ||
| 188 | + } | ||
| 189 | + return seen | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +// entry is one searchable message of a session, with its turn. | ||
| 193 | +type entry struct { | ||
| 194 | + role, text string | ||
| 195 | + turn int | ||
| 196 | + final bool // the answer its turn ended on | ||
| 197 | +} | ||
| 198 | + | ||
| 199 | +// entries lists the searchable messages of a session, numbered by question, | ||
| 200 | +// each turn's last answer marked final. | ||
| 201 | +func entries(msgs []*ai.Message) []entry { | ||
| 202 | + var out []entry | ||
| 203 | + turn := 0 | ||
| 204 | + for _, m := range msgs { | ||
| 205 | + role, text, ok := searchable(m) | ||
| 206 | + if !ok { | ||
| 207 | + continue | ||
| 208 | + } | ||
| 209 | + if role == "question" { | ||
| 210 | + turn++ | ||
| 211 | + } | ||
| 212 | + out = append(out, entry{role: role, text: text, turn: max(turn, 1)}) | ||
| 213 | + } | ||
| 214 | + for i := range out { | ||
| 215 | + next := i + 1 | ||
| 216 | + out[i].final = out[i].role == "answer" && (next == len(out) || out[next].role != "answer") | ||
| 217 | + } | ||
| 218 | + return out | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +// searchSession is the hits of one session. A question that matches comes | ||
| 222 | +// with its turn's final answer (Hit.Answer), which is then not a hit of its | ||
| 223 | +// own; a message seen already (the conversation in progress) is skipped. | ||
| 224 | +func searchSession(sess *Session, words []string, seen map[string]bool) []Hit { | ||
| 225 | + var hits []Hit | ||
| 226 | + list := entries(sess.Messages) | ||
| 227 | + answered := map[int]bool{} // entries folded into their question's hit | ||
| 228 | + for i, e := range list { | ||
| 229 | + if answered[i] || seen[e.role+"\x00"+e.text] { | ||
| 230 | + continue | ||
| 231 | + } | ||
| 232 | + score, first, last := matchWords(e.text, words) | ||
| 233 | + if score == 0 { | ||
| 234 | + continue | ||
| 235 | + } | ||
| 236 | + hit := Hit{Session: sess.ID, At: sess.UpdatedAt, Turn: e.turn, Role: e.role, Snippet: snippet(e.text, first, last), Score: score} | ||
| 237 | + if e.role == "question" { | ||
| 238 | + if j := finalAnswer(list, i); j >= 0 { | ||
| 239 | + answered[j] = true | ||
| 240 | + hit.Answer = answerSnippet(list[j].text, words) | ||
| 241 | + hit.Score, _, _ = matchWords(e.text+"\n"+list[j].text, words) | ||
| 242 | + } | ||
| 243 | + } | ||
| 244 | + hits = append(hits, hit) | ||
| 245 | + } | ||
| 246 | + return hits | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +// finalAnswer is the index of the answer the turn of question i ended on, | ||
| 250 | +// -1 when it has none (the turn failed, or the session stops there). | ||
| 251 | +func finalAnswer(list []entry, i int) int { | ||
| 252 | + for j := i + 1; j < len(list) && list[j].role != "question"; j++ { | ||
| 253 | + if list[j].final { | ||
| 254 | + return j | ||
| 255 | + } | ||
| 256 | + } | ||
| 257 | + return -1 | ||
| 258 | +} | ||
| 259 | + | ||
| 260 | +// answerSnippet is what a question's hit shows of its answer: the text | ||
| 261 | +// around the words when it holds some, its beginning otherwise. | ||
| 262 | +func answerSnippet(text string, words []string) string { | ||
| 263 | + score, first, last := matchWords(text, words) | ||
| 264 | + if score == 0 { | ||
| 265 | + first, last = 0, 0 | ||
| 266 | + } | ||
| 267 | + return snippet(text, first, last) | ||
| 268 | +} | ||
| 269 | + | ||
| 270 | +// searchable is the role and text of a message worth searching, and whether | ||
| 271 | +// it is one: a question as the user typed it (see question), an answer's | ||
| 272 | +// text, a /compact summary's notes — without the wrapper line mm puts before | ||
| 273 | +// them, and never the fixed acknowledgement that follows them. | ||
| 274 | +func searchable(m *ai.Message) (role, text string, ok bool) { | ||
| 275 | + switch { | ||
| 276 | + case m == nil: | ||
| 277 | + return "", "", false | ||
| 278 | + case compact.InSummaryPair(m): | ||
| 279 | + text = strings.TrimSpace(compact.SummaryNotes(m)) | ||
| 280 | + return "summary", text, compact.IsSummary(m) && text != "" | ||
| 281 | + case m.Role == ai.RoleUser: | ||
| 282 | + text, ok = question(m) | ||
| 283 | + return "question", text, ok | ||
| 284 | + case m.Role == ai.RoleModel: | ||
| 285 | + text = strings.TrimSpace(m.Text()) | ||
| 286 | + return "answer", text, text != "" | ||
| 287 | + } | ||
| 288 | + return "", "", false | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +// matchWords counts the words text holds and returns, in runes, where the | ||
| 292 | +// earliest one found starts and where the latest one found ends. | ||
| 293 | +func matchWords(text string, words []string) (score, first, last int) { | ||
| 294 | + folded := fold(text) | ||
| 295 | + first = -1 | ||
| 296 | + for _, w := range words { | ||
| 297 | + i := strings.Index(folded, w) | ||
| 298 | + if i < 0 { | ||
| 299 | + continue | ||
| 300 | + } | ||
| 301 | + score++ | ||
| 302 | + start := utf8.RuneCountInString(folded[:i]) | ||
| 303 | + if first < 0 || start < first { | ||
| 304 | + first = start | ||
| 305 | + } | ||
| 306 | + end := utf8.RuneCountInString(folded[:strings.LastIndex(folded, w)]) + utf8.RuneCountInString(w) | ||
| 307 | + last = max(last, end) | ||
| 308 | + } | ||
| 309 | + return score, first, last | ||
| 310 | +} | ||
| 311 | + | ||
| 312 | +// 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 | ||
| 314 | +// per rune, so a position in the folded text is the same position in the | ||
| 315 | +// original. | ||
| 316 | +func snippet(text string, first, last int) string { | ||
| 317 | + runes := []rune(text) | ||
| 318 | + from := max(first-snippetBefore, 0) | ||
| 319 | + to := min(max(first+snippetAfter, last+snippetBefore), from+snippetMax, len(runes)) | ||
| 320 | + out := strings.Join(strings.Fields(string(runes[from:to])), " ") | ||
| 321 | + if from > 0 { | ||
| 322 | + out = "…" + out | ||
| 323 | + } | ||
| 324 | + if to < len(runes) { | ||
| 325 | + out += "…" | ||
| 326 | + } | ||
| 327 | + return out | ||
| 328 | +} | ||
| 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 | +} | ||
added
internal/store/search_test.go +216 -0 | new file mode 100644 | ||
| @@ -0,0 +1,216 @@ | ||
| 1 | +package store | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + "time" | |
| 7 | + | |
| 8 | + "mm/internal/compact" | |
| 9 | + | |
| 10 | + "github.com/firebase/genkit/go/ai" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func reply(msgs []*ai.Message, q, a string) []*ai.Message { | |
| 14 | + return append(msgs, ai.NewUserTextMessage(q), ai.NewModelTextMessage(a)) | |
| 15 | +} | |
| 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 | |
| 35 | +// 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 | |
| 37 | +// which side said it. | |
| 38 | +func TestSearchRanksAndExcludes(t *testing.T) { | |
| 39 | + k := newQuestionsKeeper(t) | |
| 40 | + now := time.Now().UTC() | |
| 41 | + stored(t, k, "20260101-000000-aaaaaaaa", now.Add(-2*time.Hour), | |
| 42 | + reply(reply(nil, "hello", "hi"), "set the watchdog", "raise watchdogTimeout to 120s")) | |
| 43 | + stored(t, k, "20260102-000000-bbbbbbbb", now.Add(-time.Hour), | |
| 44 | + reply(nil, "Le WATCHDOG coupe trop tôt, quel timeout ?", "firstTokenTimeout")) | |
| 45 | + current := reply(session0(), "watchdog timeout again", "same") | |
| 46 | + stored(t, k, k.CurrentID(), now, current) | |
| 47 | + | |
| 48 | + hits := k.st.Search("watchdog TIMEOUT", current, 0) | |
| 49 | + // One hit per turn: a matching question carries its answer, and the | |
| 50 | + // answer's words count for it. | |
| 51 | + if len(hits) != 2 { | |
| 52 | + t.Fatalf("got %d hits: %+v", len(hits), hits) | |
| 53 | + } | |
| 54 | + first := hits[0] | |
| 55 | + if first.Session != "20260102-000000-bbbbbbbb" || first.Score != 2 || first.Role != "question" || first.Turn != 1 || first.Answer != "firstTokenTimeout" { | |
| 56 | + t.Errorf("first hit = %+v, want the newer two-word question with its answer", first) | |
| 57 | + } | |
| 58 | + // The older session's second question holds "watchdog", its answer | |
| 59 | + // ("watchdogTimeout") both words; the first turn ("hello") nothing. | |
| 60 | + if hits[1].Session != "20260101-000000-aaaaaaaa" || hits[1].Role != "question" || hits[1].Turn != 2 || hits[1].Score != 2 || hits[1].Answer != "raise watchdogTimeout to 120s" { | |
| 61 | + t.Errorf("second hit = %+v", hits[1]) | |
| 62 | + } | |
| 63 | + for _, h := range hits { | |
| 64 | + if h.Session == k.CurrentID() { | |
| 65 | + t.Errorf("a message of the conversation in progress came back: %+v", h) | |
| 66 | + } | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +// What is searched: questions as typed (a hook's context cut off), answers, | |
| 71 | +// /compact summaries. What is not: mm's notes, the errors array, tool calls. | |
| 72 | +func TestSearchScope(t *testing.T) { | |
| 73 | + k := newQuestionsKeeper(t) | |
| 74 | + msgs := append(compact.SummaryPair("we chose zebra as the codename", 4, 2, 0), ai.NewUserTextMessage("[mm: zebra note]")) | |
| 75 | + msgs = append(msgs, ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "echo zebra"}}))) | |
| 76 | + msgs = append(msgs, ai.NewUserTextMessage("plain question\n\n[context added by a hook]\nzebra")) | |
| 77 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), msgs) | |
| 78 | + | |
| 79 | + hits := k.st.Search("zebra", nil, 0) | |
| 80 | + if len(hits) != 1 || hits[0].Role != "summary" { | |
| 81 | + t.Fatalf("hits = %+v, want the summary alone", hits) | |
| 82 | + } | |
| 83 | + if strings.Contains(hits[0].Snippet, "Context summary") { | |
| 84 | + t.Errorf("the summary's wrapper line is in the snippet: %q", hits[0].Snippet) | |
| 85 | + } | |
| 86 | + // The wrapper and the fixed acknowledgement are mm's words, not the notes. | |
| 87 | + for _, q := range []string{"observations", "understood"} { | |
| 88 | + if hits := k.st.Search(q, nil, 0); len(hits) != 0 { | |
| 89 | + t.Errorf("%q found mm's own words: %+v", q, hits) | |
| 90 | + } | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +// A snippet reaches from the first word found to the last one, so a summary | |
| 95 | +// that names one subject in its first line and another in its last shows | |
| 96 | +// both — within snippetMax. | |
| 97 | +func TestSnippetSpansTheWordsFound(t *testing.T) { | |
| 98 | + k := newQuestionsKeeper(t) | |
| 99 | + notes := "- Deployment: one static binary.\n" + strings.Repeat("- filler line about nothing.\n", 8) + "- Backups: nightly, 14 days kept." | |
| 100 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), compact.SummaryPair(notes, 4, 2, 0)) | |
| 101 | + | |
| 102 | + hits := k.st.Search("deploy backups", nil, 0) | |
| 103 | + if len(hits) != 1 || !strings.Contains(hits[0].Snippet, "Deployment") || !strings.Contains(hits[0].Snippet, "14 days kept") { | |
| 104 | + t.Fatalf("hits = %+v", hits) | |
| 105 | + } | |
| 106 | + far := "start " + strings.Repeat("x", 2*snippetMax) + " end" | |
| 107 | + if got := []rune(snippet(far, 0, len([]rune(far)))); len(got) > snippetMax+1 { | |
| 108 | + t.Errorf("snippet of %d runes, want at most %d plus the ellipsis", len(got), snippetMax) | |
| 109 | + } | |
| 110 | +} | |
| 111 | + | |
| 112 | +func TestSearchSnippetAndLimit(t *testing.T) { | |
| 113 | + k := newQuestionsKeeper(t) | |
| 114 | + long := strings.Repeat("word ", 60) + "NEEDLE\n\nafter " + strings.Repeat("tail ", 60) | |
| 115 | + var msgs []*ai.Message | |
| 116 | + for range MaxHits + 5 { | |
| 117 | + msgs = reply(msgs, "needle?", long) | |
| 118 | + } | |
| 119 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), msgs) | |
| 120 | + | |
| 121 | + hits := k.st.Search("needle", nil, 3) | |
| 122 | + if len(hits) != 3 { | |
| 123 | + t.Fatalf("limit 3 gave %d hits", len(hits)) | |
| 124 | + } | |
| 125 | + if n := len(k.st.Search("needle", nil, 1000)); n != MaxHits { | |
| 126 | + t.Errorf("limit 1000 gave %d hits, want the cap %d", n, MaxHits) | |
| 127 | + } | |
| 128 | + if n := len(k.st.Search("needle", nil, 0)); n != DefaultHits { | |
| 129 | + t.Errorf("limit 0 gave %d hits, want %d", n, DefaultHits) | |
| 130 | + } | |
| 131 | + answer := k.st.Search("needle", nil, 1)[0].Answer | |
| 132 | + if !strings.HasPrefix(answer, "…") || !strings.HasSuffix(answer, "…") || !strings.Contains(answer, "NEEDLE after") { | |
| 133 | + t.Errorf("answer snippet = %q", answer) | |
| 134 | + } | |
| 135 | +} | |
| 136 | + | |
| 137 | +func TestMemory(t *testing.T) { | |
| 138 | + var off *Keeper | |
| 139 | + if got := off.Memory("x", nil); !strings.Contains(got, "sessions are off") { | |
| 140 | + t.Errorf("nil keeper: %q", got) | |
| 141 | + } | |
| 142 | + k := newQuestionsKeeper(t) | |
| 143 | + if got := k.Memory(" ", nil); !strings.Contains(got, "nothing to search") { | |
| 144 | + t.Errorf("empty query: %q", got) | |
| 145 | + } | |
| 146 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), reply(nil, "gopls rename", "done")) | |
| 147 | + if got := k.Memory("RENAME", nil); !strings.HasPrefix(got, "🔎 1 hit(s) for \"RENAME\"") || !strings.Contains(got, "gopls rename") { | |
| 148 | + t.Errorf("Memory = %q", got) | |
| 149 | + } | |
| 150 | +} | |
| 151 | + | |
| 152 | +func TestFormatHits(t *testing.T) { | |
| 153 | + at := time.Date(2026, 9, 26, 16, 55, 0, 0, time.Local) | |
| 154 | + got := FormatHits("watchdog", "/w/.mm/sessions", []Hit{{Session: "id1", At: at, Turn: 4, Role: "answer", Snippet: "raise it"}}) | |
| 155 | + want := "🔎 1 hit(s) for \"watchdog\" in /w/.mm/sessions\nid1 · 2026-09-26 16:55 · turn 4 · answer\n raise it" | |
| 156 | + if got != want { | |
| 157 | + t.Errorf("got\n%s\nwant\n%s", got, want) | |
| 158 | + } | |
| 159 | + if got := FormatHits("x", "/d", nil); got != `🔎 no hit for "x" in /d` { | |
| 160 | + t.Errorf("no hit: %q", got) | |
| 161 | + } | |
| 162 | +} | |
| 163 | + | |
| 164 | +// Words of one or two runes match inside nearly anything: they are dropped | |
| 165 | +// when the query has longer words, kept when it has nothing else. | |
| 166 | +func TestShortWordsAreDroppedBesideLongerOnes(t *testing.T) { | |
| 167 | + if got := searchWords("base de DONNÉES a"); strings.Join(got, ",") != "base,donnees" { | |
| 168 | + t.Errorf("searchWords = %q", got) | |
| 169 | + } | |
| 170 | + if got := searchWords("go UI"); strings.Join(got, ",") != "go,ui" { | |
| 171 | + t.Errorf("only short words: %q", got) | |
| 172 | + } | |
| 173 | + k := newQuestionsKeeper(t) | |
| 174 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), reply(nil, "Deployment with modernc", "Module done")) | |
| 175 | + if hits := k.st.Search("base de données", nil, 0); len(hits) != 0 { | |
| 176 | + t.Errorf("\"de\" matched inside words: %+v", hits) | |
| 177 | + } | |
| 178 | +} | |
| 179 | + | |
| 180 | +// A question that matches comes with the answer its turn ended on — the | |
| 181 | +// beginning of it when the answer holds none of the words —, an answer that | |
| 182 | +// matches alone is a hit of its own, and a turn that ended without an answer | |
| 183 | +// gives the question alone. | |
| 184 | +func TestQuestionHitCarriesItsFinalAnswer(t *testing.T) { | |
| 185 | + k := newQuestionsKeeper(t) | |
| 186 | + msgs := reply(nil, "Quel format pour les logs ?", "log/slog en JSON sur la sortie standard.") | |
| 187 | + msgs = append(msgs, ai.NewUserTextMessage("which logs rotate?"), ai.NewModelTextMessage("let me check"), | |
| 188 | + ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash"})), ai.NewModelTextMessage("none: systemd keeps them")) | |
| 189 | + msgs = reply(msgs, "and the port?", "8417, as for the logs viewer") | |
| 190 | + msgs = append(msgs, ai.NewUserTextMessage("logs again?")) | |
| 191 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), msgs) | |
| 192 | + | |
| 193 | + got := map[int]Hit{} | |
| 194 | + for _, h := range k.st.Search("logs", nil, 0) { | |
| 195 | + got[h.Turn] = h | |
| 196 | + } | |
| 197 | + if h := got[1]; h.Role != "question" || h.Answer != "log/slog en JSON sur la sortie standard." { | |
| 198 | + t.Errorf("turn 1: %+v", h) | |
| 199 | + } | |
| 200 | + if h := got[2]; h.Answer != "none: systemd keeps them" { | |
| 201 | + t.Errorf("turn 2 should carry its FINAL answer: %+v", h) | |
| 202 | + } | |
| 203 | + if h := got[3]; h.Role != "answer" || h.Snippet != "8417, as for the logs viewer" { | |
| 204 | + t.Errorf("turn 3: %+v", h) | |
| 205 | + } | |
| 206 | + if h := got[4]; h.Role != "question" || h.Answer != "" { | |
| 207 | + t.Errorf("turn 4 (no answer): %+v", h) | |
| 208 | + } | |
| 209 | + if len(got) != 4 { | |
| 210 | + t.Errorf("hits by turn: %+v", got) | |
| 211 | + } | |
| 212 | + out := FormatHits("logs", "/d", []Hit{got[1]}) | |
| 213 | + if !strings.HasSuffix(out, "\n Quel format pour les logs ?\n → log/slog en JSON sur la sortie standard.") { | |
| 214 | + t.Errorf("FormatHits:\n%s", out) | |
| 215 | + } | |
| 216 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,216 @@ | |||
| 1 | +package store | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + "time" | ||
| 7 | + | ||
| 8 | + "mm/internal/compact" | ||
| 9 | + | ||
| 10 | + "github.com/firebase/genkit/go/ai" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +func reply(msgs []*ai.Message, q, a string) []*ai.Message { | ||
| 14 | + return append(msgs, ai.NewUserTextMessage(q), ai.NewModelTextMessage(a)) | ||
| 15 | +} | ||
| 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 | ||
| 35 | +// 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 | ||
| 37 | +// which side said it. | ||
| 38 | +func TestSearchRanksAndExcludes(t *testing.T) { | ||
| 39 | + k := newQuestionsKeeper(t) | ||
| 40 | + now := time.Now().UTC() | ||
| 41 | + stored(t, k, "20260101-000000-aaaaaaaa", now.Add(-2*time.Hour), | ||
| 42 | + reply(reply(nil, "hello", "hi"), "set the watchdog", "raise watchdogTimeout to 120s")) | ||
| 43 | + stored(t, k, "20260102-000000-bbbbbbbb", now.Add(-time.Hour), | ||
| 44 | + reply(nil, "Le WATCHDOG coupe trop tôt, quel timeout ?", "firstTokenTimeout")) | ||
| 45 | + current := reply(session0(), "watchdog timeout again", "same") | ||
| 46 | + stored(t, k, k.CurrentID(), now, current) | ||
| 47 | + | ||
| 48 | + hits := k.st.Search("watchdog TIMEOUT", current, 0) | ||
| 49 | + // One hit per turn: a matching question carries its answer, and the | ||
| 50 | + // answer's words count for it. | ||
| 51 | + if len(hits) != 2 { | ||
| 52 | + t.Fatalf("got %d hits: %+v", len(hits), hits) | ||
| 53 | + } | ||
| 54 | + first := hits[0] | ||
| 55 | + if first.Session != "20260102-000000-bbbbbbbb" || first.Score != 2 || first.Role != "question" || first.Turn != 1 || first.Answer != "firstTokenTimeout" { | ||
| 56 | + t.Errorf("first hit = %+v, want the newer two-word question with its answer", first) | ||
| 57 | + } | ||
| 58 | + // The older session's second question holds "watchdog", its answer | ||
| 59 | + // ("watchdogTimeout") both words; the first turn ("hello") nothing. | ||
| 60 | + if hits[1].Session != "20260101-000000-aaaaaaaa" || hits[1].Role != "question" || hits[1].Turn != 2 || hits[1].Score != 2 || hits[1].Answer != "raise watchdogTimeout to 120s" { | ||
| 61 | + t.Errorf("second hit = %+v", hits[1]) | ||
| 62 | + } | ||
| 63 | + for _, h := range hits { | ||
| 64 | + if h.Session == k.CurrentID() { | ||
| 65 | + t.Errorf("a message of the conversation in progress came back: %+v", h) | ||
| 66 | + } | ||
| 67 | + } | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// What is searched: questions as typed (a hook's context cut off), answers, | ||
| 71 | +// /compact summaries. What is not: mm's notes, the errors array, tool calls. | ||
| 72 | +func TestSearchScope(t *testing.T) { | ||
| 73 | + k := newQuestionsKeeper(t) | ||
| 74 | + msgs := append(compact.SummaryPair("we chose zebra as the codename", 4, 2, 0), ai.NewUserTextMessage("[mm: zebra note]")) | ||
| 75 | + msgs = append(msgs, ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "echo zebra"}}))) | ||
| 76 | + msgs = append(msgs, ai.NewUserTextMessage("plain question\n\n[context added by a hook]\nzebra")) | ||
| 77 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), msgs) | ||
| 78 | + | ||
| 79 | + hits := k.st.Search("zebra", nil, 0) | ||
| 80 | + if len(hits) != 1 || hits[0].Role != "summary" { | ||
| 81 | + t.Fatalf("hits = %+v, want the summary alone", hits) | ||
| 82 | + } | ||
| 83 | + if strings.Contains(hits[0].Snippet, "Context summary") { | ||
| 84 | + t.Errorf("the summary's wrapper line is in the snippet: %q", hits[0].Snippet) | ||
| 85 | + } | ||
| 86 | + // The wrapper and the fixed acknowledgement are mm's words, not the notes. | ||
| 87 | + for _, q := range []string{"observations", "understood"} { | ||
| 88 | + if hits := k.st.Search(q, nil, 0); len(hits) != 0 { | ||
| 89 | + t.Errorf("%q found mm's own words: %+v", q, hits) | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// A snippet reaches from the first word found to the last one, so a summary | ||
| 95 | +// that names one subject in its first line and another in its last shows | ||
| 96 | +// both — within snippetMax. | ||
| 97 | +func TestSnippetSpansTheWordsFound(t *testing.T) { | ||
| 98 | + k := newQuestionsKeeper(t) | ||
| 99 | + notes := "- Deployment: one static binary.\n" + strings.Repeat("- filler line about nothing.\n", 8) + "- Backups: nightly, 14 days kept." | ||
| 100 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), compact.SummaryPair(notes, 4, 2, 0)) | ||
| 101 | + | ||
| 102 | + hits := k.st.Search("deploy backups", nil, 0) | ||
| 103 | + if len(hits) != 1 || !strings.Contains(hits[0].Snippet, "Deployment") || !strings.Contains(hits[0].Snippet, "14 days kept") { | ||
| 104 | + t.Fatalf("hits = %+v", hits) | ||
| 105 | + } | ||
| 106 | + far := "start " + strings.Repeat("x", 2*snippetMax) + " end" | ||
| 107 | + if got := []rune(snippet(far, 0, len([]rune(far)))); len(got) > snippetMax+1 { | ||
| 108 | + t.Errorf("snippet of %d runes, want at most %d plus the ellipsis", len(got), snippetMax) | ||
| 109 | + } | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +func TestSearchSnippetAndLimit(t *testing.T) { | ||
| 113 | + k := newQuestionsKeeper(t) | ||
| 114 | + long := strings.Repeat("word ", 60) + "NEEDLE\n\nafter " + strings.Repeat("tail ", 60) | ||
| 115 | + var msgs []*ai.Message | ||
| 116 | + for range MaxHits + 5 { | ||
| 117 | + msgs = reply(msgs, "needle?", long) | ||
| 118 | + } | ||
| 119 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), msgs) | ||
| 120 | + | ||
| 121 | + hits := k.st.Search("needle", nil, 3) | ||
| 122 | + if len(hits) != 3 { | ||
| 123 | + t.Fatalf("limit 3 gave %d hits", len(hits)) | ||
| 124 | + } | ||
| 125 | + if n := len(k.st.Search("needle", nil, 1000)); n != MaxHits { | ||
| 126 | + t.Errorf("limit 1000 gave %d hits, want the cap %d", n, MaxHits) | ||
| 127 | + } | ||
| 128 | + if n := len(k.st.Search("needle", nil, 0)); n != DefaultHits { | ||
| 129 | + t.Errorf("limit 0 gave %d hits, want %d", n, DefaultHits) | ||
| 130 | + } | ||
| 131 | + answer := k.st.Search("needle", nil, 1)[0].Answer | ||
| 132 | + if !strings.HasPrefix(answer, "…") || !strings.HasSuffix(answer, "…") || !strings.Contains(answer, "NEEDLE after") { | ||
| 133 | + t.Errorf("answer snippet = %q", answer) | ||
| 134 | + } | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +func TestMemory(t *testing.T) { | ||
| 138 | + var off *Keeper | ||
| 139 | + if got := off.Memory("x", nil); !strings.Contains(got, "sessions are off") { | ||
| 140 | + t.Errorf("nil keeper: %q", got) | ||
| 141 | + } | ||
| 142 | + k := newQuestionsKeeper(t) | ||
| 143 | + if got := k.Memory(" ", nil); !strings.Contains(got, "nothing to search") { | ||
| 144 | + t.Errorf("empty query: %q", got) | ||
| 145 | + } | ||
| 146 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), reply(nil, "gopls rename", "done")) | ||
| 147 | + if got := k.Memory("RENAME", nil); !strings.HasPrefix(got, "🔎 1 hit(s) for \"RENAME\"") || !strings.Contains(got, "gopls rename") { | ||
| 148 | + t.Errorf("Memory = %q", got) | ||
| 149 | + } | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +func TestFormatHits(t *testing.T) { | ||
| 153 | + at := time.Date(2026, 9, 26, 16, 55, 0, 0, time.Local) | ||
| 154 | + got := FormatHits("watchdog", "/w/.mm/sessions", []Hit{{Session: "id1", At: at, Turn: 4, Role: "answer", Snippet: "raise it"}}) | ||
| 155 | + want := "🔎 1 hit(s) for \"watchdog\" in /w/.mm/sessions\nid1 · 2026-09-26 16:55 · turn 4 · answer\n raise it" | ||
| 156 | + if got != want { | ||
| 157 | + t.Errorf("got\n%s\nwant\n%s", got, want) | ||
| 158 | + } | ||
| 159 | + if got := FormatHits("x", "/d", nil); got != `🔎 no hit for "x" in /d` { | ||
| 160 | + t.Errorf("no hit: %q", got) | ||
| 161 | + } | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +// Words of one or two runes match inside nearly anything: they are dropped | ||
| 165 | +// when the query has longer words, kept when it has nothing else. | ||
| 166 | +func TestShortWordsAreDroppedBesideLongerOnes(t *testing.T) { | ||
| 167 | + if got := searchWords("base de DONNÉES a"); strings.Join(got, ",") != "base,donnees" { | ||
| 168 | + t.Errorf("searchWords = %q", got) | ||
| 169 | + } | ||
| 170 | + if got := searchWords("go UI"); strings.Join(got, ",") != "go,ui" { | ||
| 171 | + t.Errorf("only short words: %q", got) | ||
| 172 | + } | ||
| 173 | + k := newQuestionsKeeper(t) | ||
| 174 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), reply(nil, "Deployment with modernc", "Module done")) | ||
| 175 | + if hits := k.st.Search("base de données", nil, 0); len(hits) != 0 { | ||
| 176 | + t.Errorf("\"de\" matched inside words: %+v", hits) | ||
| 177 | + } | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +// A question that matches comes with the answer its turn ended on — the | ||
| 181 | +// beginning of it when the answer holds none of the words —, an answer that | ||
| 182 | +// matches alone is a hit of its own, and a turn that ended without an answer | ||
| 183 | +// gives the question alone. | ||
| 184 | +func TestQuestionHitCarriesItsFinalAnswer(t *testing.T) { | ||
| 185 | + k := newQuestionsKeeper(t) | ||
| 186 | + msgs := reply(nil, "Quel format pour les logs ?", "log/slog en JSON sur la sortie standard.") | ||
| 187 | + msgs = append(msgs, ai.NewUserTextMessage("which logs rotate?"), ai.NewModelTextMessage("let me check"), | ||
| 188 | + ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash"})), ai.NewModelTextMessage("none: systemd keeps them")) | ||
| 189 | + msgs = reply(msgs, "and the port?", "8417, as for the logs viewer") | ||
| 190 | + msgs = append(msgs, ai.NewUserTextMessage("logs again?")) | ||
| 191 | + stored(t, k, "20260101-000000-aaaaaaaa", time.Now().UTC(), msgs) | ||
| 192 | + | ||
| 193 | + got := map[int]Hit{} | ||
| 194 | + for _, h := range k.st.Search("logs", nil, 0) { | ||
| 195 | + got[h.Turn] = h | ||
| 196 | + } | ||
| 197 | + if h := got[1]; h.Role != "question" || h.Answer != "log/slog en JSON sur la sortie standard." { | ||
| 198 | + t.Errorf("turn 1: %+v", h) | ||
| 199 | + } | ||
| 200 | + if h := got[2]; h.Answer != "none: systemd keeps them" { | ||
| 201 | + t.Errorf("turn 2 should carry its FINAL answer: %+v", h) | ||
| 202 | + } | ||
| 203 | + if h := got[3]; h.Role != "answer" || h.Snippet != "8417, as for the logs viewer" { | ||
| 204 | + t.Errorf("turn 3: %+v", h) | ||
| 205 | + } | ||
| 206 | + if h := got[4]; h.Role != "question" || h.Answer != "" { | ||
| 207 | + t.Errorf("turn 4 (no answer): %+v", h) | ||
| 208 | + } | ||
| 209 | + if len(got) != 4 { | ||
| 210 | + t.Errorf("hits by turn: %+v", got) | ||
| 211 | + } | ||
| 212 | + out := FormatHits("logs", "/d", []Hit{got[1]}) | ||
| 213 | + if !strings.HasSuffix(out, "\n Quel format pour les logs ?\n → log/slog en JSON sur la sortie standard.") { | ||
| 214 | + t.Errorf("FormatHits:\n%s", out) | ||
| 215 | + } | ||
| 216 | +} | ||
added
internal/tools/searchmemory.go +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +package tools | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | + | |
| 7 | + "mm/internal/config" | |
| 8 | + "mm/internal/detector" | |
| 9 | + "mm/internal/engine" | |
| 10 | + "mm/internal/spinner" | |
| 11 | + "mm/internal/store" | |
| 12 | + "mm/internal/ui" | |
| 13 | + | |
| 14 | + "github.com/firebase/genkit/go/ai" | |
| 15 | + "github.com/firebase/genkit/go/genkit" | |
| 16 | +) | |
| 17 | + | |
| 18 | +// --- search_memory (ticket 48) ----------------------------------------------- | |
| 19 | +// | |
| 20 | +// The /memory search in the model's hands: it looks through the project's | |
| 21 | +// stored sessions when it thinks an earlier conversation holds the answer. | |
| 22 | +// It reads files and changes nothing, so it is never gated, even with yolo | |
| 23 | +// off — like read_file. | |
| 24 | +// | |
| 25 | +// The sessions directory is the one the front end saves to: `sessions.dir` | |
| 26 | +// under the session's working directory (ui.Resolve — the editor's project | |
| 27 | +// over ACP). The conversation in progress is left out by content | |
| 28 | +// (store.Search): the model already reads it. | |
| 29 | + | |
| 30 | +// noHitHint follows a search that found nothing. The search matches words, | |
| 31 | +// not meanings: measured on Qwen3.5 4B (2026-10-20), a French question | |
| 32 | +// searched in French ("base de données") found nothing in a session written | |
| 33 | +// in English ("Postgres or SQLite"), and the model fell back to reading the | |
| 34 | +// session files with read_file. | |
| 35 | +const noHitHint = "The search matches words, not meanings: try again with other words — synonyms, or the words in the language the earlier conversation was likely held in (often English)." | |
| 36 | + | |
| 37 | +type searchMemoryInput struct { | |
| 38 | + Query string `json:"query" jsonschema_description:"The words to look for, e.g. 'watchdog timeout'. Case and accents do not matter; a hit holds at least one of them."` | |
| 39 | + Limit int `json:"limit,omitempty" jsonschema_description:"How many hits at most; 0 or absent = 10, at most 50."` | |
| 40 | +} | |
| 41 | + | |
| 42 | +// SearchMemory declares the `search_memory` tool (config | |
| 43 | +// sessions.autoSearchMemory, with sessions on). | |
| 44 | +// | |
| 45 | +// if config.Cfg.Sessions.MemoryTool() { | |
| 46 | +// set.tools = append(set.tools, tools.SearchMemory(e.G, d)) | |
| 47 | +// } | |
| 48 | +func SearchMemory(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | |
| 49 | + description := `Search the earlier conversations of this project (the stored sessions) for words: questions, answers and summaries that hold them. | |
| 50 | +Use it when the user refers to something discussed before ("like last time", "the decision we made about X") or when an earlier session may already hold the answer. Do not use it for what is in this conversation: you already have it. | |
| 51 | +Returns one entry per hit — session id, date, turn, who said it — with the text around the words found.` | |
| 52 | + | |
| 53 | + return genkit.DefineTool(g, "search_memory", description, | |
| 54 | + func(tctx *ai.ToolContext, in searchMemoryInput) (string, error) { | |
| 55 | + title := fmt.Sprintf("search_memory: %q", in.Query) | |
| 56 | + run := func() string { | |
| 57 | + st := store.Open(ui.Resolve(config.Cfg.Sessions.Dir)) | |
| 58 | + out := st.Memory(in.Query, engine.Conversation(tctx), in.Limit) | |
| 59 | + if strings.HasPrefix(out, "🔎 no hit") { | |
| 60 | + out += "\n" + noHitHint | |
| 61 | + } | |
| 62 | + return truncate(out) | |
| 63 | + } | |
| 64 | + if s := ui.ActiveSink(); s != nil { | |
| 65 | + ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: "search_memory", Title: title, Kind: "search", Input: map[string]any{"query": in.Query, "limit": in.Limit}} | |
| 66 | + s.ToolStart(ev) | |
| 67 | + s.ToolRunning(ev.ID) | |
| 68 | + out := run() | |
| 69 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) | |
| 70 | + return record(d, "search_memory", in.Query, out) | |
| 71 | + } | |
| 72 | + announce("🔎 " + title) | |
| 73 | + out := run() | |
| 74 | + spinner.Suspend(func() { echoOutput(out) }) | |
| 75 | + return record(d, "search_memory", in.Query, out) | |
| 76 | + }) | |
| 77 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +package tools | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strings" | ||
| 6 | + | ||
| 7 | + "mm/internal/config" | ||
| 8 | + "mm/internal/detector" | ||
| 9 | + "mm/internal/engine" | ||
| 10 | + "mm/internal/spinner" | ||
| 11 | + "mm/internal/store" | ||
| 12 | + "mm/internal/ui" | ||
| 13 | + | ||
| 14 | + "github.com/firebase/genkit/go/ai" | ||
| 15 | + "github.com/firebase/genkit/go/genkit" | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +// --- search_memory (ticket 48) ----------------------------------------------- | ||
| 19 | +// | ||
| 20 | +// The /memory search in the model's hands: it looks through the project's | ||
| 21 | +// stored sessions when it thinks an earlier conversation holds the answer. | ||
| 22 | +// It reads files and changes nothing, so it is never gated, even with yolo | ||
| 23 | +// off — like read_file. | ||
| 24 | +// | ||
| 25 | +// The sessions directory is the one the front end saves to: `sessions.dir` | ||
| 26 | +// under the session's working directory (ui.Resolve — the editor's project | ||
| 27 | +// over ACP). The conversation in progress is left out by content | ||
| 28 | +// (store.Search): the model already reads it. | ||
| 29 | + | ||
| 30 | +// noHitHint follows a search that found nothing. The search matches words, | ||
| 31 | +// not meanings: measured on Qwen3.5 4B (2026-10-20), a French question | ||
| 32 | +// searched in French ("base de données") found nothing in a session written | ||
| 33 | +// in English ("Postgres or SQLite"), and the model fell back to reading the | ||
| 34 | +// session files with read_file. | ||
| 35 | +const noHitHint = "The search matches words, not meanings: try again with other words — synonyms, or the words in the language the earlier conversation was likely held in (often English)." | ||
| 36 | + | ||
| 37 | +type searchMemoryInput struct { | ||
| 38 | + Query string `json:"query" jsonschema_description:"The words to look for, e.g. 'watchdog timeout'. Case and accents do not matter; a hit holds at least one of them."` | ||
| 39 | + Limit int `json:"limit,omitempty" jsonschema_description:"How many hits at most; 0 or absent = 10, at most 50."` | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +// SearchMemory declares the `search_memory` tool (config | ||
| 43 | +// sessions.autoSearchMemory, with sessions on). | ||
| 44 | +// | ||
| 45 | +// if config.Cfg.Sessions.MemoryTool() { | ||
| 46 | +// set.tools = append(set.tools, tools.SearchMemory(e.G, d)) | ||
| 47 | +// } | ||
| 48 | +func SearchMemory(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | ||
| 49 | + description := `Search the earlier conversations of this project (the stored sessions) for words: questions, answers and summaries that hold them. | ||
| 50 | +Use it when the user refers to something discussed before ("like last time", "the decision we made about X") or when an earlier session may already hold the answer. Do not use it for what is in this conversation: you already have it. | ||
| 51 | +Returns one entry per hit — session id, date, turn, who said it — with the text around the words found.` | ||
| 52 | + | ||
| 53 | + return genkit.DefineTool(g, "search_memory", description, | ||
| 54 | + func(tctx *ai.ToolContext, in searchMemoryInput) (string, error) { | ||
| 55 | + title := fmt.Sprintf("search_memory: %q", in.Query) | ||
| 56 | + run := func() string { | ||
| 57 | + st := store.Open(ui.Resolve(config.Cfg.Sessions.Dir)) | ||
| 58 | + out := st.Memory(in.Query, engine.Conversation(tctx), in.Limit) | ||
| 59 | + if strings.HasPrefix(out, "🔎 no hit") { | ||
| 60 | + out += "\n" + noHitHint | ||
| 61 | + } | ||
| 62 | + return truncate(out) | ||
| 63 | + } | ||
| 64 | + if s := ui.ActiveSink(); s != nil { | ||
| 65 | + ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: "search_memory", Title: title, Kind: "search", Input: map[string]any{"query": in.Query, "limit": in.Limit}} | ||
| 66 | + s.ToolStart(ev) | ||
| 67 | + s.ToolRunning(ev.ID) | ||
| 68 | + out := run() | ||
| 69 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) | ||
| 70 | + return record(d, "search_memory", in.Query, out) | ||
| 71 | + } | ||
| 72 | + announce("🔎 " + title) | ||
| 73 | + out := run() | ||
| 74 | + spinner.Suspend(func() { echoOutput(out) }) | ||
| 75 | + return record(d, "search_memory", in.Query, out) | ||
| 76 | + }) | ||
| 77 | +} | ||
added
internal/tools/searchmemory_test.go +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +package tools | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "path/filepath" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "mm/internal/config" | |
| 10 | + "mm/internal/detector" | |
| 11 | + "mm/internal/engine" | |
| 12 | + "mm/internal/store" | |
| 13 | + "mm/internal/ui" | |
| 14 | + | |
| 15 | + "github.com/firebase/genkit/go/ai" | |
| 16 | + "github.com/firebase/genkit/go/genkit" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// search_memory reads the sessions of the session's working directory, leaves | |
| 20 | +// out what the conversation in progress already holds, shows a search call, | |
| 21 | +// and never asks, even with yolo off. | |
| 22 | +func TestSearchMemoryReadsTheProjectSessions(t *testing.T) { | |
| 23 | + yoloOff(t) | |
| 24 | + saved := config.Cfg.Sessions | |
| 25 | + t.Cleanup(func() { config.Cfg.Sessions = saved }) | |
| 26 | + config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", AutoSearchMemory: true} | |
| 27 | + | |
| 28 | + project := t.TempDir() | |
| 29 | + st := store.Open(filepath.Join(project, ".mm", "sessions")) | |
| 30 | + asked := ai.NewUserTextMessage("which port for the zebra service?") | |
| 31 | + if err := st.Save(&store.Session{ID: "20260101-000000-aaaaaaaa", Messages: []*ai.Message{ | |
| 32 | + ai.NewUserTextMessage("start the zebra service"), ai.NewModelTextMessage("it listens on 8099"), asked, | |
| 33 | + }}); err != nil { | |
| 34 | + t.Fatal(err) | |
| 35 | + } | |
| 36 | + | |
| 37 | + g := genkit.Init(context.Background()) | |
| 38 | + SearchMemory(g, detector.NewLoopDetector(10, 3)) | |
| 39 | + sink := &recordingSink{dir: project} | |
| 40 | + ui.SetActive(sink) | |
| 41 | + t.Cleanup(func() { ui.SetActive(nil) }) | |
| 42 | + | |
| 43 | + ctx := engine.WithConversation(context.Background(), []*ai.Message{ai.NewSystemTextMessage("sys"), asked}) | |
| 44 | + out, err := genkit.LookupTool(g, "search_memory").RunRaw(ctx, map[string]any{"query": "zebra"}) | |
| 45 | + if err != nil { | |
| 46 | + t.Fatal(err) | |
| 47 | + } | |
| 48 | + text := out.(string) | |
| 49 | + if !strings.HasPrefix(text, `🔎 1 hit(s) for "zebra"`) || !strings.Contains(text, "start the zebra service") { | |
| 50 | + t.Errorf("output:\n%s", text) | |
| 51 | + } | |
| 52 | + if sink.asked != 0 || sink.started.Kind != "search" || sink.started.Title != `search_memory: "zebra"` { | |
| 53 | + t.Errorf("asked %d, shown as %+v", sink.asked, sink.started) | |
| 54 | + } | |
| 55 | + | |
| 56 | + // No hit: the output says how to search again — the search matches words. | |
| 57 | + out, _ = genkit.LookupTool(g, "search_memory").RunRaw(ctx, map[string]any{"query": "base de données"}) | |
| 58 | + if text := out.(string); !strings.HasPrefix(text, "🔎 no hit") || !strings.HasSuffix(text, noHitHint) { | |
| 59 | + t.Errorf("no hit:\n%s", text) | |
| 60 | + } | |
| 61 | + | |
| 62 | + out, _ = genkit.LookupTool(g, "search_memory").RunRaw(ctx, map[string]any{"query": " "}) | |
| 63 | + if !strings.Contains(out.(string), "nothing to search") { | |
| 64 | + t.Errorf("empty query: %v", out) | |
| 65 | + } | |
| 66 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +package tools | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "path/filepath" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "mm/internal/config" | ||
| 10 | + "mm/internal/detector" | ||
| 11 | + "mm/internal/engine" | ||
| 12 | + "mm/internal/store" | ||
| 13 | + "mm/internal/ui" | ||
| 14 | + | ||
| 15 | + "github.com/firebase/genkit/go/ai" | ||
| 16 | + "github.com/firebase/genkit/go/genkit" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// search_memory reads the sessions of the session's working directory, leaves | ||
| 20 | +// out what the conversation in progress already holds, shows a search call, | ||
| 21 | +// and never asks, even with yolo off. | ||
| 22 | +func TestSearchMemoryReadsTheProjectSessions(t *testing.T) { | ||
| 23 | + yoloOff(t) | ||
| 24 | + saved := config.Cfg.Sessions | ||
| 25 | + t.Cleanup(func() { config.Cfg.Sessions = saved }) | ||
| 26 | + config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", AutoSearchMemory: true} | ||
| 27 | + | ||
| 28 | + project := t.TempDir() | ||
| 29 | + st := store.Open(filepath.Join(project, ".mm", "sessions")) | ||
| 30 | + asked := ai.NewUserTextMessage("which port for the zebra service?") | ||
| 31 | + if err := st.Save(&store.Session{ID: "20260101-000000-aaaaaaaa", Messages: []*ai.Message{ | ||
| 32 | + ai.NewUserTextMessage("start the zebra service"), ai.NewModelTextMessage("it listens on 8099"), asked, | ||
| 33 | + }}); err != nil { | ||
| 34 | + t.Fatal(err) | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + g := genkit.Init(context.Background()) | ||
| 38 | + SearchMemory(g, detector.NewLoopDetector(10, 3)) | ||
| 39 | + sink := &recordingSink{dir: project} | ||
| 40 | + ui.SetActive(sink) | ||
| 41 | + t.Cleanup(func() { ui.SetActive(nil) }) | ||
| 42 | + | ||
| 43 | + ctx := engine.WithConversation(context.Background(), []*ai.Message{ai.NewSystemTextMessage("sys"), asked}) | ||
| 44 | + out, err := genkit.LookupTool(g, "search_memory").RunRaw(ctx, map[string]any{"query": "zebra"}) | ||
| 45 | + if err != nil { | ||
| 46 | + t.Fatal(err) | ||
| 47 | + } | ||
| 48 | + text := out.(string) | ||
| 49 | + if !strings.HasPrefix(text, `🔎 1 hit(s) for "zebra"`) || !strings.Contains(text, "start the zebra service") { | ||
| 50 | + t.Errorf("output:\n%s", text) | ||
| 51 | + } | ||
| 52 | + if sink.asked != 0 || sink.started.Kind != "search" || sink.started.Title != `search_memory: "zebra"` { | ||
| 53 | + t.Errorf("asked %d, shown as %+v", sink.asked, sink.started) | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + // No hit: the output says how to search again — the search matches words. | ||
| 57 | + out, _ = genkit.LookupTool(g, "search_memory").RunRaw(ctx, map[string]any{"query": "base de données"}) | ||
| 58 | + if text := out.(string); !strings.HasPrefix(text, "🔎 no hit") || !strings.HasSuffix(text, noHitHint) { | ||
| 59 | + t.Errorf("no hit:\n%s", text) | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + out, _ = genkit.LookupTool(g, "search_memory").RunRaw(ctx, map[string]any{"query": " "}) | ||
| 63 | + if !strings.Contains(out.(string), "nothing to search") { | ||
| 64 | + t.Errorf("empty query: %v", out) | ||
| 65 | + } | ||
| 66 | +} | ||
modified
internal/tui/commands.go +2 -0 | @@ -104,6 +104,8 @@ func (r *runner) dispatch(p Prompt) { | ||
| 104 | 104 | r.saveAnswer(arg) |
| 105 | 105 | case command.Unload: |
| 106 | 106 | r.unload(arg) |
| 107 | + case command.Memory: | |
| 108 | + r.t.Notice(r.keep.Memory(arg, r.messages)) | |
| 107 | 109 | } |
| 108 | 110 | } |
| 109 | 111 | |
| @@ -104,6 +104,8 @@ func (r *runner) dispatch(p Prompt) { | |||
| 104 | r.saveAnswer(arg) | 104 | r.saveAnswer(arg) |
| 105 | case command.Unload: | 105 | case command.Unload: |
| 106 | r.unload(arg) | 106 | r.unload(arg) |
| 107 | + case command.Memory: | ||
| 108 | + r.t.Notice(r.keep.Memory(arg, r.messages)) | ||
| 107 | } | 109 | } |
| 108 | } | 110 | } |
| 109 | 111 | ||
modified
internal/tui/turn_test.go +26 -0 | @@ -610,3 +610,29 @@ func TestRunnerUnload(t *testing.T) { | ||
| 610 | 610 | t.Errorf("the history changed: %d messages", len(r.messages)) |
| 611 | 611 | } |
| 612 | 612 | } |
| 613 | + | |
| 614 | +// /memory shows the hits as a notice, and the conversation in progress is | |
| 615 | +// not among them; the history does not change. | |
| 616 | +func TestRunnerMemory(t *testing.T) { | |
| 617 | + command.SetMemory(true) | |
| 618 | + t.Cleanup(func() { command.SetMemory(false) }) | |
| 619 | + r, tr := newTestRunner(t, &fakeModel{answer: "the zebra is striped"}) | |
| 620 | + config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", SearchMemory: true} | |
| 621 | + r.keep = store.NewKeeper(r.opts.Cwd) | |
| 622 | + old := store.Open(r.keep.Dir()) | |
| 623 | + if err := old.Save(&store.Session{ID: "20260101-000000-aaaaaaaa", Messages: []*ai.Message{ai.NewUserTextMessage("what about the zebra?")}}); err != nil { | |
| 624 | + t.Fatal(err) | |
| 625 | + } | |
| 626 | + r.submit(promptOf("tell me about the zebra")) | |
| 627 | + waitIdle(t, r) | |
| 628 | + before := len(r.messages) | |
| 629 | + | |
| 630 | + r.submit(promptOf("/memory zebra")) | |
| 631 | + n := notices(tr) | |
| 632 | + if !strings.Contains(n, `🔎 1 hit(s) for "zebra"`) || !strings.Contains(n, "what about the zebra?") { | |
| 633 | + t.Errorf("notices:\n%s", n) | |
| 634 | + } | |
| 635 | + if len(r.messages) != before { | |
| 636 | + t.Errorf("the history changed: %d → %d messages", before, len(r.messages)) | |
| 637 | + } | |
| 638 | +} | |
| @@ -610,3 +610,29 @@ func TestRunnerUnload(t *testing.T) { | |||
| 610 | t.Errorf("the history changed: %d messages", len(r.messages)) | 610 | t.Errorf("the history changed: %d messages", len(r.messages)) |
| 611 | } | 611 | } |
| 612 | } | 612 | } |
| 613 | + | ||
| 614 | +// /memory shows the hits as a notice, and the conversation in progress is | ||
| 615 | +// not among them; the history does not change. | ||
| 616 | +func TestRunnerMemory(t *testing.T) { | ||
| 617 | + command.SetMemory(true) | ||
| 618 | + t.Cleanup(func() { command.SetMemory(false) }) | ||
| 619 | + r, tr := newTestRunner(t, &fakeModel{answer: "the zebra is striped"}) | ||
| 620 | + config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", SearchMemory: true} | ||
| 621 | + r.keep = store.NewKeeper(r.opts.Cwd) | ||
| 622 | + old := store.Open(r.keep.Dir()) | ||
| 623 | + if err := old.Save(&store.Session{ID: "20260101-000000-aaaaaaaa", Messages: []*ai.Message{ai.NewUserTextMessage("what about the zebra?")}}); err != nil { | ||
| 624 | + t.Fatal(err) | ||
| 625 | + } | ||
| 626 | + r.submit(promptOf("tell me about the zebra")) | ||
| 627 | + waitIdle(t, r) | ||
| 628 | + before := len(r.messages) | ||
| 629 | + | ||
| 630 | + r.submit(promptOf("/memory zebra")) | ||
| 631 | + n := notices(tr) | ||
| 632 | + if !strings.Contains(n, `🔎 1 hit(s) for "zebra"`) || !strings.Contains(n, "what about the zebra?") { | ||
| 633 | + t.Errorf("notices:\n%s", n) | ||
| 634 | + } | ||
| 635 | + if len(r.messages) != before { | ||
| 636 | + t.Errorf("the history changed: %d → %d messages", before, len(r.messages)) | ||
| 637 | + } | ||
| 638 | +} | ||
modified
internal/ui/ui.go +1 -1 | @@ -38,7 +38,7 @@ type ToolEvent struct { | ||
| 38 | 38 | ID string // "call_1" — unique within the process, see NextCallID |
| 39 | 39 | Tool string // the tool's name: "bash", "read_file", … |
| 40 | 40 | Title string // one line for the client's UI: "bash: ls -1" |
| 41 | - Kind string // how a client should render it: "execute", "read", "edit" | |
| 41 | + Kind string // how a client should render it: "execute", "read", "edit", "fetch", "search", "other" | |
| 42 | 42 | Input map[string]any // the raw input, for the client to display |
| 43 | 43 | Path string // the file touched (absolute), "" when none |
| 44 | 44 | } |
| @@ -38,7 +38,7 @@ type ToolEvent struct { | |||
| 38 | ID string // "call_1" — unique within the process, see NextCallID | 38 | ID string // "call_1" — unique within the process, see NextCallID |
| 39 | Tool string // the tool's name: "bash", "read_file", … | 39 | Tool string // the tool's name: "bash", "read_file", … |
| 40 | Title string // one line for the client's UI: "bash: ls -1" | 40 | Title string // one line for the client's UI: "bash: ls -1" |
| 41 | - Kind string // how a client should render it: "execute", "read", "edit" | 41 | + Kind string // how a client should render it: "execute", "read", "edit", "fetch", "search", "other" |
| 42 | Input map[string]any // the raw input, for the client to display | 42 | Input map[string]any // the raw input, for the client to display |
| 43 | Path string // the file touched (absolute), "" when none | 43 | Path string // the file touched (absolute), "" when none |
| 44 | } | 44 | } |
modified
main.go +9 -0 | @@ -212,6 +212,7 @@ func main() { | ||
| 212 | 212 | // processes start on the first ask and stop when this one exits. |
| 213 | 213 | command.SetAgents(config.AgentNames()) |
| 214 | 214 | command.SetUnload(config.Cfg.UnloadModels) |
| 215 | + command.SetMemory(config.Cfg.Sessions.MemoryCommand()) | |
| 215 | 216 | asker := ask.NewPool() |
| 216 | 217 | defer asker.Close() |
| 217 | 218 | |
| @@ -354,6 +355,7 @@ type toolSet struct { | ||
| 354 | 355 | // `ask_agent` when mmConfigs names at least one agent, over the same pool as |
| 355 | 356 | // /ask; `ask_user` behind `askUserTool` (on by default), never with a |
| 356 | 357 | // structuredOutput; `save_answer` behind `saveAnswerTool` (off by default); |
| 358 | +// `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on); | |
| 357 | 359 | // and the agent file's declared `tools:`. |
| 358 | 360 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { |
| 359 | 361 | set := toolSet{close: func() {}} |
| @@ -404,6 +406,13 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent | ||
| 404 | 406 | set.tools = append(set.tools, tools.FetchContent(e.G, d, fetcher)) |
| 405 | 407 | set.names = append(set.names, "fetch_content") |
| 406 | 408 | } |
| 409 | + if config.Cfg.Sessions.MemoryTool() { | |
| 410 | + set.tools = append(set.tools, tools.SearchMemory(e.G, d)) | |
| 411 | + set.names = append(set.names, "search_memory") | |
| 412 | + } | |
| 413 | + if w := config.Cfg.Sessions.MemoryWarning(); w != "" { | |
| 414 | + set.warnings = append(set.warnings, w) | |
| 415 | + } | |
| 407 | 416 | if len(config.Cfg.MMConfigs) > 0 { |
| 408 | 417 | set.tools = append(set.tools, tools.AskAgent(e.G, d, asker)) |
| 409 | 418 | set.names = append(set.names, "ask_agent") |
| @@ -212,6 +212,7 @@ func main() { | |||
| 212 | // processes start on the first ask and stop when this one exits. | 212 | // processes start on the first ask and stop when this one exits. |
| 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 | asker := ask.NewPool() | 216 | asker := ask.NewPool() |
| 216 | defer asker.Close() | 217 | defer asker.Close() |
| 217 | 218 | ||
| @@ -354,6 +355,7 @@ type toolSet struct { | |||
| 354 | // `ask_agent` when mmConfigs names at least one agent, over the same pool as | 355 | // `ask_agent` when mmConfigs names at least one agent, over the same pool as |
| 355 | // /ask; `ask_user` behind `askUserTool` (on by default), never with a | 356 | // /ask; `ask_user` behind `askUserTool` (on by default), never with a |
| 356 | // structuredOutput; `save_answer` behind `saveAnswerTool` (off by default); | 357 | // structuredOutput; `save_answer` behind `saveAnswerTool` (off by default); |
| 358 | +// `search_memory` behind `sessions.autoSearchMemory` (off by default, sessions on); | ||
| 357 | // and the agent file's declared `tools:`. | 359 | // and the agent file's declared `tools:`. |
| 358 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { | 360 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { |
| 359 | set := toolSet{close: func() {}} | 361 | set := toolSet{close: func() {}} |
| @@ -404,6 +406,13 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent | |||
| 404 | set.tools = append(set.tools, tools.FetchContent(e.G, d, fetcher)) | 406 | set.tools = append(set.tools, tools.FetchContent(e.G, d, fetcher)) |
| 405 | set.names = append(set.names, "fetch_content") | 407 | set.names = append(set.names, "fetch_content") |
| 406 | } | 408 | } |
| 409 | + if config.Cfg.Sessions.MemoryTool() { | ||
| 410 | + set.tools = append(set.tools, tools.SearchMemory(e.G, d)) | ||
| 411 | + set.names = append(set.names, "search_memory") | ||
| 412 | + } | ||
| 413 | + if w := config.Cfg.Sessions.MemoryWarning(); w != "" { | ||
| 414 | + set.warnings = append(set.warnings, w) | ||
| 415 | + } | ||
| 407 | if len(config.Cfg.MMConfigs) > 0 { | 416 | if len(config.Cfg.MMConfigs) > 0 { |
| 408 | set.tools = append(set.tools, tools.AskAgent(e.G, d, asker)) | 417 | set.tools = append(set.tools, tools.AskAgent(e.G, d, asker)) |
| 409 | set.names = append(set.names, "ask_agent") | 418 | set.names = append(set.names, "ask_agent") |
added
memory_assemble_test.go +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 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_memory is declared only with sessions on AND autoSearchMemory; | |
| 16 | +// searchMemory alone (the /memory command) does not declare it; a memory key | |
| 17 | +// set with sessions off becomes a banner warning. | |
| 18 | +func TestSearchMemoryIsDeclaredOnlyWhenAsked(t *testing.T) { | |
| 19 | + cases := []struct { | |
| 20 | + name string | |
| 21 | + sessions config.SessionsConfig | |
| 22 | + want, warn bool | |
| 23 | + }{ | |
| 24 | + {"default", config.SessionsConfig{Dir: ".mm/sessions"}, false, false}, | |
| 25 | + {"sessions on", config.SessionsConfig{Enabled: true, Dir: ".mm/sessions"}, false, false}, | |
| 26 | + {"command only", config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", SearchMemory: true}, false, false}, | |
| 27 | + {"tool", config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", AutoSearchMemory: true}, true, false}, | |
| 28 | + {"tool, sessions off", config.SessionsConfig{Dir: ".mm/sessions", AutoSearchMemory: true}, false, true}, | |
| 29 | + } | |
| 30 | + for _, c := range cases { | |
| 31 | + t.Run(c.name, func(t *testing.T) { | |
| 32 | + withConfig(t, func(cfg *config.Config) { cfg.Sessions = c.sessions }) | |
| 33 | + t.Setenv("MCP_GATEWAY_URL", "") | |
| 34 | + set := assembleTools(&engine.Engine{G: genkit.Init(context.Background())}, detector.NewLoopDetector(10, 3), nil) | |
| 35 | + if got := slices.Contains(set.names, "search_memory"); got != c.want { | |
| 36 | + t.Errorf("search_memory declared = %v, want %v (names %v)", got, c.want, set.names) | |
| 37 | + } | |
| 38 | + if got := slices.Contains(set.warnings, config.Cfg.Sessions.MemoryWarning()) && config.Cfg.Sessions.MemoryWarning() != ""; got != c.warn { | |
| 39 | + t.Errorf("warning = %v, want %v (%v)", got, c.warn, set.warnings) | |
| 40 | + } | |
| 41 | + }) | |
| 42 | + } | |
| 43 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 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_memory is declared only with sessions on AND autoSearchMemory; | ||
| 16 | +// searchMemory alone (the /memory command) does not declare it; a memory key | ||
| 17 | +// set with sessions off becomes a banner warning. | ||
| 18 | +func TestSearchMemoryIsDeclaredOnlyWhenAsked(t *testing.T) { | ||
| 19 | + cases := []struct { | ||
| 20 | + name string | ||
| 21 | + sessions config.SessionsConfig | ||
| 22 | + want, warn bool | ||
| 23 | + }{ | ||
| 24 | + {"default", config.SessionsConfig{Dir: ".mm/sessions"}, false, false}, | ||
| 25 | + {"sessions on", config.SessionsConfig{Enabled: true, Dir: ".mm/sessions"}, false, false}, | ||
| 26 | + {"command only", config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", SearchMemory: true}, false, false}, | ||
| 27 | + {"tool", config.SessionsConfig{Enabled: true, Dir: ".mm/sessions", AutoSearchMemory: true}, true, false}, | ||
| 28 | + {"tool, sessions off", config.SessionsConfig{Dir: ".mm/sessions", AutoSearchMemory: true}, false, true}, | ||
| 29 | + } | ||
| 30 | + for _, c := range cases { | ||
| 31 | + t.Run(c.name, func(t *testing.T) { | ||
| 32 | + withConfig(t, func(cfg *config.Config) { cfg.Sessions = c.sessions }) | ||
| 33 | + t.Setenv("MCP_GATEWAY_URL", "") | ||
| 34 | + set := assembleTools(&engine.Engine{G: genkit.Init(context.Background())}, detector.NewLoopDetector(10, 3), nil) | ||
| 35 | + if got := slices.Contains(set.names, "search_memory"); got != c.want { | ||
| 36 | + t.Errorf("search_memory declared = %v, want %v (names %v)", got, c.want, set.names) | ||
| 37 | + } | ||
| 38 | + if got := slices.Contains(set.warnings, config.Cfg.Sessions.MemoryWarning()) && config.Cfg.Sessions.MemoryWarning() != ""; got != c.warn { | ||
| 39 | + t.Errorf("warning = %v, want %v (%v)", got, c.warn, set.warnings) | ||
| 40 | + } | ||
| 41 | + }) | ||
| 42 | + } | ||
| 43 | +} | ||