🛟 Updated.
46087c3 parent: bbcc277 modified
.tickets/issues/0036-hooks.yaml +109 -2 | @@ -6,7 +6,7 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T06:32:48.566Z |
| 9 | -updatedAt: 2026-10-06T13:16:31.213Z | |
| 9 | +updatedAt: 2026-10-14T12:06:43.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | epic: Loop |
| 12 | 12 | body: | |
| @@ -524,5 +524,112 @@ body: | | ||
| 524 | 524 | 5. Should hooks be able to modify tool configurations? |
| 525 | 525 | - Current: Tools are read-only |
| 526 | 526 | - Suggestion: No, for security |
| 527 | -tasks: [] | |
| 527 | + | |
| 528 | + ## Design retained for v1 (2026-10-14) | |
| 529 | + | |
| 530 | + The sections above are the original exploration. v1 keeps the core of that design and simplifies it. This section is what is actually implemented. | |
| 531 | + | |
| 532 | + ### Where the hooks run | |
| 533 | + All four front ends (REPL, window, ACP, `-p`) go through `engine.Generate`, and Genkit's middleware there already has a `WrapTool` that sees **every** tool call, built-in and MCP. Hooks plug in at those two points, so no front end has to change. | |
| 534 | + | |
| 535 | + ### Events | |
| 536 | + | Event | When | Can do | | |
| 537 | + | --- | --- | --- | | |
| 538 | + | `preGeneration` | once per user question, before the first model call | rewrite the prompt (`prompt`), add context (`context`), stop the turn (`action: stop` + `response`) | | |
| 539 | + | `preToolCall` | before each tool call; optional `matcher` (regexp on the tool name) | deny the call (`action: deny` + `reason`, which the model reads as the tool's output) or replace its `input` | | |
| 540 | + | `postToolCall` | after each tool call; optional `matcher` | observe; append `context` to the tool's output | | |
| 541 | + | `postGeneration` | after the turn, success or failure | observe (logging, notification) | | |
| 542 | + | |
| 543 | + ### Protocol | |
| 544 | + - **stdin:** one JSON object, for example `{"event":"preToolCall","cwd":"/abs/dir","model":"…","tool":{"id":"…","name":"bash","input":{…}}}`. `preGeneration` carries `prompt`; `postToolCall` adds `output`; `postGeneration` carries `answer` and `error`. | |
| 545 | + - **stdout:** one JSON object, or **nothing, which means `continue`**. A logging hook does not have to print anything. | |
| 546 | + - **stderr:** shown only when the hook fails. | |
| 547 | + - **Execution:** `sh -c "<command>"` in the session's working directory, with `MM_HOOK_EVENT` and `MM_CONFIG_DIR` (the YAML file's directory) in the environment, so you can write `node "$MM_CONFIG_DIR/hooks/x.js"`. | |
| 548 | + - **Chain:** hooks run in list order, each one sees the previous one's rewrite, and the first `stop`/`deny` ends the chain. | |
| 549 | + - **Failure** (non-zero exit, timeout, invalid JSON, `action: error`): `onError: fail | warn | ignore`, default `warn`. `fail` stops the turn or denies the tool. `timeout` defaults to 10s. Output is capped at 1 MiB. | |
| 550 | + - **Stopped turn:** the history keeps `[prompt blocked by a hook: <reason>]` and the hook's response. The blocked text (a secret, for example) never reaches the model, not even on a later turn. This was the user's choice. | |
| 551 | + | |
| 552 | + ### Configuration | |
| 553 | + ```yaml | |
| 554 | + hooks: | |
| 555 | + preGeneration: | |
| 556 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | |
| 557 | + timeout: 5s | |
| 558 | + onError: fail | |
| 559 | + preToolCall: | |
| 560 | + - matcher: ^bash$ | |
| 561 | + command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | |
| 562 | + postToolCall: | |
| 563 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | |
| 564 | + postGeneration: | |
| 565 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | |
| 566 | + ``` | |
| 567 | + | |
| 568 | + ### Left out of v1 (possible follow-ups) | |
| 569 | + - **Lifecycle hooks** (preStartup, postConfig, preShutdown, onError), **session hooks** and **front-end hooks**: no use case yet. | |
| 570 | + - **`async` mode and numeric priorities:** list order is enough, and a log hook is fast. | |
| 571 | + - **Returning the full `messages` array:** fragile, since a hook could rewrite history or drop the system prompt. `prompt` + `context` cover the four requirements (detect, rewrite, add context, stop). | |
| 572 | + - **`allowedPaths` / `allowedExtensions`:** the YAML already starts MCP servers and gives the model `bash`. Whoever writes the YAML is trusted, so an allowlist would protect nothing more. | |
| 573 | + - On the original open questions: no hook state directory (a hook can write files itself); hooks cannot run tools; there is no total chain timeout, only a per-hook one. | |
| 574 | +tasks: | |
| 575 | + - id: 1 | |
| 576 | + title: "Config: hooks block and validation" | |
| 577 | + state: closed | |
| 578 | + priority: high | |
| 579 | + author: | |
| 580 | + name: k33g | |
| 581 | + email: ph.charriere@gmail.com | |
| 582 | + createdAt: 2026-10-14T11:42:48.000Z | |
| 583 | + updatedAt: 2026-10-14T11:44:02.000Z | |
| 584 | + closedAt: 2026-10-14T11:44:02.000Z | |
| 585 | + - id: 2 | |
| 586 | + title: "internal/hooks: process runner, protocol, chain, onError" | |
| 587 | + state: closed | |
| 588 | + priority: high | |
| 589 | + author: | |
| 590 | + name: k33g | |
| 591 | + email: ph.charriere@gmail.com | |
| 592 | + createdAt: 2026-10-14T11:42:48.000Z | |
| 593 | + updatedAt: 2026-10-14T11:53:54.000Z | |
| 594 | + closedAt: 2026-10-14T11:53:54.000Z | |
| 595 | + - id: 3 | |
| 596 | + title: "Engine: preGeneration and postGeneration" | |
| 597 | + state: closed | |
| 598 | + priority: high | |
| 599 | + author: | |
| 600 | + name: k33g | |
| 601 | + email: ph.charriere@gmail.com | |
| 602 | + createdAt: 2026-10-14T11:42:48.000Z | |
| 603 | + updatedAt: 2026-10-14T11:57:09.000Z | |
| 604 | + closedAt: 2026-10-14T11:57:09.000Z | |
| 605 | + - id: 4 | |
| 606 | + title: "Engine: preToolCall and postToolCall (WrapTool), display, banner" | |
| 607 | + state: closed | |
| 608 | + priority: high | |
| 609 | + author: | |
| 610 | + name: k33g | |
| 611 | + email: ph.charriere@gmail.com | |
| 612 | + createdAt: 2026-10-14T11:42:48.000Z | |
| 613 | + updatedAt: 2026-10-14T11:57:09.000Z | |
| 614 | + closedAt: 2026-10-14T11:57:09.000Z | |
| 615 | + - id: 5 | |
| 616 | + title: "Demo demos/hooks with Node.js hooks (README EN+FR)" | |
| 617 | + state: closed | |
| 618 | + priority: high | |
| 619 | + author: | |
| 620 | + name: k33g | |
| 621 | + email: ph.charriere@gmail.com | |
| 622 | + createdAt: 2026-10-14T11:42:48.000Z | |
| 623 | + updatedAt: 2026-10-14T12:00:05.000Z | |
| 624 | + closedAt: 2026-10-14T12:00:05.000Z | |
| 625 | + - id: 6 | |
| 626 | + title: "Quality gate, docs EN+FR, packages diagram, .memory" | |
| 627 | + state: closed | |
| 628 | + priority: high | |
| 629 | + author: | |
| 630 | + name: k33g | |
| 631 | + email: ph.charriere@gmail.com | |
| 632 | + createdAt: 2026-10-14T11:42:48.000Z | |
| 633 | + updatedAt: 2026-10-14T12:06:43.000Z | |
| 634 | + closedAt: 2026-10-14T12:06:43.000Z | |
| 528 | 635 | comments: [] |
| @@ -6,7 +6,7 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T06:32:48.566Z | 8 | createdAt: 2026-09-21T06:32:48.566Z |
| 9 | -updatedAt: 2026-10-06T13:16:31.213Z | 9 | +updatedAt: 2026-10-14T12:06:43.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | epic: Loop | 11 | epic: Loop |
| 12 | body: | | 12 | body: | |
| @@ -524,5 +524,112 @@ body: | | |||
| 524 | 5. Should hooks be able to modify tool configurations? | 524 | 5. Should hooks be able to modify tool configurations? |
| 525 | - Current: Tools are read-only | 525 | - Current: Tools are read-only |
| 526 | - Suggestion: No, for security | 526 | - Suggestion: No, for security |
| 527 | -tasks: [] | 527 | + |
| 528 | + ## Design retained for v1 (2026-10-14) | ||
| 529 | + | ||
| 530 | + The sections above are the original exploration. v1 keeps the core of that design and simplifies it. This section is what is actually implemented. | ||
| 531 | + | ||
| 532 | + ### Where the hooks run | ||
| 533 | + All four front ends (REPL, window, ACP, `-p`) go through `engine.Generate`, and Genkit's middleware there already has a `WrapTool` that sees **every** tool call, built-in and MCP. Hooks plug in at those two points, so no front end has to change. | ||
| 534 | + | ||
| 535 | + ### Events | ||
| 536 | + | Event | When | Can do | | ||
| 537 | + | --- | --- | --- | | ||
| 538 | + | `preGeneration` | once per user question, before the first model call | rewrite the prompt (`prompt`), add context (`context`), stop the turn (`action: stop` + `response`) | | ||
| 539 | + | `preToolCall` | before each tool call; optional `matcher` (regexp on the tool name) | deny the call (`action: deny` + `reason`, which the model reads as the tool's output) or replace its `input` | | ||
| 540 | + | `postToolCall` | after each tool call; optional `matcher` | observe; append `context` to the tool's output | | ||
| 541 | + | `postGeneration` | after the turn, success or failure | observe (logging, notification) | | ||
| 542 | + | ||
| 543 | + ### Protocol | ||
| 544 | + - **stdin:** one JSON object, for example `{"event":"preToolCall","cwd":"/abs/dir","model":"…","tool":{"id":"…","name":"bash","input":{…}}}`. `preGeneration` carries `prompt`; `postToolCall` adds `output`; `postGeneration` carries `answer` and `error`. | ||
| 545 | + - **stdout:** one JSON object, or **nothing, which means `continue`**. A logging hook does not have to print anything. | ||
| 546 | + - **stderr:** shown only when the hook fails. | ||
| 547 | + - **Execution:** `sh -c "<command>"` in the session's working directory, with `MM_HOOK_EVENT` and `MM_CONFIG_DIR` (the YAML file's directory) in the environment, so you can write `node "$MM_CONFIG_DIR/hooks/x.js"`. | ||
| 548 | + - **Chain:** hooks run in list order, each one sees the previous one's rewrite, and the first `stop`/`deny` ends the chain. | ||
| 549 | + - **Failure** (non-zero exit, timeout, invalid JSON, `action: error`): `onError: fail | warn | ignore`, default `warn`. `fail` stops the turn or denies the tool. `timeout` defaults to 10s. Output is capped at 1 MiB. | ||
| 550 | + - **Stopped turn:** the history keeps `[prompt blocked by a hook: <reason>]` and the hook's response. The blocked text (a secret, for example) never reaches the model, not even on a later turn. This was the user's choice. | ||
| 551 | + | ||
| 552 | + ### Configuration | ||
| 553 | + ```yaml | ||
| 554 | + hooks: | ||
| 555 | + preGeneration: | ||
| 556 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | ||
| 557 | + timeout: 5s | ||
| 558 | + onError: fail | ||
| 559 | + preToolCall: | ||
| 560 | + - matcher: ^bash$ | ||
| 561 | + command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | ||
| 562 | + postToolCall: | ||
| 563 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | ||
| 564 | + postGeneration: | ||
| 565 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | ||
| 566 | + ``` | ||
| 567 | + | ||
| 568 | + ### Left out of v1 (possible follow-ups) | ||
| 569 | + - **Lifecycle hooks** (preStartup, postConfig, preShutdown, onError), **session hooks** and **front-end hooks**: no use case yet. | ||
| 570 | + - **`async` mode and numeric priorities:** list order is enough, and a log hook is fast. | ||
| 571 | + - **Returning the full `messages` array:** fragile, since a hook could rewrite history or drop the system prompt. `prompt` + `context` cover the four requirements (detect, rewrite, add context, stop). | ||
| 572 | + - **`allowedPaths` / `allowedExtensions`:** the YAML already starts MCP servers and gives the model `bash`. Whoever writes the YAML is trusted, so an allowlist would protect nothing more. | ||
| 573 | + - On the original open questions: no hook state directory (a hook can write files itself); hooks cannot run tools; there is no total chain timeout, only a per-hook one. | ||
| 574 | +tasks: | ||
| 575 | + - id: 1 | ||
| 576 | + title: "Config: hooks block and validation" | ||
| 577 | + state: closed | ||
| 578 | + priority: high | ||
| 579 | + author: | ||
| 580 | + name: k33g | ||
| 581 | + email: ph.charriere@gmail.com | ||
| 582 | + createdAt: 2026-10-14T11:42:48.000Z | ||
| 583 | + updatedAt: 2026-10-14T11:44:02.000Z | ||
| 584 | + closedAt: 2026-10-14T11:44:02.000Z | ||
| 585 | + - id: 2 | ||
| 586 | + title: "internal/hooks: process runner, protocol, chain, onError" | ||
| 587 | + state: closed | ||
| 588 | + priority: high | ||
| 589 | + author: | ||
| 590 | + name: k33g | ||
| 591 | + email: ph.charriere@gmail.com | ||
| 592 | + createdAt: 2026-10-14T11:42:48.000Z | ||
| 593 | + updatedAt: 2026-10-14T11:53:54.000Z | ||
| 594 | + closedAt: 2026-10-14T11:53:54.000Z | ||
| 595 | + - id: 3 | ||
| 596 | + title: "Engine: preGeneration and postGeneration" | ||
| 597 | + state: closed | ||
| 598 | + priority: high | ||
| 599 | + author: | ||
| 600 | + name: k33g | ||
| 601 | + email: ph.charriere@gmail.com | ||
| 602 | + createdAt: 2026-10-14T11:42:48.000Z | ||
| 603 | + updatedAt: 2026-10-14T11:57:09.000Z | ||
| 604 | + closedAt: 2026-10-14T11:57:09.000Z | ||
| 605 | + - id: 4 | ||
| 606 | + title: "Engine: preToolCall and postToolCall (WrapTool), display, banner" | ||
| 607 | + state: closed | ||
| 608 | + priority: high | ||
| 609 | + author: | ||
| 610 | + name: k33g | ||
| 611 | + email: ph.charriere@gmail.com | ||
| 612 | + createdAt: 2026-10-14T11:42:48.000Z | ||
| 613 | + updatedAt: 2026-10-14T11:57:09.000Z | ||
| 614 | + closedAt: 2026-10-14T11:57:09.000Z | ||
| 615 | + - id: 5 | ||
| 616 | + title: "Demo demos/hooks with Node.js hooks (README EN+FR)" | ||
| 617 | + state: closed | ||
| 618 | + priority: high | ||
| 619 | + author: | ||
| 620 | + name: k33g | ||
| 621 | + email: ph.charriere@gmail.com | ||
| 622 | + createdAt: 2026-10-14T11:42:48.000Z | ||
| 623 | + updatedAt: 2026-10-14T12:00:05.000Z | ||
| 624 | + closedAt: 2026-10-14T12:00:05.000Z | ||
| 625 | + - id: 6 | ||
| 626 | + title: "Quality gate, docs EN+FR, packages diagram, .memory" | ||
| 627 | + state: closed | ||
| 628 | + priority: high | ||
| 629 | + author: | ||
| 630 | + name: k33g | ||
| 631 | + email: ph.charriere@gmail.com | ||
| 632 | + createdAt: 2026-10-14T11:42:48.000Z | ||
| 633 | + updatedAt: 2026-10-14T12:06:43.000Z | ||
| 634 | + closedAt: 2026-10-14T12:06:43.000Z | ||
| 528 | comments: [] | 635 | comments: [] |
modified
README.md +2 -0 | @@ -116,6 +116,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In | ||
| 116 | 116 | - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md). |
| 117 | 117 | - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md). |
| 118 | 118 | - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md). |
| 119 | +- **Hooks**: your own commands — in any language — run before a question reaches the model, before and after each tool call, and after the turn; they can deny a call, stop or rewrite a question, add context, or log. See [Guard the agent with hooks](docs/en/how-to/guard-the-agent-with-hooks.md) and `demos/hooks/`. | |
| 119 | 120 | - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). |
| 120 | 121 | |
| 121 | 122 | ## Demos |
| @@ -139,6 +140,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | ||
| 139 | 140 | | `demos/codex-style/` | the same idea with Codex CLI's default instructions (preambles, plans, ambition vs. precision, final-answer format), adapted to mm and to gpt-oss 20B (harmony format, `reasoning_effort`, `model_identity`) on llmman; measured results | |
| 140 | 141 | | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results | |
| 141 | 142 | | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results | |
| 143 | +| `demos/hooks/` | hooks in Node.js around a small shell assistant (Gemma 4 E4B on llmman): a question holding a secret never reaches the model, today's date and the file list are added to every question, `rm -r` / `sudo` / `curl \| sh` are denied, every tool call and turn is logged to `.mm/hooks-audit.jsonl` | | |
| 142 | 144 | |
| 143 | 145 | ## Learn more |
| 144 | 146 | |
| @@ -116,6 +116,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In | |||
| 116 | - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md). | 116 | - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md). |
| 117 | - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md). | 117 | - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md). |
| 118 | - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md). | 118 | - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md). |
| 119 | +- **Hooks**: your own commands — in any language — run before a question reaches the model, before and after each tool call, and after the turn; they can deny a call, stop or rewrite a question, add context, or log. See [Guard the agent with hooks](docs/en/how-to/guard-the-agent-with-hooks.md) and `demos/hooks/`. | ||
| 119 | - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). | 120 | - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). |
| 120 | 121 | ||
| 121 | ## Demos | 122 | ## Demos |
| @@ -139,6 +140,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | |||
| 139 | | `demos/codex-style/` | the same idea with Codex CLI's default instructions (preambles, plans, ambition vs. precision, final-answer format), adapted to mm and to gpt-oss 20B (harmony format, `reasoning_effort`, `model_identity`) on llmman; measured results | | 140 | | `demos/codex-style/` | the same idea with Codex CLI's default instructions (preambles, plans, ambition vs. precision, final-answer format), adapted to mm and to gpt-oss 20B (harmony format, `reasoning_effort`, `model_identity`) on llmman; measured results | |
| 140 | | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results | | 141 | | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results | |
| 141 | | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results | | 142 | | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results | |
| 143 | +| `demos/hooks/` | hooks in Node.js around a small shell assistant (Gemma 4 E4B on llmman): a question holding a secret never reaches the model, today's date and the file list are added to every question, `rm -r` / `sudo` / `curl \| sh` are denied, every tool call and turn is logged to `.mm/hooks-audit.jsonl` | | ||
| 142 | 144 | ||
| 143 | ## Learn more | 145 | ## Learn more |
| 144 | 146 | ||
added
demos/hooks/README.en.md +125 -0 | new file mode 100644 | ||
| @@ -0,0 +1,125 @@ | ||
| 1 | +# Hooks demo — four Node.js hooks around a small shell assistant | |
| 2 | + | |
| 3 | +*[Version française](README.fr.md)* | |
| 4 | + | |
| 5 | +A hook is a command mm runs at a fixed moment of a turn. It reads one JSON object on stdin and may answer one JSON object on stdout. This demo puts four hooks, written in Node.js, around an agent that can run shell commands. Each hook shows one thing a hook can do: | |
| 6 | + | |
| 7 | +| Hook | Event | What it shows | | |
| 8 | +|---|---|---| | |
| 9 | +| `hooks/no-secrets.js` | `preGeneration` | **stop** a turn: a question holding a password or an API key never reaches the model | | |
| 10 | +| `hooks/add-context.js` | `preGeneration` | **add context**: today's date and the list of files, appended to every question | | |
| 11 | +| `hooks/bash-guard.js` | `preToolCall` (`matcher: ^bash$`) | **deny** a tool call: `rm -r`, `sudo`, `curl … \| sh` never run, and the model reads why | | |
| 12 | +| `hooks/audit.js` | `postToolCall` + `postGeneration` | **observe**: one JSON line per tool call and per turn in `.mm/hooks-audit.jsonl` | | |
| 13 | + | |
| 14 | +Model: **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 GB) on **llmman**. The hooks themselves do not depend on the model: they run the same way whatever answers. | |
| 15 | + | |
| 16 | +## Run | |
| 17 | + | |
| 18 | +Prerequisites: [llmman](https://llmmanorg.github.io/) running, and Node.js on the PATH. | |
| 19 | + | |
| 20 | +```bash | |
| 21 | +llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M | |
| 22 | +cd demos/hooks/playground | |
| 23 | +mm ../agent.yaml | |
| 24 | +``` | |
| 25 | + | |
| 26 | +The banner lists the hooks: | |
| 27 | + | |
| 28 | +``` | |
| 29 | +… | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1 | |
| 30 | +``` | |
| 31 | + | |
| 32 | +The agent works in `playground/`, a directory with three harmless files. The hooks are found through `$MM_CONFIG_DIR`, the directory of `agent.yaml`, so mm can be started from any directory. | |
| 33 | + | |
| 34 | +## Try it | |
| 35 | + | |
| 36 | +Each line below is a question to type, followed by what was seen on 2026-10-14 (`mm -p "<question>" ../agent.yaml`): | |
| 37 | + | |
| 38 | +**1. A secret is stopped.** The model is never called: | |
| 39 | + | |
| 40 | +``` | |
| 41 | +> My password: hunter2 — can you store it in a file? | |
| 42 | +🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again. | |
| 43 | +``` | |
| 44 | + | |
| 45 | +The history keeps `[prompt blocked by a hook: the prompt contains a password]`, never the password. The audit log gets the same marker. | |
| 46 | + | |
| 47 | +**2. Context is added.** The model answers without running anything, from what `add-context.js` added: | |
| 48 | + | |
| 49 | +``` | |
| 50 | +> Which files are here, and what is today's date? | |
| 51 | +🤖 The files in the directory are `notes.md`, `old-drafts`, and `todo.txt`. Today's date is 2026-10-14. | |
| 52 | +``` | |
| 53 | + | |
| 54 | +**3. A dangerous command is denied.** The model asks for `rm -rf old-drafts`, the hook refuses it, the directory is still there: | |
| 55 | + | |
| 56 | +``` | |
| 57 | +> Delete the old-drafts directory with rm -rf. | |
| 58 | +🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed] | |
| 59 | +🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook. | |
| 60 | +``` | |
| 61 | + | |
| 62 | +**4. Everything is logged.** After an ordinary question (`What is in todo.txt?`), `playground/.mm/hooks-audit.jsonl` holds: | |
| 63 | + | |
| 64 | +```json | |
| 65 | +{"at":"…","event":"postToolCall","tool":"bash","input":{"command":"cat todo.txt"},"output":"Buy milk\nCall the plumber\n"} | |
| 66 | +{"at":"…","event":"postGeneration","prompt":"What is in todo.txt?","answer":"The file `todo.txt` contains: …"} | |
| 67 | +``` | |
| 68 | + | |
| 69 | +## How a hook is written | |
| 70 | + | |
| 71 | +`hooks/bash-guard.js`, without its comments: | |
| 72 | + | |
| 73 | +```js | |
| 74 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | |
| 75 | +const command = input.tool.input.command || ""; | |
| 76 | + | |
| 77 | +const rules = [ | |
| 78 | + { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ }, | |
| 79 | + { reason: "sudo is not allowed", pattern: /\bsudo\b/ }, | |
| 80 | +]; | |
| 81 | + | |
| 82 | +const broken = rules.find((r) => r.pattern.test(command)); | |
| 83 | +if (broken) { | |
| 84 | + console.log(JSON.stringify({ action: "deny", reason: broken.reason })); | |
| 85 | +} | |
| 86 | +``` | |
| 87 | + | |
| 88 | +It reads its input, decides, and prints a reply only when it has something to say: **an empty stdout means "continue"**. Test a hook without mm by giving it some input: | |
| 89 | + | |
| 90 | +```bash | |
| 91 | +echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"sudo ls"}}}' \ | |
| 92 | + | node hooks/bash-guard.js | |
| 93 | +# {"action":"deny","reason":"sudo is not allowed"} | |
| 94 | +``` | |
| 95 | + | |
| 96 | +## The configuration | |
| 97 | + | |
| 98 | +```yaml | |
| 99 | +hooks: | |
| 100 | + preGeneration: | |
| 101 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | |
| 102 | + timeout: 5s | |
| 103 | + onError: fail # a guard that cannot run must not let the question through | |
| 104 | + - command: node "$MM_CONFIG_DIR/hooks/add-context.js" | |
| 105 | + timeout: 5s | |
| 106 | + preToolCall: | |
| 107 | + - matcher: ^bash$ # a regular expression on the tool name | |
| 108 | + command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | |
| 109 | + timeout: 5s | |
| 110 | + onError: fail | |
| 111 | + postToolCall: | |
| 112 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | |
| 113 | + onError: ignore # losing an audit line must not disturb the session | |
| 114 | + postGeneration: | |
| 115 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | |
| 116 | + onError: ignore | |
| 117 | +``` | |
| 118 | + | |
| 119 | +`yolo: true` here: the tools run without asking, and `bash-guard.js` is what stands between the model and a dangerous command. A `preToolCall` hook runs **before** the tool's own consent prompt, so with `yolo: false` a denied call never even asks. | |
| 120 | + | |
| 121 | +## Going further | |
| 122 | + | |
| 123 | +- Every field of the input and of the reply, the order hooks run in, `onError`: [Hooks reference](../../docs/en/reference/hooks.md). | |
| 124 | +- Writing your own hook step by step: [Guard the agent with hooks](../../docs/en/how-to/guard-the-agent-with-hooks.md). | |
| 125 | +- Why hooks are external commands and where they plug in: [Hooks](../../docs/en/explanation/hooks.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,125 @@ | |||
| 1 | +# Hooks demo — four Node.js hooks around a small shell assistant | ||
| 2 | + | ||
| 3 | +*[Version française](README.fr.md)* | ||
| 4 | + | ||
| 5 | +A hook is a command mm runs at a fixed moment of a turn. It reads one JSON object on stdin and may answer one JSON object on stdout. This demo puts four hooks, written in Node.js, around an agent that can run shell commands. Each hook shows one thing a hook can do: | ||
| 6 | + | ||
| 7 | +| Hook | Event | What it shows | | ||
| 8 | +|---|---|---| | ||
| 9 | +| `hooks/no-secrets.js` | `preGeneration` | **stop** a turn: a question holding a password or an API key never reaches the model | | ||
| 10 | +| `hooks/add-context.js` | `preGeneration` | **add context**: today's date and the list of files, appended to every question | | ||
| 11 | +| `hooks/bash-guard.js` | `preToolCall` (`matcher: ^bash$`) | **deny** a tool call: `rm -r`, `sudo`, `curl … \| sh` never run, and the model reads why | | ||
| 12 | +| `hooks/audit.js` | `postToolCall` + `postGeneration` | **observe**: one JSON line per tool call and per turn in `.mm/hooks-audit.jsonl` | | ||
| 13 | + | ||
| 14 | +Model: **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 GB) on **llmman**. The hooks themselves do not depend on the model: they run the same way whatever answers. | ||
| 15 | + | ||
| 16 | +## Run | ||
| 17 | + | ||
| 18 | +Prerequisites: [llmman](https://llmmanorg.github.io/) running, and Node.js on the PATH. | ||
| 19 | + | ||
| 20 | +```bash | ||
| 21 | +llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M | ||
| 22 | +cd demos/hooks/playground | ||
| 23 | +mm ../agent.yaml | ||
| 24 | +``` | ||
| 25 | + | ||
| 26 | +The banner lists the hooks: | ||
| 27 | + | ||
| 28 | +``` | ||
| 29 | +… | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1 | ||
| 30 | +``` | ||
| 31 | + | ||
| 32 | +The agent works in `playground/`, a directory with three harmless files. The hooks are found through `$MM_CONFIG_DIR`, the directory of `agent.yaml`, so mm can be started from any directory. | ||
| 33 | + | ||
| 34 | +## Try it | ||
| 35 | + | ||
| 36 | +Each line below is a question to type, followed by what was seen on 2026-10-14 (`mm -p "<question>" ../agent.yaml`): | ||
| 37 | + | ||
| 38 | +**1. A secret is stopped.** The model is never called: | ||
| 39 | + | ||
| 40 | +``` | ||
| 41 | +> My password: hunter2 — can you store it in a file? | ||
| 42 | +🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again. | ||
| 43 | +``` | ||
| 44 | + | ||
| 45 | +The history keeps `[prompt blocked by a hook: the prompt contains a password]`, never the password. The audit log gets the same marker. | ||
| 46 | + | ||
| 47 | +**2. Context is added.** The model answers without running anything, from what `add-context.js` added: | ||
| 48 | + | ||
| 49 | +``` | ||
| 50 | +> Which files are here, and what is today's date? | ||
| 51 | +🤖 The files in the directory are `notes.md`, `old-drafts`, and `todo.txt`. Today's date is 2026-10-14. | ||
| 52 | +``` | ||
| 53 | + | ||
| 54 | +**3. A dangerous command is denied.** The model asks for `rm -rf old-drafts`, the hook refuses it, the directory is still there: | ||
| 55 | + | ||
| 56 | +``` | ||
| 57 | +> Delete the old-drafts directory with rm -rf. | ||
| 58 | +🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed] | ||
| 59 | +🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook. | ||
| 60 | +``` | ||
| 61 | + | ||
| 62 | +**4. Everything is logged.** After an ordinary question (`What is in todo.txt?`), `playground/.mm/hooks-audit.jsonl` holds: | ||
| 63 | + | ||
| 64 | +```json | ||
| 65 | +{"at":"…","event":"postToolCall","tool":"bash","input":{"command":"cat todo.txt"},"output":"Buy milk\nCall the plumber\n"} | ||
| 66 | +{"at":"…","event":"postGeneration","prompt":"What is in todo.txt?","answer":"The file `todo.txt` contains: …"} | ||
| 67 | +``` | ||
| 68 | + | ||
| 69 | +## How a hook is written | ||
| 70 | + | ||
| 71 | +`hooks/bash-guard.js`, without its comments: | ||
| 72 | + | ||
| 73 | +```js | ||
| 74 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | ||
| 75 | +const command = input.tool.input.command || ""; | ||
| 76 | + | ||
| 77 | +const rules = [ | ||
| 78 | + { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ }, | ||
| 79 | + { reason: "sudo is not allowed", pattern: /\bsudo\b/ }, | ||
| 80 | +]; | ||
| 81 | + | ||
| 82 | +const broken = rules.find((r) => r.pattern.test(command)); | ||
| 83 | +if (broken) { | ||
| 84 | + console.log(JSON.stringify({ action: "deny", reason: broken.reason })); | ||
| 85 | +} | ||
| 86 | +``` | ||
| 87 | + | ||
| 88 | +It reads its input, decides, and prints a reply only when it has something to say: **an empty stdout means "continue"**. Test a hook without mm by giving it some input: | ||
| 89 | + | ||
| 90 | +```bash | ||
| 91 | +echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"sudo ls"}}}' \ | ||
| 92 | + | node hooks/bash-guard.js | ||
| 93 | +# {"action":"deny","reason":"sudo is not allowed"} | ||
| 94 | +``` | ||
| 95 | + | ||
| 96 | +## The configuration | ||
| 97 | + | ||
| 98 | +```yaml | ||
| 99 | +hooks: | ||
| 100 | + preGeneration: | ||
| 101 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | ||
| 102 | + timeout: 5s | ||
| 103 | + onError: fail # a guard that cannot run must not let the question through | ||
| 104 | + - command: node "$MM_CONFIG_DIR/hooks/add-context.js" | ||
| 105 | + timeout: 5s | ||
| 106 | + preToolCall: | ||
| 107 | + - matcher: ^bash$ # a regular expression on the tool name | ||
| 108 | + command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | ||
| 109 | + timeout: 5s | ||
| 110 | + onError: fail | ||
| 111 | + postToolCall: | ||
| 112 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | ||
| 113 | + onError: ignore # losing an audit line must not disturb the session | ||
| 114 | + postGeneration: | ||
| 115 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | ||
| 116 | + onError: ignore | ||
| 117 | +``` | ||
| 118 | + | ||
| 119 | +`yolo: true` here: the tools run without asking, and `bash-guard.js` is what stands between the model and a dangerous command. A `preToolCall` hook runs **before** the tool's own consent prompt, so with `yolo: false` a denied call never even asks. | ||
| 120 | + | ||
| 121 | +## Going further | ||
| 122 | + | ||
| 123 | +- Every field of the input and of the reply, the order hooks run in, `onError`: [Hooks reference](../../docs/en/reference/hooks.md). | ||
| 124 | +- Writing your own hook step by step: [Guard the agent with hooks](../../docs/en/how-to/guard-the-agent-with-hooks.md). | ||
| 125 | +- Why hooks are external commands and where they plug in: [Hooks](../../docs/en/explanation/hooks.md). | ||
added
demos/hooks/README.fr.md +125 -0 | new file mode 100644 | ||
| @@ -0,0 +1,125 @@ | ||
| 1 | +# Démo des hooks — quatre hooks Node.js autour d'un petit assistant shell | |
| 2 | + | |
| 3 | +*[English version](README.en.md)* | |
| 4 | + | |
| 5 | +Un hook est une commande que mm lance à un moment précis d'un tour. Il lit un objet JSON sur son entrée standard et peut répondre par un objet JSON sur sa sortie standard. Cette démo place quatre hooks, écrits en Node.js, autour d'un agent qui peut lancer des commandes shell. Chacun montre une chose qu'un hook sait faire : | |
| 6 | + | |
| 7 | +| Hook | Événement | Ce qu'il montre | | |
| 8 | +|---|---|---| | |
| 9 | +| `hooks/no-secrets.js` | `preGeneration` | **arrêter** un tour : une question qui contient un mot de passe ou une clé d'API n'arrive jamais au modèle | | |
| 10 | +| `hooks/add-context.js` | `preGeneration` | **ajouter du contexte** : la date du jour et la liste des fichiers, ajoutées à chaque question | | |
| 11 | +| `hooks/bash-guard.js` | `preToolCall` (`matcher: ^bash$`) | **refuser** un appel d'outil : `rm -r`, `sudo`, `curl … \| sh` ne s'exécutent jamais, et le modèle lit pourquoi | | |
| 12 | +| `hooks/audit.js` | `postToolCall` + `postGeneration` | **observer** : une ligne JSON par appel d'outil et par tour dans `.mm/hooks-audit.jsonl` | | |
| 13 | + | |
| 14 | +Modèle : **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 Go) sur **llmman**. Les hooks eux-mêmes ne dépendent pas du modèle : ils s'exécutent de la même façon quel que soit celui qui répond. | |
| 15 | + | |
| 16 | +## Lancer la démo | |
| 17 | + | |
| 18 | +Prérequis : [llmman](https://llmmanorg.github.io/) démarré, et Node.js dans le PATH. | |
| 19 | + | |
| 20 | +```bash | |
| 21 | +llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M | |
| 22 | +cd demos/hooks/playground | |
| 23 | +mm ../agent.yaml | |
| 24 | +``` | |
| 25 | + | |
| 26 | +La bannière liste les hooks : | |
| 27 | + | |
| 28 | +``` | |
| 29 | +… | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1 | |
| 30 | +``` | |
| 31 | + | |
| 32 | +L'agent travaille dans `playground/`, un dossier qui contient trois fichiers sans importance. Les hooks sont trouvés grâce à `$MM_CONFIG_DIR`, le dossier d'`agent.yaml` : mm peut donc être lancé depuis n'importe quel dossier. | |
| 33 | + | |
| 34 | +## Essayer | |
| 35 | + | |
| 36 | +Chaque ligne ci-dessous est une question à taper, suivie de ce qui a été observé le 2026-10-14 (`mm -p "<question>" ../agent.yaml`). Les questions sont en anglais, comme pendant l'essai ; l'agent répond dans la langue de la question. | |
| 37 | + | |
| 38 | +**1. Un secret est arrêté.** Le modèle n'est jamais appelé : | |
| 39 | + | |
| 40 | +``` | |
| 41 | +> My password: hunter2 — can you store it in a file? | |
| 42 | +🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again. | |
| 43 | +``` | |
| 44 | + | |
| 45 | +L'historique garde `[prompt blocked by a hook: the prompt contains a password]`, jamais le mot de passe. Le journal d'audit reçoit le même marqueur. | |
| 46 | + | |
| 47 | +**2. Du contexte est ajouté.** Le modèle répond sans rien exécuter, à partir de ce qu'`add-context.js` a ajouté : | |
| 48 | + | |
| 49 | +``` | |
| 50 | +> Which files are here, and what is today's date? | |
| 51 | +🤖 The files in the directory are `notes.md`, `old-drafts`, and `todo.txt`. Today's date is 2026-10-14. | |
| 52 | +``` | |
| 53 | + | |
| 54 | +**3. Une commande dangereuse est refusée.** Le modèle demande `rm -rf old-drafts`, le hook refuse, et le dossier est toujours là : | |
| 55 | + | |
| 56 | +``` | |
| 57 | +> Delete the old-drafts directory with rm -rf. | |
| 58 | +🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed] | |
| 59 | +🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook. | |
| 60 | +``` | |
| 61 | + | |
| 62 | +**4. Tout est journalisé.** Après une question ordinaire (`What is in todo.txt?`), `playground/.mm/hooks-audit.jsonl` contient : | |
| 63 | + | |
| 64 | +```json | |
| 65 | +{"at":"…","event":"postToolCall","tool":"bash","input":{"command":"cat todo.txt"},"output":"Buy milk\nCall the plumber\n"} | |
| 66 | +{"at":"…","event":"postGeneration","prompt":"What is in todo.txt?","answer":"The file `todo.txt` contains: …"} | |
| 67 | +``` | |
| 68 | + | |
| 69 | +## Écrire un hook | |
| 70 | + | |
| 71 | +`hooks/bash-guard.js`, sans ses commentaires : | |
| 72 | + | |
| 73 | +```js | |
| 74 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | |
| 75 | +const command = input.tool.input.command || ""; | |
| 76 | + | |
| 77 | +const rules = [ | |
| 78 | + { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ }, | |
| 79 | + { reason: "sudo is not allowed", pattern: /\bsudo\b/ }, | |
| 80 | +]; | |
| 81 | + | |
| 82 | +const broken = rules.find((r) => r.pattern.test(command)); | |
| 83 | +if (broken) { | |
| 84 | + console.log(JSON.stringify({ action: "deny", reason: broken.reason })); | |
| 85 | +} | |
| 86 | +``` | |
| 87 | + | |
| 88 | +Il lit son entrée, décide, et n'écrit une réponse que s'il a quelque chose à dire : **une sortie vide veut dire « continuer »**. On teste un hook sans mm en lui donnant une entrée : | |
| 89 | + | |
| 90 | +```bash | |
| 91 | +echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"sudo ls"}}}' \ | |
| 92 | + | node hooks/bash-guard.js | |
| 93 | +# {"action":"deny","reason":"sudo is not allowed"} | |
| 94 | +``` | |
| 95 | + | |
| 96 | +## La configuration | |
| 97 | + | |
| 98 | +```yaml | |
| 99 | +hooks: | |
| 100 | + preGeneration: | |
| 101 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | |
| 102 | + timeout: 5s | |
| 103 | + onError: fail # un garde qui ne peut pas s'exécuter ne doit pas laisser passer la question | |
| 104 | + - command: node "$MM_CONFIG_DIR/hooks/add-context.js" | |
| 105 | + timeout: 5s | |
| 106 | + preToolCall: | |
| 107 | + - matcher: ^bash$ # une expression régulière sur le nom de l'outil | |
| 108 | + command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | |
| 109 | + timeout: 5s | |
| 110 | + onError: fail | |
| 111 | + postToolCall: | |
| 112 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | |
| 113 | + onError: ignore # perdre une ligne d'audit ne doit pas perturber la session | |
| 114 | + postGeneration: | |
| 115 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | |
| 116 | + onError: ignore | |
| 117 | +``` | |
| 118 | + | |
| 119 | +Ici `yolo: true` : les outils s'exécutent sans demander, et c'est `bash-guard.js` qui se tient entre le modèle et une commande dangereuse. Un hook `preToolCall` s'exécute **avant** la demande d'autorisation de l'outil : avec `yolo: false`, un appel refusé ne demande donc même pas. | |
| 120 | + | |
| 121 | +## Pour aller plus loin | |
| 122 | + | |
| 123 | +- Tous les champs de l'entrée et de la réponse, l'ordre d'exécution des hooks, `onError` : [Référence des hooks](../../docs/fr/reference/hooks.md). | |
| 124 | +- Écrire son propre hook pas à pas : [Protéger l'agent avec des hooks](../../docs/fr/how-to/guard-the-agent-with-hooks.md). | |
| 125 | +- Pourquoi les hooks sont des commandes externes, et où ils se branchent : [Les hooks](../../docs/fr/explanation/hooks.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,125 @@ | |||
| 1 | +# Démo des hooks — quatre hooks Node.js autour d'un petit assistant shell | ||
| 2 | + | ||
| 3 | +*[English version](README.en.md)* | ||
| 4 | + | ||
| 5 | +Un hook est une commande que mm lance à un moment précis d'un tour. Il lit un objet JSON sur son entrée standard et peut répondre par un objet JSON sur sa sortie standard. Cette démo place quatre hooks, écrits en Node.js, autour d'un agent qui peut lancer des commandes shell. Chacun montre une chose qu'un hook sait faire : | ||
| 6 | + | ||
| 7 | +| Hook | Événement | Ce qu'il montre | | ||
| 8 | +|---|---|---| | ||
| 9 | +| `hooks/no-secrets.js` | `preGeneration` | **arrêter** un tour : une question qui contient un mot de passe ou une clé d'API n'arrive jamais au modèle | | ||
| 10 | +| `hooks/add-context.js` | `preGeneration` | **ajouter du contexte** : la date du jour et la liste des fichiers, ajoutées à chaque question | | ||
| 11 | +| `hooks/bash-guard.js` | `preToolCall` (`matcher: ^bash$`) | **refuser** un appel d'outil : `rm -r`, `sudo`, `curl … \| sh` ne s'exécutent jamais, et le modèle lit pourquoi | | ||
| 12 | +| `hooks/audit.js` | `postToolCall` + `postGeneration` | **observer** : une ligne JSON par appel d'outil et par tour dans `.mm/hooks-audit.jsonl` | | ||
| 13 | + | ||
| 14 | +Modèle : **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 Go) sur **llmman**. Les hooks eux-mêmes ne dépendent pas du modèle : ils s'exécutent de la même façon quel que soit celui qui répond. | ||
| 15 | + | ||
| 16 | +## Lancer la démo | ||
| 17 | + | ||
| 18 | +Prérequis : [llmman](https://llmmanorg.github.io/) démarré, et Node.js dans le PATH. | ||
| 19 | + | ||
| 20 | +```bash | ||
| 21 | +llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M | ||
| 22 | +cd demos/hooks/playground | ||
| 23 | +mm ../agent.yaml | ||
| 24 | +``` | ||
| 25 | + | ||
| 26 | +La bannière liste les hooks : | ||
| 27 | + | ||
| 28 | +``` | ||
| 29 | +… | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1 | ||
| 30 | +``` | ||
| 31 | + | ||
| 32 | +L'agent travaille dans `playground/`, un dossier qui contient trois fichiers sans importance. Les hooks sont trouvés grâce à `$MM_CONFIG_DIR`, le dossier d'`agent.yaml` : mm peut donc être lancé depuis n'importe quel dossier. | ||
| 33 | + | ||
| 34 | +## Essayer | ||
| 35 | + | ||
| 36 | +Chaque ligne ci-dessous est une question à taper, suivie de ce qui a été observé le 2026-10-14 (`mm -p "<question>" ../agent.yaml`). Les questions sont en anglais, comme pendant l'essai ; l'agent répond dans la langue de la question. | ||
| 37 | + | ||
| 38 | +**1. Un secret est arrêté.** Le modèle n'est jamais appelé : | ||
| 39 | + | ||
| 40 | +``` | ||
| 41 | +> My password: hunter2 — can you store it in a file? | ||
| 42 | +🤖 🔐 Your message looks like it contains a password. It was not sent to the model. Remove it and ask again. | ||
| 43 | +``` | ||
| 44 | + | ||
| 45 | +L'historique garde `[prompt blocked by a hook: the prompt contains a password]`, jamais le mot de passe. Le journal d'audit reçoit le même marqueur. | ||
| 46 | + | ||
| 47 | +**2. Du contexte est ajouté.** Le modèle répond sans rien exécuter, à partir de ce qu'`add-context.js` a ajouté : | ||
| 48 | + | ||
| 49 | +``` | ||
| 50 | +> Which files are here, and what is today's date? | ||
| 51 | +🤖 The files in the directory are `notes.md`, `old-drafts`, and `todo.txt`. Today's date is 2026-10-14. | ||
| 52 | +``` | ||
| 53 | + | ||
| 54 | +**3. Une commande dangereuse est refusée.** Le modèle demande `rm -rf old-drafts`, le hook refuse, et le dossier est toujours là : | ||
| 55 | + | ||
| 56 | +``` | ||
| 57 | +> Delete the old-drafts directory with rm -rf. | ||
| 58 | +🪝 bash [denied by a hook: recursive deletion (rm -r) is not allowed] | ||
| 59 | +🤖 The command was refused because recursive deletion (`rm -r`) is not allowed by a hook. | ||
| 60 | +``` | ||
| 61 | + | ||
| 62 | +**4. Tout est journalisé.** Après une question ordinaire (`What is in todo.txt?`), `playground/.mm/hooks-audit.jsonl` contient : | ||
| 63 | + | ||
| 64 | +```json | ||
| 65 | +{"at":"…","event":"postToolCall","tool":"bash","input":{"command":"cat todo.txt"},"output":"Buy milk\nCall the plumber\n"} | ||
| 66 | +{"at":"…","event":"postGeneration","prompt":"What is in todo.txt?","answer":"The file `todo.txt` contains: …"} | ||
| 67 | +``` | ||
| 68 | + | ||
| 69 | +## Écrire un hook | ||
| 70 | + | ||
| 71 | +`hooks/bash-guard.js`, sans ses commentaires : | ||
| 72 | + | ||
| 73 | +```js | ||
| 74 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | ||
| 75 | +const command = input.tool.input.command || ""; | ||
| 76 | + | ||
| 77 | +const rules = [ | ||
| 78 | + { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ }, | ||
| 79 | + { reason: "sudo is not allowed", pattern: /\bsudo\b/ }, | ||
| 80 | +]; | ||
| 81 | + | ||
| 82 | +const broken = rules.find((r) => r.pattern.test(command)); | ||
| 83 | +if (broken) { | ||
| 84 | + console.log(JSON.stringify({ action: "deny", reason: broken.reason })); | ||
| 85 | +} | ||
| 86 | +``` | ||
| 87 | + | ||
| 88 | +Il lit son entrée, décide, et n'écrit une réponse que s'il a quelque chose à dire : **une sortie vide veut dire « continuer »**. On teste un hook sans mm en lui donnant une entrée : | ||
| 89 | + | ||
| 90 | +```bash | ||
| 91 | +echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"sudo ls"}}}' \ | ||
| 92 | + | node hooks/bash-guard.js | ||
| 93 | +# {"action":"deny","reason":"sudo is not allowed"} | ||
| 94 | +``` | ||
| 95 | + | ||
| 96 | +## La configuration | ||
| 97 | + | ||
| 98 | +```yaml | ||
| 99 | +hooks: | ||
| 100 | + preGeneration: | ||
| 101 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | ||
| 102 | + timeout: 5s | ||
| 103 | + onError: fail # un garde qui ne peut pas s'exécuter ne doit pas laisser passer la question | ||
| 104 | + - command: node "$MM_CONFIG_DIR/hooks/add-context.js" | ||
| 105 | + timeout: 5s | ||
| 106 | + preToolCall: | ||
| 107 | + - matcher: ^bash$ # une expression régulière sur le nom de l'outil | ||
| 108 | + command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | ||
| 109 | + timeout: 5s | ||
| 110 | + onError: fail | ||
| 111 | + postToolCall: | ||
| 112 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | ||
| 113 | + onError: ignore # perdre une ligne d'audit ne doit pas perturber la session | ||
| 114 | + postGeneration: | ||
| 115 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | ||
| 116 | + onError: ignore | ||
| 117 | +``` | ||
| 118 | + | ||
| 119 | +Ici `yolo: true` : les outils s'exécutent sans demander, et c'est `bash-guard.js` qui se tient entre le modèle et une commande dangereuse. Un hook `preToolCall` s'exécute **avant** la demande d'autorisation de l'outil : avec `yolo: false`, un appel refusé ne demande donc même pas. | ||
| 120 | + | ||
| 121 | +## Pour aller plus loin | ||
| 122 | + | ||
| 123 | +- Tous les champs de l'entrée et de la réponse, l'ordre d'exécution des hooks, `onError` : [Référence des hooks](../../docs/fr/reference/hooks.md). | ||
| 124 | +- Écrire son propre hook pas à pas : [Protéger l'agent avec des hooks](../../docs/fr/how-to/guard-the-agent-with-hooks.md). | ||
| 125 | +- Pourquoi les hooks sont des commandes externes, et où ils se branchent : [Les hooks](../../docs/fr/explanation/hooks.md). | ||
added
demos/hooks/agent.yaml +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +# Hooks demo — a small shell assistant guarded by four Node.js hooks. | |
| 2 | +# | |
| 3 | +# hooks/no-secrets.js preGeneration stops a question that holds a secret | |
| 4 | +# hooks/add-context.js preGeneration adds today's date and the file list | |
| 5 | +# hooks/bash-guard.js preToolCall denies rm -r, sudo, curl | sh | |
| 6 | +# hooks/audit.js postToolCall writes .mm/hooks-audit.jsonl | |
| 7 | +# postGeneration | |
| 8 | +# | |
| 9 | +# Run it from the playground (any directory works: the hooks are found | |
| 10 | +# through $MM_CONFIG_DIR, the directory of this file): | |
| 11 | +# | |
| 12 | +# cd demos/hooks/playground | |
| 13 | +# mm ../agent.yaml | |
| 14 | +# | |
| 15 | +# Every key is optional; reference: docs/en/reference/configuration.md. | |
| 16 | + | |
| 17 | +provider: llmman | |
| 18 | +model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M | |
| 19 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 20 | +fallback: http://host.docker.internal:17434/v1 | |
| 21 | + | |
| 22 | +sampling: | |
| 23 | + temperature: 0.2 | |
| 24 | + parallel_tool_calls: false | |
| 25 | + | |
| 26 | +firstTokenTimeout: 3m | |
| 27 | +watchdogTimeout: 3m | |
| 28 | +maxTurns: 8 | |
| 29 | + | |
| 30 | +bashTool: true | |
| 31 | +editTools: false | |
| 32 | +planTool: false | |
| 33 | +fetchContentTool: false | |
| 34 | +askUserTool: false | |
| 35 | +skillsDir: "" | |
| 36 | + | |
| 37 | +# The tools run without asking: the bash-guard hook is what stands between | |
| 38 | +# the model and a dangerous command here. A hook runs BEFORE the tool's own | |
| 39 | +# consent prompt, so with `yolo: false` a denied call never even asks. | |
| 40 | +yolo: true | |
| 41 | + | |
| 42 | +agentsMd: | |
| 43 | + enabled: false | |
| 44 | + | |
| 45 | +acp: | |
| 46 | + name: hooks-demo | |
| 47 | + title: 🪝 Hooks demo | |
| 48 | + | |
| 49 | +hooks: | |
| 50 | + preGeneration: | |
| 51 | + # onError: fail — a guard that cannot run must not let the question through. | |
| 52 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | |
| 53 | + timeout: 5s | |
| 54 | + onError: fail | |
| 55 | + - command: node "$MM_CONFIG_DIR/hooks/add-context.js" | |
| 56 | + timeout: 5s | |
| 57 | + preToolCall: | |
| 58 | + - matcher: ^bash$ | |
| 59 | + command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | |
| 60 | + timeout: 5s | |
| 61 | + onError: fail | |
| 62 | + postToolCall: | |
| 63 | + # onError: ignore — losing an audit line must not disturb the session. | |
| 64 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | |
| 65 | + onError: ignore | |
| 66 | + postGeneration: | |
| 67 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | |
| 68 | + onError: ignore | |
| 69 | + | |
| 70 | +system: | | |
| 71 | + You are a shell assistant working in the current directory. | |
| 72 | + Use the bash tool to look at files and to run commands; never guess what a | |
| 73 | + file contains. Answer in one or two short sentences, in the user's language. | |
| 74 | + If a tool result says "[denied by a hook: …]", the command was refused: | |
| 75 | + tell the user why, and do not try to get around the refusal. | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +# Hooks demo — a small shell assistant guarded by four Node.js hooks. | ||
| 2 | +# | ||
| 3 | +# hooks/no-secrets.js preGeneration stops a question that holds a secret | ||
| 4 | +# hooks/add-context.js preGeneration adds today's date and the file list | ||
| 5 | +# hooks/bash-guard.js preToolCall denies rm -r, sudo, curl | sh | ||
| 6 | +# hooks/audit.js postToolCall writes .mm/hooks-audit.jsonl | ||
| 7 | +# postGeneration | ||
| 8 | +# | ||
| 9 | +# Run it from the playground (any directory works: the hooks are found | ||
| 10 | +# through $MM_CONFIG_DIR, the directory of this file): | ||
| 11 | +# | ||
| 12 | +# cd demos/hooks/playground | ||
| 13 | +# mm ../agent.yaml | ||
| 14 | +# | ||
| 15 | +# Every key is optional; reference: docs/en/reference/configuration.md. | ||
| 16 | + | ||
| 17 | +provider: llmman | ||
| 18 | +model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M | ||
| 19 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 20 | +fallback: http://host.docker.internal:17434/v1 | ||
| 21 | + | ||
| 22 | +sampling: | ||
| 23 | + temperature: 0.2 | ||
| 24 | + parallel_tool_calls: false | ||
| 25 | + | ||
| 26 | +firstTokenTimeout: 3m | ||
| 27 | +watchdogTimeout: 3m | ||
| 28 | +maxTurns: 8 | ||
| 29 | + | ||
| 30 | +bashTool: true | ||
| 31 | +editTools: false | ||
| 32 | +planTool: false | ||
| 33 | +fetchContentTool: false | ||
| 34 | +askUserTool: false | ||
| 35 | +skillsDir: "" | ||
| 36 | + | ||
| 37 | +# The tools run without asking: the bash-guard hook is what stands between | ||
| 38 | +# the model and a dangerous command here. A hook runs BEFORE the tool's own | ||
| 39 | +# consent prompt, so with `yolo: false` a denied call never even asks. | ||
| 40 | +yolo: true | ||
| 41 | + | ||
| 42 | +agentsMd: | ||
| 43 | + enabled: false | ||
| 44 | + | ||
| 45 | +acp: | ||
| 46 | + name: hooks-demo | ||
| 47 | + title: 🪝 Hooks demo | ||
| 48 | + | ||
| 49 | +hooks: | ||
| 50 | + preGeneration: | ||
| 51 | + # onError: fail — a guard that cannot run must not let the question through. | ||
| 52 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | ||
| 53 | + timeout: 5s | ||
| 54 | + onError: fail | ||
| 55 | + - command: node "$MM_CONFIG_DIR/hooks/add-context.js" | ||
| 56 | + timeout: 5s | ||
| 57 | + preToolCall: | ||
| 58 | + - matcher: ^bash$ | ||
| 59 | + command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | ||
| 60 | + timeout: 5s | ||
| 61 | + onError: fail | ||
| 62 | + postToolCall: | ||
| 63 | + # onError: ignore — losing an audit line must not disturb the session. | ||
| 64 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | ||
| 65 | + onError: ignore | ||
| 66 | + postGeneration: | ||
| 67 | + - command: node "$MM_CONFIG_DIR/hooks/audit.js" | ||
| 68 | + onError: ignore | ||
| 69 | + | ||
| 70 | +system: | | ||
| 71 | + You are a shell assistant working in the current directory. | ||
| 72 | + Use the bash tool to look at files and to run commands; never guess what a | ||
| 73 | + file contains. Answer in one or two short sentences, in the user's language. | ||
| 74 | + If a tool result says "[denied by a hook: …]", the command was refused: | ||
| 75 | + tell the user why, and do not try to get around the refusal. | ||
added
demos/hooks/hooks/add-context.js +22 -0 | new file mode 100755 | ||
| @@ -0,0 +1,22 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// preGeneration hook: adds context the model cannot guess — today's date and | |
| 3 | +// the files of the working directory. | |
| 4 | +// | |
| 5 | +// Printing {"context":"…"} appends that text to the question, under the | |
| 6 | +// label "[context added by a hook]". The user's words are left as they are | |
| 7 | +// (a hook could also replace them with {"prompt":"…"}). | |
| 8 | + | |
| 9 | +const fs = require("fs"); | |
| 10 | +const input = JSON.parse(fs.readFileSync(0, "utf8")); | |
| 11 | + | |
| 12 | +const today = new Date().toISOString().slice(0, 10); | |
| 13 | +let files = []; | |
| 14 | +try { | |
| 15 | + files = fs.readdirSync(input.cwd).filter((f) => !f.startsWith(".")); | |
| 16 | +} catch { | |
| 17 | + // An unreadable directory is not worth failing the turn for. | |
| 18 | +} | |
| 19 | + | |
| 20 | +console.log(JSON.stringify({ | |
| 21 | + context: `Today is ${today}. Files in ${input.cwd}: ${files.join(", ") || "none"}.`, | |
| 22 | +})); | |
| new file mode 100755 | |||
| @@ -0,0 +1,22 @@ | |||
| 1 | +#!/usr/bin/env node | ||
| 2 | +// preGeneration hook: adds context the model cannot guess — today's date and | ||
| 3 | +// the files of the working directory. | ||
| 4 | +// | ||
| 5 | +// Printing {"context":"…"} appends that text to the question, under the | ||
| 6 | +// label "[context added by a hook]". The user's words are left as they are | ||
| 7 | +// (a hook could also replace them with {"prompt":"…"}). | ||
| 8 | + | ||
| 9 | +const fs = require("fs"); | ||
| 10 | +const input = JSON.parse(fs.readFileSync(0, "utf8")); | ||
| 11 | + | ||
| 12 | +const today = new Date().toISOString().slice(0, 10); | ||
| 13 | +let files = []; | ||
| 14 | +try { | ||
| 15 | + files = fs.readdirSync(input.cwd).filter((f) => !f.startsWith(".")); | ||
| 16 | +} catch { | ||
| 17 | + // An unreadable directory is not worth failing the turn for. | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +console.log(JSON.stringify({ | ||
| 21 | + context: `Today is ${today}. Files in ${input.cwd}: ${files.join(", ") || "none"}.`, | ||
| 22 | +})); | ||
added
demos/hooks/hooks/audit.js +25 -0 | new file mode 100755 | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// postToolCall and postGeneration hook: writes one line per event to | |
| 3 | +// .mm/hooks-audit.jsonl in the working directory. | |
| 4 | +// | |
| 5 | +// It only observes, so it prints nothing on stdout. One script serves two | |
| 6 | +// events: the event's name is in the input (and in $MM_HOOK_EVENT). | |
| 7 | + | |
| 8 | +const fs = require("fs"); | |
| 9 | +const path = require("path"); | |
| 10 | +const input = JSON.parse(fs.readFileSync(0, "utf8")); | |
| 11 | + | |
| 12 | +const line = { at: new Date().toISOString(), event: input.event }; | |
| 13 | +if (input.event === "postToolCall") { | |
| 14 | + line.tool = input.tool.name; | |
| 15 | + line.input = input.tool.input; | |
| 16 | + line.output = input.output.slice(0, 200); | |
| 17 | +} else { | |
| 18 | + line.prompt = input.prompt; | |
| 19 | + line.answer = (input.answer || "").slice(0, 200); | |
| 20 | + if (input.error) line.error = input.error; | |
| 21 | +} | |
| 22 | + | |
| 23 | +const dir = path.join(input.cwd, ".mm"); | |
| 24 | +fs.mkdirSync(dir, { recursive: true }); | |
| 25 | +fs.appendFileSync(path.join(dir, "hooks-audit.jsonl"), JSON.stringify(line) + "\n"); | |
| new file mode 100755 | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | +#!/usr/bin/env node | ||
| 2 | +// postToolCall and postGeneration hook: writes one line per event to | ||
| 3 | +// .mm/hooks-audit.jsonl in the working directory. | ||
| 4 | +// | ||
| 5 | +// It only observes, so it prints nothing on stdout. One script serves two | ||
| 6 | +// events: the event's name is in the input (and in $MM_HOOK_EVENT). | ||
| 7 | + | ||
| 8 | +const fs = require("fs"); | ||
| 9 | +const path = require("path"); | ||
| 10 | +const input = JSON.parse(fs.readFileSync(0, "utf8")); | ||
| 11 | + | ||
| 12 | +const line = { at: new Date().toISOString(), event: input.event }; | ||
| 13 | +if (input.event === "postToolCall") { | ||
| 14 | + line.tool = input.tool.name; | ||
| 15 | + line.input = input.tool.input; | ||
| 16 | + line.output = input.output.slice(0, 200); | ||
| 17 | +} else { | ||
| 18 | + line.prompt = input.prompt; | ||
| 19 | + line.answer = (input.answer || "").slice(0, 200); | ||
| 20 | + if (input.error) line.error = input.error; | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +const dir = path.join(input.cwd, ".mm"); | ||
| 24 | +fs.mkdirSync(dir, { recursive: true }); | ||
| 25 | +fs.appendFileSync(path.join(dir, "hooks-audit.jsonl"), JSON.stringify(line) + "\n"); | ||
added
demos/hooks/hooks/bash-guard.js +21 -0 | new file mode 100755 | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// preToolCall hook (matcher ^bash$): refuses dangerous shell commands. | |
| 3 | +// | |
| 4 | +// mm writes {"event":"preToolCall","tool":{"name":"bash","input":{"command":"…"}},…}. | |
| 5 | +// Printing {"action":"deny","reason":"…"} means the command never runs: the | |
| 6 | +// model reads "[denied by a hook: <reason>]" as the tool's output, and can | |
| 7 | +// try something else. Printing nothing lets the command run. | |
| 8 | + | |
| 9 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | |
| 10 | +const command = input.tool.input.command || ""; | |
| 11 | + | |
| 12 | +const rules = [ | |
| 13 | + { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ }, | |
| 14 | + { reason: "sudo is not allowed", pattern: /\bsudo\b/ }, | |
| 15 | + { reason: "piping a download into a shell is not allowed", pattern: /\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh\b/ }, | |
| 16 | +]; | |
| 17 | + | |
| 18 | +const broken = rules.find((r) => r.pattern.test(command)); | |
| 19 | +if (broken) { | |
| 20 | + console.log(JSON.stringify({ action: "deny", reason: broken.reason })); | |
| 21 | +} | |
| new file mode 100755 | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | +#!/usr/bin/env node | ||
| 2 | +// preToolCall hook (matcher ^bash$): refuses dangerous shell commands. | ||
| 3 | +// | ||
| 4 | +// mm writes {"event":"preToolCall","tool":{"name":"bash","input":{"command":"…"}},…}. | ||
| 5 | +// Printing {"action":"deny","reason":"…"} means the command never runs: the | ||
| 6 | +// model reads "[denied by a hook: <reason>]" as the tool's output, and can | ||
| 7 | +// try something else. Printing nothing lets the command run. | ||
| 8 | + | ||
| 9 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | ||
| 10 | +const command = input.tool.input.command || ""; | ||
| 11 | + | ||
| 12 | +const rules = [ | ||
| 13 | + { reason: "recursive deletion (rm -r) is not allowed", pattern: /\brm\s+(-\w*\s+)*-\w*[rR]/ }, | ||
| 14 | + { reason: "sudo is not allowed", pattern: /\bsudo\b/ }, | ||
| 15 | + { reason: "piping a download into a shell is not allowed", pattern: /\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh\b/ }, | ||
| 16 | +]; | ||
| 17 | + | ||
| 18 | +const broken = rules.find((r) => r.pattern.test(command)); | ||
| 19 | +if (broken) { | ||
| 20 | + console.log(JSON.stringify({ action: "deny", reason: broken.reason })); | ||
| 21 | +} | ||
added
demos/hooks/hooks/no-secrets.js +27 -0 | new file mode 100755 | ||
| @@ -0,0 +1,27 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// preGeneration hook: stops the turn when the question holds a secret. | |
| 3 | +// | |
| 4 | +// mm writes one JSON object on stdin: {"event":"preGeneration","prompt":"…",…}. | |
| 5 | +// Printing {"action":"stop",…} means the model never sees the question; mm | |
| 6 | +// shows `response` instead and keeps "[prompt blocked by a hook: <reason>]" | |
| 7 | +// in the history, never the secret itself. | |
| 8 | +// Printing nothing means "continue". | |
| 9 | + | |
| 10 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | |
| 11 | + | |
| 12 | +const secrets = [ | |
| 13 | + { name: "a password", pattern: /\b(password|passwd|pwd|mot de passe)\s*[:=]\s*\S+/i }, | |
| 14 | + { name: "an OpenAI-style API key", pattern: /\bsk-[A-Za-z0-9_-]{20,}/ }, | |
| 15 | + { name: "a GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}/ }, | |
| 16 | + { name: "an AWS access key", pattern: /\bAKIA[0-9A-Z]{16}\b/ }, | |
| 17 | +]; | |
| 18 | + | |
| 19 | +const found = secrets.find((s) => s.pattern.test(input.prompt)); | |
| 20 | +if (found) { | |
| 21 | + console.log(JSON.stringify({ | |
| 22 | + action: "stop", | |
| 23 | + reason: `the prompt contains ${found.name}`, | |
| 24 | + response: `🔐 Your message looks like it contains ${found.name}. ` + | |
| 25 | + "It was not sent to the model. Remove it and ask again.", | |
| 26 | + })); | |
| 27 | +} | |
| new file mode 100755 | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | +#!/usr/bin/env node | ||
| 2 | +// preGeneration hook: stops the turn when the question holds a secret. | ||
| 3 | +// | ||
| 4 | +// mm writes one JSON object on stdin: {"event":"preGeneration","prompt":"…",…}. | ||
| 5 | +// Printing {"action":"stop",…} means the model never sees the question; mm | ||
| 6 | +// shows `response` instead and keeps "[prompt blocked by a hook: <reason>]" | ||
| 7 | +// in the history, never the secret itself. | ||
| 8 | +// Printing nothing means "continue". | ||
| 9 | + | ||
| 10 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | ||
| 11 | + | ||
| 12 | +const secrets = [ | ||
| 13 | + { name: "a password", pattern: /\b(password|passwd|pwd|mot de passe)\s*[:=]\s*\S+/i }, | ||
| 14 | + { name: "an OpenAI-style API key", pattern: /\bsk-[A-Za-z0-9_-]{20,}/ }, | ||
| 15 | + { name: "a GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}/ }, | ||
| 16 | + { name: "an AWS access key", pattern: /\bAKIA[0-9A-Z]{16}\b/ }, | ||
| 17 | +]; | ||
| 18 | + | ||
| 19 | +const found = secrets.find((s) => s.pattern.test(input.prompt)); | ||
| 20 | +if (found) { | ||
| 21 | + console.log(JSON.stringify({ | ||
| 22 | + action: "stop", | ||
| 23 | + reason: `the prompt contains ${found.name}`, | ||
| 24 | + response: `🔐 Your message looks like it contains ${found.name}. ` + | ||
| 25 | + "It was not sent to the model. Remove it and ask again.", | ||
| 26 | + })); | ||
| 27 | +} | ||
added
demos/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/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/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 | ||
modified
docs/diagrams/packages.drawio +1 -1 | @@ -1,3 +1,3 @@ | ||
| 1 | 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_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></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| @@ -1,3 +1,3 @@ | |||
| 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | 1 | <mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_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></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
docs/en/README.md +3 -0 | @@ -35,6 +35,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 35 | 35 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) |
| 36 | 36 | - [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 |
| 37 | 37 | - [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 |
| 38 | +- [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 | |
| 38 | 39 | |
| 39 | 40 | ### MCP (Model Context Protocol) How-to |
| 40 | 41 | - [Use Context7 for documentation](how-to/mcp-use-context7.md) |
| @@ -48,6 +49,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 48 | 49 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule |
| 49 | 50 | - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` |
| 50 | 51 | - [Skill file format](reference/skill-format.md) |
| 52 | +- [Hooks](reference/hooks.md) — the four events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors | |
| 51 | 53 | - [Agent Client Protocol surface](reference/acp.md) |
| 52 | 54 | |
| 53 | 55 | ### MCP (Model Context Protocol) Reference |
| @@ -69,6 +71,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 69 | 71 | - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer |
| 70 | 72 | - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit |
| 71 | 73 | - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap |
| 74 | +- [Hooks](explanation/hooks.md) — why external commands, why they plug into `engine.Generate` and `WrapTool`, why a stopped question leaves a marker, what ticket 36 left out | |
| 72 | 75 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 73 | 76 | |
| 74 | 77 | ### MCP (Model Context Protocol) Explanation |
| @@ -35,6 +35,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 35 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) | 35 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) |
| 36 | - [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 | 36 | - [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 |
| 37 | - [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 | 37 | - [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 |
| 38 | +- [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 | ||
| 38 | 39 | ||
| 39 | ### MCP (Model Context Protocol) How-to | 40 | ### MCP (Model Context Protocol) How-to |
| 40 | - [Use Context7 for documentation](how-to/mcp-use-context7.md) | 41 | - [Use Context7 for documentation](how-to/mcp-use-context7.md) |
| @@ -48,6 +49,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 48 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule | 49 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule |
| 49 | - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` | 50 | - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` |
| 50 | - [Skill file format](reference/skill-format.md) | 51 | - [Skill file format](reference/skill-format.md) |
| 52 | +- [Hooks](reference/hooks.md) — the four events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors | ||
| 51 | - [Agent Client Protocol surface](reference/acp.md) | 53 | - [Agent Client Protocol surface](reference/acp.md) |
| 52 | 54 | ||
| 53 | ### MCP (Model Context Protocol) Reference | 55 | ### MCP (Model Context Protocol) Reference |
| @@ -69,6 +71,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 69 | - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer | 71 | - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer |
| 70 | - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit | 72 | - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit |
| 71 | - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap | 73 | - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap |
| 74 | +- [Hooks](explanation/hooks.md) — why external commands, why they plug into `engine.Generate` and `WrapTool`, why a stopped question leaves a marker, what ticket 36 left out | ||
| 72 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | 75 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 73 | 76 | ||
| 74 | ### MCP (Model Context Protocol) Explanation | 77 | ### MCP (Model Context Protocol) Explanation |
modified
docs/en/explanation/architecture.md +1 -0 | @@ -27,6 +27,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e | ||
| 27 | 27 | | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. | |
| 28 | 28 | | `ui` | Where human-facing output goes, and the event sink the front ends share. | |
| 29 | 29 | | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). | |
| 30 | +| `hooks` | The `hooks:` block (ticket 36): runs each hook with `sh -c`, writes its JSON input, reads its reply, applies the chain order and `onError`, and returns decisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). No Genkit, no front end: `engine/hooks.go` applies the decisions around `Generate` and in the `WrapTool` middleware, so every front end gets them. See [Hooks](hooks.md). | | |
| 30 | 31 | | `agent` | The terminal REPL. | |
| 31 | 32 | | `acp` | The Agent Client Protocol façade. | |
| 32 | 33 | | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. | |
| @@ -27,6 +27,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e | |||
| 27 | | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. | | 27 | | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. | |
| 28 | | `ui` | Where human-facing output goes, and the event sink the front ends share. | | 28 | | `ui` | Where human-facing output goes, and the event sink the front ends share. | |
| 29 | | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). | | 29 | | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). | |
| 30 | +| `hooks` | The `hooks:` block (ticket 36): runs each hook with `sh -c`, writes its JSON input, reads its reply, applies the chain order and `onError`, and returns decisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). No Genkit, no front end: `engine/hooks.go` applies the decisions around `Generate` and in the `WrapTool` middleware, so every front end gets them. See [Hooks](hooks.md). | | ||
| 30 | | `agent` | The terminal REPL. | | 31 | | `agent` | The terminal REPL. | |
| 31 | | `acp` | The Agent Client Protocol façade. | | 32 | | `acp` | The Agent Client Protocol façade. | |
| 32 | | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. | | 33 | | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. | |
added
docs/en/explanation/hooks.md +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +# Hooks — explanation | |
| 2 | + | |
| 3 | +## What is this about? | |
| 4 | + | |
| 5 | +A hook is a command `mm` runs at four moments of a turn: before the model reads a question (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`), and once the turn is over (`postGeneration`). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing `mm`'s code. | |
| 6 | + | |
| 7 | +The design started from ticket 36, which explored a much larger system. This page explains what was kept, where the hooks plug in, and what was left out. | |
| 8 | + | |
| 9 | +## Why external commands | |
| 10 | + | |
| 11 | +The ticket's first requirement was that hooks can be written in **any language**. That rules out a Go plugin interface (it would need a recompilation, and Go's `plugin` package does not exist on every platform `mm` is built for) and an embedded interpreter (it would pick one language for everyone). A process reading stdin and writing stdout is the one interface every language has. | |
| 12 | + | |
| 13 | +It has costs, and they were accepted on purpose: | |
| 14 | + | |
| 15 | +- **A process start per event.** A `preToolCall` hook runs once per tool call; `node` takes some tens of milliseconds to start. Against a local model that spends seconds per answer, this is noise. It is also why every hook has a `timeout`, and why the default is short (10 s): a hook sits in the middle of the turn, and a slow one is felt at once. | |
| 16 | +- **JSON as the contract.** The input and the reply are small and flat, so that a hook in shell with `jq` is as easy to write as one in Python or Node. | |
| 17 | + | |
| 18 | +The same shape is used elsewhere in the ecosystem (editor and agent hooks, git hooks with their arguments): someone who has written one recognises the other. | |
| 19 | + | |
| 20 | +## Where they plug in | |
| 21 | + | |
| 22 | +Every front end — the line mode, the window, the ACP façade, `-p` — hands its turn to one function, `engine.Generate`. And inside it, Genkit's middleware already has a `WrapTool` hook that sees **every** tool call, built-in or MCP, before it runs. Those two points are where the hooks run: | |
| 23 | + | |
| 24 | +``` | |
| 25 | +front end ──▶ engine.Generate | |
| 26 | + ├─ preGeneration (the question, before the first model call) | |
| 27 | + ├─ model ⇄ tools ── WrapTool | |
| 28 | + │ ├─ preToolCall (before the tool, and its consent prompt) | |
| 29 | + │ └─ postToolCall (after the tool) | |
| 30 | + └─ postGeneration (the turn is over, whatever its outcome) | |
| 31 | +``` | |
| 32 | + | |
| 33 | +Placing them there rather than in each front end means one implementation, one set of tests, and no front end that forgets them. A new front end gets the hooks for free. | |
| 34 | + | |
| 35 | +`preToolCall` runs **before** the tool's consent prompt. A denied call therefore never asks the user anything; and a guard keeps working with `yolo: true`, which is when it matters most. | |
| 36 | + | |
| 37 | +The package `internal/hooks` runs the commands and turns their replies into decisions; it knows nothing about Genkit or the front ends. `internal/engine/hooks.go` applies those decisions to the messages and shows them: as the answer, as a failed tool call, or as a notice — a line in the terminal, a dim line in the window, the editor's logs over ACP. | |
| 38 | + | |
| 39 | +## Decisions worth knowing | |
| 40 | + | |
| 41 | +**A stopped question leaves a marker, not the question.** The obvious reason to stop a question is that it holds something that must not reach the model: a password, a key. Keeping it in the history would send it on the next turn. The history therefore keeps `[prompt blocked by a hook: <reason>]` followed by the hook's response — enough for the model to understand what happened, nothing it must not read. The first live run of the demo found a second leak: the audit hook on `postGeneration` received the blocked question and wrote the secret to its log. The post hooks now read the marker too. | |
| 42 | + | |
| 43 | +**Failures follow `onError`, and the default is `warn`.** A hook that crashes is shown, and the turn goes on: a broken logging script must not lock the user out of their agent. A guard should say `onError: fail` — a guard that cannot run must not let the call through. The two needs are opposite, so the choice is per hook. | |
| 44 | + | |
| 45 | +**An unknown event name is an error.** YAML ignores keys it does not know; `preToolcall:` would have been a guard that silently never runs. Refusing to start is the safer failure. | |
| 46 | + | |
| 47 | +**A denied tool call answers the model as text.** Like every tool failure in `mm`, the refusal is the tool's output: `[denied by a hook: <reason>]`. The model can tell the user or try another way, rather than the turn ending on an error. | |
| 48 | + | |
| 49 | +**`postGeneration` is not cancelled with the turn.** An audit log that loses the turns the user interrupted is the wrong way round. The hook runs after a cancellation too, bounded by its own timeout. | |
| 50 | + | |
| 51 | +**A replaced tool input is not written back into the history.** The tool runs with the hook's arguments, but the history keeps what the model asked for. The model's own request is the truthful record of what it did; a session file that shows arguments the model never wrote would mislead whoever reads it later. | |
| 52 | + | |
| 53 | +## Rejected alternatives | |
| 54 | + | |
| 55 | +Ticket 36 explored more than what was built. What was left out, and why: | |
| 56 | + | |
| 57 | +- **Returning the whole `messages` array.** The ticket's first protocol let a hook rewrite the conversation. It is fragile — a hook could drop the system prompt or rewrite past turns, and every hook would have to round-trip the whole history — and the four needs (detect, rewrite, add context, stop) are covered by `prompt`, `context` and `action`. | |
| 58 | +- **Numeric priorities.** The order of the YAML list is the order of execution. A priority field adds a second ordering to keep in mind for no gain when the list is short. | |
| 59 | +- **An `async` mode.** A logging hook is fast; when it is not, `onError: ignore` with a short `timeout` bounds it. A background mode would need a queue, a shutdown flush and a way to report late failures. | |
| 60 | +- **`allowedPaths` / `allowedExtensions`.** The YAML file already starts MCP servers and gives the model `bash`. Whoever writes it is trusted with far more than a hook path; an allowlist would protect nothing. | |
| 61 | +- **Lifecycle, session and front-end hooks** (start-up, configuration loaded, shutdown, window opened, editor connected). No use case asked for them yet. They can be added as events later without changing the protocol. | |
| 62 | + | |
| 63 | +## How it relates to the rest | |
| 64 | + | |
| 65 | +- **Yolo and consent.** Hooks and `yolo: false` are complementary: a hook decides by rule, without a human; consent asks a human, case by case. The hook runs first. See [Yolo mode and permissions](yolo-and-permissions.md). | |
| 66 | +- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's. | |
| 67 | +- **Context compression** asks the model for a summary through another path than `Generate`; the hooks do not run on it. | |
| 68 | + | |
| 69 | +See the [hooks reference](../reference/hooks.md) for every field, and [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one. | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +# Hooks — explanation | ||
| 2 | + | ||
| 3 | +## What is this about? | ||
| 4 | + | ||
| 5 | +A hook is a command `mm` runs at four moments of a turn: before the model reads a question (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`), and once the turn is over (`postGeneration`). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing `mm`'s code. | ||
| 6 | + | ||
| 7 | +The design started from ticket 36, which explored a much larger system. This page explains what was kept, where the hooks plug in, and what was left out. | ||
| 8 | + | ||
| 9 | +## Why external commands | ||
| 10 | + | ||
| 11 | +The ticket's first requirement was that hooks can be written in **any language**. That rules out a Go plugin interface (it would need a recompilation, and Go's `plugin` package does not exist on every platform `mm` is built for) and an embedded interpreter (it would pick one language for everyone). A process reading stdin and writing stdout is the one interface every language has. | ||
| 12 | + | ||
| 13 | +It has costs, and they were accepted on purpose: | ||
| 14 | + | ||
| 15 | +- **A process start per event.** A `preToolCall` hook runs once per tool call; `node` takes some tens of milliseconds to start. Against a local model that spends seconds per answer, this is noise. It is also why every hook has a `timeout`, and why the default is short (10 s): a hook sits in the middle of the turn, and a slow one is felt at once. | ||
| 16 | +- **JSON as the contract.** The input and the reply are small and flat, so that a hook in shell with `jq` is as easy to write as one in Python or Node. | ||
| 17 | + | ||
| 18 | +The same shape is used elsewhere in the ecosystem (editor and agent hooks, git hooks with their arguments): someone who has written one recognises the other. | ||
| 19 | + | ||
| 20 | +## Where they plug in | ||
| 21 | + | ||
| 22 | +Every front end — the line mode, the window, the ACP façade, `-p` — hands its turn to one function, `engine.Generate`. And inside it, Genkit's middleware already has a `WrapTool` hook that sees **every** tool call, built-in or MCP, before it runs. Those two points are where the hooks run: | ||
| 23 | + | ||
| 24 | +``` | ||
| 25 | +front end ──▶ engine.Generate | ||
| 26 | + ├─ preGeneration (the question, before the first model call) | ||
| 27 | + ├─ model ⇄ tools ── WrapTool | ||
| 28 | + │ ├─ preToolCall (before the tool, and its consent prompt) | ||
| 29 | + │ └─ postToolCall (after the tool) | ||
| 30 | + └─ postGeneration (the turn is over, whatever its outcome) | ||
| 31 | +``` | ||
| 32 | + | ||
| 33 | +Placing them there rather than in each front end means one implementation, one set of tests, and no front end that forgets them. A new front end gets the hooks for free. | ||
| 34 | + | ||
| 35 | +`preToolCall` runs **before** the tool's consent prompt. A denied call therefore never asks the user anything; and a guard keeps working with `yolo: true`, which is when it matters most. | ||
| 36 | + | ||
| 37 | +The package `internal/hooks` runs the commands and turns their replies into decisions; it knows nothing about Genkit or the front ends. `internal/engine/hooks.go` applies those decisions to the messages and shows them: as the answer, as a failed tool call, or as a notice — a line in the terminal, a dim line in the window, the editor's logs over ACP. | ||
| 38 | + | ||
| 39 | +## Decisions worth knowing | ||
| 40 | + | ||
| 41 | +**A stopped question leaves a marker, not the question.** The obvious reason to stop a question is that it holds something that must not reach the model: a password, a key. Keeping it in the history would send it on the next turn. The history therefore keeps `[prompt blocked by a hook: <reason>]` followed by the hook's response — enough for the model to understand what happened, nothing it must not read. The first live run of the demo found a second leak: the audit hook on `postGeneration` received the blocked question and wrote the secret to its log. The post hooks now read the marker too. | ||
| 42 | + | ||
| 43 | +**Failures follow `onError`, and the default is `warn`.** A hook that crashes is shown, and the turn goes on: a broken logging script must not lock the user out of their agent. A guard should say `onError: fail` — a guard that cannot run must not let the call through. The two needs are opposite, so the choice is per hook. | ||
| 44 | + | ||
| 45 | +**An unknown event name is an error.** YAML ignores keys it does not know; `preToolcall:` would have been a guard that silently never runs. Refusing to start is the safer failure. | ||
| 46 | + | ||
| 47 | +**A denied tool call answers the model as text.** Like every tool failure in `mm`, the refusal is the tool's output: `[denied by a hook: <reason>]`. The model can tell the user or try another way, rather than the turn ending on an error. | ||
| 48 | + | ||
| 49 | +**`postGeneration` is not cancelled with the turn.** An audit log that loses the turns the user interrupted is the wrong way round. The hook runs after a cancellation too, bounded by its own timeout. | ||
| 50 | + | ||
| 51 | +**A replaced tool input is not written back into the history.** The tool runs with the hook's arguments, but the history keeps what the model asked for. The model's own request is the truthful record of what it did; a session file that shows arguments the model never wrote would mislead whoever reads it later. | ||
| 52 | + | ||
| 53 | +## Rejected alternatives | ||
| 54 | + | ||
| 55 | +Ticket 36 explored more than what was built. What was left out, and why: | ||
| 56 | + | ||
| 57 | +- **Returning the whole `messages` array.** The ticket's first protocol let a hook rewrite the conversation. It is fragile — a hook could drop the system prompt or rewrite past turns, and every hook would have to round-trip the whole history — and the four needs (detect, rewrite, add context, stop) are covered by `prompt`, `context` and `action`. | ||
| 58 | +- **Numeric priorities.** The order of the YAML list is the order of execution. A priority field adds a second ordering to keep in mind for no gain when the list is short. | ||
| 59 | +- **An `async` mode.** A logging hook is fast; when it is not, `onError: ignore` with a short `timeout` bounds it. A background mode would need a queue, a shutdown flush and a way to report late failures. | ||
| 60 | +- **`allowedPaths` / `allowedExtensions`.** The YAML file already starts MCP servers and gives the model `bash`. Whoever writes it is trusted with far more than a hook path; an allowlist would protect nothing. | ||
| 61 | +- **Lifecycle, session and front-end hooks** (start-up, configuration loaded, shutdown, window opened, editor connected). No use case asked for them yet. They can be added as events later without changing the protocol. | ||
| 62 | + | ||
| 63 | +## How it relates to the rest | ||
| 64 | + | ||
| 65 | +- **Yolo and consent.** Hooks and `yolo: false` are complementary: a hook decides by rule, without a human; consent asks a human, case by case. The hook runs first. See [Yolo mode and permissions](yolo-and-permissions.md). | ||
| 66 | +- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's. | ||
| 67 | +- **Context compression** asks the model for a summary through another path than `Generate`; the hooks do not run on it. | ||
| 68 | + | ||
| 69 | +See the [hooks reference](../reference/hooks.md) for every field, and [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one. | ||
modified
docs/en/explanation/yolo-and-permissions.md +1 -0 | @@ -51,3 +51,4 @@ One refinement keeps the same principle: the user's file may give an asked agent | ||
| 51 | 51 | - How to turn it on: [How to confirm tool calls](../how-to/confirm-tool-calls.md). |
| 52 | 52 | - The event path the front ends share (`ui.Sink`, `Allow`): [Architecture](architecture.md). |
| 53 | 53 | - Asking another agent: [Asking another agent](asking-another-agent.md). |
| 54 | +- Deciding by rule rather than by asking: [Hooks](hooks.md) — a `preToolCall` hook runs before the consent question, so a call it denies is never put to the user. | |
| @@ -51,3 +51,4 @@ One refinement keeps the same principle: the user's file may give an asked agent | |||
| 51 | - How to turn it on: [How to confirm tool calls](../how-to/confirm-tool-calls.md). | 51 | - How to turn it on: [How to confirm tool calls](../how-to/confirm-tool-calls.md). |
| 52 | - The event path the front ends share (`ui.Sink`, `Allow`): [Architecture](architecture.md). | 52 | - The event path the front ends share (`ui.Sink`, `Allow`): [Architecture](architecture.md). |
| 53 | - Asking another agent: [Asking another agent](asking-another-agent.md). | 53 | - Asking another agent: [Asking another agent](asking-another-agent.md). |
| 54 | +- Deciding by rule rather than by asking: [Hooks](hooks.md) — a `preToolCall` hook runs before the consent question, so a call it denies is never put to the user. | ||
modified
docs/en/how-to/confirm-tool-calls.md +1 -0 | @@ -55,3 +55,4 @@ By default mini-me runs in **yolo mode**: `bash`, file writes and edits run as s | ||
| 55 | 55 | - Every key and its default: [configuration reference](../reference/configuration.md) (`yolo`, `permissions`). |
| 56 | 56 | - What each front end shows: [CLI reference](../reference/cli.md), [ACP reference](../reference/acp.md#sessionrequest_permission-requests-sent). |
| 57 | 57 | - Why yolo is the default and why the asking agent wins: [Yolo mode and permissions](../explanation/yolo-and-permissions.md). |
| 58 | +- Refuse some calls automatically, by rule, without asking: [Guard the agent with hooks](guard-the-agent-with-hooks.md). | |
| @@ -55,3 +55,4 @@ By default mini-me runs in **yolo mode**: `bash`, file writes and edits run as s | |||
| 55 | - Every key and its default: [configuration reference](../reference/configuration.md) (`yolo`, `permissions`). | 55 | - Every key and its default: [configuration reference](../reference/configuration.md) (`yolo`, `permissions`). |
| 56 | - What each front end shows: [CLI reference](../reference/cli.md), [ACP reference](../reference/acp.md#sessionrequest_permission-requests-sent). | 56 | - What each front end shows: [CLI reference](../reference/cli.md), [ACP reference](../reference/acp.md#sessionrequest_permission-requests-sent). |
| 57 | - Why yolo is the default and why the asking agent wins: [Yolo mode and permissions](../explanation/yolo-and-permissions.md). | 57 | - Why yolo is the default and why the asking agent wins: [Yolo mode and permissions](../explanation/yolo-and-permissions.md). |
| 58 | +- Refuse some calls automatically, by rule, without asking: [Guard the agent with hooks](guard-the-agent-with-hooks.md). | ||
added
docs/en/how-to/guard-the-agent-with-hooks.md +100 -0 | new file mode 100644 | ||
| @@ -0,0 +1,100 @@ | ||
| 1 | +# How to guard the agent with hooks | |
| 2 | + | |
| 3 | +This guide shows how to run your own command at fixed moments of a turn: to refuse a dangerous tool call, to stop a question before the model sees it, to add context, or to log what the agent does. It assumes you have an agent that runs, and a language you can write a small script in. The examples use Node.js; any program that reads stdin and writes stdout works. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +### 1. Pick the event | |
| 8 | + | |
| 9 | +| You want to… | Event | | |
| 10 | +|--------------|-------| | |
| 11 | +| refuse some tool calls, or change their arguments | `preToolCall` | | |
| 12 | +| stop or rewrite a question, or add context to it | `preGeneration` | | |
| 13 | +| add a note after a tool's output | `postToolCall` | | |
| 14 | +| log or notify, without changing anything | `postToolCall`, `postGeneration` | | |
| 15 | + | |
| 16 | +### 2. Write the hook | |
| 17 | + | |
| 18 | +The hook reads one JSON object on stdin and, when it has a decision, prints one on stdout. Printing nothing lets the turn go on. This one refuses `git push --force`: | |
| 19 | + | |
| 20 | +```js | |
| 21 | +// hooks/no-force-push.js | |
| 22 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | |
| 23 | +const command = input.tool.input.command || ""; | |
| 24 | + | |
| 25 | +if (/\bgit\s+push\b.*(--force|-f\b)/.test(command)) { | |
| 26 | + console.log(JSON.stringify({ action: "deny", reason: "force-push is not allowed in this project" })); | |
| 27 | +} | |
| 28 | +``` | |
| 29 | + | |
| 30 | +The reason is what the model reads instead of the command's output: write it for the model, so it can tell the user or try something else. | |
| 31 | + | |
| 32 | +### 3. Try it without mm | |
| 33 | + | |
| 34 | +Feed it the input mm would send: | |
| 35 | + | |
| 36 | +```bash | |
| 37 | +echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"git push -f origin main"}}}' \ | |
| 38 | + | node hooks/no-force-push.js | |
| 39 | +``` | |
| 40 | + | |
| 41 | +You should see: | |
| 42 | + | |
| 43 | +``` | |
| 44 | +{"action":"deny","reason":"force-push is not allowed in this project"} | |
| 45 | +``` | |
| 46 | + | |
| 47 | +And nothing at all for `git push origin main`. | |
| 48 | + | |
| 49 | +### 4. Declare it | |
| 50 | + | |
| 51 | +In the agent's YAML file, next to the script: | |
| 52 | + | |
| 53 | +```yaml | |
| 54 | +hooks: | |
| 55 | + preToolCall: | |
| 56 | + - matcher: ^bash$ | |
| 57 | + command: node "$MM_CONFIG_DIR/hooks/no-force-push.js" | |
| 58 | + onError: fail | |
| 59 | +``` | |
| 60 | + | |
| 61 | +- `matcher` limits the hook to the `bash` tool; without it, the hook runs for every tool call. | |
| 62 | +- `$MM_CONFIG_DIR` is the directory of the YAML file, so the script is found whatever directory `mm` is started from. | |
| 63 | +- `onError: fail` denies the call when the hook itself fails (Node missing, a syntax error, a timeout). Use it for guards; keep the default, `warn`, for hooks whose failure must not get in the way. | |
| 64 | + | |
| 65 | +### 5. Start the agent and check | |
| 66 | + | |
| 67 | +```bash | |
| 68 | +mm agent.yaml | |
| 69 | +``` | |
| 70 | + | |
| 71 | +The banner ends with the hooks it loaded: | |
| 72 | + | |
| 73 | +``` | |
| 74 | +… | yolo: on | hooks: preToolCall×1 | |
| 75 | +``` | |
| 76 | + | |
| 77 | +Ask for something that triggers the rule (`force-push the main branch`). The line mode shows: | |
| 78 | + | |
| 79 | +``` | |
| 80 | +🪝 bash [denied by a hook: force-push is not allowed in this project] | |
| 81 | +``` | |
| 82 | + | |
| 83 | +and the model's answer explains the refusal. | |
| 84 | + | |
| 85 | +## Variants | |
| 86 | + | |
| 87 | +- **Stop a question.** On `preGeneration`, read `input.prompt` and print `{"action":"stop","reason":"…","response":"…"}`. The user reads `response`; the model is not called, and the question is replaced in the history by `[prompt blocked by a hook: <reason>]`. | |
| 88 | +- **Add context.** On `preGeneration`, print `{"context":"…"}`: the text is appended to the question under `[context added by a hook]`. On `postToolCall`, the same field is appended to the tool's output. | |
| 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`. | |
| 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 | +- **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 | +- **In an editor** (`mm -acp`), the hooks run in the session's working directory, and a denied call shows as a failed tool call titled `<tool> — denied by a hook`. | |
| 95 | + | |
| 96 | +## See also | |
| 97 | + | |
| 98 | +- Every field, action, failure and limit: [Hooks reference](../reference/hooks.md) | |
| 99 | +- Why hooks are external commands, and where they plug in: [Hooks — explanation](../explanation/hooks.md) | |
| 100 | +- A complete example with four hooks: `demos/hooks/` | |
| new file mode 100644 | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | +# How to guard the agent with hooks | ||
| 2 | + | ||
| 3 | +This guide shows how to run your own command at fixed moments of a turn: to refuse a dangerous tool call, to stop a question before the model sees it, to add context, or to log what the agent does. It assumes you have an agent that runs, and a language you can write a small script in. The examples use Node.js; any program that reads stdin and writes stdout works. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +### 1. Pick the event | ||
| 8 | + | ||
| 9 | +| You want to… | Event | | ||
| 10 | +|--------------|-------| | ||
| 11 | +| refuse some tool calls, or change their arguments | `preToolCall` | | ||
| 12 | +| stop or rewrite a question, or add context to it | `preGeneration` | | ||
| 13 | +| add a note after a tool's output | `postToolCall` | | ||
| 14 | +| log or notify, without changing anything | `postToolCall`, `postGeneration` | | ||
| 15 | + | ||
| 16 | +### 2. Write the hook | ||
| 17 | + | ||
| 18 | +The hook reads one JSON object on stdin and, when it has a decision, prints one on stdout. Printing nothing lets the turn go on. This one refuses `git push --force`: | ||
| 19 | + | ||
| 20 | +```js | ||
| 21 | +// hooks/no-force-push.js | ||
| 22 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | ||
| 23 | +const command = input.tool.input.command || ""; | ||
| 24 | + | ||
| 25 | +if (/\bgit\s+push\b.*(--force|-f\b)/.test(command)) { | ||
| 26 | + console.log(JSON.stringify({ action: "deny", reason: "force-push is not allowed in this project" })); | ||
| 27 | +} | ||
| 28 | +``` | ||
| 29 | + | ||
| 30 | +The reason is what the model reads instead of the command's output: write it for the model, so it can tell the user or try something else. | ||
| 31 | + | ||
| 32 | +### 3. Try it without mm | ||
| 33 | + | ||
| 34 | +Feed it the input mm would send: | ||
| 35 | + | ||
| 36 | +```bash | ||
| 37 | +echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"git push -f origin main"}}}' \ | ||
| 38 | + | node hooks/no-force-push.js | ||
| 39 | +``` | ||
| 40 | + | ||
| 41 | +You should see: | ||
| 42 | + | ||
| 43 | +``` | ||
| 44 | +{"action":"deny","reason":"force-push is not allowed in this project"} | ||
| 45 | +``` | ||
| 46 | + | ||
| 47 | +And nothing at all for `git push origin main`. | ||
| 48 | + | ||
| 49 | +### 4. Declare it | ||
| 50 | + | ||
| 51 | +In the agent's YAML file, next to the script: | ||
| 52 | + | ||
| 53 | +```yaml | ||
| 54 | +hooks: | ||
| 55 | + preToolCall: | ||
| 56 | + - matcher: ^bash$ | ||
| 57 | + command: node "$MM_CONFIG_DIR/hooks/no-force-push.js" | ||
| 58 | + onError: fail | ||
| 59 | +``` | ||
| 60 | + | ||
| 61 | +- `matcher` limits the hook to the `bash` tool; without it, the hook runs for every tool call. | ||
| 62 | +- `$MM_CONFIG_DIR` is the directory of the YAML file, so the script is found whatever directory `mm` is started from. | ||
| 63 | +- `onError: fail` denies the call when the hook itself fails (Node missing, a syntax error, a timeout). Use it for guards; keep the default, `warn`, for hooks whose failure must not get in the way. | ||
| 64 | + | ||
| 65 | +### 5. Start the agent and check | ||
| 66 | + | ||
| 67 | +```bash | ||
| 68 | +mm agent.yaml | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +The banner ends with the hooks it loaded: | ||
| 72 | + | ||
| 73 | +``` | ||
| 74 | +… | yolo: on | hooks: preToolCall×1 | ||
| 75 | +``` | ||
| 76 | + | ||
| 77 | +Ask for something that triggers the rule (`force-push the main branch`). The line mode shows: | ||
| 78 | + | ||
| 79 | +``` | ||
| 80 | +🪝 bash [denied by a hook: force-push is not allowed in this project] | ||
| 81 | +``` | ||
| 82 | + | ||
| 83 | +and the model's answer explains the refusal. | ||
| 84 | + | ||
| 85 | +## Variants | ||
| 86 | + | ||
| 87 | +- **Stop a question.** On `preGeneration`, read `input.prompt` and print `{"action":"stop","reason":"…","response":"…"}`. The user reads `response`; the model is not called, and the question is replaced in the history by `[prompt blocked by a hook: <reason>]`. | ||
| 88 | +- **Add context.** On `preGeneration`, print `{"context":"…"}`: the text is appended to the question under `[context added by a hook]`. On `postToolCall`, the same field is appended to the tool's output. | ||
| 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`. | ||
| 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 | +- **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 | +- **In an editor** (`mm -acp`), the hooks run in the session's working directory, and a denied call shows as a failed tool call titled `<tool> — denied by a hook`. | ||
| 95 | + | ||
| 96 | +## See also | ||
| 97 | + | ||
| 98 | +- Every field, action, failure and limit: [Hooks reference](../reference/hooks.md) | ||
| 99 | +- Why hooks are external commands, and where they plug in: [Hooks — explanation](../explanation/hooks.md) | ||
| 100 | +- A complete example with four hooks: `demos/hooks/` | ||
modified
docs/en/reference/acp.md +2 -1 | @@ -8,7 +8,7 @@ | ||
| 8 | 8 | |--------|-------| |
| 9 | 9 | | Input | stdin, one JSON-RPC message per line. | |
| 10 | 10 | | Output | stdout, JSON-RPC messages only. | |
| 11 | -| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger. | | |
| 11 | +| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger, the failure lines of [hooks](hooks.md#failures) (`🪝 <event> hook …`). | | |
| 12 | 12 | | Lifetime | Until the client closes stdin or the process context is cancelled. | |
| 13 | 13 | |
| 14 | 14 | ## `initialize` response |
| @@ -97,6 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N | ||
| 97 | 97 | | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. | |
| 98 | 98 | | `tool_call_update` status `in_progress` | Permission granted, tool executing. | |
| 99 | 99 | | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. | |
| 100 | +| `tool_call` then `tool_call_update` status `failed`, title `<tool> — denied by a hook` | A `preToolCall` [hook](hooks.md) denied the call: the tool did not run and no permission was requested; the `text` content is `[denied by a hook: <reason>]`. | | |
| 100 | 101 | | `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. | |
| 101 | 102 | |
| 102 | 103 | ## Available commands |
| @@ -8,7 +8,7 @@ | |||
| 8 | |--------|-------| | 8 | |--------|-------| |
| 9 | | Input | stdin, one JSON-RPC message per line. | | 9 | | Input | stdin, one JSON-RPC message per line. | |
| 10 | | Output | stdout, JSON-RPC messages only. | | 10 | | Output | stdout, JSON-RPC messages only. | |
| 11 | -| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger. | | 11 | +| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger, the failure lines of [hooks](hooks.md#failures) (`🪝 <event> hook …`). | |
| 12 | | Lifetime | Until the client closes stdin or the process context is cancelled. | | 12 | | Lifetime | Until the client closes stdin or the process context is cancelled. | |
| 13 | 13 | ||
| 14 | ## `initialize` response | 14 | ## `initialize` response |
| @@ -97,6 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N | |||
| 97 | | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. | | 97 | | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. | |
| 98 | | `tool_call_update` status `in_progress` | Permission granted, tool executing. | | 98 | | `tool_call_update` status `in_progress` | Permission granted, tool executing. | |
| 99 | | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. | | 99 | | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. | |
| 100 | +| `tool_call` then `tool_call_update` status `failed`, title `<tool> — denied by a hook` | A `preToolCall` [hook](hooks.md) denied the call: the tool did not run and no permission was requested; the `text` content is `[denied by a hook: <reason>]`. | | ||
| 100 | | `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. | | 101 | | `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. | |
| 101 | 102 | ||
| 102 | ## Available commands | 103 | ## Available commands |
modified
docs/en/reference/cli.md +1 -1 | @@ -75,7 +75,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is | ||
| 75 | 75 | | Line | Content | |
| 76 | 76 | |------|---------| |
| 77 | 77 | | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all, an `mmConfigs` file not found (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), the deprecated `permissions` key. | |
| 78 | -| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …]` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found, `yolo: on` or `yolo: off (tools ask)`., and, while `trace.enabled` is on, `trace: <absolute dir>` (`trace: <session cwd>/<dir>` in ACP mode). `agents.md:` (with `agentsMd.enabled`): `<absolute path> (~N tokens)` when the project's file is read, `skipped (too long)` above `agentsMd.maxChars`, `ignored (structured output)` for an agent with `structuredOutput`, `<session cwd>/<path>` in ACP mode; absent when there is no file. A read file of 20 % of a known context window or more adds the warning `<path> is ~N tokens, X % of the W-token context window — every request carries it …`. | | |
| 78 | +| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …][ \| hooks: …]` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found, `yolo: on` or `yolo: off (tools ask)`., and, while `trace.enabled` is on, `trace: <absolute dir>` (`trace: <session cwd>/<dir>` in ACP mode). `hooks:` (when the configuration declares any): `<event>×<count>` per event that has hooks, e.g. `hooks: preGeneration×1 preToolCall×2` — see [hooks](hooks.md#start-up-banner). `agents.md:` (with `agentsMd.enabled`): `<absolute path> (~N tokens)` when the project's file is read, `skipped (too long)` above `agentsMd.maxChars`, `ignored (structured output)` for an agent with `structuredOutput`, `<session cwd>/<path>` in ACP mode; absent when there is no file. A read file of 20 % of a known context window or more adds the warning `<path> is ~N tokens, X % of the W-token context window — every request carries it …`. | | |
| 79 | 79 | | `Agent ready. Commands: …` | Terminal mode only. | |
| 80 | 80 | | `Shortcut: Ctrl+C …` | Terminal mode only. | |
| 81 | 81 | | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. | |
| @@ -75,7 +75,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is | |||
| 75 | | Line | Content | | 75 | | Line | Content | |
| 76 | |------|---------| | 76 | |------|---------| |
| 77 | | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all, an `mmConfigs` file not found (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), the deprecated `permissions` key. | | 77 | | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all, an `mmConfigs` file not found (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), the deprecated `permissions` key. | |
| 78 | -| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …]` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found, `yolo: on` or `yolo: off (tools ask)`., and, while `trace.enabled` is on, `trace: <absolute dir>` (`trace: <session cwd>/<dir>` in ACP mode). `agents.md:` (with `agentsMd.enabled`): `<absolute path> (~N tokens)` when the project's file is read, `skipped (too long)` above `agentsMd.maxChars`, `ignored (structured output)` for an agent with `structuredOutput`, `<session cwd>/<path>` in ACP mode; absent when there is no file. A read file of 20 % of a known context window or more adds the warning `<path> is ~N tokens, X % of the W-token context window — every request carries it …`. | | 78 | +| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …][ \| hooks: …]` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found, `yolo: on` or `yolo: off (tools ask)`., and, while `trace.enabled` is on, `trace: <absolute dir>` (`trace: <session cwd>/<dir>` in ACP mode). `hooks:` (when the configuration declares any): `<event>×<count>` per event that has hooks, e.g. `hooks: preGeneration×1 preToolCall×2` — see [hooks](hooks.md#start-up-banner). `agents.md:` (with `agentsMd.enabled`): `<absolute path> (~N tokens)` when the project's file is read, `skipped (too long)` above `agentsMd.maxChars`, `ignored (structured output)` for an agent with `structuredOutput`, `<session cwd>/<path>` in ACP mode; absent when there is no file. A read file of 20 % of a known context window or more adds the warning `<path> is ~N tokens, X % of the W-token context window — every request carries it …`. | |
| 79 | | `Agent ready. Commands: …` | Terminal mode only. | | 79 | | `Agent ready. Commands: …` | Terminal mode only. | |
| 80 | | `Shortcut: Ctrl+C …` | Terminal mode only. | | 80 | | `Shortcut: Ctrl+C …` | Terminal mode only. | |
| 81 | | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. | | 81 | | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. | |
modified
docs/en/reference/configuration.md +2 -0 | @@ -34,6 +34,7 @@ | ||
| 34 | 34 | | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. | |
| 35 | 35 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | |
| 36 | 36 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | |
| 37 | +| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) and after the turn (`postGeneration`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool events only), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | | |
| 37 | 38 | | `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`. | |
| 38 | 39 | | `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). | |
| 39 | 40 | | `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`. | |
| @@ -211,6 +212,7 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev | ||
| 211 | 212 | | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | |
| 212 | 213 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | |
| 213 | 214 | | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` | |
| 215 | +| `hooks: unknown event "x" (…)`, `hooks.<event>[N]: …` | an invalid `hooks:` block — see [hooks](hooks.md#validation-errors) | | |
| 214 | 216 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | |
| 215 | 217 | |
| 216 | 218 | A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`). |
| @@ -34,6 +34,7 @@ | |||
| 34 | | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. | | 34 | | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. | |
| 35 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | | 35 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | |
| 36 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | | 36 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | |
| 37 | +| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) and after the turn (`postGeneration`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool events only), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | | ||
| 37 | | `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`. | | 38 | | `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`. | |
| 38 | | `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). | | 39 | | `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). | |
| 39 | | `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`. | | 40 | | `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`. | |
| @@ -211,6 +212,7 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev | |||
| 211 | | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | | 212 | | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | |
| 212 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | | 213 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | |
| 213 | | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` | | 214 | | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` | |
| 215 | +| `hooks: unknown event "x" (…)`, `hooks.<event>[N]: …` | an invalid `hooks:` block — see [hooks](hooks.md#validation-errors) | | ||
| 214 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | | 216 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | |
| 215 | 217 | ||
| 216 | A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`). | 218 | A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`). |
added
docs/en/reference/hooks.md +206 -0 | new file mode 100644 | ||
| @@ -0,0 +1,206 @@ | ||
| 1 | +# Reference: hooks | |
| 2 | + | |
| 3 | +> The `hooks:` block of the configuration file: the events, the JSON a hook reads and writes, the environment it runs in, and what each decision does to the turn. | |
| 4 | + | |
| 5 | +## Configuration | |
| 6 | + | |
| 7 | +```yaml | |
| 8 | +hooks: | |
| 9 | + preGeneration: [<hook>, …] | |
| 10 | + preToolCall: [<hook>, …] | |
| 11 | + postToolCall: [<hook>, …] | |
| 12 | + postGeneration: [<hook>, …] | |
| 13 | +``` | |
| 14 | + | |
| 15 | +Each event takes a list; the hooks of one event run in the order they are written. An event that is absent, or an empty list, runs nothing. No `hooks:` block = no hook. | |
| 16 | + | |
| 17 | +### Hook entry | |
| 18 | + | |
| 19 | +| Key | Type | Default | Description | | |
| 20 | +|-----|------|---------|-------------| | |
| 21 | +| `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. | | |
| 22 | +| `matcher` | string (regular expression, Go syntax) | `""` (every tool) | `preToolCall` and `postToolCall` only. The hook runs for the tools whose name matches. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. | | |
| 23 | +| `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. | | |
| 24 | +| `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). | | |
| 25 | + | |
| 26 | +### Example | |
| 27 | + | |
| 28 | +```yaml | |
| 29 | +hooks: | |
| 30 | + preGeneration: | |
| 31 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | |
| 32 | + timeout: 5s | |
| 33 | + onError: fail | |
| 34 | + preToolCall: | |
| 35 | + - matcher: ^(bash|write_file|edit_file)$ | |
| 36 | + command: ./hooks/guard.sh | |
| 37 | + postGeneration: | |
| 38 | + - command: python3 "$MM_CONFIG_DIR/hooks/log.py" | |
| 39 | + onError: ignore | |
| 40 | +``` | |
| 41 | + | |
| 42 | +## Events | |
| 43 | + | |
| 44 | +| Event | Runs | Tools concerned | Can | | |
| 45 | +|-------|------|-----------------|-----| | |
| 46 | +| `preGeneration` | once per question, before the first call to the model | — | rewrite the question, add context, stop the turn | | |
| 47 | +| `preToolCall` | before each tool call, before the tool's own consent prompt | built-in and MCP, filtered by `matcher` | deny the call, replace its arguments | | |
| 48 | +| `postToolCall` | after each tool call that ran | built-in and MCP, filtered by `matcher` | add context after the tool's output | | |
| 49 | +| `postGeneration` | once the turn is over: answered, failed, cancelled or stopped by a hook | — | observe only | | |
| 50 | + | |
| 51 | +All four run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`, and an agent started by `/ask`, `ask_agent` or `mm --ask` (with the hooks of **its own** configuration file). A `preGeneration` hook does not run on a context-compression summary. | |
| 52 | + | |
| 53 | +## Execution | |
| 54 | + | |
| 55 | +| Aspect | Value | | |
| 56 | +|--------|-------| | |
| 57 | +| Command | `sh -c "<command>"` | | |
| 58 | +| Working directory | the session's: the directory `mm` runs in, or the editor session's `cwd` over ACP | | |
| 59 | +| Environment | `mm`'s own, plus the two variables below | | |
| 60 | +| stdin | one JSON object ([Input](#input)), then end of file | | |
| 61 | +| stdout | empty, or one JSON object ([Reply](#reply)); at most 1 MiB | | |
| 62 | +| stderr | quoted (first 300 bytes) in the failure line when the run fails; otherwise ignored | | |
| 63 | +| Concurrency | hooks of one event run one after the other; `preToolCall`/`postToolCall` may run concurrently for two tool calls of one model reply | | |
| 64 | + | |
| 65 | +### Environment variables | |
| 66 | + | |
| 67 | +| Variable | Value | | |
| 68 | +|----------|-------| | |
| 69 | +| `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall` or `postGeneration` | | |
| 70 | +| `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook | | |
| 71 | + | |
| 72 | +## Input | |
| 73 | + | |
| 74 | +The JSON object written on the hook's stdin. | |
| 75 | + | |
| 76 | +| Field | Type | Events | Description | | |
| 77 | +|-------|------|--------|-------------| | |
| 78 | +| `event` | string | all | the event's name | | |
| 79 | +| `cwd` | string | all | the session's working directory, absolute | | |
| 80 | +| `model` | string | all | the model reference, `<provider>/<model>` | | |
| 81 | +| `prompt` | string | `preGeneration`, `postGeneration` | the question as typed — `@path` and `$skill` lines included. In a chain, the question as the previous hook left it. In `postGeneration`: the question the model read, or the marker `[prompt blocked by a hook: <reason>]` when a hook stopped the turn. | | |
| 82 | +| `tool` | object | `preToolCall`, `postToolCall` | the tool call: `id` (the call's reference, may be empty), `name`, `input` (the arguments, as the model sent them or as the previous hook replaced them) | | |
| 83 | +| `output` | string | `postToolCall` | the tool's output, as the model will read it (JSON when the tool returns something other than text) | | |
| 84 | +| `answer` | string | `postGeneration` | the text of the last answer; empty when there is none | | |
| 85 | +| `error` | string | `postGeneration` | the turn's error; absent when the turn succeeded | | |
| 86 | + | |
| 87 | +Empty fields are omitted. | |
| 88 | + | |
| 89 | +### Examples | |
| 90 | + | |
| 91 | +```json | |
| 92 | +{"event":"preGeneration","cwd":"/home/me/project","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"} | |
| 93 | +``` | |
| 94 | + | |
| 95 | +```json | |
| 96 | +{"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}} | |
| 97 | +``` | |
| 98 | + | |
| 99 | +## Reply | |
| 100 | + | |
| 101 | +The JSON object a hook may write on stdout. **An empty stdout (or whitespace only) is `{"action":"continue"}`.** Every field is optional. | |
| 102 | + | |
| 103 | +| Field | Type | Read on | Description | | |
| 104 | +|-------|------|---------|-------------| | |
| 105 | +| `action` | string | all | `continue` (default, also when absent), `stop`, `deny` or `error` — see below | | |
| 106 | +| `prompt` | string | `preGeneration` | replaces the question. Absent = unchanged; `""` = an empty question. | | |
| 107 | +| `context` | string | `preGeneration`, `postToolCall` | text for the model. `preGeneration`: appended to the question. `postToolCall`: appended to the tool's output. Each one is introduced by the line `[context added by a hook]`. Blank = ignored. | | |
| 108 | +| `input` | any JSON value | `preToolCall` | replaces the tool call's arguments. Absent = unchanged. | | |
| 109 | +| `reason` | string | `preGeneration` (`stop`), `preToolCall` (`deny`) | why. | | |
| 110 | +| `response` | string | `preGeneration` (`stop`) | what the user reads instead of an answer. Absent or blank: `⛔ This request was blocked by a hook: <reason>`. | | |
| 111 | +| `error` | string | all (`error`) | the hook's own failure message. | | |
| 112 | + | |
| 113 | +Fields read on another event are ignored. | |
| 114 | + | |
| 115 | +### Actions | |
| 116 | + | |
| 117 | +| Action | Allowed on | Effect | | |
| 118 | +|--------|-----------|--------| | |
| 119 | +| `continue` | all | the turn goes on, with this hook's `prompt`, `context` and `input` applied | | |
| 120 | +| `stop` | `preGeneration` | the turn ends without calling the model; the following hooks of the event do not run | | |
| 121 | +| `deny` | `preToolCall` | the tool does not run; the following hooks of the event do not run | | |
| 122 | +| `error` | all | a failure, handled according to `onError` | | |
| 123 | +| anything else, or `stop`/`deny` on another event | — | a failure: `action "<action>" is not valid for <event>` | | |
| 124 | + | |
| 125 | +## Effects on the turn | |
| 126 | + | |
| 127 | +### `preGeneration` | |
| 128 | + | |
| 129 | +| Decision | The model reads | The history keeps | The user sees | | |
| 130 | +|----------|-----------------|-------------------|---------------| | |
| 131 | +| no change | the question | the question | — | | |
| 132 | +| `prompt` and/or `context` | the rewritten question, then each context | the rewritten question (the front end's own copy is not changed during the turn) | — | | |
| 133 | +| `stop` | nothing: the model is not called | `[prompt blocked by a hook: <reason>]` (or `[prompt blocked by a hook]`) followed by the `response` as the answer | the `response`, as the answer | | |
| 134 | + | |
| 135 | +A chain: each hook reads the `prompt` the previous one left; contexts accumulate in hook order. | |
| 136 | + | |
| 137 | +### `preToolCall` | |
| 138 | + | |
| 139 | +| Decision | The tool | The model reads | The user sees | | |
| 140 | +|----------|----------|-----------------|---------------| | |
| 141 | +| no change | runs with the model's arguments | the output | the usual tool line | | |
| 142 | +| `input` | runs with the replaced arguments | the output | the usual tool line (with the replaced arguments) | | |
| 143 | +| `deny` | does not run, and does not ask for consent | `[denied by a hook: <reason>]` as the tool's output | line mode: `🪝 <tool> [denied by a hook: <reason>]`; window and editor: a failed tool call titled `<tool> — denied by a hook` | | |
| 144 | + | |
| 145 | +The history keeps the tool call as the model wrote it, even when `input` replaced its arguments. | |
| 146 | + | |
| 147 | +### `postToolCall` | |
| 148 | + | |
| 149 | +`context` from every matching hook is joined and appended to the output: `<output>\n\n[context added by a hook]\n<context>`. The tool's output shown to the user is unchanged. | |
| 150 | + | |
| 151 | +### `postGeneration` | |
| 152 | + | |
| 153 | +No effect on the turn. The reply is decoded (an invalid one is a failure) and otherwise ignored. | |
| 154 | + | |
| 155 | +## Failures | |
| 156 | + | |
| 157 | +A run fails when: | |
| 158 | + | |
| 159 | +| Failure | Report | | |
| 160 | +|---------|--------| | |
| 161 | +| the command exits with a non-zero status | `exit status <n>: <stderr>` | | |
| 162 | +| it runs longer than `timeout` | `timed out after <timeout>` | | |
| 163 | +| the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` | | |
| 164 | +| stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` | | |
| 165 | +| stdout is not JSON | `invalid JSON on stdout: …` | | |
| 166 | +| `action` is `error` | the reply's `error`, or `the hook reported an error` | | |
| 167 | +| `action` is unknown or not allowed on the event | `action "<action>" is not valid for <event>` | | |
| 168 | + | |
| 169 | +`postGeneration` is not cancelled with the turn: it runs after a cancellation too (Escape, Ctrl-C, `/abort`), bounded by its `timeout`. | |
| 170 | + | |
| 171 | +What follows depends on `onError`: | |
| 172 | + | |
| 173 | +| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` | | |
| 174 | +|-----------|-----------------|---------------|----------------------------------| | |
| 175 | +| `fail` | report line, turn stopped (`response`: `⛔ This request was blocked by a hook: hook failed: …`) | report line, call denied (`reason`: `hook failed: …`) | report line, carry on | | |
| 176 | +| `warn` | report line, next hook | report line, next hook | report line, carry on | | |
| 177 | +| `ignore` | next hook, silently | next hook, silently | carry on, silently | | |
| 178 | + | |
| 179 | +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. | |
| 180 | + | |
| 181 | +## Start-up banner | |
| 182 | + | |
| 183 | +When at least one hook is declared, the banner ends with ` | hooks: <event>×<count> …`, one entry per event that has hooks, in the order `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`: | |
| 184 | + | |
| 185 | +``` | |
| 186 | +mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1 | |
| 187 | +``` | |
| 188 | + | |
| 189 | +## Validation errors | |
| 190 | + | |
| 191 | +Reported at start-up as `[config error: <path>: <message>]`, exit code 1. | |
| 192 | + | |
| 193 | +| Message | Cause | | |
| 194 | +|---------|-------| | |
| 195 | +| `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) | | |
| 196 | +| `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one | | |
| 197 | +| `hooks.<event>[N]: matcher is only for preToolCall and postToolCall` | a `matcher` on `preGeneration` or `postGeneration` | | |
| 198 | +| `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile | | |
| 199 | +| `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` | | |
| 200 | +| `hooks.<event>[N]: onError must be "fail", "warn" or "ignore"` | any other `onError` | | |
| 201 | + | |
| 202 | +## See also | |
| 203 | + | |
| 204 | +- [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) | |
| 205 | +- [Hooks — explanation](../explanation/hooks.md) | |
| 206 | +- The demo: `demos/hooks/` (four Node.js hooks) | |
| new file mode 100644 | |||
| @@ -0,0 +1,206 @@ | |||
| 1 | +# Reference: hooks | ||
| 2 | + | ||
| 3 | +> The `hooks:` block of the configuration file: the events, the JSON a hook reads and writes, the environment it runs in, and what each decision does to the turn. | ||
| 4 | + | ||
| 5 | +## Configuration | ||
| 6 | + | ||
| 7 | +```yaml | ||
| 8 | +hooks: | ||
| 9 | + preGeneration: [<hook>, …] | ||
| 10 | + preToolCall: [<hook>, …] | ||
| 11 | + postToolCall: [<hook>, …] | ||
| 12 | + postGeneration: [<hook>, …] | ||
| 13 | +``` | ||
| 14 | + | ||
| 15 | +Each event takes a list; the hooks of one event run in the order they are written. An event that is absent, or an empty list, runs nothing. No `hooks:` block = no hook. | ||
| 16 | + | ||
| 17 | +### Hook entry | ||
| 18 | + | ||
| 19 | +| Key | Type | Default | Description | | ||
| 20 | +|-----|------|---------|-------------| | ||
| 21 | +| `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. | | ||
| 22 | +| `matcher` | string (regular expression, Go syntax) | `""` (every tool) | `preToolCall` and `postToolCall` only. The hook runs for the tools whose name matches. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. | | ||
| 23 | +| `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. | | ||
| 24 | +| `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). | | ||
| 25 | + | ||
| 26 | +### Example | ||
| 27 | + | ||
| 28 | +```yaml | ||
| 29 | +hooks: | ||
| 30 | + preGeneration: | ||
| 31 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | ||
| 32 | + timeout: 5s | ||
| 33 | + onError: fail | ||
| 34 | + preToolCall: | ||
| 35 | + - matcher: ^(bash|write_file|edit_file)$ | ||
| 36 | + command: ./hooks/guard.sh | ||
| 37 | + postGeneration: | ||
| 38 | + - command: python3 "$MM_CONFIG_DIR/hooks/log.py" | ||
| 39 | + onError: ignore | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +## Events | ||
| 43 | + | ||
| 44 | +| Event | Runs | Tools concerned | Can | | ||
| 45 | +|-------|------|-----------------|-----| | ||
| 46 | +| `preGeneration` | once per question, before the first call to the model | — | rewrite the question, add context, stop the turn | | ||
| 47 | +| `preToolCall` | before each tool call, before the tool's own consent prompt | built-in and MCP, filtered by `matcher` | deny the call, replace its arguments | | ||
| 48 | +| `postToolCall` | after each tool call that ran | built-in and MCP, filtered by `matcher` | add context after the tool's output | | ||
| 49 | +| `postGeneration` | once the turn is over: answered, failed, cancelled or stopped by a hook | — | observe only | | ||
| 50 | + | ||
| 51 | +All four run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`, and an agent started by `/ask`, `ask_agent` or `mm --ask` (with the hooks of **its own** configuration file). A `preGeneration` hook does not run on a context-compression summary. | ||
| 52 | + | ||
| 53 | +## Execution | ||
| 54 | + | ||
| 55 | +| Aspect | Value | | ||
| 56 | +|--------|-------| | ||
| 57 | +| Command | `sh -c "<command>"` | | ||
| 58 | +| Working directory | the session's: the directory `mm` runs in, or the editor session's `cwd` over ACP | | ||
| 59 | +| Environment | `mm`'s own, plus the two variables below | | ||
| 60 | +| stdin | one JSON object ([Input](#input)), then end of file | | ||
| 61 | +| stdout | empty, or one JSON object ([Reply](#reply)); at most 1 MiB | | ||
| 62 | +| stderr | quoted (first 300 bytes) in the failure line when the run fails; otherwise ignored | | ||
| 63 | +| Concurrency | hooks of one event run one after the other; `preToolCall`/`postToolCall` may run concurrently for two tool calls of one model reply | | ||
| 64 | + | ||
| 65 | +### Environment variables | ||
| 66 | + | ||
| 67 | +| Variable | Value | | ||
| 68 | +|----------|-------| | ||
| 69 | +| `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall` or `postGeneration` | | ||
| 70 | +| `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook | | ||
| 71 | + | ||
| 72 | +## Input | ||
| 73 | + | ||
| 74 | +The JSON object written on the hook's stdin. | ||
| 75 | + | ||
| 76 | +| Field | Type | Events | Description | | ||
| 77 | +|-------|------|--------|-------------| | ||
| 78 | +| `event` | string | all | the event's name | | ||
| 79 | +| `cwd` | string | all | the session's working directory, absolute | | ||
| 80 | +| `model` | string | all | the model reference, `<provider>/<model>` | | ||
| 81 | +| `prompt` | string | `preGeneration`, `postGeneration` | the question as typed — `@path` and `$skill` lines included. In a chain, the question as the previous hook left it. In `postGeneration`: the question the model read, or the marker `[prompt blocked by a hook: <reason>]` when a hook stopped the turn. | | ||
| 82 | +| `tool` | object | `preToolCall`, `postToolCall` | the tool call: `id` (the call's reference, may be empty), `name`, `input` (the arguments, as the model sent them or as the previous hook replaced them) | | ||
| 83 | +| `output` | string | `postToolCall` | the tool's output, as the model will read it (JSON when the tool returns something other than text) | | ||
| 84 | +| `answer` | string | `postGeneration` | the text of the last answer; empty when there is none | | ||
| 85 | +| `error` | string | `postGeneration` | the turn's error; absent when the turn succeeded | | ||
| 86 | + | ||
| 87 | +Empty fields are omitted. | ||
| 88 | + | ||
| 89 | +### Examples | ||
| 90 | + | ||
| 91 | +```json | ||
| 92 | +{"event":"preGeneration","cwd":"/home/me/project","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"} | ||
| 93 | +``` | ||
| 94 | + | ||
| 95 | +```json | ||
| 96 | +{"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}} | ||
| 97 | +``` | ||
| 98 | + | ||
| 99 | +## Reply | ||
| 100 | + | ||
| 101 | +The JSON object a hook may write on stdout. **An empty stdout (or whitespace only) is `{"action":"continue"}`.** Every field is optional. | ||
| 102 | + | ||
| 103 | +| Field | Type | Read on | Description | | ||
| 104 | +|-------|------|---------|-------------| | ||
| 105 | +| `action` | string | all | `continue` (default, also when absent), `stop`, `deny` or `error` — see below | | ||
| 106 | +| `prompt` | string | `preGeneration` | replaces the question. Absent = unchanged; `""` = an empty question. | | ||
| 107 | +| `context` | string | `preGeneration`, `postToolCall` | text for the model. `preGeneration`: appended to the question. `postToolCall`: appended to the tool's output. Each one is introduced by the line `[context added by a hook]`. Blank = ignored. | | ||
| 108 | +| `input` | any JSON value | `preToolCall` | replaces the tool call's arguments. Absent = unchanged. | | ||
| 109 | +| `reason` | string | `preGeneration` (`stop`), `preToolCall` (`deny`) | why. | | ||
| 110 | +| `response` | string | `preGeneration` (`stop`) | what the user reads instead of an answer. Absent or blank: `⛔ This request was blocked by a hook: <reason>`. | | ||
| 111 | +| `error` | string | all (`error`) | the hook's own failure message. | | ||
| 112 | + | ||
| 113 | +Fields read on another event are ignored. | ||
| 114 | + | ||
| 115 | +### Actions | ||
| 116 | + | ||
| 117 | +| Action | Allowed on | Effect | | ||
| 118 | +|--------|-----------|--------| | ||
| 119 | +| `continue` | all | the turn goes on, with this hook's `prompt`, `context` and `input` applied | | ||
| 120 | +| `stop` | `preGeneration` | the turn ends without calling the model; the following hooks of the event do not run | | ||
| 121 | +| `deny` | `preToolCall` | the tool does not run; the following hooks of the event do not run | | ||
| 122 | +| `error` | all | a failure, handled according to `onError` | | ||
| 123 | +| anything else, or `stop`/`deny` on another event | — | a failure: `action "<action>" is not valid for <event>` | | ||
| 124 | + | ||
| 125 | +## Effects on the turn | ||
| 126 | + | ||
| 127 | +### `preGeneration` | ||
| 128 | + | ||
| 129 | +| Decision | The model reads | The history keeps | The user sees | | ||
| 130 | +|----------|-----------------|-------------------|---------------| | ||
| 131 | +| no change | the question | the question | — | | ||
| 132 | +| `prompt` and/or `context` | the rewritten question, then each context | the rewritten question (the front end's own copy is not changed during the turn) | — | | ||
| 133 | +| `stop` | nothing: the model is not called | `[prompt blocked by a hook: <reason>]` (or `[prompt blocked by a hook]`) followed by the `response` as the answer | the `response`, as the answer | | ||
| 134 | + | ||
| 135 | +A chain: each hook reads the `prompt` the previous one left; contexts accumulate in hook order. | ||
| 136 | + | ||
| 137 | +### `preToolCall` | ||
| 138 | + | ||
| 139 | +| Decision | The tool | The model reads | The user sees | | ||
| 140 | +|----------|----------|-----------------|---------------| | ||
| 141 | +| no change | runs with the model's arguments | the output | the usual tool line | | ||
| 142 | +| `input` | runs with the replaced arguments | the output | the usual tool line (with the replaced arguments) | | ||
| 143 | +| `deny` | does not run, and does not ask for consent | `[denied by a hook: <reason>]` as the tool's output | line mode: `🪝 <tool> [denied by a hook: <reason>]`; window and editor: a failed tool call titled `<tool> — denied by a hook` | | ||
| 144 | + | ||
| 145 | +The history keeps the tool call as the model wrote it, even when `input` replaced its arguments. | ||
| 146 | + | ||
| 147 | +### `postToolCall` | ||
| 148 | + | ||
| 149 | +`context` from every matching hook is joined and appended to the output: `<output>\n\n[context added by a hook]\n<context>`. The tool's output shown to the user is unchanged. | ||
| 150 | + | ||
| 151 | +### `postGeneration` | ||
| 152 | + | ||
| 153 | +No effect on the turn. The reply is decoded (an invalid one is a failure) and otherwise ignored. | ||
| 154 | + | ||
| 155 | +## Failures | ||
| 156 | + | ||
| 157 | +A run fails when: | ||
| 158 | + | ||
| 159 | +| Failure | Report | | ||
| 160 | +|---------|--------| | ||
| 161 | +| the command exits with a non-zero status | `exit status <n>: <stderr>` | | ||
| 162 | +| it runs longer than `timeout` | `timed out after <timeout>` | | ||
| 163 | +| the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` | | ||
| 164 | +| stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` | | ||
| 165 | +| stdout is not JSON | `invalid JSON on stdout: …` | | ||
| 166 | +| `action` is `error` | the reply's `error`, or `the hook reported an error` | | ||
| 167 | +| `action` is unknown or not allowed on the event | `action "<action>" is not valid for <event>` | | ||
| 168 | + | ||
| 169 | +`postGeneration` is not cancelled with the turn: it runs after a cancellation too (Escape, Ctrl-C, `/abort`), bounded by its `timeout`. | ||
| 170 | + | ||
| 171 | +What follows depends on `onError`: | ||
| 172 | + | ||
| 173 | +| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` | | ||
| 174 | +|-----------|-----------------|---------------|----------------------------------| | ||
| 175 | +| `fail` | report line, turn stopped (`response`: `⛔ This request was blocked by a hook: hook failed: …`) | report line, call denied (`reason`: `hook failed: …`) | report line, carry on | | ||
| 176 | +| `warn` | report line, next hook | report line, next hook | report line, carry on | | ||
| 177 | +| `ignore` | next hook, silently | next hook, silently | carry on, silently | | ||
| 178 | + | ||
| 179 | +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. | ||
| 180 | + | ||
| 181 | +## Start-up banner | ||
| 182 | + | ||
| 183 | +When at least one hook is declared, the banner ends with ` | hooks: <event>×<count> …`, one entry per event that has hooks, in the order `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`: | ||
| 184 | + | ||
| 185 | +``` | ||
| 186 | +mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1 | ||
| 187 | +``` | ||
| 188 | + | ||
| 189 | +## Validation errors | ||
| 190 | + | ||
| 191 | +Reported at start-up as `[config error: <path>: <message>]`, exit code 1. | ||
| 192 | + | ||
| 193 | +| Message | Cause | | ||
| 194 | +|---------|-------| | ||
| 195 | +| `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) | | ||
| 196 | +| `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one | | ||
| 197 | +| `hooks.<event>[N]: matcher is only for preToolCall and postToolCall` | a `matcher` on `preGeneration` or `postGeneration` | | ||
| 198 | +| `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile | | ||
| 199 | +| `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` | | ||
| 200 | +| `hooks.<event>[N]: onError must be "fail", "warn" or "ignore"` | any other `onError` | | ||
| 201 | + | ||
| 202 | +## See also | ||
| 203 | + | ||
| 204 | +- [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) | ||
| 205 | +- [Hooks — explanation](../explanation/hooks.md) | ||
| 206 | +- The demo: `demos/hooks/` (four Node.js hooks) | ||
modified
docs/fr/README.md +3 -0 | @@ -35,6 +35,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 35 | 35 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) |
| 36 | 36 | - [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 |
| 37 | 37 | - [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 |
| 38 | +- [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 | |
| 38 | 39 | |
| 39 | 40 | ### Guides pratiques MCP (Model Context Protocol) |
| 40 | 41 | - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md) |
| @@ -48,6 +49,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 48 | 49 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation |
| 49 | 50 | - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` |
| 50 | 51 | - [Format d'un fichier de skill](reference/skill-format.md) |
| 52 | +- [Les hooks](reference/hooks.md) — les quatre événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation | |
| 51 | 53 | - [Surface Agent Client Protocol](reference/acp.md) |
| 52 | 54 | |
| 53 | 55 | ### Référence MCP (Model Context Protocol) |
| @@ -69,6 +71,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 69 | 71 | - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse |
| 70 | 72 | - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur |
| 71 | 73 | - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP |
| 74 | +- [Les hooks](explanation/hooks.md) — pourquoi des commandes externes, pourquoi ils se branchent sur `engine.Generate` et `WrapTool`, pourquoi une question arrêtée laisse un marqueur, ce que le ticket 36 a laissé de côté | |
| 72 | 75 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 73 | 76 | |
| 74 | 77 | ### Explication MCP (Model Context Protocol) |
| @@ -35,6 +35,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 35 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) | 35 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) |
| 36 | - [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 | 36 | - [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 |
| 37 | - [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 | 37 | - [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 |
| 38 | +- [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 | ||
| 38 | 39 | ||
| 39 | ### Guides pratiques MCP (Model Context Protocol) | 40 | ### Guides pratiques MCP (Model Context Protocol) |
| 40 | - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md) | 41 | - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md) |
| @@ -48,6 +49,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 48 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation | 49 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation |
| 49 | - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` | 50 | - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` |
| 50 | - [Format d'un fichier de skill](reference/skill-format.md) | 51 | - [Format d'un fichier de skill](reference/skill-format.md) |
| 52 | +- [Les hooks](reference/hooks.md) — les quatre événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation | ||
| 51 | - [Surface Agent Client Protocol](reference/acp.md) | 53 | - [Surface Agent Client Protocol](reference/acp.md) |
| 52 | 54 | ||
| 53 | ### Référence MCP (Model Context Protocol) | 55 | ### Référence MCP (Model Context Protocol) |
| @@ -69,6 +71,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 69 | - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse | 71 | - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse |
| 70 | - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur | 72 | - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur |
| 71 | - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP | 73 | - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP |
| 74 | +- [Les hooks](explanation/hooks.md) — pourquoi des commandes externes, pourquoi ils se branchent sur `engine.Generate` et `WrapTool`, pourquoi une question arrêtée laisse un marqueur, ce que le ticket 36 a laissé de côté | ||
| 72 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | 75 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 73 | 76 | ||
| 74 | ### Explication MCP (Model Context Protocol) | 77 | ### Explication MCP (Model Context Protocol) |
modified
docs/fr/explanation/architecture.md +1 -0 | @@ -27,6 +27,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages | ||
| 27 | 27 | | `spinner` | L'indicateur « toujours en cours » sur un terminal. | |
| 28 | 28 | | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. | |
| 29 | 29 | | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). | |
| 30 | +| `hooks` | Le bloc `hooks:` (ticket 36) : lance chaque hook avec `sh -c`, écrit son entrée JSON, lit sa réponse, applique l'ordre de la chaîne et `onError`, et renvoie des décisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). Ni Genkit ni front end : `engine/hooks.go` applique les décisions autour de `Generate` et dans le middleware `WrapTool`, si bien que tous les front ends en profitent. Voir [Les hooks](hooks.md). | | |
| 30 | 31 | | `agent` | Le REPL du terminal. | |
| 31 | 32 | | `acp` | La façade Agent Client Protocol. | |
| 32 | 33 | | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. | |
| @@ -27,6 +27,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages | |||
| 27 | | `spinner` | L'indicateur « toujours en cours » sur un terminal. | | 27 | | `spinner` | L'indicateur « toujours en cours » sur un terminal. | |
| 28 | | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. | | 28 | | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. | |
| 29 | | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). | | 29 | | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). | |
| 30 | +| `hooks` | Le bloc `hooks:` (ticket 36) : lance chaque hook avec `sh -c`, écrit son entrée JSON, lit sa réponse, applique l'ordre de la chaîne et `onError`, et renvoie des décisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). Ni Genkit ni front end : `engine/hooks.go` applique les décisions autour de `Generate` et dans le middleware `WrapTool`, si bien que tous les front ends en profitent. Voir [Les hooks](hooks.md). | | ||
| 30 | | `agent` | Le REPL du terminal. | | 31 | | `agent` | Le REPL du terminal. | |
| 31 | | `acp` | La façade Agent Client Protocol. | | 32 | | `acp` | La façade Agent Client Protocol. | |
| 32 | | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. | | 33 | | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. | |
added
docs/fr/explanation/hooks.md +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +# Les hooks — explication | |
| 2 | + | |
| 3 | +## De quoi s'agit-il ? | |
| 4 | + | |
| 5 | +Un hook est une commande que `mm` lance à quatre moments d'un tour : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), et une fois le tour terminé (`postGeneration`). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de `mm`. | |
| 6 | + | |
| 7 | +La conception part du ticket 36, qui explorait un système bien plus vaste. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté. | |
| 8 | + | |
| 9 | +## Pourquoi des commandes externes | |
| 10 | + | |
| 11 | +La première exigence du ticket était qu'on puisse écrire un hook dans **n'importe quel langage**. Cela exclut une interface de plugins Go (il faudrait recompiler, et le paquet `plugin` de Go n'existe pas sur toutes les plateformes pour lesquelles `mm` est construit) et un interpréteur embarqué (il imposerait un langage à tout le monde). Un processus qui lit stdin et écrit sur stdout est la seule interface que tous les langages ont. | |
| 12 | + | |
| 13 | +Elle a des coûts, acceptés en connaissance de cause : | |
| 14 | + | |
| 15 | +- **Un lancement de processus par événement.** Un hook `preToolCall` s'exécute à chaque appel d'outil ; `node` met quelques dizaines de millisecondes à démarrer. Face à un modèle local qui passe des secondes sur chaque réponse, c'est négligeable. C'est aussi pourquoi chaque hook a un `timeout`, et pourquoi la valeur par défaut est courte (10 s) : un hook est au milieu du tour, et un hook lent se sent tout de suite. | |
| 16 | +- **Le JSON comme contrat.** L'entrée et la réponse sont petites et plates, pour qu'un hook en shell avec `jq` soit aussi simple à écrire qu'en Python ou en Node. | |
| 17 | + | |
| 18 | +On retrouve la même forme ailleurs dans l'écosystème (hooks d'éditeurs et d'agents, hooks git et leurs arguments) : qui en a écrit un reconnaît l'autre. | |
| 19 | + | |
| 20 | +## Où ils se branchent | |
| 21 | + | |
| 22 | +Chaque front end — le mode ligne, la fenêtre, la façade ACP, `-p` — confie son tour à une seule fonction, `engine.Generate`. Et à l'intérieur, le middleware de Genkit a déjà un point d'accroche `WrapTool` qui voit **chaque** appel d'outil, intégré ou MCP, avant qu'il s'exécute. Les hooks s'exécutent à ces deux endroits : | |
| 23 | + | |
| 24 | +``` | |
| 25 | +front end ──▶ engine.Generate | |
| 26 | + ├─ preGeneration (la question, avant le premier appel au modèle) | |
| 27 | + ├─ modèle ⇄ outils ── WrapTool | |
| 28 | + │ ├─ preToolCall (avant l'outil, et sa demande d'autorisation) | |
| 29 | + │ └─ postToolCall (après l'outil) | |
| 30 | + └─ postGeneration (le tour est terminé, quelle qu'en soit l'issue) | |
| 31 | +``` | |
| 32 | + | |
| 33 | +Les placer là plutôt que dans chaque front end, c'est une seule implémentation, un seul jeu de tests, et aucun front end qui les oublie. Un nouveau front end a les hooks sans rien faire. | |
| 34 | + | |
| 35 | +`preToolCall` s'exécute **avant** la demande d'autorisation de l'outil. Un appel refusé ne demande donc jamais rien à l'utilisateur ; et un garde continue de fonctionner avec `yolo: true`, c'est-à-dire justement quand il compte le plus. | |
| 36 | + | |
| 37 | +Le paquet `internal/hooks` lance les commandes et transforme leurs réponses en décisions ; il ne sait rien de Genkit ni des front ends. `internal/engine/hooks.go` applique ces décisions aux messages et les montre : comme réponse, comme appel d'outil en échec, ou comme remarque — une ligne dans le terminal, une ligne grisée dans la fenêtre, les journaux de l'éditeur en ACP. | |
| 38 | + | |
| 39 | +## Les décisions à connaître | |
| 40 | + | |
| 41 | +**Une question arrêtée laisse un marqueur, pas la question.** La raison évidente d'arrêter une question, c'est qu'elle contient quelque chose qui ne doit pas atteindre le modèle : un mot de passe, une clé. La garder dans l'historique l'enverrait au tour suivant. L'historique garde donc `[prompt blocked by a hook: <raison>]` suivi de la réponse du hook — assez pour que le modèle comprenne ce qui s'est passé, rien qu'il ne doive pas lire. Le premier essai réel de la démo a trouvé une seconde fuite : le hook d'audit sur `postGeneration` recevait la question bloquée et écrivait le secret dans son journal. Les hooks `post*` lisent désormais le marqueur, eux aussi. | |
| 42 | + | |
| 43 | +**Les échecs suivent `onError`, et la valeur par défaut est `warn`.** Un hook qui plante est signalé, et le tour continue : un script de journalisation cassé ne doit pas priver l'utilisateur de son agent. Un garde, lui, doit déclarer `onError: fail` — un garde qui ne peut pas s'exécuter ne doit pas laisser passer l'appel. Les deux besoins sont opposés, d'où un choix par hook. | |
| 44 | + | |
| 45 | +**Un nom d'événement inconnu est une erreur.** YAML ignore les clés qu'il ne connaît pas ; `preToolcall:` aurait été un garde qui ne s'exécute jamais, sans rien dire. Refuser de démarrer est l'échec le plus sûr. | |
| 46 | + | |
| 47 | +**Un appel d'outil refusé répond au modèle sous forme de texte.** Comme tout échec d'outil dans `mm`, le refus est la sortie de l'outil : `[denied by a hook: <raison>]`. Le modèle peut prévenir l'utilisateur ou s'y prendre autrement, au lieu que le tour se termine sur une erreur. | |
| 48 | + | |
| 49 | +**`postGeneration` n'est pas annulé avec le tour.** Un journal d'audit qui perd les tours interrompus par l'utilisateur, c'est le monde à l'envers. Le hook s'exécute aussi après une annulation, borné par son propre délai. | |
| 50 | + | |
| 51 | +**Des arguments d'outil remplacés ne sont pas réécrits dans l'historique.** L'outil s'exécute avec les arguments du hook, mais l'historique garde ce que le modèle a demandé. La requête du modèle est le compte rendu fidèle de ce qu'il a fait ; un fichier de session qui montrerait des arguments que le modèle n'a jamais écrits tromperait celui qui le lit plus tard. | |
| 52 | + | |
| 53 | +## Les alternatives écartées | |
| 54 | + | |
| 55 | +Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi : | |
| 56 | + | |
| 57 | +- **Renvoyer le tableau `messages` entier.** Le premier protocole du ticket laissait un hook réécrire la conversation. C'est fragile — un hook pourrait supprimer le prompt système ou réécrire les tours passés, et chaque hook devrait faire l'aller-retour de tout l'historique — et les quatre besoins (détecter, réécrire, ajouter du contexte, arrêter) sont couverts par `prompt`, `context` et `action`. | |
| 58 | +- **Des priorités numériques.** L'ordre de la liste YAML est l'ordre d'exécution. Un champ de priorité ajoute un second ordre à garder en tête, sans rien apporter quand la liste est courte. | |
| 59 | +- **Un mode `async`.** Un hook de journalisation est rapide ; quand il ne l'est pas, `onError: ignore` avec un `timeout` court le borne. Un mode en arrière-plan demanderait une file d'attente, un vidage à l'arrêt et une façon de signaler les échecs tardifs. | |
| 60 | +- **`allowedPaths` / `allowedExtensions`.** Le fichier YAML lance déjà des serveurs MCP et donne `bash` au modèle. On confie à qui l'écrit bien plus qu'un chemin de hook ; une liste blanche ne protégerait rien. | |
| 61 | +- **Les hooks de cycle de vie, de session et de front end** (démarrage, configuration chargée, arrêt, fenêtre ouverte, éditeur connecté). Aucun cas d'usage ne les a encore demandés. On pourra les ajouter plus tard comme nouveaux événements, sans changer le protocole. | |
| 62 | + | |
| 63 | +## Les liens avec le reste | |
| 64 | + | |
| 65 | +- **Yolo et autorisations.** Les hooks et `yolo: false` se complètent : un hook décide selon une règle, sans humain ; l'autorisation demande à un humain, au cas par cas. Le hook passe en premier. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). | |
| 66 | +- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct avec son propre fichier de configuration : il exécute **ses propres** hooks, pas ceux de l'agent qui l'interroge. | |
| 67 | +- **La compression du contexte** demande un résumé au modèle par un autre chemin que `Generate` ; les hooks ne s'y exécutent pas. | |
| 68 | + | |
| 69 | +Voir la [référence des hooks](../reference/hooks.md) pour chaque champ, et [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un. | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +# Les hooks — explication | ||
| 2 | + | ||
| 3 | +## De quoi s'agit-il ? | ||
| 4 | + | ||
| 5 | +Un hook est une commande que `mm` lance à quatre moments d'un tour : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), et une fois le tour terminé (`postGeneration`). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de `mm`. | ||
| 6 | + | ||
| 7 | +La conception part du ticket 36, qui explorait un système bien plus vaste. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté. | ||
| 8 | + | ||
| 9 | +## Pourquoi des commandes externes | ||
| 10 | + | ||
| 11 | +La première exigence du ticket était qu'on puisse écrire un hook dans **n'importe quel langage**. Cela exclut une interface de plugins Go (il faudrait recompiler, et le paquet `plugin` de Go n'existe pas sur toutes les plateformes pour lesquelles `mm` est construit) et un interpréteur embarqué (il imposerait un langage à tout le monde). Un processus qui lit stdin et écrit sur stdout est la seule interface que tous les langages ont. | ||
| 12 | + | ||
| 13 | +Elle a des coûts, acceptés en connaissance de cause : | ||
| 14 | + | ||
| 15 | +- **Un lancement de processus par événement.** Un hook `preToolCall` s'exécute à chaque appel d'outil ; `node` met quelques dizaines de millisecondes à démarrer. Face à un modèle local qui passe des secondes sur chaque réponse, c'est négligeable. C'est aussi pourquoi chaque hook a un `timeout`, et pourquoi la valeur par défaut est courte (10 s) : un hook est au milieu du tour, et un hook lent se sent tout de suite. | ||
| 16 | +- **Le JSON comme contrat.** L'entrée et la réponse sont petites et plates, pour qu'un hook en shell avec `jq` soit aussi simple à écrire qu'en Python ou en Node. | ||
| 17 | + | ||
| 18 | +On retrouve la même forme ailleurs dans l'écosystème (hooks d'éditeurs et d'agents, hooks git et leurs arguments) : qui en a écrit un reconnaît l'autre. | ||
| 19 | + | ||
| 20 | +## Où ils se branchent | ||
| 21 | + | ||
| 22 | +Chaque front end — le mode ligne, la fenêtre, la façade ACP, `-p` — confie son tour à une seule fonction, `engine.Generate`. Et à l'intérieur, le middleware de Genkit a déjà un point d'accroche `WrapTool` qui voit **chaque** appel d'outil, intégré ou MCP, avant qu'il s'exécute. Les hooks s'exécutent à ces deux endroits : | ||
| 23 | + | ||
| 24 | +``` | ||
| 25 | +front end ──▶ engine.Generate | ||
| 26 | + ├─ preGeneration (la question, avant le premier appel au modèle) | ||
| 27 | + ├─ modèle ⇄ outils ── WrapTool | ||
| 28 | + │ ├─ preToolCall (avant l'outil, et sa demande d'autorisation) | ||
| 29 | + │ └─ postToolCall (après l'outil) | ||
| 30 | + └─ postGeneration (le tour est terminé, quelle qu'en soit l'issue) | ||
| 31 | +``` | ||
| 32 | + | ||
| 33 | +Les placer là plutôt que dans chaque front end, c'est une seule implémentation, un seul jeu de tests, et aucun front end qui les oublie. Un nouveau front end a les hooks sans rien faire. | ||
| 34 | + | ||
| 35 | +`preToolCall` s'exécute **avant** la demande d'autorisation de l'outil. Un appel refusé ne demande donc jamais rien à l'utilisateur ; et un garde continue de fonctionner avec `yolo: true`, c'est-à-dire justement quand il compte le plus. | ||
| 36 | + | ||
| 37 | +Le paquet `internal/hooks` lance les commandes et transforme leurs réponses en décisions ; il ne sait rien de Genkit ni des front ends. `internal/engine/hooks.go` applique ces décisions aux messages et les montre : comme réponse, comme appel d'outil en échec, ou comme remarque — une ligne dans le terminal, une ligne grisée dans la fenêtre, les journaux de l'éditeur en ACP. | ||
| 38 | + | ||
| 39 | +## Les décisions à connaître | ||
| 40 | + | ||
| 41 | +**Une question arrêtée laisse un marqueur, pas la question.** La raison évidente d'arrêter une question, c'est qu'elle contient quelque chose qui ne doit pas atteindre le modèle : un mot de passe, une clé. La garder dans l'historique l'enverrait au tour suivant. L'historique garde donc `[prompt blocked by a hook: <raison>]` suivi de la réponse du hook — assez pour que le modèle comprenne ce qui s'est passé, rien qu'il ne doive pas lire. Le premier essai réel de la démo a trouvé une seconde fuite : le hook d'audit sur `postGeneration` recevait la question bloquée et écrivait le secret dans son journal. Les hooks `post*` lisent désormais le marqueur, eux aussi. | ||
| 42 | + | ||
| 43 | +**Les échecs suivent `onError`, et la valeur par défaut est `warn`.** Un hook qui plante est signalé, et le tour continue : un script de journalisation cassé ne doit pas priver l'utilisateur de son agent. Un garde, lui, doit déclarer `onError: fail` — un garde qui ne peut pas s'exécuter ne doit pas laisser passer l'appel. Les deux besoins sont opposés, d'où un choix par hook. | ||
| 44 | + | ||
| 45 | +**Un nom d'événement inconnu est une erreur.** YAML ignore les clés qu'il ne connaît pas ; `preToolcall:` aurait été un garde qui ne s'exécute jamais, sans rien dire. Refuser de démarrer est l'échec le plus sûr. | ||
| 46 | + | ||
| 47 | +**Un appel d'outil refusé répond au modèle sous forme de texte.** Comme tout échec d'outil dans `mm`, le refus est la sortie de l'outil : `[denied by a hook: <raison>]`. Le modèle peut prévenir l'utilisateur ou s'y prendre autrement, au lieu que le tour se termine sur une erreur. | ||
| 48 | + | ||
| 49 | +**`postGeneration` n'est pas annulé avec le tour.** Un journal d'audit qui perd les tours interrompus par l'utilisateur, c'est le monde à l'envers. Le hook s'exécute aussi après une annulation, borné par son propre délai. | ||
| 50 | + | ||
| 51 | +**Des arguments d'outil remplacés ne sont pas réécrits dans l'historique.** L'outil s'exécute avec les arguments du hook, mais l'historique garde ce que le modèle a demandé. La requête du modèle est le compte rendu fidèle de ce qu'il a fait ; un fichier de session qui montrerait des arguments que le modèle n'a jamais écrits tromperait celui qui le lit plus tard. | ||
| 52 | + | ||
| 53 | +## Les alternatives écartées | ||
| 54 | + | ||
| 55 | +Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi : | ||
| 56 | + | ||
| 57 | +- **Renvoyer le tableau `messages` entier.** Le premier protocole du ticket laissait un hook réécrire la conversation. C'est fragile — un hook pourrait supprimer le prompt système ou réécrire les tours passés, et chaque hook devrait faire l'aller-retour de tout l'historique — et les quatre besoins (détecter, réécrire, ajouter du contexte, arrêter) sont couverts par `prompt`, `context` et `action`. | ||
| 58 | +- **Des priorités numériques.** L'ordre de la liste YAML est l'ordre d'exécution. Un champ de priorité ajoute un second ordre à garder en tête, sans rien apporter quand la liste est courte. | ||
| 59 | +- **Un mode `async`.** Un hook de journalisation est rapide ; quand il ne l'est pas, `onError: ignore` avec un `timeout` court le borne. Un mode en arrière-plan demanderait une file d'attente, un vidage à l'arrêt et une façon de signaler les échecs tardifs. | ||
| 60 | +- **`allowedPaths` / `allowedExtensions`.** Le fichier YAML lance déjà des serveurs MCP et donne `bash` au modèle. On confie à qui l'écrit bien plus qu'un chemin de hook ; une liste blanche ne protégerait rien. | ||
| 61 | +- **Les hooks de cycle de vie, de session et de front end** (démarrage, configuration chargée, arrêt, fenêtre ouverte, éditeur connecté). Aucun cas d'usage ne les a encore demandés. On pourra les ajouter plus tard comme nouveaux événements, sans changer le protocole. | ||
| 62 | + | ||
| 63 | +## Les liens avec le reste | ||
| 64 | + | ||
| 65 | +- **Yolo et autorisations.** Les hooks et `yolo: false` se complètent : un hook décide selon une règle, sans humain ; l'autorisation demande à un humain, au cas par cas. Le hook passe en premier. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). | ||
| 66 | +- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct avec son propre fichier de configuration : il exécute **ses propres** hooks, pas ceux de l'agent qui l'interroge. | ||
| 67 | +- **La compression du contexte** demande un résumé au modèle par un autre chemin que `Generate` ; les hooks ne s'y exécutent pas. | ||
| 68 | + | ||
| 69 | +Voir la [référence des hooks](../reference/hooks.md) pour chaque champ, et [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un. | ||
modified
docs/fr/explanation/yolo-and-permissions.md +1 -0 | @@ -51,3 +51,4 @@ Un raffinement garde le même principe : le fichier de l'utilisateur peut donner | ||
| 51 | 51 | - Comment l'activer : [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |
| 52 | 52 | - Le chemin d'événements que partagent les façades (`ui.Sink`, `Allow`) : [Architecture](architecture.md). |
| 53 | 53 | - Interroger un autre agent : [Interroger un autre agent](asking-another-agent.md). |
| 54 | +- Décider selon une règle plutôt qu'en demandant : [Les hooks](hooks.md) — un hook `preToolCall` s'exécute avant la demande d'autorisation, si bien qu'un appel qu'il refuse n'est jamais soumis à l'utilisateur. | |
| @@ -51,3 +51,4 @@ Un raffinement garde le même principe : le fichier de l'utilisateur peut donner | |||
| 51 | - Comment l'activer : [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). | 51 | - Comment l'activer : [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |
| 52 | - Le chemin d'événements que partagent les façades (`ui.Sink`, `Allow`) : [Architecture](architecture.md). | 52 | - Le chemin d'événements que partagent les façades (`ui.Sink`, `Allow`) : [Architecture](architecture.md). |
| 53 | - Interroger un autre agent : [Interroger un autre agent](asking-another-agent.md). | 53 | - Interroger un autre agent : [Interroger un autre agent](asking-another-agent.md). |
| 54 | +- Décider selon une règle plutôt qu'en demandant : [Les hooks](hooks.md) — un hook `preToolCall` s'exécute avant la demande d'autorisation, si bien qu'un appel qu'il refuse n'est jamais soumis à l'utilisateur. | ||
modified
docs/fr/how-to/confirm-tool-calls.md +1 -0 | @@ -55,3 +55,4 @@ Par défaut, mini-me tourne en **mode yolo** : `bash`, les écritures et les mod | ||
| 55 | 55 | - Chaque clé et sa valeur par défaut : [référence de la configuration](../reference/configuration.md) (`yolo`, `permissions`). |
| 56 | 56 | - Ce que montre chaque façade : [référence de la CLI](../reference/cli.md), [référence ACP](../reference/acp.md#requêtes-sessionrequest_permission-envoyées). |
| 57 | 57 | - Pourquoi yolo est la valeur par défaut et pourquoi l'agent qui interroge l'emporte : [Le mode yolo et les permissions](../explanation/yolo-and-permissions.md). |
| 58 | +- Refuser certains appels automatiquement, selon une règle, sans rien demander : [Protéger l'agent avec des hooks](guard-the-agent-with-hooks.md). | |
| @@ -55,3 +55,4 @@ Par défaut, mini-me tourne en **mode yolo** : `bash`, les écritures et les mod | |||
| 55 | - Chaque clé et sa valeur par défaut : [référence de la configuration](../reference/configuration.md) (`yolo`, `permissions`). | 55 | - Chaque clé et sa valeur par défaut : [référence de la configuration](../reference/configuration.md) (`yolo`, `permissions`). |
| 56 | - Ce que montre chaque façade : [référence de la CLI](../reference/cli.md), [référence ACP](../reference/acp.md#requêtes-sessionrequest_permission-envoyées). | 56 | - Ce que montre chaque façade : [référence de la CLI](../reference/cli.md), [référence ACP](../reference/acp.md#requêtes-sessionrequest_permission-envoyées). |
| 57 | - Pourquoi yolo est la valeur par défaut et pourquoi l'agent qui interroge l'emporte : [Le mode yolo et les permissions](../explanation/yolo-and-permissions.md). | 57 | - Pourquoi yolo est la valeur par défaut et pourquoi l'agent qui interroge l'emporte : [Le mode yolo et les permissions](../explanation/yolo-and-permissions.md). |
| 58 | +- Refuser certains appels automatiquement, selon une règle, sans rien demander : [Protéger l'agent avec des hooks](guard-the-agent-with-hooks.md). | ||
added
docs/fr/how-to/guard-the-agent-with-hooks.md +100 -0 | new file mode 100644 | ||
| @@ -0,0 +1,100 @@ | ||
| 1 | +# Protéger l'agent avec des hooks | |
| 2 | + | |
| 3 | +Ce guide montre comment lancer votre propre commande à des moments précis d'un tour : pour refuser un appel d'outil dangereux, arrêter une question avant que le modèle la voie, ajouter du contexte, ou journaliser ce que fait l'agent. Il suppose que vous avez un agent qui fonctionne, et un langage dans lequel écrire un petit script. Les exemples sont en Node.js ; tout programme qui lit stdin et écrit sur stdout convient. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +### 1. Choisir l'événement | |
| 8 | + | |
| 9 | +| Vous voulez… | Événement | | |
| 10 | +|--------------|-----------| | |
| 11 | +| refuser certains appels d'outil, ou changer leurs arguments | `preToolCall` | | |
| 12 | +| arrêter ou réécrire une question, ou lui ajouter du contexte | `preGeneration` | | |
| 13 | +| ajouter une note après la sortie d'un outil | `postToolCall` | | |
| 14 | +| journaliser ou notifier, sans rien changer | `postToolCall`, `postGeneration` | | |
| 15 | + | |
| 16 | +### 2. Écrire le hook | |
| 17 | + | |
| 18 | +Le hook lit un objet JSON sur stdin et, quand il a une décision, en écrit un sur stdout. Ne rien écrire laisse le tour continuer. Celui-ci refuse `git push --force` : | |
| 19 | + | |
| 20 | +```js | |
| 21 | +// hooks/no-force-push.js | |
| 22 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | |
| 23 | +const command = input.tool.input.command || ""; | |
| 24 | + | |
| 25 | +if (/\bgit\s+push\b.*(--force|-f\b)/.test(command)) { | |
| 26 | + console.log(JSON.stringify({ action: "deny", reason: "force-push is not allowed in this project" })); | |
| 27 | +} | |
| 28 | +``` | |
| 29 | + | |
| 30 | +La raison est ce que le modèle lit à la place de la sortie de la commande : écrivez-la pour le modèle, afin qu'il puisse prévenir l'utilisateur ou essayer autre chose. | |
| 31 | + | |
| 32 | +### 3. L'essayer sans mm | |
| 33 | + | |
| 34 | +Donnez-lui l'entrée que mm enverrait : | |
| 35 | + | |
| 36 | +```bash | |
| 37 | +echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"git push -f origin main"}}}' \ | |
| 38 | + | node hooks/no-force-push.js | |
| 39 | +``` | |
| 40 | + | |
| 41 | +Vous devez voir : | |
| 42 | + | |
| 43 | +``` | |
| 44 | +{"action":"deny","reason":"force-push is not allowed in this project"} | |
| 45 | +``` | |
| 46 | + | |
| 47 | +Et rien du tout pour `git push origin main`. | |
| 48 | + | |
| 49 | +### 4. Le déclarer | |
| 50 | + | |
| 51 | +Dans le fichier YAML de l'agent, à côté du script : | |
| 52 | + | |
| 53 | +```yaml | |
| 54 | +hooks: | |
| 55 | + preToolCall: | |
| 56 | + - matcher: ^bash$ | |
| 57 | + command: node "$MM_CONFIG_DIR/hooks/no-force-push.js" | |
| 58 | + onError: fail | |
| 59 | +``` | |
| 60 | + | |
| 61 | +- `matcher` limite le hook à l'outil `bash` ; sans lui, le hook s'exécute pour chaque appel d'outil. | |
| 62 | +- `$MM_CONFIG_DIR` est le dossier du fichier YAML : le script est trouvé quel que soit le dossier d'où `mm` est lancé. | |
| 63 | +- `onError: fail` refuse l'appel quand le hook lui-même échoue (Node absent, erreur de syntaxe, délai dépassé). Utilisez-le pour les gardes ; gardez la valeur par défaut, `warn`, pour les hooks dont l'échec ne doit pas gêner. | |
| 64 | + | |
| 65 | +### 5. Lancer l'agent et vérifier | |
| 66 | + | |
| 67 | +```bash | |
| 68 | +mm agent.yaml | |
| 69 | +``` | |
| 70 | + | |
| 71 | +La bannière se termine par les hooks chargés : | |
| 72 | + | |
| 73 | +``` | |
| 74 | +… | yolo: on | hooks: preToolCall×1 | |
| 75 | +``` | |
| 76 | + | |
| 77 | +Demandez quelque chose qui déclenche la règle (`force-push the main branch`). Le mode ligne affiche : | |
| 78 | + | |
| 79 | +``` | |
| 80 | +🪝 bash [denied by a hook: force-push is not allowed in this project] | |
| 81 | +``` | |
| 82 | + | |
| 83 | +et la réponse du modèle explique le refus. | |
| 84 | + | |
| 85 | +## Variantes | |
| 86 | + | |
| 87 | +- **Arrêter une question.** Sur `preGeneration`, lisez `input.prompt` et écrivez `{"action":"stop","reason":"…","response":"…"}`. L'utilisateur lit `response` ; le modèle n'est pas appelé, et la question est remplacée dans l'historique par `[prompt blocked by a hook: <raison>]`. | |
| 88 | +- **Ajouter du contexte.** Sur `preGeneration`, écrivez `{"context":"…"}` : le texte est ajouté après la question, sous `[context added by a hook]`. Sur `postToolCall`, le même champ est ajouté après la sortie de l'outil. | |
| 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`. | |
| 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 | +- **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 | +- **Dans un éditeur** (`mm -acp`), les hooks s'exécutent dans le dossier de travail de la session, et un appel refusé apparaît comme un appel d'outil en échec intitulé `<outil> — denied by a hook`. | |
| 95 | + | |
| 96 | +## Voir aussi | |
| 97 | + | |
| 98 | +- Chaque champ, action, échec et limite : [Référence des hooks](../reference/hooks.md) | |
| 99 | +- Pourquoi les hooks sont des commandes externes, et où ils se branchent : [Les hooks — explication](../explanation/hooks.md) | |
| 100 | +- Un exemple complet avec quatre hooks : `demos/hooks/` | |
| new file mode 100644 | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | +# Protéger l'agent avec des hooks | ||
| 2 | + | ||
| 3 | +Ce guide montre comment lancer votre propre commande à des moments précis d'un tour : pour refuser un appel d'outil dangereux, arrêter une question avant que le modèle la voie, ajouter du contexte, ou journaliser ce que fait l'agent. Il suppose que vous avez un agent qui fonctionne, et un langage dans lequel écrire un petit script. Les exemples sont en Node.js ; tout programme qui lit stdin et écrit sur stdout convient. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +### 1. Choisir l'événement | ||
| 8 | + | ||
| 9 | +| Vous voulez… | Événement | | ||
| 10 | +|--------------|-----------| | ||
| 11 | +| refuser certains appels d'outil, ou changer leurs arguments | `preToolCall` | | ||
| 12 | +| arrêter ou réécrire une question, ou lui ajouter du contexte | `preGeneration` | | ||
| 13 | +| ajouter une note après la sortie d'un outil | `postToolCall` | | ||
| 14 | +| journaliser ou notifier, sans rien changer | `postToolCall`, `postGeneration` | | ||
| 15 | + | ||
| 16 | +### 2. Écrire le hook | ||
| 17 | + | ||
| 18 | +Le hook lit un objet JSON sur stdin et, quand il a une décision, en écrit un sur stdout. Ne rien écrire laisse le tour continuer. Celui-ci refuse `git push --force` : | ||
| 19 | + | ||
| 20 | +```js | ||
| 21 | +// hooks/no-force-push.js | ||
| 22 | +const input = JSON.parse(require("fs").readFileSync(0, "utf8")); | ||
| 23 | +const command = input.tool.input.command || ""; | ||
| 24 | + | ||
| 25 | +if (/\bgit\s+push\b.*(--force|-f\b)/.test(command)) { | ||
| 26 | + console.log(JSON.stringify({ action: "deny", reason: "force-push is not allowed in this project" })); | ||
| 27 | +} | ||
| 28 | +``` | ||
| 29 | + | ||
| 30 | +La raison est ce que le modèle lit à la place de la sortie de la commande : écrivez-la pour le modèle, afin qu'il puisse prévenir l'utilisateur ou essayer autre chose. | ||
| 31 | + | ||
| 32 | +### 3. L'essayer sans mm | ||
| 33 | + | ||
| 34 | +Donnez-lui l'entrée que mm enverrait : | ||
| 35 | + | ||
| 36 | +```bash | ||
| 37 | +echo '{"event":"preToolCall","cwd":"/tmp","tool":{"name":"bash","input":{"command":"git push -f origin main"}}}' \ | ||
| 38 | + | node hooks/no-force-push.js | ||
| 39 | +``` | ||
| 40 | + | ||
| 41 | +Vous devez voir : | ||
| 42 | + | ||
| 43 | +``` | ||
| 44 | +{"action":"deny","reason":"force-push is not allowed in this project"} | ||
| 45 | +``` | ||
| 46 | + | ||
| 47 | +Et rien du tout pour `git push origin main`. | ||
| 48 | + | ||
| 49 | +### 4. Le déclarer | ||
| 50 | + | ||
| 51 | +Dans le fichier YAML de l'agent, à côté du script : | ||
| 52 | + | ||
| 53 | +```yaml | ||
| 54 | +hooks: | ||
| 55 | + preToolCall: | ||
| 56 | + - matcher: ^bash$ | ||
| 57 | + command: node "$MM_CONFIG_DIR/hooks/no-force-push.js" | ||
| 58 | + onError: fail | ||
| 59 | +``` | ||
| 60 | + | ||
| 61 | +- `matcher` limite le hook à l'outil `bash` ; sans lui, le hook s'exécute pour chaque appel d'outil. | ||
| 62 | +- `$MM_CONFIG_DIR` est le dossier du fichier YAML : le script est trouvé quel que soit le dossier d'où `mm` est lancé. | ||
| 63 | +- `onError: fail` refuse l'appel quand le hook lui-même échoue (Node absent, erreur de syntaxe, délai dépassé). Utilisez-le pour les gardes ; gardez la valeur par défaut, `warn`, pour les hooks dont l'échec ne doit pas gêner. | ||
| 64 | + | ||
| 65 | +### 5. Lancer l'agent et vérifier | ||
| 66 | + | ||
| 67 | +```bash | ||
| 68 | +mm agent.yaml | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +La bannière se termine par les hooks chargés : | ||
| 72 | + | ||
| 73 | +``` | ||
| 74 | +… | yolo: on | hooks: preToolCall×1 | ||
| 75 | +``` | ||
| 76 | + | ||
| 77 | +Demandez quelque chose qui déclenche la règle (`force-push the main branch`). Le mode ligne affiche : | ||
| 78 | + | ||
| 79 | +``` | ||
| 80 | +🪝 bash [denied by a hook: force-push is not allowed in this project] | ||
| 81 | +``` | ||
| 82 | + | ||
| 83 | +et la réponse du modèle explique le refus. | ||
| 84 | + | ||
| 85 | +## Variantes | ||
| 86 | + | ||
| 87 | +- **Arrêter une question.** Sur `preGeneration`, lisez `input.prompt` et écrivez `{"action":"stop","reason":"…","response":"…"}`. L'utilisateur lit `response` ; le modèle n'est pas appelé, et la question est remplacée dans l'historique par `[prompt blocked by a hook: <raison>]`. | ||
| 88 | +- **Ajouter du contexte.** Sur `preGeneration`, écrivez `{"context":"…"}` : le texte est ajouté après la question, sous `[context added by a hook]`. Sur `postToolCall`, le même champ est ajouté après la sortie de l'outil. | ||
| 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`. | ||
| 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 | +- **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 | +- **Dans un éditeur** (`mm -acp`), les hooks s'exécutent dans le dossier de travail de la session, et un appel refusé apparaît comme un appel d'outil en échec intitulé `<outil> — denied by a hook`. | ||
| 95 | + | ||
| 96 | +## Voir aussi | ||
| 97 | + | ||
| 98 | +- Chaque champ, action, échec et limite : [Référence des hooks](../reference/hooks.md) | ||
| 99 | +- Pourquoi les hooks sont des commandes externes, et où ils se branchent : [Les hooks — explication](../explanation/hooks.md) | ||
| 100 | +- Un exemple complet avec quatre hooks : `demos/hooks/` | ||
modified
docs/fr/reference/acp.md +2 -1 | @@ -8,7 +8,7 @@ | ||
| 8 | 8 | |--------|--------| |
| 9 | 9 | | Entrée | stdin, un message JSON-RPC par ligne. | |
| 10 | 10 | | Sortie | stdout, messages JSON-RPC uniquement. | |
| 11 | -| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK. | | |
| 11 | +| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK, lignes d'échec des [hooks](hooks.md#échecs) (`🪝 <événement> hook …`). | | |
| 12 | 12 | | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. | |
| 13 | 13 | |
| 14 | 14 | ## Réponse à `initialize` |
| @@ -97,6 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id | ||
| 97 | 97 | | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. | |
| 98 | 98 | | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | |
| 99 | 99 | | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. | |
| 100 | +| `tool_call` puis `tool_call_update` statut `failed`, titre `<outil> — denied by a hook` | Un [hook](hooks.md) `preToolCall` a refusé l'appel : l'outil ne s'est pas exécuté et aucune permission n'a été demandée ; le contenu `text` est `[denied by a hook: <raison>]`. | | |
| 100 | 101 | | `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. | |
| 101 | 102 | |
| 102 | 103 | ## Commandes disponibles |
| @@ -8,7 +8,7 @@ | |||
| 8 | |--------|--------| | 8 | |--------|--------| |
| 9 | | Entrée | stdin, un message JSON-RPC par ligne. | | 9 | | Entrée | stdin, un message JSON-RPC par ligne. | |
| 10 | | Sortie | stdout, messages JSON-RPC uniquement. | | 10 | | Sortie | stdout, messages JSON-RPC uniquement. | |
| 11 | -| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK. | | 11 | +| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK, lignes d'échec des [hooks](hooks.md#échecs) (`🪝 <événement> hook …`). | |
| 12 | | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. | | 12 | | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. | |
| 13 | 13 | ||
| 14 | ## Réponse à `initialize` | 14 | ## Réponse à `initialize` |
| @@ -97,6 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id | |||
| 97 | | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. | | 97 | | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. | |
| 98 | | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | | 98 | | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | |
| 99 | | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. | | 99 | | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. | |
| 100 | +| `tool_call` puis `tool_call_update` statut `failed`, titre `<outil> — denied by a hook` | Un [hook](hooks.md) `preToolCall` a refusé l'appel : l'outil ne s'est pas exécuté et aucune permission n'a été demandée ; le contenu `text` est `[denied by a hook: <raison>]`. | | ||
| 100 | | `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. | | 101 | | `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. | |
| 101 | 102 | ||
| 102 | ## Commandes disponibles | 103 | ## Commandes disponibles |
modified
docs/fr/reference/cli.md +1 -1 | @@ -75,7 +75,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std | ||
| 75 | 75 | | Ligne | Contenu | |
| 76 | 76 | |-------|---------| |
| 77 | 77 | | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré, un fichier de `mmConfigs` introuvable (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), la clé obsolète `permissions`. | |
| 78 | -| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …]` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés, `yolo: on` ou `yolo: off (tools ask)`., et, tant que `trace.enabled` est actif, `trace: <répertoire absolu>` (`trace: <session cwd>/<dir>` en mode ACP). `agents.md:` (avec `agentsMd.enabled`) : `<chemin absolu> (~N tokens)` quand le fichier du projet est lu, `skipped (too long)` au-delà de `agentsMd.maxChars`, `ignored (structured output)` pour un agent avec `structuredOutput`, `<session cwd>/<chemin>` en mode ACP ; absent quand il n'y a pas de fichier. Un fichier lu qui occupe 20 % ou plus d'une fenêtre de contexte connue ajoute l'avertissement `<chemin> is ~N tokens, X % of the W-token context window — every request carries it …`. | | |
| 78 | +| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …][ \| hooks: …]` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés, `yolo: on` ou `yolo: off (tools ask)`., et, tant que `trace.enabled` est actif, `trace: <répertoire absolu>` (`trace: <session cwd>/<dir>` en mode ACP). `hooks:` (quand la configuration en déclare) : `<événement>×<nombre>` par événement qui a des hooks, par exemple `hooks: preGeneration×1 preToolCall×2` — voir [les hooks](hooks.md#bannière-de-démarrage). `agents.md:` (avec `agentsMd.enabled`) : `<chemin absolu> (~N tokens)` quand le fichier du projet est lu, `skipped (too long)` au-delà de `agentsMd.maxChars`, `ignored (structured output)` pour un agent avec `structuredOutput`, `<session cwd>/<chemin>` en mode ACP ; absent quand il n'y a pas de fichier. Un fichier lu qui occupe 20 % ou plus d'une fenêtre de contexte connue ajoute l'avertissement `<chemin> is ~N tokens, X % of the W-token context window — every request carries it …`. | | |
| 79 | 79 | | `Agent ready. Commands: …` | Mode terminal seulement. | |
| 80 | 80 | | `Shortcut: Ctrl+C …` | Mode terminal seulement. | |
| 81 | 81 | | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. | |
| @@ -75,7 +75,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std | |||
| 75 | | Ligne | Contenu | | 75 | | Ligne | Contenu | |
| 76 | |-------|---------| | 76 | |-------|---------| |
| 77 | | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré, un fichier de `mmConfigs` introuvable (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), la clé obsolète `permissions`. | | 77 | | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré, un fichier de `mmConfigs` introuvable (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`), la clé obsolète `permissions`. | |
| 78 | -| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …]` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés, `yolo: on` ou `yolo: off (tools ask)`., et, tant que `trace.enabled` est actif, `trace: <répertoire absolu>` (`trace: <session cwd>/<dir>` en mode ACP). `agents.md:` (avec `agentsMd.enabled`) : `<chemin absolu> (~N tokens)` quand le fichier du projet est lu, `skipped (too long)` au-delà de `agentsMd.maxChars`, `ignored (structured output)` pour un agent avec `structuredOutput`, `<session cwd>/<chemin>` en mode ACP ; absent quand il n'y a pas de fichier. Un fichier lu qui occupe 20 % ou plus d'une fenêtre de contexte connue ajoute l'avertissement `<chemin> is ~N tokens, X % of the W-token context window — every request carries it …`. | | 78 | +| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N \| yolo: …[ \| agents.md: …][ \| trace: …][ \| hooks: …]` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés, `yolo: on` ou `yolo: off (tools ask)`., et, tant que `trace.enabled` est actif, `trace: <répertoire absolu>` (`trace: <session cwd>/<dir>` en mode ACP). `hooks:` (quand la configuration en déclare) : `<événement>×<nombre>` par événement qui a des hooks, par exemple `hooks: preGeneration×1 preToolCall×2` — voir [les hooks](hooks.md#bannière-de-démarrage). `agents.md:` (avec `agentsMd.enabled`) : `<chemin absolu> (~N tokens)` quand le fichier du projet est lu, `skipped (too long)` au-delà de `agentsMd.maxChars`, `ignored (structured output)` pour un agent avec `structuredOutput`, `<session cwd>/<chemin>` en mode ACP ; absent quand il n'y a pas de fichier. Un fichier lu qui occupe 20 % ou plus d'une fenêtre de contexte connue ajoute l'avertissement `<chemin> is ~N tokens, X % of the W-token context window — every request carries it …`. | |
| 79 | | `Agent ready. Commands: …` | Mode terminal seulement. | | 79 | | `Agent ready. Commands: …` | Mode terminal seulement. | |
| 80 | | `Shortcut: Ctrl+C …` | Mode terminal seulement. | | 80 | | `Shortcut: Ctrl+C …` | Mode terminal seulement. | |
| 81 | | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. | | 81 | | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. | |
modified
docs/fr/reference/configuration.md +2 -0 | @@ -34,6 +34,7 @@ | ||
| 34 | 34 | | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. | |
| 35 | 35 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | |
| 36 | 36 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | |
| 37 | +| `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`) et après le tour (`postGeneration`) ; 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 seulement), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | | |
| 37 | 38 | | `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`. | |
| 38 | 39 | | `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). | |
| 39 | 40 | | `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`. | |
| @@ -211,6 +212,7 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code | ||
| 211 | 212 | | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` | |
| 212 | 213 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` | |
| 213 | 214 | | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` | |
| 215 | +| `hooks: unknown event "x" (…)`, `hooks.<événement>[N]: …` | un bloc `hooks:` invalide — voir [les hooks](hooks.md#erreurs-de-validation) | | |
| 214 | 216 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | |
| 215 | 217 | |
| 216 | 218 | Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`). |
| @@ -34,6 +34,7 @@ | |||
| 34 | | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. | | 34 | | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. | |
| 35 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | | 35 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | |
| 36 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | | 36 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | |
| 37 | +| `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`) et après le tour (`postGeneration`) ; 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 seulement), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | | ||
| 37 | | `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`. | | 38 | | `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`. | |
| 38 | | `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). | | 39 | | `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). | |
| 39 | | `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`. | | 40 | | `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`. | |
| @@ -211,6 +212,7 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code | |||
| 211 | | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` | | 212 | | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` | |
| 212 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` | | 213 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` | |
| 213 | | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` | | 214 | | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` | |
| 215 | +| `hooks: unknown event "x" (…)`, `hooks.<événement>[N]: …` | un bloc `hooks:` invalide — voir [les hooks](hooks.md#erreurs-de-validation) | | ||
| 214 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | | 216 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | |
| 215 | 217 | ||
| 216 | Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`). | 218 | Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`). |
added
docs/fr/reference/hooks.md +206 -0 | new file mode 100644 | ||
| @@ -0,0 +1,206 @@ | ||
| 1 | +# Référence : les hooks | |
| 2 | + | |
| 3 | +> Le bloc `hooks:` du fichier de configuration : les événements, le JSON qu'un hook lit et écrit, l'environnement dans lequel il s'exécute, et ce que chaque décision fait au tour. | |
| 4 | + | |
| 5 | +## Configuration | |
| 6 | + | |
| 7 | +```yaml | |
| 8 | +hooks: | |
| 9 | + preGeneration: [<hook>, …] | |
| 10 | + preToolCall: [<hook>, …] | |
| 11 | + postToolCall: [<hook>, …] | |
| 12 | + postGeneration: [<hook>, …] | |
| 13 | +``` | |
| 14 | + | |
| 15 | +Chaque événement prend une liste ; les hooks d'un événement s'exécutent dans l'ordre où ils sont écrits. Un événement absent, ou une liste vide, n'exécute rien. Pas de bloc `hooks:` = aucun hook. | |
| 16 | + | |
| 17 | +### Entrée d'un hook | |
| 18 | + | |
| 19 | +| Clé | Type | Défaut | Description | | |
| 20 | +|-----|------|--------|-------------| | |
| 21 | +| `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. | | |
| 22 | +| `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils) | `preToolCall` et `postToolCall` seulement. Le hook s'exécute pour les outils dont le nom correspond. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. | | |
| 23 | +| `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. | | |
| 24 | +| `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). | | |
| 25 | + | |
| 26 | +### Exemple | |
| 27 | + | |
| 28 | +```yaml | |
| 29 | +hooks: | |
| 30 | + preGeneration: | |
| 31 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | |
| 32 | + timeout: 5s | |
| 33 | + onError: fail | |
| 34 | + preToolCall: | |
| 35 | + - matcher: ^(bash|write_file|edit_file)$ | |
| 36 | + command: ./hooks/guard.sh | |
| 37 | + postGeneration: | |
| 38 | + - command: python3 "$MM_CONFIG_DIR/hooks/log.py" | |
| 39 | + onError: ignore | |
| 40 | +``` | |
| 41 | + | |
| 42 | +## Événements | |
| 43 | + | |
| 44 | +| Événement | S'exécute | Outils concernés | Peut | | |
| 45 | +|-----------|-----------|------------------|------| | |
| 46 | +| `preGeneration` | une fois par question, avant le premier appel au modèle | — | réécrire la question, ajouter du contexte, arrêter le tour | | |
| 47 | +| `preToolCall` | avant chaque appel d'outil, avant la demande d'autorisation de l'outil | intégrés et MCP, filtrés par `matcher` | refuser l'appel, remplacer ses arguments | | |
| 48 | +| `postToolCall` | après chaque appel d'outil exécuté | intégrés et MCP, filtrés par `matcher` | ajouter du contexte après la sortie de l'outil | | |
| 49 | +| `postGeneration` | une fois le tour terminé : répondu, en échec, annulé ou arrêté par un hook | — | observer seulement | | |
| 50 | + | |
| 51 | +Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`, et un agent démarré par `/ask`, `ask_agent` ou `mm --ask` (avec les hooks de **son propre** fichier de configuration). Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte. | |
| 52 | + | |
| 53 | +## Exécution | |
| 54 | + | |
| 55 | +| Aspect | Valeur | | |
| 56 | +|--------|--------| | |
| 57 | +| Commande | `sh -c "<command>"` | | |
| 58 | +| Dossier de travail | celui de la session : le dossier où `mm` s'exécute, ou le `cwd` de la session de l'éditeur en ACP | | |
| 59 | +| Environnement | celui de `mm`, plus les deux variables ci-dessous | | |
| 60 | +| stdin | un objet JSON ([Entrée](#entrée)), puis fin de fichier | | |
| 61 | +| stdout | vide, ou un objet JSON ([Réponse](#réponse)) ; 1 Mio au plus | | |
| 62 | +| stderr | cité (300 premiers octets) dans la ligne d'échec quand l'exécution échoue ; ignoré sinon | | |
| 63 | +| Concurrence | les hooks d'un événement s'exécutent l'un après l'autre ; `preToolCall`/`postToolCall` peuvent s'exécuter en parallèle pour deux appels d'outil d'une même réponse du modèle | | |
| 64 | + | |
| 65 | +### Variables d'environnement | |
| 66 | + | |
| 67 | +| Variable | Valeur | | |
| 68 | +|----------|--------| | |
| 69 | +| `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall` ou `postGeneration` | | |
| 70 | +| `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook | | |
| 71 | + | |
| 72 | +## Entrée | |
| 73 | + | |
| 74 | +L'objet JSON écrit sur l'entrée standard du hook. | |
| 75 | + | |
| 76 | +| Champ | Type | Événements | Description | | |
| 77 | +|-------|------|------------|-------------| | |
| 78 | +| `event` | chaîne | tous | le nom de l'événement | | |
| 79 | +| `cwd` | chaîne | tous | le dossier de travail de la session, absolu | | |
| 80 | +| `model` | chaîne | tous | la référence du modèle, `<fournisseur>/<modèle>` | | |
| 81 | +| `prompt` | chaîne | `preGeneration`, `postGeneration` | la question telle que tapée — lignes `@path` et `$skill` comprises. Dans une chaîne, la question telle que le hook précédent l'a laissée. Dans `postGeneration` : la question que le modèle a lue, ou le marqueur `[prompt blocked by a hook: <raison>]` quand un hook a arrêté le tour. | | |
| 82 | +| `tool` | objet | `preToolCall`, `postToolCall` | l'appel d'outil : `id` (la référence de l'appel, peut être vide), `name`, `input` (les arguments, tels que le modèle les a envoyés ou que le hook précédent les a remplacés) | | |
| 83 | +| `output` | chaîne | `postToolCall` | la sortie de l'outil, telle que le modèle la lira (en JSON quand l'outil renvoie autre chose que du texte) | | |
| 84 | +| `answer` | chaîne | `postGeneration` | le texte de la dernière réponse ; vide s'il n'y en a pas | | |
| 85 | +| `error` | chaîne | `postGeneration` | l'erreur du tour ; absent quand le tour a réussi | | |
| 86 | + | |
| 87 | +Les champs vides sont omis. | |
| 88 | + | |
| 89 | +### Exemples | |
| 90 | + | |
| 91 | +```json | |
| 92 | +{"event":"preGeneration","cwd":"/home/moi/projet","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"} | |
| 93 | +``` | |
| 94 | + | |
| 95 | +```json | |
| 96 | +{"event":"preToolCall","cwd":"/home/moi/projet","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}} | |
| 97 | +``` | |
| 98 | + | |
| 99 | +## Réponse | |
| 100 | + | |
| 101 | +L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (ou faite seulement d'espaces) vaut `{"action":"continue"}`.** Tous les champs sont facultatifs. | |
| 102 | + | |
| 103 | +| Champ | Type | Lu sur | Description | | |
| 104 | +|-------|------|--------|-------------| | |
| 105 | +| `action` | chaîne | tous | `continue` (par défaut, y compris quand il est absent), `stop`, `deny` ou `error` — voir plus bas | | |
| 106 | +| `prompt` | chaîne | `preGeneration` | remplace la question. Absent = inchangée ; `""` = une question vide. | | |
| 107 | +| `context` | chaîne | `preGeneration`, `postToolCall` | du texte pour le modèle. `preGeneration` : ajouté après la question. `postToolCall` : ajouté après la sortie de l'outil. Chacun est introduit par la ligne `[context added by a hook]`. Blanc = ignoré. | | |
| 108 | +| `input` | toute valeur JSON | `preToolCall` | remplace les arguments de l'appel d'outil. Absent = inchangés. | | |
| 109 | +| `reason` | chaîne | `preGeneration` (`stop`), `preToolCall` (`deny`) | la raison. | | |
| 110 | +| `response` | chaîne | `preGeneration` (`stop`) | ce que l'utilisateur lit à la place d'une réponse. Absente ou blanche : `⛔ This request was blocked by a hook: <raison>`. | | |
| 111 | +| `error` | chaîne | tous (`error`) | le message d'échec du hook lui-même. | | |
| 112 | + | |
| 113 | +Les champs lus sur un autre événement sont ignorés. | |
| 114 | + | |
| 115 | +### Actions | |
| 116 | + | |
| 117 | +| Action | Permise sur | Effet | | |
| 118 | +|--------|-------------|-------| | |
| 119 | +| `continue` | tous | le tour continue, avec le `prompt`, le `context` et l'`input` de ce hook appliqués | | |
| 120 | +| `stop` | `preGeneration` | le tour se termine sans appeler le modèle ; les hooks suivants de l'événement ne s'exécutent pas | | |
| 121 | +| `deny` | `preToolCall` | l'outil ne s'exécute pas ; les hooks suivants de l'événement ne s'exécutent pas | | |
| 122 | +| `error` | tous | un échec, traité selon `onError` | | |
| 123 | +| toute autre valeur, ou `stop`/`deny` sur un autre événement | — | un échec : `action "<action>" is not valid for <événement>` | | |
| 124 | + | |
| 125 | +## Effets sur le tour | |
| 126 | + | |
| 127 | +### `preGeneration` | |
| 128 | + | |
| 129 | +| Décision | Le modèle lit | L'historique garde | L'utilisateur voit | | |
| 130 | +|----------|---------------|--------------------|--------------------| | |
| 131 | +| aucun changement | la question | la question | — | | |
| 132 | +| `prompt` et/ou `context` | la question réécrite, puis chaque contexte | la question réécrite (la copie propre au front end n'est pas modifiée pendant le tour) | — | | |
| 133 | +| `stop` | rien : le modèle n'est pas appelé | `[prompt blocked by a hook: <raison>]` (ou `[prompt blocked by a hook]`) suivi de la `response` comme réponse | la `response`, comme réponse | | |
| 134 | + | |
| 135 | +En chaîne : chaque hook lit le `prompt` laissé par le précédent ; les contextes s'accumulent dans l'ordre des hooks. | |
| 136 | + | |
| 137 | +### `preToolCall` | |
| 138 | + | |
| 139 | +| Décision | L'outil | Le modèle lit | L'utilisateur voit | | |
| 140 | +|----------|---------|---------------|--------------------| | |
| 141 | +| aucun changement | s'exécute avec les arguments du modèle | la sortie | la ligne habituelle de l'outil | | |
| 142 | +| `input` | s'exécute avec les arguments remplacés | la sortie | la ligne habituelle de l'outil (avec les arguments remplacés) | | |
| 143 | +| `deny` | ne s'exécute pas, et ne demande pas d'autorisation | `[denied by a hook: <raison>]` comme sortie de l'outil | mode ligne : `🪝 <outil> [denied by a hook: <raison>]` ; fenêtre et éditeur : un appel d'outil en échec intitulé `<outil> — denied by a hook` | | |
| 144 | + | |
| 145 | +L'historique garde l'appel d'outil tel que le modèle l'a écrit, même quand `input` a remplacé ses arguments. | |
| 146 | + | |
| 147 | +### `postToolCall` | |
| 148 | + | |
| 149 | +Les `context` de tous les hooks qui correspondent sont réunis et ajoutés à la sortie : `<sortie>\n\n[context added by a hook]\n<contexte>`. La sortie de l'outil montrée à l'utilisateur ne change pas. | |
| 150 | + | |
| 151 | +### `postGeneration` | |
| 152 | + | |
| 153 | +Aucun effet sur le tour. La réponse est décodée (une réponse invalide est un échec), puis ignorée. | |
| 154 | + | |
| 155 | +## Échecs | |
| 156 | + | |
| 157 | +Une exécution échoue quand : | |
| 158 | + | |
| 159 | +| Échec | Message | | |
| 160 | +|-------|---------| | |
| 161 | +| la commande se termine avec un code non nul | `exit status <n>: <stderr>` | | |
| 162 | +| elle dure plus que `timeout` | `timed out after <timeout>` | | |
| 163 | +| le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` | | |
| 164 | +| stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` | | |
| 165 | +| stdout n'est pas du JSON | `invalid JSON on stdout: …` | | |
| 166 | +| `action` vaut `error` | l'`error` de la réponse, ou `the hook reported an error` | | |
| 167 | +| `action` est inconnue ou non permise sur l'événement | `action "<action>" is not valid for <événement>` | | |
| 168 | + | |
| 169 | +`postGeneration` n'est pas annulé avec le tour : il s'exécute aussi après une annulation (Échap, Ctrl-C, `/abort`), borné par son `timeout`. | |
| 170 | + | |
| 171 | +La suite dépend d'`onError` : | |
| 172 | + | |
| 173 | +| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` | | |
| 174 | +|-----------|-----------------|---------------|----------------------------------| | |
| 175 | +| `fail` | ligne d'échec, tour arrêté (`response` : `⛔ This request was blocked by a hook: hook failed: …`) | ligne d'échec, appel refusé (`reason` : `hook failed: …`) | ligne d'échec, on continue | | |
| 176 | +| `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue | | |
| 177 | +| `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire | | |
| 178 | + | |
| 179 | +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. | |
| 180 | + | |
| 181 | +## Bannière de démarrage | |
| 182 | + | |
| 183 | +Quand au moins un hook est déclaré, la bannière se termine par ` | hooks: <événement>×<nombre> …`, une entrée par événement qui a des hooks, dans l'ordre `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration` : | |
| 184 | + | |
| 185 | +``` | |
| 186 | +mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1 | |
| 187 | +``` | |
| 188 | + | |
| 189 | +## Erreurs de validation | |
| 190 | + | |
| 191 | +Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, code de sortie 1. | |
| 192 | + | |
| 193 | +| Message | Cause | | |
| 194 | +|---------|-------| | |
| 195 | +| `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) | | |
| 196 | +| `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche | | |
| 197 | +| `hooks.<événement>[N]: matcher is only for preToolCall and postToolCall` | un `matcher` sur `preGeneration` ou `postGeneration` | | |
| 198 | +| `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas | | |
| 199 | +| `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif | | |
| 200 | +| `hooks.<événement>[N]: onError must be "fail", "warn" or "ignore"` | toute autre valeur d'`onError` | | |
| 201 | + | |
| 202 | +## Voir aussi | |
| 203 | + | |
| 204 | +- [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) | |
| 205 | +- [Les hooks — explication](../explanation/hooks.md) | |
| 206 | +- La démo : `demos/hooks/` (quatre hooks Node.js) | |
| new file mode 100644 | |||
| @@ -0,0 +1,206 @@ | |||
| 1 | +# Référence : les hooks | ||
| 2 | + | ||
| 3 | +> Le bloc `hooks:` du fichier de configuration : les événements, le JSON qu'un hook lit et écrit, l'environnement dans lequel il s'exécute, et ce que chaque décision fait au tour. | ||
| 4 | + | ||
| 5 | +## Configuration | ||
| 6 | + | ||
| 7 | +```yaml | ||
| 8 | +hooks: | ||
| 9 | + preGeneration: [<hook>, …] | ||
| 10 | + preToolCall: [<hook>, …] | ||
| 11 | + postToolCall: [<hook>, …] | ||
| 12 | + postGeneration: [<hook>, …] | ||
| 13 | +``` | ||
| 14 | + | ||
| 15 | +Chaque événement prend une liste ; les hooks d'un événement s'exécutent dans l'ordre où ils sont écrits. Un événement absent, ou une liste vide, n'exécute rien. Pas de bloc `hooks:` = aucun hook. | ||
| 16 | + | ||
| 17 | +### Entrée d'un hook | ||
| 18 | + | ||
| 19 | +| Clé | Type | Défaut | Description | | ||
| 20 | +|-----|------|--------|-------------| | ||
| 21 | +| `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. | | ||
| 22 | +| `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils) | `preToolCall` et `postToolCall` seulement. Le hook s'exécute pour les outils dont le nom correspond. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. | | ||
| 23 | +| `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. | | ||
| 24 | +| `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). | | ||
| 25 | + | ||
| 26 | +### Exemple | ||
| 27 | + | ||
| 28 | +```yaml | ||
| 29 | +hooks: | ||
| 30 | + preGeneration: | ||
| 31 | + - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js" | ||
| 32 | + timeout: 5s | ||
| 33 | + onError: fail | ||
| 34 | + preToolCall: | ||
| 35 | + - matcher: ^(bash|write_file|edit_file)$ | ||
| 36 | + command: ./hooks/guard.sh | ||
| 37 | + postGeneration: | ||
| 38 | + - command: python3 "$MM_CONFIG_DIR/hooks/log.py" | ||
| 39 | + onError: ignore | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +## Événements | ||
| 43 | + | ||
| 44 | +| Événement | S'exécute | Outils concernés | Peut | | ||
| 45 | +|-----------|-----------|------------------|------| | ||
| 46 | +| `preGeneration` | une fois par question, avant le premier appel au modèle | — | réécrire la question, ajouter du contexte, arrêter le tour | | ||
| 47 | +| `preToolCall` | avant chaque appel d'outil, avant la demande d'autorisation de l'outil | intégrés et MCP, filtrés par `matcher` | refuser l'appel, remplacer ses arguments | | ||
| 48 | +| `postToolCall` | après chaque appel d'outil exécuté | intégrés et MCP, filtrés par `matcher` | ajouter du contexte après la sortie de l'outil | | ||
| 49 | +| `postGeneration` | une fois le tour terminé : répondu, en échec, annulé ou arrêté par un hook | — | observer seulement | | ||
| 50 | + | ||
| 51 | +Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`, et un agent démarré par `/ask`, `ask_agent` ou `mm --ask` (avec les hooks de **son propre** fichier de configuration). Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte. | ||
| 52 | + | ||
| 53 | +## Exécution | ||
| 54 | + | ||
| 55 | +| Aspect | Valeur | | ||
| 56 | +|--------|--------| | ||
| 57 | +| Commande | `sh -c "<command>"` | | ||
| 58 | +| Dossier de travail | celui de la session : le dossier où `mm` s'exécute, ou le `cwd` de la session de l'éditeur en ACP | | ||
| 59 | +| Environnement | celui de `mm`, plus les deux variables ci-dessous | | ||
| 60 | +| stdin | un objet JSON ([Entrée](#entrée)), puis fin de fichier | | ||
| 61 | +| stdout | vide, ou un objet JSON ([Réponse](#réponse)) ; 1 Mio au plus | | ||
| 62 | +| stderr | cité (300 premiers octets) dans la ligne d'échec quand l'exécution échoue ; ignoré sinon | | ||
| 63 | +| Concurrence | les hooks d'un événement s'exécutent l'un après l'autre ; `preToolCall`/`postToolCall` peuvent s'exécuter en parallèle pour deux appels d'outil d'une même réponse du modèle | | ||
| 64 | + | ||
| 65 | +### Variables d'environnement | ||
| 66 | + | ||
| 67 | +| Variable | Valeur | | ||
| 68 | +|----------|--------| | ||
| 69 | +| `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall` ou `postGeneration` | | ||
| 70 | +| `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook | | ||
| 71 | + | ||
| 72 | +## Entrée | ||
| 73 | + | ||
| 74 | +L'objet JSON écrit sur l'entrée standard du hook. | ||
| 75 | + | ||
| 76 | +| Champ | Type | Événements | Description | | ||
| 77 | +|-------|------|------------|-------------| | ||
| 78 | +| `event` | chaîne | tous | le nom de l'événement | | ||
| 79 | +| `cwd` | chaîne | tous | le dossier de travail de la session, absolu | | ||
| 80 | +| `model` | chaîne | tous | la référence du modèle, `<fournisseur>/<modèle>` | | ||
| 81 | +| `prompt` | chaîne | `preGeneration`, `postGeneration` | la question telle que tapée — lignes `@path` et `$skill` comprises. Dans une chaîne, la question telle que le hook précédent l'a laissée. Dans `postGeneration` : la question que le modèle a lue, ou le marqueur `[prompt blocked by a hook: <raison>]` quand un hook a arrêté le tour. | | ||
| 82 | +| `tool` | objet | `preToolCall`, `postToolCall` | l'appel d'outil : `id` (la référence de l'appel, peut être vide), `name`, `input` (les arguments, tels que le modèle les a envoyés ou que le hook précédent les a remplacés) | | ||
| 83 | +| `output` | chaîne | `postToolCall` | la sortie de l'outil, telle que le modèle la lira (en JSON quand l'outil renvoie autre chose que du texte) | | ||
| 84 | +| `answer` | chaîne | `postGeneration` | le texte de la dernière réponse ; vide s'il n'y en a pas | | ||
| 85 | +| `error` | chaîne | `postGeneration` | l'erreur du tour ; absent quand le tour a réussi | | ||
| 86 | + | ||
| 87 | +Les champs vides sont omis. | ||
| 88 | + | ||
| 89 | +### Exemples | ||
| 90 | + | ||
| 91 | +```json | ||
| 92 | +{"event":"preGeneration","cwd":"/home/moi/projet","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"} | ||
| 93 | +``` | ||
| 94 | + | ||
| 95 | +```json | ||
| 96 | +{"event":"preToolCall","cwd":"/home/moi/projet","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}} | ||
| 97 | +``` | ||
| 98 | + | ||
| 99 | +## Réponse | ||
| 100 | + | ||
| 101 | +L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (ou faite seulement d'espaces) vaut `{"action":"continue"}`.** Tous les champs sont facultatifs. | ||
| 102 | + | ||
| 103 | +| Champ | Type | Lu sur | Description | | ||
| 104 | +|-------|------|--------|-------------| | ||
| 105 | +| `action` | chaîne | tous | `continue` (par défaut, y compris quand il est absent), `stop`, `deny` ou `error` — voir plus bas | | ||
| 106 | +| `prompt` | chaîne | `preGeneration` | remplace la question. Absent = inchangée ; `""` = une question vide. | | ||
| 107 | +| `context` | chaîne | `preGeneration`, `postToolCall` | du texte pour le modèle. `preGeneration` : ajouté après la question. `postToolCall` : ajouté après la sortie de l'outil. Chacun est introduit par la ligne `[context added by a hook]`. Blanc = ignoré. | | ||
| 108 | +| `input` | toute valeur JSON | `preToolCall` | remplace les arguments de l'appel d'outil. Absent = inchangés. | | ||
| 109 | +| `reason` | chaîne | `preGeneration` (`stop`), `preToolCall` (`deny`) | la raison. | | ||
| 110 | +| `response` | chaîne | `preGeneration` (`stop`) | ce que l'utilisateur lit à la place d'une réponse. Absente ou blanche : `⛔ This request was blocked by a hook: <raison>`. | | ||
| 111 | +| `error` | chaîne | tous (`error`) | le message d'échec du hook lui-même. | | ||
| 112 | + | ||
| 113 | +Les champs lus sur un autre événement sont ignorés. | ||
| 114 | + | ||
| 115 | +### Actions | ||
| 116 | + | ||
| 117 | +| Action | Permise sur | Effet | | ||
| 118 | +|--------|-------------|-------| | ||
| 119 | +| `continue` | tous | le tour continue, avec le `prompt`, le `context` et l'`input` de ce hook appliqués | | ||
| 120 | +| `stop` | `preGeneration` | le tour se termine sans appeler le modèle ; les hooks suivants de l'événement ne s'exécutent pas | | ||
| 121 | +| `deny` | `preToolCall` | l'outil ne s'exécute pas ; les hooks suivants de l'événement ne s'exécutent pas | | ||
| 122 | +| `error` | tous | un échec, traité selon `onError` | | ||
| 123 | +| toute autre valeur, ou `stop`/`deny` sur un autre événement | — | un échec : `action "<action>" is not valid for <événement>` | | ||
| 124 | + | ||
| 125 | +## Effets sur le tour | ||
| 126 | + | ||
| 127 | +### `preGeneration` | ||
| 128 | + | ||
| 129 | +| Décision | Le modèle lit | L'historique garde | L'utilisateur voit | | ||
| 130 | +|----------|---------------|--------------------|--------------------| | ||
| 131 | +| aucun changement | la question | la question | — | | ||
| 132 | +| `prompt` et/ou `context` | la question réécrite, puis chaque contexte | la question réécrite (la copie propre au front end n'est pas modifiée pendant le tour) | — | | ||
| 133 | +| `stop` | rien : le modèle n'est pas appelé | `[prompt blocked by a hook: <raison>]` (ou `[prompt blocked by a hook]`) suivi de la `response` comme réponse | la `response`, comme réponse | | ||
| 134 | + | ||
| 135 | +En chaîne : chaque hook lit le `prompt` laissé par le précédent ; les contextes s'accumulent dans l'ordre des hooks. | ||
| 136 | + | ||
| 137 | +### `preToolCall` | ||
| 138 | + | ||
| 139 | +| Décision | L'outil | Le modèle lit | L'utilisateur voit | | ||
| 140 | +|----------|---------|---------------|--------------------| | ||
| 141 | +| aucun changement | s'exécute avec les arguments du modèle | la sortie | la ligne habituelle de l'outil | | ||
| 142 | +| `input` | s'exécute avec les arguments remplacés | la sortie | la ligne habituelle de l'outil (avec les arguments remplacés) | | ||
| 143 | +| `deny` | ne s'exécute pas, et ne demande pas d'autorisation | `[denied by a hook: <raison>]` comme sortie de l'outil | mode ligne : `🪝 <outil> [denied by a hook: <raison>]` ; fenêtre et éditeur : un appel d'outil en échec intitulé `<outil> — denied by a hook` | | ||
| 144 | + | ||
| 145 | +L'historique garde l'appel d'outil tel que le modèle l'a écrit, même quand `input` a remplacé ses arguments. | ||
| 146 | + | ||
| 147 | +### `postToolCall` | ||
| 148 | + | ||
| 149 | +Les `context` de tous les hooks qui correspondent sont réunis et ajoutés à la sortie : `<sortie>\n\n[context added by a hook]\n<contexte>`. La sortie de l'outil montrée à l'utilisateur ne change pas. | ||
| 150 | + | ||
| 151 | +### `postGeneration` | ||
| 152 | + | ||
| 153 | +Aucun effet sur le tour. La réponse est décodée (une réponse invalide est un échec), puis ignorée. | ||
| 154 | + | ||
| 155 | +## Échecs | ||
| 156 | + | ||
| 157 | +Une exécution échoue quand : | ||
| 158 | + | ||
| 159 | +| Échec | Message | | ||
| 160 | +|-------|---------| | ||
| 161 | +| la commande se termine avec un code non nul | `exit status <n>: <stderr>` | | ||
| 162 | +| elle dure plus que `timeout` | `timed out after <timeout>` | | ||
| 163 | +| le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` | | ||
| 164 | +| stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` | | ||
| 165 | +| stdout n'est pas du JSON | `invalid JSON on stdout: …` | | ||
| 166 | +| `action` vaut `error` | l'`error` de la réponse, ou `the hook reported an error` | | ||
| 167 | +| `action` est inconnue ou non permise sur l'événement | `action "<action>" is not valid for <événement>` | | ||
| 168 | + | ||
| 169 | +`postGeneration` n'est pas annulé avec le tour : il s'exécute aussi après une annulation (Échap, Ctrl-C, `/abort`), borné par son `timeout`. | ||
| 170 | + | ||
| 171 | +La suite dépend d'`onError` : | ||
| 172 | + | ||
| 173 | +| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` | | ||
| 174 | +|-----------|-----------------|---------------|----------------------------------| | ||
| 175 | +| `fail` | ligne d'échec, tour arrêté (`response` : `⛔ This request was blocked by a hook: hook failed: …`) | ligne d'échec, appel refusé (`reason` : `hook failed: …`) | ligne d'échec, on continue | | ||
| 176 | +| `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue | | ||
| 177 | +| `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire | | ||
| 178 | + | ||
| 179 | +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. | ||
| 180 | + | ||
| 181 | +## Bannière de démarrage | ||
| 182 | + | ||
| 183 | +Quand au moins un hook est déclaré, la bannière se termine par ` | hooks: <événement>×<nombre> …`, une entrée par événement qui a des hooks, dans l'ordre `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration` : | ||
| 184 | + | ||
| 185 | +``` | ||
| 186 | +mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1 | ||
| 187 | +``` | ||
| 188 | + | ||
| 189 | +## Erreurs de validation | ||
| 190 | + | ||
| 191 | +Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, code de sortie 1. | ||
| 192 | + | ||
| 193 | +| Message | Cause | | ||
| 194 | +|---------|-------| | ||
| 195 | +| `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) | | ||
| 196 | +| `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche | | ||
| 197 | +| `hooks.<événement>[N]: matcher is only for preToolCall and postToolCall` | un `matcher` sur `preGeneration` ou `postGeneration` | | ||
| 198 | +| `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas | | ||
| 199 | +| `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif | | ||
| 200 | +| `hooks.<événement>[N]: onError must be "fail", "warn" or "ignore"` | toute autre valeur d'`onError` | | ||
| 201 | + | ||
| 202 | +## Voir aussi | ||
| 203 | + | ||
| 204 | +- [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) | ||
| 205 | +- [Les hooks — explication](../explanation/hooks.md) | ||
| 206 | +- La démo : `demos/hooks/` (quatre hooks Node.js) | ||
modified
internal/config/config.go +10 -0 | @@ -209,6 +209,12 @@ type Config struct { | ||
| 209 | 209 | // is relative to THIS file's directory, and Load makes it absolute |
| 210 | 210 | // (agents.go). Each name becomes a command name, hence its format. |
| 211 | 211 | MMConfigs map[string]AgentRef `yaml:"mmConfigs"` |
| 212 | + | |
| 213 | + // Hooks: external commands run at four moments of a turn — before the | |
| 214 | + // model sees a question, before and after each tool call, after the | |
| 215 | + // turn (internal/hooks, ticket 36). They read one JSON object on stdin | |
| 216 | + // and may answer one on stdout. None by default. | |
| 217 | + Hooks HooksConfig `yaml:"hooks"` | |
| 212 | 218 | } |
| 213 | 219 | |
| 214 | 220 | // YoloEnv names the variable that overrides `yolo` for one run ("true", |
| @@ -619,6 +625,7 @@ func Load(cliPath string) (string, error) { | ||
| 619 | 625 | return "", err |
| 620 | 626 | } |
| 621 | 627 | resolveAgentPaths(path) |
| 628 | + resolveHooks(path) | |
| 622 | 629 | if err := Cfg.validate(); err != nil { |
| 623 | 630 | return "", fmt.Errorf("%s: %w", path, err) |
| 624 | 631 | } |
| @@ -794,5 +801,8 @@ func (c Config) validate() error { | ||
| 794 | 801 | if err := c.validateAgents(); err != nil { |
| 795 | 802 | return err |
| 796 | 803 | } |
| 804 | + if err := c.Hooks.validate(); err != nil { | |
| 805 | + return err | |
| 806 | + } | |
| 797 | 807 | return c.LSP.validate() |
| 798 | 808 | } |
| @@ -209,6 +209,12 @@ type Config struct { | |||
| 209 | // is relative to THIS file's directory, and Load makes it absolute | 209 | // is relative to THIS file's directory, and Load makes it absolute |
| 210 | // (agents.go). Each name becomes a command name, hence its format. | 210 | // (agents.go). Each name becomes a command name, hence its format. |
| 211 | MMConfigs map[string]AgentRef `yaml:"mmConfigs"` | 211 | MMConfigs map[string]AgentRef `yaml:"mmConfigs"` |
| 212 | + | ||
| 213 | + // Hooks: external commands run at four moments of a turn — before the | ||
| 214 | + // model sees a question, before and after each tool call, after the | ||
| 215 | + // turn (internal/hooks, ticket 36). They read one JSON object on stdin | ||
| 216 | + // and may answer one on stdout. None by default. | ||
| 217 | + Hooks HooksConfig `yaml:"hooks"` | ||
| 212 | } | 218 | } |
| 213 | 219 | ||
| 214 | // YoloEnv names the variable that overrides `yolo` for one run ("true", | 220 | // YoloEnv names the variable that overrides `yolo` for one run ("true", |
| @@ -619,6 +625,7 @@ func Load(cliPath string) (string, error) { | |||
| 619 | return "", err | 625 | return "", err |
| 620 | } | 626 | } |
| 621 | resolveAgentPaths(path) | 627 | resolveAgentPaths(path) |
| 628 | + resolveHooks(path) | ||
| 622 | if err := Cfg.validate(); err != nil { | 629 | if err := Cfg.validate(); err != nil { |
| 623 | return "", fmt.Errorf("%s: %w", path, err) | 630 | return "", fmt.Errorf("%s: %w", path, err) |
| 624 | } | 631 | } |
| @@ -794,5 +801,8 @@ func (c Config) validate() error { | |||
| 794 | if err := c.validateAgents(); err != nil { | 801 | if err := c.validateAgents(); err != nil { |
| 795 | return err | 802 | return err |
| 796 | } | 803 | } |
| 804 | + if err := c.Hooks.validate(); err != nil { | ||
| 805 | + return err | ||
| 806 | + } | ||
| 797 | return c.LSP.validate() | 807 | return c.LSP.validate() |
| 798 | } | 808 | } |
added
internal/config/hooks.go +213 -0 | new file mode 100644 | ||
| @@ -0,0 +1,213 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "path/filepath" | |
| 7 | + "regexp" | |
| 8 | + "strings" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "gopkg.in/yaml.v3" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// The four moments a hook can run at (ticket 36). The names are the keys of | |
| 15 | +// the `hooks:` block AND the "event" field of the JSON a hook reads. | |
| 16 | +const ( | |
| 17 | + // HookPreGeneration runs once per user question, before the first call | |
| 18 | + // to the model: it may rewrite the prompt, add context or stop the turn. | |
| 19 | + HookPreGeneration = "preGeneration" | |
| 20 | + // HookPreToolCall runs before each tool call: it may deny the call or | |
| 21 | + // replace its input. | |
| 22 | + HookPreToolCall = "preToolCall" | |
| 23 | + // HookPostToolCall runs after each tool call: it may append context to | |
| 24 | + // the tool's output. | |
| 25 | + HookPostToolCall = "postToolCall" | |
| 26 | + // HookPostGeneration runs once the turn is over, success or failure. It | |
| 27 | + // only observes. | |
| 28 | + HookPostGeneration = "postGeneration" | |
| 29 | +) | |
| 30 | + | |
| 31 | +// The three values of Hook.OnError: what a failing hook (non-zero exit, | |
| 32 | +// timeout, unreadable output) does to the turn. | |
| 33 | +const ( | |
| 34 | + // HookOnErrorFail stops the turn (preGeneration) or denies the call | |
| 35 | + // (preToolCall). On the two post* events there is nothing left to stop: | |
| 36 | + // it is reported like warn. | |
| 37 | + HookOnErrorFail = "fail" | |
| 38 | + // HookOnErrorWarn reports the failure in one line and carries on. The | |
| 39 | + // default: a broken hook must be seen, and must not lock the user out. | |
| 40 | + HookOnErrorWarn = "warn" | |
| 41 | + // HookOnErrorIgnore carries on without a word. | |
| 42 | + HookOnErrorIgnore = "ignore" | |
| 43 | +) | |
| 44 | + | |
| 45 | +// DefaultHookTimeout is how long a hook may run when its entry gives no | |
| 46 | +// `timeout`. A hook sits in the middle of every turn — or of every tool | |
| 47 | +// call — so a slow one is felt at once; ten seconds leaves room for a | |
| 48 | +// `node` start-up and a `git` call, not for a hung network request. | |
| 49 | +const DefaultHookTimeout = 10 * time.Second | |
| 50 | + | |
| 51 | +// hookEvents is the order hooks are listed in (banner, errors). | |
| 52 | +var hookEvents = []string{HookPreGeneration, HookPreToolCall, HookPostToolCall, HookPostGeneration} | |
| 53 | + | |
| 54 | +// HooksConfig is the `hooks:` block of the YAML: for each event, the hooks | |
| 55 | +// to run, in the order they are written. | |
| 56 | +// | |
| 57 | +// hooks: | |
| 58 | +// preToolCall: | |
| 59 | +// - matcher: ^bash$ | |
| 60 | +// command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | |
| 61 | +// onError: fail | |
| 62 | +type HooksConfig struct { | |
| 63 | + PreGeneration []Hook `yaml:"preGeneration"` | |
| 64 | + PreToolCall []Hook `yaml:"preToolCall"` | |
| 65 | + PostToolCall []Hook `yaml:"postToolCall"` | |
| 66 | + PostGeneration []Hook `yaml:"postGeneration"` | |
| 67 | + | |
| 68 | + // ConfigDir is the directory of the YAML file that declared the hooks, | |
| 69 | + // absolute, set by Load. The hooks read it as MM_CONFIG_DIR: a hook | |
| 70 | + // script shipped beside its agent.yaml is found whatever directory mm | |
| 71 | + // was started from. | |
| 72 | + ConfigDir string `yaml:"-"` | |
| 73 | +} | |
| 74 | + | |
| 75 | +// Hook is one entry of an event's list. | |
| 76 | +type Hook struct { | |
| 77 | + // Command is run with `sh -c`, in the session's working directory. | |
| 78 | + Command string `yaml:"command"` | |
| 79 | + // Matcher is a regular expression on the tool name (preToolCall and | |
| 80 | + // postToolCall only). Empty = every tool. Unanchored, like grep: | |
| 81 | + // "bash" also matches "my_bash"; write "^bash$" for the tool alone. | |
| 82 | + Matcher string `yaml:"matcher"` | |
| 83 | + // Timeout bounds one run of the hook. 0 = DefaultHookTimeout. | |
| 84 | + Timeout time.Duration `yaml:"timeout"` | |
| 85 | + // OnError is fail, warn or ignore. Empty = warn. | |
| 86 | + OnError string `yaml:"onError"` | |
| 87 | +} | |
| 88 | + | |
| 89 | +// UnmarshalYAML reads the `hooks:` block and REJECTS an unknown event name. | |
| 90 | +// A plain struct would ignore `preToolcall:` without a word, and a security | |
| 91 | +// hook that silently never runs is worse than an agent that refuses to start. | |
| 92 | +func (h *HooksConfig) UnmarshalYAML(node *yaml.Node) error { | |
| 93 | + var lists map[string][]Hook | |
| 94 | + if err := node.Decode(&lists); err != nil { | |
| 95 | + return err | |
| 96 | + } | |
| 97 | + for event, list := range lists { | |
| 98 | + slot := h.slot(event) | |
| 99 | + if slot == nil { | |
| 100 | + return fmt.Errorf("hooks: unknown event %q (want one of %s)", event, strings.Join(hookEvents, ", ")) | |
| 101 | + } | |
| 102 | + *slot = list | |
| 103 | + } | |
| 104 | + return nil | |
| 105 | +} | |
| 106 | + | |
| 107 | +// slot returns the list that holds an event's hooks, nil for an unknown | |
| 108 | +// event. | |
| 109 | +func (h *HooksConfig) slot(event string) *[]Hook { | |
| 110 | + switch event { | |
| 111 | + case HookPreGeneration: | |
| 112 | + return &h.PreGeneration | |
| 113 | + case HookPreToolCall: | |
| 114 | + return &h.PreToolCall | |
| 115 | + case HookPostToolCall: | |
| 116 | + return &h.PostToolCall | |
| 117 | + case HookPostGeneration: | |
| 118 | + return &h.PostGeneration | |
| 119 | + } | |
| 120 | + return nil | |
| 121 | +} | |
| 122 | + | |
| 123 | +// For returns the hooks declared for an event, in order; nil for an event | |
| 124 | +// with none (or an unknown one). | |
| 125 | +// | |
| 126 | +// for _, h := range config.Cfg.Hooks.For(config.HookPreToolCall) { | |
| 127 | +// fmt.Println(h.Command) | |
| 128 | +// } | |
| 129 | +func (h HooksConfig) For(event string) []Hook { | |
| 130 | + if slot := h.slot(event); slot != nil { | |
| 131 | + return *slot | |
| 132 | + } | |
| 133 | + return nil | |
| 134 | +} | |
| 135 | + | |
| 136 | +// Summary describes the declared hooks for the banner, one "event×count" | |
| 137 | +// per event that has any, in event order: "preGeneration×1 preToolCall×2". | |
| 138 | +// Empty when there is none. | |
| 139 | +func (h HooksConfig) Summary() string { | |
| 140 | + var parts []string | |
| 141 | + for _, event := range hookEvents { | |
| 142 | + if n := len(h.For(event)); n > 0 { | |
| 143 | + parts = append(parts, fmt.Sprintf("%s×%d", event, n)) | |
| 144 | + } | |
| 145 | + } | |
| 146 | + return strings.Join(parts, " ") | |
| 147 | +} | |
| 148 | + | |
| 149 | +// Matches reports whether the hook applies to a tool. A hook without a | |
| 150 | +// matcher applies to every tool. Load has rejected a pattern that does not | |
| 151 | +// compile; one that reaches here anyway (a Config built by hand) matches | |
| 152 | +// nothing rather than panicking in the middle of a turn. | |
| 153 | +func (h Hook) Matches(tool string) bool { | |
| 154 | + if h.Matcher == "" { | |
| 155 | + return true | |
| 156 | + } | |
| 157 | + re, err := regexp.Compile(h.Matcher) | |
| 158 | + return err == nil && re.MatchString(tool) | |
| 159 | +} | |
| 160 | + | |
| 161 | +// validate checks every hook entry: a command, a matcher only where a tool | |
| 162 | +// is involved and one that compiles, a timeout that is not negative, and a | |
| 163 | +// known onError. | |
| 164 | +func (h HooksConfig) validate() error { | |
| 165 | + for _, event := range hookEvents { | |
| 166 | + for i, hook := range h.For(event) { | |
| 167 | + if err := hook.validate(event); err != nil { | |
| 168 | + return fmt.Errorf("hooks.%s[%d]: %w", event, i, err) | |
| 169 | + } | |
| 170 | + } | |
| 171 | + } | |
| 172 | + return nil | |
| 173 | +} | |
| 174 | + | |
| 175 | +func (h Hook) validate(event string) error { | |
| 176 | + toolEvent := event == HookPreToolCall || event == HookPostToolCall | |
| 177 | + switch { | |
| 178 | + case strings.TrimSpace(h.Command) == "": | |
| 179 | + return errors.New("command must not be empty") | |
| 180 | + case h.Matcher != "" && !toolEvent: | |
| 181 | + return fmt.Errorf("matcher is only for %s and %s", HookPreToolCall, HookPostToolCall) | |
| 182 | + case h.Timeout < 0: | |
| 183 | + return errors.New("timeout must be >= 0") | |
| 184 | + } | |
| 185 | + switch h.OnError { | |
| 186 | + case "", HookOnErrorFail, HookOnErrorWarn, HookOnErrorIgnore: | |
| 187 | + default: | |
| 188 | + return fmt.Errorf("onError must be %q, %q or %q", HookOnErrorFail, HookOnErrorWarn, HookOnErrorIgnore) | |
| 189 | + } | |
| 190 | + if _, err := regexp.Compile(h.Matcher); err != nil { | |
| 191 | + return fmt.Errorf("matcher: %w", err) | |
| 192 | + } | |
| 193 | + return nil | |
| 194 | +} | |
| 195 | + | |
| 196 | +// resolveHooks fills what an entry may leave out (timeout, onError) and | |
| 197 | +// records the directory of the file the hooks came from. | |
| 198 | +func resolveHooks(configPath string) { | |
| 199 | + if dir, err := filepath.Abs(filepath.Dir(configPath)); err == nil { | |
| 200 | + Cfg.Hooks.ConfigDir = dir | |
| 201 | + } | |
| 202 | + for _, event := range hookEvents { | |
| 203 | + list := Cfg.Hooks.For(event) | |
| 204 | + for i := range list { | |
| 205 | + if list[i].Timeout == 0 { | |
| 206 | + list[i].Timeout = DefaultHookTimeout | |
| 207 | + } | |
| 208 | + if list[i].OnError == "" { | |
| 209 | + list[i].OnError = HookOnErrorWarn | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,213 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "errors" | ||
| 5 | + "fmt" | ||
| 6 | + "path/filepath" | ||
| 7 | + "regexp" | ||
| 8 | + "strings" | ||
| 9 | + "time" | ||
| 10 | + | ||
| 11 | + "gopkg.in/yaml.v3" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// The four moments a hook can run at (ticket 36). The names are the keys of | ||
| 15 | +// the `hooks:` block AND the "event" field of the JSON a hook reads. | ||
| 16 | +const ( | ||
| 17 | + // HookPreGeneration runs once per user question, before the first call | ||
| 18 | + // to the model: it may rewrite the prompt, add context or stop the turn. | ||
| 19 | + HookPreGeneration = "preGeneration" | ||
| 20 | + // HookPreToolCall runs before each tool call: it may deny the call or | ||
| 21 | + // replace its input. | ||
| 22 | + HookPreToolCall = "preToolCall" | ||
| 23 | + // HookPostToolCall runs after each tool call: it may append context to | ||
| 24 | + // the tool's output. | ||
| 25 | + HookPostToolCall = "postToolCall" | ||
| 26 | + // HookPostGeneration runs once the turn is over, success or failure. It | ||
| 27 | + // only observes. | ||
| 28 | + HookPostGeneration = "postGeneration" | ||
| 29 | +) | ||
| 30 | + | ||
| 31 | +// The three values of Hook.OnError: what a failing hook (non-zero exit, | ||
| 32 | +// timeout, unreadable output) does to the turn. | ||
| 33 | +const ( | ||
| 34 | + // HookOnErrorFail stops the turn (preGeneration) or denies the call | ||
| 35 | + // (preToolCall). On the two post* events there is nothing left to stop: | ||
| 36 | + // it is reported like warn. | ||
| 37 | + HookOnErrorFail = "fail" | ||
| 38 | + // HookOnErrorWarn reports the failure in one line and carries on. The | ||
| 39 | + // default: a broken hook must be seen, and must not lock the user out. | ||
| 40 | + HookOnErrorWarn = "warn" | ||
| 41 | + // HookOnErrorIgnore carries on without a word. | ||
| 42 | + HookOnErrorIgnore = "ignore" | ||
| 43 | +) | ||
| 44 | + | ||
| 45 | +// DefaultHookTimeout is how long a hook may run when its entry gives no | ||
| 46 | +// `timeout`. A hook sits in the middle of every turn — or of every tool | ||
| 47 | +// call — so a slow one is felt at once; ten seconds leaves room for a | ||
| 48 | +// `node` start-up and a `git` call, not for a hung network request. | ||
| 49 | +const DefaultHookTimeout = 10 * time.Second | ||
| 50 | + | ||
| 51 | +// hookEvents is the order hooks are listed in (banner, errors). | ||
| 52 | +var hookEvents = []string{HookPreGeneration, HookPreToolCall, HookPostToolCall, HookPostGeneration} | ||
| 53 | + | ||
| 54 | +// HooksConfig is the `hooks:` block of the YAML: for each event, the hooks | ||
| 55 | +// to run, in the order they are written. | ||
| 56 | +// | ||
| 57 | +// hooks: | ||
| 58 | +// preToolCall: | ||
| 59 | +// - matcher: ^bash$ | ||
| 60 | +// command: node "$MM_CONFIG_DIR/hooks/bash-guard.js" | ||
| 61 | +// onError: fail | ||
| 62 | +type HooksConfig struct { | ||
| 63 | + PreGeneration []Hook `yaml:"preGeneration"` | ||
| 64 | + PreToolCall []Hook `yaml:"preToolCall"` | ||
| 65 | + PostToolCall []Hook `yaml:"postToolCall"` | ||
| 66 | + PostGeneration []Hook `yaml:"postGeneration"` | ||
| 67 | + | ||
| 68 | + // ConfigDir is the directory of the YAML file that declared the hooks, | ||
| 69 | + // absolute, set by Load. The hooks read it as MM_CONFIG_DIR: a hook | ||
| 70 | + // script shipped beside its agent.yaml is found whatever directory mm | ||
| 71 | + // was started from. | ||
| 72 | + ConfigDir string `yaml:"-"` | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +// Hook is one entry of an event's list. | ||
| 76 | +type Hook struct { | ||
| 77 | + // Command is run with `sh -c`, in the session's working directory. | ||
| 78 | + Command string `yaml:"command"` | ||
| 79 | + // Matcher is a regular expression on the tool name (preToolCall and | ||
| 80 | + // postToolCall only). Empty = every tool. Unanchored, like grep: | ||
| 81 | + // "bash" also matches "my_bash"; write "^bash$" for the tool alone. | ||
| 82 | + Matcher string `yaml:"matcher"` | ||
| 83 | + // Timeout bounds one run of the hook. 0 = DefaultHookTimeout. | ||
| 84 | + Timeout time.Duration `yaml:"timeout"` | ||
| 85 | + // OnError is fail, warn or ignore. Empty = warn. | ||
| 86 | + OnError string `yaml:"onError"` | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +// UnmarshalYAML reads the `hooks:` block and REJECTS an unknown event name. | ||
| 90 | +// A plain struct would ignore `preToolcall:` without a word, and a security | ||
| 91 | +// hook that silently never runs is worse than an agent that refuses to start. | ||
| 92 | +func (h *HooksConfig) UnmarshalYAML(node *yaml.Node) error { | ||
| 93 | + var lists map[string][]Hook | ||
| 94 | + if err := node.Decode(&lists); err != nil { | ||
| 95 | + return err | ||
| 96 | + } | ||
| 97 | + for event, list := range lists { | ||
| 98 | + slot := h.slot(event) | ||
| 99 | + if slot == nil { | ||
| 100 | + return fmt.Errorf("hooks: unknown event %q (want one of %s)", event, strings.Join(hookEvents, ", ")) | ||
| 101 | + } | ||
| 102 | + *slot = list | ||
| 103 | + } | ||
| 104 | + return nil | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +// slot returns the list that holds an event's hooks, nil for an unknown | ||
| 108 | +// event. | ||
| 109 | +func (h *HooksConfig) slot(event string) *[]Hook { | ||
| 110 | + switch event { | ||
| 111 | + case HookPreGeneration: | ||
| 112 | + return &h.PreGeneration | ||
| 113 | + case HookPreToolCall: | ||
| 114 | + return &h.PreToolCall | ||
| 115 | + case HookPostToolCall: | ||
| 116 | + return &h.PostToolCall | ||
| 117 | + case HookPostGeneration: | ||
| 118 | + return &h.PostGeneration | ||
| 119 | + } | ||
| 120 | + return nil | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +// For returns the hooks declared for an event, in order; nil for an event | ||
| 124 | +// with none (or an unknown one). | ||
| 125 | +// | ||
| 126 | +// for _, h := range config.Cfg.Hooks.For(config.HookPreToolCall) { | ||
| 127 | +// fmt.Println(h.Command) | ||
| 128 | +// } | ||
| 129 | +func (h HooksConfig) For(event string) []Hook { | ||
| 130 | + if slot := h.slot(event); slot != nil { | ||
| 131 | + return *slot | ||
| 132 | + } | ||
| 133 | + return nil | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +// Summary describes the declared hooks for the banner, one "event×count" | ||
| 137 | +// per event that has any, in event order: "preGeneration×1 preToolCall×2". | ||
| 138 | +// Empty when there is none. | ||
| 139 | +func (h HooksConfig) Summary() string { | ||
| 140 | + var parts []string | ||
| 141 | + for _, event := range hookEvents { | ||
| 142 | + if n := len(h.For(event)); n > 0 { | ||
| 143 | + parts = append(parts, fmt.Sprintf("%s×%d", event, n)) | ||
| 144 | + } | ||
| 145 | + } | ||
| 146 | + return strings.Join(parts, " ") | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +// Matches reports whether the hook applies to a tool. A hook without a | ||
| 150 | +// matcher applies to every tool. Load has rejected a pattern that does not | ||
| 151 | +// compile; one that reaches here anyway (a Config built by hand) matches | ||
| 152 | +// nothing rather than panicking in the middle of a turn. | ||
| 153 | +func (h Hook) Matches(tool string) bool { | ||
| 154 | + if h.Matcher == "" { | ||
| 155 | + return true | ||
| 156 | + } | ||
| 157 | + re, err := regexp.Compile(h.Matcher) | ||
| 158 | + return err == nil && re.MatchString(tool) | ||
| 159 | +} | ||
| 160 | + | ||
| 161 | +// validate checks every hook entry: a command, a matcher only where a tool | ||
| 162 | +// is involved and one that compiles, a timeout that is not negative, and a | ||
| 163 | +// known onError. | ||
| 164 | +func (h HooksConfig) validate() error { | ||
| 165 | + for _, event := range hookEvents { | ||
| 166 | + for i, hook := range h.For(event) { | ||
| 167 | + if err := hook.validate(event); err != nil { | ||
| 168 | + return fmt.Errorf("hooks.%s[%d]: %w", event, i, err) | ||
| 169 | + } | ||
| 170 | + } | ||
| 171 | + } | ||
| 172 | + return nil | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +func (h Hook) validate(event string) error { | ||
| 176 | + toolEvent := event == HookPreToolCall || event == HookPostToolCall | ||
| 177 | + switch { | ||
| 178 | + case strings.TrimSpace(h.Command) == "": | ||
| 179 | + return errors.New("command must not be empty") | ||
| 180 | + case h.Matcher != "" && !toolEvent: | ||
| 181 | + return fmt.Errorf("matcher is only for %s and %s", HookPreToolCall, HookPostToolCall) | ||
| 182 | + case h.Timeout < 0: | ||
| 183 | + return errors.New("timeout must be >= 0") | ||
| 184 | + } | ||
| 185 | + switch h.OnError { | ||
| 186 | + case "", HookOnErrorFail, HookOnErrorWarn, HookOnErrorIgnore: | ||
| 187 | + default: | ||
| 188 | + return fmt.Errorf("onError must be %q, %q or %q", HookOnErrorFail, HookOnErrorWarn, HookOnErrorIgnore) | ||
| 189 | + } | ||
| 190 | + if _, err := regexp.Compile(h.Matcher); err != nil { | ||
| 191 | + return fmt.Errorf("matcher: %w", err) | ||
| 192 | + } | ||
| 193 | + return nil | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +// resolveHooks fills what an entry may leave out (timeout, onError) and | ||
| 197 | +// records the directory of the file the hooks came from. | ||
| 198 | +func resolveHooks(configPath string) { | ||
| 199 | + if dir, err := filepath.Abs(filepath.Dir(configPath)); err == nil { | ||
| 200 | + Cfg.Hooks.ConfigDir = dir | ||
| 201 | + } | ||
| 202 | + for _, event := range hookEvents { | ||
| 203 | + list := Cfg.Hooks.For(event) | ||
| 204 | + for i := range list { | ||
| 205 | + if list[i].Timeout == 0 { | ||
| 206 | + list[i].Timeout = DefaultHookTimeout | ||
| 207 | + } | ||
| 208 | + if list[i].OnError == "" { | ||
| 209 | + list[i].OnError = HookOnErrorWarn | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + } | ||
| 213 | +} | ||
added
internal/config/hooks_test.go +103 -0 | new file mode 100644 | ||
| @@ -0,0 +1,103 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "path/filepath" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | + "time" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// No `hooks:` block: no hook, and an empty banner summary. | |
| 11 | +func TestHooksDefaultToNone(t *testing.T) { | |
| 12 | + if err := loadYAML(t, "model: m\n"); err != nil { | |
| 13 | + t.Fatal(err) | |
| 14 | + } | |
| 15 | + if got := Cfg.Hooks.Summary(); got != "" { | |
| 16 | + t.Errorf("Summary() = %q, want empty", got) | |
| 17 | + } | |
| 18 | +} | |
| 19 | + | |
| 20 | +// Every event is read, in order, and an entry's missing timeout and onError | |
| 21 | +// get their defaults. | |
| 22 | +func TestHooksAreReadWithTheirDefaults(t *testing.T) { | |
| 23 | + err := loadYAML(t, ` | |
| 24 | +hooks: | |
| 25 | + preGeneration: | |
| 26 | + - command: node a.js | |
| 27 | + preToolCall: | |
| 28 | + - matcher: ^bash$ | |
| 29 | + command: node b.js | |
| 30 | + timeout: 3s | |
| 31 | + onError: fail | |
| 32 | + - command: node c.js | |
| 33 | + postToolCall: | |
| 34 | + - command: node d.js | |
| 35 | + onError: ignore | |
| 36 | + postGeneration: | |
| 37 | + - command: node e.js | |
| 38 | +`) | |
| 39 | + if err != nil { | |
| 40 | + t.Fatal(err) | |
| 41 | + } | |
| 42 | + h := Cfg.Hooks | |
| 43 | + if got, want := h.Summary(), "preGeneration×1 preToolCall×2 postToolCall×1 postGeneration×1"; got != want { | |
| 44 | + t.Errorf("Summary() = %q, want %q", got, want) | |
| 45 | + } | |
| 46 | + first := h.For(HookPreToolCall)[0] | |
| 47 | + if first.Timeout != 3*time.Second || first.OnError != HookOnErrorFail || first.Matcher != "^bash$" { | |
| 48 | + t.Errorf("preToolCall[0] = %+v, want timeout 3s, onError fail, matcher ^bash$", first) | |
| 49 | + } | |
| 50 | + second := h.For(HookPreToolCall)[1] | |
| 51 | + if second.Timeout != DefaultHookTimeout || second.OnError != HookOnErrorWarn { | |
| 52 | + t.Errorf("preToolCall[1] = %+v, want the defaults (10s, warn)", second) | |
| 53 | + } | |
| 54 | + if !filepath.IsAbs(h.ConfigDir) { | |
| 55 | + t.Errorf("ConfigDir = %q, want an absolute directory", h.ConfigDir) | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +// Each mistake is refused at start-up with the entry it is in, rather than | |
| 60 | +// discovered when the hook silently does not run. | |
| 61 | +func TestInvalidHooksAreRejected(t *testing.T) { | |
| 62 | + cases := map[string]struct{ yaml, want string }{ | |
| 63 | + "unknown event": { | |
| 64 | + "hooks:\n preToolcall:\n - command: x\n", `unknown event "preToolcall"`}, | |
| 65 | + "empty command": { | |
| 66 | + "hooks:\n preGeneration:\n - command: \" \"\n", "hooks.preGeneration[0]: command must not be empty"}, | |
| 67 | + "matcher outside tool events": { | |
| 68 | + "hooks:\n preGeneration:\n - command: x\n matcher: bash\n", "matcher is only for"}, | |
| 69 | + "bad matcher": { | |
| 70 | + "hooks:\n postToolCall:\n - command: x\n matcher: \"(\"\n", "hooks.postToolCall[0]: matcher:"}, | |
| 71 | + "negative timeout": { | |
| 72 | + "hooks:\n postGeneration:\n - command: x\n timeout: -1s\n", "timeout must be >= 0"}, | |
| 73 | + "unknown onError": { | |
| 74 | + "hooks:\n preToolCall:\n - command: x\n onError: retry\n", "onError must be"}, | |
| 75 | + } | |
| 76 | + for name, tc := range cases { | |
| 77 | + t.Run(name, func(t *testing.T) { | |
| 78 | + err := loadYAML(t, tc.yaml) | |
| 79 | + if err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 80 | + t.Errorf("Load() error = %v, want it to contain %q", err, tc.want) | |
| 81 | + } | |
| 82 | + }) | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +func TestHookMatches(t *testing.T) { | |
| 87 | + cases := []struct { | |
| 88 | + matcher, tool string | |
| 89 | + want bool | |
| 90 | + }{ | |
| 91 | + {"", "bash", true}, | |
| 92 | + {"^bash$", "bash", true}, | |
| 93 | + {"^bash$", "my_bash", false}, | |
| 94 | + {"bash", "my_bash", true}, | |
| 95 | + {"^(write|edit)_file$", "edit_file", true}, | |
| 96 | + {"(", "bash", false}, // never validated: matches nothing, no panic | |
| 97 | + } | |
| 98 | + for _, tc := range cases { | |
| 99 | + if got := (Hook{Matcher: tc.matcher}).Matches(tc.tool); got != tc.want { | |
| 100 | + t.Errorf("Hook{Matcher: %q}.Matches(%q) = %v, want %v", tc.matcher, tc.tool, got, tc.want) | |
| 101 | + } | |
| 102 | + } | |
| 103 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,103 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "path/filepath" | ||
| 5 | + "strings" | ||
| 6 | + "testing" | ||
| 7 | + "time" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// No `hooks:` block: no hook, and an empty banner summary. | ||
| 11 | +func TestHooksDefaultToNone(t *testing.T) { | ||
| 12 | + if err := loadYAML(t, "model: m\n"); err != nil { | ||
| 13 | + t.Fatal(err) | ||
| 14 | + } | ||
| 15 | + if got := Cfg.Hooks.Summary(); got != "" { | ||
| 16 | + t.Errorf("Summary() = %q, want empty", got) | ||
| 17 | + } | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +// Every event is read, in order, and an entry's missing timeout and onError | ||
| 21 | +// get their defaults. | ||
| 22 | +func TestHooksAreReadWithTheirDefaults(t *testing.T) { | ||
| 23 | + err := loadYAML(t, ` | ||
| 24 | +hooks: | ||
| 25 | + preGeneration: | ||
| 26 | + - command: node a.js | ||
| 27 | + preToolCall: | ||
| 28 | + - matcher: ^bash$ | ||
| 29 | + command: node b.js | ||
| 30 | + timeout: 3s | ||
| 31 | + onError: fail | ||
| 32 | + - command: node c.js | ||
| 33 | + postToolCall: | ||
| 34 | + - command: node d.js | ||
| 35 | + onError: ignore | ||
| 36 | + postGeneration: | ||
| 37 | + - command: node e.js | ||
| 38 | +`) | ||
| 39 | + if err != nil { | ||
| 40 | + t.Fatal(err) | ||
| 41 | + } | ||
| 42 | + h := Cfg.Hooks | ||
| 43 | + if got, want := h.Summary(), "preGeneration×1 preToolCall×2 postToolCall×1 postGeneration×1"; got != want { | ||
| 44 | + t.Errorf("Summary() = %q, want %q", got, want) | ||
| 45 | + } | ||
| 46 | + first := h.For(HookPreToolCall)[0] | ||
| 47 | + if first.Timeout != 3*time.Second || first.OnError != HookOnErrorFail || first.Matcher != "^bash$" { | ||
| 48 | + t.Errorf("preToolCall[0] = %+v, want timeout 3s, onError fail, matcher ^bash$", first) | ||
| 49 | + } | ||
| 50 | + second := h.For(HookPreToolCall)[1] | ||
| 51 | + if second.Timeout != DefaultHookTimeout || second.OnError != HookOnErrorWarn { | ||
| 52 | + t.Errorf("preToolCall[1] = %+v, want the defaults (10s, warn)", second) | ||
| 53 | + } | ||
| 54 | + if !filepath.IsAbs(h.ConfigDir) { | ||
| 55 | + t.Errorf("ConfigDir = %q, want an absolute directory", h.ConfigDir) | ||
| 56 | + } | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// Each mistake is refused at start-up with the entry it is in, rather than | ||
| 60 | +// discovered when the hook silently does not run. | ||
| 61 | +func TestInvalidHooksAreRejected(t *testing.T) { | ||
| 62 | + cases := map[string]struct{ yaml, want string }{ | ||
| 63 | + "unknown event": { | ||
| 64 | + "hooks:\n preToolcall:\n - command: x\n", `unknown event "preToolcall"`}, | ||
| 65 | + "empty command": { | ||
| 66 | + "hooks:\n preGeneration:\n - command: \" \"\n", "hooks.preGeneration[0]: command must not be empty"}, | ||
| 67 | + "matcher outside tool events": { | ||
| 68 | + "hooks:\n preGeneration:\n - command: x\n matcher: bash\n", "matcher is only for"}, | ||
| 69 | + "bad matcher": { | ||
| 70 | + "hooks:\n postToolCall:\n - command: x\n matcher: \"(\"\n", "hooks.postToolCall[0]: matcher:"}, | ||
| 71 | + "negative timeout": { | ||
| 72 | + "hooks:\n postGeneration:\n - command: x\n timeout: -1s\n", "timeout must be >= 0"}, | ||
| 73 | + "unknown onError": { | ||
| 74 | + "hooks:\n preToolCall:\n - command: x\n onError: retry\n", "onError must be"}, | ||
| 75 | + } | ||
| 76 | + for name, tc := range cases { | ||
| 77 | + t.Run(name, func(t *testing.T) { | ||
| 78 | + err := loadYAML(t, tc.yaml) | ||
| 79 | + if err == nil || !strings.Contains(err.Error(), tc.want) { | ||
| 80 | + t.Errorf("Load() error = %v, want it to contain %q", err, tc.want) | ||
| 81 | + } | ||
| 82 | + }) | ||
| 83 | + } | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +func TestHookMatches(t *testing.T) { | ||
| 87 | + cases := []struct { | ||
| 88 | + matcher, tool string | ||
| 89 | + want bool | ||
| 90 | + }{ | ||
| 91 | + {"", "bash", true}, | ||
| 92 | + {"^bash$", "bash", true}, | ||
| 93 | + {"^bash$", "my_bash", false}, | ||
| 94 | + {"bash", "my_bash", true}, | ||
| 95 | + {"^(write|edit)_file$", "edit_file", true}, | ||
| 96 | + {"(", "bash", false}, // never validated: matches nothing, no panic | ||
| 97 | + } | ||
| 98 | + for _, tc := range cases { | ||
| 99 | + if got := (Hook{Matcher: tc.matcher}).Matches(tc.tool); got != tc.want { | ||
| 100 | + t.Errorf("Hook{Matcher: %q}.Matches(%q) = %v, want %v", tc.matcher, tc.tool, got, tc.want) | ||
| 101 | + } | ||
| 102 | + } | ||
| 103 | +} | ||
modified
internal/engine/engine.go +22 -2 | @@ -21,6 +21,7 @@ import ( | ||
| 21 | 21 | "sync/atomic" |
| 22 | 22 | |
| 23 | 23 | "mm/internal/config" |
| 24 | + "mm/internal/hooks" | |
| 24 | 25 | "mm/internal/spinner" |
| 25 | 26 | "mm/internal/ui" |
| 26 | 27 | |
| @@ -50,6 +51,10 @@ type Engine struct { | ||
| 50 | 51 | // recent call to the model, captured in Generate's WrapModel hook. It is |
| 51 | 52 | // the exact figure the compression trigger prefers over its estimate. |
| 52 | 53 | lastInput atomic.Int64 |
| 54 | + | |
| 55 | + // hooks runs the `hooks:` block (hooks.go). nil — an Engine built by | |
| 56 | + // hand, in a test — runs none. | |
| 57 | + hooks *hooks.Runner | |
| 53 | 58 | } |
| 54 | 59 | |
| 55 | 60 | // New reads the provider named in the config, resolves its backend (URL, key, |
| @@ -69,7 +74,8 @@ func New(ctx context.Context) (*Engine, error) { | ||
| 69 | 74 | if err != nil { |
| 70 | 75 | return nil, err |
| 71 | 76 | } |
| 72 | - return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow}, nil | |
| 77 | + return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow, | |
| 78 | + hooks: hooks.New(config.Cfg.Hooks, ui.Notice)}, nil | |
| 73 | 79 | } |
| 74 | 80 | |
| 75 | 81 | // Probe asks the provider what it can learn about the server BEFORE the first |
| @@ -241,6 +247,15 @@ func squeeze(s string) string { | ||
| 241 | 247 | // is hung and we cancel the context. This is the "silent loop" of |
| 242 | 248 | // STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one. |
| 243 | 249 | func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { |
| 250 | + if e.hooks.Enabled() { | |
| 251 | + return e.generateWithHooks(ctx, messages, tools) | |
| 252 | + } | |
| 253 | + return e.generate(ctx, messages, tools) | |
| 254 | +} | |
| 255 | + | |
| 256 | +// generate is Generate without the generation hooks (hooks.go): the turn | |
| 257 | +// itself. The tool hooks run inside it, in the WrapTool middleware. | |
| 258 | +func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { | |
| 244 | 259 | ctx = WithConversation(ctx, messages) |
| 245 | 260 | // Genkit only hands us back the LAST message. The intermediate turns — "I |
| 246 | 261 | // am calling bash", "here is the output" — exist only in the request it |
| @@ -340,9 +355,14 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 340 | 355 | // whitespace the previous text left pending. The hook lands at |
| 341 | 356 | // exactly the right moment — just before the tool prints its 🛠️ |
| 342 | 357 | // line. |
| 358 | + // The tool hooks run here too (hooks.go): one place sees every | |
| 359 | + // call, built-in and MCP, before the tool asks for consent. | |
| 343 | 360 | WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) { |
| 344 | 361 | gaps.drop() |
| 345 | - return next(ctx, params) | |
| 362 | + if !e.hooks.Enabled() { | |
| 363 | + return next(ctx, params) | |
| 364 | + } | |
| 365 | + return e.runToolHooks(ctx, params, next) | |
| 346 | 366 | }, |
| 347 | 367 | }, nil |
| 348 | 368 | }) |
| @@ -21,6 +21,7 @@ import ( | |||
| 21 | "sync/atomic" | 21 | "sync/atomic" |
| 22 | 22 | ||
| 23 | "mm/internal/config" | 23 | "mm/internal/config" |
| 24 | + "mm/internal/hooks" | ||
| 24 | "mm/internal/spinner" | 25 | "mm/internal/spinner" |
| 25 | "mm/internal/ui" | 26 | "mm/internal/ui" |
| 26 | 27 | ||
| @@ -50,6 +51,10 @@ type Engine struct { | |||
| 50 | // recent call to the model, captured in Generate's WrapModel hook. It is | 51 | // recent call to the model, captured in Generate's WrapModel hook. It is |
| 51 | // the exact figure the compression trigger prefers over its estimate. | 52 | // the exact figure the compression trigger prefers over its estimate. |
| 52 | lastInput atomic.Int64 | 53 | lastInput atomic.Int64 |
| 54 | + | ||
| 55 | + // hooks runs the `hooks:` block (hooks.go). nil — an Engine built by | ||
| 56 | + // hand, in a test — runs none. | ||
| 57 | + hooks *hooks.Runner | ||
| 53 | } | 58 | } |
| 54 | 59 | ||
| 55 | // New reads the provider named in the config, resolves its backend (URL, key, | 60 | // New reads the provider named in the config, resolves its backend (URL, key, |
| @@ -69,7 +74,8 @@ func New(ctx context.Context) (*Engine, error) { | |||
| 69 | if err != nil { | 74 | if err != nil { |
| 70 | return nil, err | 75 | return nil, err |
| 71 | } | 76 | } |
| 72 | - return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow}, nil | 77 | + return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow, |
| 78 | + hooks: hooks.New(config.Cfg.Hooks, ui.Notice)}, nil | ||
| 73 | } | 79 | } |
| 74 | 80 | ||
| 75 | // Probe asks the provider what it can learn about the server BEFORE the first | 81 | // Probe asks the provider what it can learn about the server BEFORE the first |
| @@ -241,6 +247,15 @@ func squeeze(s string) string { | |||
| 241 | // is hung and we cancel the context. This is the "silent loop" of | 247 | // is hung and we cancel the context. This is the "silent loop" of |
| 242 | // STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one. | 248 | // STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one. |
| 243 | func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { | 249 | func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { |
| 250 | + if e.hooks.Enabled() { | ||
| 251 | + return e.generateWithHooks(ctx, messages, tools) | ||
| 252 | + } | ||
| 253 | + return e.generate(ctx, messages, tools) | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +// generate is Generate without the generation hooks (hooks.go): the turn | ||
| 257 | +// itself. The tool hooks run inside it, in the WrapTool middleware. | ||
| 258 | +func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { | ||
| 244 | ctx = WithConversation(ctx, messages) | 259 | ctx = WithConversation(ctx, messages) |
| 245 | // Genkit only hands us back the LAST message. The intermediate turns — "I | 260 | // Genkit only hands us back the LAST message. The intermediate turns — "I |
| 246 | // am calling bash", "here is the output" — exist only in the request it | 261 | // am calling bash", "here is the output" — exist only in the request it |
| @@ -340,9 +355,14 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | |||
| 340 | // whitespace the previous text left pending. The hook lands at | 355 | // whitespace the previous text left pending. The hook lands at |
| 341 | // exactly the right moment — just before the tool prints its 🛠️ | 356 | // exactly the right moment — just before the tool prints its 🛠️ |
| 342 | // line. | 357 | // line. |
| 358 | + // The tool hooks run here too (hooks.go): one place sees every | ||
| 359 | + // call, built-in and MCP, before the tool asks for consent. | ||
| 343 | WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) { | 360 | WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) { |
| 344 | gaps.drop() | 361 | gaps.drop() |
| 345 | - return next(ctx, params) | 362 | + if !e.hooks.Enabled() { |
| 363 | + return next(ctx, params) | ||
| 364 | + } | ||
| 365 | + return e.runToolHooks(ctx, params, next) | ||
| 346 | }, | 366 | }, |
| 347 | }, nil | 367 | }, nil |
| 348 | }) | 368 | }) |
added
internal/engine/hooks.go +230 -0 | new file mode 100644 | ||
| @@ -0,0 +1,230 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "os" | |
| 8 | + "slices" | |
| 9 | + "strings" | |
| 10 | + | |
| 11 | + "mm/internal/hooks" | |
| 12 | + "mm/internal/ui" | |
| 13 | + | |
| 14 | + "github.com/firebase/genkit/go/ai" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// The hooks of the `hooks:` block (ticket 36) plug in at the two places every | |
| 18 | +// front end goes through — REPL, window, ACP, -p alike: | |
| 19 | +// | |
| 20 | +// - Generate: preGeneration on the question before the first model call, | |
| 21 | +// postGeneration once the turn is over; | |
| 22 | +// - the WrapTool middleware: preToolCall and postToolCall around EVERY tool | |
| 23 | +// call, built-in and MCP, before the tool's own consent prompt. | |
| 24 | +// | |
| 25 | +// internal/hooks runs the commands and decides; this file applies the | |
| 26 | +// decisions to Genkit's messages and shows them. | |
| 27 | + | |
| 28 | +// blockedPrompt replaces a question a preGeneration hook stopped, in the | |
| 29 | +// history. The question itself is never kept: a hook that stops a prompt | |
| 30 | +// because it holds a secret must not have it sent on the NEXT turn. | |
| 31 | +const blockedPrompt = "[prompt blocked by a hook" | |
| 32 | + | |
| 33 | +// hookContextLabel introduces the text a hook adds for the model, so the | |
| 34 | +// model — and whoever reads a session file — can tell it from what the user | |
| 35 | +// typed or what the tool printed. | |
| 36 | +const hookContextLabel = "[context added by a hook]\n" | |
| 37 | + | |
| 38 | +// generateWithHooks wraps generate with the two generation events. Nothing | |
| 39 | +// here runs when no hook is declared. | |
| 40 | +func (e *Engine) generateWithHooks(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { | |
| 41 | + question := lastUserMessage(messages) | |
| 42 | + if question == nil { | |
| 43 | + return e.generate(ctx, messages, tools) | |
| 44 | + } | |
| 45 | + base := e.hookInput() | |
| 46 | + | |
| 47 | + in := base | |
| 48 | + in.Prompt = question.Text() | |
| 49 | + d := e.hooks.PreGeneration(ctx, in) | |
| 50 | + | |
| 51 | + var ( | |
| 52 | + resp *ai.ModelResponse | |
| 53 | + full []*ai.Message | |
| 54 | + err error | |
| 55 | + ) | |
| 56 | + after := base | |
| 57 | + after.Prompt = d.Prompt | |
| 58 | + if d.Stopped { | |
| 59 | + resp, full = blockedTurn(messages, d) | |
| 60 | + // The post hooks read the marker too, never the blocked text: an | |
| 61 | + // audit hook would otherwise write the secret to its log. | |
| 62 | + after.Prompt = blockedMarker(d.Reason) | |
| 63 | + say(d.Response) | |
| 64 | + } else { | |
| 65 | + if d.Changed(in.Prompt) { | |
| 66 | + messages = withQuestion(messages, rewrittenQuestion(question, d)) | |
| 67 | + } | |
| 68 | + resp, full, err = e.generate(ctx, messages, tools) | |
| 69 | + } | |
| 70 | + | |
| 71 | + if resp != nil { | |
| 72 | + after.Answer = resp.Text() | |
| 73 | + } | |
| 74 | + if err != nil { | |
| 75 | + after.Error = err.Error() | |
| 76 | + } | |
| 77 | + // Not the turn's context: a cancelled turn (Esc, Ctrl-C, /abort) is | |
| 78 | + // one an audit hook must still see. The hook's own timeout bounds it. | |
| 79 | + e.hooks.PostGeneration(context.WithoutCancel(ctx), after) | |
| 80 | + return resp, full, err | |
| 81 | +} | |
| 82 | + | |
| 83 | +// hookInput fills what every event shares: where the session works, and | |
| 84 | +// which model answers. | |
| 85 | +func (e *Engine) hookInput() hooks.Input { | |
| 86 | + return hooks.Input{Cwd: workDir(), Model: e.Model} | |
| 87 | +} | |
| 88 | + | |
| 89 | +// workDir is the session's working directory: the sink's (an editor's | |
| 90 | +// session cwd over ACP), else the process's own. | |
| 91 | +func workDir() string { | |
| 92 | + if s := ui.ActiveSink(); s != nil && s.WorkDir() != "" { | |
| 93 | + return s.WorkDir() | |
| 94 | + } | |
| 95 | + dir, _ := os.Getwd() | |
| 96 | + return dir | |
| 97 | +} | |
| 98 | + | |
| 99 | +// lastUserMessage returns the question of the turn — the last message, when | |
| 100 | +// it is the user's — or nil (a history that ends otherwise has no question | |
| 101 | +// for preGeneration to look at). | |
| 102 | +func lastUserMessage(messages []*ai.Message) *ai.Message { | |
| 103 | + if len(messages) == 0 { | |
| 104 | + return nil | |
| 105 | + } | |
| 106 | + last := messages[len(messages)-1] | |
| 107 | + if last == nil || last.Role != ai.RoleUser { | |
| 108 | + return nil | |
| 109 | + } | |
| 110 | + return last | |
| 111 | +} | |
| 112 | + | |
| 113 | +// withQuestion returns a copy of messages whose last one is q: the front | |
| 114 | +// end's slice is left as it was, and the history Generate returns carries the | |
| 115 | +// rewritten question — the one the model actually read. | |
| 116 | +func withQuestion(messages []*ai.Message, q *ai.Message) []*ai.Message { | |
| 117 | + out := slices.Clone(messages) | |
| 118 | + out[len(out)-1] = q | |
| 119 | + return out | |
| 120 | +} | |
| 121 | + | |
| 122 | +// rewrittenQuestion builds the question the model reads: the hooks' prompt, | |
| 123 | +// then each context they added. Parts that are not text (an image) are kept. | |
| 124 | +func rewrittenQuestion(original *ai.Message, d hooks.PromptDecision) *ai.Message { | |
| 125 | + var parts []*ai.Part | |
| 126 | + for _, p := range original.Content { | |
| 127 | + if !p.IsText() { | |
| 128 | + parts = append(parts, p) | |
| 129 | + } | |
| 130 | + } | |
| 131 | + parts = append(parts, ai.NewTextPart(d.Prompt)) | |
| 132 | + for _, c := range d.Context { | |
| 133 | + parts = append(parts, ai.NewTextPart("\n\n"+hookContextLabel+c)) | |
| 134 | + } | |
| 135 | + return ai.NewUserMessage(parts...) | |
| 136 | +} | |
| 137 | + | |
| 138 | +// blockedTurn is the turn a preGeneration hook stopped: no model call, the | |
| 139 | +// question replaced by a marker, the hook's response as the answer. The | |
| 140 | +// returned history is one message longer than the question, so every front | |
| 141 | +// end keeps it (session.AfterTurn) — marker included. | |
| 142 | +func blockedTurn(messages []*ai.Message, d hooks.PromptDecision) (*ai.ModelResponse, []*ai.Message) { | |
| 143 | + answer := ai.NewModelTextMessage(d.Response) | |
| 144 | + full := append(slices.Clone(messages[:len(messages)-1]), ai.NewUserTextMessage(blockedMarker(d.Reason)), answer) | |
| 145 | + return &ai.ModelResponse{Message: answer, FinishReason: ai.FinishReasonStop}, full | |
| 146 | +} | |
| 147 | + | |
| 148 | +// blockedMarker is what stands for a stopped question, in the history and | |
| 149 | +// for the postGeneration hooks. | |
| 150 | +func blockedMarker(reason string) string { | |
| 151 | + if reason == "" { | |
| 152 | + return blockedPrompt + "]" | |
| 153 | + } | |
| 154 | + return blockedPrompt + ": " + reason + "]" | |
| 155 | +} | |
| 156 | + | |
| 157 | +// say shows text as the agent's answer, the way a streamed answer is shown: | |
| 158 | +// the sink's Text (window, editor) or the terminal's 🤖 line. | |
| 159 | +func say(text string) { | |
| 160 | + if s := ui.ActiveSink(); s != nil { | |
| 161 | + s.Text(text) | |
| 162 | + return | |
| 163 | + } | |
| 164 | + fmt.Print("🤖 " + text) | |
| 165 | +} | |
| 166 | + | |
| 167 | +// runToolHooks is the WrapTool side: preToolCall may deny the call or | |
| 168 | +// replace its input, postToolCall may add context to its output. | |
| 169 | +func (e *Engine) runToolHooks(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) { | |
| 170 | + in := e.hookInput() | |
| 171 | + in.Tool = &hooks.ToolCall{ID: params.Request.Ref, Name: params.Request.Name, Input: params.Request.Input} | |
| 172 | + | |
| 173 | + d := e.hooks.PreToolCall(ctx, in) | |
| 174 | + if d.Denied { | |
| 175 | + return deniedCall(in.Tool, d.Reason), nil | |
| 176 | + } | |
| 177 | + // A rebuilt request rather than an edited one: params.Request is the | |
| 178 | + // model's own tool request, and the history must say what it asked for. | |
| 179 | + req := *params.Request | |
| 180 | + req.Input = d.Input | |
| 181 | + resp, err := next(ctx, &ai.ToolParams{Request: &req, Tool: params.Tool}) | |
| 182 | + if err != nil || resp == nil { | |
| 183 | + return resp, err | |
| 184 | + } | |
| 185 | + | |
| 186 | + in.Tool.Input = d.Input | |
| 187 | + in.Output = outputText(resp.Output) | |
| 188 | + if added := e.hooks.PostToolCall(ctx, in); len(added) > 0 { | |
| 189 | + resp.Output = in.Output + "\n\n" + hookContextLabel + strings.Join(added, "\n\n") | |
| 190 | + } | |
| 191 | + return resp, nil | |
| 192 | +} | |
| 193 | + | |
| 194 | +// deniedCall is what the model reads instead of the tool's output — a | |
| 195 | +// failure as text, like every tool failure — and what the user sees: a failed | |
| 196 | +// tool entry in the window and the editor, a line in the terminal. | |
| 197 | +func deniedCall(call *hooks.ToolCall, reason string) *ai.MultipartToolResponse { | |
| 198 | + text := "[denied by a hook]" | |
| 199 | + if reason != "" { | |
| 200 | + text = "[denied by a hook: " + reason + "]" | |
| 201 | + } | |
| 202 | + if s := ui.ActiveSink(); s != nil { | |
| 203 | + ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: call.Name, Title: call.Name + " — denied by a hook", Kind: "other", Input: inputMap(call.Input)} | |
| 204 | + s.ToolStart(ev) | |
| 205 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: text, Failed: true}) | |
| 206 | + } else { | |
| 207 | + ui.Notice("🪝 " + call.Name + " " + text) | |
| 208 | + } | |
| 209 | + return &ai.MultipartToolResponse{Output: text} | |
| 210 | +} | |
| 211 | + | |
| 212 | +// inputMap gives a client the call's arguments as an object when they are | |
| 213 | +// one, nil otherwise. | |
| 214 | +func inputMap(input any) map[string]any { | |
| 215 | + m, _ := input.(map[string]any) | |
| 216 | + return m | |
| 217 | +} | |
| 218 | + | |
| 219 | +// outputText is a tool's output as the postToolCall hook reads it — and as | |
| 220 | +// the model reads it once context is added: the text itself, or its JSON. | |
| 221 | +func outputText(output any) string { | |
| 222 | + if s, ok := output.(string); ok { | |
| 223 | + return s | |
| 224 | + } | |
| 225 | + data, err := json.Marshal(output) | |
| 226 | + if err != nil { | |
| 227 | + return fmt.Sprint(output) | |
| 228 | + } | |
| 229 | + return string(data) | |
| 230 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,230 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "fmt" | ||
| 7 | + "os" | ||
| 8 | + "slices" | ||
| 9 | + "strings" | ||
| 10 | + | ||
| 11 | + "mm/internal/hooks" | ||
| 12 | + "mm/internal/ui" | ||
| 13 | + | ||
| 14 | + "github.com/firebase/genkit/go/ai" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// The hooks of the `hooks:` block (ticket 36) plug in at the two places every | ||
| 18 | +// front end goes through — REPL, window, ACP, -p alike: | ||
| 19 | +// | ||
| 20 | +// - Generate: preGeneration on the question before the first model call, | ||
| 21 | +// postGeneration once the turn is over; | ||
| 22 | +// - the WrapTool middleware: preToolCall and postToolCall around EVERY tool | ||
| 23 | +// call, built-in and MCP, before the tool's own consent prompt. | ||
| 24 | +// | ||
| 25 | +// internal/hooks runs the commands and decides; this file applies the | ||
| 26 | +// decisions to Genkit's messages and shows them. | ||
| 27 | + | ||
| 28 | +// blockedPrompt replaces a question a preGeneration hook stopped, in the | ||
| 29 | +// history. The question itself is never kept: a hook that stops a prompt | ||
| 30 | +// because it holds a secret must not have it sent on the NEXT turn. | ||
| 31 | +const blockedPrompt = "[prompt blocked by a hook" | ||
| 32 | + | ||
| 33 | +// hookContextLabel introduces the text a hook adds for the model, so the | ||
| 34 | +// model — and whoever reads a session file — can tell it from what the user | ||
| 35 | +// typed or what the tool printed. | ||
| 36 | +const hookContextLabel = "[context added by a hook]\n" | ||
| 37 | + | ||
| 38 | +// generateWithHooks wraps generate with the two generation events. Nothing | ||
| 39 | +// here runs when no hook is declared. | ||
| 40 | +func (e *Engine) generateWithHooks(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { | ||
| 41 | + question := lastUserMessage(messages) | ||
| 42 | + if question == nil { | ||
| 43 | + return e.generate(ctx, messages, tools) | ||
| 44 | + } | ||
| 45 | + base := e.hookInput() | ||
| 46 | + | ||
| 47 | + in := base | ||
| 48 | + in.Prompt = question.Text() | ||
| 49 | + d := e.hooks.PreGeneration(ctx, in) | ||
| 50 | + | ||
| 51 | + var ( | ||
| 52 | + resp *ai.ModelResponse | ||
| 53 | + full []*ai.Message | ||
| 54 | + err error | ||
| 55 | + ) | ||
| 56 | + after := base | ||
| 57 | + after.Prompt = d.Prompt | ||
| 58 | + if d.Stopped { | ||
| 59 | + resp, full = blockedTurn(messages, d) | ||
| 60 | + // The post hooks read the marker too, never the blocked text: an | ||
| 61 | + // audit hook would otherwise write the secret to its log. | ||
| 62 | + after.Prompt = blockedMarker(d.Reason) | ||
| 63 | + say(d.Response) | ||
| 64 | + } else { | ||
| 65 | + if d.Changed(in.Prompt) { | ||
| 66 | + messages = withQuestion(messages, rewrittenQuestion(question, d)) | ||
| 67 | + } | ||
| 68 | + resp, full, err = e.generate(ctx, messages, tools) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + if resp != nil { | ||
| 72 | + after.Answer = resp.Text() | ||
| 73 | + } | ||
| 74 | + if err != nil { | ||
| 75 | + after.Error = err.Error() | ||
| 76 | + } | ||
| 77 | + // Not the turn's context: a cancelled turn (Esc, Ctrl-C, /abort) is | ||
| 78 | + // one an audit hook must still see. The hook's own timeout bounds it. | ||
| 79 | + e.hooks.PostGeneration(context.WithoutCancel(ctx), after) | ||
| 80 | + return resp, full, err | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +// hookInput fills what every event shares: where the session works, and | ||
| 84 | +// which model answers. | ||
| 85 | +func (e *Engine) hookInput() hooks.Input { | ||
| 86 | + return hooks.Input{Cwd: workDir(), Model: e.Model} | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +// workDir is the session's working directory: the sink's (an editor's | ||
| 90 | +// session cwd over ACP), else the process's own. | ||
| 91 | +func workDir() string { | ||
| 92 | + if s := ui.ActiveSink(); s != nil && s.WorkDir() != "" { | ||
| 93 | + return s.WorkDir() | ||
| 94 | + } | ||
| 95 | + dir, _ := os.Getwd() | ||
| 96 | + return dir | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +// lastUserMessage returns the question of the turn — the last message, when | ||
| 100 | +// it is the user's — or nil (a history that ends otherwise has no question | ||
| 101 | +// for preGeneration to look at). | ||
| 102 | +func lastUserMessage(messages []*ai.Message) *ai.Message { | ||
| 103 | + if len(messages) == 0 { | ||
| 104 | + return nil | ||
| 105 | + } | ||
| 106 | + last := messages[len(messages)-1] | ||
| 107 | + if last == nil || last.Role != ai.RoleUser { | ||
| 108 | + return nil | ||
| 109 | + } | ||
| 110 | + return last | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +// withQuestion returns a copy of messages whose last one is q: the front | ||
| 114 | +// end's slice is left as it was, and the history Generate returns carries the | ||
| 115 | +// rewritten question — the one the model actually read. | ||
| 116 | +func withQuestion(messages []*ai.Message, q *ai.Message) []*ai.Message { | ||
| 117 | + out := slices.Clone(messages) | ||
| 118 | + out[len(out)-1] = q | ||
| 119 | + return out | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +// rewrittenQuestion builds the question the model reads: the hooks' prompt, | ||
| 123 | +// then each context they added. Parts that are not text (an image) are kept. | ||
| 124 | +func rewrittenQuestion(original *ai.Message, d hooks.PromptDecision) *ai.Message { | ||
| 125 | + var parts []*ai.Part | ||
| 126 | + for _, p := range original.Content { | ||
| 127 | + if !p.IsText() { | ||
| 128 | + parts = append(parts, p) | ||
| 129 | + } | ||
| 130 | + } | ||
| 131 | + parts = append(parts, ai.NewTextPart(d.Prompt)) | ||
| 132 | + for _, c := range d.Context { | ||
| 133 | + parts = append(parts, ai.NewTextPart("\n\n"+hookContextLabel+c)) | ||
| 134 | + } | ||
| 135 | + return ai.NewUserMessage(parts...) | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +// blockedTurn is the turn a preGeneration hook stopped: no model call, the | ||
| 139 | +// question replaced by a marker, the hook's response as the answer. The | ||
| 140 | +// returned history is one message longer than the question, so every front | ||
| 141 | +// end keeps it (session.AfterTurn) — marker included. | ||
| 142 | +func blockedTurn(messages []*ai.Message, d hooks.PromptDecision) (*ai.ModelResponse, []*ai.Message) { | ||
| 143 | + answer := ai.NewModelTextMessage(d.Response) | ||
| 144 | + full := append(slices.Clone(messages[:len(messages)-1]), ai.NewUserTextMessage(blockedMarker(d.Reason)), answer) | ||
| 145 | + return &ai.ModelResponse{Message: answer, FinishReason: ai.FinishReasonStop}, full | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +// blockedMarker is what stands for a stopped question, in the history and | ||
| 149 | +// for the postGeneration hooks. | ||
| 150 | +func blockedMarker(reason string) string { | ||
| 151 | + if reason == "" { | ||
| 152 | + return blockedPrompt + "]" | ||
| 153 | + } | ||
| 154 | + return blockedPrompt + ": " + reason + "]" | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +// say shows text as the agent's answer, the way a streamed answer is shown: | ||
| 158 | +// the sink's Text (window, editor) or the terminal's 🤖 line. | ||
| 159 | +func say(text string) { | ||
| 160 | + if s := ui.ActiveSink(); s != nil { | ||
| 161 | + s.Text(text) | ||
| 162 | + return | ||
| 163 | + } | ||
| 164 | + fmt.Print("🤖 " + text) | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +// runToolHooks is the WrapTool side: preToolCall may deny the call or | ||
| 168 | +// replace its input, postToolCall may add context to its output. | ||
| 169 | +func (e *Engine) runToolHooks(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) { | ||
| 170 | + in := e.hookInput() | ||
| 171 | + in.Tool = &hooks.ToolCall{ID: params.Request.Ref, Name: params.Request.Name, Input: params.Request.Input} | ||
| 172 | + | ||
| 173 | + d := e.hooks.PreToolCall(ctx, in) | ||
| 174 | + if d.Denied { | ||
| 175 | + return deniedCall(in.Tool, d.Reason), nil | ||
| 176 | + } | ||
| 177 | + // A rebuilt request rather than an edited one: params.Request is the | ||
| 178 | + // model's own tool request, and the history must say what it asked for. | ||
| 179 | + req := *params.Request | ||
| 180 | + req.Input = d.Input | ||
| 181 | + resp, err := next(ctx, &ai.ToolParams{Request: &req, Tool: params.Tool}) | ||
| 182 | + if err != nil || resp == nil { | ||
| 183 | + return resp, err | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + in.Tool.Input = d.Input | ||
| 187 | + in.Output = outputText(resp.Output) | ||
| 188 | + if added := e.hooks.PostToolCall(ctx, in); len(added) > 0 { | ||
| 189 | + resp.Output = in.Output + "\n\n" + hookContextLabel + strings.Join(added, "\n\n") | ||
| 190 | + } | ||
| 191 | + return resp, nil | ||
| 192 | +} | ||
| 193 | + | ||
| 194 | +// deniedCall is what the model reads instead of the tool's output — a | ||
| 195 | +// failure as text, like every tool failure — and what the user sees: a failed | ||
| 196 | +// tool entry in the window and the editor, a line in the terminal. | ||
| 197 | +func deniedCall(call *hooks.ToolCall, reason string) *ai.MultipartToolResponse { | ||
| 198 | + text := "[denied by a hook]" | ||
| 199 | + if reason != "" { | ||
| 200 | + text = "[denied by a hook: " + reason + "]" | ||
| 201 | + } | ||
| 202 | + if s := ui.ActiveSink(); s != nil { | ||
| 203 | + ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: call.Name, Title: call.Name + " — denied by a hook", Kind: "other", Input: inputMap(call.Input)} | ||
| 204 | + s.ToolStart(ev) | ||
| 205 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: text, Failed: true}) | ||
| 206 | + } else { | ||
| 207 | + ui.Notice("🪝 " + call.Name + " " + text) | ||
| 208 | + } | ||
| 209 | + return &ai.MultipartToolResponse{Output: text} | ||
| 210 | +} | ||
| 211 | + | ||
| 212 | +// inputMap gives a client the call's arguments as an object when they are | ||
| 213 | +// one, nil otherwise. | ||
| 214 | +func inputMap(input any) map[string]any { | ||
| 215 | + m, _ := input.(map[string]any) | ||
| 216 | + return m | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +// outputText is a tool's output as the postToolCall hook reads it — and as | ||
| 220 | +// the model reads it once context is added: the text itself, or its JSON. | ||
| 221 | +func outputText(output any) string { | ||
| 222 | + if s, ok := output.(string); ok { | ||
| 223 | + return s | ||
| 224 | + } | ||
| 225 | + data, err := json.Marshal(output) | ||
| 226 | + if err != nil { | ||
| 227 | + return fmt.Sprint(output) | ||
| 228 | + } | ||
| 229 | + return string(data) | ||
| 230 | +} | ||
added
internal/engine/hooks_test.go +272 -0 | new file mode 100644 | ||
| @@ -0,0 +1,272 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "strings" | |
| 8 | + "sync" | |
| 9 | + "testing" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "mm/internal/config" | |
| 13 | + "mm/internal/hooks" | |
| 14 | + "mm/internal/ui" | |
| 15 | + | |
| 16 | + "github.com/firebase/genkit/go/ai" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// The hooks under test are tiny `sh -c` scripts: what is checked here is | |
| 20 | +// how the engine applies their decisions to the conversation, not the | |
| 21 | +// protocol (internal/hooks has its own tests). | |
| 22 | + | |
| 23 | +// hookedEngine is testEngine with hooks, every entry given the defaults Load | |
| 24 | +// would have filled in. | |
| 25 | +func hookedEngine(t *testing.T, e *Engine, cfg config.HooksConfig) *recordingSink { | |
| 26 | + t.Helper() | |
| 27 | + for _, list := range []*[]config.Hook{&cfg.PreGeneration, &cfg.PreToolCall, &cfg.PostToolCall, &cfg.PostGeneration} { | |
| 28 | + for i := range *list { | |
| 29 | + (*list)[i].Timeout = 5 * time.Second | |
| 30 | + (*list)[i].OnError = config.HookOnErrorWarn | |
| 31 | + } | |
| 32 | + } | |
| 33 | + e.hooks = hooks.New(cfg, ui.Notice) | |
| 34 | + sink := &recordingSink{dir: t.TempDir()} | |
| 35 | + ui.SetActive(sink) | |
| 36 | + t.Cleanup(func() { ui.SetActive(nil) }) | |
| 37 | + return sink | |
| 38 | +} | |
| 39 | + | |
| 40 | +// recordingSink is a front end that keeps what it was shown. | |
| 41 | +type recordingSink struct { | |
| 42 | + ui.Sink | |
| 43 | + dir string | |
| 44 | + mu sync.Mutex | |
| 45 | + text strings.Builder | |
| 46 | + tools []ui.ToolEvent | |
| 47 | + results []ui.ToolResult | |
| 48 | + notices []string | |
| 49 | +} | |
| 50 | + | |
| 51 | +func (s *recordingSink) WorkDir() string { return s.dir } | |
| 52 | +func (s *recordingSink) Text(t string) { s.mu.Lock(); s.text.WriteString(t); s.mu.Unlock() } | |
| 53 | +func (s *recordingSink) Reasoning(string) {} | |
| 54 | +func (s *recordingSink) ToolRunning(string) {} | |
| 55 | +func (s *recordingSink) ToolStart(ev ui.ToolEvent) { | |
| 56 | + s.mu.Lock() | |
| 57 | + s.tools = append(s.tools, ev) | |
| 58 | + s.mu.Unlock() | |
| 59 | +} | |
| 60 | +func (s *recordingSink) ToolEnd(_ string, res ui.ToolResult) { | |
| 61 | + s.mu.Lock() | |
| 62 | + s.results = append(s.results, res) | |
| 63 | + s.mu.Unlock() | |
| 64 | +} | |
| 65 | +func (s *recordingSink) Notice(text string) { | |
| 66 | + s.mu.Lock() | |
| 67 | + s.notices = append(s.notices, text) | |
| 68 | + s.mu.Unlock() | |
| 69 | +} | |
| 70 | + | |
| 71 | +func bashCall(command string) *ai.ModelResponse { | |
| 72 | + return modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": command}, Ref: "1"})) | |
| 73 | +} | |
| 74 | + | |
| 75 | +// toolOutput returns the text of the tool response the model read on its | |
| 76 | +// second call. | |
| 77 | +func toolOutput(t *testing.T, req *ai.ModelRequest) string { | |
| 78 | + t.Helper() | |
| 79 | + for _, m := range req.Messages { | |
| 80 | + for _, p := range m.Content { | |
| 81 | + if p.IsToolResponse() { | |
| 82 | + return outputText(p.ToolResponse.Output) | |
| 83 | + } | |
| 84 | + } | |
| 85 | + } | |
| 86 | + t.Fatal("no tool response in the request") | |
| 87 | + return "" | |
| 88 | +} | |
| 89 | + | |
| 90 | +// The model reads the rewritten prompt and the added context; the history | |
| 91 | +// handed back carries them; the front end's own slice is untouched. | |
| 92 | +func TestPreGenerationRewritesWhatTheModelReads(t *testing.T) { | |
| 93 | + g, seen := scriptedGenkit(t, modelTurn(ai.NewTextPart("done"))) | |
| 94 | + e := testEngine(g) | |
| 95 | + hookedEngine(t, e, config.HooksConfig{PreGeneration: []config.Hook{ | |
| 96 | + {Command: `echo '{"prompt":"list the files","context":"branch: main"}'`}, | |
| 97 | + }}) | |
| 98 | + | |
| 99 | + messages := []*ai.Message{ai.NewUserTextMessage("lsit teh files")} | |
| 100 | + _, full, err := e.Generate(context.Background(), messages, nil) | |
| 101 | + if err != nil { | |
| 102 | + t.Fatal(err) | |
| 103 | + } | |
| 104 | + read := (*seen)[0].Messages[0].Text() | |
| 105 | + if !strings.HasPrefix(read, "list the files") || !strings.Contains(read, hookContextLabel+"branch: main") { | |
| 106 | + t.Errorf("the model read %q", read) | |
| 107 | + } | |
| 108 | + if full[0].Text() != read { | |
| 109 | + t.Errorf("history question = %q, want what the model read", full[0].Text()) | |
| 110 | + } | |
| 111 | + if messages[0].Text() != "lsit teh files" { | |
| 112 | + t.Errorf("the front end's message changed to %q", messages[0].Text()) | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +// A stopped turn never reaches the model, shows the hook's response, and | |
| 117 | +// keeps a marker — never the blocked text — in the history. | |
| 118 | +func TestPreGenerationStopNeverCallsTheModel(t *testing.T) { | |
| 119 | + g, seen := scriptedGenkit(t) // any model call fails the test | |
| 120 | + e := testEngine(g) | |
| 121 | + sink := hookedEngine(t, e, config.HooksConfig{ | |
| 122 | + PreGeneration: []config.Hook{ | |
| 123 | + {Command: `echo '{"action":"stop","reason":"secret detected","response":"Remove the secret first."}'`}, | |
| 124 | + }, | |
| 125 | + PostGeneration: []config.Hook{{Command: `cat > after.json`}}, | |
| 126 | + }) | |
| 127 | + | |
| 128 | + before := []*ai.Message{ai.NewSystemTextMessage("sys"), ai.NewUserTextMessage("my password is hunter2")} | |
| 129 | + resp, full, err := e.Generate(context.Background(), before, nil) | |
| 130 | + if err != nil { | |
| 131 | + t.Fatal(err) | |
| 132 | + } | |
| 133 | + if len(*seen) != 0 { | |
| 134 | + t.Fatalf("the model was called %d times", len(*seen)) | |
| 135 | + } | |
| 136 | + if resp.Text() != "Remove the secret first." || sink.text.String() != "Remove the secret first." { | |
| 137 | + t.Errorf("answer %q, shown %q", resp.Text(), sink.text.String()) | |
| 138 | + } | |
| 139 | + if len(full) != 3 { | |
| 140 | + t.Fatalf("history has %d messages, want system + marker + answer", len(full)) | |
| 141 | + } | |
| 142 | + marker := full[1].Text() | |
| 143 | + if strings.Contains(marker, "hunter2") || marker != "[prompt blocked by a hook: secret detected]" { | |
| 144 | + t.Errorf("history keeps %q", marker) | |
| 145 | + } | |
| 146 | + // Found live: an audit hook on postGeneration wrote the secret to its | |
| 147 | + // log. The post hooks read the marker as well. | |
| 148 | + after, err := os.ReadFile(filepath.Join(sink.dir, "after.json")) | |
| 149 | + if err != nil { | |
| 150 | + t.Fatal(err) | |
| 151 | + } | |
| 152 | + if strings.Contains(string(after), "hunter2") || !strings.Contains(string(after), "prompt blocked by a hook") { | |
| 153 | + t.Errorf("postGeneration read %s", after) | |
| 154 | + } | |
| 155 | +} | |
| 156 | + | |
| 157 | +// A denied call never runs: the model reads the reason as the tool's output, | |
| 158 | +// the front end sees a failed tool entry. | |
| 159 | +func TestPreToolCallDenyIsReadByTheModel(t *testing.T) { | |
| 160 | + g, seen := scriptedGenkit(t, bashCall("rm -rf /"), modelTurn(ai.NewTextPart("ok, I will not"))) | |
| 161 | + e := testEngine(g) | |
| 162 | + sink := hookedEngine(t, e, config.HooksConfig{PreToolCall: []config.Hook{ | |
| 163 | + {Matcher: "^bash$", Command: `grep -q 'rm -rf' && echo '{"action":"deny","reason":"rm -rf is forbidden"}'`}, | |
| 164 | + }}) | |
| 165 | + | |
| 166 | + _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("clean up")}, []ai.ToolRef{ai.ToolName("bash")}) | |
| 167 | + if err != nil { | |
| 168 | + t.Fatal(err) | |
| 169 | + } | |
| 170 | + if got := toolOutput(t, (*seen)[1]); got != "[denied by a hook: rm -rf is forbidden]" { | |
| 171 | + t.Errorf("the model read %q", got) | |
| 172 | + } | |
| 173 | + if len(sink.results) != 1 || !sink.results[0].Failed || !strings.Contains(sink.tools[0].Title, "denied") { | |
| 174 | + t.Errorf("front end saw %+v / %+v, want one failed, denied tool entry", sink.tools, sink.results) | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +// A replaced input is what the tool runs with; the history keeps what the | |
| 179 | +// model asked for. | |
| 180 | +func TestPreToolCallReplacesTheInput(t *testing.T) { | |
| 181 | + g, seen := scriptedGenkit(t, bashCall("ls"), modelTurn(ai.NewTextPart("listed"))) | |
| 182 | + e := testEngine(g) | |
| 183 | + hookedEngine(t, e, config.HooksConfig{PreToolCall: []config.Hook{ | |
| 184 | + {Command: `echo '{"input":{"command":"ls -la"}}'`}, | |
| 185 | + }}) | |
| 186 | + | |
| 187 | + _, full, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("list")}, []ai.ToolRef{ai.ToolName("bash")}) | |
| 188 | + if err != nil { | |
| 189 | + t.Fatal(err) | |
| 190 | + } | |
| 191 | + if got := toolOutput(t, (*seen)[1]); got != "hello from ls -la" { | |
| 192 | + t.Errorf("the tool returned %q, want it run with the replaced input", got) | |
| 193 | + } | |
| 194 | + if cmds := CommandList(full); len(cmds) != 1 || cmds[0] != "ls" { | |
| 195 | + t.Errorf("history commands = %v, want the model's own [ls]", cmds) | |
| 196 | + } | |
| 197 | +} | |
| 198 | + | |
| 199 | +// postToolCall context is appended to what the model reads of the output. | |
| 200 | +func TestPostToolCallAddsContextToTheOutput(t *testing.T) { | |
| 201 | + g, seen := scriptedGenkit(t, bashCall("go test"), modelTurn(ai.NewTextPart("fine"))) | |
| 202 | + e := testEngine(g) | |
| 203 | + hookedEngine(t, e, config.HooksConfig{PostToolCall: []config.Hook{ | |
| 204 | + {Command: `grep -q '"output":"hello from go test"' && echo '{"context":"tests are flaky on CI"}'`}, | |
| 205 | + }}) | |
| 206 | + | |
| 207 | + if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("test")}, []ai.ToolRef{ai.ToolName("bash")}); err != nil { | |
| 208 | + t.Fatal(err) | |
| 209 | + } | |
| 210 | + if got, want := toolOutput(t, (*seen)[1]), "hello from go test\n\n"+hookContextLabel+"tests are flaky on CI"; got != want { | |
| 211 | + t.Errorf("the model read %q, want %q", got, want) | |
| 212 | + } | |
| 213 | +} | |
| 214 | + | |
| 215 | +// postGeneration sees the question and the answer, in the session's | |
| 216 | +// directory. | |
| 217 | +func TestPostGenerationSeesTheTurn(t *testing.T) { | |
| 218 | + g, _ := scriptedGenkit(t, modelTurn(ai.NewTextPart("42"))) | |
| 219 | + e := testEngine(g) | |
| 220 | + sink := hookedEngine(t, e, config.HooksConfig{PostGeneration: []config.Hook{ | |
| 221 | + {Command: `cat > turn.json`}, | |
| 222 | + }}) | |
| 223 | + | |
| 224 | + if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("the answer?")}, nil); err != nil { | |
| 225 | + t.Fatal(err) | |
| 226 | + } | |
| 227 | + data, err := os.ReadFile(filepath.Join(sink.dir, "turn.json")) | |
| 228 | + if err != nil { | |
| 229 | + t.Fatalf("the hook did not run in the session directory: %v", err) | |
| 230 | + } | |
| 231 | + for _, want := range []string{`"event":"postGeneration"`, `"prompt":"the answer?"`, `"answer":"42"`} { | |
| 232 | + if !strings.Contains(string(data), want) { | |
| 233 | + t.Errorf("the hook read %s, want %s in it", data, want) | |
| 234 | + } | |
| 235 | + } | |
| 236 | +} | |
| 237 | + | |
| 238 | +// A failing hook with onError warn is reported to the front end and the turn | |
| 239 | +// goes on. | |
| 240 | +func TestFailingHookIsReportedAndTheTurnGoesOn(t *testing.T) { | |
| 241 | + g, seen := scriptedGenkit(t, modelTurn(ai.NewTextPart("answered"))) | |
| 242 | + e := testEngine(g) | |
| 243 | + sink := hookedEngine(t, e, config.HooksConfig{PreGeneration: []config.Hook{{Command: `exit 7`}}}) | |
| 244 | + | |
| 245 | + resp, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil) | |
| 246 | + if err != nil || resp.Text() != "answered" || len(*seen) != 1 { | |
| 247 | + t.Fatalf("resp %v err %v calls %d, want the turn to go on", resp, err, len(*seen)) | |
| 248 | + } | |
| 249 | + if len(sink.notices) != 1 || !strings.Contains(sink.notices[0], "exit status 7") { | |
| 250 | + t.Errorf("notices = %v", sink.notices) | |
| 251 | + } | |
| 252 | +} | |
| 253 | + | |
| 254 | +// A cancelled turn still reaches postGeneration: an audit log must not lose | |
| 255 | +// the turns the user interrupted. | |
| 256 | +func TestPostGenerationRunsAfterACancelledTurn(t *testing.T) { | |
| 257 | + g, _ := scriptedGenkit(t, context.Canceled, context.Canceled) // the call, and the no-streaming retry | |
| 258 | + e := testEngine(g) | |
| 259 | + sink := hookedEngine(t, e, config.HooksConfig{PostGeneration: []config.Hook{{Command: `cat > turn.json`}}}) | |
| 260 | + | |
| 261 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 262 | + cancel() | |
| 263 | + _, _, _ = e.Generate(ctx, []*ai.Message{ai.NewUserTextMessage("long job")}, nil) | |
| 264 | + | |
| 265 | + data, err := os.ReadFile(filepath.Join(sink.dir, "turn.json")) | |
| 266 | + if err != nil { | |
| 267 | + t.Fatalf("postGeneration did not run after the cancellation: %v (notices %v)", err, sink.notices) | |
| 268 | + } | |
| 269 | + if !strings.Contains(string(data), `"error":`) { | |
| 270 | + t.Errorf("the hook read %s, want the turn's error in it", data) | |
| 271 | + } | |
| 272 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,272 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "os" | ||
| 6 | + "path/filepath" | ||
| 7 | + "strings" | ||
| 8 | + "sync" | ||
| 9 | + "testing" | ||
| 10 | + "time" | ||
| 11 | + | ||
| 12 | + "mm/internal/config" | ||
| 13 | + "mm/internal/hooks" | ||
| 14 | + "mm/internal/ui" | ||
| 15 | + | ||
| 16 | + "github.com/firebase/genkit/go/ai" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// The hooks under test are tiny `sh -c` scripts: what is checked here is | ||
| 20 | +// how the engine applies their decisions to the conversation, not the | ||
| 21 | +// protocol (internal/hooks has its own tests). | ||
| 22 | + | ||
| 23 | +// hookedEngine is testEngine with hooks, every entry given the defaults Load | ||
| 24 | +// would have filled in. | ||
| 25 | +func hookedEngine(t *testing.T, e *Engine, cfg config.HooksConfig) *recordingSink { | ||
| 26 | + t.Helper() | ||
| 27 | + for _, list := range []*[]config.Hook{&cfg.PreGeneration, &cfg.PreToolCall, &cfg.PostToolCall, &cfg.PostGeneration} { | ||
| 28 | + for i := range *list { | ||
| 29 | + (*list)[i].Timeout = 5 * time.Second | ||
| 30 | + (*list)[i].OnError = config.HookOnErrorWarn | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + e.hooks = hooks.New(cfg, ui.Notice) | ||
| 34 | + sink := &recordingSink{dir: t.TempDir()} | ||
| 35 | + ui.SetActive(sink) | ||
| 36 | + t.Cleanup(func() { ui.SetActive(nil) }) | ||
| 37 | + return sink | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +// recordingSink is a front end that keeps what it was shown. | ||
| 41 | +type recordingSink struct { | ||
| 42 | + ui.Sink | ||
| 43 | + dir string | ||
| 44 | + mu sync.Mutex | ||
| 45 | + text strings.Builder | ||
| 46 | + tools []ui.ToolEvent | ||
| 47 | + results []ui.ToolResult | ||
| 48 | + notices []string | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +func (s *recordingSink) WorkDir() string { return s.dir } | ||
| 52 | +func (s *recordingSink) Text(t string) { s.mu.Lock(); s.text.WriteString(t); s.mu.Unlock() } | ||
| 53 | +func (s *recordingSink) Reasoning(string) {} | ||
| 54 | +func (s *recordingSink) ToolRunning(string) {} | ||
| 55 | +func (s *recordingSink) ToolStart(ev ui.ToolEvent) { | ||
| 56 | + s.mu.Lock() | ||
| 57 | + s.tools = append(s.tools, ev) | ||
| 58 | + s.mu.Unlock() | ||
| 59 | +} | ||
| 60 | +func (s *recordingSink) ToolEnd(_ string, res ui.ToolResult) { | ||
| 61 | + s.mu.Lock() | ||
| 62 | + s.results = append(s.results, res) | ||
| 63 | + s.mu.Unlock() | ||
| 64 | +} | ||
| 65 | +func (s *recordingSink) Notice(text string) { | ||
| 66 | + s.mu.Lock() | ||
| 67 | + s.notices = append(s.notices, text) | ||
| 68 | + s.mu.Unlock() | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +func bashCall(command string) *ai.ModelResponse { | ||
| 72 | + return modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": command}, Ref: "1"})) | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +// toolOutput returns the text of the tool response the model read on its | ||
| 76 | +// second call. | ||
| 77 | +func toolOutput(t *testing.T, req *ai.ModelRequest) string { | ||
| 78 | + t.Helper() | ||
| 79 | + for _, m := range req.Messages { | ||
| 80 | + for _, p := range m.Content { | ||
| 81 | + if p.IsToolResponse() { | ||
| 82 | + return outputText(p.ToolResponse.Output) | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + } | ||
| 86 | + t.Fatal("no tool response in the request") | ||
| 87 | + return "" | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +// The model reads the rewritten prompt and the added context; the history | ||
| 91 | +// handed back carries them; the front end's own slice is untouched. | ||
| 92 | +func TestPreGenerationRewritesWhatTheModelReads(t *testing.T) { | ||
| 93 | + g, seen := scriptedGenkit(t, modelTurn(ai.NewTextPart("done"))) | ||
| 94 | + e := testEngine(g) | ||
| 95 | + hookedEngine(t, e, config.HooksConfig{PreGeneration: []config.Hook{ | ||
| 96 | + {Command: `echo '{"prompt":"list the files","context":"branch: main"}'`}, | ||
| 97 | + }}) | ||
| 98 | + | ||
| 99 | + messages := []*ai.Message{ai.NewUserTextMessage("lsit teh files")} | ||
| 100 | + _, full, err := e.Generate(context.Background(), messages, nil) | ||
| 101 | + if err != nil { | ||
| 102 | + t.Fatal(err) | ||
| 103 | + } | ||
| 104 | + read := (*seen)[0].Messages[0].Text() | ||
| 105 | + if !strings.HasPrefix(read, "list the files") || !strings.Contains(read, hookContextLabel+"branch: main") { | ||
| 106 | + t.Errorf("the model read %q", read) | ||
| 107 | + } | ||
| 108 | + if full[0].Text() != read { | ||
| 109 | + t.Errorf("history question = %q, want what the model read", full[0].Text()) | ||
| 110 | + } | ||
| 111 | + if messages[0].Text() != "lsit teh files" { | ||
| 112 | + t.Errorf("the front end's message changed to %q", messages[0].Text()) | ||
| 113 | + } | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +// A stopped turn never reaches the model, shows the hook's response, and | ||
| 117 | +// keeps a marker — never the blocked text — in the history. | ||
| 118 | +func TestPreGenerationStopNeverCallsTheModel(t *testing.T) { | ||
| 119 | + g, seen := scriptedGenkit(t) // any model call fails the test | ||
| 120 | + e := testEngine(g) | ||
| 121 | + sink := hookedEngine(t, e, config.HooksConfig{ | ||
| 122 | + PreGeneration: []config.Hook{ | ||
| 123 | + {Command: `echo '{"action":"stop","reason":"secret detected","response":"Remove the secret first."}'`}, | ||
| 124 | + }, | ||
| 125 | + PostGeneration: []config.Hook{{Command: `cat > after.json`}}, | ||
| 126 | + }) | ||
| 127 | + | ||
| 128 | + before := []*ai.Message{ai.NewSystemTextMessage("sys"), ai.NewUserTextMessage("my password is hunter2")} | ||
| 129 | + resp, full, err := e.Generate(context.Background(), before, nil) | ||
| 130 | + if err != nil { | ||
| 131 | + t.Fatal(err) | ||
| 132 | + } | ||
| 133 | + if len(*seen) != 0 { | ||
| 134 | + t.Fatalf("the model was called %d times", len(*seen)) | ||
| 135 | + } | ||
| 136 | + if resp.Text() != "Remove the secret first." || sink.text.String() != "Remove the secret first." { | ||
| 137 | + t.Errorf("answer %q, shown %q", resp.Text(), sink.text.String()) | ||
| 138 | + } | ||
| 139 | + if len(full) != 3 { | ||
| 140 | + t.Fatalf("history has %d messages, want system + marker + answer", len(full)) | ||
| 141 | + } | ||
| 142 | + marker := full[1].Text() | ||
| 143 | + if strings.Contains(marker, "hunter2") || marker != "[prompt blocked by a hook: secret detected]" { | ||
| 144 | + t.Errorf("history keeps %q", marker) | ||
| 145 | + } | ||
| 146 | + // Found live: an audit hook on postGeneration wrote the secret to its | ||
| 147 | + // log. The post hooks read the marker as well. | ||
| 148 | + after, err := os.ReadFile(filepath.Join(sink.dir, "after.json")) | ||
| 149 | + if err != nil { | ||
| 150 | + t.Fatal(err) | ||
| 151 | + } | ||
| 152 | + if strings.Contains(string(after), "hunter2") || !strings.Contains(string(after), "prompt blocked by a hook") { | ||
| 153 | + t.Errorf("postGeneration read %s", after) | ||
| 154 | + } | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +// A denied call never runs: the model reads the reason as the tool's output, | ||
| 158 | +// the front end sees a failed tool entry. | ||
| 159 | +func TestPreToolCallDenyIsReadByTheModel(t *testing.T) { | ||
| 160 | + g, seen := scriptedGenkit(t, bashCall("rm -rf /"), modelTurn(ai.NewTextPart("ok, I will not"))) | ||
| 161 | + e := testEngine(g) | ||
| 162 | + sink := hookedEngine(t, e, config.HooksConfig{PreToolCall: []config.Hook{ | ||
| 163 | + {Matcher: "^bash$", Command: `grep -q 'rm -rf' && echo '{"action":"deny","reason":"rm -rf is forbidden"}'`}, | ||
| 164 | + }}) | ||
| 165 | + | ||
| 166 | + _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("clean up")}, []ai.ToolRef{ai.ToolName("bash")}) | ||
| 167 | + if err != nil { | ||
| 168 | + t.Fatal(err) | ||
| 169 | + } | ||
| 170 | + if got := toolOutput(t, (*seen)[1]); got != "[denied by a hook: rm -rf is forbidden]" { | ||
| 171 | + t.Errorf("the model read %q", got) | ||
| 172 | + } | ||
| 173 | + if len(sink.results) != 1 || !sink.results[0].Failed || !strings.Contains(sink.tools[0].Title, "denied") { | ||
| 174 | + t.Errorf("front end saw %+v / %+v, want one failed, denied tool entry", sink.tools, sink.results) | ||
| 175 | + } | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +// A replaced input is what the tool runs with; the history keeps what the | ||
| 179 | +// model asked for. | ||
| 180 | +func TestPreToolCallReplacesTheInput(t *testing.T) { | ||
| 181 | + g, seen := scriptedGenkit(t, bashCall("ls"), modelTurn(ai.NewTextPart("listed"))) | ||
| 182 | + e := testEngine(g) | ||
| 183 | + hookedEngine(t, e, config.HooksConfig{PreToolCall: []config.Hook{ | ||
| 184 | + {Command: `echo '{"input":{"command":"ls -la"}}'`}, | ||
| 185 | + }}) | ||
| 186 | + | ||
| 187 | + _, full, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("list")}, []ai.ToolRef{ai.ToolName("bash")}) | ||
| 188 | + if err != nil { | ||
| 189 | + t.Fatal(err) | ||
| 190 | + } | ||
| 191 | + if got := toolOutput(t, (*seen)[1]); got != "hello from ls -la" { | ||
| 192 | + t.Errorf("the tool returned %q, want it run with the replaced input", got) | ||
| 193 | + } | ||
| 194 | + if cmds := CommandList(full); len(cmds) != 1 || cmds[0] != "ls" { | ||
| 195 | + t.Errorf("history commands = %v, want the model's own [ls]", cmds) | ||
| 196 | + } | ||
| 197 | +} | ||
| 198 | + | ||
| 199 | +// postToolCall context is appended to what the model reads of the output. | ||
| 200 | +func TestPostToolCallAddsContextToTheOutput(t *testing.T) { | ||
| 201 | + g, seen := scriptedGenkit(t, bashCall("go test"), modelTurn(ai.NewTextPart("fine"))) | ||
| 202 | + e := testEngine(g) | ||
| 203 | + hookedEngine(t, e, config.HooksConfig{PostToolCall: []config.Hook{ | ||
| 204 | + {Command: `grep -q '"output":"hello from go test"' && echo '{"context":"tests are flaky on CI"}'`}, | ||
| 205 | + }}) | ||
| 206 | + | ||
| 207 | + if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("test")}, []ai.ToolRef{ai.ToolName("bash")}); err != nil { | ||
| 208 | + t.Fatal(err) | ||
| 209 | + } | ||
| 210 | + if got, want := toolOutput(t, (*seen)[1]), "hello from go test\n\n"+hookContextLabel+"tests are flaky on CI"; got != want { | ||
| 211 | + t.Errorf("the model read %q, want %q", got, want) | ||
| 212 | + } | ||
| 213 | +} | ||
| 214 | + | ||
| 215 | +// postGeneration sees the question and the answer, in the session's | ||
| 216 | +// directory. | ||
| 217 | +func TestPostGenerationSeesTheTurn(t *testing.T) { | ||
| 218 | + g, _ := scriptedGenkit(t, modelTurn(ai.NewTextPart("42"))) | ||
| 219 | + e := testEngine(g) | ||
| 220 | + sink := hookedEngine(t, e, config.HooksConfig{PostGeneration: []config.Hook{ | ||
| 221 | + {Command: `cat > turn.json`}, | ||
| 222 | + }}) | ||
| 223 | + | ||
| 224 | + if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("the answer?")}, nil); err != nil { | ||
| 225 | + t.Fatal(err) | ||
| 226 | + } | ||
| 227 | + data, err := os.ReadFile(filepath.Join(sink.dir, "turn.json")) | ||
| 228 | + if err != nil { | ||
| 229 | + t.Fatalf("the hook did not run in the session directory: %v", err) | ||
| 230 | + } | ||
| 231 | + for _, want := range []string{`"event":"postGeneration"`, `"prompt":"the answer?"`, `"answer":"42"`} { | ||
| 232 | + if !strings.Contains(string(data), want) { | ||
| 233 | + t.Errorf("the hook read %s, want %s in it", data, want) | ||
| 234 | + } | ||
| 235 | + } | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +// A failing hook with onError warn is reported to the front end and the turn | ||
| 239 | +// goes on. | ||
| 240 | +func TestFailingHookIsReportedAndTheTurnGoesOn(t *testing.T) { | ||
| 241 | + g, seen := scriptedGenkit(t, modelTurn(ai.NewTextPart("answered"))) | ||
| 242 | + e := testEngine(g) | ||
| 243 | + sink := hookedEngine(t, e, config.HooksConfig{PreGeneration: []config.Hook{{Command: `exit 7`}}}) | ||
| 244 | + | ||
| 245 | + resp, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil) | ||
| 246 | + if err != nil || resp.Text() != "answered" || len(*seen) != 1 { | ||
| 247 | + t.Fatalf("resp %v err %v calls %d, want the turn to go on", resp, err, len(*seen)) | ||
| 248 | + } | ||
| 249 | + if len(sink.notices) != 1 || !strings.Contains(sink.notices[0], "exit status 7") { | ||
| 250 | + t.Errorf("notices = %v", sink.notices) | ||
| 251 | + } | ||
| 252 | +} | ||
| 253 | + | ||
| 254 | +// A cancelled turn still reaches postGeneration: an audit log must not lose | ||
| 255 | +// the turns the user interrupted. | ||
| 256 | +func TestPostGenerationRunsAfterACancelledTurn(t *testing.T) { | ||
| 257 | + g, _ := scriptedGenkit(t, context.Canceled, context.Canceled) // the call, and the no-streaming retry | ||
| 258 | + e := testEngine(g) | ||
| 259 | + sink := hookedEngine(t, e, config.HooksConfig{PostGeneration: []config.Hook{{Command: `cat > turn.json`}}}) | ||
| 260 | + | ||
| 261 | + ctx, cancel := context.WithCancel(context.Background()) | ||
| 262 | + cancel() | ||
| 263 | + _, _, _ = e.Generate(ctx, []*ai.Message{ai.NewUserTextMessage("long job")}, nil) | ||
| 264 | + | ||
| 265 | + data, err := os.ReadFile(filepath.Join(sink.dir, "turn.json")) | ||
| 266 | + if err != nil { | ||
| 267 | + t.Fatalf("postGeneration did not run after the cancellation: %v (notices %v)", err, sink.notices) | ||
| 268 | + } | ||
| 269 | + if !strings.Contains(string(data), `"error":`) { | ||
| 270 | + t.Errorf("the hook read %s, want the turn's error in it", data) | ||
| 271 | + } | ||
| 272 | +} | ||
added
internal/hooks/example_test.go +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +package hooks_test | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "time" | |
| 7 | + | |
| 8 | + "mm/internal/config" | |
| 9 | + "mm/internal/hooks" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// A preGeneration hook that stops a question holding a password. The model is | |
| 13 | +// never called; the engine shows Response instead. | |
| 14 | +func ExampleRunner_PreGeneration() { | |
| 15 | + cfg := config.HooksConfig{PreGeneration: []config.Hook{{ | |
| 16 | + Command: `grep -q password && echo '{"action":"stop","reason":"secret","response":"No secrets, please."}'`, | |
| 17 | + Timeout: 5 * time.Second, | |
| 18 | + OnError: config.HookOnErrorFail, | |
| 19 | + }}} | |
| 20 | + runner := hooks.New(cfg, nil) | |
| 21 | + | |
| 22 | + d := runner.PreGeneration(context.Background(), hooks.Input{Prompt: "my password is hunter2"}) | |
| 23 | + fmt.Println(d.Stopped, d.Reason, d.Response) | |
| 24 | + // Output: true secret No secrets, please. | |
| 25 | +} | |
| 26 | + | |
| 27 | +// A preToolCall hook limited to bash by its matcher: it denies sudo, and | |
| 28 | +// prints nothing — "continue" — for everything else. | |
| 29 | +func ExampleRunner_PreToolCall() { | |
| 30 | + cfg := config.HooksConfig{PreToolCall: []config.Hook{{ | |
| 31 | + Matcher: "^bash$", | |
| 32 | + Command: `grep -q sudo && echo '{"action":"deny","reason":"sudo is not allowed"}'; true`, | |
| 33 | + Timeout: 5 * time.Second, | |
| 34 | + OnError: config.HookOnErrorFail, | |
| 35 | + }}} | |
| 36 | + runner := hooks.New(cfg, nil) | |
| 37 | + | |
| 38 | + for _, command := range []string{"sudo ls", "ls"} { | |
| 39 | + call := &hooks.ToolCall{Name: "bash", Input: map[string]any{"command": command}} | |
| 40 | + d := runner.PreToolCall(context.Background(), hooks.Input{Tool: call}) | |
| 41 | + fmt.Printf("%s: denied=%v %s\n", command, d.Denied, d.Reason) | |
| 42 | + } | |
| 43 | + // Output: | |
| 44 | + // sudo ls: denied=true sudo is not allowed | |
| 45 | + // ls: denied=false | |
| 46 | +} | |
| 47 | + | |
| 48 | +// A postToolCall hook that adds a note for the model after the tool's output. | |
| 49 | +func ExampleRunner_PostToolCall() { | |
| 50 | + cfg := config.HooksConfig{PostToolCall: []config.Hook{{ | |
| 51 | + Command: `echo '{"context":"the test suite takes 2 minutes"}'`, | |
| 52 | + Timeout: 5 * time.Second, | |
| 53 | + OnError: config.HookOnErrorWarn, | |
| 54 | + }}} | |
| 55 | + runner := hooks.New(cfg, nil) | |
| 56 | + | |
| 57 | + added := runner.PostToolCall(context.Background(), hooks.Input{ | |
| 58 | + Tool: &hooks.ToolCall{Name: "bash", Input: map[string]any{"command": "go test ./..."}}, | |
| 59 | + Output: "ok", | |
| 60 | + }) | |
| 61 | + fmt.Println(added) | |
| 62 | + // Output: [the test suite takes 2 minutes] | |
| 63 | +} | |
| 64 | + | |
| 65 | +// A failing hook with onError warn is reported through the notify function | |
| 66 | +// and the turn goes on. | |
| 67 | +func ExampleNew() { | |
| 68 | + cfg := config.HooksConfig{PostGeneration: []config.Hook{{ | |
| 69 | + Command: "exit 3", | |
| 70 | + Timeout: 5 * time.Second, | |
| 71 | + OnError: config.HookOnErrorWarn, | |
| 72 | + }}} | |
| 73 | + runner := hooks.New(cfg, func(msg string) { fmt.Println(msg) }) | |
| 74 | + | |
| 75 | + runner.PostGeneration(context.Background(), hooks.Input{Answer: "done"}) | |
| 76 | + // Output: 🪝 postGeneration hook `exit 3`: exit status 3 | |
| 77 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +package hooks_test | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "time" | ||
| 7 | + | ||
| 8 | + "mm/internal/config" | ||
| 9 | + "mm/internal/hooks" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// A preGeneration hook that stops a question holding a password. The model is | ||
| 13 | +// never called; the engine shows Response instead. | ||
| 14 | +func ExampleRunner_PreGeneration() { | ||
| 15 | + cfg := config.HooksConfig{PreGeneration: []config.Hook{{ | ||
| 16 | + Command: `grep -q password && echo '{"action":"stop","reason":"secret","response":"No secrets, please."}'`, | ||
| 17 | + Timeout: 5 * time.Second, | ||
| 18 | + OnError: config.HookOnErrorFail, | ||
| 19 | + }}} | ||
| 20 | + runner := hooks.New(cfg, nil) | ||
| 21 | + | ||
| 22 | + d := runner.PreGeneration(context.Background(), hooks.Input{Prompt: "my password is hunter2"}) | ||
| 23 | + fmt.Println(d.Stopped, d.Reason, d.Response) | ||
| 24 | + // Output: true secret No secrets, please. | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +// A preToolCall hook limited to bash by its matcher: it denies sudo, and | ||
| 28 | +// prints nothing — "continue" — for everything else. | ||
| 29 | +func ExampleRunner_PreToolCall() { | ||
| 30 | + cfg := config.HooksConfig{PreToolCall: []config.Hook{{ | ||
| 31 | + Matcher: "^bash$", | ||
| 32 | + Command: `grep -q sudo && echo '{"action":"deny","reason":"sudo is not allowed"}'; true`, | ||
| 33 | + Timeout: 5 * time.Second, | ||
| 34 | + OnError: config.HookOnErrorFail, | ||
| 35 | + }}} | ||
| 36 | + runner := hooks.New(cfg, nil) | ||
| 37 | + | ||
| 38 | + for _, command := range []string{"sudo ls", "ls"} { | ||
| 39 | + call := &hooks.ToolCall{Name: "bash", Input: map[string]any{"command": command}} | ||
| 40 | + d := runner.PreToolCall(context.Background(), hooks.Input{Tool: call}) | ||
| 41 | + fmt.Printf("%s: denied=%v %s\n", command, d.Denied, d.Reason) | ||
| 42 | + } | ||
| 43 | + // Output: | ||
| 44 | + // sudo ls: denied=true sudo is not allowed | ||
| 45 | + // ls: denied=false | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +// A postToolCall hook that adds a note for the model after the tool's output. | ||
| 49 | +func ExampleRunner_PostToolCall() { | ||
| 50 | + cfg := config.HooksConfig{PostToolCall: []config.Hook{{ | ||
| 51 | + Command: `echo '{"context":"the test suite takes 2 minutes"}'`, | ||
| 52 | + Timeout: 5 * time.Second, | ||
| 53 | + OnError: config.HookOnErrorWarn, | ||
| 54 | + }}} | ||
| 55 | + runner := hooks.New(cfg, nil) | ||
| 56 | + | ||
| 57 | + added := runner.PostToolCall(context.Background(), hooks.Input{ | ||
| 58 | + Tool: &hooks.ToolCall{Name: "bash", Input: map[string]any{"command": "go test ./..."}}, | ||
| 59 | + Output: "ok", | ||
| 60 | + }) | ||
| 61 | + fmt.Println(added) | ||
| 62 | + // Output: [the test suite takes 2 minutes] | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +// A failing hook with onError warn is reported through the notify function | ||
| 66 | +// and the turn goes on. | ||
| 67 | +func ExampleNew() { | ||
| 68 | + cfg := config.HooksConfig{PostGeneration: []config.Hook{{ | ||
| 69 | + Command: "exit 3", | ||
| 70 | + Timeout: 5 * time.Second, | ||
| 71 | + OnError: config.HookOnErrorWarn, | ||
| 72 | + }}} | ||
| 73 | + runner := hooks.New(cfg, func(msg string) { fmt.Println(msg) }) | ||
| 74 | + | ||
| 75 | + runner.PostGeneration(context.Background(), hooks.Input{Answer: "done"}) | ||
| 76 | + // Output: 🪝 postGeneration hook `exit 3`: exit status 3 | ||
| 77 | +} | ||
added
internal/hooks/exec.go +168 -0 | new file mode 100644 | ||
| @@ -0,0 +1,168 @@ | ||
| 1 | +package hooks | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "os" | |
| 10 | + "os/exec" | |
| 11 | + "strings" | |
| 12 | + "time" | |
| 13 | + | |
| 14 | + "mm/internal/config" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// Environment variables a hook can read besides its stdin. | |
| 18 | +const ( | |
| 19 | + // EnvEvent names the event the hook runs for: one script can serve | |
| 20 | + // several events and still know which one called it. | |
| 21 | + EnvEvent = "MM_HOOK_EVENT" | |
| 22 | + // EnvConfigDir is the directory of the YAML file that declared the hook, | |
| 23 | + // so `node "$MM_CONFIG_DIR/hooks/x.js"` finds a script shipped beside | |
| 24 | + // its agent.yaml from any working directory. | |
| 25 | + EnvConfigDir = "MM_CONFIG_DIR" | |
| 26 | +) | |
| 27 | + | |
| 28 | +// maxReply caps what a hook may print on stdout. A reply is a small JSON | |
| 29 | +// object; a hook that floods its stdout is broken, and must not pull it all | |
| 30 | +// into memory nor into the model's context. | |
| 31 | +const maxReply = 1 << 20 | |
| 32 | + | |
| 33 | +// maxStderr is how much of a failing hook's stderr its report quotes. | |
| 34 | +const maxStderr = 300 | |
| 35 | + | |
| 36 | +// waitDelay bounds the wait for the pipes once the hook has exited or been | |
| 37 | +// killed: a hook that leaves a background child holding its stdout would | |
| 38 | +// otherwise block Wait forever (the same trap as the bash tool's, ticket 26). | |
| 39 | +const waitDelay = time.Second | |
| 40 | + | |
| 41 | +// execFunc runs one hook with its stdin and returns its stdout: runCommand, | |
| 42 | +// 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 | + | |
| 45 | +// call runs one hook on in and decodes its reply. An empty stdout is a | |
| 46 | +// "continue". Every failure — the hook's exit, its timeout, an unreadable | |
| 47 | +// reply, an unknown action, an explicit "error" — comes back as an error. | |
| 48 | +func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) { | |
| 49 | + stdin, err := json.Marshal(in) | |
| 50 | + if err != nil { | |
| 51 | + return Reply{}, fmt.Errorf("cannot encode the input: %w", err) | |
| 52 | + } | |
| 53 | + env := []string{EnvEvent + "=" + in.Event, EnvConfigDir + "=" + r.cfg.ConfigDir} | |
| 54 | + stdout, err := r.exec(ctx, h, env, in.Cwd, stdin) | |
| 55 | + if err != nil { | |
| 56 | + return Reply{}, err | |
| 57 | + } | |
| 58 | + return decode(stdout, in.Event) | |
| 59 | +} | |
| 60 | + | |
| 61 | +// decode reads a hook's stdout. An action that does not belong to the event | |
| 62 | +// (a "deny" from a preGeneration hook) is an error rather than a silent | |
| 63 | +// continue: whoever wrote it meant to stop something. | |
| 64 | +func decode(stdout []byte, event string) (Reply, error) { | |
| 65 | + if len(bytes.TrimSpace(stdout)) == 0 { | |
| 66 | + return Reply{Action: ActionContinue}, nil | |
| 67 | + } | |
| 68 | + var reply Reply | |
| 69 | + if err := json.Unmarshal(stdout, &reply); err != nil { | |
| 70 | + return Reply{}, fmt.Errorf("invalid JSON on stdout: %w", err) | |
| 71 | + } | |
| 72 | + if reply.Action == "" { | |
| 73 | + reply.Action = ActionContinue | |
| 74 | + } | |
| 75 | + if err := checkAction(reply, event); err != nil { | |
| 76 | + return Reply{}, err | |
| 77 | + } | |
| 78 | + return reply, nil | |
| 79 | +} | |
| 80 | + | |
| 81 | +// checkAction refuses a reply whose action is an error, unknown, or meant | |
| 82 | +// for another event. | |
| 83 | +func checkAction(reply Reply, event string) error { | |
| 84 | + switch reply.Action { | |
| 85 | + case ActionContinue: | |
| 86 | + return nil | |
| 87 | + case ActionError: | |
| 88 | + if reply.Error == "" { | |
| 89 | + return errors.New("the hook reported an error") | |
| 90 | + } | |
| 91 | + return errors.New(reply.Error) | |
| 92 | + case ActionStop: | |
| 93 | + if event == config.HookPreGeneration { | |
| 94 | + return nil | |
| 95 | + } | |
| 96 | + case ActionDeny: | |
| 97 | + if event == config.HookPreToolCall { | |
| 98 | + return nil | |
| 99 | + } | |
| 100 | + } | |
| 101 | + return fmt.Errorf("action %q is not valid for %s", reply.Action, event) | |
| 102 | +} | |
| 103 | + | |
| 104 | +// runCommand runs h.Command with `sh -c` in dir ("" = mm's own directory), | |
| 105 | +// under h.Timeout, with env added to mm's environment. | |
| 106 | +func runCommand(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { | |
| 107 | + ctx, cancel := context.WithTimeout(ctx, h.Timeout) | |
| 108 | + defer cancel() | |
| 109 | + | |
| 110 | + cmd := exec.CommandContext(ctx, "sh", "-c", h.Command) | |
| 111 | + cmd.Dir = dir | |
| 112 | + cmd.Env = append(os.Environ(), env...) | |
| 113 | + cmd.Stdin = bytes.NewReader(stdin) | |
| 114 | + stdout := &capped{max: maxReply} | |
| 115 | + stderr := &capped{max: maxStderr} | |
| 116 | + cmd.Stdout, cmd.Stderr = stdout, stderr | |
| 117 | + cmd.WaitDelay = waitDelay | |
| 118 | + | |
| 119 | + err := cmd.Run() | |
| 120 | + switch { | |
| 121 | + case errors.Is(ctx.Err(), context.DeadlineExceeded): | |
| 122 | + return nil, fmt.Errorf("timed out after %s", h.Timeout) | |
| 123 | + case errors.Is(ctx.Err(), context.Canceled): | |
| 124 | + return nil, errors.New("cancelled") | |
| 125 | + case err != nil: | |
| 126 | + return nil, withStderr(err, stderr.String()) | |
| 127 | + case stdout.overflow: | |
| 128 | + return nil, fmt.Errorf("stdout larger than %d bytes", maxReply) | |
| 129 | + } | |
| 130 | + return stdout.Bytes(), nil | |
| 131 | +} | |
| 132 | + | |
| 133 | +// withStderr adds the start of what the hook said on stderr to its exit | |
| 134 | +// error: "exit status 2" alone sends the user to read the script. | |
| 135 | +func withStderr(err error, stderr string) error { | |
| 136 | + stderr = strings.TrimSpace(stderr) | |
| 137 | + if stderr == "" { | |
| 138 | + return err | |
| 139 | + } | |
| 140 | + return fmt.Errorf("%w: %s", err, strings.Join(strings.Fields(stderr), " ")) | |
| 141 | +} | |
| 142 | + | |
| 143 | +// capped is a buffer that keeps its first max bytes and drops the rest, | |
| 144 | +// remembering that it did. It never fails a write: a hook blocked on a full | |
| 145 | +// pipe would only end at its timeout. The buffer is a field, not embedded: | |
| 146 | +// an embedded bytes.Buffer brings its ReadFrom along, and io.Copy — what | |
| 147 | +// os/exec uses to fill Stdout — would call it and bypass Write, cap included. | |
| 148 | +type capped struct { | |
| 149 | + buf bytes.Buffer | |
| 150 | + max int | |
| 151 | + overflow bool | |
| 152 | +} | |
| 153 | + | |
| 154 | +func (c *capped) Write(p []byte) (int, error) { | |
| 155 | + room := c.max - c.buf.Len() | |
| 156 | + if len(p) > room { | |
| 157 | + c.overflow = true | |
| 158 | + c.buf.Write(p[:max(room, 0)]) | |
| 159 | + return len(p), nil | |
| 160 | + } | |
| 161 | + return c.buf.Write(p) | |
| 162 | +} | |
| 163 | + | |
| 164 | +// Bytes returns what was kept. | |
| 165 | +func (c *capped) Bytes() []byte { return c.buf.Bytes() } | |
| 166 | + | |
| 167 | +// String returns what was kept, as text. | |
| 168 | +func (c *capped) String() string { return c.buf.String() } | |
| new file mode 100644 | |||
| @@ -0,0 +1,168 @@ | |||
| 1 | +package hooks | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "context" | ||
| 6 | + "encoding/json" | ||
| 7 | + "errors" | ||
| 8 | + "fmt" | ||
| 9 | + "os" | ||
| 10 | + "os/exec" | ||
| 11 | + "strings" | ||
| 12 | + "time" | ||
| 13 | + | ||
| 14 | + "mm/internal/config" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// Environment variables a hook can read besides its stdin. | ||
| 18 | +const ( | ||
| 19 | + // EnvEvent names the event the hook runs for: one script can serve | ||
| 20 | + // several events and still know which one called it. | ||
| 21 | + EnvEvent = "MM_HOOK_EVENT" | ||
| 22 | + // EnvConfigDir is the directory of the YAML file that declared the hook, | ||
| 23 | + // so `node "$MM_CONFIG_DIR/hooks/x.js"` finds a script shipped beside | ||
| 24 | + // its agent.yaml from any working directory. | ||
| 25 | + EnvConfigDir = "MM_CONFIG_DIR" | ||
| 26 | +) | ||
| 27 | + | ||
| 28 | +// maxReply caps what a hook may print on stdout. A reply is a small JSON | ||
| 29 | +// object; a hook that floods its stdout is broken, and must not pull it all | ||
| 30 | +// into memory nor into the model's context. | ||
| 31 | +const maxReply = 1 << 20 | ||
| 32 | + | ||
| 33 | +// maxStderr is how much of a failing hook's stderr its report quotes. | ||
| 34 | +const maxStderr = 300 | ||
| 35 | + | ||
| 36 | +// waitDelay bounds the wait for the pipes once the hook has exited or been | ||
| 37 | +// killed: a hook that leaves a background child holding its stdout would | ||
| 38 | +// otherwise block Wait forever (the same trap as the bash tool's, ticket 26). | ||
| 39 | +const waitDelay = time.Second | ||
| 40 | + | ||
| 41 | +// execFunc runs one hook with its stdin and returns its stdout: runCommand, | ||
| 42 | +// 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 | + | ||
| 45 | +// call runs one hook on in and decodes its reply. An empty stdout is a | ||
| 46 | +// "continue". Every failure — the hook's exit, its timeout, an unreadable | ||
| 47 | +// reply, an unknown action, an explicit "error" — comes back as an error. | ||
| 48 | +func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) { | ||
| 49 | + stdin, err := json.Marshal(in) | ||
| 50 | + if err != nil { | ||
| 51 | + return Reply{}, fmt.Errorf("cannot encode the input: %w", err) | ||
| 52 | + } | ||
| 53 | + env := []string{EnvEvent + "=" + in.Event, EnvConfigDir + "=" + r.cfg.ConfigDir} | ||
| 54 | + stdout, err := r.exec(ctx, h, env, in.Cwd, stdin) | ||
| 55 | + if err != nil { | ||
| 56 | + return Reply{}, err | ||
| 57 | + } | ||
| 58 | + return decode(stdout, in.Event) | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +// decode reads a hook's stdout. An action that does not belong to the event | ||
| 62 | +// (a "deny" from a preGeneration hook) is an error rather than a silent | ||
| 63 | +// continue: whoever wrote it meant to stop something. | ||
| 64 | +func decode(stdout []byte, event string) (Reply, error) { | ||
| 65 | + if len(bytes.TrimSpace(stdout)) == 0 { | ||
| 66 | + return Reply{Action: ActionContinue}, nil | ||
| 67 | + } | ||
| 68 | + var reply Reply | ||
| 69 | + if err := json.Unmarshal(stdout, &reply); err != nil { | ||
| 70 | + return Reply{}, fmt.Errorf("invalid JSON on stdout: %w", err) | ||
| 71 | + } | ||
| 72 | + if reply.Action == "" { | ||
| 73 | + reply.Action = ActionContinue | ||
| 74 | + } | ||
| 75 | + if err := checkAction(reply, event); err != nil { | ||
| 76 | + return Reply{}, err | ||
| 77 | + } | ||
| 78 | + return reply, nil | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +// checkAction refuses a reply whose action is an error, unknown, or meant | ||
| 82 | +// for another event. | ||
| 83 | +func checkAction(reply Reply, event string) error { | ||
| 84 | + switch reply.Action { | ||
| 85 | + case ActionContinue: | ||
| 86 | + return nil | ||
| 87 | + case ActionError: | ||
| 88 | + if reply.Error == "" { | ||
| 89 | + return errors.New("the hook reported an error") | ||
| 90 | + } | ||
| 91 | + return errors.New(reply.Error) | ||
| 92 | + case ActionStop: | ||
| 93 | + if event == config.HookPreGeneration { | ||
| 94 | + return nil | ||
| 95 | + } | ||
| 96 | + case ActionDeny: | ||
| 97 | + if event == config.HookPreToolCall { | ||
| 98 | + return nil | ||
| 99 | + } | ||
| 100 | + } | ||
| 101 | + return fmt.Errorf("action %q is not valid for %s", reply.Action, event) | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// runCommand runs h.Command with `sh -c` in dir ("" = mm's own directory), | ||
| 105 | +// under h.Timeout, with env added to mm's environment. | ||
| 106 | +func runCommand(ctx context.Context, h config.Hook, env []string, dir string, stdin []byte) ([]byte, error) { | ||
| 107 | + ctx, cancel := context.WithTimeout(ctx, h.Timeout) | ||
| 108 | + defer cancel() | ||
| 109 | + | ||
| 110 | + cmd := exec.CommandContext(ctx, "sh", "-c", h.Command) | ||
| 111 | + cmd.Dir = dir | ||
| 112 | + cmd.Env = append(os.Environ(), env...) | ||
| 113 | + cmd.Stdin = bytes.NewReader(stdin) | ||
| 114 | + stdout := &capped{max: maxReply} | ||
| 115 | + stderr := &capped{max: maxStderr} | ||
| 116 | + cmd.Stdout, cmd.Stderr = stdout, stderr | ||
| 117 | + cmd.WaitDelay = waitDelay | ||
| 118 | + | ||
| 119 | + err := cmd.Run() | ||
| 120 | + switch { | ||
| 121 | + case errors.Is(ctx.Err(), context.DeadlineExceeded): | ||
| 122 | + return nil, fmt.Errorf("timed out after %s", h.Timeout) | ||
| 123 | + case errors.Is(ctx.Err(), context.Canceled): | ||
| 124 | + return nil, errors.New("cancelled") | ||
| 125 | + case err != nil: | ||
| 126 | + return nil, withStderr(err, stderr.String()) | ||
| 127 | + case stdout.overflow: | ||
| 128 | + return nil, fmt.Errorf("stdout larger than %d bytes", maxReply) | ||
| 129 | + } | ||
| 130 | + return stdout.Bytes(), nil | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +// withStderr adds the start of what the hook said on stderr to its exit | ||
| 134 | +// error: "exit status 2" alone sends the user to read the script. | ||
| 135 | +func withStderr(err error, stderr string) error { | ||
| 136 | + stderr = strings.TrimSpace(stderr) | ||
| 137 | + if stderr == "" { | ||
| 138 | + return err | ||
| 139 | + } | ||
| 140 | + return fmt.Errorf("%w: %s", err, strings.Join(strings.Fields(stderr), " ")) | ||
| 141 | +} | ||
| 142 | + | ||
| 143 | +// capped is a buffer that keeps its first max bytes and drops the rest, | ||
| 144 | +// remembering that it did. It never fails a write: a hook blocked on a full | ||
| 145 | +// pipe would only end at its timeout. The buffer is a field, not embedded: | ||
| 146 | +// an embedded bytes.Buffer brings its ReadFrom along, and io.Copy — what | ||
| 147 | +// os/exec uses to fill Stdout — would call it and bypass Write, cap included. | ||
| 148 | +type capped struct { | ||
| 149 | + buf bytes.Buffer | ||
| 150 | + max int | ||
| 151 | + overflow bool | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +func (c *capped) Write(p []byte) (int, error) { | ||
| 155 | + room := c.max - c.buf.Len() | ||
| 156 | + if len(p) > room { | ||
| 157 | + c.overflow = true | ||
| 158 | + c.buf.Write(p[:max(room, 0)]) | ||
| 159 | + return len(p), nil | ||
| 160 | + } | ||
| 161 | + return c.buf.Write(p) | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +// Bytes returns what was kept. | ||
| 165 | +func (c *capped) Bytes() []byte { return c.buf.Bytes() } | ||
| 166 | + | ||
| 167 | +// String returns what was kept, as text. | ||
| 168 | +func (c *capped) String() string { return c.buf.String() } | ||
added
internal/hooks/hooks.go +268 -0 | new file mode 100644 | ||
| @@ -0,0 +1,268 @@ | ||
| 1 | +// Package hooks runs the external commands of the `hooks:` block (ticket 36) | |
| 2 | +// at four moments of a turn, and turns what they answer into decisions the | |
| 3 | +// engine applies. | |
| 4 | +// | |
| 5 | +// A hook is any program: it reads ONE JSON object on stdin (Input) and may | |
| 6 | +// write ONE JSON object on stdout (Reply). Writing nothing means "continue", | |
| 7 | +// so a hook that only logs has nothing to print. Its stderr is shown only | |
| 8 | +// when it fails. | |
| 9 | +// | |
| 10 | +// runner := hooks.New(config.Cfg.Hooks, func(msg string) { fmt.Println(msg) }) | |
| 11 | +// d := runner.PreGeneration(ctx, hooks.Input{Cwd: dir, Prompt: question}) | |
| 12 | +// if d.Stopped { | |
| 13 | +// fmt.Println(d.Response) // the model is never called | |
| 14 | +// } | |
| 15 | +// | |
| 16 | +// The package knows nothing about Genkit or the front ends: the engine builds | |
| 17 | +// the Input, applies the decision, and decides how to show it. | |
| 18 | +package hooks | |
| 19 | + | |
| 20 | +import ( | |
| 21 | + "context" | |
| 22 | + "encoding/json" | |
| 23 | + "strings" | |
| 24 | + | |
| 25 | + "mm/internal/config" | |
| 26 | +) | |
| 27 | + | |
| 28 | +// The actions a hook may answer. An empty action is ActionContinue. | |
| 29 | +const ( | |
| 30 | + ActionContinue = "continue" | |
| 31 | + // ActionStop ends the turn before the model is called (preGeneration). | |
| 32 | + ActionStop = "stop" | |
| 33 | + // ActionDeny refuses one tool call (preToolCall); the model reads the | |
| 34 | + // reason as the tool's output. | |
| 35 | + ActionDeny = "deny" | |
| 36 | + // ActionError is the hook saying it failed: handled like a crash, | |
| 37 | + // according to its onError. | |
| 38 | + ActionError = "error" | |
| 39 | +) | |
| 40 | + | |
| 41 | +// Input is the JSON object a hook reads on stdin. Which fields are filled | |
| 42 | +// depends on the event: | |
| 43 | +// | |
| 44 | +// preGeneration event, cwd, model, prompt | |
| 45 | +// preToolCall event, cwd, model, tool | |
| 46 | +// postToolCall event, cwd, model, tool, output | |
| 47 | +// postGeneration event, cwd, model, prompt, answer, error | |
| 48 | +type Input struct { | |
| 49 | + Event string `json:"event"` | |
| 50 | + Cwd string `json:"cwd"` | |
| 51 | + Model string `json:"model,omitempty"` | |
| 52 | + Prompt string `json:"prompt,omitempty"` | |
| 53 | + Tool *ToolCall `json:"tool,omitempty"` | |
| 54 | + Output string `json:"output,omitempty"` | |
| 55 | + Answer string `json:"answer,omitempty"` | |
| 56 | + Error string `json:"error,omitempty"` | |
| 57 | +} | |
| 58 | + | |
| 59 | +// ToolCall describes the tool call a preToolCall or postToolCall hook is | |
| 60 | +// about: the model's own arguments, as JSON. | |
| 61 | +type ToolCall struct { | |
| 62 | + ID string `json:"id,omitempty"` | |
| 63 | + Name string `json:"name"` | |
| 64 | + Input any `json:"input"` | |
| 65 | +} | |
| 66 | + | |
| 67 | +// Reply is the JSON object a hook may write on stdout. Every field is | |
| 68 | +// optional; which ones are read depends on the event (see the decisions). | |
| 69 | +type Reply struct { | |
| 70 | + Action string `json:"action,omitempty"` | |
| 71 | + // Prompt replaces the user's question (preGeneration). Absent = kept. | |
| 72 | + Prompt *string `json:"prompt,omitempty"` | |
| 73 | + // Context is text added for the model: after the question | |
| 74 | + // (preGeneration), after the tool's output (postToolCall). | |
| 75 | + Context string `json:"context,omitempty"` | |
| 76 | + // Input replaces the tool call's arguments (preToolCall). Absent = kept. | |
| 77 | + Input json.RawMessage `json:"input,omitempty"` | |
| 78 | + // Reason says why a turn was stopped or a call denied. | |
| 79 | + Reason string `json:"reason,omitempty"` | |
| 80 | + // Response is what the user reads when the turn is stopped. | |
| 81 | + Response string `json:"response,omitempty"` | |
| 82 | + // Error is the hook's own failure message, with action "error". | |
| 83 | + Error string `json:"error,omitempty"` | |
| 84 | +} | |
| 85 | + | |
| 86 | +// Runner runs the hooks of one configuration. The zero value runs none. | |
| 87 | +type Runner struct { | |
| 88 | + cfg config.HooksConfig | |
| 89 | + notify func(string) | |
| 90 | + exec execFunc | |
| 91 | +} | |
| 92 | + | |
| 93 | +// New returns a runner for cfg. notify receives the one-line reports of the | |
| 94 | +// hooks that fail with onError warn (and fail, on the post* events); nil | |
| 95 | +// drops them. | |
| 96 | +func New(cfg config.HooksConfig, notify func(string)) *Runner { | |
| 97 | + if notify == nil { | |
| 98 | + notify = func(string) {} | |
| 99 | + } | |
| 100 | + return &Runner{cfg: cfg, notify: notify, exec: runCommand} | |
| 101 | +} | |
| 102 | + | |
| 103 | +// Enabled reports whether any hook is declared — the engine skips building | |
| 104 | +// payloads when there is none. | |
| 105 | +func (r *Runner) Enabled() bool { | |
| 106 | + return r != nil && r.cfg.Summary() != "" | |
| 107 | +} | |
| 108 | + | |
| 109 | +// PromptDecision is what the preGeneration chain decided. | |
| 110 | +type PromptDecision struct { | |
| 111 | + // Prompt is the question to send: the user's, or the last rewrite. | |
| 112 | + Prompt string | |
| 113 | + // Context holds the text the hooks added, in hook order. | |
| 114 | + Context []string | |
| 115 | + // Stopped means the model must not be called; Response is what the user | |
| 116 | + // reads instead and Reason why. | |
| 117 | + Stopped bool | |
| 118 | + Response string | |
| 119 | + Reason string | |
| 120 | +} | |
| 121 | + | |
| 122 | +// Changed reports whether the chain rewrote the prompt or added context. | |
| 123 | +func (d PromptDecision) Changed(original string) bool { | |
| 124 | + return d.Prompt != original || len(d.Context) > 0 | |
| 125 | +} | |
| 126 | + | |
| 127 | +// PreGeneration runs the preGeneration chain on in.Prompt. Each hook sees the | |
| 128 | +// prompt as the previous one left it; the first stop ends the chain. | |
| 129 | +func (r *Runner) PreGeneration(ctx context.Context, in Input) PromptDecision { | |
| 130 | + in.Event = config.HookPreGeneration | |
| 131 | + d := PromptDecision{Prompt: in.Prompt} | |
| 132 | + if r == nil { | |
| 133 | + return d | |
| 134 | + } | |
| 135 | + for _, h := range r.cfg.For(config.HookPreGeneration) { | |
| 136 | + in.Prompt = d.Prompt | |
| 137 | + reply, err := r.call(ctx, h, in) | |
| 138 | + if err != nil { | |
| 139 | + if r.failed(h, in.Event, err) { | |
| 140 | + return stopped(d, "hook failed: "+err.Error()) | |
| 141 | + } | |
| 142 | + continue | |
| 143 | + } | |
| 144 | + if reply.Action == ActionStop { | |
| 145 | + return stopped(d, reply.Reason, reply.Response) | |
| 146 | + } | |
| 147 | + if reply.Prompt != nil { | |
| 148 | + d.Prompt = *reply.Prompt | |
| 149 | + } | |
| 150 | + if c := strings.TrimSpace(reply.Context); c != "" { | |
| 151 | + d.Context = append(d.Context, c) | |
| 152 | + } | |
| 153 | + } | |
| 154 | + return d | |
| 155 | +} | |
| 156 | + | |
| 157 | +// stopped marks d as stopped. The response defaults to a sentence built on | |
| 158 | +// the reason, so a hook that only says why still tells the user something. | |
| 159 | +func stopped(d PromptDecision, reason string, response ...string) PromptDecision { | |
| 160 | + d.Stopped, d.Reason = true, reason | |
| 161 | + if len(response) > 0 && strings.TrimSpace(response[0]) != "" { | |
| 162 | + d.Response = response[0] | |
| 163 | + return d | |
| 164 | + } | |
| 165 | + d.Response = "⛔ This request was blocked by a hook." | |
| 166 | + if reason != "" { | |
| 167 | + d.Response = "⛔ This request was blocked by a hook: " + reason | |
| 168 | + } | |
| 169 | + return d | |
| 170 | +} | |
| 171 | + | |
| 172 | +// ToolDecision is what the preToolCall chain decided. | |
| 173 | +type ToolDecision struct { | |
| 174 | + // Input is the call's arguments: the model's, or the last replacement. | |
| 175 | + Input any | |
| 176 | + // Denied means the tool must not run; Reason is handed to the model. | |
| 177 | + Denied bool | |
| 178 | + Reason string | |
| 179 | +} | |
| 180 | + | |
| 181 | +// PreToolCall runs the preToolCall hooks whose matcher accepts the tool. Each | |
| 182 | +// one sees the input as the previous one left it; the first deny ends the | |
| 183 | +// chain. | |
| 184 | +func (r *Runner) PreToolCall(ctx context.Context, in Input) ToolDecision { | |
| 185 | + in.Event = config.HookPreToolCall | |
| 186 | + d := ToolDecision{Input: in.Tool.Input} | |
| 187 | + if r == nil { | |
| 188 | + return d | |
| 189 | + } | |
| 190 | + // A copy: the chain rewrites the input it shows each hook, and the | |
| 191 | + // caller's ToolCall must stay what the model asked for. | |
| 192 | + tool := *in.Tool | |
| 193 | + in.Tool = &tool | |
| 194 | + for _, h := range r.cfg.For(config.HookPreToolCall) { | |
| 195 | + if !h.Matches(in.Tool.Name) { | |
| 196 | + continue | |
| 197 | + } | |
| 198 | + in.Tool.Input = d.Input | |
| 199 | + reply, err := r.call(ctx, h, in) | |
| 200 | + if err != nil { | |
| 201 | + if r.failed(h, in.Event, err) { | |
| 202 | + return ToolDecision{Input: d.Input, Denied: true, Reason: "hook failed: " + err.Error()} | |
| 203 | + } | |
| 204 | + continue | |
| 205 | + } | |
| 206 | + if reply.Action == ActionDeny { | |
| 207 | + return ToolDecision{Input: d.Input, Denied: true, Reason: reply.Reason} | |
| 208 | + } | |
| 209 | + if len(reply.Input) > 0 { | |
| 210 | + var replaced any | |
| 211 | + if err := json.Unmarshal(reply.Input, &replaced); err == nil { | |
| 212 | + d.Input = replaced | |
| 213 | + } | |
| 214 | + } | |
| 215 | + } | |
| 216 | + return d | |
| 217 | +} | |
| 218 | + | |
| 219 | +// PostToolCall runs the postToolCall hooks whose matcher accepts the tool, | |
| 220 | +// and returns the context they added for the model, in hook order. | |
| 221 | +func (r *Runner) PostToolCall(ctx context.Context, in Input) []string { | |
| 222 | + in.Event = config.HookPostToolCall | |
| 223 | + if r == nil { | |
| 224 | + return nil | |
| 225 | + } | |
| 226 | + var added []string | |
| 227 | + for _, h := range r.cfg.For(config.HookPostToolCall) { | |
| 228 | + if !h.Matches(in.Tool.Name) { | |
| 229 | + continue | |
| 230 | + } | |
| 231 | + reply, err := r.call(ctx, h, in) | |
| 232 | + if err != nil { | |
| 233 | + r.failed(h, in.Event, err) | |
| 234 | + continue | |
| 235 | + } | |
| 236 | + if c := strings.TrimSpace(reply.Context); c != "" { | |
| 237 | + added = append(added, c) | |
| 238 | + } | |
| 239 | + } | |
| 240 | + return added | |
| 241 | +} | |
| 242 | + | |
| 243 | +// PostGeneration runs the postGeneration hooks. They only observe: what they | |
| 244 | +// print is ignored, a failure is reported according to onError. | |
| 245 | +func (r *Runner) PostGeneration(ctx context.Context, in Input) { | |
| 246 | + in.Event = config.HookPostGeneration | |
| 247 | + if r == nil { | |
| 248 | + return | |
| 249 | + } | |
| 250 | + for _, h := range r.cfg.For(config.HookPostGeneration) { | |
| 251 | + if _, err := r.call(ctx, h, in); err != nil { | |
| 252 | + r.failed(h, in.Event, err) | |
| 253 | + } | |
| 254 | + } | |
| 255 | +} | |
| 256 | + | |
| 257 | +// failed reports a hook's failure according to its onError, and says whether | |
| 258 | +// the turn (or the call) must stop: only for fail, and only on the pre* | |
| 259 | +// events — after the fact there is nothing left to stop, so fail is | |
| 260 | +// reported like warn there. | |
| 261 | +func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) { | |
| 262 | + if h.OnError == config.HookOnErrorIgnore { | |
| 263 | + return false | |
| 264 | + } | |
| 265 | + r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error()) | |
| 266 | + pre := event == config.HookPreGeneration || event == config.HookPreToolCall | |
| 267 | + return pre && h.OnError == config.HookOnErrorFail | |
| 268 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,268 @@ | |||
| 1 | +// Package hooks runs the external commands of the `hooks:` block (ticket 36) | ||
| 2 | +// at four moments of a turn, and turns what they answer into decisions the | ||
| 3 | +// engine applies. | ||
| 4 | +// | ||
| 5 | +// A hook is any program: it reads ONE JSON object on stdin (Input) and may | ||
| 6 | +// write ONE JSON object on stdout (Reply). Writing nothing means "continue", | ||
| 7 | +// so a hook that only logs has nothing to print. Its stderr is shown only | ||
| 8 | +// when it fails. | ||
| 9 | +// | ||
| 10 | +// runner := hooks.New(config.Cfg.Hooks, func(msg string) { fmt.Println(msg) }) | ||
| 11 | +// d := runner.PreGeneration(ctx, hooks.Input{Cwd: dir, Prompt: question}) | ||
| 12 | +// if d.Stopped { | ||
| 13 | +// fmt.Println(d.Response) // the model is never called | ||
| 14 | +// } | ||
| 15 | +// | ||
| 16 | +// The package knows nothing about Genkit or the front ends: the engine builds | ||
| 17 | +// the Input, applies the decision, and decides how to show it. | ||
| 18 | +package hooks | ||
| 19 | + | ||
| 20 | +import ( | ||
| 21 | + "context" | ||
| 22 | + "encoding/json" | ||
| 23 | + "strings" | ||
| 24 | + | ||
| 25 | + "mm/internal/config" | ||
| 26 | +) | ||
| 27 | + | ||
| 28 | +// The actions a hook may answer. An empty action is ActionContinue. | ||
| 29 | +const ( | ||
| 30 | + ActionContinue = "continue" | ||
| 31 | + // ActionStop ends the turn before the model is called (preGeneration). | ||
| 32 | + ActionStop = "stop" | ||
| 33 | + // ActionDeny refuses one tool call (preToolCall); the model reads the | ||
| 34 | + // reason as the tool's output. | ||
| 35 | + ActionDeny = "deny" | ||
| 36 | + // ActionError is the hook saying it failed: handled like a crash, | ||
| 37 | + // according to its onError. | ||
| 38 | + ActionError = "error" | ||
| 39 | +) | ||
| 40 | + | ||
| 41 | +// Input is the JSON object a hook reads on stdin. Which fields are filled | ||
| 42 | +// depends on the event: | ||
| 43 | +// | ||
| 44 | +// preGeneration event, cwd, model, prompt | ||
| 45 | +// preToolCall event, cwd, model, tool | ||
| 46 | +// postToolCall event, cwd, model, tool, output | ||
| 47 | +// postGeneration event, cwd, model, prompt, answer, error | ||
| 48 | +type Input struct { | ||
| 49 | + Event string `json:"event"` | ||
| 50 | + Cwd string `json:"cwd"` | ||
| 51 | + Model string `json:"model,omitempty"` | ||
| 52 | + Prompt string `json:"prompt,omitempty"` | ||
| 53 | + Tool *ToolCall `json:"tool,omitempty"` | ||
| 54 | + Output string `json:"output,omitempty"` | ||
| 55 | + Answer string `json:"answer,omitempty"` | ||
| 56 | + Error string `json:"error,omitempty"` | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// ToolCall describes the tool call a preToolCall or postToolCall hook is | ||
| 60 | +// about: the model's own arguments, as JSON. | ||
| 61 | +type ToolCall struct { | ||
| 62 | + ID string `json:"id,omitempty"` | ||
| 63 | + Name string `json:"name"` | ||
| 64 | + Input any `json:"input"` | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +// Reply is the JSON object a hook may write on stdout. Every field is | ||
| 68 | +// optional; which ones are read depends on the event (see the decisions). | ||
| 69 | +type Reply struct { | ||
| 70 | + Action string `json:"action,omitempty"` | ||
| 71 | + // Prompt replaces the user's question (preGeneration). Absent = kept. | ||
| 72 | + Prompt *string `json:"prompt,omitempty"` | ||
| 73 | + // Context is text added for the model: after the question | ||
| 74 | + // (preGeneration), after the tool's output (postToolCall). | ||
| 75 | + Context string `json:"context,omitempty"` | ||
| 76 | + // Input replaces the tool call's arguments (preToolCall). Absent = kept. | ||
| 77 | + Input json.RawMessage `json:"input,omitempty"` | ||
| 78 | + // Reason says why a turn was stopped or a call denied. | ||
| 79 | + Reason string `json:"reason,omitempty"` | ||
| 80 | + // Response is what the user reads when the turn is stopped. | ||
| 81 | + Response string `json:"response,omitempty"` | ||
| 82 | + // Error is the hook's own failure message, with action "error". | ||
| 83 | + Error string `json:"error,omitempty"` | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +// Runner runs the hooks of one configuration. The zero value runs none. | ||
| 87 | +type Runner struct { | ||
| 88 | + cfg config.HooksConfig | ||
| 89 | + notify func(string) | ||
| 90 | + exec execFunc | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +// New returns a runner for cfg. notify receives the one-line reports of the | ||
| 94 | +// hooks that fail with onError warn (and fail, on the post* events); nil | ||
| 95 | +// drops them. | ||
| 96 | +func New(cfg config.HooksConfig, notify func(string)) *Runner { | ||
| 97 | + if notify == nil { | ||
| 98 | + notify = func(string) {} | ||
| 99 | + } | ||
| 100 | + return &Runner{cfg: cfg, notify: notify, exec: runCommand} | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +// Enabled reports whether any hook is declared — the engine skips building | ||
| 104 | +// payloads when there is none. | ||
| 105 | +func (r *Runner) Enabled() bool { | ||
| 106 | + return r != nil && r.cfg.Summary() != "" | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +// PromptDecision is what the preGeneration chain decided. | ||
| 110 | +type PromptDecision struct { | ||
| 111 | + // Prompt is the question to send: the user's, or the last rewrite. | ||
| 112 | + Prompt string | ||
| 113 | + // Context holds the text the hooks added, in hook order. | ||
| 114 | + Context []string | ||
| 115 | + // Stopped means the model must not be called; Response is what the user | ||
| 116 | + // reads instead and Reason why. | ||
| 117 | + Stopped bool | ||
| 118 | + Response string | ||
| 119 | + Reason string | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +// Changed reports whether the chain rewrote the prompt or added context. | ||
| 123 | +func (d PromptDecision) Changed(original string) bool { | ||
| 124 | + return d.Prompt != original || len(d.Context) > 0 | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// PreGeneration runs the preGeneration chain on in.Prompt. Each hook sees the | ||
| 128 | +// prompt as the previous one left it; the first stop ends the chain. | ||
| 129 | +func (r *Runner) PreGeneration(ctx context.Context, in Input) PromptDecision { | ||
| 130 | + in.Event = config.HookPreGeneration | ||
| 131 | + d := PromptDecision{Prompt: in.Prompt} | ||
| 132 | + if r == nil { | ||
| 133 | + return d | ||
| 134 | + } | ||
| 135 | + for _, h := range r.cfg.For(config.HookPreGeneration) { | ||
| 136 | + in.Prompt = d.Prompt | ||
| 137 | + reply, err := r.call(ctx, h, in) | ||
| 138 | + if err != nil { | ||
| 139 | + if r.failed(h, in.Event, err) { | ||
| 140 | + return stopped(d, "hook failed: "+err.Error()) | ||
| 141 | + } | ||
| 142 | + continue | ||
| 143 | + } | ||
| 144 | + if reply.Action == ActionStop { | ||
| 145 | + return stopped(d, reply.Reason, reply.Response) | ||
| 146 | + } | ||
| 147 | + if reply.Prompt != nil { | ||
| 148 | + d.Prompt = *reply.Prompt | ||
| 149 | + } | ||
| 150 | + if c := strings.TrimSpace(reply.Context); c != "" { | ||
| 151 | + d.Context = append(d.Context, c) | ||
| 152 | + } | ||
| 153 | + } | ||
| 154 | + return d | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +// stopped marks d as stopped. The response defaults to a sentence built on | ||
| 158 | +// the reason, so a hook that only says why still tells the user something. | ||
| 159 | +func stopped(d PromptDecision, reason string, response ...string) PromptDecision { | ||
| 160 | + d.Stopped, d.Reason = true, reason | ||
| 161 | + if len(response) > 0 && strings.TrimSpace(response[0]) != "" { | ||
| 162 | + d.Response = response[0] | ||
| 163 | + return d | ||
| 164 | + } | ||
| 165 | + d.Response = "⛔ This request was blocked by a hook." | ||
| 166 | + if reason != "" { | ||
| 167 | + d.Response = "⛔ This request was blocked by a hook: " + reason | ||
| 168 | + } | ||
| 169 | + return d | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +// ToolDecision is what the preToolCall chain decided. | ||
| 173 | +type ToolDecision struct { | ||
| 174 | + // Input is the call's arguments: the model's, or the last replacement. | ||
| 175 | + Input any | ||
| 176 | + // Denied means the tool must not run; Reason is handed to the model. | ||
| 177 | + Denied bool | ||
| 178 | + Reason string | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +// PreToolCall runs the preToolCall hooks whose matcher accepts the tool. Each | ||
| 182 | +// one sees the input as the previous one left it; the first deny ends the | ||
| 183 | +// chain. | ||
| 184 | +func (r *Runner) PreToolCall(ctx context.Context, in Input) ToolDecision { | ||
| 185 | + in.Event = config.HookPreToolCall | ||
| 186 | + d := ToolDecision{Input: in.Tool.Input} | ||
| 187 | + if r == nil { | ||
| 188 | + return d | ||
| 189 | + } | ||
| 190 | + // A copy: the chain rewrites the input it shows each hook, and the | ||
| 191 | + // caller's ToolCall must stay what the model asked for. | ||
| 192 | + tool := *in.Tool | ||
| 193 | + in.Tool = &tool | ||
| 194 | + for _, h := range r.cfg.For(config.HookPreToolCall) { | ||
| 195 | + if !h.Matches(in.Tool.Name) { | ||
| 196 | + continue | ||
| 197 | + } | ||
| 198 | + in.Tool.Input = d.Input | ||
| 199 | + reply, err := r.call(ctx, h, in) | ||
| 200 | + if err != nil { | ||
| 201 | + if r.failed(h, in.Event, err) { | ||
| 202 | + return ToolDecision{Input: d.Input, Denied: true, Reason: "hook failed: " + err.Error()} | ||
| 203 | + } | ||
| 204 | + continue | ||
| 205 | + } | ||
| 206 | + if reply.Action == ActionDeny { | ||
| 207 | + return ToolDecision{Input: d.Input, Denied: true, Reason: reply.Reason} | ||
| 208 | + } | ||
| 209 | + if len(reply.Input) > 0 { | ||
| 210 | + var replaced any | ||
| 211 | + if err := json.Unmarshal(reply.Input, &replaced); err == nil { | ||
| 212 | + d.Input = replaced | ||
| 213 | + } | ||
| 214 | + } | ||
| 215 | + } | ||
| 216 | + return d | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +// PostToolCall runs the postToolCall hooks whose matcher accepts the tool, | ||
| 220 | +// and returns the context they added for the model, in hook order. | ||
| 221 | +func (r *Runner) PostToolCall(ctx context.Context, in Input) []string { | ||
| 222 | + in.Event = config.HookPostToolCall | ||
| 223 | + if r == nil { | ||
| 224 | + return nil | ||
| 225 | + } | ||
| 226 | + var added []string | ||
| 227 | + for _, h := range r.cfg.For(config.HookPostToolCall) { | ||
| 228 | + if !h.Matches(in.Tool.Name) { | ||
| 229 | + continue | ||
| 230 | + } | ||
| 231 | + reply, err := r.call(ctx, h, in) | ||
| 232 | + if err != nil { | ||
| 233 | + r.failed(h, in.Event, err) | ||
| 234 | + continue | ||
| 235 | + } | ||
| 236 | + if c := strings.TrimSpace(reply.Context); c != "" { | ||
| 237 | + added = append(added, c) | ||
| 238 | + } | ||
| 239 | + } | ||
| 240 | + return added | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +// PostGeneration runs the postGeneration hooks. They only observe: what they | ||
| 244 | +// print is ignored, a failure is reported according to onError. | ||
| 245 | +func (r *Runner) PostGeneration(ctx context.Context, in Input) { | ||
| 246 | + in.Event = config.HookPostGeneration | ||
| 247 | + if r == nil { | ||
| 248 | + return | ||
| 249 | + } | ||
| 250 | + for _, h := range r.cfg.For(config.HookPostGeneration) { | ||
| 251 | + if _, err := r.call(ctx, h, in); err != nil { | ||
| 252 | + r.failed(h, in.Event, err) | ||
| 253 | + } | ||
| 254 | + } | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +// failed reports a hook's failure according to its onError, and says whether | ||
| 258 | +// the turn (or the call) must stop: only for fail, and only on the pre* | ||
| 259 | +// events — after the fact there is nothing left to stop, so fail is | ||
| 260 | +// reported like warn there. | ||
| 261 | +func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) { | ||
| 262 | + if h.OnError == config.HookOnErrorIgnore { | ||
| 263 | + return false | ||
| 264 | + } | ||
| 265 | + r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error()) | ||
| 266 | + pre := event == config.HookPreGeneration || event == config.HookPreToolCall | ||
| 267 | + return pre && h.OnError == config.HookOnErrorFail | ||
| 268 | +} | ||
added
internal/hooks/hooks_test.go +295 -0 | new file mode 100644 | ||
| @@ -0,0 +1,295 @@ | ||
| 1 | +package hooks | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "mm/internal/config" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// The tests run real hooks through `sh -c`, written as tiny shell scripts: | |
| 16 | +// the protocol (stdin, stdout, exit code, environment) is what is under test, | |
| 17 | +// and node is not needed for that. | |
| 18 | + | |
| 19 | +// hook returns a hook entry that runs script with the defaults Load would | |
| 20 | +// have filled in. | |
| 21 | +func hook(script string) config.Hook { | |
| 22 | + return config.Hook{Command: script, Timeout: 5 * time.Second, OnError: config.HookOnErrorWarn} | |
| 23 | +} | |
| 24 | + | |
| 25 | +// runner builds a Runner for cfg and collects what it reports. | |
| 26 | +func runner(cfg config.HooksConfig) (*Runner, *[]string) { | |
| 27 | + var notes []string | |
| 28 | + return New(cfg, func(s string) { notes = append(notes, s) }), ¬es | |
| 29 | +} | |
| 30 | + | |
| 31 | +func TestNoHookChangesNothing(t *testing.T) { | |
| 32 | + r, notes := runner(config.HooksConfig{}) | |
| 33 | + if r.Enabled() { | |
| 34 | + t.Error("Enabled() = true with no hook") | |
| 35 | + } | |
| 36 | + d := r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | |
| 37 | + if d.Stopped || d.Changed("hello") { | |
| 38 | + t.Errorf("PreGeneration = %+v, want the prompt untouched", d) | |
| 39 | + } | |
| 40 | + td := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "ls"}}}) | |
| 41 | + if td.Denied { | |
| 42 | + t.Errorf("PreToolCall = %+v, want allowed", td) | |
| 43 | + } | |
| 44 | + if len(*notes) > 0 { | |
| 45 | + t.Errorf("notes = %v, want none", *notes) | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +// A hook that prints nothing continues: a logging hook owes no answer. | |
| 50 | +func TestEmptyStdoutContinues(t *testing.T) { | |
| 51 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook("cat > /dev/null")}}) | |
| 52 | + d := r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | |
| 53 | + if d.Stopped || d.Prompt != "hello" || len(*notes) > 0 { | |
| 54 | + t.Errorf("decision %+v, notes %v; want untouched, no note", d, *notes) | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +// The hook reads the event, the prompt and its environment. | |
| 59 | +func TestHookReadsItsInputAndEnvironment(t *testing.T) { | |
| 60 | + dir := t.TempDir() | |
| 61 | + out := filepath.Join(dir, "seen.json") | |
| 62 | + cfg := config.HooksConfig{ | |
| 63 | + ConfigDir: "/the/config/dir", | |
| 64 | + PreGeneration: []config.Hook{hook(`cat > "` + out + `"; echo "$MM_HOOK_EVENT $MM_CONFIG_DIR $(pwd)" >> "` + out + `.env"`)}, | |
| 65 | + } | |
| 66 | + r, _ := runner(cfg) | |
| 67 | + r.PreGeneration(context.Background(), Input{Cwd: dir, Model: "m", Prompt: "list files"}) | |
| 68 | + | |
| 69 | + var seen Input | |
| 70 | + data, err := os.ReadFile(out) | |
| 71 | + if err != nil { | |
| 72 | + t.Fatal(err) | |
| 73 | + } | |
| 74 | + if err := json.Unmarshal(data, &seen); err != nil { | |
| 75 | + t.Fatalf("stdin was not JSON: %v\n%s", err, data) | |
| 76 | + } | |
| 77 | + if seen.Event != "preGeneration" || seen.Prompt != "list files" || seen.Cwd != dir || seen.Model != "m" { | |
| 78 | + t.Errorf("hook read %+v", seen) | |
| 79 | + } | |
| 80 | + env, _ := os.ReadFile(out + ".env") | |
| 81 | + resolved, _ := filepath.EvalSymlinks(dir) | |
| 82 | + if got := strings.TrimSpace(string(env)); got != "preGeneration /the/config/dir "+dir && got != "preGeneration /the/config/dir "+resolved { | |
| 83 | + t.Errorf("environment and directory = %q", got) | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +// Rewrites chain: the second hook sees the first one's prompt; contexts | |
| 88 | +// accumulate in order. | |
| 89 | +func TestPreGenerationRewritesAndAddsContextInOrder(t *testing.T) { | |
| 90 | + cfg := config.HooksConfig{PreGeneration: []config.Hook{ | |
| 91 | + hook(`echo '{"prompt":"first rewrite","context":"ctx one"}'`), | |
| 92 | + hook(`grep -q '"prompt":"first rewrite"' && echo '{"action":"continue","prompt":"second rewrite","context":"ctx two"}'`), | |
| 93 | + }} | |
| 94 | + r, notes := runner(cfg) | |
| 95 | + d := r.PreGeneration(context.Background(), Input{Prompt: "original"}) | |
| 96 | + if d.Prompt != "second rewrite" { | |
| 97 | + t.Errorf("Prompt = %q, want the second hook's rewrite (it must see the first one's)", d.Prompt) | |
| 98 | + } | |
| 99 | + if strings.Join(d.Context, "|") != "ctx one|ctx two" { | |
| 100 | + t.Errorf("Context = %v, want [ctx one ctx two]", d.Context) | |
| 101 | + } | |
| 102 | + if !d.Changed("original") || len(*notes) > 0 { | |
| 103 | + t.Errorf("Changed = false or notes %v", *notes) | |
| 104 | + } | |
| 105 | +} | |
| 106 | + | |
| 107 | +// The first stop ends the chain: the hook after it never runs. | |
| 108 | +func TestPreGenerationStopEndsTheChain(t *testing.T) { | |
| 109 | + marker := filepath.Join(t.TempDir(), "ran") | |
| 110 | + cfg := config.HooksConfig{PreGeneration: []config.Hook{ | |
| 111 | + hook(`echo '{"action":"stop","reason":"secret","response":"No secrets here."}'`), | |
| 112 | + hook(`touch "` + marker + `"`), | |
| 113 | + }} | |
| 114 | + r, _ := runner(cfg) | |
| 115 | + d := r.PreGeneration(context.Background(), Input{Prompt: "my password is x"}) | |
| 116 | + if !d.Stopped || d.Response != "No secrets here." || d.Reason != "secret" { | |
| 117 | + t.Errorf("decision = %+v, want stopped with the hook's response", d) | |
| 118 | + } | |
| 119 | + if _, err := os.Stat(marker); err == nil { | |
| 120 | + t.Error("the hook after the stop ran") | |
| 121 | + } | |
| 122 | +} | |
| 123 | + | |
| 124 | +// A stop without a response still tells the user something. | |
| 125 | +func TestStopWithoutResponseExplainsTheReason(t *testing.T) { | |
| 126 | + r, _ := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`echo '{"action":"stop","reason":"too short"}'`)}}) | |
| 127 | + d := r.PreGeneration(context.Background(), Input{Prompt: "x"}) | |
| 128 | + if !strings.Contains(d.Response, "too short") { | |
| 129 | + t.Errorf("Response = %q, want the reason in it", d.Response) | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +// onError decides what a failing hook does, on a pre* event. | |
| 134 | +func TestFailingHookFollowsOnError(t *testing.T) { | |
| 135 | + cases := []struct { | |
| 136 | + onError string | |
| 137 | + wantStopped bool | |
| 138 | + wantNote bool | |
| 139 | + }{ | |
| 140 | + {config.HookOnErrorFail, true, true}, | |
| 141 | + {config.HookOnErrorWarn, false, true}, | |
| 142 | + {config.HookOnErrorIgnore, false, false}, | |
| 143 | + } | |
| 144 | + failures := map[string]string{ | |
| 145 | + "exit code": `echo boom >&2; exit 2`, | |
| 146 | + "invalid JSON": `echo 'not json'`, | |
| 147 | + "error action": `echo '{"action":"error","error":"no config file"}'`, | |
| 148 | + "wrong action": `echo '{"action":"deny"}'`, // deny is for tools | |
| 149 | + "missing shell": `this-command-does-not-exist-anywhere`, | |
| 150 | + } | |
| 151 | + for name, script := range failures { | |
| 152 | + for _, tc := range cases { | |
| 153 | + t.Run(name+"/"+tc.onError, func(t *testing.T) { | |
| 154 | + h := hook(script) | |
| 155 | + h.OnError = tc.onError | |
| 156 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{h}}) | |
| 157 | + d := r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | |
| 158 | + if d.Stopped != tc.wantStopped { | |
| 159 | + t.Errorf("Stopped = %v, want %v (%+v)", d.Stopped, tc.wantStopped, d) | |
| 160 | + } | |
| 161 | + if (len(*notes) > 0) != tc.wantNote { | |
| 162 | + t.Errorf("notes = %v, want a note: %v", *notes, tc.wantNote) | |
| 163 | + } | |
| 164 | + }) | |
| 165 | + } | |
| 166 | + } | |
| 167 | +} | |
| 168 | + | |
| 169 | +// The report quotes the hook's stderr: "exit status 2" alone sends the user | |
| 170 | +// to read the script. | |
| 171 | +func TestFailureReportQuotesStderr(t *testing.T) { | |
| 172 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`echo "cannot read rules.json" >&2; exit 1`)}}) | |
| 173 | + r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | |
| 174 | + if len(*notes) != 1 || !strings.Contains((*notes)[0], "cannot read rules.json") { | |
| 175 | + t.Errorf("notes = %v, want the stderr quoted", *notes) | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 179 | +func TestHookTimesOut(t *testing.T) { | |
| 180 | + h := hook(`sleep 5`) | |
| 181 | + h.Timeout = 100 * time.Millisecond | |
| 182 | + h.OnError = config.HookOnErrorFail | |
| 183 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{h}}) | |
| 184 | + start := time.Now() | |
| 185 | + d := r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | |
| 186 | + if elapsed := time.Since(start); elapsed > 3*time.Second { | |
| 187 | + t.Errorf("took %s, want the timeout to cut it", elapsed) | |
| 188 | + } | |
| 189 | + if !d.Stopped || len(*notes) != 1 || !strings.Contains((*notes)[0], "timed out") { | |
| 190 | + t.Errorf("decision %+v, notes %v; want stopped on a timeout", d, *notes) | |
| 191 | + } | |
| 192 | +} | |
| 193 | + | |
| 194 | +// A hook that floods its stdout is a failure, not a 1 GB context. | |
| 195 | +func TestOversizedReplyIsAFailure(t *testing.T) { | |
| 196 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`head -c 2000000 /dev/zero`)}}) | |
| 197 | + r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | |
| 198 | + if len(*notes) != 1 || !strings.Contains((*notes)[0], "larger than") { | |
| 199 | + t.Errorf("notes = %v, want the size reported", *notes) | |
| 200 | + } | |
| 201 | +} | |
| 202 | + | |
| 203 | +func TestPreToolCallDeniesMatchingToolsOnly(t *testing.T) { | |
| 204 | + h := hook(`echo '{"action":"deny","reason":"no rm -rf"}'`) | |
| 205 | + h.Matcher = "^bash$" | |
| 206 | + r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{h}}) | |
| 207 | + | |
| 208 | + d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "rm -rf /"}}}) | |
| 209 | + if !d.Denied || d.Reason != "no rm -rf" { | |
| 210 | + t.Errorf("bash: %+v, want denied with the reason", d) | |
| 211 | + } | |
| 212 | + d = r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "read_file", Input: map[string]any{"path": "a"}}}) | |
| 213 | + if d.Denied { | |
| 214 | + t.Errorf("read_file: %+v, want allowed (the matcher is ^bash$)", d) | |
| 215 | + } | |
| 216 | +} | |
| 217 | + | |
| 218 | +// A replaced input reaches the next hook and the caller; the caller's own | |
| 219 | +// ToolCall is left as the model sent it. | |
| 220 | +func TestPreToolCallReplacesTheInput(t *testing.T) { | |
| 221 | + cfg := config.HooksConfig{PreToolCall: []config.Hook{ | |
| 222 | + hook(`echo '{"input":{"command":"ls -la"}}'`), | |
| 223 | + hook(`grep -q '"command":"ls -la"' || echo '{"action":"deny","reason":"did not see the rewrite"}'`), | |
| 224 | + }} | |
| 225 | + r, _ := runner(cfg) | |
| 226 | + call := &ToolCall{Name: "bash", Input: map[string]any{"command": "ls"}} | |
| 227 | + d := r.PreToolCall(context.Background(), Input{Tool: call}) | |
| 228 | + if d.Denied { | |
| 229 | + t.Fatalf("denied: %s", d.Reason) | |
| 230 | + } | |
| 231 | + if got := d.Input.(map[string]any)["command"]; got != "ls -la" { | |
| 232 | + t.Errorf("Input = %v, want the replacement", d.Input) | |
| 233 | + } | |
| 234 | + if got := call.Input.(map[string]any)["command"]; got != "ls" { | |
| 235 | + t.Errorf("caller's input changed to %v", got) | |
| 236 | + } | |
| 237 | +} | |
| 238 | + | |
| 239 | +// onError fail on a tool hook denies the call. | |
| 240 | +func TestFailingToolHookWithFailDenies(t *testing.T) { | |
| 241 | + h := hook(`exit 3`) | |
| 242 | + h.OnError = config.HookOnErrorFail | |
| 243 | + r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{h}}) | |
| 244 | + d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}}) | |
| 245 | + if !d.Denied || !strings.Contains(d.Reason, "hook failed") { | |
| 246 | + t.Errorf("decision = %+v, want denied because the hook failed", d) | |
| 247 | + } | |
| 248 | +} | |
| 249 | + | |
| 250 | +func TestPostToolCallReturnsContextAndSeesTheOutput(t *testing.T) { | |
| 251 | + cfg := config.HooksConfig{PostToolCall: []config.Hook{ | |
| 252 | + hook(`grep -q '"output":"3 files"' && echo '{"context":"note: the output was read"}'`), | |
| 253 | + hook(`exit 1`), // a failure after the fact is reported, never fatal | |
| 254 | + }} | |
| 255 | + cfg.PostToolCall[1].OnError = config.HookOnErrorFail | |
| 256 | + r, notes := runner(cfg) | |
| 257 | + added := r.PostToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}, Output: "3 files"}) | |
| 258 | + if len(added) != 1 || added[0] != "note: the output was read" { | |
| 259 | + t.Errorf("context = %v", added) | |
| 260 | + } | |
| 261 | + if len(*notes) != 1 { | |
| 262 | + t.Errorf("notes = %v, want the failure reported once", *notes) | |
| 263 | + } | |
| 264 | +} | |
| 265 | + | |
| 266 | +func TestPostGenerationObservesTheAnswer(t *testing.T) { | |
| 267 | + out := filepath.Join(t.TempDir(), "log") | |
| 268 | + r, notes := runner(config.HooksConfig{PostGeneration: []config.Hook{hook(`cat >> "` + out + `"`)}}) | |
| 269 | + r.PostGeneration(context.Background(), Input{Prompt: "q", Answer: "a"}) | |
| 270 | + data, _ := os.ReadFile(out) | |
| 271 | + if !strings.Contains(string(data), `"event":"postGeneration"`) || !strings.Contains(string(data), `"answer":"a"`) { | |
| 272 | + t.Errorf("the hook read %s", data) | |
| 273 | + } | |
| 274 | + if len(*notes) > 0 { | |
| 275 | + t.Errorf("notes = %v", *notes) | |
| 276 | + } | |
| 277 | +} | |
| 278 | + | |
| 279 | +// A nil runner (no hooks configured anywhere) is safe to call. | |
| 280 | +func TestNilRunnerIsSafe(t *testing.T) { | |
| 281 | + var r *Runner | |
| 282 | + if r.Enabled() { | |
| 283 | + t.Error("nil runner enabled") | |
| 284 | + } | |
| 285 | + if d := r.PreGeneration(context.Background(), Input{Prompt: "p"}); d.Prompt != "p" || d.Stopped { | |
| 286 | + t.Errorf("PreGeneration = %+v", d) | |
| 287 | + } | |
| 288 | + if d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: 1}}); d.Denied || d.Input != 1 { | |
| 289 | + t.Errorf("PreToolCall = %+v", d) | |
| 290 | + } | |
| 291 | + if r.PostToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}}) != nil { | |
| 292 | + t.Error("PostToolCall returned context") | |
| 293 | + } | |
| 294 | + r.PostGeneration(context.Background(), Input{}) | |
| 295 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,295 @@ | |||
| 1 | +package hooks | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "os" | ||
| 7 | + "path/filepath" | ||
| 8 | + "strings" | ||
| 9 | + "testing" | ||
| 10 | + "time" | ||
| 11 | + | ||
| 12 | + "mm/internal/config" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// The tests run real hooks through `sh -c`, written as tiny shell scripts: | ||
| 16 | +// the protocol (stdin, stdout, exit code, environment) is what is under test, | ||
| 17 | +// and node is not needed for that. | ||
| 18 | + | ||
| 19 | +// hook returns a hook entry that runs script with the defaults Load would | ||
| 20 | +// have filled in. | ||
| 21 | +func hook(script string) config.Hook { | ||
| 22 | + return config.Hook{Command: script, Timeout: 5 * time.Second, OnError: config.HookOnErrorWarn} | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +// runner builds a Runner for cfg and collects what it reports. | ||
| 26 | +func runner(cfg config.HooksConfig) (*Runner, *[]string) { | ||
| 27 | + var notes []string | ||
| 28 | + return New(cfg, func(s string) { notes = append(notes, s) }), ¬es | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +func TestNoHookChangesNothing(t *testing.T) { | ||
| 32 | + r, notes := runner(config.HooksConfig{}) | ||
| 33 | + if r.Enabled() { | ||
| 34 | + t.Error("Enabled() = true with no hook") | ||
| 35 | + } | ||
| 36 | + d := r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | ||
| 37 | + if d.Stopped || d.Changed("hello") { | ||
| 38 | + t.Errorf("PreGeneration = %+v, want the prompt untouched", d) | ||
| 39 | + } | ||
| 40 | + td := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "ls"}}}) | ||
| 41 | + if td.Denied { | ||
| 42 | + t.Errorf("PreToolCall = %+v, want allowed", td) | ||
| 43 | + } | ||
| 44 | + if len(*notes) > 0 { | ||
| 45 | + t.Errorf("notes = %v, want none", *notes) | ||
| 46 | + } | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +// A hook that prints nothing continues: a logging hook owes no answer. | ||
| 50 | +func TestEmptyStdoutContinues(t *testing.T) { | ||
| 51 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook("cat > /dev/null")}}) | ||
| 52 | + d := r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | ||
| 53 | + if d.Stopped || d.Prompt != "hello" || len(*notes) > 0 { | ||
| 54 | + t.Errorf("decision %+v, notes %v; want untouched, no note", d, *notes) | ||
| 55 | + } | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +// The hook reads the event, the prompt and its environment. | ||
| 59 | +func TestHookReadsItsInputAndEnvironment(t *testing.T) { | ||
| 60 | + dir := t.TempDir() | ||
| 61 | + out := filepath.Join(dir, "seen.json") | ||
| 62 | + cfg := config.HooksConfig{ | ||
| 63 | + ConfigDir: "/the/config/dir", | ||
| 64 | + PreGeneration: []config.Hook{hook(`cat > "` + out + `"; echo "$MM_HOOK_EVENT $MM_CONFIG_DIR $(pwd)" >> "` + out + `.env"`)}, | ||
| 65 | + } | ||
| 66 | + r, _ := runner(cfg) | ||
| 67 | + r.PreGeneration(context.Background(), Input{Cwd: dir, Model: "m", Prompt: "list files"}) | ||
| 68 | + | ||
| 69 | + var seen Input | ||
| 70 | + data, err := os.ReadFile(out) | ||
| 71 | + if err != nil { | ||
| 72 | + t.Fatal(err) | ||
| 73 | + } | ||
| 74 | + if err := json.Unmarshal(data, &seen); err != nil { | ||
| 75 | + t.Fatalf("stdin was not JSON: %v\n%s", err, data) | ||
| 76 | + } | ||
| 77 | + if seen.Event != "preGeneration" || seen.Prompt != "list files" || seen.Cwd != dir || seen.Model != "m" { | ||
| 78 | + t.Errorf("hook read %+v", seen) | ||
| 79 | + } | ||
| 80 | + env, _ := os.ReadFile(out + ".env") | ||
| 81 | + resolved, _ := filepath.EvalSymlinks(dir) | ||
| 82 | + if got := strings.TrimSpace(string(env)); got != "preGeneration /the/config/dir "+dir && got != "preGeneration /the/config/dir "+resolved { | ||
| 83 | + t.Errorf("environment and directory = %q", got) | ||
| 84 | + } | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +// Rewrites chain: the second hook sees the first one's prompt; contexts | ||
| 88 | +// accumulate in order. | ||
| 89 | +func TestPreGenerationRewritesAndAddsContextInOrder(t *testing.T) { | ||
| 90 | + cfg := config.HooksConfig{PreGeneration: []config.Hook{ | ||
| 91 | + hook(`echo '{"prompt":"first rewrite","context":"ctx one"}'`), | ||
| 92 | + hook(`grep -q '"prompt":"first rewrite"' && echo '{"action":"continue","prompt":"second rewrite","context":"ctx two"}'`), | ||
| 93 | + }} | ||
| 94 | + r, notes := runner(cfg) | ||
| 95 | + d := r.PreGeneration(context.Background(), Input{Prompt: "original"}) | ||
| 96 | + if d.Prompt != "second rewrite" { | ||
| 97 | + t.Errorf("Prompt = %q, want the second hook's rewrite (it must see the first one's)", d.Prompt) | ||
| 98 | + } | ||
| 99 | + if strings.Join(d.Context, "|") != "ctx one|ctx two" { | ||
| 100 | + t.Errorf("Context = %v, want [ctx one ctx two]", d.Context) | ||
| 101 | + } | ||
| 102 | + if !d.Changed("original") || len(*notes) > 0 { | ||
| 103 | + t.Errorf("Changed = false or notes %v", *notes) | ||
| 104 | + } | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +// The first stop ends the chain: the hook after it never runs. | ||
| 108 | +func TestPreGenerationStopEndsTheChain(t *testing.T) { | ||
| 109 | + marker := filepath.Join(t.TempDir(), "ran") | ||
| 110 | + cfg := config.HooksConfig{PreGeneration: []config.Hook{ | ||
| 111 | + hook(`echo '{"action":"stop","reason":"secret","response":"No secrets here."}'`), | ||
| 112 | + hook(`touch "` + marker + `"`), | ||
| 113 | + }} | ||
| 114 | + r, _ := runner(cfg) | ||
| 115 | + d := r.PreGeneration(context.Background(), Input{Prompt: "my password is x"}) | ||
| 116 | + if !d.Stopped || d.Response != "No secrets here." || d.Reason != "secret" { | ||
| 117 | + t.Errorf("decision = %+v, want stopped with the hook's response", d) | ||
| 118 | + } | ||
| 119 | + if _, err := os.Stat(marker); err == nil { | ||
| 120 | + t.Error("the hook after the stop ran") | ||
| 121 | + } | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +// A stop without a response still tells the user something. | ||
| 125 | +func TestStopWithoutResponseExplainsTheReason(t *testing.T) { | ||
| 126 | + r, _ := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`echo '{"action":"stop","reason":"too short"}'`)}}) | ||
| 127 | + d := r.PreGeneration(context.Background(), Input{Prompt: "x"}) | ||
| 128 | + if !strings.Contains(d.Response, "too short") { | ||
| 129 | + t.Errorf("Response = %q, want the reason in it", d.Response) | ||
| 130 | + } | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +// onError decides what a failing hook does, on a pre* event. | ||
| 134 | +func TestFailingHookFollowsOnError(t *testing.T) { | ||
| 135 | + cases := []struct { | ||
| 136 | + onError string | ||
| 137 | + wantStopped bool | ||
| 138 | + wantNote bool | ||
| 139 | + }{ | ||
| 140 | + {config.HookOnErrorFail, true, true}, | ||
| 141 | + {config.HookOnErrorWarn, false, true}, | ||
| 142 | + {config.HookOnErrorIgnore, false, false}, | ||
| 143 | + } | ||
| 144 | + failures := map[string]string{ | ||
| 145 | + "exit code": `echo boom >&2; exit 2`, | ||
| 146 | + "invalid JSON": `echo 'not json'`, | ||
| 147 | + "error action": `echo '{"action":"error","error":"no config file"}'`, | ||
| 148 | + "wrong action": `echo '{"action":"deny"}'`, // deny is for tools | ||
| 149 | + "missing shell": `this-command-does-not-exist-anywhere`, | ||
| 150 | + } | ||
| 151 | + for name, script := range failures { | ||
| 152 | + for _, tc := range cases { | ||
| 153 | + t.Run(name+"/"+tc.onError, func(t *testing.T) { | ||
| 154 | + h := hook(script) | ||
| 155 | + h.OnError = tc.onError | ||
| 156 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{h}}) | ||
| 157 | + d := r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | ||
| 158 | + if d.Stopped != tc.wantStopped { | ||
| 159 | + t.Errorf("Stopped = %v, want %v (%+v)", d.Stopped, tc.wantStopped, d) | ||
| 160 | + } | ||
| 161 | + if (len(*notes) > 0) != tc.wantNote { | ||
| 162 | + t.Errorf("notes = %v, want a note: %v", *notes, tc.wantNote) | ||
| 163 | + } | ||
| 164 | + }) | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +// The report quotes the hook's stderr: "exit status 2" alone sends the user | ||
| 170 | +// to read the script. | ||
| 171 | +func TestFailureReportQuotesStderr(t *testing.T) { | ||
| 172 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`echo "cannot read rules.json" >&2; exit 1`)}}) | ||
| 173 | + r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | ||
| 174 | + if len(*notes) != 1 || !strings.Contains((*notes)[0], "cannot read rules.json") { | ||
| 175 | + t.Errorf("notes = %v, want the stderr quoted", *notes) | ||
| 176 | + } | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +func TestHookTimesOut(t *testing.T) { | ||
| 180 | + h := hook(`sleep 5`) | ||
| 181 | + h.Timeout = 100 * time.Millisecond | ||
| 182 | + h.OnError = config.HookOnErrorFail | ||
| 183 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{h}}) | ||
| 184 | + start := time.Now() | ||
| 185 | + d := r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | ||
| 186 | + if elapsed := time.Since(start); elapsed > 3*time.Second { | ||
| 187 | + t.Errorf("took %s, want the timeout to cut it", elapsed) | ||
| 188 | + } | ||
| 189 | + if !d.Stopped || len(*notes) != 1 || !strings.Contains((*notes)[0], "timed out") { | ||
| 190 | + t.Errorf("decision %+v, notes %v; want stopped on a timeout", d, *notes) | ||
| 191 | + } | ||
| 192 | +} | ||
| 193 | + | ||
| 194 | +// A hook that floods its stdout is a failure, not a 1 GB context. | ||
| 195 | +func TestOversizedReplyIsAFailure(t *testing.T) { | ||
| 196 | + r, notes := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`head -c 2000000 /dev/zero`)}}) | ||
| 197 | + r.PreGeneration(context.Background(), Input{Prompt: "hello"}) | ||
| 198 | + if len(*notes) != 1 || !strings.Contains((*notes)[0], "larger than") { | ||
| 199 | + t.Errorf("notes = %v, want the size reported", *notes) | ||
| 200 | + } | ||
| 201 | +} | ||
| 202 | + | ||
| 203 | +func TestPreToolCallDeniesMatchingToolsOnly(t *testing.T) { | ||
| 204 | + h := hook(`echo '{"action":"deny","reason":"no rm -rf"}'`) | ||
| 205 | + h.Matcher = "^bash$" | ||
| 206 | + r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{h}}) | ||
| 207 | + | ||
| 208 | + d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "rm -rf /"}}}) | ||
| 209 | + if !d.Denied || d.Reason != "no rm -rf" { | ||
| 210 | + t.Errorf("bash: %+v, want denied with the reason", d) | ||
| 211 | + } | ||
| 212 | + d = r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "read_file", Input: map[string]any{"path": "a"}}}) | ||
| 213 | + if d.Denied { | ||
| 214 | + t.Errorf("read_file: %+v, want allowed (the matcher is ^bash$)", d) | ||
| 215 | + } | ||
| 216 | +} | ||
| 217 | + | ||
| 218 | +// A replaced input reaches the next hook and the caller; the caller's own | ||
| 219 | +// ToolCall is left as the model sent it. | ||
| 220 | +func TestPreToolCallReplacesTheInput(t *testing.T) { | ||
| 221 | + cfg := config.HooksConfig{PreToolCall: []config.Hook{ | ||
| 222 | + hook(`echo '{"input":{"command":"ls -la"}}'`), | ||
| 223 | + hook(`grep -q '"command":"ls -la"' || echo '{"action":"deny","reason":"did not see the rewrite"}'`), | ||
| 224 | + }} | ||
| 225 | + r, _ := runner(cfg) | ||
| 226 | + call := &ToolCall{Name: "bash", Input: map[string]any{"command": "ls"}} | ||
| 227 | + d := r.PreToolCall(context.Background(), Input{Tool: call}) | ||
| 228 | + if d.Denied { | ||
| 229 | + t.Fatalf("denied: %s", d.Reason) | ||
| 230 | + } | ||
| 231 | + if got := d.Input.(map[string]any)["command"]; got != "ls -la" { | ||
| 232 | + t.Errorf("Input = %v, want the replacement", d.Input) | ||
| 233 | + } | ||
| 234 | + if got := call.Input.(map[string]any)["command"]; got != "ls" { | ||
| 235 | + t.Errorf("caller's input changed to %v", got) | ||
| 236 | + } | ||
| 237 | +} | ||
| 238 | + | ||
| 239 | +// onError fail on a tool hook denies the call. | ||
| 240 | +func TestFailingToolHookWithFailDenies(t *testing.T) { | ||
| 241 | + h := hook(`exit 3`) | ||
| 242 | + h.OnError = config.HookOnErrorFail | ||
| 243 | + r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{h}}) | ||
| 244 | + d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}}) | ||
| 245 | + if !d.Denied || !strings.Contains(d.Reason, "hook failed") { | ||
| 246 | + t.Errorf("decision = %+v, want denied because the hook failed", d) | ||
| 247 | + } | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +func TestPostToolCallReturnsContextAndSeesTheOutput(t *testing.T) { | ||
| 251 | + cfg := config.HooksConfig{PostToolCall: []config.Hook{ | ||
| 252 | + hook(`grep -q '"output":"3 files"' && echo '{"context":"note: the output was read"}'`), | ||
| 253 | + hook(`exit 1`), // a failure after the fact is reported, never fatal | ||
| 254 | + }} | ||
| 255 | + cfg.PostToolCall[1].OnError = config.HookOnErrorFail | ||
| 256 | + r, notes := runner(cfg) | ||
| 257 | + added := r.PostToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}, Output: "3 files"}) | ||
| 258 | + if len(added) != 1 || added[0] != "note: the output was read" { | ||
| 259 | + t.Errorf("context = %v", added) | ||
| 260 | + } | ||
| 261 | + if len(*notes) != 1 { | ||
| 262 | + t.Errorf("notes = %v, want the failure reported once", *notes) | ||
| 263 | + } | ||
| 264 | +} | ||
| 265 | + | ||
| 266 | +func TestPostGenerationObservesTheAnswer(t *testing.T) { | ||
| 267 | + out := filepath.Join(t.TempDir(), "log") | ||
| 268 | + r, notes := runner(config.HooksConfig{PostGeneration: []config.Hook{hook(`cat >> "` + out + `"`)}}) | ||
| 269 | + r.PostGeneration(context.Background(), Input{Prompt: "q", Answer: "a"}) | ||
| 270 | + data, _ := os.ReadFile(out) | ||
| 271 | + if !strings.Contains(string(data), `"event":"postGeneration"`) || !strings.Contains(string(data), `"answer":"a"`) { | ||
| 272 | + t.Errorf("the hook read %s", data) | ||
| 273 | + } | ||
| 274 | + if len(*notes) > 0 { | ||
| 275 | + t.Errorf("notes = %v", *notes) | ||
| 276 | + } | ||
| 277 | +} | ||
| 278 | + | ||
| 279 | +// A nil runner (no hooks configured anywhere) is safe to call. | ||
| 280 | +func TestNilRunnerIsSafe(t *testing.T) { | ||
| 281 | + var r *Runner | ||
| 282 | + if r.Enabled() { | ||
| 283 | + t.Error("nil runner enabled") | ||
| 284 | + } | ||
| 285 | + if d := r.PreGeneration(context.Background(), Input{Prompt: "p"}); d.Prompt != "p" || d.Stopped { | ||
| 286 | + t.Errorf("PreGeneration = %+v", d) | ||
| 287 | + } | ||
| 288 | + if d := r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: 1}}); d.Denied || d.Input != 1 { | ||
| 289 | + t.Errorf("PreToolCall = %+v", d) | ||
| 290 | + } | ||
| 291 | + if r.PostToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash"}}) != nil { | ||
| 292 | + t.Error("PostToolCall returned context") | ||
| 293 | + } | ||
| 294 | + r.PostGeneration(context.Background(), Input{}) | ||
| 295 | +} | ||
modified
internal/tui/sink.go +8 -0 | @@ -20,6 +20,7 @@ type sink struct{ r *runner } | ||
| 20 | 20 | var ( |
| 21 | 21 | _ ui.Sink = (*sink)(nil) |
| 22 | 22 | _ ui.Speaker = (*sink)(nil) |
| 23 | + _ ui.Noticer = (*sink)(nil) | |
| 23 | 24 | ) |
| 24 | 25 | |
| 25 | 26 | // Speaking switches the header and the answers' label to the agent now |
| @@ -91,6 +92,13 @@ func (k *sink) ToolEnd(id string, res ui.ToolResult) { | ||
| 91 | 92 | k.r.wake() |
| 92 | 93 | } |
| 93 | 94 | |
| 95 | +// Notice shows a remark about the turn (a hook that failed, say) as a dim | |
| 96 | +// line of the transcript: printed on stdout, it would tear the screen. | |
| 97 | +func (k *sink) Notice(text string) { | |
| 98 | + k.r.t.Notice(text) | |
| 99 | + k.r.wake() | |
| 100 | +} | |
| 101 | + | |
| 94 | 102 | // WorkDir is where mm was started: bash runs there and relative paths |
| 95 | 103 | // resolve against it, as in the REPL. |
| 96 | 104 | func (k *sink) WorkDir() string { return k.r.opts.Cwd } |
| @@ -20,6 +20,7 @@ type sink struct{ r *runner } | |||
| 20 | var ( | 20 | var ( |
| 21 | _ ui.Sink = (*sink)(nil) | 21 | _ ui.Sink = (*sink)(nil) |
| 22 | _ ui.Speaker = (*sink)(nil) | 22 | _ ui.Speaker = (*sink)(nil) |
| 23 | + _ ui.Noticer = (*sink)(nil) | ||
| 23 | ) | 24 | ) |
| 24 | 25 | ||
| 25 | // Speaking switches the header and the answers' label to the agent now | 26 | // Speaking switches the header and the answers' label to the agent now |
| @@ -91,6 +92,13 @@ func (k *sink) ToolEnd(id string, res ui.ToolResult) { | |||
| 91 | k.r.wake() | 92 | k.r.wake() |
| 92 | } | 93 | } |
| 93 | 94 | ||
| 95 | +// Notice shows a remark about the turn (a hook that failed, say) as a dim | ||
| 96 | +// line of the transcript: printed on stdout, it would tear the screen. | ||
| 97 | +func (k *sink) Notice(text string) { | ||
| 98 | + k.r.t.Notice(text) | ||
| 99 | + k.r.wake() | ||
| 100 | +} | ||
| 101 | + | ||
| 94 | // WorkDir is where mm was started: bash runs there and relative paths | 102 | // WorkDir is where mm was started: bash runs there and relative paths |
| 95 | // resolve against it, as in the REPL. | 103 | // resolve against it, as in the REPL. |
| 96 | func (k *sink) WorkDir() string { return k.r.opts.Cwd } | 104 | func (k *sink) WorkDir() string { return k.r.opts.Cwd } |
modified
internal/ui/ui.go +21 -0 | @@ -114,6 +114,27 @@ type Elicitor interface { | ||
| 114 | 114 | Elicit(ctx context.Context, ev ToolEvent, f elicit.Form) elicit.Result |
| 115 | 115 | } |
| 116 | 116 | |
| 117 | +// Noticer is the optional side of a Sink that shows a one-line notice — a | |
| 118 | +// remark about the turn that is neither the answer nor a tool call, such as | |
| 119 | +// a hook that failed. The window has one (a dim transcript line); without | |
| 120 | +// it a notice goes to Out. | |
| 121 | +type Noticer interface { | |
| 122 | + Notice(text string) | |
| 123 | +} | |
| 124 | + | |
| 125 | +// Notice shows a one-line remark about the turn where the front end shows | |
| 126 | +// such things: the active sink's Noticer, or a line on Out (stdout in the | |
| 127 | +// terminal, stderr — the editor's logs — over ACP). | |
| 128 | +// | |
| 129 | +// ui.Notice("🪝 preGeneration hook `node guard.js`: exit status 1") | |
| 130 | +func Notice(text string) { | |
| 131 | + if n, ok := ActiveSink().(Noticer); ok { | |
| 132 | + n.Notice(text) | |
| 133 | + return | |
| 134 | + } | |
| 135 | + fmt.Fprintln(Out, text) | |
| 136 | +} | |
| 137 | + | |
| 117 | 138 | // Speaker is the optional side of a Sink that shows WHICH agent is answering: |
| 118 | 139 | // between Speaking and Spoken, what streams through the sink (text, tool |
| 119 | 140 | // calls) comes from another agent — the one the ask_agent tool asked — and |
| @@ -114,6 +114,27 @@ type Elicitor interface { | |||
| 114 | Elicit(ctx context.Context, ev ToolEvent, f elicit.Form) elicit.Result | 114 | Elicit(ctx context.Context, ev ToolEvent, f elicit.Form) elicit.Result |
| 115 | } | 115 | } |
| 116 | 116 | ||
| 117 | +// Noticer is the optional side of a Sink that shows a one-line notice — a | ||
| 118 | +// remark about the turn that is neither the answer nor a tool call, such as | ||
| 119 | +// a hook that failed. The window has one (a dim transcript line); without | ||
| 120 | +// it a notice goes to Out. | ||
| 121 | +type Noticer interface { | ||
| 122 | + Notice(text string) | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +// Notice shows a one-line remark about the turn where the front end shows | ||
| 126 | +// such things: the active sink's Noticer, or a line on Out (stdout in the | ||
| 127 | +// terminal, stderr — the editor's logs — over ACP). | ||
| 128 | +// | ||
| 129 | +// ui.Notice("🪝 preGeneration hook `node guard.js`: exit status 1") | ||
| 130 | +func Notice(text string) { | ||
| 131 | + if n, ok := ActiveSink().(Noticer); ok { | ||
| 132 | + n.Notice(text) | ||
| 133 | + return | ||
| 134 | + } | ||
| 135 | + fmt.Fprintln(Out, text) | ||
| 136 | +} | ||
| 137 | + | ||
| 117 | // Speaker is the optional side of a Sink that shows WHICH agent is answering: | 138 | // Speaker is the optional side of a Sink that shows WHICH agent is answering: |
| 118 | // between Speaking and Spoken, what streams through the sink (text, tool | 139 | // between Speaking and Spoken, what streams through the sink (text, tool |
| 119 | // calls) comes from another agent — the one the ask_agent tool asked — and | 140 | // calls) comes from another agent — the one the ask_agent tool asked — and |
modified
internal/ui/ui_test.go +32 -0 | @@ -53,3 +53,35 @@ func TestNextCallIDNeverRepeats(t *testing.T) { | ||
| 53 | 53 | t.Fatalf("unexpected shape: %q", a) |
| 54 | 54 | } |
| 55 | 55 | } |
| 56 | + | |
| 57 | +// noticingSink is a front end that shows notices itself (the window). | |
| 58 | +type noticingSink struct { | |
| 59 | + stubSink | |
| 60 | + seen []string | |
| 61 | +} | |
| 62 | + | |
| 63 | +func (s *noticingSink) Notice(text string) { s.seen = append(s.seen, text) } | |
| 64 | + | |
| 65 | +// A notice goes to the sink that can show it, else to Out — never both. | |
| 66 | +func TestNoticeGoesToTheSinkOrToOut(t *testing.T) { | |
| 67 | + var out strings.Builder | |
| 68 | + old := Out | |
| 69 | + Out = &out | |
| 70 | + defer func() { Out = old }() | |
| 71 | + | |
| 72 | + SetActive(nil) | |
| 73 | + Notice("terminal line") | |
| 74 | + SetActive(stubSink{}) // a sink without Notice: ACP | |
| 75 | + Notice("editor log line") | |
| 76 | + window := ¬icingSink{} | |
| 77 | + SetActive(window) | |
| 78 | + Notice("window line") | |
| 79 | + SetActive(nil) | |
| 80 | + | |
| 81 | + if got := out.String(); got != "terminal line\neditor log line\n" { | |
| 82 | + t.Errorf("Out = %q", got) | |
| 83 | + } | |
| 84 | + if len(window.seen) != 1 || window.seen[0] != "window line" { | |
| 85 | + t.Errorf("window saw %v", window.seen) | |
| 86 | + } | |
| 87 | +} | |
| @@ -53,3 +53,35 @@ func TestNextCallIDNeverRepeats(t *testing.T) { | |||
| 53 | t.Fatalf("unexpected shape: %q", a) | 53 | t.Fatalf("unexpected shape: %q", a) |
| 54 | } | 54 | } |
| 55 | } | 55 | } |
| 56 | + | ||
| 57 | +// noticingSink is a front end that shows notices itself (the window). | ||
| 58 | +type noticingSink struct { | ||
| 59 | + stubSink | ||
| 60 | + seen []string | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +func (s *noticingSink) Notice(text string) { s.seen = append(s.seen, text) } | ||
| 64 | + | ||
| 65 | +// A notice goes to the sink that can show it, else to Out — never both. | ||
| 66 | +func TestNoticeGoesToTheSinkOrToOut(t *testing.T) { | ||
| 67 | + var out strings.Builder | ||
| 68 | + old := Out | ||
| 69 | + Out = &out | ||
| 70 | + defer func() { Out = old }() | ||
| 71 | + | ||
| 72 | + SetActive(nil) | ||
| 73 | + Notice("terminal line") | ||
| 74 | + SetActive(stubSink{}) // a sink without Notice: ACP | ||
| 75 | + Notice("editor log line") | ||
| 76 | + window := ¬icingSink{} | ||
| 77 | + SetActive(window) | ||
| 78 | + Notice("window line") | ||
| 79 | + SetActive(nil) | ||
| 80 | + | ||
| 81 | + if got := out.String(); got != "terminal line\neditor log line\n" { | ||
| 82 | + t.Errorf("Out = %q", got) | ||
| 83 | + } | ||
| 84 | + if len(window.seen) != 1 || window.seen[0] != "window line" { | ||
| 85 | + t.Errorf("window saw %v", window.seen) | ||
| 86 | + } | ||
| 87 | +} | ||
modified
main.go +11 -2 | @@ -592,8 +592,17 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | ||
| 592 | 592 | // The version leads the line: it is the first thing anyone is asked for |
| 593 | 593 | // when something behaves oddly, and the banner is the one output every |
| 594 | 594 | // front end shows — the window keeps it as the conversation's first line. |
| 595 | - say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d | %s%s%s%s", | |
| 596 | - dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, yoloColumn(), agentsMdCol, traceColumn(acpMode), off) | |
| 595 | + say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d | %s%s%s%s%s", | |
| 596 | + dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, yoloColumn(), agentsMdCol, traceColumn(acpMode), hooksColumn(), off) | |
| 597 | +} | |
| 598 | + | |
| 599 | +// hooksColumn says in the banner which hooks run, per event: a command that | |
| 600 | +// can rewrite prompts or deny tool calls must not act unseen. | |
| 601 | +func hooksColumn() string { | |
| 602 | + if summary := config.Cfg.Hooks.Summary(); summary != "" { | |
| 603 | + return " | hooks: " + summary | |
| 604 | + } | |
| 605 | + return "" | |
| 597 | 606 | } |
| 598 | 607 | |
| 599 | 608 | // yoloColumn says in the banner whether the tools run unasked — the one |
| @@ -592,8 +592,17 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | |||
| 592 | // The version leads the line: it is the first thing anyone is asked for | 592 | // The version leads the line: it is the first thing anyone is asked for |
| 593 | // when something behaves oddly, and the banner is the one output every | 593 | // when something behaves oddly, and the banner is the one output every |
| 594 | // front end shows — the window keeps it as the conversation's first line. | 594 | // front end shows — the window keeps it as the conversation's first line. |
| 595 | - say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d | %s%s%s%s", | 595 | + say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d | %s%s%s%s%s", |
| 596 | - dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, yoloColumn(), agentsMdCol, traceColumn(acpMode), off) | 596 | + dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, yoloColumn(), agentsMdCol, traceColumn(acpMode), hooksColumn(), off) |
| 597 | +} | ||
| 598 | + | ||
| 599 | +// hooksColumn says in the banner which hooks run, per event: a command that | ||
| 600 | +// can rewrite prompts or deny tool calls must not act unseen. | ||
| 601 | +func hooksColumn() string { | ||
| 602 | + if summary := config.Cfg.Hooks.Summary(); summary != "" { | ||
| 603 | + return " | hooks: " + summary | ||
| 604 | + } | ||
| 605 | + return "" | ||
| 597 | } | 606 | } |
| 598 | 607 | ||
| 599 | // yoloColumn says in the banner whether the tools run unasked — the one | 608 | // yoloColumn says in the banner whether the tools run unasked — the one |