modified
.gitignore +4 -0 | @@ -21,3 +21,7 @@ release | ||
| 21 | 21 | backup |
| 22 | 22 | .backup |
| 23 | 23 | sidekicks |
| 24 | + | |
| 25 | +bob | |
| 26 | +bob-the-bot | |
| 27 | +bob-the-smoll | |
| \ No newline at end of file | ||
| @@ -21,3 +21,7 @@ release | |||
| 21 | backup | 21 | backup |
| 22 | .backup | 22 | .backup |
| 23 | sidekicks | 23 | 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: | ||
| 57 | 57 | - title: v1.0.6 |
| 58 | 58 | state: closed |
| 59 | 59 | 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.6 | 57 | - title: v1.0.6 |
| 58 | state: closed | 58 | state: closed |
| 59 | dueOn: 2026-11-15 | 59 | 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 @@ | ||
| 1 | 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | 3 | <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="<b>internal/decide</b><br/><font style="font-size:10px">decision models: /v1/systemone client, skill and tool routing</font>" 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="<b>internal/dockerinit</b><br/><font style="font-size:10px">mm dockerinit: writes the Dockerfile and compose.yaml</font>" 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="<b>internal/sbxinit</b><br/><font style="font-size:10px">mm sbxinit: writes sbxenv.yaml with the latest kit</font>" 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="<b>internal/scaffold</b><br/><font style="font-size:10px">what mm dockerinit and mm sbxinit share: the agent name, writing without destroying</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="<b>internal/decide</b><br/><font style="font-size:10px">decision models: /v1/systemone client, skill and tool routing</font>" 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="<b>internal/dockerinit</b><br/><font style="font-size:10px">mm dockerinit: writes the Dockerfile and compose.yaml</font>" 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="<b>internal/sbxinit</b><br/><font style="font-size:10px">mm sbxinit: writes sbxenv.yaml with the latest kit (.mm aware)</font>" 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="<b>internal/scaffold</b><br/><font style="font-size:10px">what mm dockerinit and mm sbxinit share: the agent name, writing without destroying</font>" 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="<b>internal/skillcreate</b><br/><font style="font-size:10px">mm skillcreate: the skill wizard, writes &lt;skillsDir&gt;/&lt;name&gt;/SKILL.md</font>" 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | 1 | <mxfile host="app.diagrams.net" modified="2026-10-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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell> | 3 | <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json), the questions ↑ recalls, /memory and search_memory</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="<b>internal/decide</b><br/><font style="font-size:10px">decision models: /v1/systemone client, skill and tool routing</font>" 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="<b>internal/dockerinit</b><br/><font style="font-size:10px">mm dockerinit: writes the Dockerfile and compose.yaml</font>" 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="<b>internal/sbxinit</b><br/><font style="font-size:10px">mm sbxinit: writes sbxenv.yaml with the latest kit</font>" 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="<b>internal/scaffold</b><br/><font style="font-size:10px">what mm dockerinit and mm sbxinit share: the agent name, writing without destroying</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="<b>internal/decide</b><br/><font style="font-size:10px">decision models: /v1/systemone client, skill and tool routing</font>" 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="<b>internal/dockerinit</b><br/><font style="font-size:10px">mm dockerinit: writes the Dockerfile and compose.yaml</font>" 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="<b>internal/sbxinit</b><br/><font style="font-size:10px">mm sbxinit: writes sbxenv.yaml with the latest kit (.mm aware)</font>" 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="<b>internal/scaffold</b><br/><font style="font-size:10px">what mm dockerinit and mm sbxinit share: the agent name, writing without destroying</font>" 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="<b>internal/skillcreate</b><br/><font style="font-size:10px">mm skillcreate: the skill wizard, writes &lt;skillsDir&gt;/&lt;name&gt;/SKILL.md</font>" 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) | ||
| 26 | 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 callback |
| 27 | 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 | 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 | 31 | - [Turn a model's reasoning off](how-to/turn-reasoning-off.md) — `chat_template_kwargs` or `reasoning_effort` in `sampling`, per server |
| 30 | 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 | 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 | 34 | - [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 | 35 | - [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 | 37 | - [Declare a custom tool](how-to/declare-a-custom-tool.md) |
| 36 | 38 | - [Write a custom tool as a WebAssembly module](how-to/write-a-wasm-tool.md) |
| 37 | 39 | - [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 callback | 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 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 server | 31 | - [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 file | 34 | - [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 editor | 35 | - [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 conversation | 39 | - [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 | ||
| 37 | 37 | |
| 38 | 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 | 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 | 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 | 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 | 69 | - **`parallel_tool_calls: false` for any agent with tools.** Otherwise small models run a check before the change it checks has landed. |
| 70 | 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 | 73 | ## 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 | 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 | 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 | 106 | - **Prompts in the user's language.** Rejected by the user's choice: the generated prompts are in English. |
| 107 | 107 | |
| 108 | 108 | ## 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 obtained | 73 | ## 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 limits | 108 | ## 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 | ||
| 26 | 26 | |
| 27 | 27 | ## 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 | 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 | 32 | - **No skills at all.** Leave the directory empty or absent: `read_skill` is then not declared, and the model never sees it. |
| 31 | 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`. |
| @@ -26,6 +26,8 @@ This guide shows how to give the agent a new markdown procedure that the model c | |||
| 26 | 26 | ||
| 27 | ## Variants | 27 | ## 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 | ||
| 16 | 16 | |
| 17 | 17 | ```text |
| 18 | 18 | 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 | + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── | |
| 51 | 51 | ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit |
| 52 | 52 | ``` |
| 53 | 53 | |
| 54 | 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 | 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 | 78 | --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 | 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 | 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 | 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. |
| @@ -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 | ```text | 17 | ```text |
| 18 | mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M | 18 | 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: qwen | 25 | + · Machine │ writer │ # |
| 26 | - · Name │ assistant │ # usage: docs | 26 | + · Ask first │ assistant │ # model family: qwen |
| 27 | - · File │ researcher │ # machine: small — a laptop, models up to about 8B | 27 | + · 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: llmman | 31 | + │ │ # 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: 0 | 36 | + │ │ # |
| 37 | - │ │ # usage docs's default: tools run without asking | 37 | + │ │ # mm -config <this file> or mm -tui <this f… |
| 38 | - │ │ yolo: true | 38 | + │ │ |
| 39 | - │ │ # usage docs | 39 | + │ │ provider: llmman |
| 40 | - │ │ bashTool: false | 40 | + │ │ # The model. Chosen for it (family qwen): sampling (temp… |
| 41 | - │ │ editTools: true | 41 | + │ │ # max_tokens) and sampling.chat_template_kwargs (enable_… |
| 42 | - │ │ planTool: false | 42 | + │ │ # model and redo them together — running mm init again i… |
| 43 | - │ │ askUserTool: false | 43 | + │ │ 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-skills | 46 | + │ │ # 0 = mm asks the server for the window it serves (the m… |
| 47 | - │ │ # usage docs | 47 | + │ │ # is 262144 tokens; the server may serve less) |
| 48 | - │ │ maxTurns: 8 | 48 | + │ │ 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 quit | 51 | ↑↓ 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 --check | 78 | --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: | ||
| 59 | 59 | ``` |
| 60 | 60 | |
| 61 | 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 | 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 | 65 | ### 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 check | 65 | ### 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" | ||
| 160 | 160 | |
| 161 | 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 | 190 | ## The model server stays on the host |
| 164 | 191 | |
| 165 | 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. |
| @@ -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 host | 190 | ## 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 | ||
| 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 | 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 | |
| @@ -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 | ||
| 11 | 11 | mm init [flags] (write a new agent.yaml; also mm --init) |
| 12 | 12 | mm dockerinit [flags] (write a Dockerfile and a compose.yaml) |
| 13 | 13 | mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox) |
| 14 | +mm skillcreate [flags] (write a new skill with a wizard) | |
| 14 | 15 | mm tui [<path>] [flags] (same as mm -tui) |
| 15 | 16 | mm acp [<path>] [flags] (same as mm -acp) |
| 16 | 17 | ``` |
| @@ -53,7 +54,21 @@ The first existing source wins. | ||
| 53 | 54 | |-------|--------|--------------| |
| 54 | 55 | | 1 | `-config` flag or positional argument | Error, exit code 1 | |
| 55 | 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 | 73 | ## Environment variables |
| 59 | 74 | |
| @@ -100,7 +115,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 100 | 115 | | stderr | Banner, warnings, `[acp] …` trail, SDK logs. | |
| 101 | 116 | | Spinner | Disabled. | |
| 102 | 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 | 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 | 121 | ## Line mode specifics |
| @@ -124,7 +139,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 124 | 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 | 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 | 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 | 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 | 144 | | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). | |
| 130 | 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 | 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 | 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 | 230 | | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten | off | |
| 216 | 231 | |
| 217 | 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 | 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 | 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 | 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 | 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 | 246 | | `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` | |
| 231 | 247 | | `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` | |
| 232 | 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 | 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 | 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 | 254 | | `--yes` | — | off; on: nothing is asked, every missing answer takes its default | |
| 237 | 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 | 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 | 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 | 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 | 266 | | Existing file | Never overwritten: `⚠️ <file> already exists — nothing was written; choose another name with -o`, exit 1, before any question. | |
| 249 | 267 | | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. | |
| 250 | 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 | 294 | ## Exit codes |
| 253 | 295 | |
| 254 | 296 | | 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 variables | 73 | ## 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 specifics | 121 | ## 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 codes | 294 | ## Exit codes |
| 253 | 295 | ||
| 254 | | Code | Cause | | 296 | | Code | Cause | |
modified
docs/en/reference/configuration.md +3 -0 | @@ -2,6 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 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 | 7 | ## Top-level keys |
| 6 | 8 | |
| 7 | 9 | | Key | Type | Default | Description | |
| @@ -11,6 +13,7 @@ | ||
| 11 | 13 | | `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. | |
| 12 | 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 | 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 | 17 | | `contextWindow` | int | `0` | Tokens the server serves. `0` = ask the server, else unknown. Must be ≥ 0. | |
| 15 | 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 | 19 | | `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 keys | 7 | ## 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 | ||
| 80 | 80 | | Variable | Value | |
| 81 | 81 | |----------|-------| |
| 82 | 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 | 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 | |
| @@ -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 @@ | ||
| 7 | 7 | | Command | At the prompt | During a generation | |
| 8 | 8 | |---------|---------------|---------------------| |
| 9 | 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 | 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 | 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 | 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 | 39 | | First line | `🗣 asking <name> (<provider>/<model>)…`, or `…, with the conversation…` for the context forms. | |
| 40 | 40 | | `<name>`'s answer | Streamed where answers appear. Its reasoning is shown as reasoning when `showThinking` is on. | |
| 41 | 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 | 43 | | `/quit` | Cancels the ask and exits. | |
| 44 | 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 | 48 | | Outcome | History | Message | |
| 49 | 49 | |---------|---------|---------| |
| 50 | 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 | 52 | | Failed | Unchanged; recorded in the session file with `where: "ask"`. | `[error: …]` (terminal, window) or a JSON-RPC error (editor). | |
| 53 | 53 | |
| 54 | 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 | 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 | 90 | | `Ctrl+D` (end of input) | Exits. | — | |
| 91 | 91 | |
| 92 | 92 | ## Window mode keys |
| @@ -157,7 +157,7 @@ The way out, in order: | ||
| 157 | 157 | |
| 158 | 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 | 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 | 162 | | `Ctrl-C` | Cancels, as above. | |
| 163 | 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 | 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 | 204 | | `[error: …]` | The provider explained a failure in one line; a question that got no answer is removed from the history. | |
| 205 | 205 | | `⚙ N command(s)` | Number of `bash` commands that actually ran in the turn. | |
| 206 | 206 | | `· 📝 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 keys | 92 | ## 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: | ||
| 26 | 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 permission |
| 27 | 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 | 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 | 31 | - [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 | 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 | 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 | 34 | - [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 | 35 | - [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 | 37 | - [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md) |
| 36 | 38 | - [Écrire un outil personnalisé sous forme de module WebAssembly](how-to/write-a-wasm-tool.md) |
| 37 | 39 | - [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 permission | 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 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 serveur | 31 | - [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 compose | 34 | - [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 éditeur | 35 | - [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 conversation | 39 | - [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 | ||
| 37 | 37 | |
| 38 | 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 | 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 | 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 | 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 | 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 | 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 | 73 | ## 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 | 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 | 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 | 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 | 108 | ## 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é obtenue | 73 | ## 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 connues | 108 | ## 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 | ||
| 26 | 26 | |
| 27 | 27 | ## 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 | 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 | 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 | 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`. |
| @@ -26,6 +26,8 @@ Ce guide montre comment donner à l'agent une nouvelle procédure en markdown qu | |||
| 26 | 26 | ||
| 27 | ## Variantes | 27 | ## 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 | ||
| 16 | 16 | |
| 17 | 17 | ```text |
| 18 | 18 | 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 | + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── | |
| 51 | 51 | ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit |
| 52 | 52 | ``` |
| 53 | 53 | |
| 54 | 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 | 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 | 78 | --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 | 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 | 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 | 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. |
| @@ -16,44 +16,44 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc | |||
| 16 | 16 | ||
| 17 | ```text | 17 | ```text |
| 18 | mm init — new agent · llmman · Qwen3.5-4B:Q4_K_M | 18 | 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: qwen | 25 | + · Machine │ writer │ # |
| 26 | - · Name │ assistant │ # usage: docs | 26 | + · Ask first │ assistant │ # model family: qwen |
| 27 | - · File │ researcher │ # machine: small — a laptop, models up to about 8B | 27 | + · 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: llmman | 31 | + │ │ # 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: 0 | 36 | + │ │ # |
| 37 | - │ │ # usage docs's default: tools run without asking | 37 | + │ │ # mm -config <this file> or mm -tui <this f… |
| 38 | - │ │ yolo: true | 38 | + │ │ |
| 39 | - │ │ # usage docs | 39 | + │ │ provider: llmman |
| 40 | - │ │ bashTool: false | 40 | + │ │ # The model. Chosen for it (family qwen): sampling (temp… |
| 41 | - │ │ editTools: true | 41 | + │ │ # max_tokens) and sampling.chat_template_kwargs (enable_… |
| 42 | - │ │ planTool: false | 42 | + │ │ # model and redo them together — running mm init again i… |
| 43 | - │ │ askUserTool: false | 43 | + │ │ 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-skills | 46 | + │ │ # 0 = mm asks the server for the window it serves (the m… |
| 47 | - │ │ # usage docs | 47 | + │ │ # is 262144 tokens; the server may serve less) |
| 48 | - │ │ maxTurns: 8 | 48 | + │ │ 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 quit | 51 | ↑↓ 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 --check | 78 | --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: | ||
| 59 | 59 | ``` |
| 60 | 60 | |
| 61 | 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 | 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 | 65 | ### 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érifier | 65 | ### 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" | ||
| 160 | 160 | |
| 161 | 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 | 190 | ## Le serveur de modèles reste sur l'hôte |
| 164 | 191 | |
| 165 | 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. |
| @@ -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ôte | 190 | ## 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 | ||
| 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 | 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 | |
| @@ -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 | ||
| 11 | 11 | mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init) |
| 12 | 12 | mm dockerinit [options] (écrire un Dockerfile et un compose.yaml) |
| 13 | 13 | mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox) |
| 14 | +mm skillcreate [options] (écrire un nouveau skill avec un assistant) | |
| 14 | 15 | mm tui [<chemin>] [options] (identique à mm -tui) |
| 15 | 16 | mm acp [<chemin>] [options] (identique à mm -acp) |
| 16 | 17 | ``` |
| @@ -53,7 +54,21 @@ La première source existante gagne. | ||
| 53 | 54 | |-------|--------|----------------| |
| 54 | 55 | | 1 | Option `-config` ou argument positionnel | Erreur, code de sortie 1 | |
| 55 | 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 | 73 | ## Variables d'environnement |
| 59 | 74 | |
| @@ -100,7 +115,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 100 | 115 | | stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. | |
| 101 | 116 | | Spinner | Désactivé. | |
| 102 | 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 | 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 | 121 | ## Spécificités du mode ligne |
| @@ -124,7 +139,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 124 | 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 | 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 | 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 | 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 | 144 | | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). | |
| 130 | 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 | 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 | 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 | 230 | | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé | désactivé | |
| 216 | 231 | |
| 217 | 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 | 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 | 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 | 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 | 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 | 246 | | `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` | |
| 231 | 247 | | `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` | |
| 232 | 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 | 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 | 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 | 254 | | `--yes` | — | désactivé ; activé : rien n'est demandé, chaque réponse manquante prend son défaut | |
| 237 | 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 | 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 | 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 | 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 | 266 | | Fichier existant | Jamais écrasé : `⚠️ <fichier> already exists — nothing was written; choose another name with -o`, code de sortie 1, avant toute question. | |
| 249 | 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 | 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 | 294 | ## Codes de sortie |
| 253 | 295 | |
| 254 | 296 | | 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'environnement | 73 | ## 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 ligne | 121 | ## 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 sortie | 294 | ## Codes de sortie |
| 253 | 295 | ||
| 254 | | Code | Cause | | 296 | | Code | Cause | |
modified
docs/fr/reference/configuration.md +3 -0 | @@ -2,6 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 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 | 7 | ## Clés de premier niveau |
| 6 | 8 | |
| 7 | 9 | | Clé | Type | Défaut | Description | |
| @@ -11,6 +13,7 @@ | ||
| 11 | 13 | | `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. | |
| 12 | 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 | 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 | 17 | | `contextWindow` | entier | `0` | Tokens servis par le serveur. `0` = demander au serveur, sinon inconnu. Doit être ≥ 0. | |
| 15 | 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 | 19 | | `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 niveau | 7 | ## 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 | ||
| 80 | 80 | | Variable | Valeur | |
| 81 | 81 | |----------|--------| |
| 82 | 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 | 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 | |
| @@ -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 @@ | ||
| 7 | 7 | | Commande | À l'invite | Pendant une génération | |
| 8 | 8 | |----------|------------|------------------------| |
| 9 | 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 | 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 | 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 | 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 | 39 | | Première ligne | `🗣 asking <nom> (<fournisseur>/<modèle>)…`, ou `…, with the conversation…` pour les formes avec contexte. | |
| 40 | 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 | 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 | 43 | | `/quit` | Annule la demande et quitte. | |
| 44 | 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 | 48 | | Issue | Historique | Message | |
| 49 | 49 | |-------|------------|---------| |
| 50 | 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 | 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 | 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 | 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 | 90 | | `Ctrl+D` (fin d'entrée) | Quitte. | — | |
| 91 | 91 | |
| 92 | 92 | ## Touches du mode fenêtre |
| @@ -157,7 +157,7 @@ La sortie, dans l'ordre : | ||
| 157 | 157 | |
| 158 | 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 | 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 | 162 | | `Ctrl-C` | Annule, comme ci-dessus. | |
| 163 | 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 | 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 | 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 | 205 | | `⚙ N command(s)` | Nombre de commandes `bash` réellement exécutées dans le tour. | |
| 206 | 206 | | `· 📝 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être | 92 | ## 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 | ||
| 24 | 24 | |
| 25 | 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 | 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 | 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 | |
| @@ -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 | ||
| 24 | 24 | |
| 25 | 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 | 28 | | `Ctrl-C` | Stops the answer in progress. If it has already been asked to stop and has not, `Ctrl-C` closes mm. | |
| 29 | 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 | |
| @@ -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 | ||
| 252 | 252 | |
| 253 | 253 | if res.err != nil { |
| 254 | 254 | if genCtx.Err() == context.Canceled { |
| 255 | - fmt.Println("\n🛑 Generation aborted.") | |
| 255 | + fmt.Println("\n🔴 Generation aborted.") | |
| 256 | 256 | } else { |
| 257 | 257 | // One line, in the provider's words: see engine.Explain — |
| 258 | 258 | // 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 | 317 | // frame would erase it. Same rule as in dmr.emit. |
| 318 | 318 | spinner.Stop() |
| 319 | 319 | prompt.drop() |
| 320 | - fmt.Println("\n🛑 Interrupted (Ctrl+C).") | |
| 320 | + fmt.Println("\n🔴 Interrupted (Ctrl+C).") | |
| 321 | 321 | cancelGen() |
| 322 | 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 | 339 | prompt.drop() |
| 340 | 340 | spinner.Stop() // le spinner tourne : on lui reprend la ligne |
| 341 | 341 | cancelGen() |
| 342 | - fmt.Println("🛑 Aborting...") | |
| 342 | + fmt.Println("🔴 Aborting...") | |
| 343 | 343 | case command.Is(nextInput, command.Quit): |
| 344 | 344 | prompt.drop() |
| 345 | 345 | 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 whoever | 258 | // 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 ligne | 340 | 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. | ||
| 49 | 49 | return a.settle(askCtx, req, messages, res.exchange, res.err), quit |
| 50 | 50 | case <-a.sigCh: |
| 51 | 51 | a.prompt.drop() |
| 52 | - fmt.Println("\n🛑 Interrupted (Ctrl+C).") | |
| 52 | + fmt.Println("\n🔴 Interrupted (Ctrl+C).") | |
| 53 | 53 | cancel() |
| 54 | 54 | case req := <-a.prompt.requestCh(): |
| 55 | 55 | a.prompt.show(req) |
| @@ -64,7 +64,7 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai. | ||
| 64 | 64 | switch { |
| 65 | 65 | case command.Is(input, command.Abort): |
| 66 | 66 | a.prompt.drop() |
| 67 | - fmt.Println("🛑 Aborting…") | |
| 67 | + fmt.Println("🔴 Aborting…") | |
| 68 | 68 | cancel() |
| 69 | 69 | case command.Is(input, command.Quit): |
| 70 | 70 | a.prompt.drop() |
| @@ -84,7 +84,7 @@ func (a asking) settle(askCtx context.Context, req command.AskRequest, messages, | ||
| 84 | 84 | fmt.Println() |
| 85 | 85 | switch { |
| 86 | 86 | case err != nil && askCtx.Err() != nil: |
| 87 | - fmt.Println("🛑 Ask aborted — nothing kept.") | |
| 87 | + fmt.Println("🔴 Ask aborted — nothing kept.") | |
| 88 | 88 | return messages |
| 89 | 89 | case err != nil: |
| 90 | 90 | 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), quit | 49 | 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 messages | 88 | 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 { | ||
| 18 | 18 | Family string // "" = detected from the model's name |
| 19 | 19 | Name string // acp.name |
| 20 | 20 | 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 | 47 | // Setting is one value of the generated file with the reason it has that |
| @@ -56,6 +80,7 @@ type Settings struct { | ||
| 56 | 80 | Sampling []Setting // under sampling: |
| 57 | 81 | Kwargs []Setting // under sampling.chat_template_kwargs: |
| 58 | 82 | Context []Setting // under context: |
| 83 | + Sessions []Setting // under sessions:, when they are on | |
| 59 | 84 | 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 | 98 | fam = p.Family(c.Family) |
| 74 | 99 | } |
| 75 | 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 | 103 | s.decideTop() |
| 79 | 104 | s.decideSampling() |
| 80 | 105 | s.decideContext() |
| 106 | + s.decideSessions() | |
| 81 | 107 | s.decideNotes() |
| 82 | 108 | return s |
| 83 | 109 | } |
| @@ -126,7 +152,10 @@ func (s *Settings) decideTop() { | ||
| 126 | 152 | if u.JSON || u.Coordinator { |
| 127 | 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 | 160 | s.add("maxTurns", fmt.Sprint(u.MaxTurns), "usage "+u.Name) |
| 132 | 161 | s.addFrom("maxOutput", fmt.Sprint(m.MaxOutput), m.Source, OriginGuide) |
| @@ -215,6 +244,9 @@ func (s *Settings) decideKwargs() { | ||
| 215 | 244 | switch sw { |
| 216 | 245 | case "enable_thinking": |
| 217 | 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 | 250 | if s.Thinking { |
| 219 | 251 | why = "reasoning on: code and multi-step work" |
| 220 | 252 | } |
| @@ -247,6 +279,34 @@ func (s *Settings) decideContext() { | ||
| 247 | 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 | 310 | func (s *Settings) decideNotes() { |
| 251 | 311 | if s.Family.Note != "" { |
| 252 | 312 | 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 name | 18 | Family string // "" = detected from the model's name |
| 19 | Name string // acp.name | 19 | Name string // acp.name |
| 20 | Yolo *bool // nil = the usage's default | 20 | 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 that | 47 | // 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 remarks | 84 | 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 s | 108 | 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 ( | ||
| 8 | 8 | "regexp" |
| 9 | 9 | "strings" |
| 10 | 10 | |
| 11 | + "mm/internal/config" | |
| 11 | 12 | "mm/internal/elicit" |
| 12 | 13 | "mm/internal/engine" |
| 13 | 14 | ) |
| @@ -23,7 +24,7 @@ type Option struct { | ||
| 23 | 24 | // Step is one question of the wizard, ready to show: the line mode prints |
| 24 | 25 | // it, the window draws it (internal/tui). |
| 25 | 26 | 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 | |
| 27 | 28 | Title string // the short name in the window's list of steps |
| 28 | 29 | Prompt string // the question |
| 29 | 30 | Kind elicit.Kind |
| @@ -31,17 +32,27 @@ type Step struct { | ||
| 31 | 32 | Default string |
| 32 | 33 | Value string // the answer given so far, "" when none |
| 33 | 34 | 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 | 45 | // stepKeys is the order of the questions. endpoint and keyenv are asked |
| 37 | 46 | // for provider openai only (ticket 177): that provider is any |
| 38 | 47 | // OpenAI-compatible service — OpenAI, Cerebras, Hugging Face… — so its |
| 39 | 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 | 52 | var stepTitles = map[string]string{ |
| 43 | 53 | "provider": "Server", "endpoint": "Endpoint", "keyenv": "API key", "model": "Model", "family": "Family", |
| 44 | 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 | 58 | // 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 | 96 | f.given[key], f.answer[key] = true, value |
| 86 | 97 | } |
| 87 | 98 | } |
| 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 | + } | |
| 90 | 103 | } |
| 91 | 104 | return f |
| 92 | 105 | } |
| @@ -223,8 +236,20 @@ func (f *Flow) Step(key string) Step { | ||
| 223 | 236 | } |
| 224 | 237 | case "name": |
| 225 | 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 | 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 | 253 | case "overwrite": |
| 229 | 254 | st.Prompt, st.Kind, st.Default = "Overwrite "+filepath.Base(f.output)+"?", elicit.Confirm, "no" |
| 230 | 255 | st.Options = []Option{ |
| @@ -235,6 +260,45 @@ func (f *Flow) Step(key string) Step { | ||
| 235 | 260 | 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 | 302 | // Set answers one question. A new server forgets the model (and the |
| 239 | 303 | // family, unless a flag gave it); a new model forgets the facts. |
| 240 | 304 | func (f *Flow) Set(key, value string) error { |
| @@ -302,9 +366,24 @@ func (f *Flow) Set(key, value string) error { | ||
| 302 | 366 | ask := value == "yes" |
| 303 | 367 | yolo := !ask |
| 304 | 368 | 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 | 381 | case "name": |
| 306 | 382 | f.c.Name = value |
| 307 | 383 | case "file": |
| 384 | + if err := checkLayout(on(f.c.Project), value); err != nil { | |
| 385 | + return err | |
| 386 | + } | |
| 308 | 387 | f.output = value |
| 309 | 388 | f.forgetOverwrite() |
| 310 | 389 | case "overwrite": |
| @@ -326,6 +405,27 @@ func (f *Flow) Set(key, value string) error { | ||
| 326 | 405 | 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 | 429 | // Unset takes an answer back (the window's Esc), unless a flag gave it. |
| 330 | 430 | func (f *Flow) Unset(key string) { |
| 331 | 431 | if f.given[key] { |
| @@ -345,6 +445,18 @@ func (f *Flow) Unset(key string) { | ||
| 345 | 445 | f.c.Machine = "" |
| 346 | 446 | case "yolo": |
| 347 | 447 | 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 | 460 | case "name": |
| 349 | 461 | f.c.Name = "" |
| 350 | 462 | 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 prints | 24 | // 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, file | 27 | + 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 steps | 28 | Title string // the short name in the window's list of steps |
| 28 | Prompt string // the question | 29 | Prompt string // the question |
| 29 | Kind elicit.Kind | 30 | Kind elicit.Kind |
| @@ -31,17 +32,27 @@ type Step struct { | |||
| 31 | Default string | 32 | Default string |
| 32 | Value string // the answer given so far, "" when none | 33 | Value string // the answer given so far, "" when none |
| 33 | Given bool // answered by a flag: shown, never asked | 34 | 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 asked | 45 | // stepKeys is the order of the questions. endpoint and keyenv are asked |
| 37 | // for provider openai only (ticket 177): that provider is any | 46 | // for provider openai only (ticket 177): that provider is any |
| 38 | // OpenAI-compatible service — OpenAI, Cerebras, Hugging Face… — so its | 47 | // 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 file | 58 | // 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, value | 96 | 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 f | 104 | 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 st | 260 | 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 the | 302 | // 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 := !ask | 367 | yolo := !ask |
| 304 | f.c.Yolo = &yolo | 368 | 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 = value | 382 | 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 = value | 387 | 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 nil | 405 | 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 = nil | 447 | 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) { | ||
| 19 | 19 | p, _ := LoadPresets() |
| 20 | 20 | base := llmmanStub(t) |
| 21 | 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 | 23 | t.Fatalf("pending = %s", got) |
| 24 | 24 | } |
| 25 | 25 | 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 { | ||
| 53 | 53 | // other providers they are not. |
| 54 | 54 | func TestOpenAIAsksTheEndpointAndTheKeyVariable(t *testing.T) { |
| 55 | 55 | 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) | |
| 58 | 58 | } |
| 59 | 59 | if st := f.Step("endpoint"); st.Kind != elicit.Text || st.Default != "https://api.openai.com/v1" { |
| 60 | 60 | 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 { | ||
| 40 | 40 | Thinking Sampling `yaml:"thinking"` |
| 41 | 41 | Precise *float64 `yaml:"precise"` |
| 42 | 42 | 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"` | |
| 45 | 52 | // Origin says how General, Thinking and Precise were obtained, keyed |
| 46 | 53 | // "general", "thinking", "precise": OriginMeasured, OriginCard or |
| 47 | 54 | // 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_tokens | 42 | 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, keyed | 52 | // Origin says how General, Thinking and Precise were obtained, keyed |
| 46 | // "general", "thinking", "precise": OriginMeasured, OriginCard or | 53 | // "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 @@ | ||
| 19 | 19 | # `rule` (a generic rule of thumb, nothing specific to the family). The |
| 20 | 20 | # generated file tags its numbers with it (ticket 121): after a model or |
| 21 | 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 | 30 | families: |
| 24 | 31 | - name: qwen-coder |
| @@ -135,6 +142,18 @@ families: | ||
| 135 | 142 | source: "Mistral Small 3.1 model card (\"a relatively low temperature, such as temperature=0.15\"); no measured demo in this repository" |
| 136 | 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 | 157 | - name: other |
| 139 | 158 | match: [] |
| 140 | 159 | reasoning: none |
| @@ -19,6 +19,13 @@ | |||
| 19 | # `rule` (a generic rule of thumb, nothing specific to the family). The | 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 | 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. | 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-coder | 31 | - 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: other | 157 | - name: other |
| 139 | match: [] | 158 | match: [] |
| 140 | reasoning: none | 159 | reasoning: none |
modified
internal/agentinit/presets_test.go +42 -1 | @@ -1,6 +1,11 @@ | ||
| 1 | 1 | package agentinit |
| 2 | 2 | |
| 3 | -import "testing" | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "mm/internal/config" | |
| 8 | +) | |
| 4 | 9 | |
| 5 | 10 | func TestPresetsLoadAndEveryEntryIsSourced(t *testing.T) { |
| 6 | 11 | p, err := LoadPresets() |
| @@ -57,6 +62,9 @@ func TestFamilyOf(t *testing.T) { | ||
| 57 | 62 | "hf.co/unsloth/gpt-oss-20b-GGUF:Q4_K_M": "gpt-oss", |
| 58 | 63 | "llama3.2:3b": "llama", |
| 59 | 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 | 68 | "some/unknown-model": "other", |
| 61 | 69 | } { |
| 62 | 70 | if got := p.FamilyOf(model).Name; got != want { |
| @@ -66,4 +74,37 @@ func TestFamilyOf(t *testing.T) { | ||
| 66 | 74 | if got := p.FamilyOf("custom:latest", "qwen35").Name; got != "qwen" { |
| 67 | 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 | } |
| @@ -1,6 +1,11 @@ | |||
| 1 | package agentinit | 1 | 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 { | ||
| 26 | 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 | 27 | fmt.Fprintf(&b, "# model facts: %s\n", factsLine(s)) |
| 28 | 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 | 35 | fmt.Fprintf(&b, "provider: %s\n", c.Provider) |
| 32 | 36 | for _, line := range wrap(coupledTo(s), 76) { |
| @@ -71,6 +75,12 @@ func Render(s Settings) string { | ||
| 71 | 75 | writeSettings(&b, " ", s.Context) |
| 72 | 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 | 84 | b.WriteString("agentsMd:\n # An AGENTS.md in the project directory is added to the system prompt.\n enabled: true\n\n") |
| 75 | 85 | fmt.Fprintf(&b, "acp:\n name: %s\n title: %s\n\n", strings.ToLower(c.Name), quote(titleOf(c.Name, u))) |
| 76 | 86 | |
| @@ -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 { | ||
| 46 | 46 | // mm init |
| 47 | 47 | // mm init --provider llmman --model hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M \ |
| 48 | 48 | // --usage docs --machine small --name rata -o agent.yaml --yes |
| 49 | +// mm init --yes --provider llmman --model … --project yes --sessions yes --skills yes | |
| 49 | 50 | func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int { |
| 50 | 51 | var o Options |
| 51 | 52 | if len(opts) > 0 { |
| @@ -54,7 +55,7 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | ||
| 54 | 55 | fs := flag.NewFlagSet("mm init", flag.ContinueOnError) |
| 55 | 56 | fs.SetOutput(stdout) |
| 56 | 57 | var c Choices |
| 57 | - var yolo, output string | |
| 58 | + var yolo, output, project, sessions, skills string | |
| 58 | 59 | var check, yes, line bool |
| 59 | 60 | fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", ")) |
| 60 | 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 | 66 | fs.StringVar(&c.Machine, "machine", "", "small, medium or large") |
| 66 | 67 | fs.StringVar(&c.Name, "name", "", "the agent's name") |
| 67 | 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 | 73 | fs.StringVar(&output, "output", "", "same as -o") |
| 70 | 74 | fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers") |
| 71 | 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 | 93 | on := yolo == "yes" || yolo == "true" |
| 90 | 94 | 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 | 108 | ctx := context.Background() |
| 93 | 109 | flow := NewFlow(ctx, presets, c, output) |
| 94 | 110 | if err := flow.validateGiven(); err != nil { |
| @@ -134,6 +150,13 @@ func finish(flow *Flow, stdout io.Writer, check bool) int { | ||
| 134 | 150 | fmt.Fprint(stdout, text) |
| 135 | 151 | 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 | 160 | write := writeNew |
| 138 | 161 | if flow.Overwrite() { |
| 139 | 162 | write = replaceFile |
| @@ -143,6 +166,10 @@ func finish(flow *Flow, stdout io.Writer, check bool) int { | ||
| 143 | 166 | return 1 |
| 144 | 167 | } |
| 145 | 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 | 173 | if _, err := config.Load(output); err != nil { |
| 147 | 174 | fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err) |
| 148 | 175 | return 1 |
| @@ -151,10 +178,35 @@ func finish(flow *Flow, stdout io.Writer, check bool) int { | ||
| 151 | 178 | if check { |
| 152 | 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 | 182 | 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 | 210 | // validateGiven refuses a flag value no question would accept. |
| 159 | 211 | func (f *Flow) validateGiven() error { |
| 160 | 212 | for _, key := range stepKeys { |
| @@ -46,6 +46,7 @@ type Options struct { | |||
| 46 | // mm init | 46 | // 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 --yes | 48 | // --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 Options | 51 | 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 Choices | 57 | var c Choices |
| 57 | - var yolo, output string | 58 | + var yolo, output, project, sessions, skills string |
| 58 | var check, yes, line bool | 59 | 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 = &on | 94 | 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 0 | 151 | 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 := writeNew | 160 | write := writeNew |
| 138 | if flow.Overwrite() { | 161 | if flow.Overwrite() { |
| 139 | write = replaceFile | 162 | write = replaceFile |
| @@ -143,6 +166,10 @@ func finish(flow *Flow, stdout io.Writer, check bool) int { | |||
| 143 | return 1 | 166 | 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 1 | 175 | 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 0 | 182 | 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) { | ||
| 74 | 74 | dir := t.TempDir() |
| 75 | 75 | out := filepath.Join(dir, "dev.yaml") |
| 76 | 76 | answers := strings.Join([]string{ |
| 77 | + "", // project layout: the default, no | |
| 77 | 78 | "1", // model: the server's only one |
| 78 | 79 | "", // family: the detected one |
| 79 | 80 | "1", // usage: developer |
| 80 | 81 | "2", // machine: medium |
| 81 | 82 | "n", // ask before each change? no → yolo on |
| 83 | + "", // sessions: the default, no | |
| 84 | + "", // skills: the default, no | |
| 82 | 85 | "bob", // name |
| 83 | 86 | out, // file |
| 84 | 87 | }, "\n") + "\n" |
| @@ -127,7 +130,7 @@ func TestMainKeepsAnExistingFileWithoutAnAnswer(t *testing.T) { | ||
| 127 | 130 | } |
| 128 | 131 | |
| 129 | 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 | 135 | func TestMainOverwritesAfterYes(t *testing.T) { |
| 133 | 136 | 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 one | 78 | "1", // model: the server's only one |
| 78 | "", // family: the detected one | 79 | "", // family: the detected one |
| 79 | "1", // usage: developer | 80 | "1", // usage: developer |
| 80 | "2", // machine: medium | 81 | "2", // machine: medium |
| 81 | "n", // ask before each change? no → yolo on | 82 | "n", // ask before each change? no → yolo on |
| 83 | + "", // sessions: the default, no | ||
| 84 | + "", // skills: the default, no | ||
| 82 | "bob", // name | 85 | "bob", // name |
| 83 | out, // file | 86 | 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 { | ||
| 55 | 55 | } |
| 56 | 56 | |
| 57 | 57 | // 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 | |
| 59 | 59 | // not depend on who started mm (a terminal, an editor, another mm). |
| 60 | 60 | func resolveAgentPaths(configPath string) { |
| 61 | - dir := filepath.Dir(configPath) | |
| 61 | + dir := BaseDir(configPath) | |
| 62 | 62 | for name, ref := range Cfg.MMConfigs { |
| 63 | 63 | if ref.Path == "" || filepath.IsAbs(ref.Path) { |
| 64 | 64 | continue |
| @@ -55,10 +55,10 @@ func (c Config) validateAgents() error { | |||
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | // resolveAgentPaths makes every relative mmConfigs path absolute against the | 57 | // 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 |
| 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 | continue | 64 | continue |
modified
internal/config/config.go +18 -2 | @@ -47,6 +47,14 @@ type Config struct { | ||
| 47 | 47 | // default variable; servers that ignore the key need none. |
| 48 | 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 | 58 | // ContextWindow: how many tokens the server actually serves, when the |
| 51 | 59 | // operator knows it (llama-server -c, `docker model configure`). 0 = ask the |
| 52 | 60 | // server, then unknown. Displayed at start-up; the context-compression work |
| @@ -624,8 +632,14 @@ type ContextConfig struct { | ||
| 624 | 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 | 641 | // 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 | 643 | const DefaultPath = "agent.yaml" |
| 630 | 644 | |
| 631 | 645 | // DefaultACPTitle: acp.title when the file does not set one. |
| @@ -768,7 +782,7 @@ func Load(cliPath string) (string, error) { | ||
| 768 | 782 | path = os.Getenv("AGENT_CONFIG") |
| 769 | 783 | } |
| 770 | 784 | if path == "" { |
| 771 | - path, explicit = DefaultPath, false | |
| 785 | + path, explicit = defaultPath(), false | |
| 772 | 786 | } |
| 773 | 787 | |
| 774 | 788 | data, err := os.ReadFile(path) |
| @@ -935,6 +949,8 @@ func (c Config) validate() error { | ||
| 935 | 949 | return errors.New("model must not be empty") |
| 936 | 950 | case c.ContextWindow < 0: |
| 937 | 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 | 954 | case c.FirstTokenTimeout < 0: |
| 939 | 955 | return errors.New("firstTokenTimeout must be >= 0") |
| 940 | 956 | 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 the | 58 | // ContextWindow: how many tokens the server actually serves, when the |
| 51 | // operator knows it (llama-server -c, `docker model configure`). 0 = ask the | 59 | // operator knows it (llama-server -c, `docker model configure`). 0 = ask the |
| 52 | // server, then unknown. Displayed at start-up; the context-compression work | 60 | // 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 nor | 641 | // 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, false | 785 | + 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 { | ||
| 289 | 289 | return nil |
| 290 | 290 | } |
| 291 | 291 | |
| 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. | |
| 294 | 294 | func resolveDecider(configPath string) { |
| 295 | 295 | p := Cfg.SkillRouting.Decider |
| 296 | 296 | if p == "" || filepath.IsAbs(p) { |
| 297 | 297 | 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 | 300 | Cfg.SkillRouting.Decider = abs |
| 301 | 301 | } |
| 302 | 302 | } |
| @@ -289,14 +289,14 @@ func (s SkillRoutingConfig) validate() error { | |||
| 289 | return nil | 289 | return nil |
| 290 | } | 290 | } |
| 291 | 291 | ||
| 292 | -// resolveDecider makes skillRouting.decider absolute against the directory | 292 | +// 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.Decider | 295 | p := Cfg.SkillRouting.Decider |
| 296 | if p == "" || filepath.IsAbs(p) { | 296 | if p == "" || filepath.IsAbs(p) { |
| 297 | return | 297 | 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 = abs | 300 | 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 { | ||
| 256 | 256 | } |
| 257 | 257 | |
| 258 | 258 | // 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 | 260 | 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) | |
| 264 | 262 | for _, event := range hookEvents { |
| 265 | 263 | list := Cfg.Hooks.For(event) |
| 266 | 264 | 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) and | 258 | // 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 { | ||
| 142 | 142 | } |
| 143 | 143 | |
| 144 | 144 | // 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 | 146 | func resolveToolDecider(configPath string) { |
| 147 | 147 | p := Cfg.ToolRouting.Decider |
| 148 | 148 | if p == "" || filepath.IsAbs(p) { |
| 149 | 149 | 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 | 152 | Cfg.ToolRouting.Decider = abs |
| 153 | 153 | } |
| 154 | 154 | } |
| @@ -142,13 +142,13 @@ func (t ToolRoutingConfig) validate() error { | |||
| 142 | } | 142 | } |
| 143 | 143 | ||
| 144 | // resolveToolDecider makes toolRouting.decider absolute against the | 144 | // 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.Decider | 147 | p := Cfg.ToolRouting.Decider |
| 148 | if p == "" || filepath.IsAbs(p) { | 148 | if p == "" || filepath.IsAbs(p) { |
| 149 | return | 149 | 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 = abs | 152 | 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 { | ||
| 295 | 295 | } |
| 296 | 296 | |
| 297 | 297 | // 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 | |
| 299 | 299 | // starts mm and from wherever. |
| 300 | 300 | func resolveToolPaths(configPath string) { |
| 301 | - dir := filepath.Dir(configPath) | |
| 301 | + dir := BaseDir(configPath) | |
| 302 | 302 | for i, t := range Cfg.Tools { |
| 303 | 303 | if t.Wasm == "" || filepath.IsAbs(t.Wasm) { |
| 304 | 304 | 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 against | 297 | // 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 |
| 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 | continue | 304 | continue |
modified
internal/engine/openai_compat.go +5 -0 | @@ -286,6 +286,11 @@ func modelSupports() *ai.ModelSupports { | ||
| 286 | 286 | // others have added their fields, and the response before they read it. |
| 287 | 287 | func middlewares(p *openaiCompat) []option.Middleware { |
| 288 | 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 | 294 | if config.Cfg.Trace.Enabled { |
| 290 | 295 | chain = append(chain, traceMiddleware(config.Cfg.Trace.Dir, config.Cfg.ACP.Name)) |
| 291 | 296 | } |
| @@ -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, "ed) == 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, "ed) == 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 ( | ||
| 14 | 14 | "strings" |
| 15 | 15 | "time" |
| 16 | 16 | |
| 17 | + "mm/internal/config" | |
| 17 | 18 | "mm/internal/scaffold" |
| 18 | 19 | |
| 19 | 20 | "gopkg.in/yaml.v3" |
| @@ -56,11 +57,13 @@ func LatestKitVersion(client *http.Client, url string) (string, error) { | ||
| 56 | 57 | |
| 57 | 58 | // Render is the content of sbxenv.yaml: name is the sandbox's, already clean |
| 58 | 59 | // (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") | |
| 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`"+`. | |
| 64 | 67 | # %[2]s |
| 65 | 68 | schemaVersion: "1" |
| 66 | 69 | |
| @@ -74,15 +77,16 @@ kits: | ||
| 74 | 77 | - docker.io/k33g/mini-me-kit:%[3]s |
| 75 | 78 | |
| 76 | 79 | workspace: %[4]s |
| 77 | - | |
| 80 | +%[5]s | |
| 78 | 81 | # Pull the kit at each creation: a tag such as latest moves, and a kit already |
| 79 | 82 | # in the local cache would hide a new one. |
| 80 | 83 | sandboxOptions: |
| 81 | 84 | pullPolicy: always |
| 82 | 85 | |
| 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 | |
| 86 | 90 | } |
| 87 | 91 | |
| 88 | 92 | // 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 | 115 | fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError) |
| 112 | 116 | fs.SetOutput(stdout) |
| 113 | 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 | 119 | workspace := fs.String("workspace", ".", "the folder the sandbox mounts, relative to the current directory or absolute") |
| 116 | 120 | yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten") |
| 117 | 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 | 134 | 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 | 146 | chosen := *name |
| 135 | 147 | if chosen == "" { |
| 136 | 148 | chosen = scaffold.AgentName(agentPath, stdout) |
| 137 | 149 | } |
| 138 | 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 | 152 | wrote, err := scaffold.Write(files, stdin, stdout, *yes) |
| 141 | 153 | if err != nil { |
| 142 | 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 | 157 | if wrote { |
| 146 | 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 | 171 | return 0 |
| 150 | 172 | } |
| @@ -160,14 +182,23 @@ func kitTag(latest func() (string, error), stdout io.Writer) string { | ||
| 160 | 182 | } |
| 161 | 183 | |
| 162 | 184 | // 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) { | |
| 166 | 190 | return agentFile |
| 167 | 191 | } |
| 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 | + } | |
| 171 | 202 | } |
| 172 | 203 | return agentFile |
| 173 | 204 | } |
| @@ -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 clean | 58 | // 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 absolute | 59 | // (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]s | 67 | # %[2]s |
| 65 | schemaVersion: "1" | 68 | schemaVersion: "1" |
| 66 | 69 | ||
| @@ -74,15 +77,16 @@ kits: | |||
| 74 | - docker.io/k33g/mini-me-kit:%[3]s | 77 | - docker.io/k33g/mini-me-kit:%[3]s |
| 75 | 78 | ||
| 76 | workspace: %[4]s | 79 | workspace: %[4]s |
| 77 | - | 80 | +%[5]s |
| 78 | # Pull the kit at each creation: a tag such as latest moves, and a kit already | 81 | # 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: always | 84 | pullPolicy: always |
| 82 | 85 | ||
| 83 | -# sbx env create --force | 86 | +# sbx env create --force%[6]s |
| 84 | -# sbx env rm --force | 87 | +# 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 and | 92 | // 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 1 | 134 | 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 := *name | 146 | 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 0 | 171 | 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, where | 184 | // 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 agentFile | 190 | 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 inWorkspace | 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 | + } | ||
| 171 | } | 202 | } |
| 172 | return agentFile | 203 | return agentFile |
| 173 | } | 204 | } |
modified
internal/sbxinit/sbxinit_test.go +128 -3 | @@ -34,7 +34,7 @@ func parse(t *testing.T, text string) envFile { | ||
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 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 | 38 | if got.SchemaVersion != "1" || got.Name != "bob-sbx" || got.Agent != "mini-me-kit" || got.Workspace != "/a/b" { |
| 39 | 39 | t.Errorf("%+v", got) |
| 40 | 40 | } |
| @@ -47,14 +47,14 @@ func TestRenderIsWhatSbxExpects(t *testing.T) { | ||
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 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 | 51 | t.Errorf("another kit is mentioned:\n%s", text) |
| 52 | 52 | } |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | 55 | func TestRenderKeepsAWorkspaceWithSpecialCharacters(t *testing.T) { |
| 56 | 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 | 58 | t.Errorf("workspace = %q, want %q", got, path) |
| 59 | 59 | } |
| 60 | 60 | } |
| @@ -255,3 +255,128 @@ func TestTheDocumentationLinkNamesAFileOfTheRepository(t *testing.T) { | ||
| 255 | 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 | +} | |
| @@ -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 { | ||
| 202 | 202 | // words per skill helps a small model match, thirty would crowd out the task. |
| 203 | 203 | 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 | 209 | // Names lists the skill names, for an error message that helps the model |
| 206 | 210 | // recover from a typo. |
| 207 | 211 | 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 = 8 | 203 | 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 model | 209 | // 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) { | ||
| 44 | 44 | (&sink{r: r}).Spoken() |
| 45 | 45 | switch { |
| 46 | 46 | case res.err != nil && res.cancelled: |
| 47 | - r.t.Notice("🛑 Ask aborted — nothing kept.") | |
| 47 | + r.t.Notice("🔴 Ask aborted — nothing kept.") | |
| 48 | 48 | case res.err != nil: |
| 49 | 49 | r.t.Error("[error: " + res.err.Error() + "]") |
| 50 | 50 | 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() { | ||
| 67 | 67 | case !r.isBusy(): |
| 68 | 68 | r.t.Notice("⚠️ No generation in progress.") |
| 69 | 69 | case r.cancelTurn(): |
| 70 | - r.t.Notice("🛑 Aborting…") | |
| 70 | + r.t.Notice("🔴 Aborting…") | |
| 71 | 71 | default: |
| 72 | 72 | r.t.Notice("⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.") |
| 73 | 73 | } |
| @@ -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 ( | ||
| 7 | 7 | |
| 8 | 8 | "mm/internal/agentinit" |
| 9 | 9 | "mm/internal/elicit" |
| 10 | + "mm/internal/skillcreate" | |
| 10 | 11 | |
| 11 | 12 | "github.com/gdamore/tcell/v2" |
| 12 | 13 | ) |
| @@ -30,10 +31,23 @@ func RunInit(_ context.Context, flow *agentinit.Flow) (bool, error) { | ||
| 30 | 31 | 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 | 45 | // initWin is the wizard's window. |
| 34 | 46 | type initWin struct { |
| 35 | 47 | 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 | |
| 37 | 51 | cursor int // the step being answered, in flow.Steps() |
| 38 | 52 | sel map[string]int // the highlighted option, per step |
| 39 | 53 | text map[string]*field // what is typed for a Text step, with its caret |
| @@ -49,11 +63,67 @@ type initWin struct { | ||
| 49 | 63 | const previewMinWidth = 100 |
| 50 | 64 | |
| 51 | 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 | 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 | 124 | for w.result == 0 { |
| 55 | 125 | w.draw() |
| 56 | - ev := screen.PollEvent() | |
| 126 | + ev := w.screen.PollEvent() | |
| 57 | 127 | if ev == nil { |
| 58 | 128 | return false |
| 59 | 129 | } |
| @@ -61,7 +131,7 @@ func runInit(screen tcell.Screen, flow *agentinit.Flow) bool { | ||
| 61 | 131 | case *tcell.EventKey: |
| 62 | 132 | w.handleKey(e) |
| 63 | 133 | case *tcell.EventResize: |
| 64 | - screen.Sync() | |
| 134 | + w.screen.Sync() | |
| 65 | 135 | } |
| 66 | 136 | } |
| 67 | 137 | return w.result == 1 |
| @@ -103,6 +173,9 @@ func (w *initWin) initSelection(st agentinit.Step) { | ||
| 103 | 173 | } |
| 104 | 174 | } |
| 105 | 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 | 179 | if _, ok := w.text[st.Key]; !ok { |
| 107 | 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 | 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 | 246 | f := w.text[st.Key] |
| 178 | 247 | if f == nil { |
| 179 | 248 | f = newField("") |
| 180 | 249 | 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 | 265 | // 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 | 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 | 318 | // key edits the text; it reports whether the key was an editing key. |
| 206 | 319 | func (f *field) key(ev *tcell.EventKey) bool { |
| 207 | 320 | switch ev.Key() { |
| 208 | 321 | 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()) | |
| 211 | 323 | case tcell.KeyBackspace, tcell.KeyBackspace2: |
| 212 | 324 | if f.caret > 0 { |
| 213 | 325 | f.runes = append(f.runes[:f.caret-1], f.runes[f.caret:]...) |
| @@ -290,15 +402,7 @@ func (w *initWin) draw() { | ||
| 290 | 402 | |
| 291 | 403 | func (w *initWin) drawHeader(p painter) { |
| 292 | 404 | 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) | |
| 302 | 406 | } |
| 303 | 407 | |
| 304 | 408 | func (w *initWin) drawStatus(p painter) { |
| @@ -307,6 +411,8 @@ func (w *initWin) drawStatus(p painter) { | ||
| 307 | 411 | switch { |
| 308 | 412 | case w.confirm: |
| 309 | 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 | 416 | case w.current().Kind == elicit.Text: |
| 311 | 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 | 466 | func (w *initWin) drawQuestion(p painter) { |
| 361 | 467 | 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) | |
| 364 | 469 | p.textLimited(1, 2, p.width()-2, "Every question is answered: the file", stBody) |
| 365 | 470 | p.textLimited(1, 3, p.width()-2, "on the right is what will be written.", stBody) |
| 366 | 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 | 474 | st := w.current() |
| 370 | 475 | p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel) |
| 476 | + if st.Multiline { | |
| 477 | + w.drawLines(p, st) | |
| 478 | + return | |
| 479 | + } | |
| 371 | 480 | if st.Kind == elicit.Text { |
| 372 | 481 | f := w.text[st.Key] |
| 373 | 482 | if f == nil { |
| @@ -377,7 +486,10 @@ func (w *initWin) drawQuestion(p painter) { | ||
| 377 | 486 | x := p.text(1, 2, "› "+string(f.runes[:f.caret]), stBody) |
| 378 | 487 | p.text(x, 2, string(f.runes[f.caret:]), stBody) |
| 379 | 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 | 493 | p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice) |
| 382 | 494 | } |
| 383 | 495 | 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 | 541 | // optionLabel is how an option is listed. A model is listed by the |
| 413 | 542 | // header's short name: its identifier is an address of seventy characters, |
| 414 | 543 | // 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), nil | 31 | 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.Screen | 47 | 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 | ||
| 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 step | 52 | sel map[string]int // the highlighted option, per step |
| 39 | text map[string]*field // what is typed for a Text step, with its caret | 53 | text map[string]*field // what is typed for a Text step, with its caret |
| @@ -49,11 +63,67 @@ type initWin struct { | |||
| 49 | const previewMinWidth = 100 | 63 | 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 false | 128 | 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 == 1 | 137 | 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] = f | 249 | 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, a | 265 | // 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 | return | 495 | 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 the | 541 | // 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 ( | ||
| 11 | 11 | "testing" |
| 12 | 12 | |
| 13 | 13 | "mm/internal/agentinit" |
| 14 | + "mm/internal/skillcreate" | |
| 14 | 15 | |
| 15 | 16 | "github.com/gdamore/tcell/v2" |
| 16 | 17 | ) |
| @@ -34,7 +35,21 @@ func initFlow(t *testing.T, output string) *agentinit.Flow { | ||
| 34 | 35 | if err != nil { |
| 35 | 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 | 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 | 61 | screen.SetSize(w, h) |
| 47 | 62 | 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 | |
| 51 | 64 | } |
| 52 | 65 | |
| 53 | 66 | func screenOf(win *initWin, screen tcell.SimulationScreen) string { |
| @@ -186,7 +199,7 @@ func TestInitWindowListsModelsByShortName(t *testing.T) { | ||
| 186 | 199 | t.Errorf("the detail does not follow the highlighted model:\n%s", shown) |
| 187 | 200 | } |
| 188 | 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 | 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 | 272 | if err != nil { |
| 260 | 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 | 277 | flow := agentinit.NewFlow(context.Background(), presets, given, out) |
| 264 | 278 | |
| 265 | 279 | win, screen := newInitWin(t, flow, 100, 30) |
| @@ -282,3 +296,121 @@ func TestInitWindowAsksBeforeOverwriting(t *testing.T) { | ||
| 282 | 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 | +} | |
| @@ -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) { | ||
| 348 | 348 | r.t.Close() |
| 349 | 349 | switch { |
| 350 | 350 | case res.err != nil && res.cancelled: |
| 351 | - r.t.Notice("🛑 Generation aborted.") | |
| 351 | + r.t.Notice("🔴 Generation aborted.") | |
| 352 | 352 | case res.err != nil: |
| 353 | 353 | explained := r.opts.Model.Explain(res.err) |
| 354 | 354 | 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) { | ||
| 205 | 205 | } |
| 206 | 206 | waitIdle(t, r) |
| 207 | 207 | waitIdle(t, r) |
| 208 | - if !strings.Contains(notices(tr), "🛑 Generation aborted.") { | |
| 208 | + if !strings.Contains(notices(tr), "🔴 Generation aborted.") { | |
| 209 | 209 | t.Errorf("notices:\n%s", notices(tr)) |
| 210 | 210 | } |
| 211 | 211 | 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 ( | ||
| 53 | 53 | "mm/internal/lsp" |
| 54 | 54 | "mm/internal/mcp" |
| 55 | 55 | "mm/internal/sbxinit" |
| 56 | + "mm/internal/skillcreate" | |
| 56 | 57 | "mm/internal/skills" |
| 57 | 58 | "mm/internal/spinner" |
| 58 | 59 | "mm/internal/tools" |
| @@ -90,6 +91,11 @@ func main() { | ||
| 90 | 91 | if len(os.Args) > 1 && isSbxInit(os.Args[1]) { |
| 91 | 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 | 99 | // `mm tui` and `mm acp` (ticket 117): the modes as words too, like |
| 94 | 100 | // `mm init` — `mm tui agent.yaml` is `mm -tui agent.yaml`. |
| 95 | 101 | os.Args = modeWordAsFlag(os.Args) |
| @@ -124,7 +130,7 @@ func main() { | ||
| 124 | 130 | // Replace os.Args for flag.Parse() |
| 125 | 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 | 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 | 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 | 136 | promptMode := flag.String("prompt", "", "run a single prompt and exit") |
| @@ -134,6 +140,7 @@ func main() { | ||
| 134 | 140 | // Listed for -h only: `mm --init` is answered before flag.Parse. |
| 135 | 141 | flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)") |
| 136 | 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 | 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 | 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 | 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 | 815 | // resolveSkillsDir makes the skills directory absolute. An absolute dir is |
| 809 | 816 | // 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. | |
| 813 | 820 | func resolveSkillsDir(dir, configPath string, acpMode bool) string { |
| 814 | 821 | if filepath.IsAbs(dir) { |
| 815 | 822 | return dir |
| 816 | 823 | } |
| 817 | 824 | anchor := "." |
| 818 | 825 | if acpMode && configPath != "" { |
| 819 | - anchor = filepath.Dir(configPath) | |
| 826 | + anchor = config.BaseDir(configPath) | |
| 820 | 827 | } |
| 821 | 828 | abs, err := filepath.Abs(filepath.Join(anchor, dir)) |
| 822 | 829 | if err != nil { |
| @@ -886,6 +893,12 @@ func isInit(arg string) bool { | ||
| 886 | 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 | 902 | // isDockerInit reports whether the first argument asks for the container files: |
| 890 | 903 | // `mm dockerinit`, `mm --dockerinit` or `mm -dockerinit`. |
| 891 | 904 | 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, like | 99 | // `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 is | 815 | // 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 there | 816 | // 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 | 817 | +// is one (the editor's working directory is not the project's) — its |
| 811 | -// current directory otherwise — for the terminal, that is where the user | 818 | +// 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 dir | 822 | 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 | +} | ||