1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
|
# Reference: built-in tools
> Neutral description of the tools declared to the model. A tool never returns a Go error for an ordinary failure: the failure text is returned to the model as the tool's output. Every tool records its call in the loop detector; the same tool, input and output three times in a row appends `[LOOP_DETECTED: …]` to the output.
## `bash`
Declared when `bashTool: true` (the default).
| Field | Type | Description |
|-------|------|-------------|
| `command` | string | The shell command to run. |
| Aspect | Behaviour |
|--------|-----------|
| Shell | `bash -c <command>`, a new shell per call, no stdin. |
| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. |
| Timeout | 30 s; the output then ends with `[error: 30s timeout exceeded]`. |
| Cancellation | The command belongs to the turn: cancelling the turn (`Esc`, `Ctrl-C`, `/abort`, or asking something else) kills it, and the output ends with `[error: cancelled]`. |
| Background jobs | The output is collected through a temporary file, not a pipe, so the tool returns as soon as the shell exits even when the command left a job running (`nohup serve &`). The job keeps running — that is usually the point — and writes into a file nobody reads. Through a pipe, `os/exec` waits for *every* process holding the write end, so such a command blocked the turn for ever. |
| Very large output | Read back from both ends (1 MiB each) before `maxOutput` truncation, with `[... output too large to read back in full ...]` between them. |
| Output | stdout and stderr merged, truncated to `maxOutput` characters. |
| Non-zero exit | Output ends with `[exit code: exit status N]`. |
| Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. |
| ACP | Tool call of kind `execute`; permission requested before running; a refusal returns `[command rejected by the user]` with status `failed`. |
| Window mode | One `‣ bash <command>` line with status, time and folded output. With `permissions: ask`, waits for `a`/`s`/`r`; a refusal returns the same text as in ACP. |
## `read_skill`
Declared only when `skillsDir` holds at least one skill (`<name>.md` or `<name>/SKILL.md`).
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Skill name, exactly as listed in the tool description. |
| Aspect | Behaviour |
|--------|-----------|
| Description | Fixed sentence followed by the catalogue: one ` <name> — <description>` line per skill, sorted by name. |
| Output | The full markdown of `<skillsDir>/<name>.md`, or of `<skillsDir>/<name>/SKILL.md` when there is no flat file, truncated to `maxOutput`. |
| Unknown name | `No skill named "x". Available skills: a, b, c` |
| Path safety | Only the base name of `name` is used; no directory traversal. |
| Terminal display | `📖 read_skill: <name>`; the content is not echoed. |
| ACP | Tool call of kind `read`; no permission dialog. |
## `read_file`
Declared when `editTools: true`.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `path` | string | — | File path, relative to the working directory. |
| `start` | int | `0` | First line (1-based); `0` = from the start. |
| `end` | int | `0` | Last line (1-based, inclusive); `0` = to the end. |
| `numbered` | bool | `false` | Prefix each line with its number. |
| Aspect | Behaviour |
|--------|-----------|
| Missing file | `<path> does not exist. Create it with write_file` |
| Output | The lines, truncated to `maxOutput`; the final newline is kept only when the end of the file is shown. |
| ACP | Kind `read`, with the absolute path as location. |
## `write_file`
Declared when `editTools: true`.
| Field | Type | Description |
|-------|------|-------------|
| `path` | string | File path, relative to the working directory; parent directories are created. |
| `content` | string | The whole content of the file. |
| Aspect | Behaviour |
|--------|-----------|
| Existing file | Entirely replaced. |
| Identical content | Nothing is written; the result says so. |
| Output | A one-line headline and the diff against the previous content. |
| ACP | Kind `edit`; permission requested; the change travels as a diff (new-file diff when created). |
## `edit_file`
Declared when `editTools: true`.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `path` | string | — | Path of an existing file. |
| `edits` | array of `{old, new}` | — | Replacements, all resolved against the original file. |
| `edits[].old` | string | — | Exact text to find, present exactly once. Whitespace is forgiven — see *How `old` is matched*. |
| `edits[].new` | string | — | Replacement; empty deletes the matched text. |
| `dry_run` | bool | `false` | Report what would change, write nothing. |
| Rule | Consequence when broken |
|------|-------------------------|
| `old` appears exactly once | Refused: not found, or ambiguous — `old text appears N times (lines 11, 20) — to change ONE of them, include its surrounding lines in old until the text is unique; to change several, send one edit per place…`. |
| Two edits must not overlap, nest or repeat | Refused. |
| Edits are resolved against the original text, never in cascade | — |
| Any refused edit | Nothing is written. |
| Aspect | Behaviour |
|--------|-----------|
| Output | A one-line headline (edits applied, lines added and deleted, first changed line, and ` · N matched ignoring whitespace` when any edit needed a relaxed match) and the diff with line numbers. |
| ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |
### How `old` is matched
Three passes, strictest first; **the first pass that finds anything at all wins**, so a file that contains the text verbatim is never matched any other way.
| Pass | What it forgives | Reported as |
|---|---|---|
| 1. Exact | Nothing. `old` is searched byte for byte. | — |
| 2. Edges | The whitespace *around* `old`: an indent the model kept on a single line, a leading or trailing newline. The same padding comes off `new` when `new` carries it, so a `new` that deliberately adds a blank line still adds it. | `matched ignoring whitespace` |
| 3. Indent | Every line's leading and trailing whitespace, for an `old` spanning two lines or more. This is the tabs-versus-spaces case: a model retypes a Go block with four spaces where the file has a tab. | `matched ignoring whitespace` |
Two things are **not** relaxed:
- **Uniqueness.** A loose match must still be the only one. Two of them are refused exactly as two exact ones are, with the same message and the same line numbers — that rule is what makes an edit verifiable.
- **The file's indentation.** Before writing, `new` is re-indented to the file's own base indent and unit, so a tab-indented file does not end up with a block of spaces in the middle of it. A blank line stays empty rather than becoming a line of spaces.
The headline then ends with ` · N matched ignoring whitespace`. It is reported rather than hidden: a model told its text did not match exactly writes the next one better.
When nothing matches, the refusal shows the region of the file that came closest, with real line numbers — half the lines have to line up before an excerpt is offered, since pointing at an innocent part of the file is worse than saying nothing:
```
main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:
│ func main() {
│ count := 2
│ print(count)
│ }
the closest the file comes to it — copy these lines:
│ 3 │ func main() {
│ 4 │ count := 1
│ 5 │ print(count)
│ 6 │ }
```
## `lsp`
Declared when `lsp.servers` lists at least one server whose command is on the `PATH` at start-up. A listed server that is not found is skipped with a `[warning: lsp server <name> (<command>) not found on the PATH — skipped]`; when none is found the tool is not declared and the banner says so.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `action` | string | — | `definition`, `references`, `hover`, `symbols`, `diagnostics` or `rename`. |
| `path` | string | — | File path, relative to the working directory. Its extension picks the server. |
| `symbol` | string | `""` | The identifier, as written in the code. Stray quotes and punctuation are cut off and a `receiver.` or `pkg.` prefix dropped (`Greet', ` and `person.Greet` both mean `Greet`). Its occurrences in the file are tried in order — code lines first, comment lines last — until the server answers. For `definition`, `references`, `hover`, `rename`. |
| `line` | int | `0` | 1-based line. With `symbol`: restricts the search to that line. With `column`: the exact position. |
| `column` | int | `0` | 1-based column, in characters, pointing inside the identifier. Only needed without `symbol`. |
| `new_name` | string | `""` | The new identifier, for `rename`. Cleaned like `symbol`. When it is empty and `symbol` holds several words (`Greet 1 12 Hello` — a small model writing symbol, line, column and new name into one string), the first identifier is the symbol and the last the new name. |
| Aspect | Behaviour |
|--------|-----------|
| Description | Names the servers and their extensions (`Servers available: gopls (.go), …`) and the six actions. |
| `definition`, `references` | One `path:line:column <source line>` per location, sorted by path then position, duplicates removed; paths inside the project are relative, others absolute. `references` is prefixed with `N reference(s):` and includes the declaration. |
| `hover` | The server's text as it sent it (signature, type, documentation; Markdown for most servers). |
| `symbols` | One `<kind> <name> [<detail>] line N` per symbol, children indented two spaces. |
| `diagnostics` | The file is synced, then the call waits up to 3 s for the server's publication. `N diagnostic(s) in <path>:` then one `<severity> path:line:column <message> [<source>]` per finding; `no diagnostics: <path> is clean`; ` (as last published)` when the file did not change since the server last spoke; `no diagnostics received for <path> within 3s (…)` when it never did. |
| `rename` | Applies the server's edits to the files on disk (UTF-16 positions converted), tells the server the new contents, and answers `renamed to <new>: N edit(s) in M file(s)` followed by one `path: N edit(s)` line per file. Without `new_name`: `[lsp: rename needs new_name — call lsp again with {"action": "rename", "path": …, "symbol": …, "new_name": "<the new identifier>"}; do not use edit_file for a rename]`. |
| Positions | 1-based lines and columns at the tool's boundary, 0-based UTF-16 on the wire. A position past the end of a line or of the file is refused with the count. |
| File sync | Before every request the file is read from disk and sent as `didOpen` (first time) or a whole-document `didChange` (when its content moved): edits made with `edit_file` are seen without any extra step. |
| Server lifecycle | Started on the first call for one of its extensions, in the project root (the directory `mm` runs in), kept for the session, stopped (`shutdown`, `exit`, then the process) when `mm` exits. A server that died is started again on the next call. |
| Failures | Always text, never a Go error: `[lsp: no language server for "x.md" — configured: gopls (.go)]`, `[lsp: "foo" not found in main.go]`, `[lsp: column 40 is past the end of line 3 (15 characters)]`, `[lsp: gopls: hover failed: …]`, `[lsp: no definition found for the symbol at main.go:5:4]`. |
| Terminal display | `🔎 lsp: definition main.go greet` (or `…:9:14` with a position, `rename main.go greet → hello`), then the answer echoed like a command's output. |
| ACP / window | Kind `read` for navigation, no permission; `rename` is kind `edit` and asks for permission like `edit_file` (`[rename rejected by the user]` when refused). |
| Counter | `🔎 N lsp call(s)` in the recap line (see below). |
## `fetch_content`
Declared only with `fetchContentTool: true`. Downloads a web page and returns it as Markdown, or saves the Markdown to a file. Package `internal/webmd`.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `url` | string | — | The page. A missing scheme means `https://` (`k33g.org`); a scheme typed without its colon is repaired (`https//k33g.org`). Only `http` and `https`. |
| `output` | string | `""` | File to save the Markdown to, relative to the working directory (the session `cwd` in ACP); parent directories are created. When set, the Markdown is not returned. |
| `baseUrl` | string | page URL | Absolute URL against which relative links and image sources are resolved. Default: the page's final URL, after redirections. |
| `tables` | string | `html` | Tables with `rowspan`/`colspan` or a nested table: `html` keeps them as raw HTML, `flatten` makes a GFM table and copies a spanned cell into every slot it covers. |
| `strikethrough` | bool | `false` | `<del>`, `<s>`, `<strike>` → `~~text~~`; otherwise the plain text. |
| `taskList` | bool | `false` | A list item starting with a checkbox → `- [ ] …` / `- [x] …`; otherwise the checkbox is dropped. |
| Aspect | Behaviour |
|--------|-----------|
| Request | `GET` with `User-Agent: mm/<version>`; at most 10 redirections; 30 s for the whole fetch; pages above 5 MiB refused. |
| robots.txt | Read (and cached 24 h per origin) before the page and before every redirection target, for the product token `mm`, else the `*` group. `Allow`/`Disallow`, longest match wins, `Allow` wins a tie, `*` and a final `$` supported (RFC 9309). A 4xx robots.txt allows everything; a 5xx or an unreachable one forbids everything. |
| Accepted types | `text/html`, `application/xhtml+xml`; no `Content-Type` → sniffed. |
| Encoding | In order: byte-order mark, `charset` of the `Content-Type` header, `<meta charset>` or `<meta http-equiv="Content-Type">` in the first 4 KiB, UTF-8 if the bytes are valid UTF-8, else `windows-1252`. An unknown name is skipped. The content is converted to UTF-8. |
| Removed | `script`, `style`, `noscript`, `template`, `meta`, `link`, `base`, `title`, `svg`, `canvas`, `iframe`, `object`, `embed`, `nav`, `aside`, `dialog`, `button`, `select`, `textarea`, inputs other than checkboxes, a `footer` outside `article`/`main`, HTML comments, ARIA roles `navigation` `banner` `complementary` `search` `contentinfo` `dialog`, `aria-hidden="true"`, `hidden`, class/id words `ad` `ads` `advert` `sponsor` `promo` `cookie` `consent` `gdpr` `newsletter` `subscribe` `popup` `modal` `share` `social` `breadcrumb` `nav` `navbar` `navigation` `menu` `sidebar` `skiplink` (and a few variants), classes `sr-only` `visually-hidden` `screen-reader-text`. An element matched by class/id is kept when it contains `main`, `article` or `h1`. |
| Attributes | All removed except `a[href]`, `img[src,alt]`, `td/th[colspan,rowspan]`, `ol[start]`, `input[type,checked]`, and the `language-*`/`lang-*` words of a `pre`/`code` class. |
| Conversion | `h1`–`h6` → `#`–`######`; `p` → paragraph, blank line between blocks; `strong`/`b` → `**…**`; `em`/`i` → `*…*`; inline `code`/`kbd`/`samp`/`tt` → backtick span; `pre` → fenced block (info string from `language-x`), fence longer than any backtick run inside; `br` → hard break `\`; `hr` → `---`; `blockquote` → `> `; `a` → `[text](url)`, the URL when there is no text, text only for `javascript:`; `img` → ``, `` without alt, alt only for a `data:` URI; `ul`/`ol` → `- ` / `N. ` (from `start`), nested lists indented by the parent marker's width; simple tables → GFM, first row as header, `|` escaped, `<br>` for line breaks in a cell, `<caption>` as a paragraph above. |
| Unknown element | Tags dropped, text kept; one that contains a block element is treated as a container. |
| Empty element | Nothing, no blank line. |
| Invalid HTML | Repaired by the HTML5 parser (`golang.org/x/net/html`), then converted. |
| Escaping | Backslash, `*`, backtick, `[`, `]`, `<`, and a `_` not inside a word get a backslash; so does a line starting with `#`, `>`, `-`, `+`, `=`, `\|` or `N.`. |
| Result | The Markdown, ending with exactly one newline; no title, front matter or preamble. Truncated to `maxOutput` characters. |
| With `output` | `[fetch_content: saved N lines of Markdown (B bytes) from <url> to <file> — the content is not shown]`. |
| Page with no content left | `[fetch_content: <url> has no text content once scripts, navigation and ads are removed — nothing saved]`; no file written. |
| Failure | `[fetch_content: error N (<class>) — <reason>]`, see the codes below. |
| Terminal display | `🌐 fetch_content: <url>` (`→ <file>` with `output`), spinner `Fetching`, then the result echoed like a command's output (only the confirmation line with `output`). |
| ACP / window | Kind `fetch`. No permission without `output`; with `output`, permission requested like `write_file` (`[fetch_content rejected by the user]` when refused), and the file's absolute path as location. |
| Code | Class | Cause |
|------|-------|-------|
| `1` | `conversion` | Not an HTML page, larger than 5 MiB, undecodable, or the output file cannot be written. |
| `2` | `input` | Host unreachable, timeout, HTTP status ≥ 300 after redirections, more than 10 redirections, robots.txt forbids the page or cannot be read. |
| `3` | `usage` | Empty or unparsable `url`, a scheme other than `http`/`https`, unknown `tables` value, `baseUrl` not absolute. |
## Loop detection
Every tool records `(tool, input, output)` in the loop detector after each call.
| Situation | Behaviour |
|-----------|-----------|
| The same triple 3 times in a row | The tool's output gets `[LOOP_DETECTED: You have attempted the same action multiple times with the same result. Please try a different approach or provide more information.]` appended. Nothing stops. |
| 3 more identical calls after that (6 in a row) | The tool returns an error instead of text and the turn ends: `[error: the model repeated the same edit_file call 6 times in a row with the same failing result — turn stopped by the loop detector; rephrase the request, or fix what the tool kept reporting]`. The history keeps everything that ran; the error is recorded in the session file's `errors` array (`where: generation`). |
| A different call | The count starts again. `/new` resets the detector; `/compact` does not. |
| A tool call whose streamed arguments repeat a pattern (the last 256 characters are one 4–64-character pattern repeated) or exceed 256 KB | The generation is stopped at once: `[error: the model's tool call ran away: its arguments repeat the same pattern (N characters so far: …) — generation stopped; rephrase the request, or try another model]`. Without it a derailed model composes garbage for minutes while the screen shows "Thinking" (the watchdog is fed by every delta). While a tool call streams, the spinner says `Composing a tool call`. |
The thresholds (3, then 3 more) are set in `main.go`; `maxTurns` remains the outer bound of a turn.
## Counters shown after a turn
| Counter | Counts |
|---------|--------|
| `⚙ N command(s)` | `bash` tool responses in the turn. |
| `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. |
| `📖 N skill(s)` | `read_skill` responses. |
| `🔎 N lsp call(s)` | `lsp` responses. |
|