🛟 Updated.
fd5d2a7 parent: 77aca16 modified
.tickets/issues/0046-add-builtin-tools-as-plugins-with-wasm.yaml +40 -5 | @@ -1,15 +1,18 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 46 |
| 3 | 3 | title: Add builtin tools as plugins with wasm |
| 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-27T02:30:34.975Z |
| 9 | -updatedAt: 2026-10-21T00:00:00.000Z | |
| 9 | +updatedAt: 2026-10-21T23:59:59.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.9.0 |
| 12 | 12 | epic: Builtin tools |
| 13 | +related: | |
| 14 | + - 119 | |
| 15 | + - 120 | |
| 13 | 16 | body: | |
| 14 | 17 | example: safe / unsafe with wasm -> LlamaGuard |
| 15 | 18 | |
| @@ -25,8 +28,8 @@ tasks: | ||
| 25 | 28 | createdAt: 2026-10-20T07:42:43.652Z |
| 26 | 29 | updatedAt: 2026-10-21T00:00:00.000Z |
| 27 | 30 | - id: 2 |
| 28 | - title: "Experiment (not started): bash-guard as a WASM hook vs the Node one — time per call, size, capability set, author experience" | |
| 29 | - state: open | |
| 31 | + title: "Experiment (done in ticket 120): bash-guard as a WASM hook vs the Node one — time per call, size, capability set, author experience" | |
| 32 | + state: closed | |
| 30 | 33 | priority: none |
| 31 | 34 | author: |
| 32 | 35 | name: k33g |
| @@ -98,4 +101,36 @@ comments: | ||
| 98 | 101 | a WASM hook with a minimal capability set, compare time per call, |
| 99 | 102 | size and the hook author's experience with the Node version. If it |
| 100 | 103 | pays, extend to declared tools, then to the LlamaGuard example. |
| 101 | - | |
| 104 | + - id: 2 | |
| 105 | + author: | |
| 106 | + name: k33g | |
| 107 | + email: ph.charriere@gmail.com | |
| 108 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 109 | + body: | | |
| 110 | + First step done in ticket 119: declared tools as WASM modules (wazero, | |
| 111 | + TinyGo-built WASI commands, JSON on stdin, `read:` / `write:` folders, | |
| 112 | + nothing else visible), with demos/wasm-tools. Measured there: compile | |
| 113 | + 176–183 ms once per process, 0.2–0.5 ms per call (vs ~62 ms for a | |
| 114 | + Node hook, comment 1); binary +3.9 MB. Task 2 (bash-guard as a WASM | |
| 115 | + HOOK) is still open: hooks are not WASM yet. | |
| 116 | + - id: 3 | |
| 117 | + author: | |
| 118 | + name: k33g | |
| 119 | + email: ph.charriere@gmail.com | |
| 120 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 121 | + body: | | |
| 122 | + Task 2 done in ticket 120 (hooks as WASM modules). bash-guard as a | |
| 123 | + TinyGo module vs demos/hooks/hooks/bash-guard.js, same denied call, | |
| 124 | + through hooks.Runner, 20 runs after a first one, twice, linux/arm64: | |
| 125 | + - time per call: Node v22 median 65–67 ms (first 68–102 ms); WASM | |
| 126 | + median 0.56–0.58 ms, first run 234–248 ms (the compilation, once | |
| 127 | + per mm process); | |
| 128 | + - size: 954 bytes of JS (+ Node) vs a 770 KB module (regexp + JSON); | |
| 129 | + - capabilities: nothing but its stdin and MM_HOOK_EVENT, vs all the | |
| 130 | + user's rights; | |
| 131 | + - author experience: same program shape (JSON in, JSON out), plus a | |
| 132 | + TinyGo build step; paths must be clean inside a module. | |
| 133 | + What stays a script: hooks that list the working directory or need | |
| 134 | + the network (guard models). With 119 (tools) and 120 (hooks), what | |
| 135 | + this ticket proposed is covered; providers and front ends were judged | |
| 136 | + not useful as plugins (comment 1). | |
| @@ -1,15 +1,18 @@ | |||
| 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: 46 | 2 | id: 46 |
| 3 | title: Add builtin tools as plugins with wasm | 3 | title: Add builtin tools as plugins with wasm |
| 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-27T02:30:34.975Z | 8 | createdAt: 2026-09-27T02:30:34.975Z |
| 9 | -updatedAt: 2026-10-21T00:00:00.000Z | 9 | +updatedAt: 2026-10-21T23:59:59.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.9.0 | 11 | milestone: v0.9.0 |
| 12 | epic: Builtin tools | 12 | epic: Builtin tools |
| 13 | +related: | ||
| 14 | + - 119 | ||
| 15 | + - 120 | ||
| 13 | body: | | 16 | body: | |
| 14 | example: safe / unsafe with wasm -> LlamaGuard | 17 | example: safe / unsafe with wasm -> LlamaGuard |
| 15 | 18 | ||
| @@ -25,8 +28,8 @@ tasks: | |||
| 25 | createdAt: 2026-10-20T07:42:43.652Z | 28 | createdAt: 2026-10-20T07:42:43.652Z |
| 26 | updatedAt: 2026-10-21T00:00:00.000Z | 29 | updatedAt: 2026-10-21T00:00:00.000Z |
| 27 | - id: 2 | 30 | - id: 2 |
| 28 | - title: "Experiment (not started): bash-guard as a WASM hook vs the Node one — time per call, size, capability set, author experience" | 31 | + title: "Experiment (done in ticket 120): bash-guard as a WASM hook vs the Node one — time per call, size, capability set, author experience" |
| 29 | - state: open | 32 | + state: closed |
| 30 | priority: none | 33 | priority: none |
| 31 | author: | 34 | author: |
| 32 | name: k33g | 35 | name: k33g |
| @@ -98,4 +101,36 @@ comments: | |||
| 98 | a WASM hook with a minimal capability set, compare time per call, | 101 | a WASM hook with a minimal capability set, compare time per call, |
| 99 | size and the hook author's experience with the Node version. If it | 102 | size and the hook author's experience with the Node version. If it |
| 100 | pays, extend to declared tools, then to the LlamaGuard example. | 103 | pays, extend to declared tools, then to the LlamaGuard example. |
| 101 | - | 104 | + - id: 2 |
| 105 | + author: | ||
| 106 | + name: k33g | ||
| 107 | + email: ph.charriere@gmail.com | ||
| 108 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 109 | + body: | | ||
| 110 | + First step done in ticket 119: declared tools as WASM modules (wazero, | ||
| 111 | + TinyGo-built WASI commands, JSON on stdin, `read:` / `write:` folders, | ||
| 112 | + nothing else visible), with demos/wasm-tools. Measured there: compile | ||
| 113 | + 176–183 ms once per process, 0.2–0.5 ms per call (vs ~62 ms for a | ||
| 114 | + Node hook, comment 1); binary +3.9 MB. Task 2 (bash-guard as a WASM | ||
| 115 | + HOOK) is still open: hooks are not WASM yet. | ||
| 116 | + - id: 3 | ||
| 117 | + author: | ||
| 118 | + name: k33g | ||
| 119 | + email: ph.charriere@gmail.com | ||
| 120 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 121 | + body: | | ||
| 122 | + Task 2 done in ticket 120 (hooks as WASM modules). bash-guard as a | ||
| 123 | + TinyGo module vs demos/hooks/hooks/bash-guard.js, same denied call, | ||
| 124 | + through hooks.Runner, 20 runs after a first one, twice, linux/arm64: | ||
| 125 | + - time per call: Node v22 median 65–67 ms (first 68–102 ms); WASM | ||
| 126 | + median 0.56–0.58 ms, first run 234–248 ms (the compilation, once | ||
| 127 | + per mm process); | ||
| 128 | + - size: 954 bytes of JS (+ Node) vs a 770 KB module (regexp + JSON); | ||
| 129 | + - capabilities: nothing but its stdin and MM_HOOK_EVENT, vs all the | ||
| 130 | + user's rights; | ||
| 131 | + - author experience: same program shape (JSON in, JSON out), plus a | ||
| 132 | + TinyGo build step; paths must be clean inside a module. | ||
| 133 | + What stays a script: hooks that list the working directory or need | ||
| 134 | + the network (guard models). With 119 (tools) and 120 (hooks), what | ||
| 135 | + this ticket proposed is covered; providers and front ends were judged | ||
| 136 | + not useful as plugins (comment 1). | ||
added
.tickets/issues/0116-how-to-use-mm-for-code-completion.yaml +76 -0 | new file mode 100644 | ||
| @@ -0,0 +1,76 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 116 | |
| 3 | +title: How to use mm for code completion? | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-21T11:38:42.420Z | |
| 9 | +updatedAt: 2026-10-21T23:59:59.000Z | |
| 10 | +labels: | |
| 11 | + - wontfix | |
| 12 | +body: | | |
| 13 | + Inline code completion in the IDEs (VS Code, Zed, …): ghost text while | |
| 14 | + typing, Copilot-style. Can mm provide it? | |
| 15 | +tasks: [] | |
| 16 | +comments: | |
| 17 | + - id: 1 | |
| 18 | + author: | |
| 19 | + name: k33g | |
| 20 | + email: ph.charriere@gmail.com | |
| 21 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 22 | + body: | | |
| 23 | + Answer (2026-10-21): wontfix — inline completion is not mm's job, and | |
| 24 | + ACP could not carry it anyway. The IDE's completion feature should | |
| 25 | + talk to a model server directly; mm stays in the agent panel. Taken | |
| 26 | + out of v0.9.0, labelled wontfix (user's decision). | |
| 27 | + | |
| 28 | + ## Why not mm | |
| 29 | + - The protocol. ACP (how mm talks to editors) is sessions and | |
| 30 | + prompts: no method or capability for inline completion was found; | |
| 31 | + the only "autocomplete" in ACP clients is slash-command completion | |
| 32 | + in the chat panel. Inline completion lives in the editor's own | |
| 33 | + completion feature (or LSP). | |
| 34 | + https://codecompanion.olimorris.dev/usage/acp-protocol | |
| 35 | + https://mintlify.com/agentclientprotocol/typescript-sdk/concepts/protocol-overview | |
| 36 | + - Latency and shape. A completion fires on every typing pause and | |
| 37 | + must answer in a few hundred ms, with a "prefix + suffix" | |
| 38 | + (fill-in-the-middle, FIM) prompt. An mm turn is a system prompt, | |
| 39 | + tool schemas, a history, sometimes several round trips: seconds. | |
| 40 | + | |
| 41 | + ## What the editors do today (web research, 2026-10-21, not tried here) | |
| 42 | + - Zed: since February 2026 "edit predictions" accept several | |
| 43 | + providers, Ollama among them; the official docs say it is set up | |
| 44 | + separately from the agent — so mm in the agent panel and a local | |
| 45 | + completion model coexist. | |
| 46 | + https://zed.dev/blog/edit-prediction-providers | |
| 47 | + https://zed.dev/docs/ai/use-a-local-model | |
| 48 | + https://www.alternativeto.net/news/2026/2/zed-editor-adds-multiple-edit-prediction-providers-and-pluggable-architecture/ | |
| 49 | + - VS Code: Continue's tab autocomplete with Ollama and a small code | |
| 50 | + model (e.g. qwen2.5-coder:1.5b) is the best documented path. One | |
| 51 | + single source claims Continue was acquired and its repo frozen in | |
| 52 | + June 2026 — it contradicts the July 2026 docs: to check. Other | |
| 53 | + extensions exist (Ollama Copilot). | |
| 54 | + https://gitea.varghacsongor.hu/GithubMirror/continue/src/commit/4fd276fbb7c8baad3feb0351df1acccfd21fb8d4/docs/customize/deep-dives/autocomplete.mdx | |
| 55 | + https://tech-insider.org/fr/ollama-vscode-assistant-ia-local-2026/ | |
| 56 | + https://awesome.ecosyste.ms/projects/github.com%2Fgnana997%2Follama-copilot | |
| 57 | + | |
| 58 | + ## Measured on llmman (this sandbox, 2026-10-21, Qwen3.5 0.8B) | |
| 59 | + | endpoint | result | | |
| 60 | + | /v1/completions (raw) | works: 0.11–0.17 s for 12 tokens, warm | | |
| 61 | + | /infill (llama.cpp FIM, used by llama.vim / llama.vscode) | 404, not exposed | | |
| 62 | + | /api/generate + "suffix" (Ollama FIM) | 400: llmman does not support the "suffix" field of /api/generate | | |
| 63 | + So today an editor's completion needs a real Ollama or llama-server | |
| 64 | + beside llmman; llmman is fine for the agent. Possible request to | |
| 65 | + llmman: accept "suffix" or expose /infill. | |
| 66 | + | |
| 67 | + ## If anything is done later | |
| 68 | + - Docs only (EN+FR): an explanation (why not mm; how mm and a | |
| 69 | + completion model share one editor) and a how-to (Zed edit | |
| 70 | + predictions / VS Code Continue on Ollama or llama-server with a | |
| 71 | + small FIM-trained model, mm in the agent panel). It needs a real | |
| 72 | + try in an editor — not possible in this sandbox. | |
| 73 | + - A separate idea, not this ticket: mm's possible contribution would | |
| 74 | + be project context (neighbouring files, LSP symbols, search_docs) | |
| 75 | + injected into a FIM prompt — what makes Copilot more than a bare | |
| 76 | + model. A project of its own. | |
| new file mode 100644 | |||
| @@ -0,0 +1,76 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 116 | ||
| 3 | +title: How to use mm for code completion? | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-21T11:38:42.420Z | ||
| 9 | +updatedAt: 2026-10-21T23:59:59.000Z | ||
| 10 | +labels: | ||
| 11 | + - wontfix | ||
| 12 | +body: | | ||
| 13 | + Inline code completion in the IDEs (VS Code, Zed, …): ghost text while | ||
| 14 | + typing, Copilot-style. Can mm provide it? | ||
| 15 | +tasks: [] | ||
| 16 | +comments: | ||
| 17 | + - id: 1 | ||
| 18 | + author: | ||
| 19 | + name: k33g | ||
| 20 | + email: ph.charriere@gmail.com | ||
| 21 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 22 | + body: | | ||
| 23 | + Answer (2026-10-21): wontfix — inline completion is not mm's job, and | ||
| 24 | + ACP could not carry it anyway. The IDE's completion feature should | ||
| 25 | + talk to a model server directly; mm stays in the agent panel. Taken | ||
| 26 | + out of v0.9.0, labelled wontfix (user's decision). | ||
| 27 | + | ||
| 28 | + ## Why not mm | ||
| 29 | + - The protocol. ACP (how mm talks to editors) is sessions and | ||
| 30 | + prompts: no method or capability for inline completion was found; | ||
| 31 | + the only "autocomplete" in ACP clients is slash-command completion | ||
| 32 | + in the chat panel. Inline completion lives in the editor's own | ||
| 33 | + completion feature (or LSP). | ||
| 34 | + https://codecompanion.olimorris.dev/usage/acp-protocol | ||
| 35 | + https://mintlify.com/agentclientprotocol/typescript-sdk/concepts/protocol-overview | ||
| 36 | + - Latency and shape. A completion fires on every typing pause and | ||
| 37 | + must answer in a few hundred ms, with a "prefix + suffix" | ||
| 38 | + (fill-in-the-middle, FIM) prompt. An mm turn is a system prompt, | ||
| 39 | + tool schemas, a history, sometimes several round trips: seconds. | ||
| 40 | + | ||
| 41 | + ## What the editors do today (web research, 2026-10-21, not tried here) | ||
| 42 | + - Zed: since February 2026 "edit predictions" accept several | ||
| 43 | + providers, Ollama among them; the official docs say it is set up | ||
| 44 | + separately from the agent — so mm in the agent panel and a local | ||
| 45 | + completion model coexist. | ||
| 46 | + https://zed.dev/blog/edit-prediction-providers | ||
| 47 | + https://zed.dev/docs/ai/use-a-local-model | ||
| 48 | + https://www.alternativeto.net/news/2026/2/zed-editor-adds-multiple-edit-prediction-providers-and-pluggable-architecture/ | ||
| 49 | + - VS Code: Continue's tab autocomplete with Ollama and a small code | ||
| 50 | + model (e.g. qwen2.5-coder:1.5b) is the best documented path. One | ||
| 51 | + single source claims Continue was acquired and its repo frozen in | ||
| 52 | + June 2026 — it contradicts the July 2026 docs: to check. Other | ||
| 53 | + extensions exist (Ollama Copilot). | ||
| 54 | + https://gitea.varghacsongor.hu/GithubMirror/continue/src/commit/4fd276fbb7c8baad3feb0351df1acccfd21fb8d4/docs/customize/deep-dives/autocomplete.mdx | ||
| 55 | + https://tech-insider.org/fr/ollama-vscode-assistant-ia-local-2026/ | ||
| 56 | + https://awesome.ecosyste.ms/projects/github.com%2Fgnana997%2Follama-copilot | ||
| 57 | + | ||
| 58 | + ## Measured on llmman (this sandbox, 2026-10-21, Qwen3.5 0.8B) | ||
| 59 | + | endpoint | result | | ||
| 60 | + | /v1/completions (raw) | works: 0.11–0.17 s for 12 tokens, warm | | ||
| 61 | + | /infill (llama.cpp FIM, used by llama.vim / llama.vscode) | 404, not exposed | | ||
| 62 | + | /api/generate + "suffix" (Ollama FIM) | 400: llmman does not support the "suffix" field of /api/generate | | ||
| 63 | + So today an editor's completion needs a real Ollama or llama-server | ||
| 64 | + beside llmman; llmman is fine for the agent. Possible request to | ||
| 65 | + llmman: accept "suffix" or expose /infill. | ||
| 66 | + | ||
| 67 | + ## If anything is done later | ||
| 68 | + - Docs only (EN+FR): an explanation (why not mm; how mm and a | ||
| 69 | + completion model share one editor) and a how-to (Zed edit | ||
| 70 | + predictions / VS Code Continue on Ollama or llama-server with a | ||
| 71 | + small FIM-trained model, mm in the agent panel). It needs a real | ||
| 72 | + try in an editor — not possible in this sandbox. | ||
| 73 | + - A separate idea, not this ticket: mm's possible contribution would | ||
| 74 | + be project context (neighbouring files, LSP symbols, search_docs) | ||
| 75 | + injected into a FIM prompt — what makes Copilot more than a bare | ||
| 76 | + model. A project of its own. | ||
deleted
.tickets/issues/0116-how-to-use-mm-for-completion.yaml +0 -14 | deleted file mode 100644 | ||
| @@ -1,14 +0,0 @@ | ||
| 1 | -# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | -id: 116 | |
| 3 | -title: How to use mm for completion? | |
| 4 | -state: open | |
| 5 | -author: | |
| 6 | - name: k33g | |
| 7 | - email: ph.charriere@gmail.com | |
| 8 | -createdAt: 2026-10-21T11:38:42.420Z | |
| 9 | -updatedAt: 2026-10-21T11:44:57.748Z | |
| 10 | -labels: [] | |
| 11 | -milestone: v0.9.0 | |
| 12 | -body: "" | |
| 13 | -tasks: [] | |
| 14 | -comments: [] | |
| deleted file mode 100644 | |||
| @@ -1,14 +0,0 @@ | |||
| 1 | -# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | -id: 116 | ||
| 3 | -title: How to use mm for completion? | ||
| 4 | -state: open | ||
| 5 | -author: | ||
| 6 | - name: k33g | ||
| 7 | - email: ph.charriere@gmail.com | ||
| 8 | -createdAt: 2026-10-21T11:38:42.420Z | ||
| 9 | -updatedAt: 2026-10-21T11:44:57.748Z | ||
| 10 | -labels: [] | ||
| 11 | -milestone: v0.9.0 | ||
| 12 | -body: "" | ||
| 13 | -tasks: [] | ||
| 14 | -comments: [] | ||
added
.tickets/issues/0119-custom-tools-as-wasm-modules.yaml +123 -0 | new file mode 100644 | ||
| @@ -0,0 +1,123 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 119 | |
| 3 | +title: Custom tools as WASM modules (wazero) | |
| 4 | +state: closed | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-21T23:59:59.000Z | |
| 9 | +updatedAt: 2026-10-21T23:59:59.000Z | |
| 10 | +labels: [] | |
| 11 | +milestone: v0.9.0 | |
| 12 | +epic: Builtin tools | |
| 13 | +related: | |
| 14 | + - 46 | |
| 15 | + - 120 | |
| 16 | +body: | | |
| 17 | + A declared tool (ticket 88, docs/en/how-to/declare-a-custom-tool.md) runs | |
| 18 | + a program with ALL the user's rights. This ticket adds a second kind of | |
| 19 | + declared tool: a WebAssembly module run inside mm by wazero (pure Go, so | |
| 20 | + mm stays a static, CGO-free binary) — the first measured step of ticket | |
| 21 | + 46's "sandbox by capability". | |
| 22 | + | |
| 23 | + User's decisions (2026-10-21): | |
| 24 | + - The module is an ordinary WASI command (TinyGo `-target=wasip1`, a | |
| 25 | + `main`). The call's arguments — checked, typed, defaults applied, as | |
| 26 | + for a `command` tool — arrive as ONE JSON object on stdin; stdout and | |
| 27 | + stderr are the result; a non-zero exit is a failure. Same shape as the | |
| 28 | + hooks. No argv template. | |
| 29 | + - Capabilities: nothing by default — no file, no environment variable, | |
| 30 | + no network. An entry may open project folders: `read: [notes]` | |
| 31 | + (read-only) and `write: [out]`. | |
| 32 | + - A demo in demos/, modules built with TinyGo. | |
| 33 | + | |
| 34 | + ```yaml | |
| 35 | + tools: | |
| 36 | + - name: word_stats | |
| 37 | + description: Count the words, lines and characters of a text. | |
| 38 | + parameters: | |
| 39 | + text: { type: string } | |
| 40 | + wasm: ./tools/word_stats.wasm | |
| 41 | + ``` | |
| 42 | +tasks: | |
| 43 | + - id: 1 | |
| 44 | + title: "config: `wasm`, `read`, `write` on a tools entry, validated (wasm XOR command)" | |
| 45 | + state: closed | |
| 46 | + priority: none | |
| 47 | + author: | |
| 48 | + name: k33g | |
| 49 | + email: ph.charriere@gmail.com | |
| 50 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 51 | + updatedAt: 2026-10-21T23:59:59.000Z | |
| 52 | + - id: 2 | |
| 53 | + title: "runtime: wazero runner (compile once, JSON on stdin, timeout, mounted folders), wired into the declared tool in every front end" | |
| 54 | + state: closed | |
| 55 | + priority: none | |
| 56 | + author: | |
| 57 | + name: k33g | |
| 58 | + email: ph.charriere@gmail.com | |
| 59 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 60 | + updatedAt: 2026-10-21T23:59:59.000Z | |
| 61 | + - id: 3 | |
| 62 | + title: "demos/wasm-tools: TinyGo modules, build script, agent.yaml, live run" | |
| 63 | + state: closed | |
| 64 | + priority: none | |
| 65 | + author: | |
| 66 | + name: k33g | |
| 67 | + email: ph.charriere@gmail.com | |
| 68 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 69 | + updatedAt: 2026-10-21T23:59:59.000Z | |
| 70 | + - id: 4 | |
| 71 | + title: "docs EN+FR (how-to, reference, explanation), diagram, ticket 46" | |
| 72 | + state: closed | |
| 73 | + priority: none | |
| 74 | + author: | |
| 75 | + name: k33g | |
| 76 | + email: ph.charriere@gmail.com | |
| 77 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 78 | + updatedAt: 2026-10-21T23:59:59.000Z | |
| 79 | +comments: | |
| 80 | + - id: 1 | |
| 81 | + author: | |
| 82 | + name: k33g | |
| 83 | + email: ph.charriere@gmail.com | |
| 84 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 85 | + body: | | |
| 86 | + Implemented on feature/wasm-custom-tools (uncommitted). | |
| 87 | + | |
| 88 | + - config (tools.go): `wasm` (relative to the agent file, resolved at | |
| 89 | + load, must exist), `read` / `write` (relative to the working | |
| 90 | + directory; refused: absolute, `.`, above it, twice; only with | |
| 91 | + wasm); command XOR wasm. | |
| 92 | + - internal/wasmtool (new): one wazero runtime per process, each module | |
| 93 | + compiled once on its first call, a fresh instance per call (several | |
| 94 | + may overlap). Sandbox: stdin = the JSON, args = the module name, no | |
| 95 | + env, real clock + crypto rand, folders mounted at /<name> (read-only | |
| 96 | + or not), 256 MiB memory cap, output kept up to 1 MiB, timeout and | |
| 97 | + cancellation stop the module. Errors as text, in runArgv's shape. | |
| 98 | + - tools/declared.go: a `job` (shown text + run) built by prepare(); | |
| 99 | + the line and evented paths are unchanged otherwise (consent, loop | |
| 100 | + detector, display). callValues now returns typed values: argv gets | |
| 101 | + them as text, wasm as JSON. | |
| 102 | + - Spike measurements (linux/arm64 sandbox): compile of a ~550 KB | |
| 103 | + TinyGo module 176–183 ms once; a call 0.2–0.5 ms. mm binary 38.97 | |
| 104 | + → 42.87 MB. CGO_ENABLED=0 cross-builds of the 5 release targets ok. | |
| 105 | + - Trap: TinyGo's WASI layer refuses "./notes/a.md" and ".." — modules | |
| 106 | + must clean paths (path.Join); documented. | |
| 107 | + - Tests: wasmtool (10, on testdata/probe.wasm, 145 KB, built from | |
| 108 | + testdata/probe/main.go without encoding/json), config (resolution, | |
| 109 | + refusals), tools (typed JSON, folders in the session directory, | |
| 110 | + consent and invalid calls). | |
| 111 | + - Demo demos/wasm-tools: Qwen3.5 4B on llmman, word_stats / | |
| 112 | + search_notes / read_note (read notes/) / save_note (write out/); | |
| 113 | + one live `mm -p` run each, all right tool first call; read_note | |
| 114 | + with ../agent.yaml refused by the sandbox. Prebuilt modules | |
| 115 | + committed (550–593 KB each), ./build.sh rebuilds them. | |
| 116 | + - Docs EN+FR: new how-to write-a-wasm-tool, reference configuration | |
| 117 | + (keys, errors, example) and tools (Wasm tools table), explanation | |
| 118 | + declared-tools (Wasm tools section; ticket 46 alternative | |
| 119 | + rewritten), declare-a-custom-tool variant, indexes, README demo | |
| 120 | + row, packages.drawio (wasmtool, wazero). | |
| 121 | + - Not done: the window and ACP paths seen live (unit-tested through | |
| 122 | + the evented path only); quality gate (qlty absent); modules from | |
| 123 | + other languages. | |
| new file mode 100644 | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 119 | ||
| 3 | +title: Custom tools as WASM modules (wazero) | ||
| 4 | +state: closed | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-21T23:59:59.000Z | ||
| 9 | +updatedAt: 2026-10-21T23:59:59.000Z | ||
| 10 | +labels: [] | ||
| 11 | +milestone: v0.9.0 | ||
| 12 | +epic: Builtin tools | ||
| 13 | +related: | ||
| 14 | + - 46 | ||
| 15 | + - 120 | ||
| 16 | +body: | | ||
| 17 | + A declared tool (ticket 88, docs/en/how-to/declare-a-custom-tool.md) runs | ||
| 18 | + a program with ALL the user's rights. This ticket adds a second kind of | ||
| 19 | + declared tool: a WebAssembly module run inside mm by wazero (pure Go, so | ||
| 20 | + mm stays a static, CGO-free binary) — the first measured step of ticket | ||
| 21 | + 46's "sandbox by capability". | ||
| 22 | + | ||
| 23 | + User's decisions (2026-10-21): | ||
| 24 | + - The module is an ordinary WASI command (TinyGo `-target=wasip1`, a | ||
| 25 | + `main`). The call's arguments — checked, typed, defaults applied, as | ||
| 26 | + for a `command` tool — arrive as ONE JSON object on stdin; stdout and | ||
| 27 | + stderr are the result; a non-zero exit is a failure. Same shape as the | ||
| 28 | + hooks. No argv template. | ||
| 29 | + - Capabilities: nothing by default — no file, no environment variable, | ||
| 30 | + no network. An entry may open project folders: `read: [notes]` | ||
| 31 | + (read-only) and `write: [out]`. | ||
| 32 | + - A demo in demos/, modules built with TinyGo. | ||
| 33 | + | ||
| 34 | + ```yaml | ||
| 35 | + tools: | ||
| 36 | + - name: word_stats | ||
| 37 | + description: Count the words, lines and characters of a text. | ||
| 38 | + parameters: | ||
| 39 | + text: { type: string } | ||
| 40 | + wasm: ./tools/word_stats.wasm | ||
| 41 | + ``` | ||
| 42 | +tasks: | ||
| 43 | + - id: 1 | ||
| 44 | + title: "config: `wasm`, `read`, `write` on a tools entry, validated (wasm XOR command)" | ||
| 45 | + state: closed | ||
| 46 | + priority: none | ||
| 47 | + author: | ||
| 48 | + name: k33g | ||
| 49 | + email: ph.charriere@gmail.com | ||
| 50 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 51 | + updatedAt: 2026-10-21T23:59:59.000Z | ||
| 52 | + - id: 2 | ||
| 53 | + title: "runtime: wazero runner (compile once, JSON on stdin, timeout, mounted folders), wired into the declared tool in every front end" | ||
| 54 | + state: closed | ||
| 55 | + priority: none | ||
| 56 | + author: | ||
| 57 | + name: k33g | ||
| 58 | + email: ph.charriere@gmail.com | ||
| 59 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 60 | + updatedAt: 2026-10-21T23:59:59.000Z | ||
| 61 | + - id: 3 | ||
| 62 | + title: "demos/wasm-tools: TinyGo modules, build script, agent.yaml, live run" | ||
| 63 | + state: closed | ||
| 64 | + priority: none | ||
| 65 | + author: | ||
| 66 | + name: k33g | ||
| 67 | + email: ph.charriere@gmail.com | ||
| 68 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 69 | + updatedAt: 2026-10-21T23:59:59.000Z | ||
| 70 | + - id: 4 | ||
| 71 | + title: "docs EN+FR (how-to, reference, explanation), diagram, ticket 46" | ||
| 72 | + state: closed | ||
| 73 | + priority: none | ||
| 74 | + author: | ||
| 75 | + name: k33g | ||
| 76 | + email: ph.charriere@gmail.com | ||
| 77 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 78 | + updatedAt: 2026-10-21T23:59:59.000Z | ||
| 79 | +comments: | ||
| 80 | + - id: 1 | ||
| 81 | + author: | ||
| 82 | + name: k33g | ||
| 83 | + email: ph.charriere@gmail.com | ||
| 84 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 85 | + body: | | ||
| 86 | + Implemented on feature/wasm-custom-tools (uncommitted). | ||
| 87 | + | ||
| 88 | + - config (tools.go): `wasm` (relative to the agent file, resolved at | ||
| 89 | + load, must exist), `read` / `write` (relative to the working | ||
| 90 | + directory; refused: absolute, `.`, above it, twice; only with | ||
| 91 | + wasm); command XOR wasm. | ||
| 92 | + - internal/wasmtool (new): one wazero runtime per process, each module | ||
| 93 | + compiled once on its first call, a fresh instance per call (several | ||
| 94 | + may overlap). Sandbox: stdin = the JSON, args = the module name, no | ||
| 95 | + env, real clock + crypto rand, folders mounted at /<name> (read-only | ||
| 96 | + or not), 256 MiB memory cap, output kept up to 1 MiB, timeout and | ||
| 97 | + cancellation stop the module. Errors as text, in runArgv's shape. | ||
| 98 | + - tools/declared.go: a `job` (shown text + run) built by prepare(); | ||
| 99 | + the line and evented paths are unchanged otherwise (consent, loop | ||
| 100 | + detector, display). callValues now returns typed values: argv gets | ||
| 101 | + them as text, wasm as JSON. | ||
| 102 | + - Spike measurements (linux/arm64 sandbox): compile of a ~550 KB | ||
| 103 | + TinyGo module 176–183 ms once; a call 0.2–0.5 ms. mm binary 38.97 | ||
| 104 | + → 42.87 MB. CGO_ENABLED=0 cross-builds of the 5 release targets ok. | ||
| 105 | + - Trap: TinyGo's WASI layer refuses "./notes/a.md" and ".." — modules | ||
| 106 | + must clean paths (path.Join); documented. | ||
| 107 | + - Tests: wasmtool (10, on testdata/probe.wasm, 145 KB, built from | ||
| 108 | + testdata/probe/main.go without encoding/json), config (resolution, | ||
| 109 | + refusals), tools (typed JSON, folders in the session directory, | ||
| 110 | + consent and invalid calls). | ||
| 111 | + - Demo demos/wasm-tools: Qwen3.5 4B on llmman, word_stats / | ||
| 112 | + search_notes / read_note (read notes/) / save_note (write out/); | ||
| 113 | + one live `mm -p` run each, all right tool first call; read_note | ||
| 114 | + with ../agent.yaml refused by the sandbox. Prebuilt modules | ||
| 115 | + committed (550–593 KB each), ./build.sh rebuilds them. | ||
| 116 | + - Docs EN+FR: new how-to write-a-wasm-tool, reference configuration | ||
| 117 | + (keys, errors, example) and tools (Wasm tools table), explanation | ||
| 118 | + declared-tools (Wasm tools section; ticket 46 alternative | ||
| 119 | + rewritten), declare-a-custom-tool variant, indexes, README demo | ||
| 120 | + row, packages.drawio (wasmtool, wazero). | ||
| 121 | + - Not done: the window and ACP paths seen live (unit-tested through | ||
| 122 | + the evented path only); quality gate (qlty absent); modules from | ||
| 123 | + other languages. | ||
added
.tickets/issues/0120-hooks-as-wasm-modules.yaml +123 -0 | new file mode 100644 | ||
| @@ -0,0 +1,123 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 120 | |
| 3 | +title: Hooks as WASM modules (wazero) | |
| 4 | +state: closed | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-21T23:59:59.000Z | |
| 9 | +updatedAt: 2026-10-21T23:59:59.000Z | |
| 10 | +labels: [] | |
| 11 | +milestone: v0.9.0 | |
| 12 | +epic: Builtin tools | |
| 13 | +related: | |
| 14 | + - 46 | |
| 15 | + - 119 | |
| 16 | +body: | | |
| 17 | + The same thing as ticket 119, for hooks: a hook entry may name a | |
| 18 | + WebAssembly module (`wasm:`) instead of a shell command (`command:`). | |
| 19 | + mm runs it itself with wazero, in the sandbox of ticket 119. | |
| 20 | + | |
| 21 | + A hook already has the right contract: one JSON object on stdin, an | |
| 22 | + optional JSON reply on stdout ({"action": "deny", "reason": …}). So the | |
| 23 | + module is an ordinary WASI command (TinyGo `-target=wasip1`), reading the | |
| 24 | + same input and printing the same reply as a script hook. | |
| 25 | + | |
| 26 | + - Nothing visible by default; `read:` / `write:` folders of the working | |
| 27 | + directory, same rules as tools. | |
| 28 | + - Environment: MM_HOOK_EVENT only (one module can serve several events, | |
| 29 | + as with scripts). Not MM_CONFIG_DIR: a host path means nothing inside | |
| 30 | + the sandbox. | |
| 31 | + - timeout, onError, matcher: unchanged. | |
| 32 | + - This is ticket 46's task 2: bash-guard as a WASM hook vs the Node one — | |
| 33 | + time per call, size, capabilities, author experience. | |
| 34 | + | |
| 35 | + ```yaml | |
| 36 | + hooks: | |
| 37 | + preToolCall: | |
| 38 | + - matcher: ^bash$ | |
| 39 | + wasm: ./hooks/bash_guard.wasm | |
| 40 | + onError: fail | |
| 41 | + ``` | |
| 42 | +tasks: | |
| 43 | + - id: 1 | |
| 44 | + title: "config: `wasm`, `read`, `write` on a hook entry (wasm XOR command), validation shared with tools" | |
| 45 | + state: closed | |
| 46 | + priority: none | |
| 47 | + author: | |
| 48 | + name: k33g | |
| 49 | + email: ph.charriere@gmail.com | |
| 50 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 51 | + updatedAt: 2026-10-21T23:59:59.000Z | |
| 52 | + - id: 2 | |
| 53 | + title: "runtime: wasmtool Exec (stdout and stderr apart, env), the hooks runner dispatches command / wasm" | |
| 54 | + state: closed | |
| 55 | + priority: none | |
| 56 | + author: | |
| 57 | + name: k33g | |
| 58 | + email: ph.charriere@gmail.com | |
| 59 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 60 | + updatedAt: 2026-10-21T23:59:59.000Z | |
| 61 | + - id: 3 | |
| 62 | + title: "demos/wasm-hooks: no_secrets, bash_guard, audit in TinyGo; measured against the Node hooks of demos/hooks" | |
| 63 | + state: closed | |
| 64 | + priority: none | |
| 65 | + author: | |
| 66 | + name: k33g | |
| 67 | + email: ph.charriere@gmail.com | |
| 68 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 69 | + updatedAt: 2026-10-21T23:59:59.000Z | |
| 70 | + - id: 4 | |
| 71 | + title: "docs EN+FR, diagram, tickets 46 and 119" | |
| 72 | + state: closed | |
| 73 | + priority: none | |
| 74 | + author: | |
| 75 | + name: k33g | |
| 76 | + email: ph.charriere@gmail.com | |
| 77 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 78 | + updatedAt: 2026-10-21T23:59:59.000Z | |
| 79 | +comments: | |
| 80 | + - id: 1 | |
| 81 | + author: | |
| 82 | + name: k33g | |
| 83 | + email: ph.charriere@gmail.com | |
| 84 | + createdAt: 2026-10-21T23:59:59.000Z | |
| 85 | + body: | | |
| 86 | + Implemented on feature/wasm-custom-tools (same branch as 119, | |
| 87 | + uncommitted). | |
| 88 | + | |
| 89 | + - config: Hook gets `wasm` (resolved against the agent file's | |
| 90 | + directory in resolveHooks), `read`, `write`; command XOR wasm; | |
| 91 | + IsWasm, Folders, Label (command or module file name — used by the | |
| 92 | + failure line and displayHooks). Folder and module checks are now | |
| 93 | + ONE function shared with tools (validateSandbox); its messages lost | |
| 94 | + their prefix, so the tool one reads `tools[N] (name): wasm: no | |
| 95 | + module at …` (docs updated). | |
| 96 | + - wasmtool: Exec(ctx, call, stdout, stderr) with typed ends | |
| 97 | + (TimeoutError, ErrCancelled, ExitError); Run (tools) is built on | |
| 98 | + it; Call.Env; `Default` runner shared by tools and hooks (a module | |
| 99 | + is compiled once per process). | |
| 100 | + - hooks: runHook dispatches to runCommand or runWasm; runWasm keeps | |
| 101 | + runCommand's caps (reply 1 MiB, stderr 300 bytes) and error texts | |
| 102 | + (`timed out after`, `cancelled`), a non-zero exit reads `exit code | |
| 103 | + <n>: <stderr>`. Env given to the module: MM_HOOK_EVENT only. | |
| 104 | + - Tests: hooks/wasm_test.go (reply honoured, env = the event variable | |
| 105 | + alone, folders, onError warn/fail + timeout), wasmtool Exec test, | |
| 106 | + config tests; the probe module got three hook replies (deny, | |
| 107 | + deny-env, deny-read). | |
| 108 | + - Demo demos/wasm-hooks: no_secrets, bash_guard, audit (write: [logs]) | |
| 109 | + in TinyGo, Gemma 4 E4B on llmman; live `mm -p`: password stopped, | |
| 110 | + rm -rf denied (old-drafts/ intact), audit lines written; | |
| 111 | + displayHooks lists the module names. add-context not ported: it | |
| 112 | + lists the working directory, which a module cannot open. | |
| 113 | + - Measured (ticket 46 task 2), bash guard through hooks.Runner, 20 | |
| 114 | + runs after a first, twice, linux/arm64: Node v22 first 68–102 ms, | |
| 115 | + median 65–67 ms; WASM first 234–248 ms (compile), median 0.56–0.58 | |
| 116 | + ms. Size: 954-byte .js vs 770 KB .wasm. | |
| 117 | + - Docs EN+FR: new how-to write-a-wasm-hook; reference hooks (entry | |
| 118 | + keys, Wasm hooks table, failures, report and display, validation | |
| 119 | + errors, example); explanation hooks (new section); guard how-to | |
| 120 | + variant; configuration (hooks row, tool error text); indexes; | |
| 121 | + README demo row; packages.drawio (hooks → wasmtool). | |
| 122 | + - Not done: hooks over ACP / in the window seen live (same runner, | |
| 123 | + unit-tested); quality gate (qlty absent). | |
| new file mode 100644 | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 120 | ||
| 3 | +title: Hooks as WASM modules (wazero) | ||
| 4 | +state: closed | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-21T23:59:59.000Z | ||
| 9 | +updatedAt: 2026-10-21T23:59:59.000Z | ||
| 10 | +labels: [] | ||
| 11 | +milestone: v0.9.0 | ||
| 12 | +epic: Builtin tools | ||
| 13 | +related: | ||
| 14 | + - 46 | ||
| 15 | + - 119 | ||
| 16 | +body: | | ||
| 17 | + The same thing as ticket 119, for hooks: a hook entry may name a | ||
| 18 | + WebAssembly module (`wasm:`) instead of a shell command (`command:`). | ||
| 19 | + mm runs it itself with wazero, in the sandbox of ticket 119. | ||
| 20 | + | ||
| 21 | + A hook already has the right contract: one JSON object on stdin, an | ||
| 22 | + optional JSON reply on stdout ({"action": "deny", "reason": …}). So the | ||
| 23 | + module is an ordinary WASI command (TinyGo `-target=wasip1`), reading the | ||
| 24 | + same input and printing the same reply as a script hook. | ||
| 25 | + | ||
| 26 | + - Nothing visible by default; `read:` / `write:` folders of the working | ||
| 27 | + directory, same rules as tools. | ||
| 28 | + - Environment: MM_HOOK_EVENT only (one module can serve several events, | ||
| 29 | + as with scripts). Not MM_CONFIG_DIR: a host path means nothing inside | ||
| 30 | + the sandbox. | ||
| 31 | + - timeout, onError, matcher: unchanged. | ||
| 32 | + - This is ticket 46's task 2: bash-guard as a WASM hook vs the Node one — | ||
| 33 | + time per call, size, capabilities, author experience. | ||
| 34 | + | ||
| 35 | + ```yaml | ||
| 36 | + hooks: | ||
| 37 | + preToolCall: | ||
| 38 | + - matcher: ^bash$ | ||
| 39 | + wasm: ./hooks/bash_guard.wasm | ||
| 40 | + onError: fail | ||
| 41 | + ``` | ||
| 42 | +tasks: | ||
| 43 | + - id: 1 | ||
| 44 | + title: "config: `wasm`, `read`, `write` on a hook entry (wasm XOR command), validation shared with tools" | ||
| 45 | + state: closed | ||
| 46 | + priority: none | ||
| 47 | + author: | ||
| 48 | + name: k33g | ||
| 49 | + email: ph.charriere@gmail.com | ||
| 50 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 51 | + updatedAt: 2026-10-21T23:59:59.000Z | ||
| 52 | + - id: 2 | ||
| 53 | + title: "runtime: wasmtool Exec (stdout and stderr apart, env), the hooks runner dispatches command / wasm" | ||
| 54 | + state: closed | ||
| 55 | + priority: none | ||
| 56 | + author: | ||
| 57 | + name: k33g | ||
| 58 | + email: ph.charriere@gmail.com | ||
| 59 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 60 | + updatedAt: 2026-10-21T23:59:59.000Z | ||
| 61 | + - id: 3 | ||
| 62 | + title: "demos/wasm-hooks: no_secrets, bash_guard, audit in TinyGo; measured against the Node hooks of demos/hooks" | ||
| 63 | + state: closed | ||
| 64 | + priority: none | ||
| 65 | + author: | ||
| 66 | + name: k33g | ||
| 67 | + email: ph.charriere@gmail.com | ||
| 68 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 69 | + updatedAt: 2026-10-21T23:59:59.000Z | ||
| 70 | + - id: 4 | ||
| 71 | + title: "docs EN+FR, diagram, tickets 46 and 119" | ||
| 72 | + state: closed | ||
| 73 | + priority: none | ||
| 74 | + author: | ||
| 75 | + name: k33g | ||
| 76 | + email: ph.charriere@gmail.com | ||
| 77 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 78 | + updatedAt: 2026-10-21T23:59:59.000Z | ||
| 79 | +comments: | ||
| 80 | + - id: 1 | ||
| 81 | + author: | ||
| 82 | + name: k33g | ||
| 83 | + email: ph.charriere@gmail.com | ||
| 84 | + createdAt: 2026-10-21T23:59:59.000Z | ||
| 85 | + body: | | ||
| 86 | + Implemented on feature/wasm-custom-tools (same branch as 119, | ||
| 87 | + uncommitted). | ||
| 88 | + | ||
| 89 | + - config: Hook gets `wasm` (resolved against the agent file's | ||
| 90 | + directory in resolveHooks), `read`, `write`; command XOR wasm; | ||
| 91 | + IsWasm, Folders, Label (command or module file name — used by the | ||
| 92 | + failure line and displayHooks). Folder and module checks are now | ||
| 93 | + ONE function shared with tools (validateSandbox); its messages lost | ||
| 94 | + their prefix, so the tool one reads `tools[N] (name): wasm: no | ||
| 95 | + module at …` (docs updated). | ||
| 96 | + - wasmtool: Exec(ctx, call, stdout, stderr) with typed ends | ||
| 97 | + (TimeoutError, ErrCancelled, ExitError); Run (tools) is built on | ||
| 98 | + it; Call.Env; `Default` runner shared by tools and hooks (a module | ||
| 99 | + is compiled once per process). | ||
| 100 | + - hooks: runHook dispatches to runCommand or runWasm; runWasm keeps | ||
| 101 | + runCommand's caps (reply 1 MiB, stderr 300 bytes) and error texts | ||
| 102 | + (`timed out after`, `cancelled`), a non-zero exit reads `exit code | ||
| 103 | + <n>: <stderr>`. Env given to the module: MM_HOOK_EVENT only. | ||
| 104 | + - Tests: hooks/wasm_test.go (reply honoured, env = the event variable | ||
| 105 | + alone, folders, onError warn/fail + timeout), wasmtool Exec test, | ||
| 106 | + config tests; the probe module got three hook replies (deny, | ||
| 107 | + deny-env, deny-read). | ||
| 108 | + - Demo demos/wasm-hooks: no_secrets, bash_guard, audit (write: [logs]) | ||
| 109 | + in TinyGo, Gemma 4 E4B on llmman; live `mm -p`: password stopped, | ||
| 110 | + rm -rf denied (old-drafts/ intact), audit lines written; | ||
| 111 | + displayHooks lists the module names. add-context not ported: it | ||
| 112 | + lists the working directory, which a module cannot open. | ||
| 113 | + - Measured (ticket 46 task 2), bash guard through hooks.Runner, 20 | ||
| 114 | + runs after a first, twice, linux/arm64: Node v22 first 68–102 ms, | ||
| 115 | + median 65–67 ms; WASM first 234–248 ms (compile), median 0.56–0.58 | ||
| 116 | + ms. Size: 954-byte .js vs 770 KB .wasm. | ||
| 117 | + - Docs EN+FR: new how-to write-a-wasm-hook; reference hooks (entry | ||
| 118 | + keys, Wasm hooks table, failures, report and display, validation | ||
| 119 | + errors, example); explanation hooks (new section); guard how-to | ||
| 120 | + variant; configuration (hooks row, tool error text); indexes; | ||
| 121 | + README demo row; packages.drawio (hooks → wasmtool). | ||
| 122 | + - Not done: hooks over ACP / in the window seen live (same runner, | ||
| 123 | + unit-tested); quality gate (qlty absent). | ||
modified
README.md +2 -0 | @@ -161,6 +161,8 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | ||
| 161 | 161 | | `demos/docs-search/` | an assistant answering from a documentation folder: nine Ratatui tutorials (Rust terminal UIs, each program checked against ratatui 0.30 by `check-tutorials.sh`), `/docs <words>` for you and the `search_docs` tool for the model; Qwen3.5 4B on llmman, measured results | |
| 162 | 162 | | `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 | |
| 163 | 163 | | `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 | |
| 164 | +| `demos/wasm-tools/` | a notes desk acting only through four WebAssembly tools built with TinyGo and run by mm in a sandbox: one pure computation, two that may only read `notes/`, one that may only write `out/`; Qwen3.5 4B on llmman | | |
| 165 | +| `demos/wasm-hooks/` | the hooks of `demos/hooks` (secret filter, `bash` guard, audit log) rewritten as WebAssembly modules built with TinyGo and run by mm in a sandbox — nothing to install; Gemma 4 E4B on llmman, measured against the Node versions | | |
| 164 | 166 | |
| 165 | 167 | ## Learn more |
| 166 | 168 | |
| @@ -161,6 +161,8 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | |||
| 161 | | `demos/docs-search/` | an assistant answering from a documentation folder: nine Ratatui tutorials (Rust terminal UIs, each program checked against ratatui 0.30 by `check-tutorials.sh`), `/docs <words>` for you and the `search_docs` tool for the model; Qwen3.5 4B on llmman, measured results | | 161 | | `demos/docs-search/` | an assistant answering from a documentation folder: nine Ratatui tutorials (Rust terminal UIs, each program checked against ratatui 0.30 by `check-tutorials.sh`), `/docs <words>` for you and the `search_docs` tool for the model; Qwen3.5 4B on llmman, measured results | |
| 162 | | `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 | | 162 | | `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 | |
| 163 | | `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 | | 163 | | `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 | |
| 164 | +| `demos/wasm-tools/` | a notes desk acting only through four WebAssembly tools built with TinyGo and run by mm in a sandbox: one pure computation, two that may only read `notes/`, one that may only write `out/`; Qwen3.5 4B on llmman | | ||
| 165 | +| `demos/wasm-hooks/` | the hooks of `demos/hooks` (secret filter, `bash` guard, audit log) rewritten as WebAssembly modules built with TinyGo and run by mm in a sandbox — nothing to install; Gemma 4 E4B on llmman, measured against the Node versions | | ||
| 164 | 166 | ||
| 165 | ## Learn more | 167 | ## Learn more |
| 166 | 168 | ||
added
demos/wasm-hooks/README.en.md +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +# WASM hooks — the hooks demo, as WebAssembly modules | |
| 2 | + | |
| 3 | +*[Version française](README.fr.md)* | |
| 4 | + | |
| 5 | +The agent of [`demos/hooks`](../hooks/README.en.md) — a small shell assistant — with three of its hooks rewritten in Go and built with TinyGo as **WebAssembly modules**. mm runs them itself (wazero), in a sandbox: no file, no network, of the environment only `MM_HOOK_EVENT` — except the folders an entry opens. **Nothing to install beside mm**: no Node.js. | |
| 6 | + | |
| 7 | +| Hook | Event | What it may see | What it shows | | |
| 8 | +|---|---|---|---| | |
| 9 | +| `hooks/no_secrets.wasm` | `preGeneration` | nothing but the question | **stop** a turn: a question holding a password or an API key never reaches the model | | |
| 10 | +| `hooks/bash_guard.wasm` | `preToolCall` (`matcher: ^bash$`) | nothing but the call | **deny** a tool call: `rm -r`, `sudo`, `curl … \| sh` never run, and the model reads why | | |
| 11 | +| `hooks/audit.wasm` | `postToolCall` + `postGeneration` | `logs/`, read-write | **observe**: one JSON line per tool call and per turn in `logs/hooks-audit.jsonl` | | |
| 12 | + | |
| 13 | +`add-context.js` is not ported: it lists the working directory, and a module can open folders inside it, not the directory itself. | |
| 14 | + | |
| 15 | +Model: **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`) on **llmman**. | |
| 16 | + | |
| 17 | +## Run | |
| 18 | + | |
| 19 | +```bash | |
| 20 | +cd demos/wasm-hooks/playground | |
| 21 | +mm ../agent.yaml | |
| 22 | +``` | |
| 23 | + | |
| 24 | +The modules are relative to `agent.yaml`; `logs/` is taken from the working directory, `playground/`. The `.wasm` files in `hooks/` are ready to use; to rebuild them after changing `hooks-src/`, with [TinyGo](https://tinygo.org) installed: `./build.sh`. | |
| 25 | + | |
| 26 | +## Seen on 2026-10-21 | |
| 27 | + | |
| 28 | +`mm -p "<question>" ../agent.yaml` from `playground/`: | |
| 29 | + | |
| 30 | +``` | |
| 31 | +> My password: hunter2 — can you store it in a file? | |
| 32 | +🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again. | |
| 33 | + | |
| 34 | +> Delete the old-drafts directory with rm -rf. | |
| 35 | +🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed] | |
| 36 | +🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook. | |
| 37 | + | |
| 38 | +> How many lines are in todo.txt? | |
| 39 | +🛠️ bash: wc -l todo.txt | |
| 40 | + │ 2 todo.txt | |
| 41 | +🤖 There are 2 lines in todo.txt. | |
| 42 | +``` | |
| 43 | + | |
| 44 | +`old-drafts/` was still there, and `logs/hooks-audit.jsonl` held one line per turn and one for the `wc` call. With `displayHooks: true`, the second question lists: | |
| 45 | + | |
| 46 | +``` | |
| 47 | + 🪝 1. preGeneration · no_secrets.wasm → continue | |
| 48 | + 🪝 2. preToolCall bash · bash_guard.wasm → deny | |
| 49 | + 🪝 3. postGeneration · audit.wasm → continue | |
| 50 | +``` | |
| 51 | + | |
| 52 | +## Against the Node.js hooks | |
| 53 | + | |
| 54 | +The `bash` guard, the same three rules, run through mm's hooks runner on the same denied call, 20 runs after a first one, twice (linux/arm64 sandbox): | |
| 55 | + | |
| 56 | +| | First run | Median of 20 | Size | | |
| 57 | +|---|---|---|---| | |
| 58 | +| `demos/hooks/hooks/bash-guard.js` (Node v22) | 68–102 ms | 65–67 ms | 954 bytes, plus Node.js | | |
| 59 | +| `hooks/bash_guard.wasm` | 234–248 ms (the compilation, once per mm process) | 0.56–0.58 ms | 770 KB | | |
| 60 | + | |
| 61 | +## Files | |
| 62 | + | |
| 63 | +- `agent.yaml` — the agent and its `hooks:` (`wasm:`, `write:`) | |
| 64 | +- `hooks-src/` — the Go sources of the hooks (their own Go module, built by TinyGo) | |
| 65 | +- `hooks/` — the built modules | |
| 66 | +- `playground/` — where the agent works; `logs/` is written by the audit hook (git-ignored) | |
| 67 | +- `build.sh` — rebuilds `hooks/` from `hooks-src/` | |
| 68 | + | |
| 69 | +See [How to write a hook as a WebAssembly module](../../docs/en/how-to/write-a-wasm-hook.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +# WASM hooks — the hooks demo, as WebAssembly modules | ||
| 2 | + | ||
| 3 | +*[Version française](README.fr.md)* | ||
| 4 | + | ||
| 5 | +The agent of [`demos/hooks`](../hooks/README.en.md) — a small shell assistant — with three of its hooks rewritten in Go and built with TinyGo as **WebAssembly modules**. mm runs them itself (wazero), in a sandbox: no file, no network, of the environment only `MM_HOOK_EVENT` — except the folders an entry opens. **Nothing to install beside mm**: no Node.js. | ||
| 6 | + | ||
| 7 | +| Hook | Event | What it may see | What it shows | | ||
| 8 | +|---|---|---|---| | ||
| 9 | +| `hooks/no_secrets.wasm` | `preGeneration` | nothing but the question | **stop** a turn: a question holding a password or an API key never reaches the model | | ||
| 10 | +| `hooks/bash_guard.wasm` | `preToolCall` (`matcher: ^bash$`) | nothing but the call | **deny** a tool call: `rm -r`, `sudo`, `curl … \| sh` never run, and the model reads why | | ||
| 11 | +| `hooks/audit.wasm` | `postToolCall` + `postGeneration` | `logs/`, read-write | **observe**: one JSON line per tool call and per turn in `logs/hooks-audit.jsonl` | | ||
| 12 | + | ||
| 13 | +`add-context.js` is not ported: it lists the working directory, and a module can open folders inside it, not the directory itself. | ||
| 14 | + | ||
| 15 | +Model: **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`) on **llmman**. | ||
| 16 | + | ||
| 17 | +## Run | ||
| 18 | + | ||
| 19 | +```bash | ||
| 20 | +cd demos/wasm-hooks/playground | ||
| 21 | +mm ../agent.yaml | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +The modules are relative to `agent.yaml`; `logs/` is taken from the working directory, `playground/`. The `.wasm` files in `hooks/` are ready to use; to rebuild them after changing `hooks-src/`, with [TinyGo](https://tinygo.org) installed: `./build.sh`. | ||
| 25 | + | ||
| 26 | +## Seen on 2026-10-21 | ||
| 27 | + | ||
| 28 | +`mm -p "<question>" ../agent.yaml` from `playground/`: | ||
| 29 | + | ||
| 30 | +``` | ||
| 31 | +> My password: hunter2 — can you store it in a file? | ||
| 32 | +🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again. | ||
| 33 | + | ||
| 34 | +> Delete the old-drafts directory with rm -rf. | ||
| 35 | +🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed] | ||
| 36 | +🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook. | ||
| 37 | + | ||
| 38 | +> How many lines are in todo.txt? | ||
| 39 | +🛠️ bash: wc -l todo.txt | ||
| 40 | + │ 2 todo.txt | ||
| 41 | +🤖 There are 2 lines in todo.txt. | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +`old-drafts/` was still there, and `logs/hooks-audit.jsonl` held one line per turn and one for the `wc` call. With `displayHooks: true`, the second question lists: | ||
| 45 | + | ||
| 46 | +``` | ||
| 47 | + 🪝 1. preGeneration · no_secrets.wasm → continue | ||
| 48 | + 🪝 2. preToolCall bash · bash_guard.wasm → deny | ||
| 49 | + 🪝 3. postGeneration · audit.wasm → continue | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +## Against the Node.js hooks | ||
| 53 | + | ||
| 54 | +The `bash` guard, the same three rules, run through mm's hooks runner on the same denied call, 20 runs after a first one, twice (linux/arm64 sandbox): | ||
| 55 | + | ||
| 56 | +| | First run | Median of 20 | Size | | ||
| 57 | +|---|---|---|---| | ||
| 58 | +| `demos/hooks/hooks/bash-guard.js` (Node v22) | 68–102 ms | 65–67 ms | 954 bytes, plus Node.js | | ||
| 59 | +| `hooks/bash_guard.wasm` | 234–248 ms (the compilation, once per mm process) | 0.56–0.58 ms | 770 KB | | ||
| 60 | + | ||
| 61 | +## Files | ||
| 62 | + | ||
| 63 | +- `agent.yaml` — the agent and its `hooks:` (`wasm:`, `write:`) | ||
| 64 | +- `hooks-src/` — the Go sources of the hooks (their own Go module, built by TinyGo) | ||
| 65 | +- `hooks/` — the built modules | ||
| 66 | +- `playground/` — where the agent works; `logs/` is written by the audit hook (git-ignored) | ||
| 67 | +- `build.sh` — rebuilds `hooks/` from `hooks-src/` | ||
| 68 | + | ||
| 69 | +See [How to write a hook as a WebAssembly module](../../docs/en/how-to/write-a-wasm-hook.md). | ||
added
demos/wasm-hooks/README.fr.md +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +# Hooks WASM — la démo des hooks, en modules WebAssembly | |
| 2 | + | |
| 3 | +*[English version](README.en.md)* | |
| 4 | + | |
| 5 | +L'agent de [`demos/hooks`](../hooks/README.fr.md) — un petit assistant shell — avec trois de ses hooks réécrits en Go et construits avec TinyGo en **modules WebAssembly**. mm les exécute lui-même (wazero), dans un bac à sable : aucun fichier, pas de réseau, de l'environnement seulement `MM_HOOK_EVENT` — sauf les dossiers qu'une entrée ouvre. **Rien à installer à côté de mm** : pas de Node.js. | |
| 6 | + | |
| 7 | +| Hook | Événement | Ce qu'il peut voir | Ce qu'il montre | | |
| 8 | +|---|---|---|---| | |
| 9 | +| `hooks/no_secrets.wasm` | `preGeneration` | rien d'autre que la question | **arrêter** un tour : une question qui contient un mot de passe ou une clé d'API n'atteint jamais le modèle | | |
| 10 | +| `hooks/bash_guard.wasm` | `preToolCall` (`matcher: ^bash$`) | rien d'autre que l'appel | **refuser** un appel d'outil : `rm -r`, `sudo`, `curl … \| sh` ne s'exécutent jamais, et le modèle lit pourquoi | | |
| 11 | +| `hooks/audit.wasm` | `postToolCall` + `postGeneration` | `logs/`, en lecture-écriture | **observer** : une ligne JSON par appel d'outil et par tour dans `logs/hooks-audit.jsonl` | | |
| 12 | + | |
| 13 | +`add-context.js` n'est pas porté : il liste le répertoire de travail, et un module peut ouvrir des dossiers à l'intérieur, pas le répertoire lui-même. | |
| 14 | + | |
| 15 | +Modèle : **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`) sur **llmman**. | |
| 16 | + | |
| 17 | +## Lancer | |
| 18 | + | |
| 19 | +```bash | |
| 20 | +cd demos/wasm-hooks/playground | |
| 21 | +mm ../agent.yaml | |
| 22 | +``` | |
| 23 | + | |
| 24 | +Les modules sont relatifs à `agent.yaml` ; `logs/` est pris dans le répertoire de travail, `playground/`. Les fichiers `.wasm` de `hooks/` sont prêts à l'emploi ; pour les reconstruire après avoir modifié `hooks-src/`, avec [TinyGo](https://tinygo.org) installé : `./build.sh`. | |
| 25 | + | |
| 26 | +## Vu le 2026-10-21 | |
| 27 | + | |
| 28 | +`mm -p "<question>" ../agent.yaml` depuis `playground/` : | |
| 29 | + | |
| 30 | +``` | |
| 31 | +> My password: hunter2 — can you store it in a file? | |
| 32 | +🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again. | |
| 33 | + | |
| 34 | +> Delete the old-drafts directory with rm -rf. | |
| 35 | +🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed] | |
| 36 | +🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook. | |
| 37 | + | |
| 38 | +> How many lines are in todo.txt? | |
| 39 | +🛠️ bash: wc -l todo.txt | |
| 40 | + │ 2 todo.txt | |
| 41 | +🤖 There are 2 lines in todo.txt. | |
| 42 | +``` | |
| 43 | + | |
| 44 | +`old-drafts/` était toujours là, et `logs/hooks-audit.jsonl` contenait une ligne par tour et une pour l'appel `wc`. Avec `displayHooks: true`, la deuxième question liste : | |
| 45 | + | |
| 46 | +``` | |
| 47 | + 🪝 1. preGeneration · no_secrets.wasm → continue | |
| 48 | + 🪝 2. preToolCall bash · bash_guard.wasm → deny | |
| 49 | + 🪝 3. postGeneration · audit.wasm → continue | |
| 50 | +``` | |
| 51 | + | |
| 52 | +## Face aux hooks Node.js | |
| 53 | + | |
| 54 | +La garde `bash`, les trois mêmes règles, exécutée par le lanceur de hooks de mm sur le même appel refusé, 20 exécutions après une première, deux fois (sandbox linux/arm64) : | |
| 55 | + | |
| 56 | +| | Première exécution | Médiane de 20 | Taille | | |
| 57 | +|---|---|---|---| | |
| 58 | +| `demos/hooks/hooks/bash-guard.js` (Node v22) | 68 à 102 ms | 65 à 67 ms | 954 octets, plus Node.js | | |
| 59 | +| `hooks/bash_guard.wasm` | 234 à 248 ms (la compilation, une fois par processus mm) | 0,56 à 0,58 ms | 770 Ko | | |
| 60 | + | |
| 61 | +## Fichiers | |
| 62 | + | |
| 63 | +- `agent.yaml` — l'agent et ses `hooks:` (`wasm:`, `write:`) | |
| 64 | +- `hooks-src/` — les sources Go des hooks (leur propre module Go, construit par TinyGo) | |
| 65 | +- `hooks/` — les modules construits | |
| 66 | +- `playground/` — là où l'agent travaille ; `logs/` est écrit par le hook d'audit (ignoré par git) | |
| 67 | +- `build.sh` — reconstruit `hooks/` à partir de `hooks-src/` | |
| 68 | + | |
| 69 | +Voir [Écrire un hook sous forme de module WebAssembly](../../docs/fr/how-to/write-a-wasm-hook.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +# Hooks WASM — la démo des hooks, en modules WebAssembly | ||
| 2 | + | ||
| 3 | +*[English version](README.en.md)* | ||
| 4 | + | ||
| 5 | +L'agent de [`demos/hooks`](../hooks/README.fr.md) — un petit assistant shell — avec trois de ses hooks réécrits en Go et construits avec TinyGo en **modules WebAssembly**. mm les exécute lui-même (wazero), dans un bac à sable : aucun fichier, pas de réseau, de l'environnement seulement `MM_HOOK_EVENT` — sauf les dossiers qu'une entrée ouvre. **Rien à installer à côté de mm** : pas de Node.js. | ||
| 6 | + | ||
| 7 | +| Hook | Événement | Ce qu'il peut voir | Ce qu'il montre | | ||
| 8 | +|---|---|---|---| | ||
| 9 | +| `hooks/no_secrets.wasm` | `preGeneration` | rien d'autre que la question | **arrêter** un tour : une question qui contient un mot de passe ou une clé d'API n'atteint jamais le modèle | | ||
| 10 | +| `hooks/bash_guard.wasm` | `preToolCall` (`matcher: ^bash$`) | rien d'autre que l'appel | **refuser** un appel d'outil : `rm -r`, `sudo`, `curl … \| sh` ne s'exécutent jamais, et le modèle lit pourquoi | | ||
| 11 | +| `hooks/audit.wasm` | `postToolCall` + `postGeneration` | `logs/`, en lecture-écriture | **observer** : une ligne JSON par appel d'outil et par tour dans `logs/hooks-audit.jsonl` | | ||
| 12 | + | ||
| 13 | +`add-context.js` n'est pas porté : il liste le répertoire de travail, et un module peut ouvrir des dossiers à l'intérieur, pas le répertoire lui-même. | ||
| 14 | + | ||
| 15 | +Modèle : **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`) sur **llmman**. | ||
| 16 | + | ||
| 17 | +## Lancer | ||
| 18 | + | ||
| 19 | +```bash | ||
| 20 | +cd demos/wasm-hooks/playground | ||
| 21 | +mm ../agent.yaml | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +Les modules sont relatifs à `agent.yaml` ; `logs/` est pris dans le répertoire de travail, `playground/`. Les fichiers `.wasm` de `hooks/` sont prêts à l'emploi ; pour les reconstruire après avoir modifié `hooks-src/`, avec [TinyGo](https://tinygo.org) installé : `./build.sh`. | ||
| 25 | + | ||
| 26 | +## Vu le 2026-10-21 | ||
| 27 | + | ||
| 28 | +`mm -p "<question>" ../agent.yaml` depuis `playground/` : | ||
| 29 | + | ||
| 30 | +``` | ||
| 31 | +> My password: hunter2 — can you store it in a file? | ||
| 32 | +🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again. | ||
| 33 | + | ||
| 34 | +> Delete the old-drafts directory with rm -rf. | ||
| 35 | +🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed] | ||
| 36 | +🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook. | ||
| 37 | + | ||
| 38 | +> How many lines are in todo.txt? | ||
| 39 | +🛠️ bash: wc -l todo.txt | ||
| 40 | + │ 2 todo.txt | ||
| 41 | +🤖 There are 2 lines in todo.txt. | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +`old-drafts/` était toujours là, et `logs/hooks-audit.jsonl` contenait une ligne par tour et une pour l'appel `wc`. Avec `displayHooks: true`, la deuxième question liste : | ||
| 45 | + | ||
| 46 | +``` | ||
| 47 | + 🪝 1. preGeneration · no_secrets.wasm → continue | ||
| 48 | + 🪝 2. preToolCall bash · bash_guard.wasm → deny | ||
| 49 | + 🪝 3. postGeneration · audit.wasm → continue | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +## Face aux hooks Node.js | ||
| 53 | + | ||
| 54 | +La garde `bash`, les trois mêmes règles, exécutée par le lanceur de hooks de mm sur le même appel refusé, 20 exécutions après une première, deux fois (sandbox linux/arm64) : | ||
| 55 | + | ||
| 56 | +| | Première exécution | Médiane de 20 | Taille | | ||
| 57 | +|---|---|---|---| | ||
| 58 | +| `demos/hooks/hooks/bash-guard.js` (Node v22) | 68 à 102 ms | 65 à 67 ms | 954 octets, plus Node.js | | ||
| 59 | +| `hooks/bash_guard.wasm` | 234 à 248 ms (la compilation, une fois par processus mm) | 0,56 à 0,58 ms | 770 Ko | | ||
| 60 | + | ||
| 61 | +## Fichiers | ||
| 62 | + | ||
| 63 | +- `agent.yaml` — l'agent et ses `hooks:` (`wasm:`, `write:`) | ||
| 64 | +- `hooks-src/` — les sources Go des hooks (leur propre module Go, construit par TinyGo) | ||
| 65 | +- `hooks/` — les modules construits | ||
| 66 | +- `playground/` — là où l'agent travaille ; `logs/` est écrit par le hook d'audit (ignoré par git) | ||
| 67 | +- `build.sh` — reconstruit `hooks/` à partir de `hooks-src/` | ||
| 68 | + | ||
| 69 | +Voir [Écrire un hook sous forme de module WebAssembly](../../docs/fr/how-to/write-a-wasm-hook.md). | ||
added
demos/wasm-hooks/agent.yaml +64 -0 | new file mode 100644 | ||
| @@ -0,0 +1,64 @@ | ||
| 1 | +# Agent settings — the hooks demo again (demos/hooks), with its hooks written | |
| 2 | +# as WebAssembly modules instead of Node.js scripts (ticket 120). Each hook is | |
| 3 | +# built with TinyGo (hooks-src/ → hooks/, ./build.sh) and run by mm itself, in | |
| 4 | +# a sandbox: no file, no environment variable but MM_HOOK_EVENT, no network — | |
| 5 | +# except the folders its entry opens. Nothing to install beside mm. | |
| 6 | +# | |
| 7 | +# cd demos/wasm-hooks/playground | |
| 8 | +# mm ../agent.yaml | |
| 9 | +provider: llmman | |
| 10 | +model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M | |
| 11 | + | |
| 12 | +sampling: | |
| 13 | + temperature: 0.2 | |
| 14 | + parallel_tool_calls: false | |
| 15 | + | |
| 16 | +firstTokenTimeout: 3m | |
| 17 | +watchdogTimeout: 3m | |
| 18 | +maxTurns: 8 | |
| 19 | + | |
| 20 | +bashTool: true | |
| 21 | +editTools: false | |
| 22 | +planTool: false | |
| 23 | +fetchContentTool: false | |
| 24 | +askUserTool: false | |
| 25 | +skillsDir: "" | |
| 26 | + | |
| 27 | +yolo: true | |
| 28 | + | |
| 29 | +agentsMd: | |
| 30 | + enabled: false | |
| 31 | + | |
| 32 | +acp: | |
| 33 | + name: wasm-hooks-demo | |
| 34 | + title: 🪝 WASM hooks demo | |
| 35 | + | |
| 36 | +hooks: | |
| 37 | + preGeneration: | |
| 38 | + # onError: fail — a guard that cannot run must not let the question through. | |
| 39 | + # No folder: it sees the question, nothing else. | |
| 40 | + - wasm: ./hooks/no_secrets.wasm | |
| 41 | + timeout: 5s | |
| 42 | + onError: fail | |
| 43 | + preToolCall: | |
| 44 | + - matcher: ^bash$ | |
| 45 | + wasm: ./hooks/bash_guard.wasm | |
| 46 | + timeout: 5s | |
| 47 | + onError: fail | |
| 48 | + postToolCall: | |
| 49 | + # Writes into logs/ only (relative to the working directory: playground/). | |
| 50 | + # onError: ignore — losing an audit line must not disturb the session. | |
| 51 | + - wasm: ./hooks/audit.wasm | |
| 52 | + write: [logs] | |
| 53 | + onError: ignore | |
| 54 | + postGeneration: | |
| 55 | + - wasm: ./hooks/audit.wasm | |
| 56 | + write: [logs] | |
| 57 | + onError: ignore | |
| 58 | + | |
| 59 | +system: | | |
| 60 | + You are a shell assistant working in the current directory. | |
| 61 | + Use the bash tool to look at files and to run commands; never guess what a | |
| 62 | + file contains. Answer in one or two short sentences, in the user's language. | |
| 63 | + If a tool result says "[denied by a hook: …]", the command was refused: | |
| 64 | + tell the user why, and do not try to get around the refusal. | |
| new file mode 100644 | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | +# Agent settings — the hooks demo again (demos/hooks), with its hooks written | ||
| 2 | +# as WebAssembly modules instead of Node.js scripts (ticket 120). Each hook is | ||
| 3 | +# built with TinyGo (hooks-src/ → hooks/, ./build.sh) and run by mm itself, in | ||
| 4 | +# a sandbox: no file, no environment variable but MM_HOOK_EVENT, no network — | ||
| 5 | +# except the folders its entry opens. Nothing to install beside mm. | ||
| 6 | +# | ||
| 7 | +# cd demos/wasm-hooks/playground | ||
| 8 | +# mm ../agent.yaml | ||
| 9 | +provider: llmman | ||
| 10 | +model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M | ||
| 11 | + | ||
| 12 | +sampling: | ||
| 13 | + temperature: 0.2 | ||
| 14 | + parallel_tool_calls: false | ||
| 15 | + | ||
| 16 | +firstTokenTimeout: 3m | ||
| 17 | +watchdogTimeout: 3m | ||
| 18 | +maxTurns: 8 | ||
| 19 | + | ||
| 20 | +bashTool: true | ||
| 21 | +editTools: false | ||
| 22 | +planTool: false | ||
| 23 | +fetchContentTool: false | ||
| 24 | +askUserTool: false | ||
| 25 | +skillsDir: "" | ||
| 26 | + | ||
| 27 | +yolo: true | ||
| 28 | + | ||
| 29 | +agentsMd: | ||
| 30 | + enabled: false | ||
| 31 | + | ||
| 32 | +acp: | ||
| 33 | + name: wasm-hooks-demo | ||
| 34 | + title: 🪝 WASM hooks demo | ||
| 35 | + | ||
| 36 | +hooks: | ||
| 37 | + preGeneration: | ||
| 38 | + # onError: fail — a guard that cannot run must not let the question through. | ||
| 39 | + # No folder: it sees the question, nothing else. | ||
| 40 | + - wasm: ./hooks/no_secrets.wasm | ||
| 41 | + timeout: 5s | ||
| 42 | + onError: fail | ||
| 43 | + preToolCall: | ||
| 44 | + - matcher: ^bash$ | ||
| 45 | + wasm: ./hooks/bash_guard.wasm | ||
| 46 | + timeout: 5s | ||
| 47 | + onError: fail | ||
| 48 | + postToolCall: | ||
| 49 | + # Writes into logs/ only (relative to the working directory: playground/). | ||
| 50 | + # onError: ignore — losing an audit line must not disturb the session. | ||
| 51 | + - wasm: ./hooks/audit.wasm | ||
| 52 | + write: [logs] | ||
| 53 | + onError: ignore | ||
| 54 | + postGeneration: | ||
| 55 | + - wasm: ./hooks/audit.wasm | ||
| 56 | + write: [logs] | ||
| 57 | + onError: ignore | ||
| 58 | + | ||
| 59 | +system: | | ||
| 60 | + You are a shell assistant working in the current directory. | ||
| 61 | + Use the bash tool to look at files and to run commands; never guess what a | ||
| 62 | + file contains. Answer in one or two short sentences, in the user's language. | ||
| 63 | + If a tool result says "[denied by a hook: …]", the command was refused: | ||
| 64 | + tell the user why, and do not try to get around the refusal. | ||
added
demos/wasm-hooks/build.sh +10 -0 | new file mode 100755 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# Builds the demo's WASM hooks with TinyGo: hooks-src/<name>/ → hooks/<name>.wasm. | |
| 3 | +# cd demos/wasm-hooks && ./build.sh | |
| 4 | +set -eu | |
| 5 | +cd "$(dirname "$0")/hooks-src" | |
| 6 | +for dir in */; do | |
| 7 | + name=${dir%/} | |
| 8 | + tinygo build -o "../hooks/$name.wasm" -target=wasip1 -no-debug "./$name" | |
| 9 | + echo "built hooks/$name.wasm ($(wc -c < "../hooks/$name.wasm") bytes)" | |
| 10 | +done | |
| new file mode 100755 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +# Builds the demo's WASM hooks with TinyGo: hooks-src/<name>/ → hooks/<name>.wasm. | ||
| 3 | +# cd demos/wasm-hooks && ./build.sh | ||
| 4 | +set -eu | ||
| 5 | +cd "$(dirname "$0")/hooks-src" | ||
| 6 | +for dir in */; do | ||
| 7 | + name=${dir%/} | ||
| 8 | + tinygo build -o "../hooks/$name.wasm" -target=wasip1 -no-debug "./$name" | ||
| 9 | + echo "built hooks/$name.wasm ($(wc -c < "../hooks/$name.wasm") bytes)" | ||
| 10 | +done | ||
added
demos/wasm-hooks/hooks-src/audit/main.go +60 -0 | new file mode 100644 | ||
| @@ -0,0 +1,60 @@ | ||
| 1 | +// audit is a postToolCall and postGeneration hook: it appends one JSON line | |
| 2 | +// per event to logs/hooks-audit.jsonl. Its entry opens logs/ for writing — | |
| 3 | +// the only folder it can see, so it could not read the files it audits. | |
| 4 | +// | |
| 5 | +// It only observes, so it prints nothing on stdout. One module serves two | |
| 6 | +// events: the event's name is in the input (and in MM_HOOK_EVENT). | |
| 7 | +package main | |
| 8 | + | |
| 9 | +import ( | |
| 10 | + "encoding/json" | |
| 11 | + "fmt" | |
| 12 | + "os" | |
| 13 | + "time" | |
| 14 | +) | |
| 15 | + | |
| 16 | +type input struct { | |
| 17 | + Event string `json:"event"` | |
| 18 | + Prompt string `json:"prompt"` | |
| 19 | + Tool *struct { | |
| 20 | + Name string `json:"name"` | |
| 21 | + Input any `json:"input"` | |
| 22 | + } `json:"tool"` | |
| 23 | + Output string `json:"output"` | |
| 24 | + Answer string `json:"answer"` | |
| 25 | + Error string `json:"error"` | |
| 26 | +} | |
| 27 | + | |
| 28 | +func clip(s string, n int) string { | |
| 29 | + if r := []rune(s); len(r) > n { | |
| 30 | + return string(r[:n]) | |
| 31 | + } | |
| 32 | + return s | |
| 33 | +} | |
| 34 | + | |
| 35 | +func main() { | |
| 36 | + var in input | |
| 37 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 38 | + fmt.Fprintln(os.Stderr, "audit: bad input:", err) | |
| 39 | + os.Exit(2) | |
| 40 | + } | |
| 41 | + line := map[string]any{"at": time.Now().UTC().Format(time.RFC3339), "event": in.Event} | |
| 42 | + if in.Event == "postToolCall" && in.Tool != nil { | |
| 43 | + line["tool"], line["input"], line["output"] = in.Tool.Name, in.Tool.Input, clip(in.Output, 200) | |
| 44 | + } else { | |
| 45 | + line["prompt"], line["answer"] = in.Prompt, clip(in.Answer, 200) | |
| 46 | + if in.Error != "" { | |
| 47 | + line["error"] = in.Error | |
| 48 | + } | |
| 49 | + } | |
| 50 | + f, err := os.OpenFile("logs/hooks-audit.jsonl", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) | |
| 51 | + if err != nil { | |
| 52 | + fmt.Fprintln(os.Stderr, "audit: cannot open the log:", err) | |
| 53 | + os.Exit(1) | |
| 54 | + } | |
| 55 | + defer f.Close() | |
| 56 | + if err := json.NewEncoder(f).Encode(line); err != nil { | |
| 57 | + fmt.Fprintln(os.Stderr, "audit: cannot write the log:", err) | |
| 58 | + os.Exit(1) | |
| 59 | + } | |
| 60 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,60 @@ | |||
| 1 | +// audit is a postToolCall and postGeneration hook: it appends one JSON line | ||
| 2 | +// per event to logs/hooks-audit.jsonl. Its entry opens logs/ for writing — | ||
| 3 | +// the only folder it can see, so it could not read the files it audits. | ||
| 4 | +// | ||
| 5 | +// It only observes, so it prints nothing on stdout. One module serves two | ||
| 6 | +// events: the event's name is in the input (and in MM_HOOK_EVENT). | ||
| 7 | +package main | ||
| 8 | + | ||
| 9 | +import ( | ||
| 10 | + "encoding/json" | ||
| 11 | + "fmt" | ||
| 12 | + "os" | ||
| 13 | + "time" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +type input struct { | ||
| 17 | + Event string `json:"event"` | ||
| 18 | + Prompt string `json:"prompt"` | ||
| 19 | + Tool *struct { | ||
| 20 | + Name string `json:"name"` | ||
| 21 | + Input any `json:"input"` | ||
| 22 | + } `json:"tool"` | ||
| 23 | + Output string `json:"output"` | ||
| 24 | + Answer string `json:"answer"` | ||
| 25 | + Error string `json:"error"` | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func clip(s string, n int) string { | ||
| 29 | + if r := []rune(s); len(r) > n { | ||
| 30 | + return string(r[:n]) | ||
| 31 | + } | ||
| 32 | + return s | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +func main() { | ||
| 36 | + var in input | ||
| 37 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 38 | + fmt.Fprintln(os.Stderr, "audit: bad input:", err) | ||
| 39 | + os.Exit(2) | ||
| 40 | + } | ||
| 41 | + line := map[string]any{"at": time.Now().UTC().Format(time.RFC3339), "event": in.Event} | ||
| 42 | + if in.Event == "postToolCall" && in.Tool != nil { | ||
| 43 | + line["tool"], line["input"], line["output"] = in.Tool.Name, in.Tool.Input, clip(in.Output, 200) | ||
| 44 | + } else { | ||
| 45 | + line["prompt"], line["answer"] = in.Prompt, clip(in.Answer, 200) | ||
| 46 | + if in.Error != "" { | ||
| 47 | + line["error"] = in.Error | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + f, err := os.OpenFile("logs/hooks-audit.jsonl", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) | ||
| 51 | + if err != nil { | ||
| 52 | + fmt.Fprintln(os.Stderr, "audit: cannot open the log:", err) | ||
| 53 | + os.Exit(1) | ||
| 54 | + } | ||
| 55 | + defer f.Close() | ||
| 56 | + if err := json.NewEncoder(f).Encode(line); err != nil { | ||
| 57 | + fmt.Fprintln(os.Stderr, "audit: cannot write the log:", err) | ||
| 58 | + os.Exit(1) | ||
| 59 | + } | ||
| 60 | +} | ||
added
demos/wasm-hooks/hooks-src/bash_guard/main.go +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +// bash_guard is a preToolCall hook (matcher ^bash$): it refuses dangerous | |
| 2 | +// shell commands. The same rules as demos/hooks/hooks/bash-guard.js, as a | |
| 3 | +// WASM module that sees no file and no environment. | |
| 4 | +// | |
| 5 | +// mm writes {"event":"preToolCall","tool":{"name":"bash","input":{"command":"…"}},…}. | |
| 6 | +// Printing {"action":"deny","reason":"…"} means the command never runs; the | |
| 7 | +// model reads "[denied by a hook: <reason>]". Printing nothing lets it run. | |
| 8 | +package main | |
| 9 | + | |
| 10 | +import ( | |
| 11 | + "encoding/json" | |
| 12 | + "fmt" | |
| 13 | + "os" | |
| 14 | + "regexp" | |
| 15 | +) | |
| 16 | + | |
| 17 | +var rules = []struct { | |
| 18 | + reason string | |
| 19 | + pattern *regexp.Regexp | |
| 20 | +}{ | |
| 21 | + {"recursive deletion (rm -r) is not allowed", regexp.MustCompile(`\brm\s+(-\w*\s+)*-\w*[rR]`)}, | |
| 22 | + {"sudo is not allowed", regexp.MustCompile(`\bsudo\b`)}, | |
| 23 | + {"piping a download into a shell is not allowed", regexp.MustCompile(`\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh\b`)}, | |
| 24 | +} | |
| 25 | + | |
| 26 | +func main() { | |
| 27 | + var in struct { | |
| 28 | + Tool struct { | |
| 29 | + Input struct { | |
| 30 | + Command string `json:"command"` | |
| 31 | + } `json:"input"` | |
| 32 | + } `json:"tool"` | |
| 33 | + } | |
| 34 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 35 | + fmt.Fprintln(os.Stderr, "bash_guard: bad input:", err) | |
| 36 | + os.Exit(2) | |
| 37 | + } | |
| 38 | + for _, r := range rules { | |
| 39 | + if r.pattern.MatchString(in.Tool.Input.Command) { | |
| 40 | + json.NewEncoder(os.Stdout).Encode(map[string]string{"action": "deny", "reason": r.reason}) | |
| 41 | + return | |
| 42 | + } | |
| 43 | + } | |
| 44 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +// bash_guard is a preToolCall hook (matcher ^bash$): it refuses dangerous | ||
| 2 | +// shell commands. The same rules as demos/hooks/hooks/bash-guard.js, as a | ||
| 3 | +// WASM module that sees no file and no environment. | ||
| 4 | +// | ||
| 5 | +// mm writes {"event":"preToolCall","tool":{"name":"bash","input":{"command":"…"}},…}. | ||
| 6 | +// Printing {"action":"deny","reason":"…"} means the command never runs; the | ||
| 7 | +// model reads "[denied by a hook: <reason>]". Printing nothing lets it run. | ||
| 8 | +package main | ||
| 9 | + | ||
| 10 | +import ( | ||
| 11 | + "encoding/json" | ||
| 12 | + "fmt" | ||
| 13 | + "os" | ||
| 14 | + "regexp" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +var rules = []struct { | ||
| 18 | + reason string | ||
| 19 | + pattern *regexp.Regexp | ||
| 20 | +}{ | ||
| 21 | + {"recursive deletion (rm -r) is not allowed", regexp.MustCompile(`\brm\s+(-\w*\s+)*-\w*[rR]`)}, | ||
| 22 | + {"sudo is not allowed", regexp.MustCompile(`\bsudo\b`)}, | ||
| 23 | + {"piping a download into a shell is not allowed", regexp.MustCompile(`\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh\b`)}, | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +func main() { | ||
| 27 | + var in struct { | ||
| 28 | + Tool struct { | ||
| 29 | + Input struct { | ||
| 30 | + Command string `json:"command"` | ||
| 31 | + } `json:"input"` | ||
| 32 | + } `json:"tool"` | ||
| 33 | + } | ||
| 34 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 35 | + fmt.Fprintln(os.Stderr, "bash_guard: bad input:", err) | ||
| 36 | + os.Exit(2) | ||
| 37 | + } | ||
| 38 | + for _, r := range rules { | ||
| 39 | + if r.pattern.MatchString(in.Tool.Input.Command) { | ||
| 40 | + json.NewEncoder(os.Stdout).Encode(map[string]string{"action": "deny", "reason": r.reason}) | ||
| 41 | + return | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | +} | ||
added
demos/wasm-hooks/hooks-src/go.mod +6 -0 | new file mode 100644 | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +// The sources of the demo's WASM hooks. Their own module, so that `go build | |
| 2 | +// ./...` in mini-me does not build them: they are built with TinyGo | |
| 3 | +// (../build.sh). | |
| 4 | +module wasmhooks | |
| 5 | + | |
| 6 | +go 1.24 | |
| new file mode 100644 | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | +// The sources of the demo's WASM hooks. Their own module, so that `go build | ||
| 2 | +// ./...` in mini-me does not build them: they are built with TinyGo | ||
| 3 | +// (../build.sh). | ||
| 4 | +module wasmhooks | ||
| 5 | + | ||
| 6 | +go 1.24 | ||
added
demos/wasm-hooks/hooks-src/no_secrets/main.go +45 -0 | new file mode 100644 | ||
| @@ -0,0 +1,45 @@ | ||
| 1 | +// no_secrets is a preGeneration hook: it stops the turn when the question | |
| 2 | +// holds a secret. Its entry opens no folder: it sees the question, nothing | |
| 3 | +// else. | |
| 4 | +// | |
| 5 | +// mm writes {"event":"preGeneration","prompt":"…",…} on stdin. Printing | |
| 6 | +// {"action":"stop",…} means the model never sees the question; printing | |
| 7 | +// nothing means "continue". | |
| 8 | +package main | |
| 9 | + | |
| 10 | +import ( | |
| 11 | + "encoding/json" | |
| 12 | + "fmt" | |
| 13 | + "os" | |
| 14 | + "regexp" | |
| 15 | +) | |
| 16 | + | |
| 17 | +var secrets = []struct { | |
| 18 | + name string | |
| 19 | + pattern *regexp.Regexp | |
| 20 | +}{ | |
| 21 | + {"a password", regexp.MustCompile(`(?i)\b(password|passwd|pwd|mot de passe)\s*[:=]\s*\S+`)}, | |
| 22 | + {"an OpenAI-style API key", regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`)}, | |
| 23 | + {"a GitHub token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{30,}`)}, | |
| 24 | + {"an AWS access key", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)}, | |
| 25 | +} | |
| 26 | + | |
| 27 | +func main() { | |
| 28 | + var in struct { | |
| 29 | + Prompt string `json:"prompt"` | |
| 30 | + } | |
| 31 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 32 | + fmt.Fprintln(os.Stderr, "no_secrets: bad input:", err) | |
| 33 | + os.Exit(2) | |
| 34 | + } | |
| 35 | + for _, s := range secrets { | |
| 36 | + if s.pattern.MatchString(in.Prompt) { | |
| 37 | + json.NewEncoder(os.Stdout).Encode(map[string]string{ | |
| 38 | + "action": "stop", | |
| 39 | + "reason": "the prompt contains " + s.name, | |
| 40 | + "response": "🔐 Your message looks like it contains " + s.name + ". It was not sent to the model. Remove it and ask again.", | |
| 41 | + }) | |
| 42 | + return | |
| 43 | + } | |
| 44 | + } | |
| 45 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | +// no_secrets is a preGeneration hook: it stops the turn when the question | ||
| 2 | +// holds a secret. Its entry opens no folder: it sees the question, nothing | ||
| 3 | +// else. | ||
| 4 | +// | ||
| 5 | +// mm writes {"event":"preGeneration","prompt":"…",…} on stdin. Printing | ||
| 6 | +// {"action":"stop",…} means the model never sees the question; printing | ||
| 7 | +// nothing means "continue". | ||
| 8 | +package main | ||
| 9 | + | ||
| 10 | +import ( | ||
| 11 | + "encoding/json" | ||
| 12 | + "fmt" | ||
| 13 | + "os" | ||
| 14 | + "regexp" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +var secrets = []struct { | ||
| 18 | + name string | ||
| 19 | + pattern *regexp.Regexp | ||
| 20 | +}{ | ||
| 21 | + {"a password", regexp.MustCompile(`(?i)\b(password|passwd|pwd|mot de passe)\s*[:=]\s*\S+`)}, | ||
| 22 | + {"an OpenAI-style API key", regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}`)}, | ||
| 23 | + {"a GitHub token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{30,}`)}, | ||
| 24 | + {"an AWS access key", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)}, | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +func main() { | ||
| 28 | + var in struct { | ||
| 29 | + Prompt string `json:"prompt"` | ||
| 30 | + } | ||
| 31 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 32 | + fmt.Fprintln(os.Stderr, "no_secrets: bad input:", err) | ||
| 33 | + os.Exit(2) | ||
| 34 | + } | ||
| 35 | + for _, s := range secrets { | ||
| 36 | + if s.pattern.MatchString(in.Prompt) { | ||
| 37 | + json.NewEncoder(os.Stdout).Encode(map[string]string{ | ||
| 38 | + "action": "stop", | ||
| 39 | + "reason": "the prompt contains " + s.name, | ||
| 40 | + "response": "🔐 Your message looks like it contains " + s.name + ". It was not sent to the model. Remove it and ask again.", | ||
| 41 | + }) | ||
| 42 | + return | ||
| 43 | + } | ||
| 44 | + } | ||
| 45 | +} | ||
added
demos/wasm-hooks/hooks/audit.wasm +0 -0 | new file mode 100755 | ||
| Binary files /dev/null and b/demos/wasm-hooks/hooks/audit.wasm differ | ||
| new file mode 100755 | |||
| Binary files /dev/null and b/demos/wasm-hooks/hooks/audit.wasm differ | Binary files /dev/null and b/demos/wasm-hooks/hooks/audit.wasm differ | ||
added
demos/wasm-hooks/hooks/bash_guard.wasm +0 -0 | new file mode 100755 | ||
| Binary files /dev/null and b/demos/wasm-hooks/hooks/bash_guard.wasm differ | ||
| new file mode 100755 | |||
| Binary files /dev/null and b/demos/wasm-hooks/hooks/bash_guard.wasm differ | Binary files /dev/null and b/demos/wasm-hooks/hooks/bash_guard.wasm differ | ||
added
demos/wasm-hooks/hooks/no_secrets.wasm +0 -0 | new file mode 100755 | ||
| Binary files /dev/null and b/demos/wasm-hooks/hooks/no_secrets.wasm differ | ||
| new file mode 100755 | |||
| Binary files /dev/null and b/demos/wasm-hooks/hooks/no_secrets.wasm differ | Binary files /dev/null and b/demos/wasm-hooks/hooks/no_secrets.wasm differ | ||
added
demos/wasm-hooks/playground/logs/.gitignore +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +# Written by the audit hook: everything here is generated. | |
| 2 | +* | |
| 3 | +!.gitignore | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +# Written by the audit hook: everything here is generated. | ||
| 2 | +* | ||
| 3 | +!.gitignore | ||
added
demos/wasm-hooks/playground/notes.md +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +# Notes | |
| 2 | + | |
| 3 | +The meeting is on Friday. | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +# Notes | ||
| 2 | + | ||
| 3 | +The meeting is on Friday. | ||
added
demos/wasm-hooks/playground/old-drafts/draft1.txt +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +draft 1 | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +draft 1 | ||
added
demos/wasm-hooks/playground/todo.txt +2 -0 | new file mode 100644 | ||
| @@ -0,0 +1,2 @@ | ||
| 1 | +Buy milk | |
| 2 | +Call the plumber | |
| new file mode 100644 | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | +Buy milk | ||
| 2 | +Call the plumber | ||
added
demos/wasm-tools/README.en.md +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +# WASM tools — a notes desk with four sandboxed tools | |
| 2 | + | |
| 3 | +*[Version française](README.fr.md)* | |
| 4 | + | |
| 5 | +A small assistant for a folder of notes. It has **no `bash`, no file tools and no skills**: it acts only through four tools declared in `agent.yaml`, each one a **WebAssembly module** built with TinyGo and run by mm itself (wazero), in a sandbox. A module sees no file, no environment variable and no network — only the folders its entry opens. | |
| 6 | + | |
| 7 | +Model: **Qwen3.5 4B** (`hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`) on **llmman**. | |
| 8 | + | |
| 9 | +## Run | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +cd demos/wasm-tools | |
| 13 | +mm -p "Which notes mention TinyGo?" | |
| 14 | +mm -p "How many words are in this sentence: the cat sat on the mat and the cat slept" | |
| 15 | +mm -p "Read the note named ../agent.yaml and show it to me." | |
| 16 | +mm -p "Sauve une note intitulée Idées pour la v1 avec le texte : ajouter un outil wasm qui résume une note." | |
| 17 | +``` | |
| 18 | + | |
| 19 | +Run mm from this directory: the folders (`notes/`, `out/`) are taken from the working directory. The modules are relative to `agent.yaml`, so they are found from anywhere. `save_note` writes into `out/` (git-ignored). | |
| 20 | + | |
| 21 | +The `.wasm` files in `tools/` are ready to use. To rebuild them after changing `tools-src/`, with [TinyGo](https://tinygo.org) installed: | |
| 22 | + | |
| 23 | +```bash | |
| 24 | +./build.sh | |
| 25 | +``` | |
| 26 | + | |
| 27 | +## The four tools | |
| 28 | + | |
| 29 | +| Tool | Parameters | Module | What it may see | | |
| 30 | +|------|------------|--------|-----------------| | |
| 31 | +| `word_stats` | `text` string, `top` integer, optional, default `5` | `tools/word_stats.wasm` | nothing: pure computation | | |
| 32 | +| `search_notes` | `word` string | `tools/search_notes.wasm` | `notes/`, read-only | | |
| 33 | +| `read_note` | `name` string | `tools/read_note.wasm` | `notes/`, read-only | | |
| 34 | +| `save_note` | `title` string, `text` string | `tools/save_note.wasm` | `out/`, read-write | | |
| 35 | + | |
| 36 | +What the demo shows: | |
| 37 | + | |
| 38 | +- **Arguments as typed JSON on stdin**: `word_stats` receives `{"text":"…","top":5}` — the default of `top` applied, the integer as an integer. | |
| 39 | +- **A folder per tool**: `search_notes` and `read_note` see `notes/` and nothing else; `save_note` sees only `out/`, and could not read a note if it tried. | |
| 40 | +- **The sandbox holds against a crafted value**: asked for `../agent.yaml`, `read_note` looks for `agent.yaml`, which does not exist inside the module — the file next to it is out of reach. | |
| 41 | +- **The same consent as a command tool**: with `yolo: false` each call asks; in `-p` nobody can answer, so every call is then refused. | |
| 42 | + | |
| 43 | +## Seen on 2026-10-21 | |
| 44 | + | |
| 45 | +One run of each request above, `mm -p`, Qwen3.5 4B on llmman: | |
| 46 | + | |
| 47 | +``` | |
| 48 | +🧰 search_notes: search_notes.wasm {"word":"TinyGo"} | |
| 49 | + │ tinygo.md:1: # TinyGo | |
| 50 | + │ tinygo.md:3: TinyGo compiles Go for small places: microcontrollers, and WebAssembly. | |
| 51 | + │ tinygo.md:4: `tinygo build -target=wasip1` makes a WASI command: a main, stdin, stdout. | |
| 52 | + │ wazero.md:4: It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | |
| 53 | + | |
| 54 | +🧰 word_stats: word_stats.wasm {"text":"the cat sat on the mat and the cat slept","top":5} | |
| 55 | + │ words: 10 | |
| 56 | + … | |
| 57 | + | |
| 58 | +🧰 read_note: read_note.wasm {"name":"../agent.yaml"} | |
| 59 | + │ cannot read agent.yaml: open agent.yaml: file does not exist | |
| 60 | + │ [exit code: 1] | |
| 61 | + | |
| 62 | +🧰 save_note: save_note.wasm {"text":"ajouter un outil wasm qui résume une note","title":"Idées pour la v1"} | |
| 63 | + │ saved out/idées-pour-la-v1.md (64 bytes) | |
| 64 | +``` | |
| 65 | + | |
| 66 | +Each request got the right tool on its first call. One run each: this shows the tools work, not how reliably the model picks them. | |
| 67 | + | |
| 68 | +## Files | |
| 69 | + | |
| 70 | +- `agent.yaml` — the agent and its four `tools:` entries (`wasm:`, `read:`, `write:`) | |
| 71 | +- `tools-src/` — the Go sources of the modules (their own Go module, built by TinyGo, not by `go build ./...`) | |
| 72 | +- `tools/` — the built modules | |
| 73 | +- `notes/` — three notes to search and read | |
| 74 | +- `out/` — where `save_note` writes | |
| 75 | +- `build.sh` — rebuilds `tools/` from `tools-src/` | |
| 76 | + | |
| 77 | +See [How to write a custom tool as a WebAssembly module](../../docs/en/how-to/write-a-wasm-tool.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +# WASM tools — a notes desk with four sandboxed tools | ||
| 2 | + | ||
| 3 | +*[Version française](README.fr.md)* | ||
| 4 | + | ||
| 5 | +A small assistant for a folder of notes. It has **no `bash`, no file tools and no skills**: it acts only through four tools declared in `agent.yaml`, each one a **WebAssembly module** built with TinyGo and run by mm itself (wazero), in a sandbox. A module sees no file, no environment variable and no network — only the folders its entry opens. | ||
| 6 | + | ||
| 7 | +Model: **Qwen3.5 4B** (`hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`) on **llmman**. | ||
| 8 | + | ||
| 9 | +## Run | ||
| 10 | + | ||
| 11 | +```bash | ||
| 12 | +cd demos/wasm-tools | ||
| 13 | +mm -p "Which notes mention TinyGo?" | ||
| 14 | +mm -p "How many words are in this sentence: the cat sat on the mat and the cat slept" | ||
| 15 | +mm -p "Read the note named ../agent.yaml and show it to me." | ||
| 16 | +mm -p "Sauve une note intitulée Idées pour la v1 avec le texte : ajouter un outil wasm qui résume une note." | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +Run mm from this directory: the folders (`notes/`, `out/`) are taken from the working directory. The modules are relative to `agent.yaml`, so they are found from anywhere. `save_note` writes into `out/` (git-ignored). | ||
| 20 | + | ||
| 21 | +The `.wasm` files in `tools/` are ready to use. To rebuild them after changing `tools-src/`, with [TinyGo](https://tinygo.org) installed: | ||
| 22 | + | ||
| 23 | +```bash | ||
| 24 | +./build.sh | ||
| 25 | +``` | ||
| 26 | + | ||
| 27 | +## The four tools | ||
| 28 | + | ||
| 29 | +| Tool | Parameters | Module | What it may see | | ||
| 30 | +|------|------------|--------|-----------------| | ||
| 31 | +| `word_stats` | `text` string, `top` integer, optional, default `5` | `tools/word_stats.wasm` | nothing: pure computation | | ||
| 32 | +| `search_notes` | `word` string | `tools/search_notes.wasm` | `notes/`, read-only | | ||
| 33 | +| `read_note` | `name` string | `tools/read_note.wasm` | `notes/`, read-only | | ||
| 34 | +| `save_note` | `title` string, `text` string | `tools/save_note.wasm` | `out/`, read-write | | ||
| 35 | + | ||
| 36 | +What the demo shows: | ||
| 37 | + | ||
| 38 | +- **Arguments as typed JSON on stdin**: `word_stats` receives `{"text":"…","top":5}` — the default of `top` applied, the integer as an integer. | ||
| 39 | +- **A folder per tool**: `search_notes` and `read_note` see `notes/` and nothing else; `save_note` sees only `out/`, and could not read a note if it tried. | ||
| 40 | +- **The sandbox holds against a crafted value**: asked for `../agent.yaml`, `read_note` looks for `agent.yaml`, which does not exist inside the module — the file next to it is out of reach. | ||
| 41 | +- **The same consent as a command tool**: with `yolo: false` each call asks; in `-p` nobody can answer, so every call is then refused. | ||
| 42 | + | ||
| 43 | +## Seen on 2026-10-21 | ||
| 44 | + | ||
| 45 | +One run of each request above, `mm -p`, Qwen3.5 4B on llmman: | ||
| 46 | + | ||
| 47 | +``` | ||
| 48 | +🧰 search_notes: search_notes.wasm {"word":"TinyGo"} | ||
| 49 | + │ tinygo.md:1: # TinyGo | ||
| 50 | + │ tinygo.md:3: TinyGo compiles Go for small places: microcontrollers, and WebAssembly. | ||
| 51 | + │ tinygo.md:4: `tinygo build -target=wasip1` makes a WASI command: a main, stdin, stdout. | ||
| 52 | + │ wazero.md:4: It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | ||
| 53 | + | ||
| 54 | +🧰 word_stats: word_stats.wasm {"text":"the cat sat on the mat and the cat slept","top":5} | ||
| 55 | + │ words: 10 | ||
| 56 | + … | ||
| 57 | + | ||
| 58 | +🧰 read_note: read_note.wasm {"name":"../agent.yaml"} | ||
| 59 | + │ cannot read agent.yaml: open agent.yaml: file does not exist | ||
| 60 | + │ [exit code: 1] | ||
| 61 | + | ||
| 62 | +🧰 save_note: save_note.wasm {"text":"ajouter un outil wasm qui résume une note","title":"Idées pour la v1"} | ||
| 63 | + │ saved out/idées-pour-la-v1.md (64 bytes) | ||
| 64 | +``` | ||
| 65 | + | ||
| 66 | +Each request got the right tool on its first call. One run each: this shows the tools work, not how reliably the model picks them. | ||
| 67 | + | ||
| 68 | +## Files | ||
| 69 | + | ||
| 70 | +- `agent.yaml` — the agent and its four `tools:` entries (`wasm:`, `read:`, `write:`) | ||
| 71 | +- `tools-src/` — the Go sources of the modules (their own Go module, built by TinyGo, not by `go build ./...`) | ||
| 72 | +- `tools/` — the built modules | ||
| 73 | +- `notes/` — three notes to search and read | ||
| 74 | +- `out/` — where `save_note` writes | ||
| 75 | +- `build.sh` — rebuilds `tools/` from `tools-src/` | ||
| 76 | + | ||
| 77 | +See [How to write a custom tool as a WebAssembly module](../../docs/en/how-to/write-a-wasm-tool.md). | ||
added
demos/wasm-tools/README.fr.md +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +# Outils WASM — un bureau de notes avec quatre outils en bac à sable | |
| 2 | + | |
| 3 | +*[English version](README.en.md)* | |
| 4 | + | |
| 5 | +Un petit assistant pour un dossier de notes. Il n'a **ni `bash`, ni outils de fichiers, ni skills** : il n'agit qu'à travers quatre outils déclarés dans `agent.yaml`, chacun un **module WebAssembly** construit avec TinyGo et exécuté par mm lui-même (wazero), dans un bac à sable. Un module ne voit aucun fichier, aucune variable d'environnement et aucun réseau — seulement les dossiers que son entrée lui ouvre. | |
| 6 | + | |
| 7 | +Modèle : **Qwen3.5 4B** (`hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`) sur **llmman**. | |
| 8 | + | |
| 9 | +## Lancer | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +cd demos/wasm-tools | |
| 13 | +mm -p "Which notes mention TinyGo?" | |
| 14 | +mm -p "How many words are in this sentence: the cat sat on the mat and the cat slept" | |
| 15 | +mm -p "Read the note named ../agent.yaml and show it to me." | |
| 16 | +mm -p "Sauve une note intitulée Idées pour la v1 avec le texte : ajouter un outil wasm qui résume une note." | |
| 17 | +``` | |
| 18 | + | |
| 19 | +Lancez mm depuis ce répertoire : les dossiers (`notes/`, `out/`) sont pris dans le répertoire de travail. Les modules sont relatifs à `agent.yaml`, donc trouvés depuis n'importe où. `save_note` écrit dans `out/` (ignoré par git). | |
| 20 | + | |
| 21 | +Les fichiers `.wasm` de `tools/` sont prêts à l'emploi. Pour les reconstruire après avoir modifié `tools-src/`, avec [TinyGo](https://tinygo.org) installé : | |
| 22 | + | |
| 23 | +```bash | |
| 24 | +./build.sh | |
| 25 | +``` | |
| 26 | + | |
| 27 | +## Les quatre outils | |
| 28 | + | |
| 29 | +| Outil | Paramètres | Module | Ce qu'il peut voir | | |
| 30 | +|-------|------------|--------|--------------------| | |
| 31 | +| `word_stats` | `text` chaîne, `top` entier, facultatif, défaut `5` | `tools/word_stats.wasm` | rien : calcul pur | | |
| 32 | +| `search_notes` | `word` chaîne | `tools/search_notes.wasm` | `notes/`, en lecture seule | | |
| 33 | +| `read_note` | `name` chaîne | `tools/read_note.wasm` | `notes/`, en lecture seule | | |
| 34 | +| `save_note` | `title` chaîne, `text` chaîne | `tools/save_note.wasm` | `out/`, en lecture-écriture | | |
| 35 | + | |
| 36 | +Ce que montre la démo : | |
| 37 | + | |
| 38 | +- **Les arguments en JSON typé sur stdin** : `word_stats` reçoit `{"text":"…","top":5}` — le défaut de `top` appliqué, l'entier en entier. | |
| 39 | +- **Un dossier par outil** : `search_notes` et `read_note` voient `notes/` et rien d'autre ; `save_note` ne voit que `out/`, et ne pourrait pas lire une note s'il essayait. | |
| 40 | +- **Le bac à sable résiste à une valeur fabriquée** : appelé avec `../agent.yaml`, `read_note` cherche `agent.yaml`, qui n'existe pas dans le module — le fichier d'à côté est hors de portée. | |
| 41 | +- **Le même consentement qu'un outil commande** : avec `yolo: false` chaque appel demande ; en `-p` personne ne peut répondre, donc chaque appel est alors refusé. | |
| 42 | + | |
| 43 | +## Vu le 2026-10-21 | |
| 44 | + | |
| 45 | +Une exécution de chaque demande ci-dessus, `mm -p`, Qwen3.5 4B sur llmman : | |
| 46 | + | |
| 47 | +``` | |
| 48 | +🧰 search_notes: search_notes.wasm {"word":"TinyGo"} | |
| 49 | + │ tinygo.md:1: # TinyGo | |
| 50 | + │ tinygo.md:3: TinyGo compiles Go for small places: microcontrollers, and WebAssembly. | |
| 51 | + │ tinygo.md:4: `tinygo build -target=wasip1` makes a WASI command: a main, stdin, stdout. | |
| 52 | + │ wazero.md:4: It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | |
| 53 | + | |
| 54 | +🧰 word_stats: word_stats.wasm {"text":"the cat sat on the mat and the cat slept","top":5} | |
| 55 | + │ words: 10 | |
| 56 | + … | |
| 57 | + | |
| 58 | +🧰 read_note: read_note.wasm {"name":"../agent.yaml"} | |
| 59 | + │ cannot read agent.yaml: open agent.yaml: file does not exist | |
| 60 | + │ [exit code: 1] | |
| 61 | + | |
| 62 | +🧰 save_note: save_note.wasm {"text":"ajouter un outil wasm qui résume une note","title":"Idées pour la v1"} | |
| 63 | + │ saved out/idées-pour-la-v1.md (64 bytes) | |
| 64 | +``` | |
| 65 | + | |
| 66 | +Chaque demande a obtenu le bon outil dès son premier appel. Une seule exécution chacune : cela montre que les outils fonctionnent, pas avec quelle fiabilité le modèle les choisit. | |
| 67 | + | |
| 68 | +## Fichiers | |
| 69 | + | |
| 70 | +- `agent.yaml` — l'agent et ses quatre entrées `tools:` (`wasm:`, `read:`, `write:`) | |
| 71 | +- `tools-src/` — les sources Go des modules (leur propre module Go, construit par TinyGo, pas par `go build ./...`) | |
| 72 | +- `tools/` — les modules construits | |
| 73 | +- `notes/` — trois notes à chercher et à lire | |
| 74 | +- `out/` — là où `save_note` écrit | |
| 75 | +- `build.sh` — reconstruit `tools/` à partir de `tools-src/` | |
| 76 | + | |
| 77 | +Voir [Écrire un outil personnalisé sous forme de module WebAssembly](../../docs/fr/how-to/write-a-wasm-tool.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +# Outils WASM — un bureau de notes avec quatre outils en bac à sable | ||
| 2 | + | ||
| 3 | +*[English version](README.en.md)* | ||
| 4 | + | ||
| 5 | +Un petit assistant pour un dossier de notes. Il n'a **ni `bash`, ni outils de fichiers, ni skills** : il n'agit qu'à travers quatre outils déclarés dans `agent.yaml`, chacun un **module WebAssembly** construit avec TinyGo et exécuté par mm lui-même (wazero), dans un bac à sable. Un module ne voit aucun fichier, aucune variable d'environnement et aucun réseau — seulement les dossiers que son entrée lui ouvre. | ||
| 6 | + | ||
| 7 | +Modèle : **Qwen3.5 4B** (`hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`) sur **llmman**. | ||
| 8 | + | ||
| 9 | +## Lancer | ||
| 10 | + | ||
| 11 | +```bash | ||
| 12 | +cd demos/wasm-tools | ||
| 13 | +mm -p "Which notes mention TinyGo?" | ||
| 14 | +mm -p "How many words are in this sentence: the cat sat on the mat and the cat slept" | ||
| 15 | +mm -p "Read the note named ../agent.yaml and show it to me." | ||
| 16 | +mm -p "Sauve une note intitulée Idées pour la v1 avec le texte : ajouter un outil wasm qui résume une note." | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +Lancez mm depuis ce répertoire : les dossiers (`notes/`, `out/`) sont pris dans le répertoire de travail. Les modules sont relatifs à `agent.yaml`, donc trouvés depuis n'importe où. `save_note` écrit dans `out/` (ignoré par git). | ||
| 20 | + | ||
| 21 | +Les fichiers `.wasm` de `tools/` sont prêts à l'emploi. Pour les reconstruire après avoir modifié `tools-src/`, avec [TinyGo](https://tinygo.org) installé : | ||
| 22 | + | ||
| 23 | +```bash | ||
| 24 | +./build.sh | ||
| 25 | +``` | ||
| 26 | + | ||
| 27 | +## Les quatre outils | ||
| 28 | + | ||
| 29 | +| Outil | Paramètres | Module | Ce qu'il peut voir | | ||
| 30 | +|-------|------------|--------|--------------------| | ||
| 31 | +| `word_stats` | `text` chaîne, `top` entier, facultatif, défaut `5` | `tools/word_stats.wasm` | rien : calcul pur | | ||
| 32 | +| `search_notes` | `word` chaîne | `tools/search_notes.wasm` | `notes/`, en lecture seule | | ||
| 33 | +| `read_note` | `name` chaîne | `tools/read_note.wasm` | `notes/`, en lecture seule | | ||
| 34 | +| `save_note` | `title` chaîne, `text` chaîne | `tools/save_note.wasm` | `out/`, en lecture-écriture | | ||
| 35 | + | ||
| 36 | +Ce que montre la démo : | ||
| 37 | + | ||
| 38 | +- **Les arguments en JSON typé sur stdin** : `word_stats` reçoit `{"text":"…","top":5}` — le défaut de `top` appliqué, l'entier en entier. | ||
| 39 | +- **Un dossier par outil** : `search_notes` et `read_note` voient `notes/` et rien d'autre ; `save_note` ne voit que `out/`, et ne pourrait pas lire une note s'il essayait. | ||
| 40 | +- **Le bac à sable résiste à une valeur fabriquée** : appelé avec `../agent.yaml`, `read_note` cherche `agent.yaml`, qui n'existe pas dans le module — le fichier d'à côté est hors de portée. | ||
| 41 | +- **Le même consentement qu'un outil commande** : avec `yolo: false` chaque appel demande ; en `-p` personne ne peut répondre, donc chaque appel est alors refusé. | ||
| 42 | + | ||
| 43 | +## Vu le 2026-10-21 | ||
| 44 | + | ||
| 45 | +Une exécution de chaque demande ci-dessus, `mm -p`, Qwen3.5 4B sur llmman : | ||
| 46 | + | ||
| 47 | +``` | ||
| 48 | +🧰 search_notes: search_notes.wasm {"word":"TinyGo"} | ||
| 49 | + │ tinygo.md:1: # TinyGo | ||
| 50 | + │ tinygo.md:3: TinyGo compiles Go for small places: microcontrollers, and WebAssembly. | ||
| 51 | + │ tinygo.md:4: `tinygo build -target=wasip1` makes a WASI command: a main, stdin, stdout. | ||
| 52 | + │ wazero.md:4: It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | ||
| 53 | + | ||
| 54 | +🧰 word_stats: word_stats.wasm {"text":"the cat sat on the mat and the cat slept","top":5} | ||
| 55 | + │ words: 10 | ||
| 56 | + … | ||
| 57 | + | ||
| 58 | +🧰 read_note: read_note.wasm {"name":"../agent.yaml"} | ||
| 59 | + │ cannot read agent.yaml: open agent.yaml: file does not exist | ||
| 60 | + │ [exit code: 1] | ||
| 61 | + | ||
| 62 | +🧰 save_note: save_note.wasm {"text":"ajouter un outil wasm qui résume une note","title":"Idées pour la v1"} | ||
| 63 | + │ saved out/idées-pour-la-v1.md (64 bytes) | ||
| 64 | +``` | ||
| 65 | + | ||
| 66 | +Chaque demande a obtenu le bon outil dès son premier appel. Une seule exécution chacune : cela montre que les outils fonctionnent, pas avec quelle fiabilité le modèle les choisit. | ||
| 67 | + | ||
| 68 | +## Fichiers | ||
| 69 | + | ||
| 70 | +- `agent.yaml` — l'agent et ses quatre entrées `tools:` (`wasm:`, `read:`, `write:`) | ||
| 71 | +- `tools-src/` — les sources Go des modules (leur propre module Go, construit par TinyGo, pas par `go build ./...`) | ||
| 72 | +- `tools/` — les modules construits | ||
| 73 | +- `notes/` — trois notes à chercher et à lire | ||
| 74 | +- `out/` — là où `save_note` écrit | ||
| 75 | +- `build.sh` — reconstruit `tools/` à partir de `tools-src/` | ||
| 76 | + | ||
| 77 | +Voir [Écrire un outil personnalisé sous forme de module WebAssembly](../../docs/fr/how-to/write-a-wasm-tool.md). | ||
added
demos/wasm-tools/agent.yaml +63 -0 | new file mode 100644 | ||
| @@ -0,0 +1,63 @@ | ||
| 1 | +# Agent settings — "notes desk": an assistant for a folder of notes that acts | |
| 2 | +# ONLY through four WASM tools (ticket 119). Each tool is a WebAssembly module | |
| 3 | +# built with TinyGo (tools-src/ → tools/, ./build.sh) and run by mm itself, in | |
| 4 | +# a sandbox: no file, no environment variable, no network — except the | |
| 5 | +# folders its entry opens. mini-me on Qwen3.5 4B, served by llmman. | |
| 6 | +# | |
| 7 | +# cd demos/wasm-tools | |
| 8 | +# mm -p "Which notes mention TinyGo?" | |
| 9 | +provider: llmman | |
| 10 | +model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" | |
| 11 | + | |
| 12 | +# The four tools below are the agent's only way to act. | |
| 13 | +bashTool: false | |
| 14 | +editTools: false | |
| 15 | +planTool: false | |
| 16 | +fetchContentTool: false | |
| 17 | +skillsDir: no-skills | |
| 18 | +# true: the tools run without asking. Set it to false to confirm each call | |
| 19 | +# (in -p nobody can answer, so every call is then refused). | |
| 20 | +yolo: true | |
| 21 | +maxTurns: 6 | |
| 22 | + | |
| 23 | +system: | | |
| 24 | + You are the notes desk: you help with the notes kept in the notes/ folder. | |
| 25 | + You act only through your tools; never invent what a note says. | |
| 26 | + To find which notes talk about something, call search_notes; to read a | |
| 27 | + whole note, call read_note with its file name (e.g. wazero.md); to count | |
| 28 | + the words of a text, call word_stats; to keep a new note, call save_note. | |
| 29 | + Then answer briefly, in the user's language, from what the tools returned. | |
| 30 | + | |
| 31 | +tools: | |
| 32 | + # Pure computation: no folder at all. | |
| 33 | + - name: word_stats | |
| 34 | + description: Count the words, lines and characters of a text, and list its most frequent words. | |
| 35 | + parameters: | |
| 36 | + text: { type: string, description: "The text to measure" } | |
| 37 | + top: { type: integer, required: false, default: 5, description: "How many frequent words to list" } | |
| 38 | + wasm: ./tools/word_stats.wasm | |
| 39 | + | |
| 40 | + # Sees notes/, read-only — nothing else of the project. | |
| 41 | + - name: search_notes | |
| 42 | + description: Find the lines of the notes that mention a word; each line comes with its note's file name and line number. | |
| 43 | + parameters: | |
| 44 | + word: { type: string, description: "One word, e.g. wazero" } | |
| 45 | + wasm: ./tools/search_notes.wasm | |
| 46 | + read: [notes] | |
| 47 | + | |
| 48 | + - name: read_note | |
| 49 | + description: Show one whole note. | |
| 50 | + parameters: | |
| 51 | + name: { type: string, description: "The note's file name, e.g. wazero.md" } | |
| 52 | + wasm: ./tools/read_note.wasm | |
| 53 | + read: [notes] | |
| 54 | + | |
| 55 | + # Writes into out/ only: it can neither read nor change notes/. | |
| 56 | + - name: save_note | |
| 57 | + description: Save a new note in out/, under a file name made from its title. | |
| 58 | + parameters: | |
| 59 | + title: { type: string, description: "The note's title, e.g. Ideas for v1" } | |
| 60 | + text: { type: string, description: "The note's text, in Markdown" } | |
| 61 | + wasm: ./tools/save_note.wasm | |
| 62 | + write: [out] | |
| 63 | + timeout: 10s | |
| new file mode 100644 | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | +# Agent settings — "notes desk": an assistant for a folder of notes that acts | ||
| 2 | +# ONLY through four WASM tools (ticket 119). Each tool is a WebAssembly module | ||
| 3 | +# built with TinyGo (tools-src/ → tools/, ./build.sh) and run by mm itself, in | ||
| 4 | +# a sandbox: no file, no environment variable, no network — except the | ||
| 5 | +# folders its entry opens. mini-me on Qwen3.5 4B, served by llmman. | ||
| 6 | +# | ||
| 7 | +# cd demos/wasm-tools | ||
| 8 | +# mm -p "Which notes mention TinyGo?" | ||
| 9 | +provider: llmman | ||
| 10 | +model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" | ||
| 11 | + | ||
| 12 | +# The four tools below are the agent's only way to act. | ||
| 13 | +bashTool: false | ||
| 14 | +editTools: false | ||
| 15 | +planTool: false | ||
| 16 | +fetchContentTool: false | ||
| 17 | +skillsDir: no-skills | ||
| 18 | +# true: the tools run without asking. Set it to false to confirm each call | ||
| 19 | +# (in -p nobody can answer, so every call is then refused). | ||
| 20 | +yolo: true | ||
| 21 | +maxTurns: 6 | ||
| 22 | + | ||
| 23 | +system: | | ||
| 24 | + You are the notes desk: you help with the notes kept in the notes/ folder. | ||
| 25 | + You act only through your tools; never invent what a note says. | ||
| 26 | + To find which notes talk about something, call search_notes; to read a | ||
| 27 | + whole note, call read_note with its file name (e.g. wazero.md); to count | ||
| 28 | + the words of a text, call word_stats; to keep a new note, call save_note. | ||
| 29 | + Then answer briefly, in the user's language, from what the tools returned. | ||
| 30 | + | ||
| 31 | +tools: | ||
| 32 | + # Pure computation: no folder at all. | ||
| 33 | + - name: word_stats | ||
| 34 | + description: Count the words, lines and characters of a text, and list its most frequent words. | ||
| 35 | + parameters: | ||
| 36 | + text: { type: string, description: "The text to measure" } | ||
| 37 | + top: { type: integer, required: false, default: 5, description: "How many frequent words to list" } | ||
| 38 | + wasm: ./tools/word_stats.wasm | ||
| 39 | + | ||
| 40 | + # Sees notes/, read-only — nothing else of the project. | ||
| 41 | + - name: search_notes | ||
| 42 | + description: Find the lines of the notes that mention a word; each line comes with its note's file name and line number. | ||
| 43 | + parameters: | ||
| 44 | + word: { type: string, description: "One word, e.g. wazero" } | ||
| 45 | + wasm: ./tools/search_notes.wasm | ||
| 46 | + read: [notes] | ||
| 47 | + | ||
| 48 | + - name: read_note | ||
| 49 | + description: Show one whole note. | ||
| 50 | + parameters: | ||
| 51 | + name: { type: string, description: "The note's file name, e.g. wazero.md" } | ||
| 52 | + wasm: ./tools/read_note.wasm | ||
| 53 | + read: [notes] | ||
| 54 | + | ||
| 55 | + # Writes into out/ only: it can neither read nor change notes/. | ||
| 56 | + - name: save_note | ||
| 57 | + description: Save a new note in out/, under a file name made from its title. | ||
| 58 | + parameters: | ||
| 59 | + title: { type: string, description: "The note's title, e.g. Ideas for v1" } | ||
| 60 | + text: { type: string, description: "The note's text, in Markdown" } | ||
| 61 | + wasm: ./tools/save_note.wasm | ||
| 62 | + write: [out] | ||
| 63 | + timeout: 10s | ||
added
demos/wasm-tools/build.sh +10 -0 | new file mode 100755 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# Builds the demo's WASM tools with TinyGo: tools-src/<name>/ → tools/<name>.wasm. | |
| 3 | +# cd demos/wasm-tools && ./build.sh | |
| 4 | +set -eu | |
| 5 | +cd "$(dirname "$0")/tools-src" | |
| 6 | +for dir in */; do | |
| 7 | + name=${dir%/} | |
| 8 | + tinygo build -o "../tools/$name.wasm" -target=wasip1 -no-debug "./$name" | |
| 9 | + echo "built tools/$name.wasm ($(wc -c < "../tools/$name.wasm") bytes)" | |
| 10 | +done | |
| new file mode 100755 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +# Builds the demo's WASM tools with TinyGo: tools-src/<name>/ → tools/<name>.wasm. | ||
| 3 | +# cd demos/wasm-tools && ./build.sh | ||
| 4 | +set -eu | ||
| 5 | +cd "$(dirname "$0")/tools-src" | ||
| 6 | +for dir in */; do | ||
| 7 | + name=${dir%/} | ||
| 8 | + tinygo build -o "../tools/$name.wasm" -target=wasip1 -no-debug "./$name" | ||
| 9 | + echo "built tools/$name.wasm ($(wc -c < "../tools/$name.wasm") bytes)" | ||
| 10 | +done | ||
added
demos/wasm-tools/notes/release-plan.md +5 -0 | new file mode 100644 | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +# Release plan | |
| 2 | + | |
| 3 | +v0.9.0: mm init, the openai provider, custom tools as WASM modules. | |
| 4 | +The WASM tools need a demo with a folder they can read and one they can write. | |
| 5 | +Before the release: run the quality gate and write the release notes. | |
| new file mode 100644 | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | +# Release plan | ||
| 2 | + | ||
| 3 | +v0.9.0: mm init, the openai provider, custom tools as WASM modules. | ||
| 4 | +The WASM tools need a demo with a folder they can read and one they can write. | ||
| 5 | +Before the release: run the quality gate and write the release notes. | ||
added
demos/wasm-tools/notes/tinygo.md +6 -0 | new file mode 100644 | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +# TinyGo | |
| 2 | + | |
| 3 | +TinyGo compiles Go for small places: microcontrollers, and WebAssembly. | |
| 4 | +`tinygo build -target=wasip1` makes a WASI command: a main, stdin, stdout. | |
| 5 | +A module that uses encoding/json weighs about half a megabyte. | |
| 6 | +Paths must be clean inside a WASI module: "notes/a.md", not "./notes/a.md". | |
| new file mode 100644 | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | +# TinyGo | ||
| 2 | + | ||
| 3 | +TinyGo compiles Go for small places: microcontrollers, and WebAssembly. | ||
| 4 | +`tinygo build -target=wasip1` makes a WASI command: a main, stdin, stdout. | ||
| 5 | +A module that uses encoding/json weighs about half a megabyte. | ||
| 6 | +Paths must be clean inside a WASI module: "notes/a.md", not "./notes/a.md". | ||
added
demos/wasm-tools/notes/wazero.md +6 -0 | new file mode 100644 | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +# wazero | |
| 2 | + | |
| 3 | +wazero is a WebAssembly runtime written in pure Go: no CGO, no shared library. | |
| 4 | +It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | |
| 5 | +A module only sees the folders the host mounts for it. | |
| 6 | +Compiling a module takes a fraction of a second; each run is a fresh instance. | |
| new file mode 100644 | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | +# wazero | ||
| 2 | + | ||
| 3 | +wazero is a WebAssembly runtime written in pure Go: no CGO, no shared library. | ||
| 4 | +It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | ||
| 5 | +A module only sees the folders the host mounts for it. | ||
| 6 | +Compiling a module takes a fraction of a second; each run is a fresh instance. | ||
added
demos/wasm-tools/out/.gitignore +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +# Written by save_note: everything here is generated. | |
| 2 | +* | |
| 3 | +!.gitignore | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +# Written by save_note: everything here is generated. | ||
| 2 | +* | ||
| 3 | +!.gitignore | ||
added
demos/wasm-tools/tools-src/go.mod +6 -0 | new file mode 100644 | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +// The sources of the demo's WASM tools. Their own module, so that `go build | |
| 2 | +// ./...` in mini-me does not build them: they are built with TinyGo | |
| 3 | +// (../build.sh). | |
| 4 | +module wasmtools | |
| 5 | + | |
| 6 | +go 1.24 | |
| new file mode 100644 | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | +// The sources of the demo's WASM tools. Their own module, so that `go build | ||
| 2 | +// ./...` in mini-me does not build them: they are built with TinyGo | ||
| 3 | +// (../build.sh). | ||
| 4 | +module wasmtools | ||
| 5 | + | ||
| 6 | +go 1.24 | ||
added
demos/wasm-tools/tools-src/read_note/main.go +35 -0 | new file mode 100644 | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +// read_note prints one note. The name comes from the model, so it may be | |
| 2 | +// anything — "../agent.yaml" included: the module looks for it under notes/, | |
| 3 | +// and the sandbox has nothing else to show it. | |
| 4 | +// | |
| 5 | +// Input (stdin): {"name": "wazero.md"} | |
| 6 | +package main | |
| 7 | + | |
| 8 | +import ( | |
| 9 | + "encoding/json" | |
| 10 | + "fmt" | |
| 11 | + "os" | |
| 12 | + "path" | |
| 13 | +) | |
| 14 | + | |
| 15 | +type input struct { | |
| 16 | + Name string `json:"name"` | |
| 17 | +} | |
| 18 | + | |
| 19 | +func main() { | |
| 20 | + var in input | |
| 21 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 22 | + fmt.Fprintln(os.Stderr, "read_note: bad input:", err) | |
| 23 | + os.Exit(2) | |
| 24 | + } | |
| 25 | + // TinyGo's WASI layer refuses "./" and ".." in a path: clean it first. | |
| 26 | + // path.Join("notes", "../agent.yaml") is "agent.yaml", which is not | |
| 27 | + // mounted — the sandbox, not this line, is what keeps it out. | |
| 28 | + name := path.Join("notes", in.Name) | |
| 29 | + data, err := os.ReadFile(name) | |
| 30 | + if err != nil { | |
| 31 | + fmt.Printf("cannot read %s: %v\n", name, err) | |
| 32 | + os.Exit(1) | |
| 33 | + } | |
| 34 | + fmt.Print(string(data)) | |
| 35 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | +// read_note prints one note. The name comes from the model, so it may be | ||
| 2 | +// anything — "../agent.yaml" included: the module looks for it under notes/, | ||
| 3 | +// and the sandbox has nothing else to show it. | ||
| 4 | +// | ||
| 5 | +// Input (stdin): {"name": "wazero.md"} | ||
| 6 | +package main | ||
| 7 | + | ||
| 8 | +import ( | ||
| 9 | + "encoding/json" | ||
| 10 | + "fmt" | ||
| 11 | + "os" | ||
| 12 | + "path" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +type input struct { | ||
| 16 | + Name string `json:"name"` | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +func main() { | ||
| 20 | + var in input | ||
| 21 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 22 | + fmt.Fprintln(os.Stderr, "read_note: bad input:", err) | ||
| 23 | + os.Exit(2) | ||
| 24 | + } | ||
| 25 | + // TinyGo's WASI layer refuses "./" and ".." in a path: clean it first. | ||
| 26 | + // path.Join("notes", "../agent.yaml") is "agent.yaml", which is not | ||
| 27 | + // mounted — the sandbox, not this line, is what keeps it out. | ||
| 28 | + name := path.Join("notes", in.Name) | ||
| 29 | + data, err := os.ReadFile(name) | ||
| 30 | + if err != nil { | ||
| 31 | + fmt.Printf("cannot read %s: %v\n", name, err) | ||
| 32 | + os.Exit(1) | ||
| 33 | + } | ||
| 34 | + fmt.Print(string(data)) | ||
| 35 | +} | ||
added
demos/wasm-tools/tools-src/save_note/main.go +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +// save_note writes a note into out/, the one folder its entry opens for | |
| 2 | +// writing. It cannot touch notes/ or anything else. | |
| 3 | +// | |
| 4 | +// Input (stdin): {"title": "…", "text": "…"} | |
| 5 | +package main | |
| 6 | + | |
| 7 | +import ( | |
| 8 | + "encoding/json" | |
| 9 | + "fmt" | |
| 10 | + "os" | |
| 11 | + "strings" | |
| 12 | + "unicode" | |
| 13 | +) | |
| 14 | + | |
| 15 | +type input struct { | |
| 16 | + Title string `json:"title"` | |
| 17 | + Text string `json:"text"` | |
| 18 | +} | |
| 19 | + | |
| 20 | +// slug turns a title into a file name: "Ideas for v1" → "ideas-for-v1". | |
| 21 | +func slug(title string) string { | |
| 22 | + var b strings.Builder | |
| 23 | + dash := false | |
| 24 | + for _, r := range strings.ToLower(title) { | |
| 25 | + if unicode.IsLetter(r) || unicode.IsDigit(r) { | |
| 26 | + b.WriteRune(r) | |
| 27 | + dash = false | |
| 28 | + } else if !dash && b.Len() > 0 { | |
| 29 | + b.WriteByte('-') | |
| 30 | + dash = true | |
| 31 | + } | |
| 32 | + } | |
| 33 | + return strings.Trim(b.String(), "-") | |
| 34 | +} | |
| 35 | + | |
| 36 | +func main() { | |
| 37 | + var in input | |
| 38 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 39 | + fmt.Fprintln(os.Stderr, "save_note: bad input:", err) | |
| 40 | + os.Exit(2) | |
| 41 | + } | |
| 42 | + name := slug(in.Title) | |
| 43 | + if name == "" { | |
| 44 | + fmt.Println("give the note a title with at least one letter or digit") | |
| 45 | + os.Exit(1) | |
| 46 | + } | |
| 47 | + file := "out/" + name + ".md" | |
| 48 | + body := "# " + strings.TrimSpace(in.Title) + "\n\n" + strings.TrimSpace(in.Text) + "\n" | |
| 49 | + if err := os.WriteFile(file, []byte(body), 0o644); err != nil { | |
| 50 | + fmt.Printf("cannot write %s: %v\n", file, err) | |
| 51 | + os.Exit(1) | |
| 52 | + } | |
| 53 | + fmt.Printf("saved %s (%d bytes)\n", file, len(body)) | |
| 54 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +// save_note writes a note into out/, the one folder its entry opens for | ||
| 2 | +// writing. It cannot touch notes/ or anything else. | ||
| 3 | +// | ||
| 4 | +// Input (stdin): {"title": "…", "text": "…"} | ||
| 5 | +package main | ||
| 6 | + | ||
| 7 | +import ( | ||
| 8 | + "encoding/json" | ||
| 9 | + "fmt" | ||
| 10 | + "os" | ||
| 11 | + "strings" | ||
| 12 | + "unicode" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +type input struct { | ||
| 16 | + Title string `json:"title"` | ||
| 17 | + Text string `json:"text"` | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +// slug turns a title into a file name: "Ideas for v1" → "ideas-for-v1". | ||
| 21 | +func slug(title string) string { | ||
| 22 | + var b strings.Builder | ||
| 23 | + dash := false | ||
| 24 | + for _, r := range strings.ToLower(title) { | ||
| 25 | + if unicode.IsLetter(r) || unicode.IsDigit(r) { | ||
| 26 | + b.WriteRune(r) | ||
| 27 | + dash = false | ||
| 28 | + } else if !dash && b.Len() > 0 { | ||
| 29 | + b.WriteByte('-') | ||
| 30 | + dash = true | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + return strings.Trim(b.String(), "-") | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +func main() { | ||
| 37 | + var in input | ||
| 38 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 39 | + fmt.Fprintln(os.Stderr, "save_note: bad input:", err) | ||
| 40 | + os.Exit(2) | ||
| 41 | + } | ||
| 42 | + name := slug(in.Title) | ||
| 43 | + if name == "" { | ||
| 44 | + fmt.Println("give the note a title with at least one letter or digit") | ||
| 45 | + os.Exit(1) | ||
| 46 | + } | ||
| 47 | + file := "out/" + name + ".md" | ||
| 48 | + body := "# " + strings.TrimSpace(in.Title) + "\n\n" + strings.TrimSpace(in.Text) + "\n" | ||
| 49 | + if err := os.WriteFile(file, []byte(body), 0o644); err != nil { | ||
| 50 | + fmt.Printf("cannot write %s: %v\n", file, err) | ||
| 51 | + os.Exit(1) | ||
| 52 | + } | ||
| 53 | + fmt.Printf("saved %s (%d bytes)\n", file, len(body)) | ||
| 54 | +} | ||
added
demos/wasm-tools/tools-src/search_notes/main.go +59 -0 | new file mode 100644 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +// search_notes finds the lines of the notes that mention a word. Its entry | |
| 2 | +// opens notes/ read-only: that folder is all this module can see. | |
| 3 | +// | |
| 4 | +// Input (stdin): {"word": "…"} | |
| 5 | +package main | |
| 6 | + | |
| 7 | +import ( | |
| 8 | + "encoding/json" | |
| 9 | + "fmt" | |
| 10 | + "os" | |
| 11 | + "path" | |
| 12 | + "sort" | |
| 13 | + "strings" | |
| 14 | +) | |
| 15 | + | |
| 16 | +type input struct { | |
| 17 | + Word string `json:"word"` | |
| 18 | +} | |
| 19 | + | |
| 20 | +func main() { | |
| 21 | + var in input | |
| 22 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 23 | + fmt.Fprintln(os.Stderr, "search_notes: bad input:", err) | |
| 24 | + os.Exit(2) | |
| 25 | + } | |
| 26 | + word := strings.ToLower(strings.TrimSpace(in.Word)) | |
| 27 | + if word == "" { | |
| 28 | + fmt.Println("give a word to look for") | |
| 29 | + os.Exit(1) | |
| 30 | + } | |
| 31 | + entries, err := os.ReadDir("notes") | |
| 32 | + if err != nil { | |
| 33 | + fmt.Println("cannot list notes/:", err) | |
| 34 | + os.Exit(1) | |
| 35 | + } | |
| 36 | + var names []string | |
| 37 | + for _, e := range entries { | |
| 38 | + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { | |
| 39 | + names = append(names, e.Name()) | |
| 40 | + } | |
| 41 | + } | |
| 42 | + sort.Strings(names) | |
| 43 | + found := 0 | |
| 44 | + for _, name := range names { | |
| 45 | + data, err := os.ReadFile(path.Join("notes", name)) | |
| 46 | + if err != nil { | |
| 47 | + continue | |
| 48 | + } | |
| 49 | + for i, line := range strings.Split(string(data), "\n") { | |
| 50 | + if strings.Contains(strings.ToLower(line), word) { | |
| 51 | + fmt.Printf("%s:%d: %s\n", name, i+1, strings.TrimSpace(line)) | |
| 52 | + found++ | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + if found == 0 { | |
| 57 | + fmt.Printf("no note mentions %q (searched %d notes)\n", in.Word, len(names)) | |
| 58 | + } | |
| 59 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +// search_notes finds the lines of the notes that mention a word. Its entry | ||
| 2 | +// opens notes/ read-only: that folder is all this module can see. | ||
| 3 | +// | ||
| 4 | +// Input (stdin): {"word": "…"} | ||
| 5 | +package main | ||
| 6 | + | ||
| 7 | +import ( | ||
| 8 | + "encoding/json" | ||
| 9 | + "fmt" | ||
| 10 | + "os" | ||
| 11 | + "path" | ||
| 12 | + "sort" | ||
| 13 | + "strings" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +type input struct { | ||
| 17 | + Word string `json:"word"` | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +func main() { | ||
| 21 | + var in input | ||
| 22 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 23 | + fmt.Fprintln(os.Stderr, "search_notes: bad input:", err) | ||
| 24 | + os.Exit(2) | ||
| 25 | + } | ||
| 26 | + word := strings.ToLower(strings.TrimSpace(in.Word)) | ||
| 27 | + if word == "" { | ||
| 28 | + fmt.Println("give a word to look for") | ||
| 29 | + os.Exit(1) | ||
| 30 | + } | ||
| 31 | + entries, err := os.ReadDir("notes") | ||
| 32 | + if err != nil { | ||
| 33 | + fmt.Println("cannot list notes/:", err) | ||
| 34 | + os.Exit(1) | ||
| 35 | + } | ||
| 36 | + var names []string | ||
| 37 | + for _, e := range entries { | ||
| 38 | + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { | ||
| 39 | + names = append(names, e.Name()) | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + sort.Strings(names) | ||
| 43 | + found := 0 | ||
| 44 | + for _, name := range names { | ||
| 45 | + data, err := os.ReadFile(path.Join("notes", name)) | ||
| 46 | + if err != nil { | ||
| 47 | + continue | ||
| 48 | + } | ||
| 49 | + for i, line := range strings.Split(string(data), "\n") { | ||
| 50 | + if strings.Contains(strings.ToLower(line), word) { | ||
| 51 | + fmt.Printf("%s:%d: %s\n", name, i+1, strings.TrimSpace(line)) | ||
| 52 | + found++ | ||
| 53 | + } | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | + if found == 0 { | ||
| 57 | + fmt.Printf("no note mentions %q (searched %d notes)\n", in.Word, len(names)) | ||
| 58 | + } | ||
| 59 | +} | ||
added
demos/wasm-tools/tools-src/word_stats/main.go +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +// word_stats counts the words, lines and characters of a text, and lists | |
| 2 | +// its most frequent words. Pure computation: the tool's entry opens no | |
| 3 | +// folder, so this module sees no file at all. | |
| 4 | +// | |
| 5 | +// Input (stdin): {"text": "…", "top": 5} | |
| 6 | +package main | |
| 7 | + | |
| 8 | +import ( | |
| 9 | + "encoding/json" | |
| 10 | + "fmt" | |
| 11 | + "os" | |
| 12 | + "sort" | |
| 13 | + "strings" | |
| 14 | + "unicode" | |
| 15 | +) | |
| 16 | + | |
| 17 | +type input struct { | |
| 18 | + Text string `json:"text"` | |
| 19 | + Top int `json:"top"` | |
| 20 | +} | |
| 21 | + | |
| 22 | +func main() { | |
| 23 | + var in input | |
| 24 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 25 | + fmt.Fprintln(os.Stderr, "word_stats: bad input:", err) | |
| 26 | + os.Exit(2) | |
| 27 | + } | |
| 28 | + words := strings.FieldsFunc(strings.ToLower(in.Text), func(r rune) bool { | |
| 29 | + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\'' && r != '-' | |
| 30 | + }) | |
| 31 | + lines := strings.Count(in.Text, "\n") | |
| 32 | + if in.Text != "" && !strings.HasSuffix(in.Text, "\n") { | |
| 33 | + lines++ | |
| 34 | + } | |
| 35 | + fmt.Printf("words: %d\nlines: %d\ncharacters: %d\n", len(words), lines, len([]rune(in.Text))) | |
| 36 | + | |
| 37 | + count := map[string]int{} | |
| 38 | + for _, w := range words { | |
| 39 | + count[w]++ | |
| 40 | + } | |
| 41 | + ranked := make([]string, 0, len(count)) | |
| 42 | + for w := range count { | |
| 43 | + ranked = append(ranked, w) | |
| 44 | + } | |
| 45 | + sort.Slice(ranked, func(i, j int) bool { | |
| 46 | + if count[ranked[i]] != count[ranked[j]] { | |
| 47 | + return count[ranked[i]] > count[ranked[j]] | |
| 48 | + } | |
| 49 | + return ranked[i] < ranked[j] | |
| 50 | + }) | |
| 51 | + if in.Top > 0 && len(ranked) > in.Top { | |
| 52 | + ranked = ranked[:in.Top] | |
| 53 | + } | |
| 54 | + fmt.Println("most frequent:") | |
| 55 | + for _, w := range ranked { | |
| 56 | + fmt.Printf(" %s: %d\n", w, count[w]) | |
| 57 | + } | |
| 58 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +// word_stats counts the words, lines and characters of a text, and lists | ||
| 2 | +// its most frequent words. Pure computation: the tool's entry opens no | ||
| 3 | +// folder, so this module sees no file at all. | ||
| 4 | +// | ||
| 5 | +// Input (stdin): {"text": "…", "top": 5} | ||
| 6 | +package main | ||
| 7 | + | ||
| 8 | +import ( | ||
| 9 | + "encoding/json" | ||
| 10 | + "fmt" | ||
| 11 | + "os" | ||
| 12 | + "sort" | ||
| 13 | + "strings" | ||
| 14 | + "unicode" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +type input struct { | ||
| 18 | + Text string `json:"text"` | ||
| 19 | + Top int `json:"top"` | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +func main() { | ||
| 23 | + var in input | ||
| 24 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 25 | + fmt.Fprintln(os.Stderr, "word_stats: bad input:", err) | ||
| 26 | + os.Exit(2) | ||
| 27 | + } | ||
| 28 | + words := strings.FieldsFunc(strings.ToLower(in.Text), func(r rune) bool { | ||
| 29 | + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\'' && r != '-' | ||
| 30 | + }) | ||
| 31 | + lines := strings.Count(in.Text, "\n") | ||
| 32 | + if in.Text != "" && !strings.HasSuffix(in.Text, "\n") { | ||
| 33 | + lines++ | ||
| 34 | + } | ||
| 35 | + fmt.Printf("words: %d\nlines: %d\ncharacters: %d\n", len(words), lines, len([]rune(in.Text))) | ||
| 36 | + | ||
| 37 | + count := map[string]int{} | ||
| 38 | + for _, w := range words { | ||
| 39 | + count[w]++ | ||
| 40 | + } | ||
| 41 | + ranked := make([]string, 0, len(count)) | ||
| 42 | + for w := range count { | ||
| 43 | + ranked = append(ranked, w) | ||
| 44 | + } | ||
| 45 | + sort.Slice(ranked, func(i, j int) bool { | ||
| 46 | + if count[ranked[i]] != count[ranked[j]] { | ||
| 47 | + return count[ranked[i]] > count[ranked[j]] | ||
| 48 | + } | ||
| 49 | + return ranked[i] < ranked[j] | ||
| 50 | + }) | ||
| 51 | + if in.Top > 0 && len(ranked) > in.Top { | ||
| 52 | + ranked = ranked[:in.Top] | ||
| 53 | + } | ||
| 54 | + fmt.Println("most frequent:") | ||
| 55 | + for _, w := range ranked { | ||
| 56 | + fmt.Printf(" %s: %d\n", w, count[w]) | ||
| 57 | + } | ||
| 58 | +} | ||
added
demos/wasm-tools/tools/read_note.wasm +0 -0 | new file mode 100755 | ||
| Binary files /dev/null and b/demos/wasm-tools/tools/read_note.wasm differ | ||
| new file mode 100755 | |||
| Binary files /dev/null and b/demos/wasm-tools/tools/read_note.wasm differ | Binary files /dev/null and b/demos/wasm-tools/tools/read_note.wasm differ | ||
added
demos/wasm-tools/tools/save_note.wasm +0 -0 | new file mode 100755 | ||
| Binary files /dev/null and b/demos/wasm-tools/tools/save_note.wasm differ | ||
| new file mode 100755 | |||
| Binary files /dev/null and b/demos/wasm-tools/tools/save_note.wasm differ | Binary files /dev/null and b/demos/wasm-tools/tools/save_note.wasm differ | ||
added
demos/wasm-tools/tools/search_notes.wasm +0 -0 | new file mode 100755 | ||
| Binary files /dev/null and b/demos/wasm-tools/tools/search_notes.wasm differ | ||
| new file mode 100755 | |||
| Binary files /dev/null and b/demos/wasm-tools/tools/search_notes.wasm differ | Binary files /dev/null and b/demos/wasm-tools/tools/search_notes.wasm differ | ||
added
demos/wasm-tools/tools/word_stats.wasm +0 -0 | new file mode 100755 | ||
| Binary files /dev/null and b/demos/wasm-tools/tools/word_stats.wasm differ | ||
| new file mode 100755 | |||
| Binary files /dev/null and b/demos/wasm-tools/tools/word_stats.wasm differ | Binary files /dev/null and b/demos/wasm-tools/tools/word_stats.wasm differ | ||
modified
docs/diagrams/packages.drawio +1 -1 | @@ -1,3 +1,3 @@ | ||
| 1 | 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| @@ -1,3 +1,3 @@ | |||
| 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
docs/en/README.md +2 -0 | @@ -28,6 +28,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 28 | 28 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor |
| 29 | 29 | - [Add a skill](how-to/add-a-skill.md) |
| 30 | 30 | - [Declare a custom tool](how-to/declare-a-custom-tool.md) |
| 31 | +- [Write a custom tool as a WebAssembly module](how-to/write-a-wasm-tool.md) | |
| 31 | 32 | - [Manage plans and tasks](how-to/manage-plans-and-tasks.md) — the `plan` tool: give a list, follow the `📋` progress line, plans per conversation |
| 32 | 33 | - [Save an answer to a file](how-to/save-an-answer.md) |
| 33 | 34 | - [Search a documentation folder](how-to/search-the-docs.md) — `/docs <words>`, the `search_docs` tool, `docSearch` (BM25, no model) |
| @@ -44,6 +45,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 44 | 45 | - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53 |
| 45 | 46 | - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it |
| 46 | 47 | - [Guard the agent with hooks](how-to/guard-the-agent-with-hooks.md) — deny a tool call, stop or rewrite a question, add context, log every turn, with a script in any language |
| 48 | +- [Write a hook as a WebAssembly module](how-to/write-a-wasm-hook.md) — a hook run by mm in a sandbox: nothing to install, only the folders it is given | |
| 47 | 49 | - [Control the hand-offs to other agents with hooks](how-to/control-agent-calls-with-hooks.md) — refuse, rewrite or mask what an agent sends to another one, check or replace what comes back (`preAsk`, `postAsk`) |
| 48 | 50 | - [Screen questions with guard models](how-to/screen-questions-with-guard-models.md) — one guard agent per risk (Granite Guardian), a `preGeneration` hook that asks them without the history, an assistant that reacts to their verdicts; the demos in `demos/guard/` and `demos/guards/` |
| 49 | 51 | |
| @@ -28,6 +28,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 28 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor | 28 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor |
| 29 | - [Add a skill](how-to/add-a-skill.md) | 29 | - [Add a skill](how-to/add-a-skill.md) |
| 30 | - [Declare a custom tool](how-to/declare-a-custom-tool.md) | 30 | - [Declare a custom tool](how-to/declare-a-custom-tool.md) |
| 31 | +- [Write a custom tool as a WebAssembly module](how-to/write-a-wasm-tool.md) | ||
| 31 | - [Manage plans and tasks](how-to/manage-plans-and-tasks.md) — the `plan` tool: give a list, follow the `📋` progress line, plans per conversation | 32 | - [Manage plans and tasks](how-to/manage-plans-and-tasks.md) — the `plan` tool: give a list, follow the `📋` progress line, plans per conversation |
| 32 | - [Save an answer to a file](how-to/save-an-answer.md) | 33 | - [Save an answer to a file](how-to/save-an-answer.md) |
| 33 | - [Search a documentation folder](how-to/search-the-docs.md) — `/docs <words>`, the `search_docs` tool, `docSearch` (BM25, no model) | 34 | - [Search a documentation folder](how-to/search-the-docs.md) — `/docs <words>`, the `search_docs` tool, `docSearch` (BM25, no model) |
| @@ -44,6 +45,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 44 | - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53 | 45 | - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53 |
| 45 | - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it | 46 | - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it |
| 46 | - [Guard the agent with hooks](how-to/guard-the-agent-with-hooks.md) — deny a tool call, stop or rewrite a question, add context, log every turn, with a script in any language | 47 | - [Guard the agent with hooks](how-to/guard-the-agent-with-hooks.md) — deny a tool call, stop or rewrite a question, add context, log every turn, with a script in any language |
| 48 | +- [Write a hook as a WebAssembly module](how-to/write-a-wasm-hook.md) — a hook run by mm in a sandbox: nothing to install, only the folders it is given | ||
| 47 | - [Control the hand-offs to other agents with hooks](how-to/control-agent-calls-with-hooks.md) — refuse, rewrite or mask what an agent sends to another one, check or replace what comes back (`preAsk`, `postAsk`) | 49 | - [Control the hand-offs to other agents with hooks](how-to/control-agent-calls-with-hooks.md) — refuse, rewrite or mask what an agent sends to another one, check or replace what comes back (`preAsk`, `postAsk`) |
| 48 | - [Screen questions with guard models](how-to/screen-questions-with-guard-models.md) — one guard agent per risk (Granite Guardian), a `preGeneration` hook that asks them without the history, an assistant that reacts to their verdicts; the demos in `demos/guard/` and `demos/guards/` | 50 | - [Screen questions with guard models](how-to/screen-questions-with-guard-models.md) — one guard agent per risk (Granite Guardian), a `preGeneration` hook that asks them without the history, an assistant that reacts to their verdicts; the demos in `demos/guard/` and `demos/guards/` |
| 49 | 51 | ||
modified
docs/en/explanation/declared-tools.md +13 -1 | @@ -43,11 +43,23 @@ The numbers come from one small fixture, two models and five runs per case. They | ||
| 43 | 43 | |
| 44 | 44 | **Names are reserved early.** A declared tool cannot take a built-in name even when that tool is turned off, so turning it on later does not break the file. A clash with an MCP tool can only be seen once the server answered, so it is a start-up warning, not an error. |
| 45 | 45 | |
| 46 | +## Wasm tools | |
| 47 | + | |
| 48 | +A command tool runs with all the user's rights: the agent's author wrote the command, but whatever it does, it can do anywhere. A tool that only needs to read one folder, or to compute something from its arguments, does not need that. A `wasm:` entry (ticket 119) runs a WebAssembly module inside mm instead, with [wazero](https://wazero.io), and the module starts with **nothing**: no file, no environment variable, no network. The entry gives it what it needs, folder by folder — `read:` or `write:` — and that list is the whole of its world. A model's value, even one a prompt injection planted, cannot reach past it: `read_note` called with `../agent.yaml` in the demo finds no such file, because nothing above `notes/` is there to be found. | |
| 49 | + | |
| 50 | +**Why a WASI command, with JSON on stdin.** The module is an ordinary program with a `main` — what TinyGo builds with `-target=wasip1` — so writing one needs no SDK and no exported functions to learn. The arguments arrive as one JSON object, already checked and typed by mm, the shape the hooks already use; an argv template like `command:` was the other choice, rejected because it turns every value back into text the module must parse again. This contract is the one a module author relies on, so it is meant to stay. | |
| 51 | + | |
| 52 | +**Why wazero.** It is pure Go: mm stays one static, CGO-free binary on every release target, with nothing to install beside it. The cost is size: the mm binary grew from 38.97 MB to 42.87 MB (linux/arm64). | |
| 53 | + | |
| 54 | +**What it costs at run time**, measured on the sandbox where it was built (linux/arm64): compiling a TinyGo module of about 550 KB took 176–183 ms, once per mm process, on its first call; each call after that, a fresh instance, took 0.2–0.5 ms. For comparison, ticket 46 measured a Node.js hook at about 62 ms per call. A fresh instance per call means no state survives between calls, and calls can overlap. | |
| 55 | + | |
| 56 | +**What it does not do.** WASI preview 1 has no network, so a tool that calls an API stays a command (or an MCP tool). Consent is the same as for a command (`yolo`): the sandbox limits what a module can touch, but the decision to run it is still the user's. Only TinyGo modules were tried. | |
| 57 | + | |
| 46 | 58 | ## Rejected alternatives |
| 47 | 59 | |
| 48 | 60 | - **Only skills.** Cheaper to write and fine for procedures, but measured to fail where it matters on the small models mm targets. |
| 49 | 61 | - **Only MCP.** Already gives typed tools, but each one needs a server to write, start and keep running, over HTTP only (mm has no MCP stdio transport). Too heavy for "run this script with two arguments". |
| 50 | -- **A Wasm plugin system** (ticket 46). Sandboxed tools are a different question; a declared tool runs with the user's rights, like `bash`. | |
| 62 | +- **A general Wasm plugin system** (ticket 46) — hooks, providers, anything as a plugin. Only declared tools became Wasm (ticket 119, above): one entry, one module, one documented contract. Hooks as Wasm modules remain an open idea there. | |
| 51 | 63 | |
| 52 | 64 | ## How it relates to the rest |
| 53 | 65 | |
| @@ -43,11 +43,23 @@ The numbers come from one small fixture, two models and five runs per case. They | |||
| 43 | 43 | ||
| 44 | **Names are reserved early.** A declared tool cannot take a built-in name even when that tool is turned off, so turning it on later does not break the file. A clash with an MCP tool can only be seen once the server answered, so it is a start-up warning, not an error. | 44 | **Names are reserved early.** A declared tool cannot take a built-in name even when that tool is turned off, so turning it on later does not break the file. A clash with an MCP tool can only be seen once the server answered, so it is a start-up warning, not an error. |
| 45 | 45 | ||
| 46 | +## Wasm tools | ||
| 47 | + | ||
| 48 | +A command tool runs with all the user's rights: the agent's author wrote the command, but whatever it does, it can do anywhere. A tool that only needs to read one folder, or to compute something from its arguments, does not need that. A `wasm:` entry (ticket 119) runs a WebAssembly module inside mm instead, with [wazero](https://wazero.io), and the module starts with **nothing**: no file, no environment variable, no network. The entry gives it what it needs, folder by folder — `read:` or `write:` — and that list is the whole of its world. A model's value, even one a prompt injection planted, cannot reach past it: `read_note` called with `../agent.yaml` in the demo finds no such file, because nothing above `notes/` is there to be found. | ||
| 49 | + | ||
| 50 | +**Why a WASI command, with JSON on stdin.** The module is an ordinary program with a `main` — what TinyGo builds with `-target=wasip1` — so writing one needs no SDK and no exported functions to learn. The arguments arrive as one JSON object, already checked and typed by mm, the shape the hooks already use; an argv template like `command:` was the other choice, rejected because it turns every value back into text the module must parse again. This contract is the one a module author relies on, so it is meant to stay. | ||
| 51 | + | ||
| 52 | +**Why wazero.** It is pure Go: mm stays one static, CGO-free binary on every release target, with nothing to install beside it. The cost is size: the mm binary grew from 38.97 MB to 42.87 MB (linux/arm64). | ||
| 53 | + | ||
| 54 | +**What it costs at run time**, measured on the sandbox where it was built (linux/arm64): compiling a TinyGo module of about 550 KB took 176–183 ms, once per mm process, on its first call; each call after that, a fresh instance, took 0.2–0.5 ms. For comparison, ticket 46 measured a Node.js hook at about 62 ms per call. A fresh instance per call means no state survives between calls, and calls can overlap. | ||
| 55 | + | ||
| 56 | +**What it does not do.** WASI preview 1 has no network, so a tool that calls an API stays a command (or an MCP tool). Consent is the same as for a command (`yolo`): the sandbox limits what a module can touch, but the decision to run it is still the user's. Only TinyGo modules were tried. | ||
| 57 | + | ||
| 46 | ## Rejected alternatives | 58 | ## Rejected alternatives |
| 47 | 59 | ||
| 48 | - **Only skills.** Cheaper to write and fine for procedures, but measured to fail where it matters on the small models mm targets. | 60 | - **Only skills.** Cheaper to write and fine for procedures, but measured to fail where it matters on the small models mm targets. |
| 49 | - **Only MCP.** Already gives typed tools, but each one needs a server to write, start and keep running, over HTTP only (mm has no MCP stdio transport). Too heavy for "run this script with two arguments". | 61 | - **Only MCP.** Already gives typed tools, but each one needs a server to write, start and keep running, over HTTP only (mm has no MCP stdio transport). Too heavy for "run this script with two arguments". |
| 50 | -- **A Wasm plugin system** (ticket 46). Sandboxed tools are a different question; a declared tool runs with the user's rights, like `bash`. | 62 | +- **A general Wasm plugin system** (ticket 46) — hooks, providers, anything as a plugin. Only declared tools became Wasm (ticket 119, above): one entry, one module, one documented contract. Hooks as Wasm modules remain an open idea there. |
| 51 | 63 | ||
| 52 | ## How it relates to the rest | 64 | ## How it relates to the rest |
| 53 | 65 | ||
modified
docs/en/explanation/hooks.md +10 -0 | @@ -67,6 +67,16 @@ Four decisions, taken with the user: | ||
| 67 | 67 | |
| 68 | 68 | One limit follows from how the answer travels: it streams to the user while the asked agent writes it. `postAsk` changes what the asking agent reads and remembers, not what was shown — the same rule as `postToolCall`. A replaced answer is announced on the notice line so the two never differ unseen. |
| 69 | 69 | |
| 70 | +## Hooks as WebAssembly modules | |
| 71 | + | |
| 72 | +A script hook pays for its freedom twice: a process start on every event, and all the user's rights — a `bash` guard written in Node can read any file and call any server. Ticket 120 adds a second way to write one: `wasm:` names a WebAssembly module that `mm` runs itself, with wazero, in the sandbox of the [wasm tools](declared-tools.md#wasm-tools). The contract does not change — the same JSON on stdin, the same reply on stdout — so a hook is the same program in either form; only what it can reach does. | |
| 73 | + | |
| 74 | +**What it sees.** No file, unless its entry opens a folder (`read:`, `write:`); no network; of the environment, `MM_HOOK_EVENT` alone. `MM_CONFIG_DIR` is left out on purpose: it is a path on the host, and inside the sandbox there is nothing at that path. The audit hook of the demo writes its log into `logs/` and could not read the files it audits. | |
| 75 | + | |
| 76 | +**What was measured** (ticket 46's experiment): the `bash` guard of `demos/hooks`, run through `mm`'s own hooks runner, 20 runs after a first one, twice, on the sandbox where it was built (linux/arm64). The Node.js script: a median of 65–67 ms per run. The same rules as a TinyGo module: 234–248 ms for the first run of the process — the compilation — then a median of 0.56–0.58 ms. Against a model that spends seconds per answer, the Node cost was already noise; what the module changes is mostly the other two points: nothing to install, and nothing it can reach unless it is given. It costs size instead: 954 bytes of JavaScript against a 770 KB module (regular expressions and JSON included), plus the runtime in `mm` itself. | |
| 77 | + | |
| 78 | +**What stays a script.** A hook that lists the working directory (the demo's `add-context.js`) or calls a server (the guard models) needs what the sandbox does not give: a module can open folders inside the working directory, not the directory itself, and WASI preview 1 has no network. | |
| 79 | + | |
| 70 | 80 | ## Rejected alternatives |
| 71 | 81 | |
| 72 | 82 | Ticket 36 explored more than what was built. What was left out, and why: |
| @@ -67,6 +67,16 @@ Four decisions, taken with the user: | |||
| 67 | 67 | ||
| 68 | One limit follows from how the answer travels: it streams to the user while the asked agent writes it. `postAsk` changes what the asking agent reads and remembers, not what was shown — the same rule as `postToolCall`. A replaced answer is announced on the notice line so the two never differ unseen. | 68 | One limit follows from how the answer travels: it streams to the user while the asked agent writes it. `postAsk` changes what the asking agent reads and remembers, not what was shown — the same rule as `postToolCall`. A replaced answer is announced on the notice line so the two never differ unseen. |
| 69 | 69 | ||
| 70 | +## Hooks as WebAssembly modules | ||
| 71 | + | ||
| 72 | +A script hook pays for its freedom twice: a process start on every event, and all the user's rights — a `bash` guard written in Node can read any file and call any server. Ticket 120 adds a second way to write one: `wasm:` names a WebAssembly module that `mm` runs itself, with wazero, in the sandbox of the [wasm tools](declared-tools.md#wasm-tools). The contract does not change — the same JSON on stdin, the same reply on stdout — so a hook is the same program in either form; only what it can reach does. | ||
| 73 | + | ||
| 74 | +**What it sees.** No file, unless its entry opens a folder (`read:`, `write:`); no network; of the environment, `MM_HOOK_EVENT` alone. `MM_CONFIG_DIR` is left out on purpose: it is a path on the host, and inside the sandbox there is nothing at that path. The audit hook of the demo writes its log into `logs/` and could not read the files it audits. | ||
| 75 | + | ||
| 76 | +**What was measured** (ticket 46's experiment): the `bash` guard of `demos/hooks`, run through `mm`'s own hooks runner, 20 runs after a first one, twice, on the sandbox where it was built (linux/arm64). The Node.js script: a median of 65–67 ms per run. The same rules as a TinyGo module: 234–248 ms for the first run of the process — the compilation — then a median of 0.56–0.58 ms. Against a model that spends seconds per answer, the Node cost was already noise; what the module changes is mostly the other two points: nothing to install, and nothing it can reach unless it is given. It costs size instead: 954 bytes of JavaScript against a 770 KB module (regular expressions and JSON included), plus the runtime in `mm` itself. | ||
| 77 | + | ||
| 78 | +**What stays a script.** A hook that lists the working directory (the demo's `add-context.js`) or calls a server (the guard models) needs what the sandbox does not give: a module can open folders inside the working directory, not the directory itself, and WASI preview 1 has no network. | ||
| 79 | + | ||
| 70 | ## Rejected alternatives | 80 | ## Rejected alternatives |
| 71 | 81 | ||
| 72 | Ticket 36 explored more than what was built. What was left out, and why: | 82 | Ticket 36 explored more than what was built. What was left out, and why: |
modified
docs/en/how-to/declare-a-custom-tool.md +1 -0 | @@ -52,6 +52,7 @@ Use it for a frequent, precise action — run the tests of a package, prepare a | ||
| 52 | 52 | - **Ask before each call.** Set `yolo: false`: the declared tools then ask like `bash` does. With `yolo: true` they run without asking. |
| 53 | 53 | - **A shell feature (pipe, redirection, `&&`).** The command runs without a shell, so write it in a script and declare the script. Passing `["bash", "-c", "…{{p}}…"]` would put the model's value back into a shell line. |
| 54 | 54 | - **The same action as an MCP tool.** A declared tool cannot take the name of an MCP tool: it is skipped with a start-up warning. Rename one of them. |
| 55 | +- **A tool without your rights.** A tool that only reads a folder or computes from its arguments can be a WebAssembly module run in a sandbox instead of a command: see [How to write a custom tool as a WebAssembly module](write-a-wasm-tool.md). | |
| 55 | 56 | - **Restrict what runs before or after.** `preToolCall` and `postToolCall` [hooks](guard-the-agent-with-hooks.md) apply to declared tools; use the tool's name as `matcher`. |
| 56 | 57 | |
| 57 | 58 | ## See also |
| @@ -52,6 +52,7 @@ Use it for a frequent, precise action — run the tests of a package, prepare a | |||
| 52 | - **Ask before each call.** Set `yolo: false`: the declared tools then ask like `bash` does. With `yolo: true` they run without asking. | 52 | - **Ask before each call.** Set `yolo: false`: the declared tools then ask like `bash` does. With `yolo: true` they run without asking. |
| 53 | - **A shell feature (pipe, redirection, `&&`).** The command runs without a shell, so write it in a script and declare the script. Passing `["bash", "-c", "…{{p}}…"]` would put the model's value back into a shell line. | 53 | - **A shell feature (pipe, redirection, `&&`).** The command runs without a shell, so write it in a script and declare the script. Passing `["bash", "-c", "…{{p}}…"]` would put the model's value back into a shell line. |
| 54 | - **The same action as an MCP tool.** A declared tool cannot take the name of an MCP tool: it is skipped with a start-up warning. Rename one of them. | 54 | - **The same action as an MCP tool.** A declared tool cannot take the name of an MCP tool: it is skipped with a start-up warning. Rename one of them. |
| 55 | +- **A tool without your rights.** A tool that only reads a folder or computes from its arguments can be a WebAssembly module run in a sandbox instead of a command: see [How to write a custom tool as a WebAssembly module](write-a-wasm-tool.md). | ||
| 55 | - **Restrict what runs before or after.** `preToolCall` and `postToolCall` [hooks](guard-the-agent-with-hooks.md) apply to declared tools; use the tool's name as `matcher`. | 56 | - **Restrict what runs before or after.** `preToolCall` and `postToolCall` [hooks](guard-the-agent-with-hooks.md) apply to declared tools; use the tool's name as `matcher`. |
| 56 | 57 | ||
| 57 | ## See also | 58 | ## See also |
modified
docs/en/how-to/guard-the-agent-with-hooks.md +1 -0 | @@ -89,6 +89,7 @@ and the model's answer explains the refusal. | ||
| 89 | 89 | - **Rewrite.** `{"prompt":"…"}` on `preGeneration` replaces the question; `{"input":{…}}` on `preToolCall` replaces the tool's arguments — the whole object, so copy the fields you keep. |
| 90 | 90 | - **Log.** On `postToolCall` and `postGeneration`, append `input` to a file and print nothing; set `onError: ignore` so a full disk never interrupts a session. One script can serve several events: read `input.event` or `$MM_HOOK_EVENT`. |
| 91 | 91 | - **Another language.** `command` is run by `sh -c`: `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (executable), or an inline `jq` pipeline all work. |
| 92 | +- **A hook without your rights, nothing to install.** Write it as a WebAssembly module and declare it with `wasm:` instead of `command:`: see [How to write a hook as a WebAssembly module](write-a-wasm-hook.md). | |
| 92 | 93 | - **Several hooks on one event** run in the order written; each one sees the previous one's rewrite, and the first `stop` or `deny` ends the chain. |
| 93 | 94 | - **With `yolo: false`**, a `preToolCall` hook runs before the consent question: a denied call is never put to the user. |
| 94 | 95 | - **See which hooks ran.** Set `displayHooks: true`: after each answer, one line per hook run says its event, its command and what it decided — `context (178 chars)`, `deny`, `failed`… In an editor, each run is a tool call. See [hooks › Display](../reference/hooks.md#display). |
| @@ -89,6 +89,7 @@ and the model's answer explains the refusal. | |||
| 89 | - **Rewrite.** `{"prompt":"…"}` on `preGeneration` replaces the question; `{"input":{…}}` on `preToolCall` replaces the tool's arguments — the whole object, so copy the fields you keep. | 89 | - **Rewrite.** `{"prompt":"…"}` on `preGeneration` replaces the question; `{"input":{…}}` on `preToolCall` replaces the tool's arguments — the whole object, so copy the fields you keep. |
| 90 | - **Log.** On `postToolCall` and `postGeneration`, append `input` to a file and print nothing; set `onError: ignore` so a full disk never interrupts a session. One script can serve several events: read `input.event` or `$MM_HOOK_EVENT`. | 90 | - **Log.** On `postToolCall` and `postGeneration`, append `input` to a file and print nothing; set `onError: ignore` so a full disk never interrupts a session. One script can serve several events: read `input.event` or `$MM_HOOK_EVENT`. |
| 91 | - **Another language.** `command` is run by `sh -c`: `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (executable), or an inline `jq` pipeline all work. | 91 | - **Another language.** `command` is run by `sh -c`: `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (executable), or an inline `jq` pipeline all work. |
| 92 | +- **A hook without your rights, nothing to install.** Write it as a WebAssembly module and declare it with `wasm:` instead of `command:`: see [How to write a hook as a WebAssembly module](write-a-wasm-hook.md). | ||
| 92 | - **Several hooks on one event** run in the order written; each one sees the previous one's rewrite, and the first `stop` or `deny` ends the chain. | 93 | - **Several hooks on one event** run in the order written; each one sees the previous one's rewrite, and the first `stop` or `deny` ends the chain. |
| 93 | - **With `yolo: false`**, a `preToolCall` hook runs before the consent question: a denied call is never put to the user. | 94 | - **With `yolo: false`**, a `preToolCall` hook runs before the consent question: a denied call is never put to the user. |
| 94 | - **See which hooks ran.** Set `displayHooks: true`: after each answer, one line per hook run says its event, its command and what it decided — `context (178 chars)`, `deny`, `failed`… In an editor, each run is a tool call. See [hooks › Display](../reference/hooks.md#display). | 95 | - **See which hooks ran.** Set `displayHooks: true`: after each answer, one line per hook run says its event, its command and what it decided — `context (178 chars)`, `deny`, `failed`… In an editor, each run is a tool call. See [hooks › Display](../reference/hooks.md#display). |
added
docs/en/how-to/write-a-wasm-hook.md +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +# How to write a hook as a WebAssembly module | |
| 2 | + | |
| 3 | +This guide shows how to write a hook that runs as a WebAssembly module inside mm, in a sandbox, instead of a script: nothing to install beside mm, no process started per event, and a hook that sees no file, no environment variable and no network except what its entry opens. It assumes you know [how hooks work](guard-the-agent-with-hooks.md) and that [TinyGo](https://tinygo.org) is installed. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Write the hook as an ordinary Go program. It reads the same JSON object on stdin as a script hook, and prints the same reply on stdout — or nothing, to let the turn go on. This one refuses `git push --force`: | |
| 8 | + | |
| 9 | + ```go | |
| 10 | + // hooks-src/no_force_push/main.go | |
| 11 | + package main | |
| 12 | + | |
| 13 | + import ( | |
| 14 | + "encoding/json" | |
| 15 | + "os" | |
| 16 | + "regexp" | |
| 17 | + ) | |
| 18 | + | |
| 19 | + var forcePush = regexp.MustCompile(`\bgit\s+push\b.*(--force|-f\b)`) | |
| 20 | + | |
| 21 | + func main() { | |
| 22 | + var in struct { | |
| 23 | + Tool struct { | |
| 24 | + Input struct { | |
| 25 | + Command string `json:"command"` | |
| 26 | + } `json:"input"` | |
| 27 | + } `json:"tool"` | |
| 28 | + } | |
| 29 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 30 | + os.Exit(2) | |
| 31 | + } | |
| 32 | + if forcePush.MatchString(in.Tool.Input.Command) { | |
| 33 | + json.NewEncoder(os.Stdout).Encode(map[string]string{ | |
| 34 | + "action": "deny", | |
| 35 | + "reason": "force-push is not allowed in this project", | |
| 36 | + }) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + ``` | |
| 40 | + | |
| 41 | +2. Build it as a WASI module: | |
| 42 | + | |
| 43 | + ```bash | |
| 44 | + tinygo build -o hooks/no_force_push.wasm -target=wasip1 -no-debug ./hooks-src/no_force_push | |
| 45 | + ``` | |
| 46 | + | |
| 47 | +3. Declare it with `wasm:` in place of `command:`; `matcher`, `timeout` and `onError` work as for a script: | |
| 48 | + | |
| 49 | + ```yaml | |
| 50 | + hooks: | |
| 51 | + preToolCall: | |
| 52 | + - matcher: ^bash$ | |
| 53 | + wasm: ./hooks/no_force_push.wasm | |
| 54 | + onError: fail | |
| 55 | + ``` | |
| 56 | + | |
| 57 | + The module path is relative to the agent file. | |
| 58 | + | |
| 59 | +4. Start the agent with `displayHooks: true` in the file, and ask for something that runs `bash`. After the answer, each run of the hook is listed under its module's name: | |
| 60 | + | |
| 61 | + ```text | |
| 62 | + 🪝 1. preToolCall bash · no_force_push.wasm → deny | |
| 63 | + ``` | |
| 64 | + | |
| 65 | +## Variants | |
| 66 | + | |
| 67 | +- **A hook that reads or writes files.** Open the folders it needs, relative to the working directory: `read: [rules]` (read-only), `write: [logs]` (read-write). It opens them by the same name (`logs/hooks-audit.jsonl`); nothing else exists for it. A folder must exist when the hook runs. | |
| 68 | +- **One module for several events.** Read `event` in the input, or the `MM_HOOK_EVENT` variable — the only one the module sees. | |
| 69 | +- **A hook that needs the whole working directory** (listing its files, say) or the network stays a script: a wasm hook can open folders inside the working directory, not the directory itself, and has no network. | |
| 70 | +- **Paths inside the module.** Write them clean: `logs/a.jsonl`, not `./logs/a.jsonl` — TinyGo's WASI layer refuses `./` and `..`. | |
| 71 | +- **The first run of a process** compiles the module (a few hundred milliseconds); the runs after it take well under a millisecond. | |
| 72 | + | |
| 73 | +## See also | |
| 74 | + | |
| 75 | +- A complete example — a secret filter, a `bash` guard and an audit log, the hooks of `demos/hooks` rewritten as modules: `demos/wasm-hooks/` | |
| 76 | +- Every key, what the module sees, the failures: [hooks reference](../reference/hooks.md#wasm-hooks) | |
| 77 | +- Why, and what was measured: [Hooks — explanation](../explanation/hooks.md#hooks-as-webassembly-modules) | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +# How to write a hook as a WebAssembly module | ||
| 2 | + | ||
| 3 | +This guide shows how to write a hook that runs as a WebAssembly module inside mm, in a sandbox, instead of a script: nothing to install beside mm, no process started per event, and a hook that sees no file, no environment variable and no network except what its entry opens. It assumes you know [how hooks work](guard-the-agent-with-hooks.md) and that [TinyGo](https://tinygo.org) is installed. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Write the hook as an ordinary Go program. It reads the same JSON object on stdin as a script hook, and prints the same reply on stdout — or nothing, to let the turn go on. This one refuses `git push --force`: | ||
| 8 | + | ||
| 9 | + ```go | ||
| 10 | + // hooks-src/no_force_push/main.go | ||
| 11 | + package main | ||
| 12 | + | ||
| 13 | + import ( | ||
| 14 | + "encoding/json" | ||
| 15 | + "os" | ||
| 16 | + "regexp" | ||
| 17 | + ) | ||
| 18 | + | ||
| 19 | + var forcePush = regexp.MustCompile(`\bgit\s+push\b.*(--force|-f\b)`) | ||
| 20 | + | ||
| 21 | + func main() { | ||
| 22 | + var in struct { | ||
| 23 | + Tool struct { | ||
| 24 | + Input struct { | ||
| 25 | + Command string `json:"command"` | ||
| 26 | + } `json:"input"` | ||
| 27 | + } `json:"tool"` | ||
| 28 | + } | ||
| 29 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 30 | + os.Exit(2) | ||
| 31 | + } | ||
| 32 | + if forcePush.MatchString(in.Tool.Input.Command) { | ||
| 33 | + json.NewEncoder(os.Stdout).Encode(map[string]string{ | ||
| 34 | + "action": "deny", | ||
| 35 | + "reason": "force-push is not allowed in this project", | ||
| 36 | + }) | ||
| 37 | + } | ||
| 38 | + } | ||
| 39 | + ``` | ||
| 40 | + | ||
| 41 | +2. Build it as a WASI module: | ||
| 42 | + | ||
| 43 | + ```bash | ||
| 44 | + tinygo build -o hooks/no_force_push.wasm -target=wasip1 -no-debug ./hooks-src/no_force_push | ||
| 45 | + ``` | ||
| 46 | + | ||
| 47 | +3. Declare it with `wasm:` in place of `command:`; `matcher`, `timeout` and `onError` work as for a script: | ||
| 48 | + | ||
| 49 | + ```yaml | ||
| 50 | + hooks: | ||
| 51 | + preToolCall: | ||
| 52 | + - matcher: ^bash$ | ||
| 53 | + wasm: ./hooks/no_force_push.wasm | ||
| 54 | + onError: fail | ||
| 55 | + ``` | ||
| 56 | + | ||
| 57 | + The module path is relative to the agent file. | ||
| 58 | + | ||
| 59 | +4. Start the agent with `displayHooks: true` in the file, and ask for something that runs `bash`. After the answer, each run of the hook is listed under its module's name: | ||
| 60 | + | ||
| 61 | + ```text | ||
| 62 | + 🪝 1. preToolCall bash · no_force_push.wasm → deny | ||
| 63 | + ``` | ||
| 64 | + | ||
| 65 | +## Variants | ||
| 66 | + | ||
| 67 | +- **A hook that reads or writes files.** Open the folders it needs, relative to the working directory: `read: [rules]` (read-only), `write: [logs]` (read-write). It opens them by the same name (`logs/hooks-audit.jsonl`); nothing else exists for it. A folder must exist when the hook runs. | ||
| 68 | +- **One module for several events.** Read `event` in the input, or the `MM_HOOK_EVENT` variable — the only one the module sees. | ||
| 69 | +- **A hook that needs the whole working directory** (listing its files, say) or the network stays a script: a wasm hook can open folders inside the working directory, not the directory itself, and has no network. | ||
| 70 | +- **Paths inside the module.** Write them clean: `logs/a.jsonl`, not `./logs/a.jsonl` — TinyGo's WASI layer refuses `./` and `..`. | ||
| 71 | +- **The first run of a process** compiles the module (a few hundred milliseconds); the runs after it take well under a millisecond. | ||
| 72 | + | ||
| 73 | +## See also | ||
| 74 | + | ||
| 75 | +- A complete example — a secret filter, a `bash` guard and an audit log, the hooks of `demos/hooks` rewritten as modules: `demos/wasm-hooks/` | ||
| 76 | +- Every key, what the module sees, the failures: [hooks reference](../reference/hooks.md#wasm-hooks) | ||
| 77 | +- Why, and what was measured: [Hooks — explanation](../explanation/hooks.md#hooks-as-webassembly-modules) | ||
added
docs/en/how-to/write-a-wasm-tool.md +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +# How to write a custom tool as a WebAssembly module | |
| 2 | + | |
| 3 | +This guide shows how to give the agent a tool that runs as a WebAssembly module inside mm, in a sandbox: the module sees no file, no environment variable and no network, except the project folders you open for it. Use it instead of a [command tool](declare-a-custom-tool.md) when the tool should not have your rights — it reads one folder, or computes something from its arguments only. It assumes [TinyGo](https://tinygo.org) is installed and that you have an agent YAML file. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Write the tool as an ordinary Go program. It reads the call's arguments as one JSON object on stdin, and what it prints on stdout or stderr is what the model gets back; exiting with a non-zero code marks the call as failed: | |
| 8 | + | |
| 9 | + ```go | |
| 10 | + // tools-src/search_notes/main.go | |
| 11 | + package main | |
| 12 | + | |
| 13 | + import ( | |
| 14 | + "encoding/json" | |
| 15 | + "fmt" | |
| 16 | + "os" | |
| 17 | + "path" | |
| 18 | + "strings" | |
| 19 | + ) | |
| 20 | + | |
| 21 | + func main() { | |
| 22 | + var in struct { | |
| 23 | + Word string `json:"word"` | |
| 24 | + } | |
| 25 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 26 | + fmt.Fprintln(os.Stderr, "bad input:", err) | |
| 27 | + os.Exit(2) | |
| 28 | + } | |
| 29 | + entries, _ := os.ReadDir("notes") | |
| 30 | + for _, e := range entries { | |
| 31 | + data, _ := os.ReadFile(path.Join("notes", e.Name())) | |
| 32 | + for i, line := range strings.Split(string(data), "\n") { | |
| 33 | + if strings.Contains(strings.ToLower(line), strings.ToLower(in.Word)) { | |
| 34 | + fmt.Printf("%s:%d: %s\n", e.Name(), i+1, line) | |
| 35 | + } | |
| 36 | + } | |
| 37 | + } | |
| 38 | + } | |
| 39 | + ``` | |
| 40 | + | |
| 41 | +2. Build it as a WASI module: | |
| 42 | + | |
| 43 | + ```bash | |
| 44 | + tinygo build -o tools/search_notes.wasm -target=wasip1 -no-debug ./tools-src/search_notes | |
| 45 | + ``` | |
| 46 | + | |
| 47 | +3. Declare it in the agent file with `wasm:` in place of `command:`, and open the folders it needs — `read:` for read-only, `write:` for read-write: | |
| 48 | + | |
| 49 | + ```yaml | |
| 50 | + tools: | |
| 51 | + - name: search_notes | |
| 52 | + description: Find the lines of the notes that mention a word. | |
| 53 | + parameters: | |
| 54 | + word: { type: string, description: "One word, e.g. wazero" } | |
| 55 | + wasm: ./tools/search_notes.wasm | |
| 56 | + read: [notes] | |
| 57 | + ``` | |
| 58 | + | |
| 59 | + The module path is relative to the agent file; the folders are relative to the working directory, and the module opens them by the same name (`notes/…`). | |
| 60 | + | |
| 61 | +4. Ask for it in your own words, from the project directory: | |
| 62 | + | |
| 63 | + ```bash | |
| 64 | + mm -p "Which notes mention TinyGo?" | |
| 65 | + ``` | |
| 66 | + | |
| 67 | + The terminal shows the module and the JSON it received, then its output: | |
| 68 | + | |
| 69 | + ``` | |
| 70 | + 🧰 search_notes: search_notes.wasm {"word":"TinyGo"} | |
| 71 | + │ tinygo.md:1: # TinyGo | |
| 72 | + │ wazero.md:4: It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | |
| 73 | + ``` | |
| 74 | + | |
| 75 | +## Variants | |
| 76 | + | |
| 77 | +- **A tool that writes.** `write: [out]` lets it create and change files in `out/` only. A folder it may only read is `read:`; a folder in neither list does not exist for it. | |
| 78 | +- **A pure computation.** Leave out `read` and `write`: the module sees no file at all. | |
| 79 | +- **Paths inside the module.** Clean them before opening: TinyGo's WASI layer refuses `./notes/a.md` and `..` — `path.Join("notes", name)` gives `notes/a.md`. A name like `../agent.yaml` then becomes `agent.yaml`, which is not mounted: the sandbox keeps it out. | |
| 80 | +- **Typed parameters, defaults.** The JSON holds each value in its declared type (`"top": 5`, `"exact": true`), the `default` of an optional parameter the call left out, and no key for an optional parameter with no default. | |
| 81 | +- **A long computation.** Add `timeout: 2m`; past it the module is stopped. Without it, `bashTimeout` applies. | |
| 82 | +- **Ask before each call.** `yolo: false` asks for a wasm tool as for a command tool. | |
| 83 | +- **Another language.** Any WASI preview 1 command module should run the same way; only TinyGo modules were tried. | |
| 84 | + | |
| 85 | +## See also | |
| 86 | + | |
| 87 | +- A complete example with four modules, a read folder and a write folder: `demos/wasm-tools/` | |
| 88 | +- Every key and error: [configuration reference](../reference/configuration.md#tools-keys) and [declared tools](../reference/tools.md#declared-tools) | |
| 89 | +- What the sandbox allows and what it costs: [Declared tools](../explanation/declared-tools.md#wasm-tools) | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +# How to write a custom tool as a WebAssembly module | ||
| 2 | + | ||
| 3 | +This guide shows how to give the agent a tool that runs as a WebAssembly module inside mm, in a sandbox: the module sees no file, no environment variable and no network, except the project folders you open for it. Use it instead of a [command tool](declare-a-custom-tool.md) when the tool should not have your rights — it reads one folder, or computes something from its arguments only. It assumes [TinyGo](https://tinygo.org) is installed and that you have an agent YAML file. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Write the tool as an ordinary Go program. It reads the call's arguments as one JSON object on stdin, and what it prints on stdout or stderr is what the model gets back; exiting with a non-zero code marks the call as failed: | ||
| 8 | + | ||
| 9 | + ```go | ||
| 10 | + // tools-src/search_notes/main.go | ||
| 11 | + package main | ||
| 12 | + | ||
| 13 | + import ( | ||
| 14 | + "encoding/json" | ||
| 15 | + "fmt" | ||
| 16 | + "os" | ||
| 17 | + "path" | ||
| 18 | + "strings" | ||
| 19 | + ) | ||
| 20 | + | ||
| 21 | + func main() { | ||
| 22 | + var in struct { | ||
| 23 | + Word string `json:"word"` | ||
| 24 | + } | ||
| 25 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 26 | + fmt.Fprintln(os.Stderr, "bad input:", err) | ||
| 27 | + os.Exit(2) | ||
| 28 | + } | ||
| 29 | + entries, _ := os.ReadDir("notes") | ||
| 30 | + for _, e := range entries { | ||
| 31 | + data, _ := os.ReadFile(path.Join("notes", e.Name())) | ||
| 32 | + for i, line := range strings.Split(string(data), "\n") { | ||
| 33 | + if strings.Contains(strings.ToLower(line), strings.ToLower(in.Word)) { | ||
| 34 | + fmt.Printf("%s:%d: %s\n", e.Name(), i+1, line) | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | + } | ||
| 39 | + ``` | ||
| 40 | + | ||
| 41 | +2. Build it as a WASI module: | ||
| 42 | + | ||
| 43 | + ```bash | ||
| 44 | + tinygo build -o tools/search_notes.wasm -target=wasip1 -no-debug ./tools-src/search_notes | ||
| 45 | + ``` | ||
| 46 | + | ||
| 47 | +3. Declare it in the agent file with `wasm:` in place of `command:`, and open the folders it needs — `read:` for read-only, `write:` for read-write: | ||
| 48 | + | ||
| 49 | + ```yaml | ||
| 50 | + tools: | ||
| 51 | + - name: search_notes | ||
| 52 | + description: Find the lines of the notes that mention a word. | ||
| 53 | + parameters: | ||
| 54 | + word: { type: string, description: "One word, e.g. wazero" } | ||
| 55 | + wasm: ./tools/search_notes.wasm | ||
| 56 | + read: [notes] | ||
| 57 | + ``` | ||
| 58 | + | ||
| 59 | + The module path is relative to the agent file; the folders are relative to the working directory, and the module opens them by the same name (`notes/…`). | ||
| 60 | + | ||
| 61 | +4. Ask for it in your own words, from the project directory: | ||
| 62 | + | ||
| 63 | + ```bash | ||
| 64 | + mm -p "Which notes mention TinyGo?" | ||
| 65 | + ``` | ||
| 66 | + | ||
| 67 | + The terminal shows the module and the JSON it received, then its output: | ||
| 68 | + | ||
| 69 | + ``` | ||
| 70 | + 🧰 search_notes: search_notes.wasm {"word":"TinyGo"} | ||
| 71 | + │ tinygo.md:1: # TinyGo | ||
| 72 | + │ wazero.md:4: It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | ||
| 73 | + ``` | ||
| 74 | + | ||
| 75 | +## Variants | ||
| 76 | + | ||
| 77 | +- **A tool that writes.** `write: [out]` lets it create and change files in `out/` only. A folder it may only read is `read:`; a folder in neither list does not exist for it. | ||
| 78 | +- **A pure computation.** Leave out `read` and `write`: the module sees no file at all. | ||
| 79 | +- **Paths inside the module.** Clean them before opening: TinyGo's WASI layer refuses `./notes/a.md` and `..` — `path.Join("notes", name)` gives `notes/a.md`. A name like `../agent.yaml` then becomes `agent.yaml`, which is not mounted: the sandbox keeps it out. | ||
| 80 | +- **Typed parameters, defaults.** The JSON holds each value in its declared type (`"top": 5`, `"exact": true`), the `default` of an optional parameter the call left out, and no key for an optional parameter with no default. | ||
| 81 | +- **A long computation.** Add `timeout: 2m`; past it the module is stopped. Without it, `bashTimeout` applies. | ||
| 82 | +- **Ask before each call.** `yolo: false` asks for a wasm tool as for a command tool. | ||
| 83 | +- **Another language.** Any WASI preview 1 command module should run the same way; only TinyGo modules were tried. | ||
| 84 | + | ||
| 85 | +## See also | ||
| 86 | + | ||
| 87 | +- A complete example with four modules, a read folder and a write folder: `demos/wasm-tools/` | ||
| 88 | +- Every key and error: [configuration reference](../reference/configuration.md#tools-keys) and [declared tools](../reference/tools.md#declared-tools) | ||
| 89 | +- What the sandbox allows and what it costs: [Declared tools](../explanation/declared-tools.md#wasm-tools) | ||
modified
docs/en/reference/configuration.md +20 -4 | @@ -38,8 +38,8 @@ | ||
| 38 | 38 | | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. | |
| 39 | 39 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | |
| 40 | 40 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | |
| 41 | -| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | | |
| 42 | -| `tools` | list | `[]` | Commands declared as typed tools: each entry a `name`, a `description`, its `parameters` and a `command` run without a shell. See [`tools` keys](#tools-keys) below and [declared tools](tools.md#declared-tools). | | |
| 41 | +| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` or `wasm` (a WebAssembly module, with its `read` / `write` folders), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | | |
| 42 | +| `tools` | list | `[]` | Commands or WebAssembly modules declared as typed tools: each entry a `name`, a `description`, its `parameters`, and a `command` run without a shell or a `wasm` module run in a sandbox. See [`tools` keys](#tools-keys) below and [declared tools](tools.md#declared-tools). | | |
| 43 | 43 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | |
| 44 | 44 | | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). | |
| 45 | 45 | | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. | |
| @@ -154,7 +154,10 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ | ||
| 154 | 154 | | `tools[].parameters.<p>.enum` | list | none | The only values accepted, each of the parameter's type. | |
| 155 | 155 | | `tools[].parameters.<p>.required` | bool | `true` | `false` makes the parameter optional. | |
| 156 | 156 | | `tools[].parameters.<p>.default` | value | none | The value of an optional parameter the call leaves out. Only with `required: false`; of the parameter's type; inside the `enum` when there is one. | |
| 157 | -| `tools[].command` | list of strings | — | The program, then its arguments, one argv entry each. Required. `{{p}}` inside an entry is replaced by the value of parameter `p`. The program (first entry) cannot contain a placeholder. Every placeholder must name a declared parameter. A relative program (`./scripts/x.sh`) is resolved from the working directory. | | |
| 157 | +| `tools[].command` | list of strings | — | The program, then its arguments, one argv entry each. Required unless `wasm` is given; not both. `{{p}}` inside an entry is replaced by the value of parameter `p`. The program (first entry) cannot contain a placeholder. Every placeholder must name a declared parameter. A relative program (`./scripts/x.sh`) is resolved from the working directory. | | |
| 158 | +| `tools[].wasm` | string | `""` | A WebAssembly module (WASI preview 1 command) run by mm instead of a command. Relative to the agent file. The file must exist at load. The call's arguments are its stdin, as one JSON object. | | |
| 159 | +| `tools[].read` | list of strings | `[]` | `wasm` only. Folders of the working directory the module may read, mounted under the same name. Relative, inside the working directory (not `.`, not above it), each listed once across `read` and `write`. | | |
| 160 | +| `tools[].write` | list of strings | `[]` | `wasm` only. Folders of the working directory the module may read and write. Same rules as `read`. | | |
| 158 | 161 | | `tools[].timeout` | duration | `0` (= `bashTimeout`) | How long one call may run. Must be ≥ 0. | |
| 159 | 162 | |
| 160 | 163 | ```yaml |
| @@ -167,6 +170,13 @@ tools: | ||
| 167 | 170 | dryRun: { type: boolean, required: false, default: false } |
| 168 | 171 | command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] |
| 169 | 172 | timeout: 60s |
| 173 | + | |
| 174 | + - name: search_notes | |
| 175 | + description: Find the lines of the notes that mention a word. | |
| 176 | + parameters: | |
| 177 | + word: { type: string } | |
| 178 | + wasm: ./tools/search_notes.wasm | |
| 179 | + read: [notes] | |
| 170 | 180 | ``` |
| 171 | 181 | |
| 172 | 182 | ## Provider defaults |
| @@ -265,7 +275,13 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev | ||
| 265 | 275 | | `tools[N] (<name>).name "<name>" is the name of a built-in tool` | a declared tool named like a built-in tool | |
| 266 | 276 | | `tools[N] (<name>): another tool is already called "<name>"` | two entries with one name | |
| 267 | 277 | | `tools[N] (<name>).description must not be empty: …` | no `description` | |
| 268 | -| `tools[N] (<name>).command must name a program, …` | no `command`, or an empty first entry | | |
| 278 | +| `tools[N] (<name>).command must name a program, …` | no `command` and no `wasm`, or an empty first entry | | |
| 279 | +| `tools[N] (<name>): give command or wasm, not both` | an entry with both | | |
| 280 | +| `tools[N] (<name>): wasm: no module at <path>` | the module file does not exist, or is a folder | | |
| 281 | +| `tools[N] (<name>): read and write only apply to a wasm tool — …` | `read` or `write` on a `command` entry | | |
| 282 | +| `tools[N] (<name>): folder "<f>" must be relative to the working directory` | an absolute folder | | |
| 283 | +| `tools[N] (<name>): folder "<f>" must be a folder inside the working directory, not it or above it` | `.`, `..` or a path that leads above | | |
| 284 | +| `tools[N] (<name>): folder "<f>" is given twice` | a folder listed twice, in `read`, `write` or both | | |
| 269 | 285 | | `tools[N] (<name>).command: the program ("{{p}}") cannot come from a parameter` | a placeholder in the first entry | |
| 270 | 286 | | `tools[N] (<name>).command: {{p}} is not a declared parameter` | a placeholder no parameter declares | |
| 271 | 287 | | `tools[N] (<name>).timeout must be >= 0` | a negative `timeout` | |
| @@ -38,8 +38,8 @@ | |||
| 38 | | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. | | 38 | | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. | |
| 39 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | | 39 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | |
| 40 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | | 40 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | |
| 41 | -| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | | 41 | +| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` or `wasm` (a WebAssembly module, with its `read` / `write` folders), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | |
| 42 | -| `tools` | list | `[]` | Commands declared as typed tools: each entry a `name`, a `description`, its `parameters` and a `command` run without a shell. See [`tools` keys](#tools-keys) below and [declared tools](tools.md#declared-tools). | | 42 | +| `tools` | list | `[]` | Commands or WebAssembly modules declared as typed tools: each entry a `name`, a `description`, its `parameters`, and a `command` run without a shell or a `wasm` module run in a sandbox. See [`tools` keys](#tools-keys) below and [declared tools](tools.md#declared-tools). | |
| 43 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | | 43 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | |
| 44 | | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). | | 44 | | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). | |
| 45 | | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. | | 45 | | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. | |
| @@ -154,7 +154,10 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ | |||
| 154 | | `tools[].parameters.<p>.enum` | list | none | The only values accepted, each of the parameter's type. | | 154 | | `tools[].parameters.<p>.enum` | list | none | The only values accepted, each of the parameter's type. | |
| 155 | | `tools[].parameters.<p>.required` | bool | `true` | `false` makes the parameter optional. | | 155 | | `tools[].parameters.<p>.required` | bool | `true` | `false` makes the parameter optional. | |
| 156 | | `tools[].parameters.<p>.default` | value | none | The value of an optional parameter the call leaves out. Only with `required: false`; of the parameter's type; inside the `enum` when there is one. | | 156 | | `tools[].parameters.<p>.default` | value | none | The value of an optional parameter the call leaves out. Only with `required: false`; of the parameter's type; inside the `enum` when there is one. | |
| 157 | -| `tools[].command` | list of strings | — | The program, then its arguments, one argv entry each. Required. `{{p}}` inside an entry is replaced by the value of parameter `p`. The program (first entry) cannot contain a placeholder. Every placeholder must name a declared parameter. A relative program (`./scripts/x.sh`) is resolved from the working directory. | | 157 | +| `tools[].command` | list of strings | — | The program, then its arguments, one argv entry each. Required unless `wasm` is given; not both. `{{p}}` inside an entry is replaced by the value of parameter `p`. The program (first entry) cannot contain a placeholder. Every placeholder must name a declared parameter. A relative program (`./scripts/x.sh`) is resolved from the working directory. | |
| 158 | +| `tools[].wasm` | string | `""` | A WebAssembly module (WASI preview 1 command) run by mm instead of a command. Relative to the agent file. The file must exist at load. The call's arguments are its stdin, as one JSON object. | | ||
| 159 | +| `tools[].read` | list of strings | `[]` | `wasm` only. Folders of the working directory the module may read, mounted under the same name. Relative, inside the working directory (not `.`, not above it), each listed once across `read` and `write`. | | ||
| 160 | +| `tools[].write` | list of strings | `[]` | `wasm` only. Folders of the working directory the module may read and write. Same rules as `read`. | | ||
| 158 | | `tools[].timeout` | duration | `0` (= `bashTimeout`) | How long one call may run. Must be ≥ 0. | | 161 | | `tools[].timeout` | duration | `0` (= `bashTimeout`) | How long one call may run. Must be ≥ 0. | |
| 159 | 162 | ||
| 160 | ```yaml | 163 | ```yaml |
| @@ -167,6 +170,13 @@ tools: | |||
| 167 | dryRun: { type: boolean, required: false, default: false } | 170 | dryRun: { type: boolean, required: false, default: false } |
| 168 | command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | 171 | command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] |
| 169 | timeout: 60s | 172 | timeout: 60s |
| 173 | + | ||
| 174 | + - name: search_notes | ||
| 175 | + description: Find the lines of the notes that mention a word. | ||
| 176 | + parameters: | ||
| 177 | + word: { type: string } | ||
| 178 | + wasm: ./tools/search_notes.wasm | ||
| 179 | + read: [notes] | ||
| 170 | ``` | 180 | ``` |
| 171 | 181 | ||
| 172 | ## Provider defaults | 182 | ## Provider defaults |
| @@ -265,7 +275,13 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev | |||
| 265 | | `tools[N] (<name>).name "<name>" is the name of a built-in tool` | a declared tool named like a built-in tool | | 275 | | `tools[N] (<name>).name "<name>" is the name of a built-in tool` | a declared tool named like a built-in tool | |
| 266 | | `tools[N] (<name>): another tool is already called "<name>"` | two entries with one name | | 276 | | `tools[N] (<name>): another tool is already called "<name>"` | two entries with one name | |
| 267 | | `tools[N] (<name>).description must not be empty: …` | no `description` | | 277 | | `tools[N] (<name>).description must not be empty: …` | no `description` | |
| 268 | -| `tools[N] (<name>).command must name a program, …` | no `command`, or an empty first entry | | 278 | +| `tools[N] (<name>).command must name a program, …` | no `command` and no `wasm`, or an empty first entry | |
| 279 | +| `tools[N] (<name>): give command or wasm, not both` | an entry with both | | ||
| 280 | +| `tools[N] (<name>): wasm: no module at <path>` | the module file does not exist, or is a folder | | ||
| 281 | +| `tools[N] (<name>): read and write only apply to a wasm tool — …` | `read` or `write` on a `command` entry | | ||
| 282 | +| `tools[N] (<name>): folder "<f>" must be relative to the working directory` | an absolute folder | | ||
| 283 | +| `tools[N] (<name>): folder "<f>" must be a folder inside the working directory, not it or above it` | `.`, `..` or a path that leads above | | ||
| 284 | +| `tools[N] (<name>): folder "<f>" is given twice` | a folder listed twice, in `read`, `write` or both | | ||
| 269 | | `tools[N] (<name>).command: the program ("{{p}}") cannot come from a parameter` | a placeholder in the first entry | | 285 | | `tools[N] (<name>).command: the program ("{{p}}") cannot come from a parameter` | a placeholder in the first entry | |
| 270 | | `tools[N] (<name>).command: {{p}} is not a declared parameter` | a placeholder no parameter declares | | 286 | | `tools[N] (<name>).command: {{p}} is not a declared parameter` | a placeholder no parameter declares | |
| 271 | | `tools[N] (<name>).timeout must be >= 0` | a negative `timeout` | | 287 | | `tools[N] (<name>).timeout must be >= 0` | a negative `timeout` | |
modified
docs/en/reference/hooks.md +34 -4 | @@ -20,7 +20,10 @@ Each event takes a list; the hooks of one event run in the order they are writte | ||
| 20 | 20 | |
| 21 | 21 | | Key | Type | Default | Description | |
| 22 | 22 | |-----|------|---------|-------------| |
| 23 | -| `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. | | |
| 23 | +| `command` | string | — | Run with `sh -c "<command>"`. Required unless `wasm` is given; not both. Must not be blank. | | |
| 24 | +| `wasm` | string | `""` | A WebAssembly module (WASI preview 1 command) run by `mm` in a sandbox instead of a command — see [Wasm hooks](#wasm-hooks). Relative to the configuration file; must exist at load. | | |
| 25 | +| `read` | list of strings | `[]` | `wasm` only. Folders of the working directory the module may read, mounted under the same name. Relative, inside the working directory (not `.`, not above it), each listed once across `read` and `write`. | | |
| 26 | +| `write` | list of strings | `[]` | `wasm` only. Folders of the working directory the module may read and write. Same rules as `read`. | | |
| 24 | 27 | | `matcher` | string (regular expression, Go syntax) | `""` (every tool or agent) | `preToolCall` and `postToolCall`: the hook runs for the tools whose name matches. `preAsk` and `postAsk`: for the asked agents whose name (its key in `mmConfigs`) matches. Not allowed on `preGeneration` and `postGeneration`. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. | |
| 25 | 28 | | `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. | |
| 26 | 29 | | `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). | |
| @@ -39,6 +42,10 @@ hooks: | ||
| 39 | 42 | postGeneration: |
| 40 | 43 | - command: python3 "$MM_CONFIG_DIR/hooks/log.py" |
| 41 | 44 | onError: ignore |
| 45 | + postToolCall: | |
| 46 | + - wasm: ./hooks/audit.wasm | |
| 47 | + write: [logs] | |
| 48 | + onError: ignore | |
| 42 | 49 | ``` |
| 43 | 50 | |
| 44 | 51 | ## Events |
| @@ -77,6 +84,20 @@ On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preA | ||
| 77 | 84 | |
| 78 | 85 | `mm`'s own environment also holds `MM_BIN` (the path of the running `mm`) and `MM_CONFIG` (its configuration file, when one was read), which `mm` sets for every process it starts — see the [command line reference](cli.md#environment-variables). |
| 79 | 86 | |
| 87 | +### Wasm hooks | |
| 88 | + | |
| 89 | +An entry with `wasm:` instead of `command:` reads the same [input](#input), writes the same [reply](#reply) and follows the same `matcher`, `timeout` and `onError`. These rows replace the ones above: | |
| 90 | + | |
| 91 | +| Aspect | Value | | |
| 92 | +|--------|-------| | |
| 93 | +| Module | a fresh instance of the module, run by `mm` (wazero); no process is started. Compiled once per `mm` process, on its first run — shared with the [wasm tools](tools.md#wasm-tools) | | |
| 94 | +| Environment | `MM_HOOK_EVENT` only. Not `MM_CONFIG_DIR`, not `mm`'s own variables | | |
| 95 | +| Files | only the `read` folders (read-only) and the `write` folders (read-write), in the session's working directory, mounted under their own name (`logs` is `logs/…` or `/logs/…` inside the module); any other path does not exist | | |
| 96 | +| Network | none (WASI preview 1 has none) | | |
| 97 | +| Clock, randomness | the real clock and sleep; the system's secure random source | | |
| 98 | +| Memory | at most 256 MiB of linear memory | | |
| 99 | +| stdout, stderr | as for a command: the reply at most 1 MiB, the first 300 bytes of stderr quoted when the run fails | | |
| 100 | + | |
| 80 | 101 | ## Input |
| 81 | 102 | |
| 82 | 103 | The JSON object written on the hook's stdin. |
| @@ -211,6 +232,9 @@ A run fails when: | ||
| 211 | 232 | | Failure | Report | |
| 212 | 233 | |---------|--------| |
| 213 | 234 | | the command exits with a non-zero status | `exit status <n>: <stderr>` | |
| 235 | +| a wasm module exits with a non-zero code | `exit code <n>: <stderr>` | | |
| 236 | +| a wasm module cannot be loaded | `<file>.wasm cannot be loaded: <reason>` | | |
| 237 | +| one of a wasm module's folders does not exist | `the folder <f> this module may see does not exist in <dir>` | | |
| 214 | 238 | | it runs longer than `timeout` | `timed out after <timeout>` | |
| 215 | 239 | | the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` | |
| 216 | 240 | | stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` | |
| @@ -228,7 +252,7 @@ What follows depends on `onError`: | ||
| 228 | 252 | | `warn` | report line, next hook | report line, next hook | report line, carry on | |
| 229 | 253 | | `ignore` | next hook, silently | next hook, silently | carry on, silently | |
| 230 | 254 | |
| 231 | -The report line is `🪝 <event> hook \`<command>\`: <failure>`: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP, on stderr with `mm --ask`. | |
| 255 | +The report line is `🪝 <event> hook \`<command>\`: <failure>` — for a wasm hook, the module's file name in place of the command: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP, on stderr with `mm --ask`. | |
| 232 | 256 | |
| 233 | 257 | ## Display |
| 234 | 258 | |
| @@ -242,7 +266,7 @@ With `displayHooks: true`, every hook run is shown — a hook that works is othe | ||
| 242 | 266 | |------|-------| |
| 243 | 267 | | `<n>` | the run's number in the turn, from 1 (line mode, `-p`, window) | |
| 244 | 268 | | `<tool or agent>` | the tool's name (`preToolCall`, `postToolCall`) or the asked agent's `mmConfigs` name (`preAsk`, `postAsk`); absent on `preGeneration` and `postGeneration` | |
| 245 | -| `<command>` | the hook's `command`, as written in the configuration | | |
| 269 | +| `<command>` | the hook's `command`, as written in the configuration; for a wasm hook, the module's file name (`bash_guard.wasm`) | | |
| 246 | 270 | | `<decision>` | `continue`, `stop`, `deny`, `failed` (the run failed; its `onError` decided what followed), or what the reply changed, joined by ` + `: `prompt replaced`, `transcript replaced`, `answer replaced`, `input replaced`, `context (<n> chars)` | |
| 247 | 271 | |
| 248 | 272 | Only the fields the event reads count: a `context` returned to `postGeneration` is shown as `continue`. The text of a context is never shown, only its length in characters. |
| @@ -277,7 +301,13 @@ Reported at start-up as `[config error: <path>: <message>]`, exit code 1. | ||
| 277 | 301 | | Message | Cause | |
| 278 | 302 | |---------|-------| |
| 279 | 303 | | `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) | |
| 280 | -| `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one | | |
| 304 | +| `hooks.<event>[N]: command must not be empty (or wasm: a module)` | no `command` and no `wasm`, or a blank `command` | | |
| 305 | +| `hooks.<event>[N]: give command or wasm, not both` | an entry with both | | |
| 306 | +| `hooks.<event>[N]: wasm: no module at <path>` | the module file does not exist, or is a folder | | |
| 307 | +| `hooks.<event>[N]: read and write only apply to a wasm hook — …` | `read` or `write` on a `command` entry | | |
| 308 | +| `hooks.<event>[N]: folder "<f>" must be relative to the working directory` | an absolute folder | | |
| 309 | +| `hooks.<event>[N]: folder "<f>" must be a folder inside the working directory, not it or above it` | `.`, `..` or a path that leads above | | |
| 310 | +| `hooks.<event>[N]: folder "<f>" is given twice` | a folder listed twice | | |
| 281 | 311 | | `hooks.<event>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | a `matcher` on `preGeneration` or `postGeneration` | |
| 282 | 312 | | `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile | |
| 283 | 313 | | `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` | |
| @@ -20,7 +20,10 @@ Each event takes a list; the hooks of one event run in the order they are writte | |||
| 20 | 20 | ||
| 21 | | Key | Type | Default | Description | | 21 | | Key | Type | Default | Description | |
| 22 | |-----|------|---------|-------------| | 22 | |-----|------|---------|-------------| |
| 23 | -| `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. | | 23 | +| `command` | string | — | Run with `sh -c "<command>"`. Required unless `wasm` is given; not both. Must not be blank. | |
| 24 | +| `wasm` | string | `""` | A WebAssembly module (WASI preview 1 command) run by `mm` in a sandbox instead of a command — see [Wasm hooks](#wasm-hooks). Relative to the configuration file; must exist at load. | | ||
| 25 | +| `read` | list of strings | `[]` | `wasm` only. Folders of the working directory the module may read, mounted under the same name. Relative, inside the working directory (not `.`, not above it), each listed once across `read` and `write`. | | ||
| 26 | +| `write` | list of strings | `[]` | `wasm` only. Folders of the working directory the module may read and write. Same rules as `read`. | | ||
| 24 | | `matcher` | string (regular expression, Go syntax) | `""` (every tool or agent) | `preToolCall` and `postToolCall`: the hook runs for the tools whose name matches. `preAsk` and `postAsk`: for the asked agents whose name (its key in `mmConfigs`) matches. Not allowed on `preGeneration` and `postGeneration`. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. | | 27 | | `matcher` | string (regular expression, Go syntax) | `""` (every tool or agent) | `preToolCall` and `postToolCall`: the hook runs for the tools whose name matches. `preAsk` and `postAsk`: for the asked agents whose name (its key in `mmConfigs`) matches. Not allowed on `preGeneration` and `postGeneration`. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. | |
| 25 | | `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. | | 28 | | `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. | |
| 26 | | `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). | | 29 | | `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). | |
| @@ -39,6 +42,10 @@ hooks: | |||
| 39 | postGeneration: | 42 | postGeneration: |
| 40 | - command: python3 "$MM_CONFIG_DIR/hooks/log.py" | 43 | - command: python3 "$MM_CONFIG_DIR/hooks/log.py" |
| 41 | onError: ignore | 44 | onError: ignore |
| 45 | + postToolCall: | ||
| 46 | + - wasm: ./hooks/audit.wasm | ||
| 47 | + write: [logs] | ||
| 48 | + onError: ignore | ||
| 42 | ``` | 49 | ``` |
| 43 | 50 | ||
| 44 | ## Events | 51 | ## Events |
| @@ -77,6 +84,20 @@ On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preA | |||
| 77 | 84 | ||
| 78 | `mm`'s own environment also holds `MM_BIN` (the path of the running `mm`) and `MM_CONFIG` (its configuration file, when one was read), which `mm` sets for every process it starts — see the [command line reference](cli.md#environment-variables). | 85 | `mm`'s own environment also holds `MM_BIN` (the path of the running `mm`) and `MM_CONFIG` (its configuration file, when one was read), which `mm` sets for every process it starts — see the [command line reference](cli.md#environment-variables). |
| 79 | 86 | ||
| 87 | +### Wasm hooks | ||
| 88 | + | ||
| 89 | +An entry with `wasm:` instead of `command:` reads the same [input](#input), writes the same [reply](#reply) and follows the same `matcher`, `timeout` and `onError`. These rows replace the ones above: | ||
| 90 | + | ||
| 91 | +| Aspect | Value | | ||
| 92 | +|--------|-------| | ||
| 93 | +| Module | a fresh instance of the module, run by `mm` (wazero); no process is started. Compiled once per `mm` process, on its first run — shared with the [wasm tools](tools.md#wasm-tools) | | ||
| 94 | +| Environment | `MM_HOOK_EVENT` only. Not `MM_CONFIG_DIR`, not `mm`'s own variables | | ||
| 95 | +| Files | only the `read` folders (read-only) and the `write` folders (read-write), in the session's working directory, mounted under their own name (`logs` is `logs/…` or `/logs/…` inside the module); any other path does not exist | | ||
| 96 | +| Network | none (WASI preview 1 has none) | | ||
| 97 | +| Clock, randomness | the real clock and sleep; the system's secure random source | | ||
| 98 | +| Memory | at most 256 MiB of linear memory | | ||
| 99 | +| stdout, stderr | as for a command: the reply at most 1 MiB, the first 300 bytes of stderr quoted when the run fails | | ||
| 100 | + | ||
| 80 | ## Input | 101 | ## Input |
| 81 | 102 | ||
| 82 | The JSON object written on the hook's stdin. | 103 | The JSON object written on the hook's stdin. |
| @@ -211,6 +232,9 @@ A run fails when: | |||
| 211 | | Failure | Report | | 232 | | Failure | Report | |
| 212 | |---------|--------| | 233 | |---------|--------| |
| 213 | | the command exits with a non-zero status | `exit status <n>: <stderr>` | | 234 | | the command exits with a non-zero status | `exit status <n>: <stderr>` | |
| 235 | +| a wasm module exits with a non-zero code | `exit code <n>: <stderr>` | | ||
| 236 | +| a wasm module cannot be loaded | `<file>.wasm cannot be loaded: <reason>` | | ||
| 237 | +| one of a wasm module's folders does not exist | `the folder <f> this module may see does not exist in <dir>` | | ||
| 214 | | it runs longer than `timeout` | `timed out after <timeout>` | | 238 | | it runs longer than `timeout` | `timed out after <timeout>` | |
| 215 | | the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` | | 239 | | the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` | |
| 216 | | stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` | | 240 | | stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` | |
| @@ -228,7 +252,7 @@ What follows depends on `onError`: | |||
| 228 | | `warn` | report line, next hook | report line, next hook | report line, carry on | | 252 | | `warn` | report line, next hook | report line, next hook | report line, carry on | |
| 229 | | `ignore` | next hook, silently | next hook, silently | carry on, silently | | 253 | | `ignore` | next hook, silently | next hook, silently | carry on, silently | |
| 230 | 254 | ||
| 231 | -The report line is `🪝 <event> hook \`<command>\`: <failure>`: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP, on stderr with `mm --ask`. | 255 | +The report line is `🪝 <event> hook \`<command>\`: <failure>` — for a wasm hook, the module's file name in place of the command: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP, on stderr with `mm --ask`. |
| 232 | 256 | ||
| 233 | ## Display | 257 | ## Display |
| 234 | 258 | ||
| @@ -242,7 +266,7 @@ With `displayHooks: true`, every hook run is shown — a hook that works is othe | |||
| 242 | |------|-------| | 266 | |------|-------| |
| 243 | | `<n>` | the run's number in the turn, from 1 (line mode, `-p`, window) | | 267 | | `<n>` | the run's number in the turn, from 1 (line mode, `-p`, window) | |
| 244 | | `<tool or agent>` | the tool's name (`preToolCall`, `postToolCall`) or the asked agent's `mmConfigs` name (`preAsk`, `postAsk`); absent on `preGeneration` and `postGeneration` | | 268 | | `<tool or agent>` | the tool's name (`preToolCall`, `postToolCall`) or the asked agent's `mmConfigs` name (`preAsk`, `postAsk`); absent on `preGeneration` and `postGeneration` | |
| 245 | -| `<command>` | the hook's `command`, as written in the configuration | | 269 | +| `<command>` | the hook's `command`, as written in the configuration; for a wasm hook, the module's file name (`bash_guard.wasm`) | |
| 246 | | `<decision>` | `continue`, `stop`, `deny`, `failed` (the run failed; its `onError` decided what followed), or what the reply changed, joined by ` + `: `prompt replaced`, `transcript replaced`, `answer replaced`, `input replaced`, `context (<n> chars)` | | 270 | | `<decision>` | `continue`, `stop`, `deny`, `failed` (the run failed; its `onError` decided what followed), or what the reply changed, joined by ` + `: `prompt replaced`, `transcript replaced`, `answer replaced`, `input replaced`, `context (<n> chars)` | |
| 247 | 271 | ||
| 248 | Only the fields the event reads count: a `context` returned to `postGeneration` is shown as `continue`. The text of a context is never shown, only its length in characters. | 272 | Only the fields the event reads count: a `context` returned to `postGeneration` is shown as `continue`. The text of a context is never shown, only its length in characters. |
| @@ -277,7 +301,13 @@ Reported at start-up as `[config error: <path>: <message>]`, exit code 1. | |||
| 277 | | Message | Cause | | 301 | | Message | Cause | |
| 278 | |---------|-------| | 302 | |---------|-------| |
| 279 | | `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) | | 303 | | `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) | |
| 280 | -| `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one | | 304 | +| `hooks.<event>[N]: command must not be empty (or wasm: a module)` | no `command` and no `wasm`, or a blank `command` | |
| 305 | +| `hooks.<event>[N]: give command or wasm, not both` | an entry with both | | ||
| 306 | +| `hooks.<event>[N]: wasm: no module at <path>` | the module file does not exist, or is a folder | | ||
| 307 | +| `hooks.<event>[N]: read and write only apply to a wasm hook — …` | `read` or `write` on a `command` entry | | ||
| 308 | +| `hooks.<event>[N]: folder "<f>" must be relative to the working directory` | an absolute folder | | ||
| 309 | +| `hooks.<event>[N]: folder "<f>" must be a folder inside the working directory, not it or above it` | `.`, `..` or a path that leads above | | ||
| 310 | +| `hooks.<event>[N]: folder "<f>" is given twice` | a folder listed twice | | ||
| 281 | | `hooks.<event>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | a `matcher` on `preGeneration` or `postGeneration` | | 311 | | `hooks.<event>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | a `matcher` on `preGeneration` or `postGeneration` | |
| 282 | | `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile | | 312 | | `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile | |
| 283 | | `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` | | 313 | | `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` | |
modified
docs/en/reference/tools.md +21 -0 | @@ -382,6 +382,27 @@ Declared from the `tools:` block of the agent file, one tool per entry (see [con | ||
| 382 | 382 | | Recap | Not counted in `⚙ N command(s)`. | |
| 383 | 383 | | Hooks | `preToolCall` and `postToolCall` apply, with the tool's name as `matcher`. | |
| 384 | 384 | |
| 385 | +### Wasm tools | |
| 386 | + | |
| 387 | +An entry with `wasm:` instead of `command:` keeps every row above except those about the command; these rows replace them. | |
| 388 | + | |
| 389 | +| Aspect | Behaviour | | |
| 390 | +|--------|-----------| | |
| 391 | +| Execution | A fresh instance of the module, run by mm (wazero) as a WASI preview 1 command; no process is started. The module is compiled once per mm process, on its first call. | | |
| 392 | +| Input | One JSON object on stdin: each parameter given or defaulted, in its declared type (`"top": 5`, `"exact": true`); no key for an optional parameter left out with no default. Arguments: the program name only (the module's file name without `.wasm`). | | |
| 393 | +| Output | stdout and stderr, in the order written, kept up to 1 MiB; past it the output ends with `[output cut at 1 MiB]`. Then as `bash` (truncation, echo). | | |
| 394 | +| Exit | `0` (or returning from `main`) is a success; another code appends `[exit code: <n>]` and marks the call failed. | | |
| 395 | +| Files | Only the `read` folders (read-only) and the `write` folders (read-write), mounted under their own name: `notes` is `notes/…` or `/notes/…` inside the module. Any other path does not exist. The folders are taken in the working directory (row above). | | |
| 396 | +| Environment, network | No environment variable. No network (WASI preview 1 has none). | | |
| 397 | +| Clock, randomness | The real clock and sleep; randomness from the system's secure source. | | |
| 398 | +| Memory | At most 256 MiB of linear memory. | | |
| 399 | +| Timeout, cancellation | The entry's `timeout`, else `bashTimeout`: the module is stopped and the output ends with `[error: <timeout> timeout exceeded]`; a cancelled turn ends it with `[error: cancelled]`. | | |
| 400 | +| Missing folder | `[error: the folder <f> this tool may see does not exist in <dir>]`; nothing runs. | | |
| 401 | +| Module that cannot be loaded | `[error: <file>.wasm cannot be loaded: <reason>]`. | | |
| 402 | +| Terminal display | `🧰 <name>: <file>.wasm <input JSON>`. | | |
| 403 | +| ACP and window | Tool call of kind `execute`, title `<name>: <file>.wasm <input JSON>`. | | |
| 404 | +| Concurrency | Calls of one module may run at once, each in its own instance; no state survives a call. | | |
| 405 | + | |
| 385 | 406 | ## Loop detection |
| 386 | 407 | |
| 387 | 408 | Every tool records `(tool, input, output)` in the loop detector after each call. |
| @@ -382,6 +382,27 @@ Declared from the `tools:` block of the agent file, one tool per entry (see [con | |||
| 382 | | Recap | Not counted in `⚙ N command(s)`. | | 382 | | Recap | Not counted in `⚙ N command(s)`. | |
| 383 | | Hooks | `preToolCall` and `postToolCall` apply, with the tool's name as `matcher`. | | 383 | | Hooks | `preToolCall` and `postToolCall` apply, with the tool's name as `matcher`. | |
| 384 | 384 | ||
| 385 | +### Wasm tools | ||
| 386 | + | ||
| 387 | +An entry with `wasm:` instead of `command:` keeps every row above except those about the command; these rows replace them. | ||
| 388 | + | ||
| 389 | +| Aspect | Behaviour | | ||
| 390 | +|--------|-----------| | ||
| 391 | +| Execution | A fresh instance of the module, run by mm (wazero) as a WASI preview 1 command; no process is started. The module is compiled once per mm process, on its first call. | | ||
| 392 | +| Input | One JSON object on stdin: each parameter given or defaulted, in its declared type (`"top": 5`, `"exact": true`); no key for an optional parameter left out with no default. Arguments: the program name only (the module's file name without `.wasm`). | | ||
| 393 | +| Output | stdout and stderr, in the order written, kept up to 1 MiB; past it the output ends with `[output cut at 1 MiB]`. Then as `bash` (truncation, echo). | | ||
| 394 | +| Exit | `0` (or returning from `main`) is a success; another code appends `[exit code: <n>]` and marks the call failed. | | ||
| 395 | +| Files | Only the `read` folders (read-only) and the `write` folders (read-write), mounted under their own name: `notes` is `notes/…` or `/notes/…` inside the module. Any other path does not exist. The folders are taken in the working directory (row above). | | ||
| 396 | +| Environment, network | No environment variable. No network (WASI preview 1 has none). | | ||
| 397 | +| Clock, randomness | The real clock and sleep; randomness from the system's secure source. | | ||
| 398 | +| Memory | At most 256 MiB of linear memory. | | ||
| 399 | +| Timeout, cancellation | The entry's `timeout`, else `bashTimeout`: the module is stopped and the output ends with `[error: <timeout> timeout exceeded]`; a cancelled turn ends it with `[error: cancelled]`. | | ||
| 400 | +| Missing folder | `[error: the folder <f> this tool may see does not exist in <dir>]`; nothing runs. | | ||
| 401 | +| Module that cannot be loaded | `[error: <file>.wasm cannot be loaded: <reason>]`. | | ||
| 402 | +| Terminal display | `🧰 <name>: <file>.wasm <input JSON>`. | | ||
| 403 | +| ACP and window | Tool call of kind `execute`, title `<name>: <file>.wasm <input JSON>`. | | ||
| 404 | +| Concurrency | Calls of one module may run at once, each in its own instance; no state survives a call. | | ||
| 405 | + | ||
| 385 | ## Loop detection | 406 | ## Loop detection |
| 386 | 407 | ||
| 387 | Every tool records `(tool, input, output)` in the loop detector after each call. | 408 | Every tool records `(tool, input, output)` in the loop detector after each call. |
modified
docs/fr/README.md +2 -0 | @@ -28,6 +28,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 28 | 28 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur |
| 29 | 29 | - [Ajouter un skill](how-to/add-a-skill.md) |
| 30 | 30 | - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md) |
| 31 | +- [Écrire un outil personnalisé sous forme de module WebAssembly](how-to/write-a-wasm-tool.md) | |
| 31 | 32 | - [Gérer des plans et des tâches](how-to/manage-plans-and-tasks.md) — l'outil `plan` : donner une liste, suivre la ligne de progression `📋`, des plans par conversation |
| 32 | 33 | - [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md) |
| 33 | 34 | - [Chercher dans un dossier de documentation](how-to/search-the-docs.md) — `/docs <mots>`, l'outil `search_docs`, `docSearch` (BM25, sans modèle) |
| @@ -44,6 +45,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 44 | 45 | - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53 |
| 45 | 46 | - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher |
| 46 | 47 | - [Protéger l'agent avec des hooks](how-to/guard-the-agent-with-hooks.md) — refuser un appel d'outil, arrêter ou réécrire une question, ajouter du contexte, journaliser chaque tour, avec un script dans n'importe quel langage |
| 48 | +- [Écrire un hook sous forme de module WebAssembly](how-to/write-a-wasm-hook.md) — un hook exécuté par mm dans un bac à sable : rien à installer, seulement les dossiers qu'on lui donne | |
| 47 | 49 | - [Contrôler les relais vers d'autres agents avec des hooks](how-to/control-agent-calls-with-hooks.md) — refuser, réécrire ou masquer ce qu'un agent envoie à un autre, vérifier ou remplacer ce qui revient (`preAsk`, `postAsk`) |
| 48 | 50 | - [Filtrer les questions avec des modèles de garde](how-to/screen-questions-with-guard-models.md) — un agent de garde par risque (Granite Guardian), un hook `preGeneration` qui les interroge sans l'historique, un assistant qui réagit à leurs verdicts ; les démos dans `demos/guard/` et `demos/guards/` |
| 49 | 51 | |
| @@ -28,6 +28,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 28 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur | 28 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur |
| 29 | - [Ajouter un skill](how-to/add-a-skill.md) | 29 | - [Ajouter un skill](how-to/add-a-skill.md) |
| 30 | - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md) | 30 | - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md) |
| 31 | +- [Écrire un outil personnalisé sous forme de module WebAssembly](how-to/write-a-wasm-tool.md) | ||
| 31 | - [Gérer des plans et des tâches](how-to/manage-plans-and-tasks.md) — l'outil `plan` : donner une liste, suivre la ligne de progression `📋`, des plans par conversation | 32 | - [Gérer des plans et des tâches](how-to/manage-plans-and-tasks.md) — l'outil `plan` : donner une liste, suivre la ligne de progression `📋`, des plans par conversation |
| 32 | - [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md) | 33 | - [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md) |
| 33 | - [Chercher dans un dossier de documentation](how-to/search-the-docs.md) — `/docs <mots>`, l'outil `search_docs`, `docSearch` (BM25, sans modèle) | 34 | - [Chercher dans un dossier de documentation](how-to/search-the-docs.md) — `/docs <mots>`, l'outil `search_docs`, `docSearch` (BM25, sans modèle) |
| @@ -44,6 +45,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 44 | - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53 | 45 | - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53 |
| 45 | - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher | 46 | - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher |
| 46 | - [Protéger l'agent avec des hooks](how-to/guard-the-agent-with-hooks.md) — refuser un appel d'outil, arrêter ou réécrire une question, ajouter du contexte, journaliser chaque tour, avec un script dans n'importe quel langage | 47 | - [Protéger l'agent avec des hooks](how-to/guard-the-agent-with-hooks.md) — refuser un appel d'outil, arrêter ou réécrire une question, ajouter du contexte, journaliser chaque tour, avec un script dans n'importe quel langage |
| 48 | +- [Écrire un hook sous forme de module WebAssembly](how-to/write-a-wasm-hook.md) — un hook exécuté par mm dans un bac à sable : rien à installer, seulement les dossiers qu'on lui donne | ||
| 47 | - [Contrôler les relais vers d'autres agents avec des hooks](how-to/control-agent-calls-with-hooks.md) — refuser, réécrire ou masquer ce qu'un agent envoie à un autre, vérifier ou remplacer ce qui revient (`preAsk`, `postAsk`) | 49 | - [Contrôler les relais vers d'autres agents avec des hooks](how-to/control-agent-calls-with-hooks.md) — refuser, réécrire ou masquer ce qu'un agent envoie à un autre, vérifier ou remplacer ce qui revient (`preAsk`, `postAsk`) |
| 48 | - [Filtrer les questions avec des modèles de garde](how-to/screen-questions-with-guard-models.md) — un agent de garde par risque (Granite Guardian), un hook `preGeneration` qui les interroge sans l'historique, un assistant qui réagit à leurs verdicts ; les démos dans `demos/guard/` et `demos/guards/` | 50 | - [Filtrer les questions avec des modèles de garde](how-to/screen-questions-with-guard-models.md) — un agent de garde par risque (Granite Guardian), un hook `preGeneration` qui les interroge sans l'historique, un assistant qui réagit à leurs verdicts ; les démos dans `demos/guard/` et `demos/guards/` |
| 49 | 51 | ||
modified
docs/fr/explanation/declared-tools.md +13 -1 | @@ -43,11 +43,23 @@ Ces chiffres viennent d'un seul petit projet de test, de deux modèles et de cin | ||
| 43 | 43 | |
| 44 | 44 | **Les noms sont réservés tôt.** Un outil déclaré ne peut pas prendre le nom d'un outil intégré, même désactivé, pour que l'activer plus tard ne casse pas le fichier. Un conflit avec un outil MCP ne se voit qu'une fois le serveur interrogé : c'est donc un avertissement au démarrage, pas une erreur. |
| 45 | 45 | |
| 46 | +## Outils wasm | |
| 47 | + | |
| 48 | +Un outil commande s'exécute avec tous les droits de l'utilisateur : l'auteur de l'agent a écrit la commande, mais quoi qu'elle fasse, elle peut le faire partout. Un outil qui doit seulement lire un dossier, ou calculer quelque chose à partir de ses arguments, n'en a pas besoin. Une entrée `wasm:` (ticket 119) exécute à la place un module WebAssembly dans mm, avec [wazero](https://wazero.io), et le module part de **rien** : aucun fichier, aucune variable d'environnement, aucun réseau. L'entrée lui donne ce dont il a besoin, dossier par dossier — `read:` ou `write:` — et cette liste est tout son monde. Une valeur du modèle, même plantée par une injection de prompt, ne peut pas aller au-delà : `read_note` appelé avec `../agent.yaml` dans la démo ne trouve aucun fichier de ce nom, parce que rien au-dessus de `notes/` n'est là pour être trouvé. | |
| 49 | + | |
| 50 | +**Pourquoi une commande WASI, avec du JSON sur stdin.** Le module est un programme ordinaire avec un `main` — ce que TinyGo construit avec `-target=wasip1` — donc en écrire un ne demande aucun SDK ni aucune fonction exportée à apprendre. Les arguments arrivent en un seul objet JSON, déjà vérifié et typé par mm, la forme qu'utilisent déjà les hooks ; un gabarit argv comme `command:` était l'autre choix, écarté parce qu'il remet chaque valeur en texte que le module doit analyser à nouveau. Ce contrat est celui sur lequel compte l'auteur d'un module : il est fait pour durer. | |
| 51 | + | |
| 52 | +**Pourquoi wazero.** Il est écrit en Go pur : mm reste un seul binaire statique, sans CGO, sur toutes les cibles de release, sans rien à installer à côté. Le prix est la taille : le binaire mm est passé de 38,97 Mo à 42,87 Mo (linux/arm64). | |
| 53 | + | |
| 54 | +**Ce que ça coûte à l'exécution**, mesuré dans le sandbox où il a été construit (linux/arm64) : compiler un module TinyGo d'environ 550 Ko a pris 176 à 183 ms, une fois par processus mm, à son premier appel ; chaque appel suivant, une instance neuve, a pris 0,2 à 0,5 ms. À titre de comparaison, le ticket 46 a mesuré un hook Node.js à environ 62 ms par appel. Une instance neuve par appel signifie qu'aucun état ne survit entre deux appels, et que des appels peuvent se chevaucher. | |
| 55 | + | |
| 56 | +**Ce qu'il ne fait pas.** WASI preview 1 n'a pas de réseau, donc un outil qui appelle une API reste une commande (ou un outil MCP). Le consentement est le même que pour une commande (`yolo`) : le bac à sable limite ce qu'un module peut toucher, mais la décision de l'exécuter reste celle de l'utilisateur. Seuls des modules TinyGo ont été essayés. | |
| 57 | + | |
| 46 | 58 | ## Alternatives écartées |
| 47 | 59 | |
| 48 | 60 | - **Seulement des skills.** Moins chers à écrire et adaptés aux procédures, mais mesurés en échec là où ça compte, sur les petits modèles que vise mm. |
| 49 | 61 | - **Seulement MCP.** Donne déjà des outils typés, mais chacun demande un serveur à écrire, à lancer et à maintenir, en HTTP seulement (mm n'a pas de transport MCP stdio). Trop lourd pour « lancer ce script avec deux arguments ». |
| 50 | -- **Un système de plugins Wasm** (ticket 46). Des outils isolés sont une autre question ; un outil déclaré s'exécute avec les droits de l'utilisateur, comme `bash`. | |
| 62 | +- **Un système général de plugins Wasm** (ticket 46) — hooks, fournisseurs, n'importe quoi en plugin. Seuls les outils déclarés sont devenus Wasm (ticket 119, ci-dessus) : une entrée, un module, un contrat documenté. Les hooks en modules Wasm y restent une idée ouverte. | |
| 51 | 63 | |
| 52 | 64 | ## Liens avec le reste |
| 53 | 65 | |
| @@ -43,11 +43,23 @@ Ces chiffres viennent d'un seul petit projet de test, de deux modèles et de cin | |||
| 43 | 43 | ||
| 44 | **Les noms sont réservés tôt.** Un outil déclaré ne peut pas prendre le nom d'un outil intégré, même désactivé, pour que l'activer plus tard ne casse pas le fichier. Un conflit avec un outil MCP ne se voit qu'une fois le serveur interrogé : c'est donc un avertissement au démarrage, pas une erreur. | 44 | **Les noms sont réservés tôt.** Un outil déclaré ne peut pas prendre le nom d'un outil intégré, même désactivé, pour que l'activer plus tard ne casse pas le fichier. Un conflit avec un outil MCP ne se voit qu'une fois le serveur interrogé : c'est donc un avertissement au démarrage, pas une erreur. |
| 45 | 45 | ||
| 46 | +## Outils wasm | ||
| 47 | + | ||
| 48 | +Un outil commande s'exécute avec tous les droits de l'utilisateur : l'auteur de l'agent a écrit la commande, mais quoi qu'elle fasse, elle peut le faire partout. Un outil qui doit seulement lire un dossier, ou calculer quelque chose à partir de ses arguments, n'en a pas besoin. Une entrée `wasm:` (ticket 119) exécute à la place un module WebAssembly dans mm, avec [wazero](https://wazero.io), et le module part de **rien** : aucun fichier, aucune variable d'environnement, aucun réseau. L'entrée lui donne ce dont il a besoin, dossier par dossier — `read:` ou `write:` — et cette liste est tout son monde. Une valeur du modèle, même plantée par une injection de prompt, ne peut pas aller au-delà : `read_note` appelé avec `../agent.yaml` dans la démo ne trouve aucun fichier de ce nom, parce que rien au-dessus de `notes/` n'est là pour être trouvé. | ||
| 49 | + | ||
| 50 | +**Pourquoi une commande WASI, avec du JSON sur stdin.** Le module est un programme ordinaire avec un `main` — ce que TinyGo construit avec `-target=wasip1` — donc en écrire un ne demande aucun SDK ni aucune fonction exportée à apprendre. Les arguments arrivent en un seul objet JSON, déjà vérifié et typé par mm, la forme qu'utilisent déjà les hooks ; un gabarit argv comme `command:` était l'autre choix, écarté parce qu'il remet chaque valeur en texte que le module doit analyser à nouveau. Ce contrat est celui sur lequel compte l'auteur d'un module : il est fait pour durer. | ||
| 51 | + | ||
| 52 | +**Pourquoi wazero.** Il est écrit en Go pur : mm reste un seul binaire statique, sans CGO, sur toutes les cibles de release, sans rien à installer à côté. Le prix est la taille : le binaire mm est passé de 38,97 Mo à 42,87 Mo (linux/arm64). | ||
| 53 | + | ||
| 54 | +**Ce que ça coûte à l'exécution**, mesuré dans le sandbox où il a été construit (linux/arm64) : compiler un module TinyGo d'environ 550 Ko a pris 176 à 183 ms, une fois par processus mm, à son premier appel ; chaque appel suivant, une instance neuve, a pris 0,2 à 0,5 ms. À titre de comparaison, le ticket 46 a mesuré un hook Node.js à environ 62 ms par appel. Une instance neuve par appel signifie qu'aucun état ne survit entre deux appels, et que des appels peuvent se chevaucher. | ||
| 55 | + | ||
| 56 | +**Ce qu'il ne fait pas.** WASI preview 1 n'a pas de réseau, donc un outil qui appelle une API reste une commande (ou un outil MCP). Le consentement est le même que pour une commande (`yolo`) : le bac à sable limite ce qu'un module peut toucher, mais la décision de l'exécuter reste celle de l'utilisateur. Seuls des modules TinyGo ont été essayés. | ||
| 57 | + | ||
| 46 | ## Alternatives écartées | 58 | ## Alternatives écartées |
| 47 | 59 | ||
| 48 | - **Seulement des skills.** Moins chers à écrire et adaptés aux procédures, mais mesurés en échec là où ça compte, sur les petits modèles que vise mm. | 60 | - **Seulement des skills.** Moins chers à écrire et adaptés aux procédures, mais mesurés en échec là où ça compte, sur les petits modèles que vise mm. |
| 49 | - **Seulement MCP.** Donne déjà des outils typés, mais chacun demande un serveur à écrire, à lancer et à maintenir, en HTTP seulement (mm n'a pas de transport MCP stdio). Trop lourd pour « lancer ce script avec deux arguments ». | 61 | - **Seulement MCP.** Donne déjà des outils typés, mais chacun demande un serveur à écrire, à lancer et à maintenir, en HTTP seulement (mm n'a pas de transport MCP stdio). Trop lourd pour « lancer ce script avec deux arguments ». |
| 50 | -- **Un système de plugins Wasm** (ticket 46). Des outils isolés sont une autre question ; un outil déclaré s'exécute avec les droits de l'utilisateur, comme `bash`. | 62 | +- **Un système général de plugins Wasm** (ticket 46) — hooks, fournisseurs, n'importe quoi en plugin. Seuls les outils déclarés sont devenus Wasm (ticket 119, ci-dessus) : une entrée, un module, un contrat documenté. Les hooks en modules Wasm y restent une idée ouverte. |
| 51 | 63 | ||
| 52 | ## Liens avec le reste | 64 | ## Liens avec le reste |
| 53 | 65 | ||
modified
docs/fr/explanation/hooks.md +10 -0 | @@ -67,6 +67,16 @@ Quatre décisions, prises avec l'utilisateur : | ||
| 67 | 67 | |
| 68 | 68 | Une limite découle du trajet de la réponse : elle s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit. `postAsk` change ce que l'agent qui appelle lit et retient, pas ce qui a été montré — la même règle que pour `postToolCall`. Une réponse remplacée est annoncée sur la ligne de notice, pour que les deux ne divergent jamais sans qu'on le voie. |
| 69 | 69 | |
| 70 | +## Des hooks en modules WebAssembly | |
| 71 | + | |
| 72 | +Un hook script paie sa liberté deux fois : un démarrage de processus à chaque événement, et tous les droits de l'utilisateur — une garde pour `bash` écrite en Node peut lire n'importe quel fichier et appeler n'importe quel serveur. Le ticket 120 ajoute une seconde façon d'en écrire un : `wasm:` nomme un module WebAssembly que `mm` exécute lui-même, avec wazero, dans le bac à sable des [outils wasm](declared-tools.md#outils-wasm). Le contrat ne change pas — le même JSON sur stdin, la même réponse sur stdout — donc un hook est le même programme sous les deux formes ; seul change ce qu'il peut atteindre. | |
| 73 | + | |
| 74 | +**Ce qu'il voit.** Aucun fichier, sauf si son entrée ouvre un dossier (`read:`, `write:`) ; pas de réseau ; de l'environnement, `MM_HOOK_EVENT` seule. `MM_CONFIG_DIR` est laissée de côté exprès : c'est un chemin de l'hôte, et dans le bac à sable il n'y a rien à ce chemin. Le hook d'audit de la démo écrit son journal dans `logs/` et ne pourrait pas lire les fichiers qu'il audite. | |
| 75 | + | |
| 76 | +**Ce qui a été mesuré** (l'expérience du ticket 46) : la garde `bash` de `demos/hooks`, exécutée par le propre lanceur de hooks de `mm`, 20 exécutions après une première, deux fois, dans le sandbox où elle a été construite (linux/arm64). Le script Node.js : une médiane de 65 à 67 ms par exécution. Les mêmes règles en module TinyGo : 234 à 248 ms pour la première exécution du processus — la compilation — puis une médiane de 0,56 à 0,58 ms. Face à un modèle qui passe des secondes par réponse, le coût de Node était déjà du bruit ; ce que le module change, ce sont surtout les deux autres points : rien à installer, et rien qu'il puisse atteindre sans qu'on le lui donne. Il coûte en taille à la place : 954 octets de JavaScript contre un module de 770 Ko (expressions régulières et JSON compris), plus le runtime dans `mm` lui-même. | |
| 77 | + | |
| 78 | +**Ce qui reste un script.** Un hook qui liste le répertoire de travail (le `add-context.js` de la démo) ou appelle un serveur (les modèles de garde) a besoin de ce que le bac à sable ne donne pas : un module peut ouvrir des dossiers à l'intérieur du répertoire de travail, pas le répertoire lui-même, et WASI preview 1 n'a pas de réseau. | |
| 79 | + | |
| 70 | 80 | ## Les alternatives écartées |
| 71 | 81 | |
| 72 | 82 | Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi : |
| @@ -67,6 +67,16 @@ Quatre décisions, prises avec l'utilisateur : | |||
| 67 | 67 | ||
| 68 | Une limite découle du trajet de la réponse : elle s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit. `postAsk` change ce que l'agent qui appelle lit et retient, pas ce qui a été montré — la même règle que pour `postToolCall`. Une réponse remplacée est annoncée sur la ligne de notice, pour que les deux ne divergent jamais sans qu'on le voie. | 68 | Une limite découle du trajet de la réponse : elle s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit. `postAsk` change ce que l'agent qui appelle lit et retient, pas ce qui a été montré — la même règle que pour `postToolCall`. Une réponse remplacée est annoncée sur la ligne de notice, pour que les deux ne divergent jamais sans qu'on le voie. |
| 69 | 69 | ||
| 70 | +## Des hooks en modules WebAssembly | ||
| 71 | + | ||
| 72 | +Un hook script paie sa liberté deux fois : un démarrage de processus à chaque événement, et tous les droits de l'utilisateur — une garde pour `bash` écrite en Node peut lire n'importe quel fichier et appeler n'importe quel serveur. Le ticket 120 ajoute une seconde façon d'en écrire un : `wasm:` nomme un module WebAssembly que `mm` exécute lui-même, avec wazero, dans le bac à sable des [outils wasm](declared-tools.md#outils-wasm). Le contrat ne change pas — le même JSON sur stdin, la même réponse sur stdout — donc un hook est le même programme sous les deux formes ; seul change ce qu'il peut atteindre. | ||
| 73 | + | ||
| 74 | +**Ce qu'il voit.** Aucun fichier, sauf si son entrée ouvre un dossier (`read:`, `write:`) ; pas de réseau ; de l'environnement, `MM_HOOK_EVENT` seule. `MM_CONFIG_DIR` est laissée de côté exprès : c'est un chemin de l'hôte, et dans le bac à sable il n'y a rien à ce chemin. Le hook d'audit de la démo écrit son journal dans `logs/` et ne pourrait pas lire les fichiers qu'il audite. | ||
| 75 | + | ||
| 76 | +**Ce qui a été mesuré** (l'expérience du ticket 46) : la garde `bash` de `demos/hooks`, exécutée par le propre lanceur de hooks de `mm`, 20 exécutions après une première, deux fois, dans le sandbox où elle a été construite (linux/arm64). Le script Node.js : une médiane de 65 à 67 ms par exécution. Les mêmes règles en module TinyGo : 234 à 248 ms pour la première exécution du processus — la compilation — puis une médiane de 0,56 à 0,58 ms. Face à un modèle qui passe des secondes par réponse, le coût de Node était déjà du bruit ; ce que le module change, ce sont surtout les deux autres points : rien à installer, et rien qu'il puisse atteindre sans qu'on le lui donne. Il coûte en taille à la place : 954 octets de JavaScript contre un module de 770 Ko (expressions régulières et JSON compris), plus le runtime dans `mm` lui-même. | ||
| 77 | + | ||
| 78 | +**Ce qui reste un script.** Un hook qui liste le répertoire de travail (le `add-context.js` de la démo) ou appelle un serveur (les modèles de garde) a besoin de ce que le bac à sable ne donne pas : un module peut ouvrir des dossiers à l'intérieur du répertoire de travail, pas le répertoire lui-même, et WASI preview 1 n'a pas de réseau. | ||
| 79 | + | ||
| 70 | ## Les alternatives écartées | 80 | ## Les alternatives écartées |
| 71 | 81 | ||
| 72 | Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi : | 82 | Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi : |
modified
docs/fr/how-to/declare-a-custom-tool.md +1 -0 | @@ -52,6 +52,7 @@ Utilisez-le pour une action fréquente et précise : lancer les tests d'un packa | ||
| 52 | 52 | - **Demander avant chaque appel.** Mettez `yolo: false` : les outils déclarés demandent alors comme `bash`. Avec `yolo: true`, ils s'exécutent sans demander. |
| 53 | 53 | - **Une fonction du shell (pipe, redirection, `&&`).** La commande s'exécute sans shell : écrivez-la dans un script et déclarez le script. Passer `["bash", "-c", "…{{p}}…"]` remettrait la valeur du modèle dans une ligne de shell. |
| 54 | 54 | - **La même action qu'un outil MCP.** Un outil déclaré ne peut pas prendre le nom d'un outil MCP : il est ignoré, avec un avertissement au démarrage. Renommez l'un des deux. |
| 55 | +- **Un outil sans vos droits.** Un outil qui lit seulement un dossier ou calcule à partir de ses arguments peut être un module WebAssembly exécuté dans un bac à sable plutôt qu'une commande : voir [Écrire un outil personnalisé sous forme de module WebAssembly](write-a-wasm-tool.md). | |
| 55 | 56 | - **Contrôler ce qui s'exécute avant ou après.** Les [hooks](guard-the-agent-with-hooks.md) `preToolCall` et `postToolCall` s'appliquent aux outils déclarés ; utilisez le nom de l'outil comme `matcher`. |
| 56 | 57 | |
| 57 | 58 | ## Voir aussi |
| @@ -52,6 +52,7 @@ Utilisez-le pour une action fréquente et précise : lancer les tests d'un packa | |||
| 52 | - **Demander avant chaque appel.** Mettez `yolo: false` : les outils déclarés demandent alors comme `bash`. Avec `yolo: true`, ils s'exécutent sans demander. | 52 | - **Demander avant chaque appel.** Mettez `yolo: false` : les outils déclarés demandent alors comme `bash`. Avec `yolo: true`, ils s'exécutent sans demander. |
| 53 | - **Une fonction du shell (pipe, redirection, `&&`).** La commande s'exécute sans shell : écrivez-la dans un script et déclarez le script. Passer `["bash", "-c", "…{{p}}…"]` remettrait la valeur du modèle dans une ligne de shell. | 53 | - **Une fonction du shell (pipe, redirection, `&&`).** La commande s'exécute sans shell : écrivez-la dans un script et déclarez le script. Passer `["bash", "-c", "…{{p}}…"]` remettrait la valeur du modèle dans une ligne de shell. |
| 54 | - **La même action qu'un outil MCP.** Un outil déclaré ne peut pas prendre le nom d'un outil MCP : il est ignoré, avec un avertissement au démarrage. Renommez l'un des deux. | 54 | - **La même action qu'un outil MCP.** Un outil déclaré ne peut pas prendre le nom d'un outil MCP : il est ignoré, avec un avertissement au démarrage. Renommez l'un des deux. |
| 55 | +- **Un outil sans vos droits.** Un outil qui lit seulement un dossier ou calcule à partir de ses arguments peut être un module WebAssembly exécuté dans un bac à sable plutôt qu'une commande : voir [Écrire un outil personnalisé sous forme de module WebAssembly](write-a-wasm-tool.md). | ||
| 55 | - **Contrôler ce qui s'exécute avant ou après.** Les [hooks](guard-the-agent-with-hooks.md) `preToolCall` et `postToolCall` s'appliquent aux outils déclarés ; utilisez le nom de l'outil comme `matcher`. | 56 | - **Contrôler ce qui s'exécute avant ou après.** Les [hooks](guard-the-agent-with-hooks.md) `preToolCall` et `postToolCall` s'appliquent aux outils déclarés ; utilisez le nom de l'outil comme `matcher`. |
| 56 | 57 | ||
| 57 | ## Voir aussi | 58 | ## Voir aussi |
modified
docs/fr/how-to/guard-the-agent-with-hooks.md +1 -0 | @@ -89,6 +89,7 @@ et la réponse du modèle explique le refus. | ||
| 89 | 89 | - **Réécrire.** `{"prompt":"…"}` sur `preGeneration` remplace la question ; `{"input":{…}}` sur `preToolCall` remplace les arguments de l'outil — l'objet entier, donc recopiez les champs que vous gardez. |
| 90 | 90 | - **Journaliser.** Sur `postToolCall` et `postGeneration`, ajoutez `input` à un fichier et n'écrivez rien ; mettez `onError: ignore` pour qu'un disque plein n'interrompe jamais une session. Un même script peut servir plusieurs événements : lisez `input.event` ou `$MM_HOOK_EVENT`. |
| 91 | 91 | - **Un autre langage.** `command` est exécutée par `sh -c` : `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (exécutable), ou une commande `jq` en ligne fonctionnent tous. |
| 92 | +- **Un hook sans vos droits, rien à installer.** Écrivez-le comme module WebAssembly et déclarez-le avec `wasm:` au lieu de `command:` : voir [Écrire un hook sous forme de module WebAssembly](write-a-wasm-hook.md). | |
| 92 | 93 | - **Plusieurs hooks sur un événement** s'exécutent dans l'ordre écrit ; chacun voit la réécriture du précédent, et le premier `stop` ou `deny` arrête la chaîne. |
| 93 | 94 | - **Avec `yolo: false`**, un hook `preToolCall` s'exécute avant la demande d'autorisation : un appel refusé n'est jamais soumis à l'utilisateur. |
| 94 | 95 | - **Voir quels hooks ont tourné.** Mettez `displayHooks: true` : après chaque réponse, une ligne par exécution de hook indique son événement, sa commande et ce qu'il a décidé — `context (178 chars)`, `deny`, `failed`… Dans un éditeur, chaque exécution est un appel d'outil. Voir [hooks › Affichage](../reference/hooks.md#affichage). |
| @@ -89,6 +89,7 @@ et la réponse du modèle explique le refus. | |||
| 89 | - **Réécrire.** `{"prompt":"…"}` sur `preGeneration` remplace la question ; `{"input":{…}}` sur `preToolCall` remplace les arguments de l'outil — l'objet entier, donc recopiez les champs que vous gardez. | 89 | - **Réécrire.** `{"prompt":"…"}` sur `preGeneration` remplace la question ; `{"input":{…}}` sur `preToolCall` remplace les arguments de l'outil — l'objet entier, donc recopiez les champs que vous gardez. |
| 90 | - **Journaliser.** Sur `postToolCall` et `postGeneration`, ajoutez `input` à un fichier et n'écrivez rien ; mettez `onError: ignore` pour qu'un disque plein n'interrompe jamais une session. Un même script peut servir plusieurs événements : lisez `input.event` ou `$MM_HOOK_EVENT`. | 90 | - **Journaliser.** Sur `postToolCall` et `postGeneration`, ajoutez `input` à un fichier et n'écrivez rien ; mettez `onError: ignore` pour qu'un disque plein n'interrompe jamais une session. Un même script peut servir plusieurs événements : lisez `input.event` ou `$MM_HOOK_EVENT`. |
| 91 | - **Un autre langage.** `command` est exécutée par `sh -c` : `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (exécutable), ou une commande `jq` en ligne fonctionnent tous. | 91 | - **Un autre langage.** `command` est exécutée par `sh -c` : `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (exécutable), ou une commande `jq` en ligne fonctionnent tous. |
| 92 | +- **Un hook sans vos droits, rien à installer.** Écrivez-le comme module WebAssembly et déclarez-le avec `wasm:` au lieu de `command:` : voir [Écrire un hook sous forme de module WebAssembly](write-a-wasm-hook.md). | ||
| 92 | - **Plusieurs hooks sur un événement** s'exécutent dans l'ordre écrit ; chacun voit la réécriture du précédent, et le premier `stop` ou `deny` arrête la chaîne. | 93 | - **Plusieurs hooks sur un événement** s'exécutent dans l'ordre écrit ; chacun voit la réécriture du précédent, et le premier `stop` ou `deny` arrête la chaîne. |
| 93 | - **Avec `yolo: false`**, un hook `preToolCall` s'exécute avant la demande d'autorisation : un appel refusé n'est jamais soumis à l'utilisateur. | 94 | - **Avec `yolo: false`**, un hook `preToolCall` s'exécute avant la demande d'autorisation : un appel refusé n'est jamais soumis à l'utilisateur. |
| 94 | - **Voir quels hooks ont tourné.** Mettez `displayHooks: true` : après chaque réponse, une ligne par exécution de hook indique son événement, sa commande et ce qu'il a décidé — `context (178 chars)`, `deny`, `failed`… Dans un éditeur, chaque exécution est un appel d'outil. Voir [hooks › Affichage](../reference/hooks.md#affichage). | 95 | - **Voir quels hooks ont tourné.** Mettez `displayHooks: true` : après chaque réponse, une ligne par exécution de hook indique son événement, sa commande et ce qu'il a décidé — `context (178 chars)`, `deny`, `failed`… Dans un éditeur, chaque exécution est un appel d'outil. Voir [hooks › Affichage](../reference/hooks.md#affichage). |
added
docs/fr/how-to/write-a-wasm-hook.md +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +# Écrire un hook sous forme de module WebAssembly | |
| 2 | + | |
| 3 | +Ce guide montre comment écrire un hook qui s'exécute comme module WebAssembly à l'intérieur de mm, dans un bac à sable, au lieu d'un script : rien à installer à côté de mm, aucun processus lancé à chaque événement, et un hook qui ne voit aucun fichier, aucune variable d'environnement et aucun réseau, sauf ce que son entrée lui ouvre. Il suppose que vous savez [comment fonctionnent les hooks](guard-the-agent-with-hooks.md) et que [TinyGo](https://tinygo.org) est installé. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Écrivez le hook comme un programme Go ordinaire. Il lit sur stdin le même objet JSON qu'un hook script, et écrit sur stdout la même réponse — ou rien, pour laisser le tour continuer. Celui-ci refuse `git push --force` : | |
| 8 | + | |
| 9 | + ```go | |
| 10 | + // hooks-src/no_force_push/main.go | |
| 11 | + package main | |
| 12 | + | |
| 13 | + import ( | |
| 14 | + "encoding/json" | |
| 15 | + "os" | |
| 16 | + "regexp" | |
| 17 | + ) | |
| 18 | + | |
| 19 | + var forcePush = regexp.MustCompile(`\bgit\s+push\b.*(--force|-f\b)`) | |
| 20 | + | |
| 21 | + func main() { | |
| 22 | + var in struct { | |
| 23 | + Tool struct { | |
| 24 | + Input struct { | |
| 25 | + Command string `json:"command"` | |
| 26 | + } `json:"input"` | |
| 27 | + } `json:"tool"` | |
| 28 | + } | |
| 29 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 30 | + os.Exit(2) | |
| 31 | + } | |
| 32 | + if forcePush.MatchString(in.Tool.Input.Command) { | |
| 33 | + json.NewEncoder(os.Stdout).Encode(map[string]string{ | |
| 34 | + "action": "deny", | |
| 35 | + "reason": "force-push is not allowed in this project", | |
| 36 | + }) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + ``` | |
| 40 | + | |
| 41 | +2. Compilez-le en module WASI : | |
| 42 | + | |
| 43 | + ```bash | |
| 44 | + tinygo build -o hooks/no_force_push.wasm -target=wasip1 -no-debug ./hooks-src/no_force_push | |
| 45 | + ``` | |
| 46 | + | |
| 47 | +3. Déclarez-le avec `wasm:` à la place de `command:` ; `matcher`, `timeout` et `onError` fonctionnent comme pour un script : | |
| 48 | + | |
| 49 | + ```yaml | |
| 50 | + hooks: | |
| 51 | + preToolCall: | |
| 52 | + - matcher: ^bash$ | |
| 53 | + wasm: ./hooks/no_force_push.wasm | |
| 54 | + onError: fail | |
| 55 | + ``` | |
| 56 | + | |
| 57 | + Le chemin du module est relatif au fichier d'agent. | |
| 58 | + | |
| 59 | +4. Lancez l'agent avec `displayHooks: true` dans le fichier, et demandez quelque chose qui lance `bash`. Après la réponse, chaque exécution du hook est listée sous le nom de son module : | |
| 60 | + | |
| 61 | + ```text | |
| 62 | + 🪝 1. preToolCall bash · no_force_push.wasm → deny | |
| 63 | + ``` | |
| 64 | + | |
| 65 | +## Variantes | |
| 66 | + | |
| 67 | +- **Un hook qui lit ou écrit des fichiers.** Ouvrez les dossiers dont il a besoin, relatifs au répertoire de travail : `read: [rules]` (lecture seule), `write: [logs]` (lecture-écriture). Il les ouvre sous le même nom (`logs/hooks-audit.jsonl`) ; rien d'autre n'existe pour lui. Un dossier doit exister quand le hook s'exécute. | |
| 68 | +- **Un module pour plusieurs événements.** Lisez `event` dans l'entrée, ou la variable `MM_HOOK_EVENT` — la seule que voit le module. | |
| 69 | +- **Un hook qui a besoin de tout le répertoire de travail** (pour en lister les fichiers, par exemple) ou du réseau reste un script : un hook wasm peut ouvrir des dossiers à l'intérieur du répertoire de travail, pas le répertoire lui-même, et n'a pas de réseau. | |
| 70 | +- **Les chemins dans le module.** Écrivez-les propres : `logs/a.jsonl`, pas `./logs/a.jsonl` — la couche WASI de TinyGo refuse `./` et `..`. | |
| 71 | +- **La première exécution d'un processus** compile le module (quelques centaines de millisecondes) ; les suivantes prennent bien moins d'une milliseconde. | |
| 72 | + | |
| 73 | +## Voir aussi | |
| 74 | + | |
| 75 | +- Un exemple complet — un filtre de secrets, une garde pour `bash` et un journal d'audit, les hooks de `demos/hooks` réécrits en modules : `demos/wasm-hooks/` | |
| 76 | +- Chaque clé, ce que voit le module, les échecs : [référence des hooks](../reference/hooks.md#hooks-wasm) | |
| 77 | +- Pourquoi, et ce qui a été mesuré : [Hooks — explication](../explanation/hooks.md#des-hooks-en-modules-webassembly) | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +# Écrire un hook sous forme de module WebAssembly | ||
| 2 | + | ||
| 3 | +Ce guide montre comment écrire un hook qui s'exécute comme module WebAssembly à l'intérieur de mm, dans un bac à sable, au lieu d'un script : rien à installer à côté de mm, aucun processus lancé à chaque événement, et un hook qui ne voit aucun fichier, aucune variable d'environnement et aucun réseau, sauf ce que son entrée lui ouvre. Il suppose que vous savez [comment fonctionnent les hooks](guard-the-agent-with-hooks.md) et que [TinyGo](https://tinygo.org) est installé. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Écrivez le hook comme un programme Go ordinaire. Il lit sur stdin le même objet JSON qu'un hook script, et écrit sur stdout la même réponse — ou rien, pour laisser le tour continuer. Celui-ci refuse `git push --force` : | ||
| 8 | + | ||
| 9 | + ```go | ||
| 10 | + // hooks-src/no_force_push/main.go | ||
| 11 | + package main | ||
| 12 | + | ||
| 13 | + import ( | ||
| 14 | + "encoding/json" | ||
| 15 | + "os" | ||
| 16 | + "regexp" | ||
| 17 | + ) | ||
| 18 | + | ||
| 19 | + var forcePush = regexp.MustCompile(`\bgit\s+push\b.*(--force|-f\b)`) | ||
| 20 | + | ||
| 21 | + func main() { | ||
| 22 | + var in struct { | ||
| 23 | + Tool struct { | ||
| 24 | + Input struct { | ||
| 25 | + Command string `json:"command"` | ||
| 26 | + } `json:"input"` | ||
| 27 | + } `json:"tool"` | ||
| 28 | + } | ||
| 29 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 30 | + os.Exit(2) | ||
| 31 | + } | ||
| 32 | + if forcePush.MatchString(in.Tool.Input.Command) { | ||
| 33 | + json.NewEncoder(os.Stdout).Encode(map[string]string{ | ||
| 34 | + "action": "deny", | ||
| 35 | + "reason": "force-push is not allowed in this project", | ||
| 36 | + }) | ||
| 37 | + } | ||
| 38 | + } | ||
| 39 | + ``` | ||
| 40 | + | ||
| 41 | +2. Compilez-le en module WASI : | ||
| 42 | + | ||
| 43 | + ```bash | ||
| 44 | + tinygo build -o hooks/no_force_push.wasm -target=wasip1 -no-debug ./hooks-src/no_force_push | ||
| 45 | + ``` | ||
| 46 | + | ||
| 47 | +3. Déclarez-le avec `wasm:` à la place de `command:` ; `matcher`, `timeout` et `onError` fonctionnent comme pour un script : | ||
| 48 | + | ||
| 49 | + ```yaml | ||
| 50 | + hooks: | ||
| 51 | + preToolCall: | ||
| 52 | + - matcher: ^bash$ | ||
| 53 | + wasm: ./hooks/no_force_push.wasm | ||
| 54 | + onError: fail | ||
| 55 | + ``` | ||
| 56 | + | ||
| 57 | + Le chemin du module est relatif au fichier d'agent. | ||
| 58 | + | ||
| 59 | +4. Lancez l'agent avec `displayHooks: true` dans le fichier, et demandez quelque chose qui lance `bash`. Après la réponse, chaque exécution du hook est listée sous le nom de son module : | ||
| 60 | + | ||
| 61 | + ```text | ||
| 62 | + 🪝 1. preToolCall bash · no_force_push.wasm → deny | ||
| 63 | + ``` | ||
| 64 | + | ||
| 65 | +## Variantes | ||
| 66 | + | ||
| 67 | +- **Un hook qui lit ou écrit des fichiers.** Ouvrez les dossiers dont il a besoin, relatifs au répertoire de travail : `read: [rules]` (lecture seule), `write: [logs]` (lecture-écriture). Il les ouvre sous le même nom (`logs/hooks-audit.jsonl`) ; rien d'autre n'existe pour lui. Un dossier doit exister quand le hook s'exécute. | ||
| 68 | +- **Un module pour plusieurs événements.** Lisez `event` dans l'entrée, ou la variable `MM_HOOK_EVENT` — la seule que voit le module. | ||
| 69 | +- **Un hook qui a besoin de tout le répertoire de travail** (pour en lister les fichiers, par exemple) ou du réseau reste un script : un hook wasm peut ouvrir des dossiers à l'intérieur du répertoire de travail, pas le répertoire lui-même, et n'a pas de réseau. | ||
| 70 | +- **Les chemins dans le module.** Écrivez-les propres : `logs/a.jsonl`, pas `./logs/a.jsonl` — la couche WASI de TinyGo refuse `./` et `..`. | ||
| 71 | +- **La première exécution d'un processus** compile le module (quelques centaines de millisecondes) ; les suivantes prennent bien moins d'une milliseconde. | ||
| 72 | + | ||
| 73 | +## Voir aussi | ||
| 74 | + | ||
| 75 | +- Un exemple complet — un filtre de secrets, une garde pour `bash` et un journal d'audit, les hooks de `demos/hooks` réécrits en modules : `demos/wasm-hooks/` | ||
| 76 | +- Chaque clé, ce que voit le module, les échecs : [référence des hooks](../reference/hooks.md#hooks-wasm) | ||
| 77 | +- Pourquoi, et ce qui a été mesuré : [Hooks — explication](../explanation/hooks.md#des-hooks-en-modules-webassembly) | ||
added
docs/fr/how-to/write-a-wasm-tool.md +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +# Écrire un outil personnalisé sous forme de module WebAssembly | |
| 2 | + | |
| 3 | +Ce guide montre comment donner à l'agent un outil qui s'exécute comme module WebAssembly à l'intérieur de mm, dans un bac à sable : le module ne voit aucun fichier, aucune variable d'environnement et aucun réseau, sauf les dossiers du projet que vous lui ouvrez. Utilisez-le à la place d'un [outil commande](declare-a-custom-tool.md) quand l'outil ne doit pas avoir vos droits — il lit un dossier, ou calcule quelque chose à partir de ses seuls arguments. Il suppose que [TinyGo](https://tinygo.org) est installé et que vous avez un fichier d'agent YAML. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Écrivez l'outil comme un programme Go ordinaire. Il lit les arguments de l'appel en un seul objet JSON sur stdin, et ce qu'il écrit sur stdout ou stderr est ce que le modèle reçoit ; sortir avec un code non nul marque l'appel comme échoué : | |
| 8 | + | |
| 9 | + ```go | |
| 10 | + // tools-src/search_notes/main.go | |
| 11 | + package main | |
| 12 | + | |
| 13 | + import ( | |
| 14 | + "encoding/json" | |
| 15 | + "fmt" | |
| 16 | + "os" | |
| 17 | + "path" | |
| 18 | + "strings" | |
| 19 | + ) | |
| 20 | + | |
| 21 | + func main() { | |
| 22 | + var in struct { | |
| 23 | + Word string `json:"word"` | |
| 24 | + } | |
| 25 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | |
| 26 | + fmt.Fprintln(os.Stderr, "bad input:", err) | |
| 27 | + os.Exit(2) | |
| 28 | + } | |
| 29 | + entries, _ := os.ReadDir("notes") | |
| 30 | + for _, e := range entries { | |
| 31 | + data, _ := os.ReadFile(path.Join("notes", e.Name())) | |
| 32 | + for i, line := range strings.Split(string(data), "\n") { | |
| 33 | + if strings.Contains(strings.ToLower(line), strings.ToLower(in.Word)) { | |
| 34 | + fmt.Printf("%s:%d: %s\n", e.Name(), i+1, line) | |
| 35 | + } | |
| 36 | + } | |
| 37 | + } | |
| 38 | + } | |
| 39 | + ``` | |
| 40 | + | |
| 41 | +2. Compilez-le en module WASI : | |
| 42 | + | |
| 43 | + ```bash | |
| 44 | + tinygo build -o tools/search_notes.wasm -target=wasip1 -no-debug ./tools-src/search_notes | |
| 45 | + ``` | |
| 46 | + | |
| 47 | +3. Déclarez-le dans le fichier d'agent avec `wasm:` à la place de `command:`, et ouvrez les dossiers dont il a besoin — `read:` en lecture seule, `write:` en lecture-écriture : | |
| 48 | + | |
| 49 | + ```yaml | |
| 50 | + tools: | |
| 51 | + - name: search_notes | |
| 52 | + description: Find the lines of the notes that mention a word. | |
| 53 | + parameters: | |
| 54 | + word: { type: string, description: "One word, e.g. wazero" } | |
| 55 | + wasm: ./tools/search_notes.wasm | |
| 56 | + read: [notes] | |
| 57 | + ``` | |
| 58 | + | |
| 59 | + Le chemin du module est relatif au fichier d'agent ; les dossiers sont relatifs au répertoire de travail, et le module les ouvre sous le même nom (`notes/…`). | |
| 60 | + | |
| 61 | +4. Demandez-le avec vos propres mots, depuis le répertoire du projet : | |
| 62 | + | |
| 63 | + ```bash | |
| 64 | + mm -p "Which notes mention TinyGo?" | |
| 65 | + ``` | |
| 66 | + | |
| 67 | + Le terminal affiche le module et le JSON qu'il a reçu, puis sa sortie : | |
| 68 | + | |
| 69 | + ``` | |
| 70 | + 🧰 search_notes: search_notes.wasm {"word":"TinyGo"} | |
| 71 | + │ tinygo.md:1: # TinyGo | |
| 72 | + │ wazero.md:4: It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | |
| 73 | + ``` | |
| 74 | + | |
| 75 | +## Variantes | |
| 76 | + | |
| 77 | +- **Un outil qui écrit.** `write: [out]` lui permet de créer et de modifier des fichiers dans `out/` seulement. Un dossier qu'il peut seulement lire va dans `read:` ; un dossier absent des deux listes n'existe pas pour lui. | |
| 78 | +- **Un calcul pur.** Omettez `read` et `write` : le module ne voit alors aucun fichier. | |
| 79 | +- **Les chemins dans le module.** Nettoyez-les avant d'ouvrir : la couche WASI de TinyGo refuse `./notes/a.md` et `..` — `path.Join("notes", name)` donne `notes/a.md`. Un nom comme `../agent.yaml` devient alors `agent.yaml`, qui n'est pas monté : le bac à sable le tient à l'écart. | |
| 80 | +- **Paramètres typés, valeurs par défaut.** Le JSON contient chaque valeur dans son type déclaré (`"top": 5`, `"exact": true`), le `default` d'un paramètre optionnel que l'appel a omis, et aucune clé pour un paramètre optionnel sans valeur par défaut. | |
| 81 | +- **Un calcul long.** Ajoutez `timeout: 2m` ; au-delà, le module est arrêté. Sans lui, `bashTimeout` s'applique. | |
| 82 | +- **Demander avant chaque appel.** `yolo: false` demande pour un outil wasm comme pour un outil commande. | |
| 83 | +- **Un autre langage.** Tout module commande WASI preview 1 devrait s'exécuter de la même façon ; seuls des modules TinyGo ont été essayés. | |
| 84 | + | |
| 85 | +## Voir aussi | |
| 86 | + | |
| 87 | +- Un exemple complet avec quatre modules, un dossier en lecture et un dossier en écriture : `demos/wasm-tools/` | |
| 88 | +- Chaque clé et chaque erreur : [référence de la configuration](../reference/configuration.md#clés-de-tools) et [outils déclarés](../reference/tools.md#outils-déclarés) | |
| 89 | +- Ce que le bac à sable permet et ce qu'il coûte : [Outils déclarés](../explanation/declared-tools.md#outils-wasm) | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +# Écrire un outil personnalisé sous forme de module WebAssembly | ||
| 2 | + | ||
| 3 | +Ce guide montre comment donner à l'agent un outil qui s'exécute comme module WebAssembly à l'intérieur de mm, dans un bac à sable : le module ne voit aucun fichier, aucune variable d'environnement et aucun réseau, sauf les dossiers du projet que vous lui ouvrez. Utilisez-le à la place d'un [outil commande](declare-a-custom-tool.md) quand l'outil ne doit pas avoir vos droits — il lit un dossier, ou calcule quelque chose à partir de ses seuls arguments. Il suppose que [TinyGo](https://tinygo.org) est installé et que vous avez un fichier d'agent YAML. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Écrivez l'outil comme un programme Go ordinaire. Il lit les arguments de l'appel en un seul objet JSON sur stdin, et ce qu'il écrit sur stdout ou stderr est ce que le modèle reçoit ; sortir avec un code non nul marque l'appel comme échoué : | ||
| 8 | + | ||
| 9 | + ```go | ||
| 10 | + // tools-src/search_notes/main.go | ||
| 11 | + package main | ||
| 12 | + | ||
| 13 | + import ( | ||
| 14 | + "encoding/json" | ||
| 15 | + "fmt" | ||
| 16 | + "os" | ||
| 17 | + "path" | ||
| 18 | + "strings" | ||
| 19 | + ) | ||
| 20 | + | ||
| 21 | + func main() { | ||
| 22 | + var in struct { | ||
| 23 | + Word string `json:"word"` | ||
| 24 | + } | ||
| 25 | + if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil { | ||
| 26 | + fmt.Fprintln(os.Stderr, "bad input:", err) | ||
| 27 | + os.Exit(2) | ||
| 28 | + } | ||
| 29 | + entries, _ := os.ReadDir("notes") | ||
| 30 | + for _, e := range entries { | ||
| 31 | + data, _ := os.ReadFile(path.Join("notes", e.Name())) | ||
| 32 | + for i, line := range strings.Split(string(data), "\n") { | ||
| 33 | + if strings.Contains(strings.ToLower(line), strings.ToLower(in.Word)) { | ||
| 34 | + fmt.Printf("%s:%d: %s\n", e.Name(), i+1, line) | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | + } | ||
| 39 | + ``` | ||
| 40 | + | ||
| 41 | +2. Compilez-le en module WASI : | ||
| 42 | + | ||
| 43 | + ```bash | ||
| 44 | + tinygo build -o tools/search_notes.wasm -target=wasip1 -no-debug ./tools-src/search_notes | ||
| 45 | + ``` | ||
| 46 | + | ||
| 47 | +3. Déclarez-le dans le fichier d'agent avec `wasm:` à la place de `command:`, et ouvrez les dossiers dont il a besoin — `read:` en lecture seule, `write:` en lecture-écriture : | ||
| 48 | + | ||
| 49 | + ```yaml | ||
| 50 | + tools: | ||
| 51 | + - name: search_notes | ||
| 52 | + description: Find the lines of the notes that mention a word. | ||
| 53 | + parameters: | ||
| 54 | + word: { type: string, description: "One word, e.g. wazero" } | ||
| 55 | + wasm: ./tools/search_notes.wasm | ||
| 56 | + read: [notes] | ||
| 57 | + ``` | ||
| 58 | + | ||
| 59 | + Le chemin du module est relatif au fichier d'agent ; les dossiers sont relatifs au répertoire de travail, et le module les ouvre sous le même nom (`notes/…`). | ||
| 60 | + | ||
| 61 | +4. Demandez-le avec vos propres mots, depuis le répertoire du projet : | ||
| 62 | + | ||
| 63 | + ```bash | ||
| 64 | + mm -p "Which notes mention TinyGo?" | ||
| 65 | + ``` | ||
| 66 | + | ||
| 67 | + Le terminal affiche le module et le JSON qu'il a reçu, puis sa sortie : | ||
| 68 | + | ||
| 69 | + ``` | ||
| 70 | + 🧰 search_notes: search_notes.wasm {"word":"TinyGo"} | ||
| 71 | + │ tinygo.md:1: # TinyGo | ||
| 72 | + │ wazero.md:4: It runs WASI modules, such as the ones TinyGo builds with -target=wasip1. | ||
| 73 | + ``` | ||
| 74 | + | ||
| 75 | +## Variantes | ||
| 76 | + | ||
| 77 | +- **Un outil qui écrit.** `write: [out]` lui permet de créer et de modifier des fichiers dans `out/` seulement. Un dossier qu'il peut seulement lire va dans `read:` ; un dossier absent des deux listes n'existe pas pour lui. | ||
| 78 | +- **Un calcul pur.** Omettez `read` et `write` : le module ne voit alors aucun fichier. | ||
| 79 | +- **Les chemins dans le module.** Nettoyez-les avant d'ouvrir : la couche WASI de TinyGo refuse `./notes/a.md` et `..` — `path.Join("notes", name)` donne `notes/a.md`. Un nom comme `../agent.yaml` devient alors `agent.yaml`, qui n'est pas monté : le bac à sable le tient à l'écart. | ||
| 80 | +- **Paramètres typés, valeurs par défaut.** Le JSON contient chaque valeur dans son type déclaré (`"top": 5`, `"exact": true`), le `default` d'un paramètre optionnel que l'appel a omis, et aucune clé pour un paramètre optionnel sans valeur par défaut. | ||
| 81 | +- **Un calcul long.** Ajoutez `timeout: 2m` ; au-delà, le module est arrêté. Sans lui, `bashTimeout` s'applique. | ||
| 82 | +- **Demander avant chaque appel.** `yolo: false` demande pour un outil wasm comme pour un outil commande. | ||
| 83 | +- **Un autre langage.** Tout module commande WASI preview 1 devrait s'exécuter de la même façon ; seuls des modules TinyGo ont été essayés. | ||
| 84 | + | ||
| 85 | +## Voir aussi | ||
| 86 | + | ||
| 87 | +- Un exemple complet avec quatre modules, un dossier en lecture et un dossier en écriture : `demos/wasm-tools/` | ||
| 88 | +- Chaque clé et chaque erreur : [référence de la configuration](../reference/configuration.md#clés-de-tools) et [outils déclarés](../reference/tools.md#outils-déclarés) | ||
| 89 | +- Ce que le bac à sable permet et ce qu'il coûte : [Outils déclarés](../explanation/declared-tools.md#outils-wasm) | ||
modified
docs/fr/reference/configuration.md +20 -4 | @@ -38,8 +38,8 @@ | ||
| 38 | 38 | | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. | |
| 39 | 39 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | |
| 40 | 40 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | |
| 41 | -| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | | |
| 42 | -| `tools` | liste | `[]` | Des commandes déclarées comme outils typés : chaque entrée a un `name`, une `description`, ses `parameters` et une `command` exécutée sans shell. Voir les [clés de `tools`](#clés-de-tools) ci-dessous et les [outils déclarés](tools.md#outils-déclarés). | | |
| 41 | +| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` ou `wasm` (un module WebAssembly, avec ses dossiers `read` / `write`), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | | |
| 42 | +| `tools` | liste | `[]` | Des commandes ou des modules WebAssembly déclarés comme outils typés : chaque entrée a un `name`, une `description`, ses `parameters`, et une `command` exécutée sans shell ou un module `wasm` exécuté dans un bac à sable. Voir les [clés de `tools`](#clés-de-tools) ci-dessous et les [outils déclarés](tools.md#outils-déclarés). | | |
| 43 | 43 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | |
| 44 | 44 | | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). | |
| 45 | 45 | | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. | |
| @@ -154,7 +154,10 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl | ||
| 154 | 154 | | `tools[].parameters.<p>.enum` | liste | aucune | Les seules valeurs acceptées, chacune du type du paramètre. | |
| 155 | 155 | | `tools[].parameters.<p>.required` | bool | `true` | `false` rend le paramètre facultatif. | |
| 156 | 156 | | `tools[].parameters.<p>.default` | valeur | aucune | La valeur d'un paramètre facultatif que l'appel omet. Seulement avec `required: false` ; du type du paramètre ; dans l'`enum` s'il y en a un. | |
| 157 | -| `tools[].command` | liste de chaînes | — | Le programme, puis ses arguments, une entrée argv chacun. Obligatoire. `{{p}}` dans une entrée est remplacé par la valeur du paramètre `p`. Le programme (première entrée) ne peut pas contenir de placeholder. Chaque placeholder doit nommer un paramètre déclaré. Un programme relatif (`./scripts/x.sh`) est résolu depuis le répertoire de travail. | | |
| 157 | +| `tools[].command` | liste de chaînes | — | Le programme, puis ses arguments, une entrée argv chacun. Obligatoire sauf si `wasm` est donné ; pas les deux. `{{p}}` dans une entrée est remplacé par la valeur du paramètre `p`. Le programme (première entrée) ne peut pas contenir de placeholder. Chaque placeholder doit nommer un paramètre déclaré. Un programme relatif (`./scripts/x.sh`) est résolu depuis le répertoire de travail. | | |
| 158 | +| `tools[].wasm` | chaîne | `""` | Un module WebAssembly (commande WASI preview 1) exécuté par mm à la place d'une commande. Relatif au fichier d'agent. Le fichier doit exister au chargement. Les arguments de l'appel sont son stdin, en un objet JSON. | | |
| 159 | +| `tools[].read` | liste de chaînes | `[]` | `wasm` seulement. Dossiers du répertoire de travail que le module peut lire, montés sous le même nom. Relatifs, dans le répertoire de travail (ni `.`, ni au-dessus), chacun une seule fois dans `read` et `write` réunis. | | |
| 160 | +| `tools[].write` | liste de chaînes | `[]` | `wasm` seulement. Dossiers du répertoire de travail que le module peut lire et écrire. Mêmes règles que `read`. | | |
| 158 | 161 | | `tools[].timeout` | durée | `0` (= `bashTimeout`) | Durée maximale d'un appel. Doit être ≥ 0. | |
| 159 | 162 | |
| 160 | 163 | ```yaml |
| @@ -167,6 +170,13 @@ tools: | ||
| 167 | 170 | dryRun: { type: boolean, required: false, default: false } |
| 168 | 171 | command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] |
| 169 | 172 | timeout: 60s |
| 173 | + | |
| 174 | + - name: search_notes | |
| 175 | + description: Find the lines of the notes that mention a word. | |
| 176 | + parameters: | |
| 177 | + word: { type: string } | |
| 178 | + wasm: ./tools/search_notes.wasm | |
| 179 | + read: [notes] | |
| 170 | 180 | ``` |
| 171 | 181 | |
| 172 | 182 | ## Valeurs par défaut des fournisseurs |
| @@ -265,7 +275,13 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code | ||
| 265 | 275 | | `tools[N] (<nom>).name "<nom>" is the name of a built-in tool` | un outil déclaré qui porte le nom d'un outil intégré | |
| 266 | 276 | | `tools[N] (<nom>): another tool is already called "<nom>"` | deux entrées de même nom | |
| 267 | 277 | | `tools[N] (<nom>).description must not be empty: …` | pas de `description` | |
| 268 | -| `tools[N] (<nom>).command must name a program, …` | pas de `command`, ou une première entrée vide | | |
| 278 | +| `tools[N] (<nom>).command must name a program, …` | ni `command` ni `wasm`, ou une première entrée vide | | |
| 279 | +| `tools[N] (<nom>): give command or wasm, not both` | une entrée avec les deux | | |
| 280 | +| `tools[N] (<nom>): wasm: no module at <chemin>` | le fichier du module n'existe pas, ou c'est un dossier | | |
| 281 | +| `tools[N] (<nom>): read and write only apply to a wasm tool — …` | `read` ou `write` sur une entrée `command` | | |
| 282 | +| `tools[N] (<nom>): folder "<d>" must be relative to the working directory` | un dossier absolu | | |
| 283 | +| `tools[N] (<nom>): folder "<d>" must be a folder inside the working directory, not it or above it` | `.`, `..` ou un chemin qui remonte au-dessus | | |
| 284 | +| `tools[N] (<nom>): folder "<d>" is given twice` | un dossier listé deux fois, dans `read`, `write` ou les deux | | |
| 269 | 285 | | `tools[N] (<nom>).command: the program ("{{p}}") cannot come from a parameter` | un placeholder dans la première entrée | |
| 270 | 286 | | `tools[N] (<nom>).command: {{p}} is not a declared parameter` | un placeholder qu'aucun paramètre ne déclare | |
| 271 | 287 | | `tools[N] (<nom>).timeout must be >= 0` | un `timeout` négatif | |
| @@ -38,8 +38,8 @@ | |||
| 38 | | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. | | 38 | | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. | |
| 39 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | | 39 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | |
| 40 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | | 40 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | |
| 41 | -| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | | 41 | +| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` ou `wasm` (un module WebAssembly, avec ses dossiers `read` / `write`), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | |
| 42 | -| `tools` | liste | `[]` | Des commandes déclarées comme outils typés : chaque entrée a un `name`, une `description`, ses `parameters` et une `command` exécutée sans shell. Voir les [clés de `tools`](#clés-de-tools) ci-dessous et les [outils déclarés](tools.md#outils-déclarés). | | 42 | +| `tools` | liste | `[]` | Des commandes ou des modules WebAssembly déclarés comme outils typés : chaque entrée a un `name`, une `description`, ses `parameters`, et une `command` exécutée sans shell ou un module `wasm` exécuté dans un bac à sable. Voir les [clés de `tools`](#clés-de-tools) ci-dessous et les [outils déclarés](tools.md#outils-déclarés). | |
| 43 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | | 43 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | |
| 44 | | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). | | 44 | | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). | |
| 45 | | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. | | 45 | | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. | |
| @@ -154,7 +154,10 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl | |||
| 154 | | `tools[].parameters.<p>.enum` | liste | aucune | Les seules valeurs acceptées, chacune du type du paramètre. | | 154 | | `tools[].parameters.<p>.enum` | liste | aucune | Les seules valeurs acceptées, chacune du type du paramètre. | |
| 155 | | `tools[].parameters.<p>.required` | bool | `true` | `false` rend le paramètre facultatif. | | 155 | | `tools[].parameters.<p>.required` | bool | `true` | `false` rend le paramètre facultatif. | |
| 156 | | `tools[].parameters.<p>.default` | valeur | aucune | La valeur d'un paramètre facultatif que l'appel omet. Seulement avec `required: false` ; du type du paramètre ; dans l'`enum` s'il y en a un. | | 156 | | `tools[].parameters.<p>.default` | valeur | aucune | La valeur d'un paramètre facultatif que l'appel omet. Seulement avec `required: false` ; du type du paramètre ; dans l'`enum` s'il y en a un. | |
| 157 | -| `tools[].command` | liste de chaînes | — | Le programme, puis ses arguments, une entrée argv chacun. Obligatoire. `{{p}}` dans une entrée est remplacé par la valeur du paramètre `p`. Le programme (première entrée) ne peut pas contenir de placeholder. Chaque placeholder doit nommer un paramètre déclaré. Un programme relatif (`./scripts/x.sh`) est résolu depuis le répertoire de travail. | | 157 | +| `tools[].command` | liste de chaînes | — | Le programme, puis ses arguments, une entrée argv chacun. Obligatoire sauf si `wasm` est donné ; pas les deux. `{{p}}` dans une entrée est remplacé par la valeur du paramètre `p`. Le programme (première entrée) ne peut pas contenir de placeholder. Chaque placeholder doit nommer un paramètre déclaré. Un programme relatif (`./scripts/x.sh`) est résolu depuis le répertoire de travail. | |
| 158 | +| `tools[].wasm` | chaîne | `""` | Un module WebAssembly (commande WASI preview 1) exécuté par mm à la place d'une commande. Relatif au fichier d'agent. Le fichier doit exister au chargement. Les arguments de l'appel sont son stdin, en un objet JSON. | | ||
| 159 | +| `tools[].read` | liste de chaînes | `[]` | `wasm` seulement. Dossiers du répertoire de travail que le module peut lire, montés sous le même nom. Relatifs, dans le répertoire de travail (ni `.`, ni au-dessus), chacun une seule fois dans `read` et `write` réunis. | | ||
| 160 | +| `tools[].write` | liste de chaînes | `[]` | `wasm` seulement. Dossiers du répertoire de travail que le module peut lire et écrire. Mêmes règles que `read`. | | ||
| 158 | | `tools[].timeout` | durée | `0` (= `bashTimeout`) | Durée maximale d'un appel. Doit être ≥ 0. | | 161 | | `tools[].timeout` | durée | `0` (= `bashTimeout`) | Durée maximale d'un appel. Doit être ≥ 0. | |
| 159 | 162 | ||
| 160 | ```yaml | 163 | ```yaml |
| @@ -167,6 +170,13 @@ tools: | |||
| 167 | dryRun: { type: boolean, required: false, default: false } | 170 | dryRun: { type: boolean, required: false, default: false } |
| 168 | command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | 171 | command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] |
| 169 | timeout: 60s | 172 | timeout: 60s |
| 173 | + | ||
| 174 | + - name: search_notes | ||
| 175 | + description: Find the lines of the notes that mention a word. | ||
| 176 | + parameters: | ||
| 177 | + word: { type: string } | ||
| 178 | + wasm: ./tools/search_notes.wasm | ||
| 179 | + read: [notes] | ||
| 170 | ``` | 180 | ``` |
| 171 | 181 | ||
| 172 | ## Valeurs par défaut des fournisseurs | 182 | ## Valeurs par défaut des fournisseurs |
| @@ -265,7 +275,13 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code | |||
| 265 | | `tools[N] (<nom>).name "<nom>" is the name of a built-in tool` | un outil déclaré qui porte le nom d'un outil intégré | | 275 | | `tools[N] (<nom>).name "<nom>" is the name of a built-in tool` | un outil déclaré qui porte le nom d'un outil intégré | |
| 266 | | `tools[N] (<nom>): another tool is already called "<nom>"` | deux entrées de même nom | | 276 | | `tools[N] (<nom>): another tool is already called "<nom>"` | deux entrées de même nom | |
| 267 | | `tools[N] (<nom>).description must not be empty: …` | pas de `description` | | 277 | | `tools[N] (<nom>).description must not be empty: …` | pas de `description` | |
| 268 | -| `tools[N] (<nom>).command must name a program, …` | pas de `command`, ou une première entrée vide | | 278 | +| `tools[N] (<nom>).command must name a program, …` | ni `command` ni `wasm`, ou une première entrée vide | |
| 279 | +| `tools[N] (<nom>): give command or wasm, not both` | une entrée avec les deux | | ||
| 280 | +| `tools[N] (<nom>): wasm: no module at <chemin>` | le fichier du module n'existe pas, ou c'est un dossier | | ||
| 281 | +| `tools[N] (<nom>): read and write only apply to a wasm tool — …` | `read` ou `write` sur une entrée `command` | | ||
| 282 | +| `tools[N] (<nom>): folder "<d>" must be relative to the working directory` | un dossier absolu | | ||
| 283 | +| `tools[N] (<nom>): folder "<d>" must be a folder inside the working directory, not it or above it` | `.`, `..` ou un chemin qui remonte au-dessus | | ||
| 284 | +| `tools[N] (<nom>): folder "<d>" is given twice` | un dossier listé deux fois, dans `read`, `write` ou les deux | | ||
| 269 | | `tools[N] (<nom>).command: the program ("{{p}}") cannot come from a parameter` | un placeholder dans la première entrée | | 285 | | `tools[N] (<nom>).command: the program ("{{p}}") cannot come from a parameter` | un placeholder dans la première entrée | |
| 270 | | `tools[N] (<nom>).command: {{p}} is not a declared parameter` | un placeholder qu'aucun paramètre ne déclare | | 286 | | `tools[N] (<nom>).command: {{p}} is not a declared parameter` | un placeholder qu'aucun paramètre ne déclare | |
| 271 | | `tools[N] (<nom>).timeout must be >= 0` | un `timeout` négatif | | 287 | | `tools[N] (<nom>).timeout must be >= 0` | un `timeout` négatif | |
modified
docs/fr/reference/hooks.md +34 -4 | @@ -20,7 +20,10 @@ Chaque événement prend une liste ; les hooks d'un événement s'exécutent dan | ||
| 20 | 20 | |
| 21 | 21 | | Clé | Type | Défaut | Description | |
| 22 | 22 | |-----|------|--------|-------------| |
| 23 | -| `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. | | |
| 23 | +| `command` | chaîne | — | Exécutée par `sh -c "<command>"`. Obligatoire sauf si `wasm` est donné ; pas les deux. Ne doit pas être blanche. | | |
| 24 | +| `wasm` | chaîne | `""` | Un module WebAssembly (commande WASI preview 1) exécuté par `mm` dans un bac à sable à la place d'une commande — voir [Hooks wasm](#hooks-wasm). Relatif au fichier de configuration ; doit exister au chargement. | | |
| 25 | +| `read` | liste de chaînes | `[]` | `wasm` seulement. Dossiers du répertoire de travail que le module peut lire, montés sous le même nom. Relatifs, dans le répertoire de travail (ni `.`, ni au-dessus), chacun une seule fois dans `read` et `write` réunis. | | |
| 26 | +| `write` | liste de chaînes | `[]` | `wasm` seulement. Dossiers du répertoire de travail que le module peut lire et écrire. Mêmes règles que `read`. | | |
| 24 | 27 | | `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils ou agents) | `preToolCall` et `postToolCall` : le hook s'exécute pour les outils dont le nom correspond. `preAsk` et `postAsk` : pour les agents appelés dont le nom (leur clé dans `mmConfigs`) correspond. Interdit sur `preGeneration` et `postGeneration`. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. | |
| 25 | 28 | | `timeout` | durée | `10s` | Durée maximale d'une exécution avant qu'elle soit tuée. `0` = la valeur par défaut. Doit être ≥ 0. | |
| 26 | 29 | | `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). | |
| @@ -39,6 +42,10 @@ hooks: | ||
| 39 | 42 | postGeneration: |
| 40 | 43 | - command: python3 "$MM_CONFIG_DIR/hooks/log.py" |
| 41 | 44 | onError: ignore |
| 45 | + postToolCall: | |
| 46 | + - wasm: ./hooks/audit.wasm | |
| 47 | + write: [logs] | |
| 48 | + onError: ignore | |
| 42 | 49 | ``` |
| 43 | 50 | |
| 44 | 51 | ## Événements |
| @@ -77,6 +84,20 @@ Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisat | ||
| 77 | 84 | |
| 78 | 85 | L'environnement de `mm` contient aussi `MM_BIN` (le chemin du `mm` en cours) et `MM_CONFIG` (son fichier de configuration, quand il en a lu un), que `mm` pose pour tous les processus qu'il lance — voir la [référence de la ligne de commande](cli.md#variables-denvironnement). |
| 79 | 86 | |
| 87 | +### Hooks wasm | |
| 88 | + | |
| 89 | +Une entrée avec `wasm:` au lieu de `command:` lit la même [entrée](#entrée), écrit la même [réponse](#réponse) et suit les mêmes `matcher`, `timeout` et `onError`. Ces lignes remplacent celles du tableau ci-dessus : | |
| 90 | + | |
| 91 | +| Aspect | Valeur | | |
| 92 | +|--------|--------| | |
| 93 | +| Module | une instance neuve du module, exécutée par `mm` (wazero) ; aucun processus n'est lancé. Compilé une fois par processus `mm`, à sa première exécution — partagé avec les [outils wasm](tools.md#outils-wasm) | | |
| 94 | +| Environnement | `MM_HOOK_EVENT` seulement. Ni `MM_CONFIG_DIR`, ni les variables de `mm` | | |
| 95 | +| Fichiers | seulement les dossiers `read` (lecture seule) et `write` (lecture-écriture), dans le répertoire de travail de la session, montés sous leur propre nom (`logs` est `logs/…` ou `/logs/…` dans le module) ; tout autre chemin n'existe pas | | |
| 96 | +| Réseau | aucun (WASI preview 1 n'en a pas) | | |
| 97 | +| Horloge, aléa | l'horloge réelle et l'attente ; la source d'aléa sûre du système | | |
| 98 | +| Mémoire | au plus 256 Mio de mémoire linéaire | | |
| 99 | +| stdout, stderr | comme pour une commande : la réponse au plus 1 Mio, les 300 premiers octets de stderr cités quand l'exécution échoue | | |
| 100 | + | |
| 80 | 101 | ## Entrée |
| 81 | 102 | |
| 82 | 103 | L'objet JSON écrit sur l'entrée standard du hook. |
| @@ -211,6 +232,9 @@ Une exécution échoue quand : | ||
| 211 | 232 | | Échec | Message | |
| 212 | 233 | |-------|---------| |
| 213 | 234 | | la commande se termine avec un code non nul | `exit status <n>: <stderr>` | |
| 235 | +| un module wasm se termine avec un code non nul | `exit code <n>: <stderr>` | | |
| 236 | +| un module wasm ne peut pas être chargé | `<fichier>.wasm cannot be loaded: <raison>` | | |
| 237 | +| un des dossiers d'un module wasm n'existe pas | `the folder <d> this module may see does not exist in <répertoire>` | | |
| 214 | 238 | | elle dure plus que `timeout` | `timed out after <timeout>` | |
| 215 | 239 | | le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` | |
| 216 | 240 | | stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` | |
| @@ -228,7 +252,7 @@ La suite dépend d'`onError` : | ||
| 228 | 252 | | `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue | |
| 229 | 253 | | `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire | |
| 230 | 254 | |
| 231 | -La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP, sur stderr avec `mm --ask`. | |
| 255 | +La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` — pour un hook wasm, le nom du fichier du module à la place de la commande : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP, sur stderr avec `mm --ask`. | |
| 232 | 256 | |
| 233 | 257 | ## Affichage |
| 234 | 258 | |
| @@ -242,7 +266,7 @@ Avec `displayHooks: true`, chaque exécution de hook est affichée — sinon, un | ||
| 242 | 266 | |--------|--------| |
| 243 | 267 | | `<n>` | le numéro de l'exécution dans le tour, à partir de 1 (mode ligne, `-p`, fenêtre) | |
| 244 | 268 | | `<outil ou agent>` | le nom de l'outil (`preToolCall`, `postToolCall`) ou le nom `mmConfigs` de l'agent interrogé (`preAsk`, `postAsk`) ; absent pour `preGeneration` et `postGeneration` | |
| 245 | -| `<commande>` | la `command` du hook, telle qu'écrite dans la configuration | | |
| 269 | +| `<commande>` | la `command` du hook, telle qu'écrite dans la configuration ; pour un hook wasm, le nom du fichier du module (`bash_guard.wasm`) | | |
| 246 | 270 | | `<décision>` | `continue`, `stop`, `deny`, `failed` (l'exécution a échoué ; son `onError` a décidé de la suite), ou ce que la réponse a changé, joint par ` + ` : `prompt replaced`, `transcript replaced`, `answer replaced`, `input replaced`, `context (<n> chars)` | |
| 247 | 271 | |
| 248 | 272 | Seuls comptent les champs que l'événement lit : un `context` renvoyé à `postGeneration` est affiché `continue`. Le texte d'un contexte n'est jamais affiché, seulement sa longueur en caractères. |
| @@ -277,7 +301,13 @@ Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, co | ||
| 277 | 301 | | Message | Cause | |
| 278 | 302 | |---------|-------| |
| 279 | 303 | | `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) | |
| 280 | -| `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche | | |
| 304 | +| `hooks.<événement>[N]: command must not be empty (or wasm: a module)` | ni `command` ni `wasm`, ou une `command` blanche | | |
| 305 | +| `hooks.<événement>[N]: give command or wasm, not both` | une entrée avec les deux | | |
| 306 | +| `hooks.<événement>[N]: wasm: no module at <chemin>` | le fichier du module n'existe pas, ou c'est un dossier | | |
| 307 | +| `hooks.<événement>[N]: read and write only apply to a wasm hook — …` | `read` ou `write` sur une entrée `command` | | |
| 308 | +| `hooks.<événement>[N]: folder "<d>" must be relative to the working directory` | un dossier absolu | | |
| 309 | +| `hooks.<événement>[N]: folder "<d>" must be a folder inside the working directory, not it or above it` | `.`, `..` ou un chemin qui remonte au-dessus | | |
| 310 | +| `hooks.<événement>[N]: folder "<d>" is given twice` | un dossier listé deux fois | | |
| 281 | 311 | | `hooks.<événement>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | un `matcher` sur `preGeneration` ou `postGeneration` | |
| 282 | 312 | | `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas | |
| 283 | 313 | | `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif | |
| @@ -20,7 +20,10 @@ Chaque événement prend une liste ; les hooks d'un événement s'exécutent dan | |||
| 20 | 20 | ||
| 21 | | Clé | Type | Défaut | Description | | 21 | | Clé | Type | Défaut | Description | |
| 22 | |-----|------|--------|-------------| | 22 | |-----|------|--------|-------------| |
| 23 | -| `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. | | 23 | +| `command` | chaîne | — | Exécutée par `sh -c "<command>"`. Obligatoire sauf si `wasm` est donné ; pas les deux. Ne doit pas être blanche. | |
| 24 | +| `wasm` | chaîne | `""` | Un module WebAssembly (commande WASI preview 1) exécuté par `mm` dans un bac à sable à la place d'une commande — voir [Hooks wasm](#hooks-wasm). Relatif au fichier de configuration ; doit exister au chargement. | | ||
| 25 | +| `read` | liste de chaînes | `[]` | `wasm` seulement. Dossiers du répertoire de travail que le module peut lire, montés sous le même nom. Relatifs, dans le répertoire de travail (ni `.`, ni au-dessus), chacun une seule fois dans `read` et `write` réunis. | | ||
| 26 | +| `write` | liste de chaînes | `[]` | `wasm` seulement. Dossiers du répertoire de travail que le module peut lire et écrire. Mêmes règles que `read`. | | ||
| 24 | | `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils ou agents) | `preToolCall` et `postToolCall` : le hook s'exécute pour les outils dont le nom correspond. `preAsk` et `postAsk` : pour les agents appelés dont le nom (leur clé dans `mmConfigs`) correspond. Interdit sur `preGeneration` et `postGeneration`. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. | | 27 | | `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils ou agents) | `preToolCall` et `postToolCall` : le hook s'exécute pour les outils dont le nom correspond. `preAsk` et `postAsk` : pour les agents appelés dont le nom (leur clé dans `mmConfigs`) correspond. Interdit sur `preGeneration` et `postGeneration`. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. | |
| 25 | | `timeout` | durée | `10s` | Durée maximale d'une exécution avant qu'elle soit tuée. `0` = la valeur par défaut. Doit être ≥ 0. | | 28 | | `timeout` | durée | `10s` | Durée maximale d'une exécution avant qu'elle soit tuée. `0` = la valeur par défaut. Doit être ≥ 0. | |
| 26 | | `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). | | 29 | | `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). | |
| @@ -39,6 +42,10 @@ hooks: | |||
| 39 | postGeneration: | 42 | postGeneration: |
| 40 | - command: python3 "$MM_CONFIG_DIR/hooks/log.py" | 43 | - command: python3 "$MM_CONFIG_DIR/hooks/log.py" |
| 41 | onError: ignore | 44 | onError: ignore |
| 45 | + postToolCall: | ||
| 46 | + - wasm: ./hooks/audit.wasm | ||
| 47 | + write: [logs] | ||
| 48 | + onError: ignore | ||
| 42 | ``` | 49 | ``` |
| 43 | 50 | ||
| 44 | ## Événements | 51 | ## Événements |
| @@ -77,6 +84,20 @@ Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisat | |||
| 77 | 84 | ||
| 78 | L'environnement de `mm` contient aussi `MM_BIN` (le chemin du `mm` en cours) et `MM_CONFIG` (son fichier de configuration, quand il en a lu un), que `mm` pose pour tous les processus qu'il lance — voir la [référence de la ligne de commande](cli.md#variables-denvironnement). | 85 | L'environnement de `mm` contient aussi `MM_BIN` (le chemin du `mm` en cours) et `MM_CONFIG` (son fichier de configuration, quand il en a lu un), que `mm` pose pour tous les processus qu'il lance — voir la [référence de la ligne de commande](cli.md#variables-denvironnement). |
| 79 | 86 | ||
| 87 | +### Hooks wasm | ||
| 88 | + | ||
| 89 | +Une entrée avec `wasm:` au lieu de `command:` lit la même [entrée](#entrée), écrit la même [réponse](#réponse) et suit les mêmes `matcher`, `timeout` et `onError`. Ces lignes remplacent celles du tableau ci-dessus : | ||
| 90 | + | ||
| 91 | +| Aspect | Valeur | | ||
| 92 | +|--------|--------| | ||
| 93 | +| Module | une instance neuve du module, exécutée par `mm` (wazero) ; aucun processus n'est lancé. Compilé une fois par processus `mm`, à sa première exécution — partagé avec les [outils wasm](tools.md#outils-wasm) | | ||
| 94 | +| Environnement | `MM_HOOK_EVENT` seulement. Ni `MM_CONFIG_DIR`, ni les variables de `mm` | | ||
| 95 | +| Fichiers | seulement les dossiers `read` (lecture seule) et `write` (lecture-écriture), dans le répertoire de travail de la session, montés sous leur propre nom (`logs` est `logs/…` ou `/logs/…` dans le module) ; tout autre chemin n'existe pas | | ||
| 96 | +| Réseau | aucun (WASI preview 1 n'en a pas) | | ||
| 97 | +| Horloge, aléa | l'horloge réelle et l'attente ; la source d'aléa sûre du système | | ||
| 98 | +| Mémoire | au plus 256 Mio de mémoire linéaire | | ||
| 99 | +| stdout, stderr | comme pour une commande : la réponse au plus 1 Mio, les 300 premiers octets de stderr cités quand l'exécution échoue | | ||
| 100 | + | ||
| 80 | ## Entrée | 101 | ## Entrée |
| 81 | 102 | ||
| 82 | L'objet JSON écrit sur l'entrée standard du hook. | 103 | L'objet JSON écrit sur l'entrée standard du hook. |
| @@ -211,6 +232,9 @@ Une exécution échoue quand : | |||
| 211 | | Échec | Message | | 232 | | Échec | Message | |
| 212 | |-------|---------| | 233 | |-------|---------| |
| 213 | | la commande se termine avec un code non nul | `exit status <n>: <stderr>` | | 234 | | la commande se termine avec un code non nul | `exit status <n>: <stderr>` | |
| 235 | +| un module wasm se termine avec un code non nul | `exit code <n>: <stderr>` | | ||
| 236 | +| un module wasm ne peut pas être chargé | `<fichier>.wasm cannot be loaded: <raison>` | | ||
| 237 | +| un des dossiers d'un module wasm n'existe pas | `the folder <d> this module may see does not exist in <répertoire>` | | ||
| 214 | | elle dure plus que `timeout` | `timed out after <timeout>` | | 238 | | elle dure plus que `timeout` | `timed out after <timeout>` | |
| 215 | | le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` | | 239 | | le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` | |
| 216 | | stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` | | 240 | | stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` | |
| @@ -228,7 +252,7 @@ La suite dépend d'`onError` : | |||
| 228 | | `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue | | 252 | | `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue | |
| 229 | | `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire | | 253 | | `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire | |
| 230 | 254 | ||
| 231 | -La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP, sur stderr avec `mm --ask`. | 255 | +La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` — pour un hook wasm, le nom du fichier du module à la place de la commande : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP, sur stderr avec `mm --ask`. |
| 232 | 256 | ||
| 233 | ## Affichage | 257 | ## Affichage |
| 234 | 258 | ||
| @@ -242,7 +266,7 @@ Avec `displayHooks: true`, chaque exécution de hook est affichée — sinon, un | |||
| 242 | |--------|--------| | 266 | |--------|--------| |
| 243 | | `<n>` | le numéro de l'exécution dans le tour, à partir de 1 (mode ligne, `-p`, fenêtre) | | 267 | | `<n>` | le numéro de l'exécution dans le tour, à partir de 1 (mode ligne, `-p`, fenêtre) | |
| 244 | | `<outil ou agent>` | le nom de l'outil (`preToolCall`, `postToolCall`) ou le nom `mmConfigs` de l'agent interrogé (`preAsk`, `postAsk`) ; absent pour `preGeneration` et `postGeneration` | | 268 | | `<outil ou agent>` | le nom de l'outil (`preToolCall`, `postToolCall`) ou le nom `mmConfigs` de l'agent interrogé (`preAsk`, `postAsk`) ; absent pour `preGeneration` et `postGeneration` | |
| 245 | -| `<commande>` | la `command` du hook, telle qu'écrite dans la configuration | | 269 | +| `<commande>` | la `command` du hook, telle qu'écrite dans la configuration ; pour un hook wasm, le nom du fichier du module (`bash_guard.wasm`) | |
| 246 | | `<décision>` | `continue`, `stop`, `deny`, `failed` (l'exécution a échoué ; son `onError` a décidé de la suite), ou ce que la réponse a changé, joint par ` + ` : `prompt replaced`, `transcript replaced`, `answer replaced`, `input replaced`, `context (<n> chars)` | | 270 | | `<décision>` | `continue`, `stop`, `deny`, `failed` (l'exécution a échoué ; son `onError` a décidé de la suite), ou ce que la réponse a changé, joint par ` + ` : `prompt replaced`, `transcript replaced`, `answer replaced`, `input replaced`, `context (<n> chars)` | |
| 247 | 271 | ||
| 248 | Seuls comptent les champs que l'événement lit : un `context` renvoyé à `postGeneration` est affiché `continue`. Le texte d'un contexte n'est jamais affiché, seulement sa longueur en caractères. | 272 | Seuls comptent les champs que l'événement lit : un `context` renvoyé à `postGeneration` est affiché `continue`. Le texte d'un contexte n'est jamais affiché, seulement sa longueur en caractères. |
| @@ -277,7 +301,13 @@ Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, co | |||
| 277 | | Message | Cause | | 301 | | Message | Cause | |
| 278 | |---------|-------| | 302 | |---------|-------| |
| 279 | | `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) | | 303 | | `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) | |
| 280 | -| `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche | | 304 | +| `hooks.<événement>[N]: command must not be empty (or wasm: a module)` | ni `command` ni `wasm`, ou une `command` blanche | |
| 305 | +| `hooks.<événement>[N]: give command or wasm, not both` | une entrée avec les deux | | ||
| 306 | +| `hooks.<événement>[N]: wasm: no module at <chemin>` | le fichier du module n'existe pas, ou c'est un dossier | | ||
| 307 | +| `hooks.<événement>[N]: read and write only apply to a wasm hook — …` | `read` ou `write` sur une entrée `command` | | ||
| 308 | +| `hooks.<événement>[N]: folder "<d>" must be relative to the working directory` | un dossier absolu | | ||
| 309 | +| `hooks.<événement>[N]: folder "<d>" must be a folder inside the working directory, not it or above it` | `.`, `..` ou un chemin qui remonte au-dessus | | ||
| 310 | +| `hooks.<événement>[N]: folder "<d>" is given twice` | un dossier listé deux fois | | ||
| 281 | | `hooks.<événement>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | un `matcher` sur `preGeneration` ou `postGeneration` | | 311 | | `hooks.<événement>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | un `matcher` sur `preGeneration` ou `postGeneration` | |
| 282 | | `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas | | 312 | | `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas | |
| 283 | | `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif | | 313 | | `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif | |
modified
docs/fr/reference/tools.md +21 -0 | @@ -382,6 +382,27 @@ Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée ( | ||
| 382 | 382 | | Récapitulatif | Non compté dans `⚙ N command(s)`. | |
| 383 | 383 | | Hooks | `preToolCall` et `postToolCall` s'appliquent, avec le nom de l'outil comme `matcher`. | |
| 384 | 384 | |
| 385 | +### Outils wasm | |
| 386 | + | |
| 387 | +Une entrée avec `wasm:` au lieu de `command:` garde toutes les lignes ci-dessus, sauf celles qui portent sur la commande ; ces lignes-ci les remplacent. | |
| 388 | + | |
| 389 | +| Aspect | Comportement | | |
| 390 | +|--------|--------------| | |
| 391 | +| Exécution | Une instance neuve du module, exécutée par mm (wazero) comme commande WASI preview 1 ; aucun processus n'est lancé. Le module est compilé une fois par processus mm, à son premier appel. | | |
| 392 | +| Entrée | Un objet JSON sur stdin : chaque paramètre donné ou pris par défaut, dans son type déclaré (`"top": 5`, `"exact": true`) ; aucune clé pour un paramètre facultatif omis sans valeur par défaut. Arguments : le nom du programme seulement (le nom du fichier du module sans `.wasm`). | | |
| 393 | +| Sortie | stdout et stderr, dans l'ordre d'écriture, conservés jusqu'à 1 Mio ; au-delà, la sortie se termine par `[output cut at 1 MiB]`. Ensuite comme `bash` (troncature, écho). | | |
| 394 | +| Fin | `0` (ou le retour de `main`) est un succès ; un autre code ajoute `[exit code: <n>]` et marque l'appel en échec. | | |
| 395 | +| Fichiers | Seulement les dossiers `read` (lecture seule) et `write` (lecture-écriture), montés sous leur propre nom : `notes` est `notes/…` ou `/notes/…` dans le module. Tout autre chemin n'existe pas. Les dossiers sont pris dans le répertoire de travail (ligne ci-dessus). | | |
| 396 | +| Environnement, réseau | Aucune variable d'environnement. Pas de réseau (WASI preview 1 n'en a pas). | | |
| 397 | +| Horloge, aléa | L'horloge réelle et l'attente ; l'aléa vient de la source sûre du système. | | |
| 398 | +| Mémoire | Au plus 256 Mio de mémoire linéaire. | | |
| 399 | +| Délai, annulation | Le `timeout` de l'entrée, sinon `bashTimeout` : le module est arrêté et la sortie se termine par `[error: <délai> timeout exceeded]` ; un tour annulé la termine par `[error: cancelled]`. | | |
| 400 | +| Dossier manquant | `[error: the folder <d> this tool may see does not exist in <répertoire>]` ; rien n'est exécuté. | | |
| 401 | +| Module impossible à charger | `[error: <fichier>.wasm cannot be loaded: <raison>]`. | | |
| 402 | +| Affichage terminal | `🧰 <nom>: <fichier>.wasm <JSON d'entrée>`. | | |
| 403 | +| ACP et fenêtre | Appel d'outil de type `execute`, titre `<nom>: <fichier>.wasm <JSON d'entrée>`. | | |
| 404 | +| Concurrence | Les appels d'un même module peuvent s'exécuter en même temps, chacun dans sa propre instance ; aucun état ne survit à un appel. | | |
| 405 | + | |
| 385 | 406 | ## Détection de boucle |
| 386 | 407 | |
| 387 | 408 | Chaque outil enregistre `(outil, entrée, sortie)` dans le détecteur de boucle après chaque appel. |
| @@ -382,6 +382,27 @@ Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée ( | |||
| 382 | | Récapitulatif | Non compté dans `⚙ N command(s)`. | | 382 | | Récapitulatif | Non compté dans `⚙ N command(s)`. | |
| 383 | | Hooks | `preToolCall` et `postToolCall` s'appliquent, avec le nom de l'outil comme `matcher`. | | 383 | | Hooks | `preToolCall` et `postToolCall` s'appliquent, avec le nom de l'outil comme `matcher`. | |
| 384 | 384 | ||
| 385 | +### Outils wasm | ||
| 386 | + | ||
| 387 | +Une entrée avec `wasm:` au lieu de `command:` garde toutes les lignes ci-dessus, sauf celles qui portent sur la commande ; ces lignes-ci les remplacent. | ||
| 388 | + | ||
| 389 | +| Aspect | Comportement | | ||
| 390 | +|--------|--------------| | ||
| 391 | +| Exécution | Une instance neuve du module, exécutée par mm (wazero) comme commande WASI preview 1 ; aucun processus n'est lancé. Le module est compilé une fois par processus mm, à son premier appel. | | ||
| 392 | +| Entrée | Un objet JSON sur stdin : chaque paramètre donné ou pris par défaut, dans son type déclaré (`"top": 5`, `"exact": true`) ; aucune clé pour un paramètre facultatif omis sans valeur par défaut. Arguments : le nom du programme seulement (le nom du fichier du module sans `.wasm`). | | ||
| 393 | +| Sortie | stdout et stderr, dans l'ordre d'écriture, conservés jusqu'à 1 Mio ; au-delà, la sortie se termine par `[output cut at 1 MiB]`. Ensuite comme `bash` (troncature, écho). | | ||
| 394 | +| Fin | `0` (ou le retour de `main`) est un succès ; un autre code ajoute `[exit code: <n>]` et marque l'appel en échec. | | ||
| 395 | +| Fichiers | Seulement les dossiers `read` (lecture seule) et `write` (lecture-écriture), montés sous leur propre nom : `notes` est `notes/…` ou `/notes/…` dans le module. Tout autre chemin n'existe pas. Les dossiers sont pris dans le répertoire de travail (ligne ci-dessus). | | ||
| 396 | +| Environnement, réseau | Aucune variable d'environnement. Pas de réseau (WASI preview 1 n'en a pas). | | ||
| 397 | +| Horloge, aléa | L'horloge réelle et l'attente ; l'aléa vient de la source sûre du système. | | ||
| 398 | +| Mémoire | Au plus 256 Mio de mémoire linéaire. | | ||
| 399 | +| Délai, annulation | Le `timeout` de l'entrée, sinon `bashTimeout` : le module est arrêté et la sortie se termine par `[error: <délai> timeout exceeded]` ; un tour annulé la termine par `[error: cancelled]`. | | ||
| 400 | +| Dossier manquant | `[error: the folder <d> this tool may see does not exist in <répertoire>]` ; rien n'est exécuté. | | ||
| 401 | +| Module impossible à charger | `[error: <fichier>.wasm cannot be loaded: <raison>]`. | | ||
| 402 | +| Affichage terminal | `🧰 <nom>: <fichier>.wasm <JSON d'entrée>`. | | ||
| 403 | +| ACP et fenêtre | Appel d'outil de type `execute`, titre `<nom>: <fichier>.wasm <JSON d'entrée>`. | | ||
| 404 | +| Concurrence | Les appels d'un même module peuvent s'exécuter en même temps, chacun dans sa propre instance ; aucun état ne survit à un appel. | | ||
| 405 | + | ||
| 385 | ## Détection de boucle | 406 | ## Détection de boucle |
| 386 | 407 | ||
| 387 | Chaque outil enregistre `(outil, entrée, sortie)` dans le détecteur de boucle après chaque appel. | 408 | Chaque outil enregistre `(outil, entrée, sortie)` dans le détecteur de boucle après chaque appel. |
modified
go.mod +1 -0 | @@ -10,6 +10,7 @@ require ( | ||
| 10 | 10 | github.com/gdamore/tcell/v2 v2.13.10 |
| 11 | 11 | github.com/openai/openai-go v1.8.2 |
| 12 | 12 | github.com/sashabaranov/go-openai v1.42.1 |
| 13 | + github.com/tetratelabs/wazero v1.12.0 | |
| 13 | 14 | go.lsp.dev/jsonrpc2 v1.0.1 |
| 14 | 15 | go.lsp.dev/protocol v1.0.1 |
| 15 | 16 | go.lsp.dev/uri v1.0.1 |
| @@ -10,6 +10,7 @@ require ( | |||
| 10 | github.com/gdamore/tcell/v2 v2.13.10 | 10 | github.com/gdamore/tcell/v2 v2.13.10 |
| 11 | github.com/openai/openai-go v1.8.2 | 11 | github.com/openai/openai-go v1.8.2 |
| 12 | github.com/sashabaranov/go-openai v1.42.1 | 12 | github.com/sashabaranov/go-openai v1.42.1 |
| 13 | + github.com/tetratelabs/wazero v1.12.0 | ||
| 13 | go.lsp.dev/jsonrpc2 v1.0.1 | 14 | go.lsp.dev/jsonrpc2 v1.0.1 |
| 14 | go.lsp.dev/protocol v1.0.1 | 15 | go.lsp.dev/protocol v1.0.1 |
| 15 | go.lsp.dev/uri v1.0.1 | 16 | go.lsp.dev/uri v1.0.1 |
modified
go.sum +2 -0 | @@ -56,6 +56,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ | ||
| 56 | 56 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
| 57 | 57 | github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= |
| 58 | 58 | github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= |
| 59 | +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= | |
| 60 | +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= | |
| 59 | 61 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= |
| 60 | 62 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= |
| 61 | 63 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= |
| @@ -56,6 +56,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ | |||
| 56 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | 56 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
| 57 | github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= | 57 | github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= |
| 58 | github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= | 58 | github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= |
| 59 | +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= | ||
| 60 | +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= | ||
| 59 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= | 61 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= |
| 60 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= | 62 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= |
| 61 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= | 63 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= |
modified
internal/config/config.go +1 -0 | @@ -693,6 +693,7 @@ func Load(cliPath string) (string, error) { | ||
| 693 | 693 | } |
| 694 | 694 | resolveAgentPaths(path) |
| 695 | 695 | resolveHooks(path) |
| 696 | + resolveToolPaths(path) | |
| 696 | 697 | if err := Cfg.validate(); err != nil { |
| 697 | 698 | return "", fmt.Errorf("%s: %w", path, err) |
| 698 | 699 | } |
| @@ -693,6 +693,7 @@ func Load(cliPath string) (string, error) { | |||
| 693 | } | 693 | } |
| 694 | resolveAgentPaths(path) | 694 | resolveAgentPaths(path) |
| 695 | resolveHooks(path) | 695 | resolveHooks(path) |
| 696 | + resolveToolPaths(path) | ||
| 696 | if err := Cfg.validate(); err != nil { | 697 | if err := Cfg.validate(); err != nil { |
| 697 | return "", fmt.Errorf("%s: %w", path, err) | 698 | return "", fmt.Errorf("%s: %w", path, err) |
| 698 | } | 699 | } |
modified
internal/config/hooks.go +52 -2 | @@ -85,6 +85,15 @@ type HooksConfig struct { | ||
| 85 | 85 | type Hook struct { |
| 86 | 86 | // Command is run with `sh -c`, in the session's working directory. |
| 87 | 87 | Command string `yaml:"command"` |
| 88 | + // Wasm: a WebAssembly module (WASI command) run instead of Command | |
| 89 | + // (ticket 120), in the sandbox of the wasm tools. Relative to the agent | |
| 90 | + // file, made absolute at load. It reads the same JSON and prints the | |
| 91 | + // same reply as a command hook. | |
| 92 | + Wasm string `yaml:"wasm"` | |
| 93 | + // Read and Write: the folders of the working directory a Wasm hook may | |
| 94 | + // see, read-only or read-write. Nothing else is visible to it. | |
| 95 | + Read []string `yaml:"read"` | |
| 96 | + Write []string `yaml:"write"` | |
| 88 | 97 | // Matcher is a regular expression on the tool name (preToolCall and |
| 89 | 98 | // postToolCall) or on the asked agent's name (preAsk and postAsk). |
| 90 | 99 | // Empty = every one. Unanchored, like grep: "bash" also matches |
| @@ -134,6 +143,27 @@ func (h *HooksConfig) slot(event string) *[]Hook { | ||
| 134 | 143 | return nil |
| 135 | 144 | } |
| 136 | 145 | |
| 146 | +// IsWasm reports whether the hook runs a WebAssembly module, not a command. | |
| 147 | +func (h Hook) IsWasm() bool { | |
| 148 | + return h.Wasm != "" | |
| 149 | +} | |
| 150 | + | |
| 151 | +// Folders lists the folders a Wasm hook opens, read first, then write. | |
| 152 | +func (h Hook) Folders() []ToolFolder { | |
| 153 | + return sandboxFolders(h.Read, h.Write) | |
| 154 | +} | |
| 155 | + | |
| 156 | +// Label names the hook in reports and warnings: its command, or its | |
| 157 | +// module's file name. | |
| 158 | +// | |
| 159 | +// Hook{Wasm: "/abs/hooks/bash_guard.wasm"}.Label() // "bash_guard.wasm" | |
| 160 | +func (h Hook) Label() string { | |
| 161 | + if h.IsWasm() { | |
| 162 | + return filepath.Base(h.Wasm) | |
| 163 | + } | |
| 164 | + return h.Command | |
| 165 | +} | |
| 166 | + | |
| 137 | 167 | // For returns the hooks declared for an event, in order; nil for an event |
| 138 | 168 | // with none (or an unknown one). |
| 139 | 169 | // |
| @@ -173,6 +203,22 @@ func (h Hook) Matches(name string) bool { | ||
| 173 | 203 | return err == nil && re.MatchString(name) |
| 174 | 204 | } |
| 175 | 205 | |
| 206 | +// validateRunnable checks what the hook runs: a command, or a module and | |
| 207 | +// its folders — not both, and folders only for a module. | |
| 208 | +func (h Hook) validateRunnable() error { | |
| 209 | + switch { | |
| 210 | + case h.IsWasm() && strings.TrimSpace(h.Command) != "": | |
| 211 | + return errors.New("give command or wasm, not both") | |
| 212 | + case h.IsWasm(): | |
| 213 | + return validateSandbox(h.Wasm, h.Folders()) | |
| 214 | + case len(h.Read)+len(h.Write) > 0: | |
| 215 | + return errors.New("read and write only apply to a wasm hook — a command already sees every file") | |
| 216 | + case strings.TrimSpace(h.Command) == "": | |
| 217 | + return errors.New("command must not be empty (or wasm: a module)") | |
| 218 | + } | |
| 219 | + return nil | |
| 220 | +} | |
| 221 | + | |
| 176 | 222 | // validate checks every hook entry: a command, a matcher only where a tool |
| 177 | 223 | // or an agent is named and one that compiles, a timeout that is not |
| 178 | 224 | // negative, and a known onError. |
| @@ -189,9 +235,10 @@ func (h HooksConfig) validate() error { | ||
| 189 | 235 | |
| 190 | 236 | func (h Hook) validate(event string) error { |
| 191 | 237 | named := event != HookPreGeneration && event != HookPostGeneration |
| 238 | + if err := h.validateRunnable(); err != nil { | |
| 239 | + return err | |
| 240 | + } | |
| 192 | 241 | switch { |
| 193 | - case strings.TrimSpace(h.Command) == "": | |
| 194 | - return errors.New("command must not be empty") | |
| 195 | 242 | case h.Matcher != "" && !named: |
| 196 | 243 | return fmt.Errorf("matcher is only for %s, %s, %s and %s", HookPreToolCall, HookPostToolCall, HookPreAsk, HookPostAsk) |
| 197 | 244 | case h.Timeout < 0: |
| @@ -217,6 +264,9 @@ func resolveHooks(configPath string) { | ||
| 217 | 264 | for _, event := range hookEvents { |
| 218 | 265 | list := Cfg.Hooks.For(event) |
| 219 | 266 | for i := range list { |
| 267 | + if list[i].Wasm != "" && !filepath.IsAbs(list[i].Wasm) { | |
| 268 | + list[i].Wasm = filepath.Join(Cfg.Hooks.ConfigDir, list[i].Wasm) | |
| 269 | + } | |
| 220 | 270 | if list[i].Timeout == 0 { |
| 221 | 271 | list[i].Timeout = DefaultHookTimeout |
| 222 | 272 | } |
| @@ -85,6 +85,15 @@ type HooksConfig struct { | |||
| 85 | type Hook struct { | 85 | type Hook struct { |
| 86 | // Command is run with `sh -c`, in the session's working directory. | 86 | // Command is run with `sh -c`, in the session's working directory. |
| 87 | Command string `yaml:"command"` | 87 | Command string `yaml:"command"` |
| 88 | + // Wasm: a WebAssembly module (WASI command) run instead of Command | ||
| 89 | + // (ticket 120), in the sandbox of the wasm tools. Relative to the agent | ||
| 90 | + // file, made absolute at load. It reads the same JSON and prints the | ||
| 91 | + // same reply as a command hook. | ||
| 92 | + Wasm string `yaml:"wasm"` | ||
| 93 | + // Read and Write: the folders of the working directory a Wasm hook may | ||
| 94 | + // see, read-only or read-write. Nothing else is visible to it. | ||
| 95 | + Read []string `yaml:"read"` | ||
| 96 | + Write []string `yaml:"write"` | ||
| 88 | // Matcher is a regular expression on the tool name (preToolCall and | 97 | // Matcher is a regular expression on the tool name (preToolCall and |
| 89 | // postToolCall) or on the asked agent's name (preAsk and postAsk). | 98 | // postToolCall) or on the asked agent's name (preAsk and postAsk). |
| 90 | // Empty = every one. Unanchored, like grep: "bash" also matches | 99 | // Empty = every one. Unanchored, like grep: "bash" also matches |
| @@ -134,6 +143,27 @@ func (h *HooksConfig) slot(event string) *[]Hook { | |||
| 134 | return nil | 143 | return nil |
| 135 | } | 144 | } |
| 136 | 145 | ||
| 146 | +// IsWasm reports whether the hook runs a WebAssembly module, not a command. | ||
| 147 | +func (h Hook) IsWasm() bool { | ||
| 148 | + return h.Wasm != "" | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +// Folders lists the folders a Wasm hook opens, read first, then write. | ||
| 152 | +func (h Hook) Folders() []ToolFolder { | ||
| 153 | + return sandboxFolders(h.Read, h.Write) | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +// Label names the hook in reports and warnings: its command, or its | ||
| 157 | +// module's file name. | ||
| 158 | +// | ||
| 159 | +// Hook{Wasm: "/abs/hooks/bash_guard.wasm"}.Label() // "bash_guard.wasm" | ||
| 160 | +func (h Hook) Label() string { | ||
| 161 | + if h.IsWasm() { | ||
| 162 | + return filepath.Base(h.Wasm) | ||
| 163 | + } | ||
| 164 | + return h.Command | ||
| 165 | +} | ||
| 166 | + | ||
| 137 | // For returns the hooks declared for an event, in order; nil for an event | 167 | // For returns the hooks declared for an event, in order; nil for an event |
| 138 | // with none (or an unknown one). | 168 | // with none (or an unknown one). |
| 139 | // | 169 | // |
| @@ -173,6 +203,22 @@ func (h Hook) Matches(name string) bool { | |||
| 173 | return err == nil && re.MatchString(name) | 203 | return err == nil && re.MatchString(name) |
| 174 | } | 204 | } |
| 175 | 205 | ||
| 206 | +// validateRunnable checks what the hook runs: a command, or a module and | ||
| 207 | +// its folders — not both, and folders only for a module. | ||
| 208 | +func (h Hook) validateRunnable() error { | ||
| 209 | + switch { | ||
| 210 | + case h.IsWasm() && strings.TrimSpace(h.Command) != "": | ||
| 211 | + return errors.New("give command or wasm, not both") | ||
| 212 | + case h.IsWasm(): | ||
| 213 | + return validateSandbox(h.Wasm, h.Folders()) | ||
| 214 | + case len(h.Read)+len(h.Write) > 0: | ||
| 215 | + return errors.New("read and write only apply to a wasm hook — a command already sees every file") | ||
| 216 | + case strings.TrimSpace(h.Command) == "": | ||
| 217 | + return errors.New("command must not be empty (or wasm: a module)") | ||
| 218 | + } | ||
| 219 | + return nil | ||
| 220 | +} | ||
| 221 | + | ||
| 176 | // validate checks every hook entry: a command, a matcher only where a tool | 222 | // validate checks every hook entry: a command, a matcher only where a tool |
| 177 | // or an agent is named and one that compiles, a timeout that is not | 223 | // or an agent is named and one that compiles, a timeout that is not |
| 178 | // negative, and a known onError. | 224 | // negative, and a known onError. |
| @@ -189,9 +235,10 @@ func (h HooksConfig) validate() error { | |||
| 189 | 235 | ||
| 190 | func (h Hook) validate(event string) error { | 236 | func (h Hook) validate(event string) error { |
| 191 | named := event != HookPreGeneration && event != HookPostGeneration | 237 | named := event != HookPreGeneration && event != HookPostGeneration |
| 238 | + if err := h.validateRunnable(); err != nil { | ||
| 239 | + return err | ||
| 240 | + } | ||
| 192 | switch { | 241 | switch { |
| 193 | - case strings.TrimSpace(h.Command) == "": | ||
| 194 | - return errors.New("command must not be empty") | ||
| 195 | case h.Matcher != "" && !named: | 242 | case h.Matcher != "" && !named: |
| 196 | return fmt.Errorf("matcher is only for %s, %s, %s and %s", HookPreToolCall, HookPostToolCall, HookPreAsk, HookPostAsk) | 243 | return fmt.Errorf("matcher is only for %s, %s, %s and %s", HookPreToolCall, HookPostToolCall, HookPreAsk, HookPostAsk) |
| 197 | case h.Timeout < 0: | 244 | case h.Timeout < 0: |
| @@ -217,6 +264,9 @@ func resolveHooks(configPath string) { | |||
| 217 | for _, event := range hookEvents { | 264 | for _, event := range hookEvents { |
| 218 | list := Cfg.Hooks.For(event) | 265 | list := Cfg.Hooks.For(event) |
| 219 | for i := range list { | 266 | for i := range list { |
| 267 | + if list[i].Wasm != "" && !filepath.IsAbs(list[i].Wasm) { | ||
| 268 | + list[i].Wasm = filepath.Join(Cfg.Hooks.ConfigDir, list[i].Wasm) | ||
| 269 | + } | ||
| 220 | if list[i].Timeout == 0 { | 270 | if list[i].Timeout == 0 { |
| 221 | list[i].Timeout = DefaultHookTimeout | 271 | list[i].Timeout = DefaultHookTimeout |
| 222 | } | 272 | } |
modified
internal/config/hooks_test.go +44 -0 | @@ -1,12 +1,48 @@ | ||
| 1 | 1 | package config |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "os" | |
| 4 | 5 | "path/filepath" |
| 5 | 6 | "strings" |
| 6 | 7 | "testing" |
| 7 | 8 | "time" |
| 8 | 9 | ) |
| 9 | 10 | |
| 11 | +// A wasm hook: its module is found next to the agent file, its folders | |
| 12 | +// stay relative, and it is labelled by its file name. | |
| 13 | +func TestWasmHookIsResolvedAgainstTheAgentFile(t *testing.T) { | |
| 14 | + defaults := Cfg | |
| 15 | + t.Cleanup(func() { Cfg = defaults }) | |
| 16 | + dir := t.TempDir() | |
| 17 | + if err := os.MkdirAll(filepath.Join(dir, "hooks"), 0o755); err != nil { | |
| 18 | + t.Fatal(err) | |
| 19 | + } | |
| 20 | + if err := os.WriteFile(filepath.Join(dir, "hooks", "guard.wasm"), []byte("\x00asm"), 0o644); err != nil { | |
| 21 | + t.Fatal(err) | |
| 22 | + } | |
| 23 | + path := filepath.Join(dir, "agent.yaml") | |
| 24 | + body := "model: m\nhooks:\n postToolCall:\n - wasm: ./hooks/guard.wasm\n write: [logs]\n" | |
| 25 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 26 | + t.Fatal(err) | |
| 27 | + } | |
| 28 | + if _, err := Load(path); err != nil { | |
| 29 | + t.Fatal(err) | |
| 30 | + } | |
| 31 | + h := Cfg.Hooks.PostToolCall[0] | |
| 32 | + if want := filepath.Join(dir, "hooks", "guard.wasm"); h.Wasm != want || !h.IsWasm() { | |
| 33 | + t.Errorf("wasm = %q, want %q", h.Wasm, want) | |
| 34 | + } | |
| 35 | + if got := h.Folders(); len(got) != 1 || got[0] != (ToolFolder{Path: "logs", Writable: true}) { | |
| 36 | + t.Errorf("folders = %v", got) | |
| 37 | + } | |
| 38 | + if h.Label() != "guard.wasm" || (Hook{Command: "node x.js"}).Label() != "node x.js" { | |
| 39 | + t.Errorf("labels = %q, %q", h.Label(), Hook{Command: "node x.js"}.Label()) | |
| 40 | + } | |
| 41 | + if h.Timeout != DefaultHookTimeout || h.OnError != HookOnErrorWarn { | |
| 42 | + t.Errorf("defaults not applied: %+v", h) | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 10 | 46 | // No `hooks:` block: no hook, and an empty banner summary. |
| 11 | 47 | func TestHooksDefaultToNone(t *testing.T) { |
| 12 | 48 | if err := loadYAML(t, "model: m\n"); err != nil { |
| @@ -106,6 +142,14 @@ func TestInvalidHooksAreRejected(t *testing.T) { | ||
| 106 | 142 | "hooks:\n postGeneration:\n - command: x\n timeout: -1s\n", "timeout must be >= 0"}, |
| 107 | 143 | "unknown onError": { |
| 108 | 144 | "hooks:\n preToolCall:\n - command: x\n onError: retry\n", "onError must be"}, |
| 145 | + "command and wasm": { | |
| 146 | + "hooks:\n preToolCall:\n - command: x\n wasm: /dev/null\n", "hooks.preToolCall[0]: give command or wasm, not both"}, | |
| 147 | + "missing module": { | |
| 148 | + "hooks:\n preGeneration:\n - wasm: ./nope.wasm\n", "hooks.preGeneration[0]: wasm: no module at"}, | |
| 149 | + "read on a command hook": { | |
| 150 | + "hooks:\n postToolCall:\n - command: x\n read: [notes]\n", "only apply to a wasm hook"}, | |
| 151 | + "folder above": { | |
| 152 | + "hooks:\n postToolCall:\n - wasm: /dev/null\n write: [../logs]\n", "not it or above it"}, | |
| 109 | 153 | } |
| 110 | 154 | for name, tc := range cases { |
| 111 | 155 | t.Run(name, func(t *testing.T) { |
| @@ -1,12 +1,48 @@ | |||
| 1 | package config | 1 | package config |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | + "os" | ||
| 4 | "path/filepath" | 5 | "path/filepath" |
| 5 | "strings" | 6 | "strings" |
| 6 | "testing" | 7 | "testing" |
| 7 | "time" | 8 | "time" |
| 8 | ) | 9 | ) |
| 9 | 10 | ||
| 11 | +// A wasm hook: its module is found next to the agent file, its folders | ||
| 12 | +// stay relative, and it is labelled by its file name. | ||
| 13 | +func TestWasmHookIsResolvedAgainstTheAgentFile(t *testing.T) { | ||
| 14 | + defaults := Cfg | ||
| 15 | + t.Cleanup(func() { Cfg = defaults }) | ||
| 16 | + dir := t.TempDir() | ||
| 17 | + if err := os.MkdirAll(filepath.Join(dir, "hooks"), 0o755); err != nil { | ||
| 18 | + t.Fatal(err) | ||
| 19 | + } | ||
| 20 | + if err := os.WriteFile(filepath.Join(dir, "hooks", "guard.wasm"), []byte("\x00asm"), 0o644); err != nil { | ||
| 21 | + t.Fatal(err) | ||
| 22 | + } | ||
| 23 | + path := filepath.Join(dir, "agent.yaml") | ||
| 24 | + body := "model: m\nhooks:\n postToolCall:\n - wasm: ./hooks/guard.wasm\n write: [logs]\n" | ||
| 25 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | ||
| 26 | + t.Fatal(err) | ||
| 27 | + } | ||
| 28 | + if _, err := Load(path); err != nil { | ||
| 29 | + t.Fatal(err) | ||
| 30 | + } | ||
| 31 | + h := Cfg.Hooks.PostToolCall[0] | ||
| 32 | + if want := filepath.Join(dir, "hooks", "guard.wasm"); h.Wasm != want || !h.IsWasm() { | ||
| 33 | + t.Errorf("wasm = %q, want %q", h.Wasm, want) | ||
| 34 | + } | ||
| 35 | + if got := h.Folders(); len(got) != 1 || got[0] != (ToolFolder{Path: "logs", Writable: true}) { | ||
| 36 | + t.Errorf("folders = %v", got) | ||
| 37 | + } | ||
| 38 | + if h.Label() != "guard.wasm" || (Hook{Command: "node x.js"}).Label() != "node x.js" { | ||
| 39 | + t.Errorf("labels = %q, %q", h.Label(), Hook{Command: "node x.js"}.Label()) | ||
| 40 | + } | ||
| 41 | + if h.Timeout != DefaultHookTimeout || h.OnError != HookOnErrorWarn { | ||
| 42 | + t.Errorf("defaults not applied: %+v", h) | ||
| 43 | + } | ||
| 44 | +} | ||
| 45 | + | ||
| 10 | // No `hooks:` block: no hook, and an empty banner summary. | 46 | // No `hooks:` block: no hook, and an empty banner summary. |
| 11 | func TestHooksDefaultToNone(t *testing.T) { | 47 | func TestHooksDefaultToNone(t *testing.T) { |
| 12 | if err := loadYAML(t, "model: m\n"); err != nil { | 48 | if err := loadYAML(t, "model: m\n"); err != nil { |
| @@ -106,6 +142,14 @@ func TestInvalidHooksAreRejected(t *testing.T) { | |||
| 106 | "hooks:\n postGeneration:\n - command: x\n timeout: -1s\n", "timeout must be >= 0"}, | 142 | "hooks:\n postGeneration:\n - command: x\n timeout: -1s\n", "timeout must be >= 0"}, |
| 107 | "unknown onError": { | 143 | "unknown onError": { |
| 108 | "hooks:\n preToolCall:\n - command: x\n onError: retry\n", "onError must be"}, | 144 | "hooks:\n preToolCall:\n - command: x\n onError: retry\n", "onError must be"}, |
| 145 | + "command and wasm": { | ||
| 146 | + "hooks:\n preToolCall:\n - command: x\n wasm: /dev/null\n", "hooks.preToolCall[0]: give command or wasm, not both"}, | ||
| 147 | + "missing module": { | ||
| 148 | + "hooks:\n preGeneration:\n - wasm: ./nope.wasm\n", "hooks.preGeneration[0]: wasm: no module at"}, | ||
| 149 | + "read on a command hook": { | ||
| 150 | + "hooks:\n postToolCall:\n - command: x\n read: [notes]\n", "only apply to a wasm hook"}, | ||
| 151 | + "folder above": { | ||
| 152 | + "hooks:\n postToolCall:\n - wasm: /dev/null\n write: [../logs]\n", "not it or above it"}, | ||
| 109 | } | 153 | } |
| 110 | for name, tc := range cases { | 154 | for name, tc := range cases { |
| 111 | t.Run(name, func(t *testing.T) { | 155 | t.Run(name, func(t *testing.T) { |
modified
internal/config/tools.go +115 -4 | @@ -18,9 +18,24 @@ package config | ||
| 18 | 18 | // The command runs WITHOUT a shell: each element is one argv entry, and a |
| 19 | 19 | // parameter's value replaces its {{placeholder}} inside one entry, so a value |
| 20 | 20 | // cannot add an argument, a pipe or a redirection. |
| 21 | +// | |
| 22 | +// An entry may name a WebAssembly module instead of a command (ticket 119): | |
| 23 | +// mm runs it itself, in a sandbox that sees no file, no environment variable | |
| 24 | +// and no network, except the project folders the entry opens. | |
| 25 | +// | |
| 26 | +// tools: | |
| 27 | +// - name: search_notes | |
| 28 | +// description: Find the notes that mention a word. | |
| 29 | +// parameters: | |
| 30 | +// word: { type: string } | |
| 31 | +// wasm: ./tools/search_notes.wasm # relative to this file | |
| 32 | +// read: [notes] # relative to the working directory | |
| 33 | +// write: [out] | |
| 21 | 34 | |
| 22 | 35 | import ( |
| 23 | 36 | "fmt" |
| 37 | + "os" | |
| 38 | + "path/filepath" | |
| 24 | 39 | "regexp" |
| 25 | 40 | "slices" |
| 26 | 41 | "strconv" |
| @@ -80,10 +95,72 @@ type DeclaredTool struct { | ||
| 80 | 95 | // Command: the program and its arguments, one argv entry each; a |
| 81 | 96 | // {{parameter}} inside an entry is replaced by the call's value. |
| 82 | 97 | Command []string `yaml:"command"` |
| 98 | + // Wasm: a WebAssembly module (WASI command) to run instead of Command. | |
| 99 | + // Relative to the agent file, made absolute at load. The call's | |
| 100 | + // arguments reach it as one JSON object on stdin. | |
| 101 | + Wasm string `yaml:"wasm"` | |
| 102 | + // Read and Write: the folders of the working directory a Wasm module | |
| 103 | + // may see, read-only or read-write. Nothing else is visible to it. | |
| 104 | + Read []string `yaml:"read"` | |
| 105 | + Write []string `yaml:"write"` | |
| 83 | 106 | // Timeout: how long one call may run. 0 = bashTimeout. |
| 84 | 107 | Timeout time.Duration `yaml:"timeout"` |
| 85 | 108 | } |
| 86 | 109 | |
| 110 | +// IsWasm reports whether the tool runs a WebAssembly module, not a command. | |
| 111 | +func (t DeclaredTool) IsWasm() bool { | |
| 112 | + return t.Wasm != "" | |
| 113 | +} | |
| 114 | + | |
| 115 | +// Folders lists the folders a Wasm tool opens, with whether each may be | |
| 116 | +// written, in the order the file gives them: read first, then write. | |
| 117 | +func (t DeclaredTool) Folders() []ToolFolder { | |
| 118 | + return sandboxFolders(t.Read, t.Write) | |
| 119 | +} | |
| 120 | + | |
| 121 | +// ToolFolder is one folder a Wasm module — a tool's or a hook's — may see: | |
| 122 | +// Path is relative to the working directory, and is also where the module | |
| 123 | +// finds it ("notes" → notes/… or /notes/… inside the module). | |
| 124 | +type ToolFolder struct { | |
| 125 | + Path string | |
| 126 | + Writable bool | |
| 127 | +} | |
| 128 | + | |
| 129 | +// sandboxFolders turns `read:` and `write:` into folders, cleaned, read | |
| 130 | +// first. | |
| 131 | +func sandboxFolders(read, write []string) []ToolFolder { | |
| 132 | + var folders []ToolFolder | |
| 133 | + for _, f := range read { | |
| 134 | + folders = append(folders, ToolFolder{Path: filepath.Clean(f)}) | |
| 135 | + } | |
| 136 | + for _, f := range write { | |
| 137 | + folders = append(folders, ToolFolder{Path: filepath.Clean(f), Writable: true}) | |
| 138 | + } | |
| 139 | + return folders | |
| 140 | +} | |
| 141 | + | |
| 142 | +// validateSandbox checks what a Wasm module — a tool's or a hook's — is | |
| 143 | +// given: a module file that exists, and folders that stay inside the | |
| 144 | +// working directory, each once. The caller names the entry. | |
| 145 | +func validateSandbox(module string, folders []ToolFolder) error { | |
| 146 | + if info, err := os.Stat(module); err != nil || info.IsDir() { | |
| 147 | + return fmt.Errorf("wasm: no module at %s", module) | |
| 148 | + } | |
| 149 | + seen := map[string]bool{} | |
| 150 | + for _, f := range folders { | |
| 151 | + switch { | |
| 152 | + case filepath.IsAbs(f.Path): | |
| 153 | + return fmt.Errorf("folder %q must be relative to the working directory", f.Path) | |
| 154 | + case f.Path == "." || f.Path == ".." || strings.HasPrefix(f.Path, ".."+string(filepath.Separator)): | |
| 155 | + return fmt.Errorf("folder %q must be a folder inside the working directory, not it or above it", f.Path) | |
| 156 | + case seen[f.Path]: | |
| 157 | + return fmt.Errorf("folder %q is given twice", f.Path) | |
| 158 | + } | |
| 159 | + seen[f.Path] = true | |
| 160 | + } | |
| 161 | + return nil | |
| 162 | +} | |
| 163 | + | |
| 87 | 164 | // ToolParameter is one argument of a declared tool. |
| 88 | 165 | type ToolParameter struct { |
| 89 | 166 | // Name: the key under `parameters:`. |
| @@ -185,19 +262,53 @@ func (t DeclaredTool) validate(where string) error { | ||
| 185 | 262 | return fmt.Errorf("%s.name %q is the name of a built-in tool", where, t.Name) |
| 186 | 263 | case strings.TrimSpace(t.Description) == "": |
| 187 | 264 | return fmt.Errorf("%s.description must not be empty: the model picks a tool from its description", where) |
| 188 | - case len(t.Command) == 0 || strings.TrimSpace(t.Command[0]) == "": | |
| 189 | - return fmt.Errorf("%s.command must name a program, e.g. [\"./scripts/run.sh\", \"{{arg}}\"]", where) | |
| 190 | - case len(Placeholders(t.Command[0])) > 0: | |
| 191 | - return fmt.Errorf("%s.command: the program (%q) cannot come from a parameter", where, t.Command[0]) | |
| 192 | 265 | case t.Timeout < 0: |
| 193 | 266 | return fmt.Errorf("%s.timeout must be >= 0", where) |
| 194 | 267 | } |
| 195 | 268 | if err := t.validateParameters(where); err != nil { |
| 196 | 269 | return err |
| 197 | 270 | } |
| 271 | + if t.IsWasm() { | |
| 272 | + return t.validateWasm(where) | |
| 273 | + } | |
| 274 | + switch { | |
| 275 | + case len(t.Read)+len(t.Write) > 0: | |
| 276 | + return fmt.Errorf("%s: read and write only apply to a wasm tool — a command already sees every file", where) | |
| 277 | + case len(t.Command) == 0 || strings.TrimSpace(t.Command[0]) == "": | |
| 278 | + return fmt.Errorf("%s.command must name a program, e.g. [\"./scripts/run.sh\", \"{{arg}}\"] (or wasm: a module)", where) | |
| 279 | + case len(Placeholders(t.Command[0])) > 0: | |
| 280 | + return fmt.Errorf("%s.command: the program (%q) cannot come from a parameter", where, t.Command[0]) | |
| 281 | + } | |
| 198 | 282 | return t.validatePlaceholders(where) |
| 199 | 283 | } |
| 200 | 284 | |
| 285 | +// validateWasm checks a wasm tool: no command beside it, a module that | |
| 286 | +// exists, and folders that stay inside the working directory. | |
| 287 | +func (t DeclaredTool) validateWasm(where string) error { | |
| 288 | + if len(t.Command) > 0 { | |
| 289 | + return fmt.Errorf("%s: give command or wasm, not both", where) | |
| 290 | + } | |
| 291 | + if err := validateSandbox(t.Wasm, t.Folders()); err != nil { | |
| 292 | + return fmt.Errorf("%s: %w", where, err) | |
| 293 | + } | |
| 294 | + return nil | |
| 295 | +} | |
| 296 | + | |
| 297 | +// resolveToolPaths makes every relative wasm module path absolute against | |
| 298 | +// the directory of the agent file: the module ships with the agent, whoever | |
| 299 | +// starts mm and from wherever. | |
| 300 | +func resolveToolPaths(configPath string) { | |
| 301 | + dir := filepath.Dir(configPath) | |
| 302 | + for i, t := range Cfg.Tools { | |
| 303 | + if t.Wasm == "" || filepath.IsAbs(t.Wasm) { | |
| 304 | + continue | |
| 305 | + } | |
| 306 | + if abs, err := filepath.Abs(filepath.Join(dir, t.Wasm)); err == nil { | |
| 307 | + Cfg.Tools[i].Wasm = abs | |
| 308 | + } | |
| 309 | + } | |
| 310 | +} | |
| 311 | + | |
| 201 | 312 | // validateParameters checks each parameter's type, enum and default. |
| 202 | 313 | func (t DeclaredTool) validateParameters(where string) error { |
| 203 | 314 | seen := map[string]bool{} |
| @@ -18,9 +18,24 @@ package config | |||
| 18 | // The command runs WITHOUT a shell: each element is one argv entry, and a | 18 | // The command runs WITHOUT a shell: each element is one argv entry, and a |
| 19 | // parameter's value replaces its {{placeholder}} inside one entry, so a value | 19 | // parameter's value replaces its {{placeholder}} inside one entry, so a value |
| 20 | // cannot add an argument, a pipe or a redirection. | 20 | // cannot add an argument, a pipe or a redirection. |
| 21 | +// | ||
| 22 | +// An entry may name a WebAssembly module instead of a command (ticket 119): | ||
| 23 | +// mm runs it itself, in a sandbox that sees no file, no environment variable | ||
| 24 | +// and no network, except the project folders the entry opens. | ||
| 25 | +// | ||
| 26 | +// tools: | ||
| 27 | +// - name: search_notes | ||
| 28 | +// description: Find the notes that mention a word. | ||
| 29 | +// parameters: | ||
| 30 | +// word: { type: string } | ||
| 31 | +// wasm: ./tools/search_notes.wasm # relative to this file | ||
| 32 | +// read: [notes] # relative to the working directory | ||
| 33 | +// write: [out] | ||
| 21 | 34 | ||
| 22 | import ( | 35 | import ( |
| 23 | "fmt" | 36 | "fmt" |
| 37 | + "os" | ||
| 38 | + "path/filepath" | ||
| 24 | "regexp" | 39 | "regexp" |
| 25 | "slices" | 40 | "slices" |
| 26 | "strconv" | 41 | "strconv" |
| @@ -80,10 +95,72 @@ type DeclaredTool struct { | |||
| 80 | // Command: the program and its arguments, one argv entry each; a | 95 | // Command: the program and its arguments, one argv entry each; a |
| 81 | // {{parameter}} inside an entry is replaced by the call's value. | 96 | // {{parameter}} inside an entry is replaced by the call's value. |
| 82 | Command []string `yaml:"command"` | 97 | Command []string `yaml:"command"` |
| 98 | + // Wasm: a WebAssembly module (WASI command) to run instead of Command. | ||
| 99 | + // Relative to the agent file, made absolute at load. The call's | ||
| 100 | + // arguments reach it as one JSON object on stdin. | ||
| 101 | + Wasm string `yaml:"wasm"` | ||
| 102 | + // Read and Write: the folders of the working directory a Wasm module | ||
| 103 | + // may see, read-only or read-write. Nothing else is visible to it. | ||
| 104 | + Read []string `yaml:"read"` | ||
| 105 | + Write []string `yaml:"write"` | ||
| 83 | // Timeout: how long one call may run. 0 = bashTimeout. | 106 | // Timeout: how long one call may run. 0 = bashTimeout. |
| 84 | Timeout time.Duration `yaml:"timeout"` | 107 | Timeout time.Duration `yaml:"timeout"` |
| 85 | } | 108 | } |
| 86 | 109 | ||
| 110 | +// IsWasm reports whether the tool runs a WebAssembly module, not a command. | ||
| 111 | +func (t DeclaredTool) IsWasm() bool { | ||
| 112 | + return t.Wasm != "" | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +// Folders lists the folders a Wasm tool opens, with whether each may be | ||
| 116 | +// written, in the order the file gives them: read first, then write. | ||
| 117 | +func (t DeclaredTool) Folders() []ToolFolder { | ||
| 118 | + return sandboxFolders(t.Read, t.Write) | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +// ToolFolder is one folder a Wasm module — a tool's or a hook's — may see: | ||
| 122 | +// Path is relative to the working directory, and is also where the module | ||
| 123 | +// finds it ("notes" → notes/… or /notes/… inside the module). | ||
| 124 | +type ToolFolder struct { | ||
| 125 | + Path string | ||
| 126 | + Writable bool | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +// sandboxFolders turns `read:` and `write:` into folders, cleaned, read | ||
| 130 | +// first. | ||
| 131 | +func sandboxFolders(read, write []string) []ToolFolder { | ||
| 132 | + var folders []ToolFolder | ||
| 133 | + for _, f := range read { | ||
| 134 | + folders = append(folders, ToolFolder{Path: filepath.Clean(f)}) | ||
| 135 | + } | ||
| 136 | + for _, f := range write { | ||
| 137 | + folders = append(folders, ToolFolder{Path: filepath.Clean(f), Writable: true}) | ||
| 138 | + } | ||
| 139 | + return folders | ||
| 140 | +} | ||
| 141 | + | ||
| 142 | +// validateSandbox checks what a Wasm module — a tool's or a hook's — is | ||
| 143 | +// given: a module file that exists, and folders that stay inside the | ||
| 144 | +// working directory, each once. The caller names the entry. | ||
| 145 | +func validateSandbox(module string, folders []ToolFolder) error { | ||
| 146 | + if info, err := os.Stat(module); err != nil || info.IsDir() { | ||
| 147 | + return fmt.Errorf("wasm: no module at %s", module) | ||
| 148 | + } | ||
| 149 | + seen := map[string]bool{} | ||
| 150 | + for _, f := range folders { | ||
| 151 | + switch { | ||
| 152 | + case filepath.IsAbs(f.Path): | ||
| 153 | + return fmt.Errorf("folder %q must be relative to the working directory", f.Path) | ||
| 154 | + case f.Path == "." || f.Path == ".." || strings.HasPrefix(f.Path, ".."+string(filepath.Separator)): | ||
| 155 | + return fmt.Errorf("folder %q must be a folder inside the working directory, not it or above it", f.Path) | ||
| 156 | + case seen[f.Path]: | ||
| 157 | + return fmt.Errorf("folder %q is given twice", f.Path) | ||
| 158 | + } | ||
| 159 | + seen[f.Path] = true | ||
| 160 | + } | ||
| 161 | + return nil | ||
| 162 | +} | ||
| 163 | + | ||
| 87 | // ToolParameter is one argument of a declared tool. | 164 | // ToolParameter is one argument of a declared tool. |
| 88 | type ToolParameter struct { | 165 | type ToolParameter struct { |
| 89 | // Name: the key under `parameters:`. | 166 | // Name: the key under `parameters:`. |
| @@ -185,19 +262,53 @@ func (t DeclaredTool) validate(where string) error { | |||
| 185 | return fmt.Errorf("%s.name %q is the name of a built-in tool", where, t.Name) | 262 | return fmt.Errorf("%s.name %q is the name of a built-in tool", where, t.Name) |
| 186 | case strings.TrimSpace(t.Description) == "": | 263 | case strings.TrimSpace(t.Description) == "": |
| 187 | return fmt.Errorf("%s.description must not be empty: the model picks a tool from its description", where) | 264 | return fmt.Errorf("%s.description must not be empty: the model picks a tool from its description", where) |
| 188 | - case len(t.Command) == 0 || strings.TrimSpace(t.Command[0]) == "": | ||
| 189 | - return fmt.Errorf("%s.command must name a program, e.g. [\"./scripts/run.sh\", \"{{arg}}\"]", where) | ||
| 190 | - case len(Placeholders(t.Command[0])) > 0: | ||
| 191 | - return fmt.Errorf("%s.command: the program (%q) cannot come from a parameter", where, t.Command[0]) | ||
| 192 | case t.Timeout < 0: | 265 | case t.Timeout < 0: |
| 193 | return fmt.Errorf("%s.timeout must be >= 0", where) | 266 | return fmt.Errorf("%s.timeout must be >= 0", where) |
| 194 | } | 267 | } |
| 195 | if err := t.validateParameters(where); err != nil { | 268 | if err := t.validateParameters(where); err != nil { |
| 196 | return err | 269 | return err |
| 197 | } | 270 | } |
| 271 | + if t.IsWasm() { | ||
| 272 | + return t.validateWasm(where) | ||
| 273 | + } | ||
| 274 | + switch { | ||
| 275 | + case len(t.Read)+len(t.Write) > 0: | ||
| 276 | + return fmt.Errorf("%s: read and write only apply to a wasm tool — a command already sees every file", where) | ||
| 277 | + case len(t.Command) == 0 || strings.TrimSpace(t.Command[0]) == "": | ||
| 278 | + return fmt.Errorf("%s.command must name a program, e.g. [\"./scripts/run.sh\", \"{{arg}}\"] (or wasm: a module)", where) | ||
| 279 | + case len(Placeholders(t.Command[0])) > 0: | ||
| 280 | + return fmt.Errorf("%s.command: the program (%q) cannot come from a parameter", where, t.Command[0]) | ||
| 281 | + } | ||
| 198 | return t.validatePlaceholders(where) | 282 | return t.validatePlaceholders(where) |
| 199 | } | 283 | } |
| 200 | 284 | ||
| 285 | +// validateWasm checks a wasm tool: no command beside it, a module that | ||
| 286 | +// exists, and folders that stay inside the working directory. | ||
| 287 | +func (t DeclaredTool) validateWasm(where string) error { | ||
| 288 | + if len(t.Command) > 0 { | ||
| 289 | + return fmt.Errorf("%s: give command or wasm, not both", where) | ||
| 290 | + } | ||
| 291 | + if err := validateSandbox(t.Wasm, t.Folders()); err != nil { | ||
| 292 | + return fmt.Errorf("%s: %w", where, err) | ||
| 293 | + } | ||
| 294 | + return nil | ||
| 295 | +} | ||
| 296 | + | ||
| 297 | +// resolveToolPaths makes every relative wasm module path absolute against | ||
| 298 | +// the directory of the agent file: the module ships with the agent, whoever | ||
| 299 | +// starts mm and from wherever. | ||
| 300 | +func resolveToolPaths(configPath string) { | ||
| 301 | + dir := filepath.Dir(configPath) | ||
| 302 | + for i, t := range Cfg.Tools { | ||
| 303 | + if t.Wasm == "" || filepath.IsAbs(t.Wasm) { | ||
| 304 | + continue | ||
| 305 | + } | ||
| 306 | + if abs, err := filepath.Abs(filepath.Join(dir, t.Wasm)); err == nil { | ||
| 307 | + Cfg.Tools[i].Wasm = abs | ||
| 308 | + } | ||
| 309 | + } | ||
| 310 | +} | ||
| 311 | + | ||
| 201 | // validateParameters checks each parameter's type, enum and default. | 312 | // validateParameters checks each parameter's type, enum and default. |
| 202 | func (t DeclaredTool) validateParameters(where string) error { | 313 | func (t DeclaredTool) validateParameters(where string) error { |
| 203 | seen := map[string]bool{} | 314 | seen := map[string]bool{} |
modified
internal/config/tools_test.go +56 -0 | @@ -1,6 +1,9 @@ | ||
| 1 | 1 | package config |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "slices" | |
| 4 | 7 | "strings" |
| 5 | 8 | "testing" |
| 6 | 9 | "time" |
| @@ -78,6 +81,10 @@ func TestInvalidDeclaredToolsAreRejected(t *testing.T) { | ||
| 78 | 81 | "negative timeout": {`[{name: t, description: d, command: [a], timeout: -1s}]`, "timeout must be >= 0"}, |
| 79 | 82 | "duplicate name": {`[{name: t, description: d, command: [a]}, {name: t, description: d, command: [b]}]`, "already called"}, |
| 80 | 83 | "parameters not a mapping": {`[{name: t, description: d, parameters: [a], command: [a]}]`, "parameters must be a mapping"}, |
| 84 | + "read on a command tool": {`[{name: t, description: d, command: [a], read: [notes]}]`, "only apply to a wasm tool"}, | |
| 85 | + "command and wasm": {`[{name: t, description: d, command: [a], wasm: /dev/null}]`, "not both"}, | |
| 86 | + "missing module": {`[{name: t, description: d, wasm: ./nope.wasm}]`, "no module at"}, | |
| 87 | + "module is a folder": {`[{name: t, description: d, wasm: /tmp}]`, "no module at"}, | |
| 81 | 88 | } |
| 82 | 89 | for name, c := range cases { |
| 83 | 90 | t.Run(name, func(t *testing.T) { |
| @@ -89,6 +96,55 @@ func TestInvalidDeclaredToolsAreRejected(t *testing.T) { | ||
| 89 | 96 | } |
| 90 | 97 | } |
| 91 | 98 | |
| 99 | +// A wasm entry: the module is found next to the agent file, wherever mm is | |
| 100 | +// started; its folders stay relative, and are listed read first. | |
| 101 | +func TestWasmToolIsResolvedAgainstTheAgentFile(t *testing.T) { | |
| 102 | + defaults := Cfg | |
| 103 | + t.Cleanup(func() { Cfg = defaults }) | |
| 104 | + dir := t.TempDir() | |
| 105 | + if err := os.MkdirAll(filepath.Join(dir, "tools"), 0o755); err != nil { | |
| 106 | + t.Fatal(err) | |
| 107 | + } | |
| 108 | + if err := os.WriteFile(filepath.Join(dir, "tools", "w.wasm"), []byte("\x00asm"), 0o644); err != nil { | |
| 109 | + t.Fatal(err) | |
| 110 | + } | |
| 111 | + path := filepath.Join(dir, "agent.yaml") | |
| 112 | + body := "model: m\ntools:\n - {name: w, description: d, wasm: ./tools/w.wasm, read: [notes/], write: [out]}\n" | |
| 113 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 114 | + t.Fatal(err) | |
| 115 | + } | |
| 116 | + if _, err := Load(path); err != nil { | |
| 117 | + t.Fatal(err) | |
| 118 | + } | |
| 119 | + tool := Cfg.Tools[0] | |
| 120 | + if want := filepath.Join(dir, "tools", "w.wasm"); tool.Wasm != want || !tool.IsWasm() { | |
| 121 | + t.Errorf("wasm = %q, want %q", tool.Wasm, want) | |
| 122 | + } | |
| 123 | + want := []ToolFolder{{Path: "notes"}, {Path: "out", Writable: true}} | |
| 124 | + if got := tool.Folders(); !slices.Equal(got, want) { | |
| 125 | + t.Errorf("folders = %v, want %v", got, want) | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +// A wasm tool's folders must stay inside the working directory, once each. | |
| 130 | +func TestWasmToolFoldersAreChecked(t *testing.T) { | |
| 131 | + cases := map[string]struct{ folders, want string }{ | |
| 132 | + "absolute": {"read: [/etc]", "must be relative"}, | |
| 133 | + "the directory": {"read: [.]", "not it or above it"}, | |
| 134 | + "above": {"write: [../x]", "not it or above it"}, | |
| 135 | + "cleaned above": {"read: [a/../../x]", "not it or above it"}, | |
| 136 | + "twice": {"read: [notes], write: [notes/]", "given twice"}, | |
| 137 | + } | |
| 138 | + for name, c := range cases { | |
| 139 | + t.Run(name, func(t *testing.T) { | |
| 140 | + err := loadYAML(t, "model: m\ntools: [{name: t, description: d, wasm: /dev/null, "+c.folders+"}]\n") | |
| 141 | + if err == nil || !strings.Contains(err.Error(), c.want) { | |
| 142 | + t.Errorf("error = %v, want it to mention %q", err, c.want) | |
| 143 | + } | |
| 144 | + }) | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 92 | 148 | // Values from the model are coerced to the declared type; the string forms |
| 93 | 149 | // models often write for numbers and booleans are accepted, nothing else. |
| 94 | 150 | func TestCoerce(t *testing.T) { |
| @@ -1,6 +1,9 @@ | |||
| 1 | package config | 1 | package config |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "slices" | ||
| 4 | "strings" | 7 | "strings" |
| 5 | "testing" | 8 | "testing" |
| 6 | "time" | 9 | "time" |
| @@ -78,6 +81,10 @@ func TestInvalidDeclaredToolsAreRejected(t *testing.T) { | |||
| 78 | "negative timeout": {`[{name: t, description: d, command: [a], timeout: -1s}]`, "timeout must be >= 0"}, | 81 | "negative timeout": {`[{name: t, description: d, command: [a], timeout: -1s}]`, "timeout must be >= 0"}, |
| 79 | "duplicate name": {`[{name: t, description: d, command: [a]}, {name: t, description: d, command: [b]}]`, "already called"}, | 82 | "duplicate name": {`[{name: t, description: d, command: [a]}, {name: t, description: d, command: [b]}]`, "already called"}, |
| 80 | "parameters not a mapping": {`[{name: t, description: d, parameters: [a], command: [a]}]`, "parameters must be a mapping"}, | 83 | "parameters not a mapping": {`[{name: t, description: d, parameters: [a], command: [a]}]`, "parameters must be a mapping"}, |
| 84 | + "read on a command tool": {`[{name: t, description: d, command: [a], read: [notes]}]`, "only apply to a wasm tool"}, | ||
| 85 | + "command and wasm": {`[{name: t, description: d, command: [a], wasm: /dev/null}]`, "not both"}, | ||
| 86 | + "missing module": {`[{name: t, description: d, wasm: ./nope.wasm}]`, "no module at"}, | ||
| 87 | + "module is a folder": {`[{name: t, description: d, wasm: /tmp}]`, "no module at"}, | ||
| 81 | } | 88 | } |
| 82 | for name, c := range cases { | 89 | for name, c := range cases { |
| 83 | t.Run(name, func(t *testing.T) { | 90 | t.Run(name, func(t *testing.T) { |
| @@ -89,6 +96,55 @@ func TestInvalidDeclaredToolsAreRejected(t *testing.T) { | |||
| 89 | } | 96 | } |
| 90 | } | 97 | } |
| 91 | 98 | ||
| 99 | +// A wasm entry: the module is found next to the agent file, wherever mm is | ||
| 100 | +// started; its folders stay relative, and are listed read first. | ||
| 101 | +func TestWasmToolIsResolvedAgainstTheAgentFile(t *testing.T) { | ||
| 102 | + defaults := Cfg | ||
| 103 | + t.Cleanup(func() { Cfg = defaults }) | ||
| 104 | + dir := t.TempDir() | ||
| 105 | + if err := os.MkdirAll(filepath.Join(dir, "tools"), 0o755); err != nil { | ||
| 106 | + t.Fatal(err) | ||
| 107 | + } | ||
| 108 | + if err := os.WriteFile(filepath.Join(dir, "tools", "w.wasm"), []byte("\x00asm"), 0o644); err != nil { | ||
| 109 | + t.Fatal(err) | ||
| 110 | + } | ||
| 111 | + path := filepath.Join(dir, "agent.yaml") | ||
| 112 | + body := "model: m\ntools:\n - {name: w, description: d, wasm: ./tools/w.wasm, read: [notes/], write: [out]}\n" | ||
| 113 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | ||
| 114 | + t.Fatal(err) | ||
| 115 | + } | ||
| 116 | + if _, err := Load(path); err != nil { | ||
| 117 | + t.Fatal(err) | ||
| 118 | + } | ||
| 119 | + tool := Cfg.Tools[0] | ||
| 120 | + if want := filepath.Join(dir, "tools", "w.wasm"); tool.Wasm != want || !tool.IsWasm() { | ||
| 121 | + t.Errorf("wasm = %q, want %q", tool.Wasm, want) | ||
| 122 | + } | ||
| 123 | + want := []ToolFolder{{Path: "notes"}, {Path: "out", Writable: true}} | ||
| 124 | + if got := tool.Folders(); !slices.Equal(got, want) { | ||
| 125 | + t.Errorf("folders = %v, want %v", got, want) | ||
| 126 | + } | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +// A wasm tool's folders must stay inside the working directory, once each. | ||
| 130 | +func TestWasmToolFoldersAreChecked(t *testing.T) { | ||
| 131 | + cases := map[string]struct{ folders, want string }{ | ||
| 132 | + "absolute": {"read: [/etc]", "must be relative"}, | ||
| 133 | + "the directory": {"read: [.]", "not it or above it"}, | ||
| 134 | + "above": {"write: [../x]", "not it or above it"}, | ||
| 135 | + "cleaned above": {"read: [a/../../x]", "not it or above it"}, | ||
| 136 | + "twice": {"read: [notes], write: [notes/]", "given twice"}, | ||
| 137 | + } | ||
| 138 | + for name, c := range cases { | ||
| 139 | + t.Run(name, func(t *testing.T) { | ||
| 140 | + err := loadYAML(t, "model: m\ntools: [{name: t, description: d, wasm: /dev/null, "+c.folders+"}]\n") | ||
| 141 | + if err == nil || !strings.Contains(err.Error(), c.want) { | ||
| 142 | + t.Errorf("error = %v, want it to mention %q", err, c.want) | ||
| 143 | + } | ||
| 144 | + }) | ||
| 145 | + } | ||
| 146 | +} | ||
| 147 | + | ||
| 92 | // Values from the model are coerced to the declared type; the string forms | 148 | // Values from the model are coerced to the declared type; the string forms |
| 93 | // models often write for numbers and booleans are accepted, nothing else. | 149 | // models often write for numbers and booleans are accepted, nothing else. |
| 94 | func TestCoerce(t *testing.T) { | 150 | func TestCoerce(t *testing.T) { |
modified
internal/hooks/exec.go +46 -2 | @@ -12,6 +12,7 @@ import ( | ||
| 12 | 12 | "time" |
| 13 | 13 | |
| 14 | 14 | "mm/internal/config" |
| 15 | + "mm/internal/wasmtool" | |
| 15 | 16 | ) |
| 16 | 17 | |
| 17 | 18 | // Environment variables a hook can read besides its stdin. |
| @@ -38,7 +39,7 @@ const maxStderr = 300 | ||
| 38 | 39 | // otherwise block Wait forever (the same trap as the bash tool's, ticket 26). |
| 39 | 40 | const waitDelay = time.Second |
| 40 | 41 | |
| 41 | -// execFunc runs one hook with its stdin and returns its stdout: runCommand, | |
| 42 | +// execFunc runs one hook with its stdin and returns its stdout: runHook, | |
| 42 | 43 | // behind a field so a test elsewhere can swap the process for a function. |
| 43 | 44 | type execFunc func(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) |
| 44 | 45 | |
| @@ -59,7 +60,7 @@ func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, erro | ||
| 59 | 60 | if err == nil { |
| 60 | 61 | reply, err = decode(stdout, in.Event) |
| 61 | 62 | } |
| 62 | - report(Run{Event: in.Event, Command: h.Command, Target: target(in), Decision: decision(in.Event, reply, err)}) | |
| 63 | + report(Run{Event: in.Event, Command: h.Label(), Target: target(in), Decision: decision(in.Event, reply, err)}) | |
| 63 | 64 | return reply, err |
| 64 | 65 | } |
| 65 | 66 | |
| @@ -121,6 +122,49 @@ func checkAction(reply Reply, event string) error { | ||
| 121 | 122 | return fmt.Errorf("action %q is not valid for %s", reply.Action, event) |
| 122 | 123 | } |
| 123 | 124 | |
| 125 | +// runHook runs a hook the way its entry says: a module in the wasm sandbox, | |
| 126 | +// or a command through the shell. | |
| 127 | +func runHook(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { | |
| 128 | + if h.IsWasm() { | |
| 129 | + return runWasm(ctx, h, env, dir, stdin) | |
| 130 | + } | |
| 131 | + return runCommand(ctx, h, env, dir, stdin) | |
| 132 | +} | |
| 133 | + | |
| 134 | +// runWasm runs h.Wasm in the sandbox of the wasm tools (ticket 120): the same | |
| 135 | +// stdin, reply, caps and errors as runCommand, but the module sees only its | |
| 136 | +// declared folders and, of env, MM_HOOK_EVENT — MM_CONFIG_DIR is a host path, | |
| 137 | +// meaningless inside the sandbox. | |
| 138 | +func runWasm(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { | |
| 139 | + if dir == "" { | |
| 140 | + dir, _ = os.Getwd() | |
| 141 | + } | |
| 142 | + call := wasmtool.Call{Module: h.Wasm, Input: stdin, Dir: dir, Timeout: h.Timeout} | |
| 143 | + for _, kv := range env { | |
| 144 | + if strings.HasPrefix(kv, EnvEvent+"=") { | |
| 145 | + call.Env = append(call.Env, kv) | |
| 146 | + } | |
| 147 | + } | |
| 148 | + for _, f := range h.Folders() { | |
| 149 | + call.Folders = append(call.Folders, wasmtool.Folder{Path: f.Path, Writable: f.Writable}) | |
| 150 | + } | |
| 151 | + stdout := &capped{max: maxReply} | |
| 152 | + stderr := &capped{max: maxStderr} | |
| 153 | + err := wasmtool.Default.Exec(ctx, call, stdout, stderr) | |
| 154 | + var timeout wasmtool.TimeoutError | |
| 155 | + switch { | |
| 156 | + case errors.As(err, &timeout): | |
| 157 | + return nil, fmt.Errorf("timed out after %s", h.Timeout) | |
| 158 | + case errors.Is(err, wasmtool.ErrCancelled): | |
| 159 | + return nil, errors.New("cancelled") | |
| 160 | + case err != nil: | |
| 161 | + return nil, withStderr(err, stderr.String()) | |
| 162 | + case stdout.overflow: | |
| 163 | + return nil, fmt.Errorf("stdout larger than %d bytes", maxReply) | |
| 164 | + } | |
| 165 | + return stdout.Bytes(), nil | |
| 166 | +} | |
| 167 | + | |
| 124 | 168 | // runCommand runs h.Command with `sh -c` in dir ("" = mm's own directory), |
| 125 | 169 | // under h.Timeout, with env added to mm's environment. |
| 126 | 170 | func runCommand(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { |
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | "time" | 12 | "time" |
| 13 | 13 | ||
| 14 | "mm/internal/config" | 14 | "mm/internal/config" |
| 15 | + "mm/internal/wasmtool" | ||
| 15 | ) | 16 | ) |
| 16 | 17 | ||
| 17 | // Environment variables a hook can read besides its stdin. | 18 | // Environment variables a hook can read besides its stdin. |
| @@ -38,7 +39,7 @@ const maxStderr = 300 | |||
| 38 | // otherwise block Wait forever (the same trap as the bash tool's, ticket 26). | 39 | // otherwise block Wait forever (the same trap as the bash tool's, ticket 26). |
| 39 | const waitDelay = time.Second | 40 | const waitDelay = time.Second |
| 40 | 41 | ||
| 41 | -// execFunc runs one hook with its stdin and returns its stdout: runCommand, | 42 | +// execFunc runs one hook with its stdin and returns its stdout: runHook, |
| 42 | // behind a field so a test elsewhere can swap the process for a function. | 43 | // behind a field so a test elsewhere can swap the process for a function. |
| 43 | type execFunc func(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) | 44 | type execFunc func(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) |
| 44 | 45 | ||
| @@ -59,7 +60,7 @@ func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, erro | |||
| 59 | if err == nil { | 60 | if err == nil { |
| 60 | reply, err = decode(stdout, in.Event) | 61 | reply, err = decode(stdout, in.Event) |
| 61 | } | 62 | } |
| 62 | - report(Run{Event: in.Event, Command: h.Command, Target: target(in), Decision: decision(in.Event, reply, err)}) | 63 | + report(Run{Event: in.Event, Command: h.Label(), Target: target(in), Decision: decision(in.Event, reply, err)}) |
| 63 | return reply, err | 64 | return reply, err |
| 64 | } | 65 | } |
| 65 | 66 | ||
| @@ -121,6 +122,49 @@ func checkAction(reply Reply, event string) error { | |||
| 121 | return fmt.Errorf("action %q is not valid for %s", reply.Action, event) | 122 | return fmt.Errorf("action %q is not valid for %s", reply.Action, event) |
| 122 | } | 123 | } |
| 123 | 124 | ||
| 125 | +// runHook runs a hook the way its entry says: a module in the wasm sandbox, | ||
| 126 | +// or a command through the shell. | ||
| 127 | +func runHook(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { | ||
| 128 | + if h.IsWasm() { | ||
| 129 | + return runWasm(ctx, h, env, dir, stdin) | ||
| 130 | + } | ||
| 131 | + return runCommand(ctx, h, env, dir, stdin) | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +// runWasm runs h.Wasm in the sandbox of the wasm tools (ticket 120): the same | ||
| 135 | +// stdin, reply, caps and errors as runCommand, but the module sees only its | ||
| 136 | +// declared folders and, of env, MM_HOOK_EVENT — MM_CONFIG_DIR is a host path, | ||
| 137 | +// meaningless inside the sandbox. | ||
| 138 | +func runWasm(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { | ||
| 139 | + if dir == "" { | ||
| 140 | + dir, _ = os.Getwd() | ||
| 141 | + } | ||
| 142 | + call := wasmtool.Call{Module: h.Wasm, Input: stdin, Dir: dir, Timeout: h.Timeout} | ||
| 143 | + for _, kv := range env { | ||
| 144 | + if strings.HasPrefix(kv, EnvEvent+"=") { | ||
| 145 | + call.Env = append(call.Env, kv) | ||
| 146 | + } | ||
| 147 | + } | ||
| 148 | + for _, f := range h.Folders() { | ||
| 149 | + call.Folders = append(call.Folders, wasmtool.Folder{Path: f.Path, Writable: f.Writable}) | ||
| 150 | + } | ||
| 151 | + stdout := &capped{max: maxReply} | ||
| 152 | + stderr := &capped{max: maxStderr} | ||
| 153 | + err := wasmtool.Default.Exec(ctx, call, stdout, stderr) | ||
| 154 | + var timeout wasmtool.TimeoutError | ||
| 155 | + switch { | ||
| 156 | + case errors.As(err, &timeout): | ||
| 157 | + return nil, fmt.Errorf("timed out after %s", h.Timeout) | ||
| 158 | + case errors.Is(err, wasmtool.ErrCancelled): | ||
| 159 | + return nil, errors.New("cancelled") | ||
| 160 | + case err != nil: | ||
| 161 | + return nil, withStderr(err, stderr.String()) | ||
| 162 | + case stdout.overflow: | ||
| 163 | + return nil, fmt.Errorf("stdout larger than %d bytes", maxReply) | ||
| 164 | + } | ||
| 165 | + return stdout.Bytes(), nil | ||
| 166 | +} | ||
| 167 | + | ||
| 124 | // runCommand runs h.Command with `sh -c` in dir ("" = mm's own directory), | 168 | // runCommand runs h.Command with `sh -c` in dir ("" = mm's own directory), |
| 125 | // under h.Timeout, with env added to mm's environment. | 169 | // under h.Timeout, with env added to mm's environment. |
| 126 | func runCommand(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { | 170 | func runCommand(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { |
modified
internal/hooks/hooks.go +2 -2 | @@ -119,7 +119,7 @@ func New(cfg config.HooksConfig, notify func(string)) *Runner { | ||
| 119 | 119 | if notify == nil { |
| 120 | 120 | notify = func(string) {} |
| 121 | 121 | } |
| 122 | - return &Runner{cfg: cfg, notify: notify, exec: runCommand} | |
| 122 | + return &Runner{cfg: cfg, notify: notify, exec: runHook} | |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | 125 | // Enabled reports whether any hook is declared — the engine skips building |
| @@ -284,7 +284,7 @@ func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) { | ||
| 284 | 284 | if h.OnError == config.HookOnErrorIgnore { |
| 285 | 285 | return false |
| 286 | 286 | } |
| 287 | - r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error()) | |
| 287 | + r.notify("🪝 " + event + " hook `" + h.Label() + "`: " + err.Error()) | |
| 288 | 288 | pre := event == config.HookPreGeneration || event == config.HookPreToolCall || event == config.HookPreAsk |
| 289 | 289 | return pre && h.OnError == config.HookOnErrorFail |
| 290 | 290 | } |
| @@ -119,7 +119,7 @@ func New(cfg config.HooksConfig, notify func(string)) *Runner { | |||
| 119 | if notify == nil { | 119 | if notify == nil { |
| 120 | notify = func(string) {} | 120 | notify = func(string) {} |
| 121 | } | 121 | } |
| 122 | - return &Runner{cfg: cfg, notify: notify, exec: runCommand} | 122 | + return &Runner{cfg: cfg, notify: notify, exec: runHook} |
| 123 | } | 123 | } |
| 124 | 124 | ||
| 125 | // Enabled reports whether any hook is declared — the engine skips building | 125 | // Enabled reports whether any hook is declared — the engine skips building |
| @@ -284,7 +284,7 @@ func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) { | |||
| 284 | if h.OnError == config.HookOnErrorIgnore { | 284 | if h.OnError == config.HookOnErrorIgnore { |
| 285 | return false | 285 | return false |
| 286 | } | 286 | } |
| 287 | - r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error()) | 287 | + r.notify("🪝 " + event + " hook `" + h.Label() + "`: " + err.Error()) |
| 288 | pre := event == config.HookPreGeneration || event == config.HookPreToolCall || event == config.HookPreAsk | 288 | pre := event == config.HookPreGeneration || event == config.HookPreToolCall || event == config.HookPreAsk |
| 289 | return pre && h.OnError == config.HookOnErrorFail | 289 | return pre && h.OnError == config.HookOnErrorFail |
| 290 | } | 290 | } |
added
internal/hooks/wasm_test.go +92 -0 | new file mode 100644 | ||
| @@ -0,0 +1,92 @@ | ||
| 1 | +package hooks | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "mm/internal/config" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// wasmHook is a hook entry over wasmtool's test module. The module finds | |
| 15 | +// its op inside the tool's input, which the hook's JSON carries. | |
| 16 | +func wasmHook(t *testing.T, folders ...string) config.Hook { | |
| 17 | + t.Helper() | |
| 18 | + module, err := filepath.Abs(filepath.Join("..", "wasmtool", "testdata", "probe.wasm")) | |
| 19 | + if err != nil { | |
| 20 | + t.Fatal(err) | |
| 21 | + } | |
| 22 | + return config.Hook{Wasm: module, Read: folders, Timeout: 5 * time.Second, OnError: config.HookOnErrorWarn} | |
| 23 | +} | |
| 24 | + | |
| 25 | +func probeCall(dir string, input map[string]any) Input { | |
| 26 | + return Input{Cwd: dir, Tool: &ToolCall{Name: "probe", Input: input}} | |
| 27 | +} | |
| 28 | + | |
| 29 | +// A wasm hook reads the same JSON as a command hook and its reply is | |
| 30 | +// honoured: here a deny, with its reason. | |
| 31 | +func TestWasmHookRepliesLikeACommandHook(t *testing.T) { | |
| 32 | + r, notes := runner(config.HooksConfig{PreToolCall: []config.Hook{wasmHook(t)}}) | |
| 33 | + d := r.PreToolCall(context.Background(), probeCall(t.TempDir(), map[string]any{"op": "deny", "text": "no thanks"})) | |
| 34 | + if !d.Denied || d.Reason != "no thanks" || len(*notes) > 0 { | |
| 35 | + t.Errorf("decision %+v, notes %v", d, *notes) | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +// The module sees MM_HOOK_EVENT and nothing else of the environment: not | |
| 40 | +// MM_CONFIG_DIR, not mm's own variables. | |
| 41 | +func TestWasmHookSeesOnlyTheEventVariable(t *testing.T) { | |
| 42 | + t.Setenv("MM_SECRET", "x") | |
| 43 | + cfg := config.HooksConfig{PreToolCall: []config.Hook{wasmHook(t)}, ConfigDir: "/somewhere"} | |
| 44 | + r, _ := runner(cfg) | |
| 45 | + d := r.PreToolCall(context.Background(), probeCall(t.TempDir(), map[string]any{"op": "deny-env"})) | |
| 46 | + if d.Reason != "preToolCall 1" { | |
| 47 | + t.Errorf("reason %q, want the event and a single variable", d.Reason) | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +// A wasm hook reads its folders in the session's directory, and nothing | |
| 52 | +// outside them. | |
| 53 | +func TestWasmHookSeesOnlyItsFolders(t *testing.T) { | |
| 54 | + dir := t.TempDir() | |
| 55 | + for name, body := range map[string]string{"rules/deny.txt": "rule one", "secret.txt": "no"} { | |
| 56 | + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { | |
| 57 | + t.Fatal(err) | |
| 58 | + } | |
| 59 | + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { | |
| 60 | + t.Fatal(err) | |
| 61 | + } | |
| 62 | + } | |
| 63 | + r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{wasmHook(t, "rules")}}) | |
| 64 | + if d := r.PreToolCall(context.Background(), probeCall(dir, map[string]any{"op": "deny-read", "path": "rules/deny.txt"})); d.Reason != "rule one" { | |
| 65 | + t.Errorf("rules/deny.txt: reason %q", d.Reason) | |
| 66 | + } | |
| 67 | + if d := r.PreToolCall(context.Background(), probeCall(dir, map[string]any{"op": "deny-read", "path": "secret.txt"})); d.Reason != "read failed" { | |
| 68 | + t.Errorf("secret.txt: reason %q, want it unreadable", d.Reason) | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +// A failing wasm hook follows onError like a command hook, and its report | |
| 73 | +// names the module: a non-zero exit warns; with fail, the call is denied; | |
| 74 | +// a module past its timeout is stopped. | |
| 75 | +func TestFailingWasmHookFollowsOnError(t *testing.T) { | |
| 76 | + h := wasmHook(t) | |
| 77 | + r, notes := runner(config.HooksConfig{PreToolCall: []config.Hook{h}}) | |
| 78 | + if d := r.PreToolCall(context.Background(), probeCall(t.TempDir(), map[string]any{"op": "exit", "code": 3})); d.Denied { | |
| 79 | + t.Errorf("warn: %+v, want allowed", d) | |
| 80 | + } | |
| 81 | + if len(*notes) != 1 || !strings.Contains((*notes)[0], "probe.wasm") || !strings.Contains((*notes)[0], "exit code 3") { | |
| 82 | + t.Errorf("notes %v", *notes) | |
| 83 | + } | |
| 84 | + | |
| 85 | + h.OnError, h.Timeout = config.HookOnErrorFail, 200*time.Millisecond | |
| 86 | + r, _ = runner(config.HooksConfig{PreToolCall: []config.Hook{h}}) | |
| 87 | + start := time.Now() | |
| 88 | + d := r.PreToolCall(context.Background(), probeCall(t.TempDir(), map[string]any{"op": "spin"})) | |
| 89 | + if !d.Denied || !strings.Contains(d.Reason, "timed out after 200ms") || time.Since(start) > 5*time.Second { | |
| 90 | + t.Errorf("fail + spin: %+v after %v", d, time.Since(start)) | |
| 91 | + } | |
| 92 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | +package hooks | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "os" | ||
| 6 | + "path/filepath" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | + "time" | ||
| 10 | + | ||
| 11 | + "mm/internal/config" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// wasmHook is a hook entry over wasmtool's test module. The module finds | ||
| 15 | +// its op inside the tool's input, which the hook's JSON carries. | ||
| 16 | +func wasmHook(t *testing.T, folders ...string) config.Hook { | ||
| 17 | + t.Helper() | ||
| 18 | + module, err := filepath.Abs(filepath.Join("..", "wasmtool", "testdata", "probe.wasm")) | ||
| 19 | + if err != nil { | ||
| 20 | + t.Fatal(err) | ||
| 21 | + } | ||
| 22 | + return config.Hook{Wasm: module, Read: folders, Timeout: 5 * time.Second, OnError: config.HookOnErrorWarn} | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +func probeCall(dir string, input map[string]any) Input { | ||
| 26 | + return Input{Cwd: dir, Tool: &ToolCall{Name: "probe", Input: input}} | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// A wasm hook reads the same JSON as a command hook and its reply is | ||
| 30 | +// honoured: here a deny, with its reason. | ||
| 31 | +func TestWasmHookRepliesLikeACommandHook(t *testing.T) { | ||
| 32 | + r, notes := runner(config.HooksConfig{PreToolCall: []config.Hook{wasmHook(t)}}) | ||
| 33 | + d := r.PreToolCall(context.Background(), probeCall(t.TempDir(), map[string]any{"op": "deny", "text": "no thanks"})) | ||
| 34 | + if !d.Denied || d.Reason != "no thanks" || len(*notes) > 0 { | ||
| 35 | + t.Errorf("decision %+v, notes %v", d, *notes) | ||
| 36 | + } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +// The module sees MM_HOOK_EVENT and nothing else of the environment: not | ||
| 40 | +// MM_CONFIG_DIR, not mm's own variables. | ||
| 41 | +func TestWasmHookSeesOnlyTheEventVariable(t *testing.T) { | ||
| 42 | + t.Setenv("MM_SECRET", "x") | ||
| 43 | + cfg := config.HooksConfig{PreToolCall: []config.Hook{wasmHook(t)}, ConfigDir: "/somewhere"} | ||
| 44 | + r, _ := runner(cfg) | ||
| 45 | + d := r.PreToolCall(context.Background(), probeCall(t.TempDir(), map[string]any{"op": "deny-env"})) | ||
| 46 | + if d.Reason != "preToolCall 1" { | ||
| 47 | + t.Errorf("reason %q, want the event and a single variable", d.Reason) | ||
| 48 | + } | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +// A wasm hook reads its folders in the session's directory, and nothing | ||
| 52 | +// outside them. | ||
| 53 | +func TestWasmHookSeesOnlyItsFolders(t *testing.T) { | ||
| 54 | + dir := t.TempDir() | ||
| 55 | + for name, body := range map[string]string{"rules/deny.txt": "rule one", "secret.txt": "no"} { | ||
| 56 | + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { | ||
| 57 | + t.Fatal(err) | ||
| 58 | + } | ||
| 59 | + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { | ||
| 60 | + t.Fatal(err) | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{wasmHook(t, "rules")}}) | ||
| 64 | + if d := r.PreToolCall(context.Background(), probeCall(dir, map[string]any{"op": "deny-read", "path": "rules/deny.txt"})); d.Reason != "rule one" { | ||
| 65 | + t.Errorf("rules/deny.txt: reason %q", d.Reason) | ||
| 66 | + } | ||
| 67 | + if d := r.PreToolCall(context.Background(), probeCall(dir, map[string]any{"op": "deny-read", "path": "secret.txt"})); d.Reason != "read failed" { | ||
| 68 | + t.Errorf("secret.txt: reason %q, want it unreadable", d.Reason) | ||
| 69 | + } | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +// A failing wasm hook follows onError like a command hook, and its report | ||
| 73 | +// names the module: a non-zero exit warns; with fail, the call is denied; | ||
| 74 | +// a module past its timeout is stopped. | ||
| 75 | +func TestFailingWasmHookFollowsOnError(t *testing.T) { | ||
| 76 | + h := wasmHook(t) | ||
| 77 | + r, notes := runner(config.HooksConfig{PreToolCall: []config.Hook{h}}) | ||
| 78 | + if d := r.PreToolCall(context.Background(), probeCall(t.TempDir(), map[string]any{"op": "exit", "code": 3})); d.Denied { | ||
| 79 | + t.Errorf("warn: %+v, want allowed", d) | ||
| 80 | + } | ||
| 81 | + if len(*notes) != 1 || !strings.Contains((*notes)[0], "probe.wasm") || !strings.Contains((*notes)[0], "exit code 3") { | ||
| 82 | + t.Errorf("notes %v", *notes) | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + h.OnError, h.Timeout = config.HookOnErrorFail, 200*time.Millisecond | ||
| 86 | + r, _ = runner(config.HooksConfig{PreToolCall: []config.Hook{h}}) | ||
| 87 | + start := time.Now() | ||
| 88 | + d := r.PreToolCall(context.Background(), probeCall(t.TempDir(), map[string]any{"op": "spin"})) | ||
| 89 | + if !d.Denied || !strings.Contains(d.Reason, "timed out after 200ms") || time.Since(start) > 5*time.Second { | ||
| 90 | + t.Errorf("fail + spin: %+v after %v", d, time.Since(start)) | ||
| 91 | + } | ||
| 92 | +} | ||
modified
internal/tools/declared.go +95 -17 | @@ -5,6 +5,8 @@ import ( | ||
| 5 | 5 | "encoding/json" |
| 6 | 6 | "fmt" |
| 7 | 7 | "maps" |
| 8 | + "os" | |
| 9 | + "path/filepath" | |
| 8 | 10 | "slices" |
| 9 | 11 | "strconv" |
| 10 | 12 | "strings" |
| @@ -15,6 +17,7 @@ import ( | ||
| 15 | 17 | "mm/internal/detector" |
| 16 | 18 | "mm/internal/spinner" |
| 17 | 19 | "mm/internal/ui" |
| 20 | + "mm/internal/wasmtool" | |
| 18 | 21 | |
| 19 | 22 | "github.com/firebase/genkit/go/ai" |
| 20 | 23 | "github.com/firebase/genkit/go/genkit" |
| @@ -38,6 +41,14 @@ import ( | ||
| 38 | 41 | // be explicit: mellum2 packs every argument into one field |
| 39 | 42 | // (`{version: "1.4.2 1.4.2, channel beta"}`) and, told only "missing |
| 40 | 43 | // channel", sends the same call again until the loop detector stops it. |
| 44 | +// | |
| 45 | +// A `wasm:` entry (ticket 119) goes through the same checks, consent and | |
| 46 | +// display; only the run differs: the arguments, typed, are one JSON object | |
| 47 | +// on the module's stdin, and the module runs in wasmtool's sandbox. | |
| 48 | + | |
| 49 | +// wasmRunner runs every wasm tool of the process: each module is compiled | |
| 50 | +// once, on its first call — shared with the wasm hooks. | |
| 51 | +var wasmRunner = wasmtool.Default | |
| 41 | 52 | |
| 42 | 53 | // DeclaredTool declares one `tools:` entry as a Genkit tool. |
| 43 | 54 | // |
| @@ -115,31 +126,77 @@ type declaredCall struct { | ||
| 115 | 126 | detector *detector.LoopDetector |
| 116 | 127 | } |
| 117 | 128 | |
| 129 | +// job is a valid call, ready to run: what the user is shown, and the run | |
| 130 | +// itself in a working directory ("" = mm's own). | |
| 131 | +type job struct { | |
| 132 | + shown string | |
| 133 | + run func(ctx context.Context, dir string) (output string, failed bool) | |
| 134 | +} | |
| 135 | + | |
| 136 | +// prepare checks a call and, when it holds, returns its job; problem is the | |
| 137 | +// text the model gets instead when it does not. | |
| 138 | +func (c declaredCall) prepare(in map[string]any) (job, string) { | |
| 139 | + if c.tool.IsWasm() { | |
| 140 | + return c.wasmJob(in) | |
| 141 | + } | |
| 142 | + argv, problem := buildArgv(c.tool, in) | |
| 143 | + if problem != "" { | |
| 144 | + return job{}, problem | |
| 145 | + } | |
| 146 | + return job{ | |
| 147 | + shown: strings.Join(argv, " "), | |
| 148 | + run: func(ctx context.Context, dir string) (string, bool) { | |
| 149 | + return runArgv(ctx, dir, c.timeout(), argv) | |
| 150 | + }, | |
| 151 | + }, "" | |
| 152 | +} | |
| 153 | + | |
| 154 | +// wasmJob is the job of a wasm tool: shown as the module and its input. | |
| 155 | +func (c declaredCall) wasmJob(in map[string]any) (job, string) { | |
| 156 | + input, problem := wasmInput(c.tool, in) | |
| 157 | + if problem != "" { | |
| 158 | + return job{}, problem | |
| 159 | + } | |
| 160 | + call := wasmtool.Call{Module: c.tool.Wasm, Input: input, Timeout: c.timeout()} | |
| 161 | + for _, f := range c.tool.Folders() { | |
| 162 | + call.Folders = append(call.Folders, wasmtool.Folder{Path: f.Path, Writable: f.Writable}) | |
| 163 | + } | |
| 164 | + return job{ | |
| 165 | + shown: filepath.Base(c.tool.Wasm) + " " + string(input), | |
| 166 | + run: func(ctx context.Context, dir string) (string, bool) { | |
| 167 | + call.Dir = dir | |
| 168 | + if call.Dir == "" { | |
| 169 | + call.Dir, _ = os.Getwd() | |
| 170 | + } | |
| 171 | + return wasmRunner.Run(ctx, call) | |
| 172 | + }, | |
| 173 | + }, "" | |
| 174 | +} | |
| 175 | + | |
| 118 | 176 | // terminal is the line mode: the call is announced, refused when invalid, |
| 119 | 177 | // gated by yolo, run, and its output echoed. |
| 120 | 178 | func (c declaredCall) terminal(ctx context.Context, in map[string]any) (string, error) { |
| 121 | - argv, problem := buildArgv(c.tool, in) | |
| 179 | + j, problem := c.prepare(in) | |
| 122 | 180 | if problem != "" { |
| 123 | 181 | announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, inputText(in))) |
| 124 | 182 | return refuse(c.detector, c.tool.Name, inputText(in), problem) |
| 125 | 183 | } |
| 126 | - shown := strings.Join(argv, " ") | |
| 127 | - announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, shown)) | |
| 128 | - if !confirmed(ctx, c.tool.Name, c.tool.Name+": "+shown) { | |
| 129 | - return refuse(c.detector, c.tool.Name, shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name)) | |
| 184 | + announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, j.shown)) | |
| 185 | + if !confirmed(ctx, c.tool.Name, c.tool.Name+": "+j.shown) { | |
| 186 | + return refuse(c.detector, c.tool.Name, j.shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name)) | |
| 130 | 187 | } |
| 131 | 188 | spinner.Start("Running") |
| 132 | - out, _ := runArgv(ctx, "", c.timeout(), argv) | |
| 189 | + out, _ := j.run(ctx, "") | |
| 133 | 190 | spinner.Suspend(func() { echoOutput(out) }) |
| 134 | 191 | spinner.Start("Thinking") |
| 135 | - return record(c.detector, c.tool.Name, shown, out) | |
| 192 | + return record(c.detector, c.tool.Name, j.shown, out) | |
| 136 | 193 | } |
| 137 | 194 | |
| 138 | 195 | // evented is the tool for an event-consuming front end (ACP, the window): a |
| 139 | -// command runs something, so it asks like bash does when yolo is off. | |
| 196 | +// declared tool runs something, so it asks like bash does when yolo is off. | |
| 140 | 197 | func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any) (string, error) { |
| 141 | - argv, problem := buildArgv(c.tool, in) | |
| 142 | - shown := strings.Join(argv, " ") | |
| 198 | + j, problem := c.prepare(in) | |
| 199 | + shown := j.shown | |
| 143 | 200 | if problem != "" { |
| 144 | 201 | shown = inputText(in) |
| 145 | 202 | } |
| @@ -162,7 +219,7 @@ func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any) | ||
| 162 | 219 | } |
| 163 | 220 | s.ToolRunning(ev.ID) |
| 164 | 221 | // The session's directory, as for bash: in an editor, the project. |
| 165 | - out, failed := runArgv(ctx, s.WorkDir(), c.timeout(), argv) | |
| 222 | + out, failed := j.run(ctx, s.WorkDir()) | |
| 166 | 223 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) |
| 167 | 224 | return record(c.detector, c.tool.Name, shown, out) |
| 168 | 225 | } |
| @@ -196,16 +253,37 @@ func buildArgv(t config.DeclaredTool, in map[string]any) (argv []string, problem | ||
| 196 | 253 | } |
| 197 | 254 | } |
| 198 | 255 | argv = append(argv, config.ReplacePlaceholders(entry, func(name string) string { |
| 199 | - return values[name] | |
| 256 | + if v, given := values[name]; given { | |
| 257 | + return argText(v) | |
| 258 | + } | |
| 259 | + return "" | |
| 200 | 260 | })) |
| 201 | 261 | } |
| 202 | 262 | return argv, "" |
| 203 | 263 | } |
| 204 | 264 | |
| 205 | -// callValues coerces each parameter of the call to its declared type and | |
| 206 | -// renders it as text, applying defaults; faults lists what is wrong. | |
| 207 | -func callValues(t config.DeclaredTool, in map[string]any) (values map[string]string, faults []string) { | |
| 208 | - values = map[string]string{} | |
| 265 | +// wasmInput checks a call like buildArgv does and, when it holds, returns | |
| 266 | +// the module's stdin: the parameters given or defaulted, typed, as JSON. | |
| 267 | +// | |
| 268 | +// wasmInput(wordStats, map[string]any{"text": "a b", "top": "3"}) | |
| 269 | +// // {"text":"a b","top":3}, "" | |
| 270 | +func wasmInput(t config.DeclaredTool, in map[string]any) (input []byte, problem string) { | |
| 271 | + values, faults := callValues(t, in) | |
| 272 | + if len(faults) > 0 { | |
| 273 | + return nil, invalidCall(t, in, faults) | |
| 274 | + } | |
| 275 | + input, err := json.Marshal(values) | |
| 276 | + if err != nil { | |
| 277 | + return nil, fmt.Sprintf("[%s: the call cannot be written as JSON: %v]", t.Name, err) | |
| 278 | + } | |
| 279 | + return input, "" | |
| 280 | +} | |
| 281 | + | |
| 282 | +// callValues coerces each parameter of the call to its declared type, | |
| 283 | +// applying defaults; faults lists what is wrong. A parameter left out with | |
| 284 | +// no default is absent from values. | |
| 285 | +func callValues(t config.DeclaredTool, in map[string]any) (values map[string]any, faults []string) { | |
| 286 | + values = map[string]any{} | |
| 209 | 287 | for _, key := range slices.Sorted(maps.Keys(in)) { |
| 210 | 288 | if _, ok := t.Parameters.Find(key); !ok { |
| 211 | 289 | faults = append(faults, fmt.Sprintf("%q is not a parameter of this tool", key)) |
| @@ -233,7 +311,7 @@ func callValues(t config.DeclaredTool, in map[string]any) (values map[string]str | ||
| 233 | 311 | faults = append(faults, fmt.Sprintf("%s: %v is not one of %s", p.Name, raw, p.EnumText())) |
| 234 | 312 | continue |
| 235 | 313 | } |
| 236 | - values[p.Name] = argText(v) | |
| 314 | + values[p.Name] = v | |
| 237 | 315 | } |
| 238 | 316 | return values, faults |
| 239 | 317 | } |
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | "encoding/json" | 5 | "encoding/json" |
| 6 | "fmt" | 6 | "fmt" |
| 7 | "maps" | 7 | "maps" |
| 8 | + "os" | ||
| 9 | + "path/filepath" | ||
| 8 | "slices" | 10 | "slices" |
| 9 | "strconv" | 11 | "strconv" |
| 10 | "strings" | 12 | "strings" |
| @@ -15,6 +17,7 @@ import ( | |||
| 15 | "mm/internal/detector" | 17 | "mm/internal/detector" |
| 16 | "mm/internal/spinner" | 18 | "mm/internal/spinner" |
| 17 | "mm/internal/ui" | 19 | "mm/internal/ui" |
| 20 | + "mm/internal/wasmtool" | ||
| 18 | 21 | ||
| 19 | "github.com/firebase/genkit/go/ai" | 22 | "github.com/firebase/genkit/go/ai" |
| 20 | "github.com/firebase/genkit/go/genkit" | 23 | "github.com/firebase/genkit/go/genkit" |
| @@ -38,6 +41,14 @@ import ( | |||
| 38 | // be explicit: mellum2 packs every argument into one field | 41 | // be explicit: mellum2 packs every argument into one field |
| 39 | // (`{version: "1.4.2 1.4.2, channel beta"}`) and, told only "missing | 42 | // (`{version: "1.4.2 1.4.2, channel beta"}`) and, told only "missing |
| 40 | // channel", sends the same call again until the loop detector stops it. | 43 | // channel", sends the same call again until the loop detector stops it. |
| 44 | +// | ||
| 45 | +// A `wasm:` entry (ticket 119) goes through the same checks, consent and | ||
| 46 | +// display; only the run differs: the arguments, typed, are one JSON object | ||
| 47 | +// on the module's stdin, and the module runs in wasmtool's sandbox. | ||
| 48 | + | ||
| 49 | +// wasmRunner runs every wasm tool of the process: each module is compiled | ||
| 50 | +// once, on its first call — shared with the wasm hooks. | ||
| 51 | +var wasmRunner = wasmtool.Default | ||
| 41 | 52 | ||
| 42 | // DeclaredTool declares one `tools:` entry as a Genkit tool. | 53 | // DeclaredTool declares one `tools:` entry as a Genkit tool. |
| 43 | // | 54 | // |
| @@ -115,31 +126,77 @@ type declaredCall struct { | |||
| 115 | detector *detector.LoopDetector | 126 | detector *detector.LoopDetector |
| 116 | } | 127 | } |
| 117 | 128 | ||
| 129 | +// job is a valid call, ready to run: what the user is shown, and the run | ||
| 130 | +// itself in a working directory ("" = mm's own). | ||
| 131 | +type job struct { | ||
| 132 | + shown string | ||
| 133 | + run func(ctx context.Context, dir string) (output string, failed bool) | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +// prepare checks a call and, when it holds, returns its job; problem is the | ||
| 137 | +// text the model gets instead when it does not. | ||
| 138 | +func (c declaredCall) prepare(in map[string]any) (job, string) { | ||
| 139 | + if c.tool.IsWasm() { | ||
| 140 | + return c.wasmJob(in) | ||
| 141 | + } | ||
| 142 | + argv, problem := buildArgv(c.tool, in) | ||
| 143 | + if problem != "" { | ||
| 144 | + return job{}, problem | ||
| 145 | + } | ||
| 146 | + return job{ | ||
| 147 | + shown: strings.Join(argv, " "), | ||
| 148 | + run: func(ctx context.Context, dir string) (string, bool) { | ||
| 149 | + return runArgv(ctx, dir, c.timeout(), argv) | ||
| 150 | + }, | ||
| 151 | + }, "" | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +// wasmJob is the job of a wasm tool: shown as the module and its input. | ||
| 155 | +func (c declaredCall) wasmJob(in map[string]any) (job, string) { | ||
| 156 | + input, problem := wasmInput(c.tool, in) | ||
| 157 | + if problem != "" { | ||
| 158 | + return job{}, problem | ||
| 159 | + } | ||
| 160 | + call := wasmtool.Call{Module: c.tool.Wasm, Input: input, Timeout: c.timeout()} | ||
| 161 | + for _, f := range c.tool.Folders() { | ||
| 162 | + call.Folders = append(call.Folders, wasmtool.Folder{Path: f.Path, Writable: f.Writable}) | ||
| 163 | + } | ||
| 164 | + return job{ | ||
| 165 | + shown: filepath.Base(c.tool.Wasm) + " " + string(input), | ||
| 166 | + run: func(ctx context.Context, dir string) (string, bool) { | ||
| 167 | + call.Dir = dir | ||
| 168 | + if call.Dir == "" { | ||
| 169 | + call.Dir, _ = os.Getwd() | ||
| 170 | + } | ||
| 171 | + return wasmRunner.Run(ctx, call) | ||
| 172 | + }, | ||
| 173 | + }, "" | ||
| 174 | +} | ||
| 175 | + | ||
| 118 | // terminal is the line mode: the call is announced, refused when invalid, | 176 | // terminal is the line mode: the call is announced, refused when invalid, |
| 119 | // gated by yolo, run, and its output echoed. | 177 | // gated by yolo, run, and its output echoed. |
| 120 | func (c declaredCall) terminal(ctx context.Context, in map[string]any) (string, error) { | 178 | func (c declaredCall) terminal(ctx context.Context, in map[string]any) (string, error) { |
| 121 | - argv, problem := buildArgv(c.tool, in) | 179 | + j, problem := c.prepare(in) |
| 122 | if problem != "" { | 180 | if problem != "" { |
| 123 | announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, inputText(in))) | 181 | announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, inputText(in))) |
| 124 | return refuse(c.detector, c.tool.Name, inputText(in), problem) | 182 | return refuse(c.detector, c.tool.Name, inputText(in), problem) |
| 125 | } | 183 | } |
| 126 | - shown := strings.Join(argv, " ") | 184 | + announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, j.shown)) |
| 127 | - announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, shown)) | 185 | + if !confirmed(ctx, c.tool.Name, c.tool.Name+": "+j.shown) { |
| 128 | - if !confirmed(ctx, c.tool.Name, c.tool.Name+": "+shown) { | 186 | + return refuse(c.detector, c.tool.Name, j.shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name)) |
| 129 | - return refuse(c.detector, c.tool.Name, shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name)) | ||
| 130 | } | 187 | } |
| 131 | spinner.Start("Running") | 188 | spinner.Start("Running") |
| 132 | - out, _ := runArgv(ctx, "", c.timeout(), argv) | 189 | + out, _ := j.run(ctx, "") |
| 133 | spinner.Suspend(func() { echoOutput(out) }) | 190 | spinner.Suspend(func() { echoOutput(out) }) |
| 134 | spinner.Start("Thinking") | 191 | spinner.Start("Thinking") |
| 135 | - return record(c.detector, c.tool.Name, shown, out) | 192 | + return record(c.detector, c.tool.Name, j.shown, out) |
| 136 | } | 193 | } |
| 137 | 194 | ||
| 138 | // evented is the tool for an event-consuming front end (ACP, the window): a | 195 | // evented is the tool for an event-consuming front end (ACP, the window): a |
| 139 | -// command runs something, so it asks like bash does when yolo is off. | 196 | +// declared tool runs something, so it asks like bash does when yolo is off. |
| 140 | func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any) (string, error) { | 197 | func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any) (string, error) { |
| 141 | - argv, problem := buildArgv(c.tool, in) | 198 | + j, problem := c.prepare(in) |
| 142 | - shown := strings.Join(argv, " ") | 199 | + shown := j.shown |
| 143 | if problem != "" { | 200 | if problem != "" { |
| 144 | shown = inputText(in) | 201 | shown = inputText(in) |
| 145 | } | 202 | } |
| @@ -162,7 +219,7 @@ func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any) | |||
| 162 | } | 219 | } |
| 163 | s.ToolRunning(ev.ID) | 220 | s.ToolRunning(ev.ID) |
| 164 | // The session's directory, as for bash: in an editor, the project. | 221 | // The session's directory, as for bash: in an editor, the project. |
| 165 | - out, failed := runArgv(ctx, s.WorkDir(), c.timeout(), argv) | 222 | + out, failed := j.run(ctx, s.WorkDir()) |
| 166 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) | 223 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) |
| 167 | return record(c.detector, c.tool.Name, shown, out) | 224 | return record(c.detector, c.tool.Name, shown, out) |
| 168 | } | 225 | } |
| @@ -196,16 +253,37 @@ func buildArgv(t config.DeclaredTool, in map[string]any) (argv []string, problem | |||
| 196 | } | 253 | } |
| 197 | } | 254 | } |
| 198 | argv = append(argv, config.ReplacePlaceholders(entry, func(name string) string { | 255 | argv = append(argv, config.ReplacePlaceholders(entry, func(name string) string { |
| 199 | - return values[name] | 256 | + if v, given := values[name]; given { |
| 257 | + return argText(v) | ||
| 258 | + } | ||
| 259 | + return "" | ||
| 200 | })) | 260 | })) |
| 201 | } | 261 | } |
| 202 | return argv, "" | 262 | return argv, "" |
| 203 | } | 263 | } |
| 204 | 264 | ||
| 205 | -// callValues coerces each parameter of the call to its declared type and | 265 | +// wasmInput checks a call like buildArgv does and, when it holds, returns |
| 206 | -// renders it as text, applying defaults; faults lists what is wrong. | 266 | +// the module's stdin: the parameters given or defaulted, typed, as JSON. |
| 207 | -func callValues(t config.DeclaredTool, in map[string]any) (values map[string]string, faults []string) { | 267 | +// |
| 208 | - values = map[string]string{} | 268 | +// wasmInput(wordStats, map[string]any{"text": "a b", "top": "3"}) |
| 269 | +// // {"text":"a b","top":3}, "" | ||
| 270 | +func wasmInput(t config.DeclaredTool, in map[string]any) (input []byte, problem string) { | ||
| 271 | + values, faults := callValues(t, in) | ||
| 272 | + if len(faults) > 0 { | ||
| 273 | + return nil, invalidCall(t, in, faults) | ||
| 274 | + } | ||
| 275 | + input, err := json.Marshal(values) | ||
| 276 | + if err != nil { | ||
| 277 | + return nil, fmt.Sprintf("[%s: the call cannot be written as JSON: %v]", t.Name, err) | ||
| 278 | + } | ||
| 279 | + return input, "" | ||
| 280 | +} | ||
| 281 | + | ||
| 282 | +// callValues coerces each parameter of the call to its declared type, | ||
| 283 | +// applying defaults; faults lists what is wrong. A parameter left out with | ||
| 284 | +// no default is absent from values. | ||
| 285 | +func callValues(t config.DeclaredTool, in map[string]any) (values map[string]any, faults []string) { | ||
| 286 | + values = map[string]any{} | ||
| 209 | for _, key := range slices.Sorted(maps.Keys(in)) { | 287 | for _, key := range slices.Sorted(maps.Keys(in)) { |
| 210 | if _, ok := t.Parameters.Find(key); !ok { | 288 | if _, ok := t.Parameters.Find(key); !ok { |
| 211 | faults = append(faults, fmt.Sprintf("%q is not a parameter of this tool", key)) | 289 | faults = append(faults, fmt.Sprintf("%q is not a parameter of this tool", key)) |
| @@ -233,7 +311,7 @@ func callValues(t config.DeclaredTool, in map[string]any) (values map[string]str | |||
| 233 | faults = append(faults, fmt.Sprintf("%s: %v is not one of %s", p.Name, raw, p.EnumText())) | 311 | faults = append(faults, fmt.Sprintf("%s: %v is not one of %s", p.Name, raw, p.EnumText())) |
| 234 | continue | 312 | continue |
| 235 | } | 313 | } |
| 236 | - values[p.Name] = argText(v) | 314 | + values[p.Name] = v |
| 237 | } | 315 | } |
| 238 | return values, faults | 316 | return values, faults |
| 239 | } | 317 | } |
modified
internal/tools/declared_test.go +75 -0 | @@ -198,3 +198,78 @@ func TestDeclaredToolTimeoutAndMissingProgram(t *testing.T) { | ||
| 198 | 198 | t.Errorf("missing: %q", out) |
| 199 | 199 | } |
| 200 | 200 | } |
| 201 | + | |
| 202 | +// probeTool is a wasm tool over wasmtool's test module, which answers its | |
| 203 | +// own stdin when op is "input" and reads a file when op is "read". | |
| 204 | +func probeTool(t *testing.T, folders ...string) config.DeclaredTool { | |
| 205 | + t.Helper() | |
| 206 | + module, err := filepath.Abs(filepath.Join("..", "wasmtool", "testdata", "probe.wasm")) | |
| 207 | + if err != nil { | |
| 208 | + t.Fatal(err) | |
| 209 | + } | |
| 210 | + optional := false | |
| 211 | + return config.DeclaredTool{ | |
| 212 | + Name: "probe", | |
| 213 | + Description: "d", | |
| 214 | + Parameters: config.ToolParameters{ | |
| 215 | + {Name: "op", Type: config.ParamString}, | |
| 216 | + {Name: "path", Type: config.ParamString, Required: &optional}, | |
| 217 | + {Name: "top", Type: config.ParamInteger, Required: &optional, Default: 3}, | |
| 218 | + {Name: "exact", Type: config.ParamBoolean, Required: &optional}, | |
| 219 | + }, | |
| 220 | + Wasm: module, | |
| 221 | + Read: folders, | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +// The module reads the call as typed JSON — coerced values, defaults | |
| 226 | +// applied, absent optionals left out — and the event shows that input. | |
| 227 | +func TestWasmToolGetsTheCallAsTypedJSON(t *testing.T) { | |
| 228 | + sink := &recordingSink{dir: t.TempDir()} | |
| 229 | + out, err := newDeclaredCall(probeTool(t)).evented(context.Background(), sink, map[string]any{"op": "input", "exact": "true"}) | |
| 230 | + if err != nil { | |
| 231 | + t.Fatal(err) | |
| 232 | + } | |
| 233 | + if want := `{"exact":true,"op":"input","top":3}`; out != want || sink.ended.Failed { | |
| 234 | + t.Errorf("stdin seen by the module = %q, want %q", out, want) | |
| 235 | + } | |
| 236 | + if sink.started.Title != `probe: probe.wasm {"exact":true,"op":"input","top":3}` || sink.started.Kind != "execute" { | |
| 237 | + t.Errorf("event %+v", sink.started) | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +// A wasm tool sees its read folders in the session's directory, nothing else. | |
| 242 | +func TestWasmToolSeesItsFoldersInTheSessionDirectory(t *testing.T) { | |
| 243 | + dir := t.TempDir() | |
| 244 | + for name, body := range map[string]string{"notes/a.md": "hello", "secret.md": "no"} { | |
| 245 | + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { | |
| 246 | + t.Fatal(err) | |
| 247 | + } | |
| 248 | + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { | |
| 249 | + t.Fatal(err) | |
| 250 | + } | |
| 251 | + } | |
| 252 | + call := newDeclaredCall(probeTool(t, "notes")) | |
| 253 | + sink := &recordingSink{dir: dir} | |
| 254 | + if out, _ := call.evented(context.Background(), sink, map[string]any{"op": "read", "path": "notes/a.md"}); out != "hello" { | |
| 255 | + t.Errorf("notes/a.md: %q", out) | |
| 256 | + } | |
| 257 | + if out, _ := call.evented(context.Background(), sink, map[string]any{"op": "read", "path": "secret.md"}); !strings.Contains(out, "read failed") || !sink.ended.Failed { | |
| 258 | + t.Errorf("secret.md: %q, ended %+v", out, sink.ended) | |
| 259 | + } | |
| 260 | +} | |
| 261 | + | |
| 262 | +// A wasm tool asks like a command when yolo is off, and an invalid call | |
| 263 | +// runs nothing. | |
| 264 | +func TestWasmToolIsGatedAndCheckedLikeACommand(t *testing.T) { | |
| 265 | + yoloOff(t) | |
| 266 | + sink := &recordingSink{allow: false, dir: t.TempDir()} | |
| 267 | + call := newDeclaredCall(probeTool(t)) | |
| 268 | + if out, _ := call.evented(context.Background(), sink, map[string]any{"op": "input"}); sink.asked != 1 || out != "[probe rejected by the user]" { | |
| 269 | + t.Errorf("refused: asked %d, out %q", sink.asked, out) | |
| 270 | + } | |
| 271 | + out, _ := call.evented(context.Background(), sink, map[string]any{"top": "many"}) | |
| 272 | + if sink.asked != 1 || !strings.Contains(out, "op is missing") || !strings.Contains(out, "top:") { | |
| 273 | + t.Errorf("invalid: asked %d, out %q", sink.asked, out) | |
| 274 | + } | |
| 275 | +} | |
| @@ -198,3 +198,78 @@ func TestDeclaredToolTimeoutAndMissingProgram(t *testing.T) { | |||
| 198 | t.Errorf("missing: %q", out) | 198 | t.Errorf("missing: %q", out) |
| 199 | } | 199 | } |
| 200 | } | 200 | } |
| 201 | + | ||
| 202 | +// probeTool is a wasm tool over wasmtool's test module, which answers its | ||
| 203 | +// own stdin when op is "input" and reads a file when op is "read". | ||
| 204 | +func probeTool(t *testing.T, folders ...string) config.DeclaredTool { | ||
| 205 | + t.Helper() | ||
| 206 | + module, err := filepath.Abs(filepath.Join("..", "wasmtool", "testdata", "probe.wasm")) | ||
| 207 | + if err != nil { | ||
| 208 | + t.Fatal(err) | ||
| 209 | + } | ||
| 210 | + optional := false | ||
| 211 | + return config.DeclaredTool{ | ||
| 212 | + Name: "probe", | ||
| 213 | + Description: "d", | ||
| 214 | + Parameters: config.ToolParameters{ | ||
| 215 | + {Name: "op", Type: config.ParamString}, | ||
| 216 | + {Name: "path", Type: config.ParamString, Required: &optional}, | ||
| 217 | + {Name: "top", Type: config.ParamInteger, Required: &optional, Default: 3}, | ||
| 218 | + {Name: "exact", Type: config.ParamBoolean, Required: &optional}, | ||
| 219 | + }, | ||
| 220 | + Wasm: module, | ||
| 221 | + Read: folders, | ||
| 222 | + } | ||
| 223 | +} | ||
| 224 | + | ||
| 225 | +// The module reads the call as typed JSON — coerced values, defaults | ||
| 226 | +// applied, absent optionals left out — and the event shows that input. | ||
| 227 | +func TestWasmToolGetsTheCallAsTypedJSON(t *testing.T) { | ||
| 228 | + sink := &recordingSink{dir: t.TempDir()} | ||
| 229 | + out, err := newDeclaredCall(probeTool(t)).evented(context.Background(), sink, map[string]any{"op": "input", "exact": "true"}) | ||
| 230 | + if err != nil { | ||
| 231 | + t.Fatal(err) | ||
| 232 | + } | ||
| 233 | + if want := `{"exact":true,"op":"input","top":3}`; out != want || sink.ended.Failed { | ||
| 234 | + t.Errorf("stdin seen by the module = %q, want %q", out, want) | ||
| 235 | + } | ||
| 236 | + if sink.started.Title != `probe: probe.wasm {"exact":true,"op":"input","top":3}` || sink.started.Kind != "execute" { | ||
| 237 | + t.Errorf("event %+v", sink.started) | ||
| 238 | + } | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +// A wasm tool sees its read folders in the session's directory, nothing else. | ||
| 242 | +func TestWasmToolSeesItsFoldersInTheSessionDirectory(t *testing.T) { | ||
| 243 | + dir := t.TempDir() | ||
| 244 | + for name, body := range map[string]string{"notes/a.md": "hello", "secret.md": "no"} { | ||
| 245 | + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil { | ||
| 246 | + t.Fatal(err) | ||
| 247 | + } | ||
| 248 | + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { | ||
| 249 | + t.Fatal(err) | ||
| 250 | + } | ||
| 251 | + } | ||
| 252 | + call := newDeclaredCall(probeTool(t, "notes")) | ||
| 253 | + sink := &recordingSink{dir: dir} | ||
| 254 | + if out, _ := call.evented(context.Background(), sink, map[string]any{"op": "read", "path": "notes/a.md"}); out != "hello" { | ||
| 255 | + t.Errorf("notes/a.md: %q", out) | ||
| 256 | + } | ||
| 257 | + if out, _ := call.evented(context.Background(), sink, map[string]any{"op": "read", "path": "secret.md"}); !strings.Contains(out, "read failed") || !sink.ended.Failed { | ||
| 258 | + t.Errorf("secret.md: %q, ended %+v", out, sink.ended) | ||
| 259 | + } | ||
| 260 | +} | ||
| 261 | + | ||
| 262 | +// A wasm tool asks like a command when yolo is off, and an invalid call | ||
| 263 | +// runs nothing. | ||
| 264 | +func TestWasmToolIsGatedAndCheckedLikeACommand(t *testing.T) { | ||
| 265 | + yoloOff(t) | ||
| 266 | + sink := &recordingSink{allow: false, dir: t.TempDir()} | ||
| 267 | + call := newDeclaredCall(probeTool(t)) | ||
| 268 | + if out, _ := call.evented(context.Background(), sink, map[string]any{"op": "input"}); sink.asked != 1 || out != "[probe rejected by the user]" { | ||
| 269 | + t.Errorf("refused: asked %d, out %q", sink.asked, out) | ||
| 270 | + } | ||
| 271 | + out, _ := call.evented(context.Background(), sink, map[string]any{"top": "many"}) | ||
| 272 | + if sink.asked != 1 || !strings.Contains(out, "op is missing") || !strings.Contains(out, "top:") { | ||
| 273 | + t.Errorf("invalid: asked %d, out %q", sink.asked, out) | ||
| 274 | + } | ||
| 275 | +} | ||
added
internal/wasmtool/testdata/probe.wasm +0 -0 | new file mode 100755 | ||
| Binary files /dev/null and b/internal/wasmtool/testdata/probe.wasm differ | ||
| new file mode 100755 | |||
| Binary files /dev/null and b/internal/wasmtool/testdata/probe.wasm differ | Binary files /dev/null and b/internal/wasmtool/testdata/probe.wasm differ | ||
added
internal/wasmtool/testdata/probe/main.go +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +// probe is the module the wasmtool tests run: it does what its input asks, | |
| 2 | +// so each test can reach one edge of the sandbox — as a tool, or, with the | |
| 3 | +// deny* ops, as a hook. It reads its JSON input with a plain string search | |
| 4 | +// rather than encoding/json, which would make the fixture ten times larger | |
| 5 | +// (the demo's modules use encoding/json). Rebuilt with: | |
| 6 | +// | |
| 7 | +// tinygo build -o ../probe.wasm -target=wasip1 -no-debug -opt=z ./main.go | |
| 8 | +package main | |
| 9 | + | |
| 10 | +import ( | |
| 11 | + "io" | |
| 12 | + "os" | |
| 13 | + "strconv" | |
| 14 | + "strings" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// field returns the string value of "key" in a flat JSON object without | |
| 18 | +// escapes — enough for the tests' inputs. | |
| 19 | +func field(in, key string) string { | |
| 20 | + at := strings.Index(in, `"`+key+`":`) | |
| 21 | + if at < 0 { | |
| 22 | + return "" | |
| 23 | + } | |
| 24 | + rest := strings.TrimLeft(in[at+len(key)+3:], " ") | |
| 25 | + if strings.HasPrefix(rest, `"`) { | |
| 26 | + rest = rest[1:] | |
| 27 | + return rest[:strings.Index(rest, `"`)] | |
| 28 | + } | |
| 29 | + end := strings.IndexAny(rest, ",}") | |
| 30 | + return strings.TrimSpace(rest[:end]) | |
| 31 | +} | |
| 32 | + | |
| 33 | +func say(s string) { os.Stdout.WriteString(s) } | |
| 34 | + | |
| 35 | +// deny prints a hook reply refusing the call, and stops. | |
| 36 | +func deny(reason string) { | |
| 37 | + say(`{"action":"deny","reason":"` + reason + `"}`) | |
| 38 | + os.Exit(0) | |
| 39 | +} | |
| 40 | + | |
| 41 | +func main() { | |
| 42 | + raw, _ := io.ReadAll(os.Stdin) | |
| 43 | + in := string(raw) | |
| 44 | + switch field(in, "op") { | |
| 45 | + case "input": | |
| 46 | + say(in) | |
| 47 | + case "echo": | |
| 48 | + say(field(in, "text")) | |
| 49 | + os.Stderr.WriteString("+err") | |
| 50 | + case "read": | |
| 51 | + b, err := os.ReadFile(field(in, "path")) | |
| 52 | + if err != nil { | |
| 53 | + say("read failed: " + err.Error()) | |
| 54 | + os.Exit(1) | |
| 55 | + } | |
| 56 | + os.Stdout.Write(b) | |
| 57 | + case "write": | |
| 58 | + if err := os.WriteFile(field(in, "path"), []byte(field(in, "text")), 0o644); err != nil { | |
| 59 | + say("write failed: " + err.Error()) | |
| 60 | + os.Exit(1) | |
| 61 | + } | |
| 62 | + say("written") | |
| 63 | + case "env": | |
| 64 | + say(strconv.Itoa(len(os.Environ()))) | |
| 65 | + case "exit": | |
| 66 | + code, _ := strconv.Atoi(field(in, "code")) | |
| 67 | + say("bye") | |
| 68 | + os.Exit(code) | |
| 69 | + // The hook replies: a deny whose reason carries what the test checks. | |
| 70 | + case "deny": | |
| 71 | + deny(field(in, "text")) | |
| 72 | + case "deny-env": | |
| 73 | + deny(os.Getenv("MM_HOOK_EVENT") + " " + strconv.Itoa(len(os.Environ()))) | |
| 74 | + case "deny-read": | |
| 75 | + b, err := os.ReadFile(field(in, "path")) | |
| 76 | + if err != nil { | |
| 77 | + deny("read failed") | |
| 78 | + } | |
| 79 | + deny(string(b)) | |
| 80 | + case "spin": | |
| 81 | + for { | |
| 82 | + } | |
| 83 | + case "flood": | |
| 84 | + line := strings.Repeat("x", 1024) | |
| 85 | + for i := 0; i < 3*1024; i++ { | |
| 86 | + say(line) | |
| 87 | + } | |
| 88 | + } | |
| 89 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +// probe is the module the wasmtool tests run: it does what its input asks, | ||
| 2 | +// so each test can reach one edge of the sandbox — as a tool, or, with the | ||
| 3 | +// deny* ops, as a hook. It reads its JSON input with a plain string search | ||
| 4 | +// rather than encoding/json, which would make the fixture ten times larger | ||
| 5 | +// (the demo's modules use encoding/json). Rebuilt with: | ||
| 6 | +// | ||
| 7 | +// tinygo build -o ../probe.wasm -target=wasip1 -no-debug -opt=z ./main.go | ||
| 8 | +package main | ||
| 9 | + | ||
| 10 | +import ( | ||
| 11 | + "io" | ||
| 12 | + "os" | ||
| 13 | + "strconv" | ||
| 14 | + "strings" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// field returns the string value of "key" in a flat JSON object without | ||
| 18 | +// escapes — enough for the tests' inputs. | ||
| 19 | +func field(in, key string) string { | ||
| 20 | + at := strings.Index(in, `"`+key+`":`) | ||
| 21 | + if at < 0 { | ||
| 22 | + return "" | ||
| 23 | + } | ||
| 24 | + rest := strings.TrimLeft(in[at+len(key)+3:], " ") | ||
| 25 | + if strings.HasPrefix(rest, `"`) { | ||
| 26 | + rest = rest[1:] | ||
| 27 | + return rest[:strings.Index(rest, `"`)] | ||
| 28 | + } | ||
| 29 | + end := strings.IndexAny(rest, ",}") | ||
| 30 | + return strings.TrimSpace(rest[:end]) | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +func say(s string) { os.Stdout.WriteString(s) } | ||
| 34 | + | ||
| 35 | +// deny prints a hook reply refusing the call, and stops. | ||
| 36 | +func deny(reason string) { | ||
| 37 | + say(`{"action":"deny","reason":"` + reason + `"}`) | ||
| 38 | + os.Exit(0) | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +func main() { | ||
| 42 | + raw, _ := io.ReadAll(os.Stdin) | ||
| 43 | + in := string(raw) | ||
| 44 | + switch field(in, "op") { | ||
| 45 | + case "input": | ||
| 46 | + say(in) | ||
| 47 | + case "echo": | ||
| 48 | + say(field(in, "text")) | ||
| 49 | + os.Stderr.WriteString("+err") | ||
| 50 | + case "read": | ||
| 51 | + b, err := os.ReadFile(field(in, "path")) | ||
| 52 | + if err != nil { | ||
| 53 | + say("read failed: " + err.Error()) | ||
| 54 | + os.Exit(1) | ||
| 55 | + } | ||
| 56 | + os.Stdout.Write(b) | ||
| 57 | + case "write": | ||
| 58 | + if err := os.WriteFile(field(in, "path"), []byte(field(in, "text")), 0o644); err != nil { | ||
| 59 | + say("write failed: " + err.Error()) | ||
| 60 | + os.Exit(1) | ||
| 61 | + } | ||
| 62 | + say("written") | ||
| 63 | + case "env": | ||
| 64 | + say(strconv.Itoa(len(os.Environ()))) | ||
| 65 | + case "exit": | ||
| 66 | + code, _ := strconv.Atoi(field(in, "code")) | ||
| 67 | + say("bye") | ||
| 68 | + os.Exit(code) | ||
| 69 | + // The hook replies: a deny whose reason carries what the test checks. | ||
| 70 | + case "deny": | ||
| 71 | + deny(field(in, "text")) | ||
| 72 | + case "deny-env": | ||
| 73 | + deny(os.Getenv("MM_HOOK_EVENT") + " " + strconv.Itoa(len(os.Environ()))) | ||
| 74 | + case "deny-read": | ||
| 75 | + b, err := os.ReadFile(field(in, "path")) | ||
| 76 | + if err != nil { | ||
| 77 | + deny("read failed") | ||
| 78 | + } | ||
| 79 | + deny(string(b)) | ||
| 80 | + case "spin": | ||
| 81 | + for { | ||
| 82 | + } | ||
| 83 | + case "flood": | ||
| 84 | + line := strings.Repeat("x", 1024) | ||
| 85 | + for i := 0; i < 3*1024; i++ { | ||
| 86 | + say(line) | ||
| 87 | + } | ||
| 88 | + } | ||
| 89 | +} | ||
added
internal/wasmtool/wasmtool.go +276 -0 | new file mode 100644 | ||
| @@ -0,0 +1,276 @@ | ||
| 1 | +// Package wasmtool runs the WebAssembly modules of declared tools (ticket | |
| 2 | +// 119) inside mm, with wazero — pure Go, so mm stays a static binary. | |
| 3 | +// | |
| 4 | +// A module is an ordinary WASI command (TinyGo `-target=wasip1`, a main): | |
| 5 | +// it reads the call's arguments as one JSON object on stdin and writes its | |
| 6 | +// answer on stdout or stderr; a non-zero exit is a failure. It runs in a | |
| 7 | +// sandbox that sees no file, no environment variable and no network: only | |
| 8 | +// the folders the tool's entry opens, mounted under their own name | |
| 9 | +// ("notes" → notes/… or /notes/… inside the module). | |
| 10 | +// | |
| 11 | +// r := wasmtool.New() | |
| 12 | +// defer r.Close(ctx) | |
| 13 | +// out, failed := r.Run(ctx, wasmtool.Call{ | |
| 14 | +// Module: "/abs/tools/search_notes.wasm", | |
| 15 | +// Input: []byte(`{"word":"wazero"}`), | |
| 16 | +// Dir: "/abs/project", | |
| 17 | +// Folders: []wasmtool.Folder{{Path: "notes"}}, | |
| 18 | +// Timeout: 30 * time.Second, | |
| 19 | +// }) | |
| 20 | +package wasmtool | |
| 21 | + | |
| 22 | +import ( | |
| 23 | + "context" | |
| 24 | + "crypto/rand" | |
| 25 | + "errors" | |
| 26 | + "fmt" | |
| 27 | + "io" | |
| 28 | + "os" | |
| 29 | + "path/filepath" | |
| 30 | + "strings" | |
| 31 | + "sync" | |
| 32 | + "time" | |
| 33 | + | |
| 34 | + "github.com/tetratelabs/wazero" | |
| 35 | + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" | |
| 36 | + "github.com/tetratelabs/wazero/sys" | |
| 37 | +) | |
| 38 | + | |
| 39 | +// memoryLimitPages caps a module's linear memory: 4096 pages of 64 KiB = | |
| 40 | +// 256 MiB. A tool that needs more is not the small, contained thing this | |
| 41 | +// sandbox is for. | |
| 42 | +const memoryLimitPages = 4096 | |
| 43 | + | |
| 44 | +// outputCap is how much of a module's output is kept; the rest is dropped | |
| 45 | +// and the answer says so. The same order of size as a command's (1 MiB). | |
| 46 | +const outputCap = 1 << 20 | |
| 47 | + | |
| 48 | +// Folder is one folder of the working directory a module may see. | |
| 49 | +type Folder struct { | |
| 50 | + // Path: relative to the working directory, already cleaned; also the | |
| 51 | + // name the module opens it by. | |
| 52 | + Path string | |
| 53 | + // Writable: false mounts it read-only. | |
| 54 | + Writable bool | |
| 55 | +} | |
| 56 | + | |
| 57 | +// Call is one run of a module. | |
| 58 | +type Call struct { | |
| 59 | + // Module: the .wasm file, absolute. | |
| 60 | + Module string | |
| 61 | + // Input: what the module reads on stdin — the call's arguments as JSON. | |
| 62 | + Input []byte | |
| 63 | + // Dir: the working directory the folders are relative to. | |
| 64 | + Dir string | |
| 65 | + // Folders: what the module may see; nothing else is mounted. | |
| 66 | + Folders []Folder | |
| 67 | + // Timeout: the call's budget; past it the module is stopped. | |
| 68 | + Timeout time.Duration | |
| 69 | + // Env: the only environment variables the module sees, "KEY=value". | |
| 70 | + // Empty for a tool; a hook gets MM_HOOK_EVENT. | |
| 71 | + Env []string | |
| 72 | +} | |
| 73 | + | |
| 74 | +// Default is the runner mm uses for every wasm tool and hook: one runtime | |
| 75 | +// per process, so a module shared by a tool and a hook is compiled once. | |
| 76 | +var Default = New() | |
| 77 | + | |
| 78 | +// ErrCancelled is Exec's error when the caller's context was cancelled. | |
| 79 | +var ErrCancelled = errors.New("cancelled") | |
| 80 | + | |
| 81 | +// TimeoutError is Exec's error when the call outlived its budget. | |
| 82 | +type TimeoutError struct{ Budget time.Duration } | |
| 83 | + | |
| 84 | +func (e TimeoutError) Error() string { return fmt.Sprintf("timed out after %s", e.Budget) } | |
| 85 | + | |
| 86 | +// ExitError is Exec's error when the module exited with a non-zero code. | |
| 87 | +type ExitError struct{ Code int } | |
| 88 | + | |
| 89 | +func (e ExitError) Error() string { return fmt.Sprintf("exit code %d", e.Code) } | |
| 90 | + | |
| 91 | +// Runner compiles each module once, the first time it is called, and runs | |
| 92 | +// a fresh instance of it for every call: no state survives between calls, | |
| 93 | +// and several calls may run at once. | |
| 94 | +type Runner struct { | |
| 95 | + mu sync.Mutex | |
| 96 | + runtime wazero.Runtime | |
| 97 | + compiled map[string]wazero.CompiledModule | |
| 98 | +} | |
| 99 | + | |
| 100 | +// New returns a Runner; nothing is started before the first call. | |
| 101 | +func New() *Runner { | |
| 102 | + return &Runner{compiled: map[string]wazero.CompiledModule{}} | |
| 103 | +} | |
| 104 | + | |
| 105 | +// Close releases the runtime and every compiled module. | |
| 106 | +func (r *Runner) Close(ctx context.Context) error { | |
| 107 | + r.mu.Lock() | |
| 108 | + defer r.mu.Unlock() | |
| 109 | + if r.runtime == nil { | |
| 110 | + return nil | |
| 111 | + } | |
| 112 | + err := r.runtime.Close(ctx) | |
| 113 | + r.runtime, r.compiled = nil, map[string]wazero.CompiledModule{} | |
| 114 | + return err | |
| 115 | +} | |
| 116 | + | |
| 117 | +// Run runs one call for a tool and returns what the module wrote — stdout | |
| 118 | +// and stderr in the order written — and whether it failed. Like a | |
| 119 | +// command's, a failure is text the model can read, appended to the output: | |
| 120 | +// the exit code, the timeout, a missing folder, a module that cannot be | |
| 121 | +// loaded. | |
| 122 | +func (r *Runner) Run(ctx context.Context, call Call) (output string, failed bool) { | |
| 123 | + out := &cappedBuffer{} | |
| 124 | + err := r.Exec(ctx, call, out, out) | |
| 125 | + text := out.text() | |
| 126 | + var timeout TimeoutError | |
| 127 | + var exit ExitError | |
| 128 | + switch { | |
| 129 | + case err == nil: | |
| 130 | + return text, false | |
| 131 | + case errors.As(err, &timeout): | |
| 132 | + return text + fmt.Sprintf("\n[error: %s timeout exceeded]", timeout.Budget), true | |
| 133 | + case errors.Is(err, ErrCancelled): | |
| 134 | + return text + "\n[error: cancelled]", true | |
| 135 | + case errors.As(err, &exit): | |
| 136 | + return text + fmt.Sprintf("\n[exit code: %d]", exit.Code), true | |
| 137 | + } | |
| 138 | + return text + fmt.Sprintf("[error: %v]", err), true | |
| 139 | +} | |
| 140 | + | |
| 141 | +// Exec runs one call with the caller's stdout and stderr, and says how it | |
| 142 | +// ended: nil for exit code 0, a TimeoutError, ErrCancelled, an ExitError, | |
| 143 | +// or an error saying why nothing could run (a missing folder, a module that | |
| 144 | +// cannot be loaded). The hooks use it: their reply is stdout alone. | |
| 145 | +// | |
| 146 | +// var stdout, stderr bytes.Buffer | |
| 147 | +// err := wasmtool.Default.Exec(ctx, call, &stdout, &stderr) | |
| 148 | +func (r *Runner) Exec(ctx context.Context, call Call, stdout, stderr io.Writer) error { | |
| 149 | + config, err := moduleConfig(call) | |
| 150 | + if err != nil { | |
| 151 | + return err | |
| 152 | + } | |
| 153 | + runtime, compiled, err := r.module(ctx, call.Module) | |
| 154 | + if err != nil { | |
| 155 | + return fmt.Errorf("%s cannot be loaded: %w", filepath.Base(call.Module), err) | |
| 156 | + } | |
| 157 | + ctx, cancel := context.WithTimeout(ctx, call.Timeout) | |
| 158 | + defer cancel() | |
| 159 | + | |
| 160 | + instance, err := runtime.InstantiateModule(ctx, compiled, config.WithStdout(stdout).WithStderr(stderr)) | |
| 161 | + if instance != nil { | |
| 162 | + _ = instance.Close(context.WithoutCancel(ctx)) | |
| 163 | + } | |
| 164 | + return ended(ctx, call.Timeout, err) | |
| 165 | +} | |
| 166 | + | |
| 167 | +// ended turns how the instance stopped into Exec's error. The context is | |
| 168 | +// read first: a module stopped at its deadline also reports an exit code. | |
| 169 | +func ended(ctx context.Context, budget time.Duration, err error) error { | |
| 170 | + switch { | |
| 171 | + case errors.Is(ctx.Err(), context.DeadlineExceeded): | |
| 172 | + return TimeoutError{Budget: budget} | |
| 173 | + case errors.Is(ctx.Err(), context.Canceled): | |
| 174 | + return ErrCancelled | |
| 175 | + } | |
| 176 | + var exit *sys.ExitError | |
| 177 | + switch { | |
| 178 | + case errors.As(err, &exit) && exit.ExitCode() == 0: | |
| 179 | + return nil | |
| 180 | + case errors.As(err, &exit): | |
| 181 | + return ExitError{Code: int(exit.ExitCode())} | |
| 182 | + } | |
| 183 | + return err | |
| 184 | +} | |
| 185 | + | |
| 186 | +// module returns the runtime and the compiled module for path, compiling it | |
| 187 | +// on first use. The runtime is created then too: an agent whose wasm tools | |
| 188 | +// are never called pays nothing. | |
| 189 | +func (r *Runner) module(ctx context.Context, path string) (wazero.Runtime, wazero.CompiledModule, error) { | |
| 190 | + r.mu.Lock() | |
| 191 | + defer r.mu.Unlock() | |
| 192 | + if m, ok := r.compiled[path]; ok { | |
| 193 | + return r.runtime, m, nil | |
| 194 | + } | |
| 195 | + if r.runtime == nil { | |
| 196 | + config := wazero.NewRuntimeConfig(). | |
| 197 | + WithCloseOnContextDone(true). | |
| 198 | + WithMemoryLimitPages(memoryLimitPages) | |
| 199 | + r.runtime = wazero.NewRuntimeWithConfig(context.WithoutCancel(ctx), config) | |
| 200 | + wasi_snapshot_preview1.MustInstantiate(context.WithoutCancel(ctx), r.runtime) | |
| 201 | + } | |
| 202 | + code, err := os.ReadFile(path) | |
| 203 | + if err != nil { | |
| 204 | + return nil, nil, err | |
| 205 | + } | |
| 206 | + m, err := r.runtime.CompileModule(context.WithoutCancel(ctx), code) | |
| 207 | + if err != nil { | |
| 208 | + return nil, nil, err | |
| 209 | + } | |
| 210 | + r.compiled[path] = m | |
| 211 | + return r.runtime, m, nil | |
| 212 | +} | |
| 213 | + | |
| 214 | +// moduleConfig is the sandbox of one call: stdin, the clock, randomness, | |
| 215 | +// the declared folders and environment variables — and nothing else (no | |
| 216 | +// arguments but the program name). It fails when a folder is not there. | |
| 217 | +func moduleConfig(call Call) (wazero.ModuleConfig, error) { | |
| 218 | + fs := wazero.NewFSConfig() | |
| 219 | + for _, f := range call.Folders { | |
| 220 | + host := filepath.Join(call.Dir, f.Path) | |
| 221 | + if info, err := os.Stat(host); err != nil || !info.IsDir() { | |
| 222 | + return nil, fmt.Errorf("the folder %s this module may see does not exist in %s", f.Path, call.Dir) | |
| 223 | + } | |
| 224 | + guest := "/" + filepath.ToSlash(f.Path) | |
| 225 | + if f.Writable { | |
| 226 | + fs = fs.WithDirMount(host, guest) | |
| 227 | + } else { | |
| 228 | + fs = fs.WithReadOnlyDirMount(host, guest) | |
| 229 | + } | |
| 230 | + } | |
| 231 | + config := wazero.NewModuleConfig(). | |
| 232 | + // An empty name lets several instances of one module run at once. | |
| 233 | + WithName(""). | |
| 234 | + WithArgs(strings.TrimSuffix(filepath.Base(call.Module), ".wasm")). | |
| 235 | + WithStdin(strings.NewReader(string(call.Input))). | |
| 236 | + WithFSConfig(fs). | |
| 237 | + WithSysWalltime(). | |
| 238 | + WithSysNanotime(). | |
| 239 | + WithSysNanosleep(). | |
| 240 | + WithRandSource(rand.Reader) | |
| 241 | + for _, kv := range call.Env { | |
| 242 | + key, value, _ := strings.Cut(kv, "=") | |
| 243 | + config = config.WithEnv(key, value) | |
| 244 | + } | |
| 245 | + return config, nil | |
| 246 | +} | |
| 247 | + | |
| 248 | +// cappedBuffer keeps the first outputCap bytes written to it. A module runs | |
| 249 | +// on one goroutine, but stdout and stderr share the buffer, hence the lock. | |
| 250 | +type cappedBuffer struct { | |
| 251 | + mu sync.Mutex | |
| 252 | + data []byte | |
| 253 | + dropped bool | |
| 254 | +} | |
| 255 | + | |
| 256 | +func (b *cappedBuffer) Write(p []byte) (int, error) { | |
| 257 | + b.mu.Lock() | |
| 258 | + defer b.mu.Unlock() | |
| 259 | + room := outputCap - len(b.data) | |
| 260 | + if len(p) > room { | |
| 261 | + b.data = append(b.data, p[:max(room, 0)]...) | |
| 262 | + b.dropped = true | |
| 263 | + return len(p), nil | |
| 264 | + } | |
| 265 | + b.data = append(b.data, p...) | |
| 266 | + return len(p), nil | |
| 267 | +} | |
| 268 | + | |
| 269 | +func (b *cappedBuffer) text() string { | |
| 270 | + b.mu.Lock() | |
| 271 | + defer b.mu.Unlock() | |
| 272 | + if b.dropped { | |
| 273 | + return string(b.data) + fmt.Sprintf("\n[output cut at %d MiB]", outputCap>>20) | |
| 274 | + } | |
| 275 | + return string(b.data) | |
| 276 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,276 @@ | |||
| 1 | +// Package wasmtool runs the WebAssembly modules of declared tools (ticket | ||
| 2 | +// 119) inside mm, with wazero — pure Go, so mm stays a static binary. | ||
| 3 | +// | ||
| 4 | +// A module is an ordinary WASI command (TinyGo `-target=wasip1`, a main): | ||
| 5 | +// it reads the call's arguments as one JSON object on stdin and writes its | ||
| 6 | +// answer on stdout or stderr; a non-zero exit is a failure. It runs in a | ||
| 7 | +// sandbox that sees no file, no environment variable and no network: only | ||
| 8 | +// the folders the tool's entry opens, mounted under their own name | ||
| 9 | +// ("notes" → notes/… or /notes/… inside the module). | ||
| 10 | +// | ||
| 11 | +// r := wasmtool.New() | ||
| 12 | +// defer r.Close(ctx) | ||
| 13 | +// out, failed := r.Run(ctx, wasmtool.Call{ | ||
| 14 | +// Module: "/abs/tools/search_notes.wasm", | ||
| 15 | +// Input: []byte(`{"word":"wazero"}`), | ||
| 16 | +// Dir: "/abs/project", | ||
| 17 | +// Folders: []wasmtool.Folder{{Path: "notes"}}, | ||
| 18 | +// Timeout: 30 * time.Second, | ||
| 19 | +// }) | ||
| 20 | +package wasmtool | ||
| 21 | + | ||
| 22 | +import ( | ||
| 23 | + "context" | ||
| 24 | + "crypto/rand" | ||
| 25 | + "errors" | ||
| 26 | + "fmt" | ||
| 27 | + "io" | ||
| 28 | + "os" | ||
| 29 | + "path/filepath" | ||
| 30 | + "strings" | ||
| 31 | + "sync" | ||
| 32 | + "time" | ||
| 33 | + | ||
| 34 | + "github.com/tetratelabs/wazero" | ||
| 35 | + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" | ||
| 36 | + "github.com/tetratelabs/wazero/sys" | ||
| 37 | +) | ||
| 38 | + | ||
| 39 | +// memoryLimitPages caps a module's linear memory: 4096 pages of 64 KiB = | ||
| 40 | +// 256 MiB. A tool that needs more is not the small, contained thing this | ||
| 41 | +// sandbox is for. | ||
| 42 | +const memoryLimitPages = 4096 | ||
| 43 | + | ||
| 44 | +// outputCap is how much of a module's output is kept; the rest is dropped | ||
| 45 | +// and the answer says so. The same order of size as a command's (1 MiB). | ||
| 46 | +const outputCap = 1 << 20 | ||
| 47 | + | ||
| 48 | +// Folder is one folder of the working directory a module may see. | ||
| 49 | +type Folder struct { | ||
| 50 | + // Path: relative to the working directory, already cleaned; also the | ||
| 51 | + // name the module opens it by. | ||
| 52 | + Path string | ||
| 53 | + // Writable: false mounts it read-only. | ||
| 54 | + Writable bool | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +// Call is one run of a module. | ||
| 58 | +type Call struct { | ||
| 59 | + // Module: the .wasm file, absolute. | ||
| 60 | + Module string | ||
| 61 | + // Input: what the module reads on stdin — the call's arguments as JSON. | ||
| 62 | + Input []byte | ||
| 63 | + // Dir: the working directory the folders are relative to. | ||
| 64 | + Dir string | ||
| 65 | + // Folders: what the module may see; nothing else is mounted. | ||
| 66 | + Folders []Folder | ||
| 67 | + // Timeout: the call's budget; past it the module is stopped. | ||
| 68 | + Timeout time.Duration | ||
| 69 | + // Env: the only environment variables the module sees, "KEY=value". | ||
| 70 | + // Empty for a tool; a hook gets MM_HOOK_EVENT. | ||
| 71 | + Env []string | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +// Default is the runner mm uses for every wasm tool and hook: one runtime | ||
| 75 | +// per process, so a module shared by a tool and a hook is compiled once. | ||
| 76 | +var Default = New() | ||
| 77 | + | ||
| 78 | +// ErrCancelled is Exec's error when the caller's context was cancelled. | ||
| 79 | +var ErrCancelled = errors.New("cancelled") | ||
| 80 | + | ||
| 81 | +// TimeoutError is Exec's error when the call outlived its budget. | ||
| 82 | +type TimeoutError struct{ Budget time.Duration } | ||
| 83 | + | ||
| 84 | +func (e TimeoutError) Error() string { return fmt.Sprintf("timed out after %s", e.Budget) } | ||
| 85 | + | ||
| 86 | +// ExitError is Exec's error when the module exited with a non-zero code. | ||
| 87 | +type ExitError struct{ Code int } | ||
| 88 | + | ||
| 89 | +func (e ExitError) Error() string { return fmt.Sprintf("exit code %d", e.Code) } | ||
| 90 | + | ||
| 91 | +// Runner compiles each module once, the first time it is called, and runs | ||
| 92 | +// a fresh instance of it for every call: no state survives between calls, | ||
| 93 | +// and several calls may run at once. | ||
| 94 | +type Runner struct { | ||
| 95 | + mu sync.Mutex | ||
| 96 | + runtime wazero.Runtime | ||
| 97 | + compiled map[string]wazero.CompiledModule | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +// New returns a Runner; nothing is started before the first call. | ||
| 101 | +func New() *Runner { | ||
| 102 | + return &Runner{compiled: map[string]wazero.CompiledModule{}} | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +// Close releases the runtime and every compiled module. | ||
| 106 | +func (r *Runner) Close(ctx context.Context) error { | ||
| 107 | + r.mu.Lock() | ||
| 108 | + defer r.mu.Unlock() | ||
| 109 | + if r.runtime == nil { | ||
| 110 | + return nil | ||
| 111 | + } | ||
| 112 | + err := r.runtime.Close(ctx) | ||
| 113 | + r.runtime, r.compiled = nil, map[string]wazero.CompiledModule{} | ||
| 114 | + return err | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +// Run runs one call for a tool and returns what the module wrote — stdout | ||
| 118 | +// and stderr in the order written — and whether it failed. Like a | ||
| 119 | +// command's, a failure is text the model can read, appended to the output: | ||
| 120 | +// the exit code, the timeout, a missing folder, a module that cannot be | ||
| 121 | +// loaded. | ||
| 122 | +func (r *Runner) Run(ctx context.Context, call Call) (output string, failed bool) { | ||
| 123 | + out := &cappedBuffer{} | ||
| 124 | + err := r.Exec(ctx, call, out, out) | ||
| 125 | + text := out.text() | ||
| 126 | + var timeout TimeoutError | ||
| 127 | + var exit ExitError | ||
| 128 | + switch { | ||
| 129 | + case err == nil: | ||
| 130 | + return text, false | ||
| 131 | + case errors.As(err, &timeout): | ||
| 132 | + return text + fmt.Sprintf("\n[error: %s timeout exceeded]", timeout.Budget), true | ||
| 133 | + case errors.Is(err, ErrCancelled): | ||
| 134 | + return text + "\n[error: cancelled]", true | ||
| 135 | + case errors.As(err, &exit): | ||
| 136 | + return text + fmt.Sprintf("\n[exit code: %d]", exit.Code), true | ||
| 137 | + } | ||
| 138 | + return text + fmt.Sprintf("[error: %v]", err), true | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +// Exec runs one call with the caller's stdout and stderr, and says how it | ||
| 142 | +// ended: nil for exit code 0, a TimeoutError, ErrCancelled, an ExitError, | ||
| 143 | +// or an error saying why nothing could run (a missing folder, a module that | ||
| 144 | +// cannot be loaded). The hooks use it: their reply is stdout alone. | ||
| 145 | +// | ||
| 146 | +// var stdout, stderr bytes.Buffer | ||
| 147 | +// err := wasmtool.Default.Exec(ctx, call, &stdout, &stderr) | ||
| 148 | +func (r *Runner) Exec(ctx context.Context, call Call, stdout, stderr io.Writer) error { | ||
| 149 | + config, err := moduleConfig(call) | ||
| 150 | + if err != nil { | ||
| 151 | + return err | ||
| 152 | + } | ||
| 153 | + runtime, compiled, err := r.module(ctx, call.Module) | ||
| 154 | + if err != nil { | ||
| 155 | + return fmt.Errorf("%s cannot be loaded: %w", filepath.Base(call.Module), err) | ||
| 156 | + } | ||
| 157 | + ctx, cancel := context.WithTimeout(ctx, call.Timeout) | ||
| 158 | + defer cancel() | ||
| 159 | + | ||
| 160 | + instance, err := runtime.InstantiateModule(ctx, compiled, config.WithStdout(stdout).WithStderr(stderr)) | ||
| 161 | + if instance != nil { | ||
| 162 | + _ = instance.Close(context.WithoutCancel(ctx)) | ||
| 163 | + } | ||
| 164 | + return ended(ctx, call.Timeout, err) | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +// ended turns how the instance stopped into Exec's error. The context is | ||
| 168 | +// read first: a module stopped at its deadline also reports an exit code. | ||
| 169 | +func ended(ctx context.Context, budget time.Duration, err error) error { | ||
| 170 | + switch { | ||
| 171 | + case errors.Is(ctx.Err(), context.DeadlineExceeded): | ||
| 172 | + return TimeoutError{Budget: budget} | ||
| 173 | + case errors.Is(ctx.Err(), context.Canceled): | ||
| 174 | + return ErrCancelled | ||
| 175 | + } | ||
| 176 | + var exit *sys.ExitError | ||
| 177 | + switch { | ||
| 178 | + case errors.As(err, &exit) && exit.ExitCode() == 0: | ||
| 179 | + return nil | ||
| 180 | + case errors.As(err, &exit): | ||
| 181 | + return ExitError{Code: int(exit.ExitCode())} | ||
| 182 | + } | ||
| 183 | + return err | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +// module returns the runtime and the compiled module for path, compiling it | ||
| 187 | +// on first use. The runtime is created then too: an agent whose wasm tools | ||
| 188 | +// are never called pays nothing. | ||
| 189 | +func (r *Runner) module(ctx context.Context, path string) (wazero.Runtime, wazero.CompiledModule, error) { | ||
| 190 | + r.mu.Lock() | ||
| 191 | + defer r.mu.Unlock() | ||
| 192 | + if m, ok := r.compiled[path]; ok { | ||
| 193 | + return r.runtime, m, nil | ||
| 194 | + } | ||
| 195 | + if r.runtime == nil { | ||
| 196 | + config := wazero.NewRuntimeConfig(). | ||
| 197 | + WithCloseOnContextDone(true). | ||
| 198 | + WithMemoryLimitPages(memoryLimitPages) | ||
| 199 | + r.runtime = wazero.NewRuntimeWithConfig(context.WithoutCancel(ctx), config) | ||
| 200 | + wasi_snapshot_preview1.MustInstantiate(context.WithoutCancel(ctx), r.runtime) | ||
| 201 | + } | ||
| 202 | + code, err := os.ReadFile(path) | ||
| 203 | + if err != nil { | ||
| 204 | + return nil, nil, err | ||
| 205 | + } | ||
| 206 | + m, err := r.runtime.CompileModule(context.WithoutCancel(ctx), code) | ||
| 207 | + if err != nil { | ||
| 208 | + return nil, nil, err | ||
| 209 | + } | ||
| 210 | + r.compiled[path] = m | ||
| 211 | + return r.runtime, m, nil | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +// moduleConfig is the sandbox of one call: stdin, the clock, randomness, | ||
| 215 | +// the declared folders and environment variables — and nothing else (no | ||
| 216 | +// arguments but the program name). It fails when a folder is not there. | ||
| 217 | +func moduleConfig(call Call) (wazero.ModuleConfig, error) { | ||
| 218 | + fs := wazero.NewFSConfig() | ||
| 219 | + for _, f := range call.Folders { | ||
| 220 | + host := filepath.Join(call.Dir, f.Path) | ||
| 221 | + if info, err := os.Stat(host); err != nil || !info.IsDir() { | ||
| 222 | + return nil, fmt.Errorf("the folder %s this module may see does not exist in %s", f.Path, call.Dir) | ||
| 223 | + } | ||
| 224 | + guest := "/" + filepath.ToSlash(f.Path) | ||
| 225 | + if f.Writable { | ||
| 226 | + fs = fs.WithDirMount(host, guest) | ||
| 227 | + } else { | ||
| 228 | + fs = fs.WithReadOnlyDirMount(host, guest) | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + config := wazero.NewModuleConfig(). | ||
| 232 | + // An empty name lets several instances of one module run at once. | ||
| 233 | + WithName(""). | ||
| 234 | + WithArgs(strings.TrimSuffix(filepath.Base(call.Module), ".wasm")). | ||
| 235 | + WithStdin(strings.NewReader(string(call.Input))). | ||
| 236 | + WithFSConfig(fs). | ||
| 237 | + WithSysWalltime(). | ||
| 238 | + WithSysNanotime(). | ||
| 239 | + WithSysNanosleep(). | ||
| 240 | + WithRandSource(rand.Reader) | ||
| 241 | + for _, kv := range call.Env { | ||
| 242 | + key, value, _ := strings.Cut(kv, "=") | ||
| 243 | + config = config.WithEnv(key, value) | ||
| 244 | + } | ||
| 245 | + return config, nil | ||
| 246 | +} | ||
| 247 | + | ||
| 248 | +// cappedBuffer keeps the first outputCap bytes written to it. A module runs | ||
| 249 | +// on one goroutine, but stdout and stderr share the buffer, hence the lock. | ||
| 250 | +type cappedBuffer struct { | ||
| 251 | + mu sync.Mutex | ||
| 252 | + data []byte | ||
| 253 | + dropped bool | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +func (b *cappedBuffer) Write(p []byte) (int, error) { | ||
| 257 | + b.mu.Lock() | ||
| 258 | + defer b.mu.Unlock() | ||
| 259 | + room := outputCap - len(b.data) | ||
| 260 | + if len(p) > room { | ||
| 261 | + b.data = append(b.data, p[:max(room, 0)]...) | ||
| 262 | + b.dropped = true | ||
| 263 | + return len(p), nil | ||
| 264 | + } | ||
| 265 | + b.data = append(b.data, p...) | ||
| 266 | + return len(p), nil | ||
| 267 | +} | ||
| 268 | + | ||
| 269 | +func (b *cappedBuffer) text() string { | ||
| 270 | + b.mu.Lock() | ||
| 271 | + defer b.mu.Unlock() | ||
| 272 | + if b.dropped { | ||
| 273 | + return string(b.data) + fmt.Sprintf("\n[output cut at %d MiB]", outputCap>>20) | ||
| 274 | + } | ||
| 275 | + return string(b.data) | ||
| 276 | +} | ||
added
internal/wasmtool/wasmtool_test.go +206 -0 | new file mode 100644 | ||
| @@ -0,0 +1,206 @@ | ||
| 1 | +package wasmtool | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "io" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + "time" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// probe is testdata/probe.wasm, built from testdata/probe/main.go. | |
| 14 | +func probe(t *testing.T) string { | |
| 15 | + t.Helper() | |
| 16 | + abs, err := filepath.Abs(filepath.Join("testdata", "probe.wasm")) | |
| 17 | + if err != nil { | |
| 18 | + t.Fatal(err) | |
| 19 | + } | |
| 20 | + return abs | |
| 21 | +} | |
| 22 | + | |
| 23 | +// project is a working directory with notes/a.md, secret/s.md and an empty | |
| 24 | +// out/ folder. | |
| 25 | +func project(t *testing.T) string { | |
| 26 | + t.Helper() | |
| 27 | + dir := t.TempDir() | |
| 28 | + for name, body := range map[string]string{"notes/a.md": "hello notes", "secret/s.md": "top secret"} { | |
| 29 | + path := filepath.Join(dir, name) | |
| 30 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 31 | + t.Fatal(err) | |
| 32 | + } | |
| 33 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 34 | + t.Fatal(err) | |
| 35 | + } | |
| 36 | + } | |
| 37 | + if err := os.Mkdir(filepath.Join(dir, "out"), 0o755); err != nil { | |
| 38 | + t.Fatal(err) | |
| 39 | + } | |
| 40 | + return dir | |
| 41 | +} | |
| 42 | + | |
| 43 | +func run(t *testing.T, r *Runner, dir, input string, folders ...Folder) (string, bool) { | |
| 44 | + t.Helper() | |
| 45 | + return r.Run(context.Background(), Call{Module: probe(t), Input: []byte(input), Dir: dir, Folders: folders, Timeout: 10 * time.Second}) | |
| 46 | +} | |
| 47 | + | |
| 48 | +func newRunner(t *testing.T) *Runner { | |
| 49 | + r := New() | |
| 50 | + t.Cleanup(func() { _ = r.Close(context.Background()) }) | |
| 51 | + return r | |
| 52 | +} | |
| 53 | + | |
| 54 | +// The input arrives on stdin as given; stdout and stderr both come back. | |
| 55 | +func TestRunPassesTheInputAndCollectsBothStreams(t *testing.T) { | |
| 56 | + r := newRunner(t) | |
| 57 | + if out, failed := run(t, r, t.TempDir(), `{"op":"input","word":"wazero"}`); failed || out != `{"op":"input","word":"wazero"}` { | |
| 58 | + t.Errorf("input: %q, failed=%v", out, failed) | |
| 59 | + } | |
| 60 | + if out, failed := run(t, r, t.TempDir(), `{"op":"echo","text":"hi"}`); failed || out != "hi+err" { | |
| 61 | + t.Errorf("echo: %q, failed=%v", out, failed) | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +// A declared folder is readable under its own name, with or without a | |
| 66 | +// leading slash; the rest of the project and of the machine is not there. | |
| 67 | +func TestRunSeesOnlyTheDeclaredFolders(t *testing.T) { | |
| 68 | + r, dir := newRunner(t), project(t) | |
| 69 | + notes := Folder{Path: "notes"} | |
| 70 | + for _, path := range []string{"notes/a.md", "/notes/a.md"} { | |
| 71 | + if out, failed := run(t, r, dir, `{"op":"read","path":"`+path+`"}`, notes); failed || out != "hello notes" { | |
| 72 | + t.Errorf("read %s: %q, failed=%v", path, out, failed) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + for _, path := range []string{"secret/s.md", "../secret/s.md", "/etc/passwd"} { | |
| 76 | + if out, failed := run(t, r, dir, `{"op":"read","path":"`+path+`"}`, notes); !failed || !strings.Contains(out, "read failed") { | |
| 77 | + t.Errorf("read %s: %q, failed=%v — want it refused", path, out, failed) | |
| 78 | + } | |
| 79 | + } | |
| 80 | +} | |
| 81 | + | |
| 82 | +// A read folder cannot be written; a write folder can, and the file lands | |
| 83 | +// in the project. | |
| 84 | +func TestRunWritesOnlyWritableFolders(t *testing.T) { | |
| 85 | + r, dir := newRunner(t), project(t) | |
| 86 | + folders := []Folder{{Path: "notes"}, {Path: "out", Writable: true}} | |
| 87 | + if out, failed := run(t, r, dir, `{"op":"write","path":"notes/b.md","text":"x"}`, folders...); !failed || !strings.Contains(out, "write failed") { | |
| 88 | + t.Errorf("write in a read folder: %q, failed=%v", out, failed) | |
| 89 | + } | |
| 90 | + if out, failed := run(t, r, dir, `{"op":"write","path":"out/b.md","text":"made"}`, folders...); failed || out != "written" { | |
| 91 | + t.Fatalf("write in a write folder: %q, failed=%v", out, failed) | |
| 92 | + } | |
| 93 | + if b, err := os.ReadFile(filepath.Join(dir, "out", "b.md")); err != nil || string(b) != "made" { | |
| 94 | + t.Errorf("out/b.md = %q, %v", b, err) | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +// No environment variable reaches the module, whatever mm's own are. | |
| 99 | +func TestRunPassesNoEnvironment(t *testing.T) { | |
| 100 | + t.Setenv("MM_SECRET", "x") | |
| 101 | + if out, failed := run(t, newRunner(t), t.TempDir(), `{"op":"env"}`); failed || out != "0" { | |
| 102 | + t.Errorf("env count = %q, failed=%v, want 0", out, failed) | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +// Exec keeps stdout and stderr apart, passes only the given environment, | |
| 107 | +// and reports how the module ended as a typed error. | |
| 108 | +func TestExecSeparatesTheStreamsAndTypesTheEnd(t *testing.T) { | |
| 109 | + r := newRunner(t) | |
| 110 | + call := Call{Module: probe(t), Dir: t.TempDir(), Timeout: 10 * time.Second, Env: []string{"MM_HOOK_EVENT=preToolCall"}} | |
| 111 | + var stdout, stderr strings.Builder | |
| 112 | + call.Input = []byte(`{"op":"echo","text":"hi"}`) | |
| 113 | + if err := r.Exec(context.Background(), call, &stdout, &stderr); err != nil || stdout.String() != "hi" || stderr.String() != "+err" { | |
| 114 | + t.Errorf("echo: stdout %q, stderr %q, err %v", stdout.String(), stderr.String(), err) | |
| 115 | + } | |
| 116 | + stdout.Reset() | |
| 117 | + call.Input = []byte(`{"op":"env"}`) | |
| 118 | + if err := r.Exec(context.Background(), call, &stdout, io.Discard); err != nil || stdout.String() != "1" { | |
| 119 | + t.Errorf("env count = %q, err %v, want 1", stdout.String(), err) | |
| 120 | + } | |
| 121 | + call.Input = []byte(`{"op":"exit","code":4}`) | |
| 122 | + if err := r.Exec(context.Background(), call, io.Discard, io.Discard); err != (ExitError{Code: 4}) { | |
| 123 | + t.Errorf("exit 4: err %v", err) | |
| 124 | + } | |
| 125 | + call.Input, call.Timeout = []byte(`{"op":"spin"}`), 200*time.Millisecond | |
| 126 | + if err := r.Exec(context.Background(), call, io.Discard, io.Discard); err != (TimeoutError{Budget: 200 * time.Millisecond}) { | |
| 127 | + t.Errorf("spin: err %v", err) | |
| 128 | + } | |
| 129 | +} | |
| 130 | + | |
| 131 | +// A non-zero exit is a failure with its code; zero is a success. | |
| 132 | +func TestRunReportsTheExitCode(t *testing.T) { | |
| 133 | + r := newRunner(t) | |
| 134 | + if out, failed := run(t, r, t.TempDir(), `{"op":"exit","code":3}`); !failed || out != "bye\n[exit code: 3]" { | |
| 135 | + t.Errorf("exit 3: %q, failed=%v", out, failed) | |
| 136 | + } | |
| 137 | + if out, failed := run(t, r, t.TempDir(), `{"op":"exit","code":0}`); failed || out != "bye" { | |
| 138 | + t.Errorf("exit 0: %q, failed=%v", out, failed) | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +// A module that never ends is stopped at its budget. | |
| 143 | +func TestRunStopsAModuleAtItsTimeout(t *testing.T) { | |
| 144 | + start := time.Now() | |
| 145 | + out, failed := newRunner(t).Run(context.Background(), Call{Module: probe(t), Input: []byte(`{"op":"spin"}`), Dir: t.TempDir(), Timeout: 300 * time.Millisecond}) | |
| 146 | + if !failed || !strings.Contains(out, "300ms timeout exceeded") { | |
| 147 | + t.Errorf("spin: %q, failed=%v", out, failed) | |
| 148 | + } | |
| 149 | + if elapsed := time.Since(start); elapsed > 5*time.Second { | |
| 150 | + t.Errorf("stopped after %v", elapsed) | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +// A cancelled turn stops the module too. | |
| 155 | +func TestRunStopsAModuleWhenCancelled(t *testing.T) { | |
| 156 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 157 | + time.AfterFunc(200*time.Millisecond, cancel) | |
| 158 | + out, failed := newRunner(t).Run(ctx, Call{Module: probe(t), Input: []byte(`{"op":"spin"}`), Dir: t.TempDir(), Timeout: time.Minute}) | |
| 159 | + if !failed || !strings.Contains(out, "[error: cancelled]") { | |
| 160 | + t.Errorf("cancelled spin: %q, failed=%v", out, failed) | |
| 161 | + } | |
| 162 | +} | |
| 163 | + | |
| 164 | +// Output past the cap is dropped, and the answer says so. | |
| 165 | +func TestRunCapsTheOutput(t *testing.T) { | |
| 166 | + out, failed := run(t, newRunner(t), t.TempDir(), `{"op":"flood"}`) | |
| 167 | + if failed || len(out) > outputCap+100 || !strings.HasSuffix(out, "[output cut at 1 MiB]") { | |
| 168 | + t.Errorf("flood: %d bytes, failed=%v, tail %q", len(out), failed, out[max(len(out)-40, 0):]) | |
| 169 | + } | |
| 170 | +} | |
| 171 | + | |
| 172 | +// What cannot run is said, and nothing is started: a missing folder, a | |
| 173 | +// file that is not WebAssembly. | |
| 174 | +func TestRunExplainsWhatCannotRun(t *testing.T) { | |
| 175 | + r, dir := newRunner(t), t.TempDir() | |
| 176 | + if out, failed := run(t, r, dir, `{}`, Folder{Path: "notes"}); !failed || !strings.Contains(out, "folder notes this module may see does not exist") { | |
| 177 | + t.Errorf("missing folder: %q, failed=%v", out, failed) | |
| 178 | + } | |
| 179 | + bogus := filepath.Join(dir, "bogus.wasm") | |
| 180 | + if err := os.WriteFile(bogus, []byte("not wasm"), 0o644); err != nil { | |
| 181 | + t.Fatal(err) | |
| 182 | + } | |
| 183 | + out, failed := r.Run(context.Background(), Call{Module: bogus, Dir: dir, Timeout: time.Second}) | |
| 184 | + if !failed || !strings.Contains(out, "bogus.wasm cannot be loaded") { | |
| 185 | + t.Errorf("bogus module: %q, failed=%v", out, failed) | |
| 186 | + } | |
| 187 | +} | |
| 188 | + | |
| 189 | +// Calls of one module may overlap: each gets its own instance. | |
| 190 | +func TestRunAllowsConcurrentCalls(t *testing.T) { | |
| 191 | + r := newRunner(t) | |
| 192 | + done := make(chan string, 4) | |
| 193 | + for i := range 4 { | |
| 194 | + go func() { | |
| 195 | + out, _ := run(t, r, t.TempDir(), `{"op":"echo","text":"`+string(rune('a'+i))+`"}`) | |
| 196 | + done <- out | |
| 197 | + }() | |
| 198 | + } | |
| 199 | + seen := map[string]bool{} | |
| 200 | + for range 4 { | |
| 201 | + seen[<-done] = true | |
| 202 | + } | |
| 203 | + if len(seen) != 4 { | |
| 204 | + t.Errorf("answers = %v, want 4 distinct", seen) | |
| 205 | + } | |
| 206 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,206 @@ | |||
| 1 | +package wasmtool | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "io" | ||
| 6 | + "os" | ||
| 7 | + "path/filepath" | ||
| 8 | + "strings" | ||
| 9 | + "testing" | ||
| 10 | + "time" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +// probe is testdata/probe.wasm, built from testdata/probe/main.go. | ||
| 14 | +func probe(t *testing.T) string { | ||
| 15 | + t.Helper() | ||
| 16 | + abs, err := filepath.Abs(filepath.Join("testdata", "probe.wasm")) | ||
| 17 | + if err != nil { | ||
| 18 | + t.Fatal(err) | ||
| 19 | + } | ||
| 20 | + return abs | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +// project is a working directory with notes/a.md, secret/s.md and an empty | ||
| 24 | +// out/ folder. | ||
| 25 | +func project(t *testing.T) string { | ||
| 26 | + t.Helper() | ||
| 27 | + dir := t.TempDir() | ||
| 28 | + for name, body := range map[string]string{"notes/a.md": "hello notes", "secret/s.md": "top secret"} { | ||
| 29 | + path := filepath.Join(dir, name) | ||
| 30 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | ||
| 31 | + t.Fatal(err) | ||
| 32 | + } | ||
| 33 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | ||
| 34 | + t.Fatal(err) | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + if err := os.Mkdir(filepath.Join(dir, "out"), 0o755); err != nil { | ||
| 38 | + t.Fatal(err) | ||
| 39 | + } | ||
| 40 | + return dir | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +func run(t *testing.T, r *Runner, dir, input string, folders ...Folder) (string, bool) { | ||
| 44 | + t.Helper() | ||
| 45 | + return r.Run(context.Background(), Call{Module: probe(t), Input: []byte(input), Dir: dir, Folders: folders, Timeout: 10 * time.Second}) | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +func newRunner(t *testing.T) *Runner { | ||
| 49 | + r := New() | ||
| 50 | + t.Cleanup(func() { _ = r.Close(context.Background()) }) | ||
| 51 | + return r | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +// The input arrives on stdin as given; stdout and stderr both come back. | ||
| 55 | +func TestRunPassesTheInputAndCollectsBothStreams(t *testing.T) { | ||
| 56 | + r := newRunner(t) | ||
| 57 | + if out, failed := run(t, r, t.TempDir(), `{"op":"input","word":"wazero"}`); failed || out != `{"op":"input","word":"wazero"}` { | ||
| 58 | + t.Errorf("input: %q, failed=%v", out, failed) | ||
| 59 | + } | ||
| 60 | + if out, failed := run(t, r, t.TempDir(), `{"op":"echo","text":"hi"}`); failed || out != "hi+err" { | ||
| 61 | + t.Errorf("echo: %q, failed=%v", out, failed) | ||
| 62 | + } | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +// A declared folder is readable under its own name, with or without a | ||
| 66 | +// leading slash; the rest of the project and of the machine is not there. | ||
| 67 | +func TestRunSeesOnlyTheDeclaredFolders(t *testing.T) { | ||
| 68 | + r, dir := newRunner(t), project(t) | ||
| 69 | + notes := Folder{Path: "notes"} | ||
| 70 | + for _, path := range []string{"notes/a.md", "/notes/a.md"} { | ||
| 71 | + if out, failed := run(t, r, dir, `{"op":"read","path":"`+path+`"}`, notes); failed || out != "hello notes" { | ||
| 72 | + t.Errorf("read %s: %q, failed=%v", path, out, failed) | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | + for _, path := range []string{"secret/s.md", "../secret/s.md", "/etc/passwd"} { | ||
| 76 | + if out, failed := run(t, r, dir, `{"op":"read","path":"`+path+`"}`, notes); !failed || !strings.Contains(out, "read failed") { | ||
| 77 | + t.Errorf("read %s: %q, failed=%v — want it refused", path, out, failed) | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +// A read folder cannot be written; a write folder can, and the file lands | ||
| 83 | +// in the project. | ||
| 84 | +func TestRunWritesOnlyWritableFolders(t *testing.T) { | ||
| 85 | + r, dir := newRunner(t), project(t) | ||
| 86 | + folders := []Folder{{Path: "notes"}, {Path: "out", Writable: true}} | ||
| 87 | + if out, failed := run(t, r, dir, `{"op":"write","path":"notes/b.md","text":"x"}`, folders...); !failed || !strings.Contains(out, "write failed") { | ||
| 88 | + t.Errorf("write in a read folder: %q, failed=%v", out, failed) | ||
| 89 | + } | ||
| 90 | + if out, failed := run(t, r, dir, `{"op":"write","path":"out/b.md","text":"made"}`, folders...); failed || out != "written" { | ||
| 91 | + t.Fatalf("write in a write folder: %q, failed=%v", out, failed) | ||
| 92 | + } | ||
| 93 | + if b, err := os.ReadFile(filepath.Join(dir, "out", "b.md")); err != nil || string(b) != "made" { | ||
| 94 | + t.Errorf("out/b.md = %q, %v", b, err) | ||
| 95 | + } | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +// No environment variable reaches the module, whatever mm's own are. | ||
| 99 | +func TestRunPassesNoEnvironment(t *testing.T) { | ||
| 100 | + t.Setenv("MM_SECRET", "x") | ||
| 101 | + if out, failed := run(t, newRunner(t), t.TempDir(), `{"op":"env"}`); failed || out != "0" { | ||
| 102 | + t.Errorf("env count = %q, failed=%v, want 0", out, failed) | ||
| 103 | + } | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +// Exec keeps stdout and stderr apart, passes only the given environment, | ||
| 107 | +// and reports how the module ended as a typed error. | ||
| 108 | +func TestExecSeparatesTheStreamsAndTypesTheEnd(t *testing.T) { | ||
| 109 | + r := newRunner(t) | ||
| 110 | + call := Call{Module: probe(t), Dir: t.TempDir(), Timeout: 10 * time.Second, Env: []string{"MM_HOOK_EVENT=preToolCall"}} | ||
| 111 | + var stdout, stderr strings.Builder | ||
| 112 | + call.Input = []byte(`{"op":"echo","text":"hi"}`) | ||
| 113 | + if err := r.Exec(context.Background(), call, &stdout, &stderr); err != nil || stdout.String() != "hi" || stderr.String() != "+err" { | ||
| 114 | + t.Errorf("echo: stdout %q, stderr %q, err %v", stdout.String(), stderr.String(), err) | ||
| 115 | + } | ||
| 116 | + stdout.Reset() | ||
| 117 | + call.Input = []byte(`{"op":"env"}`) | ||
| 118 | + if err := r.Exec(context.Background(), call, &stdout, io.Discard); err != nil || stdout.String() != "1" { | ||
| 119 | + t.Errorf("env count = %q, err %v, want 1", stdout.String(), err) | ||
| 120 | + } | ||
| 121 | + call.Input = []byte(`{"op":"exit","code":4}`) | ||
| 122 | + if err := r.Exec(context.Background(), call, io.Discard, io.Discard); err != (ExitError{Code: 4}) { | ||
| 123 | + t.Errorf("exit 4: err %v", err) | ||
| 124 | + } | ||
| 125 | + call.Input, call.Timeout = []byte(`{"op":"spin"}`), 200*time.Millisecond | ||
| 126 | + if err := r.Exec(context.Background(), call, io.Discard, io.Discard); err != (TimeoutError{Budget: 200 * time.Millisecond}) { | ||
| 127 | + t.Errorf("spin: err %v", err) | ||
| 128 | + } | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +// A non-zero exit is a failure with its code; zero is a success. | ||
| 132 | +func TestRunReportsTheExitCode(t *testing.T) { | ||
| 133 | + r := newRunner(t) | ||
| 134 | + if out, failed := run(t, r, t.TempDir(), `{"op":"exit","code":3}`); !failed || out != "bye\n[exit code: 3]" { | ||
| 135 | + t.Errorf("exit 3: %q, failed=%v", out, failed) | ||
| 136 | + } | ||
| 137 | + if out, failed := run(t, r, t.TempDir(), `{"op":"exit","code":0}`); failed || out != "bye" { | ||
| 138 | + t.Errorf("exit 0: %q, failed=%v", out, failed) | ||
| 139 | + } | ||
| 140 | +} | ||
| 141 | + | ||
| 142 | +// A module that never ends is stopped at its budget. | ||
| 143 | +func TestRunStopsAModuleAtItsTimeout(t *testing.T) { | ||
| 144 | + start := time.Now() | ||
| 145 | + out, failed := newRunner(t).Run(context.Background(), Call{Module: probe(t), Input: []byte(`{"op":"spin"}`), Dir: t.TempDir(), Timeout: 300 * time.Millisecond}) | ||
| 146 | + if !failed || !strings.Contains(out, "300ms timeout exceeded") { | ||
| 147 | + t.Errorf("spin: %q, failed=%v", out, failed) | ||
| 148 | + } | ||
| 149 | + if elapsed := time.Since(start); elapsed > 5*time.Second { | ||
| 150 | + t.Errorf("stopped after %v", elapsed) | ||
| 151 | + } | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +// A cancelled turn stops the module too. | ||
| 155 | +func TestRunStopsAModuleWhenCancelled(t *testing.T) { | ||
| 156 | + ctx, cancel := context.WithCancel(context.Background()) | ||
| 157 | + time.AfterFunc(200*time.Millisecond, cancel) | ||
| 158 | + out, failed := newRunner(t).Run(ctx, Call{Module: probe(t), Input: []byte(`{"op":"spin"}`), Dir: t.TempDir(), Timeout: time.Minute}) | ||
| 159 | + if !failed || !strings.Contains(out, "[error: cancelled]") { | ||
| 160 | + t.Errorf("cancelled spin: %q, failed=%v", out, failed) | ||
| 161 | + } | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +// Output past the cap is dropped, and the answer says so. | ||
| 165 | +func TestRunCapsTheOutput(t *testing.T) { | ||
| 166 | + out, failed := run(t, newRunner(t), t.TempDir(), `{"op":"flood"}`) | ||
| 167 | + if failed || len(out) > outputCap+100 || !strings.HasSuffix(out, "[output cut at 1 MiB]") { | ||
| 168 | + t.Errorf("flood: %d bytes, failed=%v, tail %q", len(out), failed, out[max(len(out)-40, 0):]) | ||
| 169 | + } | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +// What cannot run is said, and nothing is started: a missing folder, a | ||
| 173 | +// file that is not WebAssembly. | ||
| 174 | +func TestRunExplainsWhatCannotRun(t *testing.T) { | ||
| 175 | + r, dir := newRunner(t), t.TempDir() | ||
| 176 | + if out, failed := run(t, r, dir, `{}`, Folder{Path: "notes"}); !failed || !strings.Contains(out, "folder notes this module may see does not exist") { | ||
| 177 | + t.Errorf("missing folder: %q, failed=%v", out, failed) | ||
| 178 | + } | ||
| 179 | + bogus := filepath.Join(dir, "bogus.wasm") | ||
| 180 | + if err := os.WriteFile(bogus, []byte("not wasm"), 0o644); err != nil { | ||
| 181 | + t.Fatal(err) | ||
| 182 | + } | ||
| 183 | + out, failed := r.Run(context.Background(), Call{Module: bogus, Dir: dir, Timeout: time.Second}) | ||
| 184 | + if !failed || !strings.Contains(out, "bogus.wasm cannot be loaded") { | ||
| 185 | + t.Errorf("bogus module: %q, failed=%v", out, failed) | ||
| 186 | + } | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +// Calls of one module may overlap: each gets its own instance. | ||
| 190 | +func TestRunAllowsConcurrentCalls(t *testing.T) { | ||
| 191 | + r := newRunner(t) | ||
| 192 | + done := make(chan string, 4) | ||
| 193 | + for i := range 4 { | ||
| 194 | + go func() { | ||
| 195 | + out, _ := run(t, r, t.TempDir(), `{"op":"echo","text":"`+string(rune('a'+i))+`"}`) | ||
| 196 | + done <- out | ||
| 197 | + }() | ||
| 198 | + } | ||
| 199 | + seen := map[string]bool{} | ||
| 200 | + for range 4 { | ||
| 201 | + seen[<-done] = true | ||
| 202 | + } | ||
| 203 | + if len(seen) != 4 { | ||
| 204 | + t.Errorf("answers = %v, want 4 distinct", seen) | ||
| 205 | + } | ||
| 206 | +} | ||