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

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

Download ZIP

🛟 Updated.

k33g committed 2026-10-16T00:04:17+02:00 Browse files
8eb168b parent: cc36e7a
modified .tickets/issues/0036-hooks.yaml +8 -4
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 36
33 title: Hooks
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T06:32:48.566Z
9-updatedAt: 2026-10-14T12:06:43.000Z
9+updatedAt: 2026-10-14T22:00:49.000Z
1010 labels: []
1111 epic: Loop
1212 body: |
@@ -571,6 +571,10 @@ body: |
571571 - **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).
572572 - **`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.
573573 - 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+
575+ ### Follow-up (2026-10-14, found with demos/dungeon)
576+ - Hook input JSON is written without Go's HTML escaping: with `json.Marshal`, `&&` arrived as `\u0026\u0026`, and a shell guard that searched for `&` let `go next && rm -rf …` through. A test pins it.
577+ - A denied call is titled with its command (`bash: <command> — denied by a hook`): an asking agent's front end shows titles only, and "bash — denied by a hook" did not say what was refused.
574578 tasks:
575579 - id: 1
576580 title: "Config: hooks block and validation"
@@ -613,7 +617,7 @@ tasks:
613617 updatedAt: 2026-10-14T11:57:09.000Z
614618 closedAt: 2026-10-14T11:57:09.000Z
615619 - id: 5
616- title: "Demo demos/hooks with Node.js hooks (README EN+FR)"
620+ title: Demo demos/hooks with Node.js hooks (README EN+FR)
617621 state: closed
618622 priority: high
619623 author:
@@ -623,7 +627,7 @@ tasks:
623627 updatedAt: 2026-10-14T12:00:05.000Z
624628 closedAt: 2026-10-14T12:00:05.000Z
625629 - id: 6
626- title: "Quality gate, docs EN+FR, packages diagram, .memory"
630+ title: Quality gate, docs EN+FR, packages diagram, .memory
627631 state: closed
628632 priority: high
629633 author:
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 362 id: 36
3 title: Hooks3 title: Hooks
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T06:32:48.566Z8 createdAt: 2026-09-21T06:32:48.566Z
9-updatedAt: 2026-10-14T12:06:43.000Z9+updatedAt: 2026-10-14T22:00:49.000Z
10 labels: []10 labels: []
11 epic: Loop11 epic: Loop
12 body: |12 body: |
@@ -571,6 +571,10 @@ body: |
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).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.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.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+
575+ ### Follow-up (2026-10-14, found with demos/dungeon)
576+ - Hook input JSON is written without Go's HTML escaping: with `json.Marshal`, `&&` arrived as `\u0026\u0026`, and a shell guard that searched for `&` let `go next && rm -rf …` through. A test pins it.
577+ - A denied call is titled with its command (`bash: <command> — denied by a hook`): an asking agent's front end shows titles only, and "bash — denied by a hook" did not say what was refused.
574 tasks:578 tasks:
575 - id: 1579 - id: 1
576 title: "Config: hooks block and validation"580 title: "Config: hooks block and validation"
@@ -613,7 +617,7 @@ tasks:
613 updatedAt: 2026-10-14T11:57:09.000Z617 updatedAt: 2026-10-14T11:57:09.000Z
614 closedAt: 2026-10-14T11:57:09.000Z618 closedAt: 2026-10-14T11:57:09.000Z
615 - id: 5619 - id: 5
616- title: "Demo demos/hooks with Node.js hooks (README EN+FR)"620+ title: Demo demos/hooks with Node.js hooks (README EN+FR)
617 state: closed621 state: closed
618 priority: high622 priority: high
619 author:623 author:
@@ -623,7 +627,7 @@ tasks:
623 updatedAt: 2026-10-14T12:00:05.000Z627 updatedAt: 2026-10-14T12:00:05.000Z
624 closedAt: 2026-10-14T12:00:05.000Z628 closedAt: 2026-10-14T12:00:05.000Z
625 - id: 6629 - id: 6
626- title: "Quality gate, docs EN+FR, packages diagram, .memory"630+ title: Quality gate, docs EN+FR, packages diagram, .memory
627 state: closed631 state: closed
628 priority: high632 priority: high
629 author:633 author:
modified .tickets/issues/0086-demo-agent-like-codex.yaml +2 -2
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 86
33 title: demo agent like codex
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-12T09:36:47.030Z
9-updatedAt: 2026-10-12T09:49:39.850Z
9+updatedAt: 2026-10-15T21:56:09.008Z
1010 labels: []
1111 milestone: v0.4.0
1212 body: |
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 862 id: 86
3 title: demo agent like codex3 title: demo agent like codex
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-12T09:36:47.030Z8 createdAt: 2026-10-12T09:36:47.030Z
9-updatedAt: 2026-10-12T09:49:39.850Z9+updatedAt: 2026-10-15T21:56:09.008Z
10 labels: []10 labels: []
11 milestone: v0.4.011 milestone: v0.4.0
12 body: |12 body: |
added .tickets/issues/0087-hook-for-ask-agents.yaml +105 -0
new file mode 100644
@@ -0,0 +1,105 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 87
3+title: Hook for ask agents
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-15T21:55:43.205Z
9+updatedAt: 2026-10-15T22:30:00.000Z
10+labels: []
11+milestone: v0.5.0
12+body: |
13+ ## Proposal: two hooks around agent calls — `preAsk` and `postAsk`
14+
15+ ### Goal
16+ Let the author of a **coordinator** (the agent that asks) control each hand-off to another agent: before the question leaves (`preAsk`), and when the answer comes back to the coordinator (`postAsk`). Same protocol as the four existing hooks (`sh -c`, one JSON object on stdin, one on stdout, `matcher`, `timeout`, `onError`, chains), declared in the coordinator's own file.
17+
18+ ### Where we start (checked in the code on 2026-10-15, hooks v1 from ticket 36 uncommitted on `main`)
19+ - **Four ways to hand off**, all ending in `ask.Pool.Ask` (`internal/ask/pool.go`):
20+ - the `ask_agent` tool, called by the model (`internal/tools/askagent.go`);
21+ - `/ask`, `/ask-<name>`, `/ask-with-context-<name>`, typed by the user: the front ends call `Pool.Run` directly (`internal/agent/ask.go`, `internal/tui/ask.go`, `internal/acp/ask.go`);
22+ - `mm --ask <name>` / `--ask-with-context`, run by a skill's bash (`main.go` `runAsk` → `Pool.CLI`).
23+ - **Only `ask_agent` meets a hook today**, as an ordinary tool: `preToolCall` / `postToolCall` with `matcher: ^ask_agent$`. The `/ask` commands and `mm --ask` do not go through `engine.Generate`, so **no hook of the coordinator runs for them**.
24+ - **The asked agent runs its own hooks** (those of its own file, `preGeneration` sees the prompt including the transcript). They belong to the specialist's author, not the coordinator's, and the coordinator's author may not be able to change that file.
25+ - **What `preToolCall` / `postToolCall` on `ask_agent` cannot do:**
26+ - `preToolCall` sees `{agent, prompt, with_context}` as the model wrote them: not the asked agent's file or model, not the depth, and not the conversation that `with_context` will add (`ask.Transcript` runs inside the tool, after the hook).
27+ - `postToolCall` can only **append** context: it cannot replace the answer the coordinator reads, it receives it already truncated (`truncate`), and a failure is mixed into the text (`[ask_agent: go failed: …]`).
28+
29+ ### `preAsk` — before the hand-off
30+ **Runs** in the coordinator's process, once per hand-off, for the four paths, **before the asked agent is started or asked**, and **after** the consent prompt of `ask_agent` (decision 2): the user approves the hand-off the model asked for, then the hook may still deny or rewrite it. A hand-off the user rejected runs no `preAsk`. `matcher` is a regular expression on the **agent's name** (`^(go|rust)$`), not on a tool name.
31+
32+ **Input:**
33+ ```json
34+ {"event":"preAsk","cwd":"/abs/project","model":"llmman/…MiniCPM5-2B…",
35+ "ask":{"agent":"go","config":"/abs/agent.go.yaml","agentModel":"llmman/…Mellum2…",
36+ "via":"tool","withContext":true,"depth":0,
37+ "prompt":"How do I read a file line by line?",
38+ "transcript":"user: …\nassistant: …"}}
39+ ```
40+ `via` is `tool`, `command` or `cli`. `transcript` is present only with `with_context` and is exactly what will be sent.
41+
42+ **Reply:**
43+ | Field | Effect |
44+ | --- | --- |
45+ | `action: continue` | the hand-off goes on, with the changes below |
46+ | `action: deny` + `reason` | the asked agent is not started. Tool: the model reads `[ask denied by a hook: <reason>]` as `ask_agent`'s output. Command: the user reads `⛔ …`, the history is unchanged. CLI: on stderr, exit code 1. |
47+ | `prompt` | replaces the question |
48+ | `transcript` | replaces the conversation sent (`""` = send none) |
49+ | `context` | appended to what the asked agent receives, after `[context added by a hook]` |
50+
51+ **What I would write with it:**
52+ - **Standing instructions for specialists**: "answer in French, at most 300 words, one code block". Measured in `demos/coding-helpers`: the Go/Rust specialists answer in English; today the only lever is their own system prompt.
53+ - **A delegation budget**: count hand-offs per turn in a file and deny after N. Small coordinators (MiniCPM5-2B) ask the same agent again and again or answer the question back.
54+ - **Redacting the transcript** of `with_context` / `/ask-with-context`: keys, tokens, absolute paths — the conversation leaves for another model, possibly another provider.
55+ - **An allow-list by situation**: no `ask_agent` to the agent that can write files when the question does not ask for it; no `with_context` towards a remote provider.
56+ - **An audit log** of who asked whom, what, with which model.
57+
58+ ### `postAsk` — on return to the coordinator
59+ **Runs** in the coordinator's process once the asked agent's turn is over — answered, failed, cancelled, or no answer — **before** the result becomes `ask_agent`'s output (tool) or the `[asked …]` / `[… 's answer]` pair in the history (commands, `ask.Exchange`). Like `postGeneration`, it is not cancelled with the turn (`context.WithoutCancel`) and only observes in that case. Same `matcher` on the agent's name.
60+
61+ **Input:** the same `ask` object, plus:
62+ ```json
63+ {"answer":"<the full answer, before truncation>","error":"<absent when it succeeded>","durationMs":34120}
64+ ```
65+
66+ **Reply:**
67+ | Field | Effect |
68+ | --- | --- |
69+ | `answer` | replaces what the coordinator reads and keeps (then truncated as usual). Absent = unchanged. |
70+ | `context` | appended after the answer, after `[context added by a hook]` |
71+ | `action: continue` / `error` | as for the other hooks |
72+
73+ No `retry` action (decision 1): to have the agent asked again, `postAsk` says so in `context` and the coordinator's model decides.
74+
75+ **Limit to document:** the answer has **already streamed** to the user (window, editor, line mode). `postAsk` changes what the coordinator reads and remembers, not what was shown — the same rule as `postToolCall`.
76+
77+ **What I would write with it:**
78+ - **Checking the format** the coordinator expects: valid JSON, a code block present, a Go snippet that passes `gofmt`/`go vet`; on failure, `context: "the answer has no code block — ask again or say so"`.
79+ - **Shortening a long answer** before it lands in a small coordinator's context: keep the code blocks and the first paragraph. Today `truncate` cuts the middle blindly.
80+ - **Turning a failure into an instruction**: `error` set → `answer: "go is unavailable (llmman Compute error); answer yourself and say so"` — measured trap: the coder's calls failed with llmman 500 and the coordinator relayed the error text.
81+ - **Redacting** what comes back (a specialist that printed an `.env`).
82+ - **Audit / notification**: delegations.jsonl with question, answer length, duration, error.
83+
84+ ### Order on the `ask_agent` path
85+ `preToolCall(ask_agent)` → consent → **`preAsk`** → asked agent (its own `preGeneration` … `postGeneration`) → **`postAsk`** → `postToolCall(ask_agent)`.
86+ The existing `preToolCall` / `postToolCall` keep firing for `ask_agent`, unchanged: nothing written for v1 breaks. On `/ask` and `mm --ask`: `preAsk` → asked agent → `postAsk`.
87+
88+ ### Inside the asked agent (small extra)
89+ Its four hooks stay as they are. Add one field to their input when `MM_ASK_DEPTH` > 0: `"asked":{"depth":1}`, so a specialist's hook can behave differently when it is asked by another agent and when a person uses it directly.
90+
91+ ### Implementation sketch
92+ - `internal/config/hooks.go`: two events, `preAsk` and `postAsk`; `matcher` allowed on them (agent name); validation messages updated.
93+ - `internal/hooks`: `Input.Ask` (agent, config, agentModel, via, withContext, depth, prompt, transcript, answer, error, durationMs); `Reply.Transcript` and `Reply.Answer` as `*string` (absent ≠ empty); `deny` allowed on `preAsk` only.
94+ - **One seam in `internal/ask`** for the four paths: a `Pool.Delegate(ctx, Request{Agent, Question, Transcript, Via}, sink)` that runs `preAsk`, builds the prompt (`ask.Prompt`), calls `Ask`, runs `postAsk`. `Pool.Run`, `Pool.CLI` and `askAgentCall` call it instead of assembling the prompt themselves — `preAsk` must see question and transcript **before** `ask.Prompt` joins them.
95+ - Tests with `sh -c` hooks (no node needed), like ticket 36: deny on each path, prompt/transcript rewrite reaches the child, answer replacement reaches the history and the tool output, `postAsk` after a cancelled ask, matcher on the agent name.
96+ - Docs EN+FR: `reference/hooks.md` (events, input, reply, order), `how-to/guard-the-agent-with-hooks.md` (one recipe per hook), `explanation/hooks.md`; e-book chapter 14.
97+ - Demo: add two Node.js hooks to `demos/coding-helpers` (coordinator): `preAsk` adds the "answer in French, one code block" instruction and the budget; `postAsk` checks for a code block and writes `delegations.jsonl`. Measure before/after on the demo's question set.
98+
99+ ### Decisions (user, 2026-10-15)
100+ 1. **No `retry`** in `postAsk`. Asking again is the coordinator model's call, prompted by `context`.
101+ 2. **`preAsk` runs after the `ask_agent` consent.** The user approves what the model asked for; the hook then filters or rewrites it.
102+ 3. **`mm --ask` runs the hooks** of the file it reads (`AGENT_CONFIG`), with `via: "cli"`.
103+ 4. **No rerouting**: `preAsk` cannot change the agent.
104+tasks: []
105+comments: []
new file mode 100644
@@ -0,0 +1,105 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 87
3+title: Hook for ask agents
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-15T21:55:43.205Z
9+updatedAt: 2026-10-15T22:30:00.000Z
10+labels: []
11+milestone: v0.5.0
12+body: |
13+ ## Proposal: two hooks around agent calls — `preAsk` and `postAsk`
14+
15+ ### Goal
16+ Let the author of a **coordinator** (the agent that asks) control each hand-off to another agent: before the question leaves (`preAsk`), and when the answer comes back to the coordinator (`postAsk`). Same protocol as the four existing hooks (`sh -c`, one JSON object on stdin, one on stdout, `matcher`, `timeout`, `onError`, chains), declared in the coordinator's own file.
17+
18+ ### Where we start (checked in the code on 2026-10-15, hooks v1 from ticket 36 uncommitted on `main`)
19+ - **Four ways to hand off**, all ending in `ask.Pool.Ask` (`internal/ask/pool.go`):
20+ - the `ask_agent` tool, called by the model (`internal/tools/askagent.go`);
21+ - `/ask`, `/ask-<name>`, `/ask-with-context-<name>`, typed by the user: the front ends call `Pool.Run` directly (`internal/agent/ask.go`, `internal/tui/ask.go`, `internal/acp/ask.go`);
22+ - `mm --ask <name>` / `--ask-with-context`, run by a skill's bash (`main.go` `runAsk` → `Pool.CLI`).
23+ - **Only `ask_agent` meets a hook today**, as an ordinary tool: `preToolCall` / `postToolCall` with `matcher: ^ask_agent$`. The `/ask` commands and `mm --ask` do not go through `engine.Generate`, so **no hook of the coordinator runs for them**.
24+ - **The asked agent runs its own hooks** (those of its own file, `preGeneration` sees the prompt including the transcript). They belong to the specialist's author, not the coordinator's, and the coordinator's author may not be able to change that file.
25+ - **What `preToolCall` / `postToolCall` on `ask_agent` cannot do:**
26+ - `preToolCall` sees `{agent, prompt, with_context}` as the model wrote them: not the asked agent's file or model, not the depth, and not the conversation that `with_context` will add (`ask.Transcript` runs inside the tool, after the hook).
27+ - `postToolCall` can only **append** context: it cannot replace the answer the coordinator reads, it receives it already truncated (`truncate`), and a failure is mixed into the text (`[ask_agent: go failed: …]`).
28+
29+ ### `preAsk` — before the hand-off
30+ **Runs** in the coordinator's process, once per hand-off, for the four paths, **before the asked agent is started or asked**, and **after** the consent prompt of `ask_agent` (decision 2): the user approves the hand-off the model asked for, then the hook may still deny or rewrite it. A hand-off the user rejected runs no `preAsk`. `matcher` is a regular expression on the **agent's name** (`^(go|rust)$`), not on a tool name.
31+
32+ **Input:**
33+ ```json
34+ {"event":"preAsk","cwd":"/abs/project","model":"llmman/…MiniCPM5-2B…",
35+ "ask":{"agent":"go","config":"/abs/agent.go.yaml","agentModel":"llmman/…Mellum2…",
36+ "via":"tool","withContext":true,"depth":0,
37+ "prompt":"How do I read a file line by line?",
38+ "transcript":"user: …\nassistant: …"}}
39+ ```
40+ `via` is `tool`, `command` or `cli`. `transcript` is present only with `with_context` and is exactly what will be sent.
41+
42+ **Reply:**
43+ | Field | Effect |
44+ | --- | --- |
45+ | `action: continue` | the hand-off goes on, with the changes below |
46+ | `action: deny` + `reason` | the asked agent is not started. Tool: the model reads `[ask denied by a hook: <reason>]` as `ask_agent`'s output. Command: the user reads `⛔ …`, the history is unchanged. CLI: on stderr, exit code 1. |
47+ | `prompt` | replaces the question |
48+ | `transcript` | replaces the conversation sent (`""` = send none) |
49+ | `context` | appended to what the asked agent receives, after `[context added by a hook]` |
50+
51+ **What I would write with it:**
52+ - **Standing instructions for specialists**: "answer in French, at most 300 words, one code block". Measured in `demos/coding-helpers`: the Go/Rust specialists answer in English; today the only lever is their own system prompt.
53+ - **A delegation budget**: count hand-offs per turn in a file and deny after N. Small coordinators (MiniCPM5-2B) ask the same agent again and again or answer the question back.
54+ - **Redacting the transcript** of `with_context` / `/ask-with-context`: keys, tokens, absolute paths — the conversation leaves for another model, possibly another provider.
55+ - **An allow-list by situation**: no `ask_agent` to the agent that can write files when the question does not ask for it; no `with_context` towards a remote provider.
56+ - **An audit log** of who asked whom, what, with which model.
57+
58+ ### `postAsk` — on return to the coordinator
59+ **Runs** in the coordinator's process once the asked agent's turn is over — answered, failed, cancelled, or no answer — **before** the result becomes `ask_agent`'s output (tool) or the `[asked …]` / `[… 's answer]` pair in the history (commands, `ask.Exchange`). Like `postGeneration`, it is not cancelled with the turn (`context.WithoutCancel`) and only observes in that case. Same `matcher` on the agent's name.
60+
61+ **Input:** the same `ask` object, plus:
62+ ```json
63+ {"answer":"<the full answer, before truncation>","error":"<absent when it succeeded>","durationMs":34120}
64+ ```
65+
66+ **Reply:**
67+ | Field | Effect |
68+ | --- | --- |
69+ | `answer` | replaces what the coordinator reads and keeps (then truncated as usual). Absent = unchanged. |
70+ | `context` | appended after the answer, after `[context added by a hook]` |
71+ | `action: continue` / `error` | as for the other hooks |
72+
73+ No `retry` action (decision 1): to have the agent asked again, `postAsk` says so in `context` and the coordinator's model decides.
74+
75+ **Limit to document:** the answer has **already streamed** to the user (window, editor, line mode). `postAsk` changes what the coordinator reads and remembers, not what was shown — the same rule as `postToolCall`.
76+
77+ **What I would write with it:**
78+ - **Checking the format** the coordinator expects: valid JSON, a code block present, a Go snippet that passes `gofmt`/`go vet`; on failure, `context: "the answer has no code block — ask again or say so"`.
79+ - **Shortening a long answer** before it lands in a small coordinator's context: keep the code blocks and the first paragraph. Today `truncate` cuts the middle blindly.
80+ - **Turning a failure into an instruction**: `error` set → `answer: "go is unavailable (llmman Compute error); answer yourself and say so"` — measured trap: the coder's calls failed with llmman 500 and the coordinator relayed the error text.
81+ - **Redacting** what comes back (a specialist that printed an `.env`).
82+ - **Audit / notification**: delegations.jsonl with question, answer length, duration, error.
83+
84+ ### Order on the `ask_agent` path
85+ `preToolCall(ask_agent)` → consent → **`preAsk`** → asked agent (its own `preGeneration` … `postGeneration`) → **`postAsk`** → `postToolCall(ask_agent)`.
86+ The existing `preToolCall` / `postToolCall` keep firing for `ask_agent`, unchanged: nothing written for v1 breaks. On `/ask` and `mm --ask`: `preAsk` → asked agent → `postAsk`.
87+
88+ ### Inside the asked agent (small extra)
89+ Its four hooks stay as they are. Add one field to their input when `MM_ASK_DEPTH` > 0: `"asked":{"depth":1}`, so a specialist's hook can behave differently when it is asked by another agent and when a person uses it directly.
90+
91+ ### Implementation sketch
92+ - `internal/config/hooks.go`: two events, `preAsk` and `postAsk`; `matcher` allowed on them (agent name); validation messages updated.
93+ - `internal/hooks`: `Input.Ask` (agent, config, agentModel, via, withContext, depth, prompt, transcript, answer, error, durationMs); `Reply.Transcript` and `Reply.Answer` as `*string` (absent ≠ empty); `deny` allowed on `preAsk` only.
94+ - **One seam in `internal/ask`** for the four paths: a `Pool.Delegate(ctx, Request{Agent, Question, Transcript, Via}, sink)` that runs `preAsk`, builds the prompt (`ask.Prompt`), calls `Ask`, runs `postAsk`. `Pool.Run`, `Pool.CLI` and `askAgentCall` call it instead of assembling the prompt themselves — `preAsk` must see question and transcript **before** `ask.Prompt` joins them.
95+ - Tests with `sh -c` hooks (no node needed), like ticket 36: deny on each path, prompt/transcript rewrite reaches the child, answer replacement reaches the history and the tool output, `postAsk` after a cancelled ask, matcher on the agent name.
96+ - Docs EN+FR: `reference/hooks.md` (events, input, reply, order), `how-to/guard-the-agent-with-hooks.md` (one recipe per hook), `explanation/hooks.md`; e-book chapter 14.
97+ - Demo: add two Node.js hooks to `demos/coding-helpers` (coordinator): `preAsk` adds the "answer in French, one code block" instruction and the budget; `postAsk` checks for a code block and writes `delegations.jsonl`. Measure before/after on the demo's question set.
98+
99+ ### Decisions (user, 2026-10-15)
100+ 1. **No `retry`** in `postAsk`. Asking again is the coordinator model's call, prompted by `context`.
101+ 2. **`preAsk` runs after the `ask_agent` consent.** The user approves what the model asked for; the hook then filters or rewrites it.
102+ 3. **`mm --ask` runs the hooks** of the file it reads (`AGENT_CONFIG`), with `via: "cli"`.
103+ 4. **No rerouting**: `preAsk` cannot change the agent.
104+tasks: []
105+comments: []
modified .tickets/milestones.yaml +1 -1
@@ -16,7 +16,7 @@ milestones:
1616 state: closed
1717 dueOn: 2026-11-01
1818 - title: v0.4.0
19- state: open
19+ state: closed
2020 dueOn: 2026-10-16
2121 - title: v0.5.0
2222 state: open
@@ -16,7 +16,7 @@ milestones:
16 state: closed16 state: closed
17 dueOn: 2026-11-0117 dueOn: 2026-11-01
18 - title: v0.4.018 - title: v0.4.0
19- state: open19+ state: closed
20 dueOn: 2026-10-1620 dueOn: 2026-10-16
21 - title: v0.5.021 - title: v0.5.0
22 state: open22 state: open
modified README.md +2 -0
@@ -141,6 +141,8 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h
141141 | `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 |
142142 | `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 |
143143 | `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` |
144+| `demos/dungeon/` | a solo role-playing game in the style of Dungeons & Dragons, same principle as `demos/team`: a Game Master hands each action to a storyteller, a referee or a scribe (Qwen3.5 4B on llmman for all four); real dice and the hero sheet in shell scripts, each specialist kept to its commands by a `preToolCall` hook; measured results |
145+| `demos/coding-helpers/` | interactive documentation for Go and Rust: a coordinator (MiniCPM5 2B) hands each question to a Go or a Rust specialist (Mellum2 12B thinking) with `ask_agent` and the conversation (`with_context`), so "the same thing in Rust" works; explanations and snippets, a full program only on request; measured results |
144146
145147 ## Learn more
146148
@@ -141,6 +141,8 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h
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/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 |
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 |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` |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` |
144+| `demos/dungeon/` | a solo role-playing game in the style of Dungeons & Dragons, same principle as `demos/team`: a Game Master hands each action to a storyteller, a referee or a scribe (Qwen3.5 4B on llmman for all four); real dice and the hero sheet in shell scripts, each specialist kept to its commands by a `preToolCall` hook; measured results |
145+| `demos/coding-helpers/` | interactive documentation for Go and Rust: a coordinator (MiniCPM5 2B) hands each question to a Go or a Rust specialist (Mellum2 12B thinking) with `ask_agent` and the conversation (`with_context`), so "the same thing in Rust" works; explanations and snippets, a full program only on request; measured results |
144 146
145 ## Learn more147 ## Learn more
146 148
added demos/coding-helpers/README.en.md +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+# Coding helpers — interactive documentation for Go and Rust
2+
3+*[Version française](README.fr.md)*
4+
5+Ask what a Go or Rust concept is, or how something is done, and get an explanation with short examples — like a reference page that answers. You talk with a **coordinator**; it hands your question to the right specialist with mm's built-in `ask_agent` tool.
6+
7+| Agent | File | Model | Role |
8+|---|---|---|---|
9+| 🧭 coordinator | `agent.yaml` | MiniCPM5 2B (`hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M`, 1.6 GB) | picks `go` or `rust`, sends the question with the conversation |
10+| 🐹 go | `agent.go.yaml` | Mellum2 12B A2.5B thinking (`huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M`, 8.1 GB) | documents Go |
11+| 🦀 rust | `agent.rust.yaml` | the same Mellum2 model, loaded once | documents Rust |
12+
13+The specialists **document, they do not build**: they explain and show snippets, and only write a complete program when you ask for one. They have no tools, so they never touch your files. No skills, no hooks.
14+
15+## Run
16+
17+```bash
18+llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
19+llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/coding-helpers
23+mm # reads ./agent.yaml
24+```
25+
26+`LLMMAN_CONTEXT_LENGTH` matters: without it, llmman loads each model with its full context window (131 072 tokens for both models here), and two models plus their caches may not fit in memory.
27+
28+## Use
29+
30+Ask in your own words, in any language:
31+
32+```
33+> hey, explain how to use a Go struct and serialise it to JSON
34+> how do I do the same thing in Rust?
35+> what is pattern matching in Rust? give me an example
36+> and in Go, what is the equivalent?
37+> write me a complete Go program that reads a file line by line
38+```
39+
40+What happens on each question:
41+
42+1. The coordinator picks the specialist from the language you name — "the same thing in Rust" goes to `rust`.
43+2. It calls `ask_agent` with `with_context: true`: the specialist receives the conversation so far, so it knows what "the same thing" refers to.
44+3. The specialist's answer streams to you as it is written — the explanation, the snippets, the pitfalls.
45+4. The coordinator adds one line, sometimes a short summary in your language.
46+
47+You can also talk to a specialist directly: `mm agent.go.yaml` or `mm agent.rust.yaml`. Inside the coordinator's session, `/ask-go <question>` and `/ask-rust <question>` ask one yourself, without going through the coordinator's choice.
48+
49+The specialists often answer in English even when you write in French; the coordinator's own line follows your language.
50+
51+## Measured results
52+
53+On 2026-10-15, llmman on the host, one conversation of five messages in the REPL, then two single questions:
54+
55+| Message | Sent to | Answer |
56+|---|---|---|
57+| "explique-moi ce qu'est le pattern matching en Rust et donne-moi un exemple" | rust ✅ | what it is, a `match` on a `Result`, pitfalls (exhaustiveness, overuse) — no `main` ✅ |
58+| "et en Go, comment on fait l'équivalent ?" | go ✅ | `switch`, type switch, constant "enums", pitfalls (no exhaustiveness check) — the follow-up understood ✅ |
59+| "hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON" | go ✅ | struct tags and `json.Marshal`, snippets ✅ |
60+| "écris-moi un programme Go complet qui lit un fichier ligne par ligne" | go ✅ | a complete program with `func main`, as asked ✅ |
61+| "comment trier une liste en Python ?" | go ❌ | out of scope: declined by the Go specialist in 1 run out of 2; the coordinator answered about Python itself in the other |
62+
63+An earlier version (asked to write runnable programs) produced a Go struct-to-JSON program that compiled and printed exactly what the answer said; its Rust counterpart (Serde) was not compiled — no Rust toolchain in the test sandbox. 40 to 120 s per question.
64+
65+## Known limits
66+
67+- The coordinator is a 2B model: it routes Go and Rust questions well, but does not reliably refuse other languages.
68+- A snippet can be wrong: one Go example built a one-field struct with two values (`{"Bob", "Smith"}`), which does not compile. Read the examples as documentation, not as tested code.
69+- The coordinator does not copy your question word for word: it often rewrites it in English before handing it over; `with_context` is what keeps the meaning.
70+
71+## How it works
72+
73+The team is three mm configuration files; the only link between them is the `mmConfigs` block of `agent.yaml`:
74+
75+```yaml
76+mmConfigs:
77+ go:
78+ path: agent.go.yaml
79+ description: >-
80+ Go specialist. Explains Go concepts and how to do something in Go,
81+ with short examples, like documentation. For any question about Go.
82+ yolo: true
83+ rust:
84+ path: agent.rust.yaml
85+ description: >-
86+ Rust specialist. …
87+ yolo: true
88+```
89+
90+That block gives the coordinator the `ask_agent` tool (and the `/ask-go`, `/ask-rust` commands). Each specialist runs as a child `mm`, started on its first question and kept for the next ones. To understand the machinery: [Asking another agent](../../docs/en/explanation/asking-another-agent.md); to set it up in your own agents: [Let your agent call other agents](../../docs/en/how-to/let-the-agent-call-other-agents.md).
new file mode 100644
@@ -0,0 +1,90 @@
1+# Coding helpers — interactive documentation for Go and Rust
2+
3+*[Version française](README.fr.md)*
4+
5+Ask what a Go or Rust concept is, or how something is done, and get an explanation with short examples — like a reference page that answers. You talk with a **coordinator**; it hands your question to the right specialist with mm's built-in `ask_agent` tool.
6+
7+| Agent | File | Model | Role |
8+|---|---|---|---|
9+| 🧭 coordinator | `agent.yaml` | MiniCPM5 2B (`hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M`, 1.6 GB) | picks `go` or `rust`, sends the question with the conversation |
10+| 🐹 go | `agent.go.yaml` | Mellum2 12B A2.5B thinking (`huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M`, 8.1 GB) | documents Go |
11+| 🦀 rust | `agent.rust.yaml` | the same Mellum2 model, loaded once | documents Rust |
12+
13+The specialists **document, they do not build**: they explain and show snippets, and only write a complete program when you ask for one. They have no tools, so they never touch your files. No skills, no hooks.
14+
15+## Run
16+
17+```bash
18+llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
19+llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/coding-helpers
23+mm # reads ./agent.yaml
24+```
25+
26+`LLMMAN_CONTEXT_LENGTH` matters: without it, llmman loads each model with its full context window (131 072 tokens for both models here), and two models plus their caches may not fit in memory.
27+
28+## Use
29+
30+Ask in your own words, in any language:
31+
32+```
33+> hey, explain how to use a Go struct and serialise it to JSON
34+> how do I do the same thing in Rust?
35+> what is pattern matching in Rust? give me an example
36+> and in Go, what is the equivalent?
37+> write me a complete Go program that reads a file line by line
38+```
39+
40+What happens on each question:
41+
42+1. The coordinator picks the specialist from the language you name — "the same thing in Rust" goes to `rust`.
43+2. It calls `ask_agent` with `with_context: true`: the specialist receives the conversation so far, so it knows what "the same thing" refers to.
44+3. The specialist's answer streams to you as it is written — the explanation, the snippets, the pitfalls.
45+4. The coordinator adds one line, sometimes a short summary in your language.
46+
47+You can also talk to a specialist directly: `mm agent.go.yaml` or `mm agent.rust.yaml`. Inside the coordinator's session, `/ask-go <question>` and `/ask-rust <question>` ask one yourself, without going through the coordinator's choice.
48+
49+The specialists often answer in English even when you write in French; the coordinator's own line follows your language.
50+
51+## Measured results
52+
53+On 2026-10-15, llmman on the host, one conversation of five messages in the REPL, then two single questions:
54+
55+| Message | Sent to | Answer |
56+|---|---|---|
57+| "explique-moi ce qu'est le pattern matching en Rust et donne-moi un exemple" | rust ✅ | what it is, a `match` on a `Result`, pitfalls (exhaustiveness, overuse) — no `main` ✅ |
58+| "et en Go, comment on fait l'équivalent ?" | go ✅ | `switch`, type switch, constant "enums", pitfalls (no exhaustiveness check) — the follow-up understood ✅ |
59+| "hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON" | go ✅ | struct tags and `json.Marshal`, snippets ✅ |
60+| "écris-moi un programme Go complet qui lit un fichier ligne par ligne" | go ✅ | a complete program with `func main`, as asked ✅ |
61+| "comment trier une liste en Python ?" | go ❌ | out of scope: declined by the Go specialist in 1 run out of 2; the coordinator answered about Python itself in the other |
62+
63+An earlier version (asked to write runnable programs) produced a Go struct-to-JSON program that compiled and printed exactly what the answer said; its Rust counterpart (Serde) was not compiled — no Rust toolchain in the test sandbox. 40 to 120 s per question.
64+
65+## Known limits
66+
67+- The coordinator is a 2B model: it routes Go and Rust questions well, but does not reliably refuse other languages.
68+- A snippet can be wrong: one Go example built a one-field struct with two values (`{"Bob", "Smith"}`), which does not compile. Read the examples as documentation, not as tested code.
69+- The coordinator does not copy your question word for word: it often rewrites it in English before handing it over; `with_context` is what keeps the meaning.
70+
71+## How it works
72+
73+The team is three mm configuration files; the only link between them is the `mmConfigs` block of `agent.yaml`:
74+
75+```yaml
76+mmConfigs:
77+ go:
78+ path: agent.go.yaml
79+ description: >-
80+ Go specialist. Explains Go concepts and how to do something in Go,
81+ with short examples, like documentation. For any question about Go.
82+ yolo: true
83+ rust:
84+ path: agent.rust.yaml
85+ description: >-
86+ Rust specialist. …
87+ yolo: true
88+```
89+
90+That block gives the coordinator the `ask_agent` tool (and the `/ask-go`, `/ask-rust` commands). Each specialist runs as a child `mm`, started on its first question and kept for the next ones. To understand the machinery: [Asking another agent](../../docs/en/explanation/asking-another-agent.md); to set it up in your own agents: [Let your agent call other agents](../../docs/en/how-to/let-the-agent-call-other-agents.md).
added demos/coding-helpers/README.fr.md +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+# Coding helpers — une documentation interactive pour Go et Rust
2+
3+*[English version](README.en.md)*
4+
5+Demandez ce qu'est un concept de Go ou de Rust, ou comment on fait quelque chose, et obtenez une explication avec de courts exemples, comme une page de référence qui vous répond. Vous parlez à un **coordinateur**, qui confie votre question au bon spécialiste avec l'outil intégré `ask_agent` de mm.
6+
7+| Agent | Fichier | Modèle | Rôle |
8+|---|---|---|---|
9+| 🧭 coordinateur | `agent.yaml` | MiniCPM5 2B (`hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M`, 1,6 Go) | choisit `go` ou `rust`, envoie la question avec la conversation |
10+| 🐹 go | `agent.go.yaml` | Mellum2 12B A2.5B thinking (`huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M`, 8,1 Go) | documente Go |
11+| 🦀 rust | `agent.rust.yaml` | le même modèle Mellum2, chargé une seule fois | documente Rust |
12+
13+Les spécialistes **documentent, ils ne construisent pas** : ils expliquent et montrent des extraits, et n'écrivent un programme complet que si vous le demandez. Ils n'ont aucun outil, ils ne touchent donc jamais à vos fichiers. Ni skills ni hooks.
14+
15+## Lancer
16+
17+```bash
18+llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
19+llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/coding-helpers
23+mm # lit ./agent.yaml
24+```
25+
26+`LLMMAN_CONTEXT_LENGTH` compte : sans lui, llmman charge chaque modèle avec sa fenêtre de contexte complète (131 072 tokens pour les deux modèles ici), et deux modèles avec leurs caches peuvent ne pas tenir en mémoire.
27+
28+## Utiliser
29+
30+Posez vos questions avec vos mots, dans n'importe quelle langue :
31+
32+```
33+> hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON
34+> comment je fais la même chose en Rust ?
35+> explique-moi ce qu'est le pattern matching en Rust et donne-moi un exemple
36+> et en Go, comment on fait l'équivalent ?
37+> écris-moi un programme Go complet qui lit un fichier ligne par ligne
38+```
39+
40+Ce qui se passe à chaque question :
41+
42+1. Le coordinateur choisit le spécialiste d'après le langage que vous nommez : « la même chose en Rust » part vers `rust`.
43+2. Il appelle `ask_agent` avec `with_context: true` : le spécialiste reçoit la conversation jusque-là, et sait donc à quoi renvoie « la même chose ».
44+3. La réponse du spécialiste s'affiche au fil de l'écriture : l'explication, les extraits, les pièges.
45+4. Le coordinateur ajoute une ligne, parfois un court résumé dans votre langue.
46+
47+Vous pouvez aussi parler directement à un spécialiste : `mm agent.go.yaml` ou `mm agent.rust.yaml`. Dans la session du coordinateur, `/ask-go <question>` et `/ask-rust <question>` en interrogent un vous-même, sans passer par le choix du coordinateur.
48+
49+Les spécialistes répondent souvent en anglais, même quand vous écrivez en français ; la ligne du coordinateur, elle, suit votre langue.
50+
51+## Résultats mesurés
52+
53+Le 2026-10-15, llmman sur l'hôte, une conversation de cinq messages dans le REPL, puis deux questions isolées :
54+
55+| Message | Envoyé à | Réponse |
56+|---|---|---|
57+| « explique-moi ce qu'est le pattern matching en Rust et donne-moi un exemple » | rust ✅ | ce que c'est, un `match` sur un `Result`, les pièges (exhaustivité, usage excessif) — sans `main` ✅ |
58+| « et en Go, comment on fait l'équivalent ? » | go ✅ | `switch`, type switch, « enums » par constantes, les pièges (pas de vérification d'exhaustivité) — la suite comprise ✅ |
59+| « hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON » | go ✅ | tags de struct et `json.Marshal`, des extraits ✅ |
60+| « écris-moi un programme Go complet qui lit un fichier ligne par ligne » | go ✅ | un programme complet avec `func main`, comme demandé ✅ |
61+| « comment trier une liste en Python ? » | go ❌ | hors sujet : décliné par le spécialiste Go dans 1 essai sur 2 ; dans l'autre, le coordinateur a répondu lui-même sur Python |
62+
63+Une version précédente (à qui l'on demandait des programmes exécutables) a produit un programme Go struct → JSON qui compilait et affichait exactement ce que la réponse annonçait ; son équivalent Rust (Serde) n'a pas été compilé — pas de chaîne Rust dans le sandbox de test. De 40 à 120 s par question.
64+
65+## Limites connues
66+
67+- Le coordinateur est un modèle de 2B : il aiguille bien les questions Go et Rust, mais ne refuse pas de façon fiable les autres langages.
68+- Un extrait peut être faux : un exemple Go construisait une struct à un champ avec deux valeurs (`{"Bob", "Smith"}`), ce qui ne compile pas. Lisez les exemples comme de la documentation, pas comme du code testé.
69+- Le coordinateur ne recopie pas votre question mot pour mot : il la réécrit souvent en anglais avant de la transmettre ; c'est `with_context` qui en garde le sens.
70+
71+## Comment ça marche
72+
73+L'équipe tient en trois fichiers de configuration mm ; le seul lien entre eux est le bloc `mmConfigs` d'`agent.yaml` :
74+
75+```yaml
76+mmConfigs:
77+ go:
78+ path: agent.go.yaml
79+ description: >-
80+ Go specialist. Explains Go concepts and how to do something in Go,
81+ with short examples, like documentation. For any question about Go.
82+ yolo: true
83+ rust:
84+ path: agent.rust.yaml
85+ description: >-
86+ Rust specialist. …
87+ yolo: true
88+```
89+
90+Ce bloc donne au coordinateur l'outil `ask_agent` (et les commandes `/ask-go`, `/ask-rust`). Chaque spécialiste tourne comme un `mm` enfant, démarré à sa première question et gardé pour les suivantes. Pour comprendre la mécanique : [Interroger un autre agent](../../docs/fr/explanation/asking-another-agent.md) ; pour la mettre en place dans vos propres agents : [Laisser votre agent appeler d'autres agents](../../docs/fr/how-to/let-the-agent-call-other-agents.md).
new file mode 100644
@@ -0,0 +1,90 @@
1+# Coding helpers — une documentation interactive pour Go et Rust
2+
3+*[English version](README.en.md)*
4+
5+Demandez ce qu'est un concept de Go ou de Rust, ou comment on fait quelque chose, et obtenez une explication avec de courts exemples, comme une page de référence qui vous répond. Vous parlez à un **coordinateur**, qui confie votre question au bon spécialiste avec l'outil intégré `ask_agent` de mm.
6+
7+| Agent | Fichier | Modèle | Rôle |
8+|---|---|---|---|
9+| 🧭 coordinateur | `agent.yaml` | MiniCPM5 2B (`hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M`, 1,6 Go) | choisit `go` ou `rust`, envoie la question avec la conversation |
10+| 🐹 go | `agent.go.yaml` | Mellum2 12B A2.5B thinking (`huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M`, 8,1 Go) | documente Go |
11+| 🦀 rust | `agent.rust.yaml` | le même modèle Mellum2, chargé une seule fois | documente Rust |
12+
13+Les spécialistes **documentent, ils ne construisent pas** : ils expliquent et montrent des extraits, et n'écrivent un programme complet que si vous le demandez. Ils n'ont aucun outil, ils ne touchent donc jamais à vos fichiers. Ni skills ni hooks.
14+
15+## Lancer
16+
17+```bash
18+llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
19+llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/coding-helpers
23+mm # lit ./agent.yaml
24+```
25+
26+`LLMMAN_CONTEXT_LENGTH` compte : sans lui, llmman charge chaque modèle avec sa fenêtre de contexte complète (131 072 tokens pour les deux modèles ici), et deux modèles avec leurs caches peuvent ne pas tenir en mémoire.
27+
28+## Utiliser
29+
30+Posez vos questions avec vos mots, dans n'importe quelle langue :
31+
32+```
33+> hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON
34+> comment je fais la même chose en Rust ?
35+> explique-moi ce qu'est le pattern matching en Rust et donne-moi un exemple
36+> et en Go, comment on fait l'équivalent ?
37+> écris-moi un programme Go complet qui lit un fichier ligne par ligne
38+```
39+
40+Ce qui se passe à chaque question :
41+
42+1. Le coordinateur choisit le spécialiste d'après le langage que vous nommez : « la même chose en Rust » part vers `rust`.
43+2. Il appelle `ask_agent` avec `with_context: true` : le spécialiste reçoit la conversation jusque-là, et sait donc à quoi renvoie « la même chose ».
44+3. La réponse du spécialiste s'affiche au fil de l'écriture : l'explication, les extraits, les pièges.
45+4. Le coordinateur ajoute une ligne, parfois un court résumé dans votre langue.
46+
47+Vous pouvez aussi parler directement à un spécialiste : `mm agent.go.yaml` ou `mm agent.rust.yaml`. Dans la session du coordinateur, `/ask-go <question>` et `/ask-rust <question>` en interrogent un vous-même, sans passer par le choix du coordinateur.
48+
49+Les spécialistes répondent souvent en anglais, même quand vous écrivez en français ; la ligne du coordinateur, elle, suit votre langue.
50+
51+## Résultats mesurés
52+
53+Le 2026-10-15, llmman sur l'hôte, une conversation de cinq messages dans le REPL, puis deux questions isolées :
54+
55+| Message | Envoyé à | Réponse |
56+|---|---|---|
57+| « explique-moi ce qu'est le pattern matching en Rust et donne-moi un exemple » | rust ✅ | ce que c'est, un `match` sur un `Result`, les pièges (exhaustivité, usage excessif) — sans `main` ✅ |
58+| « et en Go, comment on fait l'équivalent ? » | go ✅ | `switch`, type switch, « enums » par constantes, les pièges (pas de vérification d'exhaustivité) — la suite comprise ✅ |
59+| « hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON » | go ✅ | tags de struct et `json.Marshal`, des extraits ✅ |
60+| « écris-moi un programme Go complet qui lit un fichier ligne par ligne » | go ✅ | un programme complet avec `func main`, comme demandé ✅ |
61+| « comment trier une liste en Python ? » | go ❌ | hors sujet : décliné par le spécialiste Go dans 1 essai sur 2 ; dans l'autre, le coordinateur a répondu lui-même sur Python |
62+
63+Une version précédente (à qui l'on demandait des programmes exécutables) a produit un programme Go struct → JSON qui compilait et affichait exactement ce que la réponse annonçait ; son équivalent Rust (Serde) n'a pas été compilé — pas de chaîne Rust dans le sandbox de test. De 40 à 120 s par question.
64+
65+## Limites connues
66+
67+- Le coordinateur est un modèle de 2B : il aiguille bien les questions Go et Rust, mais ne refuse pas de façon fiable les autres langages.
68+- Un extrait peut être faux : un exemple Go construisait une struct à un champ avec deux valeurs (`{"Bob", "Smith"}`), ce qui ne compile pas. Lisez les exemples comme de la documentation, pas comme du code testé.
69+- Le coordinateur ne recopie pas votre question mot pour mot : il la réécrit souvent en anglais avant de la transmettre ; c'est `with_context` qui en garde le sens.
70+
71+## Comment ça marche
72+
73+L'équipe tient en trois fichiers de configuration mm ; le seul lien entre eux est le bloc `mmConfigs` d'`agent.yaml` :
74+
75+```yaml
76+mmConfigs:
77+ go:
78+ path: agent.go.yaml
79+ description: >-
80+ Go specialist. Explains Go concepts and how to do something in Go,
81+ with short examples, like documentation. For any question about Go.
82+ yolo: true
83+ rust:
84+ path: agent.rust.yaml
85+ description: >-
86+ Rust specialist. …
87+ yolo: true
88+```
89+
90+Ce bloc donne au coordinateur l'outil `ask_agent` (et les commandes `/ask-go`, `/ask-rust`). Chaque spécialiste tourne comme un `mm` enfant, démarré à sa première question et gardé pour les suivantes. Pour comprendre la mécanique : [Interroger un autre agent](../../docs/fr/explanation/asking-another-agent.md) ; pour la mettre en place dans vos propres agents : [Laisser votre agent appeler d'autres agents](../../docs/fr/how-to/let-the-agent-call-other-agents.md).
added demos/coding-helpers/agent.go.yaml +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+# Go specialist — asked by the coordinator (agent.yaml) through `ask_agent`,
2+# or by you directly: `mm agent.go.yaml`. Interactive documentation: it
3+# explains a Go concept with short examples in its answer. No tools — it
4+# builds nothing, writes no file, and only writes a whole program when asked
5+# to.
6+#
7+# Mellum2 12B A2.5B thinking (JetBrains, a mixture of experts: 12B weights,
8+# 2.5B active per token): a code model that reasons before answering.
9+
10+provider: llmman
11+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
12+baseUrl: http://127.0.0.1:17434/v1
13+fallback: http://host.docker.internal:17434/v1
14+contextWindow: 32768
15+
16+sampling:
17+ # No sampling in the GGUF; values usual for a thinking model (never 0.0:
18+ # thinking models loop).
19+ temperature: 0.6
20+ top_p: 0.95
21+ # The reasoning plus an explanation with examples.
22+ max_tokens: 8192
23+ # On by default in the template; written so the choice is visible.
24+ chat_template_kwargs:
25+ enable_thinking: true
26+
27+showThinking: false
28+watchdogTimeout: 3m
29+firstTokenTimeout: 3m
30+
31+# No tools: one answer.
32+maxTurns: 2
33+maxOutput: 20000
34+
35+yolo: false
36+bashTool: false
37+editTools: false
38+planTool: false
39+fetchContentTool: false
40+askUserTool: false
41+skillsDir: no-skills
42+
43+agentsMd:
44+ enabled: false
45+
46+sessions:
47+ enabled: false
48+
49+acp:
50+ name: go
51+ title: 🐹 Go specialist
52+
53+system: |
54+ You are Go documentation that answers questions. People ask what a Go
55+ concept is or how something is done in Go; you explain it and show
56+ examples, like a good reference page would.
57+
58+ If the conversation so far is included, read it: "the same thing" means
59+ what was asked or shown before — explain it for Go.
60+
61+ Your answer:
62+ 1. What it is and when to use it, in a few sentences.
63+ 2. Short examples in ```go code blocks — a snippet per point, only
64+ the lines that show the idea (no main function, no imports unless they
65+ matter to the point).
66+ 3. The pitfalls or the idiomatic way, in a short list, when there are
67+ any.
68+
69+ RULES
70+ - Only Go. A question about another language (Python, Java…): answer in
71+ one sentence that you document Go only — never answer it with Go.
72+ - You document; you do not build. Write a complete, runnable program only
73+ when the user explicitly asks for one ("write me a program", "a full
74+ example I can run").
75+ - Standard library unless the question names a package.
76+ - Keep it short and correct: an example that does not compile is worse
77+ than no example.
new file mode 100644
@@ -0,0 +1,77 @@
1+# Go specialist — asked by the coordinator (agent.yaml) through `ask_agent`,
2+# or by you directly: `mm agent.go.yaml`. Interactive documentation: it
3+# explains a Go concept with short examples in its answer. No tools — it
4+# builds nothing, writes no file, and only writes a whole program when asked
5+# to.
6+#
7+# Mellum2 12B A2.5B thinking (JetBrains, a mixture of experts: 12B weights,
8+# 2.5B active per token): a code model that reasons before answering.
9+
10+provider: llmman
11+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
12+baseUrl: http://127.0.0.1:17434/v1
13+fallback: http://host.docker.internal:17434/v1
14+contextWindow: 32768
15+
16+sampling:
17+ # No sampling in the GGUF; values usual for a thinking model (never 0.0:
18+ # thinking models loop).
19+ temperature: 0.6
20+ top_p: 0.95
21+ # The reasoning plus an explanation with examples.
22+ max_tokens: 8192
23+ # On by default in the template; written so the choice is visible.
24+ chat_template_kwargs:
25+ enable_thinking: true
26+
27+showThinking: false
28+watchdogTimeout: 3m
29+firstTokenTimeout: 3m
30+
31+# No tools: one answer.
32+maxTurns: 2
33+maxOutput: 20000
34+
35+yolo: false
36+bashTool: false
37+editTools: false
38+planTool: false
39+fetchContentTool: false
40+askUserTool: false
41+skillsDir: no-skills
42+
43+agentsMd:
44+ enabled: false
45+
46+sessions:
47+ enabled: false
48+
49+acp:
50+ name: go
51+ title: 🐹 Go specialist
52+
53+system: |
54+ You are Go documentation that answers questions. People ask what a Go
55+ concept is or how something is done in Go; you explain it and show
56+ examples, like a good reference page would.
57+
58+ If the conversation so far is included, read it: "the same thing" means
59+ what was asked or shown before — explain it for Go.
60+
61+ Your answer:
62+ 1. What it is and when to use it, in a few sentences.
63+ 2. Short examples in ```go code blocks — a snippet per point, only
64+ the lines that show the idea (no main function, no imports unless they
65+ matter to the point).
66+ 3. The pitfalls or the idiomatic way, in a short list, when there are
67+ any.
68+
69+ RULES
70+ - Only Go. A question about another language (Python, Java…): answer in
71+ one sentence that you document Go only — never answer it with Go.
72+ - You document; you do not build. Write a complete, runnable program only
73+ when the user explicitly asks for one ("write me a program", "a full
74+ example I can run").
75+ - Standard library unless the question names a package.
76+ - Keep it short and correct: an example that does not compile is worse
77+ than no example.
added demos/coding-helpers/agent.rust.yaml +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+# Rust specialist — asked by the coordinator (agent.yaml) through `ask_agent`,
2+# or by you directly: `mm agent.rust.yaml`. Interactive documentation: it
3+# explains a Rust concept with short examples in its answer. No tools — it
4+# builds nothing, writes no file, and only writes a whole program when asked
5+# to.
6+#
7+# Mellum2 12B A2.5B thinking (JetBrains, a mixture of experts: 12B weights,
8+# 2.5B active per token): a code model that reasons before answering.
9+
10+provider: llmman
11+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
12+baseUrl: http://127.0.0.1:17434/v1
13+fallback: http://host.docker.internal:17434/v1
14+contextWindow: 32768
15+
16+sampling:
17+ # No sampling in the GGUF; values usual for a thinking model (never 0.0:
18+ # thinking models loop).
19+ temperature: 0.6
20+ top_p: 0.95
21+ # The reasoning plus an explanation with examples.
22+ max_tokens: 8192
23+ # On by default in the template; written so the choice is visible.
24+ chat_template_kwargs:
25+ enable_thinking: true
26+
27+showThinking: false
28+watchdogTimeout: 3m
29+firstTokenTimeout: 3m
30+
31+# No tools: one answer.
32+maxTurns: 2
33+maxOutput: 20000
34+
35+yolo: false
36+bashTool: false
37+editTools: false
38+planTool: false
39+fetchContentTool: false
40+askUserTool: false
41+skillsDir: no-skills
42+
43+agentsMd:
44+ enabled: false
45+
46+sessions:
47+ enabled: false
48+
49+acp:
50+ name: rust
51+ title: 🦀 Rust specialist
52+
53+system: |
54+ You are Rust documentation that answers questions. People ask what a Rust
55+ concept is or how something is done in Rust; you explain it and show
56+ examples, like a good reference page would.
57+
58+ If the conversation so far is included, read it: "the same thing" means
59+ what was asked or shown before — explain it for Rust.
60+
61+ Your answer:
62+ 1. What it is and when to use it, in a few sentences.
63+ 2. Short examples in ```rust code blocks — a snippet per point, only
64+ the lines that show the idea (no main function, no imports unless they
65+ matter to the point).
66+ 3. The pitfalls or the idiomatic way, in a short list, when there are
67+ any.
68+
69+ RULES
70+ - Only Rust. A question about another language (Python, Java…): answer in
71+ one sentence that you document Rust only — never answer it with Rust.
72+ - You document; you do not build. Write a complete, runnable program only
73+ when the user explicitly asks for one ("write me a program", "a full
74+ example I can run").
75+ - When an example needs a crate (serde…), name it and give its Cargo.toml line.
76+ - Keep it short and correct: an example that does not compile is worse
77+ than no example.
new file mode 100644
@@ -0,0 +1,77 @@
1+# Rust specialist — asked by the coordinator (agent.yaml) through `ask_agent`,
2+# or by you directly: `mm agent.rust.yaml`. Interactive documentation: it
3+# explains a Rust concept with short examples in its answer. No tools — it
4+# builds nothing, writes no file, and only writes a whole program when asked
5+# to.
6+#
7+# Mellum2 12B A2.5B thinking (JetBrains, a mixture of experts: 12B weights,
8+# 2.5B active per token): a code model that reasons before answering.
9+
10+provider: llmman
11+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
12+baseUrl: http://127.0.0.1:17434/v1
13+fallback: http://host.docker.internal:17434/v1
14+contextWindow: 32768
15+
16+sampling:
17+ # No sampling in the GGUF; values usual for a thinking model (never 0.0:
18+ # thinking models loop).
19+ temperature: 0.6
20+ top_p: 0.95
21+ # The reasoning plus an explanation with examples.
22+ max_tokens: 8192
23+ # On by default in the template; written so the choice is visible.
24+ chat_template_kwargs:
25+ enable_thinking: true
26+
27+showThinking: false
28+watchdogTimeout: 3m
29+firstTokenTimeout: 3m
30+
31+# No tools: one answer.
32+maxTurns: 2
33+maxOutput: 20000
34+
35+yolo: false
36+bashTool: false
37+editTools: false
38+planTool: false
39+fetchContentTool: false
40+askUserTool: false
41+skillsDir: no-skills
42+
43+agentsMd:
44+ enabled: false
45+
46+sessions:
47+ enabled: false
48+
49+acp:
50+ name: rust
51+ title: 🦀 Rust specialist
52+
53+system: |
54+ You are Rust documentation that answers questions. People ask what a Rust
55+ concept is or how something is done in Rust; you explain it and show
56+ examples, like a good reference page would.
57+
58+ If the conversation so far is included, read it: "the same thing" means
59+ what was asked or shown before — explain it for Rust.
60+
61+ Your answer:
62+ 1. What it is and when to use it, in a few sentences.
63+ 2. Short examples in ```rust code blocks — a snippet per point, only
64+ the lines that show the idea (no main function, no imports unless they
65+ matter to the point).
66+ 3. The pitfalls or the idiomatic way, in a short list, when there are
67+ any.
68+
69+ RULES
70+ - Only Rust. A question about another language (Python, Java…): answer in
71+ one sentence that you document Rust only — never answer it with Rust.
72+ - You document; you do not build. Write a complete, runnable program only
73+ when the user explicitly asks for one ("write me a program", "a full
74+ example I can run").
75+ - When an example needs a crate (serde…), name it and give its Cargo.toml line.
76+ - Keep it short and correct: an example that does not compile is worse
77+ than no example.
added demos/coding-helpers/agent.yaml +107 -0
new file mode 100644
@@ -0,0 +1,107 @@
1+# Coding helpers — interactive documentation for Go and Rust: ask what a
2+# concept is or how something is done, get an explanation with short
3+# examples. You talk with this coordinator; it picks the specialist with
4+# the built-in `ask_agent` tool:
5+#
6+# go — agent.go.yaml, a Go specialist
7+# rust — agent.rust.yaml, a Rust specialist
8+#
9+# "hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON"
10+# "comment je fais la même chose en Rust ?"
11+# "explique-moi ce qu'est le pattern matching en Rust, avec un exemple"
12+#
13+# The second question only makes sense after the first: the coordinator
14+# sends the conversation along (`with_context`), so the Rust specialist
15+# knows what "the same thing" is.
16+#
17+# Models (≈ 10 GB with both loaded):
18+#
19+# coordinator MiniCPM5 2B hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M 1.6 GB
20+# go, rust Mellum2 12B thinking huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M 8.1 GB (one model, loaded once)
21+#
22+# llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
23+# llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
24+# LLMMAN_CONTEXT_LENGTH=32768 llmman serve
25+#
26+# cd demos/coding-helpers
27+# mm (reads ./agent.yaml)
28+
29+provider: llmman
30+model: hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
31+baseUrl: http://127.0.0.1:17434/v1
32+fallback: http://host.docker.internal:17434/v1
33+contextWindow: 32768
34+
35+# The team. The description is what the coordinator reads to choose.
36+mmConfigs:
37+ go:
38+ path: agent.go.yaml
39+ description: >-
40+ Go specialist. Explains Go concepts and how to do something in Go,
41+ with short examples, like documentation. For any question about Go.
42+ yolo: true
43+ rust:
44+ path: agent.rust.yaml
45+ description: >-
46+ Rust specialist. Explains Rust concepts and how to do something in
47+ Rust, with short examples, like documentation. For any question about
48+ Rust.
49+ yolo: true
50+
51+sampling:
52+ # The GGUF says 1.0 / 0.95; routing wants a steady hand.
53+ temperature: 0.3
54+ top_p: 0.95
55+ max_tokens: 2048
56+ parallel_tool_calls: false
57+ # MiniCPM5's template reads enable_thinking and has no default: set it.
58+ chat_template_kwargs:
59+ enable_thinking: false
60+
61+# During ask_agent every event of the specialist counts as a sign of life;
62+# a 12B thinking model may reason a while before its first word.
63+watchdogTimeout: 5m
64+firstTokenTimeout: 3m
65+
66+# one call, one sentence.
67+maxTurns: 3
68+maxOutput: 20000
69+displayCommands: true
70+
71+yolo: true
72+bashTool: false
73+editTools: false
74+planTool: false
75+fetchContentTool: false
76+askUserTool: false
77+skillsDir: no-skills
78+
79+agentsMd:
80+ enabled: false
81+
82+sessions:
83+ enabled: false
84+
85+acp:
86+ name: coding-helpers
87+ title: 🧭 Coding helpers (Go, Rust)
88+
89+# Written for a 2B model: short, no fake call written out in the text —
90+# measured, MiniCPM5 copies such patterns into its answer instead of calling
91+# the tool.
92+system: |
93+ You are the front desk of two programming specialists: go and rust.
94+
95+ When the user asks something about programming, call the ask_agent tool
96+ once:
97+ - agent: go if the question is about Go, rust if it is about Rust. When
98+ the user says "the same thing", "la même chose", "now in …", pick the
99+ language they name now.
100+ - prompt: the user's message, copied exactly.
101+ - with_context: true, so the specialist sees the conversation so far.
102+
103+ When the tool has answered, write one short sentence in the user's
104+ language saying the explanation is above.
105+
106+ If the language is neither Go nor Rust, say in one sentence that you
107+ only know Go and Rust. When the user only greets you, greet back.
new file mode 100644
@@ -0,0 +1,107 @@
1+# Coding helpers — interactive documentation for Go and Rust: ask what a
2+# concept is or how something is done, get an explanation with short
3+# examples. You talk with this coordinator; it picks the specialist with
4+# the built-in `ask_agent` tool:
5+#
6+# go — agent.go.yaml, a Go specialist
7+# rust — agent.rust.yaml, a Rust specialist
8+#
9+# "hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON"
10+# "comment je fais la même chose en Rust ?"
11+# "explique-moi ce qu'est le pattern matching en Rust, avec un exemple"
12+#
13+# The second question only makes sense after the first: the coordinator
14+# sends the conversation along (`with_context`), so the Rust specialist
15+# knows what "the same thing" is.
16+#
17+# Models (≈ 10 GB with both loaded):
18+#
19+# coordinator MiniCPM5 2B hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M 1.6 GB
20+# go, rust Mellum2 12B thinking huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M 8.1 GB (one model, loaded once)
21+#
22+# llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
23+# llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
24+# LLMMAN_CONTEXT_LENGTH=32768 llmman serve
25+#
26+# cd demos/coding-helpers
27+# mm (reads ./agent.yaml)
28+
29+provider: llmman
30+model: hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
31+baseUrl: http://127.0.0.1:17434/v1
32+fallback: http://host.docker.internal:17434/v1
33+contextWindow: 32768
34+
35+# The team. The description is what the coordinator reads to choose.
36+mmConfigs:
37+ go:
38+ path: agent.go.yaml
39+ description: >-
40+ Go specialist. Explains Go concepts and how to do something in Go,
41+ with short examples, like documentation. For any question about Go.
42+ yolo: true
43+ rust:
44+ path: agent.rust.yaml
45+ description: >-
46+ Rust specialist. Explains Rust concepts and how to do something in
47+ Rust, with short examples, like documentation. For any question about
48+ Rust.
49+ yolo: true
50+
51+sampling:
52+ # The GGUF says 1.0 / 0.95; routing wants a steady hand.
53+ temperature: 0.3
54+ top_p: 0.95
55+ max_tokens: 2048
56+ parallel_tool_calls: false
57+ # MiniCPM5's template reads enable_thinking and has no default: set it.
58+ chat_template_kwargs:
59+ enable_thinking: false
60+
61+# During ask_agent every event of the specialist counts as a sign of life;
62+# a 12B thinking model may reason a while before its first word.
63+watchdogTimeout: 5m
64+firstTokenTimeout: 3m
65+
66+# one call, one sentence.
67+maxTurns: 3
68+maxOutput: 20000
69+displayCommands: true
70+
71+yolo: true
72+bashTool: false
73+editTools: false
74+planTool: false
75+fetchContentTool: false
76+askUserTool: false
77+skillsDir: no-skills
78+
79+agentsMd:
80+ enabled: false
81+
82+sessions:
83+ enabled: false
84+
85+acp:
86+ name: coding-helpers
87+ title: 🧭 Coding helpers (Go, Rust)
88+
89+# Written for a 2B model: short, no fake call written out in the text —
90+# measured, MiniCPM5 copies such patterns into its answer instead of calling
91+# the tool.
92+system: |
93+ You are the front desk of two programming specialists: go and rust.
94+
95+ When the user asks something about programming, call the ask_agent tool
96+ once:
97+ - agent: go if the question is about Go, rust if it is about Rust. When
98+ the user says "the same thing", "la même chose", "now in …", pick the
99+ language they name now.
100+ - prompt: the user's message, copied exactly.
101+ - with_context: true, so the specialist sees the conversation so far.
102+
103+ When the tool has answered, write one short sentence in the user's
104+ language saying the explanation is above.
105+
106+ If the language is neither Go nor Rust, say in one sentence that you
107+ only know Go and Rust. When the user only greets you, greet back.
added demos/dungeon/.gitignore +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+# The game in progress (sh new-game.sh erases it).
2+game/
new file mode 100644
@@ -0,0 +1,2 @@
1+# The game in progress (sh new-game.sh erases it).
2+game/
added demos/dungeon/AGENTS.md +91 -0
new file mode 100644
@@ -0,0 +1,91 @@
1+# Dungeon — the rules of the game
2+
3+A solo role-playing game in the style of Dungeons & Dragons, simplified. The player plays ONE hero. The game state lives in three files under `game/`; every agent of the team reads these rules.
4+
5+## Language
6+
7+Everything the player reads — answers, names, descriptions, the text inside the game files — is in the player's language: the coordinator says it as "Language: …". The field labels of the files (`HP:`, `AC:`, `Place:`…) and the words of `roll.sh` (`SUCCESS`, `FAILURE`) stay in English, always.
8+
9+## Heroes
10+
11+Four classes. The numbers are fixed: copy them, never compute them.
12+
13+| Class | HP | AC | Attack | Abilities | Special |
14+|---|---|---|---|---|---|
15+| Warrior | 12 | 16 | longsword +4, damage 1d8+2 | STR +2, DEX +1, CON +2, INT -1, WIS +1, CHA +0 | Second wind: once per game, heal 1d10+1 |
16+| Rogue | 9 | 14 | dagger +4, damage 1d6+2 | STR +0, DEX +2, CON +1, INT +1, WIS -1, CHA +2 | Sneak attack: +1d6 damage when the enemy does not see the hero |
17+| Wizard | 7 | 12 | fire bolt +4, damage 1d10 | STR -1, DEX +1, CON +1, INT +2, WIS +2, CHA +0 | Magic missile: 3 per game, 3d4+3 damage, never misses |
18+| Cleric | 10 | 16 | mace +2, damage 1d6+1 | STR +1, DEX -1, CON +2, INT +0, WIS +2, CHA +1 | Healing word: 3 per game, heal 1d8+2 |
19+
20+Every hero starts with 10 gold and one healing potion (heals 2d4+2).
21+
22+## Checks
23+
24+A risky action is a check: `sh roll.sh check 1d20+<ability modifier> <DC>`.
25+
26+| Difficulty | DC |
27+|---|---|
28+| easy | 10 |
29+| medium | 15 |
30+| hard | 20 |
31+
32+Abilities: STR (force, lift, break), DEX (sneak, climb, dodge, pick a lock), CON (endure, resist poison), INT (know, decipher), WIS (notice, sense danger), CHA (persuade, lie, intimidate). A natural 20 always succeeds, a natural 1 always fails — `roll.sh` says so.
33+
34+## Combat
35+
36+One command plays a whole round: `sh roll.sh attack` (or `sh roll.sh attack "<monster name>"` when several monsters are there). It reads `game/hero.md`, then:
37+
38+1. the hero attacks: 1d20 + the hero's attack against the monster's AC; on a HIT, the hero's damage;
39+2. if the monster still stands, it strikes back: 1d20 + its attack against the hero's AC; on a HIT, its damage;
40+3. it ends with an `EFFECTS:` line — `<monster> HP -<n>`, `<monster> defeated`, `hero HP -<n>`, or `none`.
41+
42+A creature at 0 HP or less is defeated. The hero at 0 HP falls: the game is over. Magic missile needs no attack roll: `sh roll.sh 3d4+3` is its damage.
43+
44+Fleeing is a DEX check, medium (DC 15); on a failure the monster gets one attack.
45+
46+## The files
47+
48+### `game/adventure.md` — written by the storyteller
49+
50+```
51+# <Title>
52+Goal: <what the hero must do, one sentence>
53+
54+## Places
55+1. <Name> — <two sentences>. Here: <an NPC, a monster, a treasure, a trap (DC), or nothing>.
56+2. …
57+(five places, from the start to the goal)
58+
59+## Monsters
60+- <Name>: HP <n>, AC <n>, attack +<n>, damage <NdM+K>
61+(two to four monsters, weak to strong: HP 4 to 16, AC 10 to 14, attack +2 to +4, damage from 1d4 to 1d8+2 — a hero has 7 to 12 HP)
62+
63+## Ending
64+<what happens when the goal is reached, two sentences>
65+```
66+
67+### `game/hero.md` — written only by `sheet.sh` and `roll.sh attack`
68+
69+Who records what: the storyteller (a move with `sh sheet.sh go`, which also records the monsters of the new place; loot), the referee (heals, potions, traps, magic missile; a combat round is written by `roll.sh attack` itself), the scribe (the new hero). The journal is written by the scripts themselves (symbols: → a move, ⚔ a combat round, ❤ HP, + taken, − used) and by the referee for a check.
70+
71+```
72+# <Hero's name>
73+- Class: <Warrior | Rogue | Wizard | Cleric>
74+- HP: <current>/<max>
75+- AC: <n>
76+- Attack: <weapon> +<n>, damage <NdM+K>
77+- Abilities: STR <±n>, DEX <±n>, CON <±n>, INT <±n>, WIS <±n>, CHA <±n>
78+- Special: <the class special, with uses left>
79+- Place: <number>. <name>
80+- Gold: <n>
81+- Inventory: <items, comma-separated>
82+
83+## Encounter
84+none
85+```
86+
87+When a monster is met, the `## Encounter` section holds one line per monster: `- <Name>: HP <current>/<max>, AC <n>, attack +<n>, damage <NdM+K>`. It goes back to `none` when every monster is defeated or left behind.
88+
89+### `game/journal.md` — written by `sheet.sh note`
90+
91+One line per turn, appended, never rewritten: `- <turn number>. <what happened, one sentence, with the numbers>`. Nobody edits these two files by hand: `sh sheet.sh` does (see the comments at the top of `sheet.sh` for every command).
new file mode 100644
@@ -0,0 +1,91 @@
1+# Dungeon — the rules of the game
2+
3+A solo role-playing game in the style of Dungeons & Dragons, simplified. The player plays ONE hero. The game state lives in three files under `game/`; every agent of the team reads these rules.
4+
5+## Language
6+
7+Everything the player reads — answers, names, descriptions, the text inside the game files — is in the player's language: the coordinator says it as "Language: …". The field labels of the files (`HP:`, `AC:`, `Place:`…) and the words of `roll.sh` (`SUCCESS`, `FAILURE`) stay in English, always.
8+
9+## Heroes
10+
11+Four classes. The numbers are fixed: copy them, never compute them.
12+
13+| Class | HP | AC | Attack | Abilities | Special |
14+|---|---|---|---|---|---|
15+| Warrior | 12 | 16 | longsword +4, damage 1d8+2 | STR +2, DEX +1, CON +2, INT -1, WIS +1, CHA +0 | Second wind: once per game, heal 1d10+1 |
16+| Rogue | 9 | 14 | dagger +4, damage 1d6+2 | STR +0, DEX +2, CON +1, INT +1, WIS -1, CHA +2 | Sneak attack: +1d6 damage when the enemy does not see the hero |
17+| Wizard | 7 | 12 | fire bolt +4, damage 1d10 | STR -1, DEX +1, CON +1, INT +2, WIS +2, CHA +0 | Magic missile: 3 per game, 3d4+3 damage, never misses |
18+| Cleric | 10 | 16 | mace +2, damage 1d6+1 | STR +1, DEX -1, CON +2, INT +0, WIS +2, CHA +1 | Healing word: 3 per game, heal 1d8+2 |
19+
20+Every hero starts with 10 gold and one healing potion (heals 2d4+2).
21+
22+## Checks
23+
24+A risky action is a check: `sh roll.sh check 1d20+<ability modifier> <DC>`.
25+
26+| Difficulty | DC |
27+|---|---|
28+| easy | 10 |
29+| medium | 15 |
30+| hard | 20 |
31+
32+Abilities: STR (force, lift, break), DEX (sneak, climb, dodge, pick a lock), CON (endure, resist poison), INT (know, decipher), WIS (notice, sense danger), CHA (persuade, lie, intimidate). A natural 20 always succeeds, a natural 1 always fails — `roll.sh` says so.
33+
34+## Combat
35+
36+One command plays a whole round: `sh roll.sh attack` (or `sh roll.sh attack "<monster name>"` when several monsters are there). It reads `game/hero.md`, then:
37+
38+1. the hero attacks: 1d20 + the hero's attack against the monster's AC; on a HIT, the hero's damage;
39+2. if the monster still stands, it strikes back: 1d20 + its attack against the hero's AC; on a HIT, its damage;
40+3. it ends with an `EFFECTS:` line — `<monster> HP -<n>`, `<monster> defeated`, `hero HP -<n>`, or `none`.
41+
42+A creature at 0 HP or less is defeated. The hero at 0 HP falls: the game is over. Magic missile needs no attack roll: `sh roll.sh 3d4+3` is its damage.
43+
44+Fleeing is a DEX check, medium (DC 15); on a failure the monster gets one attack.
45+
46+## The files
47+
48+### `game/adventure.md` — written by the storyteller
49+
50+```
51+# <Title>
52+Goal: <what the hero must do, one sentence>
53+
54+## Places
55+1. <Name> — <two sentences>. Here: <an NPC, a monster, a treasure, a trap (DC), or nothing>.
56+2. …
57+(five places, from the start to the goal)
58+
59+## Monsters
60+- <Name>: HP <n>, AC <n>, attack +<n>, damage <NdM+K>
61+(two to four monsters, weak to strong: HP 4 to 16, AC 10 to 14, attack +2 to +4, damage from 1d4 to 1d8+2 — a hero has 7 to 12 HP)
62+
63+## Ending
64+<what happens when the goal is reached, two sentences>
65+```
66+
67+### `game/hero.md` — written only by `sheet.sh` and `roll.sh attack`
68+
69+Who records what: the storyteller (a move with `sh sheet.sh go`, which also records the monsters of the new place; loot), the referee (heals, potions, traps, magic missile; a combat round is written by `roll.sh attack` itself), the scribe (the new hero). The journal is written by the scripts themselves (symbols: → a move, ⚔ a combat round, ❤ HP, + taken, − used) and by the referee for a check.
70+
71+```
72+# <Hero's name>
73+- Class: <Warrior | Rogue | Wizard | Cleric>
74+- HP: <current>/<max>
75+- AC: <n>
76+- Attack: <weapon> +<n>, damage <NdM+K>
77+- Abilities: STR <±n>, DEX <±n>, CON <±n>, INT <±n>, WIS <±n>, CHA <±n>
78+- Special: <the class special, with uses left>
79+- Place: <number>. <name>
80+- Gold: <n>
81+- Inventory: <items, comma-separated>
82+
83+## Encounter
84+none
85+```
86+
87+When a monster is met, the `## Encounter` section holds one line per monster: `- <Name>: HP <current>/<max>, AC <n>, attack +<n>, damage <NdM+K>`. It goes back to `none` when every monster is defeated or left behind.
88+
89+### `game/journal.md` — written by `sheet.sh note`
90+
91+One line per turn, appended, never rewritten: `- <turn number>. <what happened, one sentence, with the numbers>`. Nobody edits these two files by hand: `sh sheet.sh` does (see the comments at the top of `sheet.sh` for every command).
added demos/dungeon/README.en.md +89 -0
new file mode 100644
@@ -0,0 +1,89 @@
1+# Dungeon — a solo role-playing game run by a team of four small agents
2+
3+*[Version française](README.fr.md)*
4+
5+A role-playing game in the style of Dungeons & Dragons, simplified, for one player. Same principle as [`demos/team`](../team/): you talk with a coordinator, the **Game Master**, who hands the work to specialists with the built-in `ask_agent` tool, and the state of the game lives in files.
6+
7+| Agent | File | Role |
8+|---|---|---|
9+| 🎭 Game Master | `agent.gm.yaml` | talks with you, picks ONE specialist per action, tells the story — it has no tool but `ask_agent` |
10+| 🗺️ Storyteller | `agent.storyteller.yaml` | creates the adventure (`game/adventure.md`), moves the hero, describes places and people |
11+| ⚖️ Referee | `agent.referee.yaml` | resolves fights and risky actions with real dice |
12+| 📜 Scribe | `agent.scribe.yaml` | creates the hero sheet, tells you where you stand |
13+
14+All four run **Qwen3.5 4B** (`hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`, 3.4 GB) on **llmman**, loaded once. The game is played in the language you write in.
15+
16+## Run
17+
18+```bash
19+llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/dungeon
23+mm agent.gm.yaml
24+```
25+
26+Then:
27+
28+```
29+> New game! I am Mira, a curious wizard.
30+> I go on to the next place.
31+> I attack the monster.
32+> Where do I stand? Show me my inventory.
33+> I try to climb the rock to look far away.
34+```
35+
36+To start over: `sh new-game.sh` (erases `game/` and the saved sessions), or ask the Game Master for a new game.
37+
38+## The rules
39+
40+They are in [`AGENTS.md`](AGENTS.md), which every agent reads: four classes with fixed numbers (Warrior, Rogue, Wizard, Cleric), checks `d20 + modifier` against a DC of 10, 15 or 20, a combat round (the hero attacks, the monster strikes back), and the format of the three game files.
41+
42+| File | What it holds | Written by |
43+|---|---|---|
44+| `game/adventure.md` | the title, the goal, five places, the monsters, the ending | the storyteller, once per game |
45+| `game/hero.md` | HP, AC, attack, abilities, place, gold, inventory, the monsters facing the hero | `sheet.sh` and `roll.sh attack` — never by a model |
46+| `game/journal.md` | one line per event | the scripts (symbols: `→` a move, `⚔` a combat round, `❤` HP, `+` taken, `−` used) and the referee for a check |
47+
48+## What the models do, what the scripts do
49+
50+The models **choose** — which specialist, which check, which place, what to say. Everything a 4B model was measured getting wrong is done by three scripts:
51+
52+| Script | Does | Why (measured) |
53+|---|---|---|
54+| `roll.sh` | every die; `roll.sh check 1d20+2 15`; `roll.sh attack` plays a whole combat round from the hero sheet and writes the result into it | asked to play a round step by step, the referee never made the monster strike back (0/3); given the one-command `attack`, it first wrote the damage without running it (3/3), until its first reply had to be a tool call |
55+| `sheet.sh` | the hero sheet and the journal: `new`, `go next`, `here`, `hp`, `take`, `use`, `gold`, `note`, `show`… — `go` reads the adventure, moves the hero and records the monsters of the new place | a scribe rewriting the sheet took the hero's own blow off the hero (4/4) and cut the journal down to one line; "the next place" was right 2 times out of 4 when the Game Master relayed it |
56+| `guard.sh` | a `preToolCall` [hook](../../docs/en/reference/hooks.md): each specialist may run only the commands of its role | told "your only commands are …", the storyteller still ran `sed -i` on the hero sheet and `edit_file` on the adventure |
57+
58+Two `preGeneration` hooks erase, at the start of each request, the markers that allow one move (`sheet.sh go`) and one combat round's damage (`roll.sh attack`) per request: an asked agent's `mm` process serves several requests in a row.
59+
60+## Measured results
61+
62+On 2026-10-14, llmman on the host, Qwen3.5 4B for the four agents.
63+
64+Each specialist alone, final version:
65+
66+| Task | Result |
67+|---|---|
68+| storyteller — create an adventure (5 places with `Here:`, monsters within the limits) | 3/3 |
69+| storyteller — "go to the next place" (`sheet.sh go next`) | 4/4 |
70+| scribe — create a hero sheet | 2/2 |
71+| referee — a combat round (`roll.sh attack`, EFFECTS copied) | 4/4 |
72+| referee — a check (ability and DC chosen, roll copied) | 2/2 |
73+
74+A whole game played in the REPL (`mm agent.gm.yaml`, eight messages: new game, next place ×2, attack ×2, status, a climbing check, next place): **the game state is right at every step** — places 1 → 2 → 3 → 4 in order, the monster of place 3 met, two combat rounds counted once each, the check rolled with the right modifier, a clean journal. 488 s in all, about one minute per move.
75+
76+Tried once with **Ornith 1.5 9B** (`huggingface.co/ornith-ai/ornith-1.5-9b-gguf:Q4_K_M`, a Qwen3.5 9B fine-tune) in all four roles, same settings, six messages (2026-10-15): the scripts and hooks kept the state consistent, but the Game Master took initiatives no one asked for — after creating the game it went on playing alone (a check, a move) until its turn limit; on "I go on to the next place" it had the referee attack three times, and the hero fell. 303 s for each of those two moves. Qwen3.5 4B stays the model of this demo.
77+
78+Tried with **Qwen3.5 9B** (`hf.co/unsloth/Qwen3.5-9B-GGUF:Q4_K_M`) in all four roles, same settings, eleven messages (2026-10-15): the Game Master called exactly one specialist per action every time (the 4B sometimes calls the scribe on top), two combat rounds were exact, moves and the new game right, 26 to 59 s per move once loaded. But one check was **not rolled**: "I try to climb the rock" came back as a success with no `roll.sh` call; the other check (a lock, DEX +1 vs DC 15) was rolled. One message went to the wrong specialist ("I attack again" with no monster in front of the hero → storyteller), and the story twice claimed a place the hero was not at. Comparable to the 4B, not clearly better, twice the memory.
79+
80+The road there, in short: five full games (A to E) and about forty runs of single agents; each fix above answers a failure seen in them.
81+
82+## Known limits
83+
84+- The Game Master sometimes calls the scribe after the storyteller although its prompt says one specialist per action. It is harmless now (the guard limits the scribe to creating a hero, adding a journal note and showing the state: it cannot move the hero nor change a number) but costs 20 to 40 s.
85+- The storyteller does not always put every monster of `## Monsters` in a place's `Here:`; a monster named differently there is not met by `go`.
86+- Monster damage sometimes exceeds the limits of `AGENTS.md` (`2d6+3` was seen): the game is then harder than intended.
87+- The Game Master's story is written from the specialists' answers; it can embellish (a second creature mentioned in the place's description) — the numbers it quotes were right in every measured move, except once a die quoted without its bonus.
88+- With `mm -p`, there is no conversation history: a bare "I attack again" was once answered with a request for context.
89+- Loading the model with its full 262k context (llmman's default without `LLMMAN_CONTEXT_LENGTH`) works for this demo alone, but runs out of memory with other models loaded beside it.
new file mode 100644
@@ -0,0 +1,89 @@
1+# Dungeon — a solo role-playing game run by a team of four small agents
2+
3+*[Version française](README.fr.md)*
4+
5+A role-playing game in the style of Dungeons & Dragons, simplified, for one player. Same principle as [`demos/team`](../team/): you talk with a coordinator, the **Game Master**, who hands the work to specialists with the built-in `ask_agent` tool, and the state of the game lives in files.
6+
7+| Agent | File | Role |
8+|---|---|---|
9+| 🎭 Game Master | `agent.gm.yaml` | talks with you, picks ONE specialist per action, tells the story — it has no tool but `ask_agent` |
10+| 🗺️ Storyteller | `agent.storyteller.yaml` | creates the adventure (`game/adventure.md`), moves the hero, describes places and people |
11+| ⚖️ Referee | `agent.referee.yaml` | resolves fights and risky actions with real dice |
12+| 📜 Scribe | `agent.scribe.yaml` | creates the hero sheet, tells you where you stand |
13+
14+All four run **Qwen3.5 4B** (`hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`, 3.4 GB) on **llmman**, loaded once. The game is played in the language you write in.
15+
16+## Run
17+
18+```bash
19+llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/dungeon
23+mm agent.gm.yaml
24+```
25+
26+Then:
27+
28+```
29+> New game! I am Mira, a curious wizard.
30+> I go on to the next place.
31+> I attack the monster.
32+> Where do I stand? Show me my inventory.
33+> I try to climb the rock to look far away.
34+```
35+
36+To start over: `sh new-game.sh` (erases `game/` and the saved sessions), or ask the Game Master for a new game.
37+
38+## The rules
39+
40+They are in [`AGENTS.md`](AGENTS.md), which every agent reads: four classes with fixed numbers (Warrior, Rogue, Wizard, Cleric), checks `d20 + modifier` against a DC of 10, 15 or 20, a combat round (the hero attacks, the monster strikes back), and the format of the three game files.
41+
42+| File | What it holds | Written by |
43+|---|---|---|
44+| `game/adventure.md` | the title, the goal, five places, the monsters, the ending | the storyteller, once per game |
45+| `game/hero.md` | HP, AC, attack, abilities, place, gold, inventory, the monsters facing the hero | `sheet.sh` and `roll.sh attack` — never by a model |
46+| `game/journal.md` | one line per event | the scripts (symbols: `→` a move, `⚔` a combat round, `❤` HP, `+` taken, `−` used) and the referee for a check |
47+
48+## What the models do, what the scripts do
49+
50+The models **choose** — which specialist, which check, which place, what to say. Everything a 4B model was measured getting wrong is done by three scripts:
51+
52+| Script | Does | Why (measured) |
53+|---|---|---|
54+| `roll.sh` | every die; `roll.sh check 1d20+2 15`; `roll.sh attack` plays a whole combat round from the hero sheet and writes the result into it | asked to play a round step by step, the referee never made the monster strike back (0/3); given the one-command `attack`, it first wrote the damage without running it (3/3), until its first reply had to be a tool call |
55+| `sheet.sh` | the hero sheet and the journal: `new`, `go next`, `here`, `hp`, `take`, `use`, `gold`, `note`, `show`… — `go` reads the adventure, moves the hero and records the monsters of the new place | a scribe rewriting the sheet took the hero's own blow off the hero (4/4) and cut the journal down to one line; "the next place" was right 2 times out of 4 when the Game Master relayed it |
56+| `guard.sh` | a `preToolCall` [hook](../../docs/en/reference/hooks.md): each specialist may run only the commands of its role | told "your only commands are …", the storyteller still ran `sed -i` on the hero sheet and `edit_file` on the adventure |
57+
58+Two `preGeneration` hooks erase, at the start of each request, the markers that allow one move (`sheet.sh go`) and one combat round's damage (`roll.sh attack`) per request: an asked agent's `mm` process serves several requests in a row.
59+
60+## Measured results
61+
62+On 2026-10-14, llmman on the host, Qwen3.5 4B for the four agents.
63+
64+Each specialist alone, final version:
65+
66+| Task | Result |
67+|---|---|
68+| storyteller — create an adventure (5 places with `Here:`, monsters within the limits) | 3/3 |
69+| storyteller — "go to the next place" (`sheet.sh go next`) | 4/4 |
70+| scribe — create a hero sheet | 2/2 |
71+| referee — a combat round (`roll.sh attack`, EFFECTS copied) | 4/4 |
72+| referee — a check (ability and DC chosen, roll copied) | 2/2 |
73+
74+A whole game played in the REPL (`mm agent.gm.yaml`, eight messages: new game, next place ×2, attack ×2, status, a climbing check, next place): **the game state is right at every step** — places 1 → 2 → 3 → 4 in order, the monster of place 3 met, two combat rounds counted once each, the check rolled with the right modifier, a clean journal. 488 s in all, about one minute per move.
75+
76+Tried once with **Ornith 1.5 9B** (`huggingface.co/ornith-ai/ornith-1.5-9b-gguf:Q4_K_M`, a Qwen3.5 9B fine-tune) in all four roles, same settings, six messages (2026-10-15): the scripts and hooks kept the state consistent, but the Game Master took initiatives no one asked for — after creating the game it went on playing alone (a check, a move) until its turn limit; on "I go on to the next place" it had the referee attack three times, and the hero fell. 303 s for each of those two moves. Qwen3.5 4B stays the model of this demo.
77+
78+Tried with **Qwen3.5 9B** (`hf.co/unsloth/Qwen3.5-9B-GGUF:Q4_K_M`) in all four roles, same settings, eleven messages (2026-10-15): the Game Master called exactly one specialist per action every time (the 4B sometimes calls the scribe on top), two combat rounds were exact, moves and the new game right, 26 to 59 s per move once loaded. But one check was **not rolled**: "I try to climb the rock" came back as a success with no `roll.sh` call; the other check (a lock, DEX +1 vs DC 15) was rolled. One message went to the wrong specialist ("I attack again" with no monster in front of the hero → storyteller), and the story twice claimed a place the hero was not at. Comparable to the 4B, not clearly better, twice the memory.
79+
80+The road there, in short: five full games (A to E) and about forty runs of single agents; each fix above answers a failure seen in them.
81+
82+## Known limits
83+
84+- The Game Master sometimes calls the scribe after the storyteller although its prompt says one specialist per action. It is harmless now (the guard limits the scribe to creating a hero, adding a journal note and showing the state: it cannot move the hero nor change a number) but costs 20 to 40 s.
85+- The storyteller does not always put every monster of `## Monsters` in a place's `Here:`; a monster named differently there is not met by `go`.
86+- Monster damage sometimes exceeds the limits of `AGENTS.md` (`2d6+3` was seen): the game is then harder than intended.
87+- The Game Master's story is written from the specialists' answers; it can embellish (a second creature mentioned in the place's description) — the numbers it quotes were right in every measured move, except once a die quoted without its bonus.
88+- With `mm -p`, there is no conversation history: a bare "I attack again" was once answered with a request for context.
89+- Loading the model with its full 262k context (llmman's default without `LLMMAN_CONTEXT_LENGTH`) works for this demo alone, but runs out of memory with other models loaded beside it.
added demos/dungeon/README.fr.md +89 -0
new file mode 100644
@@ -0,0 +1,89 @@
1+# Donjon — un jeu de rôle solo mené par une équipe de quatre petits agents
2+
3+*[English version](README.en.md)*
4+
5+Un jeu de rôle à la Donjons & Dragons, simplifié, pour un joueur. Même principe que [`demos/team`](../team/) : vous parlez à un coordinateur, le **Maître du jeu**, qui confie le travail à des spécialistes avec l'outil intégré `ask_agent`, et l'état de la partie vit dans des fichiers.
6+
7+| Agent | Fichier | Rôle |
8+|---|---|---|
9+| 🎭 Maître du jeu | `agent.gm.yaml` | parle avec vous, choisit UN spécialiste par action, raconte — il n'a pas d'autre outil qu'`ask_agent` |
10+| 🗺️ Conteur | `agent.storyteller.yaml` | crée l'aventure (`game/adventure.md`), déplace le héros, décrit les lieux et les gens |
11+| ⚖️ Arbitre | `agent.referee.yaml` | résout les combats et les actions risquées avec de vrais dés |
12+| 📜 Scribe | `agent.scribe.yaml` | crée la fiche du héros, vous dit où vous en êtes |
13+
14+Les quatre utilisent **Qwen3.5 4B** (`hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`, 3,4 Go) sur **llmman**, chargé une seule fois. La partie se joue dans la langue dans laquelle vous écrivez.
15+
16+## Lancer
17+
18+```bash
19+llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/dungeon
23+mm agent.gm.yaml
24+```
25+
26+Puis :
27+
28+```
29+> Nouvelle partie ! Je suis Mira, une magicienne curieuse.
30+> J'avance vers le lieu suivant.
31+> J'attaque le monstre.
32+> Où en suis-je ? Montre-moi mon inventaire.
33+> J'essaie de grimper sur le rocher pour regarder au loin.
34+```
35+
36+Pour recommencer : `sh new-game.sh` (efface `game/` et les sessions enregistrées), ou demandez une nouvelle partie au Maître du jeu.
37+
38+## Les règles
39+
40+Elles sont dans [`AGENTS.md`](AGENTS.md), que chaque agent lit : quatre classes aux chiffres fixes (Guerrier, Voleur, Magicien, Clerc), des jets `d20 + modificateur` contre une difficulté de 10, 15 ou 20, un round de combat (le héros attaque, le monstre riposte), et le format des trois fichiers de partie.
41+
42+| Fichier | Contenu | Écrit par |
43+|---|---|---|
44+| `game/adventure.md` | le titre, le but, cinq lieux, les monstres, la fin | le conteur, une fois par partie |
45+| `game/hero.md` | PV, CA, attaque, caractéristiques, lieu, or, inventaire, les monstres face au héros | `sheet.sh` et `roll.sh attack` — jamais par un modèle |
46+| `game/journal.md` | une ligne par événement | les scripts (symboles : `→` un déplacement, `⚔` un round de combat, `❤` les PV, `+` pris, `−` utilisé) et l'arbitre pour un jet |
47+
48+## Ce que font les modèles, ce que font les scripts
49+
50+Les modèles **choisissent** : quel spécialiste, quel jet, quel lieu, quoi raconter. Tout ce qu'un modèle de 4B a été mesuré en train de rater est fait par trois scripts :
51+
52+| Script | Fait | Pourquoi (mesuré) |
53+|---|---|---|
54+| `roll.sh` | tous les dés ; `roll.sh check 1d20+2 15` ; `roll.sh attack` joue un round de combat complet à partir de la fiche et y écrit le résultat | chargé de jouer un round étape par étape, l'arbitre n'a jamais fait riposter le monstre (0/3) ; avec la commande unique `attack`, il a d'abord écrit les dégâts sans la lancer (3/3), jusqu'à ce que sa première réponse doive être un appel d'outil |
55+| `sheet.sh` | la fiche et le journal : `new`, `go next`, `here`, `hp`, `take`, `use`, `gold`, `note`, `show`… — `go` lit l'aventure, déplace le héros et inscrit les monstres du nouveau lieu | un scribe qui réécrivait la fiche retirait au héros ses propres coups (4/4) et réduisait le journal à une ligne ; « le lieu suivant » n'était juste que 2 fois sur 4 quand le Maître du jeu le relayait |
56+| `guard.sh` | un [hook](../../docs/fr/reference/hooks.md) `preToolCall` : chaque spécialiste ne peut lancer que les commandes de son rôle | à qui l'on disait « tes seules commandes sont … », le conteur lançait quand même `sed -i` sur la fiche et `edit_file` sur l'aventure |
57+
58+Deux hooks `preGeneration` effacent, au début de chaque demande, les marqueurs qui n'autorisent qu'un déplacement (`sheet.sh go`) et qu'une écriture des dégâts d'un round (`roll.sh attack`) par demande : le processus `mm` d'un agent interrogé sert plusieurs demandes à la suite.
59+
60+## Résultats mesurés
61+
62+Le 2026-10-14, llmman sur l'hôte, Qwen3.5 4B pour les quatre agents.
63+
64+Chaque spécialiste seul, version finale :
65+
66+| Tâche | Résultat |
67+|---|---|
68+| conteur — créer une aventure (5 lieux avec `Here:`, monstres dans les limites) | 3/3 |
69+| conteur — « aller au lieu suivant » (`sheet.sh go next`) | 4/4 |
70+| scribe — créer une fiche de héros | 2/2 |
71+| arbitre — un round de combat (`roll.sh attack`, EFFECTS recopié) | 4/4 |
72+| arbitre — un jet (caractéristique et difficulté choisies, jet recopié) | 2/2 |
73+
74+Une partie entière jouée dans le REPL (`mm agent.gm.yaml`, huit messages : nouvelle partie, lieu suivant ×2, attaque ×2, état, un jet d'escalade, lieu suivant) : **l'état de la partie est juste à chaque étape** — lieux 1 → 2 → 3 → 4 dans l'ordre, le monstre du lieu 3 rencontré, deux rounds de combat comptés une fois chacun, le jet lancé avec le bon modificateur, un journal propre. 488 s en tout, environ une minute par coup.
75+
76+Essayé une fois avec **Ornith 1.5 9B** (`huggingface.co/ornith-ai/ornith-1.5-9b-gguf:Q4_K_M`, une version affinée de Qwen3.5 9B) dans les quatre rôles, mêmes réglages, six messages (2026-10-15) : les scripts et les hooks ont gardé l'état cohérent, mais le Maître du jeu a pris des initiatives que personne n'avait demandées — après avoir créé la partie, il a continué à jouer seul (un jet, un déplacement) jusqu'à sa limite de tours ; à « J'avance vers le lieu suivant », il a fait attaquer l'arbitre trois fois, et l'héroïne est tombée. 303 s pour chacun de ces deux coups. Qwen3.5 4B reste le modèle de cette démo.
77+
78+Essayé avec **Qwen3.5 9B** (`hf.co/unsloth/Qwen3.5-9B-GGUF:Q4_K_M`) dans les quatre rôles, mêmes réglages, onze messages (2026-10-15) : le Maître du jeu a appelé exactement un spécialiste par action à chaque fois (le 4B appelle parfois le scribe en plus), deux rounds de combat exacts, déplacements et nouvelle partie justes, de 26 à 59 s par coup une fois chargé. Mais un jet n'a **pas été lancé** : « J'essaie de grimper sur le rocher » est revenu réussi sans aucun appel à `roll.sh` ; l'autre jet (une serrure, DEX +1 contre DD 15) a bien été lancé. Un message est parti chez le mauvais spécialiste (« J'attaque encore » sans monstre face au héros → conteur), et le récit a deux fois placé l'héroïne dans un lieu où elle n'était pas. Comparable au 4B, pas nettement meilleur, pour deux fois plus de mémoire.
79+
80+Le chemin jusque-là, en bref : cinq parties complètes (A à E) et une quarantaine d'essais d'agents seuls ; chaque correction ci-dessus répond à un échec observé pendant ces essais.
81+
82+## Limites connues
83+
84+- Le Maître du jeu appelle parfois le scribe après le conteur, alors que sa consigne dit un spécialiste par action. C'est sans conséquence désormais (le garde limite le scribe à créer un héros, ajouter une note au journal et afficher l'état : il ne peut ni déplacer le héros ni changer un chiffre), mais ça coûte 20 à 40 s.
85+- Le conteur ne place pas toujours chaque monstre de `## Monsters` dans le `Here:` d'un lieu ; un monstre nommé autrement à cet endroit n'est pas rencontré par `go`.
86+- Les dégâts des monstres dépassent parfois les limites d'`AGENTS.md` (`2d6+3` a été vu) : la partie est alors plus dure que prévu.
87+- Le récit du Maître du jeu est écrit à partir des réponses des spécialistes ; il peut broder (une deuxième créature tirée de la description du lieu) — les chiffres qu'il cite étaient justes à chaque coup mesuré, sauf une fois un dé cité sans son bonus.
88+- Avec `mm -p`, il n'y a pas d'historique de conversation : un « J'attaque encore » seul a reçu une fois une demande de contexte.
89+- Charger le modèle avec son contexte complet de 262k (le défaut de llmman sans `LLMMAN_CONTEXT_LENGTH`) marche pour cette démo seule, mais manque de mémoire dès que d'autres modèles sont chargés à côté.
new file mode 100644
@@ -0,0 +1,89 @@
1+# Donjon — un jeu de rôle solo mené par une équipe de quatre petits agents
2+
3+*[English version](README.en.md)*
4+
5+Un jeu de rôle à la Donjons & Dragons, simplifié, pour un joueur. Même principe que [`demos/team`](../team/) : vous parlez à un coordinateur, le **Maître du jeu**, qui confie le travail à des spécialistes avec l'outil intégré `ask_agent`, et l'état de la partie vit dans des fichiers.
6+
7+| Agent | Fichier | Rôle |
8+|---|---|---|
9+| 🎭 Maître du jeu | `agent.gm.yaml` | parle avec vous, choisit UN spécialiste par action, raconte — il n'a pas d'autre outil qu'`ask_agent` |
10+| 🗺️ Conteur | `agent.storyteller.yaml` | crée l'aventure (`game/adventure.md`), déplace le héros, décrit les lieux et les gens |
11+| ⚖️ Arbitre | `agent.referee.yaml` | résout les combats et les actions risquées avec de vrais dés |
12+| 📜 Scribe | `agent.scribe.yaml` | crée la fiche du héros, vous dit où vous en êtes |
13+
14+Les quatre utilisent **Qwen3.5 4B** (`hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`, 3,4 Go) sur **llmman**, chargé une seule fois. La partie se joue dans la langue dans laquelle vous écrivez.
15+
16+## Lancer
17+
18+```bash
19+llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/dungeon
23+mm agent.gm.yaml
24+```
25+
26+Puis :
27+
28+```
29+> Nouvelle partie ! Je suis Mira, une magicienne curieuse.
30+> J'avance vers le lieu suivant.
31+> J'attaque le monstre.
32+> Où en suis-je ? Montre-moi mon inventaire.
33+> J'essaie de grimper sur le rocher pour regarder au loin.
34+```
35+
36+Pour recommencer : `sh new-game.sh` (efface `game/` et les sessions enregistrées), ou demandez une nouvelle partie au Maître du jeu.
37+
38+## Les règles
39+
40+Elles sont dans [`AGENTS.md`](AGENTS.md), que chaque agent lit : quatre classes aux chiffres fixes (Guerrier, Voleur, Magicien, Clerc), des jets `d20 + modificateur` contre une difficulté de 10, 15 ou 20, un round de combat (le héros attaque, le monstre riposte), et le format des trois fichiers de partie.
41+
42+| Fichier | Contenu | Écrit par |
43+|---|---|---|
44+| `game/adventure.md` | le titre, le but, cinq lieux, les monstres, la fin | le conteur, une fois par partie |
45+| `game/hero.md` | PV, CA, attaque, caractéristiques, lieu, or, inventaire, les monstres face au héros | `sheet.sh` et `roll.sh attack` — jamais par un modèle |
46+| `game/journal.md` | une ligne par événement | les scripts (symboles : `→` un déplacement, `⚔` un round de combat, `❤` les PV, `+` pris, `−` utilisé) et l'arbitre pour un jet |
47+
48+## Ce que font les modèles, ce que font les scripts
49+
50+Les modèles **choisissent** : quel spécialiste, quel jet, quel lieu, quoi raconter. Tout ce qu'un modèle de 4B a été mesuré en train de rater est fait par trois scripts :
51+
52+| Script | Fait | Pourquoi (mesuré) |
53+|---|---|---|
54+| `roll.sh` | tous les dés ; `roll.sh check 1d20+2 15` ; `roll.sh attack` joue un round de combat complet à partir de la fiche et y écrit le résultat | chargé de jouer un round étape par étape, l'arbitre n'a jamais fait riposter le monstre (0/3) ; avec la commande unique `attack`, il a d'abord écrit les dégâts sans la lancer (3/3), jusqu'à ce que sa première réponse doive être un appel d'outil |
55+| `sheet.sh` | la fiche et le journal : `new`, `go next`, `here`, `hp`, `take`, `use`, `gold`, `note`, `show`… — `go` lit l'aventure, déplace le héros et inscrit les monstres du nouveau lieu | un scribe qui réécrivait la fiche retirait au héros ses propres coups (4/4) et réduisait le journal à une ligne ; « le lieu suivant » n'était juste que 2 fois sur 4 quand le Maître du jeu le relayait |
56+| `guard.sh` | un [hook](../../docs/fr/reference/hooks.md) `preToolCall` : chaque spécialiste ne peut lancer que les commandes de son rôle | à qui l'on disait « tes seules commandes sont … », le conteur lançait quand même `sed -i` sur la fiche et `edit_file` sur l'aventure |
57+
58+Deux hooks `preGeneration` effacent, au début de chaque demande, les marqueurs qui n'autorisent qu'un déplacement (`sheet.sh go`) et qu'une écriture des dégâts d'un round (`roll.sh attack`) par demande : le processus `mm` d'un agent interrogé sert plusieurs demandes à la suite.
59+
60+## Résultats mesurés
61+
62+Le 2026-10-14, llmman sur l'hôte, Qwen3.5 4B pour les quatre agents.
63+
64+Chaque spécialiste seul, version finale :
65+
66+| Tâche | Résultat |
67+|---|---|
68+| conteur — créer une aventure (5 lieux avec `Here:`, monstres dans les limites) | 3/3 |
69+| conteur — « aller au lieu suivant » (`sheet.sh go next`) | 4/4 |
70+| scribe — créer une fiche de héros | 2/2 |
71+| arbitre — un round de combat (`roll.sh attack`, EFFECTS recopié) | 4/4 |
72+| arbitre — un jet (caractéristique et difficulté choisies, jet recopié) | 2/2 |
73+
74+Une partie entière jouée dans le REPL (`mm agent.gm.yaml`, huit messages : nouvelle partie, lieu suivant ×2, attaque ×2, état, un jet d'escalade, lieu suivant) : **l'état de la partie est juste à chaque étape** — lieux 1 → 2 → 3 → 4 dans l'ordre, le monstre du lieu 3 rencontré, deux rounds de combat comptés une fois chacun, le jet lancé avec le bon modificateur, un journal propre. 488 s en tout, environ une minute par coup.
75+
76+Essayé une fois avec **Ornith 1.5 9B** (`huggingface.co/ornith-ai/ornith-1.5-9b-gguf:Q4_K_M`, une version affinée de Qwen3.5 9B) dans les quatre rôles, mêmes réglages, six messages (2026-10-15) : les scripts et les hooks ont gardé l'état cohérent, mais le Maître du jeu a pris des initiatives que personne n'avait demandées — après avoir créé la partie, il a continué à jouer seul (un jet, un déplacement) jusqu'à sa limite de tours ; à « J'avance vers le lieu suivant », il a fait attaquer l'arbitre trois fois, et l'héroïne est tombée. 303 s pour chacun de ces deux coups. Qwen3.5 4B reste le modèle de cette démo.
77+
78+Essayé avec **Qwen3.5 9B** (`hf.co/unsloth/Qwen3.5-9B-GGUF:Q4_K_M`) dans les quatre rôles, mêmes réglages, onze messages (2026-10-15) : le Maître du jeu a appelé exactement un spécialiste par action à chaque fois (le 4B appelle parfois le scribe en plus), deux rounds de combat exacts, déplacements et nouvelle partie justes, de 26 à 59 s par coup une fois chargé. Mais un jet n'a **pas été lancé** : « J'essaie de grimper sur le rocher » est revenu réussi sans aucun appel à `roll.sh` ; l'autre jet (une serrure, DEX +1 contre DD 15) a bien été lancé. Un message est parti chez le mauvais spécialiste (« J'attaque encore » sans monstre face au héros → conteur), et le récit a deux fois placé l'héroïne dans un lieu où elle n'était pas. Comparable au 4B, pas nettement meilleur, pour deux fois plus de mémoire.
79+
80+Le chemin jusque-là, en bref : cinq parties complètes (A à E) et une quarantaine d'essais d'agents seuls ; chaque correction ci-dessus répond à un échec observé pendant ces essais.
81+
82+## Limites connues
83+
84+- Le Maître du jeu appelle parfois le scribe après le conteur, alors que sa consigne dit un spécialiste par action. C'est sans conséquence désormais (le garde limite le scribe à créer un héros, ajouter une note au journal et afficher l'état : il ne peut ni déplacer le héros ni changer un chiffre), mais ça coûte 20 à 40 s.
85+- Le conteur ne place pas toujours chaque monstre de `## Monsters` dans le `Here:` d'un lieu ; un monstre nommé autrement à cet endroit n'est pas rencontré par `go`.
86+- Les dégâts des monstres dépassent parfois les limites d'`AGENTS.md` (`2d6+3` a été vu) : la partie est alors plus dure que prévu.
87+- Le récit du Maître du jeu est écrit à partir des réponses des spécialistes ; il peut broder (une deuxième créature tirée de la description du lieu) — les chiffres qu'il cite étaient justes à chaque coup mesuré, sauf une fois un dé cité sans son bonus.
88+- Avec `mm -p`, il n'y a pas d'historique de conversation : un « J'attaque encore » seul a reçu une fois une demande de contexte.
89+- Charger le modèle avec son contexte complet de 262k (le défaut de llmman sans `LLMMAN_CONTEXT_LENGTH`) marche pour cette démo seule, mais manque de mémoire dès que d'autres modèles sont chargés à côté.
added demos/dungeon/agent.gm.yaml +200 -0
new file mode 100644
@@ -0,0 +1,200 @@
1+# Game Master — the coordinator of a small team that runs a solo
2+# role-playing game in the style of Dungeons & Dragons. You talk with it;
3+# it hands the work to three specialists with the built-in `ask_agent` tool:
4+#
5+# storyteller — creates the adventure (game/adventure.md), describes places and NPCs
6+# referee — applies the rules and rolls the dice with roll.sh
7+# scribe — creates the hero sheet (game/hero.md), reports the hero's state
8+#
9+# Two scripts hold everything a 4B model gets wrong: roll.sh (dice, a whole
10+# combat round) and sheet.sh (the hero sheet and the journal). The models
11+# choose what to do; the scripts do the numbers.
12+#
13+# All four run Qwen3.5 4B (Q4_K_M, 3.4 GB), loaded once. The rules and the
14+# file formats are in AGENTS.md, which every member reads.
15+#
16+# The hand-off is FILE-based, like demos/team: the game state is in game/,
17+# so a game survives a restart and you can read (or cheat in) every file.
18+#
19+# llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+# LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+#
22+# Run it FROM THIS DIRECTORY (game/ lands here):
23+#
24+# cd demos/dungeon
25+# mm agent.gm.yaml
26+
27+# ─── Model and server ────────────────────────────────────────────────────────
28+
29+provider: llmman
30+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
31+baseUrl: http://127.0.0.1:17434/v1
32+fallback: http://host.docker.internal:17434/v1
33+contextWindow: 32768
34+
35+# The team. `description` is what the GM reads to choose; `yolo: true` = that
36+# agent's own commands and file writes run without asking.
37+mmConfigs:
38+ storyteller:
39+ path: agent.storyteller.yaml
40+ description: >-
41+ Storyteller. Creates a new adventure in game/adventure.md, and
42+ describes what the hero finds when moving, looking around, searching
43+ or talking to someone, from that file (and records the move, the
44+ monsters met and the loot in the hero sheet).
45+ yolo: true
46+ referee:
47+ path: agent.referee.yaml
48+ description: >-
49+ Referee. Resolves a risky action or a fight with the rules and real
50+ dice (roll.sh): checks, attacks, damage. Reports the rolls and their
51+ effects; a combat round is written into the hero sheet by roll.sh.
52+ yolo: true
53+ scribe:
54+ path: agent.scribe.yaml
55+ description: >-
56+ Scribe. Creates the hero sheet game/hero.md for a new game, and
57+ reports the hero's state (HP, place, inventory, encounter, the last
58+ lines of the journal) when the player asks.
59+ yolo: true
60+
61+# ─── Generation ──────────────────────────────────────────────────────────────
62+
63+sampling:
64+ # Qwen3.5's recommendation without thinking: 0.7 / 0.8 / 20.
65+ temperature: 0.7
66+ top_p: 0.8
67+ top_k: 20
68+ max_tokens: 4096
69+ # One specialist at a time: the scribe writes what the referee rolled.
70+ parallel_tool_calls: false
71+ # Routing and telling need no deliberation. Off is also the template's
72+ # default; written here so the choice is visible.
73+ chat_template_kwargs:
74+ enable_thinking: false
75+
76+# ─── Timeouts ────────────────────────────────────────────────────────────────
77+
78+# During ask_agent every event of the asked agent counts as a sign of life.
79+watchdogTimeout: 3m
80+firstTokenTimeout: 2m
81+
82+# ─── Agent loop ──────────────────────────────────────────────────────────────
83+
84+# A new game: storyteller + scribe + the opening = 3 model calls. An action:
85+# one specialist + the telling = 2. 4 leaves room for one retried call —
86+# measured with 6: a GM called the referee six times in a row when there was
87+# nothing to attack (287 s for one move).
88+maxTurns: 4
89+maxOutput: 12000
90+previewLines: 6
91+displayCommands: true
92+
93+# ─── Tools ───────────────────────────────────────────────────────────────────
94+
95+yolo: true
96+
97+# No tool but ask_agent: the GM tells the story, it never rolls dice nor
98+# writes the game files — which is what keeps the numbers honest.
99+bashTool: false
100+editTools: false
101+planTool: false
102+fetchContentTool: false
103+askUserTool: false
104+skillsDir: no-skills-for-the-gm
105+
106+agentsMd:
107+ enabled: true
108+ path: AGENTS.md
109+ maxChars: 8000
110+
111+# ─── Memory ──────────────────────────────────────────────────────────────────
112+
113+# A game is long: keep the session (/sessions, /resume) and compress old turns.
114+# The state that matters is in game/ anyway.
115+sessions:
116+ enabled: true
117+ dir: .mm/sessions
118+
119+context:
120+ enabled: true
121+ threshold: 60
122+ maxMessages: 60
123+ keepLastTurns: 3
124+ summaryMaxTokens: 1200
125+ showStats: false
126+
127+# ─── Identity ────────────────────────────────────────────────────────────────
128+
129+acp:
130+ name: gm
131+ title: 🎭 Game Master
132+
133+system: |
134+ You are the Game Master of a solo role-playing game in the style of
135+ Dungeons & Dragons. The player plays ONE hero. You tell the story; three
136+ specialists do the rest through the ask_agent tool: storyteller, referee,
137+ scribe. The rules are in AGENTS.md.
138+
139+ LANGUAGE: answer in the language of the player's last message. Start every
140+ request to a specialist with "Language: <that language>."
141+
142+ A game is in progress unless the player EXPLICITLY asks for a new one
143+ ("new game", "nouvelle partie", "start a game"). Any other message is an
144+ action in the current game — even a short one ("again", "encore",
145+ "I go on") and even if you do not remember the game: its state is in the
146+ game files, the specialists read them. A new game erases the current one.
147+
148+ NEW GAME (the player explicitly asks for a new game):
149+ 1. ask_agent storyteller: "Language: <L>. Create a new adventure in
150+ game/adventure.md. Player's wishes: <what the player said about the
151+ story, or 'none'>."
152+ 2. ask_agent scribe: "Language: <L>. Create the hero sheet game/hero.md.
153+ Hero: <name, class, what the player said>. Starting place: 1. <the
154+ first place of the adventure>."
155+ The class is Warrior, Rogue, Wizard or Cleric. If the player gave
156+ none, pick the one closest to their words.
157+ 3. Tell the opening scene from the storyteller's answer, give the hero's
158+ HP, and end with "What do you do?" (in the player's language).
159+
160+ EACH ACTION OF THE PLAYER — ONE specialist, then you tell:
161+ 1. Send the player's message COPIED WORD FOR WORD — add nothing, decide
162+ nothing (no place number, no monster name): the specialist reads the
163+ game files, works it out, and records the result itself.
164+ - a fight, or a risky action (attack, cast a spell, sneak, climb,
165+ persuade, lie, pick a lock, flee, drink a potion)
166+ → ask_agent referee, prompt: "Language: <L>. Resolve: <the player's
167+ message>"
168+ - the hero moves, looks around, searches, talks to someone, takes
169+ something
170+ → ask_agent storyteller, prompt: "Language: <L>. Describe: <the
171+ player's message>"
172+ - the player asks about the hero (HP, inventory, where am I, the
173+ journal)
174+ → ask_agent scribe, prompt: "Language: <L>. Status."
175+ 2. Once the specialist has answered, your next reply is the story — never
176+ another ask_agent call. Tell the player what happened in 3 to 6
177+ sentences, in the second person, with the numbers that matter (the
178+ rolls, the HP). End with "What do you do?" (in the player's language).
179+
180+ RULES
181+ - Call ask_agent with two arguments only, agent and prompt. Never
182+ with_context: the specialists read the game files, not this
183+ conversation.
184+ - Never roll dice, never invent a roll, never change HP, gold or the
185+ inventory yourself: the specialists and their scripts do.
186+ - Call the ask_agent TOOL; never write the call as text
187+ ("[ask_agent referee] …" in your answer calls nobody).
188+ - ONE ask_agent call per action, then tell the player — even when the
189+ answer is a failure or "no monster": tell that.
190+ - Never ask the player who their hero is or where they are: the
191+ specialists read the game files. Act on the message.
192+ - Never contradict the specialists: if the referee says FAILURE, the
193+ action failed.
194+ - If a result starts with "[ask_agent:", tell the player that line and
195+ stop.
196+ - If an answer says THE HERO HAS FALLEN or HP 0: the hero falls,
197+ the game is over — tell it as an ending. If the hero reaches the goal of
198+ the adventure: tell the ending.
199+ - If the player asks about the rules, answer from AGENTS.md without
200+ calling anyone.
new file mode 100644
@@ -0,0 +1,200 @@
1+# Game Master — the coordinator of a small team that runs a solo
2+# role-playing game in the style of Dungeons & Dragons. You talk with it;
3+# it hands the work to three specialists with the built-in `ask_agent` tool:
4+#
5+# storyteller — creates the adventure (game/adventure.md), describes places and NPCs
6+# referee — applies the rules and rolls the dice with roll.sh
7+# scribe — creates the hero sheet (game/hero.md), reports the hero's state
8+#
9+# Two scripts hold everything a 4B model gets wrong: roll.sh (dice, a whole
10+# combat round) and sheet.sh (the hero sheet and the journal). The models
11+# choose what to do; the scripts do the numbers.
12+#
13+# All four run Qwen3.5 4B (Q4_K_M, 3.4 GB), loaded once. The rules and the
14+# file formats are in AGENTS.md, which every member reads.
15+#
16+# The hand-off is FILE-based, like demos/team: the game state is in game/,
17+# so a game survives a restart and you can read (or cheat in) every file.
18+#
19+# llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
20+# LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+#
22+# Run it FROM THIS DIRECTORY (game/ lands here):
23+#
24+# cd demos/dungeon
25+# mm agent.gm.yaml
26+
27+# ─── Model and server ────────────────────────────────────────────────────────
28+
29+provider: llmman
30+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
31+baseUrl: http://127.0.0.1:17434/v1
32+fallback: http://host.docker.internal:17434/v1
33+contextWindow: 32768
34+
35+# The team. `description` is what the GM reads to choose; `yolo: true` = that
36+# agent's own commands and file writes run without asking.
37+mmConfigs:
38+ storyteller:
39+ path: agent.storyteller.yaml
40+ description: >-
41+ Storyteller. Creates a new adventure in game/adventure.md, and
42+ describes what the hero finds when moving, looking around, searching
43+ or talking to someone, from that file (and records the move, the
44+ monsters met and the loot in the hero sheet).
45+ yolo: true
46+ referee:
47+ path: agent.referee.yaml
48+ description: >-
49+ Referee. Resolves a risky action or a fight with the rules and real
50+ dice (roll.sh): checks, attacks, damage. Reports the rolls and their
51+ effects; a combat round is written into the hero sheet by roll.sh.
52+ yolo: true
53+ scribe:
54+ path: agent.scribe.yaml
55+ description: >-
56+ Scribe. Creates the hero sheet game/hero.md for a new game, and
57+ reports the hero's state (HP, place, inventory, encounter, the last
58+ lines of the journal) when the player asks.
59+ yolo: true
60+
61+# ─── Generation ──────────────────────────────────────────────────────────────
62+
63+sampling:
64+ # Qwen3.5's recommendation without thinking: 0.7 / 0.8 / 20.
65+ temperature: 0.7
66+ top_p: 0.8
67+ top_k: 20
68+ max_tokens: 4096
69+ # One specialist at a time: the scribe writes what the referee rolled.
70+ parallel_tool_calls: false
71+ # Routing and telling need no deliberation. Off is also the template's
72+ # default; written here so the choice is visible.
73+ chat_template_kwargs:
74+ enable_thinking: false
75+
76+# ─── Timeouts ────────────────────────────────────────────────────────────────
77+
78+# During ask_agent every event of the asked agent counts as a sign of life.
79+watchdogTimeout: 3m
80+firstTokenTimeout: 2m
81+
82+# ─── Agent loop ──────────────────────────────────────────────────────────────
83+
84+# A new game: storyteller + scribe + the opening = 3 model calls. An action:
85+# one specialist + the telling = 2. 4 leaves room for one retried call —
86+# measured with 6: a GM called the referee six times in a row when there was
87+# nothing to attack (287 s for one move).
88+maxTurns: 4
89+maxOutput: 12000
90+previewLines: 6
91+displayCommands: true
92+
93+# ─── Tools ───────────────────────────────────────────────────────────────────
94+
95+yolo: true
96+
97+# No tool but ask_agent: the GM tells the story, it never rolls dice nor
98+# writes the game files — which is what keeps the numbers honest.
99+bashTool: false
100+editTools: false
101+planTool: false
102+fetchContentTool: false
103+askUserTool: false
104+skillsDir: no-skills-for-the-gm
105+
106+agentsMd:
107+ enabled: true
108+ path: AGENTS.md
109+ maxChars: 8000
110+
111+# ─── Memory ──────────────────────────────────────────────────────────────────
112+
113+# A game is long: keep the session (/sessions, /resume) and compress old turns.
114+# The state that matters is in game/ anyway.
115+sessions:
116+ enabled: true
117+ dir: .mm/sessions
118+
119+context:
120+ enabled: true
121+ threshold: 60
122+ maxMessages: 60
123+ keepLastTurns: 3
124+ summaryMaxTokens: 1200
125+ showStats: false
126+
127+# ─── Identity ────────────────────────────────────────────────────────────────
128+
129+acp:
130+ name: gm
131+ title: 🎭 Game Master
132+
133+system: |
134+ You are the Game Master of a solo role-playing game in the style of
135+ Dungeons & Dragons. The player plays ONE hero. You tell the story; three
136+ specialists do the rest through the ask_agent tool: storyteller, referee,
137+ scribe. The rules are in AGENTS.md.
138+
139+ LANGUAGE: answer in the language of the player's last message. Start every
140+ request to a specialist with "Language: <that language>."
141+
142+ A game is in progress unless the player EXPLICITLY asks for a new one
143+ ("new game", "nouvelle partie", "start a game"). Any other message is an
144+ action in the current game — even a short one ("again", "encore",
145+ "I go on") and even if you do not remember the game: its state is in the
146+ game files, the specialists read them. A new game erases the current one.
147+
148+ NEW GAME (the player explicitly asks for a new game):
149+ 1. ask_agent storyteller: "Language: <L>. Create a new adventure in
150+ game/adventure.md. Player's wishes: <what the player said about the
151+ story, or 'none'>."
152+ 2. ask_agent scribe: "Language: <L>. Create the hero sheet game/hero.md.
153+ Hero: <name, class, what the player said>. Starting place: 1. <the
154+ first place of the adventure>."
155+ The class is Warrior, Rogue, Wizard or Cleric. If the player gave
156+ none, pick the one closest to their words.
157+ 3. Tell the opening scene from the storyteller's answer, give the hero's
158+ HP, and end with "What do you do?" (in the player's language).
159+
160+ EACH ACTION OF THE PLAYER — ONE specialist, then you tell:
161+ 1. Send the player's message COPIED WORD FOR WORD — add nothing, decide
162+ nothing (no place number, no monster name): the specialist reads the
163+ game files, works it out, and records the result itself.
164+ - a fight, or a risky action (attack, cast a spell, sneak, climb,
165+ persuade, lie, pick a lock, flee, drink a potion)
166+ → ask_agent referee, prompt: "Language: <L>. Resolve: <the player's
167+ message>"
168+ - the hero moves, looks around, searches, talks to someone, takes
169+ something
170+ → ask_agent storyteller, prompt: "Language: <L>. Describe: <the
171+ player's message>"
172+ - the player asks about the hero (HP, inventory, where am I, the
173+ journal)
174+ → ask_agent scribe, prompt: "Language: <L>. Status."
175+ 2. Once the specialist has answered, your next reply is the story — never
176+ another ask_agent call. Tell the player what happened in 3 to 6
177+ sentences, in the second person, with the numbers that matter (the
178+ rolls, the HP). End with "What do you do?" (in the player's language).
179+
180+ RULES
181+ - Call ask_agent with two arguments only, agent and prompt. Never
182+ with_context: the specialists read the game files, not this
183+ conversation.
184+ - Never roll dice, never invent a roll, never change HP, gold or the
185+ inventory yourself: the specialists and their scripts do.
186+ - Call the ask_agent TOOL; never write the call as text
187+ ("[ask_agent referee] …" in your answer calls nobody).
188+ - ONE ask_agent call per action, then tell the player — even when the
189+ answer is a failure or "no monster": tell that.
190+ - Never ask the player who their hero is or where they are: the
191+ specialists read the game files. Act on the message.
192+ - Never contradict the specialists: if the referee says FAILURE, the
193+ action failed.
194+ - If a result starts with "[ask_agent:", tell the player that line and
195+ stop.
196+ - If an answer says THE HERO HAS FALLEN or HP 0: the hero falls,
197+ the game is over — tell it as an ending. If the hero reaches the goal of
198+ the adventure: tell the ending.
199+ - If the player asks about the rules, answer from AGENTS.md without
200+ calling anyone.
added demos/dungeon/agent.referee.yaml +115 -0
new file mode 100644
@@ -0,0 +1,115 @@
1+# Referee — asked by the Game Master through `ask_agent` (or by you with
2+# /ask-referee). Reads the hero sheet, picks the check, rolls the dice with
3+# roll.sh, records the result with sheet.sh, reports. `sh roll.sh attack`
4+# writes a combat round into game/hero.md by itself.
5+
6+provider: llmman
7+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
8+baseUrl: http://127.0.0.1:17434/v1
9+fallback: http://host.docker.internal:17434/v1
10+contextWindow: 32768
11+
12+sampling:
13+ # A referee applies rules: cold.
14+ temperature: 0.2
15+ top_p: 0.8
16+ top_k: 20
17+ max_tokens: 4096
18+ # One roll at a time: the damage depends on the attack.
19+ parallel_tool_calls: false
20+ chat_template_kwargs:
21+ enable_thinking: false
22+
23+firstTokenTimeout: 2m
24+watchdogTimeout: 2m
25+bashTimeout: 30s
26+
27+# cat the sheet, attack, damage, monster attack, monster damage, answer.
28+maxTurns: 8
29+maxOutput: 12000
30+previewLines: 6
31+displayCommands: true
32+
33+yolo: false
34+
35+# bash only: `cat game/…`, `sh roll.sh …` and `sh sheet.sh …`. No file
36+# tools: the scripts write, the model never edits a file.
37+bashTool: true
38+editTools: false
39+planTool: false
40+fetchContentTool: false
41+askUserTool: false
42+skillsDir: no-skills-for-the-referee
43+
44+agentsMd:
45+ enabled: true
46+ path: AGENTS.md
47+ maxChars: 8000
48+
49+sessions:
50+ enabled: false
51+
52+# Hooks (see guard.sh): the referee's commands, enforced.
53+hooks:
54+ # A combat round's damage is written once: roll.sh attack's marker is
55+ # erased when a request starts.
56+ preGeneration:
57+ - command: rm -f game/.attacked-by
58+ onError: ignore
59+ preToolCall:
60+ - matcher: ^bash$
61+ command: sh "$MM_CONFIG_DIR/guard.sh" bash 'cat game/(hero|adventure)\.md|sh roll\.sh|sh sheet\.sh (hp|use|monster|note)' 'cat game/hero.md | sh roll.sh … | sh sheet.sh hp / use / monster / note'
62+ onError: fail
63+
64+acp:
65+ name: referee
66+ title: ⚖️ Referee
67+
68+system: |
69+ You are the Referee of a solo role-playing game in the style of
70+ Dungeons & Dragons. The Game Master asks you to resolve an action; your
71+ answer goes back to it. The rules are in AGENTS.md. Write in the language
72+ the request names.
73+
74+ "Resolve: <action>": your FIRST reply is a bash tool call — never text.
75+ Until roll.sh has answered you know no number: no hit, no damage.
76+ Pick ONE case:
77+ A. The hero attacks a monster (weapon, fire bolt, any attack):
78+ call bash with the command sh roll.sh attack "<monster name>"
79+ It plays the whole round — the hero's attack, the damage, the
80+ monster's answer — WRITES the result into game/hero.md, and prints the
81+ EFFECTS line. Call it ONCE, then answer: no other command.
82+ B. Magic missile, a healing spell, a potion: call bash with
83+ sh roll.sh <the dice of AGENTS.md> once, then record it:
84+ a heal → sh sheet.sh hp +<the total>; a potion also →
85+ sh sheet.sh use "healing potion"; magic missile on a monster →
86+ sh sheet.sh monster "<name>" -<the total>.
87+ C. Any other risky action: call bash with cat game/hero.md to read the
88+ modifiers, then call bash with ONE check:
89+ sh roll.sh check 1d20+<the ability's modifier> <DC>
90+ (easy 10, medium 15, hard 20; abilities in AGENTS.md), then
91+ sh sheet.sh note "<the action>: <SUCCESS or FAILURE> (<the total> vs
92+ <the DC>)" in the request's language. (Only for a check: an attack
93+ writes its own journal line.)
94+ A trap the hero fails to avoid hurts: sh roll.sh 1d6, then
95+ sh sheet.sh hp -<the total>.
96+
97+ Answer in this format, and nothing else:
98+ ACTION: <the action, one line>
99+ ROLLS: <the lines roll.sh PRINTED (its output, not the command)>
100+ RESULT: SUCCESS or FAILURE
101+ EFFECTS: <for an attack, the EFFECTS line of roll.sh copied whole;
102+ otherwise the sh sheet.sh commands you ran, or "none">
103+ STORY: <one or two sentences saying what happened>
104+
105+ RULES
106+ - Never invent a roll and never compute a number yourself: copy what
107+ roll.sh prints.
108+ - Only these commands exist for you: cat game/<file>, sh roll.sh … and
109+ sh sheet.sh … (to record what the dice decided).
110+ - The monster's name comes from the ## Encounter section of
111+ game/hero.md. If that section says "none", or roll.sh answers "no
112+ monster", there is nothing to attack: answer at once with RESULT:
113+ FAILURE, EFFECTS: none, STORY: there is no monster to attack here.
114+ Never try other names.
115+ - If roll.sh prints another error (the hero has fallen), report it as is.
new file mode 100644
@@ -0,0 +1,115 @@
1+# Referee — asked by the Game Master through `ask_agent` (or by you with
2+# /ask-referee). Reads the hero sheet, picks the check, rolls the dice with
3+# roll.sh, records the result with sheet.sh, reports. `sh roll.sh attack`
4+# writes a combat round into game/hero.md by itself.
5+
6+provider: llmman
7+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
8+baseUrl: http://127.0.0.1:17434/v1
9+fallback: http://host.docker.internal:17434/v1
10+contextWindow: 32768
11+
12+sampling:
13+ # A referee applies rules: cold.
14+ temperature: 0.2
15+ top_p: 0.8
16+ top_k: 20
17+ max_tokens: 4096
18+ # One roll at a time: the damage depends on the attack.
19+ parallel_tool_calls: false
20+ chat_template_kwargs:
21+ enable_thinking: false
22+
23+firstTokenTimeout: 2m
24+watchdogTimeout: 2m
25+bashTimeout: 30s
26+
27+# cat the sheet, attack, damage, monster attack, monster damage, answer.
28+maxTurns: 8
29+maxOutput: 12000
30+previewLines: 6
31+displayCommands: true
32+
33+yolo: false
34+
35+# bash only: `cat game/…`, `sh roll.sh …` and `sh sheet.sh …`. No file
36+# tools: the scripts write, the model never edits a file.
37+bashTool: true
38+editTools: false
39+planTool: false
40+fetchContentTool: false
41+askUserTool: false
42+skillsDir: no-skills-for-the-referee
43+
44+agentsMd:
45+ enabled: true
46+ path: AGENTS.md
47+ maxChars: 8000
48+
49+sessions:
50+ enabled: false
51+
52+# Hooks (see guard.sh): the referee's commands, enforced.
53+hooks:
54+ # A combat round's damage is written once: roll.sh attack's marker is
55+ # erased when a request starts.
56+ preGeneration:
57+ - command: rm -f game/.attacked-by
58+ onError: ignore
59+ preToolCall:
60+ - matcher: ^bash$
61+ command: sh "$MM_CONFIG_DIR/guard.sh" bash 'cat game/(hero|adventure)\.md|sh roll\.sh|sh sheet\.sh (hp|use|monster|note)' 'cat game/hero.md | sh roll.sh … | sh sheet.sh hp / use / monster / note'
62+ onError: fail
63+
64+acp:
65+ name: referee
66+ title: ⚖️ Referee
67+
68+system: |
69+ You are the Referee of a solo role-playing game in the style of
70+ Dungeons & Dragons. The Game Master asks you to resolve an action; your
71+ answer goes back to it. The rules are in AGENTS.md. Write in the language
72+ the request names.
73+
74+ "Resolve: <action>": your FIRST reply is a bash tool call — never text.
75+ Until roll.sh has answered you know no number: no hit, no damage.
76+ Pick ONE case:
77+ A. The hero attacks a monster (weapon, fire bolt, any attack):
78+ call bash with the command sh roll.sh attack "<monster name>"
79+ It plays the whole round — the hero's attack, the damage, the
80+ monster's answer — WRITES the result into game/hero.md, and prints the
81+ EFFECTS line. Call it ONCE, then answer: no other command.
82+ B. Magic missile, a healing spell, a potion: call bash with
83+ sh roll.sh <the dice of AGENTS.md> once, then record it:
84+ a heal → sh sheet.sh hp +<the total>; a potion also →
85+ sh sheet.sh use "healing potion"; magic missile on a monster →
86+ sh sheet.sh monster "<name>" -<the total>.
87+ C. Any other risky action: call bash with cat game/hero.md to read the
88+ modifiers, then call bash with ONE check:
89+ sh roll.sh check 1d20+<the ability's modifier> <DC>
90+ (easy 10, medium 15, hard 20; abilities in AGENTS.md), then
91+ sh sheet.sh note "<the action>: <SUCCESS or FAILURE> (<the total> vs
92+ <the DC>)" in the request's language. (Only for a check: an attack
93+ writes its own journal line.)
94+ A trap the hero fails to avoid hurts: sh roll.sh 1d6, then
95+ sh sheet.sh hp -<the total>.
96+
97+ Answer in this format, and nothing else:
98+ ACTION: <the action, one line>
99+ ROLLS: <the lines roll.sh PRINTED (its output, not the command)>
100+ RESULT: SUCCESS or FAILURE
101+ EFFECTS: <for an attack, the EFFECTS line of roll.sh copied whole;
102+ otherwise the sh sheet.sh commands you ran, or "none">
103+ STORY: <one or two sentences saying what happened>
104+
105+ RULES
106+ - Never invent a roll and never compute a number yourself: copy what
107+ roll.sh prints.
108+ - Only these commands exist for you: cat game/<file>, sh roll.sh … and
109+ sh sheet.sh … (to record what the dice decided).
110+ - The monster's name comes from the ## Encounter section of
111+ game/hero.md. If that section says "none", or roll.sh answers "no
112+ monster", there is nothing to attack: answer at once with RESULT:
113+ FAILURE, EFFECTS: none, STORY: there is no monster to attack here.
114+ Never try other names.
115+ - If roll.sh prints another error (the hero has fallen), report it as is.
added demos/dungeon/agent.scribe.yaml +93 -0
new file mode 100644
@@ -0,0 +1,93 @@
1+# Scribe — asked by the Game Master through `ask_agent` (or by you with
2+# /ask-scribe). Creates the hero sheet and reports the hero's state, through
3+# sheet.sh. The numbers of a turn are recorded where they are decided — by
4+# roll.sh (combat), the referee, the storyteller — and the scripts write the
5+# journal: measured, a GM that called the scribe after every action called
6+# it two or three times in 2 runs out of 4, each call adding notes.
7+
8+provider: llmman
9+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
10+baseUrl: http://127.0.0.1:17434/v1
11+fallback: http://host.docker.internal:17434/v1
12+contextWindow: 32768
13+
14+sampling:
15+ # Bookkeeping: cold.
16+ temperature: 0.2
17+ top_p: 0.8
18+ top_k: 20
19+ max_tokens: 4096
20+ parallel_tool_calls: false
21+ chat_template_kwargs:
22+ enable_thinking: false
23+
24+firstTokenTimeout: 2m
25+watchdogTimeout: 2m
26+
27+# new + note, or show + journal, then answer.
28+maxTurns: 5
29+maxOutput: 12000
30+previewLines: 6
31+displayCommands: true
32+
33+yolo: false
34+
35+# bash for sheet.sh only. No file tools: measured, a scribe that rewrote
36+# game/hero.md itself took the hero's damage off the HERO 4 times out of 4,
37+# and rewrote the journal down to one line.
38+bashTool: true
39+editTools: false
40+planTool: false
41+fetchContentTool: false
42+askUserTool: false
43+skillsDir: no-skills-for-the-scribe
44+
45+agentsMd:
46+ enabled: true
47+ path: AGENTS.md
48+ maxChars: 8000
49+
50+sessions:
51+ enabled: false
52+
53+# Hooks (see guard.sh): the scribe's commands, enforced.
54+hooks:
55+ preToolCall:
56+ - matcher: ^bash$
57+ command: sh "$MM_CONFIG_DIR/guard.sh" bash 'sh sheet\.sh (new|note|show|journal|here)' 'sh sheet.sh new | note | show | journal | here'
58+ onError: fail
59+
60+acp:
61+ name: scribe
62+ title: 📜 Scribe
63+
64+system: |
65+ You are the Scribe of a solo role-playing game in the style of
66+ Dungeons & Dragons. The Game Master asks you; your answer goes back to
67+ it. You keep game/hero.md and game/journal.md, ONLY through the bash
68+ command sh sheet.sh — never by hand. Write in the language the request
69+ names.
70+
71+ Every step below is ONE call of the bash tool whose command starts with
72+ sh sheet.sh (the tool runs it; do not write "bash:" in the command).
73+
74+ "Create the hero sheet":
75+ 1. sh sheet.sh new "<Name>" <Warrior|Rogue|Wizard|Cleric> <the starting
76+ place's number> — the script takes the place's name from the adventure.
77+ 2. sh sheet.sh note "<the hero> begins the adventure." (in the request's
78+ language)
79+ 3. Answer with what sheet.sh printed.
80+
81+ "Status":
82+ 1. sh sheet.sh show
83+ 2. read the last lines of the journal: sh sheet.sh journal
84+ 3. Answer with the hero's state — HP, place, gold, inventory, the
85+ monsters facing the hero — and the last three journal lines.
86+
87+ RULES
88+ - Your only command is sh sheet.sh …; never edit or print a file yourself.
89+ - Copy names (places, monsters, items) EXACTLY as the report writes
90+ them, in the report's language: never translate or shorten a name
91+ ("sifflet" stays "sifflet").
92+ - If sh sheet.sh prints an error, fix the command once (the usage is in
93+ the error), or report the error. Never read sheet.sh or the files.
new file mode 100644
@@ -0,0 +1,93 @@
1+# Scribe — asked by the Game Master through `ask_agent` (or by you with
2+# /ask-scribe). Creates the hero sheet and reports the hero's state, through
3+# sheet.sh. The numbers of a turn are recorded where they are decided — by
4+# roll.sh (combat), the referee, the storyteller — and the scripts write the
5+# journal: measured, a GM that called the scribe after every action called
6+# it two or three times in 2 runs out of 4, each call adding notes.
7+
8+provider: llmman
9+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
10+baseUrl: http://127.0.0.1:17434/v1
11+fallback: http://host.docker.internal:17434/v1
12+contextWindow: 32768
13+
14+sampling:
15+ # Bookkeeping: cold.
16+ temperature: 0.2
17+ top_p: 0.8
18+ top_k: 20
19+ max_tokens: 4096
20+ parallel_tool_calls: false
21+ chat_template_kwargs:
22+ enable_thinking: false
23+
24+firstTokenTimeout: 2m
25+watchdogTimeout: 2m
26+
27+# new + note, or show + journal, then answer.
28+maxTurns: 5
29+maxOutput: 12000
30+previewLines: 6
31+displayCommands: true
32+
33+yolo: false
34+
35+# bash for sheet.sh only. No file tools: measured, a scribe that rewrote
36+# game/hero.md itself took the hero's damage off the HERO 4 times out of 4,
37+# and rewrote the journal down to one line.
38+bashTool: true
39+editTools: false
40+planTool: false
41+fetchContentTool: false
42+askUserTool: false
43+skillsDir: no-skills-for-the-scribe
44+
45+agentsMd:
46+ enabled: true
47+ path: AGENTS.md
48+ maxChars: 8000
49+
50+sessions:
51+ enabled: false
52+
53+# Hooks (see guard.sh): the scribe's commands, enforced.
54+hooks:
55+ preToolCall:
56+ - matcher: ^bash$
57+ command: sh "$MM_CONFIG_DIR/guard.sh" bash 'sh sheet\.sh (new|note|show|journal|here)' 'sh sheet.sh new | note | show | journal | here'
58+ onError: fail
59+
60+acp:
61+ name: scribe
62+ title: 📜 Scribe
63+
64+system: |
65+ You are the Scribe of a solo role-playing game in the style of
66+ Dungeons & Dragons. The Game Master asks you; your answer goes back to
67+ it. You keep game/hero.md and game/journal.md, ONLY through the bash
68+ command sh sheet.sh — never by hand. Write in the language the request
69+ names.
70+
71+ Every step below is ONE call of the bash tool whose command starts with
72+ sh sheet.sh (the tool runs it; do not write "bash:" in the command).
73+
74+ "Create the hero sheet":
75+ 1. sh sheet.sh new "<Name>" <Warrior|Rogue|Wizard|Cleric> <the starting
76+ place's number> — the script takes the place's name from the adventure.
77+ 2. sh sheet.sh note "<the hero> begins the adventure." (in the request's
78+ language)
79+ 3. Answer with what sheet.sh printed.
80+
81+ "Status":
82+ 1. sh sheet.sh show
83+ 2. read the last lines of the journal: sh sheet.sh journal
84+ 3. Answer with the hero's state — HP, place, gold, inventory, the
85+ monsters facing the hero — and the last three journal lines.
86+
87+ RULES
88+ - Your only command is sh sheet.sh …; never edit or print a file yourself.
89+ - Copy names (places, monsters, items) EXACTLY as the report writes
90+ them, in the report's language: never translate or shorten a name
91+ ("sifflet" stays "sifflet").
92+ - If sh sheet.sh prints an error, fix the command once (the usage is in
93+ the error), or report the error. Never read sheet.sh or the files.
added demos/dungeon/agent.storyteller.yaml +128 -0
new file mode 100644
@@ -0,0 +1,128 @@
1+# Storyteller — asked by the Game Master through `ask_agent` (or by you with
2+# /ask-storyteller). Writes game/adventure.md for a new game; afterwards it
3+# reads it to describe what the hero finds, and records the move, the
4+# monsters met and the loot with sheet.sh — it is the one that knows them.
5+#
6+# Started by the GM as a child process (`mm -acp -config …`) in the GM's
7+# working directory.
8+
9+provider: llmman
10+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
11+baseUrl: http://127.0.0.1:17434/v1
12+fallback: http://host.docker.internal:17434/v1
13+contextWindow: 32768
14+
15+sampling:
16+ # Inventing is this agent's job: Qwen3.5's non-thinking values, a little
17+ # warmer on temperature.
18+ temperature: 0.8
19+ top_p: 0.8
20+ top_k: 20
21+ max_tokens: 4096
22+ parallel_tool_calls: false
23+ chat_template_kwargs:
24+ enable_thinking: false
25+
26+firstTokenTimeout: 2m
27+watchdogTimeout: 2m
28+
29+# go or here, maybe take, answer — 5 cuts a loop of refused commands short
30+# (measured with 8: nine calls denied by the hook in one turn).
31+maxTurns: 5
32+maxOutput: 12000
33+previewLines: 6
34+displayCommands: true
35+
36+# Asked by the GM: its mmConfigs entry decides (yolo: true).
37+yolo: false
38+
39+# read_file + write_file for the adventure; bash for sheet.sh only.
40+# Measured: when the Game Master relayed the storyteller's PLACE: line to
41+# the scribe, the place was right 2 times out of 4 (the GM paraphrased,
42+# translated, or invented a place); the storyteller alone found it 4/4.
43+bashTool: true
44+editTools: true
45+planTool: false
46+fetchContentTool: false
47+askUserTool: false
48+skillsDir: no-skills-for-the-storyteller
49+
50+agentsMd:
51+ enabled: true
52+ path: AGENTS.md
53+ maxChars: 8000
54+
55+sessions:
56+ enabled: false
57+
58+# Hooks (see AGENTS.md and guard.sh): the storyteller's commands, enforced.
59+# Measured without them: `sed -i` on the hero sheet, `edit_file` on the
60+# adventure, dice rolled — all against the prompt's own rules.
61+hooks:
62+ # One move per request: the marker of sheet.sh go is erased when a request
63+ # starts (this agent's mm process serves several requests).
64+ preGeneration:
65+ - command: rm -f game/.moved-by
66+ onError: ignore
67+ preToolCall:
68+ - matcher: ^bash$
69+ command: sh "$MM_CONFIG_DIR/guard.sh" bash 'sh sheet\.sh (go|here|take|gold)' 'sh sheet.sh go | here | take | gold'
70+ onError: fail
71+ - matcher: ^edit_file$
72+ command: sh "$MM_CONFIG_DIR/guard.sh" write 'none' 'nothing (write_file game/adventure.md for a new adventure)'
73+ onError: fail
74+ - matcher: ^write_file$
75+ command: sh "$MM_CONFIG_DIR/guard.sh" write 'game/adventure\.md' 'game/adventure.md (a new adventure only)'
76+ onError: fail
77+
78+acp:
79+ name: storyteller
80+ title: 🗺️ Storyteller
81+
82+system: |
83+ You are the Storyteller of a solo role-playing game in the style of
84+ Dungeons & Dragons. The Game Master asks you; your answer goes back to
85+ it. The rules and the file formats are in AGENTS.md. Write in the language
86+ the request names.
87+
88+ "Create a new adventure":
89+ 1. Your FIRST reply is a write_file tool call — not text. Nobody can
90+ read an adventure shown in your answer: the referee and the scribe
91+ read the FILE. Call write_file with path game/adventure.md and the
92+ whole adventure as content, in the exact format of AGENTS.md
93+ (# Title, Goal:, ## Places 1 to 5, ## Monsters, ## Ending).
94+ The adventure is short, for ONE hero: a goal, five places from the
95+ start to the goal, two to four monsters, one or two NPCs, one
96+ treasure, one trap. Follow the player's wishes if there are any.
97+ EVERY place line ends with "Here:" and what is there, by name:
98+ <n>. <Place> — <two sentences>. Here: <Monster name> (monster).
99+ <n>. <Place> — <two sentences>. Here: <NPC name> (NPC), <what they know>.
100+ Every monster of ## Monsters is "Here:" in one place.
101+ Monsters stay within the limits of AGENTS.md: HP 4 to 16, AC 10 to
102+ 14, attack +2 to +4, damage 1d4 to 1d8+2. The hero has 7 to 12 HP.
103+ 2. Once write_file has answered, reply with the title, the goal, and the
104+ opening scene at place 1 — three or four sentences the Game Master can
105+ tell. Do not repeat the file.
106+
107+ "Describe: <action>" — your FIRST reply is ONE bash tool call:
108+ - the hero moves (goes on, forward, to the next place, back, to a place
109+ the player names):
110+ sh sheet.sh go next | sh sheet.sh go back | sh sheet.sh go <N>
111+ It moves the hero and records the monsters of the new place.
112+ - the hero stays (looks, searches, talks, takes something):
113+ sh sheet.sh here
114+ The command prints the PLACE:, the place's description from the adventure
115+ and an ENCOUNTER: line per monster facing the hero. Describe ONLY that:
116+ never read the whole adventure, never tell what lies further on.
117+ If the hero takes an item or gold: sh sheet.sh take "<item>" or
118+ sh sheet.sh gold +<n>.
119+ Answer in three to five sentences: the place, the people, what they say,
120+ what can be seen. Then copy the PLACE: and ENCOUNTER: lines as printed.
121+
122+ RULES
123+ - Never roll dice and never decide whether an action succeeds: that is the
124+ referee's job.
125+ - The hero can only go to the places of the adventure.
126+ - You CAN run commands: the bash tool runs them. Your only commands are
127+ sh sheet.sh go / here / take / gold — go ONCE per request. Anything else
128+ is refused (a hook checks every call).
new file mode 100644
@@ -0,0 +1,128 @@
1+# Storyteller — asked by the Game Master through `ask_agent` (or by you with
2+# /ask-storyteller). Writes game/adventure.md for a new game; afterwards it
3+# reads it to describe what the hero finds, and records the move, the
4+# monsters met and the loot with sheet.sh — it is the one that knows them.
5+#
6+# Started by the GM as a child process (`mm -acp -config …`) in the GM's
7+# working directory.
8+
9+provider: llmman
10+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
11+baseUrl: http://127.0.0.1:17434/v1
12+fallback: http://host.docker.internal:17434/v1
13+contextWindow: 32768
14+
15+sampling:
16+ # Inventing is this agent's job: Qwen3.5's non-thinking values, a little
17+ # warmer on temperature.
18+ temperature: 0.8
19+ top_p: 0.8
20+ top_k: 20
21+ max_tokens: 4096
22+ parallel_tool_calls: false
23+ chat_template_kwargs:
24+ enable_thinking: false
25+
26+firstTokenTimeout: 2m
27+watchdogTimeout: 2m
28+
29+# go or here, maybe take, answer — 5 cuts a loop of refused commands short
30+# (measured with 8: nine calls denied by the hook in one turn).
31+maxTurns: 5
32+maxOutput: 12000
33+previewLines: 6
34+displayCommands: true
35+
36+# Asked by the GM: its mmConfigs entry decides (yolo: true).
37+yolo: false
38+
39+# read_file + write_file for the adventure; bash for sheet.sh only.
40+# Measured: when the Game Master relayed the storyteller's PLACE: line to
41+# the scribe, the place was right 2 times out of 4 (the GM paraphrased,
42+# translated, or invented a place); the storyteller alone found it 4/4.
43+bashTool: true
44+editTools: true
45+planTool: false
46+fetchContentTool: false
47+askUserTool: false
48+skillsDir: no-skills-for-the-storyteller
49+
50+agentsMd:
51+ enabled: true
52+ path: AGENTS.md
53+ maxChars: 8000
54+
55+sessions:
56+ enabled: false
57+
58+# Hooks (see AGENTS.md and guard.sh): the storyteller's commands, enforced.
59+# Measured without them: `sed -i` on the hero sheet, `edit_file` on the
60+# adventure, dice rolled — all against the prompt's own rules.
61+hooks:
62+ # One move per request: the marker of sheet.sh go is erased when a request
63+ # starts (this agent's mm process serves several requests).
64+ preGeneration:
65+ - command: rm -f game/.moved-by
66+ onError: ignore
67+ preToolCall:
68+ - matcher: ^bash$
69+ command: sh "$MM_CONFIG_DIR/guard.sh" bash 'sh sheet\.sh (go|here|take|gold)' 'sh sheet.sh go | here | take | gold'
70+ onError: fail
71+ - matcher: ^edit_file$
72+ command: sh "$MM_CONFIG_DIR/guard.sh" write 'none' 'nothing (write_file game/adventure.md for a new adventure)'
73+ onError: fail
74+ - matcher: ^write_file$
75+ command: sh "$MM_CONFIG_DIR/guard.sh" write 'game/adventure\.md' 'game/adventure.md (a new adventure only)'
76+ onError: fail
77+
78+acp:
79+ name: storyteller
80+ title: 🗺️ Storyteller
81+
82+system: |
83+ You are the Storyteller of a solo role-playing game in the style of
84+ Dungeons & Dragons. The Game Master asks you; your answer goes back to
85+ it. The rules and the file formats are in AGENTS.md. Write in the language
86+ the request names.
87+
88+ "Create a new adventure":
89+ 1. Your FIRST reply is a write_file tool call — not text. Nobody can
90+ read an adventure shown in your answer: the referee and the scribe
91+ read the FILE. Call write_file with path game/adventure.md and the
92+ whole adventure as content, in the exact format of AGENTS.md
93+ (# Title, Goal:, ## Places 1 to 5, ## Monsters, ## Ending).
94+ The adventure is short, for ONE hero: a goal, five places from the
95+ start to the goal, two to four monsters, one or two NPCs, one
96+ treasure, one trap. Follow the player's wishes if there are any.
97+ EVERY place line ends with "Here:" and what is there, by name:
98+ <n>. <Place> — <two sentences>. Here: <Monster name> (monster).
99+ <n>. <Place> — <two sentences>. Here: <NPC name> (NPC), <what they know>.
100+ Every monster of ## Monsters is "Here:" in one place.
101+ Monsters stay within the limits of AGENTS.md: HP 4 to 16, AC 10 to
102+ 14, attack +2 to +4, damage 1d4 to 1d8+2. The hero has 7 to 12 HP.
103+ 2. Once write_file has answered, reply with the title, the goal, and the
104+ opening scene at place 1 — three or four sentences the Game Master can
105+ tell. Do not repeat the file.
106+
107+ "Describe: <action>" — your FIRST reply is ONE bash tool call:
108+ - the hero moves (goes on, forward, to the next place, back, to a place
109+ the player names):
110+ sh sheet.sh go next | sh sheet.sh go back | sh sheet.sh go <N>
111+ It moves the hero and records the monsters of the new place.
112+ - the hero stays (looks, searches, talks, takes something):
113+ sh sheet.sh here
114+ The command prints the PLACE:, the place's description from the adventure
115+ and an ENCOUNTER: line per monster facing the hero. Describe ONLY that:
116+ never read the whole adventure, never tell what lies further on.
117+ If the hero takes an item or gold: sh sheet.sh take "<item>" or
118+ sh sheet.sh gold +<n>.
119+ Answer in three to five sentences: the place, the people, what they say,
120+ what can be seen. Then copy the PLACE: and ENCOUNTER: lines as printed.
121+
122+ RULES
123+ - Never roll dice and never decide whether an action succeeds: that is the
124+ referee's job.
125+ - The hero can only go to the places of the adventure.
126+ - You CAN run commands: the bash tool runs them. Your only commands are
127+ sh sheet.sh go / here / take / gold — go ONCE per request. Anything else
128+ is refused (a hook checks every call).
added demos/dungeon/guard.sh +59 -0
new file mode 100755
@@ -0,0 +1,59 @@
1+#!/bin/sh
2+# guard.sh — a preToolCall hook (see the `hooks:` block of each agent).
3+# It keeps a specialist to the commands of its role: a 4B model told
4+# "your only commands are …" was measured running `sed -i` on the hero sheet
5+# and `edit_file` on the adventure anyway.
6+#
7+# guard.sh bash '<allowed command regex>' '<the same, for the model>'
8+# guard.sh write '<allowed path regex>' '<the same, for the model>'
9+#
10+# (Each agent's YAML passes them; the second form is what the model reads.)
11+#
12+# mm writes the call as JSON on stdin; printing nothing lets it run, printing
13+# {"action":"deny","reason":…} refuses it — the model reads the reason.
14+set -eu
15+kind=$1 allowed=$2 hint=${3:-$2}
16+input=$(cat)
17+
18+# The value of a JSON string field, unescaped enough for a regex check.
19+value() {
20+ printf '%s' "$input" | sed -n "s/.*\"$1\":\"\(\([^\"\\\\]\|\\\\.\)*\)\".*/\1/p" | head -1 |
21+ sed 's/\\u0026/\&/g; s/\\u003c/</g; s/\\u003e/>/g; s/\\"/"/g; s/\\\\/\\/g'
22+}
23+
24+deny() {
25+ reason=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')
26+ printf '{"action":"deny","reason":"%s"}\n' "$reason"
27+ exit 0
28+}
29+
30+case "$kind" in
31+ bash)
32+ command=$(value command)
33+ # "cd <dir> && <command>": the hook drops the cd and lets the command
34+ # run (mm already runs it in the game's directory). Measured: a
35+ # scribe denied five times in a row for a leading cd never thought
36+ # of removing it.
37+ rest=$(printf '%s' "$command" | sed -n 's/^cd [^;&|]* && *//p')
38+ if [ -n "$rest" ]; then
39+ command=$rest
40+ rewrite=1
41+ fi
42+ case "$command" in
43+ *';'*|*'&'*|*'|'*|*'`'*|*'$('*|*'>'*|*'<'*|*'\n'*)
44+ deny "one command at a time, nothing chained or redirected. Your commands: $hint" ;;
45+ esac
46+ printf '%s' "$command" | grep -Eq "^($allowed)( |\$)" ||
47+ deny "not one of your commands. Your commands: $hint"
48+ if [ -n "${rewrite:-}" ]; then
49+ escaped=$(printf '%s' "$command" | sed 's/\\/\\\\/g; s/"/\\"/g')
50+ printf '{"input":{"command":"%s"}}\n' "$escaped"
51+ fi
52+ ;;
53+ write)
54+ path=$(value path)
55+ printf '%s' "$path" | grep -Eq "^($allowed)\$" ||
56+ deny "you may not change $path — the scripts keep the game files. You may write: $hint"
57+ ;;
58+ *) echo "guard.sh: unknown kind $kind" >&2; exit 2 ;;
59+esac
new file mode 100755
@@ -0,0 +1,59 @@
1+#!/bin/sh
2+# guard.sh — a preToolCall hook (see the `hooks:` block of each agent).
3+# It keeps a specialist to the commands of its role: a 4B model told
4+# "your only commands are …" was measured running `sed -i` on the hero sheet
5+# and `edit_file` on the adventure anyway.
6+#
7+# guard.sh bash '<allowed command regex>' '<the same, for the model>'
8+# guard.sh write '<allowed path regex>' '<the same, for the model>'
9+#
10+# (Each agent's YAML passes them; the second form is what the model reads.)
11+#
12+# mm writes the call as JSON on stdin; printing nothing lets it run, printing
13+# {"action":"deny","reason":…} refuses it — the model reads the reason.
14+set -eu
15+kind=$1 allowed=$2 hint=${3:-$2}
16+input=$(cat)
17+
18+# The value of a JSON string field, unescaped enough for a regex check.
19+value() {
20+ printf '%s' "$input" | sed -n "s/.*\"$1\":\"\(\([^\"\\\\]\|\\\\.\)*\)\".*/\1/p" | head -1 |
21+ sed 's/\\u0026/\&/g; s/\\u003c/</g; s/\\u003e/>/g; s/\\"/"/g; s/\\\\/\\/g'
22+}
23+
24+deny() {
25+ reason=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')
26+ printf '{"action":"deny","reason":"%s"}\n' "$reason"
27+ exit 0
28+}
29+
30+case "$kind" in
31+ bash)
32+ command=$(value command)
33+ # "cd <dir> && <command>": the hook drops the cd and lets the command
34+ # run (mm already runs it in the game's directory). Measured: a
35+ # scribe denied five times in a row for a leading cd never thought
36+ # of removing it.
37+ rest=$(printf '%s' "$command" | sed -n 's/^cd [^;&|]* && *//p')
38+ if [ -n "$rest" ]; then
39+ command=$rest
40+ rewrite=1
41+ fi
42+ case "$command" in
43+ *';'*|*'&'*|*'|'*|*'`'*|*'$('*|*'>'*|*'<'*|*'\n'*)
44+ deny "one command at a time, nothing chained or redirected. Your commands: $hint" ;;
45+ esac
46+ printf '%s' "$command" | grep -Eq "^($allowed)( |\$)" ||
47+ deny "not one of your commands. Your commands: $hint"
48+ if [ -n "${rewrite:-}" ]; then
49+ escaped=$(printf '%s' "$command" | sed 's/\\/\\\\/g; s/"/\\"/g')
50+ printf '{"input":{"command":"%s"}}\n' "$escaped"
51+ fi
52+ ;;
53+ write)
54+ path=$(value path)
55+ printf '%s' "$path" | grep -Eq "^($allowed)\$" ||
56+ deny "you may not change $path — the scripts keep the game files. You may write: $hint"
57+ ;;
58+ *) echo "guard.sh: unknown kind $kind" >&2; exit 2 ;;
59+esac
added demos/dungeon/new-game.sh +12 -0
new file mode 100755
@@ -0,0 +1,12 @@
1+#!/bin/sh
2+# new-game.sh — erase the current game (game/ and the GM's saved sessions)
3+# so the next "new game" starts from nothing. Asks first.
4+set -eu
5+cd "$(dirname "$0")"
6+if [ -d game ] && [ "${1:-}" != "-y" ]; then
7+ printf 'Erase the current game (game/, .mm/sessions)? [y/N] '
8+ read -r answer
9+ case "$answer" in y|Y|yes|o|O|oui) ;; *) echo "kept"; exit 0 ;; esac
10+fi
11+rm -rf game .mm/sessions
12+echo "game erased — start mm agent.gm.yaml and ask for a new game"
new file mode 100755
@@ -0,0 +1,12 @@
1+#!/bin/sh
2+# new-game.sh — erase the current game (game/ and the GM's saved sessions)
3+# so the next "new game" starts from nothing. Asks first.
4+set -eu
5+cd "$(dirname "$0")"
6+if [ -d game ] && [ "${1:-}" != "-y" ]; then
7+ printf 'Erase the current game (game/, .mm/sessions)? [y/N] '
8+ read -r answer
9+ case "$answer" in y|Y|yes|o|O|oui) ;; *) echo "kept"; exit 0 ;; esac
10+fi
11+rm -rf game .mm/sessions
12+echo "game erased — start mm agent.gm.yaml and ask for a new game"
added demos/dungeon/roll.sh +177 -0
new file mode 100755
@@ -0,0 +1,177 @@
1+#!/bin/sh
2+# roll.sh — the game's dice. The referee calls it; the model never invents a
3+# roll or adds numbers itself.
4+#
5+# sh roll.sh 1d20+3 a roll: 1d20+3 → [14] +3 = 17
6+# sh roll.sh 2d6 a roll: 2d6 → [3, 5] = 8
7+# sh roll.sh check 1d20+2 15 a check: 1d20+2 → [12] +2 = 14 vs 15 → FAILURE
8+# sh roll.sh attack [MONSTER] a whole combat round, read from game/hero.md:
9+# the hero attacks the monster (the first one of
10+# ## Encounter, or the one named), rolls damage
11+# on a hit, and the monster strikes back if it
12+# still stands. It WRITES the result into
13+# game/hero.md (through sheet.sh) and ends with
14+# an "EFFECTS (already written …)" line.
15+#
16+# A natural 20 on a single d20 is always a success (CRITICAL), a natural 1
17+# always a failure (FUMBLE). Randomness comes from /dev/urandom: two rolls in
18+# the same second differ.
19+set -eu
20+
21+HERE=$(dirname "$0")
22+
23+usage() {
24+ echo "usage: sh roll.sh NdM[+K|-K] | sh roll.sh check NdM[+K|-K] DC | sh roll.sh attack [MONSTER]" >&2
25+ exit 2
26+}
27+
28+# random_int MAX → an integer in 1..MAX
29+random_int() {
30+ n=$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')
31+ echo $((n % $1 + 1))
32+}
33+
34+# roll EXPR → sets ROLLS ("14" or "3, 5"), FIRST (first die), SIDES, COUNT, TOTAL, MOD_TEXT
35+roll() {
36+ expr=$(echo "$1" | tr 'D' 'd' | tr -d ' ')
37+ case "$expr" in
38+ *d*) ;;
39+ *) usage ;;
40+ esac
41+ count=${expr%%d*}
42+ rest=${expr#*d}
43+ [ -z "$count" ] && count=1
44+ case "$rest" in
45+ *+*) SIDES=${rest%%+*}; mod=${rest#*+} ;;
46+ *-*) SIDES=${rest%%-*}; mod=-${rest#*-} ;;
47+ *) SIDES=$rest; mod=0 ;;
48+ esac
49+ for v in "$count" "$SIDES" "${mod#-}"; do
50+ case "$v" in ''|*[!0-9]*) usage ;; esac
51+ done
52+ [ "$count" -ge 1 ] && [ "$count" -le 20 ] && [ "$SIDES" -ge 2 ] || usage
53+ COUNT=$count
54+ ROLLS=""
55+ TOTAL=0
56+ i=0
57+ while [ "$i" -lt "$count" ]; do
58+ r=$(random_int "$SIDES")
59+ [ "$i" -eq 0 ] && FIRST=$r
60+ ROLLS="${ROLLS:+$ROLLS, }$r"
61+ TOTAL=$((TOTAL + r))
62+ i=$((i + 1))
63+ done
64+ TOTAL=$((TOTAL + mod))
65+ if [ "$mod" -gt 0 ]; then MOD_TEXT=" +$mod"; elif [ "$mod" -lt 0 ]; then MOD_TEXT=" $mod"; else MOD_TEXT=""; fi
66+ EXPR=$expr
67+}
68+
69+# check_line EXPR TARGET → prints the roll and sets HIT (1/0)
70+check_line() {
71+ roll "$1"
72+ HIT=0
73+ [ "$TOTAL" -ge "$2" ] && HIT=1
74+ extra=""
75+ if [ "$COUNT" -eq 1 ] && [ "$SIDES" -eq 20 ]; then
76+ [ "$FIRST" -eq 20 ] && HIT=1 && extra=" (natural 20)"
77+ [ "$FIRST" -eq 1 ] && HIT=0 && extra=" (natural 1)"
78+ fi
79+ word=MISS
80+ [ "$HIT" -eq 1 ] && word=HIT
81+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL vs AC $2 → $word$extra"
82+}
83+
84+# attack [MONSTER]: one combat round, numbers read from game/hero.md.
85+attack() {
86+ sheet=game/hero.md
87+ [ -f "$sheet" ] || { echo "no $sheet: create the hero first" >&2; exit 1; }
88+ hero=$(sed -n 's/^# //p' "$sheet" | head -1)
89+ hero_ac=$(sed -n 's/^- AC: *\([0-9]*\).*/\1/p' "$sheet" | head -1)
90+ hero_bonus=$(sed -n 's/^- Attack:.*+\([0-9]*\), *damage.*/\1/p' "$sheet" | head -1)
91+ hero_damage=$(sed -n 's/^- Attack:.*damage *\([0-9dD+-]*\).*/\1/p' "$sheet" | head -1)
92+ encounter=$(sed -n '/^## Encounter/,$p' "$sheet" | grep '^- .*: *HP' || true)
93+ if [ -n "${1:-}" ]; then
94+ line=$(echo "$encounter" | grep -i -F -- "- $1" | head -1)
95+ else
96+ line=$(echo "$encounter" | head -1)
97+ fi
98+ [ -n "$line" ] || { echo "no monster${1:+ named $1} in ## Encounter of $sheet" >&2; exit 1; }
99+ name=$(echo "$line" | sed 's/^- \(.*\): *HP.*/\1/')
100+ m_hp=$(echo "$line" | sed 's/.*HP *\([0-9]*\).*/\1/')
101+ m_ac=$(echo "$line" | sed 's/.*AC *\([0-9]*\).*/\1/')
102+ m_bonus=$(echo "$line" | sed 's/.*attack *+\([0-9]*\).*/\1/')
103+ m_damage=$(echo "$line" | sed 's/.*damage *\([0-9dD+-]*\).*/\1/')
104+ case "$(sed -n 's/^- HP: *//p' "$sheet" | head -1)" in
105+ 0/*) echo "the hero has fallen (HP 0): no more attacks — the game is over" >&2; exit 1 ;;
106+ esac
107+ for v in "$hero_ac" "$hero_bonus" "$hero_damage" "$m_hp" "$m_ac" "$m_bonus" "$m_damage"; do
108+ [ -n "$v" ] || { echo "cannot read the numbers of $sheet (see AGENTS.md for the format)" >&2; exit 1; }
109+ done
110+
111+ sh "$HERE/sheet.sh" attacked
112+ effects=""
113+ summary="⚔ $hero → $name:"
114+ printf '%s attacks %s: ' "$hero" "$name"
115+ check_line "1d20+$hero_bonus" "$m_ac"
116+ if [ "$HIT" -eq 1 ]; then
117+ printf '%s damage: ' "$hero"
118+ roll "$hero_damage"
119+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL"
120+ dealt=$TOTAL
121+ [ "$dealt" -lt 0 ] && dealt=0
122+ effects="$name HP -$dealt"
123+ summary="$summary HIT -$dealt"
124+ if [ "$dealt" -ge "$m_hp" ]; then
125+ SHEET_NOLOG=1 sh "$HERE/sheet.sh" monster "$name" "-$dealt" >/dev/null
126+ SHEET_NOLOG=1 sh "$HERE/sheet.sh" note "$summary · $name ✝" >/dev/null
127+ echo "EFFECTS (already written to game/hero.md — do not apply them again): $effects, $name defeated"
128+ sh "$HERE/sheet.sh" show | sed 's/^/NOW: /'
129+ return 0
130+ fi
131+ SHEET_NOLOG=1 sh "$HERE/sheet.sh" monster "$name" "-$dealt" >/dev/null
132+ fi
133+ [ "$HIT" -eq 1 ] || summary="$summary MISS"
134+ printf '%s strikes back: ' "$name"
135+ check_line "1d20+$m_bonus" "$hero_ac"
136+ [ "$HIT" -eq 1 ] || summary="$summary · $name → $hero: MISS"
137+ if [ "$HIT" -eq 1 ]; then
138+ printf '%s damage: ' "$name"
139+ roll "$m_damage"
140+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL"
141+ taken=$TOTAL
142+ [ "$taken" -lt 0 ] && taken=0
143+ effects="${effects:+$effects, }hero HP -$taken"
144+ summary="$summary · $name → $hero: HIT -$taken"
145+ fallen=$(SHEET_NOLOG=1 sh "$HERE/sheet.sh" hp "-$taken" | grep FALLEN || true)
146+ fi
147+ SHEET_NOLOG=1 sh "$HERE/sheet.sh" note "$summary" >/dev/null
148+ echo "EFFECTS (already written to game/hero.md — do not apply them again): ${effects:-none}"
149+ sh "$HERE/sheet.sh" show | sed 's/^/NOW: /'
150+ [ -n "${fallen:-}" ] && echo "$fallen"
151+ return 0
152+}
153+
154+[ $# -ge 1 ] || usage
155+if [ "$1" = "attack" ]; then
156+ shift
157+ [ $# -le 1 ] || usage
158+ attack "${1:-}"
159+elif [ "$1" = "check" ]; then
160+ [ $# -eq 3 ] || usage
161+ case "$(sed -n 's/^- HP: *//p' game/hero.md 2>/dev/null | head -1)" in
162+ 0/*) echo "the hero has fallen (HP 0): no more actions — the game is over" >&2; exit 1 ;;
163+ esac
164+ case "$3" in ''|*[!0-9]*) usage ;; esac
165+ roll "$2"
166+ verdict=FAILURE
167+ [ "$TOTAL" -ge "$3" ] && verdict=SUCCESS
168+ if [ "$COUNT" -eq 1 ] && [ "$SIDES" -eq 20 ]; then
169+ [ "$FIRST" -eq 20 ] && verdict="SUCCESS (CRITICAL: natural 20)"
170+ [ "$FIRST" -eq 1 ] && verdict="FAILURE (FUMBLE: natural 1)"
171+ fi
172+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL vs $3 → $verdict"
173+else
174+ [ $# -eq 1 ] || usage
175+ roll "$1"
176+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL"
177+fi
new file mode 100755
@@ -0,0 +1,177 @@
1+#!/bin/sh
2+# roll.sh — the game's dice. The referee calls it; the model never invents a
3+# roll or adds numbers itself.
4+#
5+# sh roll.sh 1d20+3 a roll: 1d20+3 → [14] +3 = 17
6+# sh roll.sh 2d6 a roll: 2d6 → [3, 5] = 8
7+# sh roll.sh check 1d20+2 15 a check: 1d20+2 → [12] +2 = 14 vs 15 → FAILURE
8+# sh roll.sh attack [MONSTER] a whole combat round, read from game/hero.md:
9+# the hero attacks the monster (the first one of
10+# ## Encounter, or the one named), rolls damage
11+# on a hit, and the monster strikes back if it
12+# still stands. It WRITES the result into
13+# game/hero.md (through sheet.sh) and ends with
14+# an "EFFECTS (already written …)" line.
15+#
16+# A natural 20 on a single d20 is always a success (CRITICAL), a natural 1
17+# always a failure (FUMBLE). Randomness comes from /dev/urandom: two rolls in
18+# the same second differ.
19+set -eu
20+
21+HERE=$(dirname "$0")
22+
23+usage() {
24+ echo "usage: sh roll.sh NdM[+K|-K] | sh roll.sh check NdM[+K|-K] DC | sh roll.sh attack [MONSTER]" >&2
25+ exit 2
26+}
27+
28+# random_int MAX → an integer in 1..MAX
29+random_int() {
30+ n=$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')
31+ echo $((n % $1 + 1))
32+}
33+
34+# roll EXPR → sets ROLLS ("14" or "3, 5"), FIRST (first die), SIDES, COUNT, TOTAL, MOD_TEXT
35+roll() {
36+ expr=$(echo "$1" | tr 'D' 'd' | tr -d ' ')
37+ case "$expr" in
38+ *d*) ;;
39+ *) usage ;;
40+ esac
41+ count=${expr%%d*}
42+ rest=${expr#*d}
43+ [ -z "$count" ] && count=1
44+ case "$rest" in
45+ *+*) SIDES=${rest%%+*}; mod=${rest#*+} ;;
46+ *-*) SIDES=${rest%%-*}; mod=-${rest#*-} ;;
47+ *) SIDES=$rest; mod=0 ;;
48+ esac
49+ for v in "$count" "$SIDES" "${mod#-}"; do
50+ case "$v" in ''|*[!0-9]*) usage ;; esac
51+ done
52+ [ "$count" -ge 1 ] && [ "$count" -le 20 ] && [ "$SIDES" -ge 2 ] || usage
53+ COUNT=$count
54+ ROLLS=""
55+ TOTAL=0
56+ i=0
57+ while [ "$i" -lt "$count" ]; do
58+ r=$(random_int "$SIDES")
59+ [ "$i" -eq 0 ] && FIRST=$r
60+ ROLLS="${ROLLS:+$ROLLS, }$r"
61+ TOTAL=$((TOTAL + r))
62+ i=$((i + 1))
63+ done
64+ TOTAL=$((TOTAL + mod))
65+ if [ "$mod" -gt 0 ]; then MOD_TEXT=" +$mod"; elif [ "$mod" -lt 0 ]; then MOD_TEXT=" $mod"; else MOD_TEXT=""; fi
66+ EXPR=$expr
67+}
68+
69+# check_line EXPR TARGET → prints the roll and sets HIT (1/0)
70+check_line() {
71+ roll "$1"
72+ HIT=0
73+ [ "$TOTAL" -ge "$2" ] && HIT=1
74+ extra=""
75+ if [ "$COUNT" -eq 1 ] && [ "$SIDES" -eq 20 ]; then
76+ [ "$FIRST" -eq 20 ] && HIT=1 && extra=" (natural 20)"
77+ [ "$FIRST" -eq 1 ] && HIT=0 && extra=" (natural 1)"
78+ fi
79+ word=MISS
80+ [ "$HIT" -eq 1 ] && word=HIT
81+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL vs AC $2 → $word$extra"
82+}
83+
84+# attack [MONSTER]: one combat round, numbers read from game/hero.md.
85+attack() {
86+ sheet=game/hero.md
87+ [ -f "$sheet" ] || { echo "no $sheet: create the hero first" >&2; exit 1; }
88+ hero=$(sed -n 's/^# //p' "$sheet" | head -1)
89+ hero_ac=$(sed -n 's/^- AC: *\([0-9]*\).*/\1/p' "$sheet" | head -1)
90+ hero_bonus=$(sed -n 's/^- Attack:.*+\([0-9]*\), *damage.*/\1/p' "$sheet" | head -1)
91+ hero_damage=$(sed -n 's/^- Attack:.*damage *\([0-9dD+-]*\).*/\1/p' "$sheet" | head -1)
92+ encounter=$(sed -n '/^## Encounter/,$p' "$sheet" | grep '^- .*: *HP' || true)
93+ if [ -n "${1:-}" ]; then
94+ line=$(echo "$encounter" | grep -i -F -- "- $1" | head -1)
95+ else
96+ line=$(echo "$encounter" | head -1)
97+ fi
98+ [ -n "$line" ] || { echo "no monster${1:+ named $1} in ## Encounter of $sheet" >&2; exit 1; }
99+ name=$(echo "$line" | sed 's/^- \(.*\): *HP.*/\1/')
100+ m_hp=$(echo "$line" | sed 's/.*HP *\([0-9]*\).*/\1/')
101+ m_ac=$(echo "$line" | sed 's/.*AC *\([0-9]*\).*/\1/')
102+ m_bonus=$(echo "$line" | sed 's/.*attack *+\([0-9]*\).*/\1/')
103+ m_damage=$(echo "$line" | sed 's/.*damage *\([0-9dD+-]*\).*/\1/')
104+ case "$(sed -n 's/^- HP: *//p' "$sheet" | head -1)" in
105+ 0/*) echo "the hero has fallen (HP 0): no more attacks — the game is over" >&2; exit 1 ;;
106+ esac
107+ for v in "$hero_ac" "$hero_bonus" "$hero_damage" "$m_hp" "$m_ac" "$m_bonus" "$m_damage"; do
108+ [ -n "$v" ] || { echo "cannot read the numbers of $sheet (see AGENTS.md for the format)" >&2; exit 1; }
109+ done
110+
111+ sh "$HERE/sheet.sh" attacked
112+ effects=""
113+ summary="⚔ $hero → $name:"
114+ printf '%s attacks %s: ' "$hero" "$name"
115+ check_line "1d20+$hero_bonus" "$m_ac"
116+ if [ "$HIT" -eq 1 ]; then
117+ printf '%s damage: ' "$hero"
118+ roll "$hero_damage"
119+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL"
120+ dealt=$TOTAL
121+ [ "$dealt" -lt 0 ] && dealt=0
122+ effects="$name HP -$dealt"
123+ summary="$summary HIT -$dealt"
124+ if [ "$dealt" -ge "$m_hp" ]; then
125+ SHEET_NOLOG=1 sh "$HERE/sheet.sh" monster "$name" "-$dealt" >/dev/null
126+ SHEET_NOLOG=1 sh "$HERE/sheet.sh" note "$summary · $name ✝" >/dev/null
127+ echo "EFFECTS (already written to game/hero.md — do not apply them again): $effects, $name defeated"
128+ sh "$HERE/sheet.sh" show | sed 's/^/NOW: /'
129+ return 0
130+ fi
131+ SHEET_NOLOG=1 sh "$HERE/sheet.sh" monster "$name" "-$dealt" >/dev/null
132+ fi
133+ [ "$HIT" -eq 1 ] || summary="$summary MISS"
134+ printf '%s strikes back: ' "$name"
135+ check_line "1d20+$m_bonus" "$hero_ac"
136+ [ "$HIT" -eq 1 ] || summary="$summary · $name → $hero: MISS"
137+ if [ "$HIT" -eq 1 ]; then
138+ printf '%s damage: ' "$name"
139+ roll "$m_damage"
140+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL"
141+ taken=$TOTAL
142+ [ "$taken" -lt 0 ] && taken=0
143+ effects="${effects:+$effects, }hero HP -$taken"
144+ summary="$summary · $name → $hero: HIT -$taken"
145+ fallen=$(SHEET_NOLOG=1 sh "$HERE/sheet.sh" hp "-$taken" | grep FALLEN || true)
146+ fi
147+ SHEET_NOLOG=1 sh "$HERE/sheet.sh" note "$summary" >/dev/null
148+ echo "EFFECTS (already written to game/hero.md — do not apply them again): ${effects:-none}"
149+ sh "$HERE/sheet.sh" show | sed 's/^/NOW: /'
150+ [ -n "${fallen:-}" ] && echo "$fallen"
151+ return 0
152+}
153+
154+[ $# -ge 1 ] || usage
155+if [ "$1" = "attack" ]; then
156+ shift
157+ [ $# -le 1 ] || usage
158+ attack "${1:-}"
159+elif [ "$1" = "check" ]; then
160+ [ $# -eq 3 ] || usage
161+ case "$(sed -n 's/^- HP: *//p' game/hero.md 2>/dev/null | head -1)" in
162+ 0/*) echo "the hero has fallen (HP 0): no more actions — the game is over" >&2; exit 1 ;;
163+ esac
164+ case "$3" in ''|*[!0-9]*) usage ;; esac
165+ roll "$2"
166+ verdict=FAILURE
167+ [ "$TOTAL" -ge "$3" ] && verdict=SUCCESS
168+ if [ "$COUNT" -eq 1 ] && [ "$SIDES" -eq 20 ]; then
169+ [ "$FIRST" -eq 20 ] && verdict="SUCCESS (CRITICAL: natural 20)"
170+ [ "$FIRST" -eq 1 ] && verdict="FAILURE (FUMBLE: natural 1)"
171+ fi
172+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL vs $3 → $verdict"
173+else
174+ [ $# -eq 1 ] || usage
175+ roll "$1"
176+ echo "$EXPR → [$ROLLS]$MOD_TEXT = $TOTAL"
177+fi
added demos/dungeon/sheet.sh +294 -0
new file mode 100755
@@ -0,0 +1,294 @@
1+#!/bin/sh
2+# sheet.sh — the hero sheet (game/hero.md) and the journal (game/journal.md).
3+# The scribe calls it; the model never rewrites the files nor adds numbers.
4+#
5+# sh sheet.sh new "<Name>" <Warrior|Rogue|Wizard|Cleric> 1 (the starting place's number)
6+# sh sheet.sh hp -3 the hero loses 3 HP (or +4: heals, never above max)
7+# sh sheet.sh go next the hero moves (next | back | <number>): reads
8+# game/adventure.md, sets Place, leaves the old
9+# monsters behind, meets the ones of the new place
10+# sh sheet.sh place "2. The Bone Hall"
11+# sh sheet.sh meet "Skeleton: HP 8, AC 12, attack +3, damage 1d6+2"
12+# sh sheet.sh monster "Skeleton" -5 a monster loses HP; at 0 it is removed
13+# sh sheet.sh clear no monster left in ## Encounter
14+# sh sheet.sh gold +15 (or -5)
15+# sh sheet.sh take "silver key"
16+# sh sheet.sh use "healing potion" removes one item from the inventory
17+# sh sheet.sh note "Brann opens the gate." appends a numbered journal line
18+#
19+# go, take, gold, use and hp (and roll.sh attack) also write their own
20+# journal line, with symbols rather than words so it reads in any language:
21+# → a move, ⚔ a combat round, ❤ HP, + an item or gold taken, − one used.
22+# sh sheet.sh here prints the hero's place (its line of the
23+# adventure) and the monsters facing the hero
24+# sh sheet.sh show prints the sheet
25+# sh sheet.sh journal prints the last 5 journal lines
26+set -eu
27+
28+SHEET=game/hero.md
29+JOURNAL=game/journal.md
30+ADVENTURE=game/adventure.md
31+
32+die() { echo "sheet.sh: $*" >&2; exit 1; }
33+# log TEXT: an automatic journal line, unless the caller writes its own
34+# (roll.sh attack sets SHEET_NOLOG=1 and writes one line for the round).
35+log() { [ -n "${SHEET_NOLOG:-}" ] || [ ! -f "$JOURNAL" ] || note "$1" >/dev/null; }
36+need_sheet() { [ -f "$SHEET" ] || die "no $SHEET: create the hero first (sh sheet.sh new …)"; }
37+# alive: refuse an action once the hero has fallen — the game is over.
38+alive() { case "$(field HP)" in 0/*) die "the hero has fallen (HP 0): the game is over — start a new game" ;; esac; }
39+# not_after_attack: roll.sh attack has already written this request's
40+# damage; measured, a referee then ran `sh sheet.sh hp -4` again and the
41+# hero fell from a blow counted twice. game/.attacked-by is erased at the
42+# start of every request by the referee's preGeneration hook.
43+not_after_attack() {
44+ [ -z "${SHEET_NOLOG:-}" ] || return 0
45+ me=$(mover)
46+ if [ -n "$me" ] && [ -f game/.attacked-by ] && [ "$(cat game/.attacked-by)" = "$me" ]; then
47+ die "roll.sh attack has already written this round into the sheet — do not apply its damage again"
48+ fi
49+}
50+is_signed() { case "$1" in [+-][0-9]*|[0-9]*) case "${1#[+-]}" in *[!0-9]*) return 1 ;; esac ;; *) return 1 ;; esac; }
51+
52+# set_field LABEL VALUE → rewrites the line "- LABEL: …"
53+set_field() {
54+ tmp=$(mktemp)
55+ LABEL="$1" VALUE="$2" awk '
56+ index($0, "- " ENVIRON["LABEL"] ":") == 1 { print "- " ENVIRON["LABEL"] ": " ENVIRON["VALUE"]; next }
57+ { print }' "$SHEET" > "$tmp" && mv "$tmp" "$SHEET"
58+}
59+field() { sed -n "s/^- $1: *//p" "$SHEET" | head -1; }
60+
61+new_hero() {
62+ [ $# -eq 3 ] || die 'usage: sh sheet.sh new "<Name>" <Warrior|Rogue|Wizard|Cleric> <place number>'
63+ # The place is named from the adventure itself: only its number counts,
64+ # so the name cannot come back translated or misspelt.
65+ start=$3
66+ num=$(echo "$3" | sed -n 's/^\([0-9][0-9]*\).*/\1/p')
67+ if [ -n "$num" ] && [ -f "$ADVENTURE" ]; then
68+ pname=$(grep "^$num\. " "$ADVENTURE" | head -1 | sed 's/^[0-9]*\. *//; s/ — .*//; s/ – .*//; s/ - .*//')
69+ [ -n "$pname" ] && start="$num. $pname"
70+ fi
71+ case "$2" in
72+ Warrior|warrior) class=Warrior hp=12 ac=16 attack="longsword +4, damage 1d8+2"
73+ abilities="STR +2, DEX +1, CON +2, INT -1, WIS +1, CHA +0"
74+ special="Second wind: once per game, heal 1d10+1 (1 left)" ;;
75+ Rogue|rogue) class=Rogue hp=9 ac=14 attack="dagger +4, damage 1d6+2"
76+ abilities="STR +0, DEX +2, CON +1, INT +1, WIS -1, CHA +2"
77+ special="Sneak attack: +1d6 damage when the enemy does not see the hero" ;;
78+ Wizard|wizard) class=Wizard hp=7 ac=12 attack="fire bolt +4, damage 1d10"
79+ abilities="STR -1, DEX +1, CON +1, INT +2, WIS +2, CHA +0"
80+ special="Magic missile: 3d4+3 damage, never misses (3 left)" ;;
81+ Cleric|cleric) class=Cleric hp=10 ac=16 attack="mace +2, damage 1d6+1"
82+ abilities="STR +1, DEX -1, CON +2, INT +0, WIS +2, CHA +1"
83+ special="Healing word: heal 1d8+2 (3 left)" ;;
84+ *) die "class must be Warrior, Rogue, Wizard or Cleric (got: $2)" ;;
85+ esac
86+ mkdir -p game
87+ rm -f game/.moved-by game/.attacked-by
88+ cat > "$SHEET" <<SHEET
89+# $1
90+- Class: $class
91+- HP: $hp/$hp
92+- AC: $ac
93+- Attack: $attack
94+- Abilities: $abilities
95+- Special: $special
96+- Place: $start
97+- Gold: 10
98+- Inventory: healing potion
99+
100+## Encounter
101+none
102+SHEET
103+: > "$JOURNAL"
104+ show
105+}
106+
107+hp() {
108+ is_signed "$1" || die "usage: sh sheet.sh hp -3 (or +4)"
109+ not_after_attack
110+ cur=$(field HP | cut -d/ -f1); max=$(field HP | cut -d/ -f2)
111+ new=$((cur + $1))
112+ [ "$new" -lt 0 ] && new=0
113+ [ "$new" -gt "$max" ] && new=$max
114+ set_field HP "$new/$max"
115+ echo "hero HP: $cur/$max → $new/$max"
116+ log "❤ $1 → $new/$max"
117+ [ "$new" -eq 0 ] && echo "THE HERO HAS FALLEN"
118+ return 0
119+}
120+
121+meet() {
122+ # "Name : HP …" (a space before the colon, French typography) → "Name: HP …"
123+ line=$(echo "$1" | sed 's/^- *//; s/ *: */: /; s|HP *\([0-9]*\)/[0-9]*|HP \1|; s|HP *\([0-9]*\)|HP \1/\1|')
124+ echo "$line" | grep -q ': *HP [0-9]*/[0-9]*, *AC [0-9]*, *attack +[0-9]*, *damage [0-9dD+-]*' ||
125+ die 'usage: sh sheet.sh meet "<Name>: HP 8, AC 12, attack +3, damage 1d6+2"'
126+ name=${line%%:*}
127+ tmp=$(mktemp)
128+ NAME="$name" LINE="- $line" awk '
129+ /^## Encounter/ { print; inenc = 1; next }
130+ inenc && $0 == "none" { next }
131+ inenc && index($0, "- " ENVIRON["NAME"] ":") == 1 { next }
132+ { print }
133+ END { print ENVIRON["LINE"] }' "$SHEET" > "$tmp" && mv "$tmp" "$SHEET"
134+ echo "encounter: - $line"
135+}
136+
137+monster() {
138+ [ $# -eq 2 ] && is_signed "$2" || die 'usage: sh sheet.sh monster "<Name>" -5'
139+ not_after_attack
140+ line=$(sed -n '/^## Encounter/,$p' "$SHEET" | grep -F -- "- $1:" | head -1 || true)
141+ [ -n "$line" ] || die "no monster \"$1\" in ## Encounter"
142+ cur=$(echo "$line" | sed 's|.*HP *\([0-9]*\)/.*|\1|'); max=$(echo "$line" | sed 's|.*HP *[0-9]*/\([0-9]*\).*|\1|')
143+ new=$((cur + $2))
144+ [ "$new" -gt "$max" ] && new=$max
145+ tmp=$(mktemp)
146+ if [ "$new" -le 0 ]; then
147+ NAME="$1" awk 'index($0, "- " ENVIRON["NAME"] ":") == 1 { next } { print }' "$SHEET" > "$tmp"
148+ msg="$1 HP: $cur/$max → 0: defeated"
149+ else
150+ NAME="$1" NEW="$new" awk 'index($0, "- " ENVIRON["NAME"] ":") == 1 { sub(/HP *[0-9]+\//, "HP " ENVIRON["NEW"] "/") } { print }' "$SHEET" > "$tmp"
151+ msg="$1 HP: $cur/$max → $new/$max"
152+ fi
153+ mv "$tmp" "$SHEET"
154+ sed -n '/^## Encounter/,$p' "$SHEET" | grep -q '^- ' || clear_encounter >/dev/null
155+ echo "$msg"
156+}
157+
158+# mover: the process that runs this command — the first ancestor that is
159+# not a shell (bash -c, sh). For a storyteller asked by the GM, that is its
160+# own mm process, which lives for one request. Empty when ps is missing:
161+# then there is no guard.
162+mover() {
163+ p=$PPID
164+ for _ in 1 2 3 4 5; do
165+ comm=$(ps -o comm= -p "$p" 2>/dev/null | tr -d ' ') || return 0
166+ case "$comm" in
167+ bash|sh|dash|zsh|ash|-bash|-sh) p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ') ;;
168+ '') return 0 ;;
169+ *) echo "$p"; return 0 ;;
170+ esac
171+ done
172+}
173+
174+# go next|back|N: move along the places of game/adventure.md — once per
175+# request. Measured: a 4B storyteller asked to "go to the next place" ran
176+# `go next` three times in one turn (place 1 → 4). The marker game/.moved-by
177+# is erased at the start of every request by the storyteller's
178+# preGeneration hook (agent.storyteller.yaml): an asked agent's mm process
179+# serves several requests, so the process alone does not tell them apart.
180+go() {
181+ [ -f "$ADVENTURE" ] || die "no $ADVENTURE: create the adventure first"
182+ me=$(mover)
183+ if [ -n "$me" ] && [ -f game/.moved-by ] && [ "$(cat game/.moved-by)" = "$me" ]; then
184+ die "the hero has already moved for this request — describe the place you got: $(field Place)"
185+ fi
186+ cur=$(field Place | sed 's/^\([0-9]*\).*/\1/')
187+ case "$cur" in ''|*[!0-9]*) die "cannot read the hero's place number in $SHEET" ;; esac
188+ case "$1" in
189+ next|forward|suivant) target=$((cur + 1)) ;;
190+ back|previous|précédent) target=$((cur - 1)) ;;
191+ *[!0-9]*|'') die 'usage: sh sheet.sh go next | back | <number>' ;;
192+ *) target=$1 ;;
193+ esac
194+ place_line=$(grep "^$target\. " "$ADVENTURE" | head -1 || true)
195+ [ -n "$place_line" ] || die "there is no place $target in $ADVENTURE (the hero is at place $cur)"
196+ # Literal separators, not a bracket class: "—" is several bytes, and a
197+ # class would cut it in half outside a UTF-8 locale.
198+ place_name=$(echo "$place_line" | sed 's/^[0-9]*\. *//; s/ — .*//; s/ – .*//; s/ - .*//')
199+ set_field Place "$target. $place_name"
200+ [ -n "$me" ] && echo "$me" > game/.moved-by
201+ clear_encounter >/dev/null
202+ echo "PLACE: $target. $place_name"
203+ entry="→ $target. $place_name"
204+ echo "$place_line" | sed -n 's/.*\(Here\|Ici\|Aquí\|Hier\): */HERE: /p'
205+ # The monsters of ## Monsters whose name appears in the place's line.
206+ # (Variables named apart from meet's: a shell function has no locals.)
207+ sed -n '/^## Monsters/,/^## /p' "$ADVENTURE" | grep '^- .*: *HP' | while IFS= read -r m; do
208+ mname=$(echo "$m" | sed 's/^- *//; s/ *:.*//')
209+ if echo "$place_line" | grep -qiF -- "$mname"; then
210+ meet "$(echo "$m" | sed 's/^- *//')" >/dev/null
211+ echo "ENCOUNTER: $(echo "$m" | sed 's/^- *//')"
212+ fi
213+ done
214+ met=$(sed -n '/^## Encounter/,$p' "$SHEET" | grep '^- ' | sed 's/^- \([^:]*\):.*/\1/' | paste -sd ',' - | sed 's/,/, /g')
215+ log "$entry${met:+ · ⚔ $met}"
216+}
217+
218+# here: where the hero is, from the adventure — what a storyteller describes
219+# when the hero does not move.
220+here() {
221+ [ -f "$ADVENTURE" ] || die "no $ADVENTURE"
222+ n=$(field Place | sed 's/^\([0-9]*\).*/\1/')
223+ echo "PLACE: $(field Place)"
224+ grep "^$n\. " "$ADVENTURE" | head -1 | sed -n 's/^[0-9]*\. *//p'
225+ sed -n '/^## Encounter/,$p' "$SHEET" | grep '^- ' | sed 's/^- /ENCOUNTER: /' || true
226+}
227+
228+clear_encounter() {
229+ tmp=$(mktemp)
230+ awk '/^## Encounter/ { print; print "none"; exit } { print }' "$SHEET" > "$tmp" && mv "$tmp" "$SHEET"
231+ echo "encounter: none"
232+}
233+
234+gold() {
235+ is_signed "$1" || die "usage: sh sheet.sh gold +15 (or -5)"
236+ cur=$(field Gold); new=$((cur + $1)); [ "$new" -lt 0 ] && die "not enough gold ($cur)"
237+ set_field Gold "$new"; echo "gold: $cur → $new"
238+ log "$( [ "$1" -ge 0 ] 2>/dev/null && echo "+ ${1#+} gold" || echo "− ${1#-} gold") → $new"
239+}
240+
241+take() {
242+ inv=$(field Inventory)
243+ if [ -z "$inv" ] || [ "$inv" = "none" ]; then inv=$1; else inv="$inv, $1"; fi
244+ set_field Inventory "$inv"; echo "inventory: $inv"
245+ log "+ $1"
246+}
247+
248+use() {
249+ inv=$(field Inventory)
250+ new=$(ITEM="$1" INV="$inv" awk 'BEGIN { n = split(ENVIRON["INV"], a, /, */); out = ""; done = 0
251+ for (i = 1; i <= n; i++) { if (!done && a[i] == ENVIRON["ITEM"]) { done = 1; continue } out = out (out == "" ? "" : ", ") a[i] }
252+ if (!done) exit 1; print (out == "" ? "none" : out) }') || die "no \"$1\" in the inventory ($inv)"
253+ set_field Inventory "$new"; echo "inventory: $new"
254+ log "− $1"
255+}
256+
257+note() {
258+ [ -f "$JOURNAL" ] || die "no $JOURNAL"
259+ not_after_attack
260+ count=$(grep -c '^- ' "$JOURNAL" || true)
261+ n=$((count + 1))
262+ # The model sometimes numbers the line itself ("4. Brann…"): the script numbers.
263+ text=$(echo "$1" | sed 's/^ *-* *[0-9][0-9]*\. *//')
264+ echo "- $n. $text" >> "$JOURNAL"; echo "journal: - $n. $text"
265+}
266+
267+show() {
268+ need_sheet
269+ echo "HP $(field HP) · Place $(field Place) · Gold $(field Gold) · Inventory: $(field Inventory)"
270+ echo "Special: $(field Special)"
271+ echo "Encounter: $(sed -n '/^## Encounter/,$p' "$SHEET" | tail -n +2 | sed 's/^- //' | paste -sd ';' - | sed 's/;/; /g')"
272+}
273+
274+[ $# -ge 1 ] || die "usage: see the comments at the top of sheet.sh"
275+cmd=$1; shift
276+[ "$cmd" = new ] || need_sheet
277+case "$cmd" in
278+ new) new_hero "$@" ;;
279+ hp) [ $# -eq 1 ] || die "usage: sh sheet.sh hp -3"; hp "$1" ;;
280+ attacked) me=$(mover); [ -z "$me" ] || echo "$me" > game/.attacked-by ;;
281+ go) alive; [ $# -eq 1 ] || die 'usage: sh sheet.sh go next | back | <number>'; go "$1" ;;
282+ place) [ $# -eq 1 ] || die 'usage: sh sheet.sh place "<N. Place>"'; set_field Place "$1"; echo "place: $1" ;;
283+ meet) [ $# -eq 1 ] || die 'usage: sh sheet.sh meet "<Name>: HP 8, AC 12, attack +3, damage 1d6+2"'; meet "$1" ;;
284+ monster) monster "$@" ;;
285+ clear) clear_encounter ;;
286+ gold) alive; [ $# -eq 1 ] || die "usage: sh sheet.sh gold +15"; gold "$1" ;;
287+ take) alive; [ $# -eq 1 ] || die 'usage: sh sheet.sh take "<item>"'; take "$1" ;;
288+ use) [ $# -eq 1 ] || die 'usage: sh sheet.sh use "<item>"'; use "$1" ;;
289+ note) [ $# -eq 1 ] || die 'usage: sh sheet.sh note "<one sentence>"'; note "$1" ;;
290+ show) show ;;
291+ journal) tail -5 "$JOURNAL" ;;
292+ here) here ;;
293+ *) die "unknown command: $cmd" ;;
294+esac
new file mode 100755
@@ -0,0 +1,294 @@
1+#!/bin/sh
2+# sheet.sh — the hero sheet (game/hero.md) and the journal (game/journal.md).
3+# The scribe calls it; the model never rewrites the files nor adds numbers.
4+#
5+# sh sheet.sh new "<Name>" <Warrior|Rogue|Wizard|Cleric> 1 (the starting place's number)
6+# sh sheet.sh hp -3 the hero loses 3 HP (or +4: heals, never above max)
7+# sh sheet.sh go next the hero moves (next | back | <number>): reads
8+# game/adventure.md, sets Place, leaves the old
9+# monsters behind, meets the ones of the new place
10+# sh sheet.sh place "2. The Bone Hall"
11+# sh sheet.sh meet "Skeleton: HP 8, AC 12, attack +3, damage 1d6+2"
12+# sh sheet.sh monster "Skeleton" -5 a monster loses HP; at 0 it is removed
13+# sh sheet.sh clear no monster left in ## Encounter
14+# sh sheet.sh gold +15 (or -5)
15+# sh sheet.sh take "silver key"
16+# sh sheet.sh use "healing potion" removes one item from the inventory
17+# sh sheet.sh note "Brann opens the gate." appends a numbered journal line
18+#
19+# go, take, gold, use and hp (and roll.sh attack) also write their own
20+# journal line, with symbols rather than words so it reads in any language:
21+# → a move, ⚔ a combat round, ❤ HP, + an item or gold taken, − one used.
22+# sh sheet.sh here prints the hero's place (its line of the
23+# adventure) and the monsters facing the hero
24+# sh sheet.sh show prints the sheet
25+# sh sheet.sh journal prints the last 5 journal lines
26+set -eu
27+
28+SHEET=game/hero.md
29+JOURNAL=game/journal.md
30+ADVENTURE=game/adventure.md
31+
32+die() { echo "sheet.sh: $*" >&2; exit 1; }
33+# log TEXT: an automatic journal line, unless the caller writes its own
34+# (roll.sh attack sets SHEET_NOLOG=1 and writes one line for the round).
35+log() { [ -n "${SHEET_NOLOG:-}" ] || [ ! -f "$JOURNAL" ] || note "$1" >/dev/null; }
36+need_sheet() { [ -f "$SHEET" ] || die "no $SHEET: create the hero first (sh sheet.sh new …)"; }
37+# alive: refuse an action once the hero has fallen — the game is over.
38+alive() { case "$(field HP)" in 0/*) die "the hero has fallen (HP 0): the game is over — start a new game" ;; esac; }
39+# not_after_attack: roll.sh attack has already written this request's
40+# damage; measured, a referee then ran `sh sheet.sh hp -4` again and the
41+# hero fell from a blow counted twice. game/.attacked-by is erased at the
42+# start of every request by the referee's preGeneration hook.
43+not_after_attack() {
44+ [ -z "${SHEET_NOLOG:-}" ] || return 0
45+ me=$(mover)
46+ if [ -n "$me" ] && [ -f game/.attacked-by ] && [ "$(cat game/.attacked-by)" = "$me" ]; then
47+ die "roll.sh attack has already written this round into the sheet — do not apply its damage again"
48+ fi
49+}
50+is_signed() { case "$1" in [+-][0-9]*|[0-9]*) case "${1#[+-]}" in *[!0-9]*) return 1 ;; esac ;; *) return 1 ;; esac; }
51+
52+# set_field LABEL VALUE → rewrites the line "- LABEL: …"
53+set_field() {
54+ tmp=$(mktemp)
55+ LABEL="$1" VALUE="$2" awk '
56+ index($0, "- " ENVIRON["LABEL"] ":") == 1 { print "- " ENVIRON["LABEL"] ": " ENVIRON["VALUE"]; next }
57+ { print }' "$SHEET" > "$tmp" && mv "$tmp" "$SHEET"
58+}
59+field() { sed -n "s/^- $1: *//p" "$SHEET" | head -1; }
60+
61+new_hero() {
62+ [ $# -eq 3 ] || die 'usage: sh sheet.sh new "<Name>" <Warrior|Rogue|Wizard|Cleric> <place number>'
63+ # The place is named from the adventure itself: only its number counts,
64+ # so the name cannot come back translated or misspelt.
65+ start=$3
66+ num=$(echo "$3" | sed -n 's/^\([0-9][0-9]*\).*/\1/p')
67+ if [ -n "$num" ] && [ -f "$ADVENTURE" ]; then
68+ pname=$(grep "^$num\. " "$ADVENTURE" | head -1 | sed 's/^[0-9]*\. *//; s/ — .*//; s/ – .*//; s/ - .*//')
69+ [ -n "$pname" ] && start="$num. $pname"
70+ fi
71+ case "$2" in
72+ Warrior|warrior) class=Warrior hp=12 ac=16 attack="longsword +4, damage 1d8+2"
73+ abilities="STR +2, DEX +1, CON +2, INT -1, WIS +1, CHA +0"
74+ special="Second wind: once per game, heal 1d10+1 (1 left)" ;;
75+ Rogue|rogue) class=Rogue hp=9 ac=14 attack="dagger +4, damage 1d6+2"
76+ abilities="STR +0, DEX +2, CON +1, INT +1, WIS -1, CHA +2"
77+ special="Sneak attack: +1d6 damage when the enemy does not see the hero" ;;
78+ Wizard|wizard) class=Wizard hp=7 ac=12 attack="fire bolt +4, damage 1d10"
79+ abilities="STR -1, DEX +1, CON +1, INT +2, WIS +2, CHA +0"
80+ special="Magic missile: 3d4+3 damage, never misses (3 left)" ;;
81+ Cleric|cleric) class=Cleric hp=10 ac=16 attack="mace +2, damage 1d6+1"
82+ abilities="STR +1, DEX -1, CON +2, INT +0, WIS +2, CHA +1"
83+ special="Healing word: heal 1d8+2 (3 left)" ;;
84+ *) die "class must be Warrior, Rogue, Wizard or Cleric (got: $2)" ;;
85+ esac
86+ mkdir -p game
87+ rm -f game/.moved-by game/.attacked-by
88+ cat > "$SHEET" <<SHEET
89+# $1
90+- Class: $class
91+- HP: $hp/$hp
92+- AC: $ac
93+- Attack: $attack
94+- Abilities: $abilities
95+- Special: $special
96+- Place: $start
97+- Gold: 10
98+- Inventory: healing potion
99+
100+## Encounter
101+none
102+SHEET
103+: > "$JOURNAL"
104+ show
105+}
106+
107+hp() {
108+ is_signed "$1" || die "usage: sh sheet.sh hp -3 (or +4)"
109+ not_after_attack
110+ cur=$(field HP | cut -d/ -f1); max=$(field HP | cut -d/ -f2)
111+ new=$((cur + $1))
112+ [ "$new" -lt 0 ] && new=0
113+ [ "$new" -gt "$max" ] && new=$max
114+ set_field HP "$new/$max"
115+ echo "hero HP: $cur/$max → $new/$max"
116+ log "❤ $1 → $new/$max"
117+ [ "$new" -eq 0 ] && echo "THE HERO HAS FALLEN"
118+ return 0
119+}
120+
121+meet() {
122+ # "Name : HP …" (a space before the colon, French typography) → "Name: HP …"
123+ line=$(echo "$1" | sed 's/^- *//; s/ *: */: /; s|HP *\([0-9]*\)/[0-9]*|HP \1|; s|HP *\([0-9]*\)|HP \1/\1|')
124+ echo "$line" | grep -q ': *HP [0-9]*/[0-9]*, *AC [0-9]*, *attack +[0-9]*, *damage [0-9dD+-]*' ||
125+ die 'usage: sh sheet.sh meet "<Name>: HP 8, AC 12, attack +3, damage 1d6+2"'
126+ name=${line%%:*}
127+ tmp=$(mktemp)
128+ NAME="$name" LINE="- $line" awk '
129+ /^## Encounter/ { print; inenc = 1; next }
130+ inenc && $0 == "none" { next }
131+ inenc && index($0, "- " ENVIRON["NAME"] ":") == 1 { next }
132+ { print }
133+ END { print ENVIRON["LINE"] }' "$SHEET" > "$tmp" && mv "$tmp" "$SHEET"
134+ echo "encounter: - $line"
135+}
136+
137+monster() {
138+ [ $# -eq 2 ] && is_signed "$2" || die 'usage: sh sheet.sh monster "<Name>" -5'
139+ not_after_attack
140+ line=$(sed -n '/^## Encounter/,$p' "$SHEET" | grep -F -- "- $1:" | head -1 || true)
141+ [ -n "$line" ] || die "no monster \"$1\" in ## Encounter"
142+ cur=$(echo "$line" | sed 's|.*HP *\([0-9]*\)/.*|\1|'); max=$(echo "$line" | sed 's|.*HP *[0-9]*/\([0-9]*\).*|\1|')
143+ new=$((cur + $2))
144+ [ "$new" -gt "$max" ] && new=$max
145+ tmp=$(mktemp)
146+ if [ "$new" -le 0 ]; then
147+ NAME="$1" awk 'index($0, "- " ENVIRON["NAME"] ":") == 1 { next } { print }' "$SHEET" > "$tmp"
148+ msg="$1 HP: $cur/$max → 0: defeated"
149+ else
150+ NAME="$1" NEW="$new" awk 'index($0, "- " ENVIRON["NAME"] ":") == 1 { sub(/HP *[0-9]+\//, "HP " ENVIRON["NEW"] "/") } { print }' "$SHEET" > "$tmp"
151+ msg="$1 HP: $cur/$max → $new/$max"
152+ fi
153+ mv "$tmp" "$SHEET"
154+ sed -n '/^## Encounter/,$p' "$SHEET" | grep -q '^- ' || clear_encounter >/dev/null
155+ echo "$msg"
156+}
157+
158+# mover: the process that runs this command — the first ancestor that is
159+# not a shell (bash -c, sh). For a storyteller asked by the GM, that is its
160+# own mm process, which lives for one request. Empty when ps is missing:
161+# then there is no guard.
162+mover() {
163+ p=$PPID
164+ for _ in 1 2 3 4 5; do
165+ comm=$(ps -o comm= -p "$p" 2>/dev/null | tr -d ' ') || return 0
166+ case "$comm" in
167+ bash|sh|dash|zsh|ash|-bash|-sh) p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ') ;;
168+ '') return 0 ;;
169+ *) echo "$p"; return 0 ;;
170+ esac
171+ done
172+}
173+
174+# go next|back|N: move along the places of game/adventure.md — once per
175+# request. Measured: a 4B storyteller asked to "go to the next place" ran
176+# `go next` three times in one turn (place 1 → 4). The marker game/.moved-by
177+# is erased at the start of every request by the storyteller's
178+# preGeneration hook (agent.storyteller.yaml): an asked agent's mm process
179+# serves several requests, so the process alone does not tell them apart.
180+go() {
181+ [ -f "$ADVENTURE" ] || die "no $ADVENTURE: create the adventure first"
182+ me=$(mover)
183+ if [ -n "$me" ] && [ -f game/.moved-by ] && [ "$(cat game/.moved-by)" = "$me" ]; then
184+ die "the hero has already moved for this request — describe the place you got: $(field Place)"
185+ fi
186+ cur=$(field Place | sed 's/^\([0-9]*\).*/\1/')
187+ case "$cur" in ''|*[!0-9]*) die "cannot read the hero's place number in $SHEET" ;; esac
188+ case "$1" in
189+ next|forward|suivant) target=$((cur + 1)) ;;
190+ back|previous|précédent) target=$((cur - 1)) ;;
191+ *[!0-9]*|'') die 'usage: sh sheet.sh go next | back | <number>' ;;
192+ *) target=$1 ;;
193+ esac
194+ place_line=$(grep "^$target\. " "$ADVENTURE" | head -1 || true)
195+ [ -n "$place_line" ] || die "there is no place $target in $ADVENTURE (the hero is at place $cur)"
196+ # Literal separators, not a bracket class: "—" is several bytes, and a
197+ # class would cut it in half outside a UTF-8 locale.
198+ place_name=$(echo "$place_line" | sed 's/^[0-9]*\. *//; s/ — .*//; s/ – .*//; s/ - .*//')
199+ set_field Place "$target. $place_name"
200+ [ -n "$me" ] && echo "$me" > game/.moved-by
201+ clear_encounter >/dev/null
202+ echo "PLACE: $target. $place_name"
203+ entry="→ $target. $place_name"
204+ echo "$place_line" | sed -n 's/.*\(Here\|Ici\|Aquí\|Hier\): */HERE: /p'
205+ # The monsters of ## Monsters whose name appears in the place's line.
206+ # (Variables named apart from meet's: a shell function has no locals.)
207+ sed -n '/^## Monsters/,/^## /p' "$ADVENTURE" | grep '^- .*: *HP' | while IFS= read -r m; do
208+ mname=$(echo "$m" | sed 's/^- *//; s/ *:.*//')
209+ if echo "$place_line" | grep -qiF -- "$mname"; then
210+ meet "$(echo "$m" | sed 's/^- *//')" >/dev/null
211+ echo "ENCOUNTER: $(echo "$m" | sed 's/^- *//')"
212+ fi
213+ done
214+ met=$(sed -n '/^## Encounter/,$p' "$SHEET" | grep '^- ' | sed 's/^- \([^:]*\):.*/\1/' | paste -sd ',' - | sed 's/,/, /g')
215+ log "$entry${met:+ · ⚔ $met}"
216+}
217+
218+# here: where the hero is, from the adventure — what a storyteller describes
219+# when the hero does not move.
220+here() {
221+ [ -f "$ADVENTURE" ] || die "no $ADVENTURE"
222+ n=$(field Place | sed 's/^\([0-9]*\).*/\1/')
223+ echo "PLACE: $(field Place)"
224+ grep "^$n\. " "$ADVENTURE" | head -1 | sed -n 's/^[0-9]*\. *//p'
225+ sed -n '/^## Encounter/,$p' "$SHEET" | grep '^- ' | sed 's/^- /ENCOUNTER: /' || true
226+}
227+
228+clear_encounter() {
229+ tmp=$(mktemp)
230+ awk '/^## Encounter/ { print; print "none"; exit } { print }' "$SHEET" > "$tmp" && mv "$tmp" "$SHEET"
231+ echo "encounter: none"
232+}
233+
234+gold() {
235+ is_signed "$1" || die "usage: sh sheet.sh gold +15 (or -5)"
236+ cur=$(field Gold); new=$((cur + $1)); [ "$new" -lt 0 ] && die "not enough gold ($cur)"
237+ set_field Gold "$new"; echo "gold: $cur → $new"
238+ log "$( [ "$1" -ge 0 ] 2>/dev/null && echo "+ ${1#+} gold" || echo "− ${1#-} gold") → $new"
239+}
240+
241+take() {
242+ inv=$(field Inventory)
243+ if [ -z "$inv" ] || [ "$inv" = "none" ]; then inv=$1; else inv="$inv, $1"; fi
244+ set_field Inventory "$inv"; echo "inventory: $inv"
245+ log "+ $1"
246+}
247+
248+use() {
249+ inv=$(field Inventory)
250+ new=$(ITEM="$1" INV="$inv" awk 'BEGIN { n = split(ENVIRON["INV"], a, /, */); out = ""; done = 0
251+ for (i = 1; i <= n; i++) { if (!done && a[i] == ENVIRON["ITEM"]) { done = 1; continue } out = out (out == "" ? "" : ", ") a[i] }
252+ if (!done) exit 1; print (out == "" ? "none" : out) }') || die "no \"$1\" in the inventory ($inv)"
253+ set_field Inventory "$new"; echo "inventory: $new"
254+ log "− $1"
255+}
256+
257+note() {
258+ [ -f "$JOURNAL" ] || die "no $JOURNAL"
259+ not_after_attack
260+ count=$(grep -c '^- ' "$JOURNAL" || true)
261+ n=$((count + 1))
262+ # The model sometimes numbers the line itself ("4. Brann…"): the script numbers.
263+ text=$(echo "$1" | sed 's/^ *-* *[0-9][0-9]*\. *//')
264+ echo "- $n. $text" >> "$JOURNAL"; echo "journal: - $n. $text"
265+}
266+
267+show() {
268+ need_sheet
269+ echo "HP $(field HP) · Place $(field Place) · Gold $(field Gold) · Inventory: $(field Inventory)"
270+ echo "Special: $(field Special)"
271+ echo "Encounter: $(sed -n '/^## Encounter/,$p' "$SHEET" | tail -n +2 | sed 's/^- //' | paste -sd ';' - | sed 's/;/; /g')"
272+}
273+
274+[ $# -ge 1 ] || die "usage: see the comments at the top of sheet.sh"
275+cmd=$1; shift
276+[ "$cmd" = new ] || need_sheet
277+case "$cmd" in
278+ new) new_hero "$@" ;;
279+ hp) [ $# -eq 1 ] || die "usage: sh sheet.sh hp -3"; hp "$1" ;;
280+ attacked) me=$(mover); [ -z "$me" ] || echo "$me" > game/.attacked-by ;;
281+ go) alive; [ $# -eq 1 ] || die 'usage: sh sheet.sh go next | back | <number>'; go "$1" ;;
282+ place) [ $# -eq 1 ] || die 'usage: sh sheet.sh place "<N. Place>"'; set_field Place "$1"; echo "place: $1" ;;
283+ meet) [ $# -eq 1 ] || die 'usage: sh sheet.sh meet "<Name>: HP 8, AC 12, attack +3, damage 1d6+2"'; meet "$1" ;;
284+ monster) monster "$@" ;;
285+ clear) clear_encounter ;;
286+ gold) alive; [ $# -eq 1 ] || die "usage: sh sheet.sh gold +15"; gold "$1" ;;
287+ take) alive; [ $# -eq 1 ] || die 'usage: sh sheet.sh take "<item>"'; take "$1" ;;
288+ use) [ $# -eq 1 ] || die 'usage: sh sheet.sh use "<item>"'; use "$1" ;;
289+ note) [ $# -eq 1 ] || die 'usage: sh sheet.sh note "<one sentence>"'; note "$1" ;;
290+ show) show ;;
291+ journal) tail -5 "$JOURNAL" ;;
292+ here) here ;;
293+ *) die "unknown command: $cmd" ;;
294+esac
modified docs/en/how-to/guard-the-agent-with-hooks.md +1 -1
@@ -91,7 +91,7 @@ and the model's answer explains the refusal.
9191 - **Another language.** `command` is run by `sh -c`: `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (executable), or an inline `jq` pipeline all work.
9292 - **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.
9393 - **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`.
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>: <command> — denied by a hook`.
9595
9696 ## See also
9797
@@ -91,7 +91,7 @@ and the model's answer explains the refusal.
91 - **Another language.** `command` is run by `sh -c`: `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (executable), or an inline `jq` pipeline all work.91 - **Another language.** `command` is run by `sh -c`: `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (executable), or an inline `jq` pipeline all work.
92 - **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.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.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`.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>: <command> — denied by a hook`.
95 95
96 ## See also96 ## See also
97 97
modified docs/en/reference/acp.md +1 -1
@@ -97,7 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
9797 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
9898 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
9999 | `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+| `tool_call` then `tool_call_update` status `failed`, title `<tool>: <command or path> — 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>]`. |
101101 | `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. |
102102
103103 ## Available commands
@@ -97,7 +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+| `tool_call` then `tool_call_update` status `failed`, title `<tool>: <command or path> — 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>]`. |
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 | `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. |
102 102
103 ## Available commands103 ## Available commands
modified docs/en/reference/hooks.md +2 -2
@@ -57,7 +57,7 @@ All four run in every front end: the line mode, the window (`-tui`), an editor (
5757 | Command | `sh -c "<command>"` |
5858 | Working directory | the session's: the directory `mm` runs in, or the editor session's `cwd` over ACP |
5959 | Environment | `mm`'s own, plus the two variables below |
60-| stdin | one JSON object ([Input](#input)), then end of file |
60+| stdin | one JSON object ([Input](#input)), then end of file; `&`, `<` and `>` are written as they are (no `\u0026` escaping), so a hook can look for `&&` or `>` in a command with a plain text search |
6161 | stdout | empty, or one JSON object ([Reply](#reply)); at most 1 MiB |
6262 | stderr | quoted (first 300 bytes) in the failure line when the run fails; otherwise ignored |
6363 | Concurrency | hooks of one event run one after the other; `preToolCall`/`postToolCall` may run concurrently for two tool calls of one model reply |
@@ -140,7 +140,7 @@ A chain: each hook reads the `prompt` the previous one left; contexts accumulate
140140 |----------|----------|-----------------|---------------|
141141 | no change | runs with the model's arguments | the output | the usual tool line |
142142 | `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` |
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>: <command, path, URL or agent, 60 characters at most> — denied by a hook` (`<tool> — denied by a hook` when the input has none of these) |
144144
145145 The history keeps the tool call as the model wrote it, even when `input` replaced its arguments.
146146
@@ -57,7 +57,7 @@ All four run in every front end: the line mode, the window (`-tui`), an editor (
57 | Command | `sh -c "<command>"` |57 | Command | `sh -c "<command>"` |
58 | Working directory | the session's: the directory `mm` runs in, or the editor session's `cwd` over ACP |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 |59 | Environment | `mm`'s own, plus the two variables below |
60-| stdin | one JSON object ([Input](#input)), then end of file |60+| stdin | one JSON object ([Input](#input)), then end of file; `&`, `<` and `>` are written as they are (no `\u0026` escaping), so a hook can look for `&&` or `>` in a command with a plain text search |
61 | stdout | empty, or one JSON object ([Reply](#reply)); at most 1 MiB |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 |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 |63 | Concurrency | hooks of one event run one after the other; `preToolCall`/`postToolCall` may run concurrently for two tool calls of one model reply |
@@ -140,7 +140,7 @@ A chain: each hook reads the `prompt` the previous one left; contexts accumulate
140 |----------|----------|-----------------|---------------|140 |----------|----------|-----------------|---------------|
141 | no change | runs with the model's arguments | the output | the usual tool line |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) |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` |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>: <command, path, URL or agent, 60 characters at most> — denied by a hook` (`<tool> — denied by a hook` when the input has none of these) |
144 144
145 The history keeps the tool call as the model wrote it, even when `input` replaced its arguments.145 The history keeps the tool call as the model wrote it, even when `input` replaced its arguments.
146 146
modified docs/fr/how-to/guard-the-agent-with-hooks.md +1 -1
@@ -91,7 +91,7 @@ et la réponse du modèle explique le refus.
9191 - **Un autre langage.** `command` est exécutée par `sh -c` : `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (exécutable), ou une commande `jq` en ligne fonctionnent tous.
9292 - **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.
9393 - **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`.
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>: <commande> — denied by a hook`.
9595
9696 ## Voir aussi
9797
@@ -91,7 +91,7 @@ et la réponse du modèle explique le refus.
91 - **Un autre langage.** `command` est exécutée par `sh -c` : `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (exécutable), ou une commande `jq` en ligne fonctionnent tous.91 - **Un autre langage.** `command` est exécutée par `sh -c` : `python3 "$MM_CONFIG_DIR/hooks/guard.py"`, `"$MM_CONFIG_DIR/hooks/guard.sh"` (exécutable), ou une commande `jq` en ligne fonctionnent tous.
92 - **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.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.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`.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>: <commande> — denied by a hook`.
95 95
96 ## Voir aussi96 ## Voir aussi
97 97
modified docs/fr/reference/acp.md +1 -1
@@ -97,7 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
9797 | `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é. |
9898 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
9999 | `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+| `tool_call` puis `tool_call_update` statut `failed`, titre `<outil>: <commande ou chemin> — 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>]`. |
101101 | `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. |
102102
103103 ## Commandes disponibles
@@ -97,7 +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+| `tool_call` puis `tool_call_update` statut `failed`, titre `<outil>: <commande ou chemin> — 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>]`. |
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 | `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. |
102 102
103 ## Commandes disponibles103 ## Commandes disponibles
modified docs/fr/reference/hooks.md +2 -2
@@ -57,7 +57,7 @@ Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`
5757 | Commande | `sh -c "<command>"` |
5858 | 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 |
5959 | Environnement | celui de `mm`, plus les deux variables ci-dessous |
60-| stdin | un objet JSON ([Entrée](#entrée)), puis fin de fichier |
60+| stdin | un objet JSON ([Entrée](#entrée)), puis fin de fichier ; `&`, `<` et `>` y sont écrits tels quels (sans échappement `\u0026`), si bien qu'un hook peut chercher `&&` ou `>` dans une commande par simple recherche de texte |
6161 | stdout | vide, ou un objet JSON ([Réponse](#réponse)) ; 1 Mio au plus |
6262 | stderr | cité (300 premiers octets) dans la ligne d'échec quand l'exécution échoue ; ignoré sinon |
6363 | 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 |
@@ -140,7 +140,7 @@ En chaîne : chaque hook lit le `prompt` laissé par le précédent ; les contex
140140 |----------|---------|---------------|--------------------|
141141 | aucun changement | s'exécute avec les arguments du modèle | la sortie | la ligne habituelle de l'outil |
142142 | `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` |
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>: <commande, chemin, URL ou agent, 60 caractères au plus> — denied by a hook` (`<outil> — denied by a hook` quand l'entrée n'a rien de tout cela) |
144144
145145 L'historique garde l'appel d'outil tel que le modèle l'a écrit, même quand `input` a remplacé ses arguments.
146146
@@ -57,7 +57,7 @@ Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`
57 | Commande | `sh -c "<command>"` |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 |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 |59 | Environnement | celui de `mm`, plus les deux variables ci-dessous |
60-| stdin | un objet JSON ([Entrée](#entrée)), puis fin de fichier |60+| stdin | un objet JSON ([Entrée](#entrée)), puis fin de fichier ; `&`, `<` et `>` y sont écrits tels quels (sans échappement `\u0026`), si bien qu'un hook peut chercher `&&` ou `>` dans une commande par simple recherche de texte |
61 | stdout | vide, ou un objet JSON ([Réponse](#réponse)) ; 1 Mio au plus |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 |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 |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 |
@@ -140,7 +140,7 @@ En chaîne : chaque hook lit le `prompt` laissé par le précédent ; les contex
140 |----------|---------|---------------|--------------------|140 |----------|---------|---------------|--------------------|
141 | aucun changement | s'exécute avec les arguments du modèle | la sortie | la ligne habituelle de l'outil |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) |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` |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>: <commande, chemin, URL ou agent, 60 caractères au plus> — denied by a hook` (`<outil> — denied by a hook` quand l'entrée n'a rien de tout cela) |
144 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.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 146
modified internal/engine/hooks.go +20 -1
@@ -200,7 +200,7 @@ func deniedCall(call *hooks.ToolCall, reason string) *ai.MultipartToolResponse {
200200 text = "[denied by a hook: " + reason + "]"
201201 }
202202 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)}
203+ ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: call.Name, Title: deniedTitle(call), Kind: "other", Input: inputMap(call.Input)}
204204 s.ToolStart(ev)
205205 s.ToolEnd(ev.ID, ui.ToolResult{Output: text, Failed: true})
206206 } else {
@@ -209,6 +209,25 @@ func deniedCall(call *hooks.ToolCall, reason string) *ai.MultipartToolResponse {
209209 return &ai.MultipartToolResponse{Output: text}
210210 }
211211
212+// deniedTitle names the refused call the way the tools title their own
213+// calls — "bash: rm -rf build — denied by a hook" — so whoever watches (and
214+// an asking agent's front end, which shows titles only) sees WHAT was
215+// refused, not just that something was.
216+func deniedTitle(call *hooks.ToolCall) string {
217+ const suffix = " — denied by a hook"
218+ m := inputMap(call.Input)
219+ for _, key := range []string{"command", "path", "url", "agent"} {
220+ if v, ok := m[key].(string); ok && v != "" {
221+ v = strings.Join(strings.Fields(v), " ")
222+ if r := []rune(v); len(r) > 60 {
223+ v = string(r[:60]) + "…"
224+ }
225+ return call.Name + ": " + v + suffix
226+ }
227+ }
228+ return call.Name + suffix
229+}
230+
212231 // inputMap gives a client the call's arguments as an object when they are
213232 // one, nil otherwise.
214233 func inputMap(input any) map[string]any {
@@ -200,7 +200,7 @@ func deniedCall(call *hooks.ToolCall, reason string) *ai.MultipartToolResponse {
200 text = "[denied by a hook: " + reason + "]"200 text = "[denied by a hook: " + reason + "]"
201 }201 }
202 if s := ui.ActiveSink(); s != nil {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)}203+ ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: call.Name, Title: deniedTitle(call), Kind: "other", Input: inputMap(call.Input)}
204 s.ToolStart(ev)204 s.ToolStart(ev)
205 s.ToolEnd(ev.ID, ui.ToolResult{Output: text, Failed: true})205 s.ToolEnd(ev.ID, ui.ToolResult{Output: text, Failed: true})
206 } else {206 } else {
@@ -209,6 +209,25 @@ func deniedCall(call *hooks.ToolCall, reason string) *ai.MultipartToolResponse {
209 return &ai.MultipartToolResponse{Output: text}209 return &ai.MultipartToolResponse{Output: text}
210 }210 }
211 211
212+// deniedTitle names the refused call the way the tools title their own
213+// calls — "bash: rm -rf build — denied by a hook" — so whoever watches (and
214+// an asking agent's front end, which shows titles only) sees WHAT was
215+// refused, not just that something was.
216+func deniedTitle(call *hooks.ToolCall) string {
217+ const suffix = " — denied by a hook"
218+ m := inputMap(call.Input)
219+ for _, key := range []string{"command", "path", "url", "agent"} {
220+ if v, ok := m[key].(string); ok && v != "" {
221+ v = strings.Join(strings.Fields(v), " ")
222+ if r := []rune(v); len(r) > 60 {
223+ v = string(r[:60]) + "…"
224+ }
225+ return call.Name + ": " + v + suffix
226+ }
227+ }
228+ return call.Name + suffix
229+}
230+
212 // inputMap gives a client the call's arguments as an object when they are231 // inputMap gives a client the call's arguments as an object when they are
213 // one, nil otherwise.232 // one, nil otherwise.
214 func inputMap(input any) map[string]any {233 func inputMap(input any) map[string]any {
modified internal/engine/hooks_test.go +19 -1
@@ -170,7 +170,7 @@ func TestPreToolCallDenyIsReadByTheModel(t *testing.T) {
170170 if got := toolOutput(t, (*seen)[1]); got != "[denied by a hook: rm -rf is forbidden]" {
171171 t.Errorf("the model read %q", got)
172172 }
173- if len(sink.results) != 1 || !sink.results[0].Failed || !strings.Contains(sink.tools[0].Title, "denied") {
173+ if len(sink.results) != 1 || !sink.results[0].Failed || sink.tools[0].Title != "bash: rm -rf / — denied by a hook" {
174174 t.Errorf("front end saw %+v / %+v, want one failed, denied tool entry", sink.tools, sink.results)
175175 }
176176 }
@@ -270,3 +270,21 @@ func TestPostGenerationRunsAfterACancelledTurn(t *testing.T) {
270270 t.Errorf("the hook read %s, want the turn's error in it", data)
271271 }
272272 }
273+
274+func TestDeniedTitleNamesTheCall(t *testing.T) {
275+ long := strings.Repeat("x", 80)
276+ cases := []struct {
277+ call *hooks.ToolCall
278+ want string
279+ }{
280+ {&hooks.ToolCall{Name: "bash", Input: map[string]any{"command": "sed -i s/a/b/\n f"}}, "bash: sed -i s/a/b/ f — denied by a hook"},
281+ {&hooks.ToolCall{Name: "write_file", Input: map[string]any{"path": "game/hero.md", "content": "x"}}, "write_file: game/hero.md — denied by a hook"},
282+ {&hooks.ToolCall{Name: "bash", Input: map[string]any{"command": long}}, "bash: " + strings.Repeat("x", 60) + "… — denied by a hook"},
283+ {&hooks.ToolCall{Name: "plan", Input: map[string]any{"action": "list"}}, "plan — denied by a hook"},
284+ }
285+ for _, tc := range cases {
286+ if got := deniedTitle(tc.call); got != tc.want {
287+ t.Errorf("deniedTitle = %q, want %q", got, tc.want)
288+ }
289+ }
290+}
@@ -170,7 +170,7 @@ func TestPreToolCallDenyIsReadByTheModel(t *testing.T) {
170 if got := toolOutput(t, (*seen)[1]); got != "[denied by a hook: rm -rf is forbidden]" {170 if got := toolOutput(t, (*seen)[1]); got != "[denied by a hook: rm -rf is forbidden]" {
171 t.Errorf("the model read %q", got)171 t.Errorf("the model read %q", got)
172 }172 }
173- if len(sink.results) != 1 || !sink.results[0].Failed || !strings.Contains(sink.tools[0].Title, "denied") {173+ if len(sink.results) != 1 || !sink.results[0].Failed || sink.tools[0].Title != "bash: rm -rf / — denied by a hook" {
174 t.Errorf("front end saw %+v / %+v, want one failed, denied tool entry", sink.tools, sink.results)174 t.Errorf("front end saw %+v / %+v, want one failed, denied tool entry", sink.tools, sink.results)
175 }175 }
176 }176 }
@@ -270,3 +270,21 @@ func TestPostGenerationRunsAfterACancelledTurn(t *testing.T) {
270 t.Errorf("the hook read %s, want the turn's error in it", data)270 t.Errorf("the hook read %s, want the turn's error in it", data)
271 }271 }
272 }272 }
273+
274+func TestDeniedTitleNamesTheCall(t *testing.T) {
275+ long := strings.Repeat("x", 80)
276+ cases := []struct {
277+ call *hooks.ToolCall
278+ want string
279+ }{
280+ {&hooks.ToolCall{Name: "bash", Input: map[string]any{"command": "sed -i s/a/b/\n f"}}, "bash: sed -i s/a/b/ f — denied by a hook"},
281+ {&hooks.ToolCall{Name: "write_file", Input: map[string]any{"path": "game/hero.md", "content": "x"}}, "write_file: game/hero.md — denied by a hook"},
282+ {&hooks.ToolCall{Name: "bash", Input: map[string]any{"command": long}}, "bash: " + strings.Repeat("x", 60) + "… — denied by a hook"},
283+ {&hooks.ToolCall{Name: "plan", Input: map[string]any{"action": "list"}}, "plan — denied by a hook"},
284+ }
285+ for _, tc := range cases {
286+ if got := deniedTitle(tc.call); got != tc.want {
287+ t.Errorf("deniedTitle = %q, want %q", got, tc.want)
288+ }
289+ }
290+}
modified internal/hooks/exec.go +16 -1
@@ -46,7 +46,7 @@ type execFunc func(ctx context.Context, h config.Hook, env []string, dir string,
4646 // "continue". Every failure — the hook's exit, its timeout, an unreadable
4747 // reply, an unknown action, an explicit "error" — comes back as an error.
4848 func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) {
49- stdin, err := json.Marshal(in)
49+ stdin, err := encode(in)
5050 if err != nil {
5151 return Reply{}, fmt.Errorf("cannot encode the input: %w", err)
5252 }
@@ -58,6 +58,21 @@ func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, erro
5858 return decode(stdout, in.Event)
5959 }
6060
61+// encode writes the input as JSON WITHOUT Go's HTML escaping: json.Marshal
62+// turns & < > into \u0026 \u003c \u003e, and a hook that looks for "&&" or
63+// ">" in a command — a guard written in shell with grep — would not see them
64+// (found while writing the dungeon demo's guard: "go next && rm -rf …"
65+// passed it).
66+func encode(in Input) ([]byte, error) {
67+ var b bytes.Buffer
68+ enc := json.NewEncoder(&b)
69+ enc.SetEscapeHTML(false)
70+ if err := enc.Encode(in); err != nil {
71+ return nil, err
72+ }
73+ return bytes.TrimRight(b.Bytes(), "\n"), nil
74+}
75+
6176 // decode reads a hook's stdout. An action that does not belong to the event
6277 // (a "deny" from a preGeneration hook) is an error rather than a silent
6378 // continue: whoever wrote it meant to stop something.
@@ -46,7 +46,7 @@ type execFunc func(ctx context.Context, h config.Hook, env []string, dir string,
46 // "continue". Every failure — the hook's exit, its timeout, an unreadable46 // "continue". Every failure — the hook's exit, its timeout, an unreadable
47 // reply, an unknown action, an explicit "error" — comes back as an error.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) {48 func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) {
49- stdin, err := json.Marshal(in)49+ stdin, err := encode(in)
50 if err != nil {50 if err != nil {
51 return Reply{}, fmt.Errorf("cannot encode the input: %w", err)51 return Reply{}, fmt.Errorf("cannot encode the input: %w", err)
52 }52 }
@@ -58,6 +58,21 @@ func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, erro
58 return decode(stdout, in.Event)58 return decode(stdout, in.Event)
59 }59 }
60 60
61+// encode writes the input as JSON WITHOUT Go's HTML escaping: json.Marshal
62+// turns & < > into \u0026 \u003c \u003e, and a hook that looks for "&&" or
63+// ">" in a command — a guard written in shell with grep — would not see them
64+// (found while writing the dungeon demo's guard: "go next && rm -rf …"
65+// passed it).
66+func encode(in Input) ([]byte, error) {
67+ var b bytes.Buffer
68+ enc := json.NewEncoder(&b)
69+ enc.SetEscapeHTML(false)
70+ if err := enc.Encode(in); err != nil {
71+ return nil, err
72+ }
73+ return bytes.TrimRight(b.Bytes(), "\n"), nil
74+}
75+
61 // decode reads a hook's stdout. An action that does not belong to the event76 // 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 silent77 // (a "deny" from a preGeneration hook) is an error rather than a silent
63 // continue: whoever wrote it meant to stop something.78 // continue: whoever wrote it meant to stop something.
modified internal/hooks/hooks_test.go +15 -0
@@ -293,3 +293,18 @@ func TestNilRunnerIsSafe(t *testing.T) {
293293 }
294294 r.PostGeneration(context.Background(), Input{})
295295 }
296+
297+// A hook reads & < > as they are, not as \u0026 \u003c \u003e: a guard
298+// written in shell must see the "&&" of a chained command.
299+func TestHookReadsShellOperatorsLiterally(t *testing.T) {
300+ out := filepath.Join(t.TempDir(), "seen.json")
301+ r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{hook(`cat > "` + out + `"`)}})
302+ r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "ls && rm -rf x > y < z"}}})
303+ data, err := os.ReadFile(out)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ if !strings.Contains(string(data), `"command":"ls && rm -rf x > y < z"`) {
308+ t.Errorf("the hook read %s", data)
309+ }
310+}
@@ -293,3 +293,18 @@ func TestNilRunnerIsSafe(t *testing.T) {
293 }293 }
294 r.PostGeneration(context.Background(), Input{})294 r.PostGeneration(context.Background(), Input{})
295 }295 }
296+
297+// A hook reads & < > as they are, not as \u0026 \u003c \u003e: a guard
298+// written in shell must see the "&&" of a chained command.
299+func TestHookReadsShellOperatorsLiterally(t *testing.T) {
300+ out := filepath.Join(t.TempDir(), "seen.json")
301+ r, _ := runner(config.HooksConfig{PreToolCall: []config.Hook{hook(`cat > "` + out + `"`)}})
302+ r.PreToolCall(context.Background(), Input{Tool: &ToolCall{Name: "bash", Input: map[string]any{"command": "ls && rm -rf x > y < z"}}})
303+ data, err := os.ReadFile(out)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ if !strings.Contains(string(data), `"command":"ls && rm -rf x > y < z"`) {
308+ t.Errorf("the hook read %s", data)
309+ }
310+}