bots-garden/mini-mepublic Fork
⑂ 4a5788c
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

v1.0.7: .mm project folder, mm init/sbxinit/skillcreate, toolFormat xml_tools

Tickets 204, 205, 206, 208 (milestone v1.0.7) and 207 (v2.0.0):

- 204: mm looks for ./agent.yaml, then ./.mm/agent.yaml; a config inside a
  .mm folder resolves its relative paths (mmConfigs, hooks/MM_CONFIG_DIR,
  wasm tools, deciders, skillsDir in ACP) from the project root
  (config.BaseDir).
- 205: mm init asks first for the Project (.mm) layout, then offers sessions
  and skills (flags --project/--sessions/--skills yes|no, all default no;
  the file written without them is unchanged, pinned by golden previews).
- 206: new `mm skillcreate` wizard (internal/skillcreate): name, description,
  keywords, multi-line text; writes <skillsDir>/<name>/SKILL.md where the
  agent's config says. The mm init window is now generic over the two
  wizards, with a multi-line field.
- 207: toolFormat: xml_tools — for chat templates that read xml_tools and
  write <tool_call> text (SmolLM3): tools sent in
  chat_template_kwargs.xml_tools, <tool_call> blocks turned into tool calls
  (internal/engine/xmltools.go). mm init: smollm3 family (card 0.6/0.95,
  toolFormat xml_tools, thinkingOff).
- 208: mm sbxinit finds .mm/agent.yaml and writes, like bob-the-bot, .mm
  read-only with sessions/trace writable and AGENT_CONFIG via PROJECT_DIR,
  relative paths.
- Interrupt messages use 🔴 with two spaces.

Docs EN+FR updated (how-to, reference, explanation, package diagram).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
k33g committed 2026-11-13T13:05:42+01:00 Browse files
4a5788c parent: 28e11b2
modified .gitignore +4 -0
@@ -21,3 +21,7 @@ release
2121 backup
2222 .backup
2323 sidekicks
24+
25+bob
26+bob-the-bot
27+bob-the-smoll
\ No newline at end of file
@@ -21,3 +21,7 @@ release
21 backup21 backup
22 .backup 22 .backup
23 sidekicks23 sidekicks
24+
25+bob
26+bob-the-bot
27+bob-the-smoll
\ No newline at end of file\ No newline at end of file
added .tickets/issues/0204-look-for-the-config-in-the-mm-folder-too.yaml +96 -0
new file mode 100644
@@ -0,0 +1,96 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 204
3+title: Look for the config in the .mm folder too
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T03:48:34.000Z
9+updatedAt: 2026-11-13T12:04:33.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.7
14+epic: CLI
15+body: |
16+ When `mm` or `mm tui` is launched without a config argument, mini-me only looks
17+ for the config in the current directory (`./agent.yaml`, see `DefaultPath` in
18+ [internal/config/config.go](internal/config/config.go) and the resolution in
19+ [main.go](main.go): flag, lone argument, `$AGENT_CONFIG`, then `./agent.yaml`).
20+
21+ It should also look in the `.mm/` folder when it exists (`./.mm/agent.yaml`).
22+
23+ ## Acceptance criteria
24+ - `mm` and `mm tui` (and `mm acp`, same resolution) find `.mm/agent.yaml` when there is no `./agent.yaml`.
25+ - An explicit config (flag, argument, `AGENT_CONFIG`) still wins.
26+ - The `-config` help text and the docs (EN + FR) describe the new lookup order.
27+
28+ ## Decided
29+ - When both exist, `./agent.yaml` wins over `.mm/agent.yaml` (agreed 2026-11-13).
30+ - mm is installed globally and launched from the project root, so the relative
31+ paths of a config found in `.mm/` are relative to the **project directory**
32+ (the parent of `.mm/`), not to `.mm/` — hence `skillsDir: .mm/skills` in the
33+ example (agreed 2026-11-13). This must hold in ACP mode too.
34+
35+ ## Example
36+ [bob/.mm/agent.yaml](bob/.mm/agent.yaml): a project whose mini-me settings all
37+ live in `.mm/` (`skillsDir: .mm/skills`, `sessions.dir: .mm/sessions`).
38+ From `bob/`, `mm` and `mm tui` must start with that file, its skills and its sessions.
39+
40+ ## Trap: relative paths in ACP mode
41+ `resolveSkillsDir` ([main.go](main.go)) anchors a relative `skillsDir` to the
42+ current directory in the terminal (fine) but to the **config file's directory**
43+ in ACP mode, which would give `.mm/.mm/skills`. For a config found in `.mm/`,
44+ anchor to the parent of `.mm/` instead. Check every other path anchored to the
45+ config file (`sessions.dir`, mmConfigs, decider, toolRouting…) the same way.
46+tasks:
47+ - id: 1
48+ title: Look up .mm/agent.yaml after ./agent.yaml (mm, mm tui, mm acp)
49+ state: closed
50+ priority: none
51+ author:
52+ name: k33g
53+ email: ph.charriere@gmail.com
54+ createdAt: 2026-11-13T03:50:15.000Z
55+ updatedAt: 2026-11-13T04:39:26.000Z
56+ closedAt: 2026-11-13T04:39:26.000Z
57+ - id: 2
58+ title: Anchor relative paths of a .mm/ config to the project directory (ACP mode included)
59+ state: closed
60+ priority: none
61+ author:
62+ name: k33g
63+ email: ph.charriere@gmail.com
64+ createdAt: 2026-11-13T03:50:15.000Z
65+ updatedAt: 2026-11-13T04:39:26.000Z
66+ closedAt: 2026-11-13T04:39:26.000Z
67+ - id: 3
68+ title: Tests, -config help text and EN+FR docs
69+ state: closed
70+ priority: none
71+ author:
72+ name: k33g
73+ email: ph.charriere@gmail.com
74+ createdAt: 2026-11-13T03:50:15.000Z
75+ updatedAt: 2026-11-13T04:39:26.000Z
76+ closedAt: 2026-11-13T04:39:26.000Z
77+comments:
78+ - id: 1
79+ author:
80+ name: k33g
81+ email: ph.charriere@gmail.com
82+ createdAt: 2026-11-13T04:39:26.000Z
83+ body: |
84+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
85+
86+ - [internal/config/locate.go](internal/config/locate.go): `defaultPath()` (./agent.yaml, then ./.mm/agent.yaml) and `BaseDir()`, used by mmConfigs, hooks (`MM_CONFIG_DIR`), wasm tools, both deciders and `resolveSkillsDir` in ACP mode. The rule applies to any file directly in a `.mm` folder, also when named with `-config` / `AGENT_CONFIG`.
87+ - Tests: `internal/config/locate_test.go`, `skillsdir_test.go`; `go test ./...` green. Checked live on `bob/` (terminal and ACP).
88+ - Docs EN+FR: `how-to/keep-the-config-in-a-mm-folder.md`, `reference/cli.md`, `reference/hooks.md`, `reference/configuration.md`.
89+ - Quality gate not run: qlty is not installed in this sandbox.
90+ - id: 2
91+ author:
92+ name: k33g
93+ email: ph.charriere@gmail.com
94+ createdAt: 2026-11-13T12:04:33.000Z
95+ body: |
96+ Closed: merged into main (branch feature/config-in-mm-dir).
new file mode 100644
@@ -0,0 +1,96 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 204
3+title: Look for the config in the .mm folder too
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T03:48:34.000Z
9+updatedAt: 2026-11-13T12:04:33.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.7
14+epic: CLI
15+body: |
16+ When `mm` or `mm tui` is launched without a config argument, mini-me only looks
17+ for the config in the current directory (`./agent.yaml`, see `DefaultPath` in
18+ [internal/config/config.go](internal/config/config.go) and the resolution in
19+ [main.go](main.go): flag, lone argument, `$AGENT_CONFIG`, then `./agent.yaml`).
20+
21+ It should also look in the `.mm/` folder when it exists (`./.mm/agent.yaml`).
22+
23+ ## Acceptance criteria
24+ - `mm` and `mm tui` (and `mm acp`, same resolution) find `.mm/agent.yaml` when there is no `./agent.yaml`.
25+ - An explicit config (flag, argument, `AGENT_CONFIG`) still wins.
26+ - The `-config` help text and the docs (EN + FR) describe the new lookup order.
27+
28+ ## Decided
29+ - When both exist, `./agent.yaml` wins over `.mm/agent.yaml` (agreed 2026-11-13).
30+ - mm is installed globally and launched from the project root, so the relative
31+ paths of a config found in `.mm/` are relative to the **project directory**
32+ (the parent of `.mm/`), not to `.mm/` — hence `skillsDir: .mm/skills` in the
33+ example (agreed 2026-11-13). This must hold in ACP mode too.
34+
35+ ## Example
36+ [bob/.mm/agent.yaml](bob/.mm/agent.yaml): a project whose mini-me settings all
37+ live in `.mm/` (`skillsDir: .mm/skills`, `sessions.dir: .mm/sessions`).
38+ From `bob/`, `mm` and `mm tui` must start with that file, its skills and its sessions.
39+
40+ ## Trap: relative paths in ACP mode
41+ `resolveSkillsDir` ([main.go](main.go)) anchors a relative `skillsDir` to the
42+ current directory in the terminal (fine) but to the **config file's directory**
43+ in ACP mode, which would give `.mm/.mm/skills`. For a config found in `.mm/`,
44+ anchor to the parent of `.mm/` instead. Check every other path anchored to the
45+ config file (`sessions.dir`, mmConfigs, decider, toolRouting…) the same way.
46+tasks:
47+ - id: 1
48+ title: Look up .mm/agent.yaml after ./agent.yaml (mm, mm tui, mm acp)
49+ state: closed
50+ priority: none
51+ author:
52+ name: k33g
53+ email: ph.charriere@gmail.com
54+ createdAt: 2026-11-13T03:50:15.000Z
55+ updatedAt: 2026-11-13T04:39:26.000Z
56+ closedAt: 2026-11-13T04:39:26.000Z
57+ - id: 2
58+ title: Anchor relative paths of a .mm/ config to the project directory (ACP mode included)
59+ state: closed
60+ priority: none
61+ author:
62+ name: k33g
63+ email: ph.charriere@gmail.com
64+ createdAt: 2026-11-13T03:50:15.000Z
65+ updatedAt: 2026-11-13T04:39:26.000Z
66+ closedAt: 2026-11-13T04:39:26.000Z
67+ - id: 3
68+ title: Tests, -config help text and EN+FR docs
69+ state: closed
70+ priority: none
71+ author:
72+ name: k33g
73+ email: ph.charriere@gmail.com
74+ createdAt: 2026-11-13T03:50:15.000Z
75+ updatedAt: 2026-11-13T04:39:26.000Z
76+ closedAt: 2026-11-13T04:39:26.000Z
77+comments:
78+ - id: 1
79+ author:
80+ name: k33g
81+ email: ph.charriere@gmail.com
82+ createdAt: 2026-11-13T04:39:26.000Z
83+ body: |
84+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
85+
86+ - [internal/config/locate.go](internal/config/locate.go): `defaultPath()` (./agent.yaml, then ./.mm/agent.yaml) and `BaseDir()`, used by mmConfigs, hooks (`MM_CONFIG_DIR`), wasm tools, both deciders and `resolveSkillsDir` in ACP mode. The rule applies to any file directly in a `.mm` folder, also when named with `-config` / `AGENT_CONFIG`.
87+ - Tests: `internal/config/locate_test.go`, `skillsdir_test.go`; `go test ./...` green. Checked live on `bob/` (terminal and ACP).
88+ - Docs EN+FR: `how-to/keep-the-config-in-a-mm-folder.md`, `reference/cli.md`, `reference/hooks.md`, `reference/configuration.md`.
89+ - Quality gate not run: qlty is not installed in this sandbox.
90+ - id: 2
91+ author:
92+ name: k33g
93+ email: ph.charriere@gmail.com
94+ createdAt: 2026-11-13T12:04:33.000Z
95+ body: |
96+ Closed: merged into main (branch feature/config-in-mm-dir).
added .tickets/issues/0205-mm-init-project-mm-layout-optional-sessions-and-sk.yaml +156 -0
new file mode 100644
@@ -0,0 +1,156 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 205
3+title: "mm init: Project (.mm) layout, optional sessions and skills"
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T04:48:46.000Z
9+updatedAt: 2026-11-13T12:04:33.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.7
14+epic: Scaffolding
15+related:
16+ - 204
17+body: |
18+ Three changes to the `mm init` wizard ([internal/agentinit](internal/agentinit)).
19+
20+ ## 1. "Project (.mm)" layout — asked at the very first step
21+ A new first question: where the agent's files go.
22+ - **Project (.mm)**: the wizard creates a `.mm/` folder and everything goes in it,
23+ like the example [bob/.mm/agent.yaml](bob/.mm/agent.yaml): the config is written to
24+ `.mm/agent.yaml`, and every path in it is written **from the project root**
25+ (`skillsDir: .mm/skills`, `sessions.dir: .mm/sessions`) — see ticket 204
26+ ([#204](.tickets/issues/0204-look-for-the-config-in-the-mm-folder-too.yaml)):
27+ a config in `.mm/` resolves its relative paths from the parent of `.mm/`, and
28+ `mm` / `mm tui` find `.mm/agent.yaml` on their own.
29+ - **Current layout**: `agent.yaml` where the wizard writes it today.
30+
31+ ## 2. Sessions: offered, off by default
32+ A wizard step offers to turn sessions on (default: off). When on, the wizard writes
33+ the `sessions` section (`enabled: true`, `dir: .mm/sessions`). When off, it writes
34+ no section: the built-in default (off) applies, as today. Decided 2026-11-13.
35+
36+ ## 3. Skills: offered, off by default
37+ Today the wizard always writes `skillsDir: no-skills`. A wizard step offers to turn
38+ skills on (default: off). When on, the wizard writes `skillsDir` (`skills`, or
39+ `.mm/skills` with the Project layout) **and creates that folder**.
40+
41+ ## Acceptance criteria
42+ - The three questions exist in the line mode and in the window (`mm init` with a terminal).
43+ - With Project (.mm): `.mm/`, `.mm/agent.yaml` and (skills on) `.mm/skills/` are created;
44+ `mm` started from the project root picks the file, its skills and its sessions up.
45+ - The non-interactive path (`mm init --yes` + flags) can set all three; defaults:
46+ current layout, sessions off, skills off.
47+ - The comments the wizard writes in the file stay true (the "no skills" line, the
48+ "Next:" links).
49+ - Tests; docs EN+FR (`mm init` how-to / reference / explanation).
50+
51+ ## Flags (decided 2026-11-13)
52+ Same style as `-yolo yes|no`: absent = asked interactively, default with `--yes`.
53+
54+ | Flag | Values | Default with `--yes` | Effect |
55+ |------|--------|----------------------|--------|
56+ | `-project` | `yes` / `no` | `no` | creates `.mm/`, writes `.mm/agent.yaml`, paths from the project root |
57+ | `-sessions` | `yes` / `no` | `no` | writes the `sessions` section |
58+ | `-skills` | `yes` / `no` | `no` | writes `skillsDir` and creates the folder (`skills/` or `.mm/skills/`) |
59+
60+ - `-project yes` without `-o` → `.mm/agent.yaml`.
61+ - `-project yes` with an `-o` file outside `.mm/` → refused with a clear message.
62+ - `-o -` (stdout) stays allowed with every combination and **creates nothing** — no
63+ `.mm/`, no skills folder: it is the preview used by
64+ [.claude/skills/mm-init-presets/scripts/check.sh](.claude/skills/mm-init-presets/scripts/check.sh).
65+
66+ ## Impact on the mm-init-presets skill (checked 2026-11-13)
67+ None required: `presets.yaml` (families, usages, machines) does not change — skills and
68+ sessions are wizard questions, not usage presets. `check.sh` runs
69+ `mm init --yes --line … -o -` without the new flags, so its output must stay
70+ byte-for-byte what it is today (defaults: no project, no sessions, no skills) — pin it
71+ with a test.
72+
73+ ## Notes
74+ - The built-in default (`config.Cfg.Sessions.Enabled = false`) is unchanged.
75+ - Existing `.mm/agent.yaml` → the existing overwrite question applies to it.
76+tasks:
77+ - id: 1
78+ title: "Project (.mm) layout as the first step: .mm/ folder, .mm/agent.yaml, paths from the project root"
79+ state: closed
80+ priority: none
81+ author:
82+ name: k33g
83+ email: ph.charriere@gmail.com
84+ createdAt: 2026-11-13T04:48:46.000Z
85+ updatedAt: 2026-11-13T05:18:56.000Z
86+ closedAt: 2026-11-13T05:18:56.000Z
87+ - id: 2
88+ title: "Sessions step, off by default; when on write the sessions section"
89+ state: closed
90+ priority: none
91+ author:
92+ name: k33g
93+ email: ph.charriere@gmail.com
94+ createdAt: 2026-11-13T04:48:46.000Z
95+ updatedAt: 2026-11-13T05:18:56.000Z
96+ closedAt: 2026-11-13T05:18:56.000Z
97+ - id: 3
98+ title: "Skills step, off by default; when on write skillsDir and create the folder"
99+ state: closed
100+ priority: none
101+ author:
102+ name: k33g
103+ email: ph.charriere@gmail.com
104+ createdAt: 2026-11-13T04:48:46.000Z
105+ updatedAt: 2026-11-13T05:18:56.000Z
106+ closedAt: 2026-11-13T05:18:56.000Z
107+ - id: 4
108+ title: "Flags -project, -sessions, -skills (yes|no) for the non-interactive mode; -o - creates nothing"
109+ state: closed
110+ priority: none
111+ author:
112+ name: k33g
113+ email: ph.charriere@gmail.com
114+ createdAt: 2026-11-13T04:48:46.000Z
115+ updatedAt: 2026-11-13T05:18:56.000Z
116+ closedAt: 2026-11-13T05:18:56.000Z
117+ - id: 5
118+ title: "Tests, docs EN+FR"
119+ state: closed
120+ priority: none
121+ author:
122+ name: k33g
123+ email: ph.charriere@gmail.com
124+ createdAt: 2026-11-13T04:48:46.000Z
125+ updatedAt: 2026-11-13T05:18:56.000Z
126+ closedAt: 2026-11-13T05:18:56.000Z
127+comments:
128+ - id: 1
129+ author:
130+ name: k33g
131+ email: ph.charriere@gmail.com
132+ createdAt: 2026-11-13T05:18:56.000Z
133+ body: |
134+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
135+
136+ - [internal/agentinit](internal/agentinit): `Choices.Project/Sessions/Skills` (`*bool`, nil = no), steps `project` (first), `sessions`, `skills` (after `yolo`), flags `--project/--sessions/--skills yes|no`. The file question offers `.mm/agent.yaml` with the Project layout; a file outside `.mm/` is refused; changing the layout asks the file again. `.mm/` is created before writing, the skills folder after. `-o -` creates nothing.
137+ - The preview without the new flags is byte for byte the old one: pinned by `testdata/preview-{developer,researcher}.yaml`, generated from the code before the change (`TestPreviewWithoutTheNewFlagsIsUnchanged`) — the mm-init-presets skill's `check.sh` output is unchanged.
138+ - Tests: `internal/agentinit/layout_test.go`, `TestInitWindowAsksTheProjectLayoutFirst`; existing step-order tests updated for the three new questions. `go test ./...` green.
139+ - Checked live (llmman on the host): `mm init --yes --project yes --sessions yes --skills yes` in an empty dir → `.mm/agent.yaml` + `.mm/skills/`; `mm -p` used a skill from `.mm/skills`; a REPL turn saved `.mm/sessions/<id>.json`.
140+ - Docs EN+FR: `reference/cli.md` (mm init flags), `how-to/create-an-agent.md`, `explanation/mm-init.md`, `how-to/keep-the-config-in-a-mm-folder.md`.
141+ - Not updated: the sample window screen in `how-to/create-an-agent.md` still shows the old steps list.
142+ - Quality gate not run: qlty is not installed in this sandbox.
143+ - id: 2
144+ author:
145+ name: k33g
146+ email: ph.charriere@gmail.com
147+ createdAt: 2026-11-13T12:02:51.000Z
148+ body: |
149+ The sample window screen of `docs/{en,fr}/how-to/create-an-agent.md` is now regenerated from the real window (tcell simulation screen, 120×34, a fake llmman with Qwen3.5-4B, usage `docs` highlighted) — no hand edit, no broken characters. The step title "Project (.mm)" overflowed the steps column: shortened to "Project" (the question keeps ".mm").
150+ - id: 3
151+ author:
152+ name: k33g
153+ email: ph.charriere@gmail.com
154+ createdAt: 2026-11-13T12:04:33.000Z
155+ body: |
156+ Closed: merged into main (branch feature/config-in-mm-dir).
new file mode 100644
@@ -0,0 +1,156 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 205
3+title: "mm init: Project (.mm) layout, optional sessions and skills"
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T04:48:46.000Z
9+updatedAt: 2026-11-13T12:04:33.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.7
14+epic: Scaffolding
15+related:
16+ - 204
17+body: |
18+ Three changes to the `mm init` wizard ([internal/agentinit](internal/agentinit)).
19+
20+ ## 1. "Project (.mm)" layout — asked at the very first step
21+ A new first question: where the agent's files go.
22+ - **Project (.mm)**: the wizard creates a `.mm/` folder and everything goes in it,
23+ like the example [bob/.mm/agent.yaml](bob/.mm/agent.yaml): the config is written to
24+ `.mm/agent.yaml`, and every path in it is written **from the project root**
25+ (`skillsDir: .mm/skills`, `sessions.dir: .mm/sessions`) — see ticket 204
26+ ([#204](.tickets/issues/0204-look-for-the-config-in-the-mm-folder-too.yaml)):
27+ a config in `.mm/` resolves its relative paths from the parent of `.mm/`, and
28+ `mm` / `mm tui` find `.mm/agent.yaml` on their own.
29+ - **Current layout**: `agent.yaml` where the wizard writes it today.
30+
31+ ## 2. Sessions: offered, off by default
32+ A wizard step offers to turn sessions on (default: off). When on, the wizard writes
33+ the `sessions` section (`enabled: true`, `dir: .mm/sessions`). When off, it writes
34+ no section: the built-in default (off) applies, as today. Decided 2026-11-13.
35+
36+ ## 3. Skills: offered, off by default
37+ Today the wizard always writes `skillsDir: no-skills`. A wizard step offers to turn
38+ skills on (default: off). When on, the wizard writes `skillsDir` (`skills`, or
39+ `.mm/skills` with the Project layout) **and creates that folder**.
40+
41+ ## Acceptance criteria
42+ - The three questions exist in the line mode and in the window (`mm init` with a terminal).
43+ - With Project (.mm): `.mm/`, `.mm/agent.yaml` and (skills on) `.mm/skills/` are created;
44+ `mm` started from the project root picks the file, its skills and its sessions up.
45+ - The non-interactive path (`mm init --yes` + flags) can set all three; defaults:
46+ current layout, sessions off, skills off.
47+ - The comments the wizard writes in the file stay true (the "no skills" line, the
48+ "Next:" links).
49+ - Tests; docs EN+FR (`mm init` how-to / reference / explanation).
50+
51+ ## Flags (decided 2026-11-13)
52+ Same style as `-yolo yes|no`: absent = asked interactively, default with `--yes`.
53+
54+ | Flag | Values | Default with `--yes` | Effect |
55+ |------|--------|----------------------|--------|
56+ | `-project` | `yes` / `no` | `no` | creates `.mm/`, writes `.mm/agent.yaml`, paths from the project root |
57+ | `-sessions` | `yes` / `no` | `no` | writes the `sessions` section |
58+ | `-skills` | `yes` / `no` | `no` | writes `skillsDir` and creates the folder (`skills/` or `.mm/skills/`) |
59+
60+ - `-project yes` without `-o` → `.mm/agent.yaml`.
61+ - `-project yes` with an `-o` file outside `.mm/` → refused with a clear message.
62+ - `-o -` (stdout) stays allowed with every combination and **creates nothing** — no
63+ `.mm/`, no skills folder: it is the preview used by
64+ [.claude/skills/mm-init-presets/scripts/check.sh](.claude/skills/mm-init-presets/scripts/check.sh).
65+
66+ ## Impact on the mm-init-presets skill (checked 2026-11-13)
67+ None required: `presets.yaml` (families, usages, machines) does not change — skills and
68+ sessions are wizard questions, not usage presets. `check.sh` runs
69+ `mm init --yes --line … -o -` without the new flags, so its output must stay
70+ byte-for-byte what it is today (defaults: no project, no sessions, no skills) — pin it
71+ with a test.
72+
73+ ## Notes
74+ - The built-in default (`config.Cfg.Sessions.Enabled = false`) is unchanged.
75+ - Existing `.mm/agent.yaml` → the existing overwrite question applies to it.
76+tasks:
77+ - id: 1
78+ title: "Project (.mm) layout as the first step: .mm/ folder, .mm/agent.yaml, paths from the project root"
79+ state: closed
80+ priority: none
81+ author:
82+ name: k33g
83+ email: ph.charriere@gmail.com
84+ createdAt: 2026-11-13T04:48:46.000Z
85+ updatedAt: 2026-11-13T05:18:56.000Z
86+ closedAt: 2026-11-13T05:18:56.000Z
87+ - id: 2
88+ title: "Sessions step, off by default; when on write the sessions section"
89+ state: closed
90+ priority: none
91+ author:
92+ name: k33g
93+ email: ph.charriere@gmail.com
94+ createdAt: 2026-11-13T04:48:46.000Z
95+ updatedAt: 2026-11-13T05:18:56.000Z
96+ closedAt: 2026-11-13T05:18:56.000Z
97+ - id: 3
98+ title: "Skills step, off by default; when on write skillsDir and create the folder"
99+ state: closed
100+ priority: none
101+ author:
102+ name: k33g
103+ email: ph.charriere@gmail.com
104+ createdAt: 2026-11-13T04:48:46.000Z
105+ updatedAt: 2026-11-13T05:18:56.000Z
106+ closedAt: 2026-11-13T05:18:56.000Z
107+ - id: 4
108+ title: "Flags -project, -sessions, -skills (yes|no) for the non-interactive mode; -o - creates nothing"
109+ state: closed
110+ priority: none
111+ author:
112+ name: k33g
113+ email: ph.charriere@gmail.com
114+ createdAt: 2026-11-13T04:48:46.000Z
115+ updatedAt: 2026-11-13T05:18:56.000Z
116+ closedAt: 2026-11-13T05:18:56.000Z
117+ - id: 5
118+ title: "Tests, docs EN+FR"
119+ state: closed
120+ priority: none
121+ author:
122+ name: k33g
123+ email: ph.charriere@gmail.com
124+ createdAt: 2026-11-13T04:48:46.000Z
125+ updatedAt: 2026-11-13T05:18:56.000Z
126+ closedAt: 2026-11-13T05:18:56.000Z
127+comments:
128+ - id: 1
129+ author:
130+ name: k33g
131+ email: ph.charriere@gmail.com
132+ createdAt: 2026-11-13T05:18:56.000Z
133+ body: |
134+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
135+
136+ - [internal/agentinit](internal/agentinit): `Choices.Project/Sessions/Skills` (`*bool`, nil = no), steps `project` (first), `sessions`, `skills` (after `yolo`), flags `--project/--sessions/--skills yes|no`. The file question offers `.mm/agent.yaml` with the Project layout; a file outside `.mm/` is refused; changing the layout asks the file again. `.mm/` is created before writing, the skills folder after. `-o -` creates nothing.
137+ - The preview without the new flags is byte for byte the old one: pinned by `testdata/preview-{developer,researcher}.yaml`, generated from the code before the change (`TestPreviewWithoutTheNewFlagsIsUnchanged`) — the mm-init-presets skill's `check.sh` output is unchanged.
138+ - Tests: `internal/agentinit/layout_test.go`, `TestInitWindowAsksTheProjectLayoutFirst`; existing step-order tests updated for the three new questions. `go test ./...` green.
139+ - Checked live (llmman on the host): `mm init --yes --project yes --sessions yes --skills yes` in an empty dir → `.mm/agent.yaml` + `.mm/skills/`; `mm -p` used a skill from `.mm/skills`; a REPL turn saved `.mm/sessions/<id>.json`.
140+ - Docs EN+FR: `reference/cli.md` (mm init flags), `how-to/create-an-agent.md`, `explanation/mm-init.md`, `how-to/keep-the-config-in-a-mm-folder.md`.
141+ - Not updated: the sample window screen in `how-to/create-an-agent.md` still shows the old steps list.
142+ - Quality gate not run: qlty is not installed in this sandbox.
143+ - id: 2
144+ author:
145+ name: k33g
146+ email: ph.charriere@gmail.com
147+ createdAt: 2026-11-13T12:02:51.000Z
148+ body: |
149+ The sample window screen of `docs/{en,fr}/how-to/create-an-agent.md` is now regenerated from the real window (tcell simulation screen, 120×34, a fake llmman with Qwen3.5-4B, usage `docs` highlighted) — no hand edit, no broken characters. The step title "Project (.mm)" overflowed the steps column: shortened to "Project" (the question keeps ".mm").
150+ - id: 3
151+ author:
152+ name: k33g
153+ email: ph.charriere@gmail.com
154+ createdAt: 2026-11-13T12:04:33.000Z
155+ body: |
156+ Closed: merged into main (branch feature/config-in-mm-dir).
added .tickets/issues/0206-mm-skillcreate-a-wizard-that-writes-a-new-skill.yaml +150 -0
new file mode 100644
@@ -0,0 +1,150 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 206
3+title: "mm skillcreate: a wizard that writes a new skill"
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T05:29:17.000Z
9+updatedAt: 2026-11-13T12:04:33.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.7
14+epic: Scaffolding
15+related:
16+ - 205
17+body: |
18+ A new command, `mm skillcreate` (beside `mm init`, `mm dockerinit`, `mm sbxinit`),
19+ that asks for a skill and writes it where the agent reads its skills.
20+
21+ ## Questions
22+ 1. **Name** — normalised: lower case, spaces replaced by dashes
23+ (`Vulcan Salute` → `vulcan-salute`). The normalised name is the front matter's
24+ `name` and the skill's folder.
25+ 2. **Description** — one line, for the catalogue.
26+ 3. **Keywords** — comma-separated; only the first 8 reach the catalogue
27+ ([reference/skill-format.md](docs/en/reference/skill-format.md)): warn beyond 8.
28+ 4. **Body** — the text of the skill, several lines. Always the **last** question: in
29+ the line mode it ends with the end of the input (EOF, Ctrl-D in a terminal), after
30+ which nothing else can be asked. An existing skill of that name is therefore
31+ settled right after the name question (overwrite? / another name).
32+
33+ ## Generated file
34+ Same shape as [bob/.mm/skills/greetings/SKILL.md](bob/.mm/skills/greetings/SKILL.md):
35+
36+ ```markdown
37+ ---
38+ name: vulcan-salute
39+ description: <description>
40+ keywords: <k1>, <k2>, …
41+ ---
42+
43+ # Vulcan Salute
44+
45+ ## What to do
46+
47+ <body>
48+ ```
49+
50+ ## Level-1 title (decided 2026-11-13)
51+ The name **as typed**: `Vulcan Salute` → `# Vulcan Salute`. When the typed name is
52+ already normalised, it is turned back into a readable title — dashes to spaces, each
53+ word capitalised: `vulcan-salute` → `# Vulcan Salute`, `greetings` → `# Greetings`.
54+
55+ ## Where it is written
56+ `<skillsDir>/<name>/SKILL.md`, `skillsDir` read from the agent's config:
57+ - the config is found as `mm` finds it (`-config`, `AGENT_CONFIG`, `./agent.yaml`,
58+ then `./.mm/agent.yaml` — ticket 204); a relative `skillsDir` is relative to the
59+ directory mm is started from (the project root), e.g. `.mm/skills` for a
60+ `.mm/agent.yaml` written by `mm init --project yes` (ticket 205);
61+ - no config, or `skillsDir: no-skills` → default to `.mm/skills` when a `.mm/` folder
62+ exists, else `skills`, and say that `skillsDir` must be set in the config for the
63+ agent to see the skill;
64+ - the skills folder is created if missing; an existing skill of that name is never
65+ replaced without agreement (same rule as `mm init`).
66+
67+ ## Acceptance criteria
68+ - Window in a terminal, line by line otherwise or with `--line`, like `mm init`.
69+ - Flags for every question (`--name`, `--description`, `--keywords`, `--body` /
70+ `--body-file`, `-config`) and `--yes` for scripts; `-o -` prints without writing.
71+ - After writing: mm's skill loader reads it back (name, description, keywords) and
72+ the next step is printed (`mm` and ask for it, or `/skills`).
73+ - Tests; docs EN+FR (how-to "add a skill", CLI reference).
74+
75+ ## Decided (2026-11-13)
76+ - Level-1 title: see above.
77+ - Line mode: the body ends with the end of the input (EOF).
78+tasks:
79+ - id: 1
80+ title: "Command mm skillcreate + flags (--name, --description, --keywords, --body/--body-file, -config, --yes, --line, -o)"
81+ state: closed
82+ priority: none
83+ author:
84+ name: k33g
85+ email: ph.charriere@gmail.com
86+ createdAt: 2026-11-13T05:29:17.000Z
87+ updatedAt: 2026-11-13T05:44:37.000Z
88+ closedAt: 2026-11-13T05:44:37.000Z
89+ - id: 2
90+ title: "Name normalisation, front matter, level-1 title (as typed, or de-normalised), ## What to do, body"
91+ state: closed
92+ priority: none
93+ author:
94+ name: k33g
95+ email: ph.charriere@gmail.com
96+ createdAt: 2026-11-13T05:29:17.000Z
97+ updatedAt: 2026-11-13T05:44:37.000Z
98+ closedAt: 2026-11-13T05:44:37.000Z
99+ - id: 3
100+ title: "Find skillsDir from the agent config (.mm aware), create the folder, never overwrite silently"
101+ state: closed
102+ priority: none
103+ author:
104+ name: k33g
105+ email: ph.charriere@gmail.com
106+ createdAt: 2026-11-13T05:29:17.000Z
107+ updatedAt: 2026-11-13T05:44:37.000Z
108+ closedAt: 2026-11-13T05:44:37.000Z
109+ - id: 4
110+ title: "Window and line mode (multi-line body, last question, ends at EOF in the line mode)"
111+ state: closed
112+ priority: none
113+ author:
114+ name: k33g
115+ email: ph.charriere@gmail.com
116+ createdAt: 2026-11-13T05:29:17.000Z
117+ updatedAt: 2026-11-13T05:44:37.000Z
118+ closedAt: 2026-11-13T05:44:37.000Z
119+ - id: 5
120+ title: "Tests, docs EN+FR"
121+ state: closed
122+ priority: none
123+ author:
124+ name: k33g
125+ email: ph.charriere@gmail.com
126+ createdAt: 2026-11-13T05:29:17.000Z
127+ updatedAt: 2026-11-13T05:44:37.000Z
128+ closedAt: 2026-11-13T05:44:37.000Z
129+comments:
130+ - id: 1
131+ author:
132+ name: k33g
133+ email: ph.charriere@gmail.com
134+ createdAt: 2026-11-13T05:44:37.000Z
135+ body: |
136+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
137+
138+ - New package [internal/skillcreate](internal/skillcreate): `Normalize`, `Title`, `Render` (skill.go); `Locate`, `Write`, `Verify` with mm's own loader (place.go); `Flow` (name → overwrite? → description → keywords → body, body last); `Main` with flags `--name --description --keywords --body --body-file -config -o --yes --line`. Wired in `main.go` (`mm skillcreate`, `--skillcreate`).
139+ - Window: `internal/tui/initwin.go` now drives a small `wizardFlow` interface (mm init and mm skillcreate); `agentinit.Step` gained `Multiline` (Enter = new line, Ctrl-D = answer, ↑↓ between lines) and `Optional` (empty field, Enter = none — the keywords).
140+ - Tests: `internal/skillcreate/*_test.go`, TUI `TestSkillWindowWritesAMultiLineText`, `TestFieldMovesBetweenLines`, `TestOptionalAnswerStartsEmpty`; `go test ./...` green.
141+ - Checked live: line mode in a `mm init --project yes` project → `.mm/skills/pirate-greeting/SKILL.md`, read back by mm; the agent then used it ("Ahoy matey, Philippe!"). The window was NOT run in a real terminal (none in this sandbox): simulated-screen tests only.
142+ - Docs EN+FR: `reference/cli.md` (new § mm skillcreate, invocation line), `how-to/add-a-skill.md` (variant). Package diagram: skillcreate added (+ the missing main/tui → agentinit edges).
143+ - Quality gate not run: qlty is not installed in this sandbox.
144+ - id: 2
145+ author:
146+ name: k33g
147+ email: ph.charriere@gmail.com
148+ createdAt: 2026-11-13T12:04:33.000Z
149+ body: |
150+ Closed: merged into main (branch feature/config-in-mm-dir).
new file mode 100644
@@ -0,0 +1,150 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 206
3+title: "mm skillcreate: a wizard that writes a new skill"
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T05:29:17.000Z
9+updatedAt: 2026-11-13T12:04:33.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.7
14+epic: Scaffolding
15+related:
16+ - 205
17+body: |
18+ A new command, `mm skillcreate` (beside `mm init`, `mm dockerinit`, `mm sbxinit`),
19+ that asks for a skill and writes it where the agent reads its skills.
20+
21+ ## Questions
22+ 1. **Name** — normalised: lower case, spaces replaced by dashes
23+ (`Vulcan Salute` → `vulcan-salute`). The normalised name is the front matter's
24+ `name` and the skill's folder.
25+ 2. **Description** — one line, for the catalogue.
26+ 3. **Keywords** — comma-separated; only the first 8 reach the catalogue
27+ ([reference/skill-format.md](docs/en/reference/skill-format.md)): warn beyond 8.
28+ 4. **Body** — the text of the skill, several lines. Always the **last** question: in
29+ the line mode it ends with the end of the input (EOF, Ctrl-D in a terminal), after
30+ which nothing else can be asked. An existing skill of that name is therefore
31+ settled right after the name question (overwrite? / another name).
32+
33+ ## Generated file
34+ Same shape as [bob/.mm/skills/greetings/SKILL.md](bob/.mm/skills/greetings/SKILL.md):
35+
36+ ```markdown
37+ ---
38+ name: vulcan-salute
39+ description: <description>
40+ keywords: <k1>, <k2>, …
41+ ---
42+
43+ # Vulcan Salute
44+
45+ ## What to do
46+
47+ <body>
48+ ```
49+
50+ ## Level-1 title (decided 2026-11-13)
51+ The name **as typed**: `Vulcan Salute` → `# Vulcan Salute`. When the typed name is
52+ already normalised, it is turned back into a readable title — dashes to spaces, each
53+ word capitalised: `vulcan-salute` → `# Vulcan Salute`, `greetings` → `# Greetings`.
54+
55+ ## Where it is written
56+ `<skillsDir>/<name>/SKILL.md`, `skillsDir` read from the agent's config:
57+ - the config is found as `mm` finds it (`-config`, `AGENT_CONFIG`, `./agent.yaml`,
58+ then `./.mm/agent.yaml` — ticket 204); a relative `skillsDir` is relative to the
59+ directory mm is started from (the project root), e.g. `.mm/skills` for a
60+ `.mm/agent.yaml` written by `mm init --project yes` (ticket 205);
61+ - no config, or `skillsDir: no-skills` → default to `.mm/skills` when a `.mm/` folder
62+ exists, else `skills`, and say that `skillsDir` must be set in the config for the
63+ agent to see the skill;
64+ - the skills folder is created if missing; an existing skill of that name is never
65+ replaced without agreement (same rule as `mm init`).
66+
67+ ## Acceptance criteria
68+ - Window in a terminal, line by line otherwise or with `--line`, like `mm init`.
69+ - Flags for every question (`--name`, `--description`, `--keywords`, `--body` /
70+ `--body-file`, `-config`) and `--yes` for scripts; `-o -` prints without writing.
71+ - After writing: mm's skill loader reads it back (name, description, keywords) and
72+ the next step is printed (`mm` and ask for it, or `/skills`).
73+ - Tests; docs EN+FR (how-to "add a skill", CLI reference).
74+
75+ ## Decided (2026-11-13)
76+ - Level-1 title: see above.
77+ - Line mode: the body ends with the end of the input (EOF).
78+tasks:
79+ - id: 1
80+ title: "Command mm skillcreate + flags (--name, --description, --keywords, --body/--body-file, -config, --yes, --line, -o)"
81+ state: closed
82+ priority: none
83+ author:
84+ name: k33g
85+ email: ph.charriere@gmail.com
86+ createdAt: 2026-11-13T05:29:17.000Z
87+ updatedAt: 2026-11-13T05:44:37.000Z
88+ closedAt: 2026-11-13T05:44:37.000Z
89+ - id: 2
90+ title: "Name normalisation, front matter, level-1 title (as typed, or de-normalised), ## What to do, body"
91+ state: closed
92+ priority: none
93+ author:
94+ name: k33g
95+ email: ph.charriere@gmail.com
96+ createdAt: 2026-11-13T05:29:17.000Z
97+ updatedAt: 2026-11-13T05:44:37.000Z
98+ closedAt: 2026-11-13T05:44:37.000Z
99+ - id: 3
100+ title: "Find skillsDir from the agent config (.mm aware), create the folder, never overwrite silently"
101+ state: closed
102+ priority: none
103+ author:
104+ name: k33g
105+ email: ph.charriere@gmail.com
106+ createdAt: 2026-11-13T05:29:17.000Z
107+ updatedAt: 2026-11-13T05:44:37.000Z
108+ closedAt: 2026-11-13T05:44:37.000Z
109+ - id: 4
110+ title: "Window and line mode (multi-line body, last question, ends at EOF in the line mode)"
111+ state: closed
112+ priority: none
113+ author:
114+ name: k33g
115+ email: ph.charriere@gmail.com
116+ createdAt: 2026-11-13T05:29:17.000Z
117+ updatedAt: 2026-11-13T05:44:37.000Z
118+ closedAt: 2026-11-13T05:44:37.000Z
119+ - id: 5
120+ title: "Tests, docs EN+FR"
121+ state: closed
122+ priority: none
123+ author:
124+ name: k33g
125+ email: ph.charriere@gmail.com
126+ createdAt: 2026-11-13T05:29:17.000Z
127+ updatedAt: 2026-11-13T05:44:37.000Z
128+ closedAt: 2026-11-13T05:44:37.000Z
129+comments:
130+ - id: 1
131+ author:
132+ name: k33g
133+ email: ph.charriere@gmail.com
134+ createdAt: 2026-11-13T05:44:37.000Z
135+ body: |
136+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
137+
138+ - New package [internal/skillcreate](internal/skillcreate): `Normalize`, `Title`, `Render` (skill.go); `Locate`, `Write`, `Verify` with mm's own loader (place.go); `Flow` (name → overwrite? → description → keywords → body, body last); `Main` with flags `--name --description --keywords --body --body-file -config -o --yes --line`. Wired in `main.go` (`mm skillcreate`, `--skillcreate`).
139+ - Window: `internal/tui/initwin.go` now drives a small `wizardFlow` interface (mm init and mm skillcreate); `agentinit.Step` gained `Multiline` (Enter = new line, Ctrl-D = answer, ↑↓ between lines) and `Optional` (empty field, Enter = none — the keywords).
140+ - Tests: `internal/skillcreate/*_test.go`, TUI `TestSkillWindowWritesAMultiLineText`, `TestFieldMovesBetweenLines`, `TestOptionalAnswerStartsEmpty`; `go test ./...` green.
141+ - Checked live: line mode in a `mm init --project yes` project → `.mm/skills/pirate-greeting/SKILL.md`, read back by mm; the agent then used it ("Ahoy matey, Philippe!"). The window was NOT run in a real terminal (none in this sandbox): simulated-screen tests only.
142+ - Docs EN+FR: `reference/cli.md` (new § mm skillcreate, invocation line), `how-to/add-a-skill.md` (variant). Package diagram: skillcreate added (+ the missing main/tui → agentinit edges).
143+ - Quality gate not run: qlty is not installed in this sandbox.
144+ - id: 2
145+ author:
146+ name: k33g
147+ email: ph.charriere@gmail.com
148+ createdAt: 2026-11-13T12:04:33.000Z
149+ body: |
150+ Closed: merged into main (branch feature/config-in-mm-dir).
added .tickets/issues/0207-tool-calls-for-models-whose-template-reads-xml-tool.yaml +144 -0
new file mode 100644
@@ -0,0 +1,144 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 207
3+title: "Tool calls for models whose template reads xml_tools (SmolLM3)"
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T06:15:19.000Z
9+updatedAt: 2026-11-13T06:34:37.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+ - tools
14+milestone: v2.0.0
15+epic: Providers (LLM Engine)
16+body: |
17+ SmolLM3 ([unsloth/SmolLM3-3B-GGUF](https://huggingface.co/unsloth/SmolLM3-3B-GGUF),
18+ [SmolLM3-3B-128K-GGUF](https://huggingface.co/unsloth/SmolLM3-3B-128K-GGUF)) supports
19+ tool calling according to its card, but not through mm: its chat template reads the
20+ tools from `xml_tools` (or `python_tools`) — the variable of transformers'
21+ `apply_chat_template(xml_tools=…)` — never from the standard `tools` that mm sends
22+ over the OpenAI API and that llama-server passes to the template.
23+
24+ ## Measured (2026-11-13, llmman, SmolLM3-3B Q4_K_M)
25+ - Template rendered with Jinja2: with `tools=[bash]` the prompt holds **no tool**; with
26+ `xml_tools=[bash]` it gets a `### Tools` section and the `<tool_call>` format.
27+ - Request with `tools` only: no tool call, reasoning on or off — the model describes
28+ `ls` instead (also seen live in the user's `bob-the-smoll/`, developer usage).
29+ - Request with `tools` + `chat_template_kwargs.xml_tools`: the model answers
30+ `<tool_call>{"name": "bash", "arguments": {"command": "ls"}}</tool_call>` as plain
31+ **content**, `tool_calls: null`: llama-server does not parse it.
32+
33+ Today the `smollm3` family of `mm init` (presets.yaml) carries a note: use a usage
34+ without tools (translator, json).
35+
36+ ## What mm would need
37+ 1. **Send the tools where the template reads them**: also put the tool definitions in
38+ `chat_template_kwargs.xml_tools` (same JSON as `tools`), on every request that has
39+ tools. Possible today by hand in `sampling`, but it duplicates every tool definition.
40+ 2. **Read the calls back**: turn `<tool_call>{json}</tool_call>` blocks of the answer's
41+ content into real tool calls before Genkit sees the response — streamed and not
42+ streamed — and keep them out of the displayed text. Candidates: the openai-go
43+ middleware already used for the SSE stream
44+ ([internal/engine/reasoning.go](internal/engine/reasoning.go), `reasoningTap`/`sseTap`),
45+ or a WrapModel hook like `dropReasoning`.
46+
47+ ## Acceptance criteria
48+ - Opt-in, per agent file (e.g. `toolFormat: xml_tools`; name to decide) — off by
49+ default, no change for every other model; `mm init` could write it for family
50+ `smollm3` and drop the family's note.
51+ - With it on, SmolLM3 (3B and 3B-128K) runs `bash` / `read_file` through llmman in
52+ the terminal, the window and ACP; the tool result goes back in the format the
53+ template expects (check how it renders a `tool` message).
54+ - Malformed `<tool_call>` JSON is handed back to the model as an error, like
55+ `toolargs.go` does for bad arguments.
56+ - Tests with a fake server (no model); docs EN+FR.
57+
58+ ## Decided (2026-11-13)
59+ - Fixed **on the mm side**, not on the server side.
60+ - An **opt-in setting** in the agent file (name still to choose, e.g.
61+ `toolFormat: xml_tools`), off by default — and **documented** (EN+FR: configuration
62+ reference, a how-to for models like SmolLM3, the mm init explanation).
63+
64+ ## Still to check
65+ - Does llama.cpp have, or plan, a SmolLM3 tool-call parser? It would not replace the
66+ mm setting (decided above), but the docs should say so if it exists.
67+tasks:
68+ - id: 1
69+ title: "Name the opt-in setting; check llama.cpp for a SmolLM3 parser (for the docs)"
70+ state: closed
71+ priority: none
72+ author:
73+ name: k33g
74+ email: ph.charriere@gmail.com
75+ createdAt: 2026-11-13T06:15:19.000Z
76+ updatedAt: 2026-11-13T06:30:04.000Z
77+ closedAt: 2026-11-13T06:30:04.000Z
78+ - id: 2
79+ title: "Send the tools as chat_template_kwargs.xml_tools when the option is on"
80+ state: closed
81+ priority: none
82+ author:
83+ name: k33g
84+ email: ph.charriere@gmail.com
85+ createdAt: 2026-11-13T06:15:19.000Z
86+ updatedAt: 2026-11-13T06:30:04.000Z
87+ closedAt: 2026-11-13T06:30:04.000Z
88+ - id: 3
89+ title: "Parse <tool_call> blocks of the content into tool calls (streamed and not), hide them from the text"
90+ state: closed
91+ priority: none
92+ author:
93+ name: k33g
94+ email: ph.charriere@gmail.com
95+ createdAt: 2026-11-13T06:15:19.000Z
96+ updatedAt: 2026-11-13T06:30:04.000Z
97+ closedAt: 2026-11-13T06:30:04.000Z
98+ - id: 4
99+ title: "Tool results in the format the template expects; malformed calls returned as errors"
100+ state: closed
101+ priority: none
102+ author:
103+ name: k33g
104+ email: ph.charriere@gmail.com
105+ createdAt: 2026-11-13T06:15:19.000Z
106+ updatedAt: 2026-11-13T06:30:04.000Z
107+ closedAt: 2026-11-13T06:30:04.000Z
108+ - id: 5
109+ title: "mm init: write the option for family smollm3, update its note; tests, docs EN+FR (configuration reference, how-to, mm init explanation)"
110+ state: closed
111+ priority: none
112+ author:
113+ name: k33g
114+ email: ph.charriere@gmail.com
115+ createdAt: 2026-11-13T06:15:19.000Z
116+ updatedAt: 2026-11-13T06:30:04.000Z
117+ closedAt: 2026-11-13T06:30:04.000Z
118+comments:
119+ - id: 1
120+ author:
121+ name: k33g
122+ email: ph.charriere@gmail.com
123+ createdAt: 2026-11-13T06:30:04.000Z
124+ body: |
125+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
126+
127+ - Setting: `toolFormat: openai` (default) | `xml_tools`; anything else refused at load ([internal/config/config.go](internal/config/config.go)).
128+ - [internal/engine/xmltools.go](internal/engine/xmltools.go): openai-go middleware, first in the chain when `toolFormat: xml_tools`. Request: `tools` → `chat_template_kwargs.xml_tools` (no `tools`/`tool_choice`), assistant `tool_calls` → `<tool_call>` text in content. Response: `<tool_call>` blocks → `tool_calls` (SSE chunk by chunk with a parser that holds back a partial tag, and JSON), `finish_reason: tool_calls`; broken JSON with a readable name → a call with the broken arguments (toolargs.go hands it back); no name → stays text.
129+ - Tests: `xmltools_test.go` (request, parser in pieces and edge cases, JSON, SSE, and end to end through Genkit: the tool runs), `TestToolFormatValues`, `TestFamilyToolFormat`. `go test ./...` green.
130+ - Live (llmman, SmolLM3-3B and 3B-128K Q4_K_M): the calls run. With reasoning ON it repeats its call after the tool's answer (3/3 on the real second request); OFF it answers (6/6). Multi-step tasks stay fragile at 3B (it takes tool output for user text: the template renders a tool message as `user`); wrapping in `<tool_response>` tried, no measured gain, reverted.
131+ - mm init: `Family.ToolFormat`; family `smollm3` writes `toolFormat: xml_tools` for usages with tools; note rewritten (reasoning off with tools). Backup: `internal/agentinit/backups/presets.20261113-072654.smollm3-toolformat.yaml`.
132+ - llama.cpp `common/chat.cpp` (master, 2026-11-13): no SmolLM3 / xml_tools mention.
133+ - Docs EN+FR: new `how-to/use-a-model-that-reads-xml-tools.md` (+ index), `reference/configuration.md` (`toolFormat`), `explanation/mm-init.md`.
134+ - Open: `mm init` still writes `enable_thinking: true` for smollm3 + developer (the usage reasons) — the note says to turn it off; doing it automatically needs a decide.go rule, to discuss.
135+ - Quality gate not run: qlty is not installed in this sandbox.
136+ - id: 2
137+ author:
138+ name: k33g
139+ email: ph.charriere@gmail.com
140+ createdAt: 2026-11-13T06:34:37.000Z
141+ body: |
142+ Decided by the user: for the smollm3 family, `mm init` turns the reasoning off and writes `toolFormat: xml_tools`.
143+ Done with a generic family field `thinkingOff` (decide.go: `s.Thinking = … && !fam.ThinkingOff`, the kwarg comment says why); smollm3 sets it, note rewritten. Backup `internal/agentinit/backups/presets.20261113-073314.smollm3-thinking-off.yaml`. Test `TestFamilyToolFormat` extended (smollm3 developer → enable_thinking: false; Qwen developer keeps true).
144+ Live: `mm init --yes --provider llmman --model …SmolLM3-3B… --usage developer --project yes`, then `mm -p "List the files…"`: `🛠️ bash: ls` ran 3 runs out of 3 (answers' wording still weak at 3B). Docs EN+FR updated (how-to variant, mm init explanation).
new file mode 100644
@@ -0,0 +1,144 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 207
3+title: "Tool calls for models whose template reads xml_tools (SmolLM3)"
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T06:15:19.000Z
9+updatedAt: 2026-11-13T06:34:37.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+ - tools
14+milestone: v2.0.0
15+epic: Providers (LLM Engine)
16+body: |
17+ SmolLM3 ([unsloth/SmolLM3-3B-GGUF](https://huggingface.co/unsloth/SmolLM3-3B-GGUF),
18+ [SmolLM3-3B-128K-GGUF](https://huggingface.co/unsloth/SmolLM3-3B-128K-GGUF)) supports
19+ tool calling according to its card, but not through mm: its chat template reads the
20+ tools from `xml_tools` (or `python_tools`) — the variable of transformers'
21+ `apply_chat_template(xml_tools=…)` — never from the standard `tools` that mm sends
22+ over the OpenAI API and that llama-server passes to the template.
23+
24+ ## Measured (2026-11-13, llmman, SmolLM3-3B Q4_K_M)
25+ - Template rendered with Jinja2: with `tools=[bash]` the prompt holds **no tool**; with
26+ `xml_tools=[bash]` it gets a `### Tools` section and the `<tool_call>` format.
27+ - Request with `tools` only: no tool call, reasoning on or off — the model describes
28+ `ls` instead (also seen live in the user's `bob-the-smoll/`, developer usage).
29+ - Request with `tools` + `chat_template_kwargs.xml_tools`: the model answers
30+ `<tool_call>{"name": "bash", "arguments": {"command": "ls"}}</tool_call>` as plain
31+ **content**, `tool_calls: null`: llama-server does not parse it.
32+
33+ Today the `smollm3` family of `mm init` (presets.yaml) carries a note: use a usage
34+ without tools (translator, json).
35+
36+ ## What mm would need
37+ 1. **Send the tools where the template reads them**: also put the tool definitions in
38+ `chat_template_kwargs.xml_tools` (same JSON as `tools`), on every request that has
39+ tools. Possible today by hand in `sampling`, but it duplicates every tool definition.
40+ 2. **Read the calls back**: turn `<tool_call>{json}</tool_call>` blocks of the answer's
41+ content into real tool calls before Genkit sees the response — streamed and not
42+ streamed — and keep them out of the displayed text. Candidates: the openai-go
43+ middleware already used for the SSE stream
44+ ([internal/engine/reasoning.go](internal/engine/reasoning.go), `reasoningTap`/`sseTap`),
45+ or a WrapModel hook like `dropReasoning`.
46+
47+ ## Acceptance criteria
48+ - Opt-in, per agent file (e.g. `toolFormat: xml_tools`; name to decide) — off by
49+ default, no change for every other model; `mm init` could write it for family
50+ `smollm3` and drop the family's note.
51+ - With it on, SmolLM3 (3B and 3B-128K) runs `bash` / `read_file` through llmman in
52+ the terminal, the window and ACP; the tool result goes back in the format the
53+ template expects (check how it renders a `tool` message).
54+ - Malformed `<tool_call>` JSON is handed back to the model as an error, like
55+ `toolargs.go` does for bad arguments.
56+ - Tests with a fake server (no model); docs EN+FR.
57+
58+ ## Decided (2026-11-13)
59+ - Fixed **on the mm side**, not on the server side.
60+ - An **opt-in setting** in the agent file (name still to choose, e.g.
61+ `toolFormat: xml_tools`), off by default — and **documented** (EN+FR: configuration
62+ reference, a how-to for models like SmolLM3, the mm init explanation).
63+
64+ ## Still to check
65+ - Does llama.cpp have, or plan, a SmolLM3 tool-call parser? It would not replace the
66+ mm setting (decided above), but the docs should say so if it exists.
67+tasks:
68+ - id: 1
69+ title: "Name the opt-in setting; check llama.cpp for a SmolLM3 parser (for the docs)"
70+ state: closed
71+ priority: none
72+ author:
73+ name: k33g
74+ email: ph.charriere@gmail.com
75+ createdAt: 2026-11-13T06:15:19.000Z
76+ updatedAt: 2026-11-13T06:30:04.000Z
77+ closedAt: 2026-11-13T06:30:04.000Z
78+ - id: 2
79+ title: "Send the tools as chat_template_kwargs.xml_tools when the option is on"
80+ state: closed
81+ priority: none
82+ author:
83+ name: k33g
84+ email: ph.charriere@gmail.com
85+ createdAt: 2026-11-13T06:15:19.000Z
86+ updatedAt: 2026-11-13T06:30:04.000Z
87+ closedAt: 2026-11-13T06:30:04.000Z
88+ - id: 3
89+ title: "Parse <tool_call> blocks of the content into tool calls (streamed and not), hide them from the text"
90+ state: closed
91+ priority: none
92+ author:
93+ name: k33g
94+ email: ph.charriere@gmail.com
95+ createdAt: 2026-11-13T06:15:19.000Z
96+ updatedAt: 2026-11-13T06:30:04.000Z
97+ closedAt: 2026-11-13T06:30:04.000Z
98+ - id: 4
99+ title: "Tool results in the format the template expects; malformed calls returned as errors"
100+ state: closed
101+ priority: none
102+ author:
103+ name: k33g
104+ email: ph.charriere@gmail.com
105+ createdAt: 2026-11-13T06:15:19.000Z
106+ updatedAt: 2026-11-13T06:30:04.000Z
107+ closedAt: 2026-11-13T06:30:04.000Z
108+ - id: 5
109+ title: "mm init: write the option for family smollm3, update its note; tests, docs EN+FR (configuration reference, how-to, mm init explanation)"
110+ state: closed
111+ priority: none
112+ author:
113+ name: k33g
114+ email: ph.charriere@gmail.com
115+ createdAt: 2026-11-13T06:15:19.000Z
116+ updatedAt: 2026-11-13T06:30:04.000Z
117+ closedAt: 2026-11-13T06:30:04.000Z
118+comments:
119+ - id: 1
120+ author:
121+ name: k33g
122+ email: ph.charriere@gmail.com
123+ createdAt: 2026-11-13T06:30:04.000Z
124+ body: |
125+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
126+
127+ - Setting: `toolFormat: openai` (default) | `xml_tools`; anything else refused at load ([internal/config/config.go](internal/config/config.go)).
128+ - [internal/engine/xmltools.go](internal/engine/xmltools.go): openai-go middleware, first in the chain when `toolFormat: xml_tools`. Request: `tools` → `chat_template_kwargs.xml_tools` (no `tools`/`tool_choice`), assistant `tool_calls` → `<tool_call>` text in content. Response: `<tool_call>` blocks → `tool_calls` (SSE chunk by chunk with a parser that holds back a partial tag, and JSON), `finish_reason: tool_calls`; broken JSON with a readable name → a call with the broken arguments (toolargs.go hands it back); no name → stays text.
129+ - Tests: `xmltools_test.go` (request, parser in pieces and edge cases, JSON, SSE, and end to end through Genkit: the tool runs), `TestToolFormatValues`, `TestFamilyToolFormat`. `go test ./...` green.
130+ - Live (llmman, SmolLM3-3B and 3B-128K Q4_K_M): the calls run. With reasoning ON it repeats its call after the tool's answer (3/3 on the real second request); OFF it answers (6/6). Multi-step tasks stay fragile at 3B (it takes tool output for user text: the template renders a tool message as `user`); wrapping in `<tool_response>` tried, no measured gain, reverted.
131+ - mm init: `Family.ToolFormat`; family `smollm3` writes `toolFormat: xml_tools` for usages with tools; note rewritten (reasoning off with tools). Backup: `internal/agentinit/backups/presets.20261113-072654.smollm3-toolformat.yaml`.
132+ - llama.cpp `common/chat.cpp` (master, 2026-11-13): no SmolLM3 / xml_tools mention.
133+ - Docs EN+FR: new `how-to/use-a-model-that-reads-xml-tools.md` (+ index), `reference/configuration.md` (`toolFormat`), `explanation/mm-init.md`.
134+ - Open: `mm init` still writes `enable_thinking: true` for smollm3 + developer (the usage reasons) — the note says to turn it off; doing it automatically needs a decide.go rule, to discuss.
135+ - Quality gate not run: qlty is not installed in this sandbox.
136+ - id: 2
137+ author:
138+ name: k33g
139+ email: ph.charriere@gmail.com
140+ createdAt: 2026-11-13T06:34:37.000Z
141+ body: |
142+ Decided by the user: for the smollm3 family, `mm init` turns the reasoning off and writes `toolFormat: xml_tools`.
143+ Done with a generic family field `thinkingOff` (decide.go: `s.Thinking = … && !fam.ThinkingOff`, the kwarg comment says why); smollm3 sets it, note rewritten. Backup `internal/agentinit/backups/presets.20261113-073314.smollm3-thinking-off.yaml`. Test `TestFamilyToolFormat` extended (smollm3 developer → enable_thinking: false; Qwen developer keeps true).
144+ Live: `mm init --yes --provider llmman --model …SmolLM3-3B… --usage developer --project yes`, then `mm -p "List the files…"`: `🛠️ bash: ls` ran 3 runs out of 3 (answers' wording still weak at 3B). Docs EN+FR updated (how-to variant, mm init explanation).
added .tickets/issues/0208-mm-sbxinit-know-the-mm-folder.yaml +134 -0
new file mode 100644
@@ -0,0 +1,134 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 208
3+title: "mm sbxinit: know the .mm folder"
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T07:02:19.000Z
9+updatedAt: 2026-11-13T12:04:33.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.7
14+epic: Scaffolding
15+related:
16+ - 204
17+body: |
18+ `mm sbxinit` ([internal/sbxinit](internal/sbxinit/sbxinit.go)) only knows `./agent.yaml`
19+ (`-agent` default, `findAgentFile`). A project made with `mm init --project yes`
20+ (ticket 205) keeps its agent in `.mm/agent.yaml`, with `skillsDir: .mm/skills` and
21+ `sessions.dir: .mm/sessions`.
22+
23+ ## Seen (2026-11-13, the user's `bob-the-bot/`)
24+ A hand-written `sbxenv.yaml` mounted the whole project read-only on
25+ `/opt/bob-the-bot/.mm` with `AGENT_CONFIG: /opt/bob-the-bot/.mm/agent.yaml`:
26+ `[config error: open /opt/bob-the-bot/.mm/agent.yaml: no such file or directory]`
27+ (the file was at `.mm/.mm/agent.yaml`), sessions read-only, and a mount change that
28+ only applies on re-create. Fixed by hand with `args.PROJECT_DIR` +
29+ `AGENT_CONFIG: ${{ env.args.PROJECT_DIR }}/.mm/agent.yaml` and no extra mount —
30+ which makes every `sbx env` command need `--env-arg PROJECT_DIR="$PWD"`.
31+
32+ ## Wanted
33+ - `mm sbxinit` finds the agent like mm does: `./agent.yaml`, then
34+ `./.mm/agent.yaml` (same lookup as ticket 204; `-agent` still wins), names the
35+ sandbox after its `acp.name`, and does not warn "outside the workspace" for a
36+ `.mm/agent.yaml` inside it.
37+ - For a `.mm/agent.yaml` in the workspace, the generated `sbxenv.yaml` is the simple
38+ form: `workspace: .`, no `additionalWorkspaces`, no `AGENT_CONFIG`, no `args` —
39+ a comment says that the workspace mounts `.mm/` read-write (config, skills,
40+ sessions) and that mm finds `.mm/agent.yaml` on its own.
41+
42+ ## Conditions (to check before relying on the simple form)
43+ - The kit (mini-me-kit, own repository) ships an mm with ticket 204 (v1.0.7+): with
44+ an older mm the file must still set `AGENT_CONFIG` — the comment says so, or the
45+ generated file pins the kit version that has it.
46+ - The kit starts mm in the workspace (the lookup and `.mm/skills`, `.mm/sessions`
47+ follow mm's working directory) and sets no `AGENT_CONFIG` of its own.
48+ - ACP from an editor (`sbx exec -i … mm -acp`): mm's working directory may not be
49+ the workspace — check it; if it is not, the editor still needs `AGENT_CONFIG`
50+ (document it in the how-to).
51+
52+ ## Acceptance criteria
53+ - Tests: a project with only `.mm/agent.yaml` → simple `sbxenv.yaml`, the sandbox named
54+ after its `acp.name`, no warning; `./agent.yaml` projects unchanged.
55+ - Docs EN+FR: `how-to/run-in-a-docker-sandbox.md` (a `.mm` project), CLI reference of
56+ `mm sbxinit`; `how-to/keep-the-config-in-a-mm-folder.md` links to it.
57+tasks:
58+ - id: 1
59+ title: "Find the agent file like mm: ./agent.yaml then ./.mm/agent.yaml (-agent wins)"
60+ state: closed
61+ priority: none
62+ author:
63+ name: k33g
64+ email: ph.charriere@gmail.com
65+ createdAt: 2026-11-13T07:02:19.000Z
66+ updatedAt: 2026-11-13T07:20:32.000Z
67+ closedAt: 2026-11-13T07:20:32.000Z
68+ - id: 2
69+ title: "sbxenv.yaml for a .mm project modelled on bob-the-bot: relative paths, .mm read-only, sessions/trace writable, AGENT_CONFIG via PROJECT_DIR"
70+ state: closed
71+ priority: none
72+ author:
73+ name: k33g
74+ email: ph.charriere@gmail.com
75+ createdAt: 2026-11-13T07:02:19.000Z
76+ updatedAt: 2026-11-13T07:20:32.000Z
77+ closedAt: 2026-11-13T07:20:32.000Z
78+ - id: 3
79+ title: "Check the kit: mm version (204), working directory, no AGENT_CONFIG; ACP working directory under sbx exec"
80+ state: closed
81+ priority: none
82+ author:
83+ name: k33g
84+ email: ph.charriere@gmail.com
85+ createdAt: 2026-11-13T07:02:19.000Z
86+ updatedAt: 2026-11-13T12:04:33.000Z
87+ closedAt: 2026-11-13T12:04:33.000Z
88+ - id: 4
89+ title: "Tests, docs EN+FR"
90+ state: closed
91+ priority: none
92+ author:
93+ name: k33g
94+ email: ph.charriere@gmail.com
95+ createdAt: 2026-11-13T07:02:19.000Z
96+ updatedAt: 2026-11-13T07:20:32.000Z
97+ closedAt: 2026-11-13T07:20:32.000Z
98+comments:
99+ - id: 1
100+ author:
101+ name: k33g
102+ email: ph.charriere@gmail.com
103+ createdAt: 2026-11-13T07:20:32.000Z
104+ body: |
105+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
106+
107+ - [internal/sbxinit/sbxinit.go](internal/sbxinit/sbxinit.go): without `-agent`, `findAgentFile` tries `agent.yaml` then `.mm/agent.yaml` (current dir, then workspace); `inMMFolder` = the file is `<workspace>/.mm/…` → no "outside the workspace" warning, a line after writing, and `Render(…, mmFolder)` adds notes + two COMMENTED variants: `args.PROJECT_DIR` + `env.AGENT_CONFIG` (kit with mm < v1.0.7), and `additionalWorkspaces` `.mm` read-only + `.mm/sessions` writable (the user's bob-the-bot setup — nested mounts measured working by the user; traces need their own mount, seen the same day). The simple form sets nothing (no mounts, env or args).
108+ - Tests: `TestMainFindsTheAgentInTheMMFolder`, `TestTheMMFolderVariantsUncommentToValidYAML`, `TestRootAgentWinsAndTheAgentFlagIsExact`; existing tests unchanged in substance. `go test ./...` green. Real run: `mm init --project yes` then `mm sbxinit --yes` → `bob-the-bot-sbx`, no warning.
109+ - Docs EN+FR: how-to sandbox § "When the agent is in a .mm folder", CLI reference `--agent`, link from the .mm how-to.
110+ - Task 3 stays OPEN: the kit (other repository) — its mm version, its working directory, no AGENT_CONFIG of its own, and mm's working directory under `sbx exec … mm -acp` — not checked from here (no sbx in this sandbox).
111+ - Quality gate not run: qlty is not installed in this sandbox.
112+ - id: 2
113+ author:
114+ name: k33g
115+ email: ph.charriere@gmail.com
116+ createdAt: 2026-11-13T07:47:50.000Z
117+ body: |
118+ Changed on the user's request ("s'inspirer de bob-the-bot/sbxenv.yaml"): for a `.mm` project the file is no longer the simple form with commented variants. [internal/sbxinit/mmfolder.go](internal/sbxinit/mmfolder.go) writes, active: `additionalWorkspaces` `.mm` read-only + the agent file's `sessions.dir` / `trace.dir` (when on and under `.mm/`; mm's defaults when unnamed) writable, and `env.AGENT_CONFIG` = the agent file's ABSOLUTE path — the workspace is already written absolute and is mounted at the same path, so no `args.PROJECT_DIR` / `--env-arg`, and it works with any mm in the kit and any working directory (ACP included). That also makes task 3 (kit checks) no longer blocking.
119+ Tests rewritten (`TestMainMountsTheMMFolderReadOnly`, `TestMMFolderWritableFolders`); docs EN+FR rewritten. Known gap: other writes under `.mm/` (e.g. the user's `bob/.mm/hooks-audit.jsonl` from a hook) are not detected — the file's comment says to add a line.
120+ The user's `sbx` error "sandbox named bob-sbx … shows no sign of ever being created by sbx env" is an existing sandbox of that name, not this file: `sbx rm bob-sbx` first.
121+ - id: 3
122+ author:
123+ name: k33g
124+ email: ph.charriere@gmail.com
125+ createdAt: 2026-11-13T07:54:34.000Z
126+ body: |
127+ User's choice (2026-11-13): relative paths, like bob-the-bot — the file can be committed. For a `.mm` project: `workspace: .` (or `./<sub>`), `./.mm` read-only, `./.mm/sessions` / `./.mm/trace` writable, `args.PROJECT_DIR` required, `AGENT_CONFIG: ${{ env.args.PROJECT_DIR }}/.mm/agent.yaml`, and `--env-arg PROJECT_DIR="$PWD"` in the printed commands and the file's last lines. Projects without `.mm` keep the absolute workspace (unchanged). `MMFolder.Base` = the folder of sbxenv.yaml; `rel`, `workspace`, `envArg`. Tests: `TestMainMountsTheMMFolderReadOnly` (relative), `TestMMFolderInASubWorkspace`. Docs EN+FR updated (incl. the "shows no sign of ever being created by sbx env" error → `sbx rm <name>`).
128+ - id: 4
129+ author:
130+ name: k33g
131+ email: ph.charriere@gmail.com
132+ createdAt: 2026-11-13T12:04:33.000Z
133+ body: |
134+ Closed: merged into main (branch feature/config-in-mm-dir). Task 3 (check the kit) closed with it: the generated file always sets AGENT_CONFIG through PROJECT_DIR, so it no longer depends on the kit's mm version or working directory — checked by the user with bob/ in sbx (2026-11-13).
new file mode 100644
@@ -0,0 +1,134 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 208
3+title: "mm sbxinit: know the .mm folder"
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-11-13T07:02:19.000Z
9+updatedAt: 2026-11-13T12:04:33.000Z
10+labels:
11+ - enhancement
12+ - kind::feature
13+milestone: v1.0.7
14+epic: Scaffolding
15+related:
16+ - 204
17+body: |
18+ `mm sbxinit` ([internal/sbxinit](internal/sbxinit/sbxinit.go)) only knows `./agent.yaml`
19+ (`-agent` default, `findAgentFile`). A project made with `mm init --project yes`
20+ (ticket 205) keeps its agent in `.mm/agent.yaml`, with `skillsDir: .mm/skills` and
21+ `sessions.dir: .mm/sessions`.
22+
23+ ## Seen (2026-11-13, the user's `bob-the-bot/`)
24+ A hand-written `sbxenv.yaml` mounted the whole project read-only on
25+ `/opt/bob-the-bot/.mm` with `AGENT_CONFIG: /opt/bob-the-bot/.mm/agent.yaml`:
26+ `[config error: open /opt/bob-the-bot/.mm/agent.yaml: no such file or directory]`
27+ (the file was at `.mm/.mm/agent.yaml`), sessions read-only, and a mount change that
28+ only applies on re-create. Fixed by hand with `args.PROJECT_DIR` +
29+ `AGENT_CONFIG: ${{ env.args.PROJECT_DIR }}/.mm/agent.yaml` and no extra mount —
30+ which makes every `sbx env` command need `--env-arg PROJECT_DIR="$PWD"`.
31+
32+ ## Wanted
33+ - `mm sbxinit` finds the agent like mm does: `./agent.yaml`, then
34+ `./.mm/agent.yaml` (same lookup as ticket 204; `-agent` still wins), names the
35+ sandbox after its `acp.name`, and does not warn "outside the workspace" for a
36+ `.mm/agent.yaml` inside it.
37+ - For a `.mm/agent.yaml` in the workspace, the generated `sbxenv.yaml` is the simple
38+ form: `workspace: .`, no `additionalWorkspaces`, no `AGENT_CONFIG`, no `args` —
39+ a comment says that the workspace mounts `.mm/` read-write (config, skills,
40+ sessions) and that mm finds `.mm/agent.yaml` on its own.
41+
42+ ## Conditions (to check before relying on the simple form)
43+ - The kit (mini-me-kit, own repository) ships an mm with ticket 204 (v1.0.7+): with
44+ an older mm the file must still set `AGENT_CONFIG` — the comment says so, or the
45+ generated file pins the kit version that has it.
46+ - The kit starts mm in the workspace (the lookup and `.mm/skills`, `.mm/sessions`
47+ follow mm's working directory) and sets no `AGENT_CONFIG` of its own.
48+ - ACP from an editor (`sbx exec -i … mm -acp`): mm's working directory may not be
49+ the workspace — check it; if it is not, the editor still needs `AGENT_CONFIG`
50+ (document it in the how-to).
51+
52+ ## Acceptance criteria
53+ - Tests: a project with only `.mm/agent.yaml` → simple `sbxenv.yaml`, the sandbox named
54+ after its `acp.name`, no warning; `./agent.yaml` projects unchanged.
55+ - Docs EN+FR: `how-to/run-in-a-docker-sandbox.md` (a `.mm` project), CLI reference of
56+ `mm sbxinit`; `how-to/keep-the-config-in-a-mm-folder.md` links to it.
57+tasks:
58+ - id: 1
59+ title: "Find the agent file like mm: ./agent.yaml then ./.mm/agent.yaml (-agent wins)"
60+ state: closed
61+ priority: none
62+ author:
63+ name: k33g
64+ email: ph.charriere@gmail.com
65+ createdAt: 2026-11-13T07:02:19.000Z
66+ updatedAt: 2026-11-13T07:20:32.000Z
67+ closedAt: 2026-11-13T07:20:32.000Z
68+ - id: 2
69+ title: "sbxenv.yaml for a .mm project modelled on bob-the-bot: relative paths, .mm read-only, sessions/trace writable, AGENT_CONFIG via PROJECT_DIR"
70+ state: closed
71+ priority: none
72+ author:
73+ name: k33g
74+ email: ph.charriere@gmail.com
75+ createdAt: 2026-11-13T07:02:19.000Z
76+ updatedAt: 2026-11-13T07:20:32.000Z
77+ closedAt: 2026-11-13T07:20:32.000Z
78+ - id: 3
79+ title: "Check the kit: mm version (204), working directory, no AGENT_CONFIG; ACP working directory under sbx exec"
80+ state: closed
81+ priority: none
82+ author:
83+ name: k33g
84+ email: ph.charriere@gmail.com
85+ createdAt: 2026-11-13T07:02:19.000Z
86+ updatedAt: 2026-11-13T12:04:33.000Z
87+ closedAt: 2026-11-13T12:04:33.000Z
88+ - id: 4
89+ title: "Tests, docs EN+FR"
90+ state: closed
91+ priority: none
92+ author:
93+ name: k33g
94+ email: ph.charriere@gmail.com
95+ createdAt: 2026-11-13T07:02:19.000Z
96+ updatedAt: 2026-11-13T07:20:32.000Z
97+ closedAt: 2026-11-13T07:20:32.000Z
98+comments:
99+ - id: 1
100+ author:
101+ name: k33g
102+ email: ph.charriere@gmail.com
103+ createdAt: 2026-11-13T07:20:32.000Z
104+ body: |
105+ Implemented on branch `feature/config-in-mm-dir` (not committed yet).
106+
107+ - [internal/sbxinit/sbxinit.go](internal/sbxinit/sbxinit.go): without `-agent`, `findAgentFile` tries `agent.yaml` then `.mm/agent.yaml` (current dir, then workspace); `inMMFolder` = the file is `<workspace>/.mm/…` → no "outside the workspace" warning, a line after writing, and `Render(…, mmFolder)` adds notes + two COMMENTED variants: `args.PROJECT_DIR` + `env.AGENT_CONFIG` (kit with mm < v1.0.7), and `additionalWorkspaces` `.mm` read-only + `.mm/sessions` writable (the user's bob-the-bot setup — nested mounts measured working by the user; traces need their own mount, seen the same day). The simple form sets nothing (no mounts, env or args).
108+ - Tests: `TestMainFindsTheAgentInTheMMFolder`, `TestTheMMFolderVariantsUncommentToValidYAML`, `TestRootAgentWinsAndTheAgentFlagIsExact`; existing tests unchanged in substance. `go test ./...` green. Real run: `mm init --project yes` then `mm sbxinit --yes` → `bob-the-bot-sbx`, no warning.
109+ - Docs EN+FR: how-to sandbox § "When the agent is in a .mm folder", CLI reference `--agent`, link from the .mm how-to.
110+ - Task 3 stays OPEN: the kit (other repository) — its mm version, its working directory, no AGENT_CONFIG of its own, and mm's working directory under `sbx exec … mm -acp` — not checked from here (no sbx in this sandbox).
111+ - Quality gate not run: qlty is not installed in this sandbox.
112+ - id: 2
113+ author:
114+ name: k33g
115+ email: ph.charriere@gmail.com
116+ createdAt: 2026-11-13T07:47:50.000Z
117+ body: |
118+ Changed on the user's request ("s'inspirer de bob-the-bot/sbxenv.yaml"): for a `.mm` project the file is no longer the simple form with commented variants. [internal/sbxinit/mmfolder.go](internal/sbxinit/mmfolder.go) writes, active: `additionalWorkspaces` `.mm` read-only + the agent file's `sessions.dir` / `trace.dir` (when on and under `.mm/`; mm's defaults when unnamed) writable, and `env.AGENT_CONFIG` = the agent file's ABSOLUTE path — the workspace is already written absolute and is mounted at the same path, so no `args.PROJECT_DIR` / `--env-arg`, and it works with any mm in the kit and any working directory (ACP included). That also makes task 3 (kit checks) no longer blocking.
119+ Tests rewritten (`TestMainMountsTheMMFolderReadOnly`, `TestMMFolderWritableFolders`); docs EN+FR rewritten. Known gap: other writes under `.mm/` (e.g. the user's `bob/.mm/hooks-audit.jsonl` from a hook) are not detected — the file's comment says to add a line.
120+ The user's `sbx` error "sandbox named bob-sbx … shows no sign of ever being created by sbx env" is an existing sandbox of that name, not this file: `sbx rm bob-sbx` first.
121+ - id: 3
122+ author:
123+ name: k33g
124+ email: ph.charriere@gmail.com
125+ createdAt: 2026-11-13T07:54:34.000Z
126+ body: |
127+ User's choice (2026-11-13): relative paths, like bob-the-bot — the file can be committed. For a `.mm` project: `workspace: .` (or `./<sub>`), `./.mm` read-only, `./.mm/sessions` / `./.mm/trace` writable, `args.PROJECT_DIR` required, `AGENT_CONFIG: ${{ env.args.PROJECT_DIR }}/.mm/agent.yaml`, and `--env-arg PROJECT_DIR="$PWD"` in the printed commands and the file's last lines. Projects without `.mm` keep the absolute workspace (unchanged). `MMFolder.Base` = the folder of sbxenv.yaml; `rel`, `workspace`, `envArg`. Tests: `TestMainMountsTheMMFolderReadOnly` (relative), `TestMMFolderInASubWorkspace`. Docs EN+FR updated (incl. the "shows no sign of ever being created by sbx env" error → `sbx rm <name>`).
128+ - id: 4
129+ author:
130+ name: k33g
131+ email: ph.charriere@gmail.com
132+ createdAt: 2026-11-13T12:04:33.000Z
133+ body: |
134+ Closed: merged into main (branch feature/config-in-mm-dir). Task 3 (check the kit) closed with it: the generated file always sets AGENT_CONFIG through PROJECT_DIR, so it no longer depends on the kit's mm version or working directory — checked by the user with bob/ in sbx (2026-11-13).
modified .tickets/milestones.yaml +3 -0
@@ -57,3 +57,6 @@ milestones:
5757 - title: v1.0.6
5858 state: closed
5959 dueOn: 2026-11-15
60+ - title: v1.0.7
61+ state: open
62+ dueOn: 2026-11-14
@@ -57,3 +57,6 @@ milestones:
57 - title: v1.0.657 - title: v1.0.6
58 state: closed58 state: closed
59 dueOn: 2026-11-1559 dueOn: 2026-11-15
60+ - title: v1.0.7
61+ state: open
62+ dueOn: 2026-11-14
modified docs/diagrams/packages.drawio +1 -1
@@ -1,4 +1,4 @@
11 <mxfile host="app.diagrams.net" modified="2026-10-22T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
22 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
33 <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70consent" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell>
4- <mxCell id="e90" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="&lt;b&gt;internal/decide&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;decision models: /v1/systemone client, skill and tool routing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1140" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="ed0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="decide" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="dockerinit" value="&lt;b&gt;internal/dockerinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm dockerinit: writes the Dockerfile and compose.yaml&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="860" width="200" height="60" as="geometry" /></mxCell><mxCell id="e91" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="dockerinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="sbxinit" value="&lt;b&gt;internal/sbxinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm sbxinit: writes sbxenv.yaml with the latest kit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="960" width="200" height="60" as="geometry" /></mxCell><mxCell id="scaffold" value="&lt;b&gt;internal/scaffold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what mm dockerinit and mm sbxinit share: the agent name, writing without destroying&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1060" width="200" height="60" as="geometry" /></mxCell><mxCell id="e92" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="dockerinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e93" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="sbxinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e94" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e95" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e96" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e97" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_mcp" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_detector" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
4+ <mxCell id="e90" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="&lt;b&gt;internal/decide&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;decision models: /v1/systemone client, skill and tool routing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1140" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="ed0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="decide" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="dockerinit" value="&lt;b&gt;internal/dockerinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm dockerinit: writes the Dockerfile and compose.yaml&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="860" width="200" height="60" as="geometry" /></mxCell><mxCell id="e91" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="dockerinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="sbxinit" value="&lt;b&gt;internal/sbxinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm sbxinit: writes sbxenv.yaml with the latest kit (.mm aware)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="960" width="200" height="60" as="geometry" /></mxCell><mxCell id="scaffold" value="&lt;b&gt;internal/scaffold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what mm dockerinit and mm sbxinit share: the agent name, writing without destroying&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1060" width="200" height="60" as="geometry" /></mxCell><mxCell id="e92" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="dockerinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e93" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="sbxinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e94" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e95" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e96" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e97" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_mcp" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_detector" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="skillcreate" value="&lt;b&gt;internal/skillcreate&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm skillcreate: the skill wizard, writes &amp;lt;skillsDir&amp;gt;/&amp;lt;name&amp;gt;/SKILL.md&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1160" width="200" height="60" as="geometry" /></mxCell><mxCell id="e98" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skillcreate"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e99" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="skillcreate"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e100" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="skillcreate" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e101" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="skillcreate" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e102" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="skillcreate" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e103" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="skillcreate" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e104" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e105" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e106" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
@@ -1,4 +1,4 @@
1 <mxfile host="app.diagrams.net" modified="2026-10-22T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>1 <mxfile host="app.diagrams.net" modified="2026-10-22T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3 <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70consent" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell>3 <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70consent" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell>
4- <mxCell id="e90" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="&lt;b&gt;internal/decide&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;decision models: /v1/systemone client, skill and tool routing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1140" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="ed0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="decide" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="dockerinit" value="&lt;b&gt;internal/dockerinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm dockerinit: writes the Dockerfile and compose.yaml&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="860" width="200" height="60" as="geometry" /></mxCell><mxCell id="e91" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="dockerinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="sbxinit" value="&lt;b&gt;internal/sbxinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm sbxinit: writes sbxenv.yaml with the latest kit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="960" width="200" height="60" as="geometry" /></mxCell><mxCell id="scaffold" value="&lt;b&gt;internal/scaffold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what mm dockerinit and mm sbxinit share: the agent name, writing without destroying&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1060" width="200" height="60" as="geometry" /></mxCell><mxCell id="e92" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="dockerinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e93" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="sbxinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e94" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e95" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e96" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e97" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_mcp" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_detector" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
4+ <mxCell id="e90" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="&lt;b&gt;internal/decide&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;decision models: /v1/systemone client, skill and tool routing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1140" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="ed0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="decide" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="dockerinit" value="&lt;b&gt;internal/dockerinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm dockerinit: writes the Dockerfile and compose.yaml&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="860" width="200" height="60" as="geometry" /></mxCell><mxCell id="e91" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="dockerinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="sbxinit" value="&lt;b&gt;internal/sbxinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm sbxinit: writes sbxenv.yaml with the latest kit (.mm aware)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="960" width="200" height="60" as="geometry" /></mxCell><mxCell id="scaffold" value="&lt;b&gt;internal/scaffold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what mm dockerinit and mm sbxinit share: the agent name, writing without destroying&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1060" width="200" height="60" as="geometry" /></mxCell><mxCell id="e92" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="dockerinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e93" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="sbxinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e94" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e95" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e96" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e97" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_mcp" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_detector" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="skillcreate" value="&lt;b&gt;internal/skillcreate&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm skillcreate: the skill wizard, writes &amp;lt;skillsDir&amp;gt;/&amp;lt;name&amp;gt;/SKILL.md&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1160" width="200" height="60" as="geometry" /></mxCell><mxCell id="e98" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skillcreate"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e99" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="skillcreate"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e100" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="skillcreate" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e101" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="skillcreate" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e102" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="skillcreate" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e103" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="skillcreate" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e104" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e105" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e106" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/README.md +3 -1
@@ -26,12 +26,14 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
2626 - [Drive mini-me from a program](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js: `mm -acp` as a library: `Start`/`Complete`/`Stream`, cancel, permission callback
2727 - [Offer tools from your ACP client](how-to/offer-tools-from-an-acp-client.md) — your program's functions as agent tools: `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools`
2828 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
29+- [Keep a project's mini-me files in a `.mm` folder](how-to/keep-the-config-in-a-mm-folder.md) — `.mm/agent.yaml`, paths written from the project root
30+- [Give tools to a model whose template reads `xml_tools`](how-to/use-a-model-that-reads-xml-tools.md) — SmolLM3, `toolFormat: xml_tools`
2931 - [Turn a model's reasoning off](how-to/turn-reasoning-off.md) — `chat_template_kwargs` or `reasoning_effort` in `sampling`, per server
3032 - [Confirm tool calls before they run](how-to/confirm-tool-calls.md) — `yolo: false`, the four answers, `AGENT_YOLO`, who decides with `mmConfigs`
3133 - [Let the agent ask you questions](how-to/let-the-agent-ask-you.md) — `ask_user`: answer in the terminal, the window or an editor, turn it off, decide for the agents yours calls (`askUserTool`, `mmConfigs.<name>.askUser`, `AGENT_ASK_USER`)
3234 - [Run mini-me in a container](how-to/run-in-a-container.md) — `mm dockerinit`: a Dockerfile with the latest release, not root, and a compose file
3335 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — `mm sbxinit` for a sbxenv.yaml; the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `demos/use-with-sbx/`, window mode and editor
34-- [Add a skill](how-to/add-a-skill.md)
36+- [Add a skill](how-to/add-a-skill.md) — by hand, or with the `mm skillcreate` wizard
3537 - [Declare a custom tool](how-to/declare-a-custom-tool.md)
3638 - [Write a custom tool as a WebAssembly module](how-to/write-a-wasm-tool.md)
3739 - [Manage plans and tasks](how-to/manage-plans-and-tasks.md) — the `plan` tool: give a list, follow the `📋` progress line, plans per conversation
@@ -26,12 +26,14 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
26 - [Drive mini-me from a program](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js: `mm -acp` as a library: `Start`/`Complete`/`Stream`, cancel, permission callback26 - [Drive mini-me from a program](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js: `mm -acp` as a library: `Start`/`Complete`/`Stream`, cancel, permission callback
27 - [Offer tools from your ACP client](how-to/offer-tools-from-an-acp-client.md) — your program's functions as agent tools: `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools`27 - [Offer tools from your ACP client](how-to/offer-tools-from-an-acp-client.md) — your program's functions as agent tools: `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools`
28 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)28 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
29+- [Keep a project's mini-me files in a `.mm` folder](how-to/keep-the-config-in-a-mm-folder.md) — `.mm/agent.yaml`, paths written from the project root
30+- [Give tools to a model whose template reads `xml_tools`](how-to/use-a-model-that-reads-xml-tools.md) — SmolLM3, `toolFormat: xml_tools`
29 - [Turn a model's reasoning off](how-to/turn-reasoning-off.md) — `chat_template_kwargs` or `reasoning_effort` in `sampling`, per server31 - [Turn a model's reasoning off](how-to/turn-reasoning-off.md) — `chat_template_kwargs` or `reasoning_effort` in `sampling`, per server
30 - [Confirm tool calls before they run](how-to/confirm-tool-calls.md) — `yolo: false`, the four answers, `AGENT_YOLO`, who decides with `mmConfigs`32 - [Confirm tool calls before they run](how-to/confirm-tool-calls.md) — `yolo: false`, the four answers, `AGENT_YOLO`, who decides with `mmConfigs`
31 - [Let the agent ask you questions](how-to/let-the-agent-ask-you.md) — `ask_user`: answer in the terminal, the window or an editor, turn it off, decide for the agents yours calls (`askUserTool`, `mmConfigs.<name>.askUser`, `AGENT_ASK_USER`)33 - [Let the agent ask you questions](how-to/let-the-agent-ask-you.md) — `ask_user`: answer in the terminal, the window or an editor, turn it off, decide for the agents yours calls (`askUserTool`, `mmConfigs.<name>.askUser`, `AGENT_ASK_USER`)
32 - [Run mini-me in a container](how-to/run-in-a-container.md) — `mm dockerinit`: a Dockerfile with the latest release, not root, and a compose file34 - [Run mini-me in a container](how-to/run-in-a-container.md) — `mm dockerinit`: a Dockerfile with the latest release, not root, and a compose file
33 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — `mm sbxinit` for a sbxenv.yaml; the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `demos/use-with-sbx/`, window mode and editor35 - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — `mm sbxinit` for a sbxenv.yaml; the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `demos/use-with-sbx/`, window mode and editor
34-- [Add a skill](how-to/add-a-skill.md)36+- [Add a skill](how-to/add-a-skill.md) — by hand, or with the `mm skillcreate` wizard
35 - [Declare a custom tool](how-to/declare-a-custom-tool.md)37 - [Declare a custom tool](how-to/declare-a-custom-tool.md)
36 - [Write a custom tool as a WebAssembly module](how-to/write-a-wasm-tool.md)38 - [Write a custom tool as a WebAssembly module](how-to/write-a-wasm-tool.md)
37 - [Manage plans and tasks](how-to/manage-plans-and-tasks.md) — the `plan` tool: give a list, follow the `📋` progress line, plans per conversation39 - [Manage plans and tasks](how-to/manage-plans-and-tasks.md) — the `plan` tool: give a list, follow the `📋` progress line, plans per conversation
modified docs/en/explanation/mm-init.md +3 -3
@@ -37,7 +37,7 @@ The family is guessed from the model id: the first family whose `match` words al
3737
3838 Every number has a `source` line, and the table was filled from two kinds of source only:
3939
40-- **Model cards and published files.** The Qwen cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20), the Gemma 4 GGUF sampling, Llama 3.1 8B Instruct's `generation_config.json` (0.6 / 0.9, read from a mirror because Meta's repository needs a login), the Mistral Small 3.1 card ("a relatively low temperature, such as temperature=0.15"), and the OpenAI API reference for hosted models.
40+- **Model cards and published files.** The Qwen cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20), the Gemma 4 GGUF sampling, Llama 3.1 8B Instruct's `generation_config.json` (0.6 / 0.9, read from a mirror because Meta's repository needs a login), the Mistral Small 3.1 card ("a relatively low temperature, such as temperature=0.15"), the SmolLM3-3B card and its `generation_config.json` (0.6 / 0.95, one recommendation for both modes), and the OpenAI API reference for hosted models. The `smollm3` family also sets `toolFormat: xml_tools` (written for usages with tools): its chat template reads `xml_tools`, never the standard tool list, and writes its calls as `<tool_call>` text (ticket 207, see [How to give tools to a model whose template reads `xml_tools`](../how-to/use-a-model-that-reads-xml-tools.md)). It also sets `thinkingOff: true`: the reasoning switch is written off whatever the usage asks, because with reasoning on SmolLM3-3B repeats its tool call instead of answering (measured on llmman, 3 runs out of 3; 6 out of 6 answered with it off). `thinkingOff` and `toolFormat` are ordinary family fields: any family can set them, `decide.go` has no rule for one model.
4141 - **This repository's measured demos.** For each family, the demos that run the model were searched for the sampling they use (`demos/*/agent*.yaml`). For example, `demos/qwen-style` (Qwen3-Coder-Next), `demos/coding-helpers` (Mellum2 12B Thinking, MiniCPM5 2B), `demos/team` (Ornith 1.5 9B), `demos/guard` (Granite Guardian), `demos/codex-style` (gpt-oss 20B) and `demos/llmman` (Jan-nano, Lucy). Qwen's `precise` 0.0 comes from runs with Qwen3.5 4B, reasoning off (`demos/docs-search`, `memory-search`, `plans`).
4242
4343 When nothing is published, `other` uses the rule of the `skills/create-agent` skill: 0.7 / 0.9, and 0.2 for a code agent that does not reason.
@@ -68,7 +68,7 @@ A few values follow from the answers rather than from a table. Each rule writes
6868 - **With bash on, `watchdogTimeout` is aligned on `bashTimeout`**, as section 3 of the settings guide says. Otherwise a long command would look like a stalled model.
6969 - **`parallel_tool_calls: false` for any agent with tools.** Otherwise small models run a check before the change it checks has landed.
7070 - **`contextWindow: 0` and `context.threshold: 75`.** mm asks the server for the window it actually serves. The proposal planned a threshold computed from the model's native window. It was dropped while writing the code: Ollama often serves 4k–8k, so a percentage of the native window would compress almost at once.
71-- **`skillsDir: no-skills`.** A new agent starts without skills, and the comment says how to add some.
71+- **`skillsDir: no-skills` unless asked.** A new agent starts without skills, and the comment says how to add some; answering `yes` to the skills question writes a `skillsDir` and creates the empty folder, so the first skill has somewhere to go. Sessions follow the same rule: off, as mm's built-in default, unless the wizard is told to write the section (ticket 205).
7272
7373 ## Every number says how it was obtained
7474
@@ -102,7 +102,7 @@ The wizard overwrites an existing file only if you agree, and never with `--yes`
102102
103103 - **Generating the file with a model** (`agent.creator.yaml`). Kept for the unusual cases, but not the default: it needs a 27B model loaded and minutes per agent.
104104 - **A threshold computed from the native window.** Rejected, see above: it would compress almost at once on the windows Ollama serves.
105-- **Skills, MCP servers, hooks and `mmConfigs` in the wizard.** Left out of the first version. The file's closing comment points to the how-to guides instead.
105+- **MCP servers, hooks and `mmConfigs` in the wizard.** Left out. The file's closing comment points to the how-to guides instead. Skills and sessions were added later as two yes/no questions (ticket 205), both off by default: they cost one keystroke each and need no further answer.
106106 - **Prompts in the user's language.** Rejected by the user's choice: the generated prompts are in English.
107107
108108 ## Known limits
@@ -37,7 +37,7 @@ The family is guessed from the model id: the first family whose `match` words al
37 37
38 Every number has a `source` line, and the table was filled from two kinds of source only:38 Every number has a `source` line, and the table was filled from two kinds of source only:
39 39
40-- **Model cards and published files.** The Qwen cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20), the Gemma 4 GGUF sampling, Llama 3.1 8B Instruct's `generation_config.json` (0.6 / 0.9, read from a mirror because Meta's repository needs a login), the Mistral Small 3.1 card ("a relatively low temperature, such as temperature=0.15"), and the OpenAI API reference for hosted models.40+- **Model cards and published files.** The Qwen cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20), the Gemma 4 GGUF sampling, Llama 3.1 8B Instruct's `generation_config.json` (0.6 / 0.9, read from a mirror because Meta's repository needs a login), the Mistral Small 3.1 card ("a relatively low temperature, such as temperature=0.15"), the SmolLM3-3B card and its `generation_config.json` (0.6 / 0.95, one recommendation for both modes), and the OpenAI API reference for hosted models. The `smollm3` family also sets `toolFormat: xml_tools` (written for usages with tools): its chat template reads `xml_tools`, never the standard tool list, and writes its calls as `<tool_call>` text (ticket 207, see [How to give tools to a model whose template reads `xml_tools`](../how-to/use-a-model-that-reads-xml-tools.md)). It also sets `thinkingOff: true`: the reasoning switch is written off whatever the usage asks, because with reasoning on SmolLM3-3B repeats its tool call instead of answering (measured on llmman, 3 runs out of 3; 6 out of 6 answered with it off). `thinkingOff` and `toolFormat` are ordinary family fields: any family can set them, `decide.go` has no rule for one model.
41 - **This repository's measured demos.** For each family, the demos that run the model were searched for the sampling they use (`demos/*/agent*.yaml`). For example, `demos/qwen-style` (Qwen3-Coder-Next), `demos/coding-helpers` (Mellum2 12B Thinking, MiniCPM5 2B), `demos/team` (Ornith 1.5 9B), `demos/guard` (Granite Guardian), `demos/codex-style` (gpt-oss 20B) and `demos/llmman` (Jan-nano, Lucy). Qwen's `precise` 0.0 comes from runs with Qwen3.5 4B, reasoning off (`demos/docs-search`, `memory-search`, `plans`).41 - **This repository's measured demos.** For each family, the demos that run the model were searched for the sampling they use (`demos/*/agent*.yaml`). For example, `demos/qwen-style` (Qwen3-Coder-Next), `demos/coding-helpers` (Mellum2 12B Thinking, MiniCPM5 2B), `demos/team` (Ornith 1.5 9B), `demos/guard` (Granite Guardian), `demos/codex-style` (gpt-oss 20B) and `demos/llmman` (Jan-nano, Lucy). Qwen's `precise` 0.0 comes from runs with Qwen3.5 4B, reasoning off (`demos/docs-search`, `memory-search`, `plans`).
42 42
43 When nothing is published, `other` uses the rule of the `skills/create-agent` skill: 0.7 / 0.9, and 0.2 for a code agent that does not reason.43 When nothing is published, `other` uses the rule of the `skills/create-agent` skill: 0.7 / 0.9, and 0.2 for a code agent that does not reason.
@@ -68,7 +68,7 @@ A few values follow from the answers rather than from a table. Each rule writes
68 - **With bash on, `watchdogTimeout` is aligned on `bashTimeout`**, as section 3 of the settings guide says. Otherwise a long command would look like a stalled model.68 - **With bash on, `watchdogTimeout` is aligned on `bashTimeout`**, as section 3 of the settings guide says. Otherwise a long command would look like a stalled model.
69 - **`parallel_tool_calls: false` for any agent with tools.** Otherwise small models run a check before the change it checks has landed.69 - **`parallel_tool_calls: false` for any agent with tools.** Otherwise small models run a check before the change it checks has landed.
70 - **`contextWindow: 0` and `context.threshold: 75`.** mm asks the server for the window it actually serves. The proposal planned a threshold computed from the model's native window. It was dropped while writing the code: Ollama often serves 4k–8k, so a percentage of the native window would compress almost at once.70 - **`contextWindow: 0` and `context.threshold: 75`.** mm asks the server for the window it actually serves. The proposal planned a threshold computed from the model's native window. It was dropped while writing the code: Ollama often serves 4k–8k, so a percentage of the native window would compress almost at once.
71-- **`skillsDir: no-skills`.** A new agent starts without skills, and the comment says how to add some.71+- **`skillsDir: no-skills` unless asked.** A new agent starts without skills, and the comment says how to add some; answering `yes` to the skills question writes a `skillsDir` and creates the empty folder, so the first skill has somewhere to go. Sessions follow the same rule: off, as mm's built-in default, unless the wizard is told to write the section (ticket 205).
72 72
73 ## Every number says how it was obtained73 ## Every number says how it was obtained
74 74
@@ -102,7 +102,7 @@ The wizard overwrites an existing file only if you agree, and never with `--yes`
102 102
103 - **Generating the file with a model** (`agent.creator.yaml`). Kept for the unusual cases, but not the default: it needs a 27B model loaded and minutes per agent.103 - **Generating the file with a model** (`agent.creator.yaml`). Kept for the unusual cases, but not the default: it needs a 27B model loaded and minutes per agent.
104 - **A threshold computed from the native window.** Rejected, see above: it would compress almost at once on the windows Ollama serves.104 - **A threshold computed from the native window.** Rejected, see above: it would compress almost at once on the windows Ollama serves.
105-- **Skills, MCP servers, hooks and `mmConfigs` in the wizard.** Left out of the first version. The file's closing comment points to the how-to guides instead.105+- **MCP servers, hooks and `mmConfigs` in the wizard.** Left out. The file's closing comment points to the how-to guides instead. Skills and sessions were added later as two yes/no questions (ticket 205), both off by default: they cost one keystroke each and need no further answer.
106 - **Prompts in the user's language.** Rejected by the user's choice: the generated prompts are in English.106 - **Prompts in the user's language.** Rejected by the user's choice: the generated prompts are in English.
107 107
108 ## Known limits108 ## Known limits
modified docs/en/how-to/add-a-skill.md +2 -0
@@ -26,6 +26,8 @@ This guide shows how to give the agent a new markdown procedure that the model c
2626
2727 ## Variants
2828
29+- **With a wizard.** `mm skillcreate` asks for the name, the description, the keywords and the text, and writes `<skillsDir>/<name>/SKILL.md` where the agent's config says — `.mm/skills/` for a project made with `mm init --project yes`. The name is written lower case with dashes (`Vulcan Salute` → `vulcan-salute`); the file gets a level-1 title and a `## What to do` section. In a script: `mm skillcreate --name "Vulcan Salute" --description "Greet the Vulcan way." --keywords "vulcan, spock" --body-file salute.md --yes`. See the [`mm skillcreate` reference](../reference/cli.md#mm-skillcreate--the-skill-wizard).
30+
2931 - **Another directory.** Set `skillsDir` in the YAML. In the terminal a relative path is resolved from the directory you start `mm` in, so an installed `mm` run inside a project loads that project's skills; in ACP mode with a config file it is resolved next to that file. The banner prints `[warning: no skills found in <absolute path> …]` when the resolved directory yields nothing.
3032 - **No skills at all.** Leave the directory empty or absent: `read_skill` is then not declared, and the model never sees it.
3133 - **The shipped examples.** The repository ships `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` and `skills/what-time-is-it/SKILL.md`. Start `mm` from the repository root and the banner shows `skills: 3`.
@@ -26,6 +26,8 @@ This guide shows how to give the agent a new markdown procedure that the model c
26 26
27 ## Variants27 ## Variants
28 28
29+- **With a wizard.** `mm skillcreate` asks for the name, the description, the keywords and the text, and writes `<skillsDir>/<name>/SKILL.md` where the agent's config says — `.mm/skills/` for a project made with `mm init --project yes`. The name is written lower case with dashes (`Vulcan Salute` → `vulcan-salute`); the file gets a level-1 title and a `## What to do` section. In a script: `mm skillcreate --name "Vulcan Salute" --description "Greet the Vulcan way." --keywords "vulcan, spock" --body-file salute.md --yes`. See the [`mm skillcreate` reference](../reference/cli.md#mm-skillcreate--the-skill-wizard).
30+
29 - **Another directory.** Set `skillsDir` in the YAML. In the terminal a relative path is resolved from the directory you start `mm` in, so an installed `mm` run inside a project loads that project's skills; in ACP mode with a config file it is resolved next to that file. The banner prints `[warning: no skills found in <absolute path> …]` when the resolved directory yields nothing.31 - **Another directory.** Set `skillsDir` in the YAML. In the terminal a relative path is resolved from the directory you start `mm` in, so an installed `mm` run inside a project loads that project's skills; in ACP mode with a config file it is resolved next to that file. The banner prints `[warning: no skills found in <absolute path> …]` when the resolved directory yields nothing.
30 - **No skills at all.** Leave the directory empty or absent: `read_skill` is then not declared, and the model never sees it.32 - **No skills at all.** Leave the directory empty or absent: `read_skill` is then not declared, and the model never sees it.
31 - **The shipped examples.** The repository ships `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` and `skills/what-time-is-it/SKILL.md`. Start `mm` from the repository root and the banner shows `skills: 3`.33 - **The shipped examples.** The repository ships `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` and `skills/what-time-is-it/SKILL.md`. Start `mm` from the repository root and the banner shows `skills: 3`.
modified docs/en/how-to/create-an-agent.md +36 -34
@@ -16,44 +16,44 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's
1616
1717 ```text
1818 mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M
19- ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20- ✓ Server llmman │ What is the agent for? │ # docs — answers from a documentation folder (docs/), with lin…
21- ✓ Model Qwen3.5-…│ │ #
22- ✓ Family qwen │ developer (default) │ # Written by `mm init`. Every value says why it has that value…
23- ▸ Usage │ reviewer │ # them as you watch the agent work (guides/agent-settings-for-…
24- · Machine │ translator │ #
25- · Ask first │ writer │ # model family: qwen
26- · Name │ assistant │ # usage: docs
27- · File │ researcher │ # machine: small — a laptop, models up to about 8B
28- │ ▸ docs │ #
29- │ json │ # mm -config <this file> or mm -tui <this file>
30- │ coordinator │
31- │ │ provider: llmman
32- │ answers from a documentation folder │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
33- │ (docs/), with links to the passages │
34- │ │ # 0 = mm asks the server for the window it serves (the model's…
35- │ │ # is 262144 tokens; the server may serve less)
36- │ │ contextWindow: 0
37- │ │ # usage docs's default: tools run without asking
38- │ │ yolo: true
39- │ │ # usage docs
40- │ │ bashTool: false
41- │ │ editTools: true
42- │ │ planTool: false
43- │ │ askUserTool: false
44- │ │ # no skills: add a skills/ folder and set skillsDir to use som…
45- │ │ # https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)
46- │ │ skillsDir: no-skills
47- │ │ # usage docs
48- │ │ maxTurns: 8
49- │ │ # [guide] settings guide, row "Small model (≤ 8B), short t…
50- ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
19+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20+ ✓ Project no │ What is the agent for? │ # docs — answers from a documentation folder (docs/), wi…
21+ ✓ Server llmman │ │ #
22+ ✓ Model Qwen3.5-…│ developer (default) │ # Written by `mm init`. Every value says why it has that…
23+ ✓ Family qwen │ reviewer │ # them as you watch the agent work:
24+ ▸ Usage │ translator │ # https://rickub.com/bots-garden/mini-me/blob/main/guide…
25+ · Machine │ writer │ #
26+ · Ask first │ assistant │ # model family: qwen
27+ · Sessions │ researcher │ # usage: docs
28+ · Skills │ ▸ docs │ # machine: small — a laptop, models up to about 8B
29+ · Name │ json │ # model facts: read from llmman: native window 262144…
30+ · File │ coordinator │ #
31+ │ │ # Where the numbers come from (the tag before their comm…
32+ │ answers from a documentation folder │ # [measured] measured in a demo of mm — re-measure aft…
33+ │ (docs/), with links to the passages │ # [card] from the model card or the GGUF, not meas…
34+ │ │ # [guide] the settings guide's row for this machine
35+ │ │ # [rule] a rule of thumb, not specific to this mod…
36+ │ │ #
37+ │ │ # mm -config <this file> or mm -tui <this f…
38+ │ │
39+ │ │ provider: llmman
40+ │ │ # The model. Chosen for it (family qwen): sampling (temp…
41+ │ │ # max_tokens) and sampling.chat_template_kwargs (enable_…
42+ │ │ # model and redo them together — running mm init again i…
43+ │ │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
44+ │ │ baseUrl: "http://127.0.0.1:17434/v1"
45+ │ │
46+ │ │ # 0 = mm asks the server for the window it serves (the m…
47+ │ │ # is 262144 tokens; the server may serve less)
48+ │ │ contextWindow: 0
49+ │ │ # usage docs's default: tools run without asking
50+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
5151 ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
5252 ```
5353
5454 `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file; the endpoint and the key's variable for `openai`) is typed; `←` `→` move the cursor in it, `Home` `End` go to its ends, `Delete` removes the character after the cursor, `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file.
5555
56- The questions: the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, the agent's name and the file. The name opens the system prompt as you typed it (`Your name is Bob.`), and gives `acp.name` in lower case and a capitalised `acp.title` (`bob`, `Bob (developer)`). Pressing `Enter` takes the proposed name, the usage's.
56+ The questions: whether to keep everything in a `.mm` folder (asked first, `no` by default), the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, whether to save the conversations and to use skills (both `no` by default), the agent's name and the file. The name opens the system prompt as you typed it (`Your name is Bob.`), and gives `acp.name` in lower case and a capitalised `acp.title` (`bob`, `Bob (developer)`). Pressing `Enter` takes the proposed name, the usage's.
5757
5858 3. mm writes the file and checks that it loads:
5959
@@ -78,7 +78,9 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's
7878 --usage docs --machine small --name rata -o agent.yaml --yes --check
7979 ```
8080
81-- **See the file without writing it.** `-o -` prints it on stdout.
81+- **See the file without writing it.** `-o -` prints it on stdout. Nothing is created, not even the `.mm` or skills folder.
82+- **Everything in a `.mm` folder.** Answer `yes` to the first question, or give `--project yes`: mm creates `.mm/` and writes `.mm/agent.yaml`, its paths written from the project root. Started from that root, `mm` and `mm tui` find it with no argument. See [How to keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md).
83+- **Sessions and skills.** `--sessions yes` writes a `sessions` section, so every conversation is saved and `/resume` can reopen it. `--skills yes` writes `skillsDir` and creates the folder (`skills/`, or `.mm/skills/` with `--project yes`); add your skills there (see [How to add a skill](add-a-skill.md)).
8284 - **An OpenAI-compatible service (OpenAI, Cerebras, Hugging Face…).** Choose `openai`: two more questions follow, the endpoint (`https://api.openai.com/v1` by default) and the name of the variable that holds the API key (`OPENAI_API_KEY` by default, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Export the key before running `mm init`: with it, the model question lists the service's models; without it, you type the model's ID (the question says the variable is not set). Flags: `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. The file gets `baseUrl` and `apiKeyEnv`, and a commented `reasoning_effort: none` in `sampling`, to uncomment if the API answers "Function tools with reasoning_effort are not supported".
8385 - **A server elsewhere.** `--base-url http://192.168.1.20:17434/v1`; it is written into the file as `baseUrl`. Without it, the file has no `baseUrl` and records the provider's default address and fallback as comments, ready to uncomment.
8486 - **A server that does not answer.** mm says so and asks for the model's name: the settings then come from the family table only.
@@ -16,44 +16,44 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's
16 16
17 ```text17 ```text
18 mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M18 mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M
19- ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────19+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20- ✓ Server llmman │ What is the agent for? │ # docs — answers from a documentation folder (docs/), with lin…20+ ✓ Project no │ What is the agent for? │ # docs — answers from a documentation folder (docs/), wi…
21- ✓ Model Qwen3.5-…│ │ #21+ ✓ Server llmman │ │ #
22- ✓ Family qwen │ developer (default) │ # Written by `mm init`. Every value says why it has that value…22+ ✓ Model Qwen3.5-…│ developer (default) │ # Written by `mm init`. Every value says why it has that…
23- ▸ Usage │ reviewer │ # them as you watch the agent work (guides/agent-settings-for-…23+ ✓ Family qwen │ reviewer │ # them as you watch the agent work:
24- · Machine │ translator │ #24+ ▸ Usage │ translator │ # https://rickub.com/bots-garden/mini-me/blob/main/guide…
25- · Ask first │ writer │ # model family: qwen25+ · Machine │ writer │ #
26- · Name │ assistant │ # usage: docs26+ · Ask first │ assistant │ # model family: qwen
27- · File │ researcher │ # machine: small — a laptop, models up to about 8B27+ · Sessions │ researcher │ # usage: docs
28- │ ▸ docs │ #28+ · Skills │ ▸ docs │ # machine: small — a laptop, models up to about 8B
29- │ json │ # mm -config <this file> or mm -tui <this file>29+ · Name │ json │ # model facts: read from llmman: native window 262144…
30- │ coordinator │30+ · File │ coordinator │ #
31- │ │ provider: llmman31+ │ │ # Where the numbers come from (the tag before their comm…
32- │ answers from a documentation folder │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"32+ │ answers from a documentation folder │ # [measured] measured in a demo of mm — re-measure aft…
33- │ (docs/), with links to the passages │33+ │ (docs/), with links to the passages │ # [card] from the model card or the GGUF, not meas…
34- │ │ # 0 = mm asks the server for the window it serves (the model's…34+ │ │ # [guide] the settings guide's row for this machine
35- │ │ # is 262144 tokens; the server may serve less)35+ │ │ # [rule] a rule of thumb, not specific to this mod…
36- │ │ contextWindow: 036+ │ │ #
37- │ │ # usage docs's default: tools run without asking37+ │ │ # mm -config <this file> or mm -tui <this f…
38- │ │ yolo: true38+ │ │
39- │ │ # usage docs39+ │ │ provider: llmman
40- │ │ bashTool: false40+ │ │ # The model. Chosen for it (family qwen): sampling (temp…
41- │ │ editTools: true41+ │ │ # max_tokens) and sampling.chat_template_kwargs (enable_…
42- │ │ planTool: false42+ │ │ # model and redo them together — running mm init again i…
43- │ │ askUserTool: false43+ │ │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
44- │ │ # no skills: add a skills/ folder and set skillsDir to use som…44+ │ │ baseUrl: "http://127.0.0.1:17434/v1"
45- │ │ # https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)45+ │ │
46- │ │ skillsDir: no-skills46+ │ │ # 0 = mm asks the server for the window it serves (the m…
47- │ │ # usage docs47+ │ │ # is 262144 tokens; the server may serve less)
48- │ │ maxTurns: 848+ │ │ contextWindow: 0
49- │ │ # [guide] settings guide, row "Small model (≤ 8B), short t…49+ │ │ # usage docs's default: tools run without asking
50- ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────50+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
51 ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit51 ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
52 ```52 ```
53 53
54 `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file; the endpoint and the key's variable for `openai`) is typed; `←` `→` move the cursor in it, `Home` `End` go to its ends, `Delete` removes the character after the cursor, `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file.54 `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file; the endpoint and the key's variable for `openai`) is typed; `←` `→` move the cursor in it, `Home` `End` go to its ends, `Delete` removes the character after the cursor, `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file.
55 55
56- The questions: the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, the agent's name and the file. The name opens the system prompt as you typed it (`Your name is Bob.`), and gives `acp.name` in lower case and a capitalised `acp.title` (`bob`, `Bob (developer)`). Pressing `Enter` takes the proposed name, the usage's.56+ The questions: whether to keep everything in a `.mm` folder (asked first, `no` by default), the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, whether to save the conversations and to use skills (both `no` by default), the agent's name and the file. The name opens the system prompt as you typed it (`Your name is Bob.`), and gives `acp.name` in lower case and a capitalised `acp.title` (`bob`, `Bob (developer)`). Pressing `Enter` takes the proposed name, the usage's.
57 57
58 3. mm writes the file and checks that it loads:58 3. mm writes the file and checks that it loads:
59 59
@@ -78,7 +78,9 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's
78 --usage docs --machine small --name rata -o agent.yaml --yes --check78 --usage docs --machine small --name rata -o agent.yaml --yes --check
79 ```79 ```
80 80
81-- **See the file without writing it.** `-o -` prints it on stdout.81+- **See the file without writing it.** `-o -` prints it on stdout. Nothing is created, not even the `.mm` or skills folder.
82+- **Everything in a `.mm` folder.** Answer `yes` to the first question, or give `--project yes`: mm creates `.mm/` and writes `.mm/agent.yaml`, its paths written from the project root. Started from that root, `mm` and `mm tui` find it with no argument. See [How to keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md).
83+- **Sessions and skills.** `--sessions yes` writes a `sessions` section, so every conversation is saved and `/resume` can reopen it. `--skills yes` writes `skillsDir` and creates the folder (`skills/`, or `.mm/skills/` with `--project yes`); add your skills there (see [How to add a skill](add-a-skill.md)).
82 - **An OpenAI-compatible service (OpenAI, Cerebras, Hugging Face…).** Choose `openai`: two more questions follow, the endpoint (`https://api.openai.com/v1` by default) and the name of the variable that holds the API key (`OPENAI_API_KEY` by default, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Export the key before running `mm init`: with it, the model question lists the service's models; without it, you type the model's ID (the question says the variable is not set). Flags: `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. The file gets `baseUrl` and `apiKeyEnv`, and a commented `reasoning_effort: none` in `sampling`, to uncomment if the API answers "Function tools with reasoning_effort are not supported".84 - **An OpenAI-compatible service (OpenAI, Cerebras, Hugging Face…).** Choose `openai`: two more questions follow, the endpoint (`https://api.openai.com/v1` by default) and the name of the variable that holds the API key (`OPENAI_API_KEY` by default, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Export the key before running `mm init`: with it, the model question lists the service's models; without it, you type the model's ID (the question says the variable is not set). Flags: `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. The file gets `baseUrl` and `apiKeyEnv`, and a commented `reasoning_effort: none` in `sampling`, to uncomment if the API answers "Function tools with reasoning_effort are not supported".
83 - **A server elsewhere.** `--base-url http://192.168.1.20:17434/v1`; it is written into the file as `baseUrl`. Without it, the file has no `baseUrl` and records the provider's default address and fallback as comments, ready to uncomment.85 - **A server elsewhere.** `--base-url http://192.168.1.20:17434/v1`; it is written into the file as `baseUrl`. Without it, the file has no `baseUrl` and records the provider's default address and fallback as comments, ready to uncomment.
84 - **A server that does not answer.** mm says so and asks for the model's name: the settings then come from the family table only.86 - **A server that does not answer.** mm says so and asks for the model's name: the settings then come from the family table only.
modified docs/en/how-to/guard-the-agent-with-hooks.md +1 -1
@@ -59,7 +59,7 @@ hooks:
5959 ```
6060
6161 - `matcher` limits the hook to the `bash` tool; without it, the hook runs for every tool call.
62-- `$MM_CONFIG_DIR` is the directory of the YAML file, so the script is found whatever directory `mm` is started from.
62+- `$MM_CONFIG_DIR` is the directory of the YAML file (the project root when the file is in a `.mm` folder), so the script is found whatever directory `mm` is started from.
6363 - `onError: fail` denies the call when the hook itself fails (Node missing, a syntax error, a timeout). Use it for guards; keep the default, `warn`, for hooks whose failure must not get in the way.
6464
6565 ### 5. Start the agent and check
@@ -59,7 +59,7 @@ hooks:
59 ```59 ```
60 60
61 - `matcher` limits the hook to the `bash` tool; without it, the hook runs for every tool call.61 - `matcher` limits the hook to the `bash` tool; without it, the hook runs for every tool call.
62-- `$MM_CONFIG_DIR` is the directory of the YAML file, so the script is found whatever directory `mm` is started from.62+- `$MM_CONFIG_DIR` is the directory of the YAML file (the project root when the file is in a `.mm` folder), so the script is found whatever directory `mm` is started from.
63 - `onError: fail` denies the call when the hook itself fails (Node missing, a syntax error, a timeout). Use it for guards; keep the default, `warn`, for hooks whose failure must not get in the way.63 - `onError: fail` denies the call when the hook itself fails (Node missing, a syntax error, a timeout). Use it for guards; keep the default, `warn`, for hooks whose failure must not get in the way.
64 64
65 ### 5. Start the agent and check65 ### 5. Start the agent and check
added docs/en/how-to/keep-the-config-in-a-mm-folder.md +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+# How to keep a project's mini-me files in a `.mm` folder
2+
3+This guide shows how to put an agent's configuration, skills, hooks and sessions in a `.mm` folder at the root of a project, instead of beside its source, and still start the agent with a plain `mm` or `mm tui`. It assumes `mm` is installed on your `PATH` and that you already have an `agent.yaml` (written by `mm init`, for example).
4+
5+## Steps
6+
7+1. Create the folder and move the configuration into it:
8+
9+ ```bash
10+ mkdir -p .mm
11+ mv agent.yaml .mm/agent.yaml
12+ ```
13+
14+2. Write every relative path in `.mm/agent.yaml` **from the project root**, not from `.mm/`:
15+
16+ ```yaml
17+ skillsDir: .mm/skills
18+ sessions:
19+ enabled: true
20+ dir: .mm/sessions
21+ hooks:
22+ preToolCall:
23+ - matcher: ^bash$
24+ command: node "$MM_CONFIG_DIR/.mm/hooks/bash-guard.js"
25+ ```
26+
27+ For a file in a `.mm` folder, `$MM_CONFIG_DIR` is the project root, so a hook script in `.mm/hooks/` is named `$MM_CONFIG_DIR/.mm/hooks/…`. The same goes for `mmConfigs`, the deciders of `skillRouting` and `toolRouting`, and `tools[].wasm`.
28+
29+3. Start the agent from the project root, with no argument:
30+
31+ ```bash
32+ mm # or: mm tui
33+ ```
34+
35+ The banner names the file it read:
36+
37+ ```
38+ mm … | config: .mm/agent.yaml | provider: …
39+ ```
40+
41+## Variants
42+
43+- **A new agent.** `mm init` does all of this when you answer `yes` to its first question, or with `mm init --project yes`: it creates `.mm/`, writes `.mm/agent.yaml` with paths from the project root and, with `--skills yes`, creates `.mm/skills/`. See [How to create an agent with `mm init`](create-an-agent.md).
44+
45+- **An `agent.yaml` is also at the root.** It wins: `mm` reads `./agent.yaml` first and `./.mm/agent.yaml` only when there is none. Remove or rename the root file, or name the one you want: `mm -config .mm/agent.yaml`.
46+- **In a Docker Sandbox.** `mm sbxinit` finds `.mm/agent.yaml` and writes a `sbxenv.yaml` that mounts `.mm/` read-only, its sessions and traces writable; see [When the agent is in a `.mm` folder](run-in-a-docker-sandbox.md#when-the-agent-is-in-a-mm-folder).
47+- **From an editor (ACP).** Point `AGENT_CONFIG` at the absolute path of `.mm/agent.yaml`. Its paths keep the same meaning: a relative `skillsDir` such as `.mm/skills` is resolved from the project root, not from the editor's working directory. See [How to use mini-me from an editor](use-from-an-editor.md).
48+- **Started from a sub-directory.** The lookup only looks in the working directory: from `src/`, `mm` does not find `../.mm/agent.yaml`. Start it from the root: in a terminal a relative `skillsDir` follows the working directory, so `mm -config ../.mm/agent.yaml` from `src/` would look for the skills in `src/.mm/skills`.
49+- **Several agents in one project.** Keep the extra ones in the same folder (`.mm/agents/reviewer.yaml`) and name them with `-config`, or from `mmConfigs` (`reviewer: .mm/agents/reviewer.yaml`). Only a file *directly* in `.mm` uses the project root as its base: the paths inside `.mm/agents/reviewer.yaml` are relative to `.mm/agents/`.
50+
51+## Requirements
52+
53+`mm` v1.0.7 or later. An older `mm` does not look in `.mm/`, and resolves the paths of a `.mm/agent.yaml` from `.mm/` itself: `$MM_CONFIG_DIR/.mm/hooks/…` then points to `.mm/.mm/hooks/…`.
54+
55+When the same agent may run with both — a Docker Sandbox kit often ships an older mm than your machine — write the hook commands with `${MM_CONFIG_DIR%/.mm}`, which drops a trailing `/.mm` and gives the project root with any mm (hook commands run in `sh -c`):
56+
57+```yaml
58+hooks:
59+ preToolCall:
60+ - matcher: ^bash$
61+ command: node "${MM_CONFIG_DIR%/.mm}/.mm/hooks/bash-guard.js"
62+```
63+
64+A script that builds paths from `MM_CONFIG_DIR` itself needs the same rule: take the parent folder when its last element is `.mm`.
65+
66+## See also
67+
68+- The lookup order and the base directory rule: [Configuration file resolution](../reference/cli.md#configuration-file-resolution)
69+- Every key of the file: [Configuration reference](../reference/configuration.md)
70+- The variables a hook receives: [Hooks reference](../reference/hooks.md)
new file mode 100644
@@ -0,0 +1,70 @@
1+# How to keep a project's mini-me files in a `.mm` folder
2+
3+This guide shows how to put an agent's configuration, skills, hooks and sessions in a `.mm` folder at the root of a project, instead of beside its source, and still start the agent with a plain `mm` or `mm tui`. It assumes `mm` is installed on your `PATH` and that you already have an `agent.yaml` (written by `mm init`, for example).
4+
5+## Steps
6+
7+1. Create the folder and move the configuration into it:
8+
9+ ```bash
10+ mkdir -p .mm
11+ mv agent.yaml .mm/agent.yaml
12+ ```
13+
14+2. Write every relative path in `.mm/agent.yaml` **from the project root**, not from `.mm/`:
15+
16+ ```yaml
17+ skillsDir: .mm/skills
18+ sessions:
19+ enabled: true
20+ dir: .mm/sessions
21+ hooks:
22+ preToolCall:
23+ - matcher: ^bash$
24+ command: node "$MM_CONFIG_DIR/.mm/hooks/bash-guard.js"
25+ ```
26+
27+ For a file in a `.mm` folder, `$MM_CONFIG_DIR` is the project root, so a hook script in `.mm/hooks/` is named `$MM_CONFIG_DIR/.mm/hooks/…`. The same goes for `mmConfigs`, the deciders of `skillRouting` and `toolRouting`, and `tools[].wasm`.
28+
29+3. Start the agent from the project root, with no argument:
30+
31+ ```bash
32+ mm # or: mm tui
33+ ```
34+
35+ The banner names the file it read:
36+
37+ ```
38+ mm … | config: .mm/agent.yaml | provider: …
39+ ```
40+
41+## Variants
42+
43+- **A new agent.** `mm init` does all of this when you answer `yes` to its first question, or with `mm init --project yes`: it creates `.mm/`, writes `.mm/agent.yaml` with paths from the project root and, with `--skills yes`, creates `.mm/skills/`. See [How to create an agent with `mm init`](create-an-agent.md).
44+
45+- **An `agent.yaml` is also at the root.** It wins: `mm` reads `./agent.yaml` first and `./.mm/agent.yaml` only when there is none. Remove or rename the root file, or name the one you want: `mm -config .mm/agent.yaml`.
46+- **In a Docker Sandbox.** `mm sbxinit` finds `.mm/agent.yaml` and writes a `sbxenv.yaml` that mounts `.mm/` read-only, its sessions and traces writable; see [When the agent is in a `.mm` folder](run-in-a-docker-sandbox.md#when-the-agent-is-in-a-mm-folder).
47+- **From an editor (ACP).** Point `AGENT_CONFIG` at the absolute path of `.mm/agent.yaml`. Its paths keep the same meaning: a relative `skillsDir` such as `.mm/skills` is resolved from the project root, not from the editor's working directory. See [How to use mini-me from an editor](use-from-an-editor.md).
48+- **Started from a sub-directory.** The lookup only looks in the working directory: from `src/`, `mm` does not find `../.mm/agent.yaml`. Start it from the root: in a terminal a relative `skillsDir` follows the working directory, so `mm -config ../.mm/agent.yaml` from `src/` would look for the skills in `src/.mm/skills`.
49+- **Several agents in one project.** Keep the extra ones in the same folder (`.mm/agents/reviewer.yaml`) and name them with `-config`, or from `mmConfigs` (`reviewer: .mm/agents/reviewer.yaml`). Only a file *directly* in `.mm` uses the project root as its base: the paths inside `.mm/agents/reviewer.yaml` are relative to `.mm/agents/`.
50+
51+## Requirements
52+
53+`mm` v1.0.7 or later. An older `mm` does not look in `.mm/`, and resolves the paths of a `.mm/agent.yaml` from `.mm/` itself: `$MM_CONFIG_DIR/.mm/hooks/…` then points to `.mm/.mm/hooks/…`.
54+
55+When the same agent may run with both — a Docker Sandbox kit often ships an older mm than your machine — write the hook commands with `${MM_CONFIG_DIR%/.mm}`, which drops a trailing `/.mm` and gives the project root with any mm (hook commands run in `sh -c`):
56+
57+```yaml
58+hooks:
59+ preToolCall:
60+ - matcher: ^bash$
61+ command: node "${MM_CONFIG_DIR%/.mm}/.mm/hooks/bash-guard.js"
62+```
63+
64+A script that builds paths from `MM_CONFIG_DIR` itself needs the same rule: take the parent folder when its last element is `.mm`.
65+
66+## See also
67+
68+- The lookup order and the base directory rule: [Configuration file resolution](../reference/cli.md#configuration-file-resolution)
69+- Every key of the file: [Configuration reference](../reference/configuration.md)
70+- The variables a hook receives: [Hooks reference](../reference/hooks.md)
modified docs/en/how-to/run-in-a-docker-sandbox.md +27 -0
@@ -160,6 +160,33 @@ sbx env rm --force --env-arg PROJECT_DIR="$PWD"
160160
161161 `mm sbxinit` does not generate this form, because it makes the file longer for a case most projects do not have. Running `mm sbxinit` again and answering `yes` replaces these additions. [`demos/dev.team/sbxenv.yaml`](../../../demos/dev.team/sbxenv.yaml) is a complete example.
162162
163+### When the agent is in a `.mm` folder
164+
165+A project made with `mm init --project yes` keeps its agent in `.mm/agent.yaml`. `mm sbxinit` finds it there (after `agent.yaml`), names the sandbox after its `acp.name`, and writes a file that keeps the agent's settings out of its own reach:
166+
167+```yaml
168+workspace: .
169+
170+additionalWorkspaces:
171+ - path: ./.mm # read-only over the workspace
172+ readOnly: true
173+ - path: ./.mm/sessions # sessions.dir: writable again
174+
175+args:
176+ PROJECT_DIR:
177+ required: true
178+
179+env:
180+ AGENT_CONFIG: ${{ env.args.PROJECT_DIR }}/.mm/agent.yaml
181+```
182+
183+- `.mm/` is read-only in the sandbox: the agent cannot change its settings, skills or hooks.
184+- The folders mm writes in are writable again, read from the agent file: `sessions.dir` when the sessions are on, `trace.dir` when the trace is on. Their paths are the agent file's, so a mount and a setting cannot disagree.
185+- `AGENT_CONFIG` names the agent file through `PROJECT_DIR`, the project's folder as the sandbox sees it (the same path as on your machine): it works whatever mm's version in the kit and its working directory, `mm -acp` from an editor included. Every `sbx env` command then takes `--env-arg PROJECT_DIR="$PWD"`, run from the folder of `sbxenv.yaml`.
186+- Every path is relative to the folder of `sbxenv.yaml`, as sbx reads them: the file does not depend on where the project is, and can be committed.
187+
188+Anything else written under `.mm/` needs a line of its own — a hook that keeps a log there, for instance: with `onError: ignore`, it would fail without a word. After changing the agent's sessions or trace, run `mm sbxinit` again (answer `yes` to replace the file), then re-create the sandbox: `sbx env rm --force --env-arg PROJECT_DIR="$PWD"`, then `sbx env create --force --env-arg PROJECT_DIR="$PWD"` — a sandbox keeps the mounts it was created with. If `sbx env` answers that a sandbox of that name "shows no sign of ever being created by sbx env", one was made another way (`sbx run --name …`): remove it with `sbx rm <name>`, or change `name:`.
189+
163190 ## The model server stays on the host
164191
165192 Inside a sandbox `localhost` is the sandbox itself. Your model server keeps running on your machine and is reached through `host.docker.internal`, which the sandbox proxy maps to your host.
@@ -160,6 +160,33 @@ sbx env rm --force --env-arg PROJECT_DIR="$PWD"
160 160
161 `mm sbxinit` does not generate this form, because it makes the file longer for a case most projects do not have. Running `mm sbxinit` again and answering `yes` replaces these additions. [`demos/dev.team/sbxenv.yaml`](../../../demos/dev.team/sbxenv.yaml) is a complete example.161 `mm sbxinit` does not generate this form, because it makes the file longer for a case most projects do not have. Running `mm sbxinit` again and answering `yes` replaces these additions. [`demos/dev.team/sbxenv.yaml`](../../../demos/dev.team/sbxenv.yaml) is a complete example.
162 162
163+### When the agent is in a `.mm` folder
164+
165+A project made with `mm init --project yes` keeps its agent in `.mm/agent.yaml`. `mm sbxinit` finds it there (after `agent.yaml`), names the sandbox after its `acp.name`, and writes a file that keeps the agent's settings out of its own reach:
166+
167+```yaml
168+workspace: .
169+
170+additionalWorkspaces:
171+ - path: ./.mm # read-only over the workspace
172+ readOnly: true
173+ - path: ./.mm/sessions # sessions.dir: writable again
174+
175+args:
176+ PROJECT_DIR:
177+ required: true
178+
179+env:
180+ AGENT_CONFIG: ${{ env.args.PROJECT_DIR }}/.mm/agent.yaml
181+```
182+
183+- `.mm/` is read-only in the sandbox: the agent cannot change its settings, skills or hooks.
184+- The folders mm writes in are writable again, read from the agent file: `sessions.dir` when the sessions are on, `trace.dir` when the trace is on. Their paths are the agent file's, so a mount and a setting cannot disagree.
185+- `AGENT_CONFIG` names the agent file through `PROJECT_DIR`, the project's folder as the sandbox sees it (the same path as on your machine): it works whatever mm's version in the kit and its working directory, `mm -acp` from an editor included. Every `sbx env` command then takes `--env-arg PROJECT_DIR="$PWD"`, run from the folder of `sbxenv.yaml`.
186+- Every path is relative to the folder of `sbxenv.yaml`, as sbx reads them: the file does not depend on where the project is, and can be committed.
187+
188+Anything else written under `.mm/` needs a line of its own — a hook that keeps a log there, for instance: with `onError: ignore`, it would fail without a word. After changing the agent's sessions or trace, run `mm sbxinit` again (answer `yes` to replace the file), then re-create the sandbox: `sbx env rm --force --env-arg PROJECT_DIR="$PWD"`, then `sbx env create --force --env-arg PROJECT_DIR="$PWD"` — a sandbox keeps the mounts it was created with. If `sbx env` answers that a sandbox of that name "shows no sign of ever being created by sbx env", one was made another way (`sbx run --name …`): remove it with `sbx rm <name>`, or change `name:`.
189+
163 ## The model server stays on the host190 ## The model server stays on the host
164 191
165 Inside a sandbox `localhost` is the sandbox itself. Your model server keeps running on your machine and is reached through `host.docker.internal`, which the sandbox proxy maps to your host.192 Inside a sandbox `localhost` is the sandbox itself. Your model server keeps running on your machine and is reached through `host.docker.internal`, which the sandbox proxy maps to your host.
added docs/en/how-to/use-a-model-that-reads-xml-tools.md +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+# How to give tools to a model whose template reads `xml_tools` (SmolLM3)
2+
3+This guide shows how to make a model like SmolLM3 call mm's tools. Its card says it supports tool calling, but through mm it never calls one: it describes the command instead of running it. It assumes you already have an agent file for that model (written by `mm init`, for example).
4+
5+## Why it does not work by default
6+
7+mm sends its tools in the OpenAI API's `tools` field, and llama-server (behind llmman, Ollama, DMR…) hands them to the model's chat template under that name. SmolLM3's template never reads `tools`: it reads `xml_tools` (the argument of `apply_chat_template(xml_tools=…)` in its card's example). The model therefore never sees a tool. Given `xml_tools`, it answers with the call as text — `<tool_call>{"name": "bash", "arguments": {"command": "ls"}}</tool_call>` — which the server does not turn into a tool call.
8+
9+## Steps
10+
11+1. In the agent file, add:
12+
13+ ```yaml
14+ toolFormat: xml_tools
15+ ```
16+
17+ mm then sends the tools in `chat_template_kwargs.xml_tools`, writes earlier calls back into the history as `<tool_call>` text (the template shows a message by its text only), and turns the `<tool_call>` blocks of the answer into tool calls — streamed or not. They do not appear in the answer's text.
18+
19+2. Turn the reasoning off — SmolLM3's template turns it on by default:
20+
21+ ```yaml
22+ sampling:
23+ chat_template_kwargs:
24+ enable_thinking: false
25+ ```
26+
27+ With reasoning on, SmolLM3-3B repeats its call after the tool's answer instead of answering (measured: 3 runs out of 3; with it off, 6 out of 6 answered).
28+
29+3. Start `mm` and ask for something a tool answers: `List the files of this directory.` A `🛠️ bash: ls` line, the command's output under it and the `⚙ 1 command(s)` counter show the call ran.
30+
31+## Variants
32+
33+- **A new agent.** For the `smollm3` family, `mm init` does both steps: it writes `toolFormat: xml_tools` when the usage has tools, and `enable_thinking: false` whatever the usage, with a note at the end of the file.
34+- **A usage without tools** (`translator`, `json`): nothing to do, `toolFormat` changes nothing without tools.
35+- **See what goes over the wire.** With `trace: {enabled: true}`, the request files show `chat_template_kwargs.xml_tools` and no `tools`; the response files show the server's raw `<tool_call>` text, before mm reads it. See [How to see the JSON sent to the model](inspect-model-traffic.md).
36+- **A broken call.** A `<tool_call>` whose JSON is cut (by `max_tokens`) or malformed is handed back to the model with a note, as for any model, as long as its tool name can be read; otherwise it stays in the answer's text.
37+
38+## Limits
39+
40+- Tasks of several steps stay fragile with SmolLM3-3B: in the tests, it sometimes took a tool's output for something the user said (the template shows a tool's answer as a user message). Wrapping the output in `<tool_response>` tags made no measured difference.
41+- llama.cpp's `common/chat.cpp` had no SmolLM3-specific handling when this was written (2026-11-13), and the llama-server used by llmman did not parse the calls: hence the work on mm's side.
42+
43+## See also
44+
45+- The key: [Configuration reference](../reference/configuration.md) (`toolFormat`)
46+- Turning reasoning off for other servers: [How to turn a model's reasoning off](turn-reasoning-off.md)
47+- Where the `smollm3` numbers come from: [`mm init` explanation](../explanation/mm-init.md)
new file mode 100644
@@ -0,0 +1,47 @@
1+# How to give tools to a model whose template reads `xml_tools` (SmolLM3)
2+
3+This guide shows how to make a model like SmolLM3 call mm's tools. Its card says it supports tool calling, but through mm it never calls one: it describes the command instead of running it. It assumes you already have an agent file for that model (written by `mm init`, for example).
4+
5+## Why it does not work by default
6+
7+mm sends its tools in the OpenAI API's `tools` field, and llama-server (behind llmman, Ollama, DMR…) hands them to the model's chat template under that name. SmolLM3's template never reads `tools`: it reads `xml_tools` (the argument of `apply_chat_template(xml_tools=…)` in its card's example). The model therefore never sees a tool. Given `xml_tools`, it answers with the call as text — `<tool_call>{"name": "bash", "arguments": {"command": "ls"}}</tool_call>` — which the server does not turn into a tool call.
8+
9+## Steps
10+
11+1. In the agent file, add:
12+
13+ ```yaml
14+ toolFormat: xml_tools
15+ ```
16+
17+ mm then sends the tools in `chat_template_kwargs.xml_tools`, writes earlier calls back into the history as `<tool_call>` text (the template shows a message by its text only), and turns the `<tool_call>` blocks of the answer into tool calls — streamed or not. They do not appear in the answer's text.
18+
19+2. Turn the reasoning off — SmolLM3's template turns it on by default:
20+
21+ ```yaml
22+ sampling:
23+ chat_template_kwargs:
24+ enable_thinking: false
25+ ```
26+
27+ With reasoning on, SmolLM3-3B repeats its call after the tool's answer instead of answering (measured: 3 runs out of 3; with it off, 6 out of 6 answered).
28+
29+3. Start `mm` and ask for something a tool answers: `List the files of this directory.` A `🛠️ bash: ls` line, the command's output under it and the `⚙ 1 command(s)` counter show the call ran.
30+
31+## Variants
32+
33+- **A new agent.** For the `smollm3` family, `mm init` does both steps: it writes `toolFormat: xml_tools` when the usage has tools, and `enable_thinking: false` whatever the usage, with a note at the end of the file.
34+- **A usage without tools** (`translator`, `json`): nothing to do, `toolFormat` changes nothing without tools.
35+- **See what goes over the wire.** With `trace: {enabled: true}`, the request files show `chat_template_kwargs.xml_tools` and no `tools`; the response files show the server's raw `<tool_call>` text, before mm reads it. See [How to see the JSON sent to the model](inspect-model-traffic.md).
36+- **A broken call.** A `<tool_call>` whose JSON is cut (by `max_tokens`) or malformed is handed back to the model with a note, as for any model, as long as its tool name can be read; otherwise it stays in the answer's text.
37+
38+## Limits
39+
40+- Tasks of several steps stay fragile with SmolLM3-3B: in the tests, it sometimes took a tool's output for something the user said (the template shows a tool's answer as a user message). Wrapping the output in `<tool_response>` tags made no measured difference.
41+- llama.cpp's `common/chat.cpp` had no SmolLM3-specific handling when this was written (2026-11-13), and the llama-server used by llmman did not parse the calls: hence the work on mm's side.
42+
43+## See also
44+
45+- The key: [Configuration reference](../reference/configuration.md) (`toolFormat`)
46+- Turning reasoning off for other servers: [How to turn a model's reasoning off](turn-reasoning-off.md)
47+- Where the `smollm3` numbers come from: [`mm init` explanation](../explanation/mm-init.md)
modified docs/en/how-to/use-from-an-editor.md +1 -1
@@ -19,7 +19,7 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client
1919 }
2020 ```
2121
22-2. Point `AGENT_CONFIG` at an absolute path. The editor launches `mm` with the project's directory as working directory, so a relative `./agent.yaml` would not be found. In ACP mode a relative `skillsDir` in that file is resolved next to the file, not next to the editor's working directory.
22+2. Point `AGENT_CONFIG` at an absolute path. The editor launches `mm` with the project's directory as working directory, so a relative `./agent.yaml` would not be found. In ACP mode a relative `skillsDir` in that file is resolved next to the file — from the project root when the file is a `.mm/agent.yaml` — not next to the editor's working directory.
2323
2424 3. Open the agent panel and start a conversation. Each `bash`, `read_file`, `write_file` and `edit_file` call appears as a tool call. By default (`yolo: true`) they run at once; with `yolo: false` in the configuration, the editor asks you to allow each `bash`, `write_file` and `edit_file` call before it runs. `read_file` and `read_skill` never ask.
2525
@@ -19,7 +19,7 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client
19 }19 }
20 ```20 ```
21 21
22-2. Point `AGENT_CONFIG` at an absolute path. The editor launches `mm` with the project's directory as working directory, so a relative `./agent.yaml` would not be found. In ACP mode a relative `skillsDir` in that file is resolved next to the file, not next to the editor's working directory.22+2. Point `AGENT_CONFIG` at an absolute path. The editor launches `mm` with the project's directory as working directory, so a relative `./agent.yaml` would not be found. In ACP mode a relative `skillsDir` in that file is resolved next to the file — from the project root when the file is a `.mm/agent.yaml` — not next to the editor's working directory.
23 23
24 3. Open the agent panel and start a conversation. Each `bash`, `read_file`, `write_file` and `edit_file` call appears as a tool call. By default (`yolo: true`) they run at once; with `yolo: false` in the configuration, the editor asks you to allow each `bash`, `write_file` and `edit_file` call before it runs. `read_file` and `read_skill` never ask.24 3. Open the agent panel and start a conversation. Each `bash`, `read_file`, `write_file` and `edit_file` call appears as a tool call. By default (`yolo: true`) they run at once; with `yolo: false` in the configuration, the editor asks you to allow each `bash`, `write_file` and `edit_file` call before it runs. `read_file` and `read_skill` never ask.
25 25
modified docs/en/reference/cli.md +48 -6
@@ -11,6 +11,7 @@ mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin
1111 mm init [flags] (write a new agent.yaml; also mm --init)
1212 mm dockerinit [flags] (write a Dockerfile and a compose.yaml)
1313 mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox)
14+mm skillcreate [flags] (write a new skill with a wizard)
1415 mm tui [<path>] [flags] (same as mm -tui)
1516 mm acp [<path>] [flags] (same as mm -acp)
1617 ```
@@ -53,7 +54,21 @@ The first existing source wins.
5354 |-------|--------|--------------|
5455 | 1 | `-config` flag or positional argument | Error, exit code 1 |
5556 | 2 | `AGENT_CONFIG` environment variable | Error, exit code 1 |
56-| 3 | `./agent.yaml` | Not an error: built-in defaults are used, the banner shows `config: built-in defaults` |
57+| 3 | `./agent.yaml` | Next source |
58+| 4 | `./.mm/agent.yaml` | Not an error: built-in defaults are used, the banner shows `config: built-in defaults` |
59+
60+When both `./agent.yaml` and `./.mm/agent.yaml` exist, `./agent.yaml` is used.
61+
62+### Base directory of relative paths
63+
64+The relative paths a configuration file names (`mmConfigs`, `skillRouting.decider`, `toolRouting.decider`, `tools[].wasm`, `hooks.*[].wasm`, `skillsDir` in ACP mode) and the `MM_CONFIG_DIR` variable given to hooks are resolved against one base directory:
65+
66+| Configuration file | Base directory |
67+|--------------------|----------------|
68+| Directly inside a folder named `.mm` (`<project>/.mm/agent.yaml`) | The parent of that folder: `<project>` |
69+| Anywhere else (`<dir>/agent.yaml`) | The file's own directory: `<dir>` |
70+
71+The rule depends on where the file is, not on how it was found: `-config`, `AGENT_CONFIG` and the lookup above give the same result. `skillsDir` in terminal mode, `sessions.dir` and `agentsMd.path` are relative to the working directory, whatever the file's location.
5772
5873 ## Environment variables
5974
@@ -100,7 +115,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
100115 | stderr | Banner, warnings, `[acp] …` trail, SDK logs. |
101116 | Spinner | Disabled. |
102117 | Struggles | A `*⚠ mm: …*` paragraph in the agent's message — see [Struggle signals](#struggle-signals). |
103-| Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |
118+| Relative `skillsDir` | Resolved from the [base directory](#base-directory-of-relative-paths) of the configuration file, when a file was found (the project root for a `.mm/agent.yaml`); otherwise from the working directory, as in terminal mode. |
104119 | Permissions | `yolo: true`: no `session/request_permission` is sent for the built-in tools. `yolo: false`: one per call, with four options — `allow` (allow once), `allow_always` (this tool for the session), `reject` (reject once), `never` (`reject_always`: this tool, for the session). MCP tools marked `ask` always send one. |
105120
106121 ## Line mode specifics
@@ -124,7 +139,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
124139 | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. |
125140 | Pickers | `/` completes a command, `@` a path under the working directory, `$` a skill of the catalogue — the last two anywhere in a sentence, keeping their prefix. See [Slash commands](slash-commands.md#the-three-pickers). |
126141 | Thinking | A thinking model's reasoning is its own `‣ thinking` entry, dim italic in a different colour, before the answer. Hidden with `showThinking: false`. |
127-| Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
142+| Errors | One line `[error: <explanation>]`; a cancelled turn shows `🔴 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
128143 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject [n] never <tool>` until answered (`Esc` = `r`); a turn cancelled while asking rejects. |
129144 | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). |
130145 | Struggles | A badge on the right of the status bar until the next question — see [Struggle signals](#struggle-signals). |
@@ -211,7 +226,7 @@ Each existing file is asked about, the default being `no`: that file is kept, th
211226 |------|---------|---------|
212227 | `--workspace` | The folder the sandbox mounts, relative to the current directory or absolute. The file always holds an absolute path | the current directory |
213228 | `--name` | The agent's name, as lower case letters and digits joined by dashes. The sandbox is `<name>-sbx` | `acp.name` in the agent file, else `mm` |
214-| `--agent` | The agent file that names the sandbox, looked for in the current directory, then in the workspace | `agent.yaml` |
229+| `--agent` | The agent file that names the sandbox, looked for in the current directory, then in the workspace. Without the flag, `.mm/agent.yaml` is looked for too, after `agent.yaml` (the order mm follows); found in `<workspace>/.mm/`, the file mounts `.mm/` read-only, mounts the agent file's `sessions.dir` and `trace.dir` (when on and under `.mm/`) writable again, and sets `AGENT_CONFIG` to `${{ env.args.PROJECT_DIR }}/…/.mm/agent.yaml` with a required `PROJECT_DIR` argument; paths are relative to the folder of `sbxenv.yaml`, and the printed commands add `--env-arg PROJECT_DIR="$PWD"` | `agent.yaml`, then `.mm/agent.yaml` |
215230 | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten | off |
216231
217232 An agent file outside the workspace only gives a warning: see [when the agent file is outside the workspace](../how-to/run-in-a-docker-sandbox.md#when-the-agent-file-is-outside-the-workspace). The `mini-me-kit` version is read from its latest release on Rickub, with a 5 s limit; the tag `latest` is used if that fails. The file sets `sandboxOptions.pullPolicy: always`. An existing file is asked about, the default being `no`. Exit codes: `0` done, `1` a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments.
@@ -222,6 +237,7 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
222237
223238 | Flag | Question when absent | Default |
224239 |------|----------------------|---------|
240+| `--project yes\|no` | Keep everything in a `.mm` folder? — asked first. `yes`: the file is `.mm/agent.yaml` (the folder is created), its paths are written from the project root (`skillsDir: .mm/skills`), and `mm` / `mm tui` started there find it with no argument | `no` |
225241 | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) |
226242 | `--base-url` | `openai` only: Endpoint — the service's OpenAI-compatible address (OpenAI, Cerebras, Hugging Face…); for the other providers, not asked | `https://api.openai.com/v1` for `openai`; otherwise the provider's address, or its fallback when it does not answer. Written as `baseUrl` unless it is the default |
227243 | `--api-key-env` | `openai` only: Environment variable that holds the API key | `OPENAI_API_KEY`. Written as `apiKeyEnv` unless it is the default |
@@ -230,8 +246,10 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
230246 | `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
231247 | `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` |
232248 | `--yolo yes\|no` | Ask before each command or file change? — only for usages with `bash`, file tools or `fetch_content` | the usage's |
249+| `--sessions yes\|no` | Save the conversations? | `no`; `yes`: a `sessions` section (`enabled: true`, `dir: .mm/sessions`); `no`: no section, mm's default (off) applies |
250+| `--skills yes\|no` | Use skills? | `no` (`skillsDir: no-skills`); `yes`: `skillsDir: skills`, or `.mm/skills` with `--project yes`, and the folder is created next to the project root once the file is written |
233251 | `--name` | Agent name (`acp.name`) | the usage's name |
234-| `-o`, `--output` | File to write (`-` = stdout) | `agent.yaml` |
252+| `-o`, `--output` | File to write (`-` = stdout: nothing is created, whatever the other answers). With `--project yes`, a file that is not directly in a `.mm` folder is refused | `agent.yaml`; `.mm/agent.yaml` with `--project yes` |
235253 | `--check` | — | off; on: after writing, runs this mm with `-config <file> -p "Say hello in one line."` and reports whether the model answered (5 min at most) |
236254 | `--yes` | — | off; on: nothing is asked, every missing answer takes its default |
237255 | `--line` | — | off; on: the questions are asked line by line even in a terminal |
@@ -244,11 +262,35 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
244262 | Where each value comes from | The server's facts, then the family table, then the usage, then the machine row; the table is `internal/agentinit/presets.yaml`, each entry with its source and, per sampling block, its `origin` (`measured`, `card` or `rule`). |
245263 | Origin tags | The comment of a number about the model starts with a tag: `[measured]` (a demo of this repository ran with it), `[card]` (model card, GGUF or API reference, not measured), `[server]` (read from the server: the GGUF's sampling), `[guide]` (the settings guide's machine row: `maxOutput`, the timeouts), `[rule]` (a rule of thumb: `max_tokens`, the 0.6 floor with reasoning, a family without published values). Values that follow from the answers or mm's defaults have no tag. The header explains the tags the file uses, and only those. |
246264 | Sampling | Reasoning on (the usage asks for it and the model has a switch): the family's thinking sampling, never a temperature under 0.6, `max_tokens: 16384`. Reasoning off: the family's general sampling, or its precise temperature for tool-heavy usages, `max_tokens: 4096`. The GGUF's own sampling replaces the family's. `parallel_tool_calls: false` when any tool is on. |
247-| Written file | A header with the family, the usage, the machine, `model facts` (what the server said: native window, GGUF sampling, reasoning switch and its default — or `none`) and the origin legend; above `model:`, the settings chosen for that model (sampling keys, `chat_template_kwargs`) to redo together when it changes; without `--base-url`, the provider's default `baseUrl` and `fallback` as comments; every value preceded by a comment saying why it has that value; `contextWindow: 0` (mm asks the server), with the native window in its comment; `context.threshold: 75`; the usage's English system prompt; for `docs` a `docSearch` block, for `json` a `structuredOutput` example and every tool off, for `coordinator` a commented `mmConfigs` example. |
265+| Written file | A header with the family, the usage, the machine, `model facts` (what the server said: native window, GGUF sampling, reasoning switch and its default — or `none`) and the origin legend; above `model:`, the settings chosen for that model (sampling keys, `chat_template_kwargs`) to redo together when it changes; without `--base-url`, the provider's default `baseUrl` and `fallback` as comments; every value preceded by a comment saying why it has that value; `contextWindow: 0` (mm asks the server), with the native window in its comment; `context.threshold: 75`; the usage's English system prompt; for `docs` a `docSearch` block, for `json` a `structuredOutput` example and every tool off, for `coordinator` a commented `mmConfigs` example; a `sessions` section with `--sessions yes`; with `--project yes`, a header line saying to start `mm` from the project root. |
248266 | Existing file | Never overwritten: `⚠️ <file> already exists — nothing was written; choose another name with -o`, exit 1, before any question. |
249267 | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. |
250268 | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. |
251269
270+## `mm skillcreate` — the skill wizard
271+
272+`mm skillcreate` (also `--skillcreate`, `-skillcreate`) asks for a skill and writes it as `<skillsDir>/<name>/SKILL.md`. It is answered before any agent starts. Each question below has a flag; a question whose flag is given is not asked. See [How to add a skill](../how-to/add-a-skill.md).
273+
274+| Flag | Question when absent | Default |
275+|------|----------------------|---------|
276+| `--name` | Skill name — written lower case, runs of spaces turned into one dash (`Vulcan Salute` → `vulcan-salute`): the front matter's `name` and the skill's folder. A name with `/`, `\` or a leading dot is refused | none |
277+| — | `Replace <name>?` — asked right after the name when the skill exists (as `<name>/SKILL.md` or `<name>.md`); `no` asks the name again | `no` |
278+| `--description` | Description (one line) | none |
279+| `--keywords` | Keywords (comma-separated); more than 8 → a warning: only the first 8 reach the catalogue | none (`-` in the line mode) |
280+| `--body`, `--body-file` | What to do — the skill's text, several lines. Always the last question. Line mode: read to the end of the input (`Ctrl-D` in a terminal). Window: `Enter` adds a line, `Ctrl-D` answers | none |
281+| `-config` | — | the agent file found as `mm` finds it ([Configuration file resolution](#configuration-file-resolution)) |
282+| `-o` | — | `<skillsDir>/<name>/SKILL.md`; `-` prints the skill on stdout and creates nothing |
283+| `--yes` | — | off; on: nothing is asked — `--name`, `--description` and a text must be given, an existing skill is never replaced |
284+| `--line` | — | off; on: the questions are asked line by line even in a terminal |
285+
286+| Aspect | Behaviour |
287+|--------|-----------|
288+| Written file | Front matter `name` (normalised), `description`, `keywords` (omitted when none); a level-1 title — the name as typed, or, typed already normalised, turned back into words, each capitalised (`vulcan-salute` → `# Vulcan Salute`); `## What to do`; the text. |
289+| Skills directory | The agent file's `skillsDir`, relative to the directory `mm skillcreate` is run from. No agent file, or `skillsDir: no-skills`: `.mm/skills` when a `.mm` folder exists, else `skills`, with a `⚠️` line saying which `skillsDir` to set. The folders are created. |
290+| Window | When stdin and stdout are terminals (and neither `--yes` nor `--line`): the window of `mm init`, the `SKILL.md` as it would be written on the right, headed by the file's path. |
291+| After writing | `✅ wrote <file>`; the skills directory is read back with mm's own loader: `✅ mm reads it: <name> — <description>`, or an error. |
292+| Exit codes | 0 written (or printed); 1 a missing answer with `--yes`, an existing skill with `--yes`, an unreadable `--body-file` or agent file, quitting the window; 2 a bad flag. |
293+
252294 ## Exit codes
253295
254296 | Code | Cause |
@@ -11,6 +11,7 @@ mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin
11 mm init [flags] (write a new agent.yaml; also mm --init)11 mm init [flags] (write a new agent.yaml; also mm --init)
12 mm dockerinit [flags] (write a Dockerfile and a compose.yaml)12 mm dockerinit [flags] (write a Dockerfile and a compose.yaml)
13 mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox)13 mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox)
14+mm skillcreate [flags] (write a new skill with a wizard)
14 mm tui [<path>] [flags] (same as mm -tui)15 mm tui [<path>] [flags] (same as mm -tui)
15 mm acp [<path>] [flags] (same as mm -acp)16 mm acp [<path>] [flags] (same as mm -acp)
16 ```17 ```
@@ -53,7 +54,21 @@ The first existing source wins.
53 |-------|--------|--------------|54 |-------|--------|--------------|
54 | 1 | `-config` flag or positional argument | Error, exit code 1 |55 | 1 | `-config` flag or positional argument | Error, exit code 1 |
55 | 2 | `AGENT_CONFIG` environment variable | Error, exit code 1 |56 | 2 | `AGENT_CONFIG` environment variable | Error, exit code 1 |
56-| 3 | `./agent.yaml` | Not an error: built-in defaults are used, the banner shows `config: built-in defaults` |57+| 3 | `./agent.yaml` | Next source |
58+| 4 | `./.mm/agent.yaml` | Not an error: built-in defaults are used, the banner shows `config: built-in defaults` |
59+
60+When both `./agent.yaml` and `./.mm/agent.yaml` exist, `./agent.yaml` is used.
61+
62+### Base directory of relative paths
63+
64+The relative paths a configuration file names (`mmConfigs`, `skillRouting.decider`, `toolRouting.decider`, `tools[].wasm`, `hooks.*[].wasm`, `skillsDir` in ACP mode) and the `MM_CONFIG_DIR` variable given to hooks are resolved against one base directory:
65+
66+| Configuration file | Base directory |
67+|--------------------|----------------|
68+| Directly inside a folder named `.mm` (`<project>/.mm/agent.yaml`) | The parent of that folder: `<project>` |
69+| Anywhere else (`<dir>/agent.yaml`) | The file's own directory: `<dir>` |
70+
71+The rule depends on where the file is, not on how it was found: `-config`, `AGENT_CONFIG` and the lookup above give the same result. `skillsDir` in terminal mode, `sessions.dir` and `agentsMd.path` are relative to the working directory, whatever the file's location.
57 72
58 ## Environment variables73 ## Environment variables
59 74
@@ -100,7 +115,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
100 | stderr | Banner, warnings, `[acp] …` trail, SDK logs. |115 | stderr | Banner, warnings, `[acp] …` trail, SDK logs. |
101 | Spinner | Disabled. |116 | Spinner | Disabled. |
102 | Struggles | A `*⚠ mm: …*` paragraph in the agent's message — see [Struggle signals](#struggle-signals). |117 | Struggles | A `*⚠ mm: …*` paragraph in the agent's message — see [Struggle signals](#struggle-signals). |
103-| Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |118+| Relative `skillsDir` | Resolved from the [base directory](#base-directory-of-relative-paths) of the configuration file, when a file was found (the project root for a `.mm/agent.yaml`); otherwise from the working directory, as in terminal mode. |
104 | Permissions | `yolo: true`: no `session/request_permission` is sent for the built-in tools. `yolo: false`: one per call, with four options — `allow` (allow once), `allow_always` (this tool for the session), `reject` (reject once), `never` (`reject_always`: this tool, for the session). MCP tools marked `ask` always send one. |119 | Permissions | `yolo: true`: no `session/request_permission` is sent for the built-in tools. `yolo: false`: one per call, with four options — `allow` (allow once), `allow_always` (this tool for the session), `reject` (reject once), `never` (`reject_always`: this tool, for the session). MCP tools marked `ask` always send one. |
105 120
106 ## Line mode specifics121 ## Line mode specifics
@@ -124,7 +139,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
124 | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. |139 | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. |
125 | Pickers | `/` completes a command, `@` a path under the working directory, `$` a skill of the catalogue — the last two anywhere in a sentence, keeping their prefix. See [Slash commands](slash-commands.md#the-three-pickers). |140 | Pickers | `/` completes a command, `@` a path under the working directory, `$` a skill of the catalogue — the last two anywhere in a sentence, keeping their prefix. See [Slash commands](slash-commands.md#the-three-pickers). |
126 | Thinking | A thinking model's reasoning is its own `‣ thinking` entry, dim italic in a different colour, before the answer. Hidden with `showThinking: false`. |141 | Thinking | A thinking model's reasoning is its own `‣ thinking` entry, dim italic in a different colour, before the answer. Hidden with `showThinking: false`. |
127-| Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |142+| Errors | One line `[error: <explanation>]`; a cancelled turn shows `🔴 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
128 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject [n] never <tool>` until answered (`Esc` = `r`); a turn cancelled while asking rejects. |143 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject [n] never <tool>` until answered (`Esc` = `r`); a turn cancelled while asking rejects. |
129 | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). |144 | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). |
130 | Struggles | A badge on the right of the status bar until the next question — see [Struggle signals](#struggle-signals). |145 | Struggles | A badge on the right of the status bar until the next question — see [Struggle signals](#struggle-signals). |
@@ -211,7 +226,7 @@ Each existing file is asked about, the default being `no`: that file is kept, th
211 |------|---------|---------|226 |------|---------|---------|
212 | `--workspace` | The folder the sandbox mounts, relative to the current directory or absolute. The file always holds an absolute path | the current directory |227 | `--workspace` | The folder the sandbox mounts, relative to the current directory or absolute. The file always holds an absolute path | the current directory |
213 | `--name` | The agent's name, as lower case letters and digits joined by dashes. The sandbox is `<name>-sbx` | `acp.name` in the agent file, else `mm` |228 | `--name` | The agent's name, as lower case letters and digits joined by dashes. The sandbox is `<name>-sbx` | `acp.name` in the agent file, else `mm` |
214-| `--agent` | The agent file that names the sandbox, looked for in the current directory, then in the workspace | `agent.yaml` |229+| `--agent` | The agent file that names the sandbox, looked for in the current directory, then in the workspace. Without the flag, `.mm/agent.yaml` is looked for too, after `agent.yaml` (the order mm follows); found in `<workspace>/.mm/`, the file mounts `.mm/` read-only, mounts the agent file's `sessions.dir` and `trace.dir` (when on and under `.mm/`) writable again, and sets `AGENT_CONFIG` to `${{ env.args.PROJECT_DIR }}/…/.mm/agent.yaml` with a required `PROJECT_DIR` argument; paths are relative to the folder of `sbxenv.yaml`, and the printed commands add `--env-arg PROJECT_DIR="$PWD"` | `agent.yaml`, then `.mm/agent.yaml` |
215 | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten | off |230 | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten | off |
216 231
217 An agent file outside the workspace only gives a warning: see [when the agent file is outside the workspace](../how-to/run-in-a-docker-sandbox.md#when-the-agent-file-is-outside-the-workspace). The `mini-me-kit` version is read from its latest release on Rickub, with a 5 s limit; the tag `latest` is used if that fails. The file sets `sandboxOptions.pullPolicy: always`. An existing file is asked about, the default being `no`. Exit codes: `0` done, `1` a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments.232 An agent file outside the workspace only gives a warning: see [when the agent file is outside the workspace](../how-to/run-in-a-docker-sandbox.md#when-the-agent-file-is-outside-the-workspace). The `mini-me-kit` version is read from its latest release on Rickub, with a 5 s limit; the tag `latest` is used if that fails. The file sets `sandboxOptions.pullPolicy: always`. An existing file is asked about, the default being `no`. Exit codes: `0` done, `1` a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments.
@@ -222,6 +237,7 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
222 237
223 | Flag | Question when absent | Default |238 | Flag | Question when absent | Default |
224 |------|----------------------|---------|239 |------|----------------------|---------|
240+| `--project yes\|no` | Keep everything in a `.mm` folder? — asked first. `yes`: the file is `.mm/agent.yaml` (the folder is created), its paths are written from the project root (`skillsDir: .mm/skills`), and `mm` / `mm tui` started there find it with no argument | `no` |
225 | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) |241 | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) |
226 | `--base-url` | `openai` only: Endpoint — the service's OpenAI-compatible address (OpenAI, Cerebras, Hugging Face…); for the other providers, not asked | `https://api.openai.com/v1` for `openai`; otherwise the provider's address, or its fallback when it does not answer. Written as `baseUrl` unless it is the default |242 | `--base-url` | `openai` only: Endpoint — the service's OpenAI-compatible address (OpenAI, Cerebras, Hugging Face…); for the other providers, not asked | `https://api.openai.com/v1` for `openai`; otherwise the provider's address, or its fallback when it does not answer. Written as `baseUrl` unless it is the default |
227 | `--api-key-env` | `openai` only: Environment variable that holds the API key | `OPENAI_API_KEY`. Written as `apiKeyEnv` unless it is the default |243 | `--api-key-env` | `openai` only: Environment variable that holds the API key | `OPENAI_API_KEY`. Written as `apiKeyEnv` unless it is the default |
@@ -230,8 +246,10 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
230 | `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |246 | `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
231 | `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` |247 | `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` |
232 | `--yolo yes\|no` | Ask before each command or file change? — only for usages with `bash`, file tools or `fetch_content` | the usage's |248 | `--yolo yes\|no` | Ask before each command or file change? — only for usages with `bash`, file tools or `fetch_content` | the usage's |
249+| `--sessions yes\|no` | Save the conversations? | `no`; `yes`: a `sessions` section (`enabled: true`, `dir: .mm/sessions`); `no`: no section, mm's default (off) applies |
250+| `--skills yes\|no` | Use skills? | `no` (`skillsDir: no-skills`); `yes`: `skillsDir: skills`, or `.mm/skills` with `--project yes`, and the folder is created next to the project root once the file is written |
233 | `--name` | Agent name (`acp.name`) | the usage's name |251 | `--name` | Agent name (`acp.name`) | the usage's name |
234-| `-o`, `--output` | File to write (`-` = stdout) | `agent.yaml` |252+| `-o`, `--output` | File to write (`-` = stdout: nothing is created, whatever the other answers). With `--project yes`, a file that is not directly in a `.mm` folder is refused | `agent.yaml`; `.mm/agent.yaml` with `--project yes` |
235 | `--check` | — | off; on: after writing, runs this mm with `-config <file> -p "Say hello in one line."` and reports whether the model answered (5 min at most) |253 | `--check` | — | off; on: after writing, runs this mm with `-config <file> -p "Say hello in one line."` and reports whether the model answered (5 min at most) |
236 | `--yes` | — | off; on: nothing is asked, every missing answer takes its default |254 | `--yes` | — | off; on: nothing is asked, every missing answer takes its default |
237 | `--line` | — | off; on: the questions are asked line by line even in a terminal |255 | `--line` | — | off; on: the questions are asked line by line even in a terminal |
@@ -244,11 +262,35 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
244 | Where each value comes from | The server's facts, then the family table, then the usage, then the machine row; the table is `internal/agentinit/presets.yaml`, each entry with its source and, per sampling block, its `origin` (`measured`, `card` or `rule`). |262 | Where each value comes from | The server's facts, then the family table, then the usage, then the machine row; the table is `internal/agentinit/presets.yaml`, each entry with its source and, per sampling block, its `origin` (`measured`, `card` or `rule`). |
245 | Origin tags | The comment of a number about the model starts with a tag: `[measured]` (a demo of this repository ran with it), `[card]` (model card, GGUF or API reference, not measured), `[server]` (read from the server: the GGUF's sampling), `[guide]` (the settings guide's machine row: `maxOutput`, the timeouts), `[rule]` (a rule of thumb: `max_tokens`, the 0.6 floor with reasoning, a family without published values). Values that follow from the answers or mm's defaults have no tag. The header explains the tags the file uses, and only those. |263 | Origin tags | The comment of a number about the model starts with a tag: `[measured]` (a demo of this repository ran with it), `[card]` (model card, GGUF or API reference, not measured), `[server]` (read from the server: the GGUF's sampling), `[guide]` (the settings guide's machine row: `maxOutput`, the timeouts), `[rule]` (a rule of thumb: `max_tokens`, the 0.6 floor with reasoning, a family without published values). Values that follow from the answers or mm's defaults have no tag. The header explains the tags the file uses, and only those. |
246 | Sampling | Reasoning on (the usage asks for it and the model has a switch): the family's thinking sampling, never a temperature under 0.6, `max_tokens: 16384`. Reasoning off: the family's general sampling, or its precise temperature for tool-heavy usages, `max_tokens: 4096`. The GGUF's own sampling replaces the family's. `parallel_tool_calls: false` when any tool is on. |264 | Sampling | Reasoning on (the usage asks for it and the model has a switch): the family's thinking sampling, never a temperature under 0.6, `max_tokens: 16384`. Reasoning off: the family's general sampling, or its precise temperature for tool-heavy usages, `max_tokens: 4096`. The GGUF's own sampling replaces the family's. `parallel_tool_calls: false` when any tool is on. |
247-| Written file | A header with the family, the usage, the machine, `model facts` (what the server said: native window, GGUF sampling, reasoning switch and its default — or `none`) and the origin legend; above `model:`, the settings chosen for that model (sampling keys, `chat_template_kwargs`) to redo together when it changes; without `--base-url`, the provider's default `baseUrl` and `fallback` as comments; every value preceded by a comment saying why it has that value; `contextWindow: 0` (mm asks the server), with the native window in its comment; `context.threshold: 75`; the usage's English system prompt; for `docs` a `docSearch` block, for `json` a `structuredOutput` example and every tool off, for `coordinator` a commented `mmConfigs` example. |265+| Written file | A header with the family, the usage, the machine, `model facts` (what the server said: native window, GGUF sampling, reasoning switch and its default — or `none`) and the origin legend; above `model:`, the settings chosen for that model (sampling keys, `chat_template_kwargs`) to redo together when it changes; without `--base-url`, the provider's default `baseUrl` and `fallback` as comments; every value preceded by a comment saying why it has that value; `contextWindow: 0` (mm asks the server), with the native window in its comment; `context.threshold: 75`; the usage's English system prompt; for `docs` a `docSearch` block, for `json` a `structuredOutput` example and every tool off, for `coordinator` a commented `mmConfigs` example; a `sessions` section with `--sessions yes`; with `--project yes`, a header line saying to start `mm` from the project root. |
248 | Existing file | Never overwritten: `⚠️ <file> already exists — nothing was written; choose another name with -o`, exit 1, before any question. |266 | Existing file | Never overwritten: `⚠️ <file> already exists — nothing was written; choose another name with -o`, exit 1, before any question. |
249 | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. |267 | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. |
250 | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. |268 | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. |
251 269
270+## `mm skillcreate` — the skill wizard
271+
272+`mm skillcreate` (also `--skillcreate`, `-skillcreate`) asks for a skill and writes it as `<skillsDir>/<name>/SKILL.md`. It is answered before any agent starts. Each question below has a flag; a question whose flag is given is not asked. See [How to add a skill](../how-to/add-a-skill.md).
273+
274+| Flag | Question when absent | Default |
275+|------|----------------------|---------|
276+| `--name` | Skill name — written lower case, runs of spaces turned into one dash (`Vulcan Salute` → `vulcan-salute`): the front matter's `name` and the skill's folder. A name with `/`, `\` or a leading dot is refused | none |
277+| — | `Replace <name>?` — asked right after the name when the skill exists (as `<name>/SKILL.md` or `<name>.md`); `no` asks the name again | `no` |
278+| `--description` | Description (one line) | none |
279+| `--keywords` | Keywords (comma-separated); more than 8 → a warning: only the first 8 reach the catalogue | none (`-` in the line mode) |
280+| `--body`, `--body-file` | What to do — the skill's text, several lines. Always the last question. Line mode: read to the end of the input (`Ctrl-D` in a terminal). Window: `Enter` adds a line, `Ctrl-D` answers | none |
281+| `-config` | — | the agent file found as `mm` finds it ([Configuration file resolution](#configuration-file-resolution)) |
282+| `-o` | — | `<skillsDir>/<name>/SKILL.md`; `-` prints the skill on stdout and creates nothing |
283+| `--yes` | — | off; on: nothing is asked — `--name`, `--description` and a text must be given, an existing skill is never replaced |
284+| `--line` | — | off; on: the questions are asked line by line even in a terminal |
285+
286+| Aspect | Behaviour |
287+|--------|-----------|
288+| Written file | Front matter `name` (normalised), `description`, `keywords` (omitted when none); a level-1 title — the name as typed, or, typed already normalised, turned back into words, each capitalised (`vulcan-salute` → `# Vulcan Salute`); `## What to do`; the text. |
289+| Skills directory | The agent file's `skillsDir`, relative to the directory `mm skillcreate` is run from. No agent file, or `skillsDir: no-skills`: `.mm/skills` when a `.mm` folder exists, else `skills`, with a `⚠️` line saying which `skillsDir` to set. The folders are created. |
290+| Window | When stdin and stdout are terminals (and neither `--yes` nor `--line`): the window of `mm init`, the `SKILL.md` as it would be written on the right, headed by the file's path. |
291+| After writing | `✅ wrote <file>`; the skills directory is read back with mm's own loader: `✅ mm reads it: <name> — <description>`, or an error. |
292+| Exit codes | 0 written (or printed); 1 a missing answer with `--yes`, an existing skill with `--yes`, an unreadable `--body-file` or agent file, quitting the window; 2 a bad flag. |
293+
252 ## Exit codes294 ## Exit codes
253 295
254 | Code | Cause |296 | Code | Cause |
modified docs/en/reference/configuration.md +3 -0
@@ -2,6 +2,8 @@
22
33 > Neutral, exhaustive description of the YAML file read by `mm`. Every key is optional; a key left out keeps its built-in default. The file is decoded on top of the defaults, so unknown keys are ignored.
44
5+Where `mm` looks for this file: [Configuration file resolution](cli.md#configuration-file-resolution). "Relative to this file" / "relative to the agent file" below means relative to its [base directory](cli.md#base-directory-of-relative-paths): the file's own directory, or the project root for a file in a `.mm` folder.
6+
57 ## Top-level keys
68
79 | Key | Type | Default | Description |
@@ -11,6 +13,7 @@
1113 | `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. |
1214 | `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. |
1315 | `apiKeyEnv` | string | `""` (provider default) | Name of the environment variable holding the API key. |
16+| `toolFormat` | string | `openai` | How the tools reach the model. `openai`: the OpenAI API's `tools` field, as every server expects. `xml_tools`: for a model whose chat template reads its tools from an `xml_tools` variable and writes its calls as `<tool_call>` text (SmolLM3) — mm sends the tools in `chat_template_kwargs.xml_tools` (no `tools` field), writes earlier calls into the history as `<tool_call>` text, and turns the answer's `<tool_call>` blocks into tool calls, streamed or not. Any other value: `toolFormat must be openai or xml_tools`. See [How to give tools to a model whose template reads `xml_tools`](../how-to/use-a-model-that-reads-xml-tools.md). |
1417 | `contextWindow` | int | `0` | Tokens the server serves. `0` = ask the server, else unknown. Must be ≥ 0. |
1518 | `maxOutput` | int | `16000` | Maximum characters a tool returns to the model; longer output keeps its head and up to 2000 characters of tail. Must be > 0. |
1619 | `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. |
@@ -2,6 +2,8 @@
2 2
3 > Neutral, exhaustive description of the YAML file read by `mm`. Every key is optional; a key left out keeps its built-in default. The file is decoded on top of the defaults, so unknown keys are ignored.3 > Neutral, exhaustive description of the YAML file read by `mm`. Every key is optional; a key left out keeps its built-in default. The file is decoded on top of the defaults, so unknown keys are ignored.
4 4
5+Where `mm` looks for this file: [Configuration file resolution](cli.md#configuration-file-resolution). "Relative to this file" / "relative to the agent file" below means relative to its [base directory](cli.md#base-directory-of-relative-paths): the file's own directory, or the project root for a file in a `.mm` folder.
6+
5 ## Top-level keys7 ## Top-level keys
6 8
7 | Key | Type | Default | Description |9 | Key | Type | Default | Description |
@@ -11,6 +13,7 @@
11 | `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. |13 | `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. |
12 | `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. |14 | `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. |
13 | `apiKeyEnv` | string | `""` (provider default) | Name of the environment variable holding the API key. |15 | `apiKeyEnv` | string | `""` (provider default) | Name of the environment variable holding the API key. |
16+| `toolFormat` | string | `openai` | How the tools reach the model. `openai`: the OpenAI API's `tools` field, as every server expects. `xml_tools`: for a model whose chat template reads its tools from an `xml_tools` variable and writes its calls as `<tool_call>` text (SmolLM3) — mm sends the tools in `chat_template_kwargs.xml_tools` (no `tools` field), writes earlier calls into the history as `<tool_call>` text, and turns the answer's `<tool_call>` blocks into tool calls, streamed or not. Any other value: `toolFormat must be openai or xml_tools`. See [How to give tools to a model whose template reads `xml_tools`](../how-to/use-a-model-that-reads-xml-tools.md). |
14 | `contextWindow` | int | `0` | Tokens the server serves. `0` = ask the server, else unknown. Must be ≥ 0. |17 | `contextWindow` | int | `0` | Tokens the server serves. `0` = ask the server, else unknown. Must be ≥ 0. |
15 | `maxOutput` | int | `16000` | Maximum characters a tool returns to the model; longer output keeps its head and up to 2000 characters of tail. Must be > 0. |18 | `maxOutput` | int | `16000` | Maximum characters a tool returns to the model; longer output keeps its head and up to 2000 characters of tail. Must be > 0. |
16 | `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. |19 | `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. |
modified docs/en/reference/hooks.md +1 -1
@@ -80,7 +80,7 @@ On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preA
8080 | Variable | Value |
8181 |----------|-------|
8282 | `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` or `postAsk` |
83-| `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook |
83+| `MM_CONFIG_DIR` | absolute [base directory](cli.md#base-directory-of-relative-paths) of the configuration file that declared the hook: its own directory, or the project root for a file in a `.mm` folder |
8484
8585 `mm`'s own environment also holds `MM_BIN` (the path of the running `mm`) and `MM_CONFIG` (its configuration file, when one was read), which `mm` sets for every process it starts — see the [command line reference](cli.md#environment-variables).
8686
@@ -80,7 +80,7 @@ On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preA
80 | Variable | Value |80 | Variable | Value |
81 |----------|-------|81 |----------|-------|
82 | `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` or `postAsk` |82 | `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` or `postAsk` |
83-| `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook |83+| `MM_CONFIG_DIR` | absolute [base directory](cli.md#base-directory-of-relative-paths) of the configuration file that declared the hook: its own directory, or the project root for a file in a `.mm` folder |
84 84
85 `mm`'s own environment also holds `MM_BIN` (the path of the running `mm`) and `MM_CONFIG` (its configuration file, when one was read), which `mm` sets for every process it starts — see the [command line reference](cli.md#environment-variables).85 `mm`'s own environment also holds `MM_BIN` (the path of the running `mm`) and `MM_CONFIG` (its configuration file, when one was read), which `mm` sets for every process it starts — see the [command line reference](cli.md#environment-variables).
86 86
modified docs/en/reference/slash-commands.md +6 -6
@@ -7,7 +7,7 @@
77 | Command | At the prompt | During a generation |
88 |---------|---------------|---------------------|
99 | `/quit` | Exits the program. | Cancels the generation, prints `Goodbye.`, exits. |
10-| `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |
10+| `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🔴 Aborting...`; the commands already run stay in the history. |
1111 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |
1212 | `/new` | Clears the history back to the system prompt, resets the loop detector, forgets the server's last token count and the conversation's plans; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |
1313 | `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question, then ` · ⚠️ N error(s)` when the session recorded errors; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |
@@ -39,7 +39,7 @@ While it runs:
3939 | First line | `🗣 asking <name> (<provider>/<model>)…`, or `…, with the conversation…` for the context forms. |
4040 | `<name>`'s answer | Streamed where answers appear. Its reasoning is shown as reasoning when `showThinking` is on. |
4141 | `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. |
42-| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` |
42+| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🔴 Ask aborted — nothing kept.` |
4343 | `/quit` | Cancels the ask and exits. |
4444 | Any other line (line mode) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`; the line runs once the ask has ended, like a line typed during a generation (below). |
4545
@@ -48,7 +48,7 @@ When it ends:
4848 | Outcome | History | Message |
4949 |---------|---------|---------|
5050 | Answered | Two messages appended: user `[asked <name> (<provider>/<model>)[, with this conversation as context]: <prompt>]`, assistant `[<name>'s answer]` + the answer (capped at `maxOutput` characters, beginning and end kept). Saved with the session. | `🗣 <name>'s answer is in the history for your next question` |
51-| Cancelled | Unchanged. | `🛑 Ask aborted — nothing kept.` |
51+| Cancelled | Unchanged. | `🔴 Ask aborted — nothing kept.` |
5252 | Failed | Unchanged; recorded in the session file with `where: "ask"`. | `[error: …]` (terminal, window) or a JSON-RPC error (editor). |
5353
5454 The transcript sent by the context forms holds the user's questions, the assistant's answers, one line per tool call (`Assistant ran <tool>: <args>`, args cut at 160 characters) and the compaction summary if any; never the system prompt, tool outputs or reasoning. Capped at `maxOutput` characters, newest lines kept, `[… earlier conversation omitted …]` marking the cut.
@@ -86,7 +86,7 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
8686
8787 | Key | At the prompt | During a generation |
8888 |-----|---------------|---------------------|
89-| `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |
89+| `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🔴 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |
9090 | `Ctrl+D` (end of input) | Exits. | — |
9191
9292 ## Window mode keys
@@ -157,7 +157,7 @@ The way out, in order:
157157
158158 | You do | The window does |
159159 |--------|-----------------|
160-| `Esc` or `/abort` | Cancels the turn, and says `🛑 Aborting…`. |
160+| `Esc` or `/abort` | Cancels the turn, and says `🔴 Aborting…`. |
161161 | `Esc` or `/abort` again | The turn is still there: `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |
162162 | `Ctrl-C` | Cancels, as above. |
163163 | `Ctrl-C` again | Leaves `mm`, turn or no turn. |
@@ -200,7 +200,7 @@ The notation works in the line mode and over ACP too, typed by hand: a `$skill`
200200
201201 | Message | Meaning |
202202 |---------|---------|
203-| `🛑 Generation aborted.` | The turn was cancelled (`/abort`, `Ctrl+C`). The question and any completed tool turns stay in the history. |
203+| `🔴 Generation aborted.` | The turn was cancelled (`/abort`, `Ctrl+C`). The question and any completed tool turns stay in the history. |
204204 | `[error: …]` | The provider explained a failure in one line; a question that got no answer is removed from the history. |
205205 | `⚙ N command(s)` | Number of `bash` commands that actually ran in the turn. |
206206 | `· 📝 N file op(s)` | Appended when file tools ran. |
@@ -7,7 +7,7 @@
7 | Command | At the prompt | During a generation |7 | Command | At the prompt | During a generation |
8 |---------|---------------|---------------------|8 |---------|---------------|---------------------|
9 | `/quit` | Exits the program. | Cancels the generation, prints `Goodbye.`, exits. |9 | `/quit` | Exits the program. | Cancels the generation, prints `Goodbye.`, exits. |
10-| `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |10+| `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🔴 Aborting...`; the commands already run stay in the history. |
11 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |11 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |
12 | `/new` | Clears the history back to the system prompt, resets the loop detector, forgets the server's last token count and the conversation's plans; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |12 | `/new` | Clears the history back to the system prompt, resets the loop detector, forgets the server's last token count and the conversation's plans; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |
13 | `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question, then ` · ⚠️ N error(s)` when the session recorded errors; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |13 | `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question, then ` · ⚠️ N error(s)` when the session recorded errors; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |
@@ -39,7 +39,7 @@ While it runs:
39 | First line | `🗣 asking <name> (<provider>/<model>)…`, or `…, with the conversation…` for the context forms. |39 | First line | `🗣 asking <name> (<provider>/<model>)…`, or `…, with the conversation…` for the context forms. |
40 | `<name>`'s answer | Streamed where answers appear. Its reasoning is shown as reasoning when `showThinking` is on. |40 | `<name>`'s answer | Streamed where answers appear. Its reasoning is shown as reasoning when `showThinking` is on. |
41 | `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. |41 | `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. |
42-| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` |42+| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🔴 Ask aborted — nothing kept.` |
43 | `/quit` | Cancels the ask and exits. |43 | `/quit` | Cancels the ask and exits. |
44 | Any other line (line mode) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`; the line runs once the ask has ended, like a line typed during a generation (below). |44 | Any other line (line mode) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`; the line runs once the ask has ended, like a line typed during a generation (below). |
45 45
@@ -48,7 +48,7 @@ When it ends:
48 | Outcome | History | Message |48 | Outcome | History | Message |
49 |---------|---------|---------|49 |---------|---------|---------|
50 | Answered | Two messages appended: user `[asked <name> (<provider>/<model>)[, with this conversation as context]: <prompt>]`, assistant `[<name>'s answer]` + the answer (capped at `maxOutput` characters, beginning and end kept). Saved with the session. | `🗣 <name>'s answer is in the history for your next question` |50 | Answered | Two messages appended: user `[asked <name> (<provider>/<model>)[, with this conversation as context]: <prompt>]`, assistant `[<name>'s answer]` + the answer (capped at `maxOutput` characters, beginning and end kept). Saved with the session. | `🗣 <name>'s answer is in the history for your next question` |
51-| Cancelled | Unchanged. | `🛑 Ask aborted — nothing kept.` |51+| Cancelled | Unchanged. | `🔴 Ask aborted — nothing kept.` |
52 | Failed | Unchanged; recorded in the session file with `where: "ask"`. | `[error: …]` (terminal, window) or a JSON-RPC error (editor). |52 | Failed | Unchanged; recorded in the session file with `where: "ask"`. | `[error: …]` (terminal, window) or a JSON-RPC error (editor). |
53 53
54 The transcript sent by the context forms holds the user's questions, the assistant's answers, one line per tool call (`Assistant ran <tool>: <args>`, args cut at 160 characters) and the compaction summary if any; never the system prompt, tool outputs or reasoning. Capped at `maxOutput` characters, newest lines kept, `[… earlier conversation omitted …]` marking the cut.54 The transcript sent by the context forms holds the user's questions, the assistant's answers, one line per tool call (`Assistant ran <tool>: <args>`, args cut at 160 characters) and the compaction summary if any; never the system prompt, tool outputs or reasoning. Capped at `maxOutput` characters, newest lines kept, `[… earlier conversation omitted …]` marking the cut.
@@ -86,7 +86,7 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
86 86
87 | Key | At the prompt | During a generation |87 | Key | At the prompt | During a generation |
88 |-----|---------------|---------------------|88 |-----|---------------|---------------------|
89-| `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |89+| `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🔴 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |
90 | `Ctrl+D` (end of input) | Exits. | — |90 | `Ctrl+D` (end of input) | Exits. | — |
91 91
92 ## Window mode keys92 ## Window mode keys
@@ -157,7 +157,7 @@ The way out, in order:
157 157
158 | You do | The window does |158 | You do | The window does |
159 |--------|-----------------|159 |--------|-----------------|
160-| `Esc` or `/abort` | Cancels the turn, and says `🛑 Aborting…`. |160+| `Esc` or `/abort` | Cancels the turn, and says `🔴 Aborting…`. |
161 | `Esc` or `/abort` again | The turn is still there: `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |161 | `Esc` or `/abort` again | The turn is still there: `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |
162 | `Ctrl-C` | Cancels, as above. |162 | `Ctrl-C` | Cancels, as above. |
163 | `Ctrl-C` again | Leaves `mm`, turn or no turn. |163 | `Ctrl-C` again | Leaves `mm`, turn or no turn. |
@@ -200,7 +200,7 @@ The notation works in the line mode and over ACP too, typed by hand: a `$skill`
200 200
201 | Message | Meaning |201 | Message | Meaning |
202 |---------|---------|202 |---------|---------|
203-| `🛑 Generation aborted.` | The turn was cancelled (`/abort`, `Ctrl+C`). The question and any completed tool turns stay in the history. |203+| `🔴 Generation aborted.` | The turn was cancelled (`/abort`, `Ctrl+C`). The question and any completed tool turns stay in the history. |
204 | `[error: …]` | The provider explained a failure in one line; a question that got no answer is removed from the history. |204 | `[error: …]` | The provider explained a failure in one line; a question that got no answer is removed from the history. |
205 | `⚙ N command(s)` | Number of `bash` commands that actually ran in the turn. |205 | `⚙ N command(s)` | Number of `bash` commands that actually ran in the turn. |
206 | `· 📝 N file op(s)` | Appended when file tools ran. |206 | `· 📝 N file op(s)` | Appended when file tools ran. |
modified docs/fr/README.md +3 -1
@@ -26,12 +26,14 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
2626 - [Piloter mini-me depuis un programme](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js : `mm -acp` comme bibliothèque : `Start`/`Complete`/`Stream`, annulation, callback de permission
2727 - [Proposer des outils depuis votre client ACP](how-to/offer-tools-from-an-acp-client.md) — les fonctions de votre programme comme outils de l'agent : `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools`
2828 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
29+- [Ranger les fichiers mini-me d'un projet dans un dossier `.mm`](how-to/keep-the-config-in-a-mm-folder.md) — `.mm/agent.yaml`, chemins écrits depuis la racine du projet
30+- [Donner des outils à un modèle dont le template lit `xml_tools`](how-to/use-a-model-that-reads-xml-tools.md) — SmolLM3, `toolFormat: xml_tools`
2931 - [Couper le raisonnement d'un modèle](how-to/turn-reasoning-off.md) — `chat_template_kwargs` ou `reasoning_effort` dans `sampling`, selon le serveur
3032 - [Confirmer les appels d'outils avant leur exécution](how-to/confirm-tool-calls.md) — `yolo: false`, les quatre réponses, `AGENT_YOLO`, qui décide avec `mmConfigs`
3133 - [Laisser l'agent vous poser des questions](how-to/let-the-agent-ask-you.md) — `ask_user` : répondre dans le terminal, la fenêtre ou un éditeur, couper l'outil, décider pour les agents que le vôtre appelle (`askUserTool`, `mmConfigs.<nom>.askUser`, `AGENT_ASK_USER`)
3234 - [Faire tourner mini-me dans un conteneur](how-to/run-in-a-container.md) — `mm dockerinit` : un Dockerfile avec la dernière version, sans root, et un fichier compose
3335 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — `mm sbxinit` pour un sbxenv.yaml ; le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `demos/use-with-sbx/`, mode fenêtre et éditeur
34-- [Ajouter un skill](how-to/add-a-skill.md)
36+- [Ajouter un skill](how-to/add-a-skill.md) — à la main, ou avec l'assistant `mm skillcreate`
3537 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)
3638 - [Écrire un outil personnalisé sous forme de module WebAssembly](how-to/write-a-wasm-tool.md)
3739 - [Gérer des plans et des tâches](how-to/manage-plans-and-tasks.md) — l'outil `plan` : donner une liste, suivre la ligne de progression `📋`, des plans par conversation
@@ -26,12 +26,14 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
26 - [Piloter mini-me depuis un programme](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js : `mm -acp` comme bibliothèque : `Start`/`Complete`/`Stream`, annulation, callback de permission26 - [Piloter mini-me depuis un programme](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js : `mm -acp` comme bibliothèque : `Start`/`Complete`/`Stream`, annulation, callback de permission
27 - [Proposer des outils depuis votre client ACP](how-to/offer-tools-from-an-acp-client.md) — les fonctions de votre programme comme outils de l'agent : `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools`27 - [Proposer des outils depuis votre client ACP](how-to/offer-tools-from-an-acp-client.md) — les fonctions de votre programme comme outils de l'agent : `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools`
28 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)28 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
29+- [Ranger les fichiers mini-me d'un projet dans un dossier `.mm`](how-to/keep-the-config-in-a-mm-folder.md) — `.mm/agent.yaml`, chemins écrits depuis la racine du projet
30+- [Donner des outils à un modèle dont le template lit `xml_tools`](how-to/use-a-model-that-reads-xml-tools.md) — SmolLM3, `toolFormat: xml_tools`
29 - [Couper le raisonnement d'un modèle](how-to/turn-reasoning-off.md) — `chat_template_kwargs` ou `reasoning_effort` dans `sampling`, selon le serveur31 - [Couper le raisonnement d'un modèle](how-to/turn-reasoning-off.md) — `chat_template_kwargs` ou `reasoning_effort` dans `sampling`, selon le serveur
30 - [Confirmer les appels d'outils avant leur exécution](how-to/confirm-tool-calls.md) — `yolo: false`, les quatre réponses, `AGENT_YOLO`, qui décide avec `mmConfigs`32 - [Confirmer les appels d'outils avant leur exécution](how-to/confirm-tool-calls.md) — `yolo: false`, les quatre réponses, `AGENT_YOLO`, qui décide avec `mmConfigs`
31 - [Laisser l'agent vous poser des questions](how-to/let-the-agent-ask-you.md) — `ask_user` : répondre dans le terminal, la fenêtre ou un éditeur, couper l'outil, décider pour les agents que le vôtre appelle (`askUserTool`, `mmConfigs.<nom>.askUser`, `AGENT_ASK_USER`)33 - [Laisser l'agent vous poser des questions](how-to/let-the-agent-ask-you.md) — `ask_user` : répondre dans le terminal, la fenêtre ou un éditeur, couper l'outil, décider pour les agents que le vôtre appelle (`askUserTool`, `mmConfigs.<nom>.askUser`, `AGENT_ASK_USER`)
32 - [Faire tourner mini-me dans un conteneur](how-to/run-in-a-container.md) — `mm dockerinit` : un Dockerfile avec la dernière version, sans root, et un fichier compose34 - [Faire tourner mini-me dans un conteneur](how-to/run-in-a-container.md) — `mm dockerinit` : un Dockerfile avec la dernière version, sans root, et un fichier compose
33 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — `mm sbxinit` pour un sbxenv.yaml ; le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `demos/use-with-sbx/`, mode fenêtre et éditeur35 - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — `mm sbxinit` pour un sbxenv.yaml ; le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `demos/use-with-sbx/`, mode fenêtre et éditeur
34-- [Ajouter un skill](how-to/add-a-skill.md)36+- [Ajouter un skill](how-to/add-a-skill.md) — à la main, ou avec l'assistant `mm skillcreate`
35 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)37 - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md)
36 - [Écrire un outil personnalisé sous forme de module WebAssembly](how-to/write-a-wasm-tool.md)38 - [Écrire un outil personnalisé sous forme de module WebAssembly](how-to/write-a-wasm-tool.md)
37 - [Gérer des plans et des tâches](how-to/manage-plans-and-tasks.md) — l'outil `plan` : donner une liste, suivre la ligne de progression `📋`, des plans par conversation39 - [Gérer des plans et des tâches](how-to/manage-plans-and-tasks.md) — l'outil `plan` : donner une liste, suivre la ligne de progression `📋`, des plans par conversation
modified docs/fr/explanation/mm-init.md +3 -3
@@ -37,7 +37,7 @@ La famille est déduite de l'identifiant du modèle : la première famille dont
3737
3838 Chaque valeur a une ligne `source`, et la table n'a été remplie qu'à partir de deux sortes de sources :
3939
40-- **Les fiches de modèles et les fichiers publiés.** Les fiches Qwen (avec raisonnement 0.6 / 0.95 / 20, sans raisonnement 0.7 / 0.8 / 20), l'échantillonnage du GGUF de Gemma 4, le `generation_config.json` de Llama 3.1 8B Instruct (0.6 / 0.9, lu sur un miroir parce que le dépôt de Meta demande une connexion), la fiche de Mistral Small 3.1 (« a relatively low temperature, such as temperature=0.15 »), et la référence de l'API OpenAI pour les modèles hébergés.
40+- **Les fiches de modèles et les fichiers publiés.** Les fiches Qwen (avec raisonnement 0.6 / 0.95 / 20, sans raisonnement 0.7 / 0.8 / 20), l'échantillonnage du GGUF de Gemma 4, le `generation_config.json` de Llama 3.1 8B Instruct (0.6 / 0.9, lu sur un miroir parce que le dépôt de Meta demande une connexion), la fiche de Mistral Small 3.1 (« a relatively low temperature, such as temperature=0.15 »), la fiche de SmolLM3-3B et son `generation_config.json` (0.6 / 0.95, une seule recommandation pour les deux modes), et la référence de l'API OpenAI pour les modèles hébergés. La famille `smollm3` fixe aussi `toolFormat: xml_tools` (écrit pour les usages avec outils) : son chat template lit `xml_tools`, jamais la liste d'outils standard, et écrit ses appels en texte `<tool_call>` (ticket 207, voir [Comment donner des outils à un modèle dont le template lit `xml_tools`](../how-to/use-a-model-that-reads-xml-tools.md)). Elle fixe aussi `thinkingOff: true` : l'interrupteur de raisonnement est écrit désactivé quoi que demande l'usage, parce qu'avec le raisonnement SmolLM3-3B répète son appel d'outil au lieu de répondre (mesuré sur llmman, 3 essais sur 3 ; 6 sur 6 ont répondu sans). `thinkingOff` et `toolFormat` sont des champs de famille ordinaires : toute famille peut les fixer, `decide.go` n'a pas de règle propre à un modèle.
4141 - **Les démos mesurées de ce dépôt.** Pour chaque famille, on a cherché dans les démos qui font tourner le modèle l'échantillonnage qu'elles utilisent (`demos/*/agent*.yaml`). Par exemple `demos/qwen-style` (Qwen3-Coder-Next), `demos/coding-helpers` (Mellum2 12B Thinking, MiniCPM5 2B), `demos/team` (Ornith 1.5 9B), `demos/guard` (Granite Guardian), `demos/codex-style` (gpt-oss 20B) et `demos/llmman` (Jan-nano, Lucy). Le `precise` à 0.0 de Qwen vient d'exécutions avec Qwen3.5 4B sans raisonnement (`demos/docs-search`, `memory-search`, `plans`).
4242
4343 Quand rien n'est publié, `other` applique la règle du skill `skills/create-agent` : 0.7 / 0.9, et 0.2 pour un agent de code qui ne raisonne pas.
@@ -68,7 +68,7 @@ Quelques valeurs découlent des réponses plutôt que d'une table. Chaque règle
6868 - **Avec bash, `watchdogTimeout` est aligné sur `bashTimeout`**, comme le dit la section 3 du guide des réglages. Sinon une commande longue passerait pour un modèle bloqué.
6969 - **`parallel_tool_calls: false` dès que l'agent a des outils.** Sinon les petits modèles lancent une vérification avant que la modification qu'elle vérifie soit faite.
7070 - **`contextWindow: 0` et `context.threshold: 75`.** mm demande au serveur la fenêtre qu'il sert vraiment. La proposition prévoyait un seuil calculé sur la fenêtre native du modèle. Il a été abandonné pendant l'écriture du code : Ollama sert souvent 4k à 8k, donc un pourcentage de la fenêtre native compresserait presque tout de suite.
71-- **`skillsDir: no-skills`.** Un nouvel agent démarre sans skills, et le commentaire dit comment en ajouter.
71+- **`skillsDir: no-skills` sauf demande.** Un nouvel agent démarre sans skills, et le commentaire dit comment en ajouter ; répondre `yes` à la question des skills écrit un `skillsDir` et crée le dossier vide, pour que le premier skill ait sa place. Les sessions suivent la même règle : désactivées, comme le défaut intégré de mm, sauf si l'on demande à l'assistant d'écrire la section (ticket 205).
7272
7373 ## Chaque valeur dit comment elle a été obtenue
7474
@@ -102,7 +102,7 @@ L'assistant n'écrase un fichier existant que si vous l'acceptez, et jamais avec
102102
103103 - **Générer le fichier avec un modèle** (`agent.creator.yaml`). Conservé pour les cas qui sortent de l'ordinaire, mais pas par défaut : il faut un modèle de 27B chargé et plusieurs minutes par agent.
104104 - **Un seuil calculé sur la fenêtre native.** Écarté, voir plus haut : il compresserait presque tout de suite avec les fenêtres que sert Ollama.
105-- **Les skills, les serveurs MCP, les hooks et les `mmConfigs` dans l'assistant.** Laissés hors de la première version. Le commentaire de fin du fichier renvoie aux guides pratiques.
105+- **Les serveurs MCP, les hooks et les `mmConfigs` dans l'assistant.** Laissés de côté. Le commentaire de fin du fichier renvoie aux guides pratiques. Les skills et les sessions ont été ajoutés ensuite sous forme de deux questions oui/non (ticket 205), désactivées par défaut : elles coûtent une touche chacune et n'appellent aucune autre réponse.
106106 - **Des prompts dans la langue de l'utilisateur.** Écarté par le choix de l'utilisateur : les prompts générés sont en anglais.
107107
108108 ## Limites connues
@@ -37,7 +37,7 @@ La famille est déduite de l'identifiant du modèle : la première famille dont
37 37
38 Chaque valeur a une ligne `source`, et la table n'a été remplie qu'à partir de deux sortes de sources :38 Chaque valeur a une ligne `source`, et la table n'a été remplie qu'à partir de deux sortes de sources :
39 39
40-- **Les fiches de modèles et les fichiers publiés.** Les fiches Qwen (avec raisonnement 0.6 / 0.95 / 20, sans raisonnement 0.7 / 0.8 / 20), l'échantillonnage du GGUF de Gemma 4, le `generation_config.json` de Llama 3.1 8B Instruct (0.6 / 0.9, lu sur un miroir parce que le dépôt de Meta demande une connexion), la fiche de Mistral Small 3.1 (« a relatively low temperature, such as temperature=0.15 »), et la référence de l'API OpenAI pour les modèles hébergés.40+- **Les fiches de modèles et les fichiers publiés.** Les fiches Qwen (avec raisonnement 0.6 / 0.95 / 20, sans raisonnement 0.7 / 0.8 / 20), l'échantillonnage du GGUF de Gemma 4, le `generation_config.json` de Llama 3.1 8B Instruct (0.6 / 0.9, lu sur un miroir parce que le dépôt de Meta demande une connexion), la fiche de Mistral Small 3.1 (« a relatively low temperature, such as temperature=0.15 »), la fiche de SmolLM3-3B et son `generation_config.json` (0.6 / 0.95, une seule recommandation pour les deux modes), et la référence de l'API OpenAI pour les modèles hébergés. La famille `smollm3` fixe aussi `toolFormat: xml_tools` (écrit pour les usages avec outils) : son chat template lit `xml_tools`, jamais la liste d'outils standard, et écrit ses appels en texte `<tool_call>` (ticket 207, voir [Comment donner des outils à un modèle dont le template lit `xml_tools`](../how-to/use-a-model-that-reads-xml-tools.md)). Elle fixe aussi `thinkingOff: true` : l'interrupteur de raisonnement est écrit désactivé quoi que demande l'usage, parce qu'avec le raisonnement SmolLM3-3B répète son appel d'outil au lieu de répondre (mesuré sur llmman, 3 essais sur 3 ; 6 sur 6 ont répondu sans). `thinkingOff` et `toolFormat` sont des champs de famille ordinaires : toute famille peut les fixer, `decide.go` n'a pas de règle propre à un modèle.
41 - **Les démos mesurées de ce dépôt.** Pour chaque famille, on a cherché dans les démos qui font tourner le modèle l'échantillonnage qu'elles utilisent (`demos/*/agent*.yaml`). Par exemple `demos/qwen-style` (Qwen3-Coder-Next), `demos/coding-helpers` (Mellum2 12B Thinking, MiniCPM5 2B), `demos/team` (Ornith 1.5 9B), `demos/guard` (Granite Guardian), `demos/codex-style` (gpt-oss 20B) et `demos/llmman` (Jan-nano, Lucy). Le `precise` à 0.0 de Qwen vient d'exécutions avec Qwen3.5 4B sans raisonnement (`demos/docs-search`, `memory-search`, `plans`).41 - **Les démos mesurées de ce dépôt.** Pour chaque famille, on a cherché dans les démos qui font tourner le modèle l'échantillonnage qu'elles utilisent (`demos/*/agent*.yaml`). Par exemple `demos/qwen-style` (Qwen3-Coder-Next), `demos/coding-helpers` (Mellum2 12B Thinking, MiniCPM5 2B), `demos/team` (Ornith 1.5 9B), `demos/guard` (Granite Guardian), `demos/codex-style` (gpt-oss 20B) et `demos/llmman` (Jan-nano, Lucy). Le `precise` à 0.0 de Qwen vient d'exécutions avec Qwen3.5 4B sans raisonnement (`demos/docs-search`, `memory-search`, `plans`).
42 42
43 Quand rien n'est publié, `other` applique la règle du skill `skills/create-agent` : 0.7 / 0.9, et 0.2 pour un agent de code qui ne raisonne pas.43 Quand rien n'est publié, `other` applique la règle du skill `skills/create-agent` : 0.7 / 0.9, et 0.2 pour un agent de code qui ne raisonne pas.
@@ -68,7 +68,7 @@ Quelques valeurs découlent des réponses plutôt que d'une table. Chaque règle
68 - **Avec bash, `watchdogTimeout` est aligné sur `bashTimeout`**, comme le dit la section 3 du guide des réglages. Sinon une commande longue passerait pour un modèle bloqué.68 - **Avec bash, `watchdogTimeout` est aligné sur `bashTimeout`**, comme le dit la section 3 du guide des réglages. Sinon une commande longue passerait pour un modèle bloqué.
69 - **`parallel_tool_calls: false` dès que l'agent a des outils.** Sinon les petits modèles lancent une vérification avant que la modification qu'elle vérifie soit faite.69 - **`parallel_tool_calls: false` dès que l'agent a des outils.** Sinon les petits modèles lancent une vérification avant que la modification qu'elle vérifie soit faite.
70 - **`contextWindow: 0` et `context.threshold: 75`.** mm demande au serveur la fenêtre qu'il sert vraiment. La proposition prévoyait un seuil calculé sur la fenêtre native du modèle. Il a été abandonné pendant l'écriture du code : Ollama sert souvent 4k à 8k, donc un pourcentage de la fenêtre native compresserait presque tout de suite.70 - **`contextWindow: 0` et `context.threshold: 75`.** mm demande au serveur la fenêtre qu'il sert vraiment. La proposition prévoyait un seuil calculé sur la fenêtre native du modèle. Il a été abandonné pendant l'écriture du code : Ollama sert souvent 4k à 8k, donc un pourcentage de la fenêtre native compresserait presque tout de suite.
71-- **`skillsDir: no-skills`.** Un nouvel agent démarre sans skills, et le commentaire dit comment en ajouter.71+- **`skillsDir: no-skills` sauf demande.** Un nouvel agent démarre sans skills, et le commentaire dit comment en ajouter ; répondre `yes` à la question des skills écrit un `skillsDir` et crée le dossier vide, pour que le premier skill ait sa place. Les sessions suivent la même règle : désactivées, comme le défaut intégré de mm, sauf si l'on demande à l'assistant d'écrire la section (ticket 205).
72 72
73 ## Chaque valeur dit comment elle a été obtenue73 ## Chaque valeur dit comment elle a été obtenue
74 74
@@ -102,7 +102,7 @@ L'assistant n'écrase un fichier existant que si vous l'acceptez, et jamais avec
102 102
103 - **Générer le fichier avec un modèle** (`agent.creator.yaml`). Conservé pour les cas qui sortent de l'ordinaire, mais pas par défaut : il faut un modèle de 27B chargé et plusieurs minutes par agent.103 - **Générer le fichier avec un modèle** (`agent.creator.yaml`). Conservé pour les cas qui sortent de l'ordinaire, mais pas par défaut : il faut un modèle de 27B chargé et plusieurs minutes par agent.
104 - **Un seuil calculé sur la fenêtre native.** Écarté, voir plus haut : il compresserait presque tout de suite avec les fenêtres que sert Ollama.104 - **Un seuil calculé sur la fenêtre native.** Écarté, voir plus haut : il compresserait presque tout de suite avec les fenêtres que sert Ollama.
105-- **Les skills, les serveurs MCP, les hooks et les `mmConfigs` dans l'assistant.** Laissés hors de la première version. Le commentaire de fin du fichier renvoie aux guides pratiques.105+- **Les serveurs MCP, les hooks et les `mmConfigs` dans l'assistant.** Laissés de côté. Le commentaire de fin du fichier renvoie aux guides pratiques. Les skills et les sessions ont été ajoutés ensuite sous forme de deux questions oui/non (ticket 205), désactivées par défaut : elles coûtent une touche chacune et n'appellent aucune autre réponse.
106 - **Des prompts dans la langue de l'utilisateur.** Écarté par le choix de l'utilisateur : les prompts générés sont en anglais.106 - **Des prompts dans la langue de l'utilisateur.** Écarté par le choix de l'utilisateur : les prompts générés sont en anglais.
107 107
108 ## Limites connues108 ## Limites connues
modified docs/fr/how-to/add-a-skill.md +2 -0
@@ -26,6 +26,8 @@ Ce guide montre comment donner à l'agent une nouvelle procédure en markdown qu
2626
2727 ## Variantes
2828
29+- **Avec un assistant.** `mm skillcreate` demande le nom, la description, les mots-clés et le texte, et écrit `<skillsDir>/<nom>/SKILL.md` là où le dit la configuration de l'agent — `.mm/skills/` pour un projet créé avec `mm init --project yes`. Le nom est écrit en minuscules avec des tirets (`Vulcan Salute` → `vulcan-salute`) ; le fichier reçoit un titre de niveau 1 et une section `## What to do`. Dans un script : `mm skillcreate --name "Vulcan Salute" --description "Greet the Vulcan way." --keywords "vulcan, spock" --body-file salute.md --yes`. Voir la [référence de `mm skillcreate`](../reference/cli.md#mm-skillcreate--lassistant-de-skill).
30+
2931 - **Un autre répertoire.** Fixez `skillsDir` dans le YAML. Dans le terminal, un chemin relatif est résolu depuis le répertoire d'où vous lancez `mm`, donc un `mm` installé et lancé dans un projet charge les skills de ce projet ; en mode ACP avec un fichier de configuration, il est résolu à côté de ce fichier. La bannière affiche `[warning: no skills found in <chemin absolu> …]` quand le répertoire résolu ne donne rien.
3032 - **Aucun skill.** Laissez le répertoire vide ou absent : `read_skill` n'est alors pas déclaré, et le modèle ne le voit jamais.
3133 - **Les exemples livrés.** Le dépôt livre `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` et `skills/what-time-is-it/SKILL.md`. Lancez `mm` depuis la racine du dépôt et la bannière affiche `skills: 3`.
@@ -26,6 +26,8 @@ Ce guide montre comment donner à l'agent une nouvelle procédure en markdown qu
26 26
27 ## Variantes27 ## Variantes
28 28
29+- **Avec un assistant.** `mm skillcreate` demande le nom, la description, les mots-clés et le texte, et écrit `<skillsDir>/<nom>/SKILL.md` là où le dit la configuration de l'agent — `.mm/skills/` pour un projet créé avec `mm init --project yes`. Le nom est écrit en minuscules avec des tirets (`Vulcan Salute` → `vulcan-salute`) ; le fichier reçoit un titre de niveau 1 et une section `## What to do`. Dans un script : `mm skillcreate --name "Vulcan Salute" --description "Greet the Vulcan way." --keywords "vulcan, spock" --body-file salute.md --yes`. Voir la [référence de `mm skillcreate`](../reference/cli.md#mm-skillcreate--lassistant-de-skill).
30+
29 - **Un autre répertoire.** Fixez `skillsDir` dans le YAML. Dans le terminal, un chemin relatif est résolu depuis le répertoire d'où vous lancez `mm`, donc un `mm` installé et lancé dans un projet charge les skills de ce projet ; en mode ACP avec un fichier de configuration, il est résolu à côté de ce fichier. La bannière affiche `[warning: no skills found in <chemin absolu> …]` quand le répertoire résolu ne donne rien.31 - **Un autre répertoire.** Fixez `skillsDir` dans le YAML. Dans le terminal, un chemin relatif est résolu depuis le répertoire d'où vous lancez `mm`, donc un `mm` installé et lancé dans un projet charge les skills de ce projet ; en mode ACP avec un fichier de configuration, il est résolu à côté de ce fichier. La bannière affiche `[warning: no skills found in <chemin absolu> …]` quand le répertoire résolu ne donne rien.
30 - **Aucun skill.** Laissez le répertoire vide ou absent : `read_skill` n'est alors pas déclaré, et le modèle ne le voit jamais.32 - **Aucun skill.** Laissez le répertoire vide ou absent : `read_skill` n'est alors pas déclaré, et le modèle ne le voit jamais.
31 - **Les exemples livrés.** Le dépôt livre `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` et `skills/what-time-is-it/SKILL.md`. Lancez `mm` depuis la racine du dépôt et la bannière affiche `skills: 3`.33 - **Les exemples livrés.** Le dépôt livre `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` et `skills/what-time-is-it/SKILL.md`. Lancez `mm` depuis la racine du dépôt et la bannière affiche `skills: 3`.
modified docs/fr/how-to/create-an-agent.md +36 -34
@@ -16,44 +16,44 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc
1616
1717 ```text
1818 mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M
19- ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20- ✓ Server llmman │ What is the agent for? │ # docs — answers from a documentation folder (docs/), with lin…
21- ✓ Model Qwen3.5-…│ │ #
22- ✓ Family qwen │ developer (default) │ # Written by `mm init`. Every value says why it has that value…
23- ▸ Usage │ reviewer │ # them as you watch the agent work (guides/agent-settings-for-…
24- · Machine │ translator │ #
25- · Ask first │ writer │ # model family: qwen
26- · Name │ assistant │ # usage: docs
27- · File │ researcher │ # machine: small — a laptop, models up to about 8B
28- │ ▸ docs │ #
29- │ json │ # mm -config <this file> or mm -tui <this file>
30- │ coordinator │
31- │ │ provider: llmman
32- │ answers from a documentation folder │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
33- │ (docs/), with links to the passages │
34- │ │ # 0 = mm asks the server for the window it serves (the model's…
35- │ │ # is 262144 tokens; the server may serve less)
36- │ │ contextWindow: 0
37- │ │ # usage docs's default: tools run without asking
38- │ │ yolo: true
39- │ │ # usage docs
40- │ │ bashTool: false
41- │ │ editTools: true
42- │ │ planTool: false
43- │ │ askUserTool: false
44- │ │ # no skills: add a skills/ folder and set skillsDir to use som…
45- │ │ # https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)
46- │ │ skillsDir: no-skills
47- │ │ # usage docs
48- │ │ maxTurns: 8
49- │ │ # [guide] settings guide, row "Small model (≤ 8B), short t…
50- ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
19+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20+ ✓ Project no │ What is the agent for? │ # docs — answers from a documentation folder (docs/), wi…
21+ ✓ Server llmman │ │ #
22+ ✓ Model Qwen3.5-…│ developer (default) │ # Written by `mm init`. Every value says why it has that…
23+ ✓ Family qwen │ reviewer │ # them as you watch the agent work:
24+ ▸ Usage │ translator │ # https://rickub.com/bots-garden/mini-me/blob/main/guide…
25+ · Machine │ writer │ #
26+ · Ask first │ assistant │ # model family: qwen
27+ · Sessions │ researcher │ # usage: docs
28+ · Skills │ ▸ docs │ # machine: small — a laptop, models up to about 8B
29+ · Name │ json │ # model facts: read from llmman: native window 262144…
30+ · File │ coordinator │ #
31+ │ │ # Where the numbers come from (the tag before their comm…
32+ │ answers from a documentation folder │ # [measured] measured in a demo of mm — re-measure aft…
33+ │ (docs/), with links to the passages │ # [card] from the model card or the GGUF, not meas…
34+ │ │ # [guide] the settings guide's row for this machine
35+ │ │ # [rule] a rule of thumb, not specific to this mod…
36+ │ │ #
37+ │ │ # mm -config <this file> or mm -tui <this f…
38+ │ │
39+ │ │ provider: llmman
40+ │ │ # The model. Chosen for it (family qwen): sampling (temp…
41+ │ │ # max_tokens) and sampling.chat_template_kwargs (enable_…
42+ │ │ # model and redo them together — running mm init again i…
43+ │ │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
44+ │ │ baseUrl: "http://127.0.0.1:17434/v1"
45+ │ │
46+ │ │ # 0 = mm asks the server for the window it serves (the m…
47+ │ │ # is 262144 tokens; the server may serve less)
48+ │ │ contextWindow: 0
49+ │ │ # usage docs's default: tools run without asking
50+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
5151 ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
5252 ```
5353
5454 `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier ; l'endpoint et la variable de la clé pour `openai`) se tape ; `←` `→` déplacent le curseur, `Début` `Fin` vont à ses bouts, `Suppr` efface le caractère après le curseur, `Ctrl-U` efface tout. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier.
5555
56- Les questions : le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, le nom de l'agent et le fichier. Le nom ouvre le prompt système tel que vous l'avez saisi (`Your name is Bob.`), et donne `acp.name` en minuscules et un `acp.title` avec une majuscule (`bob`, `Bob (developer)`). `Entrée` prend le nom proposé, celui de l'usage.
56+ Les questions : s'il faut tout ranger dans un dossier `.mm` (posée en premier, `no` par défaut), le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, s'il faut enregistrer les conversations et utiliser des skills (`no` par défaut pour les deux), le nom de l'agent et le fichier. Le nom ouvre le prompt système tel que vous l'avez saisi (`Your name is Bob.`), et donne `acp.name` en minuscules et un `acp.title` avec une majuscule (`bob`, `Bob (developer)`). `Entrée` prend le nom proposé, celui de l'usage.
5757
5858 3. mm écrit le fichier et vérifie qu'il se charge :
5959
@@ -78,7 +78,9 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc
7878 --usage docs --machine small --name rata -o agent.yaml --yes --check
7979 ```
8080
81-- **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard.
81+- **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard. Rien n'est créé, pas même le dossier `.mm` ni celui des skills.
82+- **Tout dans un dossier `.mm`.** Répondez `yes` à la première question, ou donnez `--project yes` : mm crée `.mm/` et écrit `.mm/agent.yaml`, avec ses chemins écrits depuis la racine du projet. Lancés depuis cette racine, `mm` et `mm tui` le trouvent sans argument. Voir [Comment ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md).
83+- **Sessions et skills.** `--sessions yes` écrit une section `sessions` : chaque conversation est enregistrée, et `/resume` peut la rouvrir. `--skills yes` écrit `skillsDir` et crée le dossier (`skills/`, ou `.mm/skills/` avec `--project yes`) ; ajoutez-y vos skills (voir [Comment ajouter un skill](add-a-skill.md)).
8284 - **Un service compatible OpenAI (OpenAI, Cerebras, Hugging Face…).** Choisissez `openai` : deux questions de plus suivent, l'endpoint (`https://api.openai.com/v1` par défaut) et le nom de la variable qui contient la clé d'API (`OPENAI_API_KEY` par défaut, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Exportez la clé avant de lancer `mm init` : avec elle, la question du modèle liste les modèles du service ; sans elle, vous tapez l'ID du modèle (la question dit que la variable n'est pas renseignée). Options : `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. Le fichier reçoit `baseUrl` et `apiKeyEnv`, et un `reasoning_effort: none` en commentaire dans `sampling`, à décommenter si l'API répond « Function tools with reasoning_effort are not supported ».
8385 - **Un serveur ailleurs.** `--base-url http://192.168.1.20:17434/v1` ; il est écrit dans le fichier comme `baseUrl`. Sans lui, le fichier n'a pas de `baseUrl` et garde en commentaire l'adresse par défaut du fournisseur et son fallback, prêts à être décommentés.
8486 - **Un serveur qui ne répond pas.** mm le dit et demande le nom du modèle : les réglages viennent alors de la seule table des familles.
@@ -16,44 +16,44 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc
16 16
17 ```text17 ```text
18 mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M18 mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M
19- ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────19+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
20- ✓ Server llmman │ What is the agent for? │ # docs — answers from a documentation folder (docs/), with lin…20+ ✓ Project no │ What is the agent for? │ # docs — answers from a documentation folder (docs/), wi…
21- ✓ Model Qwen3.5-…│ │ #21+ ✓ Server llmman │ │ #
22- ✓ Family qwen │ developer (default) │ # Written by `mm init`. Every value says why it has that value…22+ ✓ Model Qwen3.5-…│ developer (default) │ # Written by `mm init`. Every value says why it has that…
23- ▸ Usage │ reviewer │ # them as you watch the agent work (guides/agent-settings-for-…23+ ✓ Family qwen │ reviewer │ # them as you watch the agent work:
24- · Machine │ translator │ #24+ ▸ Usage │ translator │ # https://rickub.com/bots-garden/mini-me/blob/main/guide…
25- · Ask first │ writer │ # model family: qwen25+ · Machine │ writer │ #
26- · Name │ assistant │ # usage: docs26+ · Ask first │ assistant │ # model family: qwen
27- · File │ researcher │ # machine: small — a laptop, models up to about 8B27+ · Sessions │ researcher │ # usage: docs
28- │ ▸ docs │ #28+ · Skills │ ▸ docs │ # machine: small — a laptop, models up to about 8B
29- │ json │ # mm -config <this file> or mm -tui <this file>29+ · Name │ json │ # model facts: read from llmman: native window 262144…
30- │ coordinator │30+ · File │ coordinator │ #
31- │ │ provider: llmman31+ │ │ # Where the numbers come from (the tag before their comm…
32- │ answers from a documentation folder │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"32+ │ answers from a documentation folder │ # [measured] measured in a demo of mm — re-measure aft…
33- │ (docs/), with links to the passages │33+ │ (docs/), with links to the passages │ # [card] from the model card or the GGUF, not meas…
34- │ │ # 0 = mm asks the server for the window it serves (the model's…34+ │ │ # [guide] the settings guide's row for this machine
35- │ │ # is 262144 tokens; the server may serve less)35+ │ │ # [rule] a rule of thumb, not specific to this mod…
36- │ │ contextWindow: 036+ │ │ #
37- │ │ # usage docs's default: tools run without asking37+ │ │ # mm -config <this file> or mm -tui <this f…
38- │ │ yolo: true38+ │ │
39- │ │ # usage docs39+ │ │ provider: llmman
40- │ │ bashTool: false40+ │ │ # The model. Chosen for it (family qwen): sampling (temp…
41- │ │ editTools: true41+ │ │ # max_tokens) and sampling.chat_template_kwargs (enable_…
42- │ │ planTool: false42+ │ │ # model and redo them together — running mm init again i…
43- │ │ askUserTool: false43+ │ │ model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
44- │ │ # no skills: add a skills/ folder and set skillsDir to use som…44+ │ │ baseUrl: "http://127.0.0.1:17434/v1"
45- │ │ # https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)45+ │ │
46- │ │ skillsDir: no-skills46+ │ │ # 0 = mm asks the server for the window it serves (the m…
47- │ │ # usage docs47+ │ │ # is 262144 tokens; the server may serve less)
48- │ │ maxTurns: 848+ │ │ contextWindow: 0
49- │ │ # [guide] settings guide, row "Small model (≤ 8B), short t…49+ │ │ # usage docs's default: tools run without asking
50- ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────50+ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
51 ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit51 ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
52 ```52 ```
53 53
54 `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier ; l'endpoint et la variable de la clé pour `openai`) se tape ; `←` `→` déplacent le curseur, `Début` `Fin` vont à ses bouts, `Suppr` efface le caractère après le curseur, `Ctrl-U` efface tout. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier.54 `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier ; l'endpoint et la variable de la clé pour `openai`) se tape ; `←` `→` déplacent le curseur, `Début` `Fin` vont à ses bouts, `Suppr` efface le caractère après le curseur, `Ctrl-U` efface tout. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier.
55 55
56- Les questions : le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, le nom de l'agent et le fichier. Le nom ouvre le prompt système tel que vous l'avez saisi (`Your name is Bob.`), et donne `acp.name` en minuscules et un `acp.title` avec une majuscule (`bob`, `Bob (developer)`). `Entrée` prend le nom proposé, celui de l'usage.56+ Les questions : s'il faut tout ranger dans un dossier `.mm` (posée en premier, `no` par défaut), le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, s'il faut enregistrer les conversations et utiliser des skills (`no` par défaut pour les deux), le nom de l'agent et le fichier. Le nom ouvre le prompt système tel que vous l'avez saisi (`Your name is Bob.`), et donne `acp.name` en minuscules et un `acp.title` avec une majuscule (`bob`, `Bob (developer)`). `Entrée` prend le nom proposé, celui de l'usage.
57 57
58 3. mm écrit le fichier et vérifie qu'il se charge :58 3. mm écrit le fichier et vérifie qu'il se charge :
59 59
@@ -78,7 +78,9 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc
78 --usage docs --machine small --name rata -o agent.yaml --yes --check78 --usage docs --machine small --name rata -o agent.yaml --yes --check
79 ```79 ```
80 80
81-- **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard.81+- **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard. Rien n'est créé, pas même le dossier `.mm` ni celui des skills.
82+- **Tout dans un dossier `.mm`.** Répondez `yes` à la première question, ou donnez `--project yes` : mm crée `.mm/` et écrit `.mm/agent.yaml`, avec ses chemins écrits depuis la racine du projet. Lancés depuis cette racine, `mm` et `mm tui` le trouvent sans argument. Voir [Comment ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md).
83+- **Sessions et skills.** `--sessions yes` écrit une section `sessions` : chaque conversation est enregistrée, et `/resume` peut la rouvrir. `--skills yes` écrit `skillsDir` et crée le dossier (`skills/`, ou `.mm/skills/` avec `--project yes`) ; ajoutez-y vos skills (voir [Comment ajouter un skill](add-a-skill.md)).
82 - **Un service compatible OpenAI (OpenAI, Cerebras, Hugging Face…).** Choisissez `openai` : deux questions de plus suivent, l'endpoint (`https://api.openai.com/v1` par défaut) et le nom de la variable qui contient la clé d'API (`OPENAI_API_KEY` par défaut, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Exportez la clé avant de lancer `mm init` : avec elle, la question du modèle liste les modèles du service ; sans elle, vous tapez l'ID du modèle (la question dit que la variable n'est pas renseignée). Options : `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. Le fichier reçoit `baseUrl` et `apiKeyEnv`, et un `reasoning_effort: none` en commentaire dans `sampling`, à décommenter si l'API répond « Function tools with reasoning_effort are not supported ».84 - **Un service compatible OpenAI (OpenAI, Cerebras, Hugging Face…).** Choisissez `openai` : deux questions de plus suivent, l'endpoint (`https://api.openai.com/v1` par défaut) et le nom de la variable qui contient la clé d'API (`OPENAI_API_KEY` par défaut, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Exportez la clé avant de lancer `mm init` : avec elle, la question du modèle liste les modèles du service ; sans elle, vous tapez l'ID du modèle (la question dit que la variable n'est pas renseignée). Options : `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. Le fichier reçoit `baseUrl` et `apiKeyEnv`, et un `reasoning_effort: none` en commentaire dans `sampling`, à décommenter si l'API répond « Function tools with reasoning_effort are not supported ».
83 - **Un serveur ailleurs.** `--base-url http://192.168.1.20:17434/v1` ; il est écrit dans le fichier comme `baseUrl`. Sans lui, le fichier n'a pas de `baseUrl` et garde en commentaire l'adresse par défaut du fournisseur et son fallback, prêts à être décommentés.85 - **Un serveur ailleurs.** `--base-url http://192.168.1.20:17434/v1` ; il est écrit dans le fichier comme `baseUrl`. Sans lui, le fichier n'a pas de `baseUrl` et garde en commentaire l'adresse par défaut du fournisseur et son fallback, prêts à être décommentés.
84 - **Un serveur qui ne répond pas.** mm le dit et demande le nom du modèle : les réglages viennent alors de la seule table des familles.86 - **Un serveur qui ne répond pas.** mm le dit et demande le nom du modèle : les réglages viennent alors de la seule table des familles.
modified docs/fr/how-to/guard-the-agent-with-hooks.md +1 -1
@@ -59,7 +59,7 @@ hooks:
5959 ```
6060
6161 - `matcher` limite le hook à l'outil `bash` ; sans lui, le hook s'exécute pour chaque appel d'outil.
62-- `$MM_CONFIG_DIR` est le dossier du fichier YAML : le script est trouvé quel que soit le dossier d'où `mm` est lancé.
62+- `$MM_CONFIG_DIR` est le dossier du fichier YAML (la racine du projet quand le fichier est dans un dossier `.mm`) : le script est trouvé quel que soit le dossier d'où `mm` est lancé.
6363 - `onError: fail` refuse l'appel quand le hook lui-même échoue (Node absent, erreur de syntaxe, délai dépassé). Utilisez-le pour les gardes ; gardez la valeur par défaut, `warn`, pour les hooks dont l'échec ne doit pas gêner.
6464
6565 ### 5. Lancer l'agent et vérifier
@@ -59,7 +59,7 @@ hooks:
59 ```59 ```
60 60
61 - `matcher` limite le hook à l'outil `bash` ; sans lui, le hook s'exécute pour chaque appel d'outil.61 - `matcher` limite le hook à l'outil `bash` ; sans lui, le hook s'exécute pour chaque appel d'outil.
62-- `$MM_CONFIG_DIR` est le dossier du fichier YAML : le script est trouvé quel que soit le dossier d'où `mm` est lancé.62+- `$MM_CONFIG_DIR` est le dossier du fichier YAML (la racine du projet quand le fichier est dans un dossier `.mm`) : le script est trouvé quel que soit le dossier d'où `mm` est lancé.
63 - `onError: fail` refuse l'appel quand le hook lui-même échoue (Node absent, erreur de syntaxe, délai dépassé). Utilisez-le pour les gardes ; gardez la valeur par défaut, `warn`, pour les hooks dont l'échec ne doit pas gêner.63 - `onError: fail` refuse l'appel quand le hook lui-même échoue (Node absent, erreur de syntaxe, délai dépassé). Utilisez-le pour les gardes ; gardez la valeur par défaut, `warn`, pour les hooks dont l'échec ne doit pas gêner.
64 64
65 ### 5. Lancer l'agent et vérifier65 ### 5. Lancer l'agent et vérifier
added docs/fr/how-to/keep-the-config-in-a-mm-folder.md +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+# Comment ranger les fichiers mini-me d'un projet dans un dossier `.mm`
2+
3+Ce guide montre comment mettre la configuration d'un agent, ses skills, ses hooks et ses sessions dans un dossier `.mm` à la racine d'un projet, plutôt qu'à côté de ses sources, tout en lançant l'agent par un simple `mm` ou `mm tui`. Il suppose que `mm` est installé dans votre `PATH` et que vous avez déjà un `agent.yaml` (écrit par `mm init`, par exemple).
4+
5+## Étapes
6+
7+1. Créez le dossier et déplacez-y la configuration :
8+
9+ ```bash
10+ mkdir -p .mm
11+ mv agent.yaml .mm/agent.yaml
12+ ```
13+
14+2. Écrivez chaque chemin relatif de `.mm/agent.yaml` **depuis la racine du projet**, pas depuis `.mm/` :
15+
16+ ```yaml
17+ skillsDir: .mm/skills
18+ sessions:
19+ enabled: true
20+ dir: .mm/sessions
21+ hooks:
22+ preToolCall:
23+ - matcher: ^bash$
24+ command: node "$MM_CONFIG_DIR/.mm/hooks/bash-guard.js"
25+ ```
26+
27+ Pour un fichier dans un dossier `.mm`, `$MM_CONFIG_DIR` est la racine du projet : un script de hook rangé dans `.mm/hooks/` s'écrit donc `$MM_CONFIG_DIR/.mm/hooks/…`. Il en va de même pour `mmConfigs`, les deciders de `skillRouting` et de `toolRouting`, et `tools[].wasm`.
28+
29+3. Lancez l'agent depuis la racine du projet, sans argument :
30+
31+ ```bash
32+ mm # ou : mm tui
33+ ```
34+
35+ La bannière indique le fichier lu :
36+
37+ ```
38+ mm … | config: .mm/agent.yaml | provider: …
39+ ```
40+
41+## Variantes
42+
43+- **Un nouvel agent.** `mm init` fait tout cela si vous répondez `yes` à sa première question, ou avec `mm init --project yes` : il crée `.mm/`, écrit `.mm/agent.yaml` avec des chemins depuis la racine du projet et, avec `--skills yes`, crée `.mm/skills/`. Voir [Comment créer un agent avec `mm init`](create-an-agent.md).
44+
45+- **Un `agent.yaml` se trouve aussi à la racine.** C'est lui qui l'emporte : `mm` lit d'abord `./agent.yaml`, et `./.mm/agent.yaml` seulement s'il n'y en a pas. Supprimez ou renommez le fichier de la racine, ou nommez celui que vous voulez : `mm -config .mm/agent.yaml`.
46+- **Dans un Docker Sandbox.** `mm sbxinit` trouve `.mm/agent.yaml` et écrit un `sbxenv.yaml` qui monte `.mm/` en lecture seule, ses sessions et ses traces en écriture ; voir [Quand l'agent est dans un dossier `.mm`](run-in-a-docker-sandbox.md#quand-lagent-est-dans-un-dossier-mm).
47+- **Depuis un éditeur (ACP).** Donnez à `AGENT_CONFIG` le chemin absolu de `.mm/agent.yaml`. Ses chemins gardent le même sens : un `skillsDir` relatif comme `.mm/skills` est résolu depuis la racine du projet, pas depuis le répertoire de travail de l'éditeur. Voir [Comment utiliser mini-me depuis un éditeur](use-from-an-editor.md).
48+- **Lancé depuis un sous-dossier.** La recherche ne regarde que le répertoire de travail : depuis `src/`, `mm` ne trouve pas `../.mm/agent.yaml`. Lancez-le depuis la racine : dans un terminal, un `skillsDir` relatif suit le répertoire de travail, donc `mm -config ../.mm/agent.yaml` lancé depuis `src/` chercherait les skills dans `src/.mm/skills`.
49+- **Plusieurs agents dans un projet.** Rangez les autres dans le même dossier (`.mm/agents/reviewer.yaml`) et nommez-les avec `-config`, ou depuis `mmConfigs` (`reviewer: .mm/agents/reviewer.yaml`). Seul un fichier placé *directement* dans `.mm` prend la racine du projet comme base : les chemins de `.mm/agents/reviewer.yaml` sont relatifs à `.mm/agents/`.
50+
51+## Prérequis
52+
53+`mm` v1.0.7 ou plus récent. Un `mm` plus ancien ne cherche pas dans `.mm/`, et résout les chemins d'un `.mm/agent.yaml` depuis `.mm/` lui-même : `$MM_CONFIG_DIR/.mm/hooks/…` désigne alors `.mm/.mm/hooks/…`.
54+
55+Quand le même agent peut tourner avec les deux — un kit Docker Sandbox embarque souvent un mm plus ancien que celui de votre machine —, écrivez les commandes des hooks avec `${MM_CONFIG_DIR%/.mm}`, qui retire un `/.mm` final et donne la racine du projet avec n'importe quel mm (les commandes de hooks tournent dans `sh -c`) :
56+
57+```yaml
58+hooks:
59+ preToolCall:
60+ - matcher: ^bash$
61+ command: node "${MM_CONFIG_DIR%/.mm}/.mm/hooks/bash-guard.js"
62+```
63+
64+Un script qui construit lui-même des chemins à partir de `MM_CONFIG_DIR` suit la même règle : prendre le dossier parent quand le dernier élément est `.mm`.
65+
66+## Voir aussi
67+
68+- L'ordre de recherche et la règle du répertoire de base : [Résolution du fichier de configuration](../reference/cli.md#résolution-du-fichier-de-configuration)
69+- Toutes les clés du fichier : [Référence de la configuration](../reference/configuration.md)
70+- Les variables reçues par un hook : [Référence des hooks](../reference/hooks.md)
new file mode 100644
@@ -0,0 +1,70 @@
1+# Comment ranger les fichiers mini-me d'un projet dans un dossier `.mm`
2+
3+Ce guide montre comment mettre la configuration d'un agent, ses skills, ses hooks et ses sessions dans un dossier `.mm` à la racine d'un projet, plutôt qu'à côté de ses sources, tout en lançant l'agent par un simple `mm` ou `mm tui`. Il suppose que `mm` est installé dans votre `PATH` et que vous avez déjà un `agent.yaml` (écrit par `mm init`, par exemple).
4+
5+## Étapes
6+
7+1. Créez le dossier et déplacez-y la configuration :
8+
9+ ```bash
10+ mkdir -p .mm
11+ mv agent.yaml .mm/agent.yaml
12+ ```
13+
14+2. Écrivez chaque chemin relatif de `.mm/agent.yaml` **depuis la racine du projet**, pas depuis `.mm/` :
15+
16+ ```yaml
17+ skillsDir: .mm/skills
18+ sessions:
19+ enabled: true
20+ dir: .mm/sessions
21+ hooks:
22+ preToolCall:
23+ - matcher: ^bash$
24+ command: node "$MM_CONFIG_DIR/.mm/hooks/bash-guard.js"
25+ ```
26+
27+ Pour un fichier dans un dossier `.mm`, `$MM_CONFIG_DIR` est la racine du projet : un script de hook rangé dans `.mm/hooks/` s'écrit donc `$MM_CONFIG_DIR/.mm/hooks/…`. Il en va de même pour `mmConfigs`, les deciders de `skillRouting` et de `toolRouting`, et `tools[].wasm`.
28+
29+3. Lancez l'agent depuis la racine du projet, sans argument :
30+
31+ ```bash
32+ mm # ou : mm tui
33+ ```
34+
35+ La bannière indique le fichier lu :
36+
37+ ```
38+ mm … | config: .mm/agent.yaml | provider: …
39+ ```
40+
41+## Variantes
42+
43+- **Un nouvel agent.** `mm init` fait tout cela si vous répondez `yes` à sa première question, ou avec `mm init --project yes` : il crée `.mm/`, écrit `.mm/agent.yaml` avec des chemins depuis la racine du projet et, avec `--skills yes`, crée `.mm/skills/`. Voir [Comment créer un agent avec `mm init`](create-an-agent.md).
44+
45+- **Un `agent.yaml` se trouve aussi à la racine.** C'est lui qui l'emporte : `mm` lit d'abord `./agent.yaml`, et `./.mm/agent.yaml` seulement s'il n'y en a pas. Supprimez ou renommez le fichier de la racine, ou nommez celui que vous voulez : `mm -config .mm/agent.yaml`.
46+- **Dans un Docker Sandbox.** `mm sbxinit` trouve `.mm/agent.yaml` et écrit un `sbxenv.yaml` qui monte `.mm/` en lecture seule, ses sessions et ses traces en écriture ; voir [Quand l'agent est dans un dossier `.mm`](run-in-a-docker-sandbox.md#quand-lagent-est-dans-un-dossier-mm).
47+- **Depuis un éditeur (ACP).** Donnez à `AGENT_CONFIG` le chemin absolu de `.mm/agent.yaml`. Ses chemins gardent le même sens : un `skillsDir` relatif comme `.mm/skills` est résolu depuis la racine du projet, pas depuis le répertoire de travail de l'éditeur. Voir [Comment utiliser mini-me depuis un éditeur](use-from-an-editor.md).
48+- **Lancé depuis un sous-dossier.** La recherche ne regarde que le répertoire de travail : depuis `src/`, `mm` ne trouve pas `../.mm/agent.yaml`. Lancez-le depuis la racine : dans un terminal, un `skillsDir` relatif suit le répertoire de travail, donc `mm -config ../.mm/agent.yaml` lancé depuis `src/` chercherait les skills dans `src/.mm/skills`.
49+- **Plusieurs agents dans un projet.** Rangez les autres dans le même dossier (`.mm/agents/reviewer.yaml`) et nommez-les avec `-config`, ou depuis `mmConfigs` (`reviewer: .mm/agents/reviewer.yaml`). Seul un fichier placé *directement* dans `.mm` prend la racine du projet comme base : les chemins de `.mm/agents/reviewer.yaml` sont relatifs à `.mm/agents/`.
50+
51+## Prérequis
52+
53+`mm` v1.0.7 ou plus récent. Un `mm` plus ancien ne cherche pas dans `.mm/`, et résout les chemins d'un `.mm/agent.yaml` depuis `.mm/` lui-même : `$MM_CONFIG_DIR/.mm/hooks/…` désigne alors `.mm/.mm/hooks/…`.
54+
55+Quand le même agent peut tourner avec les deux — un kit Docker Sandbox embarque souvent un mm plus ancien que celui de votre machine —, écrivez les commandes des hooks avec `${MM_CONFIG_DIR%/.mm}`, qui retire un `/.mm` final et donne la racine du projet avec n'importe quel mm (les commandes de hooks tournent dans `sh -c`) :
56+
57+```yaml
58+hooks:
59+ preToolCall:
60+ - matcher: ^bash$
61+ command: node "${MM_CONFIG_DIR%/.mm}/.mm/hooks/bash-guard.js"
62+```
63+
64+Un script qui construit lui-même des chemins à partir de `MM_CONFIG_DIR` suit la même règle : prendre le dossier parent quand le dernier élément est `.mm`.
65+
66+## Voir aussi
67+
68+- L'ordre de recherche et la règle du répertoire de base : [Résolution du fichier de configuration](../reference/cli.md#résolution-du-fichier-de-configuration)
69+- Toutes les clés du fichier : [Référence de la configuration](../reference/configuration.md)
70+- Les variables reçues par un hook : [Référence des hooks](../reference/hooks.md)
modified docs/fr/how-to/run-in-a-docker-sandbox.md +27 -0
@@ -160,6 +160,33 @@ sbx env rm --force --env-arg PROJECT_DIR="$PWD"
160160
161161 `mm sbxinit` ne génère pas cette forme, car elle allonge le fichier pour un cas que la plupart des projets n'ont pas. Relancer `mm sbxinit` en répondant `yes` remplace ces ajouts. [`demos/dev.team/sbxenv.yaml`](../../../demos/dev.team/sbxenv.yaml) en est un exemple complet.
162162
163+### Quand l'agent est dans un dossier `.mm`
164+
165+Un projet créé avec `mm init --project yes` garde son agent dans `.mm/agent.yaml`. `mm sbxinit` le trouve là (après `agent.yaml`), nomme la sandbox d'après son `acp.name`, et écrit un fichier qui met les réglages de l'agent hors de sa portée :
166+
167+```yaml
168+workspace: .
169+
170+additionalWorkspaces:
171+ - path: ./.mm # lecture seule par-dessus le workspace
172+ readOnly: true
173+ - path: ./.mm/sessions # sessions.dir : de nouveau inscriptible
174+
175+args:
176+ PROJECT_DIR:
177+ required: true
178+
179+env:
180+ AGENT_CONFIG: ${{ env.args.PROJECT_DIR }}/.mm/agent.yaml
181+```
182+
183+- `.mm/` est en lecture seule dans la sandbox : l'agent ne peut modifier ni ses réglages, ni ses skills, ni ses hooks.
184+- Les dossiers où mm écrit redeviennent inscriptibles, lus dans le fichier d'agent : `sessions.dir` quand les sessions sont activées, `trace.dir` quand la trace l'est. Leurs chemins sont ceux du fichier d'agent : un montage et un réglage ne peuvent pas diverger.
185+- `AGENT_CONFIG` désigne le fichier d'agent via `PROJECT_DIR`, le dossier du projet tel que la sandbox le voit (le même chemin que sur votre machine) : ça marche quelle que soit la version de mm du kit et son répertoire de travail, `mm -acp` depuis un éditeur compris. Chaque commande `sbx env` prend alors `--env-arg PROJECT_DIR="$PWD"`, lancée depuis le dossier de `sbxenv.yaml`.
186+- Tous les chemins sont relatifs au dossier de `sbxenv.yaml`, comme sbx les lit : le fichier ne dépend pas de l'endroit où se trouve le projet, et peut être commité.
187+
188+Tout ce qui est écrit d'autre sous `.mm/` demande sa propre ligne — un hook qui y tient un journal, par exemple : avec `onError: ignore`, il échouerait sans rien dire. Après avoir changé les sessions ou la trace de l'agent, relancez `mm sbxinit` (répondez `yes` pour remplacer le fichier), puis recréez la sandbox : `sbx env rm --force --env-arg PROJECT_DIR="$PWD"`, puis `sbx env create --force --env-arg PROJECT_DIR="$PWD"` — une sandbox garde les montages de sa création. Si `sbx env` répond qu'une sandbox de ce nom « shows no sign of ever being created by sbx env », elle a été créée autrement (`sbx run --name …`) : supprimez-la avec `sbx rm <nom>`, ou changez `name:`.
189+
163190 ## Le serveur de modèles reste sur l'hôte
164191
165192 Dans un sandbox, `localhost` est le sandbox lui-même. Votre serveur de modèles continue de tourner sur votre machine et se joint par `host.docker.internal`, que le proxy du sandbox redirige vers votre machine.
@@ -160,6 +160,33 @@ sbx env rm --force --env-arg PROJECT_DIR="$PWD"
160 160
161 `mm sbxinit` ne génère pas cette forme, car elle allonge le fichier pour un cas que la plupart des projets n'ont pas. Relancer `mm sbxinit` en répondant `yes` remplace ces ajouts. [`demos/dev.team/sbxenv.yaml`](../../../demos/dev.team/sbxenv.yaml) en est un exemple complet.161 `mm sbxinit` ne génère pas cette forme, car elle allonge le fichier pour un cas que la plupart des projets n'ont pas. Relancer `mm sbxinit` en répondant `yes` remplace ces ajouts. [`demos/dev.team/sbxenv.yaml`](../../../demos/dev.team/sbxenv.yaml) en est un exemple complet.
162 162
163+### Quand l'agent est dans un dossier `.mm`
164+
165+Un projet créé avec `mm init --project yes` garde son agent dans `.mm/agent.yaml`. `mm sbxinit` le trouve là (après `agent.yaml`), nomme la sandbox d'après son `acp.name`, et écrit un fichier qui met les réglages de l'agent hors de sa portée :
166+
167+```yaml
168+workspace: .
169+
170+additionalWorkspaces:
171+ - path: ./.mm # lecture seule par-dessus le workspace
172+ readOnly: true
173+ - path: ./.mm/sessions # sessions.dir : de nouveau inscriptible
174+
175+args:
176+ PROJECT_DIR:
177+ required: true
178+
179+env:
180+ AGENT_CONFIG: ${{ env.args.PROJECT_DIR }}/.mm/agent.yaml
181+```
182+
183+- `.mm/` est en lecture seule dans la sandbox : l'agent ne peut modifier ni ses réglages, ni ses skills, ni ses hooks.
184+- Les dossiers où mm écrit redeviennent inscriptibles, lus dans le fichier d'agent : `sessions.dir` quand les sessions sont activées, `trace.dir` quand la trace l'est. Leurs chemins sont ceux du fichier d'agent : un montage et un réglage ne peuvent pas diverger.
185+- `AGENT_CONFIG` désigne le fichier d'agent via `PROJECT_DIR`, le dossier du projet tel que la sandbox le voit (le même chemin que sur votre machine) : ça marche quelle que soit la version de mm du kit et son répertoire de travail, `mm -acp` depuis un éditeur compris. Chaque commande `sbx env` prend alors `--env-arg PROJECT_DIR="$PWD"`, lancée depuis le dossier de `sbxenv.yaml`.
186+- Tous les chemins sont relatifs au dossier de `sbxenv.yaml`, comme sbx les lit : le fichier ne dépend pas de l'endroit où se trouve le projet, et peut être commité.
187+
188+Tout ce qui est écrit d'autre sous `.mm/` demande sa propre ligne — un hook qui y tient un journal, par exemple : avec `onError: ignore`, il échouerait sans rien dire. Après avoir changé les sessions ou la trace de l'agent, relancez `mm sbxinit` (répondez `yes` pour remplacer le fichier), puis recréez la sandbox : `sbx env rm --force --env-arg PROJECT_DIR="$PWD"`, puis `sbx env create --force --env-arg PROJECT_DIR="$PWD"` — une sandbox garde les montages de sa création. Si `sbx env` répond qu'une sandbox de ce nom « shows no sign of ever being created by sbx env », elle a été créée autrement (`sbx run --name …`) : supprimez-la avec `sbx rm <nom>`, ou changez `name:`.
189+
163 ## Le serveur de modèles reste sur l'hôte190 ## Le serveur de modèles reste sur l'hôte
164 191
165 Dans un sandbox, `localhost` est le sandbox lui-même. Votre serveur de modèles continue de tourner sur votre machine et se joint par `host.docker.internal`, que le proxy du sandbox redirige vers votre machine.192 Dans un sandbox, `localhost` est le sandbox lui-même. Votre serveur de modèles continue de tourner sur votre machine et se joint par `host.docker.internal`, que le proxy du sandbox redirige vers votre machine.
added docs/fr/how-to/use-a-model-that-reads-xml-tools.md +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+# Comment donner des outils à un modèle dont le template lit `xml_tools` (SmolLM3)
2+
3+Ce guide montre comment faire appeler les outils de mm par un modèle comme SmolLM3. Sa fiche dit qu'il sait appeler des outils, mais à travers mm il n'en appelle jamais : il décrit la commande au lieu de la lancer. Il suppose que vous avez déjà un fichier d'agent pour ce modèle (écrit par `mm init`, par exemple).
4+
5+## Pourquoi ça ne marche pas par défaut
6+
7+mm envoie ses outils dans le champ `tools` de l'API OpenAI, et llama-server (derrière llmman, Ollama, DMR…) les transmet au chat template du modèle sous ce nom. Le template de SmolLM3 ne lit jamais `tools` : il lit `xml_tools` (l'argument de `apply_chat_template(xml_tools=…)` dans l'exemple de sa fiche). Le modèle ne voit donc aucun outil. Quand on lui donne `xml_tools`, il répond par l'appel sous forme de texte — `<tool_call>{"name": "bash", "arguments": {"command": "ls"}}</tool_call>` — que le serveur ne transforme pas en appel d'outil.
8+
9+## Étapes
10+
11+1. Dans le fichier de l'agent, ajoutez :
12+
13+ ```yaml
14+ toolFormat: xml_tools
15+ ```
16+
17+ mm envoie alors les outils dans `chat_template_kwargs.xml_tools`, réécrit les appels précédents dans l'historique sous forme de texte `<tool_call>` (le template n'affiche un message que par son texte), et transforme les blocs `<tool_call>` de la réponse en appels d'outils — en streaming ou non. Ils n'apparaissent pas dans le texte de la réponse.
18+
19+2. Coupez le raisonnement — le template de SmolLM3 l'active par défaut :
20+
21+ ```yaml
22+ sampling:
23+ chat_template_kwargs:
24+ enable_thinking: false
25+ ```
26+
27+ Avec le raisonnement activé, SmolLM3-3B répète son appel après la réponse de l'outil au lieu de répondre (mesuré : 3 essais sur 3 ; sans raisonnement, 6 sur 6 ont répondu).
28+
29+3. Lancez `mm` et demandez quelque chose qu'un outil sait faire : `List the files of this directory.` Une ligne `🛠️ bash: ls`, la sortie de la commande en dessous et le compteur `⚙ 1 command(s)` montrent que l'appel a eu lieu.
30+
31+## Variantes
32+
33+- **Un nouvel agent.** Pour la famille `smollm3`, `mm init` fait les deux étapes : il écrit `toolFormat: xml_tools` quand l'usage a des outils, et `enable_thinking: false` quel que soit l'usage, avec une note en fin de fichier.
34+- **Un usage sans outils** (`translator`, `json`) : rien à faire, `toolFormat` ne change rien sans outils.
35+- **Voir ce qui passe sur le réseau.** Avec `trace: {enabled: true}`, les fichiers de requête montrent `chat_template_kwargs.xml_tools` et aucun `tools` ; les fichiers de réponse montrent le texte `<tool_call>` brut du serveur, avant que mm ne le lise. Voir [Comment voir le JSON envoyé au modèle](inspect-model-traffic.md).
36+- **Un appel cassé.** Un `<tool_call>` dont le JSON est coupé (par `max_tokens`) ou mal formé est rendu au modèle avec une note, comme pour tout modèle, du moment que le nom de l'outil se lit ; sinon il reste dans le texte de la réponse.
37+
38+## Limites
39+
40+- Les tâches en plusieurs étapes restent fragiles avec SmolLM3-3B : pendant les essais, il a parfois pris la sortie d'un outil pour une phrase de l'utilisateur (le template affiche la réponse d'un outil comme un message de l'utilisateur). Entourer cette sortie de balises `<tool_response>` n'a rien changé de mesurable.
41+- Le `common/chat.cpp` de llama.cpp ne traitait pas SmolLM3 à part au moment où ce guide a été écrit (13 novembre 2026), et le llama-server de llmman n'analysait pas ces appels : d'où le travail fait du côté de mm.
42+
43+## Voir aussi
44+
45+- La clé : [Référence de la configuration](../reference/configuration.md) (`toolFormat`)
46+- Couper le raisonnement sur d'autres serveurs : [Comment couper le raisonnement d'un modèle](turn-reasoning-off.md)
47+- D'où viennent les chiffres de `smollm3` : [Explication de `mm init`](../explanation/mm-init.md)
new file mode 100644
@@ -0,0 +1,47 @@
1+# Comment donner des outils à un modèle dont le template lit `xml_tools` (SmolLM3)
2+
3+Ce guide montre comment faire appeler les outils de mm par un modèle comme SmolLM3. Sa fiche dit qu'il sait appeler des outils, mais à travers mm il n'en appelle jamais : il décrit la commande au lieu de la lancer. Il suppose que vous avez déjà un fichier d'agent pour ce modèle (écrit par `mm init`, par exemple).
4+
5+## Pourquoi ça ne marche pas par défaut
6+
7+mm envoie ses outils dans le champ `tools` de l'API OpenAI, et llama-server (derrière llmman, Ollama, DMR…) les transmet au chat template du modèle sous ce nom. Le template de SmolLM3 ne lit jamais `tools` : il lit `xml_tools` (l'argument de `apply_chat_template(xml_tools=…)` dans l'exemple de sa fiche). Le modèle ne voit donc aucun outil. Quand on lui donne `xml_tools`, il répond par l'appel sous forme de texte — `<tool_call>{"name": "bash", "arguments": {"command": "ls"}}</tool_call>` — que le serveur ne transforme pas en appel d'outil.
8+
9+## Étapes
10+
11+1. Dans le fichier de l'agent, ajoutez :
12+
13+ ```yaml
14+ toolFormat: xml_tools
15+ ```
16+
17+ mm envoie alors les outils dans `chat_template_kwargs.xml_tools`, réécrit les appels précédents dans l'historique sous forme de texte `<tool_call>` (le template n'affiche un message que par son texte), et transforme les blocs `<tool_call>` de la réponse en appels d'outils — en streaming ou non. Ils n'apparaissent pas dans le texte de la réponse.
18+
19+2. Coupez le raisonnement — le template de SmolLM3 l'active par défaut :
20+
21+ ```yaml
22+ sampling:
23+ chat_template_kwargs:
24+ enable_thinking: false
25+ ```
26+
27+ Avec le raisonnement activé, SmolLM3-3B répète son appel après la réponse de l'outil au lieu de répondre (mesuré : 3 essais sur 3 ; sans raisonnement, 6 sur 6 ont répondu).
28+
29+3. Lancez `mm` et demandez quelque chose qu'un outil sait faire : `List the files of this directory.` Une ligne `🛠️ bash: ls`, la sortie de la commande en dessous et le compteur `⚙ 1 command(s)` montrent que l'appel a eu lieu.
30+
31+## Variantes
32+
33+- **Un nouvel agent.** Pour la famille `smollm3`, `mm init` fait les deux étapes : il écrit `toolFormat: xml_tools` quand l'usage a des outils, et `enable_thinking: false` quel que soit l'usage, avec une note en fin de fichier.
34+- **Un usage sans outils** (`translator`, `json`) : rien à faire, `toolFormat` ne change rien sans outils.
35+- **Voir ce qui passe sur le réseau.** Avec `trace: {enabled: true}`, les fichiers de requête montrent `chat_template_kwargs.xml_tools` et aucun `tools` ; les fichiers de réponse montrent le texte `<tool_call>` brut du serveur, avant que mm ne le lise. Voir [Comment voir le JSON envoyé au modèle](inspect-model-traffic.md).
36+- **Un appel cassé.** Un `<tool_call>` dont le JSON est coupé (par `max_tokens`) ou mal formé est rendu au modèle avec une note, comme pour tout modèle, du moment que le nom de l'outil se lit ; sinon il reste dans le texte de la réponse.
37+
38+## Limites
39+
40+- Les tâches en plusieurs étapes restent fragiles avec SmolLM3-3B : pendant les essais, il a parfois pris la sortie d'un outil pour une phrase de l'utilisateur (le template affiche la réponse d'un outil comme un message de l'utilisateur). Entourer cette sortie de balises `<tool_response>` n'a rien changé de mesurable.
41+- Le `common/chat.cpp` de llama.cpp ne traitait pas SmolLM3 à part au moment où ce guide a été écrit (13 novembre 2026), et le llama-server de llmman n'analysait pas ces appels : d'où le travail fait du côté de mm.
42+
43+## Voir aussi
44+
45+- La clé : [Référence de la configuration](../reference/configuration.md) (`toolFormat`)
46+- Couper le raisonnement sur d'autres serveurs : [Comment couper le raisonnement d'un modèle](turn-reasoning-off.md)
47+- D'où viennent les chiffres de `smollm3` : [Explication de `mm init`](../explanation/mm-init.md)
modified docs/fr/how-to/use-from-an-editor.md +1 -1
@@ -19,7 +19,7 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client
1919 }
2020 ```
2121
22-2. Donnez à `AGENT_CONFIG` un chemin absolu. L'éditeur lance `mm` avec le répertoire du projet comme répertoire de travail, donc un `./agent.yaml` relatif ne serait pas trouvé. En mode ACP, un `skillsDir` relatif dans ce fichier est résolu à côté du fichier, pas à côté du répertoire de travail de l'éditeur.
22+2. Donnez à `AGENT_CONFIG` un chemin absolu. L'éditeur lance `mm` avec le répertoire du projet comme répertoire de travail, donc un `./agent.yaml` relatif ne serait pas trouvé. En mode ACP, un `skillsDir` relatif dans ce fichier est résolu à côté du fichier (depuis la racine du projet quand le fichier est un `.mm/agent.yaml`), pas à côté du répertoire de travail de l'éditeur.
2323
2424 3. Ouvrez le panneau d'agent et démarrez une conversation. Chaque appel à `bash`, `read_file`, `write_file` et `edit_file` apparaît comme un appel d'outil. Par défaut (`yolo: true`), ils s'exécutent aussitôt ; avec `yolo: false` dans la configuration, l'éditeur vous demande d'autoriser chaque appel à `bash`, `write_file` et `edit_file` avant exécution. `read_file` et `read_skill` ne demandent jamais.
2525
@@ -19,7 +19,7 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client
19 }19 }
20 ```20 ```
21 21
22-2. Donnez à `AGENT_CONFIG` un chemin absolu. L'éditeur lance `mm` avec le répertoire du projet comme répertoire de travail, donc un `./agent.yaml` relatif ne serait pas trouvé. En mode ACP, un `skillsDir` relatif dans ce fichier est résolu à côté du fichier, pas à côté du répertoire de travail de l'éditeur.22+2. Donnez à `AGENT_CONFIG` un chemin absolu. L'éditeur lance `mm` avec le répertoire du projet comme répertoire de travail, donc un `./agent.yaml` relatif ne serait pas trouvé. En mode ACP, un `skillsDir` relatif dans ce fichier est résolu à côté du fichier (depuis la racine du projet quand le fichier est un `.mm/agent.yaml`), pas à côté du répertoire de travail de l'éditeur.
23 23
24 3. Ouvrez le panneau d'agent et démarrez une conversation. Chaque appel à `bash`, `read_file`, `write_file` et `edit_file` apparaît comme un appel d'outil. Par défaut (`yolo: true`), ils s'exécutent aussitôt ; avec `yolo: false` dans la configuration, l'éditeur vous demande d'autoriser chaque appel à `bash`, `write_file` et `edit_file` avant exécution. `read_file` et `read_skill` ne demandent jamais.24 3. Ouvrez le panneau d'agent et démarrez une conversation. Chaque appel à `bash`, `read_file`, `write_file` et `edit_file` apparaît comme un appel d'outil. Par défaut (`yolo: true`), ils s'exécutent aussitôt ; avec `yolo: false` dans la configuration, l'éditeur vous demande d'autoriser chaque appel à `bash`, `write_file` et `edit_file` avant exécution. `read_file` et `read_skill` ne demandent jamais.
25 25
modified docs/fr/reference/cli.md +48 -6
@@ -11,6 +11,7 @@ mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur s
1111 mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init)
1212 mm dockerinit [options] (écrire un Dockerfile et un compose.yaml)
1313 mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox)
14+mm skillcreate [options] (écrire un nouveau skill avec un assistant)
1415 mm tui [<chemin>] [options] (identique à mm -tui)
1516 mm acp [<chemin>] [options] (identique à mm -acp)
1617 ```
@@ -53,7 +54,21 @@ La première source existante gagne.
5354 |-------|--------|----------------|
5455 | 1 | Option `-config` ou argument positionnel | Erreur, code de sortie 1 |
5556 | 2 | Variable d'environnement `AGENT_CONFIG` | Erreur, code de sortie 1 |
56-| 3 | `./agent.yaml` | Pas une erreur : les valeurs par défaut intégrées sont utilisées, la bannière affiche `config: built-in defaults` |
57+| 3 | `./agent.yaml` | Source suivante |
58+| 4 | `./.mm/agent.yaml` | Pas une erreur : les valeurs par défaut intégrées sont utilisées, la bannière affiche `config: built-in defaults` |
59+
60+Quand `./agent.yaml` et `./.mm/agent.yaml` existent tous les deux, c'est `./agent.yaml` qui est utilisé.
61+
62+### Répertoire de base des chemins relatifs
63+
64+Les chemins relatifs qu'un fichier de configuration indique (`mmConfigs`, `skillRouting.decider`, `toolRouting.decider`, `tools[].wasm`, `hooks.*[].wasm`, `skillsDir` en mode ACP) et la variable `MM_CONFIG_DIR` transmise aux hooks sont résolus depuis un même répertoire de base :
65+
66+| Fichier de configuration | Répertoire de base |
67+|--------------------------|--------------------|
68+| Directement dans un dossier nommé `.mm` (`<projet>/.mm/agent.yaml`) | Le parent de ce dossier : `<projet>` |
69+| Partout ailleurs (`<dossier>/agent.yaml`) | Le dossier du fichier : `<dossier>` |
70+
71+La règle dépend de l'emplacement du fichier, pas de la façon dont il a été trouvé : `-config`, `AGENT_CONFIG` et la recherche ci-dessus donnent le même résultat. `skillsDir` en mode terminal, `sessions.dir` et `agentsMd.path` sont relatifs au répertoire de travail, quel que soit l'emplacement du fichier.
5772
5873 ## Variables d'environnement
5974
@@ -100,7 +115,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
100115 | stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. |
101116 | Spinner | Désactivé. |
102117 | Difficultés | Un paragraphe `*⚠ mm: …*` dans le message de l'agent — voir [Signaux de difficulté](#signaux-de-difficulté). |
103-| `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |
118+| `skillsDir` relatif | Résolu depuis le [répertoire de base](#répertoire-de-base-des-chemins-relatifs) du fichier de configuration, quand un fichier a été trouvé (la racine du projet pour un `.mm/agent.yaml`) ; sinon depuis le répertoire de travail, comme en mode terminal. |
104119 | Permissions | `yolo: true` : aucune `session/request_permission` n'est envoyée pour les outils intégrés. `yolo: false` : une par appel, avec quatre options — `allow` (autoriser une fois), `allow_always` (cet outil pour la session), `reject` (refuser une fois), `never` (`reject_always` : cet outil, pour la session). Les outils MCP marqués `ask` en envoient toujours une. |
105120
106121 ## Spécificités du mode ligne
@@ -124,7 +139,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
124139 | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. |
125140 | Sélecteurs | `/` complète une commande, `@` un chemin sous le répertoire de travail, `$` un skill du catalogue — les deux derniers n'importe où dans une phrase, en conservant leur préfixe. Voir [Commandes slash](slash-commands.md#les-trois-sélecteurs). |
126141 | Réflexion | Le raisonnement d'un modèle à raisonnement forme son entrée `‣ thinking`, grisée, en italique et d'une autre couleur, avant la réponse. Masquée avec `showThinking: false`. |
127-| Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
142+| Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🔴 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
128143 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject [n] never <outil>` jusqu'à la réponse (`Échap` = `r`) ; un tour annulé pendant la question refuse. |
129144 | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). |
130145 | Difficultés | Un badge à droite de la barre d'état jusqu'à la question suivante — voir [Signaux de difficulté](#signaux-de-difficulté). |
@@ -211,7 +226,7 @@ La question est posée pour chaque fichier existant, avec `no` par défaut : ce
211226 |--------|---------------|--------|
212227 | `--workspace` | Le dossier monté par la sandbox, relatif au dossier courant ou absolu. Le fichier contient toujours un chemin absolu | le dossier courant |
213228 | `--name` | Le nom de l'agent, en minuscules et chiffres reliés par des tirets. La sandbox est `<nom>-sbx` | `acp.name` dans le fichier d'agent, sinon `mm` |
214-| `--agent` | Le fichier d'agent qui nomme la sandbox, cherché dans le dossier courant, puis dans le workspace | `agent.yaml` |
229+| `--agent` | Le fichier d'agent qui nomme la sandbox, cherché dans le dossier courant, puis dans le workspace. Sans l'option, `.mm/agent.yaml` est cherché aussi, après `agent.yaml` (l'ordre que suit mm) ; trouvé dans `<workspace>/.mm/`, le fichier monte `.mm/` en lecture seule, remonte en écriture le `sessions.dir` et le `trace.dir` du fichier d'agent (quand ils sont activés et sous `.mm/`), et fixe `AGENT_CONFIG` à `${{ env.args.PROJECT_DIR }}/…/.mm/agent.yaml` avec un argument `PROJECT_DIR` obligatoire ; les chemins sont relatifs au dossier de `sbxenv.yaml`, et les commandes affichées ajoutent `--env-arg PROJECT_DIR="$PWD"` | `agent.yaml`, puis `.mm/agent.yaml` |
215230 | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé | désactivé |
216231
217232 Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand le fichier d'agent est hors du workspace](../how-to/run-in-a-docker-sandbox.md#quand-le-fichier-dagent-est-hors-du-workspace). La version de `mini-me-kit` est lue dans sa dernière release sur Rickub, avec une limite de 5 s ; le tag `latest` est utilisé en cas d'échec. Le fichier définit `sandboxOptions.pullPolicy: always`. Pour un fichier existant, la question est posée avec `no` par défaut. Codes de sortie : `0` terminé, `1` un fichier existe et personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects.
@@ -222,6 +237,7 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
222237
223238 | Option | Question quand elle est absente | Défaut |
224239 |--------|---------------------------------|--------|
240+| `--project yes\|no` | Tout ranger dans un dossier `.mm` ? — posée en premier. `yes` : le fichier est `.mm/agent.yaml` (le dossier est créé), ses chemins sont écrits depuis la racine du projet (`skillsDir: .mm/skills`), et `mm` / `mm tui` lancés là le trouvent sans argument | `no` |
225241 | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | le premier serveur local qui répond, sinon `llmman` (`openai` n'est jamais sondé : son adresse par défaut est l'API d'OpenAI) |
226242 | `--base-url` | `openai` seulement : Endpoint — l'adresse compatible OpenAI du service (OpenAI, Cerebras, Hugging Face…) ; pour les autres fournisseurs, pas posée | `https://api.openai.com/v1` pour `openai` ; sinon l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas. Écrite comme `baseUrl` sauf si c'est la valeur par défaut |
227243 | `--api-key-env` | `openai` seulement : variable d'environnement qui contient la clé d'API | `OPENAI_API_KEY`. Écrite comme `apiKeyEnv` sauf si c'est la valeur par défaut |
@@ -230,8 +246,10 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
230246 | `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
231247 | `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` |
232248 | `--yolo yes\|no` | Demander avant chaque commande ou modification de fichier ? — seulement pour les usages avec `bash`, les outils de fichiers ou `fetch_content` | celui de l'usage |
249+| `--sessions yes\|no` | Enregistrer les conversations ? | `no` ; `yes` : une section `sessions` (`enabled: true`, `dir: .mm/sessions`) ; `no` : pas de section, le défaut de mm (désactivé) s'applique |
250+| `--skills yes\|no` | Utiliser des skills ? | `no` (`skillsDir: no-skills`) ; `yes` : `skillsDir: skills`, ou `.mm/skills` avec `--project yes`, et le dossier est créé depuis la racine du projet une fois le fichier écrit |
233251 | `--name` | Nom de l'agent (`acp.name`) | le nom de l'usage |
234-| `-o`, `--output` | Fichier à écrire (`-` = sortie standard) | `agent.yaml` |
252+| `-o`, `--output` | Fichier à écrire (`-` = sortie standard : rien n'est créé, quelles que soient les autres réponses). Avec `--project yes`, un fichier qui n'est pas directement dans un dossier `.mm` est refusé | `agent.yaml` ; `.mm/agent.yaml` avec `--project yes` |
235253 | `--check` | — | désactivé ; activé : après l'écriture, lance ce mm avec `-config <fichier> -p "Say hello in one line."` et indique si le modèle a répondu (5 min au plus) |
236254 | `--yes` | — | désactivé ; activé : rien n'est demandé, chaque réponse manquante prend son défaut |
237255 | `--line` | — | désactivé ; activé : les questions sont posées ligne par ligne même dans un terminal |
@@ -244,11 +262,35 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
244262 | Origine de chaque valeur | Les faits du serveur, puis la table des familles, puis l'usage, puis la ligne de la machine ; la table est `internal/agentinit/presets.yaml`, chaque entrée avec sa source et, par bloc d'échantillonnage, son `origin` (`measured`, `card` ou `rule`). |
245263 | Tags d'origine | Le commentaire d'un nombre qui concerne le modèle commence par un tag : `[measured]` (une démo de ce dépôt a tourné avec), `[card]` (carte du modèle, GGUF ou référence d'API, non mesuré), `[server]` (lu sur le serveur : l'échantillonnage du GGUF), `[guide]` (la ligne machine du guide des réglages : `maxOutput`, les délais), `[rule]` (une règle empirique : `max_tokens`, le plancher de 0,6 avec raisonnement, une famille sans valeurs publiées). Les valeurs qui découlent des réponses ou des défauts de mm n'ont pas de tag. L'en-tête explique les tags que le fichier utilise, et seulement ceux-là. |
246264 | Échantillonnage | Raisonnement activé (l'usage le demande et le modèle a un interrupteur) : l'échantillonnage « thinking » de la famille, jamais une température sous 0,6, `max_tokens: 16384`. Raisonnement désactivé : l'échantillonnage général de la famille, ou sa température « précise » pour les usages qui utilisent beaucoup d'outils, `max_tokens: 4096`. L'échantillonnage du GGUF remplace celui de la famille. `parallel_tool_calls: false` dès qu'un outil est actif. |
247-| Fichier écrit | Un en-tête avec la famille, l'usage, la machine, `model facts` (ce que disait le serveur : fenêtre native, échantillonnage du GGUF, interrupteur de raisonnement et son défaut — ou `none`) et la légende des origines ; au-dessus de `model:`, les réglages choisis pour ce modèle (clés d'échantillonnage, `chat_template_kwargs`), à refaire ensemble s'il change ; sans `--base-url`, le `baseUrl` et le `fallback` par défaut du fournisseur en commentaire ; chaque valeur précédée d'un commentaire qui dit pourquoi elle a cette valeur ; `contextWindow: 0` (mm demande au serveur), avec la fenêtre native en commentaire ; `context.threshold: 75` ; le prompt système en anglais de l'usage ; pour `docs` un bloc `docSearch`, pour `json` un exemple de `structuredOutput` et tous les outils désactivés, pour `coordinator` un exemple commenté de `mmConfigs`. |
265+| Fichier écrit | Un en-tête avec la famille, l'usage, la machine, `model facts` (ce que disait le serveur : fenêtre native, échantillonnage du GGUF, interrupteur de raisonnement et son défaut — ou `none`) et la légende des origines ; au-dessus de `model:`, les réglages choisis pour ce modèle (clés d'échantillonnage, `chat_template_kwargs`), à refaire ensemble s'il change ; sans `--base-url`, le `baseUrl` et le `fallback` par défaut du fournisseur en commentaire ; chaque valeur précédée d'un commentaire qui dit pourquoi elle a cette valeur ; `contextWindow: 0` (mm demande au serveur), avec la fenêtre native en commentaire ; `context.threshold: 75` ; le prompt système en anglais de l'usage ; pour `docs` un bloc `docSearch`, pour `json` un exemple de `structuredOutput` et tous les outils désactivés, pour `coordinator` un exemple commenté de `mmConfigs` ; une section `sessions` avec `--sessions yes` ; avec `--project yes`, une ligne d'en-tête qui dit de lancer `mm` depuis la racine du projet. |
248266 | Fichier existant | Jamais écrasé : `⚠️ <fichier> already exists — nothing was written; choose another name with -o`, code de sortie 1, avant toute question. |
249267 | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. |
250268 | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. |
251269
270+## `mm skillcreate` — l'assistant de skill
271+
272+`mm skillcreate` (aussi `--skillcreate`, `-skillcreate`) demande un skill et l'écrit dans `<skillsDir>/<nom>/SKILL.md`. Il répond avant qu'aucun agent ne démarre. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Comment ajouter un skill](../how-to/add-a-skill.md).
273+
274+| Option | Question quand elle est absente | Défaut |
275+|--------|---------------------------------|--------|
276+| `--name` | Nom du skill — écrit en minuscules, chaque suite d'espaces remplacée par un tiret (`Vulcan Salute` → `vulcan-salute`) : le `name` de l'en-tête et le dossier du skill. Un nom avec `/`, `\` ou un point initial est refusé | aucun |
277+| — | `Replace <nom>?` — posée juste après le nom quand le skill existe (en `<nom>/SKILL.md` ou `<nom>.md`) ; `no` redemande le nom | `no` |
278+| `--description` | Description (une ligne) | aucune |
279+| `--keywords` | Mots-clés (séparés par des virgules) ; plus de 8 → un avertissement : seuls les 8 premiers arrivent au catalogue | aucun (`-` en mode ligne) |
280+| `--body`, `--body-file` | What to do — le texte du skill, sur plusieurs lignes. Toujours la dernière question. Mode ligne : lu jusqu'à la fin de l'entrée (`Ctrl-D` dans un terminal). Fenêtre : `Entrée` ajoute une ligne, `Ctrl-D` répond | aucun |
281+| `-config` | — | le fichier d'agent trouvé comme le trouve `mm` ([Résolution du fichier de configuration](#résolution-du-fichier-de-configuration)) |
282+| `-o` | — | `<skillsDir>/<nom>/SKILL.md` ; `-` affiche le skill sur la sortie standard et ne crée rien |
283+| `--yes` | — | désactivé ; activé : rien n'est demandé — `--name`, `--description` et un texte doivent être donnés, un skill existant n'est jamais remplacé |
284+| `--line` | — | désactivé ; activé : les questions sont posées ligne par ligne même dans un terminal |
285+
286+| Aspect | Comportement |
287+|--------|--------------|
288+| Fichier écrit | En-tête `name` (normalisé), `description`, `keywords` (omis s'il n'y en a pas) ; un titre de niveau 1 — le nom tel que saisi ou, s'il a été saisi déjà normalisé, retransformé en mots avec une majuscule chacun (`vulcan-salute` → `# Vulcan Salute`) ; `## What to do` ; le texte. |
289+| Répertoire des skills | Le `skillsDir` du fichier d'agent, relatif au répertoire d'où `mm skillcreate` est lancé. Pas de fichier d'agent, ou `skillsDir: no-skills` : `.mm/skills` si un dossier `.mm` existe, sinon `skills`, avec une ligne `⚠️` qui dit quel `skillsDir` régler. Les dossiers sont créés. |
290+| Fenêtre | Quand l'entrée et la sortie standard sont des terminaux (et ni `--yes` ni `--line`) : la fenêtre de `mm init`, avec à droite le `SKILL.md` tel qu'il serait écrit, précédé du chemin du fichier. |
291+| Après l'écriture | `✅ wrote <fichier>` ; le répertoire des skills est relu avec le chargeur de mm : `✅ mm reads it: <nom> — <description>`, ou une erreur. |
292+| Codes de sortie | 0 écrit (ou affiché) ; 1 une réponse manquante avec `--yes`, un skill existant avec `--yes`, un `--body-file` ou un fichier d'agent illisible, la fenêtre quittée ; 2 une option invalide. |
293+
252294 ## Codes de sortie
253295
254296 | Code | Cause |
@@ -11,6 +11,7 @@ mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur s
11 mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init)11 mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init)
12 mm dockerinit [options] (écrire un Dockerfile et un compose.yaml)12 mm dockerinit [options] (écrire un Dockerfile et un compose.yaml)
13 mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox)13 mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox)
14+mm skillcreate [options] (écrire un nouveau skill avec un assistant)
14 mm tui [<chemin>] [options] (identique à mm -tui)15 mm tui [<chemin>] [options] (identique à mm -tui)
15 mm acp [<chemin>] [options] (identique à mm -acp)16 mm acp [<chemin>] [options] (identique à mm -acp)
16 ```17 ```
@@ -53,7 +54,21 @@ La première source existante gagne.
53 |-------|--------|----------------|54 |-------|--------|----------------|
54 | 1 | Option `-config` ou argument positionnel | Erreur, code de sortie 1 |55 | 1 | Option `-config` ou argument positionnel | Erreur, code de sortie 1 |
55 | 2 | Variable d'environnement `AGENT_CONFIG` | Erreur, code de sortie 1 |56 | 2 | Variable d'environnement `AGENT_CONFIG` | Erreur, code de sortie 1 |
56-| 3 | `./agent.yaml` | Pas une erreur : les valeurs par défaut intégrées sont utilisées, la bannière affiche `config: built-in defaults` |57+| 3 | `./agent.yaml` | Source suivante |
58+| 4 | `./.mm/agent.yaml` | Pas une erreur : les valeurs par défaut intégrées sont utilisées, la bannière affiche `config: built-in defaults` |
59+
60+Quand `./agent.yaml` et `./.mm/agent.yaml` existent tous les deux, c'est `./agent.yaml` qui est utilisé.
61+
62+### Répertoire de base des chemins relatifs
63+
64+Les chemins relatifs qu'un fichier de configuration indique (`mmConfigs`, `skillRouting.decider`, `toolRouting.decider`, `tools[].wasm`, `hooks.*[].wasm`, `skillsDir` en mode ACP) et la variable `MM_CONFIG_DIR` transmise aux hooks sont résolus depuis un même répertoire de base :
65+
66+| Fichier de configuration | Répertoire de base |
67+|--------------------------|--------------------|
68+| Directement dans un dossier nommé `.mm` (`<projet>/.mm/agent.yaml`) | Le parent de ce dossier : `<projet>` |
69+| Partout ailleurs (`<dossier>/agent.yaml`) | Le dossier du fichier : `<dossier>` |
70+
71+La règle dépend de l'emplacement du fichier, pas de la façon dont il a été trouvé : `-config`, `AGENT_CONFIG` et la recherche ci-dessus donnent le même résultat. `skillsDir` en mode terminal, `sessions.dir` et `agentsMd.path` sont relatifs au répertoire de travail, quel que soit l'emplacement du fichier.
57 72
58 ## Variables d'environnement73 ## Variables d'environnement
59 74
@@ -100,7 +115,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
100 | stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. |115 | stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. |
101 | Spinner | Désactivé. |116 | Spinner | Désactivé. |
102 | Difficultés | Un paragraphe `*⚠ mm: …*` dans le message de l'agent — voir [Signaux de difficulté](#signaux-de-difficulté). |117 | Difficultés | Un paragraphe `*⚠ mm: …*` dans le message de l'agent — voir [Signaux de difficulté](#signaux-de-difficulté). |
103-| `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |118+| `skillsDir` relatif | Résolu depuis le [répertoire de base](#répertoire-de-base-des-chemins-relatifs) du fichier de configuration, quand un fichier a été trouvé (la racine du projet pour un `.mm/agent.yaml`) ; sinon depuis le répertoire de travail, comme en mode terminal. |
104 | Permissions | `yolo: true` : aucune `session/request_permission` n'est envoyée pour les outils intégrés. `yolo: false` : une par appel, avec quatre options — `allow` (autoriser une fois), `allow_always` (cet outil pour la session), `reject` (refuser une fois), `never` (`reject_always` : cet outil, pour la session). Les outils MCP marqués `ask` en envoient toujours une. |119 | Permissions | `yolo: true` : aucune `session/request_permission` n'est envoyée pour les outils intégrés. `yolo: false` : une par appel, avec quatre options — `allow` (autoriser une fois), `allow_always` (cet outil pour la session), `reject` (refuser une fois), `never` (`reject_always` : cet outil, pour la session). Les outils MCP marqués `ask` en envoient toujours une. |
105 120
106 ## Spécificités du mode ligne121 ## Spécificités du mode ligne
@@ -124,7 +139,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
124 | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. |139 | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. |
125 | Sélecteurs | `/` complète une commande, `@` un chemin sous le répertoire de travail, `$` un skill du catalogue — les deux derniers n'importe où dans une phrase, en conservant leur préfixe. Voir [Commandes slash](slash-commands.md#les-trois-sélecteurs). |140 | Sélecteurs | `/` complète une commande, `@` un chemin sous le répertoire de travail, `$` un skill du catalogue — les deux derniers n'importe où dans une phrase, en conservant leur préfixe. Voir [Commandes slash](slash-commands.md#les-trois-sélecteurs). |
126 | Réflexion | Le raisonnement d'un modèle à raisonnement forme son entrée `‣ thinking`, grisée, en italique et d'une autre couleur, avant la réponse. Masquée avec `showThinking: false`. |141 | Réflexion | Le raisonnement d'un modèle à raisonnement forme son entrée `‣ thinking`, grisée, en italique et d'une autre couleur, avant la réponse. Masquée avec `showThinking: false`. |
127-| Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |142+| Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🔴 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
128 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject [n] never <outil>` jusqu'à la réponse (`Échap` = `r`) ; un tour annulé pendant la question refuse. |143 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject [n] never <outil>` jusqu'à la réponse (`Échap` = `r`) ; un tour annulé pendant la question refuse. |
129 | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). |144 | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). |
130 | Difficultés | Un badge à droite de la barre d'état jusqu'à la question suivante — voir [Signaux de difficulté](#signaux-de-difficulté). |145 | Difficultés | Un badge à droite de la barre d'état jusqu'à la question suivante — voir [Signaux de difficulté](#signaux-de-difficulté). |
@@ -211,7 +226,7 @@ La question est posée pour chaque fichier existant, avec `no` par défaut : ce
211 |--------|---------------|--------|226 |--------|---------------|--------|
212 | `--workspace` | Le dossier monté par la sandbox, relatif au dossier courant ou absolu. Le fichier contient toujours un chemin absolu | le dossier courant |227 | `--workspace` | Le dossier monté par la sandbox, relatif au dossier courant ou absolu. Le fichier contient toujours un chemin absolu | le dossier courant |
213 | `--name` | Le nom de l'agent, en minuscules et chiffres reliés par des tirets. La sandbox est `<nom>-sbx` | `acp.name` dans le fichier d'agent, sinon `mm` |228 | `--name` | Le nom de l'agent, en minuscules et chiffres reliés par des tirets. La sandbox est `<nom>-sbx` | `acp.name` dans le fichier d'agent, sinon `mm` |
214-| `--agent` | Le fichier d'agent qui nomme la sandbox, cherché dans le dossier courant, puis dans le workspace | `agent.yaml` |229+| `--agent` | Le fichier d'agent qui nomme la sandbox, cherché dans le dossier courant, puis dans le workspace. Sans l'option, `.mm/agent.yaml` est cherché aussi, après `agent.yaml` (l'ordre que suit mm) ; trouvé dans `<workspace>/.mm/`, le fichier monte `.mm/` en lecture seule, remonte en écriture le `sessions.dir` et le `trace.dir` du fichier d'agent (quand ils sont activés et sous `.mm/`), et fixe `AGENT_CONFIG` à `${{ env.args.PROJECT_DIR }}/…/.mm/agent.yaml` avec un argument `PROJECT_DIR` obligatoire ; les chemins sont relatifs au dossier de `sbxenv.yaml`, et les commandes affichées ajoutent `--env-arg PROJECT_DIR="$PWD"` | `agent.yaml`, puis `.mm/agent.yaml` |
215 | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé | désactivé |230 | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé | désactivé |
216 231
217 Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand le fichier d'agent est hors du workspace](../how-to/run-in-a-docker-sandbox.md#quand-le-fichier-dagent-est-hors-du-workspace). La version de `mini-me-kit` est lue dans sa dernière release sur Rickub, avec une limite de 5 s ; le tag `latest` est utilisé en cas d'échec. Le fichier définit `sandboxOptions.pullPolicy: always`. Pour un fichier existant, la question est posée avec `no` par défaut. Codes de sortie : `0` terminé, `1` un fichier existe et personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects.232 Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand le fichier d'agent est hors du workspace](../how-to/run-in-a-docker-sandbox.md#quand-le-fichier-dagent-est-hors-du-workspace). La version de `mini-me-kit` est lue dans sa dernière release sur Rickub, avec une limite de 5 s ; le tag `latest` est utilisé en cas d'échec. Le fichier définit `sandboxOptions.pullPolicy: always`. Pour un fichier existant, la question est posée avec `no` par défaut. Codes de sortie : `0` terminé, `1` un fichier existe et personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects.
@@ -222,6 +237,7 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
222 237
223 | Option | Question quand elle est absente | Défaut |238 | Option | Question quand elle est absente | Défaut |
224 |--------|---------------------------------|--------|239 |--------|---------------------------------|--------|
240+| `--project yes\|no` | Tout ranger dans un dossier `.mm` ? — posée en premier. `yes` : le fichier est `.mm/agent.yaml` (le dossier est créé), ses chemins sont écrits depuis la racine du projet (`skillsDir: .mm/skills`), et `mm` / `mm tui` lancés là le trouvent sans argument | `no` |
225 | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | le premier serveur local qui répond, sinon `llmman` (`openai` n'est jamais sondé : son adresse par défaut est l'API d'OpenAI) |241 | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | le premier serveur local qui répond, sinon `llmman` (`openai` n'est jamais sondé : son adresse par défaut est l'API d'OpenAI) |
226 | `--base-url` | `openai` seulement : Endpoint — l'adresse compatible OpenAI du service (OpenAI, Cerebras, Hugging Face…) ; pour les autres fournisseurs, pas posée | `https://api.openai.com/v1` pour `openai` ; sinon l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas. Écrite comme `baseUrl` sauf si c'est la valeur par défaut |242 | `--base-url` | `openai` seulement : Endpoint — l'adresse compatible OpenAI du service (OpenAI, Cerebras, Hugging Face…) ; pour les autres fournisseurs, pas posée | `https://api.openai.com/v1` pour `openai` ; sinon l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas. Écrite comme `baseUrl` sauf si c'est la valeur par défaut |
227 | `--api-key-env` | `openai` seulement : variable d'environnement qui contient la clé d'API | `OPENAI_API_KEY`. Écrite comme `apiKeyEnv` sauf si c'est la valeur par défaut |243 | `--api-key-env` | `openai` seulement : variable d'environnement qui contient la clé d'API | `OPENAI_API_KEY`. Écrite comme `apiKeyEnv` sauf si c'est la valeur par défaut |
@@ -230,8 +246,10 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
230 | `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |246 | `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
231 | `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` |247 | `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` |
232 | `--yolo yes\|no` | Demander avant chaque commande ou modification de fichier ? — seulement pour les usages avec `bash`, les outils de fichiers ou `fetch_content` | celui de l'usage |248 | `--yolo yes\|no` | Demander avant chaque commande ou modification de fichier ? — seulement pour les usages avec `bash`, les outils de fichiers ou `fetch_content` | celui de l'usage |
249+| `--sessions yes\|no` | Enregistrer les conversations ? | `no` ; `yes` : une section `sessions` (`enabled: true`, `dir: .mm/sessions`) ; `no` : pas de section, le défaut de mm (désactivé) s'applique |
250+| `--skills yes\|no` | Utiliser des skills ? | `no` (`skillsDir: no-skills`) ; `yes` : `skillsDir: skills`, ou `.mm/skills` avec `--project yes`, et le dossier est créé depuis la racine du projet une fois le fichier écrit |
233 | `--name` | Nom de l'agent (`acp.name`) | le nom de l'usage |251 | `--name` | Nom de l'agent (`acp.name`) | le nom de l'usage |
234-| `-o`, `--output` | Fichier à écrire (`-` = sortie standard) | `agent.yaml` |252+| `-o`, `--output` | Fichier à écrire (`-` = sortie standard : rien n'est créé, quelles que soient les autres réponses). Avec `--project yes`, un fichier qui n'est pas directement dans un dossier `.mm` est refusé | `agent.yaml` ; `.mm/agent.yaml` avec `--project yes` |
235 | `--check` | — | désactivé ; activé : après l'écriture, lance ce mm avec `-config <fichier> -p "Say hello in one line."` et indique si le modèle a répondu (5 min au plus) |253 | `--check` | — | désactivé ; activé : après l'écriture, lance ce mm avec `-config <fichier> -p "Say hello in one line."` et indique si le modèle a répondu (5 min au plus) |
236 | `--yes` | — | désactivé ; activé : rien n'est demandé, chaque réponse manquante prend son défaut |254 | `--yes` | — | désactivé ; activé : rien n'est demandé, chaque réponse manquante prend son défaut |
237 | `--line` | — | désactivé ; activé : les questions sont posées ligne par ligne même dans un terminal |255 | `--line` | — | désactivé ; activé : les questions sont posées ligne par ligne même dans un terminal |
@@ -244,11 +262,35 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
244 | Origine de chaque valeur | Les faits du serveur, puis la table des familles, puis l'usage, puis la ligne de la machine ; la table est `internal/agentinit/presets.yaml`, chaque entrée avec sa source et, par bloc d'échantillonnage, son `origin` (`measured`, `card` ou `rule`). |262 | Origine de chaque valeur | Les faits du serveur, puis la table des familles, puis l'usage, puis la ligne de la machine ; la table est `internal/agentinit/presets.yaml`, chaque entrée avec sa source et, par bloc d'échantillonnage, son `origin` (`measured`, `card` ou `rule`). |
245 | Tags d'origine | Le commentaire d'un nombre qui concerne le modèle commence par un tag : `[measured]` (une démo de ce dépôt a tourné avec), `[card]` (carte du modèle, GGUF ou référence d'API, non mesuré), `[server]` (lu sur le serveur : l'échantillonnage du GGUF), `[guide]` (la ligne machine du guide des réglages : `maxOutput`, les délais), `[rule]` (une règle empirique : `max_tokens`, le plancher de 0,6 avec raisonnement, une famille sans valeurs publiées). Les valeurs qui découlent des réponses ou des défauts de mm n'ont pas de tag. L'en-tête explique les tags que le fichier utilise, et seulement ceux-là. |263 | Tags d'origine | Le commentaire d'un nombre qui concerne le modèle commence par un tag : `[measured]` (une démo de ce dépôt a tourné avec), `[card]` (carte du modèle, GGUF ou référence d'API, non mesuré), `[server]` (lu sur le serveur : l'échantillonnage du GGUF), `[guide]` (la ligne machine du guide des réglages : `maxOutput`, les délais), `[rule]` (une règle empirique : `max_tokens`, le plancher de 0,6 avec raisonnement, une famille sans valeurs publiées). Les valeurs qui découlent des réponses ou des défauts de mm n'ont pas de tag. L'en-tête explique les tags que le fichier utilise, et seulement ceux-là. |
246 | Échantillonnage | Raisonnement activé (l'usage le demande et le modèle a un interrupteur) : l'échantillonnage « thinking » de la famille, jamais une température sous 0,6, `max_tokens: 16384`. Raisonnement désactivé : l'échantillonnage général de la famille, ou sa température « précise » pour les usages qui utilisent beaucoup d'outils, `max_tokens: 4096`. L'échantillonnage du GGUF remplace celui de la famille. `parallel_tool_calls: false` dès qu'un outil est actif. |264 | Échantillonnage | Raisonnement activé (l'usage le demande et le modèle a un interrupteur) : l'échantillonnage « thinking » de la famille, jamais une température sous 0,6, `max_tokens: 16384`. Raisonnement désactivé : l'échantillonnage général de la famille, ou sa température « précise » pour les usages qui utilisent beaucoup d'outils, `max_tokens: 4096`. L'échantillonnage du GGUF remplace celui de la famille. `parallel_tool_calls: false` dès qu'un outil est actif. |
247-| Fichier écrit | Un en-tête avec la famille, l'usage, la machine, `model facts` (ce que disait le serveur : fenêtre native, échantillonnage du GGUF, interrupteur de raisonnement et son défaut — ou `none`) et la légende des origines ; au-dessus de `model:`, les réglages choisis pour ce modèle (clés d'échantillonnage, `chat_template_kwargs`), à refaire ensemble s'il change ; sans `--base-url`, le `baseUrl` et le `fallback` par défaut du fournisseur en commentaire ; chaque valeur précédée d'un commentaire qui dit pourquoi elle a cette valeur ; `contextWindow: 0` (mm demande au serveur), avec la fenêtre native en commentaire ; `context.threshold: 75` ; le prompt système en anglais de l'usage ; pour `docs` un bloc `docSearch`, pour `json` un exemple de `structuredOutput` et tous les outils désactivés, pour `coordinator` un exemple commenté de `mmConfigs`. |265+| Fichier écrit | Un en-tête avec la famille, l'usage, la machine, `model facts` (ce que disait le serveur : fenêtre native, échantillonnage du GGUF, interrupteur de raisonnement et son défaut — ou `none`) et la légende des origines ; au-dessus de `model:`, les réglages choisis pour ce modèle (clés d'échantillonnage, `chat_template_kwargs`), à refaire ensemble s'il change ; sans `--base-url`, le `baseUrl` et le `fallback` par défaut du fournisseur en commentaire ; chaque valeur précédée d'un commentaire qui dit pourquoi elle a cette valeur ; `contextWindow: 0` (mm demande au serveur), avec la fenêtre native en commentaire ; `context.threshold: 75` ; le prompt système en anglais de l'usage ; pour `docs` un bloc `docSearch`, pour `json` un exemple de `structuredOutput` et tous les outils désactivés, pour `coordinator` un exemple commenté de `mmConfigs` ; une section `sessions` avec `--sessions yes` ; avec `--project yes`, une ligne d'en-tête qui dit de lancer `mm` depuis la racine du projet. |
248 | Fichier existant | Jamais écrasé : `⚠️ <fichier> already exists — nothing was written; choose another name with -o`, code de sortie 1, avant toute question. |266 | Fichier existant | Jamais écrasé : `⚠️ <fichier> already exists — nothing was written; choose another name with -o`, code de sortie 1, avant toute question. |
249 | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. |267 | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. |
250 | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. |268 | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. |
251 269
270+## `mm skillcreate` — l'assistant de skill
271+
272+`mm skillcreate` (aussi `--skillcreate`, `-skillcreate`) demande un skill et l'écrit dans `<skillsDir>/<nom>/SKILL.md`. Il répond avant qu'aucun agent ne démarre. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Comment ajouter un skill](../how-to/add-a-skill.md).
273+
274+| Option | Question quand elle est absente | Défaut |
275+|--------|---------------------------------|--------|
276+| `--name` | Nom du skill — écrit en minuscules, chaque suite d'espaces remplacée par un tiret (`Vulcan Salute` → `vulcan-salute`) : le `name` de l'en-tête et le dossier du skill. Un nom avec `/`, `\` ou un point initial est refusé | aucun |
277+| — | `Replace <nom>?` — posée juste après le nom quand le skill existe (en `<nom>/SKILL.md` ou `<nom>.md`) ; `no` redemande le nom | `no` |
278+| `--description` | Description (une ligne) | aucune |
279+| `--keywords` | Mots-clés (séparés par des virgules) ; plus de 8 → un avertissement : seuls les 8 premiers arrivent au catalogue | aucun (`-` en mode ligne) |
280+| `--body`, `--body-file` | What to do — le texte du skill, sur plusieurs lignes. Toujours la dernière question. Mode ligne : lu jusqu'à la fin de l'entrée (`Ctrl-D` dans un terminal). Fenêtre : `Entrée` ajoute une ligne, `Ctrl-D` répond | aucun |
281+| `-config` | — | le fichier d'agent trouvé comme le trouve `mm` ([Résolution du fichier de configuration](#résolution-du-fichier-de-configuration)) |
282+| `-o` | — | `<skillsDir>/<nom>/SKILL.md` ; `-` affiche le skill sur la sortie standard et ne crée rien |
283+| `--yes` | — | désactivé ; activé : rien n'est demandé — `--name`, `--description` et un texte doivent être donnés, un skill existant n'est jamais remplacé |
284+| `--line` | — | désactivé ; activé : les questions sont posées ligne par ligne même dans un terminal |
285+
286+| Aspect | Comportement |
287+|--------|--------------|
288+| Fichier écrit | En-tête `name` (normalisé), `description`, `keywords` (omis s'il n'y en a pas) ; un titre de niveau 1 — le nom tel que saisi ou, s'il a été saisi déjà normalisé, retransformé en mots avec une majuscule chacun (`vulcan-salute` → `# Vulcan Salute`) ; `## What to do` ; le texte. |
289+| Répertoire des skills | Le `skillsDir` du fichier d'agent, relatif au répertoire d'où `mm skillcreate` est lancé. Pas de fichier d'agent, ou `skillsDir: no-skills` : `.mm/skills` si un dossier `.mm` existe, sinon `skills`, avec une ligne `⚠️` qui dit quel `skillsDir` régler. Les dossiers sont créés. |
290+| Fenêtre | Quand l'entrée et la sortie standard sont des terminaux (et ni `--yes` ni `--line`) : la fenêtre de `mm init`, avec à droite le `SKILL.md` tel qu'il serait écrit, précédé du chemin du fichier. |
291+| Après l'écriture | `✅ wrote <fichier>` ; le répertoire des skills est relu avec le chargeur de mm : `✅ mm reads it: <nom> — <description>`, ou une erreur. |
292+| Codes de sortie | 0 écrit (ou affiché) ; 1 une réponse manquante avec `--yes`, un skill existant avec `--yes`, un `--body-file` ou un fichier d'agent illisible, la fenêtre quittée ; 2 une option invalide. |
293+
252 ## Codes de sortie294 ## Codes de sortie
253 295
254 | Code | Cause |296 | Code | Cause |
modified docs/fr/reference/configuration.md +3 -0
@@ -2,6 +2,8 @@
22
33 > Description neutre et exhaustive du fichier YAML lu par `mm`. Chaque clé est optionnelle ; une clé omise garde sa valeur par défaut intégrée. Le fichier est décodé par-dessus les valeurs par défaut, donc les clés inconnues sont ignorées.
44
5+Où `mm` cherche ce fichier : [Résolution du fichier de configuration](cli.md#résolution-du-fichier-de-configuration). « Relatif à ce fichier » / « relatif au fichier d'agent » ci-dessous signifie relatif à son [répertoire de base](cli.md#répertoire-de-base-des-chemins-relatifs) : le dossier du fichier, ou la racine du projet pour un fichier dans un dossier `.mm`.
6+
57 ## Clés de premier niveau
68
79 | Clé | Type | Défaut | Description |
@@ -11,6 +13,7 @@
1113 | `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. |
1214 | `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. |
1315 | `apiKeyEnv` | chaîne | `""` (défaut du fournisseur) | Nom de la variable d'environnement qui contient la clé d'API. |
16+| `toolFormat` | chaîne | `openai` | Comment les outils arrivent au modèle. `openai` : le champ `tools` de l'API OpenAI, celui qu'attendent tous les serveurs. `xml_tools` : pour un modèle dont le chat template lit ses outils dans une variable `xml_tools` et écrit ses appels en texte `<tool_call>` (SmolLM3) — mm envoie les outils dans `chat_template_kwargs.xml_tools` (sans champ `tools`), réécrit les appels précédents dans l'historique en texte `<tool_call>`, et transforme les blocs `<tool_call>` de la réponse en appels d'outils, en streaming ou non. Toute autre valeur : `toolFormat must be openai or xml_tools`. Voir [Comment donner des outils à un modèle dont le template lit `xml_tools`](../how-to/use-a-model-that-reads-xml-tools.md). |
1417 | `contextWindow` | entier | `0` | Tokens servis par le serveur. `0` = demander au serveur, sinon inconnu. Doit être ≥ 0. |
1518 | `maxOutput` | entier | `16000` | Nombre maximal de caractères qu'un outil renvoie au modèle ; au-delà, la sortie garde son début et jusqu'à 2000 caractères de fin. Doit être > 0. |
1619 | `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. |
@@ -2,6 +2,8 @@
2 2
3 > Description neutre et exhaustive du fichier YAML lu par `mm`. Chaque clé est optionnelle ; une clé omise garde sa valeur par défaut intégrée. Le fichier est décodé par-dessus les valeurs par défaut, donc les clés inconnues sont ignorées.3 > Description neutre et exhaustive du fichier YAML lu par `mm`. Chaque clé est optionnelle ; une clé omise garde sa valeur par défaut intégrée. Le fichier est décodé par-dessus les valeurs par défaut, donc les clés inconnues sont ignorées.
4 4
5+Où `mm` cherche ce fichier : [Résolution du fichier de configuration](cli.md#résolution-du-fichier-de-configuration). « Relatif à ce fichier » / « relatif au fichier d'agent » ci-dessous signifie relatif à son [répertoire de base](cli.md#répertoire-de-base-des-chemins-relatifs) : le dossier du fichier, ou la racine du projet pour un fichier dans un dossier `.mm`.
6+
5 ## Clés de premier niveau7 ## Clés de premier niveau
6 8
7 | Clé | Type | Défaut | Description |9 | Clé | Type | Défaut | Description |
@@ -11,6 +13,7 @@
11 | `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. |13 | `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. |
12 | `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. |14 | `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. |
13 | `apiKeyEnv` | chaîne | `""` (défaut du fournisseur) | Nom de la variable d'environnement qui contient la clé d'API. |15 | `apiKeyEnv` | chaîne | `""` (défaut du fournisseur) | Nom de la variable d'environnement qui contient la clé d'API. |
16+| `toolFormat` | chaîne | `openai` | Comment les outils arrivent au modèle. `openai` : le champ `tools` de l'API OpenAI, celui qu'attendent tous les serveurs. `xml_tools` : pour un modèle dont le chat template lit ses outils dans une variable `xml_tools` et écrit ses appels en texte `<tool_call>` (SmolLM3) — mm envoie les outils dans `chat_template_kwargs.xml_tools` (sans champ `tools`), réécrit les appels précédents dans l'historique en texte `<tool_call>`, et transforme les blocs `<tool_call>` de la réponse en appels d'outils, en streaming ou non. Toute autre valeur : `toolFormat must be openai or xml_tools`. Voir [Comment donner des outils à un modèle dont le template lit `xml_tools`](../how-to/use-a-model-that-reads-xml-tools.md). |
14 | `contextWindow` | entier | `0` | Tokens servis par le serveur. `0` = demander au serveur, sinon inconnu. Doit être ≥ 0. |17 | `contextWindow` | entier | `0` | Tokens servis par le serveur. `0` = demander au serveur, sinon inconnu. Doit être ≥ 0. |
15 | `maxOutput` | entier | `16000` | Nombre maximal de caractères qu'un outil renvoie au modèle ; au-delà, la sortie garde son début et jusqu'à 2000 caractères de fin. Doit être > 0. |18 | `maxOutput` | entier | `16000` | Nombre maximal de caractères qu'un outil renvoie au modèle ; au-delà, la sortie garde son début et jusqu'à 2000 caractères de fin. Doit être > 0. |
16 | `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. |19 | `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. |
modified docs/fr/reference/hooks.md +1 -1
@@ -80,7 +80,7 @@ Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisat
8080 | Variable | Valeur |
8181 |----------|--------|
8282 | `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` ou `postAsk` |
83-| `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook |
83+| `MM_CONFIG_DIR` | [répertoire de base](cli.md#répertoire-de-base-des-chemins-relatifs) absolu du fichier de configuration qui a déclaré le hook : son propre dossier, ou la racine du projet pour un fichier dans un dossier `.mm` |
8484
8585 L'environnement de `mm` contient aussi `MM_BIN` (le chemin du `mm` en cours) et `MM_CONFIG` (son fichier de configuration, quand il en a lu un), que `mm` pose pour tous les processus qu'il lance — voir la [référence de la ligne de commande](cli.md#variables-denvironnement).
8686
@@ -80,7 +80,7 @@ Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisat
80 | Variable | Valeur |80 | Variable | Valeur |
81 |----------|--------|81 |----------|--------|
82 | `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` ou `postAsk` |82 | `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` ou `postAsk` |
83-| `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook |83+| `MM_CONFIG_DIR` | [répertoire de base](cli.md#répertoire-de-base-des-chemins-relatifs) absolu du fichier de configuration qui a déclaré le hook : son propre dossier, ou la racine du projet pour un fichier dans un dossier `.mm` |
84 84
85 L'environnement de `mm` contient aussi `MM_BIN` (le chemin du `mm` en cours) et `MM_CONFIG` (son fichier de configuration, quand il en a lu un), que `mm` pose pour tous les processus qu'il lance — voir la [référence de la ligne de commande](cli.md#variables-denvironnement).85 L'environnement de `mm` contient aussi `MM_BIN` (le chemin du `mm` en cours) et `MM_CONFIG` (son fichier de configuration, quand il en a lu un), que `mm` pose pour tous les processus qu'il lance — voir la [référence de la ligne de commande](cli.md#variables-denvironnement).
86 86
modified docs/fr/reference/slash-commands.md +6 -6
@@ -7,7 +7,7 @@
77 | Commande | À l'invite | Pendant une génération |
88 |----------|------------|------------------------|
99 | `/quit` | Quitte le programme. | Annule la génération, affiche `Goodbye.`, quitte. |
10-| `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
10+| `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🔴 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
1111 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |
1212 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles, oublie le dernier compte de tokens du serveur et les plans de la conversation ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |
1313 | `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question, puis ` · ⚠️ N error(s)` quand la session a enregistré des erreurs ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
@@ -39,7 +39,7 @@ Pendant l'exécution :
3939 | Première ligne | `🗣 asking <nom> (<fournisseur>/<modèle>)…`, ou `…, with the conversation…` pour les formes avec contexte. |
4040 | Réponse de `<nom>` | Affichée au fil de l'eau là où s'affichent les réponses. Son raisonnement est affiché comme un raisonnement quand `showThinking` est actif. |
4141 | Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. |
42-| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` |
42+| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🔴 Ask aborted — nothing kept.` |
4343 | `/quit` | Annule la demande et quitte. |
4444 | Toute autre ligne (mode ligne) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn` ; la ligne part une fois la demande terminée, comme une ligne tapée pendant une génération (plus bas). |
4545
@@ -48,7 +48,7 @@ Pendant l'exécution :
4848 | Issue | Historique | Message |
4949 |-------|------------|---------|
5050 | Réponse obtenue | Deux messages ajoutés : utilisateur `[asked <nom> (<fournisseur>/<modèle>)[, with this conversation as context]: <prompt>]`, assistant `[<nom>'s answer]` + la réponse (plafonnée à `maxOutput` caractères, début et fin conservés). Enregistrés avec la session. | `🗣 <nom>'s answer is in the history for your next question` |
51-| Annulée | Inchangé. | `🛑 Ask aborted — nothing kept.` |
51+| Annulée | Inchangé. | `🔴 Ask aborted — nothing kept.` |
5252 | Échec | Inchangé ; enregistré dans le fichier de session avec `where: "ask"`. | `[error: …]` (terminal, fenêtre) ou une erreur JSON-RPC (éditeur). |
5353
5454 La transcription envoyée par les formes avec contexte contient les questions de l'utilisateur, les réponses de l'assistant, une ligne par appel d'outil (`Assistant ran <outil>: <arguments>`, arguments coupés à 160 caractères) et le résumé de compaction s'il existe ; jamais le prompt système, les sorties d'outils ni le raisonnement. Plafonnée à `maxOutput` caractères, en gardant les lignes les plus récentes, `[… earlier conversation omitted …]` marquant la coupure.
@@ -86,7 +86,7 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t
8686
8787 | Touche | À l'invite | Pendant une génération |
8888 |--------|------------|------------------------|
89-| `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |
89+| `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🔴 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |
9090 | `Ctrl+D` (fin d'entrée) | Quitte. | — |
9191
9292 ## Touches du mode fenêtre
@@ -157,7 +157,7 @@ La sortie, dans l'ordre :
157157
158158 | Vous faites | La fenêtre fait |
159159 |-------------|-----------------|
160-| `Échap` ou `/abort` | Annule le tour, et affiche `🛑 Aborting…`. |
160+| `Échap` ou `/abort` | Annule le tour, et affiche `🔴 Aborting…`. |
161161 | `Échap` ou `/abort` à nouveau | Le tour est toujours là : `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |
162162 | `Ctrl-C` | Annule, comme ci-dessus. |
163163 | `Ctrl-C` à nouveau | Quitte `mm`, tour en cours ou non. |
@@ -200,7 +200,7 @@ La notation marche aussi en mode ligne et en ACP, tapée à la main : un `$skill
200200
201201 | Message | Signification |
202202 |---------|---------------|
203-| `🛑 Generation aborted.` | Le tour a été annulé (`/abort`, `Ctrl+C`). La question et les tours d'outils terminés restent dans l'historique. |
203+| `🔴 Generation aborted.` | Le tour a été annulé (`/abort`, `Ctrl+C`). La question et les tours d'outils terminés restent dans l'historique. |
204204 | `[error: …]` | Le fournisseur a expliqué un échec en une ligne ; une question restée sans réponse est retirée de l'historique. |
205205 | `⚙ N command(s)` | Nombre de commandes `bash` réellement exécutées dans le tour. |
206206 | `· 📝 N file op(s)` | Ajouté quand des outils de fichiers ont tourné. |
@@ -7,7 +7,7 @@
7 | Commande | À l'invite | Pendant une génération |7 | Commande | À l'invite | Pendant une génération |
8 |----------|------------|------------------------|8 |----------|------------|------------------------|
9 | `/quit` | Quitte le programme. | Annule la génération, affiche `Goodbye.`, quitte. |9 | `/quit` | Quitte le programme. | Annule la génération, affiche `Goodbye.`, quitte. |
10-| `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |10+| `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🔴 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
11 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |11 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |
12 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles, oublie le dernier compte de tokens du serveur et les plans de la conversation ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |12 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles, oublie le dernier compte de tokens du serveur et les plans de la conversation ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |
13 | `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question, puis ` · ⚠️ N error(s)` quand la session a enregistré des erreurs ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |13 | `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question, puis ` · ⚠️ N error(s)` quand la session a enregistré des erreurs ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
@@ -39,7 +39,7 @@ Pendant l'exécution :
39 | Première ligne | `🗣 asking <nom> (<fournisseur>/<modèle>)…`, ou `…, with the conversation…` pour les formes avec contexte. |39 | Première ligne | `🗣 asking <nom> (<fournisseur>/<modèle>)…`, ou `…, with the conversation…` pour les formes avec contexte. |
40 | Réponse de `<nom>` | Affichée au fil de l'eau là où s'affichent les réponses. Son raisonnement est affiché comme un raisonnement quand `showThinking` est actif. |40 | Réponse de `<nom>` | Affichée au fil de l'eau là où s'affichent les réponses. Son raisonnement est affiché comme un raisonnement quand `showThinking` est actif. |
41 | Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. |41 | Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. |
42-| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` |42+| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🔴 Ask aborted — nothing kept.` |
43 | `/quit` | Annule la demande et quitte. |43 | `/quit` | Annule la demande et quitte. |
44 | Toute autre ligne (mode ligne) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn` ; la ligne part une fois la demande terminée, comme une ligne tapée pendant une génération (plus bas). |44 | Toute autre ligne (mode ligne) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn` ; la ligne part une fois la demande terminée, comme une ligne tapée pendant une génération (plus bas). |
45 45
@@ -48,7 +48,7 @@ Pendant l'exécution :
48 | Issue | Historique | Message |48 | Issue | Historique | Message |
49 |-------|------------|---------|49 |-------|------------|---------|
50 | Réponse obtenue | Deux messages ajoutés : utilisateur `[asked <nom> (<fournisseur>/<modèle>)[, with this conversation as context]: <prompt>]`, assistant `[<nom>'s answer]` + la réponse (plafonnée à `maxOutput` caractères, début et fin conservés). Enregistrés avec la session. | `🗣 <nom>'s answer is in the history for your next question` |50 | Réponse obtenue | Deux messages ajoutés : utilisateur `[asked <nom> (<fournisseur>/<modèle>)[, with this conversation as context]: <prompt>]`, assistant `[<nom>'s answer]` + la réponse (plafonnée à `maxOutput` caractères, début et fin conservés). Enregistrés avec la session. | `🗣 <nom>'s answer is in the history for your next question` |
51-| Annulée | Inchangé. | `🛑 Ask aborted — nothing kept.` |51+| Annulée | Inchangé. | `🔴 Ask aborted — nothing kept.` |
52 | Échec | Inchangé ; enregistré dans le fichier de session avec `where: "ask"`. | `[error: …]` (terminal, fenêtre) ou une erreur JSON-RPC (éditeur). |52 | Échec | Inchangé ; enregistré dans le fichier de session avec `where: "ask"`. | `[error: …]` (terminal, fenêtre) ou une erreur JSON-RPC (éditeur). |
53 53
54 La transcription envoyée par les formes avec contexte contient les questions de l'utilisateur, les réponses de l'assistant, une ligne par appel d'outil (`Assistant ran <outil>: <arguments>`, arguments coupés à 160 caractères) et le résumé de compaction s'il existe ; jamais le prompt système, les sorties d'outils ni le raisonnement. Plafonnée à `maxOutput` caractères, en gardant les lignes les plus récentes, `[… earlier conversation omitted …]` marquant la coupure.54 La transcription envoyée par les formes avec contexte contient les questions de l'utilisateur, les réponses de l'assistant, une ligne par appel d'outil (`Assistant ran <outil>: <arguments>`, arguments coupés à 160 caractères) et le résumé de compaction s'il existe ; jamais le prompt système, les sorties d'outils ni le raisonnement. Plafonnée à `maxOutput` caractères, en gardant les lignes les plus récentes, `[… earlier conversation omitted …]` marquant la coupure.
@@ -86,7 +86,7 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t
86 86
87 | Touche | À l'invite | Pendant une génération |87 | Touche | À l'invite | Pendant une génération |
88 |--------|------------|------------------------|88 |--------|------------|------------------------|
89-| `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |89+| `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🔴 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |
90 | `Ctrl+D` (fin d'entrée) | Quitte. | — |90 | `Ctrl+D` (fin d'entrée) | Quitte. | — |
91 91
92 ## Touches du mode fenêtre92 ## Touches du mode fenêtre
@@ -157,7 +157,7 @@ La sortie, dans l'ordre :
157 157
158 | Vous faites | La fenêtre fait |158 | Vous faites | La fenêtre fait |
159 |-------------|-----------------|159 |-------------|-----------------|
160-| `Échap` ou `/abort` | Annule le tour, et affiche `🛑 Aborting…`. |160+| `Échap` ou `/abort` | Annule le tour, et affiche `🔴 Aborting…`. |
161 | `Échap` ou `/abort` à nouveau | Le tour est toujours là : `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |161 | `Échap` ou `/abort` à nouveau | Le tour est toujours là : `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |
162 | `Ctrl-C` | Annule, comme ci-dessus. |162 | `Ctrl-C` | Annule, comme ci-dessus. |
163 | `Ctrl-C` à nouveau | Quitte `mm`, tour en cours ou non. |163 | `Ctrl-C` à nouveau | Quitte `mm`, tour en cours ou non. |
@@ -200,7 +200,7 @@ La notation marche aussi en mode ligne et en ACP, tapée à la main : un `$skill
200 200
201 | Message | Signification |201 | Message | Signification |
202 |---------|---------------|202 |---------|---------------|
203-| `🛑 Generation aborted.` | Le tour a été annulé (`/abort`, `Ctrl+C`). La question et les tours d'outils terminés restent dans l'historique. |203+| `🔴 Generation aborted.` | Le tour a été annulé (`/abort`, `Ctrl+C`). La question et les tours d'outils terminés restent dans l'historique. |
204 | `[error: …]` | Le fournisseur a expliqué un échec en une ligne ; une question restée sans réponse est retirée de l'historique. |204 | `[error: …]` | Le fournisseur a expliqué un échec en une ligne ; une question restée sans réponse est retirée de l'historique. |
205 | `⚙ N command(s)` | Nombre de commandes `bash` réellement exécutées dans le tour. |205 | `⚙ N command(s)` | Nombre de commandes `bash` réellement exécutées dans le tour. |
206 | `· 📝 N file op(s)` | Ajouté quand des outils de fichiers ont tourné. |206 | `· 📝 N file op(s)` | Ajouté quand des outils de fichiers ont tourné. |
modified guides/raccourcis-clavier-tui.md +1 -1
@@ -24,7 +24,7 @@ Deux endroits peuvent recevoir les touches : la **zone de saisie** en bas, où v
2424
2525 | Touche | Effet |
2626 |---|---|
27-| `Échap` | Arrête la réponse en cours (comme `/abort`) et affiche `🛑 Aborting…`. Appuyé à nouveau quand le tour ne s'arrête pas, il vous indique d'utiliser Ctrl-C. |
27+| `Échap` | Arrête la réponse en cours (comme `/abort`) et affiche `🔴 Aborting…`. Appuyé à nouveau quand le tour ne s'arrête pas, il vous indique d'utiliser Ctrl-C. |
2828 | `Ctrl-C` | Arrête la réponse en cours. Si elle a déjà reçu l'ordre de s'arrêter et ne l'a pas fait, `Ctrl-C` ferme mm. |
2929 | `Entrée` | Arrête la réponse en cours et envoie ce que vous avez tapé une fois qu'elle est arrêtée : vous pouvez changer d'avis sans attendre. |
3030
@@ -24,7 +24,7 @@ Deux endroits peuvent recevoir les touches : la **zone de saisie** en bas, où v
24 24
25 | Touche | Effet |25 | Touche | Effet |
26 |---|---|26 |---|---|
27-| `Échap` | Arrête la réponse en cours (comme `/abort`) et affiche `🛑 Aborting…`. Appuyé à nouveau quand le tour ne s'arrête pas, il vous indique d'utiliser Ctrl-C. |27+| `Échap` | Arrête la réponse en cours (comme `/abort`) et affiche `🔴 Aborting…`. Appuyé à nouveau quand le tour ne s'arrête pas, il vous indique d'utiliser Ctrl-C. |
28 | `Ctrl-C` | Arrête la réponse en cours. Si elle a déjà reçu l'ordre de s'arrêter et ne l'a pas fait, `Ctrl-C` ferme mm. |28 | `Ctrl-C` | Arrête la réponse en cours. Si elle a déjà reçu l'ordre de s'arrêter et ne l'a pas fait, `Ctrl-C` ferme mm. |
29 | `Entrée` | Arrête la réponse en cours et envoie ce que vous avez tapé une fois qu'elle est arrêtée : vous pouvez changer d'avis sans attendre. |29 | `Entrée` | Arrête la réponse en cours et envoie ce que vous avez tapé une fois qu'elle est arrêtée : vous pouvez changer d'avis sans attendre. |
30 30
modified guides/tui-keyboard-shortcuts.md +1 -1
@@ -24,7 +24,7 @@ The window has two places that can take the keys: the **box** at the bottom, whe
2424
2525 | Key | What it does |
2626 |---|---|
27-| `Esc` | Stops the answer in progress (same as `/abort`) and says `🛑 Aborting…`. Pressed again when the turn does not stop, it tells you to use Ctrl-C. |
27+| `Esc` | Stops the answer in progress (same as `/abort`) and says `🔴 Aborting…`. Pressed again when the turn does not stop, it tells you to use Ctrl-C. |
2828 | `Ctrl-C` | Stops the answer in progress. If it has already been asked to stop and has not, `Ctrl-C` closes mm. |
2929 | `Enter` | Stops the answer in progress and sends what you typed once it has stopped: you can change your mind without waiting. |
3030
@@ -24,7 +24,7 @@ The window has two places that can take the keys: the **box** at the bottom, whe
24 24
25 | Key | What it does |25 | Key | What it does |
26 |---|---|26 |---|---|
27-| `Esc` | Stops the answer in progress (same as `/abort`) and says `🛑 Aborting…`. Pressed again when the turn does not stop, it tells you to use Ctrl-C. |27+| `Esc` | Stops the answer in progress (same as `/abort`) and says `🔴 Aborting…`. Pressed again when the turn does not stop, it tells you to use Ctrl-C. |
28 | `Ctrl-C` | Stops the answer in progress. If it has already been asked to stop and has not, `Ctrl-C` closes mm. |28 | `Ctrl-C` | Stops the answer in progress. If it has already been asked to stop and has not, `Ctrl-C` closes mm. |
29 | `Enter` | Stops the answer in progress and sends what you typed once it has stopped: you can change your mind without waiting. |29 | `Enter` | Stops the answer in progress and sends what you typed once it has stopped: you can change your mind without waiting. |
30 30
modified internal/agent/agent.go +3 -3
@@ -252,7 +252,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
252252
253253 if res.err != nil {
254254 if genCtx.Err() == context.Canceled {
255- fmt.Println("\n🛑 Generation aborted.")
255+ fmt.Println("\n🔴 Generation aborted.")
256256 } else {
257257 // One line, in the provider's words: see engine.Explain —
258258 // and the same line in the session file, for whoever
@@ -317,7 +317,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
317317 // frame would erase it. Same rule as in dmr.emit.
318318 spinner.Stop()
319319 prompt.drop()
320- fmt.Println("\n🛑 Interrupted (Ctrl+C).")
320+ fmt.Println("\n🔴 Interrupted (Ctrl+C).")
321321 cancelGen()
322322 // We don't set generationRunning=false; we wait for resultCh to catch the Canceled error.
323323
@@ -339,7 +339,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
339339 prompt.drop()
340340 spinner.Stop() // le spinner tourne : on lui reprend la ligne
341341 cancelGen()
342- fmt.Println("🛑 Aborting...")
342+ fmt.Println("🔴 Aborting...")
343343 case command.Is(nextInput, command.Quit):
344344 prompt.drop()
345345 spinner.Stop()
@@ -252,7 +252,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
252 252
253 if res.err != nil {253 if res.err != nil {
254 if genCtx.Err() == context.Canceled {254 if genCtx.Err() == context.Canceled {
255- fmt.Println("\n🛑 Generation aborted.")255+ fmt.Println("\n🔴 Generation aborted.")
256 } else {256 } else {
257 // One line, in the provider's words: see engine.Explain —257 // One line, in the provider's words: see engine.Explain —
258 // and the same line in the session file, for whoever258 // and the same line in the session file, for whoever
@@ -317,7 +317,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
317 // frame would erase it. Same rule as in dmr.emit.317 // frame would erase it. Same rule as in dmr.emit.
318 spinner.Stop()318 spinner.Stop()
319 prompt.drop()319 prompt.drop()
320- fmt.Println("\n🛑 Interrupted (Ctrl+C).")320+ fmt.Println("\n🔴 Interrupted (Ctrl+C).")
321 cancelGen()321 cancelGen()
322 // We don't set generationRunning=false; we wait for resultCh to catch the Canceled error.322 // We don't set generationRunning=false; we wait for resultCh to catch the Canceled error.
323 323
@@ -339,7 +339,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
339 prompt.drop()339 prompt.drop()
340 spinner.Stop() // le spinner tourne : on lui reprend la ligne340 spinner.Stop() // le spinner tourne : on lui reprend la ligne
341 cancelGen()341 cancelGen()
342- fmt.Println("🛑 Aborting...")342+ fmt.Println("🔴 Aborting...")
343 case command.Is(nextInput, command.Quit):343 case command.Is(nextInput, command.Quit):
344 prompt.drop()344 prompt.drop()
345 spinner.Stop()345 spinner.Stop()
modified internal/agent/ask.go +3 -3
@@ -49,7 +49,7 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
4949 return a.settle(askCtx, req, messages, res.exchange, res.err), quit
5050 case <-a.sigCh:
5151 a.prompt.drop()
52- fmt.Println("\n🛑 Interrupted (Ctrl+C).")
52+ fmt.Println("\n🔴 Interrupted (Ctrl+C).")
5353 cancel()
5454 case req := <-a.prompt.requestCh():
5555 a.prompt.show(req)
@@ -64,7 +64,7 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
6464 switch {
6565 case command.Is(input, command.Abort):
6666 a.prompt.drop()
67- fmt.Println("🛑 Aborting…")
67+ fmt.Println("🔴 Aborting…")
6868 cancel()
6969 case command.Is(input, command.Quit):
7070 a.prompt.drop()
@@ -84,7 +84,7 @@ func (a asking) settle(askCtx context.Context, req command.AskRequest, messages,
8484 fmt.Println()
8585 switch {
8686 case err != nil && askCtx.Err() != nil:
87- fmt.Println("🛑 Ask aborted — nothing kept.")
87+ fmt.Println("🔴 Ask aborted — nothing kept.")
8888 return messages
8989 case err != nil:
9090 fmt.Println("[error: " + err.Error() + "]")
@@ -49,7 +49,7 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
49 return a.settle(askCtx, req, messages, res.exchange, res.err), quit49 return a.settle(askCtx, req, messages, res.exchange, res.err), quit
50 case <-a.sigCh:50 case <-a.sigCh:
51 a.prompt.drop()51 a.prompt.drop()
52- fmt.Println("\n🛑 Interrupted (Ctrl+C).")52+ fmt.Println("\n🔴 Interrupted (Ctrl+C).")
53 cancel()53 cancel()
54 case req := <-a.prompt.requestCh():54 case req := <-a.prompt.requestCh():
55 a.prompt.show(req)55 a.prompt.show(req)
@@ -64,7 +64,7 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
64 switch {64 switch {
65 case command.Is(input, command.Abort):65 case command.Is(input, command.Abort):
66 a.prompt.drop()66 a.prompt.drop()
67- fmt.Println("🛑 Aborting…")67+ fmt.Println("🔴 Aborting…")
68 cancel()68 cancel()
69 case command.Is(input, command.Quit):69 case command.Is(input, command.Quit):
70 a.prompt.drop()70 a.prompt.drop()
@@ -84,7 +84,7 @@ func (a asking) settle(askCtx context.Context, req command.AskRequest, messages,
84 fmt.Println()84 fmt.Println()
85 switch {85 switch {
86 case err != nil && askCtx.Err() != nil:86 case err != nil && askCtx.Err() != nil:
87- fmt.Println("🛑 Ask aborted — nothing kept.")87+ fmt.Println("🔴 Ask aborted — nothing kept.")
88 return messages88 return messages
89 case err != nil:89 case err != nil:
90 fmt.Println("[error: " + err.Error() + "]")90 fmt.Println("[error: " + err.Error() + "]")
added internal/agentinit/backups/presets.20261113-065906.add-smollm3.yaml +305 -0
new file mode 100644
@@ -0,0 +1,305 @@
1+# presets.yaml — what `mm init` knows about model families, usages and
2+# machines (ticket 97). ONE table, shared on purpose: `mm init` embeds it, and
3+# the Claude skill of ticket 111 reads this same file. Every value says where
4+# it comes from: a model card, or a measured demo of this repository.
5+#
6+# Families are matched against the lower-cased model id, in order: the first
7+# entry whose `match` words all appear wins, so a specific family (qwen +
8+# coder) comes before its general one (qwen). `other` matches anything.
9+#
10+# Sampling: `general` is the author's recommendation for normal use (thinking
11+# off); `thinking` is used when the agent reasons; `precise` is the
12+# temperature for tool-heavy, thinking-off agents (developer, docs, JSON…).
13+# When the model server tells the GGUF's own sampling (llmman, Ollama), it
14+# replaces `general` and `thinking` — the GGUF is the most specific source.
15+#
16+# `origin` says, for `general`, `thinking` and `precise`, how each was
17+# obtained: `measured` (a demo of this repository ran with it), `card` (copied
18+# from the model card, the GGUF or the API reference, not measured here) or
19+# `rule` (a generic rule of thumb, nothing specific to the family). The
20+# generated file tags its numbers with it (ticket 121): after a model or
21+# quantization change, a measured number is re-measured, a card number re-read.
22+
23+families:
24+ - name: qwen-coder
25+ match: [qwen, coder]
26+ reasoning: none
27+ general: {temperature: 1.0, top_p: 0.95, top_k: 40}
28+ precise: 0.2
29+ source: "demos/qwen-style (Qwen3-Coder-Next, measured: 1.0 / 0.95 / 40 from the GGUF)"
30+ origin: {general: measured, precise: rule}
31+
32+ - name: qwen
33+ match: [qwen]
34+ reasoning: enable_thinking
35+ general: {temperature: 0.7, top_p: 0.8, top_k: 20}
36+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
37+ precise: 0.0
38+ source: "Qwen model cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, thinking off (demos/docs-search, memory-search, plans)"
39+ origin: {general: card, thinking: card, precise: measured}
40+
41+ - name: gemma
42+ match: [gemma]
43+ reasoning: enable_thinking
44+ general: {temperature: 1.0, top_p: 0.95, top_k: 64}
45+ thinking: {temperature: 1.0, top_p: 0.95, top_k: 64}
46+ precise: 0.2
47+ source: "Gemma 4 GGUF sampling 1.0 / 0.95 / 64 (demos/gemini-style); precise 0.2 measured with gemma-4-E4B (demos/hooks, guards); enable_thinking is off by default in the template"
48+ origin: {general: card, thinking: card, precise: measured}
49+
50+ - name: mellum-thinking
51+ match: [mellum, thinking]
52+ reasoning: enable_thinking
53+ general: {temperature: 0.6, top_p: 0.95}
54+ thinking: {temperature: 0.6, top_p: 0.95}
55+ precise: 0.2
56+ source: "demos/coding-helpers (Mellum2 12B Thinking: 0.6 / 0.95, thinking on); demos/programming-answers (Mellum2.1: 0.2 / 0.9)"
57+ origin: {general: measured, thinking: measured, precise: measured}
58+
59+ - name: mellum
60+ match: [mellum]
61+ reasoning: none
62+ general: {temperature: 0.2, top_p: 0.9}
63+ precise: 0.0
64+ source: "demos/little-coder (Mellum2 12B Instruct, measured 0.2 / 0.9); agent.dmr.yaml (0.0 / 0.9)"
65+ origin: {general: measured, precise: rule}
66+
67+ - name: gpt-oss
68+ match: [gpt-oss]
69+ reasoning: reasoning_effort
70+ effortValues: [low, medium, high]
71+ general: {temperature: 1.0, top_p: 1.0}
72+ thinking: {temperature: 1.0, top_p: 1.0}
73+ precise: 1.0
74+ source: "demos/codex-style (gpt-oss 20B: 1.0 / 1.0, harmony format, reasoning_effort low | medium | high, default medium) — the model always reasons: never a low temperature"
75+ origin: {general: measured, thinking: measured, precise: measured}
76+
77+ - name: openai
78+ match: [gpt-]
79+ reasoning: none
80+ maxTokensKey: max_completion_tokens
81+ general: {temperature: 1.0}
82+ precise: 1.0
83+ note: "a hosted OpenAI model: its reasoning models (gpt-5, the o-series) accept only the default temperature (1.0) and take max_completion_tokens instead of max_tokens. With provider openai, mm sends this sampling block as written and adds none of its defaults — remove a key the API still refuses"
84+ source: "OpenAI API reference; not measured in this repository"
85+ origin: {general: card, precise: card}
86+
87+ - name: minicpm
88+ match: [minicpm]
89+ reasoning: enable_thinking
90+ general: {temperature: 0.3, top_p: 0.95}
91+ thinking: {temperature: 0.6, top_p: 0.95}
92+ precise: 0.3
93+ source: "demos/coding-helpers (MiniCPM5 2B: 0.3 / 0.95, enable_thinking false — its template has no default: always set it)"
94+ origin: {general: measured, thinking: rule, precise: measured}
95+
96+ - name: ornith
97+ match: [ornith]
98+ reasoning: enable_thinking
99+ general: {temperature: 0.3, top_p: 0.8, top_k: 20}
100+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
101+ precise: 0.3
102+ source: "demos/team (Ornith 1.5 9B: 0.3–0.4 / 0.8 / 20 thinking off, 0.6 / 0.95 / 20 thinking on)"
103+ origin: {general: measured, thinking: measured, precise: measured}
104+
105+ - name: granite-guardian
106+ match: [granite, guardian]
107+ reasoning: none
108+ general: {temperature: 0.0}
109+ precise: 0.0
110+ note: "a guard model: it answers Yes / No about a risk named in the system message, it is not a general agent — see demos/guard"
111+ source: "demos/guard (Granite Guardian 3.2 3B-A800M: temperature 0.0)"
112+ origin: {general: measured, precise: measured}
113+
114+ - name: menlo
115+ match: [menlo]
116+ reasoning: none
117+ general: {temperature: 0.0, top_p: 0.9}
118+ precise: 0.0
119+ source: "demos/llmman (Jan-nano, Lucy: 0.0 / 0.9)"
120+ origin: {general: measured, precise: measured}
121+
122+ - name: llama
123+ match: [llama]
124+ reasoning: none
125+ general: {temperature: 0.6, top_p: 0.9}
126+ precise: 0.2
127+ source: "Llama 3.1 8B Instruct generation_config.json (0.6 / 0.9); no measured demo in this repository"
128+ origin: {general: card, precise: rule}
129+
130+ - name: mistral
131+ match: [mistral]
132+ reasoning: none
133+ general: {temperature: 0.15}
134+ precise: 0.15
135+ source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
136+ origin: {general: card, precise: card}
137+
138+ - name: other
139+ match: []
140+ reasoning: none
141+ general: {temperature: 0.7, top_p: 0.9}
142+ precise: 0.2
143+ source: "skills/create-agent rule when nothing is published: 0.7 / 0.9, 0.2 for a code agent without reasoning"
144+ origin: {general: rule, precise: rule}
145+
146+# Usages: what the agent is for. `tools` are the YAML switches; `thinking`
147+# asks for reasoning when the family has a switch; `precise` picks the
148+# family's precise temperature (when not thinking); `docs` turns on the
149+# documentation search; `json` writes a structuredOutput example (which
150+# forbids every tool).
151+usages:
152+ - name: developer
153+ description: "a coding agent: reads and changes the project, runs commands"
154+ tools: {bashTool: true, editTools: true, planTool: true, askUserTool: true}
155+ thinking: true
156+ precise: true
157+ maxTurns: 40
158+ yolo: false
159+ system: |
160+ You are a coding agent working in a terminal, in the user's project.
161+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
162+ plan tool.
163+ Read before you change: look at the files and run the commands that tell
164+ you how the project works. Make the smallest change that does the job,
165+ then run the tests or the build to check it.
166+ Never state the output of a command you did not run in this answer.
167+ When a task has several steps, make a plan and update it as you go.
168+
169+ - name: reviewer
170+ description: "reviews code and changes, suggests fixes, changes nothing unasked"
171+ tools: {bashTool: true, editTools: true, planTool: false, askUserTool: true}
172+ thinking: true
173+ precise: true
174+ maxTurns: 30
175+ yolo: false
176+ system: |
177+ You are a code reviewer working in a terminal, in the user's project.
178+ Read the code and the changes (git diff, git log) with your tools, and
179+ report: bugs first, then risks, then style — each with the file and line.
180+ Do not change any file unless the user asks you to.
181+ Never state the output of a command you did not run in this answer.
182+
183+ - name: translator
184+ description: "translates texts between languages, no tools"
185+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
186+ thinking: false
187+ precise: true
188+ maxTurns: 3
189+ yolo: true
190+ system: |
191+ You are a translator. Translate the text the user gives you into the
192+ language they ask for (English ↔ French when they do not say).
193+ Keep the meaning, the tone and the formatting (Markdown, code blocks,
194+ lists). Do not translate code, file names or product names.
195+ Answer with the translation only, no comment.
196+
197+ - name: writer
198+ description: "writes documents and saves them to files"
199+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: true, saveAnswerTool: true}
200+ thinking: false
201+ precise: false
202+ maxTurns: 10
203+ yolo: false
204+ system: |
205+ You are a technical writer. Write clear, well-structured documents in
206+ Markdown: short paragraphs, headings, examples.
207+ When the user asks you to save a document you just wrote, call
208+ save_answer with the file name; to change an existing file, read it
209+ first, then edit it.
210+
211+ - name: assistant
212+ description: "answers questions, no tools"
213+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true}
214+ thinking: false
215+ precise: false
216+ maxTurns: 5
217+ yolo: true
218+ system: |
219+ You are a helpful assistant. Answer clearly and briefly, in the user's
220+ language. Say when you do not know; never invent facts, names or
221+ figures.
222+
223+ - name: researcher
224+ description: "reads web pages and summarises them"
225+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true, fetchContentTool: true, saveAnswerTool: true}
226+ thinking: false
227+ precise: false
228+ maxTurns: 15
229+ yolo: false
230+ system: |
231+ You are a research assistant. To answer, read the web pages the user
232+ names with fetch_content, then summarise what they say, citing each
233+ page's address. Never state what a page says without having read it in
234+ this answer.
235+
236+ - name: docs
237+ description: "answers from a documentation folder (docs/), with links to the passages"
238+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: false}
239+ docs: true
240+ thinking: false
241+ precise: true
242+ maxTurns: 8
243+ yolo: true
244+ system: |
245+ You answer questions about the project from its documentation, in the
246+ folder docs/. For every question the documentation may cover, call
247+ search_docs first, with the words the documentation is likely to use
248+ (often English, names of commands and types). Answer from the passages
249+ it returns, and end with the link of the passage you used (path:line).
250+ If nothing is found, try other words once, then say the documentation
251+ does not cover it.
252+
253+ - name: json
254+ description: "answers in JSON shaped by Go types (structuredOutput), no tools"
255+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
256+ json: true
257+ thinking: false
258+ precise: true
259+ maxTurns: 3
260+ yolo: true
261+ system: |
262+ You extract facts from the user's text and answer in the JSON shape you
263+ are given. Fill every field from the text; use null when the text does
264+ not say.
265+
266+ - name: coordinator
267+ description: "hands each question to other mm agents (mmConfigs) and relays their answers"
268+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
269+ coordinator: true
270+ thinking: false
271+ precise: true
272+ maxTurns: 4
273+ yolo: true
274+ system: |
275+ You are a coordinator. You do not answer the questions yourself: for
276+ each question, call ask_agent with the agent whose description fits,
277+ then give its answer to the user, in the user's language.
278+ For a greeting or a question about you, answer in one short sentence,
279+ without any tool.
280+
281+# Machines: from guides/agent-settings-for-small-models.md, "Summary:
282+# typical profiles". With bash on, watchdogTimeout is aligned on bashTimeout
283+# (section 3 of the guide).
284+machines:
285+ - name: small
286+ description: "a laptop, models up to about 8B"
287+ firstTokenTimeout: 3m
288+ watchdogTimeout: 20s
289+ bashTimeout: 30s
290+ maxOutput: 8000
291+ source: "settings guide, row \"Small model (≤ 8B), short tasks\""
292+ - name: medium
293+ description: "a workstation, models up to about 30B"
294+ firstTokenTimeout: 5m
295+ watchdogTimeout: 2m
296+ bashTimeout: 2m
297+ maxOutput: 16000
298+ source: "settings guide, row \"Small/medium model, general coding agent\""
299+ - name: large
300+ description: "a big model, or a modest machine running on the CPU"
301+ firstTokenTimeout: 10m
302+ watchdogTimeout: 5m
303+ bashTimeout: 5m
304+ maxOutput: 24000
305+ source: "settings guide, row \"Larger model or modest machine (CPU)\""
new file mode 100644
@@ -0,0 +1,305 @@
1+# presets.yaml — what `mm init` knows about model families, usages and
2+# machines (ticket 97). ONE table, shared on purpose: `mm init` embeds it, and
3+# the Claude skill of ticket 111 reads this same file. Every value says where
4+# it comes from: a model card, or a measured demo of this repository.
5+#
6+# Families are matched against the lower-cased model id, in order: the first
7+# entry whose `match` words all appear wins, so a specific family (qwen +
8+# coder) comes before its general one (qwen). `other` matches anything.
9+#
10+# Sampling: `general` is the author's recommendation for normal use (thinking
11+# off); `thinking` is used when the agent reasons; `precise` is the
12+# temperature for tool-heavy, thinking-off agents (developer, docs, JSON…).
13+# When the model server tells the GGUF's own sampling (llmman, Ollama), it
14+# replaces `general` and `thinking` — the GGUF is the most specific source.
15+#
16+# `origin` says, for `general`, `thinking` and `precise`, how each was
17+# obtained: `measured` (a demo of this repository ran with it), `card` (copied
18+# from the model card, the GGUF or the API reference, not measured here) or
19+# `rule` (a generic rule of thumb, nothing specific to the family). The
20+# generated file tags its numbers with it (ticket 121): after a model or
21+# quantization change, a measured number is re-measured, a card number re-read.
22+
23+families:
24+ - name: qwen-coder
25+ match: [qwen, coder]
26+ reasoning: none
27+ general: {temperature: 1.0, top_p: 0.95, top_k: 40}
28+ precise: 0.2
29+ source: "demos/qwen-style (Qwen3-Coder-Next, measured: 1.0 / 0.95 / 40 from the GGUF)"
30+ origin: {general: measured, precise: rule}
31+
32+ - name: qwen
33+ match: [qwen]
34+ reasoning: enable_thinking
35+ general: {temperature: 0.7, top_p: 0.8, top_k: 20}
36+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
37+ precise: 0.0
38+ source: "Qwen model cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, thinking off (demos/docs-search, memory-search, plans)"
39+ origin: {general: card, thinking: card, precise: measured}
40+
41+ - name: gemma
42+ match: [gemma]
43+ reasoning: enable_thinking
44+ general: {temperature: 1.0, top_p: 0.95, top_k: 64}
45+ thinking: {temperature: 1.0, top_p: 0.95, top_k: 64}
46+ precise: 0.2
47+ source: "Gemma 4 GGUF sampling 1.0 / 0.95 / 64 (demos/gemini-style); precise 0.2 measured with gemma-4-E4B (demos/hooks, guards); enable_thinking is off by default in the template"
48+ origin: {general: card, thinking: card, precise: measured}
49+
50+ - name: mellum-thinking
51+ match: [mellum, thinking]
52+ reasoning: enable_thinking
53+ general: {temperature: 0.6, top_p: 0.95}
54+ thinking: {temperature: 0.6, top_p: 0.95}
55+ precise: 0.2
56+ source: "demos/coding-helpers (Mellum2 12B Thinking: 0.6 / 0.95, thinking on); demos/programming-answers (Mellum2.1: 0.2 / 0.9)"
57+ origin: {general: measured, thinking: measured, precise: measured}
58+
59+ - name: mellum
60+ match: [mellum]
61+ reasoning: none
62+ general: {temperature: 0.2, top_p: 0.9}
63+ precise: 0.0
64+ source: "demos/little-coder (Mellum2 12B Instruct, measured 0.2 / 0.9); agent.dmr.yaml (0.0 / 0.9)"
65+ origin: {general: measured, precise: rule}
66+
67+ - name: gpt-oss
68+ match: [gpt-oss]
69+ reasoning: reasoning_effort
70+ effortValues: [low, medium, high]
71+ general: {temperature: 1.0, top_p: 1.0}
72+ thinking: {temperature: 1.0, top_p: 1.0}
73+ precise: 1.0
74+ source: "demos/codex-style (gpt-oss 20B: 1.0 / 1.0, harmony format, reasoning_effort low | medium | high, default medium) — the model always reasons: never a low temperature"
75+ origin: {general: measured, thinking: measured, precise: measured}
76+
77+ - name: openai
78+ match: [gpt-]
79+ reasoning: none
80+ maxTokensKey: max_completion_tokens
81+ general: {temperature: 1.0}
82+ precise: 1.0
83+ note: "a hosted OpenAI model: its reasoning models (gpt-5, the o-series) accept only the default temperature (1.0) and take max_completion_tokens instead of max_tokens. With provider openai, mm sends this sampling block as written and adds none of its defaults — remove a key the API still refuses"
84+ source: "OpenAI API reference; not measured in this repository"
85+ origin: {general: card, precise: card}
86+
87+ - name: minicpm
88+ match: [minicpm]
89+ reasoning: enable_thinking
90+ general: {temperature: 0.3, top_p: 0.95}
91+ thinking: {temperature: 0.6, top_p: 0.95}
92+ precise: 0.3
93+ source: "demos/coding-helpers (MiniCPM5 2B: 0.3 / 0.95, enable_thinking false — its template has no default: always set it)"
94+ origin: {general: measured, thinking: rule, precise: measured}
95+
96+ - name: ornith
97+ match: [ornith]
98+ reasoning: enable_thinking
99+ general: {temperature: 0.3, top_p: 0.8, top_k: 20}
100+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
101+ precise: 0.3
102+ source: "demos/team (Ornith 1.5 9B: 0.3–0.4 / 0.8 / 20 thinking off, 0.6 / 0.95 / 20 thinking on)"
103+ origin: {general: measured, thinking: measured, precise: measured}
104+
105+ - name: granite-guardian
106+ match: [granite, guardian]
107+ reasoning: none
108+ general: {temperature: 0.0}
109+ precise: 0.0
110+ note: "a guard model: it answers Yes / No about a risk named in the system message, it is not a general agent — see demos/guard"
111+ source: "demos/guard (Granite Guardian 3.2 3B-A800M: temperature 0.0)"
112+ origin: {general: measured, precise: measured}
113+
114+ - name: menlo
115+ match: [menlo]
116+ reasoning: none
117+ general: {temperature: 0.0, top_p: 0.9}
118+ precise: 0.0
119+ source: "demos/llmman (Jan-nano, Lucy: 0.0 / 0.9)"
120+ origin: {general: measured, precise: measured}
121+
122+ - name: llama
123+ match: [llama]
124+ reasoning: none
125+ general: {temperature: 0.6, top_p: 0.9}
126+ precise: 0.2
127+ source: "Llama 3.1 8B Instruct generation_config.json (0.6 / 0.9); no measured demo in this repository"
128+ origin: {general: card, precise: rule}
129+
130+ - name: mistral
131+ match: [mistral]
132+ reasoning: none
133+ general: {temperature: 0.15}
134+ precise: 0.15
135+ source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
136+ origin: {general: card, precise: card}
137+
138+ - name: other
139+ match: []
140+ reasoning: none
141+ general: {temperature: 0.7, top_p: 0.9}
142+ precise: 0.2
143+ source: "skills/create-agent rule when nothing is published: 0.7 / 0.9, 0.2 for a code agent without reasoning"
144+ origin: {general: rule, precise: rule}
145+
146+# Usages: what the agent is for. `tools` are the YAML switches; `thinking`
147+# asks for reasoning when the family has a switch; `precise` picks the
148+# family's precise temperature (when not thinking); `docs` turns on the
149+# documentation search; `json` writes a structuredOutput example (which
150+# forbids every tool).
151+usages:
152+ - name: developer
153+ description: "a coding agent: reads and changes the project, runs commands"
154+ tools: {bashTool: true, editTools: true, planTool: true, askUserTool: true}
155+ thinking: true
156+ precise: true
157+ maxTurns: 40
158+ yolo: false
159+ system: |
160+ You are a coding agent working in a terminal, in the user's project.
161+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
162+ plan tool.
163+ Read before you change: look at the files and run the commands that tell
164+ you how the project works. Make the smallest change that does the job,
165+ then run the tests or the build to check it.
166+ Never state the output of a command you did not run in this answer.
167+ When a task has several steps, make a plan and update it as you go.
168+
169+ - name: reviewer
170+ description: "reviews code and changes, suggests fixes, changes nothing unasked"
171+ tools: {bashTool: true, editTools: true, planTool: false, askUserTool: true}
172+ thinking: true
173+ precise: true
174+ maxTurns: 30
175+ yolo: false
176+ system: |
177+ You are a code reviewer working in a terminal, in the user's project.
178+ Read the code and the changes (git diff, git log) with your tools, and
179+ report: bugs first, then risks, then style — each with the file and line.
180+ Do not change any file unless the user asks you to.
181+ Never state the output of a command you did not run in this answer.
182+
183+ - name: translator
184+ description: "translates texts between languages, no tools"
185+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
186+ thinking: false
187+ precise: true
188+ maxTurns: 3
189+ yolo: true
190+ system: |
191+ You are a translator. Translate the text the user gives you into the
192+ language they ask for (English ↔ French when they do not say).
193+ Keep the meaning, the tone and the formatting (Markdown, code blocks,
194+ lists). Do not translate code, file names or product names.
195+ Answer with the translation only, no comment.
196+
197+ - name: writer
198+ description: "writes documents and saves them to files"
199+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: true, saveAnswerTool: true}
200+ thinking: false
201+ precise: false
202+ maxTurns: 10
203+ yolo: false
204+ system: |
205+ You are a technical writer. Write clear, well-structured documents in
206+ Markdown: short paragraphs, headings, examples.
207+ When the user asks you to save a document you just wrote, call
208+ save_answer with the file name; to change an existing file, read it
209+ first, then edit it.
210+
211+ - name: assistant
212+ description: "answers questions, no tools"
213+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true}
214+ thinking: false
215+ precise: false
216+ maxTurns: 5
217+ yolo: true
218+ system: |
219+ You are a helpful assistant. Answer clearly and briefly, in the user's
220+ language. Say when you do not know; never invent facts, names or
221+ figures.
222+
223+ - name: researcher
224+ description: "reads web pages and summarises them"
225+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true, fetchContentTool: true, saveAnswerTool: true}
226+ thinking: false
227+ precise: false
228+ maxTurns: 15
229+ yolo: false
230+ system: |
231+ You are a research assistant. To answer, read the web pages the user
232+ names with fetch_content, then summarise what they say, citing each
233+ page's address. Never state what a page says without having read it in
234+ this answer.
235+
236+ - name: docs
237+ description: "answers from a documentation folder (docs/), with links to the passages"
238+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: false}
239+ docs: true
240+ thinking: false
241+ precise: true
242+ maxTurns: 8
243+ yolo: true
244+ system: |
245+ You answer questions about the project from its documentation, in the
246+ folder docs/. For every question the documentation may cover, call
247+ search_docs first, with the words the documentation is likely to use
248+ (often English, names of commands and types). Answer from the passages
249+ it returns, and end with the link of the passage you used (path:line).
250+ If nothing is found, try other words once, then say the documentation
251+ does not cover it.
252+
253+ - name: json
254+ description: "answers in JSON shaped by Go types (structuredOutput), no tools"
255+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
256+ json: true
257+ thinking: false
258+ precise: true
259+ maxTurns: 3
260+ yolo: true
261+ system: |
262+ You extract facts from the user's text and answer in the JSON shape you
263+ are given. Fill every field from the text; use null when the text does
264+ not say.
265+
266+ - name: coordinator
267+ description: "hands each question to other mm agents (mmConfigs) and relays their answers"
268+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
269+ coordinator: true
270+ thinking: false
271+ precise: true
272+ maxTurns: 4
273+ yolo: true
274+ system: |
275+ You are a coordinator. You do not answer the questions yourself: for
276+ each question, call ask_agent with the agent whose description fits,
277+ then give its answer to the user, in the user's language.
278+ For a greeting or a question about you, answer in one short sentence,
279+ without any tool.
280+
281+# Machines: from guides/agent-settings-for-small-models.md, "Summary:
282+# typical profiles". With bash on, watchdogTimeout is aligned on bashTimeout
283+# (section 3 of the guide).
284+machines:
285+ - name: small
286+ description: "a laptop, models up to about 8B"
287+ firstTokenTimeout: 3m
288+ watchdogTimeout: 20s
289+ bashTimeout: 30s
290+ maxOutput: 8000
291+ source: "settings guide, row \"Small model (≤ 8B), short tasks\""
292+ - name: medium
293+ description: "a workstation, models up to about 30B"
294+ firstTokenTimeout: 5m
295+ watchdogTimeout: 2m
296+ bashTimeout: 2m
297+ maxOutput: 16000
298+ source: "settings guide, row \"Small/medium model, general coding agent\""
299+ - name: large
300+ description: "a big model, or a modest machine running on the CPU"
301+ firstTokenTimeout: 10m
302+ watchdogTimeout: 5m
303+ bashTimeout: 5m
304+ maxOutput: 24000
305+ source: "settings guide, row \"Larger model or modest machine (CPU)\""
added internal/agentinit/backups/presets.20261113-072654.smollm3-toolformat.yaml +315 -0
new file mode 100644
@@ -0,0 +1,315 @@
1+# presets.yaml — what `mm init` knows about model families, usages and
2+# machines (ticket 97). ONE table, shared on purpose: `mm init` embeds it, and
3+# the Claude skill of ticket 111 reads this same file. Every value says where
4+# it comes from: a model card, or a measured demo of this repository.
5+#
6+# Families are matched against the lower-cased model id, in order: the first
7+# entry whose `match` words all appear wins, so a specific family (qwen +
8+# coder) comes before its general one (qwen). `other` matches anything.
9+#
10+# Sampling: `general` is the author's recommendation for normal use (thinking
11+# off); `thinking` is used when the agent reasons; `precise` is the
12+# temperature for tool-heavy, thinking-off agents (developer, docs, JSON…).
13+# When the model server tells the GGUF's own sampling (llmman, Ollama), it
14+# replaces `general` and `thinking` — the GGUF is the most specific source.
15+#
16+# `origin` says, for `general`, `thinking` and `precise`, how each was
17+# obtained: `measured` (a demo of this repository ran with it), `card` (copied
18+# from the model card, the GGUF or the API reference, not measured here) or
19+# `rule` (a generic rule of thumb, nothing specific to the family). The
20+# generated file tags its numbers with it (ticket 121): after a model or
21+# quantization change, a measured number is re-measured, a card number re-read.
22+
23+families:
24+ - name: qwen-coder
25+ match: [qwen, coder]
26+ reasoning: none
27+ general: {temperature: 1.0, top_p: 0.95, top_k: 40}
28+ precise: 0.2
29+ source: "demos/qwen-style (Qwen3-Coder-Next, measured: 1.0 / 0.95 / 40 from the GGUF)"
30+ origin: {general: measured, precise: rule}
31+
32+ - name: qwen
33+ match: [qwen]
34+ reasoning: enable_thinking
35+ general: {temperature: 0.7, top_p: 0.8, top_k: 20}
36+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
37+ precise: 0.0
38+ source: "Qwen model cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, thinking off (demos/docs-search, memory-search, plans)"
39+ origin: {general: card, thinking: card, precise: measured}
40+
41+ - name: gemma
42+ match: [gemma]
43+ reasoning: enable_thinking
44+ general: {temperature: 1.0, top_p: 0.95, top_k: 64}
45+ thinking: {temperature: 1.0, top_p: 0.95, top_k: 64}
46+ precise: 0.2
47+ source: "Gemma 4 GGUF sampling 1.0 / 0.95 / 64 (demos/gemini-style); precise 0.2 measured with gemma-4-E4B (demos/hooks, guards); enable_thinking is off by default in the template"
48+ origin: {general: card, thinking: card, precise: measured}
49+
50+ - name: mellum-thinking
51+ match: [mellum, thinking]
52+ reasoning: enable_thinking
53+ general: {temperature: 0.6, top_p: 0.95}
54+ thinking: {temperature: 0.6, top_p: 0.95}
55+ precise: 0.2
56+ source: "demos/coding-helpers (Mellum2 12B Thinking: 0.6 / 0.95, thinking on); demos/programming-answers (Mellum2.1: 0.2 / 0.9)"
57+ origin: {general: measured, thinking: measured, precise: measured}
58+
59+ - name: mellum
60+ match: [mellum]
61+ reasoning: none
62+ general: {temperature: 0.2, top_p: 0.9}
63+ precise: 0.0
64+ source: "demos/little-coder (Mellum2 12B Instruct, measured 0.2 / 0.9); agent.dmr.yaml (0.0 / 0.9)"
65+ origin: {general: measured, precise: rule}
66+
67+ - name: gpt-oss
68+ match: [gpt-oss]
69+ reasoning: reasoning_effort
70+ effortValues: [low, medium, high]
71+ general: {temperature: 1.0, top_p: 1.0}
72+ thinking: {temperature: 1.0, top_p: 1.0}
73+ precise: 1.0
74+ source: "demos/codex-style (gpt-oss 20B: 1.0 / 1.0, harmony format, reasoning_effort low | medium | high, default medium) — the model always reasons: never a low temperature"
75+ origin: {general: measured, thinking: measured, precise: measured}
76+
77+ - name: openai
78+ match: [gpt-]
79+ reasoning: none
80+ maxTokensKey: max_completion_tokens
81+ general: {temperature: 1.0}
82+ precise: 1.0
83+ note: "a hosted OpenAI model: its reasoning models (gpt-5, the o-series) accept only the default temperature (1.0) and take max_completion_tokens instead of max_tokens. With provider openai, mm sends this sampling block as written and adds none of its defaults — remove a key the API still refuses"
84+ source: "OpenAI API reference; not measured in this repository"
85+ origin: {general: card, precise: card}
86+
87+ - name: minicpm
88+ match: [minicpm]
89+ reasoning: enable_thinking
90+ general: {temperature: 0.3, top_p: 0.95}
91+ thinking: {temperature: 0.6, top_p: 0.95}
92+ precise: 0.3
93+ source: "demos/coding-helpers (MiniCPM5 2B: 0.3 / 0.95, enable_thinking false — its template has no default: always set it)"
94+ origin: {general: measured, thinking: rule, precise: measured}
95+
96+ - name: ornith
97+ match: [ornith]
98+ reasoning: enable_thinking
99+ general: {temperature: 0.3, top_p: 0.8, top_k: 20}
100+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
101+ precise: 0.3
102+ source: "demos/team (Ornith 1.5 9B: 0.3–0.4 / 0.8 / 20 thinking off, 0.6 / 0.95 / 20 thinking on)"
103+ origin: {general: measured, thinking: measured, precise: measured}
104+
105+ - name: granite-guardian
106+ match: [granite, guardian]
107+ reasoning: none
108+ general: {temperature: 0.0}
109+ precise: 0.0
110+ note: "a guard model: it answers Yes / No about a risk named in the system message, it is not a general agent — see demos/guard"
111+ source: "demos/guard (Granite Guardian 3.2 3B-A800M: temperature 0.0)"
112+ origin: {general: measured, precise: measured}
113+
114+ - name: menlo
115+ match: [menlo]
116+ reasoning: none
117+ general: {temperature: 0.0, top_p: 0.9}
118+ precise: 0.0
119+ source: "demos/llmman (Jan-nano, Lucy: 0.0 / 0.9)"
120+ origin: {general: measured, precise: measured}
121+
122+ - name: llama
123+ match: [llama]
124+ reasoning: none
125+ general: {temperature: 0.6, top_p: 0.9}
126+ precise: 0.2
127+ source: "Llama 3.1 8B Instruct generation_config.json (0.6 / 0.9); no measured demo in this repository"
128+ origin: {general: card, precise: rule}
129+
130+ - name: mistral
131+ match: [mistral]
132+ reasoning: none
133+ general: {temperature: 0.15}
134+ precise: 0.15
135+ source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
136+ origin: {general: card, precise: card}
137+
138+ - name: smollm3
139+ match: [smollm3]
140+ reasoning: enable_thinking
141+ general: {temperature: 0.6, top_p: 0.95}
142+ thinking: {temperature: 0.6, top_p: 0.95}
143+ precise: 0.2
144+ note: "unable to call mm's tools through llmman or llama-server: its chat template reads xml_tools / python_tools, never the standard tools list (measured 2026-11-13 on SmolLM3-3B Q4_K_M: no tool call, with or without reasoning; given xml_tools it writes <tool_call> as plain text, which the server does not turn into a call). Choose a usage without tools — translator or json"
145+ source: "SmolLM3-3B model card (\"We recommend setting temperature=0.6 and top_p=0.95 in the sampling parameters\"; generation_config.json: temperature 0.6, top_p 0.95) — one recommendation, used for both modes; reasoning on by default in the template (enable_thinking defaults to true; a /think or /no_think flag in the system prompt overrides the kwarg); precise: rule"
146+ origin: {general: card, thinking: card, precise: rule}
147+
148+ - name: other
149+ match: []
150+ reasoning: none
151+ general: {temperature: 0.7, top_p: 0.9}
152+ precise: 0.2
153+ source: "skills/create-agent rule when nothing is published: 0.7 / 0.9, 0.2 for a code agent without reasoning"
154+ origin: {general: rule, precise: rule}
155+
156+# Usages: what the agent is for. `tools` are the YAML switches; `thinking`
157+# asks for reasoning when the family has a switch; `precise` picks the
158+# family's precise temperature (when not thinking); `docs` turns on the
159+# documentation search; `json` writes a structuredOutput example (which
160+# forbids every tool).
161+usages:
162+ - name: developer
163+ description: "a coding agent: reads and changes the project, runs commands"
164+ tools: {bashTool: true, editTools: true, planTool: true, askUserTool: true}
165+ thinking: true
166+ precise: true
167+ maxTurns: 40
168+ yolo: false
169+ system: |
170+ You are a coding agent working in a terminal, in the user's project.
171+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
172+ plan tool.
173+ Read before you change: look at the files and run the commands that tell
174+ you how the project works. Make the smallest change that does the job,
175+ then run the tests or the build to check it.
176+ Never state the output of a command you did not run in this answer.
177+ When a task has several steps, make a plan and update it as you go.
178+
179+ - name: reviewer
180+ description: "reviews code and changes, suggests fixes, changes nothing unasked"
181+ tools: {bashTool: true, editTools: true, planTool: false, askUserTool: true}
182+ thinking: true
183+ precise: true
184+ maxTurns: 30
185+ yolo: false
186+ system: |
187+ You are a code reviewer working in a terminal, in the user's project.
188+ Read the code and the changes (git diff, git log) with your tools, and
189+ report: bugs first, then risks, then style — each with the file and line.
190+ Do not change any file unless the user asks you to.
191+ Never state the output of a command you did not run in this answer.
192+
193+ - name: translator
194+ description: "translates texts between languages, no tools"
195+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
196+ thinking: false
197+ precise: true
198+ maxTurns: 3
199+ yolo: true
200+ system: |
201+ You are a translator. Translate the text the user gives you into the
202+ language they ask for (English ↔ French when they do not say).
203+ Keep the meaning, the tone and the formatting (Markdown, code blocks,
204+ lists). Do not translate code, file names or product names.
205+ Answer with the translation only, no comment.
206+
207+ - name: writer
208+ description: "writes documents and saves them to files"
209+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: true, saveAnswerTool: true}
210+ thinking: false
211+ precise: false
212+ maxTurns: 10
213+ yolo: false
214+ system: |
215+ You are a technical writer. Write clear, well-structured documents in
216+ Markdown: short paragraphs, headings, examples.
217+ When the user asks you to save a document you just wrote, call
218+ save_answer with the file name; to change an existing file, read it
219+ first, then edit it.
220+
221+ - name: assistant
222+ description: "answers questions, no tools"
223+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true}
224+ thinking: false
225+ precise: false
226+ maxTurns: 5
227+ yolo: true
228+ system: |
229+ You are a helpful assistant. Answer clearly and briefly, in the user's
230+ language. Say when you do not know; never invent facts, names or
231+ figures.
232+
233+ - name: researcher
234+ description: "reads web pages and summarises them"
235+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true, fetchContentTool: true, saveAnswerTool: true}
236+ thinking: false
237+ precise: false
238+ maxTurns: 15
239+ yolo: false
240+ system: |
241+ You are a research assistant. To answer, read the web pages the user
242+ names with fetch_content, then summarise what they say, citing each
243+ page's address. Never state what a page says without having read it in
244+ this answer.
245+
246+ - name: docs
247+ description: "answers from a documentation folder (docs/), with links to the passages"
248+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: false}
249+ docs: true
250+ thinking: false
251+ precise: true
252+ maxTurns: 8
253+ yolo: true
254+ system: |
255+ You answer questions about the project from its documentation, in the
256+ folder docs/. For every question the documentation may cover, call
257+ search_docs first, with the words the documentation is likely to use
258+ (often English, names of commands and types). Answer from the passages
259+ it returns, and end with the link of the passage you used (path:line).
260+ If nothing is found, try other words once, then say the documentation
261+ does not cover it.
262+
263+ - name: json
264+ description: "answers in JSON shaped by Go types (structuredOutput), no tools"
265+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
266+ json: true
267+ thinking: false
268+ precise: true
269+ maxTurns: 3
270+ yolo: true
271+ system: |
272+ You extract facts from the user's text and answer in the JSON shape you
273+ are given. Fill every field from the text; use null when the text does
274+ not say.
275+
276+ - name: coordinator
277+ description: "hands each question to other mm agents (mmConfigs) and relays their answers"
278+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
279+ coordinator: true
280+ thinking: false
281+ precise: true
282+ maxTurns: 4
283+ yolo: true
284+ system: |
285+ You are a coordinator. You do not answer the questions yourself: for
286+ each question, call ask_agent with the agent whose description fits,
287+ then give its answer to the user, in the user's language.
288+ For a greeting or a question about you, answer in one short sentence,
289+ without any tool.
290+
291+# Machines: from guides/agent-settings-for-small-models.md, "Summary:
292+# typical profiles". With bash on, watchdogTimeout is aligned on bashTimeout
293+# (section 3 of the guide).
294+machines:
295+ - name: small
296+ description: "a laptop, models up to about 8B"
297+ firstTokenTimeout: 3m
298+ watchdogTimeout: 20s
299+ bashTimeout: 30s
300+ maxOutput: 8000
301+ source: "settings guide, row \"Small model (≤ 8B), short tasks\""
302+ - name: medium
303+ description: "a workstation, models up to about 30B"
304+ firstTokenTimeout: 5m
305+ watchdogTimeout: 2m
306+ bashTimeout: 2m
307+ maxOutput: 16000
308+ source: "settings guide, row \"Small/medium model, general coding agent\""
309+ - name: large
310+ description: "a big model, or a modest machine running on the CPU"
311+ firstTokenTimeout: 10m
312+ watchdogTimeout: 5m
313+ bashTimeout: 5m
314+ maxOutput: 24000
315+ source: "settings guide, row \"Larger model or modest machine (CPU)\""
new file mode 100644
@@ -0,0 +1,315 @@
1+# presets.yaml — what `mm init` knows about model families, usages and
2+# machines (ticket 97). ONE table, shared on purpose: `mm init` embeds it, and
3+# the Claude skill of ticket 111 reads this same file. Every value says where
4+# it comes from: a model card, or a measured demo of this repository.
5+#
6+# Families are matched against the lower-cased model id, in order: the first
7+# entry whose `match` words all appear wins, so a specific family (qwen +
8+# coder) comes before its general one (qwen). `other` matches anything.
9+#
10+# Sampling: `general` is the author's recommendation for normal use (thinking
11+# off); `thinking` is used when the agent reasons; `precise` is the
12+# temperature for tool-heavy, thinking-off agents (developer, docs, JSON…).
13+# When the model server tells the GGUF's own sampling (llmman, Ollama), it
14+# replaces `general` and `thinking` — the GGUF is the most specific source.
15+#
16+# `origin` says, for `general`, `thinking` and `precise`, how each was
17+# obtained: `measured` (a demo of this repository ran with it), `card` (copied
18+# from the model card, the GGUF or the API reference, not measured here) or
19+# `rule` (a generic rule of thumb, nothing specific to the family). The
20+# generated file tags its numbers with it (ticket 121): after a model or
21+# quantization change, a measured number is re-measured, a card number re-read.
22+
23+families:
24+ - name: qwen-coder
25+ match: [qwen, coder]
26+ reasoning: none
27+ general: {temperature: 1.0, top_p: 0.95, top_k: 40}
28+ precise: 0.2
29+ source: "demos/qwen-style (Qwen3-Coder-Next, measured: 1.0 / 0.95 / 40 from the GGUF)"
30+ origin: {general: measured, precise: rule}
31+
32+ - name: qwen
33+ match: [qwen]
34+ reasoning: enable_thinking
35+ general: {temperature: 0.7, top_p: 0.8, top_k: 20}
36+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
37+ precise: 0.0
38+ source: "Qwen model cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, thinking off (demos/docs-search, memory-search, plans)"
39+ origin: {general: card, thinking: card, precise: measured}
40+
41+ - name: gemma
42+ match: [gemma]
43+ reasoning: enable_thinking
44+ general: {temperature: 1.0, top_p: 0.95, top_k: 64}
45+ thinking: {temperature: 1.0, top_p: 0.95, top_k: 64}
46+ precise: 0.2
47+ source: "Gemma 4 GGUF sampling 1.0 / 0.95 / 64 (demos/gemini-style); precise 0.2 measured with gemma-4-E4B (demos/hooks, guards); enable_thinking is off by default in the template"
48+ origin: {general: card, thinking: card, precise: measured}
49+
50+ - name: mellum-thinking
51+ match: [mellum, thinking]
52+ reasoning: enable_thinking
53+ general: {temperature: 0.6, top_p: 0.95}
54+ thinking: {temperature: 0.6, top_p: 0.95}
55+ precise: 0.2
56+ source: "demos/coding-helpers (Mellum2 12B Thinking: 0.6 / 0.95, thinking on); demos/programming-answers (Mellum2.1: 0.2 / 0.9)"
57+ origin: {general: measured, thinking: measured, precise: measured}
58+
59+ - name: mellum
60+ match: [mellum]
61+ reasoning: none
62+ general: {temperature: 0.2, top_p: 0.9}
63+ precise: 0.0
64+ source: "demos/little-coder (Mellum2 12B Instruct, measured 0.2 / 0.9); agent.dmr.yaml (0.0 / 0.9)"
65+ origin: {general: measured, precise: rule}
66+
67+ - name: gpt-oss
68+ match: [gpt-oss]
69+ reasoning: reasoning_effort
70+ effortValues: [low, medium, high]
71+ general: {temperature: 1.0, top_p: 1.0}
72+ thinking: {temperature: 1.0, top_p: 1.0}
73+ precise: 1.0
74+ source: "demos/codex-style (gpt-oss 20B: 1.0 / 1.0, harmony format, reasoning_effort low | medium | high, default medium) — the model always reasons: never a low temperature"
75+ origin: {general: measured, thinking: measured, precise: measured}
76+
77+ - name: openai
78+ match: [gpt-]
79+ reasoning: none
80+ maxTokensKey: max_completion_tokens
81+ general: {temperature: 1.0}
82+ precise: 1.0
83+ note: "a hosted OpenAI model: its reasoning models (gpt-5, the o-series) accept only the default temperature (1.0) and take max_completion_tokens instead of max_tokens. With provider openai, mm sends this sampling block as written and adds none of its defaults — remove a key the API still refuses"
84+ source: "OpenAI API reference; not measured in this repository"
85+ origin: {general: card, precise: card}
86+
87+ - name: minicpm
88+ match: [minicpm]
89+ reasoning: enable_thinking
90+ general: {temperature: 0.3, top_p: 0.95}
91+ thinking: {temperature: 0.6, top_p: 0.95}
92+ precise: 0.3
93+ source: "demos/coding-helpers (MiniCPM5 2B: 0.3 / 0.95, enable_thinking false — its template has no default: always set it)"
94+ origin: {general: measured, thinking: rule, precise: measured}
95+
96+ - name: ornith
97+ match: [ornith]
98+ reasoning: enable_thinking
99+ general: {temperature: 0.3, top_p: 0.8, top_k: 20}
100+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
101+ precise: 0.3
102+ source: "demos/team (Ornith 1.5 9B: 0.3–0.4 / 0.8 / 20 thinking off, 0.6 / 0.95 / 20 thinking on)"
103+ origin: {general: measured, thinking: measured, precise: measured}
104+
105+ - name: granite-guardian
106+ match: [granite, guardian]
107+ reasoning: none
108+ general: {temperature: 0.0}
109+ precise: 0.0
110+ note: "a guard model: it answers Yes / No about a risk named in the system message, it is not a general agent — see demos/guard"
111+ source: "demos/guard (Granite Guardian 3.2 3B-A800M: temperature 0.0)"
112+ origin: {general: measured, precise: measured}
113+
114+ - name: menlo
115+ match: [menlo]
116+ reasoning: none
117+ general: {temperature: 0.0, top_p: 0.9}
118+ precise: 0.0
119+ source: "demos/llmman (Jan-nano, Lucy: 0.0 / 0.9)"
120+ origin: {general: measured, precise: measured}
121+
122+ - name: llama
123+ match: [llama]
124+ reasoning: none
125+ general: {temperature: 0.6, top_p: 0.9}
126+ precise: 0.2
127+ source: "Llama 3.1 8B Instruct generation_config.json (0.6 / 0.9); no measured demo in this repository"
128+ origin: {general: card, precise: rule}
129+
130+ - name: mistral
131+ match: [mistral]
132+ reasoning: none
133+ general: {temperature: 0.15}
134+ precise: 0.15
135+ source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
136+ origin: {general: card, precise: card}
137+
138+ - name: smollm3
139+ match: [smollm3]
140+ reasoning: enable_thinking
141+ general: {temperature: 0.6, top_p: 0.95}
142+ thinking: {temperature: 0.6, top_p: 0.95}
143+ precise: 0.2
144+ note: "unable to call mm's tools through llmman or llama-server: its chat template reads xml_tools / python_tools, never the standard tools list (measured 2026-11-13 on SmolLM3-3B Q4_K_M: no tool call, with or without reasoning; given xml_tools it writes <tool_call> as plain text, which the server does not turn into a call). Choose a usage without tools — translator or json"
145+ source: "SmolLM3-3B model card (\"We recommend setting temperature=0.6 and top_p=0.95 in the sampling parameters\"; generation_config.json: temperature 0.6, top_p 0.95) — one recommendation, used for both modes; reasoning on by default in the template (enable_thinking defaults to true; a /think or /no_think flag in the system prompt overrides the kwarg); precise: rule"
146+ origin: {general: card, thinking: card, precise: rule}
147+
148+ - name: other
149+ match: []
150+ reasoning: none
151+ general: {temperature: 0.7, top_p: 0.9}
152+ precise: 0.2
153+ source: "skills/create-agent rule when nothing is published: 0.7 / 0.9, 0.2 for a code agent without reasoning"
154+ origin: {general: rule, precise: rule}
155+
156+# Usages: what the agent is for. `tools` are the YAML switches; `thinking`
157+# asks for reasoning when the family has a switch; `precise` picks the
158+# family's precise temperature (when not thinking); `docs` turns on the
159+# documentation search; `json` writes a structuredOutput example (which
160+# forbids every tool).
161+usages:
162+ - name: developer
163+ description: "a coding agent: reads and changes the project, runs commands"
164+ tools: {bashTool: true, editTools: true, planTool: true, askUserTool: true}
165+ thinking: true
166+ precise: true
167+ maxTurns: 40
168+ yolo: false
169+ system: |
170+ You are a coding agent working in a terminal, in the user's project.
171+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
172+ plan tool.
173+ Read before you change: look at the files and run the commands that tell
174+ you how the project works. Make the smallest change that does the job,
175+ then run the tests or the build to check it.
176+ Never state the output of a command you did not run in this answer.
177+ When a task has several steps, make a plan and update it as you go.
178+
179+ - name: reviewer
180+ description: "reviews code and changes, suggests fixes, changes nothing unasked"
181+ tools: {bashTool: true, editTools: true, planTool: false, askUserTool: true}
182+ thinking: true
183+ precise: true
184+ maxTurns: 30
185+ yolo: false
186+ system: |
187+ You are a code reviewer working in a terminal, in the user's project.
188+ Read the code and the changes (git diff, git log) with your tools, and
189+ report: bugs first, then risks, then style — each with the file and line.
190+ Do not change any file unless the user asks you to.
191+ Never state the output of a command you did not run in this answer.
192+
193+ - name: translator
194+ description: "translates texts between languages, no tools"
195+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
196+ thinking: false
197+ precise: true
198+ maxTurns: 3
199+ yolo: true
200+ system: |
201+ You are a translator. Translate the text the user gives you into the
202+ language they ask for (English ↔ French when they do not say).
203+ Keep the meaning, the tone and the formatting (Markdown, code blocks,
204+ lists). Do not translate code, file names or product names.
205+ Answer with the translation only, no comment.
206+
207+ - name: writer
208+ description: "writes documents and saves them to files"
209+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: true, saveAnswerTool: true}
210+ thinking: false
211+ precise: false
212+ maxTurns: 10
213+ yolo: false
214+ system: |
215+ You are a technical writer. Write clear, well-structured documents in
216+ Markdown: short paragraphs, headings, examples.
217+ When the user asks you to save a document you just wrote, call
218+ save_answer with the file name; to change an existing file, read it
219+ first, then edit it.
220+
221+ - name: assistant
222+ description: "answers questions, no tools"
223+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true}
224+ thinking: false
225+ precise: false
226+ maxTurns: 5
227+ yolo: true
228+ system: |
229+ You are a helpful assistant. Answer clearly and briefly, in the user's
230+ language. Say when you do not know; never invent facts, names or
231+ figures.
232+
233+ - name: researcher
234+ description: "reads web pages and summarises them"
235+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true, fetchContentTool: true, saveAnswerTool: true}
236+ thinking: false
237+ precise: false
238+ maxTurns: 15
239+ yolo: false
240+ system: |
241+ You are a research assistant. To answer, read the web pages the user
242+ names with fetch_content, then summarise what they say, citing each
243+ page's address. Never state what a page says without having read it in
244+ this answer.
245+
246+ - name: docs
247+ description: "answers from a documentation folder (docs/), with links to the passages"
248+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: false}
249+ docs: true
250+ thinking: false
251+ precise: true
252+ maxTurns: 8
253+ yolo: true
254+ system: |
255+ You answer questions about the project from its documentation, in the
256+ folder docs/. For every question the documentation may cover, call
257+ search_docs first, with the words the documentation is likely to use
258+ (often English, names of commands and types). Answer from the passages
259+ it returns, and end with the link of the passage you used (path:line).
260+ If nothing is found, try other words once, then say the documentation
261+ does not cover it.
262+
263+ - name: json
264+ description: "answers in JSON shaped by Go types (structuredOutput), no tools"
265+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
266+ json: true
267+ thinking: false
268+ precise: true
269+ maxTurns: 3
270+ yolo: true
271+ system: |
272+ You extract facts from the user's text and answer in the JSON shape you
273+ are given. Fill every field from the text; use null when the text does
274+ not say.
275+
276+ - name: coordinator
277+ description: "hands each question to other mm agents (mmConfigs) and relays their answers"
278+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
279+ coordinator: true
280+ thinking: false
281+ precise: true
282+ maxTurns: 4
283+ yolo: true
284+ system: |
285+ You are a coordinator. You do not answer the questions yourself: for
286+ each question, call ask_agent with the agent whose description fits,
287+ then give its answer to the user, in the user's language.
288+ For a greeting or a question about you, answer in one short sentence,
289+ without any tool.
290+
291+# Machines: from guides/agent-settings-for-small-models.md, "Summary:
292+# typical profiles". With bash on, watchdogTimeout is aligned on bashTimeout
293+# (section 3 of the guide).
294+machines:
295+ - name: small
296+ description: "a laptop, models up to about 8B"
297+ firstTokenTimeout: 3m
298+ watchdogTimeout: 20s
299+ bashTimeout: 30s
300+ maxOutput: 8000
301+ source: "settings guide, row \"Small model (≤ 8B), short tasks\""
302+ - name: medium
303+ description: "a workstation, models up to about 30B"
304+ firstTokenTimeout: 5m
305+ watchdogTimeout: 2m
306+ bashTimeout: 2m
307+ maxOutput: 16000
308+ source: "settings guide, row \"Small/medium model, general coding agent\""
309+ - name: large
310+ description: "a big model, or a modest machine running on the CPU"
311+ firstTokenTimeout: 10m
312+ watchdogTimeout: 5m
313+ bashTimeout: 5m
314+ maxOutput: 24000
315+ source: "settings guide, row \"Larger model or modest machine (CPU)\""
added internal/agentinit/backups/presets.20261113-073314.smollm3-thinking-off.yaml +316 -0
new file mode 100644
@@ -0,0 +1,316 @@
1+# presets.yaml — what `mm init` knows about model families, usages and
2+# machines (ticket 97). ONE table, shared on purpose: `mm init` embeds it, and
3+# the Claude skill of ticket 111 reads this same file. Every value says where
4+# it comes from: a model card, or a measured demo of this repository.
5+#
6+# Families are matched against the lower-cased model id, in order: the first
7+# entry whose `match` words all appear wins, so a specific family (qwen +
8+# coder) comes before its general one (qwen). `other` matches anything.
9+#
10+# Sampling: `general` is the author's recommendation for normal use (thinking
11+# off); `thinking` is used when the agent reasons; `precise` is the
12+# temperature for tool-heavy, thinking-off agents (developer, docs, JSON…).
13+# When the model server tells the GGUF's own sampling (llmman, Ollama), it
14+# replaces `general` and `thinking` — the GGUF is the most specific source.
15+#
16+# `origin` says, for `general`, `thinking` and `precise`, how each was
17+# obtained: `measured` (a demo of this repository ran with it), `card` (copied
18+# from the model card, the GGUF or the API reference, not measured here) or
19+# `rule` (a generic rule of thumb, nothing specific to the family). The
20+# generated file tags its numbers with it (ticket 121): after a model or
21+# quantization change, a measured number is re-measured, a card number re-read.
22+
23+families:
24+ - name: qwen-coder
25+ match: [qwen, coder]
26+ reasoning: none
27+ general: {temperature: 1.0, top_p: 0.95, top_k: 40}
28+ precise: 0.2
29+ source: "demos/qwen-style (Qwen3-Coder-Next, measured: 1.0 / 0.95 / 40 from the GGUF)"
30+ origin: {general: measured, precise: rule}
31+
32+ - name: qwen
33+ match: [qwen]
34+ reasoning: enable_thinking
35+ general: {temperature: 0.7, top_p: 0.8, top_k: 20}
36+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
37+ precise: 0.0
38+ source: "Qwen model cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, thinking off (demos/docs-search, memory-search, plans)"
39+ origin: {general: card, thinking: card, precise: measured}
40+
41+ - name: gemma
42+ match: [gemma]
43+ reasoning: enable_thinking
44+ general: {temperature: 1.0, top_p: 0.95, top_k: 64}
45+ thinking: {temperature: 1.0, top_p: 0.95, top_k: 64}
46+ precise: 0.2
47+ source: "Gemma 4 GGUF sampling 1.0 / 0.95 / 64 (demos/gemini-style); precise 0.2 measured with gemma-4-E4B (demos/hooks, guards); enable_thinking is off by default in the template"
48+ origin: {general: card, thinking: card, precise: measured}
49+
50+ - name: mellum-thinking
51+ match: [mellum, thinking]
52+ reasoning: enable_thinking
53+ general: {temperature: 0.6, top_p: 0.95}
54+ thinking: {temperature: 0.6, top_p: 0.95}
55+ precise: 0.2
56+ source: "demos/coding-helpers (Mellum2 12B Thinking: 0.6 / 0.95, thinking on); demos/programming-answers (Mellum2.1: 0.2 / 0.9)"
57+ origin: {general: measured, thinking: measured, precise: measured}
58+
59+ - name: mellum
60+ match: [mellum]
61+ reasoning: none
62+ general: {temperature: 0.2, top_p: 0.9}
63+ precise: 0.0
64+ source: "demos/little-coder (Mellum2 12B Instruct, measured 0.2 / 0.9); agent.dmr.yaml (0.0 / 0.9)"
65+ origin: {general: measured, precise: rule}
66+
67+ - name: gpt-oss
68+ match: [gpt-oss]
69+ reasoning: reasoning_effort
70+ effortValues: [low, medium, high]
71+ general: {temperature: 1.0, top_p: 1.0}
72+ thinking: {temperature: 1.0, top_p: 1.0}
73+ precise: 1.0
74+ source: "demos/codex-style (gpt-oss 20B: 1.0 / 1.0, harmony format, reasoning_effort low | medium | high, default medium) — the model always reasons: never a low temperature"
75+ origin: {general: measured, thinking: measured, precise: measured}
76+
77+ - name: openai
78+ match: [gpt-]
79+ reasoning: none
80+ maxTokensKey: max_completion_tokens
81+ general: {temperature: 1.0}
82+ precise: 1.0
83+ note: "a hosted OpenAI model: its reasoning models (gpt-5, the o-series) accept only the default temperature (1.0) and take max_completion_tokens instead of max_tokens. With provider openai, mm sends this sampling block as written and adds none of its defaults — remove a key the API still refuses"
84+ source: "OpenAI API reference; not measured in this repository"
85+ origin: {general: card, precise: card}
86+
87+ - name: minicpm
88+ match: [minicpm]
89+ reasoning: enable_thinking
90+ general: {temperature: 0.3, top_p: 0.95}
91+ thinking: {temperature: 0.6, top_p: 0.95}
92+ precise: 0.3
93+ source: "demos/coding-helpers (MiniCPM5 2B: 0.3 / 0.95, enable_thinking false — its template has no default: always set it)"
94+ origin: {general: measured, thinking: rule, precise: measured}
95+
96+ - name: ornith
97+ match: [ornith]
98+ reasoning: enable_thinking
99+ general: {temperature: 0.3, top_p: 0.8, top_k: 20}
100+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
101+ precise: 0.3
102+ source: "demos/team (Ornith 1.5 9B: 0.3–0.4 / 0.8 / 20 thinking off, 0.6 / 0.95 / 20 thinking on)"
103+ origin: {general: measured, thinking: measured, precise: measured}
104+
105+ - name: granite-guardian
106+ match: [granite, guardian]
107+ reasoning: none
108+ general: {temperature: 0.0}
109+ precise: 0.0
110+ note: "a guard model: it answers Yes / No about a risk named in the system message, it is not a general agent — see demos/guard"
111+ source: "demos/guard (Granite Guardian 3.2 3B-A800M: temperature 0.0)"
112+ origin: {general: measured, precise: measured}
113+
114+ - name: menlo
115+ match: [menlo]
116+ reasoning: none
117+ general: {temperature: 0.0, top_p: 0.9}
118+ precise: 0.0
119+ source: "demos/llmman (Jan-nano, Lucy: 0.0 / 0.9)"
120+ origin: {general: measured, precise: measured}
121+
122+ - name: llama
123+ match: [llama]
124+ reasoning: none
125+ general: {temperature: 0.6, top_p: 0.9}
126+ precise: 0.2
127+ source: "Llama 3.1 8B Instruct generation_config.json (0.6 / 0.9); no measured demo in this repository"
128+ origin: {general: card, precise: rule}
129+
130+ - name: mistral
131+ match: [mistral]
132+ reasoning: none
133+ general: {temperature: 0.15}
134+ precise: 0.15
135+ source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
136+ origin: {general: card, precise: card}
137+
138+ - name: smollm3
139+ match: [smollm3]
140+ reasoning: enable_thinking
141+ general: {temperature: 0.6, top_p: 0.95}
142+ thinking: {temperature: 0.6, top_p: 0.95}
143+ precise: 0.2
144+ toolFormat: xml_tools
145+ note: "usable with tools only with reasoning off — set sampling.chat_template_kwargs.enable_thinking: false. Measured 2026-11-13 on SmolLM3-3B Q4_K_M with toolFormat: xml_tools, with reasoning on it repeats its call after the tool's answer (3 runs out of 3), with it off it answers (6 out of 6). Tasks of several steps stay fragile at 3B"
146+ source: "SmolLM3-3B model card (\"We recommend setting temperature=0.6 and top_p=0.95 in the sampling parameters\"; generation_config.json: temperature 0.6, top_p 0.95) — one recommendation, used for both modes; reasoning on by default in the template (enable_thinking defaults to true; a /think or /no_think flag in the system prompt overrides the kwarg); precise: rule"
147+ origin: {general: card, thinking: card, precise: rule}
148+
149+ - name: other
150+ match: []
151+ reasoning: none
152+ general: {temperature: 0.7, top_p: 0.9}
153+ precise: 0.2
154+ source: "skills/create-agent rule when nothing is published: 0.7 / 0.9, 0.2 for a code agent without reasoning"
155+ origin: {general: rule, precise: rule}
156+
157+# Usages: what the agent is for. `tools` are the YAML switches; `thinking`
158+# asks for reasoning when the family has a switch; `precise` picks the
159+# family's precise temperature (when not thinking); `docs` turns on the
160+# documentation search; `json` writes a structuredOutput example (which
161+# forbids every tool).
162+usages:
163+ - name: developer
164+ description: "a coding agent: reads and changes the project, runs commands"
165+ tools: {bashTool: true, editTools: true, planTool: true, askUserTool: true}
166+ thinking: true
167+ precise: true
168+ maxTurns: 40
169+ yolo: false
170+ system: |
171+ You are a coding agent working in a terminal, in the user's project.
172+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
173+ plan tool.
174+ Read before you change: look at the files and run the commands that tell
175+ you how the project works. Make the smallest change that does the job,
176+ then run the tests or the build to check it.
177+ Never state the output of a command you did not run in this answer.
178+ When a task has several steps, make a plan and update it as you go.
179+
180+ - name: reviewer
181+ description: "reviews code and changes, suggests fixes, changes nothing unasked"
182+ tools: {bashTool: true, editTools: true, planTool: false, askUserTool: true}
183+ thinking: true
184+ precise: true
185+ maxTurns: 30
186+ yolo: false
187+ system: |
188+ You are a code reviewer working in a terminal, in the user's project.
189+ Read the code and the changes (git diff, git log) with your tools, and
190+ report: bugs first, then risks, then style — each with the file and line.
191+ Do not change any file unless the user asks you to.
192+ Never state the output of a command you did not run in this answer.
193+
194+ - name: translator
195+ description: "translates texts between languages, no tools"
196+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
197+ thinking: false
198+ precise: true
199+ maxTurns: 3
200+ yolo: true
201+ system: |
202+ You are a translator. Translate the text the user gives you into the
203+ language they ask for (English ↔ French when they do not say).
204+ Keep the meaning, the tone and the formatting (Markdown, code blocks,
205+ lists). Do not translate code, file names or product names.
206+ Answer with the translation only, no comment.
207+
208+ - name: writer
209+ description: "writes documents and saves them to files"
210+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: true, saveAnswerTool: true}
211+ thinking: false
212+ precise: false
213+ maxTurns: 10
214+ yolo: false
215+ system: |
216+ You are a technical writer. Write clear, well-structured documents in
217+ Markdown: short paragraphs, headings, examples.
218+ When the user asks you to save a document you just wrote, call
219+ save_answer with the file name; to change an existing file, read it
220+ first, then edit it.
221+
222+ - name: assistant
223+ description: "answers questions, no tools"
224+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true}
225+ thinking: false
226+ precise: false
227+ maxTurns: 5
228+ yolo: true
229+ system: |
230+ You are a helpful assistant. Answer clearly and briefly, in the user's
231+ language. Say when you do not know; never invent facts, names or
232+ figures.
233+
234+ - name: researcher
235+ description: "reads web pages and summarises them"
236+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true, fetchContentTool: true, saveAnswerTool: true}
237+ thinking: false
238+ precise: false
239+ maxTurns: 15
240+ yolo: false
241+ system: |
242+ You are a research assistant. To answer, read the web pages the user
243+ names with fetch_content, then summarise what they say, citing each
244+ page's address. Never state what a page says without having read it in
245+ this answer.
246+
247+ - name: docs
248+ description: "answers from a documentation folder (docs/), with links to the passages"
249+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: false}
250+ docs: true
251+ thinking: false
252+ precise: true
253+ maxTurns: 8
254+ yolo: true
255+ system: |
256+ You answer questions about the project from its documentation, in the
257+ folder docs/. For every question the documentation may cover, call
258+ search_docs first, with the words the documentation is likely to use
259+ (often English, names of commands and types). Answer from the passages
260+ it returns, and end with the link of the passage you used (path:line).
261+ If nothing is found, try other words once, then say the documentation
262+ does not cover it.
263+
264+ - name: json
265+ description: "answers in JSON shaped by Go types (structuredOutput), no tools"
266+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
267+ json: true
268+ thinking: false
269+ precise: true
270+ maxTurns: 3
271+ yolo: true
272+ system: |
273+ You extract facts from the user's text and answer in the JSON shape you
274+ are given. Fill every field from the text; use null when the text does
275+ not say.
276+
277+ - name: coordinator
278+ description: "hands each question to other mm agents (mmConfigs) and relays their answers"
279+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
280+ coordinator: true
281+ thinking: false
282+ precise: true
283+ maxTurns: 4
284+ yolo: true
285+ system: |
286+ You are a coordinator. You do not answer the questions yourself: for
287+ each question, call ask_agent with the agent whose description fits,
288+ then give its answer to the user, in the user's language.
289+ For a greeting or a question about you, answer in one short sentence,
290+ without any tool.
291+
292+# Machines: from guides/agent-settings-for-small-models.md, "Summary:
293+# typical profiles". With bash on, watchdogTimeout is aligned on bashTimeout
294+# (section 3 of the guide).
295+machines:
296+ - name: small
297+ description: "a laptop, models up to about 8B"
298+ firstTokenTimeout: 3m
299+ watchdogTimeout: 20s
300+ bashTimeout: 30s
301+ maxOutput: 8000
302+ source: "settings guide, row \"Small model (≤ 8B), short tasks\""
303+ - name: medium
304+ description: "a workstation, models up to about 30B"
305+ firstTokenTimeout: 5m
306+ watchdogTimeout: 2m
307+ bashTimeout: 2m
308+ maxOutput: 16000
309+ source: "settings guide, row \"Small/medium model, general coding agent\""
310+ - name: large
311+ description: "a big model, or a modest machine running on the CPU"
312+ firstTokenTimeout: 10m
313+ watchdogTimeout: 5m
314+ bashTimeout: 5m
315+ maxOutput: 24000
316+ source: "settings guide, row \"Larger model or modest machine (CPU)\""
new file mode 100644
@@ -0,0 +1,316 @@
1+# presets.yaml — what `mm init` knows about model families, usages and
2+# machines (ticket 97). ONE table, shared on purpose: `mm init` embeds it, and
3+# the Claude skill of ticket 111 reads this same file. Every value says where
4+# it comes from: a model card, or a measured demo of this repository.
5+#
6+# Families are matched against the lower-cased model id, in order: the first
7+# entry whose `match` words all appear wins, so a specific family (qwen +
8+# coder) comes before its general one (qwen). `other` matches anything.
9+#
10+# Sampling: `general` is the author's recommendation for normal use (thinking
11+# off); `thinking` is used when the agent reasons; `precise` is the
12+# temperature for tool-heavy, thinking-off agents (developer, docs, JSON…).
13+# When the model server tells the GGUF's own sampling (llmman, Ollama), it
14+# replaces `general` and `thinking` — the GGUF is the most specific source.
15+#
16+# `origin` says, for `general`, `thinking` and `precise`, how each was
17+# obtained: `measured` (a demo of this repository ran with it), `card` (copied
18+# from the model card, the GGUF or the API reference, not measured here) or
19+# `rule` (a generic rule of thumb, nothing specific to the family). The
20+# generated file tags its numbers with it (ticket 121): after a model or
21+# quantization change, a measured number is re-measured, a card number re-read.
22+
23+families:
24+ - name: qwen-coder
25+ match: [qwen, coder]
26+ reasoning: none
27+ general: {temperature: 1.0, top_p: 0.95, top_k: 40}
28+ precise: 0.2
29+ source: "demos/qwen-style (Qwen3-Coder-Next, measured: 1.0 / 0.95 / 40 from the GGUF)"
30+ origin: {general: measured, precise: rule}
31+
32+ - name: qwen
33+ match: [qwen]
34+ reasoning: enable_thinking
35+ general: {temperature: 0.7, top_p: 0.8, top_k: 20}
36+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
37+ precise: 0.0
38+ source: "Qwen model cards (thinking 0.6 / 0.95 / 20, non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, thinking off (demos/docs-search, memory-search, plans)"
39+ origin: {general: card, thinking: card, precise: measured}
40+
41+ - name: gemma
42+ match: [gemma]
43+ reasoning: enable_thinking
44+ general: {temperature: 1.0, top_p: 0.95, top_k: 64}
45+ thinking: {temperature: 1.0, top_p: 0.95, top_k: 64}
46+ precise: 0.2
47+ source: "Gemma 4 GGUF sampling 1.0 / 0.95 / 64 (demos/gemini-style); precise 0.2 measured with gemma-4-E4B (demos/hooks, guards); enable_thinking is off by default in the template"
48+ origin: {general: card, thinking: card, precise: measured}
49+
50+ - name: mellum-thinking
51+ match: [mellum, thinking]
52+ reasoning: enable_thinking
53+ general: {temperature: 0.6, top_p: 0.95}
54+ thinking: {temperature: 0.6, top_p: 0.95}
55+ precise: 0.2
56+ source: "demos/coding-helpers (Mellum2 12B Thinking: 0.6 / 0.95, thinking on); demos/programming-answers (Mellum2.1: 0.2 / 0.9)"
57+ origin: {general: measured, thinking: measured, precise: measured}
58+
59+ - name: mellum
60+ match: [mellum]
61+ reasoning: none
62+ general: {temperature: 0.2, top_p: 0.9}
63+ precise: 0.0
64+ source: "demos/little-coder (Mellum2 12B Instruct, measured 0.2 / 0.9); agent.dmr.yaml (0.0 / 0.9)"
65+ origin: {general: measured, precise: rule}
66+
67+ - name: gpt-oss
68+ match: [gpt-oss]
69+ reasoning: reasoning_effort
70+ effortValues: [low, medium, high]
71+ general: {temperature: 1.0, top_p: 1.0}
72+ thinking: {temperature: 1.0, top_p: 1.0}
73+ precise: 1.0
74+ source: "demos/codex-style (gpt-oss 20B: 1.0 / 1.0, harmony format, reasoning_effort low | medium | high, default medium) — the model always reasons: never a low temperature"
75+ origin: {general: measured, thinking: measured, precise: measured}
76+
77+ - name: openai
78+ match: [gpt-]
79+ reasoning: none
80+ maxTokensKey: max_completion_tokens
81+ general: {temperature: 1.0}
82+ precise: 1.0
83+ note: "a hosted OpenAI model: its reasoning models (gpt-5, the o-series) accept only the default temperature (1.0) and take max_completion_tokens instead of max_tokens. With provider openai, mm sends this sampling block as written and adds none of its defaults — remove a key the API still refuses"
84+ source: "OpenAI API reference; not measured in this repository"
85+ origin: {general: card, precise: card}
86+
87+ - name: minicpm
88+ match: [minicpm]
89+ reasoning: enable_thinking
90+ general: {temperature: 0.3, top_p: 0.95}
91+ thinking: {temperature: 0.6, top_p: 0.95}
92+ precise: 0.3
93+ source: "demos/coding-helpers (MiniCPM5 2B: 0.3 / 0.95, enable_thinking false — its template has no default: always set it)"
94+ origin: {general: measured, thinking: rule, precise: measured}
95+
96+ - name: ornith
97+ match: [ornith]
98+ reasoning: enable_thinking
99+ general: {temperature: 0.3, top_p: 0.8, top_k: 20}
100+ thinking: {temperature: 0.6, top_p: 0.95, top_k: 20}
101+ precise: 0.3
102+ source: "demos/team (Ornith 1.5 9B: 0.3–0.4 / 0.8 / 20 thinking off, 0.6 / 0.95 / 20 thinking on)"
103+ origin: {general: measured, thinking: measured, precise: measured}
104+
105+ - name: granite-guardian
106+ match: [granite, guardian]
107+ reasoning: none
108+ general: {temperature: 0.0}
109+ precise: 0.0
110+ note: "a guard model: it answers Yes / No about a risk named in the system message, it is not a general agent — see demos/guard"
111+ source: "demos/guard (Granite Guardian 3.2 3B-A800M: temperature 0.0)"
112+ origin: {general: measured, precise: measured}
113+
114+ - name: menlo
115+ match: [menlo]
116+ reasoning: none
117+ general: {temperature: 0.0, top_p: 0.9}
118+ precise: 0.0
119+ source: "demos/llmman (Jan-nano, Lucy: 0.0 / 0.9)"
120+ origin: {general: measured, precise: measured}
121+
122+ - name: llama
123+ match: [llama]
124+ reasoning: none
125+ general: {temperature: 0.6, top_p: 0.9}
126+ precise: 0.2
127+ source: "Llama 3.1 8B Instruct generation_config.json (0.6 / 0.9); no measured demo in this repository"
128+ origin: {general: card, precise: rule}
129+
130+ - name: mistral
131+ match: [mistral]
132+ reasoning: none
133+ general: {temperature: 0.15}
134+ precise: 0.15
135+ source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
136+ origin: {general: card, precise: card}
137+
138+ - name: smollm3
139+ match: [smollm3]
140+ reasoning: enable_thinking
141+ general: {temperature: 0.6, top_p: 0.95}
142+ thinking: {temperature: 0.6, top_p: 0.95}
143+ precise: 0.2
144+ toolFormat: xml_tools
145+ note: "usable with tools only with reasoning off — set sampling.chat_template_kwargs.enable_thinking: false. Measured 2026-11-13 on SmolLM3-3B Q4_K_M with toolFormat: xml_tools, with reasoning on it repeats its call after the tool's answer (3 runs out of 3), with it off it answers (6 out of 6). Tasks of several steps stay fragile at 3B"
146+ source: "SmolLM3-3B model card (\"We recommend setting temperature=0.6 and top_p=0.95 in the sampling parameters\"; generation_config.json: temperature 0.6, top_p 0.95) — one recommendation, used for both modes; reasoning on by default in the template (enable_thinking defaults to true; a /think or /no_think flag in the system prompt overrides the kwarg); precise: rule"
147+ origin: {general: card, thinking: card, precise: rule}
148+
149+ - name: other
150+ match: []
151+ reasoning: none
152+ general: {temperature: 0.7, top_p: 0.9}
153+ precise: 0.2
154+ source: "skills/create-agent rule when nothing is published: 0.7 / 0.9, 0.2 for a code agent without reasoning"
155+ origin: {general: rule, precise: rule}
156+
157+# Usages: what the agent is for. `tools` are the YAML switches; `thinking`
158+# asks for reasoning when the family has a switch; `precise` picks the
159+# family's precise temperature (when not thinking); `docs` turns on the
160+# documentation search; `json` writes a structuredOutput example (which
161+# forbids every tool).
162+usages:
163+ - name: developer
164+ description: "a coding agent: reads and changes the project, runs commands"
165+ tools: {bashTool: true, editTools: true, planTool: true, askUserTool: true}
166+ thinking: true
167+ precise: true
168+ maxTurns: 40
169+ yolo: false
170+ system: |
171+ You are a coding agent working in a terminal, in the user's project.
172+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
173+ plan tool.
174+ Read before you change: look at the files and run the commands that tell
175+ you how the project works. Make the smallest change that does the job,
176+ then run the tests or the build to check it.
177+ Never state the output of a command you did not run in this answer.
178+ When a task has several steps, make a plan and update it as you go.
179+
180+ - name: reviewer
181+ description: "reviews code and changes, suggests fixes, changes nothing unasked"
182+ tools: {bashTool: true, editTools: true, planTool: false, askUserTool: true}
183+ thinking: true
184+ precise: true
185+ maxTurns: 30
186+ yolo: false
187+ system: |
188+ You are a code reviewer working in a terminal, in the user's project.
189+ Read the code and the changes (git diff, git log) with your tools, and
190+ report: bugs first, then risks, then style — each with the file and line.
191+ Do not change any file unless the user asks you to.
192+ Never state the output of a command you did not run in this answer.
193+
194+ - name: translator
195+ description: "translates texts between languages, no tools"
196+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
197+ thinking: false
198+ precise: true
199+ maxTurns: 3
200+ yolo: true
201+ system: |
202+ You are a translator. Translate the text the user gives you into the
203+ language they ask for (English ↔ French when they do not say).
204+ Keep the meaning, the tone and the formatting (Markdown, code blocks,
205+ lists). Do not translate code, file names or product names.
206+ Answer with the translation only, no comment.
207+
208+ - name: writer
209+ description: "writes documents and saves them to files"
210+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: true, saveAnswerTool: true}
211+ thinking: false
212+ precise: false
213+ maxTurns: 10
214+ yolo: false
215+ system: |
216+ You are a technical writer. Write clear, well-structured documents in
217+ Markdown: short paragraphs, headings, examples.
218+ When the user asks you to save a document you just wrote, call
219+ save_answer with the file name; to change an existing file, read it
220+ first, then edit it.
221+
222+ - name: assistant
223+ description: "answers questions, no tools"
224+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true}
225+ thinking: false
226+ precise: false
227+ maxTurns: 5
228+ yolo: true
229+ system: |
230+ You are a helpful assistant. Answer clearly and briefly, in the user's
231+ language. Say when you do not know; never invent facts, names or
232+ figures.
233+
234+ - name: researcher
235+ description: "reads web pages and summarises them"
236+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: true, fetchContentTool: true, saveAnswerTool: true}
237+ thinking: false
238+ precise: false
239+ maxTurns: 15
240+ yolo: false
241+ system: |
242+ You are a research assistant. To answer, read the web pages the user
243+ names with fetch_content, then summarise what they say, citing each
244+ page's address. Never state what a page says without having read it in
245+ this answer.
246+
247+ - name: docs
248+ description: "answers from a documentation folder (docs/), with links to the passages"
249+ tools: {bashTool: false, editTools: true, planTool: false, askUserTool: false}
250+ docs: true
251+ thinking: false
252+ precise: true
253+ maxTurns: 8
254+ yolo: true
255+ system: |
256+ You answer questions about the project from its documentation, in the
257+ folder docs/. For every question the documentation may cover, call
258+ search_docs first, with the words the documentation is likely to use
259+ (often English, names of commands and types). Answer from the passages
260+ it returns, and end with the link of the passage you used (path:line).
261+ If nothing is found, try other words once, then say the documentation
262+ does not cover it.
263+
264+ - name: json
265+ description: "answers in JSON shaped by Go types (structuredOutput), no tools"
266+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
267+ json: true
268+ thinking: false
269+ precise: true
270+ maxTurns: 3
271+ yolo: true
272+ system: |
273+ You extract facts from the user's text and answer in the JSON shape you
274+ are given. Fill every field from the text; use null when the text does
275+ not say.
276+
277+ - name: coordinator
278+ description: "hands each question to other mm agents (mmConfigs) and relays their answers"
279+ tools: {bashTool: false, editTools: false, planTool: false, askUserTool: false}
280+ coordinator: true
281+ thinking: false
282+ precise: true
283+ maxTurns: 4
284+ yolo: true
285+ system: |
286+ You are a coordinator. You do not answer the questions yourself: for
287+ each question, call ask_agent with the agent whose description fits,
288+ then give its answer to the user, in the user's language.
289+ For a greeting or a question about you, answer in one short sentence,
290+ without any tool.
291+
292+# Machines: from guides/agent-settings-for-small-models.md, "Summary:
293+# typical profiles". With bash on, watchdogTimeout is aligned on bashTimeout
294+# (section 3 of the guide).
295+machines:
296+ - name: small
297+ description: "a laptop, models up to about 8B"
298+ firstTokenTimeout: 3m
299+ watchdogTimeout: 20s
300+ bashTimeout: 30s
301+ maxOutput: 8000
302+ source: "settings guide, row \"Small model (≤ 8B), short tasks\""
303+ - name: medium
304+ description: "a workstation, models up to about 30B"
305+ firstTokenTimeout: 5m
306+ watchdogTimeout: 2m
307+ bashTimeout: 2m
308+ maxOutput: 16000
309+ source: "settings guide, row \"Small/medium model, general coding agent\""
310+ - name: large
311+ description: "a big model, or a modest machine running on the CPU"
312+ firstTokenTimeout: 10m
313+ watchdogTimeout: 5m
314+ bashTimeout: 5m
315+ maxOutput: 24000
316+ source: "settings guide, row \"Larger model or modest machine (CPU)\""
modified internal/agentinit/decide.go +62 -2
@@ -18,6 +18,30 @@ type Choices struct {
1818 Family string // "" = detected from the model's name
1919 Name string // acp.name
2020 Yolo *bool // nil = the usage's default
21+ // Project (ticket 205): the agent's files live in a .mm folder —
22+ // .mm/agent.yaml, its paths written from the project root. nil = no.
23+ Project *bool
24+ // Sessions writes a sessions section that turns them on. nil = no: no
25+ // section, mm's built-in default (off) applies.
26+ Sessions *bool
27+ // Skills writes a skillsDir (skills, or .mm/skills with Project) and
28+ // creates that folder. nil = no: skillsDir: no-skills.
29+ Skills *bool
30+}
31+
32+// on reads an optional yes/no answer: nil is no.
33+func on(b *bool) bool { return b != nil && *b }
34+
35+// SkillsDir is the skillsDir the file names when skills are on: from the
36+// project root, so inside .mm/ for the Project layout (ticket 204: a
37+// .mm/agent.yaml resolves its relative paths from the parent of .mm).
38+//
39+// Choices{Project: &yes}.SkillsDir() // ".mm/skills"
40+func (c Choices) SkillsDir() string {
41+ if on(c.Project) {
42+ return ".mm/skills"
43+ }
44+ return "skills"
2145 }
2246
2347 // Setting is one value of the generated file with the reason it has that
@@ -56,6 +80,7 @@ type Settings struct {
5680 Sampling []Setting // under sampling:
5781 Kwargs []Setting // under sampling.chat_template_kwargs:
5882 Context []Setting // under context:
83+ Sessions []Setting // under sessions:, when they are on
5984 Notes []string // the file's closing remarks
6085 }
6186
@@ -73,11 +98,12 @@ func Decide(p Presets, c Choices, f Facts) Settings {
7398 fam = p.Family(c.Family)
7499 }
75100 s := Settings{Choices: c, Family: fam, Usage: u, Machine: m, Facts: f}
76- s.Thinking = u.Thinking && reasons(fam, f)
101+ s.Thinking = u.Thinking && reasons(fam, f) && !fam.ThinkingOff
77102
78103 s.decideTop()
79104 s.decideSampling()
80105 s.decideContext()
106+ s.decideSessions()
81107 s.decideNotes()
82108 return s
83109 }
@@ -126,7 +152,10 @@ func (s *Settings) decideTop() {
126152 if u.JSON || u.Coordinator {
127153 s.add("unloadModels", "false", "no unload_model tool: this agent "+map[bool]string{true: "answers in JSON, which forbids tools", false: "only hands questions to other agents"}[u.JSON])
128154 }
129- s.add("skillsDir", "no-skills", "no skills: add a skills/ folder and set skillsDir to use some (see "+docsURL+"docs/en/how-to/add-a-skill.md)")
155+ if f := s.Family.ToolFormat; f != "" && s.anyTool() {
156+ s.add("toolFormat", f, "family "+s.Family.Name+": its chat template reads the tools from "+f+" and writes its calls as <tool_call> text — mm sends them there and reads the calls back")
157+ }
158+ s.decideSkills()
130159
131160 s.add("maxTurns", fmt.Sprint(u.MaxTurns), "usage "+u.Name)
132161 s.addFrom("maxOutput", fmt.Sprint(m.MaxOutput), m.Source, OriginGuide)
@@ -215,6 +244,9 @@ func (s *Settings) decideKwargs() {
215244 switch sw {
216245 case "enable_thinking":
217246 why := "reasoning off: short, direct answers"
247+ if s.Family.ThinkingOff {
248+ why = "reasoning off: family " + s.Family.Name + " is kept off — see the note at the end of the file"
249+ }
218250 if s.Thinking {
219251 why = "reasoning on: code and multi-step work"
220252 }
@@ -247,6 +279,34 @@ func (s *Settings) decideContext() {
247279 s.Context = append(s.Context, Setting{Key: "threshold", Value: "75", Reason: "mm's default: 75 % of the window the server serves, a quarter left for the next question and its tool outputs"})
248280 }
249281
282+// decideSkills writes skillsDir: the folder mm init creates when skills are
283+// on, else no-skills — with the hint the file has always carried, worded
284+// for the .mm folder in the Project layout.
285+func (s *Settings) decideSkills() {
286+ c := s.Choices
287+ howTo := " (see " + docsURL + "docs/en/how-to/add-a-skill.md)"
288+ switch {
289+ case on(c.Skills):
290+ s.add("skillsDir", c.SkillsDir(), "your answer: a <name>.md or <name>/SKILL.md per skill, in "+c.SkillsDir()+"/ — relative to the directory mm is started from, the project root"+howTo)
291+ case on(c.Project):
292+ s.add("skillsDir", "no-skills", "no skills: add a .mm/skills/ folder and set skillsDir: .mm/skills to use some"+howTo)
293+ default:
294+ s.add("skillsDir", "no-skills", "no skills: add a skills/ folder and set skillsDir to use some"+howTo)
295+ }
296+}
297+
298+// decideSessions turns the sessions on when asked; otherwise the file has
299+// no sessions section and mm's default (off) applies.
300+func (s *Settings) decideSessions() {
301+ if !on(s.Choices.Sessions) {
302+ return
303+ }
304+ s.Sessions = append(s.Sessions,
305+ Setting{Key: "enabled", Value: "true", Reason: "your answer: every conversation is saved; /sessions lists them, /resume reopens one"},
306+ Setting{Key: "dir", Value: ".mm/sessions", Reason: "relative to the directory mm is started from: one folder per project"},
307+ )
308+}
309+
250310 func (s *Settings) decideNotes() {
251311 if s.Family.Note != "" {
252312 s.Notes = append(s.Notes, "This model is "+s.Family.Note+".")
@@ -18,6 +18,30 @@ type Choices struct {
18 Family string // "" = detected from the model's name18 Family string // "" = detected from the model's name
19 Name string // acp.name19 Name string // acp.name
20 Yolo *bool // nil = the usage's default20 Yolo *bool // nil = the usage's default
21+ // Project (ticket 205): the agent's files live in a .mm folder —
22+ // .mm/agent.yaml, its paths written from the project root. nil = no.
23+ Project *bool
24+ // Sessions writes a sessions section that turns them on. nil = no: no
25+ // section, mm's built-in default (off) applies.
26+ Sessions *bool
27+ // Skills writes a skillsDir (skills, or .mm/skills with Project) and
28+ // creates that folder. nil = no: skillsDir: no-skills.
29+ Skills *bool
30+}
31+
32+// on reads an optional yes/no answer: nil is no.
33+func on(b *bool) bool { return b != nil && *b }
34+
35+// SkillsDir is the skillsDir the file names when skills are on: from the
36+// project root, so inside .mm/ for the Project layout (ticket 204: a
37+// .mm/agent.yaml resolves its relative paths from the parent of .mm).
38+//
39+// Choices{Project: &yes}.SkillsDir() // ".mm/skills"
40+func (c Choices) SkillsDir() string {
41+ if on(c.Project) {
42+ return ".mm/skills"
43+ }
44+ return "skills"
21 }45 }
22 46
23 // Setting is one value of the generated file with the reason it has that47 // Setting is one value of the generated file with the reason it has that
@@ -56,6 +80,7 @@ type Settings struct {
56 Sampling []Setting // under sampling:80 Sampling []Setting // under sampling:
57 Kwargs []Setting // under sampling.chat_template_kwargs:81 Kwargs []Setting // under sampling.chat_template_kwargs:
58 Context []Setting // under context:82 Context []Setting // under context:
83+ Sessions []Setting // under sessions:, when they are on
59 Notes []string // the file's closing remarks84 Notes []string // the file's closing remarks
60 }85 }
61 86
@@ -73,11 +98,12 @@ func Decide(p Presets, c Choices, f Facts) Settings {
73 fam = p.Family(c.Family)98 fam = p.Family(c.Family)
74 }99 }
75 s := Settings{Choices: c, Family: fam, Usage: u, Machine: m, Facts: f}100 s := Settings{Choices: c, Family: fam, Usage: u, Machine: m, Facts: f}
76- s.Thinking = u.Thinking && reasons(fam, f)101+ s.Thinking = u.Thinking && reasons(fam, f) && !fam.ThinkingOff
77 102
78 s.decideTop()103 s.decideTop()
79 s.decideSampling()104 s.decideSampling()
80 s.decideContext()105 s.decideContext()
106+ s.decideSessions()
81 s.decideNotes()107 s.decideNotes()
82 return s108 return s
83 }109 }
@@ -126,7 +152,10 @@ func (s *Settings) decideTop() {
126 if u.JSON || u.Coordinator {152 if u.JSON || u.Coordinator {
127 s.add("unloadModels", "false", "no unload_model tool: this agent "+map[bool]string{true: "answers in JSON, which forbids tools", false: "only hands questions to other agents"}[u.JSON])153 s.add("unloadModels", "false", "no unload_model tool: this agent "+map[bool]string{true: "answers in JSON, which forbids tools", false: "only hands questions to other agents"}[u.JSON])
128 }154 }
129- s.add("skillsDir", "no-skills", "no skills: add a skills/ folder and set skillsDir to use some (see "+docsURL+"docs/en/how-to/add-a-skill.md)")155+ if f := s.Family.ToolFormat; f != "" && s.anyTool() {
156+ s.add("toolFormat", f, "family "+s.Family.Name+": its chat template reads the tools from "+f+" and writes its calls as <tool_call> text — mm sends them there and reads the calls back")
157+ }
158+ s.decideSkills()
130 159
131 s.add("maxTurns", fmt.Sprint(u.MaxTurns), "usage "+u.Name)160 s.add("maxTurns", fmt.Sprint(u.MaxTurns), "usage "+u.Name)
132 s.addFrom("maxOutput", fmt.Sprint(m.MaxOutput), m.Source, OriginGuide)161 s.addFrom("maxOutput", fmt.Sprint(m.MaxOutput), m.Source, OriginGuide)
@@ -215,6 +244,9 @@ func (s *Settings) decideKwargs() {
215 switch sw {244 switch sw {
216 case "enable_thinking":245 case "enable_thinking":
217 why := "reasoning off: short, direct answers"246 why := "reasoning off: short, direct answers"
247+ if s.Family.ThinkingOff {
248+ why = "reasoning off: family " + s.Family.Name + " is kept off — see the note at the end of the file"
249+ }
218 if s.Thinking {250 if s.Thinking {
219 why = "reasoning on: code and multi-step work"251 why = "reasoning on: code and multi-step work"
220 }252 }
@@ -247,6 +279,34 @@ func (s *Settings) decideContext() {
247 s.Context = append(s.Context, Setting{Key: "threshold", Value: "75", Reason: "mm's default: 75 % of the window the server serves, a quarter left for the next question and its tool outputs"})279 s.Context = append(s.Context, Setting{Key: "threshold", Value: "75", Reason: "mm's default: 75 % of the window the server serves, a quarter left for the next question and its tool outputs"})
248 }280 }
249 281
282+// decideSkills writes skillsDir: the folder mm init creates when skills are
283+// on, else no-skills — with the hint the file has always carried, worded
284+// for the .mm folder in the Project layout.
285+func (s *Settings) decideSkills() {
286+ c := s.Choices
287+ howTo := " (see " + docsURL + "docs/en/how-to/add-a-skill.md)"
288+ switch {
289+ case on(c.Skills):
290+ s.add("skillsDir", c.SkillsDir(), "your answer: a <name>.md or <name>/SKILL.md per skill, in "+c.SkillsDir()+"/ — relative to the directory mm is started from, the project root"+howTo)
291+ case on(c.Project):
292+ s.add("skillsDir", "no-skills", "no skills: add a .mm/skills/ folder and set skillsDir: .mm/skills to use some"+howTo)
293+ default:
294+ s.add("skillsDir", "no-skills", "no skills: add a skills/ folder and set skillsDir to use some"+howTo)
295+ }
296+}
297+
298+// decideSessions turns the sessions on when asked; otherwise the file has
299+// no sessions section and mm's default (off) applies.
300+func (s *Settings) decideSessions() {
301+ if !on(s.Choices.Sessions) {
302+ return
303+ }
304+ s.Sessions = append(s.Sessions,
305+ Setting{Key: "enabled", Value: "true", Reason: "your answer: every conversation is saved; /sessions lists them, /resume reopens one"},
306+ Setting{Key: "dir", Value: ".mm/sessions", Reason: "relative to the directory mm is started from: one folder per project"},
307+ )
308+}
309+
250 func (s *Settings) decideNotes() {310 func (s *Settings) decideNotes() {
251 if s.Family.Note != "" {311 if s.Family.Note != "" {
252 s.Notes = append(s.Notes, "This model is "+s.Family.Note+".")312 s.Notes = append(s.Notes, "This model is "+s.Family.Note+".")
modified internal/agentinit/flow.go +117 -5
@@ -8,6 +8,7 @@ import (
88 "regexp"
99 "strings"
1010
11+ "mm/internal/config"
1112 "mm/internal/elicit"
1213 "mm/internal/engine"
1314 )
@@ -23,7 +24,7 @@ type Option struct {
2324 // Step is one question of the wizard, ready to show: the line mode prints
2425 // it, the window draws it (internal/tui).
2526 type Step struct {
26- Key string // provider, endpoint, keyenv, model, family, usage, machine, yolo, name, file
27+ Key string // project, provider, endpoint, keyenv, model, family, usage, machine, yolo, sessions, skills, name, file
2728 Title string // the short name in the window's list of steps
2829 Prompt string // the question
2930 Kind elicit.Kind
@@ -31,17 +32,27 @@ type Step struct {
3132 Default string
3233 Value string // the answer given so far, "" when none
3334 Given bool // answered by a flag: shown, never asked
35+ // Multiline: a Text answer of several lines (mm skillcreate's body). The
36+ // window takes Enter as a new line and Ctrl-D as the answer; the line
37+ // mode reads to the end of the input.
38+ Multiline bool
39+ // Optional: a Text answer that may be left empty — the window shows an
40+ // empty field instead of the default, and Enter on it takes the
41+ // default, which stands for "none" (mm skillcreate's keywords: "-").
42+ Optional bool
3443 }
3544
3645 // stepKeys is the order of the questions. endpoint and keyenv are asked
3746 // for provider openai only (ticket 177): that provider is any
3847 // OpenAI-compatible service — OpenAI, Cerebras, Hugging Face… — so its
3948 // address and the variable holding its key are the user's to give.
40-var stepKeys = []string{"provider", "endpoint", "keyenv", "model", "family", "usage", "machine", "yolo", "name", "file"}
49+// project comes first (ticket 205): it decides where everything else goes.
50+var stepKeys = []string{"project", "provider", "endpoint", "keyenv", "model", "family", "usage", "machine", "yolo", "sessions", "skills", "name", "file"}
4151
4252 var stepTitles = map[string]string{
4353 "provider": "Server", "endpoint": "Endpoint", "keyenv": "API key", "model": "Model", "family": "Family",
4454 "usage": "Usage", "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File", "overwrite": "Overwrite",
55+ "project": "Project", "sessions": "Sessions", "skills": "Skills",
4556 }
4657
4758 // defaultKeyEnv is where the openai provider reads its key when the file
@@ -85,8 +96,10 @@ func NewFlow(ctx context.Context, p Presets, given Choices, output string) *Flow
8596 f.given[key], f.answer[key] = true, value
8697 }
8798 }
88- if given.Yolo != nil {
89- f.given["yolo"], f.answer["yolo"] = true, yesNo(*given.Yolo)
99+ for key, value := range map[string]*bool{"yolo": given.Yolo, "project": given.Project, "sessions": given.Sessions, "skills": given.Skills} {
100+ if value != nil {
101+ f.given[key], f.answer[key] = true, yesNo(*value)
102+ }
90103 }
91104 return f
92105 }
@@ -223,8 +236,20 @@ func (f *Flow) Step(key string) Step {
223236 }
224237 case "name":
225238 st.Prompt, st.Kind, st.Default = "Agent name?", elicit.Text, f.usageOrDefault()
239+ case "project":
240+ st = yesNoStep(st, "Keep everything in a .mm folder?",
241+ "creates .mm/ and writes .mm/agent.yaml; its paths start at the project root, and mm finds it on its own",
242+ "one agent.yaml, in the file you choose")
243+ case "sessions":
244+ st = yesNoStep(st, "Save the conversations?",
245+ "writes a sessions section: every conversation is saved in .mm/sessions, /resume reopens one",
246+ "no sessions section: nothing is saved (mm's default)")
247+ case "skills":
248+ st = yesNoStep(st, "Use skills?",
249+ "writes skillsDir: "+f.c.SkillsDir()+" and creates that folder",
250+ "skillsDir: no-skills")
226251 case "file":
227- st.Prompt, st.Kind, st.Default = "File to write?", elicit.Text, "agent.yaml"
252+ st.Prompt, st.Kind, st.Default = "File to write?", elicit.Text, f.defaultFile()
228253 case "overwrite":
229254 st.Prompt, st.Kind, st.Default = "Overwrite "+filepath.Base(f.output)+"?", elicit.Confirm, "no"
230255 st.Options = []Option{
@@ -235,6 +260,45 @@ func (f *Flow) Step(key string) Step {
235260 return st
236261 }
237262
263+// yesNoStep is a yes/no question whose default is no.
264+func yesNoStep(st Step, prompt, yes, no string) Step {
265+ st.Prompt, st.Kind, st.Default = prompt, elicit.Confirm, "no"
266+ st.Options = []Option{{Value: "yes", Label: "yes", Detail: yes}, {Value: "no", Label: "no", Detail: no}}
267+ return st
268+}
269+
270+// defaultFile is the file offered: .mm/agent.yaml for the Project layout,
271+// where mm looks for it after ./agent.yaml.
272+func (f *Flow) defaultFile() string {
273+ if on(f.c.Project) {
274+ return filepath.Join(config.ProjectDir, config.DefaultPath)
275+ }
276+ return config.DefaultPath
277+}
278+
279+// checkLayout refuses a Project layout whose file is not in .mm/: its paths
280+// are written from the project root, which holds only for a file directly
281+// in a .mm folder. "-" (stdout) is always fine: nothing is created.
282+func checkLayout(project bool, output string) error {
283+ if !project || output == "" || output == "-" {
284+ return nil
285+ }
286+ if filepath.Base(filepath.Dir(filepath.Clean(output))) != config.ProjectDir {
287+ return fmt.Errorf("file: %q is not in a %s folder — the Project layout writes .mm/agent.yaml (or answer no to the Project question)", output, config.ProjectDir)
288+ }
289+ return nil
290+}
291+
292+// setYesNo reads a yes/no answer into one of the optional switches.
293+func setYesNo(key, value string, target **bool) error {
294+ if value != "yes" && value != "no" {
295+ return fmt.Errorf("%s: answer yes or no, not %q", key, value)
296+ }
297+ answer := value == "yes"
298+ *target = &answer
299+ return nil
300+}
301+
238302 // Set answers one question. A new server forgets the model (and the
239303 // family, unless a flag gave it); a new model forgets the facts.
240304 func (f *Flow) Set(key, value string) error {
@@ -302,9 +366,24 @@ func (f *Flow) Set(key, value string) error {
302366 ask := value == "yes"
303367 yolo := !ask
304368 f.c.Yolo = &yolo
369+ case "project":
370+ if err := f.setProject(value); err != nil {
371+ return err
372+ }
373+ case "sessions":
374+ if err := setYesNo(key, value, &f.c.Sessions); err != nil {
375+ return err
376+ }
377+ case "skills":
378+ if err := setYesNo(key, value, &f.c.Skills); err != nil {
379+ return err
380+ }
305381 case "name":
306382 f.c.Name = value
307383 case "file":
384+ if err := checkLayout(on(f.c.Project), value); err != nil {
385+ return err
386+ }
308387 f.output = value
309388 f.forgetOverwrite()
310389 case "overwrite":
@@ -326,6 +405,27 @@ func (f *Flow) Set(key, value string) error {
326405 return nil
327406 }
328407
408+// setProject answers the layout question. A file named by -o must suit the
409+// layout; a file answered here for the other layout is asked again, with
410+// the other default.
411+func (f *Flow) setProject(value string) error {
412+ if f.given["file"] {
413+ if err := checkLayout(value == "yes", f.output); err != nil {
414+ return err
415+ }
416+ }
417+ before := on(f.c.Project)
418+ if err := setYesNo("project", value, &f.c.Project); err != nil {
419+ return err
420+ }
421+ if on(f.c.Project) != before && !f.given["file"] {
422+ delete(f.answer, "file")
423+ f.output = ""
424+ f.forgetOverwrite()
425+ }
426+ return nil
427+}
428+
329429 // Unset takes an answer back (the window's Esc), unless a flag gave it.
330430 func (f *Flow) Unset(key string) {
331431 if f.given[key] {
@@ -345,6 +445,18 @@ func (f *Flow) Unset(key string) {
345445 f.c.Machine = ""
346446 case "yolo":
347447 f.c.Yolo = nil
448+ case "project":
449+ f.c.Project = nil
450+ // The file offered depended on the layout.
451+ if !f.given["file"] {
452+ delete(f.answer, "file")
453+ f.output = ""
454+ f.forgetOverwrite()
455+ }
456+ case "sessions":
457+ f.c.Sessions = nil
458+ case "skills":
459+ f.c.Skills = nil
348460 case "name":
349461 f.c.Name = ""
350462 case "file":
@@ -8,6 +8,7 @@ import (
8 "regexp"8 "regexp"
9 "strings"9 "strings"
10 10
11+ "mm/internal/config"
11 "mm/internal/elicit"12 "mm/internal/elicit"
12 "mm/internal/engine"13 "mm/internal/engine"
13 )14 )
@@ -23,7 +24,7 @@ type Option struct {
23 // Step is one question of the wizard, ready to show: the line mode prints24 // Step is one question of the wizard, ready to show: the line mode prints
24 // it, the window draws it (internal/tui).25 // it, the window draws it (internal/tui).
25 type Step struct {26 type Step struct {
26- Key string // provider, endpoint, keyenv, model, family, usage, machine, yolo, name, file27+ Key string // project, provider, endpoint, keyenv, model, family, usage, machine, yolo, sessions, skills, name, file
27 Title string // the short name in the window's list of steps28 Title string // the short name in the window's list of steps
28 Prompt string // the question29 Prompt string // the question
29 Kind elicit.Kind30 Kind elicit.Kind
@@ -31,17 +32,27 @@ type Step struct {
31 Default string32 Default string
32 Value string // the answer given so far, "" when none33 Value string // the answer given so far, "" when none
33 Given bool // answered by a flag: shown, never asked34 Given bool // answered by a flag: shown, never asked
35+ // Multiline: a Text answer of several lines (mm skillcreate's body). The
36+ // window takes Enter as a new line and Ctrl-D as the answer; the line
37+ // mode reads to the end of the input.
38+ Multiline bool
39+ // Optional: a Text answer that may be left empty — the window shows an
40+ // empty field instead of the default, and Enter on it takes the
41+ // default, which stands for "none" (mm skillcreate's keywords: "-").
42+ Optional bool
34 }43 }
35 44
36 // stepKeys is the order of the questions. endpoint and keyenv are asked45 // stepKeys is the order of the questions. endpoint and keyenv are asked
37 // for provider openai only (ticket 177): that provider is any46 // for provider openai only (ticket 177): that provider is any
38 // OpenAI-compatible service — OpenAI, Cerebras, Hugging Face… — so its47 // OpenAI-compatible service — OpenAI, Cerebras, Hugging Face… — so its
39 // address and the variable holding its key are the user's to give.48 // address and the variable holding its key are the user's to give.
40-var stepKeys = []string{"provider", "endpoint", "keyenv", "model", "family", "usage", "machine", "yolo", "name", "file"}49+// project comes first (ticket 205): it decides where everything else goes.
50+var stepKeys = []string{"project", "provider", "endpoint", "keyenv", "model", "family", "usage", "machine", "yolo", "sessions", "skills", "name", "file"}
41 51
42 var stepTitles = map[string]string{52 var stepTitles = map[string]string{
43 "provider": "Server", "endpoint": "Endpoint", "keyenv": "API key", "model": "Model", "family": "Family",53 "provider": "Server", "endpoint": "Endpoint", "keyenv": "API key", "model": "Model", "family": "Family",
44 "usage": "Usage", "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File", "overwrite": "Overwrite",54 "usage": "Usage", "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File", "overwrite": "Overwrite",
55+ "project": "Project", "sessions": "Sessions", "skills": "Skills",
45 }56 }
46 57
47 // defaultKeyEnv is where the openai provider reads its key when the file58 // defaultKeyEnv is where the openai provider reads its key when the file
@@ -85,8 +96,10 @@ func NewFlow(ctx context.Context, p Presets, given Choices, output string) *Flow
85 f.given[key], f.answer[key] = true, value96 f.given[key], f.answer[key] = true, value
86 }97 }
87 }98 }
88- if given.Yolo != nil {99+ for key, value := range map[string]*bool{"yolo": given.Yolo, "project": given.Project, "sessions": given.Sessions, "skills": given.Skills} {
89- f.given["yolo"], f.answer["yolo"] = true, yesNo(*given.Yolo)100+ if value != nil {
101+ f.given[key], f.answer[key] = true, yesNo(*value)
102+ }
90 }103 }
91 return f104 return f
92 }105 }
@@ -223,8 +236,20 @@ func (f *Flow) Step(key string) Step {
223 }236 }
224 case "name":237 case "name":
225 st.Prompt, st.Kind, st.Default = "Agent name?", elicit.Text, f.usageOrDefault()238 st.Prompt, st.Kind, st.Default = "Agent name?", elicit.Text, f.usageOrDefault()
239+ case "project":
240+ st = yesNoStep(st, "Keep everything in a .mm folder?",
241+ "creates .mm/ and writes .mm/agent.yaml; its paths start at the project root, and mm finds it on its own",
242+ "one agent.yaml, in the file you choose")
243+ case "sessions":
244+ st = yesNoStep(st, "Save the conversations?",
245+ "writes a sessions section: every conversation is saved in .mm/sessions, /resume reopens one",
246+ "no sessions section: nothing is saved (mm's default)")
247+ case "skills":
248+ st = yesNoStep(st, "Use skills?",
249+ "writes skillsDir: "+f.c.SkillsDir()+" and creates that folder",
250+ "skillsDir: no-skills")
226 case "file":251 case "file":
227- st.Prompt, st.Kind, st.Default = "File to write?", elicit.Text, "agent.yaml"252+ st.Prompt, st.Kind, st.Default = "File to write?", elicit.Text, f.defaultFile()
228 case "overwrite":253 case "overwrite":
229 st.Prompt, st.Kind, st.Default = "Overwrite "+filepath.Base(f.output)+"?", elicit.Confirm, "no"254 st.Prompt, st.Kind, st.Default = "Overwrite "+filepath.Base(f.output)+"?", elicit.Confirm, "no"
230 st.Options = []Option{255 st.Options = []Option{
@@ -235,6 +260,45 @@ func (f *Flow) Step(key string) Step {
235 return st260 return st
236 }261 }
237 262
263+// yesNoStep is a yes/no question whose default is no.
264+func yesNoStep(st Step, prompt, yes, no string) Step {
265+ st.Prompt, st.Kind, st.Default = prompt, elicit.Confirm, "no"
266+ st.Options = []Option{{Value: "yes", Label: "yes", Detail: yes}, {Value: "no", Label: "no", Detail: no}}
267+ return st
268+}
269+
270+// defaultFile is the file offered: .mm/agent.yaml for the Project layout,
271+// where mm looks for it after ./agent.yaml.
272+func (f *Flow) defaultFile() string {
273+ if on(f.c.Project) {
274+ return filepath.Join(config.ProjectDir, config.DefaultPath)
275+ }
276+ return config.DefaultPath
277+}
278+
279+// checkLayout refuses a Project layout whose file is not in .mm/: its paths
280+// are written from the project root, which holds only for a file directly
281+// in a .mm folder. "-" (stdout) is always fine: nothing is created.
282+func checkLayout(project bool, output string) error {
283+ if !project || output == "" || output == "-" {
284+ return nil
285+ }
286+ if filepath.Base(filepath.Dir(filepath.Clean(output))) != config.ProjectDir {
287+ return fmt.Errorf("file: %q is not in a %s folder — the Project layout writes .mm/agent.yaml (or answer no to the Project question)", output, config.ProjectDir)
288+ }
289+ return nil
290+}
291+
292+// setYesNo reads a yes/no answer into one of the optional switches.
293+func setYesNo(key, value string, target **bool) error {
294+ if value != "yes" && value != "no" {
295+ return fmt.Errorf("%s: answer yes or no, not %q", key, value)
296+ }
297+ answer := value == "yes"
298+ *target = &answer
299+ return nil
300+}
301+
238 // Set answers one question. A new server forgets the model (and the302 // Set answers one question. A new server forgets the model (and the
239 // family, unless a flag gave it); a new model forgets the facts.303 // family, unless a flag gave it); a new model forgets the facts.
240 func (f *Flow) Set(key, value string) error {304 func (f *Flow) Set(key, value string) error {
@@ -302,9 +366,24 @@ func (f *Flow) Set(key, value string) error {
302 ask := value == "yes"366 ask := value == "yes"
303 yolo := !ask367 yolo := !ask
304 f.c.Yolo = &yolo368 f.c.Yolo = &yolo
369+ case "project":
370+ if err := f.setProject(value); err != nil {
371+ return err
372+ }
373+ case "sessions":
374+ if err := setYesNo(key, value, &f.c.Sessions); err != nil {
375+ return err
376+ }
377+ case "skills":
378+ if err := setYesNo(key, value, &f.c.Skills); err != nil {
379+ return err
380+ }
305 case "name":381 case "name":
306 f.c.Name = value382 f.c.Name = value
307 case "file":383 case "file":
384+ if err := checkLayout(on(f.c.Project), value); err != nil {
385+ return err
386+ }
308 f.output = value387 f.output = value
309 f.forgetOverwrite()388 f.forgetOverwrite()
310 case "overwrite":389 case "overwrite":
@@ -326,6 +405,27 @@ func (f *Flow) Set(key, value string) error {
326 return nil405 return nil
327 }406 }
328 407
408+// setProject answers the layout question. A file named by -o must suit the
409+// layout; a file answered here for the other layout is asked again, with
410+// the other default.
411+func (f *Flow) setProject(value string) error {
412+ if f.given["file"] {
413+ if err := checkLayout(value == "yes", f.output); err != nil {
414+ return err
415+ }
416+ }
417+ before := on(f.c.Project)
418+ if err := setYesNo("project", value, &f.c.Project); err != nil {
419+ return err
420+ }
421+ if on(f.c.Project) != before && !f.given["file"] {
422+ delete(f.answer, "file")
423+ f.output = ""
424+ f.forgetOverwrite()
425+ }
426+ return nil
427+}
428+
329 // Unset takes an answer back (the window's Esc), unless a flag gave it.429 // Unset takes an answer back (the window's Esc), unless a flag gave it.
330 func (f *Flow) Unset(key string) {430 func (f *Flow) Unset(key string) {
331 if f.given[key] {431 if f.given[key] {
@@ -345,6 +445,18 @@ func (f *Flow) Unset(key string) {
345 f.c.Machine = ""445 f.c.Machine = ""
346 case "yolo":446 case "yolo":
347 f.c.Yolo = nil447 f.c.Yolo = nil
448+ case "project":
449+ f.c.Project = nil
450+ // The file offered depended on the layout.
451+ if !f.given["file"] {
452+ delete(f.answer, "file")
453+ f.output = ""
454+ f.forgetOverwrite()
455+ }
456+ case "sessions":
457+ f.c.Sessions = nil
458+ case "skills":
459+ f.c.Skills = nil
348 case "name":460 case "name":
349 f.c.Name = ""461 f.c.Name = ""
350 case "file":462 case "file":
modified internal/agentinit/flow_test.go +1 -1
@@ -19,7 +19,7 @@ func TestFlowSteps(t *testing.T) {
1919 p, _ := LoadPresets()
2020 base := llmmanStub(t)
2121 f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: base}, "")
22- if got := keys(f.Pending()); got != "model,family,usage,machine,yolo,name,file" {
22+ if got := keys(f.Pending()); got != "project,model,family,usage,machine,yolo,sessions,skills,name,file" {
2323 t.Fatalf("pending = %s", got)
2424 }
2525 if st := f.Step("model"); st.Default != "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" || len(st.Options) != 1 {
@@ -19,7 +19,7 @@ func TestFlowSteps(t *testing.T) {
19 p, _ := LoadPresets()19 p, _ := LoadPresets()
20 base := llmmanStub(t)20 base := llmmanStub(t)
21 f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: base}, "")21 f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: base}, "")
22- if got := keys(f.Pending()); got != "model,family,usage,machine,yolo,name,file" {22+ if got := keys(f.Pending()); got != "project,model,family,usage,machine,yolo,sessions,skills,name,file" {
23 t.Fatalf("pending = %s", got)23 t.Fatalf("pending = %s", got)
24 }24 }
25 if st := f.Step("model"); st.Default != "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" || len(st.Options) != 1 {25 if st := f.Step("model"); st.Default != "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M" || len(st.Options) != 1 {
added internal/agentinit/layout_test.go +165 -0
new file mode 100644
@@ -0,0 +1,165 @@
1+package agentinit
2+
3+import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "mm/internal/config"
12+)
13+
14+// preview runs `mm init --yes --line … -o -` — what the mm-init-presets
15+// skill's check.sh runs — and returns stdout, the stub's address replaced
16+// by a fixed one so the text can be compared.
17+func preview(t *testing.T, extra ...string) (string, int) {
18+ t.Helper()
19+ keepConfig(t)
20+ base := llmmanStub(t)
21+ args := append([]string{"--yes", "--line", "--provider", "llmman", "--base-url", base, "--machine", "small", "-o", "-"}, extra...)
22+ var stdout bytes.Buffer
23+ code := Main(args, strings.NewReader(""), &stdout)
24+ return strings.ReplaceAll(stdout.String(), base, "http://stub/v1"), code
25+}
26+
27+// Without the new flags, the file is byte for byte the one mm init wrote
28+// before ticket 205. testdata/preview-*.yaml were produced by that code.
29+func TestPreviewWithoutTheNewFlagsIsUnchanged(t *testing.T) {
30+ for _, usage := range []string{"developer", "researcher"} {
31+ want, err := os.ReadFile(filepath.Join("testdata", "preview-"+usage+".yaml"))
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ got, code := preview(t, "--usage", usage)
36+ if code != 0 || got != string(want) {
37+ t.Errorf("usage %s: exit %d, the preview changed:\n%s", usage, code, got)
38+ }
39+ }
40+}
41+
42+// inTempDir runs the test in an empty directory, the project root.
43+func inTempDir(t *testing.T) string {
44+ t.Helper()
45+ dir := t.TempDir()
46+ t.Chdir(dir)
47+ return dir
48+}
49+
50+func TestProjectLayoutWritesEverythingInTheMMFolder(t *testing.T) {
51+ inTempDir(t)
52+ keepConfig(t)
53+ var stdout bytes.Buffer
54+ code := Main([]string{"--yes", "--provider", "llmman", "--base-url", llmmanStub(t),
55+ "--project", "yes", "--sessions", "yes", "--skills", "yes"}, strings.NewReader(""), &stdout)
56+ if code != 0 {
57+ t.Fatalf("exit %d:\n%s", code, stdout.String())
58+ }
59+ data, err := os.ReadFile(filepath.Join(".mm", "agent.yaml"))
60+ if err != nil {
61+ t.Fatalf(".mm/agent.yaml not written: %v\n%s", err, stdout.String())
62+ }
63+ text := string(data)
64+ for _, want := range []string{"\nskillsDir: .mm/skills\n", "\nsessions:\n", " enabled: true\n", " dir: .mm/sessions\n", "started from the project root"} {
65+ if !strings.Contains(text, want) {
66+ t.Errorf("the file lacks %q:\n%s", want, text)
67+ }
68+ }
69+ if info, err := os.Stat(filepath.Join(".mm", "skills")); err != nil || !info.IsDir() {
70+ t.Errorf(".mm/skills not created: %v", err)
71+ }
72+ if !strings.Contains(stdout.String(), "Next: mm (or mm tui)") {
73+ t.Errorf("the next step should be a plain mm:\n%s", stdout.String())
74+ }
75+ // mm itself finds it with no argument (ticket 204) and reads the sessions.
76+ path, err := config.Load("")
77+ if err != nil || path != filepath.Join(".mm", "agent.yaml") || !config.Cfg.Sessions.Enabled {
78+ t.Errorf("config.Load(\"\") = %q, %v, sessions %v", path, err, config.Cfg.Sessions.Enabled)
79+ }
80+}
81+
82+func TestSkillsWithoutTheProjectLayoutUseASkillsFolder(t *testing.T) {
83+ inTempDir(t)
84+ keepConfig(t)
85+ var stdout bytes.Buffer
86+ code := Main([]string{"--yes", "--provider", "llmman", "--base-url", llmmanStub(t), "--skills", "yes"}, strings.NewReader(""), &stdout)
87+ if code != 0 {
88+ t.Fatalf("exit %d:\n%s", code, stdout.String())
89+ }
90+ data, _ := os.ReadFile("agent.yaml")
91+ if !strings.Contains(string(data), "\nskillsDir: skills\n") || strings.Contains(string(data), "\nsessions:") {
92+ t.Errorf("want skillsDir: skills and no sessions section:\n%s", data)
93+ }
94+ if info, err := os.Stat("skills"); err != nil || !info.IsDir() {
95+ t.Errorf("skills/ not created: %v", err)
96+ }
97+ if _, err := os.Stat(".mm"); err == nil {
98+ t.Error(".mm created without the Project layout")
99+ }
100+}
101+
102+// -o - is a preview: whatever the answers, nothing is created.
103+func TestPreviewCreatesNothing(t *testing.T) {
104+ dir := inTempDir(t)
105+ text, code := preview(t, "--project", "yes", "--sessions", "yes", "--skills", "yes")
106+ if code != 0 || !strings.Contains(text, "skillsDir: .mm/skills") {
107+ t.Fatalf("exit %d:\n%s", code, text)
108+ }
109+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
110+ t.Errorf("the preview created %v", entries)
111+ }
112+}
113+
114+func TestProjectLayoutRefusesAFileOutsideTheMMFolder(t *testing.T) {
115+ inTempDir(t)
116+ keepConfig(t)
117+ var stdout bytes.Buffer
118+ code := Main([]string{"--yes", "--provider", "llmman", "--base-url", llmmanStub(t), "--project", "yes", "-o", "agent.yaml"}, strings.NewReader(""), &stdout)
119+ if code == 0 || !strings.Contains(stdout.String(), "is not in a .mm folder") {
120+ t.Errorf("exit %d, want a refusal:\n%s", code, stdout.String())
121+ }
122+ if exists("agent.yaml") || exists(".mm") {
123+ t.Error("something was written")
124+ }
125+}
126+
127+func TestYesNoFlagsRefuseOtherValues(t *testing.T) {
128+ keepConfig(t)
129+ var stdout bytes.Buffer
130+ if code := Main([]string{"--sessions", "maybe"}, strings.NewReader(""), &stdout); code != 2 || !strings.Contains(stdout.String(), `--sessions: answer yes or no, not "maybe"`) {
131+ t.Errorf("exit %d:\n%s", code, stdout.String())
132+ }
133+}
134+
135+// The file offered follows the layout, and changing the layout asks the
136+// file again.
137+func TestTheFileQuestionFollowsTheLayout(t *testing.T) {
138+ keepConfig(t)
139+ p, _ := LoadPresets()
140+ f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: llmmanStub(t)}, "")
141+ if st := f.Pending()[0]; st.Key != "project" || st.Default != "no" {
142+ t.Fatalf("first question %q (default %q), want project (no)", st.Key, st.Default)
143+ }
144+ if got := f.Step("file").Default; got != "agent.yaml" {
145+ t.Errorf("file default %q, want agent.yaml", got)
146+ }
147+ if err := f.Set("project", "yes"); err != nil {
148+ t.Fatal(err)
149+ }
150+ if got := f.Step("file").Default; got != filepath.Join(".mm", "agent.yaml") {
151+ t.Errorf("file default %q, want .mm/agent.yaml", got)
152+ }
153+ if err := f.Set("file", "other.yaml"); err == nil {
154+ t.Error("a file outside .mm/ was accepted for the Project layout")
155+ }
156+ if err := f.Set("file", filepath.Join(".mm", "agent.yaml")); err != nil {
157+ t.Fatal(err)
158+ }
159+ if err := f.Set("project", "no"); err != nil {
160+ t.Fatal(err)
161+ }
162+ if _, _, out := f.Result(); out != "" {
163+ t.Errorf("the file %q answered for the Project layout was kept", out)
164+ }
165+}
new file mode 100644
@@ -0,0 +1,165 @@
1+package agentinit
2+
3+import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "mm/internal/config"
12+)
13+
14+// preview runs `mm init --yes --line … -o -` — what the mm-init-presets
15+// skill's check.sh runs — and returns stdout, the stub's address replaced
16+// by a fixed one so the text can be compared.
17+func preview(t *testing.T, extra ...string) (string, int) {
18+ t.Helper()
19+ keepConfig(t)
20+ base := llmmanStub(t)
21+ args := append([]string{"--yes", "--line", "--provider", "llmman", "--base-url", base, "--machine", "small", "-o", "-"}, extra...)
22+ var stdout bytes.Buffer
23+ code := Main(args, strings.NewReader(""), &stdout)
24+ return strings.ReplaceAll(stdout.String(), base, "http://stub/v1"), code
25+}
26+
27+// Without the new flags, the file is byte for byte the one mm init wrote
28+// before ticket 205. testdata/preview-*.yaml were produced by that code.
29+func TestPreviewWithoutTheNewFlagsIsUnchanged(t *testing.T) {
30+ for _, usage := range []string{"developer", "researcher"} {
31+ want, err := os.ReadFile(filepath.Join("testdata", "preview-"+usage+".yaml"))
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ got, code := preview(t, "--usage", usage)
36+ if code != 0 || got != string(want) {
37+ t.Errorf("usage %s: exit %d, the preview changed:\n%s", usage, code, got)
38+ }
39+ }
40+}
41+
42+// inTempDir runs the test in an empty directory, the project root.
43+func inTempDir(t *testing.T) string {
44+ t.Helper()
45+ dir := t.TempDir()
46+ t.Chdir(dir)
47+ return dir
48+}
49+
50+func TestProjectLayoutWritesEverythingInTheMMFolder(t *testing.T) {
51+ inTempDir(t)
52+ keepConfig(t)
53+ var stdout bytes.Buffer
54+ code := Main([]string{"--yes", "--provider", "llmman", "--base-url", llmmanStub(t),
55+ "--project", "yes", "--sessions", "yes", "--skills", "yes"}, strings.NewReader(""), &stdout)
56+ if code != 0 {
57+ t.Fatalf("exit %d:\n%s", code, stdout.String())
58+ }
59+ data, err := os.ReadFile(filepath.Join(".mm", "agent.yaml"))
60+ if err != nil {
61+ t.Fatalf(".mm/agent.yaml not written: %v\n%s", err, stdout.String())
62+ }
63+ text := string(data)
64+ for _, want := range []string{"\nskillsDir: .mm/skills\n", "\nsessions:\n", " enabled: true\n", " dir: .mm/sessions\n", "started from the project root"} {
65+ if !strings.Contains(text, want) {
66+ t.Errorf("the file lacks %q:\n%s", want, text)
67+ }
68+ }
69+ if info, err := os.Stat(filepath.Join(".mm", "skills")); err != nil || !info.IsDir() {
70+ t.Errorf(".mm/skills not created: %v", err)
71+ }
72+ if !strings.Contains(stdout.String(), "Next: mm (or mm tui)") {
73+ t.Errorf("the next step should be a plain mm:\n%s", stdout.String())
74+ }
75+ // mm itself finds it with no argument (ticket 204) and reads the sessions.
76+ path, err := config.Load("")
77+ if err != nil || path != filepath.Join(".mm", "agent.yaml") || !config.Cfg.Sessions.Enabled {
78+ t.Errorf("config.Load(\"\") = %q, %v, sessions %v", path, err, config.Cfg.Sessions.Enabled)
79+ }
80+}
81+
82+func TestSkillsWithoutTheProjectLayoutUseASkillsFolder(t *testing.T) {
83+ inTempDir(t)
84+ keepConfig(t)
85+ var stdout bytes.Buffer
86+ code := Main([]string{"--yes", "--provider", "llmman", "--base-url", llmmanStub(t), "--skills", "yes"}, strings.NewReader(""), &stdout)
87+ if code != 0 {
88+ t.Fatalf("exit %d:\n%s", code, stdout.String())
89+ }
90+ data, _ := os.ReadFile("agent.yaml")
91+ if !strings.Contains(string(data), "\nskillsDir: skills\n") || strings.Contains(string(data), "\nsessions:") {
92+ t.Errorf("want skillsDir: skills and no sessions section:\n%s", data)
93+ }
94+ if info, err := os.Stat("skills"); err != nil || !info.IsDir() {
95+ t.Errorf("skills/ not created: %v", err)
96+ }
97+ if _, err := os.Stat(".mm"); err == nil {
98+ t.Error(".mm created without the Project layout")
99+ }
100+}
101+
102+// -o - is a preview: whatever the answers, nothing is created.
103+func TestPreviewCreatesNothing(t *testing.T) {
104+ dir := inTempDir(t)
105+ text, code := preview(t, "--project", "yes", "--sessions", "yes", "--skills", "yes")
106+ if code != 0 || !strings.Contains(text, "skillsDir: .mm/skills") {
107+ t.Fatalf("exit %d:\n%s", code, text)
108+ }
109+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
110+ t.Errorf("the preview created %v", entries)
111+ }
112+}
113+
114+func TestProjectLayoutRefusesAFileOutsideTheMMFolder(t *testing.T) {
115+ inTempDir(t)
116+ keepConfig(t)
117+ var stdout bytes.Buffer
118+ code := Main([]string{"--yes", "--provider", "llmman", "--base-url", llmmanStub(t), "--project", "yes", "-o", "agent.yaml"}, strings.NewReader(""), &stdout)
119+ if code == 0 || !strings.Contains(stdout.String(), "is not in a .mm folder") {
120+ t.Errorf("exit %d, want a refusal:\n%s", code, stdout.String())
121+ }
122+ if exists("agent.yaml") || exists(".mm") {
123+ t.Error("something was written")
124+ }
125+}
126+
127+func TestYesNoFlagsRefuseOtherValues(t *testing.T) {
128+ keepConfig(t)
129+ var stdout bytes.Buffer
130+ if code := Main([]string{"--sessions", "maybe"}, strings.NewReader(""), &stdout); code != 2 || !strings.Contains(stdout.String(), `--sessions: answer yes or no, not "maybe"`) {
131+ t.Errorf("exit %d:\n%s", code, stdout.String())
132+ }
133+}
134+
135+// The file offered follows the layout, and changing the layout asks the
136+// file again.
137+func TestTheFileQuestionFollowsTheLayout(t *testing.T) {
138+ keepConfig(t)
139+ p, _ := LoadPresets()
140+ f := NewFlow(context.Background(), p, Choices{Provider: "llmman", BaseURL: llmmanStub(t)}, "")
141+ if st := f.Pending()[0]; st.Key != "project" || st.Default != "no" {
142+ t.Fatalf("first question %q (default %q), want project (no)", st.Key, st.Default)
143+ }
144+ if got := f.Step("file").Default; got != "agent.yaml" {
145+ t.Errorf("file default %q, want agent.yaml", got)
146+ }
147+ if err := f.Set("project", "yes"); err != nil {
148+ t.Fatal(err)
149+ }
150+ if got := f.Step("file").Default; got != filepath.Join(".mm", "agent.yaml") {
151+ t.Errorf("file default %q, want .mm/agent.yaml", got)
152+ }
153+ if err := f.Set("file", "other.yaml"); err == nil {
154+ t.Error("a file outside .mm/ was accepted for the Project layout")
155+ }
156+ if err := f.Set("file", filepath.Join(".mm", "agent.yaml")); err != nil {
157+ t.Fatal(err)
158+ }
159+ if err := f.Set("project", "no"); err != nil {
160+ t.Fatal(err)
161+ }
162+ if _, _, out := f.Result(); out != "" {
163+ t.Errorf("the file %q answered for the Project layout was kept", out)
164+ }
165+}
modified internal/agentinit/openai_flow_test.go +2 -2
@@ -53,8 +53,8 @@ func openaiFlow(t *testing.T, given Choices) *Flow {
5353 // other providers they are not.
5454 func TestOpenAIAsksTheEndpointAndTheKeyVariable(t *testing.T) {
5555 f := openaiFlow(t, Choices{})
56- if got := keys(f.Pending()); !strings.HasPrefix(got, "endpoint,keyenv,model,") {
57- t.Errorf("pending = %s, want endpoint, keyenv, then the model", got)
56+ if got := keys(f.Pending()); !strings.HasPrefix(got, "project,endpoint,keyenv,model,") {
57+ t.Errorf("pending = %s, want project, endpoint, keyenv, then the model", got)
5858 }
5959 if st := f.Step("endpoint"); st.Kind != elicit.Text || st.Default != "https://api.openai.com/v1" {
6060 t.Errorf("endpoint step: %+v", st)
@@ -53,8 +53,8 @@ func openaiFlow(t *testing.T, given Choices) *Flow {
53 // other providers they are not.53 // other providers they are not.
54 func TestOpenAIAsksTheEndpointAndTheKeyVariable(t *testing.T) {54 func TestOpenAIAsksTheEndpointAndTheKeyVariable(t *testing.T) {
55 f := openaiFlow(t, Choices{})55 f := openaiFlow(t, Choices{})
56- if got := keys(f.Pending()); !strings.HasPrefix(got, "endpoint,keyenv,model,") {56+ if got := keys(f.Pending()); !strings.HasPrefix(got, "project,endpoint,keyenv,model,") {
57- t.Errorf("pending = %s, want endpoint, keyenv, then the model", got)57+ t.Errorf("pending = %s, want project, endpoint, keyenv, then the model", got)
58 }58 }
59 if st := f.Step("endpoint"); st.Kind != elicit.Text || st.Default != "https://api.openai.com/v1" {59 if st := f.Step("endpoint"); st.Kind != elicit.Text || st.Default != "https://api.openai.com/v1" {
60 t.Errorf("endpoint step: %+v", st)60 t.Errorf("endpoint step: %+v", st)
modified internal/agentinit/presets.go +9 -2
@@ -40,8 +40,15 @@ type Family struct {
4040 Thinking Sampling `yaml:"thinking"`
4141 Precise *float64 `yaml:"precise"`
4242 MaxTokensKey string `yaml:"maxTokensKey"` // "" = max_tokens
43- Note string `yaml:"note"`
44- Source string `yaml:"source"`
43+ // ToolFormat is written as toolFormat when not empty: how the tools reach
44+ // a model whose chat template does not read the OpenAI tools (ticket 207).
45+ ToolFormat string `yaml:"toolFormat"`
46+ // ThinkingOff writes the reasoning switch off whatever the usage asks:
47+ // for a family measured to misbehave with reasoning on (SmolLM3 repeats
48+ // its tool call instead of answering, ticket 207).
49+ ThinkingOff bool `yaml:"thinkingOff"`
50+ Note string `yaml:"note"`
51+ Source string `yaml:"source"`
4552 // Origin says how General, Thinking and Precise were obtained, keyed
4653 // "general", "thinking", "precise": OriginMeasured, OriginCard or
4754 // OriginRule. The generated file tags their numbers with it.
@@ -40,8 +40,15 @@ type Family struct {
40 Thinking Sampling `yaml:"thinking"`40 Thinking Sampling `yaml:"thinking"`
41 Precise *float64 `yaml:"precise"`41 Precise *float64 `yaml:"precise"`
42 MaxTokensKey string `yaml:"maxTokensKey"` // "" = max_tokens42 MaxTokensKey string `yaml:"maxTokensKey"` // "" = max_tokens
43- Note string `yaml:"note"`43+ // ToolFormat is written as toolFormat when not empty: how the tools reach
44- Source string `yaml:"source"`44+ // a model whose chat template does not read the OpenAI tools (ticket 207).
45+ ToolFormat string `yaml:"toolFormat"`
46+ // ThinkingOff writes the reasoning switch off whatever the usage asks:
47+ // for a family measured to misbehave with reasoning on (SmolLM3 repeats
48+ // its tool call instead of answering, ticket 207).
49+ ThinkingOff bool `yaml:"thinkingOff"`
50+ Note string `yaml:"note"`
51+ Source string `yaml:"source"`
45 // Origin says how General, Thinking and Precise were obtained, keyed52 // Origin says how General, Thinking and Precise were obtained, keyed
46 // "general", "thinking", "precise": OriginMeasured, OriginCard or53 // "general", "thinking", "precise": OriginMeasured, OriginCard or
47 // OriginRule. The generated file tags their numbers with it.54 // OriginRule. The generated file tags their numbers with it.
modified internal/agentinit/presets.yaml +19 -0
@@ -19,6 +19,13 @@
1919 # `rule` (a generic rule of thumb, nothing specific to the family). The
2020 # generated file tags its numbers with it (ticket 121): after a model or
2121 # quantization change, a measured number is re-measured, a card number re-read.
22+#
23+# Two optional family fields change what mm init writes for every model of the
24+# family (ticket 207): `toolFormat` (e.g. xml_tools) is written as toolFormat
25+# for a usage with tools — for a chat template that does not read the OpenAI
26+# tools; `thinkingOff: true` writes the reasoning switch off whatever the usage
27+# asks — for a family measured to misbehave with reasoning on. Both need a
28+# measurement, recorded in the family's `note` or `source`.
2229
2330 families:
2431 - name: qwen-coder
@@ -135,6 +142,18 @@ families:
135142 source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
136143 origin: {general: card, precise: card}
137144
145+ - name: smollm3
146+ match: [smollm3]
147+ reasoning: enable_thinking
148+ general: {temperature: 0.6, top_p: 0.95}
149+ thinking: {temperature: 0.6, top_p: 0.95}
150+ precise: 0.2
151+ toolFormat: xml_tools
152+ thinkingOff: true
153+ note: "kept with reasoning off (enable_thinking: false), whatever the usage: measured 2026-11-13 on SmolLM3-3B Q4_K_M with toolFormat: xml_tools, with reasoning on it repeats its call after the tool's answer (3 runs out of 3), with it off it answers (6 out of 6). Tasks of several steps stay fragile at 3B"
154+ source: "SmolLM3-3B model card (\"We recommend setting temperature=0.6 and top_p=0.95 in the sampling parameters\"; generation_config.json: temperature 0.6, top_p 0.95) — one recommendation, used for both modes; reasoning on by default in the template (enable_thinking defaults to true; a /think or /no_think flag in the system prompt overrides the kwarg); precise: rule"
155+ origin: {general: card, thinking: card, precise: rule}
156+
138157 - name: other
139158 match: []
140159 reasoning: none
@@ -19,6 +19,13 @@
19 # `rule` (a generic rule of thumb, nothing specific to the family). The19 # `rule` (a generic rule of thumb, nothing specific to the family). The
20 # generated file tags its numbers with it (ticket 121): after a model or20 # generated file tags its numbers with it (ticket 121): after a model or
21 # quantization change, a measured number is re-measured, a card number re-read.21 # quantization change, a measured number is re-measured, a card number re-read.
22+#
23+# Two optional family fields change what mm init writes for every model of the
24+# family (ticket 207): `toolFormat` (e.g. xml_tools) is written as toolFormat
25+# for a usage with tools — for a chat template that does not read the OpenAI
26+# tools; `thinkingOff: true` writes the reasoning switch off whatever the usage
27+# asks — for a family measured to misbehave with reasoning on. Both need a
28+# measurement, recorded in the family's `note` or `source`.
22 29
23 families:30 families:
24 - name: qwen-coder31 - name: qwen-coder
@@ -135,6 +142,18 @@ families:
135 source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"142 source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository"
136 origin: {general: card, precise: card}143 origin: {general: card, precise: card}
137 144
145+ - name: smollm3
146+ match: [smollm3]
147+ reasoning: enable_thinking
148+ general: {temperature: 0.6, top_p: 0.95}
149+ thinking: {temperature: 0.6, top_p: 0.95}
150+ precise: 0.2
151+ toolFormat: xml_tools
152+ thinkingOff: true
153+ note: "kept with reasoning off (enable_thinking: false), whatever the usage: measured 2026-11-13 on SmolLM3-3B Q4_K_M with toolFormat: xml_tools, with reasoning on it repeats its call after the tool's answer (3 runs out of 3), with it off it answers (6 out of 6). Tasks of several steps stay fragile at 3B"
154+ source: "SmolLM3-3B model card (\"We recommend setting temperature=0.6 and top_p=0.95 in the sampling parameters\"; generation_config.json: temperature 0.6, top_p 0.95) — one recommendation, used for both modes; reasoning on by default in the template (enable_thinking defaults to true; a /think or /no_think flag in the system prompt overrides the kwarg); precise: rule"
155+ origin: {general: card, thinking: card, precise: rule}
156+
138 - name: other157 - name: other
139 match: []158 match: []
140 reasoning: none159 reasoning: none
modified internal/agentinit/presets_test.go +42 -1
@@ -1,6 +1,11 @@
11 package agentinit
22
3-import "testing"
3+import (
4+ "strings"
5+ "testing"
6+
7+ "mm/internal/config"
8+)
49
510 func TestPresetsLoadAndEveryEntryIsSourced(t *testing.T) {
611 p, err := LoadPresets()
@@ -57,6 +62,9 @@ func TestFamilyOf(t *testing.T) {
5762 "hf.co/unsloth/gpt-oss-20b-GGUF:Q4_K_M": "gpt-oss",
5863 "llama3.2:3b": "llama",
5964 "mistral-small3.1": "mistral",
65+ "hf.co/unsloth/SmolLM3-3B-GGUF:Q4_K_M": "smollm3",
66+ "hf.co/unsloth/SmolLM3-3B-128K-GGUF:Q4_K_M": "smollm3",
67+ "hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M": "other",
6068 "some/unknown-model": "other",
6169 } {
6270 if got := p.FamilyOf(model).Name; got != want {
@@ -66,4 +74,37 @@ func TestFamilyOf(t *testing.T) {
6674 if got := p.FamilyOf("custom:latest", "qwen35").Name; got != "qwen" {
6775 t.Errorf("the server's family name is not used: %s", got)
6876 }
77+ if got := p.FamilyOf("my-small-model:latest", "smollm3").Name; got != "smollm3" {
78+ t.Errorf("the server's family name smollm3 is not used: %s", got)
79+ }
80+}
81+
82+// A family's toolFormat (ticket 207) is written for a usage with tools only,
83+// and it is a value mm accepts.
84+func TestFamilyToolFormat(t *testing.T) {
85+ p, _ := LoadPresets()
86+ for _, f := range p.Families {
87+ if f.ToolFormat != "" && f.ToolFormat != config.ToolFormatOpenAI && f.ToolFormat != config.ToolFormatXML {
88+ t.Errorf("family %s: toolFormat %q is not a value mm accepts", f.Name, f.ToolFormat)
89+ }
90+ }
91+ text := func(usage string) string {
92+ return Render(Decide(p, Choices{Provider: "llmman", Model: "hf.co/unsloth/SmolLM3-3B-GGUF:Q4_K_M", Usage: usage, Machine: "small", Name: "bob"}, Facts{}))
93+ }
94+ // thinkingOff: developer asks for reasoning, the family keeps it off.
95+ if dev := text("developer"); !strings.Contains(dev, " enable_thinking: false\n") || !strings.Contains(dev, "is kept off") {
96+ t.Errorf("developer on SmolLM3: reasoning not written off:\n%s", dev)
97+ }
98+ if !strings.Contains(Render(Decide(p, Choices{Provider: "llmman", Model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M", Usage: "developer", Machine: "small", Name: "bob"}, Facts{})), " enable_thinking: true\n") {
99+ t.Error("a family without thinkingOff lost its reasoning")
100+ }
101+ if !strings.Contains(text("developer"), "\ntoolFormat: xml_tools\n") {
102+ t.Errorf("developer on SmolLM3: no toolFormat line:\n%s", text("developer"))
103+ }
104+ if strings.Contains(text("translator"), "\ntoolFormat:") {
105+ t.Error("translator has no tools: toolFormat written anyway")
106+ }
107+ if strings.Contains(Render(Decide(p, Choices{Provider: "llmman", Model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M", Usage: "developer", Machine: "small", Name: "bob"}, Facts{})), "\ntoolFormat:") {
108+ t.Error("a family without toolFormat got the line")
109+ }
69110 }
@@ -1,6 +1,11 @@
1 package agentinit1 package agentinit
2 2
3-import "testing"3+import (
4+ "strings"
5+ "testing"
6+
7+ "mm/internal/config"
8+)
4 9
5 func TestPresetsLoadAndEveryEntryIsSourced(t *testing.T) {10 func TestPresetsLoadAndEveryEntryIsSourced(t *testing.T) {
6 p, err := LoadPresets()11 p, err := LoadPresets()
@@ -57,6 +62,9 @@ func TestFamilyOf(t *testing.T) {
57 "hf.co/unsloth/gpt-oss-20b-GGUF:Q4_K_M": "gpt-oss",62 "hf.co/unsloth/gpt-oss-20b-GGUF:Q4_K_M": "gpt-oss",
58 "llama3.2:3b": "llama",63 "llama3.2:3b": "llama",
59 "mistral-small3.1": "mistral",64 "mistral-small3.1": "mistral",
65+ "hf.co/unsloth/SmolLM3-3B-GGUF:Q4_K_M": "smollm3",
66+ "hf.co/unsloth/SmolLM3-3B-128K-GGUF:Q4_K_M": "smollm3",
67+ "hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M": "other",
60 "some/unknown-model": "other",68 "some/unknown-model": "other",
61 } {69 } {
62 if got := p.FamilyOf(model).Name; got != want {70 if got := p.FamilyOf(model).Name; got != want {
@@ -66,4 +74,37 @@ func TestFamilyOf(t *testing.T) {
66 if got := p.FamilyOf("custom:latest", "qwen35").Name; got != "qwen" {74 if got := p.FamilyOf("custom:latest", "qwen35").Name; got != "qwen" {
67 t.Errorf("the server's family name is not used: %s", got)75 t.Errorf("the server's family name is not used: %s", got)
68 }76 }
77+ if got := p.FamilyOf("my-small-model:latest", "smollm3").Name; got != "smollm3" {
78+ t.Errorf("the server's family name smollm3 is not used: %s", got)
79+ }
80+}
81+
82+// A family's toolFormat (ticket 207) is written for a usage with tools only,
83+// and it is a value mm accepts.
84+func TestFamilyToolFormat(t *testing.T) {
85+ p, _ := LoadPresets()
86+ for _, f := range p.Families {
87+ if f.ToolFormat != "" && f.ToolFormat != config.ToolFormatOpenAI && f.ToolFormat != config.ToolFormatXML {
88+ t.Errorf("family %s: toolFormat %q is not a value mm accepts", f.Name, f.ToolFormat)
89+ }
90+ }
91+ text := func(usage string) string {
92+ return Render(Decide(p, Choices{Provider: "llmman", Model: "hf.co/unsloth/SmolLM3-3B-GGUF:Q4_K_M", Usage: usage, Machine: "small", Name: "bob"}, Facts{}))
93+ }
94+ // thinkingOff: developer asks for reasoning, the family keeps it off.
95+ if dev := text("developer"); !strings.Contains(dev, " enable_thinking: false\n") || !strings.Contains(dev, "is kept off") {
96+ t.Errorf("developer on SmolLM3: reasoning not written off:\n%s", dev)
97+ }
98+ if !strings.Contains(Render(Decide(p, Choices{Provider: "llmman", Model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M", Usage: "developer", Machine: "small", Name: "bob"}, Facts{})), " enable_thinking: true\n") {
99+ t.Error("a family without thinkingOff lost its reasoning")
100+ }
101+ if !strings.Contains(text("developer"), "\ntoolFormat: xml_tools\n") {
102+ t.Errorf("developer on SmolLM3: no toolFormat line:\n%s", text("developer"))
103+ }
104+ if strings.Contains(text("translator"), "\ntoolFormat:") {
105+ t.Error("translator has no tools: toolFormat written anyway")
106+ }
107+ if strings.Contains(Render(Decide(p, Choices{Provider: "llmman", Model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M", Usage: "developer", Machine: "small", Name: "bob"}, Facts{})), "\ntoolFormat:") {
108+ t.Error("a family without toolFormat got the line")
109+ }
69 }110 }
modified internal/agentinit/render.go +11 -1
@@ -26,7 +26,11 @@ func Render(s Settings) string {
2626 fmt.Fprintf(&b, "# model family: %s\n# usage: %s\n# machine: %s — %s\n", s.Family.Name, u.Name, s.Machine.Name, s.Machine.Description)
2727 fmt.Fprintf(&b, "# model facts: %s\n", factsLine(s))
2828 writeLegend(&b, s)
29- fmt.Fprintf(&b, "#\n# mm %s or mm -tui %s\n\n", "-config <this file>", "<this file>")
29+ if on(c.Project) {
30+ b.WriteString("#\n# mm or mm tui started from the project root (it finds .mm/agent.yaml)\n\n")
31+ } else {
32+ fmt.Fprintf(&b, "#\n# mm %s or mm -tui %s\n\n", "-config <this file>", "<this file>")
33+ }
3034
3135 fmt.Fprintf(&b, "provider: %s\n", c.Provider)
3236 for _, line := range wrap(coupledTo(s), 76) {
@@ -71,6 +75,12 @@ func Render(s Settings) string {
7175 writeSettings(&b, " ", s.Context)
7276 b.WriteString("\n")
7377
78+ if len(s.Sessions) > 0 {
79+ b.WriteString("sessions:\n")
80+ writeSettings(&b, " ", s.Sessions)
81+ b.WriteString("\n")
82+ }
83+
7484 b.WriteString("agentsMd:\n # An AGENTS.md in the project directory is added to the system prompt.\n enabled: true\n\n")
7585 fmt.Fprintf(&b, "acp:\n name: %s\n title: %s\n\n", strings.ToLower(c.Name), quote(titleOf(c.Name, u)))
7686
@@ -26,7 +26,11 @@ func Render(s Settings) string {
26 fmt.Fprintf(&b, "# model family: %s\n# usage: %s\n# machine: %s — %s\n", s.Family.Name, u.Name, s.Machine.Name, s.Machine.Description)26 fmt.Fprintf(&b, "# model family: %s\n# usage: %s\n# machine: %s — %s\n", s.Family.Name, u.Name, s.Machine.Name, s.Machine.Description)
27 fmt.Fprintf(&b, "# model facts: %s\n", factsLine(s))27 fmt.Fprintf(&b, "# model facts: %s\n", factsLine(s))
28 writeLegend(&b, s)28 writeLegend(&b, s)
29- fmt.Fprintf(&b, "#\n# mm %s or mm -tui %s\n\n", "-config <this file>", "<this file>")29+ if on(c.Project) {
30+ b.WriteString("#\n# mm or mm tui started from the project root (it finds .mm/agent.yaml)\n\n")
31+ } else {
32+ fmt.Fprintf(&b, "#\n# mm %s or mm -tui %s\n\n", "-config <this file>", "<this file>")
33+ }
30 34
31 fmt.Fprintf(&b, "provider: %s\n", c.Provider)35 fmt.Fprintf(&b, "provider: %s\n", c.Provider)
32 for _, line := range wrap(coupledTo(s), 76) {36 for _, line := range wrap(coupledTo(s), 76) {
@@ -71,6 +75,12 @@ func Render(s Settings) string {
71 writeSettings(&b, " ", s.Context)75 writeSettings(&b, " ", s.Context)
72 b.WriteString("\n")76 b.WriteString("\n")
73 77
78+ if len(s.Sessions) > 0 {
79+ b.WriteString("sessions:\n")
80+ writeSettings(&b, " ", s.Sessions)
81+ b.WriteString("\n")
82+ }
83+
74 b.WriteString("agentsMd:\n # An AGENTS.md in the project directory is added to the system prompt.\n enabled: true\n\n")84 b.WriteString("agentsMd:\n # An AGENTS.md in the project directory is added to the system prompt.\n enabled: true\n\n")
75 fmt.Fprintf(&b, "acp:\n name: %s\n title: %s\n\n", strings.ToLower(c.Name), quote(titleOf(c.Name, u)))85 fmt.Fprintf(&b, "acp:\n name: %s\n title: %s\n\n", strings.ToLower(c.Name), quote(titleOf(c.Name, u)))
76 86
added internal/agentinit/testdata/preview-developer.yaml +97 -0
new file mode 100644
@@ -0,0 +1,97 @@
1+# developer — a coding agent: reads and changes the project, runs commands.
2+#
3+# Written by `mm init`. Every value says why it has that value; change
4+# them as you watch the agent work:
5+# https://rickub.com/bots-garden/mini-me/blob/main/guides/agent-settings-for-small-models.md
6+#
7+# model family: qwen
8+# usage: developer
9+# machine: small — a laptop, models up to about 8B
10+# model facts: read from llmman: native window 32768 tokens; reasoning switch enable_thinking (default off)
11+#
12+# Where the numbers come from (the tag before their comment):
13+# [card] from the model card or the GGUF, not measured — re-read the new card
14+# [guide] the settings guide's row for this machine
15+# [rule] a rule of thumb, not specific to this model
16+#
17+# mm -config <this file> or mm -tui <this file>
18+
19+provider: llmman
20+# The model. Chosen for it (family qwen): sampling (temperature, top_p, top_k,
21+# max_tokens) and sampling.chat_template_kwargs (enable_thinking). Change the
22+# model and redo them together — running mm init again is the quick way.
23+model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
24+baseUrl: "http://stub/v1"
25+
26+# 0 = mm asks the server for the window it serves (the model's native window
27+# is 32768 tokens; the server may serve less)
28+contextWindow: 0
29+# usage developer's default: every command and file change waits for your
30+# answer
31+yolo: false
32+# usage developer
33+bashTool: true
34+editTools: true
35+planTool: true
36+askUserTool: true
37+# no skills: add a skills/ folder and set skillsDir to use some (see
38+# https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)
39+skillsDir: no-skills
40+# usage developer
41+maxTurns: 40
42+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
43+maxOutput: 8000
44+firstTokenTimeout: 3m
45+# [guide] aligned on bashTimeout, as the settings guide says when bash is on
46+# (section 3)
47+watchdogTimeout: 30s
48+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
49+bashTimeout: 30s
50+
51+sampling:
52+ # [card] family qwen: Qwen model cards (thinking 0.6 / 0.95 / 20,
53+ # non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B,
54+ # thinking off (demos/docs-search, memory-search, plans)
55+ temperature: 0.6
56+ top_p: 0.95
57+ top_k: 20
58+ # [rule] reasoning on: room for the thoughts plus a whole tool call
59+ max_tokens: 16384
60+ # one tool call per reply: small models run a check before the change it
61+ # checks has landed otherwise
62+ parallel_tool_calls: false
63+ chat_template_kwargs:
64+ # reasoning on: code and multi-step work; the template's default is off
65+ # (read from the chat template)
66+ enable_thinking: true
67+
68+context:
69+ # compress the history before the window is full (settings guide, every row)
70+ enabled: true
71+ # mm's default: 75 % of the window the server serves, a quarter left for the
72+ # next question and its tool outputs
73+ threshold: 75
74+
75+agentsMd:
76+ # An AGENTS.md in the project directory is added to the system prompt.
77+ enabled: true
78+
79+acp:
80+ name: developer
81+ title: "Developer (developer)"
82+
83+system: |
84+ Your name is developer.
85+ You are a coding agent working in a terminal, in the user's project.
86+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
87+ plan tool.
88+ Read before you change: look at the files and run the commands that tell
89+ you how the project works. Make the smallest change that does the job,
90+ then run the tests or the build to check it.
91+ Never state the output of a command you did not run in this answer.
92+ When a task has several steps, make a plan and update it as you go.
93+
94+# Next: skills https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md
95+# MCP servers https://rickub.com/bots-garden/mini-me/blob/main/docs/en/tutorials/mcp-getting-started.md
96+# hooks https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/guard-the-agent-with-hooks.md
97+# sessions https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/keep-sessions.md
new file mode 100644
@@ -0,0 +1,97 @@
1+# developer — a coding agent: reads and changes the project, runs commands.
2+#
3+# Written by `mm init`. Every value says why it has that value; change
4+# them as you watch the agent work:
5+# https://rickub.com/bots-garden/mini-me/blob/main/guides/agent-settings-for-small-models.md
6+#
7+# model family: qwen
8+# usage: developer
9+# machine: small — a laptop, models up to about 8B
10+# model facts: read from llmman: native window 32768 tokens; reasoning switch enable_thinking (default off)
11+#
12+# Where the numbers come from (the tag before their comment):
13+# [card] from the model card or the GGUF, not measured — re-read the new card
14+# [guide] the settings guide's row for this machine
15+# [rule] a rule of thumb, not specific to this model
16+#
17+# mm -config <this file> or mm -tui <this file>
18+
19+provider: llmman
20+# The model. Chosen for it (family qwen): sampling (temperature, top_p, top_k,
21+# max_tokens) and sampling.chat_template_kwargs (enable_thinking). Change the
22+# model and redo them together — running mm init again is the quick way.
23+model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
24+baseUrl: "http://stub/v1"
25+
26+# 0 = mm asks the server for the window it serves (the model's native window
27+# is 32768 tokens; the server may serve less)
28+contextWindow: 0
29+# usage developer's default: every command and file change waits for your
30+# answer
31+yolo: false
32+# usage developer
33+bashTool: true
34+editTools: true
35+planTool: true
36+askUserTool: true
37+# no skills: add a skills/ folder and set skillsDir to use some (see
38+# https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)
39+skillsDir: no-skills
40+# usage developer
41+maxTurns: 40
42+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
43+maxOutput: 8000
44+firstTokenTimeout: 3m
45+# [guide] aligned on bashTimeout, as the settings guide says when bash is on
46+# (section 3)
47+watchdogTimeout: 30s
48+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
49+bashTimeout: 30s
50+
51+sampling:
52+ # [card] family qwen: Qwen model cards (thinking 0.6 / 0.95 / 20,
53+ # non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B,
54+ # thinking off (demos/docs-search, memory-search, plans)
55+ temperature: 0.6
56+ top_p: 0.95
57+ top_k: 20
58+ # [rule] reasoning on: room for the thoughts plus a whole tool call
59+ max_tokens: 16384
60+ # one tool call per reply: small models run a check before the change it
61+ # checks has landed otherwise
62+ parallel_tool_calls: false
63+ chat_template_kwargs:
64+ # reasoning on: code and multi-step work; the template's default is off
65+ # (read from the chat template)
66+ enable_thinking: true
67+
68+context:
69+ # compress the history before the window is full (settings guide, every row)
70+ enabled: true
71+ # mm's default: 75 % of the window the server serves, a quarter left for the
72+ # next question and its tool outputs
73+ threshold: 75
74+
75+agentsMd:
76+ # An AGENTS.md in the project directory is added to the system prompt.
77+ enabled: true
78+
79+acp:
80+ name: developer
81+ title: "Developer (developer)"
82+
83+system: |
84+ Your name is developer.
85+ You are a coding agent working in a terminal, in the user's project.
86+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
87+ plan tool.
88+ Read before you change: look at the files and run the commands that tell
89+ you how the project works. Make the smallest change that does the job,
90+ then run the tests or the build to check it.
91+ Never state the output of a command you did not run in this answer.
92+ When a task has several steps, make a plan and update it as you go.
93+
94+# Next: skills https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md
95+# MCP servers https://rickub.com/bots-garden/mini-me/blob/main/docs/en/tutorials/mcp-getting-started.md
96+# hooks https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/guard-the-agent-with-hooks.md
97+# sessions https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/keep-sessions.md
added internal/agentinit/testdata/preview-researcher.yaml +91 -0
new file mode 100644
@@ -0,0 +1,91 @@
1+# researcher — reads web pages and summarises them.
2+#
3+# Written by `mm init`. Every value says why it has that value; change
4+# them as you watch the agent work:
5+# https://rickub.com/bots-garden/mini-me/blob/main/guides/agent-settings-for-small-models.md
6+#
7+# model family: qwen
8+# usage: researcher
9+# machine: small — a laptop, models up to about 8B
10+# model facts: read from llmman: native window 32768 tokens; reasoning switch enable_thinking (default off)
11+#
12+# Where the numbers come from (the tag before their comment):
13+# [card] from the model card or the GGUF, not measured — re-read the new card
14+# [guide] the settings guide's row for this machine
15+# [rule] a rule of thumb, not specific to this model
16+#
17+# mm -config <this file> or mm -tui <this file>
18+
19+provider: llmman
20+# The model. Chosen for it (family qwen): sampling (temperature, top_p, top_k,
21+# max_tokens) and sampling.chat_template_kwargs (enable_thinking). Change the
22+# model and redo them together — running mm init again is the quick way.
23+model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
24+baseUrl: "http://stub/v1"
25+
26+# 0 = mm asks the server for the window it serves (the model's native window
27+# is 32768 tokens; the server may serve less)
28+contextWindow: 0
29+# usage researcher's default: every command and file change waits for your
30+# answer
31+yolo: false
32+# usage researcher
33+bashTool: false
34+editTools: false
35+planTool: false
36+askUserTool: true
37+fetchContentTool: true
38+saveAnswerTool: true
39+# no skills: add a skills/ folder and set skillsDir to use some (see
40+# https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)
41+skillsDir: no-skills
42+# usage researcher
43+maxTurns: 15
44+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
45+maxOutput: 8000
46+firstTokenTimeout: 3m
47+watchdogTimeout: 20s
48+
49+sampling:
50+ # [card] family qwen: Qwen model cards (thinking 0.6 / 0.95 / 20,
51+ # non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B,
52+ # thinking off (demos/docs-search, memory-search, plans)
53+ temperature: 0.7
54+ top_p: 0.8
55+ top_k: 20
56+ # [rule] room for an answer or a whole file in one tool call
57+ max_tokens: 4096
58+ # one tool call per reply: small models run a check before the change it
59+ # checks has landed otherwise
60+ parallel_tool_calls: false
61+ chat_template_kwargs:
62+ # reasoning off: short, direct answers; the template's default is off
63+ # (read from the chat template)
64+ enable_thinking: false
65+
66+context:
67+ # compress the history before the window is full (settings guide, every row)
68+ enabled: true
69+ # mm's default: 75 % of the window the server serves, a quarter left for the
70+ # next question and its tool outputs
71+ threshold: 75
72+
73+agentsMd:
74+ # An AGENTS.md in the project directory is added to the system prompt.
75+ enabled: true
76+
77+acp:
78+ name: researcher
79+ title: "Researcher (researcher)"
80+
81+system: |
82+ Your name is researcher.
83+ You are a research assistant. To answer, read the web pages the user
84+ names with fetch_content, then summarise what they say, citing each
85+ page's address. Never state what a page says without having read it in
86+ this answer.
87+
88+# Next: skills https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md
89+# MCP servers https://rickub.com/bots-garden/mini-me/blob/main/docs/en/tutorials/mcp-getting-started.md
90+# hooks https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/guard-the-agent-with-hooks.md
91+# sessions https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/keep-sessions.md
new file mode 100644
@@ -0,0 +1,91 @@
1+# researcher — reads web pages and summarises them.
2+#
3+# Written by `mm init`. Every value says why it has that value; change
4+# them as you watch the agent work:
5+# https://rickub.com/bots-garden/mini-me/blob/main/guides/agent-settings-for-small-models.md
6+#
7+# model family: qwen
8+# usage: researcher
9+# machine: small — a laptop, models up to about 8B
10+# model facts: read from llmman: native window 32768 tokens; reasoning switch enable_thinking (default off)
11+#
12+# Where the numbers come from (the tag before their comment):
13+# [card] from the model card or the GGUF, not measured — re-read the new card
14+# [guide] the settings guide's row for this machine
15+# [rule] a rule of thumb, not specific to this model
16+#
17+# mm -config <this file> or mm -tui <this file>
18+
19+provider: llmman
20+# The model. Chosen for it (family qwen): sampling (temperature, top_p, top_k,
21+# max_tokens) and sampling.chat_template_kwargs (enable_thinking). Change the
22+# model and redo them together — running mm init again is the quick way.
23+model: "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"
24+baseUrl: "http://stub/v1"
25+
26+# 0 = mm asks the server for the window it serves (the model's native window
27+# is 32768 tokens; the server may serve less)
28+contextWindow: 0
29+# usage researcher's default: every command and file change waits for your
30+# answer
31+yolo: false
32+# usage researcher
33+bashTool: false
34+editTools: false
35+planTool: false
36+askUserTool: true
37+fetchContentTool: true
38+saveAnswerTool: true
39+# no skills: add a skills/ folder and set skillsDir to use some (see
40+# https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)
41+skillsDir: no-skills
42+# usage researcher
43+maxTurns: 15
44+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
45+maxOutput: 8000
46+firstTokenTimeout: 3m
47+watchdogTimeout: 20s
48+
49+sampling:
50+ # [card] family qwen: Qwen model cards (thinking 0.6 / 0.95 / 20,
51+ # non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B,
52+ # thinking off (demos/docs-search, memory-search, plans)
53+ temperature: 0.7
54+ top_p: 0.8
55+ top_k: 20
56+ # [rule] room for an answer or a whole file in one tool call
57+ max_tokens: 4096
58+ # one tool call per reply: small models run a check before the change it
59+ # checks has landed otherwise
60+ parallel_tool_calls: false
61+ chat_template_kwargs:
62+ # reasoning off: short, direct answers; the template's default is off
63+ # (read from the chat template)
64+ enable_thinking: false
65+
66+context:
67+ # compress the history before the window is full (settings guide, every row)
68+ enabled: true
69+ # mm's default: 75 % of the window the server serves, a quarter left for the
70+ # next question and its tool outputs
71+ threshold: 75
72+
73+agentsMd:
74+ # An AGENTS.md in the project directory is added to the system prompt.
75+ enabled: true
76+
77+acp:
78+ name: researcher
79+ title: "Researcher (researcher)"
80+
81+system: |
82+ Your name is researcher.
83+ You are a research assistant. To answer, read the web pages the user
84+ names with fetch_content, then summarise what they say, citing each
85+ page's address. Never state what a page says without having read it in
86+ this answer.
87+
88+# Next: skills https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md
89+# MCP servers https://rickub.com/bots-garden/mini-me/blob/main/docs/en/tutorials/mcp-getting-started.md
90+# hooks https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/guard-the-agent-with-hooks.md
91+# sessions https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/keep-sessions.md
modified internal/agentinit/wizard.go +55 -3
@@ -46,6 +46,7 @@ type Options struct {
4646 // mm init
4747 // mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \
4848 // --usage docs --machine small --name rata -o agent.yaml --yes
49+// mm init --yes --provider llmman --model … --project yes --sessions yes --skills yes
4950 func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int {
5051 var o Options
5152 if len(opts) > 0 {
@@ -54,7 +55,7 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
5455 fs := flag.NewFlagSet("mm init", flag.ContinueOnError)
5556 fs.SetOutput(stdout)
5657 var c Choices
57- var yolo, output string
58+ var yolo, output, project, sessions, skills string
5859 var check, yes, line bool
5960 fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", "))
6061 fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question")
@@ -65,7 +66,10 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
6566 fs.StringVar(&c.Machine, "machine", "", "small, medium or large")
6667 fs.StringVar(&c.Name, "name", "", "the agent's name")
6768 fs.StringVar(&yolo, "yolo", "", "yes: tools run without asking; no: every change waits for your answer (default: the usage's)")
68- fs.StringVar(&output, "o", "", "the file to write (default agent.yaml; - for stdout); an existing file is replaced only if you agree, never with --yes")
69+ fs.StringVar(&project, "project", "", "yes: create .mm/ and write .mm/agent.yaml, paths from the project root; no: one agent.yaml (default: no)")
70+ fs.StringVar(&sessions, "sessions", "", "yes: write a sessions section that saves every conversation; no: none (default: no)")
71+ fs.StringVar(&skills, "skills", "", "yes: write skillsDir and create the folder (skills/, or .mm/skills/ with --project yes); no: skillsDir: no-skills (default: no)")
72+ fs.StringVar(&output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes")
6973 fs.StringVar(&output, "output", "", "same as -o")
7074 fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers")
7175 fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default")
@@ -89,6 +93,18 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
8993 on := yolo == "yes" || yolo == "true"
9094 c.Yolo = &on
9195 }
96+ for flagName, value := range map[string]struct {
97+ text string
98+ target **bool
99+ }{"project": {project, &c.Project}, "sessions": {sessions, &c.Sessions}, "skills": {skills, &c.Skills}} {
100+ if value.text == "" {
101+ continue
102+ }
103+ if err := setYesNo("--"+flagName, value.text, value.target); err != nil {
104+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
105+ return 2
106+ }
107+ }
92108 ctx := context.Background()
93109 flow := NewFlow(ctx, presets, c, output)
94110 if err := flow.validateGiven(); err != nil {
@@ -134,6 +150,13 @@ func finish(flow *Flow, stdout io.Writer, check bool) int {
134150 fmt.Fprint(stdout, text)
135151 return 0
136152 }
153+ // The Project layout's file lives in .mm/, which may not exist yet.
154+ if on(choices.Project) {
155+ if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
156+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
157+ return 1
158+ }
159+ }
137160 write := writeNew
138161 if flow.Overwrite() {
139162 write = replaceFile
@@ -143,6 +166,10 @@ func finish(flow *Flow, stdout io.Writer, check bool) int {
143166 return 1
144167 }
145168 fmt.Fprintf(stdout, "✅ wrote %s\n", output)
169+ if err := createSkillsFolder(choices, output, stdout); err != nil {
170+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
171+ return 1
172+ }
146173 if _, err := config.Load(output); err != nil {
147174 fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err)
148175 return 1
@@ -151,10 +178,35 @@ func finish(flow *Flow, stdout io.Writer, check bool) int {
151178 if check {
152179 return checkFile(stdout, output)
153180 }
154- fmt.Fprintf(stdout, "Next: mm -config %s (or mm -tui %s)\n", output, output)
181+ fmt.Fprintln(stdout, nextLine(output))
155182 return 0
156183 }
157184
185+// createSkillsFolder makes the folder skillsDir names, when skills are on
186+// (ticket 205), once the file is written: in the file's base directory —
187+// the project root for .mm/agent.yaml — where mm started there reads it.
188+// Never called for "-": a preview creates nothing.
189+func createSkillsFolder(c Choices, output string, stdout io.Writer) error {
190+ if !on(c.Skills) {
191+ return nil
192+ }
193+ dir := filepath.Join(config.BaseDir(output), c.SkillsDir())
194+ if err := os.MkdirAll(dir, 0o755); err != nil {
195+ return err
196+ }
197+ fmt.Fprintf(stdout, "✅ skills folder %s\n", c.SkillsDir())
198+ return nil
199+}
200+
201+// nextLine says how to start the agent: a plain `mm` for .mm/agent.yaml,
202+// which mm finds on its own (ticket 204), -config otherwise.
203+func nextLine(output string) string {
204+ if output == filepath.Join(config.ProjectDir, config.DefaultPath) {
205+ return "Next: mm (or mm tui), started from this directory"
206+ }
207+ return fmt.Sprintf("Next: mm -config %s (or mm -tui %s)", output, output)
208+}
209+
158210 // validateGiven refuses a flag value no question would accept.
159211 func (f *Flow) validateGiven() error {
160212 for _, key := range stepKeys {
@@ -46,6 +46,7 @@ type Options struct {
46 // mm init46 // mm init
47 // mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \47 // mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \
48 // --usage docs --machine small --name rata -o agent.yaml --yes48 // --usage docs --machine small --name rata -o agent.yaml --yes
49+// mm init --yes --provider llmman --model … --project yes --sessions yes --skills yes
49 func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int {50 func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int {
50 var o Options51 var o Options
51 if len(opts) > 0 {52 if len(opts) > 0 {
@@ -54,7 +55,7 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
54 fs := flag.NewFlagSet("mm init", flag.ContinueOnError)55 fs := flag.NewFlagSet("mm init", flag.ContinueOnError)
55 fs.SetOutput(stdout)56 fs.SetOutput(stdout)
56 var c Choices57 var c Choices
57- var yolo, output string58+ var yolo, output, project, sessions, skills string
58 var check, yes, line bool59 var check, yes, line bool
59 fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", "))60 fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", "))
60 fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question")61 fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question")
@@ -65,7 +66,10 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
65 fs.StringVar(&c.Machine, "machine", "", "small, medium or large")66 fs.StringVar(&c.Machine, "machine", "", "small, medium or large")
66 fs.StringVar(&c.Name, "name", "", "the agent's name")67 fs.StringVar(&c.Name, "name", "", "the agent's name")
67 fs.StringVar(&yolo, "yolo", "", "yes: tools run without asking; no: every change waits for your answer (default: the usage's)")68 fs.StringVar(&yolo, "yolo", "", "yes: tools run without asking; no: every change waits for your answer (default: the usage's)")
68- fs.StringVar(&output, "o", "", "the file to write (default agent.yaml; - for stdout); an existing file is replaced only if you agree, never with --yes")69+ fs.StringVar(&project, "project", "", "yes: create .mm/ and write .mm/agent.yaml, paths from the project root; no: one agent.yaml (default: no)")
70+ fs.StringVar(&sessions, "sessions", "", "yes: write a sessions section that saves every conversation; no: none (default: no)")
71+ fs.StringVar(&skills, "skills", "", "yes: write skillsDir and create the folder (skills/, or .mm/skills/ with --project yes); no: skillsDir: no-skills (default: no)")
72+ fs.StringVar(&output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes")
69 fs.StringVar(&output, "output", "", "same as -o")73 fs.StringVar(&output, "output", "", "same as -o")
70 fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers")74 fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers")
71 fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default")75 fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default")
@@ -89,6 +93,18 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
89 on := yolo == "yes" || yolo == "true"93 on := yolo == "yes" || yolo == "true"
90 c.Yolo = &on94 c.Yolo = &on
91 }95 }
96+ for flagName, value := range map[string]struct {
97+ text string
98+ target **bool
99+ }{"project": {project, &c.Project}, "sessions": {sessions, &c.Sessions}, "skills": {skills, &c.Skills}} {
100+ if value.text == "" {
101+ continue
102+ }
103+ if err := setYesNo("--"+flagName, value.text, value.target); err != nil {
104+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
105+ return 2
106+ }
107+ }
92 ctx := context.Background()108 ctx := context.Background()
93 flow := NewFlow(ctx, presets, c, output)109 flow := NewFlow(ctx, presets, c, output)
94 if err := flow.validateGiven(); err != nil {110 if err := flow.validateGiven(); err != nil {
@@ -134,6 +150,13 @@ func finish(flow *Flow, stdout io.Writer, check bool) int {
134 fmt.Fprint(stdout, text)150 fmt.Fprint(stdout, text)
135 return 0151 return 0
136 }152 }
153+ // The Project layout's file lives in .mm/, which may not exist yet.
154+ if on(choices.Project) {
155+ if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
156+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
157+ return 1
158+ }
159+ }
137 write := writeNew160 write := writeNew
138 if flow.Overwrite() {161 if flow.Overwrite() {
139 write = replaceFile162 write = replaceFile
@@ -143,6 +166,10 @@ func finish(flow *Flow, stdout io.Writer, check bool) int {
143 return 1166 return 1
144 }167 }
145 fmt.Fprintf(stdout, "✅ wrote %s\n", output)168 fmt.Fprintf(stdout, "✅ wrote %s\n", output)
169+ if err := createSkillsFolder(choices, output, stdout); err != nil {
170+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
171+ return 1
172+ }
146 if _, err := config.Load(output); err != nil {173 if _, err := config.Load(output); err != nil {
147 fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err)174 fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err)
148 return 1175 return 1
@@ -151,10 +178,35 @@ func finish(flow *Flow, stdout io.Writer, check bool) int {
151 if check {178 if check {
152 return checkFile(stdout, output)179 return checkFile(stdout, output)
153 }180 }
154- fmt.Fprintf(stdout, "Next: mm -config %s (or mm -tui %s)\n", output, output)181+ fmt.Fprintln(stdout, nextLine(output))
155 return 0182 return 0
156 }183 }
157 184
185+// createSkillsFolder makes the folder skillsDir names, when skills are on
186+// (ticket 205), once the file is written: in the file's base directory —
187+// the project root for .mm/agent.yaml — where mm started there reads it.
188+// Never called for "-": a preview creates nothing.
189+func createSkillsFolder(c Choices, output string, stdout io.Writer) error {
190+ if !on(c.Skills) {
191+ return nil
192+ }
193+ dir := filepath.Join(config.BaseDir(output), c.SkillsDir())
194+ if err := os.MkdirAll(dir, 0o755); err != nil {
195+ return err
196+ }
197+ fmt.Fprintf(stdout, "✅ skills folder %s\n", c.SkillsDir())
198+ return nil
199+}
200+
201+// nextLine says how to start the agent: a plain `mm` for .mm/agent.yaml,
202+// which mm finds on its own (ticket 204), -config otherwise.
203+func nextLine(output string) string {
204+ if output == filepath.Join(config.ProjectDir, config.DefaultPath) {
205+ return "Next: mm (or mm tui), started from this directory"
206+ }
207+ return fmt.Sprintf("Next: mm -config %s (or mm -tui %s)", output, output)
208+}
209+
158 // validateGiven refuses a flag value no question would accept.210 // validateGiven refuses a flag value no question would accept.
159 func (f *Flow) validateGiven() error {211 func (f *Flow) validateGiven() error {
160 for _, key := range stepKeys {212 for _, key := range stepKeys {
modified internal/agentinit/wizard_test.go +4 -1
@@ -74,11 +74,14 @@ func TestMainAsksTheQuestions(t *testing.T) {
7474 dir := t.TempDir()
7575 out := filepath.Join(dir, "dev.yaml")
7676 answers := strings.Join([]string{
77+ "", // project layout: the default, no
7778 "1", // model: the server's only one
7879 "", // family: the detected one
7980 "1", // usage: developer
8081 "2", // machine: medium
8182 "n", // ask before each change? no → yolo on
83+ "", // sessions: the default, no
84+ "", // skills: the default, no
8285 "bob", // name
8386 out, // file
8487 }, "\n") + "\n"
@@ -127,7 +130,7 @@ func TestMainKeepsAnExistingFileWithoutAnAnswer(t *testing.T) {
127130 }
128131
129132 // overwriteFlags answer every question but the file and the overwrite.
130-var overwriteFlags = []string{"--provider", "dmr", "--base-url", "http://127.0.0.1:1/v1", "--model", "x", "--usage", "assistant", "--family", "other", "--machine", "small", "--name", "Bob"}
133+var overwriteFlags = []string{"--provider", "dmr", "--base-url", "http://127.0.0.1:1/v1", "--model", "x", "--usage", "assistant", "--family", "other", "--machine", "small", "--name", "Bob", "--project", "no", "--sessions", "no", "--skills", "no"}
131134
132135 func TestMainOverwritesAfterYes(t *testing.T) {
133136 keepConfig(t)
@@ -74,11 +74,14 @@ func TestMainAsksTheQuestions(t *testing.T) {
74 dir := t.TempDir()74 dir := t.TempDir()
75 out := filepath.Join(dir, "dev.yaml")75 out := filepath.Join(dir, "dev.yaml")
76 answers := strings.Join([]string{76 answers := strings.Join([]string{
77+ "", // project layout: the default, no
77 "1", // model: the server's only one78 "1", // model: the server's only one
78 "", // family: the detected one79 "", // family: the detected one
79 "1", // usage: developer80 "1", // usage: developer
80 "2", // machine: medium81 "2", // machine: medium
81 "n", // ask before each change? no → yolo on82 "n", // ask before each change? no → yolo on
83+ "", // sessions: the default, no
84+ "", // skills: the default, no
82 "bob", // name85 "bob", // name
83 out, // file86 out, // file
84 }, "\n") + "\n"87 }, "\n") + "\n"
@@ -127,7 +130,7 @@ func TestMainKeepsAnExistingFileWithoutAnAnswer(t *testing.T) {
127 }130 }
128 131
129 // overwriteFlags answer every question but the file and the overwrite.132 // overwriteFlags answer every question but the file and the overwrite.
130-var overwriteFlags = []string{"--provider", "dmr", "--base-url", "http://127.0.0.1:1/v1", "--model", "x", "--usage", "assistant", "--family", "other", "--machine", "small", "--name", "Bob"}133+var overwriteFlags = []string{"--provider", "dmr", "--base-url", "http://127.0.0.1:1/v1", "--model", "x", "--usage", "assistant", "--family", "other", "--machine", "small", "--name", "Bob", "--project", "no", "--sessions", "no", "--skills", "no"}
131 134
132 func TestMainOverwritesAfterYes(t *testing.T) {135 func TestMainOverwritesAfterYes(t *testing.T) {
133 keepConfig(t)136 keepConfig(t)
modified internal/config/agents.go +2 -2
@@ -55,10 +55,10 @@ func (c Config) validateAgents() error {
5555 }
5656
5757 // resolveAgentPaths makes every relative mmConfigs path absolute against the
58-// directory of the config file that declared it — the one anchor that does
58+// BaseDir of the config file that declared it — the one anchor that does
5959 // not depend on who started mm (a terminal, an editor, another mm).
6060 func resolveAgentPaths(configPath string) {
61- dir := filepath.Dir(configPath)
61+ dir := BaseDir(configPath)
6262 for name, ref := range Cfg.MMConfigs {
6363 if ref.Path == "" || filepath.IsAbs(ref.Path) {
6464 continue
@@ -55,10 +55,10 @@ func (c Config) validateAgents() error {
55 }55 }
56 56
57 // resolveAgentPaths makes every relative mmConfigs path absolute against the57 // resolveAgentPaths makes every relative mmConfigs path absolute against the
58-// directory of the config file that declared it — the one anchor that does58+// BaseDir of the config file that declared it — the one anchor that does
59 // not depend on who started mm (a terminal, an editor, another mm).59 // not depend on who started mm (a terminal, an editor, another mm).
60 func resolveAgentPaths(configPath string) {60 func resolveAgentPaths(configPath string) {
61- dir := filepath.Dir(configPath)61+ dir := BaseDir(configPath)
62 for name, ref := range Cfg.MMConfigs {62 for name, ref := range Cfg.MMConfigs {
63 if ref.Path == "" || filepath.IsAbs(ref.Path) {63 if ref.Path == "" || filepath.IsAbs(ref.Path) {
64 continue64 continue
modified internal/config/config.go +18 -2
@@ -47,6 +47,14 @@ type Config struct {
4747 // default variable; servers that ignore the key need none.
4848 APIKeyEnv string `yaml:"apiKeyEnv"`
4949
50+ // ToolFormat says how the tools reach the model (ticket 207). "" or
51+ // "openai": the OpenAI API's `tools` field, as every server expects.
52+ // "xml_tools": for models whose chat template reads the tools from an
53+ // xml_tools variable and writes its calls as <tool_call> text (SmolLM3) —
54+ // mm sends the tools in chat_template_kwargs.xml_tools and turns those
55+ // blocks back into tool calls (internal/engine/xmltools.go).
56+ ToolFormat string `yaml:"toolFormat"`
57+
5058 // ContextWindow: how many tokens the server actually serves, when the
5159 // operator knows it (llama-server -c, `docker model configure`). 0 = ask the
5260 // server, then unknown. Displayed at start-up; the context-compression work
@@ -624,8 +632,14 @@ type ContextConfig struct {
624632 ShowStats bool `yaml:"showStats"`
625633 }
626634
635+// The values of toolFormat.
636+const (
637+ ToolFormatOpenAI = "openai" // the OpenAI API's tools field (the default)
638+ ToolFormatXML = "xml_tools" // chat_template_kwargs.xml_tools + <tool_call> text
639+)
640+
627641 // DefaultPath: the config file looked up when neither the command line nor
628-// AGENT_CONFIG says otherwise.
642+// AGENT_CONFIG says otherwise — then .mm/agent.yaml (see defaultPaths).
629643 const DefaultPath = "agent.yaml"
630644
631645 // DefaultACPTitle: acp.title when the file does not set one.
@@ -768,7 +782,7 @@ func Load(cliPath string) (string, error) {
768782 path = os.Getenv("AGENT_CONFIG")
769783 }
770784 if path == "" {
771- path, explicit = DefaultPath, false
785+ path, explicit = defaultPath(), false
772786 }
773787
774788 data, err := os.ReadFile(path)
@@ -935,6 +949,8 @@ func (c Config) validate() error {
935949 return errors.New("model must not be empty")
936950 case c.ContextWindow < 0:
937951 return errors.New("contextWindow must be >= 0")
952+ case c.ToolFormat != "" && c.ToolFormat != ToolFormatOpenAI && c.ToolFormat != ToolFormatXML:
953+ return fmt.Errorf("toolFormat must be %s or %s, not %q", ToolFormatOpenAI, ToolFormatXML, c.ToolFormat)
938954 case c.FirstTokenTimeout < 0:
939955 return errors.New("firstTokenTimeout must be >= 0")
940956 case c.BashTimeout <= 0:
@@ -47,6 +47,14 @@ type Config struct {
47 // default variable; servers that ignore the key need none.47 // default variable; servers that ignore the key need none.
48 APIKeyEnv string `yaml:"apiKeyEnv"`48 APIKeyEnv string `yaml:"apiKeyEnv"`
49 49
50+ // ToolFormat says how the tools reach the model (ticket 207). "" or
51+ // "openai": the OpenAI API's `tools` field, as every server expects.
52+ // "xml_tools": for models whose chat template reads the tools from an
53+ // xml_tools variable and writes its calls as <tool_call> text (SmolLM3) —
54+ // mm sends the tools in chat_template_kwargs.xml_tools and turns those
55+ // blocks back into tool calls (internal/engine/xmltools.go).
56+ ToolFormat string `yaml:"toolFormat"`
57+
50 // ContextWindow: how many tokens the server actually serves, when the58 // ContextWindow: how many tokens the server actually serves, when the
51 // operator knows it (llama-server -c, `docker model configure`). 0 = ask the59 // operator knows it (llama-server -c, `docker model configure`). 0 = ask the
52 // server, then unknown. Displayed at start-up; the context-compression work60 // server, then unknown. Displayed at start-up; the context-compression work
@@ -624,8 +632,14 @@ type ContextConfig struct {
624 ShowStats bool `yaml:"showStats"`632 ShowStats bool `yaml:"showStats"`
625 }633 }
626 634
635+// The values of toolFormat.
636+const (
637+ ToolFormatOpenAI = "openai" // the OpenAI API's tools field (the default)
638+ ToolFormatXML = "xml_tools" // chat_template_kwargs.xml_tools + <tool_call> text
639+)
640+
627 // DefaultPath: the config file looked up when neither the command line nor641 // DefaultPath: the config file looked up when neither the command line nor
628-// AGENT_CONFIG says otherwise.642+// AGENT_CONFIG says otherwise — then .mm/agent.yaml (see defaultPaths).
629 const DefaultPath = "agent.yaml"643 const DefaultPath = "agent.yaml"
630 644
631 // DefaultACPTitle: acp.title when the file does not set one.645 // DefaultACPTitle: acp.title when the file does not set one.
@@ -768,7 +782,7 @@ func Load(cliPath string) (string, error) {
768 path = os.Getenv("AGENT_CONFIG")782 path = os.Getenv("AGENT_CONFIG")
769 }783 }
770 if path == "" {784 if path == "" {
771- path, explicit = DefaultPath, false785+ path, explicit = defaultPath(), false
772 }786 }
773 787
774 data, err := os.ReadFile(path)788 data, err := os.ReadFile(path)
@@ -935,6 +949,8 @@ func (c Config) validate() error {
935 return errors.New("model must not be empty")949 return errors.New("model must not be empty")
936 case c.ContextWindow < 0:950 case c.ContextWindow < 0:
937 return errors.New("contextWindow must be >= 0")951 return errors.New("contextWindow must be >= 0")
952+ case c.ToolFormat != "" && c.ToolFormat != ToolFormatOpenAI && c.ToolFormat != ToolFormatXML:
953+ return fmt.Errorf("toolFormat must be %s or %s, not %q", ToolFormatOpenAI, ToolFormatXML, c.ToolFormat)
938 case c.FirstTokenTimeout < 0:954 case c.FirstTokenTimeout < 0:
939 return errors.New("firstTokenTimeout must be >= 0")955 return errors.New("firstTokenTimeout must be >= 0")
940 case c.BashTimeout <= 0:956 case c.BashTimeout <= 0:
modified internal/config/decision.go +3 -3
@@ -289,14 +289,14 @@ func (s SkillRoutingConfig) validate() error {
289289 return nil
290290 }
291291
292-// resolveDecider makes skillRouting.decider absolute against the directory
293-// of the config file, like mmConfigs paths.
292+// resolveDecider makes skillRouting.decider absolute against the BaseDir of
293+// the config file, like mmConfigs paths.
294294 func resolveDecider(configPath string) {
295295 p := Cfg.SkillRouting.Decider
296296 if p == "" || filepath.IsAbs(p) {
297297 return
298298 }
299- if abs, err := filepath.Abs(filepath.Join(filepath.Dir(configPath), p)); err == nil {
299+ if abs, err := filepath.Abs(filepath.Join(BaseDir(configPath), p)); err == nil {
300300 Cfg.SkillRouting.Decider = abs
301301 }
302302 }
@@ -289,14 +289,14 @@ func (s SkillRoutingConfig) validate() error {
289 return nil289 return nil
290 }290 }
291 291
292-// resolveDecider makes skillRouting.decider absolute against the directory292+// resolveDecider makes skillRouting.decider absolute against the BaseDir of
293-// of the config file, like mmConfigs paths.293+// the config file, like mmConfigs paths.
294 func resolveDecider(configPath string) {294 func resolveDecider(configPath string) {
295 p := Cfg.SkillRouting.Decider295 p := Cfg.SkillRouting.Decider
296 if p == "" || filepath.IsAbs(p) {296 if p == "" || filepath.IsAbs(p) {
297 return297 return
298 }298 }
299- if abs, err := filepath.Abs(filepath.Join(filepath.Dir(configPath), p)); err == nil {299+ if abs, err := filepath.Abs(filepath.Join(BaseDir(configPath), p)); err == nil {
300 Cfg.SkillRouting.Decider = abs300 Cfg.SkillRouting.Decider = abs
301 }301 }
302 }302 }
modified internal/config/hooks.go +2 -4
@@ -256,11 +256,9 @@ func (h Hook) validate(event string) error {
256256 }
257257
258258 // resolveHooks fills what an entry may leave out (timeout, onError) and
259-// records the directory of the file the hooks came from.
259+// records the BaseDir of the file the hooks came from.
260260 func resolveHooks(configPath string) {
261- if dir, err := filepath.Abs(filepath.Dir(configPath)); err == nil {
262- Cfg.Hooks.ConfigDir = dir
263- }
261+ Cfg.Hooks.ConfigDir = BaseDir(configPath)
264262 for _, event := range hookEvents {
265263 list := Cfg.Hooks.For(event)
266264 for i := range list {
@@ -256,11 +256,9 @@ func (h Hook) validate(event string) error {
256 }256 }
257 257
258 // resolveHooks fills what an entry may leave out (timeout, onError) and258 // resolveHooks fills what an entry may leave out (timeout, onError) and
259-// records the directory of the file the hooks came from.259+// records the BaseDir of the file the hooks came from.
260 func resolveHooks(configPath string) {260 func resolveHooks(configPath string) {
261- if dir, err := filepath.Abs(filepath.Dir(configPath)); err == nil {261+ Cfg.Hooks.ConfigDir = BaseDir(configPath)
262- Cfg.Hooks.ConfigDir = dir
263- }
264 for _, event := range hookEvents {262 for _, event := range hookEvents {
265 list := Cfg.Hooks.For(event)263 list := Cfg.Hooks.For(event)
266 for i := range list {264 for i := range list {
added internal/config/locate.go +52 -0
new file mode 100644
@@ -0,0 +1,52 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+)
7+
8+// ProjectDir: the folder a project may keep its mini-me files in — its
9+// agent.yaml, skills, sessions — instead of its root (ticket 204).
10+const ProjectDir = ".mm"
11+
12+// defaultPaths: the config files looked up, in this order, when neither the
13+// command line nor AGENT_CONFIG names one. The first that exists wins, so a
14+// ./agent.yaml beside a .mm/agent.yaml keeps the meaning it always had.
15+var defaultPaths = []string{DefaultPath, filepath.Join(ProjectDir, DefaultPath)}
16+
17+// defaultPath returns the first of defaultPaths that exists, or DefaultPath
18+// when none does — Load then finds no file and the built-in defaults stand.
19+//
20+// // in a project with only .mm/agent.yaml
21+// defaultPath() // ".mm/agent.yaml"
22+func defaultPath() string {
23+ for _, p := range defaultPaths {
24+ if info, err := os.Stat(p); err == nil && !info.IsDir() {
25+ return p
26+ }
27+ }
28+ return DefaultPath
29+}
30+
31+// BaseDir returns the absolute directory the relative paths of the config
32+// file at configPath are resolved against: mmConfigs, hooks, wasm tools,
33+// the deciders, and skillsDir in ACP mode.
34+//
35+// That is the file's own directory — except for a file inside a .mm folder,
36+// whose paths are written from the project root, the parent of .mm: an mm
37+// installed once and started at the root reads `skillsDir: .mm/skills`
38+// there, and the same file must mean the same thing when an editor starts
39+// it over ACP from elsewhere.
40+//
41+// BaseDir("/work/bob/agent.yaml") // "/work/bob"
42+// BaseDir("/work/bob/.mm/agent.yaml") // "/work/bob"
43+func BaseDir(configPath string) string {
44+ dir := filepath.Dir(configPath)
45+ if abs, err := filepath.Abs(dir); err == nil {
46+ dir = abs
47+ }
48+ if filepath.Base(dir) == ProjectDir {
49+ return filepath.Dir(dir)
50+ }
51+ return dir
52+}
new file mode 100644
@@ -0,0 +1,52 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+)
7+
8+// ProjectDir: the folder a project may keep its mini-me files in — its
9+// agent.yaml, skills, sessions — instead of its root (ticket 204).
10+const ProjectDir = ".mm"
11+
12+// defaultPaths: the config files looked up, in this order, when neither the
13+// command line nor AGENT_CONFIG names one. The first that exists wins, so a
14+// ./agent.yaml beside a .mm/agent.yaml keeps the meaning it always had.
15+var defaultPaths = []string{DefaultPath, filepath.Join(ProjectDir, DefaultPath)}
16+
17+// defaultPath returns the first of defaultPaths that exists, or DefaultPath
18+// when none does — Load then finds no file and the built-in defaults stand.
19+//
20+// // in a project with only .mm/agent.yaml
21+// defaultPath() // ".mm/agent.yaml"
22+func defaultPath() string {
23+ for _, p := range defaultPaths {
24+ if info, err := os.Stat(p); err == nil && !info.IsDir() {
25+ return p
26+ }
27+ }
28+ return DefaultPath
29+}
30+
31+// BaseDir returns the absolute directory the relative paths of the config
32+// file at configPath are resolved against: mmConfigs, hooks, wasm tools,
33+// the deciders, and skillsDir in ACP mode.
34+//
35+// That is the file's own directory — except for a file inside a .mm folder,
36+// whose paths are written from the project root, the parent of .mm: an mm
37+// installed once and started at the root reads `skillsDir: .mm/skills`
38+// there, and the same file must mean the same thing when an editor starts
39+// it over ACP from elsewhere.
40+//
41+// BaseDir("/work/bob/agent.yaml") // "/work/bob"
42+// BaseDir("/work/bob/.mm/agent.yaml") // "/work/bob"
43+func BaseDir(configPath string) string {
44+ dir := filepath.Dir(configPath)
45+ if abs, err := filepath.Abs(dir); err == nil {
46+ dir = abs
47+ }
48+ if filepath.Base(dir) == ProjectDir {
49+ return filepath.Dir(dir)
50+ }
51+ return dir
52+}
added internal/config/locate_test.go +177 -0
new file mode 100644
@@ -0,0 +1,177 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// inProject moves the test into a fresh project directory holding the given
11+// files (path → content), and restores the working directory and Cfg after.
12+func inProject(t *testing.T, files map[string]string) string {
13+ t.Helper()
14+ defaults := Cfg
15+ t.Cleanup(func() { Cfg = defaults })
16+ t.Setenv("AGENT_CONFIG", "")
17+
18+ dir := t.TempDir()
19+ for name, content := range files {
20+ path := filepath.Join(dir, name)
21+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
22+ t.Fatal(err)
23+ }
24+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
25+ t.Fatal(err)
26+ }
27+ }
28+ t.Chdir(dir)
29+ return dir
30+}
31+
32+func TestLoadFindsTheConfigInTheMMFolder(t *testing.T) {
33+ inProject(t, map[string]string{".mm/agent.yaml": "model: from-mm-folder\n"})
34+
35+ path, err := Load("")
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if want := filepath.Join(".mm", "agent.yaml"); path != want {
40+ t.Errorf("path = %q, want %q", path, want)
41+ }
42+ if Cfg.Model != "from-mm-folder" {
43+ t.Errorf("model = %q, want the one of .mm/agent.yaml", Cfg.Model)
44+ }
45+}
46+
47+func TestLoadPrefersAgentYamlAtTheRootOverTheMMFolder(t *testing.T) {
48+ inProject(t, map[string]string{
49+ "agent.yaml": "model: from-root\n",
50+ ".mm/agent.yaml": "model: from-mm-folder\n",
51+ })
52+
53+ path, err := Load("")
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if path != DefaultPath || Cfg.Model != "from-root" {
58+ t.Errorf("path = %q, model = %q; want ./agent.yaml and its model", path, Cfg.Model)
59+ }
60+}
61+
62+func TestLoadKeepsTheDefaultsWithNoConfigAnywhere(t *testing.T) {
63+ inProject(t, map[string]string{".mm/sessions/keep": ""})
64+ model := Cfg.Model
65+
66+ path, err := Load("")
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if path != "" || Cfg.Model != model {
71+ t.Errorf("path = %q, model = %q; want no file and the built-in model", path, Cfg.Model)
72+ }
73+}
74+
75+func TestAnExplicitConfigWinsOverTheMMFolder(t *testing.T) {
76+ inProject(t, map[string]string{
77+ "other.yaml": "model: from-flag\n",
78+ "env.yaml": "model: from-env\n",
79+ ".mm/agent.yaml": "model: from-mm-folder\n",
80+ })
81+
82+ if _, err := Load("other.yaml"); err != nil || Cfg.Model != "from-flag" {
83+ t.Errorf("Load(other.yaml): model = %q, err = %v; want the named file", Cfg.Model, err)
84+ }
85+ t.Setenv("AGENT_CONFIG", "env.yaml")
86+ if _, err := Load(""); err != nil || Cfg.Model != "from-env" {
87+ t.Errorf("AGENT_CONFIG: model = %q, err = %v; want the file it names", Cfg.Model, err)
88+ }
89+}
90+
91+func TestBaseDirIsTheProjectRootForAFileInTheMMFolder(t *testing.T) {
92+ root := t.TempDir()
93+ cases := map[string]string{
94+ filepath.Join(root, "agent.yaml"): root,
95+ filepath.Join(root, ".mm", "agent.yaml"): root,
96+ filepath.Join(root, "demos", "x", "agent.yaml"): filepath.Join(root, "demos", "x"),
97+ filepath.Join(root, "mm", "agent.yaml"): filepath.Join(root, "mm"),
98+ }
99+ for path, want := range cases {
100+ if got := BaseDir(path); got != want {
101+ t.Errorf("BaseDir(%q) = %q, want %q", path, got, want)
102+ }
103+ }
104+}
105+
106+func TestBaseDirOfARelativePathIsAbsolute(t *testing.T) {
107+ root := inProject(t, nil)
108+ if got := BaseDir(filepath.Join(".mm", "agent.yaml")); got != root {
109+ t.Errorf("BaseDir(.mm/agent.yaml) = %q, want the project root %q", got, root)
110+ }
111+}
112+
113+// The relative paths of a .mm/agent.yaml are written from the project root:
114+// mmConfigs, hooks, wasm tools and both deciders must all land there.
115+func TestPathsOfAConfigInTheMMFolderAreResolvedFromTheProjectRoot(t *testing.T) {
116+ root := inProject(t, map[string]string{".mm/agent.yaml": `
117+mmConfigs:
118+ lucy: .mm/agents/lucy.yaml
119+skillRouting:
120+ decider: .mm/decider.yaml
121+`,
122+ // The decider must exist: Load checks it, which is what proves it
123+ // was looked for at the root and not in .mm/.mm/.
124+ ".mm/decider.yaml": "model: decider\n",
125+ })
126+
127+ path, err := Load("")
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ mm := filepath.Join(root, ".mm")
132+ checks := map[string][2]string{
133+ "mmConfigs.lucy": {Cfg.MMConfigs["lucy"].Path, filepath.Join(mm, "agents", "lucy.yaml")},
134+ "skillRouting.decider": {Cfg.SkillRouting.Decider, filepath.Join(mm, "decider.yaml")},
135+ }
136+ for key, c := range checks {
137+ if c[0] != c[1] {
138+ t.Errorf("%s = %q, want %q", key, c[0], c[1])
139+ }
140+ }
141+
142+ // Hooks, wasm tools and the tool decider go through the same resolve
143+ // step; checked on the functions themselves so the test does not depend
144+ // on their validation rules (toolRouting needs groups, for one).
145+ resolveHooks(path)
146+ if Cfg.Hooks.ConfigDir != root {
147+ t.Errorf("hooks ConfigDir = %q, want the project root %q", Cfg.Hooks.ConfigDir, root)
148+ }
149+ Cfg.Tools = []DeclaredTool{{Wasm: ".mm/tools/echo.wasm"}}
150+ resolveToolPaths(path)
151+ if want := filepath.Join(mm, "tools", "echo.wasm"); Cfg.Tools[0].Wasm != want {
152+ t.Errorf("tools[0].wasm = %q, want %q", Cfg.Tools[0].Wasm, want)
153+ }
154+ Cfg.ToolRouting.Decider = ".mm/tool-decider.yaml"
155+ resolveToolDecider(path)
156+ if want := filepath.Join(mm, "tool-decider.yaml"); Cfg.ToolRouting.Decider != want {
157+ t.Errorf("toolRouting.decider = %q, want %q", Cfg.ToolRouting.Decider, want)
158+ }
159+}
160+
161+// toolFormat (ticket 207): openai (or nothing) and xml_tools are read, any
162+// other value stops the load with the two accepted ones named.
163+func TestToolFormatValues(t *testing.T) {
164+ for value, ok := range map[string]bool{"": true, "openai": true, "xml_tools": true, "xml": false} {
165+ // One subtest each: inProject restores Cfg when the subtest ends.
166+ t.Run(value, func(t *testing.T) {
167+ inProject(t, map[string]string{"agent.yaml": "toolFormat: " + value + "\n"})
168+ _, err := Load("")
169+ if (err == nil) != ok {
170+ t.Errorf("toolFormat %q: err = %v", value, err)
171+ }
172+ if !ok && (err == nil || !strings.Contains(err.Error(), "openai or xml_tools")) {
173+ t.Errorf("toolFormat %q: the message does not name the accepted values: %v", value, err)
174+ }
175+ })
176+ }
177+}
new file mode 100644
@@ -0,0 +1,177 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// inProject moves the test into a fresh project directory holding the given
11+// files (path → content), and restores the working directory and Cfg after.
12+func inProject(t *testing.T, files map[string]string) string {
13+ t.Helper()
14+ defaults := Cfg
15+ t.Cleanup(func() { Cfg = defaults })
16+ t.Setenv("AGENT_CONFIG", "")
17+
18+ dir := t.TempDir()
19+ for name, content := range files {
20+ path := filepath.Join(dir, name)
21+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
22+ t.Fatal(err)
23+ }
24+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
25+ t.Fatal(err)
26+ }
27+ }
28+ t.Chdir(dir)
29+ return dir
30+}
31+
32+func TestLoadFindsTheConfigInTheMMFolder(t *testing.T) {
33+ inProject(t, map[string]string{".mm/agent.yaml": "model: from-mm-folder\n"})
34+
35+ path, err := Load("")
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if want := filepath.Join(".mm", "agent.yaml"); path != want {
40+ t.Errorf("path = %q, want %q", path, want)
41+ }
42+ if Cfg.Model != "from-mm-folder" {
43+ t.Errorf("model = %q, want the one of .mm/agent.yaml", Cfg.Model)
44+ }
45+}
46+
47+func TestLoadPrefersAgentYamlAtTheRootOverTheMMFolder(t *testing.T) {
48+ inProject(t, map[string]string{
49+ "agent.yaml": "model: from-root\n",
50+ ".mm/agent.yaml": "model: from-mm-folder\n",
51+ })
52+
53+ path, err := Load("")
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if path != DefaultPath || Cfg.Model != "from-root" {
58+ t.Errorf("path = %q, model = %q; want ./agent.yaml and its model", path, Cfg.Model)
59+ }
60+}
61+
62+func TestLoadKeepsTheDefaultsWithNoConfigAnywhere(t *testing.T) {
63+ inProject(t, map[string]string{".mm/sessions/keep": ""})
64+ model := Cfg.Model
65+
66+ path, err := Load("")
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if path != "" || Cfg.Model != model {
71+ t.Errorf("path = %q, model = %q; want no file and the built-in model", path, Cfg.Model)
72+ }
73+}
74+
75+func TestAnExplicitConfigWinsOverTheMMFolder(t *testing.T) {
76+ inProject(t, map[string]string{
77+ "other.yaml": "model: from-flag\n",
78+ "env.yaml": "model: from-env\n",
79+ ".mm/agent.yaml": "model: from-mm-folder\n",
80+ })
81+
82+ if _, err := Load("other.yaml"); err != nil || Cfg.Model != "from-flag" {
83+ t.Errorf("Load(other.yaml): model = %q, err = %v; want the named file", Cfg.Model, err)
84+ }
85+ t.Setenv("AGENT_CONFIG", "env.yaml")
86+ if _, err := Load(""); err != nil || Cfg.Model != "from-env" {
87+ t.Errorf("AGENT_CONFIG: model = %q, err = %v; want the file it names", Cfg.Model, err)
88+ }
89+}
90+
91+func TestBaseDirIsTheProjectRootForAFileInTheMMFolder(t *testing.T) {
92+ root := t.TempDir()
93+ cases := map[string]string{
94+ filepath.Join(root, "agent.yaml"): root,
95+ filepath.Join(root, ".mm", "agent.yaml"): root,
96+ filepath.Join(root, "demos", "x", "agent.yaml"): filepath.Join(root, "demos", "x"),
97+ filepath.Join(root, "mm", "agent.yaml"): filepath.Join(root, "mm"),
98+ }
99+ for path, want := range cases {
100+ if got := BaseDir(path); got != want {
101+ t.Errorf("BaseDir(%q) = %q, want %q", path, got, want)
102+ }
103+ }
104+}
105+
106+func TestBaseDirOfARelativePathIsAbsolute(t *testing.T) {
107+ root := inProject(t, nil)
108+ if got := BaseDir(filepath.Join(".mm", "agent.yaml")); got != root {
109+ t.Errorf("BaseDir(.mm/agent.yaml) = %q, want the project root %q", got, root)
110+ }
111+}
112+
113+// The relative paths of a .mm/agent.yaml are written from the project root:
114+// mmConfigs, hooks, wasm tools and both deciders must all land there.
115+func TestPathsOfAConfigInTheMMFolderAreResolvedFromTheProjectRoot(t *testing.T) {
116+ root := inProject(t, map[string]string{".mm/agent.yaml": `
117+mmConfigs:
118+ lucy: .mm/agents/lucy.yaml
119+skillRouting:
120+ decider: .mm/decider.yaml
121+`,
122+ // The decider must exist: Load checks it, which is what proves it
123+ // was looked for at the root and not in .mm/.mm/.
124+ ".mm/decider.yaml": "model: decider\n",
125+ })
126+
127+ path, err := Load("")
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ mm := filepath.Join(root, ".mm")
132+ checks := map[string][2]string{
133+ "mmConfigs.lucy": {Cfg.MMConfigs["lucy"].Path, filepath.Join(mm, "agents", "lucy.yaml")},
134+ "skillRouting.decider": {Cfg.SkillRouting.Decider, filepath.Join(mm, "decider.yaml")},
135+ }
136+ for key, c := range checks {
137+ if c[0] != c[1] {
138+ t.Errorf("%s = %q, want %q", key, c[0], c[1])
139+ }
140+ }
141+
142+ // Hooks, wasm tools and the tool decider go through the same resolve
143+ // step; checked on the functions themselves so the test does not depend
144+ // on their validation rules (toolRouting needs groups, for one).
145+ resolveHooks(path)
146+ if Cfg.Hooks.ConfigDir != root {
147+ t.Errorf("hooks ConfigDir = %q, want the project root %q", Cfg.Hooks.ConfigDir, root)
148+ }
149+ Cfg.Tools = []DeclaredTool{{Wasm: ".mm/tools/echo.wasm"}}
150+ resolveToolPaths(path)
151+ if want := filepath.Join(mm, "tools", "echo.wasm"); Cfg.Tools[0].Wasm != want {
152+ t.Errorf("tools[0].wasm = %q, want %q", Cfg.Tools[0].Wasm, want)
153+ }
154+ Cfg.ToolRouting.Decider = ".mm/tool-decider.yaml"
155+ resolveToolDecider(path)
156+ if want := filepath.Join(mm, "tool-decider.yaml"); Cfg.ToolRouting.Decider != want {
157+ t.Errorf("toolRouting.decider = %q, want %q", Cfg.ToolRouting.Decider, want)
158+ }
159+}
160+
161+// toolFormat (ticket 207): openai (or nothing) and xml_tools are read, any
162+// other value stops the load with the two accepted ones named.
163+func TestToolFormatValues(t *testing.T) {
164+ for value, ok := range map[string]bool{"": true, "openai": true, "xml_tools": true, "xml": false} {
165+ // One subtest each: inProject restores Cfg when the subtest ends.
166+ t.Run(value, func(t *testing.T) {
167+ inProject(t, map[string]string{"agent.yaml": "toolFormat: " + value + "\n"})
168+ _, err := Load("")
169+ if (err == nil) != ok {
170+ t.Errorf("toolFormat %q: err = %v", value, err)
171+ }
172+ if !ok && (err == nil || !strings.Contains(err.Error(), "openai or xml_tools")) {
173+ t.Errorf("toolFormat %q: the message does not name the accepted values: %v", value, err)
174+ }
175+ })
176+ }
177+}
modified internal/config/toolrouting.go +2 -2
@@ -142,13 +142,13 @@ func (t ToolRoutingConfig) validate() error {
142142 }
143143
144144 // resolveToolDecider makes toolRouting.decider absolute against the
145-// directory of the config file, like skillRouting.decider.
145+// BaseDir of the config file, like skillRouting.decider.
146146 func resolveToolDecider(configPath string) {
147147 p := Cfg.ToolRouting.Decider
148148 if p == "" || filepath.IsAbs(p) {
149149 return
150150 }
151- if abs, err := filepath.Abs(filepath.Join(filepath.Dir(configPath), p)); err == nil {
151+ if abs, err := filepath.Abs(filepath.Join(BaseDir(configPath), p)); err == nil {
152152 Cfg.ToolRouting.Decider = abs
153153 }
154154 }
@@ -142,13 +142,13 @@ func (t ToolRoutingConfig) validate() error {
142 }142 }
143 143
144 // resolveToolDecider makes toolRouting.decider absolute against the144 // resolveToolDecider makes toolRouting.decider absolute against the
145-// directory of the config file, like skillRouting.decider.145+// BaseDir of the config file, like skillRouting.decider.
146 func resolveToolDecider(configPath string) {146 func resolveToolDecider(configPath string) {
147 p := Cfg.ToolRouting.Decider147 p := Cfg.ToolRouting.Decider
148 if p == "" || filepath.IsAbs(p) {148 if p == "" || filepath.IsAbs(p) {
149 return149 return
150 }150 }
151- if abs, err := filepath.Abs(filepath.Join(filepath.Dir(configPath), p)); err == nil {151+ if abs, err := filepath.Abs(filepath.Join(BaseDir(configPath), p)); err == nil {
152 Cfg.ToolRouting.Decider = abs152 Cfg.ToolRouting.Decider = abs
153 }153 }
154 }154 }
modified internal/config/tools.go +2 -2
@@ -295,10 +295,10 @@ func (t DeclaredTool) validateWasm(where string) error {
295295 }
296296
297297 // resolveToolPaths makes every relative wasm module path absolute against
298-// the directory of the agent file: the module ships with the agent, whoever
298+// the BaseDir of the agent file: the module ships with the agent, whoever
299299 // starts mm and from wherever.
300300 func resolveToolPaths(configPath string) {
301- dir := filepath.Dir(configPath)
301+ dir := BaseDir(configPath)
302302 for i, t := range Cfg.Tools {
303303 if t.Wasm == "" || filepath.IsAbs(t.Wasm) {
304304 continue
@@ -295,10 +295,10 @@ func (t DeclaredTool) validateWasm(where string) error {
295 }295 }
296 296
297 // resolveToolPaths makes every relative wasm module path absolute against297 // resolveToolPaths makes every relative wasm module path absolute against
298-// the directory of the agent file: the module ships with the agent, whoever298+// the BaseDir of the agent file: the module ships with the agent, whoever
299 // starts mm and from wherever.299 // starts mm and from wherever.
300 func resolveToolPaths(configPath string) {300 func resolveToolPaths(configPath string) {
301- dir := filepath.Dir(configPath)301+ dir := BaseDir(configPath)
302 for i, t := range Cfg.Tools {302 for i, t := range Cfg.Tools {
303 if t.Wasm == "" || filepath.IsAbs(t.Wasm) {303 if t.Wasm == "" || filepath.IsAbs(t.Wasm) {
304 continue304 continue
modified internal/engine/openai_compat.go +5 -0
@@ -286,6 +286,11 @@ func modelSupports() *ai.ModelSupports {
286286 // others have added their fields, and the response before they read it.
287287 func middlewares(p *openaiCompat) []option.Middleware {
288288 chain := []option.Middleware{streamOptions(p.progress, p.thinkingOff), reasoningTap}
289+ // toolFormat: xml_tools (ticket 207) goes first: Genkit then sees an
290+ // ordinary tool call, and the trace records the request as rewritten.
291+ if config.Cfg.ToolFormat == config.ToolFormatXML {
292+ chain = append([]option.Middleware{xmlTools}, chain...)
293+ }
289294 if config.Cfg.Trace.Enabled {
290295 chain = append(chain, traceMiddleware(config.Cfg.Trace.Dir, config.Cfg.ACP.Name))
291296 }
@@ -286,6 +286,11 @@ func modelSupports() *ai.ModelSupports {
286 // others have added their fields, and the response before they read it.286 // others have added their fields, and the response before they read it.
287 func middlewares(p *openaiCompat) []option.Middleware {287 func middlewares(p *openaiCompat) []option.Middleware {
288 chain := []option.Middleware{streamOptions(p.progress, p.thinkingOff), reasoningTap}288 chain := []option.Middleware{streamOptions(p.progress, p.thinkingOff), reasoningTap}
289+ // toolFormat: xml_tools (ticket 207) goes first: Genkit then sees an
290+ // ordinary tool call, and the trace records the request as rewritten.
291+ if config.Cfg.ToolFormat == config.ToolFormatXML {
292+ chain = append([]option.Middleware{xmlTools}, chain...)
293+ }
289 if config.Cfg.Trace.Enabled {294 if config.Cfg.Trace.Enabled {
290 chain = append(chain, traceMiddleware(config.Cfg.Trace.Dir, config.Cfg.ACP.Name))295 chain = append(chain, traceMiddleware(config.Cfg.Trace.Dir, config.Cfg.ACP.Name))
291 }296 }
added internal/engine/xmltools.go +473 -0
new file mode 100644
@@ -0,0 +1,473 @@
1+package engine
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "regexp"
10+ "strings"
11+ "sync/atomic"
12+
13+ "github.com/openai/openai-go/option"
14+)
15+
16+// toolFormat: xml_tools (ticket 207). Some models read their tools from a
17+// chat-template variable named xml_tools and write their calls as text —
18+// SmolLM3's template never looks at the OpenAI API's `tools`, which is what
19+// llama-server hands it, so the model never sees mm's tools; given
20+// xml_tools it answers
21+//
22+// <tool_call>
23+// {"name": "bash", "arguments": {"command": "ls"}}
24+// </tool_call>
25+//
26+// as plain content, which the server does not turn into a tool call.
27+//
28+// This middleware makes such a model look like any other to Genkit:
29+//
30+// - the request's `tools` go to chat_template_kwargs.xml_tools instead;
31+// - an assistant message's tool_calls are written back into its content as
32+// <tool_call> blocks — the template renders a message by its content
33+// only, so the model would not see what it called;
34+// - in the answer, streamed or not, each <tool_call> block becomes a tool
35+// call and leaves the text. A block whose JSON is broken still becomes a
36+// call when a name can be read, with the broken arguments: Genkit then
37+// fails to parse them and toolargs.go hands the call back to the model,
38+// as for any other model.
39+
40+const (
41+ toolCallOpen = "<tool_call>"
42+ toolCallClose = "</tool_call>"
43+)
44+
45+// xmlTools is the openai-go middleware for toolFormat: xml_tools.
46+func xmlTools(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
47+ if req.Method != http.MethodPost || req.Body == nil || !strings.HasSuffix(req.URL.Path, "/chat/completions") {
48+ return next(req)
49+ }
50+ if body, err := io.ReadAll(req.Body); err == nil {
51+ _ = req.Body.Close()
52+ body = xmlToolsRequest(body)
53+ req.Body = io.NopCloser(bytes.NewReader(body))
54+ req.ContentLength = int64(len(body))
55+ req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
56+ }
57+ resp, err := next(req)
58+ if err != nil || resp == nil || resp.Body == nil || resp.StatusCode != http.StatusOK {
59+ return resp, err
60+ }
61+ if strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") {
62+ resp.Body = &sseCalls{rc: resp.Body, ids: newCallIDs()}
63+ return resp, nil
64+ }
65+ data, err := io.ReadAll(resp.Body)
66+ _ = resp.Body.Close()
67+ if err != nil {
68+ return nil, err
69+ }
70+ data = xmlToolsResponse(data, newCallIDs())
71+ resp.Body = io.NopCloser(bytes.NewReader(data))
72+ resp.ContentLength = int64(len(data))
73+ resp.Header.Del("Content-Length")
74+ return resp, nil
75+}
76+
77+// xmlToolsRequest moves the tools into chat_template_kwargs.xml_tools and
78+// writes the history's tool calls into their messages' content. A body that
79+// is not the JSON it expects is returned as it came.
80+func xmlToolsRequest(body []byte) []byte {
81+ var m map[string]json.RawMessage
82+ if json.Unmarshal(body, &m) != nil {
83+ return body
84+ }
85+ changed := false
86+ if tools, ok := m["tools"]; ok && string(tools) != "null" {
87+ kwargs := map[string]json.RawMessage{}
88+ if raw, ok := m["chat_template_kwargs"]; ok {
89+ _ = json.Unmarshal(raw, &kwargs)
90+ }
91+ kwargs["xml_tools"] = tools
92+ m["chat_template_kwargs"], _ = json.Marshal(kwargs)
93+ delete(m, "tools")
94+ delete(m, "tool_choice")
95+ changed = true
96+ }
97+ if msgs, ok := callsAsText(m["messages"]); ok {
98+ m["messages"] = msgs
99+ changed = true
100+ }
101+ if !changed {
102+ return body
103+ }
104+ out, err := json.Marshal(m)
105+ if err != nil {
106+ return body
107+ }
108+ return out
109+}
110+
111+// callsAsText rewrites every assistant message that has tool_calls: the
112+// calls become <tool_call> blocks after its text. A tool's answer is left as
113+// it is: the template shows it as a user message, and wrapping it in
114+// <tool_response> tags made no measured difference (2026-11-13, SmolLM3-3B).
115+// ok is false when nothing changed.
116+func callsAsText(raw json.RawMessage) (json.RawMessage, bool) {
117+ var msgs []map[string]json.RawMessage
118+ if len(raw) == 0 || json.Unmarshal(raw, &msgs) != nil {
119+ return raw, false
120+ }
121+ changed := false
122+ for _, msg := range msgs {
123+ calls, ok := msg["tool_calls"]
124+ if !ok || string(calls) == "null" {
125+ continue
126+ }
127+ var list []struct {
128+ Function struct {
129+ Name string `json:"name"`
130+ Arguments string `json:"arguments"`
131+ } `json:"function"`
132+ }
133+ if json.Unmarshal(calls, &list) != nil {
134+ continue
135+ }
136+ var b strings.Builder
137+ b.WriteString(contentText(msg["content"]))
138+ for _, c := range list {
139+ b.WriteString(callText(c.Function.Name, c.Function.Arguments))
140+ }
141+ msg["content"], _ = json.Marshal(b.String())
142+ delete(msg, "tool_calls")
143+ changed = true
144+ }
145+ if !changed {
146+ return raw, false
147+ }
148+ out, err := json.Marshal(msgs)
149+ if err != nil {
150+ return raw, false
151+ }
152+ return out, true
153+}
154+
155+// contentText is a message's text: a string, or the text parts of an array.
156+func contentText(raw json.RawMessage) string {
157+ var s string
158+ if json.Unmarshal(raw, &s) == nil {
159+ return s
160+ }
161+ var parts []struct {
162+ Text string `json:"text"`
163+ }
164+ if json.Unmarshal(raw, &parts) != nil {
165+ return ""
166+ }
167+ var b strings.Builder
168+ for _, p := range parts {
169+ b.WriteString(p.Text)
170+ }
171+ return b.String()
172+}
173+
174+// callText is one call in the form the template asks for:
175+// {"name": <function-name>, "arguments": <args-json-object>} in <tool_call>.
176+func callText(name, args string) string {
177+ arguments := json.RawMessage(args)
178+ if !json.Valid(arguments) {
179+ arguments, _ = json.Marshal(args)
180+ }
181+ call, _ := json.Marshal(struct {
182+ Name string `json:"name"`
183+ Arguments json.RawMessage `json:"arguments"`
184+ }{name, arguments})
185+ return "\n" + toolCallOpen + "\n" + string(call) + "\n" + toolCallClose
186+}
187+
188+// xmlCall is a tool call read from the text.
189+type xmlCall struct {
190+ Name string
191+ Arguments string // JSON, or the broken text the model wrote
192+}
193+
194+var callName = regexp.MustCompile(`"name"\s*:\s*"([^"]+)"`)
195+
196+// parseCall reads the inside of a <tool_call> block. A block that is not
197+// JSON still makes a call when its name can be read — its arguments are the
198+// broken text, which Genkit refuses and toolargs.go hands back to the model.
199+func parseCall(inside string) (xmlCall, bool) {
200+ inside = strings.TrimSpace(inside)
201+ var v struct {
202+ Name string `json:"name"`
203+ Arguments json.RawMessage `json:"arguments"`
204+ }
205+ if json.Unmarshal([]byte(inside), &v) == nil && v.Name != "" {
206+ args := string(v.Arguments)
207+ var quoted string
208+ if json.Unmarshal(v.Arguments, &quoted) == nil {
209+ args = quoted // arguments written as a JSON string
210+ }
211+ if strings.TrimSpace(args) == "" || args == "null" {
212+ args = "{}"
213+ }
214+ return xmlCall{Name: v.Name, Arguments: args}, true
215+ }
216+ m := callName.FindStringSubmatch(inside)
217+ if m == nil {
218+ return xmlCall{}, false
219+ }
220+ args := inside
221+ if _, after, found := strings.Cut(inside, `"arguments"`); found {
222+ args = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(after), ":"))
223+ }
224+ return xmlCall{Name: m[1], Arguments: args}, true
225+}
226+
227+// callParser takes the answer's text piece by piece and separates the
228+// <tool_call> blocks from the rest. It holds back what could be the start
229+// of a block, so no "<tool_c" reaches the screen before it is known.
230+type callParser struct {
231+ buf string
232+ inBlock bool
233+}
234+
235+// feed takes the next piece of text; it returns the text to show and the
236+// calls completed by this piece.
237+func (p *callParser) feed(s string) (string, []xmlCall) {
238+ p.buf += s
239+ var out strings.Builder
240+ var calls []xmlCall
241+ for {
242+ if p.inBlock {
243+ end := strings.Index(p.buf, toolCallClose)
244+ if end < 0 {
245+ break
246+ }
247+ inside := p.buf[:end]
248+ p.buf, p.inBlock = p.buf[end+len(toolCallClose):], false
249+ if c, ok := parseCall(inside); ok {
250+ calls = append(calls, c)
251+ } else {
252+ out.WriteString(toolCallOpen + inside + toolCallClose)
253+ }
254+ continue
255+ }
256+ if start := strings.Index(p.buf, toolCallOpen); start >= 0 {
257+ out.WriteString(p.buf[:start])
258+ p.buf, p.inBlock = p.buf[start+len(toolCallOpen):], true
259+ continue
260+ }
261+ hold := partialOpen(p.buf)
262+ out.WriteString(p.buf[:len(p.buf)-hold])
263+ p.buf = p.buf[len(p.buf)-hold:]
264+ break
265+ }
266+ return out.String(), calls
267+}
268+
269+// flush ends the answer: what was held back is shown, and a block cut by
270+// the end (max_tokens) still makes a call when its name can be read.
271+func (p *callParser) flush() (string, []xmlCall) {
272+ rest, inBlock := p.buf, p.inBlock
273+ p.buf, p.inBlock = "", false
274+ if !inBlock {
275+ return rest, nil
276+ }
277+ if c, ok := parseCall(rest); ok {
278+ return "", []xmlCall{c}
279+ }
280+ return toolCallOpen + rest, nil
281+}
282+
283+// partialOpen is the length of the longest end of s that begins "<tool_call>".
284+func partialOpen(s string) int {
285+ for n := min(len(toolCallOpen)-1, len(s)); n > 0; n-- {
286+ if strings.HasSuffix(s, toolCallOpen[:n]) {
287+ return n
288+ }
289+ }
290+ return 0
291+}
292+
293+// callIDs numbers the calls of one answer; the prefix keeps two answers'
294+// ids apart in the history.
295+type callIDs struct {
296+ prefix string
297+ next int
298+}
299+
300+var answerCount atomic.Uint64
301+
302+func newCallIDs() *callIDs {
303+ return &callIDs{prefix: fmt.Sprintf("call_xml%d", answerCount.Add(1))}
304+}
305+
306+func (c *callIDs) id() string {
307+ c.next++
308+ return fmt.Sprintf("%s_%d", c.prefix, c.next)
309+}
310+
311+// openaiCall is a tool call in the OpenAI API's shape; Index is set in a
312+// streamed delta only.
313+type openaiCall struct {
314+ Index *int `json:"index,omitempty"`
315+ ID string `json:"id"`
316+ Type string `json:"type"`
317+ Function struct {
318+ Name string `json:"name"`
319+ Arguments string `json:"arguments"`
320+ } `json:"function"`
321+}
322+
323+func toOpenAI(c xmlCall, id string, index *int) openaiCall {
324+ o := openaiCall{Index: index, ID: id, Type: "function"}
325+ o.Function.Name, o.Function.Arguments = c.Name, c.Arguments
326+ return o
327+}
328+
329+// xmlToolsResponse rewrites a whole (not streamed) answer.
330+func xmlToolsResponse(data []byte, ids *callIDs) []byte {
331+ var m map[string]json.RawMessage
332+ if json.Unmarshal(data, &m) != nil {
333+ return data
334+ }
335+ var choices []map[string]json.RawMessage
336+ if json.Unmarshal(m["choices"], &choices) != nil {
337+ return data
338+ }
339+ changed := false
340+ for _, choice := range choices {
341+ var msg map[string]json.RawMessage
342+ if json.Unmarshal(choice["message"], &msg) != nil {
343+ continue
344+ }
345+ var p callParser
346+ text, calls := p.feed(contentText(msg["content"]))
347+ rest, more := p.flush()
348+ text, calls = text+rest, append(calls, more...)
349+ if len(calls) == 0 {
350+ continue
351+ }
352+ list := make([]openaiCall, 0, len(calls))
353+ for _, c := range calls {
354+ list = append(list, toOpenAI(c, ids.id(), nil))
355+ }
356+ msg["content"], _ = json.Marshal(strings.TrimSpace(text))
357+ msg["tool_calls"], _ = json.Marshal(list)
358+ choice["message"], _ = json.Marshal(msg)
359+ choice["finish_reason"] = json.RawMessage(`"tool_calls"`)
360+ changed = true
361+ }
362+ if !changed {
363+ return data
364+ }
365+ m["choices"], _ = json.Marshal(choices)
366+ out, err := json.Marshal(m)
367+ if err != nil {
368+ return data
369+ }
370+ return out
371+}
372+
373+// sseCalls rewrites a streamed answer line by line: the content of the
374+// first choice goes through a callParser, and each completed block is sent
375+// as a tool_calls delta in the chunk that completed it.
376+type sseCalls struct {
377+ rc io.ReadCloser
378+ ids *callIDs
379+ parser callParser
380+ calls int // calls sent so far: the next delta index
381+ in []byte
382+ out bytes.Buffer
383+ eof bool
384+ err error
385+}
386+
387+func (s *sseCalls) Close() error { return s.rc.Close() }
388+
389+func (s *sseCalls) Read(p []byte) (int, error) {
390+ for s.out.Len() == 0 && !s.eof {
391+ chunk := make([]byte, 4096)
392+ n, err := s.rc.Read(chunk)
393+ s.in = append(s.in, chunk[:n]...)
394+ for {
395+ i := bytes.IndexByte(s.in, '\n')
396+ if i < 0 {
397+ break
398+ }
399+ s.line(string(s.in[:i]))
400+ s.in = s.in[i+1:]
401+ }
402+ if err != nil {
403+ if len(s.in) > 0 {
404+ s.line(string(s.in))
405+ s.in = nil
406+ }
407+ s.eof, s.err = true, err
408+ }
409+ }
410+ if s.out.Len() > 0 {
411+ return s.out.Read(p)
412+ }
413+ return 0, s.err
414+}
415+
416+// line rewrites one SSE line; anything but a data chunk goes through.
417+func (s *sseCalls) line(line string) {
418+ data, ok := strings.CutPrefix(strings.TrimRight(line, "\r"), "data:")
419+ data = strings.TrimSpace(data)
420+ if !ok || data == "" || data == "[DONE]" {
421+ s.out.WriteString(line + "\n")
422+ return
423+ }
424+ rewritten, err := s.chunk([]byte(data))
425+ if err != nil {
426+ s.out.WriteString(line + "\n")
427+ return
428+ }
429+ s.out.WriteString("data: " + string(rewritten) + "\n")
430+}
431+
432+// chunk rewrites one streamed chunk's first choice.
433+func (s *sseCalls) chunk(data []byte) ([]byte, error) {
434+ var m map[string]json.RawMessage
435+ if err := json.Unmarshal(data, &m); err != nil {
436+ return nil, err
437+ }
438+ var choices []map[string]json.RawMessage
439+ if err := json.Unmarshal(m["choices"], &choices); err != nil || len(choices) == 0 {
440+ return data, nil
441+ }
442+ choice := choices[0]
443+ delta := map[string]json.RawMessage{}
444+ if raw, ok := choice["delta"]; ok {
445+ if err := json.Unmarshal(raw, &delta); err != nil {
446+ return nil, err
447+ }
448+ }
449+ text, calls := s.parser.feed(contentText(delta["content"]))
450+ finished := len(choice["finish_reason"]) > 0 && string(choice["finish_reason"]) != "null"
451+ if finished {
452+ rest, more := s.parser.flush()
453+ text, calls = text+rest, append(calls, more...)
454+ }
455+ if _, had := delta["content"]; had || text != "" {
456+ delta["content"], _ = json.Marshal(text)
457+ }
458+ if len(calls) > 0 {
459+ list := make([]openaiCall, 0, len(calls))
460+ for _, c := range calls {
461+ index := s.calls
462+ s.calls++
463+ list = append(list, toOpenAI(c, s.ids.id(), &index))
464+ }
465+ delta["tool_calls"], _ = json.Marshal(list)
466+ }
467+ if finished && s.calls > 0 {
468+ choice["finish_reason"] = json.RawMessage(`"tool_calls"`)
469+ }
470+ choice["delta"], _ = json.Marshal(delta)
471+ m["choices"], _ = json.Marshal(choices)
472+ return json.Marshal(m)
473+}
new file mode 100644
@@ -0,0 +1,473 @@
1+package engine
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "regexp"
10+ "strings"
11+ "sync/atomic"
12+
13+ "github.com/openai/openai-go/option"
14+)
15+
16+// toolFormat: xml_tools (ticket 207). Some models read their tools from a
17+// chat-template variable named xml_tools and write their calls as text —
18+// SmolLM3's template never looks at the OpenAI API's `tools`, which is what
19+// llama-server hands it, so the model never sees mm's tools; given
20+// xml_tools it answers
21+//
22+// <tool_call>
23+// {"name": "bash", "arguments": {"command": "ls"}}
24+// </tool_call>
25+//
26+// as plain content, which the server does not turn into a tool call.
27+//
28+// This middleware makes such a model look like any other to Genkit:
29+//
30+// - the request's `tools` go to chat_template_kwargs.xml_tools instead;
31+// - an assistant message's tool_calls are written back into its content as
32+// <tool_call> blocks — the template renders a message by its content
33+// only, so the model would not see what it called;
34+// - in the answer, streamed or not, each <tool_call> block becomes a tool
35+// call and leaves the text. A block whose JSON is broken still becomes a
36+// call when a name can be read, with the broken arguments: Genkit then
37+// fails to parse them and toolargs.go hands the call back to the model,
38+// as for any other model.
39+
40+const (
41+ toolCallOpen = "<tool_call>"
42+ toolCallClose = "</tool_call>"
43+)
44+
45+// xmlTools is the openai-go middleware for toolFormat: xml_tools.
46+func xmlTools(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
47+ if req.Method != http.MethodPost || req.Body == nil || !strings.HasSuffix(req.URL.Path, "/chat/completions") {
48+ return next(req)
49+ }
50+ if body, err := io.ReadAll(req.Body); err == nil {
51+ _ = req.Body.Close()
52+ body = xmlToolsRequest(body)
53+ req.Body = io.NopCloser(bytes.NewReader(body))
54+ req.ContentLength = int64(len(body))
55+ req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
56+ }
57+ resp, err := next(req)
58+ if err != nil || resp == nil || resp.Body == nil || resp.StatusCode != http.StatusOK {
59+ return resp, err
60+ }
61+ if strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") {
62+ resp.Body = &sseCalls{rc: resp.Body, ids: newCallIDs()}
63+ return resp, nil
64+ }
65+ data, err := io.ReadAll(resp.Body)
66+ _ = resp.Body.Close()
67+ if err != nil {
68+ return nil, err
69+ }
70+ data = xmlToolsResponse(data, newCallIDs())
71+ resp.Body = io.NopCloser(bytes.NewReader(data))
72+ resp.ContentLength = int64(len(data))
73+ resp.Header.Del("Content-Length")
74+ return resp, nil
75+}
76+
77+// xmlToolsRequest moves the tools into chat_template_kwargs.xml_tools and
78+// writes the history's tool calls into their messages' content. A body that
79+// is not the JSON it expects is returned as it came.
80+func xmlToolsRequest(body []byte) []byte {
81+ var m map[string]json.RawMessage
82+ if json.Unmarshal(body, &m) != nil {
83+ return body
84+ }
85+ changed := false
86+ if tools, ok := m["tools"]; ok && string(tools) != "null" {
87+ kwargs := map[string]json.RawMessage{}
88+ if raw, ok := m["chat_template_kwargs"]; ok {
89+ _ = json.Unmarshal(raw, &kwargs)
90+ }
91+ kwargs["xml_tools"] = tools
92+ m["chat_template_kwargs"], _ = json.Marshal(kwargs)
93+ delete(m, "tools")
94+ delete(m, "tool_choice")
95+ changed = true
96+ }
97+ if msgs, ok := callsAsText(m["messages"]); ok {
98+ m["messages"] = msgs
99+ changed = true
100+ }
101+ if !changed {
102+ return body
103+ }
104+ out, err := json.Marshal(m)
105+ if err != nil {
106+ return body
107+ }
108+ return out
109+}
110+
111+// callsAsText rewrites every assistant message that has tool_calls: the
112+// calls become <tool_call> blocks after its text. A tool's answer is left as
113+// it is: the template shows it as a user message, and wrapping it in
114+// <tool_response> tags made no measured difference (2026-11-13, SmolLM3-3B).
115+// ok is false when nothing changed.
116+func callsAsText(raw json.RawMessage) (json.RawMessage, bool) {
117+ var msgs []map[string]json.RawMessage
118+ if len(raw) == 0 || json.Unmarshal(raw, &msgs) != nil {
119+ return raw, false
120+ }
121+ changed := false
122+ for _, msg := range msgs {
123+ calls, ok := msg["tool_calls"]
124+ if !ok || string(calls) == "null" {
125+ continue
126+ }
127+ var list []struct {
128+ Function struct {
129+ Name string `json:"name"`
130+ Arguments string `json:"arguments"`
131+ } `json:"function"`
132+ }
133+ if json.Unmarshal(calls, &list) != nil {
134+ continue
135+ }
136+ var b strings.Builder
137+ b.WriteString(contentText(msg["content"]))
138+ for _, c := range list {
139+ b.WriteString(callText(c.Function.Name, c.Function.Arguments))
140+ }
141+ msg["content"], _ = json.Marshal(b.String())
142+ delete(msg, "tool_calls")
143+ changed = true
144+ }
145+ if !changed {
146+ return raw, false
147+ }
148+ out, err := json.Marshal(msgs)
149+ if err != nil {
150+ return raw, false
151+ }
152+ return out, true
153+}
154+
155+// contentText is a message's text: a string, or the text parts of an array.
156+func contentText(raw json.RawMessage) string {
157+ var s string
158+ if json.Unmarshal(raw, &s) == nil {
159+ return s
160+ }
161+ var parts []struct {
162+ Text string `json:"text"`
163+ }
164+ if json.Unmarshal(raw, &parts) != nil {
165+ return ""
166+ }
167+ var b strings.Builder
168+ for _, p := range parts {
169+ b.WriteString(p.Text)
170+ }
171+ return b.String()
172+}
173+
174+// callText is one call in the form the template asks for:
175+// {"name": <function-name>, "arguments": <args-json-object>} in <tool_call>.
176+func callText(name, args string) string {
177+ arguments := json.RawMessage(args)
178+ if !json.Valid(arguments) {
179+ arguments, _ = json.Marshal(args)
180+ }
181+ call, _ := json.Marshal(struct {
182+ Name string `json:"name"`
183+ Arguments json.RawMessage `json:"arguments"`
184+ }{name, arguments})
185+ return "\n" + toolCallOpen + "\n" + string(call) + "\n" + toolCallClose
186+}
187+
188+// xmlCall is a tool call read from the text.
189+type xmlCall struct {
190+ Name string
191+ Arguments string // JSON, or the broken text the model wrote
192+}
193+
194+var callName = regexp.MustCompile(`"name"\s*:\s*"([^"]+)"`)
195+
196+// parseCall reads the inside of a <tool_call> block. A block that is not
197+// JSON still makes a call when its name can be read — its arguments are the
198+// broken text, which Genkit refuses and toolargs.go hands back to the model.
199+func parseCall(inside string) (xmlCall, bool) {
200+ inside = strings.TrimSpace(inside)
201+ var v struct {
202+ Name string `json:"name"`
203+ Arguments json.RawMessage `json:"arguments"`
204+ }
205+ if json.Unmarshal([]byte(inside), &v) == nil && v.Name != "" {
206+ args := string(v.Arguments)
207+ var quoted string
208+ if json.Unmarshal(v.Arguments, &quoted) == nil {
209+ args = quoted // arguments written as a JSON string
210+ }
211+ if strings.TrimSpace(args) == "" || args == "null" {
212+ args = "{}"
213+ }
214+ return xmlCall{Name: v.Name, Arguments: args}, true
215+ }
216+ m := callName.FindStringSubmatch(inside)
217+ if m == nil {
218+ return xmlCall{}, false
219+ }
220+ args := inside
221+ if _, after, found := strings.Cut(inside, `"arguments"`); found {
222+ args = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(after), ":"))
223+ }
224+ return xmlCall{Name: m[1], Arguments: args}, true
225+}
226+
227+// callParser takes the answer's text piece by piece and separates the
228+// <tool_call> blocks from the rest. It holds back what could be the start
229+// of a block, so no "<tool_c" reaches the screen before it is known.
230+type callParser struct {
231+ buf string
232+ inBlock bool
233+}
234+
235+// feed takes the next piece of text; it returns the text to show and the
236+// calls completed by this piece.
237+func (p *callParser) feed(s string) (string, []xmlCall) {
238+ p.buf += s
239+ var out strings.Builder
240+ var calls []xmlCall
241+ for {
242+ if p.inBlock {
243+ end := strings.Index(p.buf, toolCallClose)
244+ if end < 0 {
245+ break
246+ }
247+ inside := p.buf[:end]
248+ p.buf, p.inBlock = p.buf[end+len(toolCallClose):], false
249+ if c, ok := parseCall(inside); ok {
250+ calls = append(calls, c)
251+ } else {
252+ out.WriteString(toolCallOpen + inside + toolCallClose)
253+ }
254+ continue
255+ }
256+ if start := strings.Index(p.buf, toolCallOpen); start >= 0 {
257+ out.WriteString(p.buf[:start])
258+ p.buf, p.inBlock = p.buf[start+len(toolCallOpen):], true
259+ continue
260+ }
261+ hold := partialOpen(p.buf)
262+ out.WriteString(p.buf[:len(p.buf)-hold])
263+ p.buf = p.buf[len(p.buf)-hold:]
264+ break
265+ }
266+ return out.String(), calls
267+}
268+
269+// flush ends the answer: what was held back is shown, and a block cut by
270+// the end (max_tokens) still makes a call when its name can be read.
271+func (p *callParser) flush() (string, []xmlCall) {
272+ rest, inBlock := p.buf, p.inBlock
273+ p.buf, p.inBlock = "", false
274+ if !inBlock {
275+ return rest, nil
276+ }
277+ if c, ok := parseCall(rest); ok {
278+ return "", []xmlCall{c}
279+ }
280+ return toolCallOpen + rest, nil
281+}
282+
283+// partialOpen is the length of the longest end of s that begins "<tool_call>".
284+func partialOpen(s string) int {
285+ for n := min(len(toolCallOpen)-1, len(s)); n > 0; n-- {
286+ if strings.HasSuffix(s, toolCallOpen[:n]) {
287+ return n
288+ }
289+ }
290+ return 0
291+}
292+
293+// callIDs numbers the calls of one answer; the prefix keeps two answers'
294+// ids apart in the history.
295+type callIDs struct {
296+ prefix string
297+ next int
298+}
299+
300+var answerCount atomic.Uint64
301+
302+func newCallIDs() *callIDs {
303+ return &callIDs{prefix: fmt.Sprintf("call_xml%d", answerCount.Add(1))}
304+}
305+
306+func (c *callIDs) id() string {
307+ c.next++
308+ return fmt.Sprintf("%s_%d", c.prefix, c.next)
309+}
310+
311+// openaiCall is a tool call in the OpenAI API's shape; Index is set in a
312+// streamed delta only.
313+type openaiCall struct {
314+ Index *int `json:"index,omitempty"`
315+ ID string `json:"id"`
316+ Type string `json:"type"`
317+ Function struct {
318+ Name string `json:"name"`
319+ Arguments string `json:"arguments"`
320+ } `json:"function"`
321+}
322+
323+func toOpenAI(c xmlCall, id string, index *int) openaiCall {
324+ o := openaiCall{Index: index, ID: id, Type: "function"}
325+ o.Function.Name, o.Function.Arguments = c.Name, c.Arguments
326+ return o
327+}
328+
329+// xmlToolsResponse rewrites a whole (not streamed) answer.
330+func xmlToolsResponse(data []byte, ids *callIDs) []byte {
331+ var m map[string]json.RawMessage
332+ if json.Unmarshal(data, &m) != nil {
333+ return data
334+ }
335+ var choices []map[string]json.RawMessage
336+ if json.Unmarshal(m["choices"], &choices) != nil {
337+ return data
338+ }
339+ changed := false
340+ for _, choice := range choices {
341+ var msg map[string]json.RawMessage
342+ if json.Unmarshal(choice["message"], &msg) != nil {
343+ continue
344+ }
345+ var p callParser
346+ text, calls := p.feed(contentText(msg["content"]))
347+ rest, more := p.flush()
348+ text, calls = text+rest, append(calls, more...)
349+ if len(calls) == 0 {
350+ continue
351+ }
352+ list := make([]openaiCall, 0, len(calls))
353+ for _, c := range calls {
354+ list = append(list, toOpenAI(c, ids.id(), nil))
355+ }
356+ msg["content"], _ = json.Marshal(strings.TrimSpace(text))
357+ msg["tool_calls"], _ = json.Marshal(list)
358+ choice["message"], _ = json.Marshal(msg)
359+ choice["finish_reason"] = json.RawMessage(`"tool_calls"`)
360+ changed = true
361+ }
362+ if !changed {
363+ return data
364+ }
365+ m["choices"], _ = json.Marshal(choices)
366+ out, err := json.Marshal(m)
367+ if err != nil {
368+ return data
369+ }
370+ return out
371+}
372+
373+// sseCalls rewrites a streamed answer line by line: the content of the
374+// first choice goes through a callParser, and each completed block is sent
375+// as a tool_calls delta in the chunk that completed it.
376+type sseCalls struct {
377+ rc io.ReadCloser
378+ ids *callIDs
379+ parser callParser
380+ calls int // calls sent so far: the next delta index
381+ in []byte
382+ out bytes.Buffer
383+ eof bool
384+ err error
385+}
386+
387+func (s *sseCalls) Close() error { return s.rc.Close() }
388+
389+func (s *sseCalls) Read(p []byte) (int, error) {
390+ for s.out.Len() == 0 && !s.eof {
391+ chunk := make([]byte, 4096)
392+ n, err := s.rc.Read(chunk)
393+ s.in = append(s.in, chunk[:n]...)
394+ for {
395+ i := bytes.IndexByte(s.in, '\n')
396+ if i < 0 {
397+ break
398+ }
399+ s.line(string(s.in[:i]))
400+ s.in = s.in[i+1:]
401+ }
402+ if err != nil {
403+ if len(s.in) > 0 {
404+ s.line(string(s.in))
405+ s.in = nil
406+ }
407+ s.eof, s.err = true, err
408+ }
409+ }
410+ if s.out.Len() > 0 {
411+ return s.out.Read(p)
412+ }
413+ return 0, s.err
414+}
415+
416+// line rewrites one SSE line; anything but a data chunk goes through.
417+func (s *sseCalls) line(line string) {
418+ data, ok := strings.CutPrefix(strings.TrimRight(line, "\r"), "data:")
419+ data = strings.TrimSpace(data)
420+ if !ok || data == "" || data == "[DONE]" {
421+ s.out.WriteString(line + "\n")
422+ return
423+ }
424+ rewritten, err := s.chunk([]byte(data))
425+ if err != nil {
426+ s.out.WriteString(line + "\n")
427+ return
428+ }
429+ s.out.WriteString("data: " + string(rewritten) + "\n")
430+}
431+
432+// chunk rewrites one streamed chunk's first choice.
433+func (s *sseCalls) chunk(data []byte) ([]byte, error) {
434+ var m map[string]json.RawMessage
435+ if err := json.Unmarshal(data, &m); err != nil {
436+ return nil, err
437+ }
438+ var choices []map[string]json.RawMessage
439+ if err := json.Unmarshal(m["choices"], &choices); err != nil || len(choices) == 0 {
440+ return data, nil
441+ }
442+ choice := choices[0]
443+ delta := map[string]json.RawMessage{}
444+ if raw, ok := choice["delta"]; ok {
445+ if err := json.Unmarshal(raw, &delta); err != nil {
446+ return nil, err
447+ }
448+ }
449+ text, calls := s.parser.feed(contentText(delta["content"]))
450+ finished := len(choice["finish_reason"]) > 0 && string(choice["finish_reason"]) != "null"
451+ if finished {
452+ rest, more := s.parser.flush()
453+ text, calls = text+rest, append(calls, more...)
454+ }
455+ if _, had := delta["content"]; had || text != "" {
456+ delta["content"], _ = json.Marshal(text)
457+ }
458+ if len(calls) > 0 {
459+ list := make([]openaiCall, 0, len(calls))
460+ for _, c := range calls {
461+ index := s.calls
462+ s.calls++
463+ list = append(list, toOpenAI(c, s.ids.id(), &index))
464+ }
465+ delta["tool_calls"], _ = json.Marshal(list)
466+ }
467+ if finished && s.calls > 0 {
468+ choice["finish_reason"] = json.RawMessage(`"tool_calls"`)
469+ }
470+ choice["delta"], _ = json.Marshal(delta)
471+ m["choices"], _ = json.Marshal(choices)
472+ return json.Marshal(m)
473+}
added internal/engine/xmltools_test.go +255 -0
new file mode 100644
@@ -0,0 +1,255 @@
1+package engine
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "net/http/httptest"
12+ "strings"
13+ "sync"
14+ "testing"
15+
16+ "mm/internal/config"
17+
18+ "github.com/firebase/genkit/go/ai"
19+ "github.com/firebase/genkit/go/genkit"
20+)
21+
22+// The tools move to chat_template_kwargs.xml_tools (beside what the config
23+// put there), and an earlier tool call is written into its message.
24+func TestXMLToolsRequest(t *testing.T) {
25+ body := `{"model":"m","tools":[{"type":"function","function":{"name":"bash"}}],"tool_choice":"auto",
26+ "chat_template_kwargs":{"enable_thinking":false},
27+ "messages":[{"role":"user","content":"list"},
28+ {"role":"assistant","content":"Let me look.","tool_calls":[{"id":"c1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"ls\"}"}}]},
29+ {"role":"tool","tool_call_id":"c1","content":"a.txt"}]}`
30+ var got struct {
31+ Tools any `json:"tools"`
32+ ToolChoice any `json:"tool_choice"`
33+ Kwargs map[string]any `json:"chat_template_kwargs"`
34+ Messages []map[string]any
35+ }
36+ if err := json.Unmarshal(xmlToolsRequest([]byte(body)), &got); err != nil {
37+ t.Fatal(err)
38+ }
39+ if got.Tools != nil || got.ToolChoice != nil {
40+ t.Errorf("tools / tool_choice still sent: %v %v", got.Tools, got.ToolChoice)
41+ }
42+ if got.Kwargs["enable_thinking"] != false || got.Kwargs["xml_tools"] == nil {
43+ t.Errorf("chat_template_kwargs = %v", got.Kwargs)
44+ }
45+ assistant := got.Messages[1]
46+ want := "Let me look.\n<tool_call>\n{\"name\":\"bash\",\"arguments\":{\"command\":\"ls\"}}\n</tool_call>"
47+ if assistant["content"] != want || assistant["tool_calls"] != nil {
48+ t.Errorf("assistant message = %v", assistant)
49+ }
50+ if got.Messages[2]["content"] != "a.txt" {
51+ t.Errorf("the tool result changed: %v", got.Messages[2])
52+ }
53+ if same := xmlToolsRequest([]byte(`{"messages":[{"role":"user","content":"hi"}]}`)); string(same) != `{"messages":[{"role":"user","content":"hi"}]}` {
54+ t.Errorf("a request without tools changed: %s", same)
55+ }
56+}
57+
58+// The parser gets the answer in pieces, as a stream cuts it: no piece of
59+// the tag reaches the text, the call comes out whole.
60+func TestCallParserAcrossPieces(t *testing.T) {
61+ answer := "Let me look.\n<tool_call>\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls -a\"}}\n</tool_call>\nDone."
62+ for _, size := range []int{1, 3, 7, len(answer)} {
63+ var p callParser
64+ var text strings.Builder
65+ var calls []xmlCall
66+ for i := 0; i < len(answer); i += size {
67+ s, c := p.feed(answer[i:min(i+size, len(answer))])
68+ if strings.Contains(s, "<") {
69+ t.Errorf("size %d: a piece of the tag was shown: %q", size, s)
70+ }
71+ text.WriteString(s)
72+ calls = append(calls, c...)
73+ }
74+ rest, more := p.flush()
75+ text.WriteString(rest)
76+ calls = append(calls, more...)
77+ if text.String() != "Let me look.\n\nDone." || len(calls) != 1 || calls[0].Name != "bash" || calls[0].Arguments != `{"command": "ls -a"}` {
78+ t.Errorf("size %d: text %q, calls %+v", size, text.String(), calls)
79+ }
80+ }
81+}
82+
83+func TestCallParserEdges(t *testing.T) {
84+ cases := []struct {
85+ name, in, text string
86+ calls []xmlCall
87+ }{
88+ {"a < that is not a tag", "a < b and <tool", "a < b and <tool", nil},
89+ {"arguments as a JSON string", `<tool_call>{"name":"bash","arguments":"{\"command\":\"ls\"}"}</tool_call>`, "", []xmlCall{{"bash", `{"command":"ls"}`}}},
90+ {"no arguments", `<tool_call>{"name":"plan"}</tool_call>`, "", []xmlCall{{"plan", "{}"}}},
91+ {"broken JSON, name readable", `<tool_call>{"name": "bash", "arguments": {"command": "ls"</tool_call>`, "", []xmlCall{{"bash", `{"command": "ls"`}}},
92+ {"not a call at all", `<tool_call>hello</tool_call>`, "<tool_call>hello</tool_call>", nil},
93+ {"cut by the end (max_tokens)", `ok <tool_call>{"name": "write_file", "arguments": {"path": "a`, "ok ", []xmlCall{{"write_file", `{"path": "a`}}},
94+ {"cut, nameless", `ok <tool_call>{"na`, `ok <tool_call>{"na`, nil},
95+ {"two calls", `<tool_call>{"name":"a"}</tool_call><tool_call>{"name":"b"}</tool_call>`, "", []xmlCall{{"a", "{}"}, {"b", "{}"}}},
96+ }
97+ for _, c := range cases {
98+ var p callParser
99+ text, calls := p.feed(c.in)
100+ rest, more := p.flush()
101+ text, calls = text+rest, append(calls, more...)
102+ if text != c.text || fmt.Sprint(calls) != fmt.Sprint(c.calls) {
103+ t.Errorf("%s: text %q calls %+v, want %q %+v", c.name, text, calls, c.text, c.calls)
104+ }
105+ }
106+}
107+
108+func TestXMLToolsResponseNotStreamed(t *testing.T) {
109+ data := `{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":"\n<tool_call>\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}\n</tool_call>"},"finish_reason":"stop"}]}`
110+ var got struct {
111+ Choices []struct {
112+ Message struct {
113+ Content string `json:"content"`
114+ ToolCalls []openaiCall `json:"tool_calls"`
115+ } `json:"message"`
116+ FinishReason string `json:"finish_reason"`
117+ } `json:"choices"`
118+ }
119+ if err := json.Unmarshal(xmlToolsResponse([]byte(data), newCallIDs()), &got); err != nil {
120+ t.Fatal(err)
121+ }
122+ c := got.Choices[0]
123+ if c.Message.Content != "" || len(c.Message.ToolCalls) != 1 || c.Message.ToolCalls[0].Function.Arguments != `{"command": "ls"}` || c.FinishReason != "tool_calls" {
124+ t.Errorf("rewritten = %+v", c)
125+ }
126+}
127+
128+// The stream is rewritten chunk by chunk: the call goes out as a tool_calls
129+// delta, the tag never as content, and the answer ends with tool_calls.
130+func TestXMLToolsStream(t *testing.T) {
131+ var in bytes.Buffer
132+ for _, piece := range []string{"Let me look.\n<tool_c", "all>\n{\"name\": \"bash\", \"argu", "ments\": {\"command\": \"ls\"}}\n</tool_call>"} {
133+ delta, _ := json.Marshal(map[string]string{"content": piece})
134+ in.WriteString(sseChunkLine(string(delta), ""))
135+ }
136+ in.WriteString(sseChunkLine(`{}`, "stop"))
137+ in.WriteString("data: [DONE]\n\n")
138+ s := &sseCalls{rc: io.NopCloser(&in), ids: newCallIDs()}
139+ out, err := io.ReadAll(s)
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ var content strings.Builder
144+ var calls []openaiCall
145+ finish := ""
146+ sc := bufio.NewScanner(bytes.NewReader(out))
147+ for sc.Scan() {
148+ data, ok := strings.CutPrefix(sc.Text(), "data: ")
149+ if !ok || data == "[DONE]" {
150+ continue
151+ }
152+ var chunk struct {
153+ Choices []struct {
154+ Delta struct {
155+ Content string `json:"content"`
156+ ToolCalls []openaiCall `json:"tool_calls"`
157+ } `json:"delta"`
158+ FinishReason *string `json:"finish_reason"`
159+ } `json:"choices"`
160+ }
161+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
162+ t.Fatalf("bad chunk %q: %v", data, err)
163+ }
164+ content.WriteString(chunk.Choices[0].Delta.Content)
165+ calls = append(calls, chunk.Choices[0].Delta.ToolCalls...)
166+ if f := chunk.Choices[0].FinishReason; f != nil {
167+ finish = *f
168+ }
169+ }
170+ if content.String() != "Let me look.\n" || len(calls) != 1 || calls[0].Function.Name != "bash" || *calls[0].Index != 0 || finish != "tool_calls" {
171+ t.Errorf("content %q, calls %+v, finish %q\n%s", content.String(), calls, finish, out)
172+ }
173+ if !strings.HasSuffix(string(out), "data: [DONE]\n\n") {
174+ t.Errorf("the stream's end changed:\n%s", out)
175+ }
176+}
177+
178+// End to end through Genkit: with toolFormat: xml_tools the model's
179+// <tool_call> text runs the tool, and the next request shows the model its
180+// call as text and the tool's answer.
181+func TestXMLToolsRunTheToolThroughGenkit(t *testing.T) {
182+ old := config.Cfg.ToolFormat
183+ t.Cleanup(func() { config.Cfg.ToolFormat = old })
184+ config.Cfg.ToolFormat = config.ToolFormatXML
185+
186+ var mu sync.Mutex
187+ var bodies []map[string]any
188+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189+ if !strings.HasSuffix(r.URL.Path, "/chat/completions") {
190+ http.NotFound(w, r)
191+ return
192+ }
193+ var m map[string]any
194+ b, _ := io.ReadAll(r.Body)
195+ _ = json.Unmarshal(b, &m)
196+ mu.Lock()
197+ bodies = append(bodies, m)
198+ call := len(bodies)
199+ mu.Unlock()
200+ w.Header().Set("Content-Type", "text/event-stream")
201+ if call == 1 {
202+ for _, piece := range []string{"\n<tool_c", "all>\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}\n</tool_call>"} {
203+ delta, _ := json.Marshal(map[string]string{"content": piece})
204+ _, _ = fmt.Fprint(w, sseChunkLine(string(delta), ""))
205+ }
206+ } else {
207+ _, _ = fmt.Fprint(w, sseChunkLine(`{"role":"assistant","content":"There is a.txt."}`, ""))
208+ }
209+ _, _ = fmt.Fprint(w, sseChunkLine(`{}`, "stop"))
210+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
211+ }))
212+ t.Cleanup(srv.Close)
213+ e := engineOn(t, srv.URL+"/v1")
214+ ran := ""
215+ genkit.DefineTool(e.G, "bash", "run a shell command",
216+ func(_ *ai.ToolContext, in struct {
217+ Command string `json:"command"`
218+ }) (string, error) {
219+ ran = in.Command
220+ return "a.txt", nil
221+ })
222+
223+ resp, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("list the files")}, []ai.ToolRef{ai.ToolName("bash")})
224+ if err != nil {
225+ t.Fatalf("Generate: %v", err)
226+ }
227+ if ran != "ls" || resp.Text() != "There is a.txt." {
228+ t.Fatalf("tool ran %q, answer %q", ran, resp.Text())
229+ }
230+ mu.Lock()
231+ defer mu.Unlock()
232+ if len(bodies) != 2 {
233+ t.Fatalf("%d requests, want 2", len(bodies))
234+ }
235+ kw, _ := bodies[0]["chat_template_kwargs"].(map[string]any)
236+ if bodies[0]["tools"] != nil || kw["xml_tools"] == nil {
237+ t.Errorf("first request: tools %v, chat_template_kwargs %v", bodies[0]["tools"], kw)
238+ }
239+ msgs, _ := bodies[1]["messages"].([]any)
240+ var assistant, tool map[string]any
241+ for _, m := range msgs {
242+ switch mm := m.(map[string]any); mm["role"] {
243+ case "assistant":
244+ assistant = mm
245+ case "tool":
246+ tool = mm
247+ }
248+ }
249+ if content, _ := assistant["content"].(string); !strings.Contains(content, `<tool_call>`) || !strings.Contains(content, `"command":"ls"`) || assistant["tool_calls"] != nil {
250+ t.Errorf("the model does not see its call as text: %v", assistant)
251+ }
252+ if tool == nil || !strings.Contains(fmt.Sprint(tool["content"]), "a.txt") {
253+ t.Errorf("tool result not sent: %v", tool)
254+ }
255+}
new file mode 100644
@@ -0,0 +1,255 @@
1+package engine
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "net/http/httptest"
12+ "strings"
13+ "sync"
14+ "testing"
15+
16+ "mm/internal/config"
17+
18+ "github.com/firebase/genkit/go/ai"
19+ "github.com/firebase/genkit/go/genkit"
20+)
21+
22+// The tools move to chat_template_kwargs.xml_tools (beside what the config
23+// put there), and an earlier tool call is written into its message.
24+func TestXMLToolsRequest(t *testing.T) {
25+ body := `{"model":"m","tools":[{"type":"function","function":{"name":"bash"}}],"tool_choice":"auto",
26+ "chat_template_kwargs":{"enable_thinking":false},
27+ "messages":[{"role":"user","content":"list"},
28+ {"role":"assistant","content":"Let me look.","tool_calls":[{"id":"c1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"ls\"}"}}]},
29+ {"role":"tool","tool_call_id":"c1","content":"a.txt"}]}`
30+ var got struct {
31+ Tools any `json:"tools"`
32+ ToolChoice any `json:"tool_choice"`
33+ Kwargs map[string]any `json:"chat_template_kwargs"`
34+ Messages []map[string]any
35+ }
36+ if err := json.Unmarshal(xmlToolsRequest([]byte(body)), &got); err != nil {
37+ t.Fatal(err)
38+ }
39+ if got.Tools != nil || got.ToolChoice != nil {
40+ t.Errorf("tools / tool_choice still sent: %v %v", got.Tools, got.ToolChoice)
41+ }
42+ if got.Kwargs["enable_thinking"] != false || got.Kwargs["xml_tools"] == nil {
43+ t.Errorf("chat_template_kwargs = %v", got.Kwargs)
44+ }
45+ assistant := got.Messages[1]
46+ want := "Let me look.\n<tool_call>\n{\"name\":\"bash\",\"arguments\":{\"command\":\"ls\"}}\n</tool_call>"
47+ if assistant["content"] != want || assistant["tool_calls"] != nil {
48+ t.Errorf("assistant message = %v", assistant)
49+ }
50+ if got.Messages[2]["content"] != "a.txt" {
51+ t.Errorf("the tool result changed: %v", got.Messages[2])
52+ }
53+ if same := xmlToolsRequest([]byte(`{"messages":[{"role":"user","content":"hi"}]}`)); string(same) != `{"messages":[{"role":"user","content":"hi"}]}` {
54+ t.Errorf("a request without tools changed: %s", same)
55+ }
56+}
57+
58+// The parser gets the answer in pieces, as a stream cuts it: no piece of
59+// the tag reaches the text, the call comes out whole.
60+func TestCallParserAcrossPieces(t *testing.T) {
61+ answer := "Let me look.\n<tool_call>\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls -a\"}}\n</tool_call>\nDone."
62+ for _, size := range []int{1, 3, 7, len(answer)} {
63+ var p callParser
64+ var text strings.Builder
65+ var calls []xmlCall
66+ for i := 0; i < len(answer); i += size {
67+ s, c := p.feed(answer[i:min(i+size, len(answer))])
68+ if strings.Contains(s, "<") {
69+ t.Errorf("size %d: a piece of the tag was shown: %q", size, s)
70+ }
71+ text.WriteString(s)
72+ calls = append(calls, c...)
73+ }
74+ rest, more := p.flush()
75+ text.WriteString(rest)
76+ calls = append(calls, more...)
77+ if text.String() != "Let me look.\n\nDone." || len(calls) != 1 || calls[0].Name != "bash" || calls[0].Arguments != `{"command": "ls -a"}` {
78+ t.Errorf("size %d: text %q, calls %+v", size, text.String(), calls)
79+ }
80+ }
81+}
82+
83+func TestCallParserEdges(t *testing.T) {
84+ cases := []struct {
85+ name, in, text string
86+ calls []xmlCall
87+ }{
88+ {"a < that is not a tag", "a < b and <tool", "a < b and <tool", nil},
89+ {"arguments as a JSON string", `<tool_call>{"name":"bash","arguments":"{\"command\":\"ls\"}"}</tool_call>`, "", []xmlCall{{"bash", `{"command":"ls"}`}}},
90+ {"no arguments", `<tool_call>{"name":"plan"}</tool_call>`, "", []xmlCall{{"plan", "{}"}}},
91+ {"broken JSON, name readable", `<tool_call>{"name": "bash", "arguments": {"command": "ls"</tool_call>`, "", []xmlCall{{"bash", `{"command": "ls"`}}},
92+ {"not a call at all", `<tool_call>hello</tool_call>`, "<tool_call>hello</tool_call>", nil},
93+ {"cut by the end (max_tokens)", `ok <tool_call>{"name": "write_file", "arguments": {"path": "a`, "ok ", []xmlCall{{"write_file", `{"path": "a`}}},
94+ {"cut, nameless", `ok <tool_call>{"na`, `ok <tool_call>{"na`, nil},
95+ {"two calls", `<tool_call>{"name":"a"}</tool_call><tool_call>{"name":"b"}</tool_call>`, "", []xmlCall{{"a", "{}"}, {"b", "{}"}}},
96+ }
97+ for _, c := range cases {
98+ var p callParser
99+ text, calls := p.feed(c.in)
100+ rest, more := p.flush()
101+ text, calls = text+rest, append(calls, more...)
102+ if text != c.text || fmt.Sprint(calls) != fmt.Sprint(c.calls) {
103+ t.Errorf("%s: text %q calls %+v, want %q %+v", c.name, text, calls, c.text, c.calls)
104+ }
105+ }
106+}
107+
108+func TestXMLToolsResponseNotStreamed(t *testing.T) {
109+ data := `{"id":"x","choices":[{"index":0,"message":{"role":"assistant","content":"\n<tool_call>\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}\n</tool_call>"},"finish_reason":"stop"}]}`
110+ var got struct {
111+ Choices []struct {
112+ Message struct {
113+ Content string `json:"content"`
114+ ToolCalls []openaiCall `json:"tool_calls"`
115+ } `json:"message"`
116+ FinishReason string `json:"finish_reason"`
117+ } `json:"choices"`
118+ }
119+ if err := json.Unmarshal(xmlToolsResponse([]byte(data), newCallIDs()), &got); err != nil {
120+ t.Fatal(err)
121+ }
122+ c := got.Choices[0]
123+ if c.Message.Content != "" || len(c.Message.ToolCalls) != 1 || c.Message.ToolCalls[0].Function.Arguments != `{"command": "ls"}` || c.FinishReason != "tool_calls" {
124+ t.Errorf("rewritten = %+v", c)
125+ }
126+}
127+
128+// The stream is rewritten chunk by chunk: the call goes out as a tool_calls
129+// delta, the tag never as content, and the answer ends with tool_calls.
130+func TestXMLToolsStream(t *testing.T) {
131+ var in bytes.Buffer
132+ for _, piece := range []string{"Let me look.\n<tool_c", "all>\n{\"name\": \"bash\", \"argu", "ments\": {\"command\": \"ls\"}}\n</tool_call>"} {
133+ delta, _ := json.Marshal(map[string]string{"content": piece})
134+ in.WriteString(sseChunkLine(string(delta), ""))
135+ }
136+ in.WriteString(sseChunkLine(`{}`, "stop"))
137+ in.WriteString("data: [DONE]\n\n")
138+ s := &sseCalls{rc: io.NopCloser(&in), ids: newCallIDs()}
139+ out, err := io.ReadAll(s)
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ var content strings.Builder
144+ var calls []openaiCall
145+ finish := ""
146+ sc := bufio.NewScanner(bytes.NewReader(out))
147+ for sc.Scan() {
148+ data, ok := strings.CutPrefix(sc.Text(), "data: ")
149+ if !ok || data == "[DONE]" {
150+ continue
151+ }
152+ var chunk struct {
153+ Choices []struct {
154+ Delta struct {
155+ Content string `json:"content"`
156+ ToolCalls []openaiCall `json:"tool_calls"`
157+ } `json:"delta"`
158+ FinishReason *string `json:"finish_reason"`
159+ } `json:"choices"`
160+ }
161+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
162+ t.Fatalf("bad chunk %q: %v", data, err)
163+ }
164+ content.WriteString(chunk.Choices[0].Delta.Content)
165+ calls = append(calls, chunk.Choices[0].Delta.ToolCalls...)
166+ if f := chunk.Choices[0].FinishReason; f != nil {
167+ finish = *f
168+ }
169+ }
170+ if content.String() != "Let me look.\n" || len(calls) != 1 || calls[0].Function.Name != "bash" || *calls[0].Index != 0 || finish != "tool_calls" {
171+ t.Errorf("content %q, calls %+v, finish %q\n%s", content.String(), calls, finish, out)
172+ }
173+ if !strings.HasSuffix(string(out), "data: [DONE]\n\n") {
174+ t.Errorf("the stream's end changed:\n%s", out)
175+ }
176+}
177+
178+// End to end through Genkit: with toolFormat: xml_tools the model's
179+// <tool_call> text runs the tool, and the next request shows the model its
180+// call as text and the tool's answer.
181+func TestXMLToolsRunTheToolThroughGenkit(t *testing.T) {
182+ old := config.Cfg.ToolFormat
183+ t.Cleanup(func() { config.Cfg.ToolFormat = old })
184+ config.Cfg.ToolFormat = config.ToolFormatXML
185+
186+ var mu sync.Mutex
187+ var bodies []map[string]any
188+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189+ if !strings.HasSuffix(r.URL.Path, "/chat/completions") {
190+ http.NotFound(w, r)
191+ return
192+ }
193+ var m map[string]any
194+ b, _ := io.ReadAll(r.Body)
195+ _ = json.Unmarshal(b, &m)
196+ mu.Lock()
197+ bodies = append(bodies, m)
198+ call := len(bodies)
199+ mu.Unlock()
200+ w.Header().Set("Content-Type", "text/event-stream")
201+ if call == 1 {
202+ for _, piece := range []string{"\n<tool_c", "all>\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}\n</tool_call>"} {
203+ delta, _ := json.Marshal(map[string]string{"content": piece})
204+ _, _ = fmt.Fprint(w, sseChunkLine(string(delta), ""))
205+ }
206+ } else {
207+ _, _ = fmt.Fprint(w, sseChunkLine(`{"role":"assistant","content":"There is a.txt."}`, ""))
208+ }
209+ _, _ = fmt.Fprint(w, sseChunkLine(`{}`, "stop"))
210+ _, _ = fmt.Fprint(w, "data: [DONE]\n\n")
211+ }))
212+ t.Cleanup(srv.Close)
213+ e := engineOn(t, srv.URL+"/v1")
214+ ran := ""
215+ genkit.DefineTool(e.G, "bash", "run a shell command",
216+ func(_ *ai.ToolContext, in struct {
217+ Command string `json:"command"`
218+ }) (string, error) {
219+ ran = in.Command
220+ return "a.txt", nil
221+ })
222+
223+ resp, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("list the files")}, []ai.ToolRef{ai.ToolName("bash")})
224+ if err != nil {
225+ t.Fatalf("Generate: %v", err)
226+ }
227+ if ran != "ls" || resp.Text() != "There is a.txt." {
228+ t.Fatalf("tool ran %q, answer %q", ran, resp.Text())
229+ }
230+ mu.Lock()
231+ defer mu.Unlock()
232+ if len(bodies) != 2 {
233+ t.Fatalf("%d requests, want 2", len(bodies))
234+ }
235+ kw, _ := bodies[0]["chat_template_kwargs"].(map[string]any)
236+ if bodies[0]["tools"] != nil || kw["xml_tools"] == nil {
237+ t.Errorf("first request: tools %v, chat_template_kwargs %v", bodies[0]["tools"], kw)
238+ }
239+ msgs, _ := bodies[1]["messages"].([]any)
240+ var assistant, tool map[string]any
241+ for _, m := range msgs {
242+ switch mm := m.(map[string]any); mm["role"] {
243+ case "assistant":
244+ assistant = mm
245+ case "tool":
246+ tool = mm
247+ }
248+ }
249+ if content, _ := assistant["content"].(string); !strings.Contains(content, `<tool_call>`) || !strings.Contains(content, `"command":"ls"`) || assistant["tool_calls"] != nil {
250+ t.Errorf("the model does not see its call as text: %v", assistant)
251+ }
252+ if tool == nil || !strings.Contains(fmt.Sprint(tool["content"]), "a.txt") {
253+ t.Errorf("tool result not sent: %v", tool)
254+ }
255+}
added internal/sbxinit/mmfolder.go +163 -0
new file mode 100644
@@ -0,0 +1,163 @@
1+package sbxinit
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+
8+ "mm/internal/config"
9+
10+ "gopkg.in/yaml.v3"
11+)
12+
13+// MMFolder is a project whose agent is <workspace>/.mm/<file> (ticket 208):
14+// the sandbox mounts .mm/ read-only over the workspace, gives write access
15+// back to the folders mm writes in, and names the agent file through the
16+// PROJECT_DIR argument — like the user's bob-the-bot/sbxenv.yaml. Its paths
17+// are written relative to Base, the folder of sbxenv.yaml, which is how sbx
18+// reads them: the file does not depend on where the project is.
19+type MMFolder struct {
20+ Agent string // the agent file, absolute
21+ Writable []Writable // folders under .mm/ that mm writes in
22+ Base string // the folder of sbxenv.yaml, absolute; "" = write absolute paths
23+}
24+
25+// Writable is a folder mounted writable again over the read-only .mm/, and
26+// the agent-file key it comes from.
27+type Writable struct {
28+ Path string // absolute
29+ Key string // "sessions.dir", "trace.dir"
30+}
31+
32+// mmFolderOf describes the project when agentPath is <workspace>/.mm/<file>,
33+// nil otherwise. The writable folders are the agent file's sessions.dir and
34+// trace.dir when they are on and fall under .mm/ — relative paths read from
35+// the workspace, where mm runs in the sandbox.
36+//
37+// f := mmFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on
38+func mmFolderOf(agentPath, workspace string) *MMFolder {
39+ agent, err := filepath.Abs(agentPath)
40+ if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) {
41+ return nil
42+ }
43+ folder := &MMFolder{Agent: agent}
44+ data, err := os.ReadFile(agent)
45+ if err != nil {
46+ return folder
47+ }
48+ var settings struct {
49+ Sessions struct {
50+ Enabled bool `yaml:"enabled"`
51+ Dir string `yaml:"dir"`
52+ } `yaml:"sessions"`
53+ Trace struct {
54+ Enabled bool `yaml:"enabled"`
55+ Dir string `yaml:"dir"`
56+ } `yaml:"trace"`
57+ }
58+ if yaml.Unmarshal(data, &settings) != nil {
59+ return folder
60+ }
61+ // mm's own defaults (config.Cfg) when the file names no dir.
62+ for _, w := range []struct {
63+ on bool
64+ dir, def string
65+ key string
66+ }{
67+ {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"},
68+ {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"},
69+ } {
70+ if !w.on {
71+ continue
72+ }
73+ dir := w.dir
74+ if dir == "" {
75+ dir = w.def
76+ }
77+ if !filepath.IsAbs(dir) {
78+ dir = filepath.Join(workspace, dir)
79+ }
80+ if under(dir, filepath.Join(workspace, config.ProjectDir)) {
81+ folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key})
82+ }
83+ }
84+ return folder
85+}
86+
87+// under says path is dir or inside it.
88+func under(path, dir string) bool {
89+ rel, err := filepath.Rel(dir, path)
90+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
91+}
92+
93+// rel writes path relative to Base, as sbx reads it: "./.mm", "." —
94+// absolute when there is no Base or the path is not below it.
95+func (m *MMFolder) rel(path string) string {
96+ if m.Base == "" {
97+ return path
98+ }
99+ r, err := filepath.Rel(m.Base, path)
100+ if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
101+ return path
102+ }
103+ if r == "." {
104+ return "."
105+ }
106+ return "./" + filepath.ToSlash(r)
107+}
108+
109+// workspace is the workspace line's value: relative for a .mm project.
110+func (m *MMFolder) workspace(absolute string) string {
111+ if m == nil {
112+ return absolute
113+ }
114+ return m.rel(absolute)
115+}
116+
117+// envArg is what every sbx env command of a .mm project needs.
118+func (m *MMFolder) envArg() string {
119+ if m == nil {
120+ return ""
121+ }
122+ return ` --env-arg PROJECT_DIR="$PWD"`
123+}
124+
125+// block is the part of sbxenv.yaml a .mm project adds after the workspace.
126+func (m *MMFolder) block() string {
127+ if m == nil {
128+ return ""
129+ }
130+ agent := m.rel(m.Agent)
131+ config := "${{ env.args.PROJECT_DIR }}/" + strings.TrimPrefix(agent, "./")
132+ if !strings.HasPrefix(agent, "./") {
133+ config = agent // not below the folder of sbxenv.yaml: its own path
134+ }
135+ var b strings.Builder
136+ b.WriteString(`
137+# The agent is .mm/agent.yaml (mm init --project yes). .mm/ is mounted
138+# read-only over the workspace: the agent cannot change its own settings,
139+# skills or hooks. The folders mm writes in are mounted writable again, with
140+# the paths of the agent file; anything else written under .mm/ (a hook's log)
141+# needs a line of its own. After a change to these mounts, re-create the
142+# sandbox: sbx env rm, then sbx env create.
143+additionalWorkspaces:
144+ - path: ` + yamlScalar(m.rel(filepath.Dir(m.Agent))) + `
145+ readOnly: true
146+`)
147+ for _, w := range m.Writable {
148+ b.WriteString(" - path: " + yamlScalar(m.rel(w.Path)) + " # " + w.Key + "\n")
149+ }
150+ b.WriteString(`
151+# This folder as the sandbox sees it: give it on every sbx env command,
152+# --env-arg PROJECT_DIR="$PWD".
153+args:
154+ PROJECT_DIR:
155+ required: true
156+
157+env:
158+ # mm, mm tui and mm -acp read this agent, whatever mm's version and its
159+ # working directory.
160+ AGENT_CONFIG: ` + yamlScalar(config) + `
161+`)
162+ return b.String()
163+}
new file mode 100644
@@ -0,0 +1,163 @@
1+package sbxinit
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+
8+ "mm/internal/config"
9+
10+ "gopkg.in/yaml.v3"
11+)
12+
13+// MMFolder is a project whose agent is <workspace>/.mm/<file> (ticket 208):
14+// the sandbox mounts .mm/ read-only over the workspace, gives write access
15+// back to the folders mm writes in, and names the agent file through the
16+// PROJECT_DIR argument — like the user's bob-the-bot/sbxenv.yaml. Its paths
17+// are written relative to Base, the folder of sbxenv.yaml, which is how sbx
18+// reads them: the file does not depend on where the project is.
19+type MMFolder struct {
20+ Agent string // the agent file, absolute
21+ Writable []Writable // folders under .mm/ that mm writes in
22+ Base string // the folder of sbxenv.yaml, absolute; "" = write absolute paths
23+}
24+
25+// Writable is a folder mounted writable again over the read-only .mm/, and
26+// the agent-file key it comes from.
27+type Writable struct {
28+ Path string // absolute
29+ Key string // "sessions.dir", "trace.dir"
30+}
31+
32+// mmFolderOf describes the project when agentPath is <workspace>/.mm/<file>,
33+// nil otherwise. The writable folders are the agent file's sessions.dir and
34+// trace.dir when they are on and fall under .mm/ — relative paths read from
35+// the workspace, where mm runs in the sandbox.
36+//
37+// f := mmFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on
38+func mmFolderOf(agentPath, workspace string) *MMFolder {
39+ agent, err := filepath.Abs(agentPath)
40+ if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) {
41+ return nil
42+ }
43+ folder := &MMFolder{Agent: agent}
44+ data, err := os.ReadFile(agent)
45+ if err != nil {
46+ return folder
47+ }
48+ var settings struct {
49+ Sessions struct {
50+ Enabled bool `yaml:"enabled"`
51+ Dir string `yaml:"dir"`
52+ } `yaml:"sessions"`
53+ Trace struct {
54+ Enabled bool `yaml:"enabled"`
55+ Dir string `yaml:"dir"`
56+ } `yaml:"trace"`
57+ }
58+ if yaml.Unmarshal(data, &settings) != nil {
59+ return folder
60+ }
61+ // mm's own defaults (config.Cfg) when the file names no dir.
62+ for _, w := range []struct {
63+ on bool
64+ dir, def string
65+ key string
66+ }{
67+ {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"},
68+ {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"},
69+ } {
70+ if !w.on {
71+ continue
72+ }
73+ dir := w.dir
74+ if dir == "" {
75+ dir = w.def
76+ }
77+ if !filepath.IsAbs(dir) {
78+ dir = filepath.Join(workspace, dir)
79+ }
80+ if under(dir, filepath.Join(workspace, config.ProjectDir)) {
81+ folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key})
82+ }
83+ }
84+ return folder
85+}
86+
87+// under says path is dir or inside it.
88+func under(path, dir string) bool {
89+ rel, err := filepath.Rel(dir, path)
90+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
91+}
92+
93+// rel writes path relative to Base, as sbx reads it: "./.mm", "." —
94+// absolute when there is no Base or the path is not below it.
95+func (m *MMFolder) rel(path string) string {
96+ if m.Base == "" {
97+ return path
98+ }
99+ r, err := filepath.Rel(m.Base, path)
100+ if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) {
101+ return path
102+ }
103+ if r == "." {
104+ return "."
105+ }
106+ return "./" + filepath.ToSlash(r)
107+}
108+
109+// workspace is the workspace line's value: relative for a .mm project.
110+func (m *MMFolder) workspace(absolute string) string {
111+ if m == nil {
112+ return absolute
113+ }
114+ return m.rel(absolute)
115+}
116+
117+// envArg is what every sbx env command of a .mm project needs.
118+func (m *MMFolder) envArg() string {
119+ if m == nil {
120+ return ""
121+ }
122+ return ` --env-arg PROJECT_DIR="$PWD"`
123+}
124+
125+// block is the part of sbxenv.yaml a .mm project adds after the workspace.
126+func (m *MMFolder) block() string {
127+ if m == nil {
128+ return ""
129+ }
130+ agent := m.rel(m.Agent)
131+ config := "${{ env.args.PROJECT_DIR }}/" + strings.TrimPrefix(agent, "./")
132+ if !strings.HasPrefix(agent, "./") {
133+ config = agent // not below the folder of sbxenv.yaml: its own path
134+ }
135+ var b strings.Builder
136+ b.WriteString(`
137+# The agent is .mm/agent.yaml (mm init --project yes). .mm/ is mounted
138+# read-only over the workspace: the agent cannot change its own settings,
139+# skills or hooks. The folders mm writes in are mounted writable again, with
140+# the paths of the agent file; anything else written under .mm/ (a hook's log)
141+# needs a line of its own. After a change to these mounts, re-create the
142+# sandbox: sbx env rm, then sbx env create.
143+additionalWorkspaces:
144+ - path: ` + yamlScalar(m.rel(filepath.Dir(m.Agent))) + `
145+ readOnly: true
146+`)
147+ for _, w := range m.Writable {
148+ b.WriteString(" - path: " + yamlScalar(m.rel(w.Path)) + " # " + w.Key + "\n")
149+ }
150+ b.WriteString(`
151+# This folder as the sandbox sees it: give it on every sbx env command,
152+# --env-arg PROJECT_DIR="$PWD".
153+args:
154+ PROJECT_DIR:
155+ required: true
156+
157+env:
158+ # mm, mm tui and mm -acp read this agent, whatever mm's version and its
159+ # working directory.
160+ AGENT_CONFIG: ` + yamlScalar(config) + `
161+`)
162+ return b.String()
163+}
modified internal/sbxinit/sbxinit.go +49 -18
@@ -14,6 +14,7 @@ import (
1414 "strings"
1515 "time"
1616
17+ "mm/internal/config"
1718 "mm/internal/scaffold"
1819
1920 "gopkg.in/yaml.v3"
@@ -56,11 +57,13 @@ func LatestKitVersion(client *http.Client, url string) (string, error) {
5657
5758 // Render is the content of sbxenv.yaml: name is the sandbox's, already clean
5859 // (see scaffold.Name), kitTag a version or "latest", and workspace an absolute
59-// path.
60+// path. mm is set when the agent is <workspace>/.mm/agent.yaml (ticket 208):
61+// the file then mounts .mm/ read-only, its writable folders, and names the
62+// agent file; nil for any other project.
6063 //
61-// sbxinit.Render("bob-sbx", "0.1.2", "/home/me/project")
62-func Render(name, kitTag, workspace string) string {
63- return fmt.Sprintf(`# %[1]s: mm in a Docker Sandbox. Written by `+"`mm sbxinit`"+`.
64+// sbxinit.Render("bob-sbx", "0.1.2", "/home/me/project", nil)
65+func Render(name, kitTag, workspace string, mm *MMFolder) string {
66+ text := fmt.Sprintf(`# %[1]s: mm in a Docker Sandbox. Written by `+"`mm sbxinit`"+`.
6467 # %[2]s
6568 schemaVersion: "1"
6669
@@ -74,15 +77,16 @@ kits:
7477 - docker.io/k33g/mini-me-kit:%[3]s
7578
7679 workspace: %[4]s
77-
80+%[5]s
7881 # Pull the kit at each creation: a tag such as latest moves, and a kit already
7982 # in the local cache would hide a new one.
8083 sandboxOptions:
8184 pullPolicy: always
8285
83-# sbx env create --force
84-# sbx env rm --force
85-`, name, howToURL, kitTag, yamlScalar(workspace))
86+# sbx env create --force%[6]s
87+# sbx env rm --force%[6]s
88+`, name, howToURL, kitTag, yamlScalar(mm.workspace(workspace)), mm.block(), mm.envArg())
89+ return text
8690 }
8791
8892 // yamlScalar writes a path so that YAML reads it back unchanged, spaces and
@@ -111,7 +115,7 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
111115 fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError)
112116 fs.SetOutput(stdout)
113117 name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)")
114- agentFile := fs.String("agent", "agent.yaml", "the agent file to name the sandbox after")
118+ agentFile := fs.String("agent", "agent.yaml", "the agent file to name the sandbox after (default: agent.yaml, then .mm/agent.yaml)")
115119 workspace := fs.String("workspace", ".", "the folder the sandbox mounts, relative to the current directory or absolute")
116120 yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten")
117121 if err := fs.Parse(args); err != nil {
@@ -130,13 +134,21 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
130134 return 1
131135 }
132136
133- agentPath := findAgentFile(*agentFile, absolute)
137+ explicit := false
138+ fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" })
139+ agentPath := findAgentFile(*agentFile, absolute, explicit)
140+ mmFolder := mmFolderOf(agentPath, absolute)
141+ if mmFolder != nil {
142+ // The paths of a .mm project's file are relative to the folder of
143+ // sbxenv.yaml, as sbx reads them: the file can be committed.
144+ mmFolder.Base, _ = os.Getwd()
145+ }
134146 chosen := *name
135147 if chosen == "" {
136148 chosen = scaffold.AgentName(agentPath, stdout)
137149 }
138150 sandbox := scaffold.Name(chosen) + "-sbx"
139- files := []scaffold.File{{Path: "sbxenv.yaml", Text: Render(sandbox, kitTag(latest, stdout), absolute)}}
151+ files := []scaffold.File{{Path: "sbxenv.yaml", Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}}
140152 wrote, err := scaffold.Write(files, stdin, stdout, *yes)
141153 if err != nil {
142154 fmt.Fprintln(stdout, "⚠️ "+err.Error())
@@ -144,7 +156,17 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
144156 }
145157 if wrote {
146158 warnAboutWorkspace(stdout, absolute, agentPath)
147- fmt.Fprintf(stdout, "\nCreate the sandbox %s:\n sbx env create --force\n\nRemove it:\n sbx env rm --force\n", sandbox)
159+ if mmFolder != nil {
160+ fmt.Fprintf(stdout, "\nThe agent is %s: .mm/ is mounted read-only, writable again:", agentPath)
161+ if len(mmFolder.Writable) == 0 {
162+ fmt.Fprint(stdout, " nothing (no sessions, no trace)")
163+ }
164+ for _, w := range mmFolder.Writable {
165+ fmt.Fprintf(stdout, " %s (%s)", mmFolder.rel(w.Path), w.Key)
166+ }
167+ fmt.Fprintln(stdout)
168+ }
169+ fmt.Fprintf(stdout, "\nCreate the sandbox %s:\n sbx env create --force%s\n\nRemove it:\n sbx env rm --force%s\n", sandbox, mmFolder.envArg(), mmFolder.envArg())
148170 }
149171 return 0
150172 }
@@ -160,14 +182,23 @@ func kitTag(latest func() (string, error), stdout io.Writer) string {
160182 }
161183
162184 // findAgentFile looks in the current directory, then in the workspace, where
163-// the sandbox will read it. It returns the first path, even missing, otherwise.
164-func findAgentFile(agentFile, workspace string) string {
165- if _, err := os.Stat(agentFile); err == nil || filepath.IsAbs(agentFile) {
185+// the sandbox will read it. Without -agent it looks for agent.yaml, then for
186+// .mm/agent.yaml — the order mm itself follows (config.DefaultPath, then the
187+// .mm folder). It returns the first path, even missing, otherwise.
188+func findAgentFile(agentFile, workspace string, explicit bool) string {
189+ if filepath.IsAbs(agentFile) {
166190 return agentFile
167191 }
168- inWorkspace := filepath.Join(workspace, agentFile)
169- if _, err := os.Stat(inWorkspace); err == nil {
170- return inWorkspace
192+ names := []string{agentFile}
193+ if !explicit {
194+ names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath))
195+ }
196+ for _, name := range names {
197+ for _, path := range []string{name, filepath.Join(workspace, name)} {
198+ if _, err := os.Stat(path); err == nil {
199+ return path
200+ }
201+ }
171202 }
172203 return agentFile
173204 }
@@ -14,6 +14,7 @@ import (
14 "strings"14 "strings"
15 "time"15 "time"
16 16
17+ "mm/internal/config"
17 "mm/internal/scaffold"18 "mm/internal/scaffold"
18 19
19 "gopkg.in/yaml.v3"20 "gopkg.in/yaml.v3"
@@ -56,11 +57,13 @@ func LatestKitVersion(client *http.Client, url string) (string, error) {
56 57
57 // Render is the content of sbxenv.yaml: name is the sandbox's, already clean58 // Render is the content of sbxenv.yaml: name is the sandbox's, already clean
58 // (see scaffold.Name), kitTag a version or "latest", and workspace an absolute59 // (see scaffold.Name), kitTag a version or "latest", and workspace an absolute
59-// path.60+// path. mm is set when the agent is <workspace>/.mm/agent.yaml (ticket 208):
61+// the file then mounts .mm/ read-only, its writable folders, and names the
62+// agent file; nil for any other project.
60 //63 //
61-// sbxinit.Render("bob-sbx", "0.1.2", "/home/me/project")64+// sbxinit.Render("bob-sbx", "0.1.2", "/home/me/project", nil)
62-func Render(name, kitTag, workspace string) string {65+func Render(name, kitTag, workspace string, mm *MMFolder) string {
63- return fmt.Sprintf(`# %[1]s: mm in a Docker Sandbox. Written by `+"`mm sbxinit`"+`.66+ text := fmt.Sprintf(`# %[1]s: mm in a Docker Sandbox. Written by `+"`mm sbxinit`"+`.
64 # %[2]s67 # %[2]s
65 schemaVersion: "1"68 schemaVersion: "1"
66 69
@@ -74,15 +77,16 @@ kits:
74 - docker.io/k33g/mini-me-kit:%[3]s77 - docker.io/k33g/mini-me-kit:%[3]s
75 78
76 workspace: %[4]s79 workspace: %[4]s
77-80+%[5]s
78 # Pull the kit at each creation: a tag such as latest moves, and a kit already81 # Pull the kit at each creation: a tag such as latest moves, and a kit already
79 # in the local cache would hide a new one.82 # in the local cache would hide a new one.
80 sandboxOptions:83 sandboxOptions:
81 pullPolicy: always84 pullPolicy: always
82 85
83-# sbx env create --force86+# sbx env create --force%[6]s
84-# sbx env rm --force87+# sbx env rm --force%[6]s
85-`, name, howToURL, kitTag, yamlScalar(workspace))88+`, name, howToURL, kitTag, yamlScalar(mm.workspace(workspace)), mm.block(), mm.envArg())
89+ return text
86 }90 }
87 91
88 // yamlScalar writes a path so that YAML reads it back unchanged, spaces and92 // yamlScalar writes a path so that YAML reads it back unchanged, spaces and
@@ -111,7 +115,7 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
111 fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError)115 fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError)
112 fs.SetOutput(stdout)116 fs.SetOutput(stdout)
113 name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)")117 name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)")
114- agentFile := fs.String("agent", "agent.yaml", "the agent file to name the sandbox after")118+ agentFile := fs.String("agent", "agent.yaml", "the agent file to name the sandbox after (default: agent.yaml, then .mm/agent.yaml)")
115 workspace := fs.String("workspace", ".", "the folder the sandbox mounts, relative to the current directory or absolute")119 workspace := fs.String("workspace", ".", "the folder the sandbox mounts, relative to the current directory or absolute")
116 yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten")120 yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten")
117 if err := fs.Parse(args); err != nil {121 if err := fs.Parse(args); err != nil {
@@ -130,13 +134,21 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
130 return 1134 return 1
131 }135 }
132 136
133- agentPath := findAgentFile(*agentFile, absolute)137+ explicit := false
138+ fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" })
139+ agentPath := findAgentFile(*agentFile, absolute, explicit)
140+ mmFolder := mmFolderOf(agentPath, absolute)
141+ if mmFolder != nil {
142+ // The paths of a .mm project's file are relative to the folder of
143+ // sbxenv.yaml, as sbx reads them: the file can be committed.
144+ mmFolder.Base, _ = os.Getwd()
145+ }
134 chosen := *name146 chosen := *name
135 if chosen == "" {147 if chosen == "" {
136 chosen = scaffold.AgentName(agentPath, stdout)148 chosen = scaffold.AgentName(agentPath, stdout)
137 }149 }
138 sandbox := scaffold.Name(chosen) + "-sbx"150 sandbox := scaffold.Name(chosen) + "-sbx"
139- files := []scaffold.File{{Path: "sbxenv.yaml", Text: Render(sandbox, kitTag(latest, stdout), absolute)}}151+ files := []scaffold.File{{Path: "sbxenv.yaml", Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}}
140 wrote, err := scaffold.Write(files, stdin, stdout, *yes)152 wrote, err := scaffold.Write(files, stdin, stdout, *yes)
141 if err != nil {153 if err != nil {
142 fmt.Fprintln(stdout, "⚠️ "+err.Error())154 fmt.Fprintln(stdout, "⚠️ "+err.Error())
@@ -144,7 +156,17 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string
144 }156 }
145 if wrote {157 if wrote {
146 warnAboutWorkspace(stdout, absolute, agentPath)158 warnAboutWorkspace(stdout, absolute, agentPath)
147- fmt.Fprintf(stdout, "\nCreate the sandbox %s:\n sbx env create --force\n\nRemove it:\n sbx env rm --force\n", sandbox)159+ if mmFolder != nil {
160+ fmt.Fprintf(stdout, "\nThe agent is %s: .mm/ is mounted read-only, writable again:", agentPath)
161+ if len(mmFolder.Writable) == 0 {
162+ fmt.Fprint(stdout, " nothing (no sessions, no trace)")
163+ }
164+ for _, w := range mmFolder.Writable {
165+ fmt.Fprintf(stdout, " %s (%s)", mmFolder.rel(w.Path), w.Key)
166+ }
167+ fmt.Fprintln(stdout)
168+ }
169+ fmt.Fprintf(stdout, "\nCreate the sandbox %s:\n sbx env create --force%s\n\nRemove it:\n sbx env rm --force%s\n", sandbox, mmFolder.envArg(), mmFolder.envArg())
148 }170 }
149 return 0171 return 0
150 }172 }
@@ -160,14 +182,23 @@ func kitTag(latest func() (string, error), stdout io.Writer) string {
160 }182 }
161 183
162 // findAgentFile looks in the current directory, then in the workspace, where184 // findAgentFile looks in the current directory, then in the workspace, where
163-// the sandbox will read it. It returns the first path, even missing, otherwise.185+// the sandbox will read it. Without -agent it looks for agent.yaml, then for
164-func findAgentFile(agentFile, workspace string) string {186+// .mm/agent.yaml — the order mm itself follows (config.DefaultPath, then the
165- if _, err := os.Stat(agentFile); err == nil || filepath.IsAbs(agentFile) {187+// .mm folder). It returns the first path, even missing, otherwise.
188+func findAgentFile(agentFile, workspace string, explicit bool) string {
189+ if filepath.IsAbs(agentFile) {
166 return agentFile190 return agentFile
167 }191 }
168- inWorkspace := filepath.Join(workspace, agentFile)192+ names := []string{agentFile}
169- if _, err := os.Stat(inWorkspace); err == nil {193+ if !explicit {
170- return inWorkspace194+ names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath))
195+ }
196+ for _, name := range names {
197+ for _, path := range []string{name, filepath.Join(workspace, name)} {
198+ if _, err := os.Stat(path); err == nil {
199+ return path
200+ }
201+ }
171 }202 }
172 return agentFile203 return agentFile
173 }204 }
modified internal/sbxinit/sbxinit_test.go +128 -3
@@ -34,7 +34,7 @@ func parse(t *testing.T, text string) envFile {
3434 }
3535
3636 func TestRenderIsWhatSbxExpects(t *testing.T) {
37- got := parse(t, Render("bob-sbx", "0.1.2", "/a/b"))
37+ got := parse(t, Render("bob-sbx", "0.1.2", "/a/b", nil))
3838 if got.SchemaVersion != "1" || got.Name != "bob-sbx" || got.Agent != "mini-me-kit" || got.Workspace != "/a/b" {
3939 t.Errorf("%+v", got)
4040 }
@@ -47,14 +47,14 @@ func TestRenderIsWhatSbxExpects(t *testing.T) {
4747 }
4848
4949 func TestRenderNamesOnlyTheMiniMeKit(t *testing.T) {
50- if text := Render("bob-sbx", "0.1.2", "/a/b"); strings.Contains(text, "python") {
50+ if text := Render("bob-sbx", "0.1.2", "/a/b", nil); strings.Contains(text, "python") {
5151 t.Errorf("another kit is mentioned:\n%s", text)
5252 }
5353 }
5454
5555 func TestRenderKeepsAWorkspaceWithSpecialCharacters(t *testing.T) {
5656 for _, path := range []string{"/a b/c", "/a: b/#c", `/it's "here"`} {
57- if got := parse(t, Render("x", "1", path)).Workspace; got != path {
57+ if got := parse(t, Render("x", "1", path, nil)).Workspace; got != path {
5858 t.Errorf("workspace = %q, want %q", got, path)
5959 }
6060 }
@@ -255,3 +255,128 @@ func TestTheDocumentationLinkNamesAFileOfTheRepository(t *testing.T) {
255255 t.Error(err)
256256 }
257257 }
258+
259+// Ticket 208: a project made with `mm init --project yes` has only
260+// .mm/agent.yaml. Like the user's bob-the-bot/sbxenv.yaml: .mm/ read-only,
261+// the folders mm writes in writable again, AGENT_CONFIG through PROJECT_DIR —
262+// every path relative to the folder of sbxenv.yaml, so the file can be
263+// committed — and --env-arg on every command shown.
264+func TestMainMountsTheMMFolderReadOnly(t *testing.T) {
265+ agent := "acp:\n name: Bob The Bot\nsessions:\n enabled: true\n dir: .mm/sessions\ntrace:\n enabled: true\n dir: .mm/trace\n"
266+ _, code, out := runIn(t, map[string]string{".mm/agent.yaml": agent}, "", version)
267+ if code != 0 || strings.Contains(out, "outside the workspace") || strings.Contains(out, "No ") {
268+ t.Fatalf("exit %d:\n%s", code, out)
269+ }
270+ got := readSbxEnv(t)
271+ if got.Name != "bob-the-bot-sbx" || got.Workspace != "." || !got.Args["PROJECT_DIR"]["required"] || got.Env["AGENT_CONFIG"] != "${{ env.args.PROJECT_DIR }}/.mm/agent.yaml" {
272+ t.Errorf("name %q, workspace %q, args %v, env %v", got.Name, got.Workspace, got.Args, got.Env)
273+ }
274+ if want := "./.mm ro, ./.mm/sessions, ./.mm/trace"; got.mounts() != want {
275+ t.Errorf("additionalWorkspaces = %s, want %s", got.mounts(), want)
276+ }
277+ if !strings.Contains(out, `sbx env create --force --env-arg PROJECT_DIR="$PWD"`) || !strings.Contains(readFile(t), `# sbx env rm --force --env-arg PROJECT_DIR="$PWD"`) {
278+ t.Errorf("the commands lack --env-arg:\n%s", out)
279+ }
280+}
281+
282+// With --workspace, the paths stay relative to the folder of sbxenv.yaml.
283+func TestMMFolderInASubWorkspace(t *testing.T) {
284+ files := map[string]string{"work/.mm/agent.yaml": "sessions:\n enabled: true\n"}
285+ _, code, out := runIn(t, files, "", version, "--workspace", "./work")
286+ if code != 0 {
287+ t.Fatalf("exit %d:\n%s", code, out)
288+ }
289+ got := readSbxEnv(t)
290+ if got.Workspace != "./work" || got.Env["AGENT_CONFIG"] != "${{ env.args.PROJECT_DIR }}/work/.mm/agent.yaml" || got.mounts() != "./work/.mm ro, ./work/.mm/sessions" {
291+ t.Errorf("workspace %q, env %v, mounts %s", got.Workspace, got.Env, got.mounts())
292+ }
293+}
294+
295+type sbxEnv struct {
296+ Name string `yaml:"name"`
297+ Workspace string `yaml:"workspace"`
298+ Env map[string]string `yaml:"env"`
299+ Args map[string]map[string]bool `yaml:"args"`
300+ AdditionalWorkspaces []struct {
301+ Path string `yaml:"path"`
302+ ReadOnly bool `yaml:"readOnly"`
303+ } `yaml:"additionalWorkspaces"`
304+}
305+
306+func (e sbxEnv) mounts() string {
307+ var list []string
308+ for _, w := range e.AdditionalWorkspaces {
309+ if w.ReadOnly {
310+ list = append(list, w.Path+" ro")
311+ } else {
312+ list = append(list, w.Path)
313+ }
314+ }
315+ return strings.Join(list, ", ")
316+}
317+
318+func readSbxEnv(t *testing.T) sbxEnv {
319+ t.Helper()
320+ var got sbxEnv
321+ if err := yaml.Unmarshal([]byte(readFile(t)), &got); err != nil {
322+ t.Fatal(err)
323+ }
324+ return got
325+}
326+
327+// The writable folders follow the agent file: mm's default dirs when it
328+// names none, nothing for what is off, nothing outside .mm/.
329+func TestMMFolderWritableFolders(t *testing.T) {
330+ cases := map[string][]string{
331+ "sessions:\n enabled: true\ntrace:\n enabled: true\n": {".mm/sessions", ".mm/traces"},
332+ "sessions:\n enabled: false\n": nil,
333+ "sessions:\n enabled: true\n dir: history\n": nil,
334+ "model: x\n": nil,
335+ "trace:\n enabled: true\n dir: .mm/trace\nsessions:\n enabled: true\n": {".mm/sessions", ".mm/trace"},
336+ }
337+ for agent, want := range cases {
338+ dir := t.TempDir()
339+ path := filepath.Join(dir, ".mm", "agent.yaml")
340+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
341+ t.Fatal(err)
342+ }
343+ if err := os.WriteFile(path, []byte(agent), 0o644); err != nil {
344+ t.Fatal(err)
345+ }
346+ folder := mmFolderOf(path, dir)
347+ var got []string
348+ for _, w := range folder.Writable {
349+ rel, _ := filepath.Rel(dir, w.Path)
350+ got = append(got, rel)
351+ }
352+ if strings.Join(got, ",") != strings.Join(want, ",") {
353+ t.Errorf("%q: writable %v, want %v", agent, got, want)
354+ }
355+ }
356+ if mmFolderOf("/p/agent.yaml", "/p") != nil {
357+ t.Error("an agent at the root is not a .mm project")
358+ }
359+}
360+
361+// agent.yaml at the root still wins over .mm/agent.yaml, like in mm; -agent
362+// names exactly one file.
363+func TestRootAgentWinsAndTheAgentFlagIsExact(t *testing.T) {
364+ files := map[string]string{"agent.yaml": "acp:\n name: root\n", ".mm/agent.yaml": "acp:\n name: folder\n"}
365+ _, code, out := runIn(t, files, "", version)
366+ if code != 0 || readEnv(t).Name != "root-sbx" || strings.Contains(out, "The agent is .mm") || strings.Contains(readFile(t), "additionalWorkspaces") {
367+ t.Errorf("exit %d, name %q:\n%s", code, readEnv(t).Name, out)
368+ }
369+ _, code, out = runIn(t, map[string]string{".mm/agent.yaml": "acp:\n name: folder\n"}, "", version, "--agent", "agent.yaml")
370+ if code != 0 || readEnv(t).Name != "mm-sbx" || !strings.Contains(out, "No agent.yaml yet") {
371+ t.Errorf("-agent agent.yaml fell back to .mm: exit %d, name %q:\n%s", code, readEnv(t).Name, out)
372+ }
373+}
374+
375+func readFile(t *testing.T) string {
376+ t.Helper()
377+ data, err := os.ReadFile("sbxenv.yaml")
378+ if err != nil {
379+ t.Fatal(err)
380+ }
381+ return string(data)
382+}
@@ -34,7 +34,7 @@ func parse(t *testing.T, text string) envFile {
34 }34 }
35 35
36 func TestRenderIsWhatSbxExpects(t *testing.T) {36 func TestRenderIsWhatSbxExpects(t *testing.T) {
37- got := parse(t, Render("bob-sbx", "0.1.2", "/a/b"))37+ got := parse(t, Render("bob-sbx", "0.1.2", "/a/b", nil))
38 if got.SchemaVersion != "1" || got.Name != "bob-sbx" || got.Agent != "mini-me-kit" || got.Workspace != "/a/b" {38 if got.SchemaVersion != "1" || got.Name != "bob-sbx" || got.Agent != "mini-me-kit" || got.Workspace != "/a/b" {
39 t.Errorf("%+v", got)39 t.Errorf("%+v", got)
40 }40 }
@@ -47,14 +47,14 @@ func TestRenderIsWhatSbxExpects(t *testing.T) {
47 }47 }
48 48
49 func TestRenderNamesOnlyTheMiniMeKit(t *testing.T) {49 func TestRenderNamesOnlyTheMiniMeKit(t *testing.T) {
50- if text := Render("bob-sbx", "0.1.2", "/a/b"); strings.Contains(text, "python") {50+ if text := Render("bob-sbx", "0.1.2", "/a/b", nil); strings.Contains(text, "python") {
51 t.Errorf("another kit is mentioned:\n%s", text)51 t.Errorf("another kit is mentioned:\n%s", text)
52 }52 }
53 }53 }
54 54
55 func TestRenderKeepsAWorkspaceWithSpecialCharacters(t *testing.T) {55 func TestRenderKeepsAWorkspaceWithSpecialCharacters(t *testing.T) {
56 for _, path := range []string{"/a b/c", "/a: b/#c", `/it's "here"`} {56 for _, path := range []string{"/a b/c", "/a: b/#c", `/it's "here"`} {
57- if got := parse(t, Render("x", "1", path)).Workspace; got != path {57+ if got := parse(t, Render("x", "1", path, nil)).Workspace; got != path {
58 t.Errorf("workspace = %q, want %q", got, path)58 t.Errorf("workspace = %q, want %q", got, path)
59 }59 }
60 }60 }
@@ -255,3 +255,128 @@ func TestTheDocumentationLinkNamesAFileOfTheRepository(t *testing.T) {
255 t.Error(err)255 t.Error(err)
256 }256 }
257 }257 }
258+
259+// Ticket 208: a project made with `mm init --project yes` has only
260+// .mm/agent.yaml. Like the user's bob-the-bot/sbxenv.yaml: .mm/ read-only,
261+// the folders mm writes in writable again, AGENT_CONFIG through PROJECT_DIR —
262+// every path relative to the folder of sbxenv.yaml, so the file can be
263+// committed — and --env-arg on every command shown.
264+func TestMainMountsTheMMFolderReadOnly(t *testing.T) {
265+ agent := "acp:\n name: Bob The Bot\nsessions:\n enabled: true\n dir: .mm/sessions\ntrace:\n enabled: true\n dir: .mm/trace\n"
266+ _, code, out := runIn(t, map[string]string{".mm/agent.yaml": agent}, "", version)
267+ if code != 0 || strings.Contains(out, "outside the workspace") || strings.Contains(out, "No ") {
268+ t.Fatalf("exit %d:\n%s", code, out)
269+ }
270+ got := readSbxEnv(t)
271+ if got.Name != "bob-the-bot-sbx" || got.Workspace != "." || !got.Args["PROJECT_DIR"]["required"] || got.Env["AGENT_CONFIG"] != "${{ env.args.PROJECT_DIR }}/.mm/agent.yaml" {
272+ t.Errorf("name %q, workspace %q, args %v, env %v", got.Name, got.Workspace, got.Args, got.Env)
273+ }
274+ if want := "./.mm ro, ./.mm/sessions, ./.mm/trace"; got.mounts() != want {
275+ t.Errorf("additionalWorkspaces = %s, want %s", got.mounts(), want)
276+ }
277+ if !strings.Contains(out, `sbx env create --force --env-arg PROJECT_DIR="$PWD"`) || !strings.Contains(readFile(t), `# sbx env rm --force --env-arg PROJECT_DIR="$PWD"`) {
278+ t.Errorf("the commands lack --env-arg:\n%s", out)
279+ }
280+}
281+
282+// With --workspace, the paths stay relative to the folder of sbxenv.yaml.
283+func TestMMFolderInASubWorkspace(t *testing.T) {
284+ files := map[string]string{"work/.mm/agent.yaml": "sessions:\n enabled: true\n"}
285+ _, code, out := runIn(t, files, "", version, "--workspace", "./work")
286+ if code != 0 {
287+ t.Fatalf("exit %d:\n%s", code, out)
288+ }
289+ got := readSbxEnv(t)
290+ if got.Workspace != "./work" || got.Env["AGENT_CONFIG"] != "${{ env.args.PROJECT_DIR }}/work/.mm/agent.yaml" || got.mounts() != "./work/.mm ro, ./work/.mm/sessions" {
291+ t.Errorf("workspace %q, env %v, mounts %s", got.Workspace, got.Env, got.mounts())
292+ }
293+}
294+
295+type sbxEnv struct {
296+ Name string `yaml:"name"`
297+ Workspace string `yaml:"workspace"`
298+ Env map[string]string `yaml:"env"`
299+ Args map[string]map[string]bool `yaml:"args"`
300+ AdditionalWorkspaces []struct {
301+ Path string `yaml:"path"`
302+ ReadOnly bool `yaml:"readOnly"`
303+ } `yaml:"additionalWorkspaces"`
304+}
305+
306+func (e sbxEnv) mounts() string {
307+ var list []string
308+ for _, w := range e.AdditionalWorkspaces {
309+ if w.ReadOnly {
310+ list = append(list, w.Path+" ro")
311+ } else {
312+ list = append(list, w.Path)
313+ }
314+ }
315+ return strings.Join(list, ", ")
316+}
317+
318+func readSbxEnv(t *testing.T) sbxEnv {
319+ t.Helper()
320+ var got sbxEnv
321+ if err := yaml.Unmarshal([]byte(readFile(t)), &got); err != nil {
322+ t.Fatal(err)
323+ }
324+ return got
325+}
326+
327+// The writable folders follow the agent file: mm's default dirs when it
328+// names none, nothing for what is off, nothing outside .mm/.
329+func TestMMFolderWritableFolders(t *testing.T) {
330+ cases := map[string][]string{
331+ "sessions:\n enabled: true\ntrace:\n enabled: true\n": {".mm/sessions", ".mm/traces"},
332+ "sessions:\n enabled: false\n": nil,
333+ "sessions:\n enabled: true\n dir: history\n": nil,
334+ "model: x\n": nil,
335+ "trace:\n enabled: true\n dir: .mm/trace\nsessions:\n enabled: true\n": {".mm/sessions", ".mm/trace"},
336+ }
337+ for agent, want := range cases {
338+ dir := t.TempDir()
339+ path := filepath.Join(dir, ".mm", "agent.yaml")
340+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
341+ t.Fatal(err)
342+ }
343+ if err := os.WriteFile(path, []byte(agent), 0o644); err != nil {
344+ t.Fatal(err)
345+ }
346+ folder := mmFolderOf(path, dir)
347+ var got []string
348+ for _, w := range folder.Writable {
349+ rel, _ := filepath.Rel(dir, w.Path)
350+ got = append(got, rel)
351+ }
352+ if strings.Join(got, ",") != strings.Join(want, ",") {
353+ t.Errorf("%q: writable %v, want %v", agent, got, want)
354+ }
355+ }
356+ if mmFolderOf("/p/agent.yaml", "/p") != nil {
357+ t.Error("an agent at the root is not a .mm project")
358+ }
359+}
360+
361+// agent.yaml at the root still wins over .mm/agent.yaml, like in mm; -agent
362+// names exactly one file.
363+func TestRootAgentWinsAndTheAgentFlagIsExact(t *testing.T) {
364+ files := map[string]string{"agent.yaml": "acp:\n name: root\n", ".mm/agent.yaml": "acp:\n name: folder\n"}
365+ _, code, out := runIn(t, files, "", version)
366+ if code != 0 || readEnv(t).Name != "root-sbx" || strings.Contains(out, "The agent is .mm") || strings.Contains(readFile(t), "additionalWorkspaces") {
367+ t.Errorf("exit %d, name %q:\n%s", code, readEnv(t).Name, out)
368+ }
369+ _, code, out = runIn(t, map[string]string{".mm/agent.yaml": "acp:\n name: folder\n"}, "", version, "--agent", "agent.yaml")
370+ if code != 0 || readEnv(t).Name != "mm-sbx" || !strings.Contains(out, "No agent.yaml yet") {
371+ t.Errorf("-agent agent.yaml fell back to .mm: exit %d, name %q:\n%s", code, readEnv(t).Name, out)
372+ }
373+}
374+
375+func readFile(t *testing.T) string {
376+ t.Helper()
377+ data, err := os.ReadFile("sbxenv.yaml")
378+ if err != nil {
379+ t.Fatal(err)
380+ }
381+ return string(data)
382+}
added internal/skillcreate/flow.go +230 -0
new file mode 100644
@@ -0,0 +1,230 @@
1+package skillcreate
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+
9+ "mm/internal/agentinit"
10+ "mm/internal/elicit"
11+)
12+
13+// noKeywords is the answer that means "no keywords": a wizard answer cannot
14+// be empty (an empty answer is an unanswered question).
15+const noKeywords = "-"
16+
17+// stepKeys is the order of the questions. The body is last: in the line
18+// mode it ends with the end of the input, after which nothing can be asked
19+// — so an existing skill is settled right after the name.
20+var stepKeys = []string{"name", "overwrite", "description", "keywords", "body"}
21+
22+var stepTitles = map[string]string{
23+ "name": "Name", "overwrite": "Overwrite", "description": "Description", "keywords": "Keywords", "body": "Text",
24+}
25+
26+// Flow is the wizard's state: the answers so far, which came from flags,
27+// and where the skill goes. The line mode and the window (internal/tui)
28+// both drive one, through the same Step type as `mm init`.
29+//
30+// f := skillcreate.NewFlow(place, skillcreate.Spec{Name: "Vulcan Salute"}, "")
31+// for _, st := range f.Pending() { … f.Set(st.Key, answer) … }
32+// spec, place, output := f.Result()
33+type Flow struct {
34+ place Place
35+ spec Spec
36+ output string // "" = File(place.Dir, name); "-" = stdout
37+ given map[string]bool
38+ answer map[string]string
39+ overwrite bool // the user agreed to replace the existing skill
40+}
41+
42+// NewFlow starts a flow; what the flags gave is marked given and never
43+// asked. output is -o: "" for the skills directory, "-" for stdout, or a
44+// file.
45+func NewFlow(place Place, given Spec, output string) *Flow {
46+ f := &Flow{place: place, output: output, given: map[string]bool{}, answer: map[string]string{}}
47+ for key, value := range map[string]string{"name": given.Name, "description": given.Description, "keywords": given.Keywords, "body": given.Body} {
48+ if value != "" {
49+ f.given[key] = true
50+ _ = f.Set(key, value)
51+ }
52+ }
53+ return f
54+}
55+
56+// Place is where the skill goes.
57+func (f *Flow) Place() Place { return f.place }
58+
59+// Output is the file the skill is written to ("-" = stdout); "" before the
60+// name is known.
61+func (f *Flow) Output() string {
62+ if f.output != "" || f.spec.Name == "" {
63+ return f.output
64+ }
65+ return File(f.place.Dir, f.spec.Name)
66+}
67+
68+// existing is the file the answered name would replace, or "".
69+func (f *Flow) existing() string {
70+ switch {
71+ case f.spec.Name == "" || f.output == "-":
72+ return ""
73+ case f.output != "":
74+ if _, err := os.Stat(f.output); err == nil {
75+ return f.output
76+ }
77+ return ""
78+ }
79+ return Existing(f.place.Dir, f.spec.Name)
80+}
81+
82+// Steps is every question in order, answered or not; the overwrite
83+// question exists only when the name is taken.
84+func (f *Flow) Steps() []agentinit.Step {
85+ var out []agentinit.Step
86+ for _, key := range stepKeys {
87+ if key == "overwrite" && f.existing() == "" {
88+ continue
89+ }
90+ out = append(out, f.Step(key))
91+ }
92+ return out
93+}
94+
95+// Pending is the questions still to answer, in order.
96+func (f *Flow) Pending() []agentinit.Step {
97+ var out []agentinit.Step
98+ for _, st := range f.Steps() {
99+ if st.Value == "" {
100+ out = append(out, st)
101+ }
102+ }
103+ return out
104+}
105+
106+// Step builds one question as it stands now.
107+func (f *Flow) Step(key string) agentinit.Step {
108+ st := agentinit.Step{Key: key, Title: stepTitles[key], Value: f.answer[key], Given: f.given[key], Kind: elicit.Text}
109+ switch key {
110+ case "name":
111+ st.Prompt = "Skill name?"
112+ case "overwrite":
113+ st.Prompt, st.Kind, st.Default = "Replace "+Normalize(f.spec.Name)+"?", elicit.Confirm, "no"
114+ st.Options = []agentinit.Option{
115+ {Value: "yes", Label: "yes", Detail: "replace " + f.existing()},
116+ {Value: "no", Label: "no", Detail: "keep it and choose another name"},
117+ }
118+ case "description":
119+ st.Prompt = "Description (one line)?"
120+ case "keywords":
121+ st.Prompt, st.Default, st.Optional = "Keywords (comma-separated)?", noKeywords, true
122+ case "body":
123+ st.Prompt, st.Multiline = "What to do (the skill's text)?", true
124+ }
125+ return st
126+}
127+
128+// Set answers one question.
129+func (f *Flow) Set(key, value string) error {
130+ if key != "body" {
131+ value = strings.TrimSpace(value)
132+ }
133+ if strings.TrimSpace(value) == "" {
134+ return fmt.Errorf("%s: an answer is needed", key)
135+ }
136+ switch key {
137+ case "name":
138+ if err := CheckName(value); err != nil {
139+ return err
140+ }
141+ if Normalize(value) != Normalize(f.spec.Name) {
142+ f.forgetOverwrite()
143+ }
144+ f.spec.Name = value
145+ case "overwrite":
146+ if value != "yes" && value != "no" {
147+ return fmt.Errorf("overwrite: answer yes or no, not %q", value)
148+ }
149+ if value == "no" {
150+ // Back to the name question, even for a name given by a flag.
151+ f.spec.Name = ""
152+ delete(f.given, "name")
153+ delete(f.answer, "name")
154+ return nil
155+ }
156+ f.overwrite = true
157+ case "description":
158+ f.spec.Description = value
159+ case "keywords":
160+ f.spec.Keywords = ""
161+ if value != noKeywords {
162+ f.spec.Keywords = value
163+ }
164+ case "body":
165+ f.spec.Body = value
166+ default:
167+ return fmt.Errorf("no question %q", key)
168+ }
169+ f.answer[key] = value
170+ return nil
171+}
172+
173+// Unset takes an answer back (the window's Esc), unless a flag gave it.
174+func (f *Flow) Unset(key string) {
175+ if f.given[key] {
176+ return
177+ }
178+ delete(f.answer, key)
179+ switch key {
180+ case "name":
181+ f.spec.Name = ""
182+ f.forgetOverwrite()
183+ case "overwrite":
184+ f.forgetOverwrite()
185+ case "description":
186+ f.spec.Description = ""
187+ case "keywords":
188+ f.spec.Keywords = ""
189+ case "body":
190+ f.spec.Body = ""
191+ }
192+}
193+
194+func (f *Flow) forgetOverwrite() {
195+ f.overwrite = false
196+ delete(f.answer, "overwrite")
197+}
198+
199+// Overwrite says the user agreed to replace the existing skill.
200+func (f *Flow) Overwrite() bool { return f.overwrite }
201+
202+// Preview is the SKILL.md as it would be written now — with candidate as
203+// the answer to key, when given. Unanswered questions show placeholders.
204+func (f *Flow) Preview(key, candidate string) string {
205+ trial := *f
206+ trial.answer, trial.given = map[string]string{}, map[string]bool{}
207+ if key != "" && key != "overwrite" && strings.TrimSpace(candidate) != "" {
208+ _ = trial.Set(key, candidate)
209+ }
210+ s := trial.spec
211+ if s.Name == "" {
212+ s.Name = "<name>"
213+ }
214+ if s.Description == "" {
215+ s.Description = "<description>"
216+ }
217+ if s.Body == "" {
218+ s.Body = "<the skill's text>"
219+ }
220+ head := "# " + f.place.Dir + "\n"
221+ if out := trial.Output(); out != "" && out != "-" {
222+ head = "# " + filepath.ToSlash(out) + "\n"
223+ }
224+ return head + "\n" + Render(s)
225+}
226+
227+// Result is the skill, where it goes, and the file to write.
228+func (f *Flow) Result() (Spec, Place, string) {
229+ return f.spec, f.place, f.Output()
230+}
new file mode 100644
@@ -0,0 +1,230 @@
1+package skillcreate
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+
9+ "mm/internal/agentinit"
10+ "mm/internal/elicit"
11+)
12+
13+// noKeywords is the answer that means "no keywords": a wizard answer cannot
14+// be empty (an empty answer is an unanswered question).
15+const noKeywords = "-"
16+
17+// stepKeys is the order of the questions. The body is last: in the line
18+// mode it ends with the end of the input, after which nothing can be asked
19+// — so an existing skill is settled right after the name.
20+var stepKeys = []string{"name", "overwrite", "description", "keywords", "body"}
21+
22+var stepTitles = map[string]string{
23+ "name": "Name", "overwrite": "Overwrite", "description": "Description", "keywords": "Keywords", "body": "Text",
24+}
25+
26+// Flow is the wizard's state: the answers so far, which came from flags,
27+// and where the skill goes. The line mode and the window (internal/tui)
28+// both drive one, through the same Step type as `mm init`.
29+//
30+// f := skillcreate.NewFlow(place, skillcreate.Spec{Name: "Vulcan Salute"}, "")
31+// for _, st := range f.Pending() { … f.Set(st.Key, answer) … }
32+// spec, place, output := f.Result()
33+type Flow struct {
34+ place Place
35+ spec Spec
36+ output string // "" = File(place.Dir, name); "-" = stdout
37+ given map[string]bool
38+ answer map[string]string
39+ overwrite bool // the user agreed to replace the existing skill
40+}
41+
42+// NewFlow starts a flow; what the flags gave is marked given and never
43+// asked. output is -o: "" for the skills directory, "-" for stdout, or a
44+// file.
45+func NewFlow(place Place, given Spec, output string) *Flow {
46+ f := &Flow{place: place, output: output, given: map[string]bool{}, answer: map[string]string{}}
47+ for key, value := range map[string]string{"name": given.Name, "description": given.Description, "keywords": given.Keywords, "body": given.Body} {
48+ if value != "" {
49+ f.given[key] = true
50+ _ = f.Set(key, value)
51+ }
52+ }
53+ return f
54+}
55+
56+// Place is where the skill goes.
57+func (f *Flow) Place() Place { return f.place }
58+
59+// Output is the file the skill is written to ("-" = stdout); "" before the
60+// name is known.
61+func (f *Flow) Output() string {
62+ if f.output != "" || f.spec.Name == "" {
63+ return f.output
64+ }
65+ return File(f.place.Dir, f.spec.Name)
66+}
67+
68+// existing is the file the answered name would replace, or "".
69+func (f *Flow) existing() string {
70+ switch {
71+ case f.spec.Name == "" || f.output == "-":
72+ return ""
73+ case f.output != "":
74+ if _, err := os.Stat(f.output); err == nil {
75+ return f.output
76+ }
77+ return ""
78+ }
79+ return Existing(f.place.Dir, f.spec.Name)
80+}
81+
82+// Steps is every question in order, answered or not; the overwrite
83+// question exists only when the name is taken.
84+func (f *Flow) Steps() []agentinit.Step {
85+ var out []agentinit.Step
86+ for _, key := range stepKeys {
87+ if key == "overwrite" && f.existing() == "" {
88+ continue
89+ }
90+ out = append(out, f.Step(key))
91+ }
92+ return out
93+}
94+
95+// Pending is the questions still to answer, in order.
96+func (f *Flow) Pending() []agentinit.Step {
97+ var out []agentinit.Step
98+ for _, st := range f.Steps() {
99+ if st.Value == "" {
100+ out = append(out, st)
101+ }
102+ }
103+ return out
104+}
105+
106+// Step builds one question as it stands now.
107+func (f *Flow) Step(key string) agentinit.Step {
108+ st := agentinit.Step{Key: key, Title: stepTitles[key], Value: f.answer[key], Given: f.given[key], Kind: elicit.Text}
109+ switch key {
110+ case "name":
111+ st.Prompt = "Skill name?"
112+ case "overwrite":
113+ st.Prompt, st.Kind, st.Default = "Replace "+Normalize(f.spec.Name)+"?", elicit.Confirm, "no"
114+ st.Options = []agentinit.Option{
115+ {Value: "yes", Label: "yes", Detail: "replace " + f.existing()},
116+ {Value: "no", Label: "no", Detail: "keep it and choose another name"},
117+ }
118+ case "description":
119+ st.Prompt = "Description (one line)?"
120+ case "keywords":
121+ st.Prompt, st.Default, st.Optional = "Keywords (comma-separated)?", noKeywords, true
122+ case "body":
123+ st.Prompt, st.Multiline = "What to do (the skill's text)?", true
124+ }
125+ return st
126+}
127+
128+// Set answers one question.
129+func (f *Flow) Set(key, value string) error {
130+ if key != "body" {
131+ value = strings.TrimSpace(value)
132+ }
133+ if strings.TrimSpace(value) == "" {
134+ return fmt.Errorf("%s: an answer is needed", key)
135+ }
136+ switch key {
137+ case "name":
138+ if err := CheckName(value); err != nil {
139+ return err
140+ }
141+ if Normalize(value) != Normalize(f.spec.Name) {
142+ f.forgetOverwrite()
143+ }
144+ f.spec.Name = value
145+ case "overwrite":
146+ if value != "yes" && value != "no" {
147+ return fmt.Errorf("overwrite: answer yes or no, not %q", value)
148+ }
149+ if value == "no" {
150+ // Back to the name question, even for a name given by a flag.
151+ f.spec.Name = ""
152+ delete(f.given, "name")
153+ delete(f.answer, "name")
154+ return nil
155+ }
156+ f.overwrite = true
157+ case "description":
158+ f.spec.Description = value
159+ case "keywords":
160+ f.spec.Keywords = ""
161+ if value != noKeywords {
162+ f.spec.Keywords = value
163+ }
164+ case "body":
165+ f.spec.Body = value
166+ default:
167+ return fmt.Errorf("no question %q", key)
168+ }
169+ f.answer[key] = value
170+ return nil
171+}
172+
173+// Unset takes an answer back (the window's Esc), unless a flag gave it.
174+func (f *Flow) Unset(key string) {
175+ if f.given[key] {
176+ return
177+ }
178+ delete(f.answer, key)
179+ switch key {
180+ case "name":
181+ f.spec.Name = ""
182+ f.forgetOverwrite()
183+ case "overwrite":
184+ f.forgetOverwrite()
185+ case "description":
186+ f.spec.Description = ""
187+ case "keywords":
188+ f.spec.Keywords = ""
189+ case "body":
190+ f.spec.Body = ""
191+ }
192+}
193+
194+func (f *Flow) forgetOverwrite() {
195+ f.overwrite = false
196+ delete(f.answer, "overwrite")
197+}
198+
199+// Overwrite says the user agreed to replace the existing skill.
200+func (f *Flow) Overwrite() bool { return f.overwrite }
201+
202+// Preview is the SKILL.md as it would be written now — with candidate as
203+// the answer to key, when given. Unanswered questions show placeholders.
204+func (f *Flow) Preview(key, candidate string) string {
205+ trial := *f
206+ trial.answer, trial.given = map[string]string{}, map[string]bool{}
207+ if key != "" && key != "overwrite" && strings.TrimSpace(candidate) != "" {
208+ _ = trial.Set(key, candidate)
209+ }
210+ s := trial.spec
211+ if s.Name == "" {
212+ s.Name = "<name>"
213+ }
214+ if s.Description == "" {
215+ s.Description = "<description>"
216+ }
217+ if s.Body == "" {
218+ s.Body = "<the skill's text>"
219+ }
220+ head := "# " + f.place.Dir + "\n"
221+ if out := trial.Output(); out != "" && out != "-" {
222+ head = "# " + filepath.ToSlash(out) + "\n"
223+ }
224+ return head + "\n" + Render(s)
225+}
226+
227+// Result is the skill, where it goes, and the file to write.
228+func (f *Flow) Result() (Spec, Place, string) {
229+ return f.spec, f.place, f.Output()
230+}
added internal/skillcreate/main.go +254 -0
new file mode 100644
@@ -0,0 +1,254 @@
1+package skillcreate
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "os"
11+ "strings"
12+
13+ "mm/internal/agentinit"
14+ "mm/internal/elicit"
15+ "mm/internal/skills"
16+)
17+
18+// Window asks the flow's questions in a full-screen window (internal/tui's
19+// RunSkillCreate); false when the user quit without writing. main hands it
20+// over: internal/tui imports this package, not the other way round.
21+type Window func(ctx context.Context, f *Flow) (bool, error)
22+
23+// Options are what main gives Main besides the arguments.
24+type Options struct {
25+ Window Window // nil: the line mode only
26+ Terminal func() bool // whether stdin and stdout are terminals
27+}
28+
29+// Main is `mm skillcreate`: it asks for the skill — in a window in a
30+// terminal, line by line otherwise or with --line — or reads it from the
31+// flags, writes <skillsDir>/<name>/SKILL.md where the agent's config says,
32+// and reads it back with mm's skill loader. It returns the exit code.
33+//
34+// mm skillcreate
35+// mm skillcreate --name "Vulcan Salute" --description "Greet the Vulcan way." \
36+// --keywords "vulcan, spock" --body-file salute.md --yes
37+func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int {
38+ var o Options
39+ if len(opts) > 0 {
40+ o = opts[0]
41+ }
42+ fs := flag.NewFlagSet("mm skillcreate", flag.ContinueOnError)
43+ fs.SetOutput(stdout)
44+ var spec Spec
45+ var configPath, bodyFile, output string
46+ var yes, line bool
47+ fs.StringVar(&spec.Name, "name", "", "the skill's name; written lower case with dashes (Vulcan Salute → vulcan-salute)")
48+ fs.StringVar(&spec.Description, "description", "", "one line, shown in the catalogue the model chooses from")
49+ fs.StringVar(&spec.Keywords, "keywords", "", "comma-separated words a user might type; the first 8 reach the catalogue")
50+ fs.StringVar(&spec.Body, "body", "", "the skill's text, written under \"## What to do\"")
51+ fs.StringVar(&bodyFile, "body-file", "", "read the skill's text from this file")
52+ fs.StringVar(&configPath, "config", "", "the agent file whose skillsDir is used (default: as mm finds it — $AGENT_CONFIG, ./agent.yaml, ./.mm/agent.yaml)")
53+ fs.StringVar(&output, "o", "", "the file to write (default <skillsDir>/<name>/SKILL.md; - for stdout, which creates nothing)")
54+ fs.BoolVar(&yes, "yes", false, "ask nothing: name, description and text must be given; an existing skill is never replaced")
55+ fs.BoolVar(&line, "line", false, "ask line by line even in a terminal (no window)")
56+ if err := fs.Parse(args); err != nil {
57+ if errors.Is(err, flag.ErrHelp) {
58+ return 0
59+ }
60+ return 2
61+ }
62+ if bodyFile != "" {
63+ data, err := os.ReadFile(bodyFile)
64+ if err != nil {
65+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
66+ return 1
67+ }
68+ spec.Body = string(data)
69+ }
70+ place, err := Locate(configPath)
71+ if err != nil {
72+ fmt.Fprintf(stdout, "⚠️ config error: %v\n", err)
73+ return 1
74+ }
75+ flow := NewFlow(place, Spec{}, output)
76+ if err := flow.setGiven(spec); err != nil {
77+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
78+ return 1
79+ }
80+
81+ switch {
82+ case yes:
83+ if err := flow.takeDefaults(); err != nil {
84+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
85+ return 1
86+ }
87+ case o.Window != nil && !line && o.Terminal != nil && o.Terminal():
88+ done, err := o.Window(context.Background(), flow)
89+ if err != nil {
90+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
91+ return 1
92+ }
93+ if !done {
94+ fmt.Fprintln(stdout, "nothing written")
95+ return 1
96+ }
97+ default:
98+ if err := (&lineWizard{in: bufio.NewReader(stdin), out: stdout}).run(flow); err != nil {
99+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
100+ return 1
101+ }
102+ }
103+ return finish(flow, stdout)
104+}
105+
106+// setGiven records the flags' answers, refusing a value no question would
107+// accept.
108+func (f *Flow) setGiven(s Spec) error {
109+ for _, kv := range [][2]string{{"name", s.Name}, {"description", s.Description}, {"keywords", s.Keywords}, {"body", s.Body}} {
110+ if kv[1] == "" {
111+ continue
112+ }
113+ f.given[kv[0]] = true
114+ if err := f.Set(kv[0], kv[1]); err != nil {
115+ return err
116+ }
117+ }
118+ return nil
119+}
120+
121+// takeDefaults answers what has a default (--yes); a question without one
122+// must have come from a flag.
123+func (f *Flow) takeDefaults() error {
124+ for _, st := range f.Pending() {
125+ if st.Key == "overwrite" {
126+ return fmt.Errorf("%s %w — nothing was written; choose another name", f.existing(), ErrExists)
127+ }
128+ if st.Default == "" {
129+ flagName := map[string]string{"name": "--name", "description": "--description", "body": "--body or --body-file"}[st.Key]
130+ return fmt.Errorf("%s: no default — give it with %s", strings.ToLower(st.Title), flagName)
131+ }
132+ if err := f.Set(st.Key, st.Default); err != nil {
133+ return err
134+ }
135+ }
136+ return nil
137+}
138+
139+// finish writes the skill the flow describes and reads it back.
140+func finish(flow *Flow, stdout io.Writer) int {
141+ spec, place, output := flow.Result()
142+ if output == "-" {
143+ fmt.Fprint(stdout, Render(spec))
144+ return 0
145+ }
146+ path, err := write(flow, spec, place, output)
147+ if err != nil {
148+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
149+ return 1
150+ }
151+ fmt.Fprintf(stdout, "✅ wrote %s\n", path)
152+ if n := len(KeywordList(spec.Keywords)); n > skills.MaxKeywords {
153+ fmt.Fprintf(stdout, "⚠️ %d keywords: only the first %d reach the catalogue — put the most distinctive first\n", n, skills.MaxKeywords)
154+ }
155+ if path == File(place.Dir, spec.Name) {
156+ if err := Verify(place.Dir, spec); err != nil {
157+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
158+ return 1
159+ }
160+ fmt.Fprintf(stdout, "✅ mm reads it: %s — %s\n", Normalize(spec.Name), oneLine(spec.Description))
161+ }
162+ if place.Note != "" {
163+ fmt.Fprintln(stdout, "⚠️ "+place.Note)
164+ return 0
165+ }
166+ fmt.Fprintln(stdout, "Next: start mm from this directory — the banner counts it under skills: N")
167+ return 0
168+}
169+
170+// write writes the skill where Output says: the skills directory, or the -o
171+// file.
172+func write(flow *Flow, spec Spec, place Place, output string) (string, error) {
173+ if output == File(place.Dir, spec.Name) {
174+ return Write(place.Dir, spec, flow.Overwrite())
175+ }
176+ if _, err := os.Stat(output); err == nil && !flow.Overwrite() {
177+ return "", fmt.Errorf("%s %w — nothing was written", output, ErrExists)
178+ }
179+ return output, os.WriteFile(output, []byte(Render(spec)), 0o644)
180+}
181+
182+// lineWizard asks the questions one per line, like mm init's line mode; the
183+// skill's text is read to the end of the input.
184+type lineWizard struct {
185+ in *bufio.Reader
186+ out io.Writer
187+}
188+
189+func (w *lineWizard) run(f *Flow) error {
190+ for {
191+ pending := f.Pending()
192+ if len(pending) == 0 {
193+ return nil
194+ }
195+ st := pending[0]
196+ answer, ended := w.ask(st)
197+ if strings.TrimSpace(answer) == "" {
198+ if ended {
199+ return fmt.Errorf("%s: no answer", strings.ToLower(st.Title))
200+ }
201+ // A question without a default is asked again.
202+ fmt.Fprintf(w.out, "⚠️ %s: an answer is needed\n", strings.ToLower(st.Title))
203+ continue
204+ }
205+ if err := f.Set(st.Key, answer); err != nil {
206+ fmt.Fprintln(w.out, "⚠️ "+err.Error())
207+ if ended {
208+ return err
209+ }
210+ continue
211+ }
212+ if st.Key == "keywords" {
213+ if n := len(KeywordList(f.spec.Keywords)); n > skills.MaxKeywords {
214+ fmt.Fprintf(w.out, "⚠️ %d keywords: only the first %d reach the catalogue\n", n, skills.MaxKeywords)
215+ }
216+ }
217+ }
218+}
219+
220+// ask asks one step; it reports whether the input ended.
221+func (w *lineWizard) ask(st agentinit.Step) (string, bool) {
222+ if st.Multiline {
223+ fmt.Fprintln(w.out, "? "+st.Prompt)
224+ fmt.Fprintln(w.out, " (several lines; end with Ctrl-D — the end of the input)")
225+ data, _ := io.ReadAll(w.in)
226+ return string(data), true
227+ }
228+ q := elicit.Question{Prompt: st.Prompt, Kind: st.Kind, Default: st.Default}
229+ for _, o := range st.Options {
230+ q.Options = append(q.Options, o.Value)
231+ }
232+ for {
233+ lines := elicit.Lines(q)
234+ lines[len(lines)-1] = strings.Replace(lines[len(lines)-1], "/skip to decline", "Enter = the default", 1)
235+ if st.Optional {
236+ lines[len(lines)-1] = " your answer (Enter = none)"
237+ }
238+ for _, l := range lines {
239+ fmt.Fprintln(w.out, l)
240+ }
241+ fmt.Fprint(w.out, "> ")
242+ text, err := w.in.ReadString('\n')
243+ ended := err != nil
244+ if elicit.IsSkip(text) || strings.TrimSpace(text) == "" {
245+ return q.Default, ended
246+ }
247+ if a, ok := elicit.ParseLine(q, text); ok && len(a.Values) > 0 {
248+ return a.Values[0], ended
249+ }
250+ if ended {
251+ return q.Default, true
252+ }
253+ }
254+}
new file mode 100644
@@ -0,0 +1,254 @@
1+package skillcreate
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "os"
11+ "strings"
12+
13+ "mm/internal/agentinit"
14+ "mm/internal/elicit"
15+ "mm/internal/skills"
16+)
17+
18+// Window asks the flow's questions in a full-screen window (internal/tui's
19+// RunSkillCreate); false when the user quit without writing. main hands it
20+// over: internal/tui imports this package, not the other way round.
21+type Window func(ctx context.Context, f *Flow) (bool, error)
22+
23+// Options are what main gives Main besides the arguments.
24+type Options struct {
25+ Window Window // nil: the line mode only
26+ Terminal func() bool // whether stdin and stdout are terminals
27+}
28+
29+// Main is `mm skillcreate`: it asks for the skill — in a window in a
30+// terminal, line by line otherwise or with --line — or reads it from the
31+// flags, writes <skillsDir>/<name>/SKILL.md where the agent's config says,
32+// and reads it back with mm's skill loader. It returns the exit code.
33+//
34+// mm skillcreate
35+// mm skillcreate --name "Vulcan Salute" --description "Greet the Vulcan way." \
36+// --keywords "vulcan, spock" --body-file salute.md --yes
37+func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int {
38+ var o Options
39+ if len(opts) > 0 {
40+ o = opts[0]
41+ }
42+ fs := flag.NewFlagSet("mm skillcreate", flag.ContinueOnError)
43+ fs.SetOutput(stdout)
44+ var spec Spec
45+ var configPath, bodyFile, output string
46+ var yes, line bool
47+ fs.StringVar(&spec.Name, "name", "", "the skill's name; written lower case with dashes (Vulcan Salute → vulcan-salute)")
48+ fs.StringVar(&spec.Description, "description", "", "one line, shown in the catalogue the model chooses from")
49+ fs.StringVar(&spec.Keywords, "keywords", "", "comma-separated words a user might type; the first 8 reach the catalogue")
50+ fs.StringVar(&spec.Body, "body", "", "the skill's text, written under \"## What to do\"")
51+ fs.StringVar(&bodyFile, "body-file", "", "read the skill's text from this file")
52+ fs.StringVar(&configPath, "config", "", "the agent file whose skillsDir is used (default: as mm finds it — $AGENT_CONFIG, ./agent.yaml, ./.mm/agent.yaml)")
53+ fs.StringVar(&output, "o", "", "the file to write (default <skillsDir>/<name>/SKILL.md; - for stdout, which creates nothing)")
54+ fs.BoolVar(&yes, "yes", false, "ask nothing: name, description and text must be given; an existing skill is never replaced")
55+ fs.BoolVar(&line, "line", false, "ask line by line even in a terminal (no window)")
56+ if err := fs.Parse(args); err != nil {
57+ if errors.Is(err, flag.ErrHelp) {
58+ return 0
59+ }
60+ return 2
61+ }
62+ if bodyFile != "" {
63+ data, err := os.ReadFile(bodyFile)
64+ if err != nil {
65+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
66+ return 1
67+ }
68+ spec.Body = string(data)
69+ }
70+ place, err := Locate(configPath)
71+ if err != nil {
72+ fmt.Fprintf(stdout, "⚠️ config error: %v\n", err)
73+ return 1
74+ }
75+ flow := NewFlow(place, Spec{}, output)
76+ if err := flow.setGiven(spec); err != nil {
77+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
78+ return 1
79+ }
80+
81+ switch {
82+ case yes:
83+ if err := flow.takeDefaults(); err != nil {
84+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
85+ return 1
86+ }
87+ case o.Window != nil && !line && o.Terminal != nil && o.Terminal():
88+ done, err := o.Window(context.Background(), flow)
89+ if err != nil {
90+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
91+ return 1
92+ }
93+ if !done {
94+ fmt.Fprintln(stdout, "nothing written")
95+ return 1
96+ }
97+ default:
98+ if err := (&lineWizard{in: bufio.NewReader(stdin), out: stdout}).run(flow); err != nil {
99+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
100+ return 1
101+ }
102+ }
103+ return finish(flow, stdout)
104+}
105+
106+// setGiven records the flags' answers, refusing a value no question would
107+// accept.
108+func (f *Flow) setGiven(s Spec) error {
109+ for _, kv := range [][2]string{{"name", s.Name}, {"description", s.Description}, {"keywords", s.Keywords}, {"body", s.Body}} {
110+ if kv[1] == "" {
111+ continue
112+ }
113+ f.given[kv[0]] = true
114+ if err := f.Set(kv[0], kv[1]); err != nil {
115+ return err
116+ }
117+ }
118+ return nil
119+}
120+
121+// takeDefaults answers what has a default (--yes); a question without one
122+// must have come from a flag.
123+func (f *Flow) takeDefaults() error {
124+ for _, st := range f.Pending() {
125+ if st.Key == "overwrite" {
126+ return fmt.Errorf("%s %w — nothing was written; choose another name", f.existing(), ErrExists)
127+ }
128+ if st.Default == "" {
129+ flagName := map[string]string{"name": "--name", "description": "--description", "body": "--body or --body-file"}[st.Key]
130+ return fmt.Errorf("%s: no default — give it with %s", strings.ToLower(st.Title), flagName)
131+ }
132+ if err := f.Set(st.Key, st.Default); err != nil {
133+ return err
134+ }
135+ }
136+ return nil
137+}
138+
139+// finish writes the skill the flow describes and reads it back.
140+func finish(flow *Flow, stdout io.Writer) int {
141+ spec, place, output := flow.Result()
142+ if output == "-" {
143+ fmt.Fprint(stdout, Render(spec))
144+ return 0
145+ }
146+ path, err := write(flow, spec, place, output)
147+ if err != nil {
148+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
149+ return 1
150+ }
151+ fmt.Fprintf(stdout, "✅ wrote %s\n", path)
152+ if n := len(KeywordList(spec.Keywords)); n > skills.MaxKeywords {
153+ fmt.Fprintf(stdout, "⚠️ %d keywords: only the first %d reach the catalogue — put the most distinctive first\n", n, skills.MaxKeywords)
154+ }
155+ if path == File(place.Dir, spec.Name) {
156+ if err := Verify(place.Dir, spec); err != nil {
157+ fmt.Fprintln(stdout, "⚠️ "+err.Error())
158+ return 1
159+ }
160+ fmt.Fprintf(stdout, "✅ mm reads it: %s — %s\n", Normalize(spec.Name), oneLine(spec.Description))
161+ }
162+ if place.Note != "" {
163+ fmt.Fprintln(stdout, "⚠️ "+place.Note)
164+ return 0
165+ }
166+ fmt.Fprintln(stdout, "Next: start mm from this directory — the banner counts it under skills: N")
167+ return 0
168+}
169+
170+// write writes the skill where Output says: the skills directory, or the -o
171+// file.
172+func write(flow *Flow, spec Spec, place Place, output string) (string, error) {
173+ if output == File(place.Dir, spec.Name) {
174+ return Write(place.Dir, spec, flow.Overwrite())
175+ }
176+ if _, err := os.Stat(output); err == nil && !flow.Overwrite() {
177+ return "", fmt.Errorf("%s %w — nothing was written", output, ErrExists)
178+ }
179+ return output, os.WriteFile(output, []byte(Render(spec)), 0o644)
180+}
181+
182+// lineWizard asks the questions one per line, like mm init's line mode; the
183+// skill's text is read to the end of the input.
184+type lineWizard struct {
185+ in *bufio.Reader
186+ out io.Writer
187+}
188+
189+func (w *lineWizard) run(f *Flow) error {
190+ for {
191+ pending := f.Pending()
192+ if len(pending) == 0 {
193+ return nil
194+ }
195+ st := pending[0]
196+ answer, ended := w.ask(st)
197+ if strings.TrimSpace(answer) == "" {
198+ if ended {
199+ return fmt.Errorf("%s: no answer", strings.ToLower(st.Title))
200+ }
201+ // A question without a default is asked again.
202+ fmt.Fprintf(w.out, "⚠️ %s: an answer is needed\n", strings.ToLower(st.Title))
203+ continue
204+ }
205+ if err := f.Set(st.Key, answer); err != nil {
206+ fmt.Fprintln(w.out, "⚠️ "+err.Error())
207+ if ended {
208+ return err
209+ }
210+ continue
211+ }
212+ if st.Key == "keywords" {
213+ if n := len(KeywordList(f.spec.Keywords)); n > skills.MaxKeywords {
214+ fmt.Fprintf(w.out, "⚠️ %d keywords: only the first %d reach the catalogue\n", n, skills.MaxKeywords)
215+ }
216+ }
217+ }
218+}
219+
220+// ask asks one step; it reports whether the input ended.
221+func (w *lineWizard) ask(st agentinit.Step) (string, bool) {
222+ if st.Multiline {
223+ fmt.Fprintln(w.out, "? "+st.Prompt)
224+ fmt.Fprintln(w.out, " (several lines; end with Ctrl-D — the end of the input)")
225+ data, _ := io.ReadAll(w.in)
226+ return string(data), true
227+ }
228+ q := elicit.Question{Prompt: st.Prompt, Kind: st.Kind, Default: st.Default}
229+ for _, o := range st.Options {
230+ q.Options = append(q.Options, o.Value)
231+ }
232+ for {
233+ lines := elicit.Lines(q)
234+ lines[len(lines)-1] = strings.Replace(lines[len(lines)-1], "/skip to decline", "Enter = the default", 1)
235+ if st.Optional {
236+ lines[len(lines)-1] = " your answer (Enter = none)"
237+ }
238+ for _, l := range lines {
239+ fmt.Fprintln(w.out, l)
240+ }
241+ fmt.Fprint(w.out, "> ")
242+ text, err := w.in.ReadString('\n')
243+ ended := err != nil
244+ if elicit.IsSkip(text) || strings.TrimSpace(text) == "" {
245+ return q.Default, ended
246+ }
247+ if a, ok := elicit.ParseLine(q, text); ok && len(a.Values) > 0 {
248+ return a.Values[0], ended
249+ }
250+ if ended {
251+ return q.Default, true
252+ }
253+ }
254+}
added internal/skillcreate/main_test.go +142 -0
new file mode 100644
@@ -0,0 +1,142 @@
1+package skillcreate
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+)
10+
11+// run is `mm skillcreate <args>` in the line mode, with stdin.
12+func run(t *testing.T, stdin string, args ...string) (string, int) {
13+ t.Helper()
14+ var stdout bytes.Buffer
15+ code := Main(append([]string{"--line"}, args...), strings.NewReader(stdin), &stdout)
16+ return stdout.String(), code
17+}
18+
19+// The line mode asks name, description, keywords, then reads the text to
20+// the end of the input; the skill lands in the config's skillsDir.
21+func TestLineModeWritesTheSkillWhereTheConfigSays(t *testing.T) {
22+ project(t, map[string]string{".mm/agent.yaml": "skillsDir: .mm/skills\n"})
23+ stdin := "Vulcan Salute\nGreet the Vulcan way.\nvulcan, spock\nRaise your right hand.\n\nSay: live long and prosper.\n"
24+ out, code := run(t, stdin)
25+ if code != 0 {
26+ t.Fatalf("exit %d:\n%s", code, out)
27+ }
28+ data, err := os.ReadFile(filepath.Join(".mm", "skills", "vulcan-salute", "SKILL.md"))
29+ if err != nil {
30+ t.Fatalf("not written: %v\n%s", err, out)
31+ }
32+ for _, want := range []string{"name: vulcan-salute\n", "keywords: vulcan, spock\n", "# Vulcan Salute\n\n## What to do\n\nRaise your right hand.\n\nSay: live long and prosper.\n"} {
33+ if !strings.Contains(string(data), want) {
34+ t.Errorf("SKILL.md lacks %q:\n%s", want, data)
35+ }
36+ }
37+ for _, want := range []string{"? Skill name?", "end with Ctrl-D", "✅ mm reads it: vulcan-salute — Greet the Vulcan way.", "Next: start mm"} {
38+ if !strings.Contains(out, want) {
39+ t.Errorf("output lacks %q:\n%s", want, out)
40+ }
41+ }
42+}
43+
44+// An empty name is asked again; Enter on the keywords means none.
45+func TestLineModeAsksAgainAndKeywordsMayBeEmpty(t *testing.T) {
46+ project(t, map[string]string{"agent.yaml": "skillsDir: skills\n"})
47+ out, code := run(t, "\ngreetings\nSay hello.\n\nSay hello.\n")
48+ if code != 0 || !strings.Contains(out, "name: an answer is needed") {
49+ t.Fatalf("exit %d:\n%s", code, out)
50+ }
51+ data, _ := os.ReadFile(filepath.Join("skills", "greetings", "SKILL.md"))
52+ if strings.Contains(string(data), "keywords:") || !strings.Contains(string(data), "# Greetings\n") {
53+ t.Errorf("want no keywords line and the title Greetings:\n%s", data)
54+ }
55+}
56+
57+// An existing skill is settled right after the name: the text comes last
58+// and ends the input, nothing could be asked after it.
59+func TestAnExistingSkillIsAskedAboutRightAfterTheName(t *testing.T) {
60+ project(t, map[string]string{"agent.yaml": "skillsDir: skills\n", "skills/greetings/SKILL.md": "mine"})
61+ out, code := run(t, "greetings\nno\nhello\nSay hello.\n-\nSay hello.\n")
62+ if code != 0 {
63+ t.Fatalf("exit %d:\n%s", code, out)
64+ }
65+ if data, _ := os.ReadFile(filepath.Join("skills", "greetings", "SKILL.md")); string(data) != "mine" {
66+ t.Error("the existing skill was changed")
67+ }
68+ if !exists(filepath.Join("skills", "hello", "SKILL.md")) || !strings.Contains(out, "? Replace greetings?") {
69+ t.Errorf("want the overwrite question, then skills/hello:\n%s", out)
70+ }
71+
72+ out, code = run(t, "greetings\nyes\nSay hi.\n-\nSay hi.\n")
73+ if data, _ := os.ReadFile(filepath.Join("skills", "greetings", "SKILL.md")); code != 0 || !strings.Contains(string(data), "description: Say hi.") {
74+ t.Errorf("yes did not replace it (exit %d):\n%s", code, out)
75+ }
76+}
77+
78+func TestFlagsOnlyWithYes(t *testing.T) {
79+ project(t, map[string]string{".mm/agent.yaml": "skillsDir: .mm/skills\n", "body.md": "Raise your hand.\n"})
80+ out, code := run(t, "", "--yes", "--name", "vulcan-salute", "--description", "Greet the Vulcan way.", "--body-file", "body.md")
81+ if code != 0 {
82+ t.Fatalf("exit %d:\n%s", code, out)
83+ }
84+ data, _ := os.ReadFile(filepath.Join(".mm", "skills", "vulcan-salute", "SKILL.md"))
85+ if !strings.Contains(string(data), "# Vulcan Salute\n") || !strings.Contains(string(data), "Raise your hand.") {
86+ t.Errorf("SKILL.md:\n%s", data)
87+ }
88+ // --yes never replaces, and names what is missing.
89+ if out, code = run(t, "", "--yes", "--name", "vulcan-salute", "--description", "x", "--body", "y"); code == 0 || !strings.Contains(out, "already exists") {
90+ t.Errorf("exit %d, want a refusal:\n%s", code, out)
91+ }
92+ if out, code = run(t, "", "--yes", "--name", "other"); code == 0 || !strings.Contains(out, "give it with --description") {
93+ t.Errorf("exit %d, want the missing flag named:\n%s", code, out)
94+ }
95+}
96+
97+// -o - prints the skill and creates nothing.
98+func TestPrintCreatesNothing(t *testing.T) {
99+ project(t, nil)
100+ out, code := run(t, "", "--yes", "--name", "Vulcan Salute", "--description", "d", "--body", "b", "-o", "-")
101+ if code != 0 || !strings.HasPrefix(out, "---\nname: vulcan-salute\n") {
102+ t.Fatalf("exit %d:\n%s", code, out)
103+ }
104+ if entries, _ := os.ReadDir("."); len(entries) != 0 {
105+ t.Errorf("created %v", entries)
106+ }
107+}
108+
109+// No skills in the config: the skill goes to .mm/skills when .mm exists, and
110+// the user is told to set skillsDir.
111+func TestNoSkillsInTheConfigSaysWhatToSet(t *testing.T) {
112+ project(t, map[string]string{".mm/agent.yaml": "skillsDir: no-skills\n"})
113+ out, code := run(t, "", "--yes", "--name", "greetings", "--description", "d", "--body", "b")
114+ if code != 0 || !exists(filepath.Join(".mm", "skills", "greetings", "SKILL.md")) || !strings.Contains(out, "set skillsDir: .mm/skills") {
115+ t.Errorf("exit %d:\n%s", code, out)
116+ }
117+}
118+
119+func TestTooManyKeywordsAreWarnedAbout(t *testing.T) {
120+ project(t, nil)
121+ out, _ := run(t, "", "--yes", "--name", "x", "--description", "d", "--body", "b", "--keywords", "a,b,c,d,e,f,g,h,i")
122+ if !strings.Contains(out, "9 keywords: only the first 8 reach the catalogue") {
123+ t.Errorf("no warning:\n%s", out)
124+ }
125+}
126+
127+// The preview shows the file with the highlighted answer, and where it goes.
128+func TestPreviewFollowsTheCandidate(t *testing.T) {
129+ f := NewFlow(Place{Dir: ".mm/skills"}, Spec{}, "")
130+ got := f.Preview("name", "Vulcan Salute")
131+ if !strings.Contains(got, "# .mm/skills/vulcan-salute/SKILL.md") || !strings.Contains(got, "# Vulcan Salute") || !strings.Contains(got, "<description>") {
132+ t.Errorf("preview:\n%s", got)
133+ }
134+ if f.Output() != "" {
135+ t.Error("the preview answered the question")
136+ }
137+}
138+
139+func exists(path string) bool {
140+ _, err := os.Stat(path)
141+ return err == nil
142+}
new file mode 100644
@@ -0,0 +1,142 @@
1+package skillcreate
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+)
10+
11+// run is `mm skillcreate <args>` in the line mode, with stdin.
12+func run(t *testing.T, stdin string, args ...string) (string, int) {
13+ t.Helper()
14+ var stdout bytes.Buffer
15+ code := Main(append([]string{"--line"}, args...), strings.NewReader(stdin), &stdout)
16+ return stdout.String(), code
17+}
18+
19+// The line mode asks name, description, keywords, then reads the text to
20+// the end of the input; the skill lands in the config's skillsDir.
21+func TestLineModeWritesTheSkillWhereTheConfigSays(t *testing.T) {
22+ project(t, map[string]string{".mm/agent.yaml": "skillsDir: .mm/skills\n"})
23+ stdin := "Vulcan Salute\nGreet the Vulcan way.\nvulcan, spock\nRaise your right hand.\n\nSay: live long and prosper.\n"
24+ out, code := run(t, stdin)
25+ if code != 0 {
26+ t.Fatalf("exit %d:\n%s", code, out)
27+ }
28+ data, err := os.ReadFile(filepath.Join(".mm", "skills", "vulcan-salute", "SKILL.md"))
29+ if err != nil {
30+ t.Fatalf("not written: %v\n%s", err, out)
31+ }
32+ for _, want := range []string{"name: vulcan-salute\n", "keywords: vulcan, spock\n", "# Vulcan Salute\n\n## What to do\n\nRaise your right hand.\n\nSay: live long and prosper.\n"} {
33+ if !strings.Contains(string(data), want) {
34+ t.Errorf("SKILL.md lacks %q:\n%s", want, data)
35+ }
36+ }
37+ for _, want := range []string{"? Skill name?", "end with Ctrl-D", "✅ mm reads it: vulcan-salute — Greet the Vulcan way.", "Next: start mm"} {
38+ if !strings.Contains(out, want) {
39+ t.Errorf("output lacks %q:\n%s", want, out)
40+ }
41+ }
42+}
43+
44+// An empty name is asked again; Enter on the keywords means none.
45+func TestLineModeAsksAgainAndKeywordsMayBeEmpty(t *testing.T) {
46+ project(t, map[string]string{"agent.yaml": "skillsDir: skills\n"})
47+ out, code := run(t, "\ngreetings\nSay hello.\n\nSay hello.\n")
48+ if code != 0 || !strings.Contains(out, "name: an answer is needed") {
49+ t.Fatalf("exit %d:\n%s", code, out)
50+ }
51+ data, _ := os.ReadFile(filepath.Join("skills", "greetings", "SKILL.md"))
52+ if strings.Contains(string(data), "keywords:") || !strings.Contains(string(data), "# Greetings\n") {
53+ t.Errorf("want no keywords line and the title Greetings:\n%s", data)
54+ }
55+}
56+
57+// An existing skill is settled right after the name: the text comes last
58+// and ends the input, nothing could be asked after it.
59+func TestAnExistingSkillIsAskedAboutRightAfterTheName(t *testing.T) {
60+ project(t, map[string]string{"agent.yaml": "skillsDir: skills\n", "skills/greetings/SKILL.md": "mine"})
61+ out, code := run(t, "greetings\nno\nhello\nSay hello.\n-\nSay hello.\n")
62+ if code != 0 {
63+ t.Fatalf("exit %d:\n%s", code, out)
64+ }
65+ if data, _ := os.ReadFile(filepath.Join("skills", "greetings", "SKILL.md")); string(data) != "mine" {
66+ t.Error("the existing skill was changed")
67+ }
68+ if !exists(filepath.Join("skills", "hello", "SKILL.md")) || !strings.Contains(out, "? Replace greetings?") {
69+ t.Errorf("want the overwrite question, then skills/hello:\n%s", out)
70+ }
71+
72+ out, code = run(t, "greetings\nyes\nSay hi.\n-\nSay hi.\n")
73+ if data, _ := os.ReadFile(filepath.Join("skills", "greetings", "SKILL.md")); code != 0 || !strings.Contains(string(data), "description: Say hi.") {
74+ t.Errorf("yes did not replace it (exit %d):\n%s", code, out)
75+ }
76+}
77+
78+func TestFlagsOnlyWithYes(t *testing.T) {
79+ project(t, map[string]string{".mm/agent.yaml": "skillsDir: .mm/skills\n", "body.md": "Raise your hand.\n"})
80+ out, code := run(t, "", "--yes", "--name", "vulcan-salute", "--description", "Greet the Vulcan way.", "--body-file", "body.md")
81+ if code != 0 {
82+ t.Fatalf("exit %d:\n%s", code, out)
83+ }
84+ data, _ := os.ReadFile(filepath.Join(".mm", "skills", "vulcan-salute", "SKILL.md"))
85+ if !strings.Contains(string(data), "# Vulcan Salute\n") || !strings.Contains(string(data), "Raise your hand.") {
86+ t.Errorf("SKILL.md:\n%s", data)
87+ }
88+ // --yes never replaces, and names what is missing.
89+ if out, code = run(t, "", "--yes", "--name", "vulcan-salute", "--description", "x", "--body", "y"); code == 0 || !strings.Contains(out, "already exists") {
90+ t.Errorf("exit %d, want a refusal:\n%s", code, out)
91+ }
92+ if out, code = run(t, "", "--yes", "--name", "other"); code == 0 || !strings.Contains(out, "give it with --description") {
93+ t.Errorf("exit %d, want the missing flag named:\n%s", code, out)
94+ }
95+}
96+
97+// -o - prints the skill and creates nothing.
98+func TestPrintCreatesNothing(t *testing.T) {
99+ project(t, nil)
100+ out, code := run(t, "", "--yes", "--name", "Vulcan Salute", "--description", "d", "--body", "b", "-o", "-")
101+ if code != 0 || !strings.HasPrefix(out, "---\nname: vulcan-salute\n") {
102+ t.Fatalf("exit %d:\n%s", code, out)
103+ }
104+ if entries, _ := os.ReadDir("."); len(entries) != 0 {
105+ t.Errorf("created %v", entries)
106+ }
107+}
108+
109+// No skills in the config: the skill goes to .mm/skills when .mm exists, and
110+// the user is told to set skillsDir.
111+func TestNoSkillsInTheConfigSaysWhatToSet(t *testing.T) {
112+ project(t, map[string]string{".mm/agent.yaml": "skillsDir: no-skills\n"})
113+ out, code := run(t, "", "--yes", "--name", "greetings", "--description", "d", "--body", "b")
114+ if code != 0 || !exists(filepath.Join(".mm", "skills", "greetings", "SKILL.md")) || !strings.Contains(out, "set skillsDir: .mm/skills") {
115+ t.Errorf("exit %d:\n%s", code, out)
116+ }
117+}
118+
119+func TestTooManyKeywordsAreWarnedAbout(t *testing.T) {
120+ project(t, nil)
121+ out, _ := run(t, "", "--yes", "--name", "x", "--description", "d", "--body", "b", "--keywords", "a,b,c,d,e,f,g,h,i")
122+ if !strings.Contains(out, "9 keywords: only the first 8 reach the catalogue") {
123+ t.Errorf("no warning:\n%s", out)
124+ }
125+}
126+
127+// The preview shows the file with the highlighted answer, and where it goes.
128+func TestPreviewFollowsTheCandidate(t *testing.T) {
129+ f := NewFlow(Place{Dir: ".mm/skills"}, Spec{}, "")
130+ got := f.Preview("name", "Vulcan Salute")
131+ if !strings.Contains(got, "# .mm/skills/vulcan-salute/SKILL.md") || !strings.Contains(got, "# Vulcan Salute") || !strings.Contains(got, "<description>") {
132+ t.Errorf("preview:\n%s", got)
133+ }
134+ if f.Output() != "" {
135+ t.Error("the preview answered the question")
136+ }
137+}
138+
139+func exists(path string) bool {
140+ _, err := os.Stat(path)
141+ return err == nil
142+}
added internal/skillcreate/place.go +102 -0
new file mode 100644
@@ -0,0 +1,102 @@
1+package skillcreate
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "slices"
9+ "strings"
10+
11+ "mm/internal/config"
12+ "mm/internal/skills"
13+)
14+
15+// noSkills is the skillsDir `mm init` writes for an agent without skills.
16+const noSkills = "no-skills"
17+
18+// Place is where a new skill goes, and why.
19+type Place struct {
20+ Dir string // the skills directory, as the agent names it
21+ Config string // the agent file it was read from; "" = none found
22+ // Note says what the user must still do for the agent to see the skill
23+ // (no config, or a config without skills); "" = nothing.
24+ Note string
25+}
26+
27+// Locate finds the skills directory the way mm does: the config named by
28+// configPath, else AGENT_CONFIG, else ./agent.yaml, else ./.mm/agent.yaml
29+// (config.Load), and its skillsDir. A relative skillsDir stays relative: mm
30+// started from this directory reads it from here. Without a config, or with
31+// skillsDir: no-skills, the skill goes to .mm/skills when a .mm folder
32+// exists, else to skills — and Note says to set skillsDir.
33+//
34+// place, err := skillcreate.Locate("")
35+// // place.Dir == ".mm/skills" for a project made by `mm init --project yes`
36+func Locate(configPath string) (Place, error) {
37+ path, err := config.Load(configPath)
38+ if err != nil {
39+ return Place{}, err
40+ }
41+ dir := config.Cfg.SkillsDir
42+ if path != "" && dir != "" && dir != noSkills {
43+ return Place{Dir: dir, Config: path}, nil
44+ }
45+ p := Place{Dir: "skills", Config: path}
46+ if info, err := os.Stat(config.ProjectDir); err == nil && info.IsDir() {
47+ p.Dir = filepath.Join(config.ProjectDir, "skills")
48+ }
49+ if path == "" {
50+ p.Note = fmt.Sprintf("no agent config here: an agent sees this skill with skillsDir: %s", p.Dir)
51+ } else {
52+ p.Note = fmt.Sprintf("%s has no skills (skillsDir: %s): set skillsDir: %s for the agent to see this skill", path, dir, p.Dir)
53+ }
54+ return p, nil
55+}
56+
57+// File is the skill's file in dir: one folder per skill.
58+//
59+// File(".mm/skills", "Vulcan Salute") // ".mm/skills/vulcan-salute/SKILL.md"
60+func File(dir, name string) string {
61+ return filepath.Join(dir, Normalize(name), "SKILL.md")
62+}
63+
64+// Existing returns the file a skill of that name already has in dir — the
65+// SKILL.md, or a flat <name>.md, which mm would serve instead — or "".
66+func Existing(dir, name string) string {
67+ for _, p := range []string{File(dir, name), filepath.Join(dir, Normalize(name)+".md")} {
68+ if _, err := os.Stat(p); err == nil {
69+ return p
70+ }
71+ }
72+ return ""
73+}
74+
75+// ErrExists is returned by Write for a skill that is already there.
76+var ErrExists = errors.New("already exists")
77+
78+// Write writes the skill in dir, creating the folders. An existing skill is
79+// replaced only when overwrite is true.
80+func Write(dir string, s Spec, overwrite bool) (string, error) {
81+ path := File(dir, s.Name)
82+ if found := Existing(dir, s.Name); found != "" && !overwrite {
83+ return "", fmt.Errorf("%s %w — nothing was written; choose another name", found, ErrExists)
84+ }
85+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
86+ return "", err
87+ }
88+ return path, os.WriteFile(path, []byte(Render(s)), 0o644)
89+}
90+
91+// Verify reads the skills directory back with mm's own loader and checks the
92+// new skill is there with its description and keywords.
93+func Verify(dir string, s Spec) error {
94+ got, ok := skills.Find(skills.List(dir), Normalize(s.Name))
95+ if !ok {
96+ return fmt.Errorf("mm does not list %s in %s", Normalize(s.Name), dir)
97+ }
98+ if got.Description != oneLine(s.Description) || !slices.Equal(got.Keywords, KeywordList(s.Keywords)) {
99+ return fmt.Errorf("mm reads %s back differently: description %q, keywords %s", got.Path, got.Description, strings.Join(got.Keywords, ", "))
100+ }
101+ return nil
102+}
new file mode 100644
@@ -0,0 +1,102 @@
1+package skillcreate
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "slices"
9+ "strings"
10+
11+ "mm/internal/config"
12+ "mm/internal/skills"
13+)
14+
15+// noSkills is the skillsDir `mm init` writes for an agent without skills.
16+const noSkills = "no-skills"
17+
18+// Place is where a new skill goes, and why.
19+type Place struct {
20+ Dir string // the skills directory, as the agent names it
21+ Config string // the agent file it was read from; "" = none found
22+ // Note says what the user must still do for the agent to see the skill
23+ // (no config, or a config without skills); "" = nothing.
24+ Note string
25+}
26+
27+// Locate finds the skills directory the way mm does: the config named by
28+// configPath, else AGENT_CONFIG, else ./agent.yaml, else ./.mm/agent.yaml
29+// (config.Load), and its skillsDir. A relative skillsDir stays relative: mm
30+// started from this directory reads it from here. Without a config, or with
31+// skillsDir: no-skills, the skill goes to .mm/skills when a .mm folder
32+// exists, else to skills — and Note says to set skillsDir.
33+//
34+// place, err := skillcreate.Locate("")
35+// // place.Dir == ".mm/skills" for a project made by `mm init --project yes`
36+func Locate(configPath string) (Place, error) {
37+ path, err := config.Load(configPath)
38+ if err != nil {
39+ return Place{}, err
40+ }
41+ dir := config.Cfg.SkillsDir
42+ if path != "" && dir != "" && dir != noSkills {
43+ return Place{Dir: dir, Config: path}, nil
44+ }
45+ p := Place{Dir: "skills", Config: path}
46+ if info, err := os.Stat(config.ProjectDir); err == nil && info.IsDir() {
47+ p.Dir = filepath.Join(config.ProjectDir, "skills")
48+ }
49+ if path == "" {
50+ p.Note = fmt.Sprintf("no agent config here: an agent sees this skill with skillsDir: %s", p.Dir)
51+ } else {
52+ p.Note = fmt.Sprintf("%s has no skills (skillsDir: %s): set skillsDir: %s for the agent to see this skill", path, dir, p.Dir)
53+ }
54+ return p, nil
55+}
56+
57+// File is the skill's file in dir: one folder per skill.
58+//
59+// File(".mm/skills", "Vulcan Salute") // ".mm/skills/vulcan-salute/SKILL.md"
60+func File(dir, name string) string {
61+ return filepath.Join(dir, Normalize(name), "SKILL.md")
62+}
63+
64+// Existing returns the file a skill of that name already has in dir — the
65+// SKILL.md, or a flat <name>.md, which mm would serve instead — or "".
66+func Existing(dir, name string) string {
67+ for _, p := range []string{File(dir, name), filepath.Join(dir, Normalize(name)+".md")} {
68+ if _, err := os.Stat(p); err == nil {
69+ return p
70+ }
71+ }
72+ return ""
73+}
74+
75+// ErrExists is returned by Write for a skill that is already there.
76+var ErrExists = errors.New("already exists")
77+
78+// Write writes the skill in dir, creating the folders. An existing skill is
79+// replaced only when overwrite is true.
80+func Write(dir string, s Spec, overwrite bool) (string, error) {
81+ path := File(dir, s.Name)
82+ if found := Existing(dir, s.Name); found != "" && !overwrite {
83+ return "", fmt.Errorf("%s %w — nothing was written; choose another name", found, ErrExists)
84+ }
85+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
86+ return "", err
87+ }
88+ return path, os.WriteFile(path, []byte(Render(s)), 0o644)
89+}
90+
91+// Verify reads the skills directory back with mm's own loader and checks the
92+// new skill is there with its description and keywords.
93+func Verify(dir string, s Spec) error {
94+ got, ok := skills.Find(skills.List(dir), Normalize(s.Name))
95+ if !ok {
96+ return fmt.Errorf("mm does not list %s in %s", Normalize(s.Name), dir)
97+ }
98+ if got.Description != oneLine(s.Description) || !slices.Equal(got.Keywords, KeywordList(s.Keywords)) {
99+ return fmt.Errorf("mm reads %s back differently: description %q, keywords %s", got.Path, got.Description, strings.Join(got.Keywords, ", "))
100+ }
101+ return nil
102+}
added internal/skillcreate/skill.go +96 -0
new file mode 100644
@@ -0,0 +1,96 @@
1+// Package skillcreate is `mm skillcreate` (ticket 206): a wizard that asks for
2+// a skill — name, description, keywords, body — and writes it as
3+// <skillsDir>/<name>/SKILL.md, where the agent's config says its skills are.
4+package skillcreate
5+
6+import (
7+ "fmt"
8+ "strings"
9+ "unicode"
10+ "unicode/utf8"
11+)
12+
13+// Spec is a skill as the user described it.
14+type Spec struct {
15+ Name string // as typed: "Vulcan Salute" or "vulcan-salute"
16+ Description string
17+ Keywords string // comma-separated, as typed
18+ Body string // the text under "## What to do"
19+}
20+
21+// Normalize is the skill's name on disk and in the catalogue: lower case, the
22+// runs of spaces turned into one dash.
23+//
24+// Normalize("Vulcan Salute") // "vulcan-salute"
25+func Normalize(name string) string {
26+ return strings.ToLower(strings.Join(strings.Fields(name), "-"))
27+}
28+
29+// CheckName refuses a name that cannot be a folder of the skills directory.
30+func CheckName(name string) error {
31+ n := Normalize(name)
32+ switch {
33+ case n == "":
34+ return fmt.Errorf("name: an answer is needed")
35+ case strings.ContainsAny(n, `/\`) || n == "." || n == ".." || strings.HasPrefix(n, "."):
36+ return fmt.Errorf("name: %q cannot be a folder name (no /, no leading dot)", n)
37+ }
38+ return nil
39+}
40+
41+// Title is the skill's level-1 title: the name as typed — or, when it was
42+// typed already normalised, turned back into words, each capitalised.
43+//
44+// Title("Vulcan Salute") // "Vulcan Salute"
45+// Title("vulcan-salute") // "Vulcan Salute"
46+func Title(name string) string {
47+ name = strings.Join(strings.Fields(name), " ")
48+ if name != Normalize(name) {
49+ return name
50+ }
51+ words := strings.Split(name, "-")
52+ for i, w := range words {
53+ if r, size := utf8.DecodeRuneInString(w); size > 0 {
54+ words[i] = string(unicode.ToUpper(r)) + w[size:]
55+ }
56+ }
57+ return strings.Join(words, " ")
58+}
59+
60+// KeywordList splits the typed keywords, dropping the empty ones.
61+//
62+// KeywordList("greet, hello,, bonjour") // [greet hello bonjour]
63+func KeywordList(keywords string) []string {
64+ var out []string
65+ for _, k := range strings.Split(keywords, ",") {
66+ if k = strings.Join(strings.Fields(k), " "); k != "" {
67+ out = append(out, k)
68+ }
69+ }
70+ return out
71+}
72+
73+// oneLine joins the lines of a front matter value: the header is read line
74+// by line, so a second line would be lost.
75+func oneLine(s string) string {
76+ return strings.Join(strings.Fields(s), " ")
77+}
78+
79+// Render is the SKILL.md text: the front matter mm's skill loader reads,
80+// the level-1 title, "## What to do", then the body.
81+//
82+// text := skillcreate.Render(skillcreate.Spec{Name: "Vulcan Salute", Description: "…", Body: "…"})
83+func Render(s Spec) string {
84+ var b strings.Builder
85+ b.WriteString("---\n")
86+ fmt.Fprintf(&b, "name: %s\n", Normalize(s.Name))
87+ fmt.Fprintf(&b, "description: %s\n", oneLine(s.Description))
88+ if kw := KeywordList(s.Keywords); len(kw) > 0 {
89+ fmt.Fprintf(&b, "keywords: %s\n", strings.Join(kw, ", "))
90+ }
91+ b.WriteString("---\n\n")
92+ fmt.Fprintf(&b, "# %s\n\n## What to do\n\n", Title(s.Name))
93+ b.WriteString(strings.Trim(s.Body, "\n"))
94+ b.WriteString("\n")
95+ return b.String()
96+}
new file mode 100644
@@ -0,0 +1,96 @@
1+// Package skillcreate is `mm skillcreate` (ticket 206): a wizard that asks for
2+// a skill — name, description, keywords, body — and writes it as
3+// <skillsDir>/<name>/SKILL.md, where the agent's config says its skills are.
4+package skillcreate
5+
6+import (
7+ "fmt"
8+ "strings"
9+ "unicode"
10+ "unicode/utf8"
11+)
12+
13+// Spec is a skill as the user described it.
14+type Spec struct {
15+ Name string // as typed: "Vulcan Salute" or "vulcan-salute"
16+ Description string
17+ Keywords string // comma-separated, as typed
18+ Body string // the text under "## What to do"
19+}
20+
21+// Normalize is the skill's name on disk and in the catalogue: lower case, the
22+// runs of spaces turned into one dash.
23+//
24+// Normalize("Vulcan Salute") // "vulcan-salute"
25+func Normalize(name string) string {
26+ return strings.ToLower(strings.Join(strings.Fields(name), "-"))
27+}
28+
29+// CheckName refuses a name that cannot be a folder of the skills directory.
30+func CheckName(name string) error {
31+ n := Normalize(name)
32+ switch {
33+ case n == "":
34+ return fmt.Errorf("name: an answer is needed")
35+ case strings.ContainsAny(n, `/\`) || n == "." || n == ".." || strings.HasPrefix(n, "."):
36+ return fmt.Errorf("name: %q cannot be a folder name (no /, no leading dot)", n)
37+ }
38+ return nil
39+}
40+
41+// Title is the skill's level-1 title: the name as typed — or, when it was
42+// typed already normalised, turned back into words, each capitalised.
43+//
44+// Title("Vulcan Salute") // "Vulcan Salute"
45+// Title("vulcan-salute") // "Vulcan Salute"
46+func Title(name string) string {
47+ name = strings.Join(strings.Fields(name), " ")
48+ if name != Normalize(name) {
49+ return name
50+ }
51+ words := strings.Split(name, "-")
52+ for i, w := range words {
53+ if r, size := utf8.DecodeRuneInString(w); size > 0 {
54+ words[i] = string(unicode.ToUpper(r)) + w[size:]
55+ }
56+ }
57+ return strings.Join(words, " ")
58+}
59+
60+// KeywordList splits the typed keywords, dropping the empty ones.
61+//
62+// KeywordList("greet, hello,, bonjour") // [greet hello bonjour]
63+func KeywordList(keywords string) []string {
64+ var out []string
65+ for _, k := range strings.Split(keywords, ",") {
66+ if k = strings.Join(strings.Fields(k), " "); k != "" {
67+ out = append(out, k)
68+ }
69+ }
70+ return out
71+}
72+
73+// oneLine joins the lines of a front matter value: the header is read line
74+// by line, so a second line would be lost.
75+func oneLine(s string) string {
76+ return strings.Join(strings.Fields(s), " ")
77+}
78+
79+// Render is the SKILL.md text: the front matter mm's skill loader reads,
80+// the level-1 title, "## What to do", then the body.
81+//
82+// text := skillcreate.Render(skillcreate.Spec{Name: "Vulcan Salute", Description: "…", Body: "…"})
83+func Render(s Spec) string {
84+ var b strings.Builder
85+ b.WriteString("---\n")
86+ fmt.Fprintf(&b, "name: %s\n", Normalize(s.Name))
87+ fmt.Fprintf(&b, "description: %s\n", oneLine(s.Description))
88+ if kw := KeywordList(s.Keywords); len(kw) > 0 {
89+ fmt.Fprintf(&b, "keywords: %s\n", strings.Join(kw, ", "))
90+ }
91+ b.WriteString("---\n\n")
92+ fmt.Fprintf(&b, "# %s\n\n## What to do\n\n", Title(s.Name))
93+ b.WriteString(strings.Trim(s.Body, "\n"))
94+ b.WriteString("\n")
95+ return b.String()
96+}
added internal/skillcreate/skill_test.go +174 -0
new file mode 100644
@@ -0,0 +1,174 @@
1+package skillcreate
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/config"
11+)
12+
13+func TestNormalize(t *testing.T) {
14+ for typed, want := range map[string]string{
15+ "Vulcan Salute": "vulcan-salute",
16+ " What time is IT ": "what-time-is-it",
17+ "greetings": "greetings",
18+ "vulcan-salute": "vulcan-salute",
19+ } {
20+ if got := Normalize(typed); got != want {
21+ t.Errorf("Normalize(%q) = %q, want %q", typed, got, want)
22+ }
23+ }
24+}
25+
26+func TestCheckNameRefusesWhatCannotBeAFolder(t *testing.T) {
27+ for _, bad := range []string{"", " ", "a/b", `a\b`, "..", ".hidden"} {
28+ if CheckName(bad) == nil {
29+ t.Errorf("CheckName(%q) accepted", bad)
30+ }
31+ }
32+ if err := CheckName("Vulcan Salute"); err != nil {
33+ t.Errorf("CheckName(Vulcan Salute) = %v", err)
34+ }
35+}
36+
37+// The title is the name as typed; a name typed already normalised is turned
38+// back into words (ticket 206).
39+func TestTitle(t *testing.T) {
40+ for typed, want := range map[string]string{
41+ "Vulcan Salute": "Vulcan Salute",
42+ " Vulcan Salute": "Vulcan Salute",
43+ "vulcan-salute": "Vulcan Salute",
44+ "greetings": "Greetings",
45+ "what-time-is-it": "What Time Is It",
46+ "élan-vital": "Élan Vital",
47+ } {
48+ if got := Title(typed); got != want {
49+ t.Errorf("Title(%q) = %q, want %q", typed, got, want)
50+ }
51+ }
52+}
53+
54+func TestRender(t *testing.T) {
55+ got := Render(Spec{
56+ Name: "Vulcan Salute",
57+ Description: "Greet\nthe Vulcan way.",
58+ Keywords: "vulcan, , salute,spock ",
59+ Body: "\nRaise your hand.\n\nSay: live long and prosper.\n\n",
60+ })
61+ want := `---
62+name: vulcan-salute
63+description: Greet the Vulcan way.
64+keywords: vulcan, salute, spock
65+---
66+
67+# Vulcan Salute
68+
69+## What to do
70+
71+Raise your hand.
72+
73+Say: live long and prosper.
74+`
75+ if got != want {
76+ t.Errorf("Render =\n%s\nwant\n%s", got, want)
77+ }
78+ if strings.Contains(Render(Spec{Name: "x", Description: "d", Body: "b"}), "keywords:") {
79+ t.Error("a skill without keywords got a keywords line")
80+ }
81+}
82+
83+func TestWriteThenVerifyWithMMsLoader(t *testing.T) {
84+ dir := filepath.Join(t.TempDir(), ".mm", "skills")
85+ s := Spec{Name: "Vulcan Salute", Description: "Greet the Vulcan way.", Keywords: "vulcan, salute", Body: "Raise your hand."}
86+ path, err := Write(dir, s, false)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if want := filepath.Join(dir, "vulcan-salute", "SKILL.md"); path != want {
91+ t.Errorf("path = %q, want %q", path, want)
92+ }
93+ if err := Verify(dir, s); err != nil {
94+ t.Error(err)
95+ }
96+ if _, err := Write(dir, s, false); !errors.Is(err, ErrExists) {
97+ t.Errorf("second Write = %v, want ErrExists", err)
98+ }
99+ s.Description = "Changed."
100+ if _, err := Write(dir, s, true); err != nil {
101+ t.Fatal(err)
102+ }
103+ if err := Verify(dir, s); err != nil {
104+ t.Errorf("after overwrite: %v", err)
105+ }
106+}
107+
108+// A flat <name>.md is the same skill for mm: it counts as existing.
109+func TestExistingSeesAFlatSkillToo(t *testing.T) {
110+ dir := t.TempDir()
111+ if err := os.WriteFile(filepath.Join(dir, "greetings.md"), []byte("x"), 0o644); err != nil {
112+ t.Fatal(err)
113+ }
114+ if got := Existing(dir, "Greetings"); got != filepath.Join(dir, "greetings.md") {
115+ t.Errorf("Existing = %q", got)
116+ }
117+}
118+
119+// project moves the test into a fresh directory with the given files and
120+// restores the configuration after.
121+func project(t *testing.T, files map[string]string) {
122+ t.Helper()
123+ saved := config.Cfg
124+ t.Cleanup(func() { config.Cfg = saved })
125+ t.Setenv("AGENT_CONFIG", "")
126+ dir := t.TempDir()
127+ for name, content := range files {
128+ path := filepath.Join(dir, name)
129+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ }
136+ t.Chdir(dir)
137+}
138+
139+func TestLocate(t *testing.T) {
140+ cases := []struct {
141+ name string
142+ files map[string]string
143+ wantDir string
144+ wantNote bool
145+ }{
146+ {"config in .mm", map[string]string{".mm/agent.yaml": "skillsDir: .mm/skills\n"}, ".mm/skills", false},
147+ {"config at the root", map[string]string{"agent.yaml": "skillsDir: my-skills\n"}, "my-skills", false},
148+ {"config without the key: mm's default", map[string]string{"agent.yaml": "model: x\n"}, "skills", false},
149+ {"no-skills, .mm present", map[string]string{".mm/agent.yaml": "skillsDir: no-skills\n"}, ".mm/skills", true},
150+ {"no-skills, no .mm", map[string]string{"agent.yaml": "skillsDir: no-skills\n"}, "skills", true},
151+ {"no config, .mm present", map[string]string{".mm/sessions/x": ""}, ".mm/skills", true},
152+ {"no config at all", nil, "skills", true},
153+ }
154+ for _, c := range cases {
155+ t.Run(c.name, func(t *testing.T) {
156+ project(t, c.files)
157+ p, err := Locate("")
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ if p.Dir != c.wantDir || (p.Note != "") != c.wantNote {
162+ t.Errorf("Locate = %+v, want dir %q, note %v", p, c.wantDir, c.wantNote)
163+ }
164+ })
165+ }
166+}
167+
168+func TestLocateFollowsANamedConfig(t *testing.T) {
169+ project(t, map[string]string{"agents/bob.yaml": "skillsDir: bob-skills\n", "agent.yaml": "skillsDir: other\n"})
170+ p, err := Locate("agents/bob.yaml")
171+ if err != nil || p.Dir != "bob-skills" || p.Config != "agents/bob.yaml" {
172+ t.Errorf("Locate(agents/bob.yaml) = %+v, %v", p, err)
173+ }
174+}
new file mode 100644
@@ -0,0 +1,174 @@
1+package skillcreate
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/config"
11+)
12+
13+func TestNormalize(t *testing.T) {
14+ for typed, want := range map[string]string{
15+ "Vulcan Salute": "vulcan-salute",
16+ " What time is IT ": "what-time-is-it",
17+ "greetings": "greetings",
18+ "vulcan-salute": "vulcan-salute",
19+ } {
20+ if got := Normalize(typed); got != want {
21+ t.Errorf("Normalize(%q) = %q, want %q", typed, got, want)
22+ }
23+ }
24+}
25+
26+func TestCheckNameRefusesWhatCannotBeAFolder(t *testing.T) {
27+ for _, bad := range []string{"", " ", "a/b", `a\b`, "..", ".hidden"} {
28+ if CheckName(bad) == nil {
29+ t.Errorf("CheckName(%q) accepted", bad)
30+ }
31+ }
32+ if err := CheckName("Vulcan Salute"); err != nil {
33+ t.Errorf("CheckName(Vulcan Salute) = %v", err)
34+ }
35+}
36+
37+// The title is the name as typed; a name typed already normalised is turned
38+// back into words (ticket 206).
39+func TestTitle(t *testing.T) {
40+ for typed, want := range map[string]string{
41+ "Vulcan Salute": "Vulcan Salute",
42+ " Vulcan Salute": "Vulcan Salute",
43+ "vulcan-salute": "Vulcan Salute",
44+ "greetings": "Greetings",
45+ "what-time-is-it": "What Time Is It",
46+ "élan-vital": "Élan Vital",
47+ } {
48+ if got := Title(typed); got != want {
49+ t.Errorf("Title(%q) = %q, want %q", typed, got, want)
50+ }
51+ }
52+}
53+
54+func TestRender(t *testing.T) {
55+ got := Render(Spec{
56+ Name: "Vulcan Salute",
57+ Description: "Greet\nthe Vulcan way.",
58+ Keywords: "vulcan, , salute,spock ",
59+ Body: "\nRaise your hand.\n\nSay: live long and prosper.\n\n",
60+ })
61+ want := `---
62+name: vulcan-salute
63+description: Greet the Vulcan way.
64+keywords: vulcan, salute, spock
65+---
66+
67+# Vulcan Salute
68+
69+## What to do
70+
71+Raise your hand.
72+
73+Say: live long and prosper.
74+`
75+ if got != want {
76+ t.Errorf("Render =\n%s\nwant\n%s", got, want)
77+ }
78+ if strings.Contains(Render(Spec{Name: "x", Description: "d", Body: "b"}), "keywords:") {
79+ t.Error("a skill without keywords got a keywords line")
80+ }
81+}
82+
83+func TestWriteThenVerifyWithMMsLoader(t *testing.T) {
84+ dir := filepath.Join(t.TempDir(), ".mm", "skills")
85+ s := Spec{Name: "Vulcan Salute", Description: "Greet the Vulcan way.", Keywords: "vulcan, salute", Body: "Raise your hand."}
86+ path, err := Write(dir, s, false)
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if want := filepath.Join(dir, "vulcan-salute", "SKILL.md"); path != want {
91+ t.Errorf("path = %q, want %q", path, want)
92+ }
93+ if err := Verify(dir, s); err != nil {
94+ t.Error(err)
95+ }
96+ if _, err := Write(dir, s, false); !errors.Is(err, ErrExists) {
97+ t.Errorf("second Write = %v, want ErrExists", err)
98+ }
99+ s.Description = "Changed."
100+ if _, err := Write(dir, s, true); err != nil {
101+ t.Fatal(err)
102+ }
103+ if err := Verify(dir, s); err != nil {
104+ t.Errorf("after overwrite: %v", err)
105+ }
106+}
107+
108+// A flat <name>.md is the same skill for mm: it counts as existing.
109+func TestExistingSeesAFlatSkillToo(t *testing.T) {
110+ dir := t.TempDir()
111+ if err := os.WriteFile(filepath.Join(dir, "greetings.md"), []byte("x"), 0o644); err != nil {
112+ t.Fatal(err)
113+ }
114+ if got := Existing(dir, "Greetings"); got != filepath.Join(dir, "greetings.md") {
115+ t.Errorf("Existing = %q", got)
116+ }
117+}
118+
119+// project moves the test into a fresh directory with the given files and
120+// restores the configuration after.
121+func project(t *testing.T, files map[string]string) {
122+ t.Helper()
123+ saved := config.Cfg
124+ t.Cleanup(func() { config.Cfg = saved })
125+ t.Setenv("AGENT_CONFIG", "")
126+ dir := t.TempDir()
127+ for name, content := range files {
128+ path := filepath.Join(dir, name)
129+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
130+ t.Fatal(err)
131+ }
132+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ }
136+ t.Chdir(dir)
137+}
138+
139+func TestLocate(t *testing.T) {
140+ cases := []struct {
141+ name string
142+ files map[string]string
143+ wantDir string
144+ wantNote bool
145+ }{
146+ {"config in .mm", map[string]string{".mm/agent.yaml": "skillsDir: .mm/skills\n"}, ".mm/skills", false},
147+ {"config at the root", map[string]string{"agent.yaml": "skillsDir: my-skills\n"}, "my-skills", false},
148+ {"config without the key: mm's default", map[string]string{"agent.yaml": "model: x\n"}, "skills", false},
149+ {"no-skills, .mm present", map[string]string{".mm/agent.yaml": "skillsDir: no-skills\n"}, ".mm/skills", true},
150+ {"no-skills, no .mm", map[string]string{"agent.yaml": "skillsDir: no-skills\n"}, "skills", true},
151+ {"no config, .mm present", map[string]string{".mm/sessions/x": ""}, ".mm/skills", true},
152+ {"no config at all", nil, "skills", true},
153+ }
154+ for _, c := range cases {
155+ t.Run(c.name, func(t *testing.T) {
156+ project(t, c.files)
157+ p, err := Locate("")
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ if p.Dir != c.wantDir || (p.Note != "") != c.wantNote {
162+ t.Errorf("Locate = %+v, want dir %q, note %v", p, c.wantDir, c.wantNote)
163+ }
164+ })
165+ }
166+}
167+
168+func TestLocateFollowsANamedConfig(t *testing.T) {
169+ project(t, map[string]string{"agents/bob.yaml": "skillsDir: bob-skills\n", "agent.yaml": "skillsDir: other\n"})
170+ p, err := Locate("agents/bob.yaml")
171+ if err != nil || p.Dir != "bob-skills" || p.Config != "agents/bob.yaml" {
172+ t.Errorf("Locate(agents/bob.yaml) = %+v, %v", p, err)
173+ }
174+}
modified internal/skills/skills.go +4 -0
@@ -202,6 +202,10 @@ func Catalogue(list []Skill) string {
202202 // words per skill helps a small model match, thirty would crowd out the task.
203203 const maxKeywords = 8
204204
205+// MaxKeywords is how many keywords of one skill reach the catalogue; the
206+// rest are ignored (mm skillcreate warns about them).
207+const MaxKeywords = maxKeywords
208+
205209 // Names lists the skill names, for an error message that helps the model
206210 // recover from a typo.
207211 func Names(list []Skill) []string {
@@ -202,6 +202,10 @@ func Catalogue(list []Skill) string {
202 // words per skill helps a small model match, thirty would crowd out the task.202 // words per skill helps a small model match, thirty would crowd out the task.
203 const maxKeywords = 8203 const maxKeywords = 8
204 204
205+// MaxKeywords is how many keywords of one skill reach the catalogue; the
206+// rest are ignored (mm skillcreate warns about them).
207+const MaxKeywords = maxKeywords
208+
205 // Names lists the skill names, for an error message that helps the model209 // Names lists the skill names, for an error message that helps the model
206 // recover from a typo.210 // recover from a typo.
207 func Names(list []Skill) []string {211 func Names(list []Skill) []string {
modified internal/tui/ask.go +1 -1
@@ -44,7 +44,7 @@ func (r *runner) applyAsk(res outcome) {
4444 (&sink{r: r}).Spoken()
4545 switch {
4646 case res.err != nil && res.cancelled:
47- r.t.Notice("🛑 Ask aborted — nothing kept.")
47+ r.t.Notice("🔴 Ask aborted — nothing kept.")
4848 case res.err != nil:
4949 r.t.Error("[error: " + res.err.Error() + "]")
5050 r.record(store.NewFailure(r.messages, store.FailAsk, res.err.Error(), res.err))
@@ -44,7 +44,7 @@ func (r *runner) applyAsk(res outcome) {
44 (&sink{r: r}).Spoken()44 (&sink{r: r}).Spoken()
45 switch {45 switch {
46 case res.err != nil && res.cancelled:46 case res.err != nil && res.cancelled:
47- r.t.Notice("🛑 Ask aborted — nothing kept.")47+ r.t.Notice("🔴 Ask aborted — nothing kept.")
48 case res.err != nil:48 case res.err != nil:
49 r.t.Error("[error: " + res.err.Error() + "]")49 r.t.Error("[error: " + res.err.Error() + "]")
50 r.record(store.NewFailure(r.messages, store.FailAsk, res.err.Error(), res.err))50 r.record(store.NewFailure(r.messages, store.FailAsk, res.err.Error(), res.err))
modified internal/tui/commands.go +1 -1
@@ -67,7 +67,7 @@ func (r *runner) abort() {
6767 case !r.isBusy():
6868 r.t.Notice("⚠️ No generation in progress.")
6969 case r.cancelTurn():
70- r.t.Notice("🛑 Aborting…")
70+ r.t.Notice("🔴 Aborting…")
7171 default:
7272 r.t.Notice("⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.")
7373 }
@@ -67,7 +67,7 @@ func (r *runner) abort() {
67 case !r.isBusy():67 case !r.isBusy():
68 r.t.Notice("⚠️ No generation in progress.")68 r.t.Notice("⚠️ No generation in progress.")
69 case r.cancelTurn():69 case r.cancelTurn():
70- r.t.Notice("🛑 Aborting…")70+ r.t.Notice("🔴 Aborting…")
71 default:71 default:
72 r.t.Notice("⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.")72 r.t.Notice("⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.")
73 }73 }
modified internal/tui/initwin.go +152 -23
@@ -7,6 +7,7 @@ import (
77
88 "mm/internal/agentinit"
99 "mm/internal/elicit"
10+ "mm/internal/skillcreate"
1011
1112 "github.com/gdamore/tcell/v2"
1213 )
@@ -30,10 +31,23 @@ func RunInit(_ context.Context, flow *agentinit.Flow) (bool, error) {
3031 return runInit(screen, flow), nil
3132 }
3233
34+// wizardFlow is what the window drives: mm init's agentinit.Flow and mm
35+// skillcreate's skillcreate.Flow (ticket 206) — questions, answers, and the
36+// file as it would be written.
37+type wizardFlow interface {
38+ Steps() []agentinit.Step
39+ Step(key string) agentinit.Step
40+ Set(key, value string) error
41+ Unset(key string)
42+ Preview(key, candidate string) string
43+}
44+
3345 // initWin is the wizard's window.
3446 type initWin struct {
3547 screen tcell.Screen
36- flow *agentinit.Flow
48+ flow wizardFlow
49+ heading func() string // the top line
50+ target func() string // the file the confirmation names
3751 cursor int // the step being answered, in flow.Steps()
3852 sel map[string]int // the highlighted option, per step
3953 text map[string]*field // what is typed for a Text step, with its caret
@@ -49,11 +63,67 @@ type initWin struct {
4963 const previewMinWidth = 100
5064
5165 func runInit(screen tcell.Screen, flow *agentinit.Flow) bool {
52- w := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string]*field{}}
66+ return initWindow(screen, flow).run()
67+}
68+
69+// initWindow is mm init's window: its heading names the server and model.
70+func initWindow(screen tcell.Screen, flow *agentinit.Flow) *initWin {
71+ heading := func() string {
72+ title := " mm init — new agent"
73+ c, _, _ := flow.Result()
74+ if c.Provider != "" {
75+ title += " · " + c.Provider
76+ }
77+ if c.Model != "" {
78+ title += " · " + shortModel(c.Model)
79+ }
80+ return title
81+ }
82+ target := func() string { _, _, output := flow.Result(); return output }
83+ return newWizardWin(screen, flow, heading, target)
84+}
85+
86+// RunSkillCreate is `mm skillcreate` in a window (ticket 206): the same
87+// window as mm init, the SKILL.md as it would be written on the right. The
88+// skill's text is several lines: Enter adds one, Ctrl-D answers.
89+//
90+// skillcreate.Main(args, os.Stdin, os.Stdout, skillcreate.Options{Window: tui.RunSkillCreate, Terminal: tui.Terminal})
91+func RunSkillCreate(_ context.Context, flow *skillcreate.Flow) (bool, error) {
92+ screen, err := tcell.NewScreen()
93+ if err != nil {
94+ return false, err
95+ }
96+ if err := screen.Init(); err != nil {
97+ return false, err
98+ }
99+ defer screen.Fini()
100+ return runSkillCreate(screen, flow), nil
101+}
102+
103+func runSkillCreate(screen tcell.Screen, flow *skillcreate.Flow) bool {
104+ return skillWindow(screen, flow).run()
105+}
106+
107+// skillWindow is mm skillcreate's window: its heading names the skills
108+// directory.
109+func skillWindow(screen tcell.Screen, flow *skillcreate.Flow) *initWin {
110+ heading := func() string { return " mm skillcreate — new skill · " + flow.Place().Dir }
111+ return newWizardWin(screen, flow, heading, flow.Output)
112+}
113+
114+// newWizardWin is the window on its first unanswered question.
115+func newWizardWin(screen tcell.Screen, flow wizardFlow, heading, target func() string) *initWin {
116+ w := &initWin{screen: screen, flow: flow, heading: heading, target: target, sel: map[string]int{}, text: map[string]*field{}}
53117 w.toFirstPending()
118+ return w
119+}
120+
121+// run runs the window until the file is to be written (true) or the user
122+// quits (false).
123+func (w *initWin) run() bool {
54124 for w.result == 0 {
55125 w.draw()
56- ev := screen.PollEvent()
126+ ev := w.screen.PollEvent()
57127 if ev == nil {
58128 return false
59129 }
@@ -61,7 +131,7 @@ func runInit(screen tcell.Screen, flow *agentinit.Flow) bool {
61131 case *tcell.EventKey:
62132 w.handleKey(e)
63133 case *tcell.EventResize:
64- screen.Sync()
134+ w.screen.Sync()
65135 }
66136 }
67137 return w.result == 1
@@ -103,6 +173,9 @@ func (w *initWin) initSelection(st agentinit.Step) {
103173 }
104174 }
105175 if st.Kind == elicit.Text {
176+ if st.Optional && st.Value == "" {
177+ want = "" // empty means none; the default only stands for it
178+ }
106179 if _, ok := w.text[st.Key]; !ok {
107180 w.text[st.Key] = newField(want)
108181 }
@@ -170,16 +243,23 @@ func (w *initWin) handleKey(ev *tcell.EventKey) {
170243 }
171244
172245 func (w *initWin) textKey(st agentinit.Step, ev *tcell.EventKey) {
173- if ev.Key() == tcell.KeyEnter {
174- w.answer(st)
175- return
176- }
177246 f := w.text[st.Key]
178247 if f == nil {
179248 f = newField("")
180249 w.text[st.Key] = f
181250 }
182- f.key(ev)
251+ switch {
252+ case st.Multiline && ev.Key() == tcell.KeyCtrlD:
253+ w.answer(st)
254+ case st.Multiline && ev.Key() == tcell.KeyEnter:
255+ f.insert('\n')
256+ case st.Multiline && (ev.Key() == tcell.KeyUp || ev.Key() == tcell.KeyDown):
257+ f.moveLine(map[tcell.Key]int{tcell.KeyUp: -1, tcell.KeyDown: 1}[ev.Key()])
258+ case ev.Key() == tcell.KeyEnter:
259+ w.answer(st)
260+ default:
261+ f.key(ev)
262+ }
183263 }
184264
185265 // field is a one-line text being typed — an endpoint, a variable name, a
@@ -202,12 +282,44 @@ func newField(s string) *field {
202282
203283 func (f *field) String() string { return string(f.runes) }
204284
285+// insert puts r at the caret.
286+func (f *field) insert(r rune) {
287+ f.runes = append(f.runes[:f.caret], append([]rune{r}, f.runes[f.caret:]...)...)
288+ f.caret++
289+}
290+
291+// lineAt is the line of the caret (0-based) and the caret's column in it.
292+func (f *field) lineAt() (line, col int) {
293+ for _, r := range f.runes[:f.caret] {
294+ col++
295+ if r == '\n' {
296+ line, col = line+1, 0
297+ }
298+ }
299+ return line, col
300+}
301+
302+// moveLine moves the caret to the line above (-1) or below (+1), at the
303+// same column or the end of that line when it is shorter.
304+func (f *field) moveLine(delta int) {
305+ line, col := f.lineAt()
306+ lines := strings.Split(string(f.runes), "\n")
307+ target := line + delta
308+ if target < 0 || target >= len(lines) {
309+ return
310+ }
311+ caret := 0
312+ for i := range target {
313+ caret += len([]rune(lines[i])) + 1
314+ }
315+ f.caret = caret + min(col, len([]rune(lines[target])))
316+}
317+
205318 // key edits the text; it reports whether the key was an editing key.
206319 func (f *field) key(ev *tcell.EventKey) bool {
207320 switch ev.Key() {
208321 case tcell.KeyRune:
209- f.runes = append(f.runes[:f.caret], append([]rune{ev.Rune()}, f.runes[f.caret:]...)...)
210- f.caret++
322+ f.insert(ev.Rune())
211323 case tcell.KeyBackspace, tcell.KeyBackspace2:
212324 if f.caret > 0 {
213325 f.runes = append(f.runes[:f.caret-1], f.runes[f.caret:]...)
@@ -290,15 +402,7 @@ func (w *initWin) draw() {
290402
291403 func (w *initWin) drawHeader(p painter) {
292404 p.fill(' ', stBar)
293- title := " mm init — new agent"
294- c, _, _ := w.flow.Result()
295- if c.Provider != "" {
296- title += " · " + c.Provider
297- }
298- if c.Model != "" {
299- title += " · " + shortModel(c.Model)
300- }
301- p.textLimited(0, 0, p.width(), title, stBar)
405+ p.textLimited(0, 0, p.width(), w.heading(), stBar)
302406 }
303407
304408 func (w *initWin) drawStatus(p painter) {
@@ -307,6 +411,8 @@ func (w *initWin) drawStatus(p painter) {
307411 switch {
308412 case w.confirm:
309413 keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit "
414+ case w.current().Multiline:
415+ keys = " type · ←→↑↓ move · Enter new line · Ctrl-D done · Esc back · PgUp/PgDn preview · Ctrl-C quit "
310416 case w.current().Kind == elicit.Text:
311417 keys = " type · ←→ move · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit "
312418 }
@@ -359,8 +465,7 @@ func (w *initWin) drawSteps(p painter) {
359465
360466 func (w *initWin) drawQuestion(p painter) {
361467 if w.confirm {
362- _, _, output := w.flow.Result()
363- p.textLimited(1, 0, p.width()-2, "Ready to write "+output, stLabel)
468+ p.textLimited(1, 0, p.width()-2, "Ready to write "+w.target(), stLabel)
364469 p.textLimited(1, 2, p.width()-2, "Every question is answered: the file", stBody)
365470 p.textLimited(1, 3, p.width()-2, "on the right is what will be written.", stBody)
366471 p.textLimited(1, 5, p.width()-2, "Enter writes it; Esc goes back.", stNotice)
@@ -368,6 +473,10 @@ func (w *initWin) drawQuestion(p painter) {
368473 }
369474 st := w.current()
370475 p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel)
476+ if st.Multiline {
477+ w.drawLines(p, st)
478+ return
479+ }
371480 if st.Kind == elicit.Text {
372481 f := w.text[st.Key]
373482 if f == nil {
@@ -377,7 +486,10 @@ func (w *initWin) drawQuestion(p painter) {
377486 x := p.text(1, 2, "› "+string(f.runes[:f.caret]), stBody)
378487 p.text(x, 2, string(f.runes[f.caret:]), stBody)
379488 p.showCursor(x, 2)
380- if st.Default != "" {
489+ switch {
490+ case st.Optional:
491+ p.textLimited(1, 4, p.width()-2, "leave empty for none", stNotice)
492+ case st.Default != "":
381493 p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice)
382494 }
383495 return
@@ -409,6 +521,23 @@ func (w *initWin) drawQuestion(p painter) {
409521 }
410522 }
411523
524+// drawLines draws a multi-line answer (mm skillcreate's text), scrolled so
525+// the caret's line is visible; a line wider than the column is cut.
526+func (w *initWin) drawLines(p painter, st agentinit.Step) {
527+ f := w.text[st.Key]
528+ if f == nil {
529+ f = newField("")
530+ }
531+ lines := strings.Split(f.String(), "\n")
532+ line, col := f.lineAt()
533+ height := max(p.height()-3, 1)
534+ top := max(line-height+1, 0)
535+ for i := 0; i < height && top+i < len(lines); i++ {
536+ p.textLimited(1, 2+i, p.width()-2, "› "+lines[top+i], stBody)
537+ }
538+ p.showCursor(min(3+col, p.width()-2), 2+line-top)
539+}
540+
412541 // optionLabel is how an option is listed. A model is listed by the
413542 // header's short name: its identifier is an address of seventy characters,
414543 // cut by the column before the part that tells two models apart.
@@ -7,6 +7,7 @@ import (
7 7
8 "mm/internal/agentinit"8 "mm/internal/agentinit"
9 "mm/internal/elicit"9 "mm/internal/elicit"
10+ "mm/internal/skillcreate"
10 11
11 "github.com/gdamore/tcell/v2"12 "github.com/gdamore/tcell/v2"
12 )13 )
@@ -30,10 +31,23 @@ func RunInit(_ context.Context, flow *agentinit.Flow) (bool, error) {
30 return runInit(screen, flow), nil31 return runInit(screen, flow), nil
31 }32 }
32 33
34+// wizardFlow is what the window drives: mm init's agentinit.Flow and mm
35+// skillcreate's skillcreate.Flow (ticket 206) — questions, answers, and the
36+// file as it would be written.
37+type wizardFlow interface {
38+ Steps() []agentinit.Step
39+ Step(key string) agentinit.Step
40+ Set(key, value string) error
41+ Unset(key string)
42+ Preview(key, candidate string) string
43+}
44+
33 // initWin is the wizard's window.45 // initWin is the wizard's window.
34 type initWin struct {46 type initWin struct {
35 screen tcell.Screen47 screen tcell.Screen
36- flow *agentinit.Flow48+ flow wizardFlow
49+ heading func() string // the top line
50+ target func() string // the file the confirmation names
37 cursor int // the step being answered, in flow.Steps()51 cursor int // the step being answered, in flow.Steps()
38 sel map[string]int // the highlighted option, per step52 sel map[string]int // the highlighted option, per step
39 text map[string]*field // what is typed for a Text step, with its caret53 text map[string]*field // what is typed for a Text step, with its caret
@@ -49,11 +63,67 @@ type initWin struct {
49 const previewMinWidth = 10063 const previewMinWidth = 100
50 64
51 func runInit(screen tcell.Screen, flow *agentinit.Flow) bool {65 func runInit(screen tcell.Screen, flow *agentinit.Flow) bool {
52- w := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string]*field{}}66+ return initWindow(screen, flow).run()
67+}
68+
69+// initWindow is mm init's window: its heading names the server and model.
70+func initWindow(screen tcell.Screen, flow *agentinit.Flow) *initWin {
71+ heading := func() string {
72+ title := " mm init — new agent"
73+ c, _, _ := flow.Result()
74+ if c.Provider != "" {
75+ title += " · " + c.Provider
76+ }
77+ if c.Model != "" {
78+ title += " · " + shortModel(c.Model)
79+ }
80+ return title
81+ }
82+ target := func() string { _, _, output := flow.Result(); return output }
83+ return newWizardWin(screen, flow, heading, target)
84+}
85+
86+// RunSkillCreate is `mm skillcreate` in a window (ticket 206): the same
87+// window as mm init, the SKILL.md as it would be written on the right. The
88+// skill's text is several lines: Enter adds one, Ctrl-D answers.
89+//
90+// skillcreate.Main(args, os.Stdin, os.Stdout, skillcreate.Options{Window: tui.RunSkillCreate, Terminal: tui.Terminal})
91+func RunSkillCreate(_ context.Context, flow *skillcreate.Flow) (bool, error) {
92+ screen, err := tcell.NewScreen()
93+ if err != nil {
94+ return false, err
95+ }
96+ if err := screen.Init(); err != nil {
97+ return false, err
98+ }
99+ defer screen.Fini()
100+ return runSkillCreate(screen, flow), nil
101+}
102+
103+func runSkillCreate(screen tcell.Screen, flow *skillcreate.Flow) bool {
104+ return skillWindow(screen, flow).run()
105+}
106+
107+// skillWindow is mm skillcreate's window: its heading names the skills
108+// directory.
109+func skillWindow(screen tcell.Screen, flow *skillcreate.Flow) *initWin {
110+ heading := func() string { return " mm skillcreate — new skill · " + flow.Place().Dir }
111+ return newWizardWin(screen, flow, heading, flow.Output)
112+}
113+
114+// newWizardWin is the window on its first unanswered question.
115+func newWizardWin(screen tcell.Screen, flow wizardFlow, heading, target func() string) *initWin {
116+ w := &initWin{screen: screen, flow: flow, heading: heading, target: target, sel: map[string]int{}, text: map[string]*field{}}
53 w.toFirstPending()117 w.toFirstPending()
118+ return w
119+}
120+
121+// run runs the window until the file is to be written (true) or the user
122+// quits (false).
123+func (w *initWin) run() bool {
54 for w.result == 0 {124 for w.result == 0 {
55 w.draw()125 w.draw()
56- ev := screen.PollEvent()126+ ev := w.screen.PollEvent()
57 if ev == nil {127 if ev == nil {
58 return false128 return false
59 }129 }
@@ -61,7 +131,7 @@ func runInit(screen tcell.Screen, flow *agentinit.Flow) bool {
61 case *tcell.EventKey:131 case *tcell.EventKey:
62 w.handleKey(e)132 w.handleKey(e)
63 case *tcell.EventResize:133 case *tcell.EventResize:
64- screen.Sync()134+ w.screen.Sync()
65 }135 }
66 }136 }
67 return w.result == 1137 return w.result == 1
@@ -103,6 +173,9 @@ func (w *initWin) initSelection(st agentinit.Step) {
103 }173 }
104 }174 }
105 if st.Kind == elicit.Text {175 if st.Kind == elicit.Text {
176+ if st.Optional && st.Value == "" {
177+ want = "" // empty means none; the default only stands for it
178+ }
106 if _, ok := w.text[st.Key]; !ok {179 if _, ok := w.text[st.Key]; !ok {
107 w.text[st.Key] = newField(want)180 w.text[st.Key] = newField(want)
108 }181 }
@@ -170,16 +243,23 @@ func (w *initWin) handleKey(ev *tcell.EventKey) {
170 }243 }
171 244
172 func (w *initWin) textKey(st agentinit.Step, ev *tcell.EventKey) {245 func (w *initWin) textKey(st agentinit.Step, ev *tcell.EventKey) {
173- if ev.Key() == tcell.KeyEnter {
174- w.answer(st)
175- return
176- }
177 f := w.text[st.Key]246 f := w.text[st.Key]
178 if f == nil {247 if f == nil {
179 f = newField("")248 f = newField("")
180 w.text[st.Key] = f249 w.text[st.Key] = f
181 }250 }
182- f.key(ev)251+ switch {
252+ case st.Multiline && ev.Key() == tcell.KeyCtrlD:
253+ w.answer(st)
254+ case st.Multiline && ev.Key() == tcell.KeyEnter:
255+ f.insert('\n')
256+ case st.Multiline && (ev.Key() == tcell.KeyUp || ev.Key() == tcell.KeyDown):
257+ f.moveLine(map[tcell.Key]int{tcell.KeyUp: -1, tcell.KeyDown: 1}[ev.Key()])
258+ case ev.Key() == tcell.KeyEnter:
259+ w.answer(st)
260+ default:
261+ f.key(ev)
262+ }
183 }263 }
184 264
185 // field is a one-line text being typed — an endpoint, a variable name, a265 // field is a one-line text being typed — an endpoint, a variable name, a
@@ -202,12 +282,44 @@ func newField(s string) *field {
202 282
203 func (f *field) String() string { return string(f.runes) }283 func (f *field) String() string { return string(f.runes) }
204 284
285+// insert puts r at the caret.
286+func (f *field) insert(r rune) {
287+ f.runes = append(f.runes[:f.caret], append([]rune{r}, f.runes[f.caret:]...)...)
288+ f.caret++
289+}
290+
291+// lineAt is the line of the caret (0-based) and the caret's column in it.
292+func (f *field) lineAt() (line, col int) {
293+ for _, r := range f.runes[:f.caret] {
294+ col++
295+ if r == '\n' {
296+ line, col = line+1, 0
297+ }
298+ }
299+ return line, col
300+}
301+
302+// moveLine moves the caret to the line above (-1) or below (+1), at the
303+// same column or the end of that line when it is shorter.
304+func (f *field) moveLine(delta int) {
305+ line, col := f.lineAt()
306+ lines := strings.Split(string(f.runes), "\n")
307+ target := line + delta
308+ if target < 0 || target >= len(lines) {
309+ return
310+ }
311+ caret := 0
312+ for i := range target {
313+ caret += len([]rune(lines[i])) + 1
314+ }
315+ f.caret = caret + min(col, len([]rune(lines[target])))
316+}
317+
205 // key edits the text; it reports whether the key was an editing key.318 // key edits the text; it reports whether the key was an editing key.
206 func (f *field) key(ev *tcell.EventKey) bool {319 func (f *field) key(ev *tcell.EventKey) bool {
207 switch ev.Key() {320 switch ev.Key() {
208 case tcell.KeyRune:321 case tcell.KeyRune:
209- f.runes = append(f.runes[:f.caret], append([]rune{ev.Rune()}, f.runes[f.caret:]...)...)322+ f.insert(ev.Rune())
210- f.caret++
211 case tcell.KeyBackspace, tcell.KeyBackspace2:323 case tcell.KeyBackspace, tcell.KeyBackspace2:
212 if f.caret > 0 {324 if f.caret > 0 {
213 f.runes = append(f.runes[:f.caret-1], f.runes[f.caret:]...)325 f.runes = append(f.runes[:f.caret-1], f.runes[f.caret:]...)
@@ -290,15 +402,7 @@ func (w *initWin) draw() {
290 402
291 func (w *initWin) drawHeader(p painter) {403 func (w *initWin) drawHeader(p painter) {
292 p.fill(' ', stBar)404 p.fill(' ', stBar)
293- title := " mm init — new agent"405+ p.textLimited(0, 0, p.width(), w.heading(), stBar)
294- c, _, _ := w.flow.Result()
295- if c.Provider != "" {
296- title += " · " + c.Provider
297- }
298- if c.Model != "" {
299- title += " · " + shortModel(c.Model)
300- }
301- p.textLimited(0, 0, p.width(), title, stBar)
302 }406 }
303 407
304 func (w *initWin) drawStatus(p painter) {408 func (w *initWin) drawStatus(p painter) {
@@ -307,6 +411,8 @@ func (w *initWin) drawStatus(p painter) {
307 switch {411 switch {
308 case w.confirm:412 case w.confirm:
309 keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit "413 keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit "
414+ case w.current().Multiline:
415+ keys = " type · ←→↑↓ move · Enter new line · Ctrl-D done · Esc back · PgUp/PgDn preview · Ctrl-C quit "
310 case w.current().Kind == elicit.Text:416 case w.current().Kind == elicit.Text:
311 keys = " type · ←→ move · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit "417 keys = " type · ←→ move · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit "
312 }418 }
@@ -359,8 +465,7 @@ func (w *initWin) drawSteps(p painter) {
359 465
360 func (w *initWin) drawQuestion(p painter) {466 func (w *initWin) drawQuestion(p painter) {
361 if w.confirm {467 if w.confirm {
362- _, _, output := w.flow.Result()468+ p.textLimited(1, 0, p.width()-2, "Ready to write "+w.target(), stLabel)
363- p.textLimited(1, 0, p.width()-2, "Ready to write "+output, stLabel)
364 p.textLimited(1, 2, p.width()-2, "Every question is answered: the file", stBody)469 p.textLimited(1, 2, p.width()-2, "Every question is answered: the file", stBody)
365 p.textLimited(1, 3, p.width()-2, "on the right is what will be written.", stBody)470 p.textLimited(1, 3, p.width()-2, "on the right is what will be written.", stBody)
366 p.textLimited(1, 5, p.width()-2, "Enter writes it; Esc goes back.", stNotice)471 p.textLimited(1, 5, p.width()-2, "Enter writes it; Esc goes back.", stNotice)
@@ -368,6 +473,10 @@ func (w *initWin) drawQuestion(p painter) {
368 }473 }
369 st := w.current()474 st := w.current()
370 p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel)475 p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel)
476+ if st.Multiline {
477+ w.drawLines(p, st)
478+ return
479+ }
371 if st.Kind == elicit.Text {480 if st.Kind == elicit.Text {
372 f := w.text[st.Key]481 f := w.text[st.Key]
373 if f == nil {482 if f == nil {
@@ -377,7 +486,10 @@ func (w *initWin) drawQuestion(p painter) {
377 x := p.text(1, 2, "› "+string(f.runes[:f.caret]), stBody)486 x := p.text(1, 2, "› "+string(f.runes[:f.caret]), stBody)
378 p.text(x, 2, string(f.runes[f.caret:]), stBody)487 p.text(x, 2, string(f.runes[f.caret:]), stBody)
379 p.showCursor(x, 2)488 p.showCursor(x, 2)
380- if st.Default != "" {489+ switch {
490+ case st.Optional:
491+ p.textLimited(1, 4, p.width()-2, "leave empty for none", stNotice)
492+ case st.Default != "":
381 p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice)493 p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice)
382 }494 }
383 return495 return
@@ -409,6 +521,23 @@ func (w *initWin) drawQuestion(p painter) {
409 }521 }
410 }522 }
411 523
524+// drawLines draws a multi-line answer (mm skillcreate's text), scrolled so
525+// the caret's line is visible; a line wider than the column is cut.
526+func (w *initWin) drawLines(p painter, st agentinit.Step) {
527+ f := w.text[st.Key]
528+ if f == nil {
529+ f = newField("")
530+ }
531+ lines := strings.Split(f.String(), "\n")
532+ line, col := f.lineAt()
533+ height := max(p.height()-3, 1)
534+ top := max(line-height+1, 0)
535+ for i := 0; i < height && top+i < len(lines); i++ {
536+ p.textLimited(1, 2+i, p.width()-2, "› "+lines[top+i], stBody)
537+ }
538+ p.showCursor(min(3+col, p.width()-2), 2+line-top)
539+}
540+
412 // optionLabel is how an option is listed. A model is listed by the541 // optionLabel is how an option is listed. A model is listed by the
413 // header's short name: its identifier is an address of seventy characters,542 // header's short name: its identifier is an address of seventy characters,
414 // cut by the column before the part that tells two models apart.543 // cut by the column before the part that tells two models apart.
modified internal/tui/initwin_test.go +138 -6
@@ -11,6 +11,7 @@ import (
1111 "testing"
1212
1313 "mm/internal/agentinit"
14+ "mm/internal/skillcreate"
1415
1516 "github.com/gdamore/tcell/v2"
1617 )
@@ -34,7 +35,21 @@ func initFlow(t *testing.T, output string) *agentinit.Flow {
3435 if err != nil {
3536 t.Fatal(err)
3637 }
37- return agentinit.NewFlow(context.Background(), p, agentinit.Choices{Provider: "llmman", BaseURL: srv.URL + "/v1"}, output)
38+ // The layout, sessions and skills questions (ticket 205) are answered
39+ // here, so the tests below drive the questions they were written for;
40+ // TestInitWindowAsksTheProjectLayoutFirst drives those three.
41+ no := false
42+ return agentinit.NewFlow(context.Background(), p, agentinit.Choices{Provider: "llmman", BaseURL: srv.URL + "/v1", Project: &no, Sessions: &no, Skills: &no}, output)
43+}
44+
45+// stepValue is the answer to one step, found by its key.
46+func stepValue(win *initWin, key string) string {
47+ for _, st := range win.steps() {
48+ if st.Key == key {
49+ return st.Value
50+ }
51+ }
52+ return ""
3853 }
3954
4055 func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.SimulationScreen) {
@@ -45,9 +60,7 @@ func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.S
4560 }
4661 screen.SetSize(w, h)
4762 t.Cleanup(screen.Fini)
48- win := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string]*field{}}
49- win.toFirstPending()
50- return win, screen
63+ return initWindow(screen, flow), screen
5164 }
5265
5366 func screenOf(win *initWin, screen tcell.SimulationScreen) string {
@@ -186,7 +199,7 @@ func TestInitWindowListsModelsByShortName(t *testing.T) {
186199 t.Errorf("the detail does not follow the highlighted model:\n%s", shown)
187200 }
188201 key(win, tcell.KeyEnter)
189- if got := win.steps()[1].Value; got != "hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" {
202+ if got := stepValue(win, "model"); got != "hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" {
190203 t.Errorf("the answer is %q, want the full identifier", got)
191204 }
192205 }
@@ -259,7 +272,8 @@ func TestInitWindowAsksBeforeOverwriting(t *testing.T) {
259272 if err != nil {
260273 t.Fatal(err)
261274 }
262- given := agentinit.Choices{Provider: "dmr", BaseURL: "http://127.0.0.1:1/v1", Model: "x", Family: "other", Usage: "assistant", Machine: "small", Name: "bob"}
275+ no := false
276+ given := agentinit.Choices{Provider: "dmr", BaseURL: "http://127.0.0.1:1/v1", Model: "x", Family: "other", Usage: "assistant", Machine: "small", Name: "bob", Project: &no, Sessions: &no, Skills: &no}
263277 flow := agentinit.NewFlow(context.Background(), presets, given, out)
264278
265279 win, screen := newInitWin(t, flow, 100, 30)
@@ -282,3 +296,121 @@ func TestInitWindowAsksBeforeOverwriting(t *testing.T) {
282296 t.Errorf("y + Enter did not agree to overwrite:\n%s", screenOf(win, screen))
283297 }
284298 }
299+
300+// Ticket 205: with nothing given, the first question is the Project layout
301+// (no by default); answering yes makes the file question offer
302+// .mm/agent.yaml, and the sessions and skills questions are asked too.
303+func TestInitWindowAsksTheProjectLayoutFirst(t *testing.T) {
304+ flow := initFlow(t, "")
305+ flow = agentinit.NewFlow(context.Background(), mustPresets(t), agentinit.Choices{Provider: "llmman", BaseURL: flow.Base()}, "")
306+ win, screen := newInitWin(t, flow, 120, 40)
307+ if st := win.current(); st.Key != "project" || st.Default != "no" {
308+ t.Fatalf("first step %q (default %q), want project (no)", st.Key, st.Default)
309+ }
310+ if shown := screenOf(win, screen); !strings.Contains(shown, "Keep everything in a .mm folder?") {
311+ t.Errorf("project question not shown:\n%s", shown)
312+ }
313+ win.handleKey(tcell.NewEventKey(tcell.KeyRune, 'y', tcell.ModNone))
314+ key(win, tcell.KeyEnter)
315+ if got := stepValue(win, "project"); got != "yes" {
316+ t.Fatalf("project = %q, want yes", got)
317+ }
318+ if got := flow.Step("file").Default; got != ".mm/agent.yaml" {
319+ t.Errorf("file default %q, want .mm/agent.yaml", got)
320+ }
321+ var asked []string
322+ for _, st := range flow.Pending() {
323+ asked = append(asked, st.Key)
324+ }
325+ if got := strings.Join(asked, ","); !strings.Contains(got, "sessions,skills,name,file") {
326+ t.Errorf("pending = %s, want sessions and skills before name and file", got)
327+ }
328+}
329+
330+func mustPresets(t *testing.T) agentinit.Presets {
331+ t.Helper()
332+ p, err := agentinit.LoadPresets()
333+ if err != nil {
334+ t.Fatal(err)
335+ }
336+ return p
337+}
338+
339+// mm skillcreate in the window (ticket 206): the same window, the SKILL.md
340+// on the right; the text takes Enter as a new line and Ctrl-D as the answer.
341+func TestSkillWindowWritesAMultiLineText(t *testing.T) {
342+ screen := tcell.NewSimulationScreen("UTF-8")
343+ if err := screen.Init(); err != nil {
344+ t.Fatal(err)
345+ }
346+ screen.SetSize(130, 40)
347+ t.Cleanup(screen.Fini)
348+ flow := skillcreate.NewFlow(skillcreate.Place{Dir: ".mm/skills"}, skillcreate.Spec{Name: "Vulcan Salute", Description: "Greet the Vulcan way."}, "")
349+ win := skillWindow(screen, flow)
350+ if st := win.current(); st.Key != "keywords" {
351+ t.Fatalf("first question %q, want keywords (name and description given)", st.Key)
352+ }
353+ shown := screenOf(win, screen)
354+ for _, want := range []string{"mm skillcreate — new skill · .mm/skills", "# Vulcan Salute", "## What to do"} {
355+ if !strings.Contains(shown, want) {
356+ t.Errorf("missing %q:\n%s", want, shown)
357+ }
358+ }
359+ typeText(win, "vulcan, spock")
360+ key(win, tcell.KeyEnter)
361+ if st := win.current(); st.Key != "body" || !st.Multiline {
362+ t.Fatalf("current %q, want the multi-line body", st.Key)
363+ }
364+ typeText(win, "Raise your hand.")
365+ key(win, tcell.KeyEnter) // a new line, not the answer
366+ if st := win.current(); st.Key != "body" {
367+ t.Fatalf("Enter answered the text (now on %q)", st.Key)
368+ }
369+ typeText(win, "Say: live long.")
370+ if shown := screenOf(win, screen); !strings.Contains(shown, "Ctrl-D done") || !strings.Contains(shown, "› Say: live long.") {
371+ t.Errorf("text not shown on two lines:\n%s", shown)
372+ }
373+ key(win, tcell.KeyCtrlD)
374+ if !win.confirm {
375+ t.Fatal("Ctrl-D did not answer the text")
376+ }
377+ key(win, tcell.KeyEnter)
378+ spec, _, out := flow.Result()
379+ if win.result != 1 || spec.Body != "Raise your hand.\nSay: live long." || spec.Keywords != "vulcan, spock" || out != ".mm/skills/vulcan-salute/SKILL.md" {
380+ t.Errorf("result %d, spec %+v, file %q", win.result, spec, out)
381+ }
382+}
383+
384+// Up and Down move between the lines of a multi-line text.
385+func TestFieldMovesBetweenLines(t *testing.T) {
386+ f := newField("first line\nab")
387+ f.moveLine(-1) // from the end of "ab" to column 2 of the first line
388+ f.insert('X')
389+ if got := f.String(); got != "fiXrst line\nab" {
390+ t.Errorf("got %q", got)
391+ }
392+ f.moveLine(1)
393+ f.insert('Y')
394+ if got := f.String(); got != "fiXrst line\nabY" {
395+ t.Errorf("got %q, want the caret at the end of the shorter line", got)
396+ }
397+}
398+
399+// An optional answer (the keywords) starts empty, and Enter on it means none.
400+func TestOptionalAnswerStartsEmpty(t *testing.T) {
401+ screen := tcell.NewSimulationScreen("UTF-8")
402+ if err := screen.Init(); err != nil {
403+ t.Fatal(err)
404+ }
405+ screen.SetSize(130, 40)
406+ t.Cleanup(screen.Fini)
407+ flow := skillcreate.NewFlow(skillcreate.Place{Dir: "skills"}, skillcreate.Spec{Name: "x", Description: "d"}, "")
408+ win := skillWindow(screen, flow)
409+ if shown := screenOf(win, screen); !strings.Contains(shown, "leave empty for none") || strings.Contains(shown, "› -") {
410+ t.Errorf("the keywords field is not empty:\n%s", shown)
411+ }
412+ key(win, tcell.KeyEnter)
413+ if spec, _, _ := flow.Result(); spec.Keywords != "" || win.current().Key != "body" {
414+ t.Errorf("keywords %q, current %q", spec.Keywords, win.current().Key)
415+ }
416+}
@@ -11,6 +11,7 @@ import (
11 "testing"11 "testing"
12 12
13 "mm/internal/agentinit"13 "mm/internal/agentinit"
14+ "mm/internal/skillcreate"
14 15
15 "github.com/gdamore/tcell/v2"16 "github.com/gdamore/tcell/v2"
16 )17 )
@@ -34,7 +35,21 @@ func initFlow(t *testing.T, output string) *agentinit.Flow {
34 if err != nil {35 if err != nil {
35 t.Fatal(err)36 t.Fatal(err)
36 }37 }
37- return agentinit.NewFlow(context.Background(), p, agentinit.Choices{Provider: "llmman", BaseURL: srv.URL + "/v1"}, output)38+ // The layout, sessions and skills questions (ticket 205) are answered
39+ // here, so the tests below drive the questions they were written for;
40+ // TestInitWindowAsksTheProjectLayoutFirst drives those three.
41+ no := false
42+ return agentinit.NewFlow(context.Background(), p, agentinit.Choices{Provider: "llmman", BaseURL: srv.URL + "/v1", Project: &no, Sessions: &no, Skills: &no}, output)
43+}
44+
45+// stepValue is the answer to one step, found by its key.
46+func stepValue(win *initWin, key string) string {
47+ for _, st := range win.steps() {
48+ if st.Key == key {
49+ return st.Value
50+ }
51+ }
52+ return ""
38 }53 }
39 54
40 func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.SimulationScreen) {55 func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.SimulationScreen) {
@@ -45,9 +60,7 @@ func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.S
45 }60 }
46 screen.SetSize(w, h)61 screen.SetSize(w, h)
47 t.Cleanup(screen.Fini)62 t.Cleanup(screen.Fini)
48- win := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string]*field{}}63+ return initWindow(screen, flow), screen
49- win.toFirstPending()
50- return win, screen
51 }64 }
52 65
53 func screenOf(win *initWin, screen tcell.SimulationScreen) string {66 func screenOf(win *initWin, screen tcell.SimulationScreen) string {
@@ -186,7 +199,7 @@ func TestInitWindowListsModelsByShortName(t *testing.T) {
186 t.Errorf("the detail does not follow the highlighted model:\n%s", shown)199 t.Errorf("the detail does not follow the highlighted model:\n%s", shown)
187 }200 }
188 key(win, tcell.KeyEnter)201 key(win, tcell.KeyEnter)
189- if got := win.steps()[1].Value; got != "hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" {202+ if got := stepValue(win, "model"); got != "hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" {
190 t.Errorf("the answer is %q, want the full identifier", got)203 t.Errorf("the answer is %q, want the full identifier", got)
191 }204 }
192 }205 }
@@ -259,7 +272,8 @@ func TestInitWindowAsksBeforeOverwriting(t *testing.T) {
259 if err != nil {272 if err != nil {
260 t.Fatal(err)273 t.Fatal(err)
261 }274 }
262- given := agentinit.Choices{Provider: "dmr", BaseURL: "http://127.0.0.1:1/v1", Model: "x", Family: "other", Usage: "assistant", Machine: "small", Name: "bob"}275+ no := false
276+ given := agentinit.Choices{Provider: "dmr", BaseURL: "http://127.0.0.1:1/v1", Model: "x", Family: "other", Usage: "assistant", Machine: "small", Name: "bob", Project: &no, Sessions: &no, Skills: &no}
263 flow := agentinit.NewFlow(context.Background(), presets, given, out)277 flow := agentinit.NewFlow(context.Background(), presets, given, out)
264 278
265 win, screen := newInitWin(t, flow, 100, 30)279 win, screen := newInitWin(t, flow, 100, 30)
@@ -282,3 +296,121 @@ func TestInitWindowAsksBeforeOverwriting(t *testing.T) {
282 t.Errorf("y + Enter did not agree to overwrite:\n%s", screenOf(win, screen))296 t.Errorf("y + Enter did not agree to overwrite:\n%s", screenOf(win, screen))
283 }297 }
284 }298 }
299+
300+// Ticket 205: with nothing given, the first question is the Project layout
301+// (no by default); answering yes makes the file question offer
302+// .mm/agent.yaml, and the sessions and skills questions are asked too.
303+func TestInitWindowAsksTheProjectLayoutFirst(t *testing.T) {
304+ flow := initFlow(t, "")
305+ flow = agentinit.NewFlow(context.Background(), mustPresets(t), agentinit.Choices{Provider: "llmman", BaseURL: flow.Base()}, "")
306+ win, screen := newInitWin(t, flow, 120, 40)
307+ if st := win.current(); st.Key != "project" || st.Default != "no" {
308+ t.Fatalf("first step %q (default %q), want project (no)", st.Key, st.Default)
309+ }
310+ if shown := screenOf(win, screen); !strings.Contains(shown, "Keep everything in a .mm folder?") {
311+ t.Errorf("project question not shown:\n%s", shown)
312+ }
313+ win.handleKey(tcell.NewEventKey(tcell.KeyRune, 'y', tcell.ModNone))
314+ key(win, tcell.KeyEnter)
315+ if got := stepValue(win, "project"); got != "yes" {
316+ t.Fatalf("project = %q, want yes", got)
317+ }
318+ if got := flow.Step("file").Default; got != ".mm/agent.yaml" {
319+ t.Errorf("file default %q, want .mm/agent.yaml", got)
320+ }
321+ var asked []string
322+ for _, st := range flow.Pending() {
323+ asked = append(asked, st.Key)
324+ }
325+ if got := strings.Join(asked, ","); !strings.Contains(got, "sessions,skills,name,file") {
326+ t.Errorf("pending = %s, want sessions and skills before name and file", got)
327+ }
328+}
329+
330+func mustPresets(t *testing.T) agentinit.Presets {
331+ t.Helper()
332+ p, err := agentinit.LoadPresets()
333+ if err != nil {
334+ t.Fatal(err)
335+ }
336+ return p
337+}
338+
339+// mm skillcreate in the window (ticket 206): the same window, the SKILL.md
340+// on the right; the text takes Enter as a new line and Ctrl-D as the answer.
341+func TestSkillWindowWritesAMultiLineText(t *testing.T) {
342+ screen := tcell.NewSimulationScreen("UTF-8")
343+ if err := screen.Init(); err != nil {
344+ t.Fatal(err)
345+ }
346+ screen.SetSize(130, 40)
347+ t.Cleanup(screen.Fini)
348+ flow := skillcreate.NewFlow(skillcreate.Place{Dir: ".mm/skills"}, skillcreate.Spec{Name: "Vulcan Salute", Description: "Greet the Vulcan way."}, "")
349+ win := skillWindow(screen, flow)
350+ if st := win.current(); st.Key != "keywords" {
351+ t.Fatalf("first question %q, want keywords (name and description given)", st.Key)
352+ }
353+ shown := screenOf(win, screen)
354+ for _, want := range []string{"mm skillcreate — new skill · .mm/skills", "# Vulcan Salute", "## What to do"} {
355+ if !strings.Contains(shown, want) {
356+ t.Errorf("missing %q:\n%s", want, shown)
357+ }
358+ }
359+ typeText(win, "vulcan, spock")
360+ key(win, tcell.KeyEnter)
361+ if st := win.current(); st.Key != "body" || !st.Multiline {
362+ t.Fatalf("current %q, want the multi-line body", st.Key)
363+ }
364+ typeText(win, "Raise your hand.")
365+ key(win, tcell.KeyEnter) // a new line, not the answer
366+ if st := win.current(); st.Key != "body" {
367+ t.Fatalf("Enter answered the text (now on %q)", st.Key)
368+ }
369+ typeText(win, "Say: live long.")
370+ if shown := screenOf(win, screen); !strings.Contains(shown, "Ctrl-D done") || !strings.Contains(shown, "› Say: live long.") {
371+ t.Errorf("text not shown on two lines:\n%s", shown)
372+ }
373+ key(win, tcell.KeyCtrlD)
374+ if !win.confirm {
375+ t.Fatal("Ctrl-D did not answer the text")
376+ }
377+ key(win, tcell.KeyEnter)
378+ spec, _, out := flow.Result()
379+ if win.result != 1 || spec.Body != "Raise your hand.\nSay: live long." || spec.Keywords != "vulcan, spock" || out != ".mm/skills/vulcan-salute/SKILL.md" {
380+ t.Errorf("result %d, spec %+v, file %q", win.result, spec, out)
381+ }
382+}
383+
384+// Up and Down move between the lines of a multi-line text.
385+func TestFieldMovesBetweenLines(t *testing.T) {
386+ f := newField("first line\nab")
387+ f.moveLine(-1) // from the end of "ab" to column 2 of the first line
388+ f.insert('X')
389+ if got := f.String(); got != "fiXrst line\nab" {
390+ t.Errorf("got %q", got)
391+ }
392+ f.moveLine(1)
393+ f.insert('Y')
394+ if got := f.String(); got != "fiXrst line\nabY" {
395+ t.Errorf("got %q, want the caret at the end of the shorter line", got)
396+ }
397+}
398+
399+// An optional answer (the keywords) starts empty, and Enter on it means none.
400+func TestOptionalAnswerStartsEmpty(t *testing.T) {
401+ screen := tcell.NewSimulationScreen("UTF-8")
402+ if err := screen.Init(); err != nil {
403+ t.Fatal(err)
404+ }
405+ screen.SetSize(130, 40)
406+ t.Cleanup(screen.Fini)
407+ flow := skillcreate.NewFlow(skillcreate.Place{Dir: "skills"}, skillcreate.Spec{Name: "x", Description: "d"}, "")
408+ win := skillWindow(screen, flow)
409+ if shown := screenOf(win, screen); !strings.Contains(shown, "leave empty for none") || strings.Contains(shown, "› -") {
410+ t.Errorf("the keywords field is not empty:\n%s", shown)
411+ }
412+ key(win, tcell.KeyEnter)
413+ if spec, _, _ := flow.Result(); spec.Keywords != "" || win.current().Key != "body" {
414+ t.Errorf("keywords %q, current %q", spec.Keywords, win.current().Key)
415+ }
416+}
modified internal/tui/turn.go +1 -1
@@ -348,7 +348,7 @@ func (r *runner) apply(res outcome) {
348348 r.t.Close()
349349 switch {
350350 case res.err != nil && res.cancelled:
351- r.t.Notice("🛑 Generation aborted.")
351+ r.t.Notice("🔴 Generation aborted.")
352352 case res.err != nil:
353353 explained := r.opts.Model.Explain(res.err)
354354 r.t.Error("[error: " + explained + "]")
@@ -348,7 +348,7 @@ func (r *runner) apply(res outcome) {
348 r.t.Close()348 r.t.Close()
349 switch {349 switch {
350 case res.err != nil && res.cancelled:350 case res.err != nil && res.cancelled:
351- r.t.Notice("🛑 Generation aborted.")351+ r.t.Notice("🔴 Generation aborted.")
352 case res.err != nil:352 case res.err != nil:
353 explained := r.opts.Model.Explain(res.err)353 explained := r.opts.Model.Explain(res.err)
354 r.t.Error("[error: " + explained + "]")354 r.t.Error("[error: " + explained + "]")
modified internal/tui/turn_test.go +1 -1
@@ -205,7 +205,7 @@ func TestStoppingTheTurnRunsTheQueue(t *testing.T) {
205205 }
206206 waitIdle(t, r)
207207 waitIdle(t, r)
208- if !strings.Contains(notices(tr), "🛑 Generation aborted.") {
208+ if !strings.Contains(notices(tr), "🔴 Generation aborted.") {
209209 t.Errorf("notices:\n%s", notices(tr))
210210 }
211211 if m.calls.Load() != 2 || len(r.messages) != 3 || r.messages[1].Text() != "second" {
@@ -205,7 +205,7 @@ func TestStoppingTheTurnRunsTheQueue(t *testing.T) {
205 }205 }
206 waitIdle(t, r)206 waitIdle(t, r)
207 waitIdle(t, r)207 waitIdle(t, r)
208- if !strings.Contains(notices(tr), "🛑 Generation aborted.") {208+ if !strings.Contains(notices(tr), "🔴 Generation aborted.") {
209 t.Errorf("notices:\n%s", notices(tr))209 t.Errorf("notices:\n%s", notices(tr))
210 }210 }
211 if m.calls.Load() != 2 || len(r.messages) != 3 || r.messages[1].Text() != "second" {211 if m.calls.Load() != 2 || len(r.messages) != 3 || r.messages[1].Text() != "second" {
modified main.go +18 -5
@@ -53,6 +53,7 @@ import (
5353 "mm/internal/lsp"
5454 "mm/internal/mcp"
5555 "mm/internal/sbxinit"
56+ "mm/internal/skillcreate"
5657 "mm/internal/skills"
5758 "mm/internal/spinner"
5859 "mm/internal/tools"
@@ -90,6 +91,11 @@ func main() {
9091 if len(os.Args) > 1 && isSbxInit(os.Args[1]) {
9192 os.Exit(sbxinit.Main(os.Args[2:], os.Stdin, os.Stdout))
9293 }
94+ // `mm skillcreate` (ticket 206): a wizard that writes a new skill where
95+ // the agent's config reads its skills; it reads the config itself.
96+ if len(os.Args) > 1 && isSkillCreate(os.Args[1]) {
97+ os.Exit(skillcreate.Main(os.Args[2:], os.Stdin, os.Stdout, skillcreate.Options{Window: tui.RunSkillCreate, Terminal: tui.Terminal}))
98+ }
9399 // `mm tui` and `mm acp` (ticket 117): the modes as words too, like
94100 // `mm init` — `mm tui agent.yaml` is `mm -tui agent.yaml`.
95101 os.Args = modeWordAsFlag(os.Args)
@@ -124,7 +130,7 @@ func main() {
124130 // Replace os.Args for flag.Parse()
125131 os.Args = append([]string{os.Args[0]}, remainingArgs...)
126132
127- configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
133+ configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml, then ./.mm/agent.yaml)")
128134 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL (same as: mm acp)")
129135 tuiMode := flag.Bool("tui", false, "open the single-window mode (conversation above, input box below); falls back to the line mode when stdout is not a terminal (same as: mm tui)")
130136 promptMode := flag.String("prompt", "", "run a single prompt and exit")
@@ -134,6 +140,7 @@ func main() {
134140 // Listed for -h only: `mm --init` is answered before flag.Parse.
135141 flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)")
136142 flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)")
143+ flag.Bool("skillcreate", false, "write a new skill with a wizard, where the agent reads its skills (same as mm skillcreate; mm skillcreate -h for its flags)")
137144 flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)")
138145 askAgent := flag.String("ask", "", "ask the agent `name` (a key of mmConfigs) the -p prompt, or stdin; print only its answer on stdout, and exit")
139146 askWithContext := flag.String("ask-with-context", "", "same as --ask, with the conversation read from stdin (question with -p, or after a \"---\" line)")
@@ -807,16 +814,16 @@ func fail(format string, args ...any) {
807814
808815 // resolveSkillsDir makes the skills directory absolute. An absolute dir is
809816 // kept as-is. A relative one follows the config file in ACP mode when there
810-// is one (the editor's working directory is not the project's), and the
811-// current directory otherwise — for the terminal, that is where the user
812-// started mm.
817+// is one (the editor's working directory is not the project's) — its
818+// config.BaseDir, the project root for a .mm/agent.yaml — and the current
819+// directory otherwise: for the terminal, that is where the user started mm.
813820 func resolveSkillsDir(dir, configPath string, acpMode bool) string {
814821 if filepath.IsAbs(dir) {
815822 return dir
816823 }
817824 anchor := "."
818825 if acpMode && configPath != "" {
819- anchor = filepath.Dir(configPath)
826+ anchor = config.BaseDir(configPath)
820827 }
821828 abs, err := filepath.Abs(filepath.Join(anchor, dir))
822829 if err != nil {
@@ -886,6 +893,12 @@ func isInit(arg string) bool {
886893 return arg == "init" || arg == "--init" || arg == "-init"
887894 }
888895
896+// isSkillCreate reports whether the first argument asks for the skill wizard:
897+// `mm skillcreate`, `mm --skillcreate` or `mm -skillcreate`.
898+func isSkillCreate(arg string) bool {
899+ return arg == "skillcreate" || arg == "--skillcreate" || arg == "-skillcreate"
900+}
901+
889902 // isDockerInit reports whether the first argument asks for the container files:
890903 // `mm dockerinit`, `mm --dockerinit` or `mm -dockerinit`.
891904 func isDockerInit(arg string) bool {
@@ -53,6 +53,7 @@ import (
53 "mm/internal/lsp"53 "mm/internal/lsp"
54 "mm/internal/mcp"54 "mm/internal/mcp"
55 "mm/internal/sbxinit"55 "mm/internal/sbxinit"
56+ "mm/internal/skillcreate"
56 "mm/internal/skills"57 "mm/internal/skills"
57 "mm/internal/spinner"58 "mm/internal/spinner"
58 "mm/internal/tools"59 "mm/internal/tools"
@@ -90,6 +91,11 @@ func main() {
90 if len(os.Args) > 1 && isSbxInit(os.Args[1]) {91 if len(os.Args) > 1 && isSbxInit(os.Args[1]) {
91 os.Exit(sbxinit.Main(os.Args[2:], os.Stdin, os.Stdout))92 os.Exit(sbxinit.Main(os.Args[2:], os.Stdin, os.Stdout))
92 }93 }
94+ // `mm skillcreate` (ticket 206): a wizard that writes a new skill where
95+ // the agent's config reads its skills; it reads the config itself.
96+ if len(os.Args) > 1 && isSkillCreate(os.Args[1]) {
97+ os.Exit(skillcreate.Main(os.Args[2:], os.Stdin, os.Stdout, skillcreate.Options{Window: tui.RunSkillCreate, Terminal: tui.Terminal}))
98+ }
93 // `mm tui` and `mm acp` (ticket 117): the modes as words too, like99 // `mm tui` and `mm acp` (ticket 117): the modes as words too, like
94 // `mm init` — `mm tui agent.yaml` is `mm -tui agent.yaml`.100 // `mm init` — `mm tui agent.yaml` is `mm -tui agent.yaml`.
95 os.Args = modeWordAsFlag(os.Args)101 os.Args = modeWordAsFlag(os.Args)
@@ -124,7 +130,7 @@ func main() {
124 // Replace os.Args for flag.Parse()130 // Replace os.Args for flag.Parse()
125 os.Args = append([]string{os.Args[0]}, remainingArgs...)131 os.Args = append([]string{os.Args[0]}, remainingArgs...)
126 132
127- configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")133+ configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml, then ./.mm/agent.yaml)")
128 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL (same as: mm acp)")134 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL (same as: mm acp)")
129 tuiMode := flag.Bool("tui", false, "open the single-window mode (conversation above, input box below); falls back to the line mode when stdout is not a terminal (same as: mm tui)")135 tuiMode := flag.Bool("tui", false, "open the single-window mode (conversation above, input box below); falls back to the line mode when stdout is not a terminal (same as: mm tui)")
130 promptMode := flag.String("prompt", "", "run a single prompt and exit")136 promptMode := flag.String("prompt", "", "run a single prompt and exit")
@@ -134,6 +140,7 @@ func main() {
134 // Listed for -h only: `mm --init` is answered before flag.Parse.140 // Listed for -h only: `mm --init` is answered before flag.Parse.
135 flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)")141 flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)")
136 flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)")142 flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)")
143+ flag.Bool("skillcreate", false, "write a new skill with a wizard, where the agent reads its skills (same as mm skillcreate; mm skillcreate -h for its flags)")
137 flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)")144 flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)")
138 askAgent := flag.String("ask", "", "ask the agent `name` (a key of mmConfigs) the -p prompt, or stdin; print only its answer on stdout, and exit")145 askAgent := flag.String("ask", "", "ask the agent `name` (a key of mmConfigs) the -p prompt, or stdin; print only its answer on stdout, and exit")
139 askWithContext := flag.String("ask-with-context", "", "same as --ask, with the conversation read from stdin (question with -p, or after a \"---\" line)")146 askWithContext := flag.String("ask-with-context", "", "same as --ask, with the conversation read from stdin (question with -p, or after a \"---\" line)")
@@ -807,16 +814,16 @@ func fail(format string, args ...any) {
807 814
808 // resolveSkillsDir makes the skills directory absolute. An absolute dir is815 // resolveSkillsDir makes the skills directory absolute. An absolute dir is
809 // kept as-is. A relative one follows the config file in ACP mode when there816 // kept as-is. A relative one follows the config file in ACP mode when there
810-// is one (the editor's working directory is not the project's), and the817+// is one (the editor's working directory is not the project's) — its
811-// current directory otherwise — for the terminal, that is where the user818+// config.BaseDir, the project root for a .mm/agent.yaml — and the current
812-// started mm.819+// directory otherwise: for the terminal, that is where the user started mm.
813 func resolveSkillsDir(dir, configPath string, acpMode bool) string {820 func resolveSkillsDir(dir, configPath string, acpMode bool) string {
814 if filepath.IsAbs(dir) {821 if filepath.IsAbs(dir) {
815 return dir822 return dir
816 }823 }
817 anchor := "."824 anchor := "."
818 if acpMode && configPath != "" {825 if acpMode && configPath != "" {
819- anchor = filepath.Dir(configPath)826+ anchor = config.BaseDir(configPath)
820 }827 }
821 abs, err := filepath.Abs(filepath.Join(anchor, dir))828 abs, err := filepath.Abs(filepath.Join(anchor, dir))
822 if err != nil {829 if err != nil {
@@ -886,6 +893,12 @@ func isInit(arg string) bool {
886 return arg == "init" || arg == "--init" || arg == "-init"893 return arg == "init" || arg == "--init" || arg == "-init"
887 }894 }
888 895
896+// isSkillCreate reports whether the first argument asks for the skill wizard:
897+// `mm skillcreate`, `mm --skillcreate` or `mm -skillcreate`.
898+func isSkillCreate(arg string) bool {
899+ return arg == "skillcreate" || arg == "--skillcreate" || arg == "-skillcreate"
900+}
901+
889 // isDockerInit reports whether the first argument asks for the container files:902 // isDockerInit reports whether the first argument asks for the container files:
890 // `mm dockerinit`, `mm --dockerinit` or `mm -dockerinit`.903 // `mm dockerinit`, `mm --dockerinit` or `mm -dockerinit`.
891 func isDockerInit(arg string) bool {904 func isDockerInit(arg string) bool {
added skillsdir_test.go +33 -0
new file mode 100644
@@ -0,0 +1,33 @@
1+package main
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+// A relative skillsDir follows the current directory in the terminal and the
9+// config file in ACP mode — and for a .mm/agent.yaml, whose paths are written
10+// from the project root (`skillsDir: .mm/skills`), that is the root, not
11+// .mm/ (ticket 204: it used to give .mm/.mm/skills over ACP).
12+func TestResolveSkillsDir(t *testing.T) {
13+ project := t.TempDir()
14+ editor := t.TempDir() // where an editor starts mm: not the project
15+ t.Chdir(editor)
16+
17+ cases := []struct {
18+ name, dir, config string
19+ acp bool
20+ want string
21+ }{
22+ {"absolute dir kept", "/opt/skills", filepath.Join(project, "agent.yaml"), true, "/opt/skills"},
23+ {"terminal: current directory", "skills", filepath.Join(project, "agent.yaml"), false, filepath.Join(editor, "skills")},
24+ {"acp: the config file's directory", "skills", filepath.Join(project, "agent.yaml"), true, filepath.Join(project, "skills")},
25+ {"acp without a config: current directory", "skills", "", true, filepath.Join(editor, "skills")},
26+ {"acp, config in .mm/: the project root", ".mm/skills", filepath.Join(project, ".mm", "agent.yaml"), true, filepath.Join(project, ".mm", "skills")},
27+ }
28+ for _, c := range cases {
29+ if got := resolveSkillsDir(c.dir, c.config, c.acp); got != c.want {
30+ t.Errorf("%s: resolveSkillsDir(%q, %q, %v) = %q, want %q", c.name, c.dir, c.config, c.acp, got, c.want)
31+ }
32+ }
33+}
new file mode 100644
@@ -0,0 +1,33 @@
1+package main
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+// A relative skillsDir follows the current directory in the terminal and the
9+// config file in ACP mode — and for a .mm/agent.yaml, whose paths are written
10+// from the project root (`skillsDir: .mm/skills`), that is the root, not
11+// .mm/ (ticket 204: it used to give .mm/.mm/skills over ACP).
12+func TestResolveSkillsDir(t *testing.T) {
13+ project := t.TempDir()
14+ editor := t.TempDir() // where an editor starts mm: not the project
15+ t.Chdir(editor)
16+
17+ cases := []struct {
18+ name, dir, config string
19+ acp bool
20+ want string
21+ }{
22+ {"absolute dir kept", "/opt/skills", filepath.Join(project, "agent.yaml"), true, "/opt/skills"},
23+ {"terminal: current directory", "skills", filepath.Join(project, "agent.yaml"), false, filepath.Join(editor, "skills")},
24+ {"acp: the config file's directory", "skills", filepath.Join(project, "agent.yaml"), true, filepath.Join(project, "skills")},
25+ {"acp without a config: current directory", "skills", "", true, filepath.Join(editor, "skills")},
26+ {"acp, config in .mm/: the project root", ".mm/skills", filepath.Join(project, ".mm", "agent.yaml"), true, filepath.Join(project, ".mm", "skills")},
27+ }
28+ for _, c := range cases {
29+ if got := resolveSkillsDir(c.dir, c.config, c.acp); got != c.want {
30+ t.Errorf("%s: resolveSkillsDir(%q, %q, %v) = %q, want %q", c.name, c.dir, c.config, c.acp, got, c.want)
31+ }
32+ }
33+}