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

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

Download ZIP

Merge feature/wasm-custom-tools into main

rickub committed 2026-10-21T14:30:24Z Browse files
b8680ab parents: 77aca16 fd5d2a7
modified .tickets/issues/0046-add-builtin-tools-as-plugins-with-wasm.yaml +40 -5
@@ -1,15 +1,18 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 46
33 title: Add builtin tools as plugins with wasm
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-27T02:30:34.975Z
9-updatedAt: 2026-10-21T00:00:00.000Z
9+updatedAt: 2026-10-21T23:59:59.000Z
1010 labels: []
1111 milestone: v0.9.0
1212 epic: Builtin tools
13+related:
14+ - 119
15+ - 120
1316 body: |
1417 example: safe / unsafe with wasm -> LlamaGuard
1518
@@ -25,8 +28,8 @@ tasks:
2528 createdAt: 2026-10-20T07:42:43.652Z
2629 updatedAt: 2026-10-21T00:00:00.000Z
2730 - 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
3033 priority: none
3134 author:
3235 name: k33g
@@ -98,4 +101,36 @@ comments:
98101 a WASM hook with a minimal capability set, compare time per call,
99102 size and the hook author's experience with the Node version. If it
100103 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: 462 id: 46
3 title: Add builtin tools as plugins with wasm3 title: Add builtin tools as plugins with wasm
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-27T02:30:34.975Z8 createdAt: 2026-09-27T02:30:34.975Z
9-updatedAt: 2026-10-21T00:00:00.000Z9+updatedAt: 2026-10-21T23:59:59.000Z
10 labels: []10 labels: []
11 milestone: v0.9.011 milestone: v0.9.0
12 epic: Builtin tools12 epic: Builtin tools
13+related:
14+ - 119
15+ - 120
13 body: |16 body: |
14 example: safe / unsafe with wasm -> LlamaGuard17 example: safe / unsafe with wasm -> LlamaGuard
15 18
@@ -25,8 +28,8 @@ tasks:
25 createdAt: 2026-10-20T07:42:43.652Z28 createdAt: 2026-10-20T07:42:43.652Z
26 updatedAt: 2026-10-21T00:00:00.000Z29 updatedAt: 2026-10-21T00:00:00.000Z
27 - id: 230 - 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: open32+ state: closed
30 priority: none33 priority: none
31 author:34 author:
32 name: k33g35 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 it102 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
161161 | `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 |
162162 | `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 |
163163 | `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 |
164166
165167 ## Learn more
166168
@@ -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 more167 ## 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 differBinary 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 differBinary 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 differBinary 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 differBinary 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 differBinary 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 differBinary 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 differBinary files /dev/null and b/demos/wasm-tools/tools/word_stats.wasm differ
modified docs/diagrams/packages.drawio +1 -1
@@ -1,3 +1,3 @@
11 <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
22 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3- <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><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="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" 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="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><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="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" 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="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" 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="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" 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="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>1 <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3- <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><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="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" 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="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><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="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" 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="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" 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="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" 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)
2828 - [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
2929 - [Add a skill](how-to/add-a-skill.md)
3030 - [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)
3132 - [Manage plans and tasks](how-to/manage-plans-and-tasks.md) — the `plan` tool: give a list, follow the `📋` progress line, plans per conversation
3233 - [Save an answer to a file](how-to/save-an-answer.md)
3334 - [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)
4445 - [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
4546 - [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
4647 - [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
4749 - [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`)
4850 - [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/`
4951
@@ -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 editor28 - [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 conversation32 - [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 5345 - [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 it46 - [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 language47 - [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
4343
4444 **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.
4545
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+
4658 ## Rejected alternatives
4759
4860 - **Only skills.** Cheaper to write and fine for procedures, but measured to fail where it matters on the small models mm targets.
4961 - **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.
5163
5264 ## How it relates to the rest
5365
@@ -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 alternatives58 ## 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 rest64 ## 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:
6767
6868 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.
6969
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+
7080 ## Rejected alternatives
7181
7282 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 alternatives80 ## 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
5252 - **Ask before each call.** Set `yolo: false`: the declared tools then ask like `bash` does. With `yolo: true` they run without asking.
5353 - **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.
5454 - **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).
5556 - **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`.
5657
5758 ## 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 also58 ## 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.
8989 - **Rewrite.** `{"prompt":"…"}` on `preGeneration` replaces the question; `{"input":{…}}` on `preToolCall` replaces the tool's arguments — the whole object, so copy the fields you keep.
9090 - **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`.
9191 - **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).
9293 - **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.
9394 - **With `yolo: false`**, a `preToolCall` hook runs before the consent question: a denied call is never put to the user.
9495 - **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 @@
3838 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |
3939 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |
4040 | `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). |
4343 | `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`. |
4444 | `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). |
4545 | `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
154154 | `tools[].parameters.<p>.enum` | list | none | The only values accepted, each of the parameter's type. |
155155 | `tools[].parameters.<p>.required` | bool | `true` | `false` makes the parameter optional. |
156156 | `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`. |
158161 | `tools[].timeout` | duration | `0` (= `bashTimeout`) | How long one call may run. Must be ≥ 0. |
159162
160163 ```yaml
@@ -167,6 +170,13 @@ tools:
167170 dryRun: { type: boolean, required: false, default: false }
168171 command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"]
169172 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]
170180 ```
171181
172182 ## Provider defaults
@@ -265,7 +275,13 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev
265275 | `tools[N] (<name>).name "<name>" is the name of a built-in tool` | a declared tool named like a built-in tool |
266276 | `tools[N] (<name>): another tool is already called "<name>"` | two entries with one name |
267277 | `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 |
269285 | `tools[N] (<name>).command: the program ("{{p}}") cannot come from a parameter` | a placeholder in the first entry |
270286 | `tools[N] (<name>).command: {{p}} is not a declared parameter` | a placeholder no parameter declares |
271287 | `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 ```yaml163 ```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: 60s172 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 defaults182 ## 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
2020
2121 | Key | Type | Default | Description |
2222 |-----|------|---------|-------------|
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`. |
2427 | `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. |
2528 | `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. |
2629 | `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). |
@@ -39,6 +42,10 @@ hooks:
3942 postGeneration:
4043 - command: python3 "$MM_CONFIG_DIR/hooks/log.py"
4144 onError: ignore
45+ postToolCall:
46+ - wasm: ./hooks/audit.wasm
47+ write: [logs]
48+ onError: ignore
4249 ```
4350
4451 ## Events
@@ -77,6 +84,20 @@ On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preA
7784
7885 `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).
7986
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+
80101 ## Input
81102
82103 The JSON object written on the hook's stdin.
@@ -211,6 +232,9 @@ A run fails when:
211232 | Failure | Report |
212233 |---------|--------|
213234 | 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>` |
214238 | it runs longer than `timeout` | `timed out after <timeout>` |
215239 | the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` |
216240 | stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` |
@@ -228,7 +252,7 @@ What follows depends on `onError`:
228252 | `warn` | report line, next hook | report line, next hook | report line, carry on |
229253 | `ignore` | next hook, silently | next hook, silently | carry on, silently |
230254
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`.
232256
233257 ## Display
234258
@@ -242,7 +266,7 @@ With `displayHooks: true`, every hook run is shown — a hook that works is othe
242266 |------|-------|
243267 | `<n>` | the run's number in the turn, from 1 (line mode, `-p`, window) |
244268 | `<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`) |
246270 | `<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)` |
247271
248272 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.
277301 | Message | Cause |
278302 |---------|-------|
279303 | `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 |
281311 | `hooks.<event>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | a `matcher` on `preGeneration` or `postGeneration` |
282312 | `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile |
283313 | `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: ignore44 onError: ignore
45+ postToolCall:
46+ - wasm: ./hooks/audit.wasm
47+ write: [logs]
48+ onError: ignore
42 ```49 ```
43 50
44 ## Events51 ## 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 ## Input101 ## 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 ## Display257 ## 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
382382 | Recap | Not counted in `⚙ N command(s)`. |
383383 | Hooks | `preToolCall` and `postToolCall` apply, with the tool's name as `matcher`. |
384384
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+
385406 ## Loop detection
386407
387408 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 detection406 ## 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:
2828 - [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
2929 - [Ajouter un skill](how-to/add-a-skill.md)
3030 - [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)
3132 - [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
3233 - [Sauvegarder une réponse dans un fichier](how-to/save-an-answer.md)
3334 - [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:
4445 - [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
4546 - [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
4647 - [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
4749 - [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`)
4850 - [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/`
4951
@@ -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 éditeur28 - [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 conversation32 - [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 5345 - [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'afficher46 - [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 langage47 - [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
4343
4444 **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.
4545
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+
4658 ## Alternatives écartées
4759
4860 - **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.
4961 - **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.
5163
5264 ## Liens avec le reste
5365
@@ -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ées58 ## 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 reste64 ## 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 :
6767
6868 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.
6969
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+
7080 ## Les alternatives écartées
7181
7282 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ées80 ## 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
5252 - **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.
5353 - **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.
5454 - **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).
5556 - **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`.
5657
5758 ## 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 aussi58 ## 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.
8989 - **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.
9090 - **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`.
9191 - **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).
9293 - **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.
9394 - **Avec `yolo: false`**, un hook `preToolCall` s'exécute avant la demande d'autorisation : un appel refusé n'est jamais soumis à l'utilisateur.
9495 - **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 @@
3838 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |
3939 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |
4040 | `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). |
4343 | `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`. |
4444 | `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). |
4545 | `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
154154 | `tools[].parameters.<p>.enum` | liste | aucune | Les seules valeurs acceptées, chacune du type du paramètre. |
155155 | `tools[].parameters.<p>.required` | bool | `true` | `false` rend le paramètre facultatif. |
156156 | `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`. |
158161 | `tools[].timeout` | durée | `0` (= `bashTimeout`) | Durée maximale d'un appel. Doit être ≥ 0. |
159162
160163 ```yaml
@@ -167,6 +170,13 @@ tools:
167170 dryRun: { type: boolean, required: false, default: false }
168171 command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"]
169172 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]
170180 ```
171181
172182 ## Valeurs par défaut des fournisseurs
@@ -265,7 +275,13 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code
265275 | `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é |
266276 | `tools[N] (<nom>): another tool is already called "<nom>"` | deux entrées de même nom |
267277 | `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 |
269285 | `tools[N] (<nom>).command: the program ("{{p}}") cannot come from a parameter` | un placeholder dans la première entrée |
270286 | `tools[N] (<nom>).command: {{p}} is not a declared parameter` | un placeholder qu'aucun paramètre ne déclare |
271287 | `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 ```yaml163 ```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: 60s172 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 fournisseurs182 ## 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
2020
2121 | Clé | Type | Défaut | Description |
2222 |-----|------|--------|-------------|
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`. |
2427 | `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`. |
2528 | `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. |
2629 | `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). |
@@ -39,6 +42,10 @@ hooks:
3942 postGeneration:
4043 - command: python3 "$MM_CONFIG_DIR/hooks/log.py"
4144 onError: ignore
45+ postToolCall:
46+ - wasm: ./hooks/audit.wasm
47+ write: [logs]
48+ onError: ignore
4249 ```
4350
4451 ## Événements
@@ -77,6 +84,20 @@ Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisat
7784
7885 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).
7986
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+
80101 ## Entrée
81102
82103 L'objet JSON écrit sur l'entrée standard du hook.
@@ -211,6 +232,9 @@ Une exécution échoue quand :
211232 | Échec | Message |
212233 |-------|---------|
213234 | 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>` |
214238 | elle dure plus que `timeout` | `timed out after <timeout>` |
215239 | le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` |
216240 | stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` |
@@ -228,7 +252,7 @@ La suite dépend d'`onError` :
228252 | `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue |
229253 | `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire |
230254
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`.
232256
233257 ## Affichage
234258
@@ -242,7 +266,7 @@ Avec `displayHooks: true`, chaque exécution de hook est affichée — sinon, un
242266 |--------|--------|
243267 | `<n>` | le numéro de l'exécution dans le tour, à partir de 1 (mode ligne, `-p`, fenêtre) |
244268 | `<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`) |
246270 | `<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)` |
247271
248272 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
277301 | Message | Cause |
278302 |---------|-------|
279303 | `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 |
281311 | `hooks.<événement>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | un `matcher` sur `preGeneration` ou `postGeneration` |
282312 | `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas |
283313 | `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: ignore44 onError: ignore
45+ postToolCall:
46+ - wasm: ./hooks/audit.wasm
47+ write: [logs]
48+ onError: ignore
42 ```49 ```
43 50
44 ## Événements51 ## É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ée101 ## 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 ## Affichage257 ## 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 (
382382 | Récapitulatif | Non compté dans `⚙ N command(s)`. |
383383 | Hooks | `preToolCall` et `postToolCall` s'appliquent, avec le nom de l'outil comme `matcher`. |
384384
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+
385406 ## Détection de boucle
386407
387408 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 boucle406 ## 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 (
1010 github.com/gdamore/tcell/v2 v2.13.10
1111 github.com/openai/openai-go v1.8.2
1212 github.com/sashabaranov/go-openai v1.42.1
13+ github.com/tetratelabs/wazero v1.12.0
1314 go.lsp.dev/jsonrpc2 v1.0.1
1415 go.lsp.dev/protocol v1.0.1
1516 go.lsp.dev/uri v1.0.1
@@ -10,6 +10,7 @@ require (
10 github.com/gdamore/tcell/v2 v2.13.1010 github.com/gdamore/tcell/v2 v2.13.10
11 github.com/openai/openai-go v1.8.211 github.com/openai/openai-go v1.8.2
12 github.com/sashabaranov/go-openai v1.42.112 github.com/sashabaranov/go-openai v1.42.1
13+ github.com/tetratelabs/wazero v1.12.0
13 go.lsp.dev/jsonrpc2 v1.0.114 go.lsp.dev/jsonrpc2 v1.0.1
14 go.lsp.dev/protocol v1.0.115 go.lsp.dev/protocol v1.0.1
15 go.lsp.dev/uri v1.0.116 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+
5656 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
5757 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
5858 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=
5961 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
6062 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
6163 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) {
693693 }
694694 resolveAgentPaths(path)
695695 resolveHooks(path)
696+ resolveToolPaths(path)
696697 if err := Cfg.validate(); err != nil {
697698 return "", fmt.Errorf("%s: %w", path, err)
698699 }
@@ -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 {
8585 type Hook struct {
8686 // Command is run with `sh -c`, in the session's working directory.
8787 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"`
8897 // Matcher is a regular expression on the tool name (preToolCall and
8998 // postToolCall) or on the asked agent's name (preAsk and postAsk).
9099 // Empty = every one. Unanchored, like grep: "bash" also matches
@@ -134,6 +143,27 @@ func (h *HooksConfig) slot(event string) *[]Hook {
134143 return nil
135144 }
136145
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+
137167 // For returns the hooks declared for an event, in order; nil for an event
138168 // with none (or an unknown one).
139169 //
@@ -173,6 +203,22 @@ func (h Hook) Matches(name string) bool {
173203 return err == nil && re.MatchString(name)
174204 }
175205
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+
176222 // validate checks every hook entry: a command, a matcher only where a tool
177223 // or an agent is named and one that compiles, a timeout that is not
178224 // negative, and a known onError.
@@ -189,9 +235,10 @@ func (h HooksConfig) validate() error {
189235
190236 func (h Hook) validate(event string) error {
191237 named := event != HookPreGeneration && event != HookPostGeneration
238+ if err := h.validateRunnable(); err != nil {
239+ return err
240+ }
192241 switch {
193- case strings.TrimSpace(h.Command) == "":
194- return errors.New("command must not be empty")
195242 case h.Matcher != "" && !named:
196243 return fmt.Errorf("matcher is only for %s, %s, %s and %s", HookPreToolCall, HookPostToolCall, HookPreAsk, HookPostAsk)
197244 case h.Timeout < 0:
@@ -217,6 +264,9 @@ func resolveHooks(configPath string) {
217264 for _, event := range hookEvents {
218265 list := Cfg.Hooks.For(event)
219266 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+ }
220270 if list[i].Timeout == 0 {
221271 list[i].Timeout = DefaultHookTimeout
222272 }
@@ -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 and97 // 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 matches99 // Empty = every one. Unanchored, like grep: "bash" also matches
@@ -134,6 +143,27 @@ func (h *HooksConfig) slot(event string) *[]Hook {
134 return nil143 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 event167 // 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 tool222 // 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 not223 // 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 != HookPostGeneration237 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 = DefaultHookTimeout271 list[i].Timeout = DefaultHookTimeout
222 }272 }
modified internal/config/hooks_test.go +44 -0
@@ -1,12 +1,48 @@
11 package config
22
33 import (
4+ "os"
45 "path/filepath"
56 "strings"
67 "testing"
78 "time"
89 )
910
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+
1046 // No `hooks:` block: no hook, and an empty banner summary.
1147 func TestHooksDefaultToNone(t *testing.T) {
1248 if err := loadYAML(t, "model: m\n"); err != nil {
@@ -106,6 +142,14 @@ func TestInvalidHooksAreRejected(t *testing.T) {
106142 "hooks:\n postGeneration:\n - command: x\n timeout: -1s\n", "timeout must be >= 0"},
107143 "unknown onError": {
108144 "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"},
109153 }
110154 for name, tc := range cases {
111155 t.Run(name, func(t *testing.T) {
@@ -1,12 +1,48 @@
1 package config1 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
1818 // The command runs WITHOUT a shell: each element is one argv entry, and a
1919 // parameter's value replaces its {{placeholder}} inside one entry, so a value
2020 // 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]
2134
2235 import (
2336 "fmt"
37+ "os"
38+ "path/filepath"
2439 "regexp"
2540 "slices"
2641 "strconv"
@@ -80,10 +95,72 @@ type DeclaredTool struct {
8095 // Command: the program and its arguments, one argv entry each; a
8196 // {{parameter}} inside an entry is replaced by the call's value.
8297 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"`
83106 // Timeout: how long one call may run. 0 = bashTimeout.
84107 Timeout time.Duration `yaml:"timeout"`
85108 }
86109
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+
87164 // ToolParameter is one argument of a declared tool.
88165 type ToolParameter struct {
89166 // Name: the key under `parameters:`.
@@ -185,19 +262,53 @@ func (t DeclaredTool) validate(where string) error {
185262 return fmt.Errorf("%s.name %q is the name of a built-in tool", where, t.Name)
186263 case strings.TrimSpace(t.Description) == "":
187264 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])
192265 case t.Timeout < 0:
193266 return fmt.Errorf("%s.timeout must be >= 0", where)
194267 }
195268 if err := t.validateParameters(where); err != nil {
196269 return err
197270 }
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+ }
198282 return t.validatePlaceholders(where)
199283 }
200284
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+
201312 // validateParameters checks each parameter's type, enum and default.
202313 func (t DeclaredTool) validateParameters(where string) error {
203314 seen := map[string]bool{}
@@ -18,9 +18,24 @@ package config
18 // The command runs WITHOUT a shell: each element is one argv entry, and a18 // 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 value19 // 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; a95 // 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 err269 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 @@
11 package config
22
33 import (
4+ "os"
5+ "path/filepath"
6+ "slices"
47 "strings"
58 "testing"
69 "time"
@@ -78,6 +81,10 @@ func TestInvalidDeclaredToolsAreRejected(t *testing.T) {
7881 "negative timeout": {`[{name: t, description: d, command: [a], timeout: -1s}]`, "timeout must be >= 0"},
7982 "duplicate name": {`[{name: t, description: d, command: [a]}, {name: t, description: d, command: [b]}]`, "already called"},
8083 "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"},
8188 }
8289 for name, c := range cases {
8390 t.Run(name, func(t *testing.T) {
@@ -89,6 +96,55 @@ func TestInvalidDeclaredToolsAreRejected(t *testing.T) {
8996 }
9097 }
9198
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+
92148 // Values from the model are coerced to the declared type; the string forms
93149 // models often write for numbers and booleans are accepted, nothing else.
94150 func TestCoerce(t *testing.T) {
@@ -1,6 +1,9 @@
1 package config1 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 forms148 // 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 (
1212 "time"
1313
1414 "mm/internal/config"
15+ "mm/internal/wasmtool"
1516 )
1617
1718 // Environment variables a hook can read besides its stdin.
@@ -38,7 +39,7 @@ const maxStderr = 300
3839 // otherwise block Wait forever (the same trap as the bash tool's, ticket 26).
3940 const waitDelay = time.Second
4041
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,
4243 // behind a field so a test elsewhere can swap the process for a function.
4344 type execFunc func(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error)
4445
@@ -59,7 +60,7 @@ func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, erro
5960 if err == nil {
6061 reply, err = decode(stdout, in.Event)
6162 }
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)})
6364 return reply, err
6465 }
6566
@@ -121,6 +122,49 @@ func checkAction(reply Reply, event string) error {
121122 return fmt.Errorf("action %q is not valid for %s", reply.Action, event)
122123 }
123124
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+
124168 // runCommand runs h.Command with `sh -c` in dir ("" = mm's own directory),
125169 // under h.Timeout, with env added to mm's environment.
126170 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.Second40 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, err64 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 {
119119 if notify == nil {
120120 notify = func(string) {}
121121 }
122- return &Runner{cfg: cfg, notify: notify, exec: runCommand}
122+ return &Runner{cfg: cfg, notify: notify, exec: runHook}
123123 }
124124
125125 // 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) {
284284 if h.OnError == config.HookOnErrorIgnore {
285285 return false
286286 }
287- r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error())
287+ r.notify("🪝 " + event + " hook `" + h.Label() + "`: " + err.Error())
288288 pre := event == config.HookPreGeneration || event == config.HookPreToolCall || event == config.HookPreAsk
289289 return pre && h.OnError == config.HookOnErrorFail
290290 }
@@ -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 building125 // 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 false285 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.HookPreAsk288 pre := event == config.HookPreGeneration || event == config.HookPreToolCall || event == config.HookPreAsk
289 return pre && h.OnError == config.HookOnErrorFail289 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 (
55 "encoding/json"
66 "fmt"
77 "maps"
8+ "os"
9+ "path/filepath"
810 "slices"
911 "strconv"
1012 "strings"
@@ -15,6 +17,7 @@ import (
1517 "mm/internal/detector"
1618 "mm/internal/spinner"
1719 "mm/internal/ui"
20+ "mm/internal/wasmtool"
1821
1922 "github.com/firebase/genkit/go/ai"
2023 "github.com/firebase/genkit/go/genkit"
@@ -38,6 +41,14 @@ import (
3841 // be explicit: mellum2 packs every argument into one field
3942 // (`{version: "1.4.2 1.4.2, channel beta"}`) and, told only "missing
4043 // 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
4152
4253 // DeclaredTool declares one `tools:` entry as a Genkit tool.
4354 //
@@ -115,31 +126,77 @@ type declaredCall struct {
115126 detector *detector.LoopDetector
116127 }
117128
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+
118176 // terminal is the line mode: the call is announced, refused when invalid,
119177 // gated by yolo, run, and its output echoed.
120178 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)
122180 if problem != "" {
123181 announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, inputText(in)))
124182 return refuse(c.detector, c.tool.Name, inputText(in), problem)
125183 }
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))
130187 }
131188 spinner.Start("Running")
132- out, _ := runArgv(ctx, "", c.timeout(), argv)
189+ out, _ := j.run(ctx, "")
133190 spinner.Suspend(func() { echoOutput(out) })
134191 spinner.Start("Thinking")
135- return record(c.detector, c.tool.Name, shown, out)
192+ return record(c.detector, c.tool.Name, j.shown, out)
136193 }
137194
138195 // 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.
140197 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
143200 if problem != "" {
144201 shown = inputText(in)
145202 }
@@ -162,7 +219,7 @@ func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any)
162219 }
163220 s.ToolRunning(ev.ID)
164221 // 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())
166223 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
167224 return record(c.detector, c.tool.Name, shown, out)
168225 }
@@ -196,16 +253,37 @@ func buildArgv(t config.DeclaredTool, in map[string]any) (argv []string, problem
196253 }
197254 }
198255 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 ""
200260 }))
201261 }
202262 return argv, ""
203263 }
204264
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{}
209287 for _, key := range slices.Sorted(maps.Keys(in)) {
210288 if _, ok := t.Parameters.Find(key); !ok {
211289 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
233311 faults = append(faults, fmt.Sprintf("%s: %v is not one of %s", p.Name, raw, p.EnumText()))
234312 continue
235313 }
236- values[p.Name] = argText(v)
314+ values[p.Name] = v
237315 }
238316 return values, faults
239317 }
@@ -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 field41 // be explicit: mellum2 packs every argument into one field
39 // (`{version: "1.4.2 1.4.2, channel beta"}`) and, told only "missing42 // (`{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.LoopDetector126 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): a195 // 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 and265+// 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 continue312 continue
235 }313 }
236- values[p.Name] = argText(v)314+ values[p.Name] = v
237 }315 }
238 return values, faults316 return values, faults
239 }317 }
modified internal/tools/declared_test.go +75 -0
@@ -198,3 +198,78 @@ func TestDeclaredToolTimeoutAndMissingProgram(t *testing.T) {
198198 t.Errorf("missing: %q", out)
199199 }
200200 }
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 differBinary 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+}