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

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

Download ZIP

Merge feature/lsp-builtin into main

rickub committed 2026-09-18T18:10:39Z Browse files
4ff5108 parents: 6ff6840 220938a
modified .tickets/issues/0017-add-lsp-support-to-mini-me.yaml +88 -4
@@ -1,16 +1,100 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 17
33 title: Add LSP support to Mini Me
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-17T13:06:27.245Z
9-updatedAt: 2026-09-17T13:36:09.722Z
9+updatedAt: 2026-09-18T18:07:46.008Z
1010 labels:
1111 - priority::urgent
1212 - builtin tool
1313 milestone: v0.0.2
14-body: ""
15-tasks: []
14+body: |
15+ - Specify in the agent configuration file which LSP server(s) to use
16+ - Create a builtin tool for the usage of LSP server(s)
17+ - Create a documentation providing some instruction system samples that helps the agent to use correctly the LSP server
18+ - If the LSP server(s) is/are not available, display a warning message and do not use the builtin
19+
20+ ## Implementation note (Claude, 2026-09-18, branch `feature/lsp-builtin`)
21+
22+ **Config** — `lsp.servers`, a list of `{name, command, args, extensions}`; the file extension picks the server; validation refuses a missing name/command/extensions, an extension without its dot, and an extension served twice. Both shipped YAMLs carry the block commented out; the new `agent.lsp.yaml` is `agent.dmr.yaml` with gopls on, five other servers ready to uncomment (typescript-language-server, pyright, rust-analyzer, clangd, jdtls), and a system prompt section `CODE NAVIGATION (lsp)` that maps user intents to actions.
23+
24+ **Package `internal/lsp`** (on `go.lsp.dev/protocol` + `go.lsp.dev/jsonrpc2` v1.0.1, the user's choice over a hand-rolled client) — one `Client` per project; a server starts on the first call for one of its extensions (initialize/initialized, project root = the directory mm runs in), is kept for the session and stopped at exit (`shutdown`, `exit`, process). Before every request the file is re-read from disk and synced (`didOpen`, then whole-document `didChange` when it moved) so `edit_file` changes are seen. Operations: `Definition`, `References`, `Hover`, `Symbols`, `Diagnostics` (waits up to 3 s for the server's `publishDiagnostics`; stale results labelled `as last published`), `Rename` (applies the `WorkspaceEdit` to disk, UTF-16 → byte conversion, re-syncs), `Locate` (identifier → occurrences, whole word, comment lines last). Answers are text for the model: `path:line:column <source line>`. Positions 1-based at the boundary, 0-based UTF-16 on the wire.
25+
26+ **Tool `lsp`** (`internal/tools/lsp.go`) — one tool, `action` ∈ definition|references|hover|symbols|diagnostics|rename, `path`, `symbol`, `line`, `column`, `new_name`. `symbol` replaces line+column: the first end-to-end run showed the 12B model sending `column: 0` on every call; with `symbol` it answered right in two calls. Occurrences are tried in order until the server answers (misses are `lsp.ErrNoResult`). Failures come back as text. Terminal: `🔎 lsp: definition main.go greet`; ACP/window: kind `read` for navigation, kind `edit` + permission for rename. Recap counter `🔎 N lsp call(s)` (`engine.LSPCalls`) in the REPL, the window and the ACP stderr line.
27+
28+ **Availability** — `main.go`/`assembleLSP`: `exec.LookPath` per server; missing → `[warning: lsp server <name> (<command>) not found on the PATH — skipped]`; none found → `lsp: none of the configured servers is on the PATH — the lsp tool is not declared`. Servers are stopped by `defer set.close()`.
29+
30+ **Skill** — `skills/lsp-navigation/SKILL.md`: when to use it, how to point at a symbol, the actions table, a change procedure (symbols → references → edit → diagnostics → tests).
31+
32+ **Docs EN+FR** — how-to `navigate-code-with-lsp.md` (install table for 7 servers, YAML for 4, the system-prompt block, banner, a table of test prompts and what to expect, ACP/window, variants), explanation `lsp-tool.md`, reference `tools.md` (`lsp` section + counter), `configuration.md` (`lsp` keys + validation errors), architecture table + `packages.drawio` (`internal/lsp`, `go.lsp.dev` nodes and edges), README (`agent.lsp.yaml` bullet).
33+
34+ **Tests** — `internal/lsp` over an in-memory fake server (`protocol.NewServer` on `net.Pipe`): the six operations, refusals, silent server, UTF-16 conversions, `Locate`, `Available`/`Describe`; `internal/tools` dispatch/title/failure-as-text/symbol fallback; `internal/config` LSP validation; `internal/engine` counter. `go test ./...` green. End to end in the sandbox with the host's DMR model (mellum2-12b) and real `gopls` on a scratch module: definition, references, hover, symbols, diagnostics (`declared and not used`), rename (file rewritten, references answered on the new name), through `agent.lsp.yaml`.
35+
36+ **Side effect to know** — `go.lsp.dev/*` v1.0.1 require Go 1.26: `go.mod` went from `go 1.25.0` to `go 1.26` (README and tutorials updated). qlty's golangci-lint plugin runs a Go 1.22 runtime that cannot fetch a 1.26 toolchain in the sandbox, so the errcheck warnings are not measured until that runtime is updated; the smells count is complete (no new one in the LSP code after refactoring).
37+
38+ **Not done** — position encoding negotiation (UTF-16 assumed, fine for ASCII code and handled for accents; wide-char columns on the model's side are runes); workspace symbols (`workspace/symbol`), code actions, type definition, implementation; per-session workspace root over ACP (the root is the directory mm runs in); Python docstrings are not recognised as comment lines by `Locate`.
39+tasks:
40+ - id: 1
41+ title: Config `lsp.servers` (name, command, args, extensions) with validation; commented block in the shipped YAMLs; `agent.lsp.yaml`
42+ state: closed
43+ priority: none
44+ author:
45+ name: k33g
46+ email: ph.charriere@gmail.com
47+ createdAt: 2026-09-18T17:40:00.000Z
48+ updatedAt: 2026-09-18T17:40:00.000Z
49+ closedAt: 2026-09-18T17:40:00.000Z
50+ - id: 2
51+ title: "`internal/lsp` client on go.lsp.dev: lazy start, file sync from disk, definition/references/hover/symbols/diagnostics/rename, Locate by identifier; fake-server tests"
52+ state: closed
53+ priority: none
54+ author:
55+ name: k33g
56+ email: ph.charriere@gmail.com
57+ createdAt: 2026-09-18T17:40:00.000Z
58+ updatedAt: 2026-09-18T17:40:00.000Z
59+ closedAt: 2026-09-18T17:40:00.000Z
60+ - id: 3
61+ title: Builtin tool `lsp` (one tool, `action` field, `symbol` instead of columns, failures as text, rename gated by permission) + `🔎 N lsp call(s)` counter
62+ state: closed
63+ priority: none
64+ author:
65+ name: k33g
66+ email: ph.charriere@gmail.com
67+ createdAt: 2026-09-18T17:40:00.000Z
68+ updatedAt: 2026-09-18T17:40:00.000Z
69+ closedAt: 2026-09-18T17:40:00.000Z
70+ - id: 4
71+ title: "Missing server: warning at start-up and server skipped; no server found: tool not declared"
72+ state: closed
73+ priority: none
74+ author:
75+ name: k33g
76+ email: ph.charriere@gmail.com
77+ createdAt: 2026-09-18T17:40:00.000Z
78+ updatedAt: 2026-09-18T17:40:00.000Z
79+ closedAt: 2026-09-18T17:40:00.000Z
80+ - id: 5
81+ title: Docs EN+FR (how-to with system-prompt samples, install table, test prompts; explanation; tools and configuration references; architecture + drawio), skill `lsp-navigation`
82+ state: closed
83+ priority: none
84+ author:
85+ name: k33g
86+ email: ph.charriere@gmail.com
87+ createdAt: 2026-09-18T17:40:00.000Z
88+ updatedAt: 2026-09-18T17:40:00.000Z
89+ closedAt: 2026-09-18T17:40:00.000Z
90+ - id: 6
91+ title: Verify on macOS with the installed binary and gopls on a real project (definition, references, rename, diagnostics); accept the Go 1.26 floor in go.mod
92+ state: closed
93+ priority: high
94+ author:
95+ name: k33g
96+ email: ph.charriere@gmail.com
97+ createdAt: 2026-09-18T17:40:00.000Z
98+ updatedAt: 2026-09-18T18:07:40.930Z
99+ closedAt: 2026-09-18T18:07:40.930Z
16100 comments: []
@@ -1,16 +1,100 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 172 id: 17
3 title: Add LSP support to Mini Me3 title: Add LSP support to Mini Me
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-17T13:06:27.245Z8 createdAt: 2026-09-17T13:06:27.245Z
9-updatedAt: 2026-09-17T13:36:09.722Z9+updatedAt: 2026-09-18T18:07:46.008Z
10 labels:10 labels:
11 - priority::urgent11 - priority::urgent
12 - builtin tool12 - builtin tool
13 milestone: v0.0.213 milestone: v0.0.2
14-body: ""14+body: |
15-tasks: []15+ - Specify in the agent configuration file which LSP server(s) to use
16+ - Create a builtin tool for the usage of LSP server(s)
17+ - Create a documentation providing some instruction system samples that helps the agent to use correctly the LSP server
18+ - If the LSP server(s) is/are not available, display a warning message and do not use the builtin
19+
20+ ## Implementation note (Claude, 2026-09-18, branch `feature/lsp-builtin`)
21+
22+ **Config** — `lsp.servers`, a list of `{name, command, args, extensions}`; the file extension picks the server; validation refuses a missing name/command/extensions, an extension without its dot, and an extension served twice. Both shipped YAMLs carry the block commented out; the new `agent.lsp.yaml` is `agent.dmr.yaml` with gopls on, five other servers ready to uncomment (typescript-language-server, pyright, rust-analyzer, clangd, jdtls), and a system prompt section `CODE NAVIGATION (lsp)` that maps user intents to actions.
23+
24+ **Package `internal/lsp`** (on `go.lsp.dev/protocol` + `go.lsp.dev/jsonrpc2` v1.0.1, the user's choice over a hand-rolled client) — one `Client` per project; a server starts on the first call for one of its extensions (initialize/initialized, project root = the directory mm runs in), is kept for the session and stopped at exit (`shutdown`, `exit`, process). Before every request the file is re-read from disk and synced (`didOpen`, then whole-document `didChange` when it moved) so `edit_file` changes are seen. Operations: `Definition`, `References`, `Hover`, `Symbols`, `Diagnostics` (waits up to 3 s for the server's `publishDiagnostics`; stale results labelled `as last published`), `Rename` (applies the `WorkspaceEdit` to disk, UTF-16 → byte conversion, re-syncs), `Locate` (identifier → occurrences, whole word, comment lines last). Answers are text for the model: `path:line:column <source line>`. Positions 1-based at the boundary, 0-based UTF-16 on the wire.
25+
26+ **Tool `lsp`** (`internal/tools/lsp.go`) — one tool, `action` ∈ definition|references|hover|symbols|diagnostics|rename, `path`, `symbol`, `line`, `column`, `new_name`. `symbol` replaces line+column: the first end-to-end run showed the 12B model sending `column: 0` on every call; with `symbol` it answered right in two calls. Occurrences are tried in order until the server answers (misses are `lsp.ErrNoResult`). Failures come back as text. Terminal: `🔎 lsp: definition main.go greet`; ACP/window: kind `read` for navigation, kind `edit` + permission for rename. Recap counter `🔎 N lsp call(s)` (`engine.LSPCalls`) in the REPL, the window and the ACP stderr line.
27+
28+ **Availability** — `main.go`/`assembleLSP`: `exec.LookPath` per server; missing → `[warning: lsp server <name> (<command>) not found on the PATH — skipped]`; none found → `lsp: none of the configured servers is on the PATH — the lsp tool is not declared`. Servers are stopped by `defer set.close()`.
29+
30+ **Skill** — `skills/lsp-navigation/SKILL.md`: when to use it, how to point at a symbol, the actions table, a change procedure (symbols → references → edit → diagnostics → tests).
31+
32+ **Docs EN+FR** — how-to `navigate-code-with-lsp.md` (install table for 7 servers, YAML for 4, the system-prompt block, banner, a table of test prompts and what to expect, ACP/window, variants), explanation `lsp-tool.md`, reference `tools.md` (`lsp` section + counter), `configuration.md` (`lsp` keys + validation errors), architecture table + `packages.drawio` (`internal/lsp`, `go.lsp.dev` nodes and edges), README (`agent.lsp.yaml` bullet).
33+
34+ **Tests** — `internal/lsp` over an in-memory fake server (`protocol.NewServer` on `net.Pipe`): the six operations, refusals, silent server, UTF-16 conversions, `Locate`, `Available`/`Describe`; `internal/tools` dispatch/title/failure-as-text/symbol fallback; `internal/config` LSP validation; `internal/engine` counter. `go test ./...` green. End to end in the sandbox with the host's DMR model (mellum2-12b) and real `gopls` on a scratch module: definition, references, hover, symbols, diagnostics (`declared and not used`), rename (file rewritten, references answered on the new name), through `agent.lsp.yaml`.
35+
36+ **Side effect to know** — `go.lsp.dev/*` v1.0.1 require Go 1.26: `go.mod` went from `go 1.25.0` to `go 1.26` (README and tutorials updated). qlty's golangci-lint plugin runs a Go 1.22 runtime that cannot fetch a 1.26 toolchain in the sandbox, so the errcheck warnings are not measured until that runtime is updated; the smells count is complete (no new one in the LSP code after refactoring).
37+
38+ **Not done** — position encoding negotiation (UTF-16 assumed, fine for ASCII code and handled for accents; wide-char columns on the model's side are runes); workspace symbols (`workspace/symbol`), code actions, type definition, implementation; per-session workspace root over ACP (the root is the directory mm runs in); Python docstrings are not recognised as comment lines by `Locate`.
39+tasks:
40+ - id: 1
41+ title: Config `lsp.servers` (name, command, args, extensions) with validation; commented block in the shipped YAMLs; `agent.lsp.yaml`
42+ state: closed
43+ priority: none
44+ author:
45+ name: k33g
46+ email: ph.charriere@gmail.com
47+ createdAt: 2026-09-18T17:40:00.000Z
48+ updatedAt: 2026-09-18T17:40:00.000Z
49+ closedAt: 2026-09-18T17:40:00.000Z
50+ - id: 2
51+ title: "`internal/lsp` client on go.lsp.dev: lazy start, file sync from disk, definition/references/hover/symbols/diagnostics/rename, Locate by identifier; fake-server tests"
52+ state: closed
53+ priority: none
54+ author:
55+ name: k33g
56+ email: ph.charriere@gmail.com
57+ createdAt: 2026-09-18T17:40:00.000Z
58+ updatedAt: 2026-09-18T17:40:00.000Z
59+ closedAt: 2026-09-18T17:40:00.000Z
60+ - id: 3
61+ title: Builtin tool `lsp` (one tool, `action` field, `symbol` instead of columns, failures as text, rename gated by permission) + `🔎 N lsp call(s)` counter
62+ state: closed
63+ priority: none
64+ author:
65+ name: k33g
66+ email: ph.charriere@gmail.com
67+ createdAt: 2026-09-18T17:40:00.000Z
68+ updatedAt: 2026-09-18T17:40:00.000Z
69+ closedAt: 2026-09-18T17:40:00.000Z
70+ - id: 4
71+ title: "Missing server: warning at start-up and server skipped; no server found: tool not declared"
72+ state: closed
73+ priority: none
74+ author:
75+ name: k33g
76+ email: ph.charriere@gmail.com
77+ createdAt: 2026-09-18T17:40:00.000Z
78+ updatedAt: 2026-09-18T17:40:00.000Z
79+ closedAt: 2026-09-18T17:40:00.000Z
80+ - id: 5
81+ title: Docs EN+FR (how-to with system-prompt samples, install table, test prompts; explanation; tools and configuration references; architecture + drawio), skill `lsp-navigation`
82+ state: closed
83+ priority: none
84+ author:
85+ name: k33g
86+ email: ph.charriere@gmail.com
87+ createdAt: 2026-09-18T17:40:00.000Z
88+ updatedAt: 2026-09-18T17:40:00.000Z
89+ closedAt: 2026-09-18T17:40:00.000Z
90+ - id: 6
91+ title: Verify on macOS with the installed binary and gopls on a real project (definition, references, rename, diagnostics); accept the Go 1.26 floor in go.mod
92+ state: closed
93+ priority: high
94+ author:
95+ name: k33g
96+ email: ph.charriere@gmail.com
97+ createdAt: 2026-09-18T17:40:00.000Z
98+ updatedAt: 2026-09-18T18:07:40.930Z
99+ closedAt: 2026-09-18T18:07:40.930Z
16 comments: []100 comments: []
modified .vscode/settings.json +1 -1
@@ -59,5 +59,5 @@
5959 "ui.semanticTokens": true,
6060 "ui.completion.usePlaceholders": true
6161 },
62- "issuespec.groupBy": "label"
62+ "issuespec.groupBy": "milestone"
6363 }
\ No newline at end of file
@@ -59,5 +59,5 @@
59 "ui.semanticTokens": true,59 "ui.semanticTokens": true,
60 "ui.completion.usePlaceholders": true60 "ui.completion.usePlaceholders": true
61 },61 },
62- "issuespec.groupBy": "label"62+ "issuespec.groupBy": "milestone"
63 }63 }
\ No newline at end of file\ No newline at end of file
modified README.md +3 -2
@@ -12,7 +12,7 @@ From there it grew into the coding agent I wanted for local models (Docker Model
1212
1313 - **hallucinations** — the agent shows the real output of every command before the model comments on it, and the prompt forbids stating anything a command did not return;
1414 - **silent failures** — a failing command, a refused edit or a rejected permission goes back to the model as text, so it reads why and tries something else;
15-- **endless loops** — the same action with the same result, repeated, is detected and the model is told to change approach; a watchdog catches a connection that went silent.
15+- **endless loops** — the same action with the same result, repeated, is detected and the model is told to change approach; if it keeps repeating anyway, the turn is stopped and the error is recorded in the session; a watchdog catches a connection that went silent.
1616
1717 What makes Mini Me a bit special is that it was designed from the start to be used **from an editor through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com)**: the same loop runs in a terminal REPL, in a full-screen terminal window, and inside Zed.
1818
@@ -20,7 +20,7 @@ What makes Mini Me a bit special is that it was designed from the start to be us
2020
2121 ## Install
2222
23-You need Go 1.25+ and a model server that can emit tool calls: Docker Model Runner (default) or Llamacpp.
23+You need Go 1.26+ and a model server that can emit tool calls: Docker Model Runner (default) or Llamacpp.
2424
2525 ```bash
2626 git clone https://git.rickub.com/bots-garden/mini-me.git
@@ -97,3 +97,4 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
9797
9898 - [Documentation](docs/README.md) — tutorials, how-to guides, reference and explanations, in English and French.
9999 - The `skills/` directory: Markdown procedures the agent can load with its `read_skill` tool.
100+- `agent.lsp.yaml`: the same agent with a language server behind an `lsp` tool (definition, references, hover, symbols, diagnostics, rename) — see [Navigate and refactor code with a language server](docs/en/how-to/navigate-code-with-lsp.md).
@@ -12,7 +12,7 @@ From there it grew into the coding agent I wanted for local models (Docker Model
12 12
13 - **hallucinations** — the agent shows the real output of every command before the model comments on it, and the prompt forbids stating anything a command did not return;13 - **hallucinations** — the agent shows the real output of every command before the model comments on it, and the prompt forbids stating anything a command did not return;
14 - **silent failures** — a failing command, a refused edit or a rejected permission goes back to the model as text, so it reads why and tries something else;14 - **silent failures** — a failing command, a refused edit or a rejected permission goes back to the model as text, so it reads why and tries something else;
15-- **endless loops** — the same action with the same result, repeated, is detected and the model is told to change approach; a watchdog catches a connection that went silent.15+- **endless loops** — the same action with the same result, repeated, is detected and the model is told to change approach; if it keeps repeating anyway, the turn is stopped and the error is recorded in the session; a watchdog catches a connection that went silent.
16 16
17 What makes Mini Me a bit special is that it was designed from the start to be used **from an editor through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com)**: the same loop runs in a terminal REPL, in a full-screen terminal window, and inside Zed.17 What makes Mini Me a bit special is that it was designed from the start to be used **from an editor through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com)**: the same loop runs in a terminal REPL, in a full-screen terminal window, and inside Zed.
18 18
@@ -20,7 +20,7 @@ What makes Mini Me a bit special is that it was designed from the start to be us
20 20
21 ## Install21 ## Install
22 22
23-You need Go 1.25+ and a model server that can emit tool calls: Docker Model Runner (default) or Llamacpp.23+You need Go 1.26+ and a model server that can emit tool calls: Docker Model Runner (default) or Llamacpp.
24 24
25 ```bash25 ```bash
26 git clone https://git.rickub.com/bots-garden/mini-me.git26 git clone https://git.rickub.com/bots-garden/mini-me.git
@@ -97,3 +97,4 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
97 97
98 - [Documentation](docs/README.md) — tutorials, how-to guides, reference and explanations, in English and French.98 - [Documentation](docs/README.md) — tutorials, how-to guides, reference and explanations, in English and French.
99 - The `skills/` directory: Markdown procedures the agent can load with its `read_skill` tool.99 - The `skills/` directory: Markdown procedures the agent can load with its `read_skill` tool.
100+- `agent.lsp.yaml`: the same agent with a language server behind an `lsp` tool (definition, references, hover, symbols, diagnostics, rename) — see [Navigate and refactor code with a language server](docs/en/how-to/navigate-code-with-lsp.md).
modified agent.dmr.yaml +20 -0
@@ -27,6 +27,26 @@ bashTool: true
2727 # The built-in file tools: read_file, write_file, edit_file.
2828 editTools: true
2929
30+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
31+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
32+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
33+# lsp:
34+# servers:
35+# - name: gopls
36+# command: gopls
37+# extensions: [".go"]
38+# - name: typescript
39+# command: typescript-language-server
40+# args: ["--stdio"]
41+# extensions: [".ts", ".tsx", ".js", ".jsx"]
42+# - name: python
43+# command: pyright-langserver
44+# args: ["--stdio"]
45+# extensions: [".py"]
46+# - name: rust
47+# command: rust-analyzer
48+# extensions: [".rs"]
49+
3050 # Where the `read_skill` tool looks for its markdown procedures.
3151 # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
3252 skillsDir: skills
@@ -27,6 +27,26 @@ bashTool: true
27 # The built-in file tools: read_file, write_file, edit_file. 27 # The built-in file tools: read_file, write_file, edit_file.
28 editTools: true28 editTools: true
29 29
30+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
31+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
32+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
33+# lsp:
34+# servers:
35+# - name: gopls
36+# command: gopls
37+# extensions: [".go"]
38+# - name: typescript
39+# command: typescript-language-server
40+# args: ["--stdio"]
41+# extensions: [".ts", ".tsx", ".js", ".jsx"]
42+# - name: python
43+# command: pyright-langserver
44+# args: ["--stdio"]
45+# extensions: [".py"]
46+# - name: rust
47+# command: rust-analyzer
48+# extensions: [".rs"]
49+
30 # Where the `read_skill` tool looks for its markdown procedures. 50 # Where the `read_skill` tool looks for its markdown procedures.
31 # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.51 # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
32 skillsDir: skills52 skillsDir: skills
modified agent.llamacpp.yaml +20 -0
@@ -23,6 +23,26 @@ bashTool: true
2323 # The built-in file tools: read_file, write_file, edit_file.
2424 editTools: true
2525
26+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
27+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
28+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
29+# lsp:
30+# servers:
31+# - name: gopls
32+# command: gopls
33+# extensions: [".go"]
34+# - name: typescript
35+# command: typescript-language-server
36+# args: ["--stdio"]
37+# extensions: [".ts", ".tsx", ".js", ".jsx"]
38+# - name: python
39+# command: pyright-langserver
40+# args: ["--stdio"]
41+# extensions: [".py"]
42+# - name: rust
43+# command: rust-analyzer
44+# extensions: [".rs"]
45+
2646 # How many tokens the server actually serves.
2747 # 0 = ask the server (llama-server tells it on /props)
2848 contextWindow: 0
@@ -23,6 +23,26 @@ bashTool: true
23 # The built-in file tools: read_file, write_file, edit_file. 23 # The built-in file tools: read_file, write_file, edit_file.
24 editTools: true24 editTools: true
25 25
26+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
27+# The file extension picks the server. A server missing from the PATH is skipped with a warning.
28+# No server listed = no `lsp` tool. Examples (uncomment what you have installed):
29+# lsp:
30+# servers:
31+# - name: gopls
32+# command: gopls
33+# extensions: [".go"]
34+# - name: typescript
35+# command: typescript-language-server
36+# args: ["--stdio"]
37+# extensions: [".ts", ".tsx", ".js", ".jsx"]
38+# - name: python
39+# command: pyright-langserver
40+# args: ["--stdio"]
41+# extensions: [".py"]
42+# - name: rust
43+# command: rust-analyzer
44+# extensions: [".rs"]
45+
26 # How many tokens the server actually serves.46 # How many tokens the server actually serves.
27 # 0 = ask the server (llama-server tells it on /props)47 # 0 = ask the server (llama-server tells it on /props)
28 contextWindow: 048 contextWindow: 0
added agent.lsp.yaml +203 -0
new file mode 100644
@@ -0,0 +1,203 @@
1+# Agent settings — the LSP variant of agent.dmr.yaml.
2+#
3+# Same model and server as agent.dmr.yaml; the difference is the `lsp` block
4+# below (gopls on, the other servers ready to uncomment) and a system prompt
5+# that tells the model WHEN to ask the language server instead of grepping.
6+#
7+# mm agent.lsp.yaml # line mode
8+# mm -tui agent.lsp.yaml # window mode
9+#
10+provider: dmr
11+
12+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
13+baseUrl: http://localhost:12434/engines/v1
14+fallback: http://host.docker.internal:12434/engines/v1
15+
16+
17+# How many tokens the server actually serves.
18+# 0 = ask the server
19+contextWindow: 0
20+
21+# Max number of characters a tool returns to the model.
22+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
23+maxOutput: 16000
24+
25+# Max number of model <-> tools round trips for a single question.
26+# Raise it for tasks that need long chains of commands.
27+maxTurns: 40
28+
29+# The `bash` tool.
30+# `false` removes it: the model can still read and edit files (editTools) and load skills,
31+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
32+bashTool: true
33+
34+# The built-in file tools: read_file, write_file, edit_file.
35+editTools: true
36+
37+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
38+# The file extension picks the server. A server missing from the PATH is skipped with a warning at start-up;
39+# with no server on the PATH the tool is not declared. Install what you use, uncomment its entry.
40+lsp:
41+ servers:
42+ - name: gopls # go install golang.org/x/tools/gopls@latest
43+ command: gopls
44+ extensions: [".go"]
45+ # - name: typescript # npm i -g typescript typescript-language-server
46+ # command: typescript-language-server
47+ # args: ["--stdio"]
48+ # extensions: [".ts", ".tsx", ".js", ".jsx"]
49+ # - name: python # pip install pyright (or: npm i -g pyright)
50+ # command: pyright-langserver
51+ # args: ["--stdio"]
52+ # extensions: [".py"]
53+ # - name: rust # rustup component add rust-analyzer
54+ # command: rust-analyzer
55+ # extensions: [".rs"]
56+ # - name: clangd # apt install clangd / brew install llvm
57+ # command: clangd
58+ # extensions: [".c", ".h", ".cc", ".cpp", ".hpp"]
59+ # - name: java # jdtls (Eclipse JDT language server), or another Java server
60+ # command: jdtls
61+ # extensions: [".java"]
62+
63+# Where the `read_skill` tool looks for its markdown procedures.
64+# The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
65+skillsDir: skills
66+
67+# Persistent sessions, per project.
68+# When enabled, every conversation is saved as one JSON file under `dir`
69+sessions:
70+ enabled: true
71+ dir: .mm/sessions
72+
73+# How many lines of a command's output are echoed to the terminal.
74+# 0 disables it.
75+previewLines: 20
76+
77+# The system prompt: what the agent is, and what it is allowed to do.
78+# This is the knob to play with — it is the shortest path to changing behaviour.
79+system: |
80+ Your name is Bob.
81+ You are a coding agent working in a terminal.
82+ You have a "bash" tool to run shell commands, file tools to read and edit,
83+ and an "lsp" tool to ask the language server about the code.
84+ Use bash to run tests, build, list and search; use lsp for anything about a symbol.
85+ Chain several commands if needed, then answer clearly in English.
86+
87+ A request often mixes things you answer from yourself ("say hello") with
88+ things only a command can answer ("list the files"). Handle every part, in
89+ the order asked, and run a command for each part that needs one.
90+ Never state the contents of a file, the output of a command, or the state of
91+ the repository unless a command in THIS answer returned it. What you did not
92+ read, you do not know: run the command instead of recalling it.
93+
94+ SKILLS
95+ You have a second tool, `read_skill`. Its description lists the procedures
96+ available for this project — one per kind of task.
97+
98+ Any request to DO something to a Go project is a skill, not a shell command
99+ you invent. Match the request against that list, call `read_skill` FIRST,
100+ before any bash command, and then follow what it says step by step.
101+
102+ FILE EDITING
103+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
104+ They are how a file gets read and changed here: each change is exact,
105+ checked before it is written, and comes back as a diff with line numbers.
106+ bash is for running things — building, testing, listing, searching.
107+
108+ - Read before you write: call `read_file` on the file (numbered=true when
109+ you need line numbers). You cannot target text you have not seen; never
110+ rely on what you think you remember about a file.
111+ - To change an existing file, call `edit_file` with one or more {old, new}
112+ pairs. `old` is copied from the file character for character — same
113+ spaces, same indentation, same line breaks — and appears exactly once:
114+ add the surrounding lines until it is unique. Several pairs are applied
115+ together, against the original file. An empty `new` deletes the text.
116+ - Call `write_file` only to create a file, or to rewrite one entirely and
117+ on purpose. On an existing file it replaces everything, including what
118+ you did not intend to touch.
119+ - Read the diff the tool returns: it says exactly what changed and on which
120+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
121+ edits — read the file again and fix `old`. Do not fall back to
122+ `write_file` to force the change through.
123+ - After editing code, run the narrowest check with bash: the formatter, the
124+ compiler, or the test covering that file.
125+
126+ CODE NAVIGATION (lsp)
127+ You have an `lsp` tool: it asks the project's language server what the
128+ compiler knows. Its description says which file extensions it covers.
129+ Use it INSTEAD of grep or reading whole files for anything about a symbol:
130+ - "where is X defined / declared" → action definition, symbol X
131+ - "who calls / uses X", "find usages" → action references, symbol X
132+ - "what is the type / signature of X" → action hover, symbol X
133+ - "what does this file contain" → action symbols, path only
134+ - "are there errors", after every edit → action diagnostics, path only
135+ - "rename X to Y everywhere" → action rename, symbol X, new_name Y
136+ (rename NEEDS new_name — the new identifier; never rename with edit_file:
137+ {"action": "rename", "path": "main.go", "symbol": "Greet", "new_name": "Hello"})
138+ Point at a symbol with its NAME (the `symbol` field), exactly as written in
139+ the code; add `line` only to pick one occurrence among several. Do not
140+ guess columns. Answers are "path:line:column <source line>": quote them,
141+ they are the evidence.
142+ Before changing a symbol used in several files, call references and say how
143+ many places are affected. After editing, call diagnostics on each edited
144+ file and fix what it reports before running tests. A `[lsp: …]` answer is
145+ information: "no language server for" means use bash; "not found in" means
146+ the name is not spelled as in the code — read the file and copy it.
147+
148+ RULES
149+ - Keep everything the file already does, unless the user asked to remove it.
150+ - Touch only the files the request is about. Do not add tests, files or
151+ features that were not asked for.
152+ - Never run a git command unless the user says git, commit or push.
153+ - Never move, rename or delete a file unless the user asked for it.
154+ - Then answer in English, in a few lines.
155+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
156+ to understand the options, then run the command.
157+
158+ BACKGROUND JOBS
159+ Never let a command block the answer. Anything that serves, watches or runs
160+ long goes to the background, with BOTH streams redirected and its pid kept:
161+
162+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
163+
164+ Redirecting only stdout still blocks until the process exits. Read the
165+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
166+ rule you cannot guess. Stop every job you started before you finish, and say
167+ which ones you left running.
168+
169+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
170+# we want precise and reproducible answers, not creativity.
171+sampling:
172+ temperature: 0.0
173+ parallel_tool_calls: false
174+ top_p: 0.9
175+ max_tokens: 4096
176+
177+watchdogTimeout: 30s
178+
179+# Context compression
180+context:
181+ enabled: false
182+
183+ # Compress when the history reaches this share of the window.
184+ threshold: 75
185+
186+ # Fallback on the message count, for when the window is unknown.
187+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
188+ maxMessages: 80
189+
190+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
191+ # The model works on the last few turns, so summarising them would make it redo what it just did.
192+ keepLastTurns: 3
193+
194+ # max_tokens of the summary request.
195+ summaryMaxTokens: 1200
196+
197+ # Replaces the built-in summary prompt when set.
198+ # Empty = built-in.
199+ prompt: ""
200+
201+ showStats: true
202+
203+displayCommands: true
new file mode 100644
@@ -0,0 +1,203 @@
1+# Agent settings — the LSP variant of agent.dmr.yaml.
2+#
3+# Same model and server as agent.dmr.yaml; the difference is the `lsp` block
4+# below (gopls on, the other servers ready to uncomment) and a system prompt
5+# that tells the model WHEN to ask the language server instead of grepping.
6+#
7+# mm agent.lsp.yaml # line mode
8+# mm -tui agent.lsp.yaml # window mode
9+#
10+provider: dmr
11+
12+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
13+baseUrl: http://localhost:12434/engines/v1
14+fallback: http://host.docker.internal:12434/engines/v1
15+
16+
17+# How many tokens the server actually serves.
18+# 0 = ask the server
19+contextWindow: 0
20+
21+# Max number of characters a tool returns to the model.
22+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
23+maxOutput: 16000
24+
25+# Max number of model <-> tools round trips for a single question.
26+# Raise it for tasks that need long chains of commands.
27+maxTurns: 40
28+
29+# The `bash` tool.
30+# `false` removes it: the model can still read and edit files (editTools) and load skills,
31+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
32+bashTool: true
33+
34+# The built-in file tools: read_file, write_file, edit_file.
35+editTools: true
36+
37+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
38+# The file extension picks the server. A server missing from the PATH is skipped with a warning at start-up;
39+# with no server on the PATH the tool is not declared. Install what you use, uncomment its entry.
40+lsp:
41+ servers:
42+ - name: gopls # go install golang.org/x/tools/gopls@latest
43+ command: gopls
44+ extensions: [".go"]
45+ # - name: typescript # npm i -g typescript typescript-language-server
46+ # command: typescript-language-server
47+ # args: ["--stdio"]
48+ # extensions: [".ts", ".tsx", ".js", ".jsx"]
49+ # - name: python # pip install pyright (or: npm i -g pyright)
50+ # command: pyright-langserver
51+ # args: ["--stdio"]
52+ # extensions: [".py"]
53+ # - name: rust # rustup component add rust-analyzer
54+ # command: rust-analyzer
55+ # extensions: [".rs"]
56+ # - name: clangd # apt install clangd / brew install llvm
57+ # command: clangd
58+ # extensions: [".c", ".h", ".cc", ".cpp", ".hpp"]
59+ # - name: java # jdtls (Eclipse JDT language server), or another Java server
60+ # command: jdtls
61+ # extensions: [".java"]
62+
63+# Where the `read_skill` tool looks for its markdown procedures.
64+# The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
65+skillsDir: skills
66+
67+# Persistent sessions, per project.
68+# When enabled, every conversation is saved as one JSON file under `dir`
69+sessions:
70+ enabled: true
71+ dir: .mm/sessions
72+
73+# How many lines of a command's output are echoed to the terminal.
74+# 0 disables it.
75+previewLines: 20
76+
77+# The system prompt: what the agent is, and what it is allowed to do.
78+# This is the knob to play with — it is the shortest path to changing behaviour.
79+system: |
80+ Your name is Bob.
81+ You are a coding agent working in a terminal.
82+ You have a "bash" tool to run shell commands, file tools to read and edit,
83+ and an "lsp" tool to ask the language server about the code.
84+ Use bash to run tests, build, list and search; use lsp for anything about a symbol.
85+ Chain several commands if needed, then answer clearly in English.
86+
87+ A request often mixes things you answer from yourself ("say hello") with
88+ things only a command can answer ("list the files"). Handle every part, in
89+ the order asked, and run a command for each part that needs one.
90+ Never state the contents of a file, the output of a command, or the state of
91+ the repository unless a command in THIS answer returned it. What you did not
92+ read, you do not know: run the command instead of recalling it.
93+
94+ SKILLS
95+ You have a second tool, `read_skill`. Its description lists the procedures
96+ available for this project — one per kind of task.
97+
98+ Any request to DO something to a Go project is a skill, not a shell command
99+ you invent. Match the request against that list, call `read_skill` FIRST,
100+ before any bash command, and then follow what it says step by step.
101+
102+ FILE EDITING
103+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
104+ They are how a file gets read and changed here: each change is exact,
105+ checked before it is written, and comes back as a diff with line numbers.
106+ bash is for running things — building, testing, listing, searching.
107+
108+ - Read before you write: call `read_file` on the file (numbered=true when
109+ you need line numbers). You cannot target text you have not seen; never
110+ rely on what you think you remember about a file.
111+ - To change an existing file, call `edit_file` with one or more {old, new}
112+ pairs. `old` is copied from the file character for character — same
113+ spaces, same indentation, same line breaks — and appears exactly once:
114+ add the surrounding lines until it is unique. Several pairs are applied
115+ together, against the original file. An empty `new` deletes the text.
116+ - Call `write_file` only to create a file, or to rewrite one entirely and
117+ on purpose. On an existing file it replaces everything, including what
118+ you did not intend to touch.
119+ - Read the diff the tool returns: it says exactly what changed and on which
120+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
121+ edits — read the file again and fix `old`. Do not fall back to
122+ `write_file` to force the change through.
123+ - After editing code, run the narrowest check with bash: the formatter, the
124+ compiler, or the test covering that file.
125+
126+ CODE NAVIGATION (lsp)
127+ You have an `lsp` tool: it asks the project's language server what the
128+ compiler knows. Its description says which file extensions it covers.
129+ Use it INSTEAD of grep or reading whole files for anything about a symbol:
130+ - "where is X defined / declared" → action definition, symbol X
131+ - "who calls / uses X", "find usages" → action references, symbol X
132+ - "what is the type / signature of X" → action hover, symbol X
133+ - "what does this file contain" → action symbols, path only
134+ - "are there errors", after every edit → action diagnostics, path only
135+ - "rename X to Y everywhere" → action rename, symbol X, new_name Y
136+ (rename NEEDS new_name — the new identifier; never rename with edit_file:
137+ {"action": "rename", "path": "main.go", "symbol": "Greet", "new_name": "Hello"})
138+ Point at a symbol with its NAME (the `symbol` field), exactly as written in
139+ the code; add `line` only to pick one occurrence among several. Do not
140+ guess columns. Answers are "path:line:column <source line>": quote them,
141+ they are the evidence.
142+ Before changing a symbol used in several files, call references and say how
143+ many places are affected. After editing, call diagnostics on each edited
144+ file and fix what it reports before running tests. A `[lsp: …]` answer is
145+ information: "no language server for" means use bash; "not found in" means
146+ the name is not spelled as in the code — read the file and copy it.
147+
148+ RULES
149+ - Keep everything the file already does, unless the user asked to remove it.
150+ - Touch only the files the request is about. Do not add tests, files or
151+ features that were not asked for.
152+ - Never run a git command unless the user says git, commit or push.
153+ - Never move, rename or delete a file unless the user asked for it.
154+ - Then answer in English, in a few lines.
155+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
156+ to understand the options, then run the command.
157+
158+ BACKGROUND JOBS
159+ Never let a command block the answer. Anything that serves, watches or runs
160+ long goes to the background, with BOTH streams redirected and its pid kept:
161+
162+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
163+
164+ Redirecting only stdout still blocks until the process exits. Read the
165+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
166+ rule you cannot guess. Stop every job you started before you finish, and say
167+ which ones you left running.
168+
169+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
170+# we want precise and reproducible answers, not creativity.
171+sampling:
172+ temperature: 0.0
173+ parallel_tool_calls: false
174+ top_p: 0.9
175+ max_tokens: 4096
176+
177+watchdogTimeout: 30s
178+
179+# Context compression
180+context:
181+ enabled: false
182+
183+ # Compress when the history reaches this share of the window.
184+ threshold: 75
185+
186+ # Fallback on the message count, for when the window is unknown.
187+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
188+ maxMessages: 80
189+
190+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
191+ # The model works on the last few turns, so summarising them would make it redo what it just did.
192+ keepLastTurns: 3
193+
194+ # max_tokens of the summary request.
195+ summaryMaxTokens: 1200
196+
197+ # Replaces the built-in summary prompt when set.
198+ # Empty = built-in.
199+ prompt: ""
200+
201+ showStats: true
202+
203+displayCommands: true
added demo/agent.lsp.yaml +201 -0
new file mode 100644
@@ -0,0 +1,201 @@
1+# Agent settings — the LSP variant of agent.dmr.yaml.
2+#
3+# Same model and server as agent.dmr.yaml; the difference is the `lsp` block
4+# below (gopls on, the other servers ready to uncomment) and a system prompt
5+# that tells the model WHEN to ask the language server instead of grepping.
6+#
7+# mm agent.lsp.yaml # line mode
8+# mm -tui agent.lsp.yaml # window mode
9+#
10+provider: dmr
11+
12+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
13+baseUrl: http://localhost:12434/engines/v1
14+fallback: http://host.docker.internal:12434/engines/v1
15+
16+
17+# How many tokens the server actually serves.
18+# 0 = ask the server
19+contextWindow: 0
20+
21+# Max number of characters a tool returns to the model.
22+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
23+maxOutput: 16000
24+
25+# Max number of model <-> tools round trips for a single question.
26+# Raise it for tasks that need long chains of commands.
27+maxTurns: 40
28+
29+# The `bash` tool.
30+# `false` removes it: the model can still read and edit files (editTools) and load skills,
31+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
32+bashTool: true
33+
34+# The built-in file tools: read_file, write_file, edit_file.
35+editTools: true
36+
37+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
38+# The file extension picks the server. A server missing from the PATH is skipped with a warning at start-up;
39+# with no server on the PATH the tool is not declared. Install what you use, uncomment its entry.
40+lsp:
41+ servers:
42+ - name: gopls # go install golang.org/x/tools/gopls@latest
43+ command: gopls
44+ extensions: [".go"]
45+ # - name: typescript # npm i -g typescript typescript-language-server
46+ # command: typescript-language-server
47+ # args: ["--stdio"]
48+ # extensions: [".ts", ".tsx", ".js", ".jsx"]
49+ # - name: python # pip install pyright (or: npm i -g pyright)
50+ # command: pyright-langserver
51+ # args: ["--stdio"]
52+ # extensions: [".py"]
53+ # - name: rust # rustup component add rust-analyzer
54+ # command: rust-analyzer
55+ # extensions: [".rs"]
56+ # - name: clangd # apt install clangd / brew install llvm
57+ # command: clangd
58+ # extensions: [".c", ".h", ".cc", ".cpp", ".hpp"]
59+ # - name: java # jdtls (Eclipse JDT language server), or another Java server
60+ # command: jdtls
61+ # extensions: [".java"]
62+
63+# Where the `read_skill` tool looks for its markdown procedures.
64+# The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
65+skillsDir: skills
66+
67+# Persistent sessions, per project.
68+# When enabled, every conversation is saved as one JSON file under `dir`
69+sessions:
70+ enabled: true
71+ dir: .mm/sessions
72+
73+# How many lines of a command's output are echoed to the terminal.
74+# 0 disables it.
75+previewLines: 20
76+
77+# The system prompt: what the agent is, and what it is allowed to do.
78+# This is the knob to play with — it is the shortest path to changing behaviour.
79+system: |
80+ Your name is Bob.
81+ You are a coding agent working in a terminal.
82+ You have a "bash" tool to run shell commands, file tools to read and edit,
83+ and an "lsp" tool to ask the language server about the code.
84+ Use bash to run tests, build, list and search; use lsp for anything about a symbol.
85+ Chain several commands if needed, then answer clearly in English.
86+
87+ A request often mixes things you answer from yourself ("say hello") with
88+ things only a command can answer ("list the files"). Handle every part, in
89+ the order asked, and run a command for each part that needs one.
90+ Never state the contents of a file, the output of a command, or the state of
91+ the repository unless a command in THIS answer returned it. What you did not
92+ read, you do not know: run the command instead of recalling it.
93+
94+ SKILLS
95+ You have a second tool, `read_skill`. Its description lists the procedures
96+ available for this project — one per kind of task.
97+
98+ Any request to DO something to a Go project is a skill, not a shell command
99+ you invent. Match the request against that list, call `read_skill` FIRST,
100+ before any bash command, and then follow what it says step by step.
101+
102+ FILE EDITING
103+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
104+ They are how a file gets read and changed here: each change is exact,
105+ checked before it is written, and comes back as a diff with line numbers.
106+ bash is for running things — building, testing, listing, searching.
107+
108+ - Read before you write: call `read_file` on the file (numbered=true when
109+ you need line numbers). You cannot target text you have not seen; never
110+ rely on what you think you remember about a file.
111+ - To change an existing file, call `edit_file` with one or more {old, new}
112+ pairs. `old` is copied from the file character for character — same
113+ spaces, same indentation, same line breaks — and appears exactly once:
114+ add the surrounding lines until it is unique. Several pairs are applied
115+ together, against the original file. An empty `new` deletes the text.
116+ - Call `write_file` only to create a file, or to rewrite one entirely and
117+ on purpose. On an existing file it replaces everything, including what
118+ you did not intend to touch.
119+ - Read the diff the tool returns: it says exactly what changed and on which
120+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
121+ edits — read the file again and fix `old`. Do not fall back to
122+ `write_file` to force the change through.
123+ - After editing code, run the narrowest check with bash: the formatter, the
124+ compiler, or the test covering that file.
125+
126+ CODE NAVIGATION (lsp)
127+ You have an `lsp` tool: it asks the project's language server what the
128+ compiler knows. Its description says which file extensions it covers.
129+ Use it INSTEAD of grep or reading whole files for anything about a symbol:
130+ - "where is X defined / declared" → action definition, symbol X
131+ - "who calls / uses X", "find usages" → action references, symbol X
132+ - "what is the type / signature of X" → action hover, symbol X
133+ - "what does this file contain" → action symbols, path only
134+ - "are there errors", after every edit → action diagnostics, path only
135+ - "rename X to Y everywhere" → action rename, symbol X, new_name Y
136+ Point at a symbol with its NAME (the `symbol` field), exactly as written in
137+ the code; add `line` only to pick one occurrence among several. Do not
138+ guess columns. Answers are "path:line:column <source line>": quote them,
139+ they are the evidence.
140+ Before changing a symbol used in several files, call references and say how
141+ many places are affected. After editing, call diagnostics on each edited
142+ file and fix what it reports before running tests. A `[lsp: …]` answer is
143+ information: "no language server for" means use bash; "not found in" means
144+ the name is not spelled as in the code — read the file and copy it.
145+
146+ RULES
147+ - Keep everything the file already does, unless the user asked to remove it.
148+ - Touch only the files the request is about. Do not add tests, files or
149+ features that were not asked for.
150+ - Never run a git command unless the user says git, commit or push.
151+ - Never move, rename or delete a file unless the user asked for it.
152+ - Then answer in English, in a few lines.
153+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
154+ to understand the options, then run the command.
155+
156+ BACKGROUND JOBS
157+ Never let a command block the answer. Anything that serves, watches or runs
158+ long goes to the background, with BOTH streams redirected and its pid kept:
159+
160+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
161+
162+ Redirecting only stdout still blocks until the process exits. Read the
163+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
164+ rule you cannot guess. Stop every job you started before you finish, and say
165+ which ones you left running.
166+
167+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
168+# we want precise and reproducible answers, not creativity.
169+sampling:
170+ temperature: 0.0
171+ parallel_tool_calls: false
172+ top_p: 0.9
173+ max_tokens: 4096
174+
175+watchdogTimeout: 30s
176+
177+# Context compression
178+context:
179+ enabled: false
180+
181+ # Compress when the history reaches this share of the window.
182+ threshold: 75
183+
184+ # Fallback on the message count, for when the window is unknown.
185+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
186+ maxMessages: 80
187+
188+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
189+ # The model works on the last few turns, so summarising them would make it redo what it just did.
190+ keepLastTurns: 3
191+
192+ # max_tokens of the summary request.
193+ summaryMaxTokens: 1200
194+
195+ # Replaces the built-in summary prompt when set.
196+ # Empty = built-in.
197+ prompt: ""
198+
199+ showStats: true
200+
201+displayCommands: true
new file mode 100644
@@ -0,0 +1,201 @@
1+# Agent settings — the LSP variant of agent.dmr.yaml.
2+#
3+# Same model and server as agent.dmr.yaml; the difference is the `lsp` block
4+# below (gopls on, the other servers ready to uncomment) and a system prompt
5+# that tells the model WHEN to ask the language server instead of grepping.
6+#
7+# mm agent.lsp.yaml # line mode
8+# mm -tui agent.lsp.yaml # window mode
9+#
10+provider: dmr
11+
12+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
13+baseUrl: http://localhost:12434/engines/v1
14+fallback: http://host.docker.internal:12434/engines/v1
15+
16+
17+# How many tokens the server actually serves.
18+# 0 = ask the server
19+contextWindow: 0
20+
21+# Max number of characters a tool returns to the model.
22+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
23+maxOutput: 16000
24+
25+# Max number of model <-> tools round trips for a single question.
26+# Raise it for tasks that need long chains of commands.
27+maxTurns: 40
28+
29+# The `bash` tool.
30+# `false` removes it: the model can still read and edit files (editTools) and load skills,
31+# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool".
32+bashTool: true
33+
34+# The built-in file tools: read_file, write_file, edit_file.
35+editTools: true
36+
37+# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
38+# The file extension picks the server. A server missing from the PATH is skipped with a warning at start-up;
39+# with no server on the PATH the tool is not declared. Install what you use, uncomment its entry.
40+lsp:
41+ servers:
42+ - name: gopls # go install golang.org/x/tools/gopls@latest
43+ command: gopls
44+ extensions: [".go"]
45+ # - name: typescript # npm i -g typescript typescript-language-server
46+ # command: typescript-language-server
47+ # args: ["--stdio"]
48+ # extensions: [".ts", ".tsx", ".js", ".jsx"]
49+ # - name: python # pip install pyright (or: npm i -g pyright)
50+ # command: pyright-langserver
51+ # args: ["--stdio"]
52+ # extensions: [".py"]
53+ # - name: rust # rustup component add rust-analyzer
54+ # command: rust-analyzer
55+ # extensions: [".rs"]
56+ # - name: clangd # apt install clangd / brew install llvm
57+ # command: clangd
58+ # extensions: [".c", ".h", ".cc", ".cpp", ".hpp"]
59+ # - name: java # jdtls (Eclipse JDT language server), or another Java server
60+ # command: jdtls
61+ # extensions: [".java"]
62+
63+# Where the `read_skill` tool looks for its markdown procedures.
64+# The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
65+skillsDir: skills
66+
67+# Persistent sessions, per project.
68+# When enabled, every conversation is saved as one JSON file under `dir`
69+sessions:
70+ enabled: true
71+ dir: .mm/sessions
72+
73+# How many lines of a command's output are echoed to the terminal.
74+# 0 disables it.
75+previewLines: 20
76+
77+# The system prompt: what the agent is, and what it is allowed to do.
78+# This is the knob to play with — it is the shortest path to changing behaviour.
79+system: |
80+ Your name is Bob.
81+ You are a coding agent working in a terminal.
82+ You have a "bash" tool to run shell commands, file tools to read and edit,
83+ and an "lsp" tool to ask the language server about the code.
84+ Use bash to run tests, build, list and search; use lsp for anything about a symbol.
85+ Chain several commands if needed, then answer clearly in English.
86+
87+ A request often mixes things you answer from yourself ("say hello") with
88+ things only a command can answer ("list the files"). Handle every part, in
89+ the order asked, and run a command for each part that needs one.
90+ Never state the contents of a file, the output of a command, or the state of
91+ the repository unless a command in THIS answer returned it. What you did not
92+ read, you do not know: run the command instead of recalling it.
93+
94+ SKILLS
95+ You have a second tool, `read_skill`. Its description lists the procedures
96+ available for this project — one per kind of task.
97+
98+ Any request to DO something to a Go project is a skill, not a shell command
99+ you invent. Match the request against that list, call `read_skill` FIRST,
100+ before any bash command, and then follow what it says step by step.
101+
102+ FILE EDITING
103+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
104+ They are how a file gets read and changed here: each change is exact,
105+ checked before it is written, and comes back as a diff with line numbers.
106+ bash is for running things — building, testing, listing, searching.
107+
108+ - Read before you write: call `read_file` on the file (numbered=true when
109+ you need line numbers). You cannot target text you have not seen; never
110+ rely on what you think you remember about a file.
111+ - To change an existing file, call `edit_file` with one or more {old, new}
112+ pairs. `old` is copied from the file character for character — same
113+ spaces, same indentation, same line breaks — and appears exactly once:
114+ add the surrounding lines until it is unique. Several pairs are applied
115+ together, against the original file. An empty `new` deletes the text.
116+ - Call `write_file` only to create a file, or to rewrite one entirely and
117+ on purpose. On an existing file it replaces everything, including what
118+ you did not intend to touch.
119+ - Read the diff the tool returns: it says exactly what changed and on which
120+ line. If `edit_file` refuses — text not found, ambiguous, overlapping
121+ edits — read the file again and fix `old`. Do not fall back to
122+ `write_file` to force the change through.
123+ - After editing code, run the narrowest check with bash: the formatter, the
124+ compiler, or the test covering that file.
125+
126+ CODE NAVIGATION (lsp)
127+ You have an `lsp` tool: it asks the project's language server what the
128+ compiler knows. Its description says which file extensions it covers.
129+ Use it INSTEAD of grep or reading whole files for anything about a symbol:
130+ - "where is X defined / declared" → action definition, symbol X
131+ - "who calls / uses X", "find usages" → action references, symbol X
132+ - "what is the type / signature of X" → action hover, symbol X
133+ - "what does this file contain" → action symbols, path only
134+ - "are there errors", after every edit → action diagnostics, path only
135+ - "rename X to Y everywhere" → action rename, symbol X, new_name Y
136+ Point at a symbol with its NAME (the `symbol` field), exactly as written in
137+ the code; add `line` only to pick one occurrence among several. Do not
138+ guess columns. Answers are "path:line:column <source line>": quote them,
139+ they are the evidence.
140+ Before changing a symbol used in several files, call references and say how
141+ many places are affected. After editing, call diagnostics on each edited
142+ file and fix what it reports before running tests. A `[lsp: …]` answer is
143+ information: "no language server for" means use bash; "not found in" means
144+ the name is not spelled as in the code — read the file and copy it.
145+
146+ RULES
147+ - Keep everything the file already does, unless the user asked to remove it.
148+ - Touch only the files the request is about. Do not add tests, files or
149+ features that were not asked for.
150+ - Never run a git command unless the user says git, commit or push.
151+ - Never move, rename or delete a file unless the user asked for it.
152+ - Then answer in English, in a few lines.
153+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
154+ to understand the options, then run the command.
155+
156+ BACKGROUND JOBS
157+ Never let a command block the answer. Anything that serves, watches or runs
158+ long goes to the background, with BOTH streams redirected and its pid kept:
159+
160+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
161+
162+ Redirecting only stdout still blocks until the process exits. Read the
163+ `bg-jobs` skill before you wait on, inspect or stop such a job — each has a
164+ rule you cannot guess. Stop every job you started before you finish, and say
165+ which ones you left running.
166+
167+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
168+# we want precise and reproducible answers, not creativity.
169+sampling:
170+ temperature: 0.0
171+ parallel_tool_calls: false
172+ top_p: 0.9
173+ max_tokens: 4096
174+
175+watchdogTimeout: 30s
176+
177+# Context compression
178+context:
179+ enabled: false
180+
181+ # Compress when the history reaches this share of the window.
182+ threshold: 75
183+
184+ # Fallback on the message count, for when the window is unknown.
185+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
186+ maxMessages: 80
187+
188+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
189+ # The model works on the last few turns, so summarising them would make it redo what it just did.
190+ keepLastTurns: 3
191+
192+ # max_tokens of the summary request.
193+ summaryMaxTokens: 1200
194+
195+ # Replaces the built-in summary prompt when set.
196+ # Empty = built-in.
197+ prompt: ""
198+
199+ showStats: true
200+
201+displayCommands: true
added demo/main.go +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+package main
2+
3+import "fmt"
4+
5+// Human represents a person with a name
6+type Human struct {
7+ Name string
8+}
9+
10+// Hello returns a greeting message from the human
11+func (h Human) Hello() string {
12+ return fmt.Sprintf("Hello, %s!", h.Name)
13+}
14+
15+func main() {
16+ // Create a new human
17+ person := Human{Name: "World"}
18+
19+ // Print the greeting
20+ fmt.Println(person.Hello())
21+}
new file mode 100644
@@ -0,0 +1,21 @@
1+package main
2+
3+import "fmt"
4+
5+// Human represents a person with a name
6+type Human struct {
7+ Name string
8+}
9+
10+// Hello returns a greeting message from the human
11+func (h Human) Hello() string {
12+ return fmt.Sprintf("Hello, %s!", h.Name)
13+}
14+
15+func main() {
16+ // Create a new human
17+ person := Human{Name: "World"}
18+
19+ // Print the greeting
20+ fmt.Println(person.Hello())
21+}
modified docs/diagrams/packages.drawio +1 -1
@@ -1 +1 @@
1-<mxfile host="app.diagrams.net" modified="2026-09-15T00: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="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools&lt;/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="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
1+<mxfile host="app.diagrams.net" modified="2026-09-15T00: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="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp&lt;/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="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
@@ -1 +1 @@
1-<mxfile host="app.diagrams.net" modified="2026-09-15T00: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="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools&lt;/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="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
1+<mxfile host="app.diagrams.net" modified="2026-09-15T00: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="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp&lt;/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="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/README.md +2 -0
@@ -18,6 +18,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
1818 - [Point the model at a file with `@path`](how-to/attach-a-file.md)
1919 - [Keep sessions and resume them](how-to/keep-sessions.md)
2020 - [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md)
21+- [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md)
2122
2223 ## Reference — look it up
2324
@@ -34,6 +35,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
3435 - [Providers: one implementation per protocol](explanation/providers.md)
3536 - [Context compression](explanation/context-compression.md)
3637 - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`
38+- [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission
3739 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
3840
3941 ## Scope
@@ -18,6 +18,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
18 - [Point the model at a file with `@path`](how-to/attach-a-file.md)18 - [Point the model at a file with `@path`](how-to/attach-a-file.md)
19 - [Keep sessions and resume them](how-to/keep-sessions.md)19 - [Keep sessions and resume them](how-to/keep-sessions.md)
20 - [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md)20 - [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md)
21+- [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md)
21 22
22 ## Reference — look it up23 ## Reference — look it up
23 24
@@ -34,6 +35,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
34 - [Providers: one implementation per protocol](explanation/providers.md)35 - [Providers: one implementation per protocol](explanation/providers.md)
35 - [Context compression](explanation/context-compression.md)36 - [Context compression](explanation/context-compression.md)
36 - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`37 - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`
38+- [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission
37 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)39 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
38 40
39 ## Scope41 ## Scope
modified docs/en/explanation/architecture.md +2 -1
@@ -12,6 +12,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
1212 | `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. |
1313 | `tools` | The built-in tools the model can call. |
1414 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
15+| `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. |
1516 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
1617 | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |
1718 | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |
@@ -45,7 +46,7 @@ A draw.io diagram of these packages and their dependencies is kept at [`docs/dia
4546
4647 **Turns are serialised in ACP mode.** The active sink is process-global and the engine was never built for concurrent generations, so a second `session/prompt` waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway.
4748
48-**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new.
49+**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new. Advice is not enough for every model: on 2026-09-18 a 12B model repeated the same refused `edit_file` call thirty times, `[LOOP_DETECTED]` appended to each answer, until the user pressed Escape. So the detector has a second level: three flagged repeats after the threshold — six identical calls — and the tool returns a Go error instead of text, the one deliberate exception to "tools return text": the agent is stopping itself, not reporting a failure for the model to read. The engine shows the detector's sentence as the turn's error, does not retry the turn, and the session file records it. A third guard sits in the stream itself: the arguments of a tool call arrive delta by delta and none of it is shown, so a model that derails while composing one — `"Greet 2023-06-15 11:16:00 +0000 UTC 2023-06-15 …"` for four minutes, seen the same day — looks like nothing happening. The engine watches those deltas and stops the generation as soon as they repeat a pattern or pass a quarter megabyte.
4950
5051 ## Rejected alternatives
5152
@@ -12,6 +12,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
12 | `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. |12 | `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. |
13 | `tools` | The built-in tools the model can call. |13 | `tools` | The built-in tools the model can call. |
14 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |14 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
15+| `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. |
15 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |16 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
16 | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |17 | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |
17 | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |18 | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |
@@ -45,7 +46,7 @@ A draw.io diagram of these packages and their dependencies is kept at [`docs/dia
45 46
46 **Turns are serialised in ACP mode.** The active sink is process-global and the engine was never built for concurrent generations, so a second `session/prompt` waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway.47 **Turns are serialised in ACP mode.** The active sink is process-global and the engine was never built for concurrent generations, so a second `session/prompt` waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway.
47 48
48-**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new.49+**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new. Advice is not enough for every model: on 2026-09-18 a 12B model repeated the same refused `edit_file` call thirty times, `[LOOP_DETECTED]` appended to each answer, until the user pressed Escape. So the detector has a second level: three flagged repeats after the threshold — six identical calls — and the tool returns a Go error instead of text, the one deliberate exception to "tools return text": the agent is stopping itself, not reporting a failure for the model to read. The engine shows the detector's sentence as the turn's error, does not retry the turn, and the session file records it. A third guard sits in the stream itself: the arguments of a tool call arrive delta by delta and none of it is shown, so a model that derails while composing one — `"Greet 2023-06-15 11:16:00 +0000 UTC 2023-06-15 …"` for four minutes, seen the same day — looks like nothing happening. The engine watches those deltas and stops the generation as soon as they repeat a pattern or pass a quarter megabyte.
49 50
50 ## Rejected alternatives51 ## Rejected alternatives
51 52
added docs/en/explanation/lsp-tool.md +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+# The `lsp` tool: why one tool, and why a `symbol` field — explanation
2+
3+## What is this about?
4+
5+Until 2026-09-18 the model knew a project through `bash` (grep, ls, go build) and the file tools. That is how a human without an editor works, and it is slow and error-prone for the questions an editor answers instantly: where is this defined, who calls it, what type is this, what does the compiler say. Language servers answer those questions for every mainstream language over one protocol, the Language Server Protocol (LSP). Ticket 17 asked to give `mm` a client for it: configured servers, a built-in tool, documentation with system-prompt samples, and a warning rather than a failure when a server is missing.
6+
7+## Why one tool with an `action` field
8+
9+Six requests were wanted: definition, references, hover, document symbols, diagnostics, rename. They could have been six tools. They are one, `lsp`, with an `action` enum, for the model this agent targets — a 12B model that reads a tool list of five entries reliably and a list of eleven less so. One tool also carries one description, and the description is where the workflow lives: "references before you change a shared symbol, diagnostics after you edit". Six descriptions would have said it six times or not at all.
10+
11+## Why a `symbol` field, and positions tried in order
12+
13+LSP requests take a position: zero-based line and UTF-16 column. The first end-to-end run showed the model sending `column: 0` on every call — even when the question gave the column — and looping on the same error. Asking a small model to count characters is asking for a number it cannot produce. So the tool takes the identifier instead: `symbol: greet`. The client finds its whole-word occurrences in the file and tries them in order until the server answers something; occurrences on comment lines are tried last, because a name is usually mentioned in the comment above its declaration and the server has nothing to say about a comment. The second run, with `symbol`, answered the same question in two calls with the right lines. Exact `line` and `column` remain accepted, 1-based like `read_file numbered=true`, for the rare model that has them.
14+
15+The same idea governs the answers: every location comes back as `path:line:column <the source line>`. The model does not need to open the file to know what a hit is, so it does not — one call instead of three.
16+
17+## Why the files are synced from disk before every request
18+
19+An editor tells its server about every keystroke. `mm` has no keystrokes: the model edits with `edit_file`, and the file on disk is the truth. So before every request the client reads the file and sends its content — `didOpen` the first time, a whole-document `didChange` when it moved since — and the server always answers on what the model just wrote. Nothing else has to remember to notify anyone.
20+
21+## Why `rename` writes the files itself, and asks first
22+
23+A rename is a `WorkspaceEdit`: ranges to replace in several files. The client applies them to disk — from the end of each file backwards so earlier positions stay valid, with UTF-16 columns converted to bytes — then re-syncs the changed files so the next question is answered on the renamed code. In the terminal it runs like `edit_file` does; where a front end has a permission dialog (an editor over ACP, the window mode with `permissions: ask`) it asks, because it writes, while the five navigation actions do not: a dialog on every read teaches people to click "allow" without reading.
24+
25+## Why a missing server is a warning
26+
27+A configuration travels with a project; the machines it runs on differ. A server named in the YAML but absent from the `PATH` is reported once at start-up and left out of the tool's description; when none is found, the tool is not declared at all and the banner says so. The agent starts either way. Declaring a server the tool cannot start would have handed the model a tool that always fails — the silent-failure trap the whole project is built against.
28+
29+## Rejected alternatives
30+
31+- **One tool per action.** Rejected for the model's sake: a longer tool list, six descriptions, the workflow scattered.
32+- **A hand-rolled JSON-RPC client.** Considered, to keep the dependency count at zero. The user chose `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`: typed requests and results, the union types the protocol is full of already decoded. The price is two direct dependencies (plus `go.uber.org/zap`-free v1 packages' own few) and a Go 1.26 floor, which those modules require.
33+- **Letting the model count columns.** Rejected by evidence, see above.
34+- **Starting every configured server at launch.** Rejected: a Python server on a Go project would run for nothing. Servers start on the first call for a file they serve and stop when `mm` exits.
35+- **Feeding diagnostics to the model on its own.** Rejected: the model asks for them when the skill and the prompt say to (after an edit). Pushing them unasked would put text in the history the model did not request and cannot place.
36+
37+## How it relates to the rest
38+
39+`internal/lsp` is the client: server lifecycle, file sync, the six operations, the text shaping, the rename applied to disk. `internal/tools` declares the tool over it and decides what is printed or emitted as events, like the other tools. `internal/config` holds `lsp.servers` and refuses two servers on one extension. `main.go` checks the `PATH`, prints the warnings, and stops the servers at exit. The recap line gained a `🔎 N lsp call(s)` counter, kept apart from `⚙ commands` for the same reason `📝 file op(s)` is: asking the compiler is not running a command.
new file mode 100644
@@ -0,0 +1,39 @@
1+# The `lsp` tool: why one tool, and why a `symbol` field — explanation
2+
3+## What is this about?
4+
5+Until 2026-09-18 the model knew a project through `bash` (grep, ls, go build) and the file tools. That is how a human without an editor works, and it is slow and error-prone for the questions an editor answers instantly: where is this defined, who calls it, what type is this, what does the compiler say. Language servers answer those questions for every mainstream language over one protocol, the Language Server Protocol (LSP). Ticket 17 asked to give `mm` a client for it: configured servers, a built-in tool, documentation with system-prompt samples, and a warning rather than a failure when a server is missing.
6+
7+## Why one tool with an `action` field
8+
9+Six requests were wanted: definition, references, hover, document symbols, diagnostics, rename. They could have been six tools. They are one, `lsp`, with an `action` enum, for the model this agent targets — a 12B model that reads a tool list of five entries reliably and a list of eleven less so. One tool also carries one description, and the description is where the workflow lives: "references before you change a shared symbol, diagnostics after you edit". Six descriptions would have said it six times or not at all.
10+
11+## Why a `symbol` field, and positions tried in order
12+
13+LSP requests take a position: zero-based line and UTF-16 column. The first end-to-end run showed the model sending `column: 0` on every call — even when the question gave the column — and looping on the same error. Asking a small model to count characters is asking for a number it cannot produce. So the tool takes the identifier instead: `symbol: greet`. The client finds its whole-word occurrences in the file and tries them in order until the server answers something; occurrences on comment lines are tried last, because a name is usually mentioned in the comment above its declaration and the server has nothing to say about a comment. The second run, with `symbol`, answered the same question in two calls with the right lines. Exact `line` and `column` remain accepted, 1-based like `read_file numbered=true`, for the rare model that has them.
14+
15+The same idea governs the answers: every location comes back as `path:line:column <the source line>`. The model does not need to open the file to know what a hit is, so it does not — one call instead of three.
16+
17+## Why the files are synced from disk before every request
18+
19+An editor tells its server about every keystroke. `mm` has no keystrokes: the model edits with `edit_file`, and the file on disk is the truth. So before every request the client reads the file and sends its content — `didOpen` the first time, a whole-document `didChange` when it moved since — and the server always answers on what the model just wrote. Nothing else has to remember to notify anyone.
20+
21+## Why `rename` writes the files itself, and asks first
22+
23+A rename is a `WorkspaceEdit`: ranges to replace in several files. The client applies them to disk — from the end of each file backwards so earlier positions stay valid, with UTF-16 columns converted to bytes — then re-syncs the changed files so the next question is answered on the renamed code. In the terminal it runs like `edit_file` does; where a front end has a permission dialog (an editor over ACP, the window mode with `permissions: ask`) it asks, because it writes, while the five navigation actions do not: a dialog on every read teaches people to click "allow" without reading.
24+
25+## Why a missing server is a warning
26+
27+A configuration travels with a project; the machines it runs on differ. A server named in the YAML but absent from the `PATH` is reported once at start-up and left out of the tool's description; when none is found, the tool is not declared at all and the banner says so. The agent starts either way. Declaring a server the tool cannot start would have handed the model a tool that always fails — the silent-failure trap the whole project is built against.
28+
29+## Rejected alternatives
30+
31+- **One tool per action.** Rejected for the model's sake: a longer tool list, six descriptions, the workflow scattered.
32+- **A hand-rolled JSON-RPC client.** Considered, to keep the dependency count at zero. The user chose `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`: typed requests and results, the union types the protocol is full of already decoded. The price is two direct dependencies (plus `go.uber.org/zap`-free v1 packages' own few) and a Go 1.26 floor, which those modules require.
33+- **Letting the model count columns.** Rejected by evidence, see above.
34+- **Starting every configured server at launch.** Rejected: a Python server on a Go project would run for nothing. Servers start on the first call for a file they serve and stop when `mm` exits.
35+- **Feeding diagnostics to the model on its own.** Rejected: the model asks for them when the skill and the prompt say to (after an edit). Pushing them unasked would put text in the history the model did not request and cannot place.
36+
37+## How it relates to the rest
38+
39+`internal/lsp` is the client: server lifecycle, file sync, the six operations, the text shaping, the rename applied to disk. `internal/tools` declares the tool over it and decides what is printed or emitted as events, like the other tools. `internal/config` holds `lsp.servers` and refuses two servers on one extension. `main.go` checks the `PATH`, prints the warnings, and stops the servers at exit. The recap line gained a `🔎 N lsp call(s)` counter, kept apart from `⚙ commands` for the same reason `📝 file op(s)` is: asking the compiler is not running a command.
added docs/en/how-to/navigate-code-with-lsp.md +110 -0
new file mode 100644
@@ -0,0 +1,110 @@
1+# How to navigate and refactor code with a language server (`lsp`)
2+
3+This guide shows how to give `mm` a language server, so the model can ask the compiler where a symbol is defined, who uses it, what its type is, what is wrong in a file, and rename it everywhere — instead of grepping and guessing. It assumes `mm` runs with a configuration file you can edit, and that at least one language server is installed.
4+
5+## Steps
6+
7+1. Install the server for your language and check it is on the `PATH`:
8+
9+ | Language | Server | Install | `command` / `args` |
10+ |----------|--------|---------|--------------------|
11+ | Go | gopls | `go install golang.org/x/tools/gopls@latest` | `gopls` |
12+ | TypeScript, JavaScript | typescript-language-server | `npm i -g typescript typescript-language-server` | `typescript-language-server`, `["--stdio"]` |
13+ | Python | pyright | `pip install pyright` or `npm i -g pyright` | `pyright-langserver`, `["--stdio"]` |
14+ | Rust | rust-analyzer | `rustup component add rust-analyzer` | `rust-analyzer` |
15+ | C, C++ | clangd | `apt install clangd`, `brew install llvm` | `clangd` |
16+ | Java | Eclipse JDT LS | your package manager, or the jdtls release | `jdtls` |
17+ | Zig | zls | `zig` toolchain release, or `brew install zls` | `zls` |
18+
19+ `which gopls` (or the server's name) must answer. A server that is configured but not found is skipped with a warning at start-up.
20+
21+2. Declare the servers in the YAML file. The file extension picks the server:
22+
23+ ```yaml
24+ lsp:
25+ servers:
26+ - name: gopls
27+ command: gopls
28+ extensions: [".go"]
29+ - name: typescript
30+ command: typescript-language-server
31+ args: ["--stdio"]
32+ extensions: [".ts", ".tsx", ".js", ".jsx"]
33+ - name: python
34+ command: pyright-langserver
35+ args: ["--stdio"]
36+ extensions: [".py"]
37+ - name: rust
38+ command: rust-analyzer
39+ extensions: [".rs"]
40+ ```
41+
42+ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block (gopls on, the others ready to uncomment) and a system prompt written for the tool — start from it.
43+
44+3. Tell the model when to use the tool. A small model does not reach for a new tool by itself; the system prompt has to say so. The block below is the one in `agent.lsp.yaml`; paste it into your own `system:`:
45+
46+ ```text
47+ CODE NAVIGATION (lsp)
48+ You have an `lsp` tool: it asks the project's language server what the
49+ compiler knows. Its description says which file extensions it covers.
50+ Use it INSTEAD of grep or reading whole files for anything about a symbol:
51+ - "where is X defined / declared" → action definition, symbol X
52+ - "who calls / uses X", "find usages" → action references, symbol X
53+ - "what is the type / signature of X" → action hover, symbol X
54+ - "what does this file contain" → action symbols, path only
55+ - "are there errors", after every edit → action diagnostics, path only
56+ - "rename X to Y everywhere" → action rename, symbol X, new_name Y
57+ (rename NEEDS new_name — the new identifier; never rename with edit_file:
58+ {"action": "rename", "path": "main.go", "symbol": "Greet", "new_name": "Hello"})
59+ Point at a symbol with its NAME (the `symbol` field), exactly as written in
60+ the code; add `line` only to pick one occurrence among several. Do not
61+ guess columns. Answers are "path:line:column <source line>": quote them,
62+ they are the evidence.
63+ Before changing a symbol used in several files, call references and say how
64+ many places are affected. After editing, call diagnostics on each edited
65+ file and fix what it reports before running tests.
66+ ```
67+
68+ The `lsp-navigation` skill shipped in `skills/` says the same at more length; with `skillsDir` pointing at it, the model can load it with `read_skill` when the task calls for it.
69+
70+4. Start `mm` from the project directory (the servers get it as their workspace root) and read the banner:
71+
72+ ```text
73+ [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]
74+ config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
75+ ```
76+
77+ `lsp` in the `tools:` column means at least one server was found. Servers start on their first use, not here.
78+
79+5. Ask. A few prompts to test the six actions, on any project the server covers:
80+
81+ | Prompt | What you should see |
82+ |--------|---------------------|
83+ | `Where is the function greet defined in main.go? Use lsp.` | `🔎 lsp: definition main.go greet` then one `main.go:6:6 func greet(…)` line, quoted in the answer. |
84+ | `Who uses greet? List every call site.` | `🔎 lsp: references main.go greet`, `N reference(s):` with one line per site. |
85+ | `What is the signature of greet?` | `🔎 lsp: hover main.go greet`, the server's signature and doc comment. |
86+ | `What does main.go contain? Give me its outline.` | `🔎 lsp: symbols main.go`, one line per function, type, variable. |
87+ | `Are there compile errors in main.go?` | `🔎 lsp: diagnostics main.go`, `no diagnostics: main.go is clean` or one line per error. |
88+ | `Rename greet to hello everywhere, then check main.go for errors.` | `🔎 lsp: rename main.go greet → hello`, `renamed to hello: N edit(s) in M file(s)`, then a `diagnostics` call. |
89+ | `Add a parameter to greet. Before you edit, tell me how many places call it.` | `references` first, then `edit_file`, then `diagnostics` — the procedure the skill teaches. |
90+
91+ The recap line after the answer counts the calls: `⚙ 0 command(s) · 🔎 2 lsp call(s)`.
92+
93+## From an editor (ACP) and in the window mode
94+
95+Nothing to configure: the same YAML applies. Navigation calls show up as tool calls of kind `read`, with no permission dialog; `rename` is kind `edit` and asks for permission like `edit_file` does (`permissions: ask` in the window mode). The servers start in the directory `mm` was launched in — for an editor, the project it opened.
96+
97+## Variants
98+
99+- **A project in several languages.** List one server per extension family; each starts only when a file of its kind is asked about.
100+- **A server with settings.** Pass them as `args` (many servers read a flag or a config file in the project root); `mm` answers "no settings" to a server that asks for configuration, so it runs with its defaults.
101+- **Exact positions.** When the model has an exact position — from `read_file` with `numbered=true` — it may send `line` and `column` (1-based, inside the identifier) instead of `symbol`. `symbol` is the reliable road: small models rarely count columns right.
102+- **No server on this machine.** The tool is not declared and the banner says `lsp: none of the configured servers is on the PATH — the lsp tool is not declared`. The configuration can stay; the agent works without it.
103+- **Slow first answer.** The first call for a language starts the server and lets it index the project; `diagnostics` may say `as last published` or `no diagnostics received … within 3s` — ask again once.
104+
105+## See also
106+
107+- [Built-in tools reference](../reference/tools.md) — the `lsp` fields, outputs and failures
108+- [Configuration reference](../reference/configuration.md) — the `lsp.servers` keys and validation errors
109+- [Why one `lsp` tool, and why a `symbol` field](../explanation/lsp-tool.md)
110+- [Add a skill](add-a-skill.md) — the `lsp-navigation` skill's format
new file mode 100644
@@ -0,0 +1,110 @@
1+# How to navigate and refactor code with a language server (`lsp`)
2+
3+This guide shows how to give `mm` a language server, so the model can ask the compiler where a symbol is defined, who uses it, what its type is, what is wrong in a file, and rename it everywhere — instead of grepping and guessing. It assumes `mm` runs with a configuration file you can edit, and that at least one language server is installed.
4+
5+## Steps
6+
7+1. Install the server for your language and check it is on the `PATH`:
8+
9+ | Language | Server | Install | `command` / `args` |
10+ |----------|--------|---------|--------------------|
11+ | Go | gopls | `go install golang.org/x/tools/gopls@latest` | `gopls` |
12+ | TypeScript, JavaScript | typescript-language-server | `npm i -g typescript typescript-language-server` | `typescript-language-server`, `["--stdio"]` |
13+ | Python | pyright | `pip install pyright` or `npm i -g pyright` | `pyright-langserver`, `["--stdio"]` |
14+ | Rust | rust-analyzer | `rustup component add rust-analyzer` | `rust-analyzer` |
15+ | C, C++ | clangd | `apt install clangd`, `brew install llvm` | `clangd` |
16+ | Java | Eclipse JDT LS | your package manager, or the jdtls release | `jdtls` |
17+ | Zig | zls | `zig` toolchain release, or `brew install zls` | `zls` |
18+
19+ `which gopls` (or the server's name) must answer. A server that is configured but not found is skipped with a warning at start-up.
20+
21+2. Declare the servers in the YAML file. The file extension picks the server:
22+
23+ ```yaml
24+ lsp:
25+ servers:
26+ - name: gopls
27+ command: gopls
28+ extensions: [".go"]
29+ - name: typescript
30+ command: typescript-language-server
31+ args: ["--stdio"]
32+ extensions: [".ts", ".tsx", ".js", ".jsx"]
33+ - name: python
34+ command: pyright-langserver
35+ args: ["--stdio"]
36+ extensions: [".py"]
37+ - name: rust
38+ command: rust-analyzer
39+ extensions: [".rs"]
40+ ```
41+
42+ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block (gopls on, the others ready to uncomment) and a system prompt written for the tool — start from it.
43+
44+3. Tell the model when to use the tool. A small model does not reach for a new tool by itself; the system prompt has to say so. The block below is the one in `agent.lsp.yaml`; paste it into your own `system:`:
45+
46+ ```text
47+ CODE NAVIGATION (lsp)
48+ You have an `lsp` tool: it asks the project's language server what the
49+ compiler knows. Its description says which file extensions it covers.
50+ Use it INSTEAD of grep or reading whole files for anything about a symbol:
51+ - "where is X defined / declared" → action definition, symbol X
52+ - "who calls / uses X", "find usages" → action references, symbol X
53+ - "what is the type / signature of X" → action hover, symbol X
54+ - "what does this file contain" → action symbols, path only
55+ - "are there errors", after every edit → action diagnostics, path only
56+ - "rename X to Y everywhere" → action rename, symbol X, new_name Y
57+ (rename NEEDS new_name — the new identifier; never rename with edit_file:
58+ {"action": "rename", "path": "main.go", "symbol": "Greet", "new_name": "Hello"})
59+ Point at a symbol with its NAME (the `symbol` field), exactly as written in
60+ the code; add `line` only to pick one occurrence among several. Do not
61+ guess columns. Answers are "path:line:column <source line>": quote them,
62+ they are the evidence.
63+ Before changing a symbol used in several files, call references and say how
64+ many places are affected. After editing, call diagnostics on each edited
65+ file and fix what it reports before running tests.
66+ ```
67+
68+ The `lsp-navigation` skill shipped in `skills/` says the same at more length; with `skillsDir` pointing at it, the model can load it with `read_skill` when the task calls for it.
69+
70+4. Start `mm` from the project directory (the servers get it as their workspace root) and read the banner:
71+
72+ ```text
73+ [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]
74+ config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
75+ ```
76+
77+ `lsp` in the `tools:` column means at least one server was found. Servers start on their first use, not here.
78+
79+5. Ask. A few prompts to test the six actions, on any project the server covers:
80+
81+ | Prompt | What you should see |
82+ |--------|---------------------|
83+ | `Where is the function greet defined in main.go? Use lsp.` | `🔎 lsp: definition main.go greet` then one `main.go:6:6 func greet(…)` line, quoted in the answer. |
84+ | `Who uses greet? List every call site.` | `🔎 lsp: references main.go greet`, `N reference(s):` with one line per site. |
85+ | `What is the signature of greet?` | `🔎 lsp: hover main.go greet`, the server's signature and doc comment. |
86+ | `What does main.go contain? Give me its outline.` | `🔎 lsp: symbols main.go`, one line per function, type, variable. |
87+ | `Are there compile errors in main.go?` | `🔎 lsp: diagnostics main.go`, `no diagnostics: main.go is clean` or one line per error. |
88+ | `Rename greet to hello everywhere, then check main.go for errors.` | `🔎 lsp: rename main.go greet → hello`, `renamed to hello: N edit(s) in M file(s)`, then a `diagnostics` call. |
89+ | `Add a parameter to greet. Before you edit, tell me how many places call it.` | `references` first, then `edit_file`, then `diagnostics` — the procedure the skill teaches. |
90+
91+ The recap line after the answer counts the calls: `⚙ 0 command(s) · 🔎 2 lsp call(s)`.
92+
93+## From an editor (ACP) and in the window mode
94+
95+Nothing to configure: the same YAML applies. Navigation calls show up as tool calls of kind `read`, with no permission dialog; `rename` is kind `edit` and asks for permission like `edit_file` does (`permissions: ask` in the window mode). The servers start in the directory `mm` was launched in — for an editor, the project it opened.
96+
97+## Variants
98+
99+- **A project in several languages.** List one server per extension family; each starts only when a file of its kind is asked about.
100+- **A server with settings.** Pass them as `args` (many servers read a flag or a config file in the project root); `mm` answers "no settings" to a server that asks for configuration, so it runs with its defaults.
101+- **Exact positions.** When the model has an exact position — from `read_file` with `numbered=true` — it may send `line` and `column` (1-based, inside the identifier) instead of `symbol`. `symbol` is the reliable road: small models rarely count columns right.
102+- **No server on this machine.** The tool is not declared and the banner says `lsp: none of the configured servers is on the PATH — the lsp tool is not declared`. The configuration can stay; the agent works without it.
103+- **Slow first answer.** The first call for a language starts the server and lets it index the project; `diagnostics` may say `as last published` or `no diagnostics received … within 3s` — ask again once.
104+
105+## See also
106+
107+- [Built-in tools reference](../reference/tools.md) — the `lsp` fields, outputs and failures
108+- [Configuration reference](../reference/configuration.md) — the `lsp.servers` keys and validation errors
109+- [Why one `lsp` tool, and why a `symbol` field](../explanation/lsp-tool.md)
110+- [Add a skill](add-a-skill.md) — the `lsp-navigation` skill's format
modified docs/en/reference/configuration.md +30 -0
@@ -24,6 +24,7 @@
2424 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
2525 | `context` | map | see below | Automatic history compression. |
2626 | `sessions` | map | see below | Persistent, per-project sessions. |
27+| `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |
2728 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |
2829 | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. |
2930
@@ -46,6 +47,30 @@
4647 | `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |
4748 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |
4849
50+## `lsp` keys
51+
52+| Key | Type | Default | Description |
53+|-----|------|---------|-------------|
54+| `lsp.servers` | list | `[]` | One entry per language server. The file extension picks the server. |
55+| `lsp.servers[].name` | string | — | How the banner, the tool description and the warnings call it. Required. |
56+| `lsp.servers[].command` | string | — | The executable, looked up on the `PATH` at start-up. Required. Not found: warning, server skipped. |
57+| `lsp.servers[].args` | list of strings | `[]` | Its arguments (many servers need `--stdio`). |
58+| `lsp.servers[].extensions` | list of strings | — | File extensions it serves, with the dot (`.go`, `.ts`). Required, at least one; matched case-insensitively; an extension may be served by one server only. |
59+
60+```yaml
61+lsp:
62+ servers:
63+ - name: gopls
64+ command: gopls
65+ extensions: [".go"]
66+ - name: typescript
67+ command: typescript-language-server
68+ args: ["--stdio"]
69+ extensions: [".ts", ".tsx", ".js", ".jsx"]
70+```
71+
72+The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls active, other servers ready to uncomment) and a system prompt that says when to use the tool.
73+
4974 ## Provider defaults
5075
5176 | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required |
@@ -70,6 +95,11 @@ Reported as `[config error: <path>: <message>]`, exit code 1.
7095 | `context.summaryMaxTokens must be > 0` | zero or negative |
7196 | `context.maxMessages must be >= 0` | negative value |
7297 | `sessions.dir must not be empty` | empty string |
98+| `lsp.servers[N].name must not be empty` | a server without `name` |
99+| `lsp.servers[N].command must not be empty` | a server without `command` |
100+| `lsp.servers[N].extensions must list at least one extension` | a server without `extensions` |
101+| `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot |
102+| `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension |
73103 | `permissions must be "auto" or "ask"` | any other value |
74104 | `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. |
75105
@@ -24,6 +24,7 @@
24 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |24 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
25 | `context` | map | see below | Automatic history compression. |25 | `context` | map | see below | Automatic history compression. |
26 | `sessions` | map | see below | Persistent, per-project sessions. |26 | `sessions` | map | see below | Persistent, per-project sessions. |
27+| `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |
27 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |28 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |
28 | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. |29 | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. |
29 30
@@ -46,6 +47,30 @@
46 | `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |47 | `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |
47 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |48 | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |
48 49
50+## `lsp` keys
51+
52+| Key | Type | Default | Description |
53+|-----|------|---------|-------------|
54+| `lsp.servers` | list | `[]` | One entry per language server. The file extension picks the server. |
55+| `lsp.servers[].name` | string | — | How the banner, the tool description and the warnings call it. Required. |
56+| `lsp.servers[].command` | string | — | The executable, looked up on the `PATH` at start-up. Required. Not found: warning, server skipped. |
57+| `lsp.servers[].args` | list of strings | `[]` | Its arguments (many servers need `--stdio`). |
58+| `lsp.servers[].extensions` | list of strings | — | File extensions it serves, with the dot (`.go`, `.ts`). Required, at least one; matched case-insensitively; an extension may be served by one server only. |
59+
60+```yaml
61+lsp:
62+ servers:
63+ - name: gopls
64+ command: gopls
65+ extensions: [".go"]
66+ - name: typescript
67+ command: typescript-language-server
68+ args: ["--stdio"]
69+ extensions: [".ts", ".tsx", ".js", ".jsx"]
70+```
71+
72+The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls active, other servers ready to uncomment) and a system prompt that says when to use the tool.
73+
49 ## Provider defaults74 ## Provider defaults
50 75
51 | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required |76 | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required |
@@ -70,6 +95,11 @@ Reported as `[config error: <path>: <message>]`, exit code 1.
70 | `context.summaryMaxTokens must be > 0` | zero or negative |95 | `context.summaryMaxTokens must be > 0` | zero or negative |
71 | `context.maxMessages must be >= 0` | negative value |96 | `context.maxMessages must be >= 0` | negative value |
72 | `sessions.dir must not be empty` | empty string |97 | `sessions.dir must not be empty` | empty string |
98+| `lsp.servers[N].name must not be empty` | a server without `name` |
99+| `lsp.servers[N].command must not be empty` | a server without `command` |
100+| `lsp.servers[N].extensions must list at least one extension` | a server without `extensions` |
101+| `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot |
102+| `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension |
73 | `permissions must be "auto" or "ask"` | any other value |103 | `permissions must be "auto" or "ask"` | any other value |
74 | `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. |104 | `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. |
75 105
modified docs/en/reference/tools.md +44 -1
@@ -85,7 +85,7 @@ Declared when `editTools: true`.
8585
8686 | Rule | Consequence when broken |
8787 |------|-------------------------|
88-| `old` appears exactly once | Refused: not found, or ambiguous. |
88+| `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…`. |
8989 | Two edits must not overlap, nest or repeat | Refused. |
9090 | Edits are resolved against the original text, never in cascade | — |
9191 | Any refused edit | Nothing is written. |
@@ -95,6 +95,48 @@ Declared when `editTools: true`.
9595 | Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. |
9696 | ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |
9797
98+## `lsp`
99+
100+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.
101+
102+| Field | Type | Default | Description |
103+|-------|------|---------|-------------|
104+| `action` | string | — | `definition`, `references`, `hover`, `symbols`, `diagnostics` or `rename`. |
105+| `path` | string | — | File path, relative to the working directory. Its extension picks the server. |
106+| `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`. |
107+| `line` | int | `0` | 1-based line. With `symbol`: restricts the search to that line. With `column`: the exact position. |
108+| `column` | int | `0` | 1-based column, in characters, pointing inside the identifier. Only needed without `symbol`. |
109+| `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. |
110+
111+| Aspect | Behaviour |
112+|--------|-----------|
113+| Description | Names the servers and their extensions (`Servers available: gopls (.go), …`) and the six actions. |
114+| `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. |
115+| `hover` | The server's text as it sent it (signature, type, documentation; Markdown for most servers). |
116+| `symbols` | One `<kind> <name> [<detail>] line N` per symbol, children indented two spaces. |
117+| `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. |
118+| `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]`. |
119+| 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. |
120+| 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. |
121+| 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. |
122+| 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]`. |
123+| 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. |
124+| 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). |
125+| Counter | `🔎 N lsp call(s)` in the recap line (see below). |
126+
127+## Loop detection
128+
129+Every tool records `(tool, input, output)` in the loop detector after each call.
130+
131+| Situation | Behaviour |
132+|-----------|-----------|
133+| 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. |
134+| 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`). |
135+| A different call | The count starts again. `/new` resets the detector; `/compact` does not. |
136+| 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`. |
137+
138+The thresholds (3, then 3 more) are set in `main.go`; `maxTurns` remains the outer bound of a turn.
139+
98140 ## Counters shown after a turn
99141
100142 | Counter | Counts |
@@ -102,3 +144,4 @@ Declared when `editTools: true`.
102144 | `⚙ N command(s)` | `bash` tool responses in the turn. |
103145 | `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. |
104146 | `📖 N skill(s)` | `read_skill` responses. |
147+| `🔎 N lsp call(s)` | `lsp` responses. |
@@ -85,7 +85,7 @@ Declared when `editTools: true`.
85 85
86 | Rule | Consequence when broken |86 | Rule | Consequence when broken |
87 |------|-------------------------|87 |------|-------------------------|
88-| `old` appears exactly once | Refused: not found, or ambiguous. |88+| `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…`. |
89 | Two edits must not overlap, nest or repeat | Refused. |89 | Two edits must not overlap, nest or repeat | Refused. |
90 | Edits are resolved against the original text, never in cascade | — |90 | Edits are resolved against the original text, never in cascade | — |
91 | Any refused edit | Nothing is written. |91 | Any refused edit | Nothing is written. |
@@ -95,6 +95,48 @@ Declared when `editTools: true`.
95 | Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. |95 | Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. |
96 | ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |96 | ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |
97 97
98+## `lsp`
99+
100+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.
101+
102+| Field | Type | Default | Description |
103+|-------|------|---------|-------------|
104+| `action` | string | — | `definition`, `references`, `hover`, `symbols`, `diagnostics` or `rename`. |
105+| `path` | string | — | File path, relative to the working directory. Its extension picks the server. |
106+| `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`. |
107+| `line` | int | `0` | 1-based line. With `symbol`: restricts the search to that line. With `column`: the exact position. |
108+| `column` | int | `0` | 1-based column, in characters, pointing inside the identifier. Only needed without `symbol`. |
109+| `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. |
110+
111+| Aspect | Behaviour |
112+|--------|-----------|
113+| Description | Names the servers and their extensions (`Servers available: gopls (.go), …`) and the six actions. |
114+| `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. |
115+| `hover` | The server's text as it sent it (signature, type, documentation; Markdown for most servers). |
116+| `symbols` | One `<kind> <name> [<detail>] line N` per symbol, children indented two spaces. |
117+| `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. |
118+| `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]`. |
119+| 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. |
120+| 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. |
121+| 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. |
122+| 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]`. |
123+| 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. |
124+| 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). |
125+| Counter | `🔎 N lsp call(s)` in the recap line (see below). |
126+
127+## Loop detection
128+
129+Every tool records `(tool, input, output)` in the loop detector after each call.
130+
131+| Situation | Behaviour |
132+|-----------|-----------|
133+| 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. |
134+| 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`). |
135+| A different call | The count starts again. `/new` resets the detector; `/compact` does not. |
136+| 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`. |
137+
138+The thresholds (3, then 3 more) are set in `main.go`; `maxTurns` remains the outer bound of a turn.
139+
98 ## Counters shown after a turn140 ## Counters shown after a turn
99 141
100 | Counter | Counts |142 | Counter | Counts |
@@ -102,3 +144,4 @@ Declared when `editTools: true`.
102 | `⚙ N command(s)` | `bash` tool responses in the turn. |144 | `⚙ N command(s)` | `bash` tool responses in the turn. |
103 | `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. |145 | `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. |
104 | `📖 N skill(s)` | `read_skill` responses. |146 | `📖 N skill(s)` | `read_skill` responses. |
147+| `🔎 N lsp call(s)` | `lsp` responses. |
modified docs/en/tutorials/getting-started.md +1 -1
@@ -4,7 +4,7 @@ By the end of this tutorial, you will have built the `mm` binary, started it aga
44
55 ## Prerequisites
66
7-- Go 1.25 or later on your `PATH` (`go version` answers).
7+- Go 1.26 or later on your `PATH` (`go version` answers).
88 - Docker Desktop with Docker Model Runner enabled and reachable on `http://localhost:12434`.
99 - The model named in `agent.yaml` pulled locally. The file shipped with the project names `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`; pull it with `docker model pull <that name>`.
1010 - A terminal open at the root of the repository (the directory that holds `main.go` and `agent.yaml`).
@@ -4,7 +4,7 @@ By the end of this tutorial, you will have built the `mm` binary, started it aga
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
7-- Go 1.25 or later on your `PATH` (`go version` answers).7+- Go 1.26 or later on your `PATH` (`go version` answers).
8 - Docker Desktop with Docker Model Runner enabled and reachable on `http://localhost:12434`.8 - Docker Desktop with Docker Model Runner enabled and reachable on `http://localhost:12434`.
9 - The model named in `agent.yaml` pulled locally. The file shipped with the project names `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`; pull it with `docker model pull <that name>`.9 - The model named in `agent.yaml` pulled locally. The file shipped with the project names `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`; pull it with `docker model pull <that name>`.
10 - A terminal open at the root of the repository (the directory that holds `main.go` and `agent.yaml`).10 - A terminal open at the root of the repository (the directory that holds `main.go` and `agent.yaml`).
modified docs/fr/README.md +2 -0
@@ -18,6 +18,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
1818 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
1919 - [Conserver les sessions et les reprendre](how-to/keep-sessions.md)
2020 - [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md)
21+- [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md)
2122
2223 ## Référence — consulter
2324
@@ -34,6 +35,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
3435 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)
3536 - [Compression du contexte](explanation/context-compression.md)
3637 - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent
38+- [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission
3739 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
3840
3941 ## Périmètre
@@ -18,6 +18,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
18 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)18 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
19 - [Conserver les sessions et les reprendre](how-to/keep-sessions.md)19 - [Conserver les sessions et les reprendre](how-to/keep-sessions.md)
20 - [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md)20 - [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md)
21+- [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md)
21 22
22 ## Référence — consulter23 ## Référence — consulter
23 24
@@ -34,6 +35,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
34 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)35 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)
35 - [Compression du contexte](explanation/context-compression.md)36 - [Compression du contexte](explanation/context-compression.md)
36 - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent37 - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent
38+- [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission
37 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)39 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
38 40
39 ## Périmètre41 ## Périmètre
modified docs/fr/explanation/architecture.md +2 -1
@@ -12,6 +12,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
1212 | `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, et le registre des fournisseurs. |
1313 | `tools` | Les outils intégrés que le modèle peut appeler. |
1414 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
15+| `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. |
1516 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
1617 | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. |
1718 | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. |
@@ -45,7 +46,7 @@ Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans
4546
4647 **Les tours sont sérialisés en mode ACP.** Le puits actif est global au processus et le moteur n'a jamais été conçu pour des générations concurrentes, donc un second `session/prompt` attend la fin du premier. Le protocole laisse ce choix à l'agent, et les éditeurs n'envoient de toute façon pas de prompts qui se chevauchent.
4748
48-**Le détecteur de boucle est séparé de l'historique.** Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par `/compact` : oublier une conversation ne rend pas nouvelle une commande répétée.
49+**Le détecteur de boucle est séparé de l'historique.** Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par `/compact` : oublier une conversation ne rend pas nouvelle une commande répétée. Le conseil ne suffit pas à tous les modèles : le 18 septembre 2026, un modèle de 12 milliards de paramètres a répété trente fois le même `edit_file` refusé, `[LOOP_DETECTED]` ajouté à chaque réponse, jusqu'à ce que l'utilisateur appuie sur Échap. Le détecteur a donc un second niveau : trois répétitions signalées après le seuil — six appels identiques — et l'outil renvoie une erreur Go au lieu de texte, la seule exception délibérée à « les outils répondent en texte » : l'agent s'arrête lui-même, il ne rapporte pas un échec à lire par le modèle. Le moteur affiche la phrase du détecteur comme erreur du tour, ne rejoue pas le tour, et le fichier de session l'enregistre. Un troisième garde-fou est dans le flux lui-même : les arguments d'un appel d'outil arrivent fragment par fragment et rien n'en est affiché, donc un modèle qui déraille en composant un appel — `"Greet 2023-06-15 11:16:00 +0000 UTC 2023-06-15 …"` pendant quatre minutes, vu le même jour — ressemble à « rien ne se passe ». Le moteur surveille ces fragments et arrête la génération dès qu'ils répètent un motif ou dépassent un quart de mégaoctet.
4950
5051 ## Alternatives écartées
5152
@@ -12,6 +12,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
12 | `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, et le registre des fournisseurs. |12 | `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, et le registre des fournisseurs. |
13 | `tools` | Les outils intégrés que le modèle peut appeler. |13 | `tools` | Les outils intégrés que le modèle peut appeler. |
14 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |14 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
15+| `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. |
15 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |16 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
16 | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. |17 | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. |
17 | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. |18 | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. |
@@ -45,7 +46,7 @@ Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans
45 46
46 **Les tours sont sérialisés en mode ACP.** Le puits actif est global au processus et le moteur n'a jamais été conçu pour des générations concurrentes, donc un second `session/prompt` attend la fin du premier. Le protocole laisse ce choix à l'agent, et les éditeurs n'envoient de toute façon pas de prompts qui se chevauchent.47 **Les tours sont sérialisés en mode ACP.** Le puits actif est global au processus et le moteur n'a jamais été conçu pour des générations concurrentes, donc un second `session/prompt` attend la fin du premier. Le protocole laisse ce choix à l'agent, et les éditeurs n'envoient de toute façon pas de prompts qui se chevauchent.
47 48
48-**Le détecteur de boucle est séparé de l'historique.** Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par `/compact` : oublier une conversation ne rend pas nouvelle une commande répétée.49+**Le détecteur de boucle est séparé de l'historique.** Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par `/compact` : oublier une conversation ne rend pas nouvelle une commande répétée. Le conseil ne suffit pas à tous les modèles : le 18 septembre 2026, un modèle de 12 milliards de paramètres a répété trente fois le même `edit_file` refusé, `[LOOP_DETECTED]` ajouté à chaque réponse, jusqu'à ce que l'utilisateur appuie sur Échap. Le détecteur a donc un second niveau : trois répétitions signalées après le seuil — six appels identiques — et l'outil renvoie une erreur Go au lieu de texte, la seule exception délibérée à « les outils répondent en texte » : l'agent s'arrête lui-même, il ne rapporte pas un échec à lire par le modèle. Le moteur affiche la phrase du détecteur comme erreur du tour, ne rejoue pas le tour, et le fichier de session l'enregistre. Un troisième garde-fou est dans le flux lui-même : les arguments d'un appel d'outil arrivent fragment par fragment et rien n'en est affiché, donc un modèle qui déraille en composant un appel — `"Greet 2023-06-15 11:16:00 +0000 UTC 2023-06-15 …"` pendant quatre minutes, vu le même jour — ressemble à « rien ne se passe ». Le moteur surveille ces fragments et arrête la génération dès qu'ils répètent un motif ou dépassent un quart de mégaoctet.
49 50
50 ## Alternatives écartées51 ## Alternatives écartées
51 52
added docs/fr/explanation/lsp-tool.md +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+# L'outil `lsp` : pourquoi un seul outil, et pourquoi un champ `symbol` — explication
2+
3+## De quoi s'agit-il ?
4+
5+Jusqu'au 18 septembre 2026, le modèle connaissait un projet par `bash` (grep, ls, go build) et par les outils de fichiers. C'est ainsi que travaille un humain sans éditeur, et c'est lent et fragile pour les questions qu'un éditeur règle à l'instant : où est-ce défini, qui l'appelle, quel est ce type, que dit le compilateur. Les serveurs de langage répondent à ces questions pour tous les langages courants au travers d'un même protocole, le Language Server Protocol (LSP). Le ticket 17 demandait d'en donner un client à `mm` : des serveurs configurés, un outil intégré, une documentation avec des exemples de prompt système, et un avertissement plutôt qu'un échec quand un serveur manque.
6+
7+## Pourquoi un seul outil avec un champ `action`
8+
9+Six requêtes étaient voulues : définition, références, hover, symboles du document, diagnostics, renommage. Elles auraient pu être six outils. Elles n'en font qu'un, `lsp`, avec une énumération `action`, pour le modèle que vise cet agent — un modèle de 12 milliards de paramètres qui lit fiablement une liste de cinq outils, moins bien une liste de onze. Un seul outil porte aussi une seule description, et c'est dans la description que vit la méthode : « references avant de changer un symbole partagé, diagnostics après avoir édité ». Six descriptions l'auraient dit six fois, ou pas du tout.
10+
11+## Pourquoi un champ `symbol`, et des positions essayées dans l'ordre
12+
13+Les requêtes LSP prennent une position : ligne en base 0 et colonne en unités UTF-16. Le premier essai de bout en bout a montré le modèle envoyant `column: 0` à chaque appel — même quand la question donnait la colonne — et bouclant sur la même erreur. Demander à un petit modèle de compter des caractères, c'est lui demander un nombre qu'il ne sait pas produire. L'outil prend donc l'identifiant à la place : `symbol: greet`. Le client cherche ses occurrences en mot entier dans le fichier et les essaie dans l'ordre jusqu'à ce que le serveur réponde quelque chose ; les occurrences sur des lignes de commentaire passent en dernier, parce qu'un nom est souvent cité dans le commentaire au-dessus de sa déclaration et que le serveur n'a rien à dire d'un commentaire. Le second essai, avec `symbol`, a répondu à la même question en deux appels avec les bonnes lignes. `line` et `column` exacts restent acceptés, en base 1 comme `read_file numbered=true`, pour le rare modèle qui les a.
14+
15+La même idée gouverne les réponses : chaque emplacement revient sous la forme `chemin:ligne:colonne <la ligne source>`. Le modèle n'a pas besoin d'ouvrir le fichier pour savoir ce qu'est un résultat, donc il ne l'ouvre pas — un appel au lieu de trois.
16+
17+## Pourquoi les fichiers sont synchronisés depuis le disque avant chaque requête
18+
19+Un éditeur informe son serveur de chaque frappe. `mm` n'a pas de frappes : le modèle édite avec `edit_file`, et le fichier sur disque est la vérité. Avant chaque requête, le client relit donc le fichier et envoie son contenu — `didOpen` la première fois, un `didChange` du document entier quand il a bougé depuis — et le serveur répond toujours sur ce que le modèle vient d'écrire. Rien d'autre n'a à penser à prévenir qui que ce soit.
20+
21+## Pourquoi `rename` écrit lui-même les fichiers, et demande d'abord
22+
23+Un renommage est un `WorkspaceEdit` : des plages à remplacer dans plusieurs fichiers. Le client les applique sur disque — de la fin de chaque fichier vers le début pour que les positions antérieures restent valides, les colonnes UTF-16 converties en octets — puis resynchronise les fichiers changés pour que la question suivante porte sur le code renommé. Dans le terminal il s'exécute comme `edit_file` ; là où une façade a une boîte de permission (un éditeur en ACP, le mode fenêtre avec `permissions: ask`) il demande, parce qu'il écrit, alors que les cinq actions de navigation ne demandent pas : une boîte à chaque lecture apprend aux gens à cliquer « autoriser » sans lire.
24+
25+## Pourquoi un serveur manquant est un avertissement
26+
27+Une configuration voyage avec un projet ; les machines où elle tourne diffèrent. Un serveur nommé dans le YAML mais absent du `PATH` est signalé une fois au démarrage et retiré de la description de l'outil ; quand aucun n'est trouvé, l'outil n'est pas déclaré du tout et la bannière le dit. L'agent démarre dans tous les cas. Déclarer un serveur que l'outil ne peut pas lancer aurait donné au modèle un outil qui échoue toujours — le piège de l'échec silencieux contre lequel tout le projet est construit.
28+
29+## Alternatives écartées
30+
31+- **Un outil par action.** Écarté pour le modèle : une liste d'outils plus longue, six descriptions, la méthode dispersée.
32+- **Un client JSON-RPC écrit à la main.** Envisagé, pour garder zéro dépendance. L'utilisateur a choisi `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2` : requêtes et résultats typés, les types union dont le protocole regorge déjà décodés. Le prix : deux dépendances directes et un plancher Go 1.26, exigé par ces modules.
33+- **Laisser le modèle compter les colonnes.** Écarté par l'expérience, voir plus haut.
34+- **Démarrer tous les serveurs configurés au lancement.** Écarté : un serveur Python sur un projet Go tournerait pour rien. Les serveurs démarrent au premier appel sur un fichier qu'ils servent et s'arrêtent à la sortie de `mm`.
35+- **Pousser les diagnostics au modèle de lui-même.** Écarté : le modèle les demande quand le skill et le prompt le lui disent (après une édition). Les pousser sans demande mettrait dans l'historique du texte que le modèle n'a pas réclamé et ne sait pas situer.
36+
37+## Liens avec le reste
38+
39+`internal/lsp` est le client : cycle de vie des serveurs, synchronisation des fichiers, les six opérations, la mise en forme, le renommage appliqué au disque. `internal/tools` déclare l'outil au-dessus et décide de ce qui est affiché ou émis en événements, comme les autres outils. `internal/config` porte `lsp.servers` et refuse deux serveurs sur une même extension. `main.go` vérifie le `PATH`, affiche les avertissements et arrête les serveurs à la sortie. La ligne de récapitulatif a gagné un compteur `🔎 N lsp call(s)`, tenu à part de `⚙ commands` pour la même raison que `📝 file op(s)` : interroger le compilateur n'est pas exécuter une commande.
new file mode 100644
@@ -0,0 +1,39 @@
1+# L'outil `lsp` : pourquoi un seul outil, et pourquoi un champ `symbol` — explication
2+
3+## De quoi s'agit-il ?
4+
5+Jusqu'au 18 septembre 2026, le modèle connaissait un projet par `bash` (grep, ls, go build) et par les outils de fichiers. C'est ainsi que travaille un humain sans éditeur, et c'est lent et fragile pour les questions qu'un éditeur règle à l'instant : où est-ce défini, qui l'appelle, quel est ce type, que dit le compilateur. Les serveurs de langage répondent à ces questions pour tous les langages courants au travers d'un même protocole, le Language Server Protocol (LSP). Le ticket 17 demandait d'en donner un client à `mm` : des serveurs configurés, un outil intégré, une documentation avec des exemples de prompt système, et un avertissement plutôt qu'un échec quand un serveur manque.
6+
7+## Pourquoi un seul outil avec un champ `action`
8+
9+Six requêtes étaient voulues : définition, références, hover, symboles du document, diagnostics, renommage. Elles auraient pu être six outils. Elles n'en font qu'un, `lsp`, avec une énumération `action`, pour le modèle que vise cet agent — un modèle de 12 milliards de paramètres qui lit fiablement une liste de cinq outils, moins bien une liste de onze. Un seul outil porte aussi une seule description, et c'est dans la description que vit la méthode : « references avant de changer un symbole partagé, diagnostics après avoir édité ». Six descriptions l'auraient dit six fois, ou pas du tout.
10+
11+## Pourquoi un champ `symbol`, et des positions essayées dans l'ordre
12+
13+Les requêtes LSP prennent une position : ligne en base 0 et colonne en unités UTF-16. Le premier essai de bout en bout a montré le modèle envoyant `column: 0` à chaque appel — même quand la question donnait la colonne — et bouclant sur la même erreur. Demander à un petit modèle de compter des caractères, c'est lui demander un nombre qu'il ne sait pas produire. L'outil prend donc l'identifiant à la place : `symbol: greet`. Le client cherche ses occurrences en mot entier dans le fichier et les essaie dans l'ordre jusqu'à ce que le serveur réponde quelque chose ; les occurrences sur des lignes de commentaire passent en dernier, parce qu'un nom est souvent cité dans le commentaire au-dessus de sa déclaration et que le serveur n'a rien à dire d'un commentaire. Le second essai, avec `symbol`, a répondu à la même question en deux appels avec les bonnes lignes. `line` et `column` exacts restent acceptés, en base 1 comme `read_file numbered=true`, pour le rare modèle qui les a.
14+
15+La même idée gouverne les réponses : chaque emplacement revient sous la forme `chemin:ligne:colonne <la ligne source>`. Le modèle n'a pas besoin d'ouvrir le fichier pour savoir ce qu'est un résultat, donc il ne l'ouvre pas — un appel au lieu de trois.
16+
17+## Pourquoi les fichiers sont synchronisés depuis le disque avant chaque requête
18+
19+Un éditeur informe son serveur de chaque frappe. `mm` n'a pas de frappes : le modèle édite avec `edit_file`, et le fichier sur disque est la vérité. Avant chaque requête, le client relit donc le fichier et envoie son contenu — `didOpen` la première fois, un `didChange` du document entier quand il a bougé depuis — et le serveur répond toujours sur ce que le modèle vient d'écrire. Rien d'autre n'a à penser à prévenir qui que ce soit.
20+
21+## Pourquoi `rename` écrit lui-même les fichiers, et demande d'abord
22+
23+Un renommage est un `WorkspaceEdit` : des plages à remplacer dans plusieurs fichiers. Le client les applique sur disque — de la fin de chaque fichier vers le début pour que les positions antérieures restent valides, les colonnes UTF-16 converties en octets — puis resynchronise les fichiers changés pour que la question suivante porte sur le code renommé. Dans le terminal il s'exécute comme `edit_file` ; là où une façade a une boîte de permission (un éditeur en ACP, le mode fenêtre avec `permissions: ask`) il demande, parce qu'il écrit, alors que les cinq actions de navigation ne demandent pas : une boîte à chaque lecture apprend aux gens à cliquer « autoriser » sans lire.
24+
25+## Pourquoi un serveur manquant est un avertissement
26+
27+Une configuration voyage avec un projet ; les machines où elle tourne diffèrent. Un serveur nommé dans le YAML mais absent du `PATH` est signalé une fois au démarrage et retiré de la description de l'outil ; quand aucun n'est trouvé, l'outil n'est pas déclaré du tout et la bannière le dit. L'agent démarre dans tous les cas. Déclarer un serveur que l'outil ne peut pas lancer aurait donné au modèle un outil qui échoue toujours — le piège de l'échec silencieux contre lequel tout le projet est construit.
28+
29+## Alternatives écartées
30+
31+- **Un outil par action.** Écarté pour le modèle : une liste d'outils plus longue, six descriptions, la méthode dispersée.
32+- **Un client JSON-RPC écrit à la main.** Envisagé, pour garder zéro dépendance. L'utilisateur a choisi `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2` : requêtes et résultats typés, les types union dont le protocole regorge déjà décodés. Le prix : deux dépendances directes et un plancher Go 1.26, exigé par ces modules.
33+- **Laisser le modèle compter les colonnes.** Écarté par l'expérience, voir plus haut.
34+- **Démarrer tous les serveurs configurés au lancement.** Écarté : un serveur Python sur un projet Go tournerait pour rien. Les serveurs démarrent au premier appel sur un fichier qu'ils servent et s'arrêtent à la sortie de `mm`.
35+- **Pousser les diagnostics au modèle de lui-même.** Écarté : le modèle les demande quand le skill et le prompt le lui disent (après une édition). Les pousser sans demande mettrait dans l'historique du texte que le modèle n'a pas réclamé et ne sait pas situer.
36+
37+## Liens avec le reste
38+
39+`internal/lsp` est le client : cycle de vie des serveurs, synchronisation des fichiers, les six opérations, la mise en forme, le renommage appliqué au disque. `internal/tools` déclare l'outil au-dessus et décide de ce qui est affiché ou émis en événements, comme les autres outils. `internal/config` porte `lsp.servers` et refuse deux serveurs sur une même extension. `main.go` vérifie le `PATH`, affiche les avertissements et arrête les serveurs à la sortie. La ligne de récapitulatif a gagné un compteur `🔎 N lsp call(s)`, tenu à part de `⚙ commands` pour la même raison que `📝 file op(s)` : interroger le compilateur n'est pas exécuter une commande.
added docs/fr/how-to/navigate-code-with-lsp.md +110 -0
new file mode 100644
@@ -0,0 +1,110 @@
1+# Naviguer et refactorer le code avec un serveur de langage (`lsp`)
2+
3+Ce guide montre comment donner un serveur de langage à `mm`, pour que le modèle demande au compilateur où un symbole est défini, qui l'utilise, quel est son type, ce qui ne va pas dans un fichier, et le renomme partout — plutôt que de grepper et de deviner. Il suppose que `mm` tourne avec un fichier de configuration modifiable, et qu'au moins un serveur de langage est installé.
4+
5+## Étapes
6+
7+1. Installez le serveur de votre langage et vérifiez qu'il est sur le `PATH` :
8+
9+ | Langage | Serveur | Installation | `command` / `args` |
10+ |---------|---------|--------------|--------------------|
11+ | Go | gopls | `go install golang.org/x/tools/gopls@latest` | `gopls` |
12+ | TypeScript, JavaScript | typescript-language-server | `npm i -g typescript typescript-language-server` | `typescript-language-server`, `["--stdio"]` |
13+ | Python | pyright | `pip install pyright` ou `npm i -g pyright` | `pyright-langserver`, `["--stdio"]` |
14+ | Rust | rust-analyzer | `rustup component add rust-analyzer` | `rust-analyzer` |
15+ | C, C++ | clangd | `apt install clangd`, `brew install llvm` | `clangd` |
16+ | Java | Eclipse JDT LS | votre gestionnaire de paquets, ou la release jdtls | `jdtls` |
17+ | Zig | zls | release de la chaîne `zig`, ou `brew install zls` | `zls` |
18+
19+ `which gopls` (ou le nom du serveur) doit répondre. Un serveur configuré mais introuvable est ignoré avec un avertissement au démarrage.
20+
21+2. Déclarez les serveurs dans le fichier YAML. L'extension du fichier choisit le serveur :
22+
23+ ```yaml
24+ lsp:
25+ servers:
26+ - name: gopls
27+ command: gopls
28+ extensions: [".go"]
29+ - name: typescript
30+ command: typescript-language-server
31+ args: ["--stdio"]
32+ extensions: [".ts", ".tsx", ".js", ".jsx"]
33+ - name: python
34+ command: pyright-langserver
35+ args: ["--stdio"]
36+ extensions: [".py"]
37+ - name: rust
38+ command: rust-analyzer
39+ extensions: [".rs"]
40+ ```
41+
42+ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc (gopls actif, les autres prêts à décommenter) et un prompt système écrit pour l'outil — partez de lui.
43+
44+3. Dites au modèle quand utiliser l'outil. Un petit modèle ne va pas chercher un nouvel outil de lui-même ; le prompt système doit le dire. Le bloc ci-dessous est celui d'`agent.lsp.yaml` ; collez-le dans votre `system:` (il est en anglais, la langue dans laquelle le modèle lit le mieux ses consignes) :
45+
46+ ```text
47+ CODE NAVIGATION (lsp)
48+ You have an `lsp` tool: it asks the project's language server what the
49+ compiler knows. Its description says which file extensions it covers.
50+ Use it INSTEAD of grep or reading whole files for anything about a symbol:
51+ - "where is X defined / declared" → action definition, symbol X
52+ - "who calls / uses X", "find usages" → action references, symbol X
53+ - "what is the type / signature of X" → action hover, symbol X
54+ - "what does this file contain" → action symbols, path only
55+ - "are there errors", after every edit → action diagnostics, path only
56+ - "rename X to Y everywhere" → action rename, symbol X, new_name Y
57+ (rename NEEDS new_name — the new identifier; never rename with edit_file:
58+ {"action": "rename", "path": "main.go", "symbol": "Greet", "new_name": "Hello"})
59+ Point at a symbol with its NAME (the `symbol` field), exactly as written in
60+ the code; add `line` only to pick one occurrence among several. Do not
61+ guess columns. Answers are "path:line:column <source line>": quote them,
62+ they are the evidence.
63+ Before changing a symbol used in several files, call references and say how
64+ many places are affected. After editing, call diagnostics on each edited
65+ file and fix what it reports before running tests.
66+ ```
67+
68+ Le skill `lsp-navigation` livré dans `skills/` dit la même chose plus longuement ; avec `skillsDir` pointant dessus, le modèle peut le charger avec `read_skill` quand la tâche le demande.
69+
70+4. Lancez `mm` depuis le répertoire du projet (les serveurs le reçoivent comme racine de travail) et lisez la bannière :
71+
72+ ```text
73+ [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]
74+ config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
75+ ```
76+
77+ `lsp` dans la colonne `tools:` signifie qu'au moins un serveur a été trouvé. Les serveurs démarrent à leur premier usage, pas ici.
78+
79+5. Posez des questions. Quelques prompts pour tester les six actions, sur n'importe quel projet couvert par le serveur :
80+
81+ | Prompt | Ce que vous devez voir |
82+ |--------|------------------------|
83+ | `Where is the function greet defined in main.go? Use lsp.` | `🔎 lsp: definition main.go greet` puis une ligne `main.go:6:6 func greet(…)`, citée dans la réponse. |
84+ | `Who uses greet? List every call site.` | `🔎 lsp: references main.go greet`, `N reference(s):` avec une ligne par site. |
85+ | `What is the signature of greet?` | `🔎 lsp: hover main.go greet`, la signature et le commentaire de doc du serveur. |
86+ | `What does main.go contain? Give me its outline.` | `🔎 lsp: symbols main.go`, une ligne par fonction, type, variable. |
87+ | `Are there compile errors in main.go?` | `🔎 lsp: diagnostics main.go`, `no diagnostics: main.go is clean` ou une ligne par erreur. |
88+ | `Rename greet to hello everywhere, then check main.go for errors.` | `🔎 lsp: rename main.go greet → hello`, `renamed to hello: N edit(s) in M file(s)`, puis un appel `diagnostics`. |
89+ | `Add a parameter to greet. Before you edit, tell me how many places call it.` | `references` d'abord, puis `edit_file`, puis `diagnostics` — la procédure que le skill enseigne. |
90+
91+ Les prompts en français marchent aussi (« Où est définie la fonction greet ? ») : c'est le prompt système qui décide de l'outil, pas la langue de la question. La ligne de récapitulatif après la réponse compte les appels : `⚙ 0 command(s) · 🔎 2 lsp call(s)`.
92+
93+## Depuis un éditeur (ACP) et dans le mode fenêtre
94+
95+Rien à configurer : le même YAML s'applique. Les appels de navigation apparaissent comme des appels d'outil de sorte `read`, sans boîte de permission ; `rename` est de sorte `edit` et demande la permission comme `edit_file` (`permissions: ask` dans le mode fenêtre). Les serveurs démarrent dans le répertoire où `mm` a été lancé — pour un éditeur, le projet qu'il a ouvert.
96+
97+## Variantes
98+
99+- **Un projet en plusieurs langages.** Listez un serveur par famille d'extensions ; chacun ne démarre que quand on l'interroge sur un fichier de sa sorte.
100+- **Un serveur avec des réglages.** Passez-les en `args` (beaucoup de serveurs lisent un drapeau ou un fichier de configuration à la racine du projet) ; `mm` répond « pas de réglages » à un serveur qui demande sa configuration, qui tourne alors avec ses valeurs par défaut.
101+- **Positions exactes.** Quand le modèle a une position exacte — via `read_file` avec `numbered=true` — il peut envoyer `line` et `column` (base 1, dans l'identifiant) au lieu de `symbol`. `symbol` est la voie fiable : les petits modèles comptent rarement les colonnes juste.
102+- **Aucun serveur sur cette machine.** L'outil n'est pas déclaré et la bannière dit `lsp: none of the configured servers is on the PATH — the lsp tool is not declared`. La configuration peut rester ; l'agent fonctionne sans.
103+- **Première réponse lente.** Le premier appel pour un langage démarre le serveur et le laisse indexer le projet ; `diagnostics` peut dire `as last published` ou `no diagnostics received … within 3s` — redemandez une fois.
104+
105+## Voir aussi
106+
107+- [Référence des outils intégrés](../reference/tools.md) — les champs, sorties et échecs de `lsp`
108+- [Référence de la configuration](../reference/configuration.md) — les clés `lsp.servers` et les erreurs de validation
109+- [Pourquoi un seul outil `lsp`, et pourquoi un champ `symbol`](../explanation/lsp-tool.md)
110+- [Ajouter un skill](add-a-skill.md) — le format du skill `lsp-navigation`
new file mode 100644
@@ -0,0 +1,110 @@
1+# Naviguer et refactorer le code avec un serveur de langage (`lsp`)
2+
3+Ce guide montre comment donner un serveur de langage à `mm`, pour que le modèle demande au compilateur où un symbole est défini, qui l'utilise, quel est son type, ce qui ne va pas dans un fichier, et le renomme partout — plutôt que de grepper et de deviner. Il suppose que `mm` tourne avec un fichier de configuration modifiable, et qu'au moins un serveur de langage est installé.
4+
5+## Étapes
6+
7+1. Installez le serveur de votre langage et vérifiez qu'il est sur le `PATH` :
8+
9+ | Langage | Serveur | Installation | `command` / `args` |
10+ |---------|---------|--------------|--------------------|
11+ | Go | gopls | `go install golang.org/x/tools/gopls@latest` | `gopls` |
12+ | TypeScript, JavaScript | typescript-language-server | `npm i -g typescript typescript-language-server` | `typescript-language-server`, `["--stdio"]` |
13+ | Python | pyright | `pip install pyright` ou `npm i -g pyright` | `pyright-langserver`, `["--stdio"]` |
14+ | Rust | rust-analyzer | `rustup component add rust-analyzer` | `rust-analyzer` |
15+ | C, C++ | clangd | `apt install clangd`, `brew install llvm` | `clangd` |
16+ | Java | Eclipse JDT LS | votre gestionnaire de paquets, ou la release jdtls | `jdtls` |
17+ | Zig | zls | release de la chaîne `zig`, ou `brew install zls` | `zls` |
18+
19+ `which gopls` (ou le nom du serveur) doit répondre. Un serveur configuré mais introuvable est ignoré avec un avertissement au démarrage.
20+
21+2. Déclarez les serveurs dans le fichier YAML. L'extension du fichier choisit le serveur :
22+
23+ ```yaml
24+ lsp:
25+ servers:
26+ - name: gopls
27+ command: gopls
28+ extensions: [".go"]
29+ - name: typescript
30+ command: typescript-language-server
31+ args: ["--stdio"]
32+ extensions: [".ts", ".tsx", ".js", ".jsx"]
33+ - name: python
34+ command: pyright-langserver
35+ args: ["--stdio"]
36+ extensions: [".py"]
37+ - name: rust
38+ command: rust-analyzer
39+ extensions: [".rs"]
40+ ```
41+
42+ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc (gopls actif, les autres prêts à décommenter) et un prompt système écrit pour l'outil — partez de lui.
43+
44+3. Dites au modèle quand utiliser l'outil. Un petit modèle ne va pas chercher un nouvel outil de lui-même ; le prompt système doit le dire. Le bloc ci-dessous est celui d'`agent.lsp.yaml` ; collez-le dans votre `system:` (il est en anglais, la langue dans laquelle le modèle lit le mieux ses consignes) :
45+
46+ ```text
47+ CODE NAVIGATION (lsp)
48+ You have an `lsp` tool: it asks the project's language server what the
49+ compiler knows. Its description says which file extensions it covers.
50+ Use it INSTEAD of grep or reading whole files for anything about a symbol:
51+ - "where is X defined / declared" → action definition, symbol X
52+ - "who calls / uses X", "find usages" → action references, symbol X
53+ - "what is the type / signature of X" → action hover, symbol X
54+ - "what does this file contain" → action symbols, path only
55+ - "are there errors", after every edit → action diagnostics, path only
56+ - "rename X to Y everywhere" → action rename, symbol X, new_name Y
57+ (rename NEEDS new_name — the new identifier; never rename with edit_file:
58+ {"action": "rename", "path": "main.go", "symbol": "Greet", "new_name": "Hello"})
59+ Point at a symbol with its NAME (the `symbol` field), exactly as written in
60+ the code; add `line` only to pick one occurrence among several. Do not
61+ guess columns. Answers are "path:line:column <source line>": quote them,
62+ they are the evidence.
63+ Before changing a symbol used in several files, call references and say how
64+ many places are affected. After editing, call diagnostics on each edited
65+ file and fix what it reports before running tests.
66+ ```
67+
68+ Le skill `lsp-navigation` livré dans `skills/` dit la même chose plus longuement ; avec `skillsDir` pointant dessus, le modèle peut le charger avec `read_skill` quand la tâche le demande.
69+
70+4. Lancez `mm` depuis le répertoire du projet (les serveurs le reçoivent comme racine de travail) et lisez la bannière :
71+
72+ ```text
73+ [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]
74+ config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
75+ ```
76+
77+ `lsp` dans la colonne `tools:` signifie qu'au moins un serveur a été trouvé. Les serveurs démarrent à leur premier usage, pas ici.
78+
79+5. Posez des questions. Quelques prompts pour tester les six actions, sur n'importe quel projet couvert par le serveur :
80+
81+ | Prompt | Ce que vous devez voir |
82+ |--------|------------------------|
83+ | `Where is the function greet defined in main.go? Use lsp.` | `🔎 lsp: definition main.go greet` puis une ligne `main.go:6:6 func greet(…)`, citée dans la réponse. |
84+ | `Who uses greet? List every call site.` | `🔎 lsp: references main.go greet`, `N reference(s):` avec une ligne par site. |
85+ | `What is the signature of greet?` | `🔎 lsp: hover main.go greet`, la signature et le commentaire de doc du serveur. |
86+ | `What does main.go contain? Give me its outline.` | `🔎 lsp: symbols main.go`, une ligne par fonction, type, variable. |
87+ | `Are there compile errors in main.go?` | `🔎 lsp: diagnostics main.go`, `no diagnostics: main.go is clean` ou une ligne par erreur. |
88+ | `Rename greet to hello everywhere, then check main.go for errors.` | `🔎 lsp: rename main.go greet → hello`, `renamed to hello: N edit(s) in M file(s)`, puis un appel `diagnostics`. |
89+ | `Add a parameter to greet. Before you edit, tell me how many places call it.` | `references` d'abord, puis `edit_file`, puis `diagnostics` — la procédure que le skill enseigne. |
90+
91+ Les prompts en français marchent aussi (« Où est définie la fonction greet ? ») : c'est le prompt système qui décide de l'outil, pas la langue de la question. La ligne de récapitulatif après la réponse compte les appels : `⚙ 0 command(s) · 🔎 2 lsp call(s)`.
92+
93+## Depuis un éditeur (ACP) et dans le mode fenêtre
94+
95+Rien à configurer : le même YAML s'applique. Les appels de navigation apparaissent comme des appels d'outil de sorte `read`, sans boîte de permission ; `rename` est de sorte `edit` et demande la permission comme `edit_file` (`permissions: ask` dans le mode fenêtre). Les serveurs démarrent dans le répertoire où `mm` a été lancé — pour un éditeur, le projet qu'il a ouvert.
96+
97+## Variantes
98+
99+- **Un projet en plusieurs langages.** Listez un serveur par famille d'extensions ; chacun ne démarre que quand on l'interroge sur un fichier de sa sorte.
100+- **Un serveur avec des réglages.** Passez-les en `args` (beaucoup de serveurs lisent un drapeau ou un fichier de configuration à la racine du projet) ; `mm` répond « pas de réglages » à un serveur qui demande sa configuration, qui tourne alors avec ses valeurs par défaut.
101+- **Positions exactes.** Quand le modèle a une position exacte — via `read_file` avec `numbered=true` — il peut envoyer `line` et `column` (base 1, dans l'identifiant) au lieu de `symbol`. `symbol` est la voie fiable : les petits modèles comptent rarement les colonnes juste.
102+- **Aucun serveur sur cette machine.** L'outil n'est pas déclaré et la bannière dit `lsp: none of the configured servers is on the PATH — the lsp tool is not declared`. La configuration peut rester ; l'agent fonctionne sans.
103+- **Première réponse lente.** Le premier appel pour un langage démarre le serveur et le laisse indexer le projet ; `diagnostics` peut dire `as last published` ou `no diagnostics received … within 3s` — redemandez une fois.
104+
105+## Voir aussi
106+
107+- [Référence des outils intégrés](../reference/tools.md) — les champs, sorties et échecs de `lsp`
108+- [Référence de la configuration](../reference/configuration.md) — les clés `lsp.servers` et les erreurs de validation
109+- [Pourquoi un seul outil `lsp`, et pourquoi un champ `symbol`](../explanation/lsp-tool.md)
110+- [Ajouter un skill](add-a-skill.md) — le format du skill `lsp-navigation`
modified docs/fr/reference/configuration.md +30 -0
@@ -24,6 +24,7 @@
2424 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
2525 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
2626 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
27+| `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |
2728 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |
2829 | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. |
2930
@@ -46,6 +47,30 @@
4647 | `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |
4748 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |
4849
50+## Clés de `lsp`
51+
52+| Clé | Type | Défaut | Description |
53+|-----|------|--------|-------------|
54+| `lsp.servers` | liste | `[]` | Une entrée par serveur de langage. L'extension du fichier choisit le serveur. |
55+| `lsp.servers[].name` | chaîne | — | Le nom qu'utilisent la bannière, la description de l'outil et les avertissements. Obligatoire. |
56+| `lsp.servers[].command` | chaîne | — | L'exécutable, cherché sur le `PATH` au démarrage. Obligatoire. Introuvable : avertissement, serveur ignoré. |
57+| `lsp.servers[].args` | liste de chaînes | `[]` | Ses arguments (beaucoup de serveurs veulent `--stdio`). |
58+| `lsp.servers[].extensions` | liste de chaînes | — | Les extensions de fichier qu'il sert, avec le point (`.go`, `.ts`). Obligatoire, au moins une ; comparaison insensible à la casse ; une extension ne peut être servie que par un serveur. |
59+
60+```yaml
61+lsp:
62+ servers:
63+ - name: gopls
64+ command: gopls
65+ extensions: [".go"]
66+ - name: typescript
67+ command: typescript-language-server
68+ args: ["--stdio"]
69+ extensions: [".ts", ".tsx", ".js", ".jsx"]
70+```
71+
72+Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopls, les autres serveurs prêts à décommenter) et un prompt système qui dit quand utiliser l'outil.
73+
4974 ## Valeurs par défaut des fournisseurs
5075
5176 | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise |
@@ -70,6 +95,11 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1
7095 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |
7196 | `context.maxMessages must be >= 0` | valeur négative |
7297 | `sessions.dir must not be empty` | chaîne vide |
98+| `lsp.servers[N].name must not be empty` | un serveur sans `name` |
99+| `lsp.servers[N].command must not be empty` | un serveur sans `command` |
100+| `lsp.servers[N].extensions must list at least one extension` | un serveur sans `extensions` |
101+| `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point |
102+| `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension |
73103 | `permissions must be "auto" or "ask"` | toute autre valeur |
74104 | `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
75105
@@ -24,6 +24,7 @@
24 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |24 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
25 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |25 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
26 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |26 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
27+| `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |
27 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |28 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |
28 | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. |29 | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. |
29 30
@@ -46,6 +47,30 @@
46 | `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |47 | `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |
47 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |48 | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |
48 49
50+## Clés de `lsp`
51+
52+| Clé | Type | Défaut | Description |
53+|-----|------|--------|-------------|
54+| `lsp.servers` | liste | `[]` | Une entrée par serveur de langage. L'extension du fichier choisit le serveur. |
55+| `lsp.servers[].name` | chaîne | — | Le nom qu'utilisent la bannière, la description de l'outil et les avertissements. Obligatoire. |
56+| `lsp.servers[].command` | chaîne | — | L'exécutable, cherché sur le `PATH` au démarrage. Obligatoire. Introuvable : avertissement, serveur ignoré. |
57+| `lsp.servers[].args` | liste de chaînes | `[]` | Ses arguments (beaucoup de serveurs veulent `--stdio`). |
58+| `lsp.servers[].extensions` | liste de chaînes | — | Les extensions de fichier qu'il sert, avec le point (`.go`, `.ts`). Obligatoire, au moins une ; comparaison insensible à la casse ; une extension ne peut être servie que par un serveur. |
59+
60+```yaml
61+lsp:
62+ servers:
63+ - name: gopls
64+ command: gopls
65+ extensions: [".go"]
66+ - name: typescript
67+ command: typescript-language-server
68+ args: ["--stdio"]
69+ extensions: [".ts", ".tsx", ".js", ".jsx"]
70+```
71+
72+Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopls, les autres serveurs prêts à décommenter) et un prompt système qui dit quand utiliser l'outil.
73+
49 ## Valeurs par défaut des fournisseurs74 ## Valeurs par défaut des fournisseurs
50 75
51 | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise |76 | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise |
@@ -70,6 +95,11 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1
70 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |95 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |
71 | `context.maxMessages must be >= 0` | valeur négative |96 | `context.maxMessages must be >= 0` | valeur négative |
72 | `sessions.dir must not be empty` | chaîne vide |97 | `sessions.dir must not be empty` | chaîne vide |
98+| `lsp.servers[N].name must not be empty` | un serveur sans `name` |
99+| `lsp.servers[N].command must not be empty` | un serveur sans `command` |
100+| `lsp.servers[N].extensions must list at least one extension` | un serveur sans `extensions` |
101+| `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point |
102+| `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension |
73 | `permissions must be "auto" or "ask"` | toute autre valeur |103 | `permissions must be "auto" or "ask"` | toute autre valeur |
74 | `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. |104 | `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
75 105
modified docs/fr/reference/tools.md +44 -1
@@ -85,7 +85,7 @@ Déclaré quand `editTools: true`.
8585
8686 | Règle | Conséquence si violée |
8787 |-------|-----------------------|
88-| `old` apparaît exactement une fois | Refus : introuvable, ou ambigu. |
88+| `old` apparaît exactement une fois | Refusé : introuvable, ou ambigu — `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…`. |
8989 | Deux éditions ne doivent ni se chevaucher, ni s'imbriquer, ni se répéter | Refus. |
9090 | Les éditions sont résolues sur le texte original, jamais en cascade | — |
9191 | Toute édition refusée | Rien n'est écrit. |
@@ -95,6 +95,48 @@ Déclaré quand `editTools: true`.
9595 | Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. |
9696 | ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. |
9797
98+## `lsp`
99+
100+Déclaré quand `lsp.servers` liste au moins un serveur dont la commande est sur le `PATH` au démarrage. Un serveur listé mais introuvable est ignoré avec un `[warning: lsp server <nom> (<commande>) not found on the PATH — skipped]` ; quand aucun n'est trouvé, l'outil n'est pas déclaré et la bannière le dit.
101+
102+| Champ | Type | Défaut | Description |
103+|-------|------|--------|-------------|
104+| `action` | chaîne | — | `definition`, `references`, `hover`, `symbols`, `diagnostics` ou `rename`. |
105+| `path` | chaîne | — | Chemin du fichier, relatif au répertoire de travail. Son extension choisit le serveur. |
106+| `symbol` | chaîne | `""` | L'identifiant, tel qu'écrit dans le code. Guillemets et ponctuation parasites sont coupés, un préfixe `receveur.` ou `pkg.` est retiré (`Greet', ` et `person.Greet` désignent tous deux `Greet`). Ses occurrences dans le fichier sont essayées dans l'ordre — lignes de code d'abord, lignes de commentaire en dernier — jusqu'à ce que le serveur réponde. Pour `definition`, `references`, `hover`, `rename`. |
107+| `line` | entier | `0` | Ligne en base 1. Avec `symbol` : restreint la recherche à cette ligne. Avec `column` : la position exacte. |
108+| `column` | entier | `0` | Colonne en base 1, en caractères, à l'intérieur de l'identifiant. Nécessaire seulement sans `symbol`. |
109+| `new_name` | chaîne | `""` | Le nouvel identifiant, pour `rename`. Nettoyé comme `symbol`. Quand il est vide et que `symbol` contient plusieurs mots (`Greet 1 12 Hello` — un petit modèle qui écrit symbole, ligne, colonne et nouveau nom dans une seule chaîne), le premier identifiant est le symbole et le dernier le nouveau nom. |
110+
111+| Aspect | Comportement |
112+|--------|--------------|
113+| Description | Nomme les serveurs et leurs extensions (`Servers available: gopls (.go), …`) et les six actions. |
114+| `definition`, `references` | Une ligne `chemin:ligne:colonne <ligne source>` par emplacement, triée par chemin puis position, doublons retirés ; les chemins du projet sont relatifs, les autres absolus. `references` est préfixé de `N reference(s):` et inclut la déclaration. |
115+| `hover` | Le texte du serveur tel qu'envoyé (signature, type, documentation ; Markdown pour la plupart des serveurs). |
116+| `symbols` | Une ligne `<sorte> <nom> [<détail>] line N` par symbole, enfants indentés de deux espaces. |
117+| `diagnostics` | Le fichier est synchronisé, puis l'appel attend jusqu'à 3 s la publication du serveur. `N diagnostic(s) in <chemin>:` puis une ligne `<sévérité> chemin:ligne:colonne <message> [<source>]` par constat ; `no diagnostics: <chemin> is clean` ; ` (as last published)` quand le fichier n'a pas changé depuis la dernière publication ; `no diagnostics received for <chemin> within 3s (…)` quand le serveur n'a jamais publié. |
118+| `rename` | Applique les modifications du serveur aux fichiers sur disque (positions UTF-16 converties), informe le serveur du nouveau contenu, et répond `renamed to <nouveau>: N edit(s) in M file(s)` suivi d'une ligne `chemin: N edit(s)` par fichier. Sans `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]`. |
119+| Positions | Lignes et colonnes en base 1 à la frontière de l'outil, UTF-16 en base 0 sur le fil. Une position au-delà de la fin d'une ligne ou du fichier est refusée, avec le compte. |
120+| Synchronisation | Avant chaque requête, le fichier est relu sur disque et envoyé en `didOpen` (première fois) ou en `didChange` complet (quand son contenu a bougé) : les modifications faites avec `edit_file` sont vues sans étape supplémentaire. |
121+| Cycle de vie du serveur | Démarré au premier appel pour l'une de ses extensions, dans la racine du projet (le répertoire où `mm` tourne), gardé pour la session, arrêté (`shutdown`, `exit`, puis le processus) à la sortie de `mm`. Un serveur mort est relancé à l'appel suivant. |
122+| Échecs | Toujours du texte, jamais une erreur Go : `[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]`. |
123+| Affichage terminal | `🔎 lsp: definition main.go greet` (ou `…:9:14` avec une position, `rename main.go greet → hello`), puis la réponse affichée comme la sortie d'une commande. |
124+| ACP / fenêtre | Sorte `read` pour la navigation, sans permission ; `rename` est de sorte `edit` et demande la permission comme `edit_file` (`[rename rejected by the user]` en cas de refus). |
125+| Compteur | `🔎 N lsp call(s)` dans la ligne de récapitulatif (voir plus bas). |
126+
127+## Détection de boucle
128+
129+Chaque outil enregistre `(outil, entrée, sortie)` dans le détecteur de boucle après chaque appel.
130+
131+| Situation | Comportement |
132+|-----------|--------------|
133+| Le même triplet 3 fois de suite | La sortie de l'outil reçoit `[LOOP_DETECTED: You have attempted the same action multiple times with the same result. Please try a different approach or provide more information.]` en annexe. Rien ne s'arrête. |
134+| 3 appels identiques de plus (6 de suite) | L'outil renvoie une erreur au lieu de texte et le tour se termine : `[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]`. L'historique garde tout ce qui a tourné ; l'erreur est enregistrée dans le tableau `errors` du fichier de session (`where: generation`). |
135+| Un appel différent | Le compte repart. `/new` réinitialise le détecteur ; `/compact` non. |
136+| Un appel d'outil dont les arguments diffusés répètent un motif (les 256 derniers caractères sont un motif de 4 à 64 caractères répété) ou dépassent 256 Ko | La génération est arrêtée aussitôt : `[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]`. Sans cela, un modèle qui déraille compose du charabia pendant des minutes pendant que l'écran affiche « Thinking » (le chien de garde est nourri par chaque fragment). Pendant qu'un appel d'outil est diffusé, le spinner dit `Composing a tool call`. |
137+
138+Les seuils (3, puis 3 de plus) sont fixés dans `main.go` ; `maxTurns` reste la borne extérieure d'un tour.
139+
98140 ## Compteurs affichés après un tour
99141
100142 | Compteur | Compte |
@@ -102,3 +144,4 @@ Déclaré quand `editTools: true`.
102144 | `⚙ N command(s)` | Réponses de l'outil `bash` dans le tour. |
103145 | `📝 N file op(s)` | Réponses de `read_file`, `write_file`, `edit_file`. |
104146 | `📖 N skill(s)` | Réponses de `read_skill`. |
147+| `🔎 N lsp call(s)` | Réponses de `lsp`. |
@@ -85,7 +85,7 @@ Déclaré quand `editTools: true`.
85 85
86 | Règle | Conséquence si violée |86 | Règle | Conséquence si violée |
87 |-------|-----------------------|87 |-------|-----------------------|
88-| `old` apparaît exactement une fois | Refus : introuvable, ou ambigu. |88+| `old` apparaît exactement une fois | Refusé : introuvable, ou ambigu — `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…`. |
89 | Deux éditions ne doivent ni se chevaucher, ni s'imbriquer, ni se répéter | Refus. |89 | Deux éditions ne doivent ni se chevaucher, ni s'imbriquer, ni se répéter | Refus. |
90 | Les éditions sont résolues sur le texte original, jamais en cascade | — |90 | Les éditions sont résolues sur le texte original, jamais en cascade | — |
91 | Toute édition refusée | Rien n'est écrit. |91 | Toute édition refusée | Rien n'est écrit. |
@@ -95,6 +95,48 @@ Déclaré quand `editTools: true`.
95 | Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. |95 | Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. |
96 | ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. |96 | ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. |
97 97
98+## `lsp`
99+
100+Déclaré quand `lsp.servers` liste au moins un serveur dont la commande est sur le `PATH` au démarrage. Un serveur listé mais introuvable est ignoré avec un `[warning: lsp server <nom> (<commande>) not found on the PATH — skipped]` ; quand aucun n'est trouvé, l'outil n'est pas déclaré et la bannière le dit.
101+
102+| Champ | Type | Défaut | Description |
103+|-------|------|--------|-------------|
104+| `action` | chaîne | — | `definition`, `references`, `hover`, `symbols`, `diagnostics` ou `rename`. |
105+| `path` | chaîne | — | Chemin du fichier, relatif au répertoire de travail. Son extension choisit le serveur. |
106+| `symbol` | chaîne | `""` | L'identifiant, tel qu'écrit dans le code. Guillemets et ponctuation parasites sont coupés, un préfixe `receveur.` ou `pkg.` est retiré (`Greet', ` et `person.Greet` désignent tous deux `Greet`). Ses occurrences dans le fichier sont essayées dans l'ordre — lignes de code d'abord, lignes de commentaire en dernier — jusqu'à ce que le serveur réponde. Pour `definition`, `references`, `hover`, `rename`. |
107+| `line` | entier | `0` | Ligne en base 1. Avec `symbol` : restreint la recherche à cette ligne. Avec `column` : la position exacte. |
108+| `column` | entier | `0` | Colonne en base 1, en caractères, à l'intérieur de l'identifiant. Nécessaire seulement sans `symbol`. |
109+| `new_name` | chaîne | `""` | Le nouvel identifiant, pour `rename`. Nettoyé comme `symbol`. Quand il est vide et que `symbol` contient plusieurs mots (`Greet 1 12 Hello` — un petit modèle qui écrit symbole, ligne, colonne et nouveau nom dans une seule chaîne), le premier identifiant est le symbole et le dernier le nouveau nom. |
110+
111+| Aspect | Comportement |
112+|--------|--------------|
113+| Description | Nomme les serveurs et leurs extensions (`Servers available: gopls (.go), …`) et les six actions. |
114+| `definition`, `references` | Une ligne `chemin:ligne:colonne <ligne source>` par emplacement, triée par chemin puis position, doublons retirés ; les chemins du projet sont relatifs, les autres absolus. `references` est préfixé de `N reference(s):` et inclut la déclaration. |
115+| `hover` | Le texte du serveur tel qu'envoyé (signature, type, documentation ; Markdown pour la plupart des serveurs). |
116+| `symbols` | Une ligne `<sorte> <nom> [<détail>] line N` par symbole, enfants indentés de deux espaces. |
117+| `diagnostics` | Le fichier est synchronisé, puis l'appel attend jusqu'à 3 s la publication du serveur. `N diagnostic(s) in <chemin>:` puis une ligne `<sévérité> chemin:ligne:colonne <message> [<source>]` par constat ; `no diagnostics: <chemin> is clean` ; ` (as last published)` quand le fichier n'a pas changé depuis la dernière publication ; `no diagnostics received for <chemin> within 3s (…)` quand le serveur n'a jamais publié. |
118+| `rename` | Applique les modifications du serveur aux fichiers sur disque (positions UTF-16 converties), informe le serveur du nouveau contenu, et répond `renamed to <nouveau>: N edit(s) in M file(s)` suivi d'une ligne `chemin: N edit(s)` par fichier. Sans `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]`. |
119+| Positions | Lignes et colonnes en base 1 à la frontière de l'outil, UTF-16 en base 0 sur le fil. Une position au-delà de la fin d'une ligne ou du fichier est refusée, avec le compte. |
120+| Synchronisation | Avant chaque requête, le fichier est relu sur disque et envoyé en `didOpen` (première fois) ou en `didChange` complet (quand son contenu a bougé) : les modifications faites avec `edit_file` sont vues sans étape supplémentaire. |
121+| Cycle de vie du serveur | Démarré au premier appel pour l'une de ses extensions, dans la racine du projet (le répertoire où `mm` tourne), gardé pour la session, arrêté (`shutdown`, `exit`, puis le processus) à la sortie de `mm`. Un serveur mort est relancé à l'appel suivant. |
122+| Échecs | Toujours du texte, jamais une erreur Go : `[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]`. |
123+| Affichage terminal | `🔎 lsp: definition main.go greet` (ou `…:9:14` avec une position, `rename main.go greet → hello`), puis la réponse affichée comme la sortie d'une commande. |
124+| ACP / fenêtre | Sorte `read` pour la navigation, sans permission ; `rename` est de sorte `edit` et demande la permission comme `edit_file` (`[rename rejected by the user]` en cas de refus). |
125+| Compteur | `🔎 N lsp call(s)` dans la ligne de récapitulatif (voir plus bas). |
126+
127+## Détection de boucle
128+
129+Chaque outil enregistre `(outil, entrée, sortie)` dans le détecteur de boucle après chaque appel.
130+
131+| Situation | Comportement |
132+|-----------|--------------|
133+| Le même triplet 3 fois de suite | La sortie de l'outil reçoit `[LOOP_DETECTED: You have attempted the same action multiple times with the same result. Please try a different approach or provide more information.]` en annexe. Rien ne s'arrête. |
134+| 3 appels identiques de plus (6 de suite) | L'outil renvoie une erreur au lieu de texte et le tour se termine : `[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]`. L'historique garde tout ce qui a tourné ; l'erreur est enregistrée dans le tableau `errors` du fichier de session (`where: generation`). |
135+| Un appel différent | Le compte repart. `/new` réinitialise le détecteur ; `/compact` non. |
136+| Un appel d'outil dont les arguments diffusés répètent un motif (les 256 derniers caractères sont un motif de 4 à 64 caractères répété) ou dépassent 256 Ko | La génération est arrêtée aussitôt : `[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]`. Sans cela, un modèle qui déraille compose du charabia pendant des minutes pendant que l'écran affiche « Thinking » (le chien de garde est nourri par chaque fragment). Pendant qu'un appel d'outil est diffusé, le spinner dit `Composing a tool call`. |
137+
138+Les seuils (3, puis 3 de plus) sont fixés dans `main.go` ; `maxTurns` reste la borne extérieure d'un tour.
139+
98 ## Compteurs affichés après un tour140 ## Compteurs affichés après un tour
99 141
100 | Compteur | Compte |142 | Compteur | Compte |
@@ -102,3 +144,4 @@ Déclaré quand `editTools: true`.
102 | `⚙ N command(s)` | Réponses de l'outil `bash` dans le tour. |144 | `⚙ N command(s)` | Réponses de l'outil `bash` dans le tour. |
103 | `📝 N file op(s)` | Réponses de `read_file`, `write_file`, `edit_file`. |145 | `📝 N file op(s)` | Réponses de `read_file`, `write_file`, `edit_file`. |
104 | `📖 N skill(s)` | Réponses de `read_skill`. |146 | `📖 N skill(s)` | Réponses de `read_skill`. |
147+| `🔎 N lsp call(s)` | Réponses de `lsp`. |
modified docs/fr/tutorials/getting-started.md +1 -1
@@ -4,7 +4,7 @@
44
55 ## Prérequis
66
7-- Go 1.25 ou plus récent dans votre `PATH` (`go version` répond).
7+- Go 1.26 ou plus récent dans votre `PATH` (`go version` répond).
88 - Docker Desktop avec Docker Model Runner activé et joignable sur `http://localhost:12434`.
99 - Le modèle nommé dans `agent.yaml` téléchargé localement. Le fichier livré avec le projet nomme `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` ; récupérez-le avec `docker model pull <ce nom>`.
1010 - Un terminal ouvert à la racine du dépôt (le répertoire qui contient `main.go` et `agent.yaml`).
@@ -4,7 +4,7 @@
4 4
5 ## Prérequis5 ## Prérequis
6 6
7-- Go 1.25 ou plus récent dans votre `PATH` (`go version` répond).7+- Go 1.26 ou plus récent dans votre `PATH` (`go version` répond).
8 - Docker Desktop avec Docker Model Runner activé et joignable sur `http://localhost:12434`.8 - Docker Desktop avec Docker Model Runner activé et joignable sur `http://localhost:12434`.
9 - Le modèle nommé dans `agent.yaml` téléchargé localement. Le fichier livré avec le projet nomme `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` ; récupérez-le avec `docker model pull <ce nom>`.9 - Le modèle nommé dans `agent.yaml` téléchargé localement. Le fichier livré avec le projet nomme `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` ; récupérez-le avec `docker model pull <ce nom>`.
10 - Un terminal ouvert à la racine du dépôt (le répertoire qui contient `main.go` et `agent.yaml`).10 - Un terminal ouvert à la racine du dépôt (le répertoire qui contient `main.go` et `agent.yaml`).
modified go.mod +9 -5
@@ -1,20 +1,24 @@
11 module mm
22
3-go 1.25.0
3+go 1.26
44
55 require (
66 github.com/firebase/genkit/go v1.10.0
77 github.com/gdamore/tcell/v2 v2.13.10
88 github.com/openai/openai-go v1.8.2
9+ go.lsp.dev/jsonrpc2 v1.0.1
10+ go.lsp.dev/protocol v1.0.1
11+ go.lsp.dev/uri v1.0.1
912 gopkg.in/yaml.v3 v3.0.1
1013 )
1114
1215 require (
1316 github.com/gdamore/encoding v1.0.1 // indirect
14- github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
17+ github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
18+ github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
1519 github.com/rivo/uniseg v0.4.7 // indirect
16- golang.org/x/term v0.37.0 // indirect
17- golang.org/x/text v0.31.0 // indirect
20+ golang.org/x/term v0.39.0 // indirect
21+ golang.org/x/text v0.36.0 // indirect
1822 )
1923
2024 require (
@@ -44,5 +48,5 @@ require (
4448 go.opentelemetry.io/otel/metric v1.36.0 // indirect
4549 go.opentelemetry.io/otel/sdk v1.36.0 // indirect
4650 go.opentelemetry.io/otel/trace v1.36.0 // indirect
47- golang.org/x/sys v0.38.0 // indirect
51+ golang.org/x/sys v0.45.0 // indirect
4852 )
@@ -1,20 +1,24 @@
1 module mm1 module mm
2 2
3-go 1.25.03+go 1.26
4 4
5 require (5 require (
6 github.com/firebase/genkit/go v1.10.06 github.com/firebase/genkit/go v1.10.0
7 github.com/gdamore/tcell/v2 v2.13.107 github.com/gdamore/tcell/v2 v2.13.10
8 github.com/openai/openai-go v1.8.28 github.com/openai/openai-go v1.8.2
9+ go.lsp.dev/jsonrpc2 v1.0.1
10+ go.lsp.dev/protocol v1.0.1
11+ go.lsp.dev/uri v1.0.1
9 gopkg.in/yaml.v3 v3.0.112 gopkg.in/yaml.v3 v3.0.1
10 )13 )
11 14
12 require (15 require (
13 github.com/gdamore/encoding v1.0.1 // indirect16 github.com/gdamore/encoding v1.0.1 // indirect
14- github.com/lucasb-eyer/go-colorful v1.3.0 // indirect17+ github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
18+ github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
15 github.com/rivo/uniseg v0.4.7 // indirect19 github.com/rivo/uniseg v0.4.7 // indirect
16- golang.org/x/term v0.37.0 // indirect20+ golang.org/x/term v0.39.0 // indirect
17- golang.org/x/text v0.31.0 // indirect21+ golang.org/x/text v0.36.0 // indirect
18 )22 )
19 23
20 require (24 require (
@@ -44,5 +48,5 @@ require (
44 go.opentelemetry.io/otel/metric v1.36.0 // indirect48 go.opentelemetry.io/otel/metric v1.36.0 // indirect
45 go.opentelemetry.io/otel/sdk v1.36.0 // indirect49 go.opentelemetry.io/otel/sdk v1.36.0 // indirect
46 go.opentelemetry.io/otel/trace v1.36.0 // indirect50 go.opentelemetry.io/otel/trace v1.36.0 // indirect
47- golang.org/x/sys v0.38.0 // indirect51+ golang.org/x/sys v0.45.0 // indirect
48 )52 )
modified go.sum +20 -12
@@ -15,6 +15,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
1515 github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
1616 github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
1717 github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
18+github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
19+github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
1820 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
1921 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
2022 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -34,8 +36,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
3436 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3537 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
3638 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
37-github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
38-github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
39+github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
40+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
3941 github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
4042 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
4143 github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=
@@ -46,12 +48,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
4648 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
4749 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
4850 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
49-github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
50-github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
51+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
52+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
5153 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
5254 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
53-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
54-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
55+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
56+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
5557 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
5658 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
5759 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -74,6 +76,12 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ
7476 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
7577 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
7678 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
79+go.lsp.dev/jsonrpc2 v1.0.1 h1:JZAchpm6RcOuwEOnZ7kF3kA1EpEjdJZ/6rMSfOF3+F4=
80+go.lsp.dev/jsonrpc2 v1.0.1/go.mod h1:WN+TesqrPumBmUdsuWyn9DHq3zl4i85L/qG0NLdj8qg=
81+go.lsp.dev/protocol v1.0.1 h1:kLWUIk1v/deXxo1D0VO4avjZnfeuZ82mouJgJd/NbdU=
82+go.lsp.dev/protocol v1.0.1/go.mod h1:e//i41aEqc9ml32xiNuneUwbjOjuvTA4+uv+AkssJgk=
83+go.lsp.dev/uri v1.0.1 h1:ywxJN1UXoQ+v0I9lkoQNMmsm7lub5G9ZG7KXT8YTx30=
84+go.lsp.dev/uri v1.0.1/go.mod h1:06a0ghafs4PuqkLsJGUmlgPHw6+kH79edRXjOT/rA8s=
7785 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
7886 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
7987 go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
@@ -103,20 +111,20 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
103111 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104112 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
105113 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
106-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
107-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
114+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
115+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
108116 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
109117 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
110118 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
111-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
112-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
119+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
120+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
113121 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
114122 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
115123 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
116124 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
117125 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
118-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
119-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
126+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
127+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
120128 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
121129 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
122130 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -15,6 +15,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
15 github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=15 github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
16 github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=16 github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
17 github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=17 github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
18+github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
19+github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
18 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=20 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
19 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=21 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
20 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=22 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -34,8 +36,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
34 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=36 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
35 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=37 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
36 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=38 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
37-github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=39+github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
38-github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=40+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
39 github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=41 github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
40 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=42 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
41 github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=43 github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=
@@ -46,12 +48,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
46 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=48 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
47 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=49 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
48 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=50 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
49-github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=51+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
50-github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=52+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
51 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=53 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
52 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=54 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
53-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=55+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
54-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=56+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
55 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=57 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
56 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=58 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
57 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=59 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -74,6 +76,12 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ
74 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=76 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
75 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=77 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
76 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=78 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
79+go.lsp.dev/jsonrpc2 v1.0.1 h1:JZAchpm6RcOuwEOnZ7kF3kA1EpEjdJZ/6rMSfOF3+F4=
80+go.lsp.dev/jsonrpc2 v1.0.1/go.mod h1:WN+TesqrPumBmUdsuWyn9DHq3zl4i85L/qG0NLdj8qg=
81+go.lsp.dev/protocol v1.0.1 h1:kLWUIk1v/deXxo1D0VO4avjZnfeuZ82mouJgJd/NbdU=
82+go.lsp.dev/protocol v1.0.1/go.mod h1:e//i41aEqc9ml32xiNuneUwbjOjuvTA4+uv+AkssJgk=
83+go.lsp.dev/uri v1.0.1 h1:ywxJN1UXoQ+v0I9lkoQNMmsm7lub5G9ZG7KXT8YTx30=
84+go.lsp.dev/uri v1.0.1/go.mod h1:06a0ghafs4PuqkLsJGUmlgPHw6+kH79edRXjOT/rA8s=
77 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=85 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
78 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=86 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
79 go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=87 go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
@@ -103,20 +111,20 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
103 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=111 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=112 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
105 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=113 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
106-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=114+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
107-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=115+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
108 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=116 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
109 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=117 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
110 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=118 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
111-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=119+golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
112-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=120+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
113 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=121 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
114 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=122 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
115 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=123 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
116 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=124 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
117 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=125 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
118-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=126+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
119-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=127+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
120 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=128 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
121 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=129 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
122 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=130 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
modified internal/acp/acp.go +2 -2
@@ -320,8 +320,8 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
320320 // every tool call, but the trail is what lets `dev: open acp logs` say at a
321321 // glance whether the model worked or improvised.
322322 turn := s.messages[min(before, len(s.messages)):]
323- fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n",
324- len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn))
323+ fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n",
324+ len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn))
325325 f.persist(p.SessionId, s)
326326
327327 switch {
@@ -320,8 +320,8 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
320 // every tool call, but the trail is what lets `dev: open acp logs` say at a320 // every tool call, but the trail is what lets `dev: open acp logs` say at a
321 // glance whether the model worked or improvised.321 // glance whether the model worked or improvised.
322 turn := s.messages[min(before, len(s.messages)):]322 turn := s.messages[min(before, len(s.messages)):]
323- fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n",323+ fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n",
324- len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn))324+ len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn))
325 f.persist(p.SessionId, s)325 f.persist(p.SessionId, s)
326 326
327 switch {327 switch {
modified internal/agent/agent.go +3 -0
@@ -254,6 +254,9 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
254254 if k := engine.Skills(turn); k > 0 {
255255 line += fmt.Sprintf(" · 📖 %d skill(s)", k)
256256 }
257+ if k := engine.LSPCalls(turn); k > 0 {
258+ line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)
259+ }
257260 if spinner.Styled() {
258261 fmt.Printf("\033[2m%s\033[0m\n", line)
259262 } else {
@@ -254,6 +254,9 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
254 if k := engine.Skills(turn); k > 0 {254 if k := engine.Skills(turn); k > 0 {
255 line += fmt.Sprintf(" · 📖 %d skill(s)", k)255 line += fmt.Sprintf(" · 📖 %d skill(s)", k)
256 }256 }
257+ if k := engine.LSPCalls(turn); k > 0 {
258+ line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)
259+ }
257 if spinner.Styled() {260 if spinner.Styled() {
258 fmt.Printf("\033[2m%s\033[0m\n", line)261 fmt.Printf("\033[2m%s\033[0m\n", line)
259 } else {262 } else {
modified internal/config/config.go +96 -1
@@ -11,6 +11,8 @@ import (
1111 "fmt"
1212 "io/fs"
1313 "os"
14+ "path/filepath"
15+ "strings"
1416 "time"
1517
1618 "gopkg.in/yaml.v3"
@@ -109,6 +111,13 @@ type Config struct {
109111 // Falls back to the line mode when stdout is not a terminal.
110112 TUI bool `yaml:"tui"`
111113
114+ // LSP: the language servers the `lsp` tool may talk to (definition,
115+ // references, hover, symbols, diagnostics, rename). No server listed =
116+ // no tool declared. A listed server whose command is not on the PATH is
117+ // skipped with a warning at start-up, never an error: the agent must run
118+ // on a machine that lacks it.
119+ LSP LSPConfig `yaml:"lsp"`
120+
112121 // Permissions: whether the window mode asks before `bash` and the file
113122 // tools run — "ask" shows a/s/r in the box, "auto" (default) runs them as
114123 // the terminal always has. Only the window mode reads it today; the
@@ -123,6 +132,46 @@ const (
123132 )
124133
125134 // SessionsConfig is the `sessions:` block of the YAML.
135+// LSPConfig is the `lsp` key.
136+type LSPConfig struct {
137+ // Servers: one entry per language server. The file's extension picks
138+ // the server; an extension may be served by one server only.
139+ Servers []LSPServer `yaml:"servers"`
140+}
141+
142+// LSPServer describes one language server the tool can start.
143+//
144+// lsp:
145+// servers:
146+// - name: gopls
147+// command: gopls
148+// extensions: [".go"]
149+// - name: typescript
150+// command: typescript-language-server
151+// args: ["--stdio"]
152+// extensions: [".ts", ".tsx", ".js"]
153+type LSPServer struct {
154+ // Name: how the banner, the tool description and the warnings call it.
155+ Name string `yaml:"name"`
156+ // Command: the executable, looked up on the PATH at start-up.
157+ Command string `yaml:"command"`
158+ // Args: its arguments (many servers need "--stdio").
159+ Args []string `yaml:"args"`
160+ // Extensions: the file extensions it serves, with the dot (".go").
161+ Extensions []string `yaml:"extensions"`
162+}
163+
164+// Serves reports whether the server handles a file by its extension.
165+func (s LSPServer) Serves(path string) bool {
166+ ext := strings.ToLower(filepath.Ext(path))
167+ for _, e := range s.Extensions {
168+ if strings.ToLower(e) == ext {
169+ return true
170+ }
171+ }
172+ return false
173+}
174+
126175 type SessionsConfig struct {
127176 // Enabled turns the persistence on. Without it nothing is written and
128177 // ACP does not advertise session/load.
@@ -298,6 +347,52 @@ func applyEnv() {
298347 }
299348 }
300349
350+// validate checks the language servers: every one named and runnable, every
351+// extension written with its dot and served once — two servers for ".ts"
352+// would make the tool's choice arbitrary.
353+func (l LSPConfig) validate() error {
354+ seen := map[string]string{}
355+ for i, s := range l.Servers {
356+ if err := s.validate(fmt.Sprintf("lsp.servers[%d]", i), seen); err != nil {
357+ return err
358+ }
359+ }
360+ return nil
361+}
362+
363+// validate checks one server and claims its extensions in seen (extension →
364+// server name), so a second server on the same extension is refused.
365+func (s LSPServer) validate(where string, seen map[string]string) error {
366+ switch {
367+ case s.Name == "":
368+ return errors.New(where + ".name must not be empty")
369+ case s.Command == "":
370+ return errors.New(where + ".command must not be empty")
371+ case len(s.Extensions) == 0:
372+ return errors.New(where + ".extensions must list at least one extension")
373+ }
374+ for _, e := range s.Extensions {
375+ if err := s.claimExtension(where, e, seen); err != nil {
376+ return err
377+ }
378+ }
379+ return nil
380+}
381+
382+// claimExtension checks one extension's spelling and records the server as
383+// its owner, refusing an extension another server already owns.
384+func (s LSPServer) claimExtension(where, e string, seen map[string]string) error {
385+ ext := strings.ToLower(e)
386+ if !strings.HasPrefix(ext, ".") || len(ext) < 2 {
387+ return fmt.Errorf("%s.extensions: %q must start with a dot, like \".go\"", where, e)
388+ }
389+ if other, dup := seen[ext]; dup {
390+ return fmt.Errorf("%s: extension %q is already served by %q", where, e, other)
391+ }
392+ seen[ext] = s.Name
393+ return nil
394+}
395+
301396 // validate catches the settings that would break the agent at runtime.
302397 func (c Config) validate() error {
303398 switch {
@@ -329,5 +424,5 @@ func (c Config) validate() error {
329424 // `enabled: true` with no window and no maxMessages would never trigger —
330425 // but the window may still come from the server's probe at start-up, so
331426 // that case is a warning in main, not an error here.
332- return nil
427+ return c.LSP.validate()
333428 }
@@ -11,6 +11,8 @@ import (
11 "fmt"11 "fmt"
12 "io/fs"12 "io/fs"
13 "os"13 "os"
14+ "path/filepath"
15+ "strings"
14 "time"16 "time"
15 17
16 "gopkg.in/yaml.v3"18 "gopkg.in/yaml.v3"
@@ -109,6 +111,13 @@ type Config struct {
109 // Falls back to the line mode when stdout is not a terminal.111 // Falls back to the line mode when stdout is not a terminal.
110 TUI bool `yaml:"tui"`112 TUI bool `yaml:"tui"`
111 113
114+ // LSP: the language servers the `lsp` tool may talk to (definition,
115+ // references, hover, symbols, diagnostics, rename). No server listed =
116+ // no tool declared. A listed server whose command is not on the PATH is
117+ // skipped with a warning at start-up, never an error: the agent must run
118+ // on a machine that lacks it.
119+ LSP LSPConfig `yaml:"lsp"`
120+
112 // Permissions: whether the window mode asks before `bash` and the file121 // Permissions: whether the window mode asks before `bash` and the file
113 // tools run — "ask" shows a/s/r in the box, "auto" (default) runs them as122 // tools run — "ask" shows a/s/r in the box, "auto" (default) runs them as
114 // the terminal always has. Only the window mode reads it today; the123 // the terminal always has. Only the window mode reads it today; the
@@ -123,6 +132,46 @@ const (
123 )132 )
124 133
125 // SessionsConfig is the `sessions:` block of the YAML.134 // SessionsConfig is the `sessions:` block of the YAML.
135+// LSPConfig is the `lsp` key.
136+type LSPConfig struct {
137+ // Servers: one entry per language server. The file's extension picks
138+ // the server; an extension may be served by one server only.
139+ Servers []LSPServer `yaml:"servers"`
140+}
141+
142+// LSPServer describes one language server the tool can start.
143+//
144+// lsp:
145+// servers:
146+// - name: gopls
147+// command: gopls
148+// extensions: [".go"]
149+// - name: typescript
150+// command: typescript-language-server
151+// args: ["--stdio"]
152+// extensions: [".ts", ".tsx", ".js"]
153+type LSPServer struct {
154+ // Name: how the banner, the tool description and the warnings call it.
155+ Name string `yaml:"name"`
156+ // Command: the executable, looked up on the PATH at start-up.
157+ Command string `yaml:"command"`
158+ // Args: its arguments (many servers need "--stdio").
159+ Args []string `yaml:"args"`
160+ // Extensions: the file extensions it serves, with the dot (".go").
161+ Extensions []string `yaml:"extensions"`
162+}
163+
164+// Serves reports whether the server handles a file by its extension.
165+func (s LSPServer) Serves(path string) bool {
166+ ext := strings.ToLower(filepath.Ext(path))
167+ for _, e := range s.Extensions {
168+ if strings.ToLower(e) == ext {
169+ return true
170+ }
171+ }
172+ return false
173+}
174+
126 type SessionsConfig struct {175 type SessionsConfig struct {
127 // Enabled turns the persistence on. Without it nothing is written and176 // Enabled turns the persistence on. Without it nothing is written and
128 // ACP does not advertise session/load.177 // ACP does not advertise session/load.
@@ -298,6 +347,52 @@ func applyEnv() {
298 }347 }
299 }348 }
300 349
350+// validate checks the language servers: every one named and runnable, every
351+// extension written with its dot and served once — two servers for ".ts"
352+// would make the tool's choice arbitrary.
353+func (l LSPConfig) validate() error {
354+ seen := map[string]string{}
355+ for i, s := range l.Servers {
356+ if err := s.validate(fmt.Sprintf("lsp.servers[%d]", i), seen); err != nil {
357+ return err
358+ }
359+ }
360+ return nil
361+}
362+
363+// validate checks one server and claims its extensions in seen (extension →
364+// server name), so a second server on the same extension is refused.
365+func (s LSPServer) validate(where string, seen map[string]string) error {
366+ switch {
367+ case s.Name == "":
368+ return errors.New(where + ".name must not be empty")
369+ case s.Command == "":
370+ return errors.New(where + ".command must not be empty")
371+ case len(s.Extensions) == 0:
372+ return errors.New(where + ".extensions must list at least one extension")
373+ }
374+ for _, e := range s.Extensions {
375+ if err := s.claimExtension(where, e, seen); err != nil {
376+ return err
377+ }
378+ }
379+ return nil
380+}
381+
382+// claimExtension checks one extension's spelling and records the server as
383+// its owner, refusing an extension another server already owns.
384+func (s LSPServer) claimExtension(where, e string, seen map[string]string) error {
385+ ext := strings.ToLower(e)
386+ if !strings.HasPrefix(ext, ".") || len(ext) < 2 {
387+ return fmt.Errorf("%s.extensions: %q must start with a dot, like \".go\"", where, e)
388+ }
389+ if other, dup := seen[ext]; dup {
390+ return fmt.Errorf("%s: extension %q is already served by %q", where, e, other)
391+ }
392+ seen[ext] = s.Name
393+ return nil
394+}
395+
301 // validate catches the settings that would break the agent at runtime.396 // validate catches the settings that would break the agent at runtime.
302 func (c Config) validate() error {397 func (c Config) validate() error {
303 switch {398 switch {
@@ -329,5 +424,5 @@ func (c Config) validate() error {
329 // `enabled: true` with no window and no maxMessages would never trigger —424 // `enabled: true` with no window and no maxMessages would never trigger —
330 // but the window may still come from the server's probe at start-up, so425 // but the window may still come from the server's probe at start-up, so
331 // that case is a warning in main, not an error here.426 // that case is a warning in main, not an error here.
332- return nil427+ return c.LSP.validate()
333 }428 }
modified internal/config/config_test.go +55 -0
@@ -108,3 +108,58 @@ func TestBashToolConfig(t *testing.T) {
108108 t.Error("after silent.yaml: bashTool off, want the default kept")
109109 }
110110 }
111+
112+// lsp.servers: absent by default, read from the YAML with its arguments and
113+// extensions, matched by extension case-insensitively; a server without a
114+// name, a command or extensions, an extension without its dot, or one served
115+// twice is refused with the offending entry named.
116+func TestLSPConfig(t *testing.T) {
117+ defaults := Cfg
118+ t.Cleanup(func() { Cfg = defaults })
119+ if len(Cfg.LSP.Servers) != 0 {
120+ t.Fatalf("defaults: %d servers, want none", len(Cfg.LSP.Servers))
121+ }
122+
123+ dir := t.TempDir()
124+ write := func(name, body string) string {
125+ path := filepath.Join(dir, name)
126+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
127+ t.Fatal(err)
128+ }
129+ return path
130+ }
131+ good := write("good.yaml", `lsp:
132+ servers:
133+ - name: gopls
134+ command: gopls
135+ extensions: [".go"]
136+ - name: typescript
137+ command: typescript-language-server
138+ args: ["--stdio"]
139+ extensions: [".ts", ".TSX"]
140+`)
141+ if _, err := Load(good); err != nil {
142+ t.Fatal(err)
143+ }
144+ servers := Cfg.LSP.Servers
145+ if len(servers) != 2 || servers[1].Args[0] != "--stdio" || len(servers[1].Extensions) != 2 {
146+ t.Fatalf("servers read: %+v", servers)
147+ }
148+ if !servers[0].Serves("cmd/main.go") || servers[0].Serves("app.ts") || !servers[1].Serves("App.tsx") || servers[1].Serves("Makefile") {
149+ t.Errorf("Serves picks the wrong server: %+v", servers)
150+ }
151+
152+ for _, bad := range []struct{ body, want string }{
153+ {"lsp:\n servers:\n - command: gopls\n extensions: [\".go\"]\n", "lsp.servers[0].name must not be empty"},
154+ {"lsp:\n servers:\n - name: gopls\n extensions: [\".go\"]\n", "lsp.servers[0].command must not be empty"},
155+ {"lsp:\n servers:\n - name: gopls\n command: gopls\n", "lsp.servers[0].extensions must list at least one extension"},
156+ {"lsp:\n servers:\n - name: gopls\n command: gopls\n extensions: [\"go\"]\n", `"go" must start with a dot`},
157+ {"lsp:\n servers:\n - name: a\n command: a\n extensions: [\".ts\"]\n - name: b\n command: b\n extensions: [\".js\", \".TS\"]\n", `lsp.servers[1]: extension ".TS" is already served by "a"`},
158+ } {
159+ Cfg = defaults
160+ _, err := Load(write("bad.yaml", bad.body))
161+ if err == nil || !strings.Contains(err.Error(), bad.want) {
162+ t.Errorf("config\n%s→ error %v, want %q", bad.body, err, bad.want)
163+ }
164+ }
165+}
@@ -108,3 +108,58 @@ func TestBashToolConfig(t *testing.T) {
108 t.Error("after silent.yaml: bashTool off, want the default kept")108 t.Error("after silent.yaml: bashTool off, want the default kept")
109 }109 }
110 }110 }
111+
112+// lsp.servers: absent by default, read from the YAML with its arguments and
113+// extensions, matched by extension case-insensitively; a server without a
114+// name, a command or extensions, an extension without its dot, or one served
115+// twice is refused with the offending entry named.
116+func TestLSPConfig(t *testing.T) {
117+ defaults := Cfg
118+ t.Cleanup(func() { Cfg = defaults })
119+ if len(Cfg.LSP.Servers) != 0 {
120+ t.Fatalf("defaults: %d servers, want none", len(Cfg.LSP.Servers))
121+ }
122+
123+ dir := t.TempDir()
124+ write := func(name, body string) string {
125+ path := filepath.Join(dir, name)
126+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
127+ t.Fatal(err)
128+ }
129+ return path
130+ }
131+ good := write("good.yaml", `lsp:
132+ servers:
133+ - name: gopls
134+ command: gopls
135+ extensions: [".go"]
136+ - name: typescript
137+ command: typescript-language-server
138+ args: ["--stdio"]
139+ extensions: [".ts", ".TSX"]
140+`)
141+ if _, err := Load(good); err != nil {
142+ t.Fatal(err)
143+ }
144+ servers := Cfg.LSP.Servers
145+ if len(servers) != 2 || servers[1].Args[0] != "--stdio" || len(servers[1].Extensions) != 2 {
146+ t.Fatalf("servers read: %+v", servers)
147+ }
148+ if !servers[0].Serves("cmd/main.go") || servers[0].Serves("app.ts") || !servers[1].Serves("App.tsx") || servers[1].Serves("Makefile") {
149+ t.Errorf("Serves picks the wrong server: %+v", servers)
150+ }
151+
152+ for _, bad := range []struct{ body, want string }{
153+ {"lsp:\n servers:\n - command: gopls\n extensions: [\".go\"]\n", "lsp.servers[0].name must not be empty"},
154+ {"lsp:\n servers:\n - name: gopls\n extensions: [\".go\"]\n", "lsp.servers[0].command must not be empty"},
155+ {"lsp:\n servers:\n - name: gopls\n command: gopls\n", "lsp.servers[0].extensions must list at least one extension"},
156+ {"lsp:\n servers:\n - name: gopls\n command: gopls\n extensions: [\"go\"]\n", `"go" must start with a dot`},
157+ {"lsp:\n servers:\n - name: a\n command: a\n extensions: [\".ts\"]\n - name: b\n command: b\n extensions: [\".js\", \".TS\"]\n", `lsp.servers[1]: extension ".TS" is already served by "a"`},
158+ } {
159+ Cfg = defaults
160+ _, err := Load(write("bad.yaml", bad.body))
161+ if err == nil || !strings.Contains(err.Error(), bad.want) {
162+ t.Errorf("config\n%s→ error %v, want %q", bad.body, err, bad.want)
163+ }
164+ }
165+}
modified internal/detector/detector.go +61 -0
@@ -1,7 +1,24 @@
11 // Package detector provides a mechanism to detect repetitive patterns in agent actions.
2+//
3+// Two levels. The same action (tool, input, output) repeated `threshold`
4+// times makes Record return true: the tool appends LoopError to its answer
5+// and the model is told to change its approach. A model that ignores that
6+// advice keeps repeating; after `stopAfter` more flagged repeats the detector
7+// is Stuck, and the tool ends the turn with StuckError — the one case where
8+// a tool returns a Go error on purpose, because the agent is stopping
9+// itself, not reporting a failure for the model to read.
10+//
11+// d := detector.NewLoopDetector(10, 3).WithStop(3)
12+// if d.Record(action) {
13+// if d.Stuck() {
14+// return "", d.StuckError("edit_file")
15+// }
16+// out += "\n\n" + d.LoopError()
17+// }
218 package detector
319
420 import (
21+ "fmt"
522 "sync"
623 )
724
@@ -18,6 +35,21 @@ type LoopDetector struct {
1835 historyMu sync.Mutex
1936 maxHistory int
2037 threshold int
38+ // stopAfter is how many flagged repeats end the turn; 0 never stops.
39+ stopAfter int
40+ // flagged counts the consecutive Record calls that returned true.
41+ flagged int
42+}
43+
44+// Stuck is the error a tool returns when the detector says the turn must
45+// end: the same call, with the same failing result, Repeats times in a row.
46+type Stuck struct {
47+ Tool string
48+ Repeats int
49+}
50+
51+func (s *Stuck) Error() string {
52+ return fmt.Sprintf("the model repeated the same %s call %d 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", s.Tool, s.Repeats)
2153 }
2254
2355 // NewLoopDetector creates a new detector with a given history limit and threshold.
@@ -29,6 +61,16 @@ func NewLoopDetector(maxHistory, threshold int) *LoopDetector {
2961 }
3062 }
3163
64+// WithStop makes the detector Stuck after n flagged repeats — n calls
65+// beyond the threshold, each already answered with LoopError. 0 (the
66+// default) never stops: the model is only advised.
67+func (d *LoopDetector) WithStop(n int) *LoopDetector {
68+ d.historyMu.Lock()
69+ defer d.historyMu.Unlock()
70+ d.stopAfter = n
71+ return d
72+}
73+
3274 // Record adds an action to the history and returns true if a loop is detected.
3375 func (d *LoopDetector) Record(action Action) bool {
3476 d.historyMu.Lock()
@@ -42,12 +84,14 @@ func (d *LoopDetector) Record(action Action) bool {
4284 d.history[i].Output == action.Output {
4385 count++
4486 if count >= d.threshold {
87+ d.flagged++
4588 return true
4689 }
4790 } else {
4891 break
4992 }
5093 }
94+ d.flagged = 0
5195
5296 // Append the new action.
5397 d.history = append(d.history, action)
@@ -58,6 +102,22 @@ func (d *LoopDetector) Record(action Action) bool {
58102 return false
59103 }
60104
105+// Stuck reports whether the flagged repeats have reached the stop count:
106+// the last Record returned true, and so did the stopAfter-1 before it.
107+func (d *LoopDetector) Stuck() bool {
108+ d.historyMu.Lock()
109+ defer d.historyMu.Unlock()
110+ return d.stopAfter > 0 && d.flagged >= d.stopAfter
111+}
112+
113+// StuckError is the error that ends the turn, naming the tool and how many
114+// identical calls it took.
115+func (d *LoopDetector) StuckError(tool string) error {
116+ d.historyMu.Lock()
117+ defer d.historyMu.Unlock()
118+ return &Stuck{Tool: tool, Repeats: d.threshold + d.flagged}
119+}
120+
61121 // Reset forgets every recorded action and keeps the settings. It is what the
62122 // /new command calls: a new session is a new task, and a command the model
63123 // repeated in the previous one must not be flagged as a loop in this one.
@@ -69,6 +129,7 @@ func (d *LoopDetector) Reset() {
69129 d.historyMu.Lock()
70130 defer d.historyMu.Unlock()
71131 d.history = d.history[:0]
132+ d.flagged = 0
72133 }
73134
74135 // LoopError returns a standardized error message when a loop is detected.
@@ -1,7 +1,24 @@
1 // Package detector provides a mechanism to detect repetitive patterns in agent actions.1 // Package detector provides a mechanism to detect repetitive patterns in agent actions.
2+//
3+// Two levels. The same action (tool, input, output) repeated `threshold`
4+// times makes Record return true: the tool appends LoopError to its answer
5+// and the model is told to change its approach. A model that ignores that
6+// advice keeps repeating; after `stopAfter` more flagged repeats the detector
7+// is Stuck, and the tool ends the turn with StuckError — the one case where
8+// a tool returns a Go error on purpose, because the agent is stopping
9+// itself, not reporting a failure for the model to read.
10+//
11+// d := detector.NewLoopDetector(10, 3).WithStop(3)
12+// if d.Record(action) {
13+// if d.Stuck() {
14+// return "", d.StuckError("edit_file")
15+// }
16+// out += "\n\n" + d.LoopError()
17+// }
2 package detector18 package detector
3 19
4 import (20 import (
21+ "fmt"
5 "sync"22 "sync"
6 )23 )
7 24
@@ -18,6 +35,21 @@ type LoopDetector struct {
18 historyMu sync.Mutex35 historyMu sync.Mutex
19 maxHistory int36 maxHistory int
20 threshold int37 threshold int
38+ // stopAfter is how many flagged repeats end the turn; 0 never stops.
39+ stopAfter int
40+ // flagged counts the consecutive Record calls that returned true.
41+ flagged int
42+}
43+
44+// Stuck is the error a tool returns when the detector says the turn must
45+// end: the same call, with the same failing result, Repeats times in a row.
46+type Stuck struct {
47+ Tool string
48+ Repeats int
49+}
50+
51+func (s *Stuck) Error() string {
52+ return fmt.Sprintf("the model repeated the same %s call %d 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", s.Tool, s.Repeats)
21 }53 }
22 54
23 // NewLoopDetector creates a new detector with a given history limit and threshold.55 // NewLoopDetector creates a new detector with a given history limit and threshold.
@@ -29,6 +61,16 @@ func NewLoopDetector(maxHistory, threshold int) *LoopDetector {
29 }61 }
30 }62 }
31 63
64+// WithStop makes the detector Stuck after n flagged repeats — n calls
65+// beyond the threshold, each already answered with LoopError. 0 (the
66+// default) never stops: the model is only advised.
67+func (d *LoopDetector) WithStop(n int) *LoopDetector {
68+ d.historyMu.Lock()
69+ defer d.historyMu.Unlock()
70+ d.stopAfter = n
71+ return d
72+}
73+
32 // Record adds an action to the history and returns true if a loop is detected.74 // Record adds an action to the history and returns true if a loop is detected.
33 func (d *LoopDetector) Record(action Action) bool {75 func (d *LoopDetector) Record(action Action) bool {
34 d.historyMu.Lock()76 d.historyMu.Lock()
@@ -42,12 +84,14 @@ func (d *LoopDetector) Record(action Action) bool {
42 d.history[i].Output == action.Output {84 d.history[i].Output == action.Output {
43 count++85 count++
44 if count >= d.threshold {86 if count >= d.threshold {
87+ d.flagged++
45 return true88 return true
46 }89 }
47 } else {90 } else {
48 break91 break
49 }92 }
50 }93 }
94+ d.flagged = 0
51 95
52 // Append the new action.96 // Append the new action.
53 d.history = append(d.history, action)97 d.history = append(d.history, action)
@@ -58,6 +102,22 @@ func (d *LoopDetector) Record(action Action) bool {
58 return false102 return false
59 }103 }
60 104
105+// Stuck reports whether the flagged repeats have reached the stop count:
106+// the last Record returned true, and so did the stopAfter-1 before it.
107+func (d *LoopDetector) Stuck() bool {
108+ d.historyMu.Lock()
109+ defer d.historyMu.Unlock()
110+ return d.stopAfter > 0 && d.flagged >= d.stopAfter
111+}
112+
113+// StuckError is the error that ends the turn, naming the tool and how many
114+// identical calls it took.
115+func (d *LoopDetector) StuckError(tool string) error {
116+ d.historyMu.Lock()
117+ defer d.historyMu.Unlock()
118+ return &Stuck{Tool: tool, Repeats: d.threshold + d.flagged}
119+}
120+
61 // Reset forgets every recorded action and keeps the settings. It is what the121 // Reset forgets every recorded action and keeps the settings. It is what the
62 // /new command calls: a new session is a new task, and a command the model122 // /new command calls: a new session is a new task, and a command the model
63 // repeated in the previous one must not be flagged as a loop in this one.123 // repeated in the previous one must not be flagged as a loop in this one.
@@ -69,6 +129,7 @@ func (d *LoopDetector) Reset() {
69 d.historyMu.Lock()129 d.historyMu.Lock()
70 defer d.historyMu.Unlock()130 defer d.historyMu.Unlock()
71 d.history = d.history[:0]131 d.history = d.history[:0]
132+ d.flagged = 0
72 }133 }
73 134
74 // LoopError returns a standardized error message when a loop is detected.135 // LoopError returns a standardized error message when a loop is detected.
modified internal/detector/detector_test.go +45 -0
@@ -1,7 +1,9 @@
11 package detector
22
33 import (
4+ "errors"
45 "fmt"
6+ "strings"
57 "testing"
68 )
79
@@ -55,3 +57,46 @@ func ExampleLoopDetector_Reset() {
5557 // true
5658 // false
5759 }
60+
61+// With a stop count, the detector advises first — LoopError on each flagged
62+// repeat — then declares the turn stuck once the model has ignored the
63+// advice stopAfter times; a different action, or Reset, starts over.
64+func TestStopAfterFlaggedRepeats(t *testing.T) {
65+ d := NewLoopDetector(10, 3).WithStop(2)
66+ for i := 0; i < 3; i++ {
67+ d.Record(same())
68+ }
69+ if !d.Record(same()) || d.Stuck() {
70+ t.Fatal("4th identical call: flagged, but not stuck yet")
71+ }
72+ if !d.Record(same()) || !d.Stuck() {
73+ t.Fatal("5th identical call: the second flagged repeat must be stuck")
74+ }
75+ err := d.StuckError("edit_file")
76+ var st *Stuck
77+ if !errors.As(err, &st) || st.Tool != "edit_file" || st.Repeats != 5 {
78+ t.Errorf("StuckError = %v", err)
79+ }
80+ if !strings.Contains(err.Error(), "the same edit_file call 5 times in a row") || !strings.Contains(err.Error(), "turn stopped by the loop detector") {
81+ t.Errorf("message %q", err.Error())
82+ }
83+
84+ if d.Record(Action{ToolName: "bash", Input: "ls -la", Output: "x"}) || d.Stuck() {
85+ t.Error("a different action must clear the flagged count")
86+ }
87+ for i := 0; i < 4; i++ {
88+ d.Record(same())
89+ }
90+ d.Reset()
91+ if d.Stuck() {
92+ t.Error("Reset must clear the flagged count")
93+ }
94+
95+ plain := NewLoopDetector(10, 2)
96+ for i := 0; i < 10; i++ {
97+ plain.Record(same())
98+ }
99+ if plain.Stuck() {
100+ t.Error("without WithStop the detector never declares the turn stuck")
101+ }
102+}
@@ -1,7 +1,9 @@
1 package detector1 package detector
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
6+ "strings"
5 "testing"7 "testing"
6 )8 )
7 9
@@ -55,3 +57,46 @@ func ExampleLoopDetector_Reset() {
55 // true57 // true
56 // false58 // false
57 }59 }
60+
61+// With a stop count, the detector advises first — LoopError on each flagged
62+// repeat — then declares the turn stuck once the model has ignored the
63+// advice stopAfter times; a different action, or Reset, starts over.
64+func TestStopAfterFlaggedRepeats(t *testing.T) {
65+ d := NewLoopDetector(10, 3).WithStop(2)
66+ for i := 0; i < 3; i++ {
67+ d.Record(same())
68+ }
69+ if !d.Record(same()) || d.Stuck() {
70+ t.Fatal("4th identical call: flagged, but not stuck yet")
71+ }
72+ if !d.Record(same()) || !d.Stuck() {
73+ t.Fatal("5th identical call: the second flagged repeat must be stuck")
74+ }
75+ err := d.StuckError("edit_file")
76+ var st *Stuck
77+ if !errors.As(err, &st) || st.Tool != "edit_file" || st.Repeats != 5 {
78+ t.Errorf("StuckError = %v", err)
79+ }
80+ if !strings.Contains(err.Error(), "the same edit_file call 5 times in a row") || !strings.Contains(err.Error(), "turn stopped by the loop detector") {
81+ t.Errorf("message %q", err.Error())
82+ }
83+
84+ if d.Record(Action{ToolName: "bash", Input: "ls -la", Output: "x"}) || d.Stuck() {
85+ t.Error("a different action must clear the flagged count")
86+ }
87+ for i := 0; i < 4; i++ {
88+ d.Record(same())
89+ }
90+ d.Reset()
91+ if d.Stuck() {
92+ t.Error("Reset must clear the flagged count")
93+ }
94+
95+ plain := NewLoopDetector(10, 2)
96+ for i := 0; i < 10; i++ {
97+ plain.Record(same())
98+ }
99+ if plain.Stuck() {
100+ t.Error("without WithStop the detector never declares the turn stuck")
101+ }
102+}
modified internal/engine/engine.go +32 -1
@@ -12,6 +12,7 @@ package engine
1212 import (
1313 "context"
1414 "encoding/json"
15+ "errors"
1516 "fmt"
1617 "maps"
1718 "slices"
@@ -125,6 +126,11 @@ func (e *Engine) ForgetInputTokens() { e.lastInput.Store(0) }
125126 // Explain turns a transport error into the one-line, actionable message the
126127 // REPL prints between brackets. See Provider.Explain for the rationale.
127128 func (e *Engine) Explain(err error) string {
129+ // The loop detector ended the turn: its own sentence is the whole
130+ // diagnosis, whatever the provider.
131+ if sentence, ok := stuckTurn(err); ok {
132+ return sentence
133+ }
128134 return e.Provider.Explain(err, e.Backend)
129135 }
130136
@@ -319,13 +325,27 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
319325 spinner.Start("Thinking")
320326 defer spinner.Stop()
321327
328+ // The guard watches the tool-call arguments as they stream (runaway.go):
329+ // a derailed model repeating itself for minutes is stopped at once, and
330+ // the spinner says a tool call is being composed rather than nothing.
331+ guard := &toolCallGuard{}
322332 resp, err := genkit.Generate(genCtx, e.G, append(opts,
323333 ai.WithStreaming(func(_ context.Context, chunk *ai.ModelResponseChunk) error {
324334 beat()
335+ wasComposing := guard.composing
336+ if err := guard.observe(chunk); err != nil {
337+ return err
338+ }
339+ if guard.composing && !wasComposing {
340+ spinner.Start("Composing a tool call")
341+ }
325342 emit(chunk.Text())
326343 return nil
327344 }),
328345 )...)
346+ if guard.reason != "" {
347+ return nil, guard.keep(full, resp), errors.New(guard.reason)
348+ }
329349
330350 // `full` grows as soon as a tool turn has happened: it is the witness that
331351 // says whether commands ran, and it is used twice below.
@@ -352,7 +372,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
352372 // happens AFTER commands have run, and replaying the turn would replay them:
353373 // measured, an `echo >>` wrote itself twice. `full` grows as soon as a tool
354374 // turn has happened, which is enough to tell the two apart.
355- if err != nil && !printed && !toolsRan {
375+ // The loop detector's stop is never retried either: replaying the turn
376+ // would run the repeated tool once more before stopping again.
377+ if _, stuck := stuckTurn(err); err != nil && !printed && !toolsRan && !stuck {
356378 spinner.Start("Retrying without streaming")
357379 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {
358380 emit(resp.Text())
@@ -462,6 +484,7 @@ const (
462484 toolRead = "read_file"
463485 toolWrite = "write_file"
464486 toolEdit = "edit_file"
487+ toolLSP = "lsp"
465488 )
466489
467490 // executed returns, in order, the text of the calls to a given tool that
@@ -620,3 +643,11 @@ func fileOpText(r *ai.ToolRequest) string {
620643 func Skills(history []*ai.Message) int {
621644 return responses(history, toolSkill)
622645 }
646+
647+// LSPCalls counts the `lsp` tool responses in a history — questions put to a
648+// language server (definition, references, hover, symbols, diagnostics,
649+// rename). Shown apart from the commands for the same reason as the file
650+// ops: asking the compiler is not running a command.
651+func LSPCalls(history []*ai.Message) int {
652+ return responses(history, toolLSP)
653+}
@@ -12,6 +12,7 @@ package engine
12 import (12 import (
13 "context"13 "context"
14 "encoding/json"14 "encoding/json"
15+ "errors"
15 "fmt"16 "fmt"
16 "maps"17 "maps"
17 "slices"18 "slices"
@@ -125,6 +126,11 @@ func (e *Engine) ForgetInputTokens() { e.lastInput.Store(0) }
125 // Explain turns a transport error into the one-line, actionable message the126 // Explain turns a transport error into the one-line, actionable message the
126 // REPL prints between brackets. See Provider.Explain for the rationale.127 // REPL prints between brackets. See Provider.Explain for the rationale.
127 func (e *Engine) Explain(err error) string {128 func (e *Engine) Explain(err error) string {
129+ // The loop detector ended the turn: its own sentence is the whole
130+ // diagnosis, whatever the provider.
131+ if sentence, ok := stuckTurn(err); ok {
132+ return sentence
133+ }
128 return e.Provider.Explain(err, e.Backend)134 return e.Provider.Explain(err, e.Backend)
129 }135 }
130 136
@@ -319,13 +325,27 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
319 spinner.Start("Thinking")325 spinner.Start("Thinking")
320 defer spinner.Stop()326 defer spinner.Stop()
321 327
328+ // The guard watches the tool-call arguments as they stream (runaway.go):
329+ // a derailed model repeating itself for minutes is stopped at once, and
330+ // the spinner says a tool call is being composed rather than nothing.
331+ guard := &toolCallGuard{}
322 resp, err := genkit.Generate(genCtx, e.G, append(opts,332 resp, err := genkit.Generate(genCtx, e.G, append(opts,
323 ai.WithStreaming(func(_ context.Context, chunk *ai.ModelResponseChunk) error {333 ai.WithStreaming(func(_ context.Context, chunk *ai.ModelResponseChunk) error {
324 beat()334 beat()
335+ wasComposing := guard.composing
336+ if err := guard.observe(chunk); err != nil {
337+ return err
338+ }
339+ if guard.composing && !wasComposing {
340+ spinner.Start("Composing a tool call")
341+ }
325 emit(chunk.Text())342 emit(chunk.Text())
326 return nil343 return nil
327 }),344 }),
328 )...)345 )...)
346+ if guard.reason != "" {
347+ return nil, guard.keep(full, resp), errors.New(guard.reason)
348+ }
329 349
330 // `full` grows as soon as a tool turn has happened: it is the witness that350 // `full` grows as soon as a tool turn has happened: it is the witness that
331 // says whether commands ran, and it is used twice below.351 // says whether commands ran, and it is used twice below.
@@ -352,7 +372,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
352 // happens AFTER commands have run, and replaying the turn would replay them:372 // happens AFTER commands have run, and replaying the turn would replay them:
353 // measured, an `echo >>` wrote itself twice. `full` grows as soon as a tool373 // measured, an `echo >>` wrote itself twice. `full` grows as soon as a tool
354 // turn has happened, which is enough to tell the two apart.374 // turn has happened, which is enough to tell the two apart.
355- if err != nil && !printed && !toolsRan {375+ // The loop detector's stop is never retried either: replaying the turn
376+ // would run the repeated tool once more before stopping again.
377+ if _, stuck := stuckTurn(err); err != nil && !printed && !toolsRan && !stuck {
356 spinner.Start("Retrying without streaming")378 spinner.Start("Retrying without streaming")
357 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {379 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {
358 emit(resp.Text())380 emit(resp.Text())
@@ -462,6 +484,7 @@ const (
462 toolRead = "read_file"484 toolRead = "read_file"
463 toolWrite = "write_file"485 toolWrite = "write_file"
464 toolEdit = "edit_file"486 toolEdit = "edit_file"
487+ toolLSP = "lsp"
465 )488 )
466 489
467 // executed returns, in order, the text of the calls to a given tool that490 // executed returns, in order, the text of the calls to a given tool that
@@ -620,3 +643,11 @@ func fileOpText(r *ai.ToolRequest) string {
620 func Skills(history []*ai.Message) int {643 func Skills(history []*ai.Message) int {
621 return responses(history, toolSkill)644 return responses(history, toolSkill)
622 }645 }
646+
647+// LSPCalls counts the `lsp` tool responses in a history — questions put to a
648+// language server (definition, references, hover, symbols, diagnostics,
649+// rename). Shown apart from the commands for the same reason as the file
650+// ops: asking the compiler is not running a command.
651+func LSPCalls(history []*ai.Message) int {
652+ return responses(history, toolLSP)
653+}
modified internal/engine/engine_test.go +53 -0
@@ -17,6 +17,7 @@ import (
1717 "testing"
1818
1919 "mm/internal/config"
20+ "mm/internal/detector"
2021
2122 "github.com/firebase/genkit/go/ai"
2223 "github.com/firebase/genkit/go/genkit"
@@ -360,3 +361,55 @@ func TestFileOpsKeepsTheOrder(t *testing.T) {
360361 t.Errorf("Commands = %d, want 1 — file ops are not commands", n)
361362 }
362363 }
364+
365+// LSPCalls counts the `lsp` tool's answered calls and nothing else: a
366+// question to the language server is neither a command nor a file op.
367+func TestLSPCallsCountsTheLSPTool(t *testing.T) {
368+ history := []*ai.Message{
369+ ai.NewUserTextMessage("where is hello defined?"),
370+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: "c1", Name: "lsp", Input: map[string]any{"action": "definition"}})),
371+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: "c1", Name: "lsp", Output: "main.go:6:6 func hello()"})),
372+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: "c2", Name: "bash", Input: map[string]any{"command": "ls"}})),
373+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: "c2", Name: "bash", Output: "main.go"})),
374+ ai.NewModelMessage(ai.NewTextPart("line 6")),
375+ }
376+ if n := LSPCalls(history); n != 1 {
377+ t.Errorf("LSPCalls = %d, want 1", n)
378+ }
379+ if n := Commands(history); n != 1 {
380+ t.Errorf("Commands = %d, want 1 — the lsp call must not count as a command", n)
381+ }
382+ if ops := FileOps(history); len(ops) != 0 {
383+ t.Errorf("FileOps = %v, want none", ops)
384+ }
385+}
386+
387+// A tool that ends the turn with the loop detector's error: Generate fails,
388+// the history keeps the calls that ran, and Explain returns the detector's
389+// sentence whichever way Genkit wrapped the error.
390+func TestStuckToolEndsTheTurnAndIsExplained(t *testing.T) {
391+ stuckReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{
392+ Name: "stuck",
393+ Input: map[string]any{},
394+ }))
395+ g, _ := fakeGenkit(t, stuckReq, modelTurn(ai.NewTextPart("never reached")))
396+ genkit.DefineTool(g, "stuck", "always stuck",
397+ func(_ *ai.ToolContext, _ struct{}) (string, error) {
398+ return "", &detector.Stuck{Tool: "edit_file", Repeats: 6}
399+ })
400+ e := testEngine(g)
401+ e.Provider, e.Backend = registry["dmr"], Backend{Provider: "dmr"}
402+ _, full, err := e.Generate(context.Background(),
403+ []*ai.Message{ai.NewUserTextMessage("rename greet")},
404+ []ai.ToolRef{ai.ToolName("stuck")})
405+ if err == nil {
406+ t.Fatal("Generate succeeded despite the stuck tool")
407+ }
408+ if len(full) < 1 {
409+ t.Errorf("history lost: %d messages", len(full))
410+ }
411+ want := "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"
412+ if got := e.Explain(err); got != want {
413+ t.Errorf("Explain = %q\nwant %q", got, want)
414+ }
415+}
@@ -17,6 +17,7 @@ import (
17 "testing"17 "testing"
18 18
19 "mm/internal/config"19 "mm/internal/config"
20+ "mm/internal/detector"
20 21
21 "github.com/firebase/genkit/go/ai"22 "github.com/firebase/genkit/go/ai"
22 "github.com/firebase/genkit/go/genkit"23 "github.com/firebase/genkit/go/genkit"
@@ -360,3 +361,55 @@ func TestFileOpsKeepsTheOrder(t *testing.T) {
360 t.Errorf("Commands = %d, want 1 — file ops are not commands", n)361 t.Errorf("Commands = %d, want 1 — file ops are not commands", n)
361 }362 }
362 }363 }
364+
365+// LSPCalls counts the `lsp` tool's answered calls and nothing else: a
366+// question to the language server is neither a command nor a file op.
367+func TestLSPCallsCountsTheLSPTool(t *testing.T) {
368+ history := []*ai.Message{
369+ ai.NewUserTextMessage("where is hello defined?"),
370+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: "c1", Name: "lsp", Input: map[string]any{"action": "definition"}})),
371+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: "c1", Name: "lsp", Output: "main.go:6:6 func hello()"})),
372+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: "c2", Name: "bash", Input: map[string]any{"command": "ls"}})),
373+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: "c2", Name: "bash", Output: "main.go"})),
374+ ai.NewModelMessage(ai.NewTextPart("line 6")),
375+ }
376+ if n := LSPCalls(history); n != 1 {
377+ t.Errorf("LSPCalls = %d, want 1", n)
378+ }
379+ if n := Commands(history); n != 1 {
380+ t.Errorf("Commands = %d, want 1 — the lsp call must not count as a command", n)
381+ }
382+ if ops := FileOps(history); len(ops) != 0 {
383+ t.Errorf("FileOps = %v, want none", ops)
384+ }
385+}
386+
387+// A tool that ends the turn with the loop detector's error: Generate fails,
388+// the history keeps the calls that ran, and Explain returns the detector's
389+// sentence whichever way Genkit wrapped the error.
390+func TestStuckToolEndsTheTurnAndIsExplained(t *testing.T) {
391+ stuckReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{
392+ Name: "stuck",
393+ Input: map[string]any{},
394+ }))
395+ g, _ := fakeGenkit(t, stuckReq, modelTurn(ai.NewTextPart("never reached")))
396+ genkit.DefineTool(g, "stuck", "always stuck",
397+ func(_ *ai.ToolContext, _ struct{}) (string, error) {
398+ return "", &detector.Stuck{Tool: "edit_file", Repeats: 6}
399+ })
400+ e := testEngine(g)
401+ e.Provider, e.Backend = registry["dmr"], Backend{Provider: "dmr"}
402+ _, full, err := e.Generate(context.Background(),
403+ []*ai.Message{ai.NewUserTextMessage("rename greet")},
404+ []ai.ToolRef{ai.ToolName("stuck")})
405+ if err == nil {
406+ t.Fatal("Generate succeeded despite the stuck tool")
407+ }
408+ if len(full) < 1 {
409+ t.Errorf("history lost: %d messages", len(full))
410+ }
411+ want := "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"
412+ if got := e.Explain(err); got != want {
413+ t.Errorf("Explain = %q\nwant %q", got, want)
414+ }
415+}
modified internal/engine/explain.go +21 -0
@@ -8,9 +8,30 @@ import (
88 "strconv"
99 "strings"
1010
11+ "mm/internal/detector"
12+
1113 "github.com/openai/openai-go"
1214 )
1315
16+// stuckTurn recognises the loop detector's stop (detector.Stuck) and returns
17+// its sentence. Genkit rebuilds a tool's error as text (`tool "x" failed:
18+// …`), so the typed error is usually out of reach of errors.As and the
19+// sentence is found in the text.
20+func stuckTurn(err error) (string, bool) {
21+ if err == nil {
22+ return "", false
23+ }
24+ var stuck *detector.Stuck
25+ if errors.As(err, &stuck) {
26+ return stuck.Error(), true
27+ }
28+ msg := err.Error()
29+ if i := strings.Index(msg, "the model repeated the same"); i >= 0 {
30+ return msg[i:], true
31+ }
32+ return "", false
33+}
34+
1435 // statusInText recovers the HTTP status from the OpenAI client's error text
1536 // (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors
1637 // with %v instead of %w, and the typed error is then out of reach of errors.As;
@@ -8,9 +8,30 @@ import (
8 "strconv"8 "strconv"
9 "strings"9 "strings"
10 10
11+ "mm/internal/detector"
12+
11 "github.com/openai/openai-go"13 "github.com/openai/openai-go"
12 )14 )
13 15
16+// stuckTurn recognises the loop detector's stop (detector.Stuck) and returns
17+// its sentence. Genkit rebuilds a tool's error as text (`tool "x" failed:
18+// …`), so the typed error is usually out of reach of errors.As and the
19+// sentence is found in the text.
20+func stuckTurn(err error) (string, bool) {
21+ if err == nil {
22+ return "", false
23+ }
24+ var stuck *detector.Stuck
25+ if errors.As(err, &stuck) {
26+ return stuck.Error(), true
27+ }
28+ msg := err.Error()
29+ if i := strings.Index(msg, "the model repeated the same"); i >= 0 {
30+ return msg[i:], true
31+ }
32+ return "", false
33+}
34+
14 // statusInText recovers the HTTP status from the OpenAI client's error text35 // statusInText recovers the HTTP status from the OpenAI client's error text
15 // (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors36 // (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors
16 // with %v instead of %w, and the typed error is then out of reach of errors.As;37 // with %v instead of %w, and the typed error is then out of reach of errors.As;
added internal/engine/runaway.go +108 -0
new file mode 100644
@@ -0,0 +1,108 @@
1+package engine
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/firebase/genkit/go/ai"
8+)
9+
10+// A model composing a tool call streams its arguments one delta at a time,
11+// and nothing of it reaches the screen: the terminal shows "Thinking" and the
12+// watchdog is fed by every delta. A small model that derails on that call —
13+// seen on 2026-09-18 with a rename: `"symbol": "Greet 2023-06-15 11:16:00
14+// +0000 UTC 2023-06-15 11:16:00 +0000 UTC …"` for four and a half minutes,
15+// up to max_tokens — therefore looks exactly like nothing happening. The
16+// guard below reads the deltas as they come and ends the generation as soon
17+// as the arguments repeat themselves or grow past any legitimate size.
18+
19+const (
20+ // runawayTail is how many trailing bytes must be periodic for the
21+ // arguments to count as repeating.
22+ runawayTail = 256
23+ // runawayMinPeriod and runawayMaxPeriod bound the pattern length looked
24+ // for: shorter than 4 is a legitimate run (`====`), longer than 64 is
25+ // not what a derailed model produces.
26+ runawayMinPeriod = 4
27+ runawayMaxPeriod = 64
28+ // maxToolArgBytes is the size past which no tool call is legitimate:
29+ // a write_file of a quarter megabyte is not something the model should
30+ // compose in one go.
31+ maxToolArgBytes = 256 << 10
32+)
33+
34+// toolCallGuard watches the tool-call arguments streamed during one
35+// generation.
36+type toolCallGuard struct {
37+ args strings.Builder
38+ reason string // set once the guard tripped; the turn's error message
39+ // composing says a tool call is being streamed (for the spinner label).
40+ composing bool
41+}
42+
43+// observe reads one chunk and returns an error when the arguments ran away;
44+// Genkit aborts the stream on a callback error.
45+func (g *toolCallGuard) observe(chunk *ai.ModelResponseChunk) error {
46+ if chunk == nil {
47+ return nil
48+ }
49+ for _, p := range chunk.Content {
50+ if !p.IsToolRequest() || p.ToolRequest == nil {
51+ continue
52+ }
53+ g.composing = true
54+ if s, ok := p.ToolRequest.Input.(string); ok {
55+ g.args.WriteString(s)
56+ }
57+ }
58+ switch {
59+ case g.args.Len() > maxToolArgBytes:
60+ g.reason = fmt.Sprintf("the model's tool call ran away: %d KB of arguments and still going — generation stopped; ask for a smaller change, or try another model", g.args.Len()>>10)
61+ case g.args.Len() >= runawayTail*2 && periodicTail(g.args.String(), runawayTail, runawayMinPeriod, runawayMaxPeriod):
62+ g.reason = fmt.Sprintf("the model's tool call ran away: its arguments repeat the same pattern (%d characters so far: …%s) — generation stopped; rephrase the request, or try another model", g.args.Len(), lastRunes(g.args.String(), 40))
63+ default:
64+ return nil
65+ }
66+ return fmt.Errorf("%s", g.reason)
67+}
68+
69+// keep is the history to hand back after the guard tripped: our own stop,
70+// not a server failure, so what ran is kept (and the turn is not retried —
71+// the retry would let the model derail again).
72+func (g *toolCallGuard) keep(full []*ai.Message, resp *ai.ModelResponse) []*ai.Message {
73+ if resp != nil && resp.Message != nil {
74+ full = append(full, resp.Message)
75+ }
76+ return full
77+}
78+
79+// periodicTail reports whether the last `tail` bytes of s are one pattern of
80+// length between minPeriod and maxPeriod repeated end to end.
81+func periodicTail(s string, tail, minPeriod, maxPeriod int) bool {
82+ if len(s) < tail {
83+ return false
84+ }
85+ t := s[len(s)-tail:]
86+ for p := minPeriod; p <= maxPeriod; p++ {
87+ periodic := true
88+ for i := 0; i+p < len(t); i++ {
89+ if t[i] != t[i+p] {
90+ periodic = false
91+ break
92+ }
93+ }
94+ if periodic {
95+ return true
96+ }
97+ }
98+ return false
99+}
100+
101+// lastRunes is the end of s, at most n runes, on one line.
102+func lastRunes(s string, n int) string {
103+ r := []rune(s)
104+ if len(r) > n {
105+ r = r[len(r)-n:]
106+ }
107+ return strings.Join(strings.Fields(string(r)), " ")
108+}
new file mode 100644
@@ -0,0 +1,108 @@
1+package engine
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/firebase/genkit/go/ai"
8+)
9+
10+// A model composing a tool call streams its arguments one delta at a time,
11+// and nothing of it reaches the screen: the terminal shows "Thinking" and the
12+// watchdog is fed by every delta. A small model that derails on that call —
13+// seen on 2026-09-18 with a rename: `"symbol": "Greet 2023-06-15 11:16:00
14+// +0000 UTC 2023-06-15 11:16:00 +0000 UTC …"` for four and a half minutes,
15+// up to max_tokens — therefore looks exactly like nothing happening. The
16+// guard below reads the deltas as they come and ends the generation as soon
17+// as the arguments repeat themselves or grow past any legitimate size.
18+
19+const (
20+ // runawayTail is how many trailing bytes must be periodic for the
21+ // arguments to count as repeating.
22+ runawayTail = 256
23+ // runawayMinPeriod and runawayMaxPeriod bound the pattern length looked
24+ // for: shorter than 4 is a legitimate run (`====`), longer than 64 is
25+ // not what a derailed model produces.
26+ runawayMinPeriod = 4
27+ runawayMaxPeriod = 64
28+ // maxToolArgBytes is the size past which no tool call is legitimate:
29+ // a write_file of a quarter megabyte is not something the model should
30+ // compose in one go.
31+ maxToolArgBytes = 256 << 10
32+)
33+
34+// toolCallGuard watches the tool-call arguments streamed during one
35+// generation.
36+type toolCallGuard struct {
37+ args strings.Builder
38+ reason string // set once the guard tripped; the turn's error message
39+ // composing says a tool call is being streamed (for the spinner label).
40+ composing bool
41+}
42+
43+// observe reads one chunk and returns an error when the arguments ran away;
44+// Genkit aborts the stream on a callback error.
45+func (g *toolCallGuard) observe(chunk *ai.ModelResponseChunk) error {
46+ if chunk == nil {
47+ return nil
48+ }
49+ for _, p := range chunk.Content {
50+ if !p.IsToolRequest() || p.ToolRequest == nil {
51+ continue
52+ }
53+ g.composing = true
54+ if s, ok := p.ToolRequest.Input.(string); ok {
55+ g.args.WriteString(s)
56+ }
57+ }
58+ switch {
59+ case g.args.Len() > maxToolArgBytes:
60+ g.reason = fmt.Sprintf("the model's tool call ran away: %d KB of arguments and still going — generation stopped; ask for a smaller change, or try another model", g.args.Len()>>10)
61+ case g.args.Len() >= runawayTail*2 && periodicTail(g.args.String(), runawayTail, runawayMinPeriod, runawayMaxPeriod):
62+ g.reason = fmt.Sprintf("the model's tool call ran away: its arguments repeat the same pattern (%d characters so far: …%s) — generation stopped; rephrase the request, or try another model", g.args.Len(), lastRunes(g.args.String(), 40))
63+ default:
64+ return nil
65+ }
66+ return fmt.Errorf("%s", g.reason)
67+}
68+
69+// keep is the history to hand back after the guard tripped: our own stop,
70+// not a server failure, so what ran is kept (and the turn is not retried —
71+// the retry would let the model derail again).
72+func (g *toolCallGuard) keep(full []*ai.Message, resp *ai.ModelResponse) []*ai.Message {
73+ if resp != nil && resp.Message != nil {
74+ full = append(full, resp.Message)
75+ }
76+ return full
77+}
78+
79+// periodicTail reports whether the last `tail` bytes of s are one pattern of
80+// length between minPeriod and maxPeriod repeated end to end.
81+func periodicTail(s string, tail, minPeriod, maxPeriod int) bool {
82+ if len(s) < tail {
83+ return false
84+ }
85+ t := s[len(s)-tail:]
86+ for p := minPeriod; p <= maxPeriod; p++ {
87+ periodic := true
88+ for i := 0; i+p < len(t); i++ {
89+ if t[i] != t[i+p] {
90+ periodic = false
91+ break
92+ }
93+ }
94+ if periodic {
95+ return true
96+ }
97+ }
98+ return false
99+}
100+
101+// lastRunes is the end of s, at most n runes, on one line.
102+func lastRunes(s string, n int) string {
103+ r := []rune(s)
104+ if len(r) > n {
105+ r = r[len(r)-n:]
106+ }
107+ return strings.Join(strings.Fields(string(r)), " ")
108+}
added internal/engine/runaway_test.go +81 -0
new file mode 100644
@@ -0,0 +1,81 @@
1+package engine
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/firebase/genkit/go/ai"
9+)
10+
11+func toolDelta(s string) *ai.ModelResponseChunk {
12+ return &ai.ModelResponseChunk{Content: []*ai.Part{ai.NewToolRequestPart(&ai.ToolRequest{Name: "lsp", Input: s})}}
13+}
14+
15+// The guard lets a real tool call through — a long write_file content, a
16+// sane rename — and trips on arguments that repeat a pattern or never end.
17+func TestToolCallGuard(t *testing.T) {
18+ var g toolCallGuard
19+ if err := g.observe(&ai.ModelResponseChunk{Content: []*ai.Part{ai.NewTextPart("hello")}}); err != nil || g.composing {
20+ t.Fatalf("text chunk: %v, composing %v", err, g.composing)
21+ }
22+ for _, d := range []string{`{"action": "rename", "path": "main.go",`, ` "symbol": "Greet", "new_name": "Hello"}`} {
23+ if err := g.observe(toolDelta(d)); err != nil {
24+ t.Fatalf("sane call: %v", err)
25+ }
26+ }
27+ if !g.composing {
28+ t.Error("a tool delta must mark the guard as composing")
29+ }
30+
31+ // A big but legitimate content: numbered lines never form a period ≤ 64.
32+ var big toolCallGuard
33+ var b strings.Builder
34+ for i := 0; i < 2000; i++ {
35+ b.WriteString("line ")
36+ b.WriteString(strings.Repeat("x", i%7))
37+ b.WriteString(" of a long file, number ")
38+ b.WriteString(strings.Repeat("0", 1+i%3))
39+ b.WriteString("\n")
40+ }
41+ if err := big.observe(toolDelta(`{"path": "big.txt", "content": "` + b.String())); err != nil {
42+ t.Errorf("legitimate 60 KB content refused: %v", err)
43+ }
44+
45+ // The derailed rename of 2026-09-18, delta by delta.
46+ var bad toolCallGuard
47+ _ = bad.observe(toolDelta(`{"action": "rename", "path": "main.go", "symbol": "Greet`))
48+ var tripped error
49+ for i := 0; i < 200 && tripped == nil; i++ {
50+ tripped = bad.observe(toolDelta(" 2023-06-15 11:16:00 +0000 UTC"))
51+ }
52+ if tripped == nil || !strings.Contains(tripped.Error(), "ran away: its arguments repeat the same pattern") || bad.args.Len() > 2*runawayTail+64 {
53+ t.Errorf("repeating arguments not caught early: err %v after %d bytes", tripped, bad.args.Len())
54+ }
55+ if bad.reason == "" || !strings.Contains(bad.reason, "2023-06-15 11:16:00") {
56+ t.Errorf("reason %q should quote the tail", bad.reason)
57+ }
58+
59+ // Sheer size trips it too — on content that never repeats.
60+ var huge toolCallGuard
61+ _ = huge.observe(toolDelta(`{"content": "`))
62+ var sizeErr error
63+ for i := 0; i < 400000 && sizeErr == nil; i++ {
64+ sizeErr = huge.observe(toolDelta(fmt.Sprintf("%d,", i*7919)))
65+ }
66+ if sizeErr == nil || !strings.Contains(sizeErr.Error(), "KB of arguments") {
67+ t.Errorf("size cap: %v", sizeErr)
68+ }
69+}
70+
71+func TestPeriodicTail(t *testing.T) {
72+ if !periodicTail(strings.Repeat(" 1", 200), 256, 4, 64) {
73+ t.Error("' 1' repeated is periodic (period 2 divides 4)")
74+ }
75+ if periodicTail(strings.Repeat("abc", 90)+"x"+strings.Repeat("abc", 40), 256, 4, 64) {
76+ t.Error("a pattern broken inside the tail is not periodic")
77+ }
78+ if periodicTail("short", 256, 4, 64) {
79+ t.Error("shorter than the tail is never periodic")
80+ }
81+}
new file mode 100644
@@ -0,0 +1,81 @@
1+package engine
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/firebase/genkit/go/ai"
9+)
10+
11+func toolDelta(s string) *ai.ModelResponseChunk {
12+ return &ai.ModelResponseChunk{Content: []*ai.Part{ai.NewToolRequestPart(&ai.ToolRequest{Name: "lsp", Input: s})}}
13+}
14+
15+// The guard lets a real tool call through — a long write_file content, a
16+// sane rename — and trips on arguments that repeat a pattern or never end.
17+func TestToolCallGuard(t *testing.T) {
18+ var g toolCallGuard
19+ if err := g.observe(&ai.ModelResponseChunk{Content: []*ai.Part{ai.NewTextPart("hello")}}); err != nil || g.composing {
20+ t.Fatalf("text chunk: %v, composing %v", err, g.composing)
21+ }
22+ for _, d := range []string{`{"action": "rename", "path": "main.go",`, ` "symbol": "Greet", "new_name": "Hello"}`} {
23+ if err := g.observe(toolDelta(d)); err != nil {
24+ t.Fatalf("sane call: %v", err)
25+ }
26+ }
27+ if !g.composing {
28+ t.Error("a tool delta must mark the guard as composing")
29+ }
30+
31+ // A big but legitimate content: numbered lines never form a period ≤ 64.
32+ var big toolCallGuard
33+ var b strings.Builder
34+ for i := 0; i < 2000; i++ {
35+ b.WriteString("line ")
36+ b.WriteString(strings.Repeat("x", i%7))
37+ b.WriteString(" of a long file, number ")
38+ b.WriteString(strings.Repeat("0", 1+i%3))
39+ b.WriteString("\n")
40+ }
41+ if err := big.observe(toolDelta(`{"path": "big.txt", "content": "` + b.String())); err != nil {
42+ t.Errorf("legitimate 60 KB content refused: %v", err)
43+ }
44+
45+ // The derailed rename of 2026-09-18, delta by delta.
46+ var bad toolCallGuard
47+ _ = bad.observe(toolDelta(`{"action": "rename", "path": "main.go", "symbol": "Greet`))
48+ var tripped error
49+ for i := 0; i < 200 && tripped == nil; i++ {
50+ tripped = bad.observe(toolDelta(" 2023-06-15 11:16:00 +0000 UTC"))
51+ }
52+ if tripped == nil || !strings.Contains(tripped.Error(), "ran away: its arguments repeat the same pattern") || bad.args.Len() > 2*runawayTail+64 {
53+ t.Errorf("repeating arguments not caught early: err %v after %d bytes", tripped, bad.args.Len())
54+ }
55+ if bad.reason == "" || !strings.Contains(bad.reason, "2023-06-15 11:16:00") {
56+ t.Errorf("reason %q should quote the tail", bad.reason)
57+ }
58+
59+ // Sheer size trips it too — on content that never repeats.
60+ var huge toolCallGuard
61+ _ = huge.observe(toolDelta(`{"content": "`))
62+ var sizeErr error
63+ for i := 0; i < 400000 && sizeErr == nil; i++ {
64+ sizeErr = huge.observe(toolDelta(fmt.Sprintf("%d,", i*7919)))
65+ }
66+ if sizeErr == nil || !strings.Contains(sizeErr.Error(), "KB of arguments") {
67+ t.Errorf("size cap: %v", sizeErr)
68+ }
69+}
70+
71+func TestPeriodicTail(t *testing.T) {
72+ if !periodicTail(strings.Repeat(" 1", 200), 256, 4, 64) {
73+ t.Error("' 1' repeated is periodic (period 2 divides 4)")
74+ }
75+ if periodicTail(strings.Repeat("abc", 90)+"x"+strings.Repeat("abc", 40), 256, 4, 64) {
76+ t.Error("a pattern broken inside the tail is not periodic")
77+ }
78+ if periodicTail("short", 256, 4, 64) {
79+ t.Error("shorter than the tail is never periodic")
80+ }
81+}
modified internal/fileedit/edits.go +19 -2
@@ -3,6 +3,7 @@ package fileedit
33 import (
44 "fmt"
55 "sort"
6+ "strconv"
67 "strings"
78 )
89
@@ -88,14 +89,30 @@ func locate(body string, e Edit, i int) (span, error) {
8889 i+1, quote(e.Old))
8990 case n > 1:
9091 return span{}, fmt.Errorf(
91- "edit #%d: old text appears %d times — add the surrounding lines until it is unique:\n%s",
92- i+1, n, quote(e.Old))
92+ "edit #%d: old text appears %d times (lines %s) — to change ONE of them, include its surrounding lines in old until the text is unique; to change several, send one edit per place, each with its own surrounding lines:\n%s",
93+ i+1, n, occurrenceLines(body, e.Old), quote(e.Old))
9394 }
9495
9596 start := strings.Index(body, e.Old)
9697 return span{start: start, end: start + len(e.Old), index: i}, nil
9798 }
9899
100+// occurrenceLines lists the 1-based lines where text occurs in body, as
101+// "11, 20": the model has to pick one, and a line number is what lets it.
102+func occurrenceLines(body, text string) string {
103+ var lines []string
104+ for at, from := 0, 0; ; {
105+ i := strings.Index(body[from:], text)
106+ if i < 0 {
107+ break
108+ }
109+ at = from + i
110+ lines = append(lines, strconv.Itoa(lineOf(body, at)))
111+ from = at + len(text)
112+ }
113+ return strings.Join(lines, ", ")
114+}
115+
99116 // lineOf returns the 1-based line number of an offset in the body.
100117 func lineOf(body string, offset int) int {
101118 return 1 + strings.Count(body[:offset], "\n")
@@ -3,6 +3,7 @@ package fileedit
3 import (3 import (
4 "fmt"4 "fmt"
5 "sort"5 "sort"
6+ "strconv"
6 "strings"7 "strings"
7 )8 )
8 9
@@ -88,14 +89,30 @@ func locate(body string, e Edit, i int) (span, error) {
88 i+1, quote(e.Old))89 i+1, quote(e.Old))
89 case n > 1:90 case n > 1:
90 return span{}, fmt.Errorf(91 return span{}, fmt.Errorf(
91- "edit #%d: old text appears %d times — add the surrounding lines until it is unique:\n%s",92+ "edit #%d: old text appears %d times (lines %s) — to change ONE of them, include its surrounding lines in old until the text is unique; to change several, send one edit per place, each with its own surrounding lines:\n%s",
92- i+1, n, quote(e.Old))93+ i+1, n, occurrenceLines(body, e.Old), quote(e.Old))
93 }94 }
94 95
95 start := strings.Index(body, e.Old)96 start := strings.Index(body, e.Old)
96 return span{start: start, end: start + len(e.Old), index: i}, nil97 return span{start: start, end: start + len(e.Old), index: i}, nil
97 }98 }
98 99
100+// occurrenceLines lists the 1-based lines where text occurs in body, as
101+// "11, 20": the model has to pick one, and a line number is what lets it.
102+func occurrenceLines(body, text string) string {
103+ var lines []string
104+ for at, from := 0, 0; ; {
105+ i := strings.Index(body[from:], text)
106+ if i < 0 {
107+ break
108+ }
109+ at = from + i
110+ lines = append(lines, strconv.Itoa(lineOf(body, at)))
111+ from = at + len(text)
112+ }
113+ return strings.Join(lines, ", ")
114+}
115+
99 // lineOf returns the 1-based line number of an offset in the body.116 // lineOf returns the 1-based line number of an offset in the body.
100 func lineOf(body string, offset int) int {117 func lineOf(body string, offset int) int {
101 return 1 + strings.Count(body[:offset], "\n")118 return 1 + strings.Count(body[:offset], "\n")
modified internal/fileedit/fileedit_test.go +1 -1
@@ -75,7 +75,7 @@ func TestApplyRefuses(t *testing.T) {
7575 says string
7676 }{
7777 {"text not found", "a\n", []Edit{{"b", "c"}}, "not found"},
78- {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times — add the surrounding lines until it is unique"},
78+ {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times (lines 1, 2) — to change ONE of them"},
7979 {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n",
8080 []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}},
8181 "overlap"},
@@ -75,7 +75,7 @@ func TestApplyRefuses(t *testing.T) {
75 says string75 says string
76 }{76 }{
77 {"text not found", "a\n", []Edit{{"b", "c"}}, "not found"},77 {"text not found", "a\n", []Edit{{"b", "c"}}, "not found"},
78- {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times — add the surrounding lines until it is unique"},78+ {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times (lines 1, 2) — to change ONE of them"},
79 {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n",79 {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n",
80 []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}},80 []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}},
81 "overlap"},81 "overlap"},
added internal/lsp/change.go +159 -0
new file mode 100644
@@ -0,0 +1,159 @@
1+package lsp
2+
3+// The two operations that look at a file's state rather than a symbol's
4+// place: what the compiler reports, and a rename applied to disk.
5+
6+import (
7+ "context"
8+ "fmt"
9+ "sort"
10+ "strings"
11+ "time"
12+
13+ "go.lsp.dev/protocol"
14+ "go.lsp.dev/uri"
15+)
16+
17+// Diagnostics reports what the server finds wrong in a file — errors,
18+// warnings, hints — one per line with its position. The file is synced
19+// first, then the call waits (briefly) for the server's publication; a
20+// server that publishes nothing new in time is reported as such rather than
21+// blocking the turn.
22+func (c *Client) Diagnostics(ctx context.Context, path string) (string, error) {
23+ if path == "" {
24+ return "", fmt.Errorf("path is required")
25+ }
26+ s, err := c.serverFor(ctx, path)
27+ if err != nil {
28+ return "", err
29+ }
30+ // The sequence is read BEFORE the sync: the publication the sync
31+ // triggers may arrive before this function gets to look.
32+ u := uri.File(mustAbs(path))
33+ before := s.publications(u)
34+ if _, err := s.sync(ctx, path); err != nil {
35+ return "", err
36+ }
37+ fresh, err := s.waitPublication(ctx, u, before, c.diagWait)
38+ if err != nil {
39+ return "", err
40+ }
41+ return c.renderDiagnostics(s, u, fresh), nil
42+}
43+
44+// publications is how many times the server has published for a file.
45+func (s *session) publications(u uri.URI) int {
46+ s.diagMu.Lock()
47+ defer s.diagMu.Unlock()
48+ return s.seq[u]
49+}
50+
51+// waitPublication waits until the server has published for u since `before`,
52+// or the delay is over, and reports which.
53+func (s *session) waitPublication(ctx context.Context, u uri.URI, before int, wait time.Duration) (bool, error) {
54+ deadline := time.After(wait)
55+ for s.publications(u) <= before {
56+ select {
57+ case <-s.wake:
58+ case <-deadline:
59+ return false, nil
60+ case <-ctx.Done():
61+ return false, ctx.Err()
62+ }
63+ }
64+ return true, nil
65+}
66+
67+// renderDiagnostics renders what the server last said about u. An
68+// unchanged file gets no new publication: what the server last said still
69+// holds, and is labelled as such.
70+func (c *Client) renderDiagnostics(s *session, u uri.URI, fresh bool) string {
71+ s.diagMu.Lock()
72+ diags := append([]protocol.Diagnostic(nil), s.diags[u]...)
73+ known := s.seq[u] > 0
74+ s.diagMu.Unlock()
75+
76+ rel := c.relative(u.FsPath())
77+ switch {
78+ case !known:
79+ return fmt.Sprintf("no diagnostics received for %s within %s (the server may still be indexing — ask again)", rel, c.diagWait)
80+ case len(diags) == 0:
81+ return "no diagnostics: " + rel + " is clean"
82+ }
83+ stale := ""
84+ if !fresh {
85+ stale = " (as last published)"
86+ }
87+ sort.Slice(diags, func(i, j int) bool {
88+ a, b := diags[i].Range.Start, diags[j].Range.Start
89+ return a.Line < b.Line || (a.Line == b.Line && a.Character < b.Character)
90+ })
91+ var b strings.Builder
92+ fmt.Fprintf(&b, "%d diagnostic(s) in %s%s:\n", len(diags), rel, stale)
93+ for _, d := range diags {
94+ source := ""
95+ if src, ok := d.Source.Get(); ok && src != "" {
96+ source = " [" + src + "]"
97+ }
98+ fmt.Fprintf(&b, "%s %s:%d:%d %s%s\n", severityName(d.Severity), rel, d.Range.Start.Line+1, d.Range.Start.Character+1, strings.TrimSpace(diagnosticMessage(d)), source)
99+ }
100+ return strings.TrimRight(b.String(), "\n")
101+}
102+
103+// Rename renames the symbol at path:line:col everywhere the server knows
104+// about and applies the edits to the files on disk. The answer lists the
105+// files touched; nothing is written when the server refuses.
106+func (c *Client) Rename(ctx context.Context, path string, line, col int, newName string) (string, error) {
107+ if strings.TrimSpace(newName) == "" {
108+ return "", fmt.Errorf("rename needs new_name")
109+ }
110+ s, pos, err := c.at(ctx, path, line, col)
111+ if err != nil {
112+ return "", err
113+ }
114+ changed, err := s.rename(ctx, pos, newName)
115+ if err != nil {
116+ return "", err
117+ }
118+ if len(changed) == 0 {
119+ return "", noResult("the server proposed no change for the symbol at " + where(path, line, col))
120+ }
121+ return c.renderRename(newName, changed), nil
122+}
123+
124+// rename asks the server, writes its edit to disk, and tells the server
125+// about the new contents of what it holds, so the next question is answered
126+// on the renamed code. A nil edit is no change.
127+func (s *session) rename(ctx context.Context, pos protocol.TextDocumentPositionParams, newName string) ([]changedFile, error) {
128+ edit, err := s.server.Rename(ctx, &protocol.RenameParams{TextDocumentPositionParams: pos, NewName: newName})
129+ switch {
130+ case err != nil:
131+ return nil, s.wrap("rename", err)
132+ case edit == nil:
133+ return nil, nil
134+ }
135+ changed, err := applyWorkspaceEdit(edit)
136+ if err != nil {
137+ return nil, fmt.Errorf("rename: %w", err)
138+ }
139+ for _, ch := range changed {
140+ if _, err := s.sync(ctx, ch.path); err != nil {
141+ return nil, err
142+ }
143+ }
144+ return changed, nil
145+}
146+
147+// renderRename is the rename's report: the total, then one line per file.
148+func (c *Client) renderRename(newName string, changed []changedFile) string {
149+ var b strings.Builder
150+ total := 0
151+ for _, ch := range changed {
152+ total += ch.edits
153+ }
154+ fmt.Fprintf(&b, "renamed to %s: %d edit(s) in %d file(s)\n", newName, total, len(changed))
155+ for _, ch := range changed {
156+ fmt.Fprintf(&b, "%s: %d edit(s)\n", c.relative(ch.path), ch.edits)
157+ }
158+ return strings.TrimRight(b.String(), "\n")
159+}
new file mode 100644
@@ -0,0 +1,159 @@
1+package lsp
2+
3+// The two operations that look at a file's state rather than a symbol's
4+// place: what the compiler reports, and a rename applied to disk.
5+
6+import (
7+ "context"
8+ "fmt"
9+ "sort"
10+ "strings"
11+ "time"
12+
13+ "go.lsp.dev/protocol"
14+ "go.lsp.dev/uri"
15+)
16+
17+// Diagnostics reports what the server finds wrong in a file — errors,
18+// warnings, hints — one per line with its position. The file is synced
19+// first, then the call waits (briefly) for the server's publication; a
20+// server that publishes nothing new in time is reported as such rather than
21+// blocking the turn.
22+func (c *Client) Diagnostics(ctx context.Context, path string) (string, error) {
23+ if path == "" {
24+ return "", fmt.Errorf("path is required")
25+ }
26+ s, err := c.serverFor(ctx, path)
27+ if err != nil {
28+ return "", err
29+ }
30+ // The sequence is read BEFORE the sync: the publication the sync
31+ // triggers may arrive before this function gets to look.
32+ u := uri.File(mustAbs(path))
33+ before := s.publications(u)
34+ if _, err := s.sync(ctx, path); err != nil {
35+ return "", err
36+ }
37+ fresh, err := s.waitPublication(ctx, u, before, c.diagWait)
38+ if err != nil {
39+ return "", err
40+ }
41+ return c.renderDiagnostics(s, u, fresh), nil
42+}
43+
44+// publications is how many times the server has published for a file.
45+func (s *session) publications(u uri.URI) int {
46+ s.diagMu.Lock()
47+ defer s.diagMu.Unlock()
48+ return s.seq[u]
49+}
50+
51+// waitPublication waits until the server has published for u since `before`,
52+// or the delay is over, and reports which.
53+func (s *session) waitPublication(ctx context.Context, u uri.URI, before int, wait time.Duration) (bool, error) {
54+ deadline := time.After(wait)
55+ for s.publications(u) <= before {
56+ select {
57+ case <-s.wake:
58+ case <-deadline:
59+ return false, nil
60+ case <-ctx.Done():
61+ return false, ctx.Err()
62+ }
63+ }
64+ return true, nil
65+}
66+
67+// renderDiagnostics renders what the server last said about u. An
68+// unchanged file gets no new publication: what the server last said still
69+// holds, and is labelled as such.
70+func (c *Client) renderDiagnostics(s *session, u uri.URI, fresh bool) string {
71+ s.diagMu.Lock()
72+ diags := append([]protocol.Diagnostic(nil), s.diags[u]...)
73+ known := s.seq[u] > 0
74+ s.diagMu.Unlock()
75+
76+ rel := c.relative(u.FsPath())
77+ switch {
78+ case !known:
79+ return fmt.Sprintf("no diagnostics received for %s within %s (the server may still be indexing — ask again)", rel, c.diagWait)
80+ case len(diags) == 0:
81+ return "no diagnostics: " + rel + " is clean"
82+ }
83+ stale := ""
84+ if !fresh {
85+ stale = " (as last published)"
86+ }
87+ sort.Slice(diags, func(i, j int) bool {
88+ a, b := diags[i].Range.Start, diags[j].Range.Start
89+ return a.Line < b.Line || (a.Line == b.Line && a.Character < b.Character)
90+ })
91+ var b strings.Builder
92+ fmt.Fprintf(&b, "%d diagnostic(s) in %s%s:\n", len(diags), rel, stale)
93+ for _, d := range diags {
94+ source := ""
95+ if src, ok := d.Source.Get(); ok && src != "" {
96+ source = " [" + src + "]"
97+ }
98+ fmt.Fprintf(&b, "%s %s:%d:%d %s%s\n", severityName(d.Severity), rel, d.Range.Start.Line+1, d.Range.Start.Character+1, strings.TrimSpace(diagnosticMessage(d)), source)
99+ }
100+ return strings.TrimRight(b.String(), "\n")
101+}
102+
103+// Rename renames the symbol at path:line:col everywhere the server knows
104+// about and applies the edits to the files on disk. The answer lists the
105+// files touched; nothing is written when the server refuses.
106+func (c *Client) Rename(ctx context.Context, path string, line, col int, newName string) (string, error) {
107+ if strings.TrimSpace(newName) == "" {
108+ return "", fmt.Errorf("rename needs new_name")
109+ }
110+ s, pos, err := c.at(ctx, path, line, col)
111+ if err != nil {
112+ return "", err
113+ }
114+ changed, err := s.rename(ctx, pos, newName)
115+ if err != nil {
116+ return "", err
117+ }
118+ if len(changed) == 0 {
119+ return "", noResult("the server proposed no change for the symbol at " + where(path, line, col))
120+ }
121+ return c.renderRename(newName, changed), nil
122+}
123+
124+// rename asks the server, writes its edit to disk, and tells the server
125+// about the new contents of what it holds, so the next question is answered
126+// on the renamed code. A nil edit is no change.
127+func (s *session) rename(ctx context.Context, pos protocol.TextDocumentPositionParams, newName string) ([]changedFile, error) {
128+ edit, err := s.server.Rename(ctx, &protocol.RenameParams{TextDocumentPositionParams: pos, NewName: newName})
129+ switch {
130+ case err != nil:
131+ return nil, s.wrap("rename", err)
132+ case edit == nil:
133+ return nil, nil
134+ }
135+ changed, err := applyWorkspaceEdit(edit)
136+ if err != nil {
137+ return nil, fmt.Errorf("rename: %w", err)
138+ }
139+ for _, ch := range changed {
140+ if _, err := s.sync(ctx, ch.path); err != nil {
141+ return nil, err
142+ }
143+ }
144+ return changed, nil
145+}
146+
147+// renderRename is the rename's report: the total, then one line per file.
148+func (c *Client) renderRename(newName string, changed []changedFile) string {
149+ var b strings.Builder
150+ total := 0
151+ for _, ch := range changed {
152+ total += ch.edits
153+ }
154+ fmt.Fprintf(&b, "renamed to %s: %d edit(s) in %d file(s)\n", newName, total, len(changed))
155+ for _, ch := range changed {
156+ fmt.Fprintf(&b, "%s: %d edit(s)\n", c.relative(ch.path), ch.edits)
157+ }
158+ return strings.TrimRight(b.String(), "\n")
159+}
added internal/lsp/edit.go +167 -0
new file mode 100644
@@ -0,0 +1,167 @@
1+package lsp
2+
3+import (
4+ "fmt"
5+ "os"
6+ "sort"
7+ "strings"
8+ "unicode/utf16"
9+ "unicode/utf8"
10+
11+ "go.lsp.dev/protocol"
12+ "go.lsp.dev/uri"
13+)
14+
15+// LSP positions count UTF-16 code units, not bytes or runes: "é" is one
16+// unit, "𐐀" two. The two conversions below keep the agent's 1-based
17+// rune columns and the wire's units in step, so a rename in a file with
18+// accented comments still lands on the right characters.
19+
20+// utf16Column converts a 1-based rune column on a 1-based line of text into
21+// the wire's 0-based UTF-16 offset. A column past the end of the line is an
22+// error: the model miscounted.
23+func utf16Column(text string, line, col int) (uint32, error) {
24+ lines := strings.Split(text, "\n")
25+ if line > len(lines) {
26+ return 0, fmt.Errorf("line %d is past the end of the file (%d lines)", line, len(lines))
27+ }
28+ runes := []rune(lines[line-1])
29+ if col-1 > len(runes) {
30+ return 0, fmt.Errorf("column %d is past the end of line %d (%d characters)", col, line, len(runes))
31+ }
32+ units := 0
33+ for _, r := range runes[:col-1] {
34+ units += utf16.RuneLen(r)
35+ }
36+ return uint32(units), nil
37+}
38+
39+// byteOffset converts a wire position into a byte offset in text. A position
40+// past the end of a line clamps to the line's end, past the last line to the
41+// text's end — servers do send end-of-line positions.
42+func byteOffset(text string, pos protocol.Position) int {
43+ offset := 0
44+ for l := uint32(0); l < pos.Line; l++ {
45+ nl := strings.IndexByte(text[offset:], '\n')
46+ if nl < 0 {
47+ return len(text)
48+ }
49+ offset += nl + 1
50+ }
51+ units := uint32(0)
52+ for offset < len(text) && text[offset] != '\n' && units < pos.Character {
53+ r, size := utf8.DecodeRuneInString(text[offset:])
54+ units += uint32(utf16.RuneLen(r))
55+ offset += size
56+ }
57+ return offset
58+}
59+
60+// applyTextEdits applies a server's edits to text. Edits are applied from
61+// the end of the document backwards so the earlier positions stay valid.
62+func applyTextEdits(text string, edits []protocol.TextEdit) string {
63+ sorted := append([]protocol.TextEdit(nil), edits...)
64+ sort.SliceStable(sorted, func(i, j int) bool {
65+ a, b := sorted[i].Range.Start, sorted[j].Range.Start
66+ return a.Line > b.Line || (a.Line == b.Line && a.Character > b.Character)
67+ })
68+ for _, e := range sorted {
69+ start := byteOffset(text, e.Range.Start)
70+ end := byteOffset(text, e.Range.End)
71+ if end < start {
72+ end = start
73+ }
74+ text = text[:start] + e.NewText + text[end:]
75+ }
76+ return text
77+}
78+
79+// changedFile is one file a workspace edit rewrote.
80+type changedFile struct {
81+ path string
82+ edits int
83+}
84+
85+// applyWorkspaceEdit writes a server's edits to disk, file by file, and says
86+// which files changed. Both shapes are handled — the legacy `changes` map
87+// and `documentChanges`; file create/rename/delete operations, which no
88+// rename produces, are refused rather than guessed at.
89+func applyWorkspaceEdit(edit *protocol.WorkspaceEdit) ([]changedFile, error) {
90+ perFile, err := editsPerFile(edit)
91+ if err != nil {
92+ return nil, err
93+ }
94+ paths := make([]uri.URI, 0, len(perFile))
95+ for u := range perFile {
96+ paths = append(paths, u)
97+ }
98+ sort.Slice(paths, func(i, j int) bool { return paths[i] < paths[j] })
99+
100+ var out []changedFile
101+ for _, u := range paths {
102+ changed, err := rewriteFile(u.FsPath(), perFile[u])
103+ if err != nil {
104+ return nil, err
105+ }
106+ if changed {
107+ out = append(out, changedFile{path: u.FsPath(), edits: len(perFile[u])})
108+ }
109+ }
110+ return out, nil
111+}
112+
113+// editsPerFile gathers a workspace edit's text edits by file, from both
114+// shapes the protocol allows.
115+func editsPerFile(edit *protocol.WorkspaceEdit) (map[uri.URI][]protocol.TextEdit, error) {
116+ perFile := map[uri.URI][]protocol.TextEdit{}
117+ for u, edits := range edit.Changes {
118+ perFile[u] = append(perFile[u], edits...)
119+ }
120+ for _, dc := range edit.DocumentChanges {
121+ tde, ok := dc.(*protocol.TextDocumentEdit)
122+ if !ok {
123+ return nil, fmt.Errorf("the server asked for a file operation (%T) — not applied", dc)
124+ }
125+ for _, e := range tde.Edits {
126+ te, ok := textEditOf(e)
127+ if !ok {
128+ return nil, fmt.Errorf("unsupported edit shape %T — not applied", e)
129+ }
130+ perFile[tde.TextDocument.URI] = append(perFile[tde.TextDocument.URI], te)
131+ }
132+ }
133+ return perFile, nil
134+}
135+
136+// rewriteFile applies edits to one file on disk and says whether its
137+// content moved.
138+func rewriteFile(path string, edits []protocol.TextEdit) (bool, error) {
139+ if len(edits) == 0 {
140+ return false, nil
141+ }
142+ data, err := os.ReadFile(path)
143+ if err != nil {
144+ return false, err
145+ }
146+ updated := applyTextEdits(string(data), edits)
147+ if updated == string(data) {
148+ return false, nil
149+ }
150+ return true, os.WriteFile(path, []byte(updated), 0o644)
151+}
152+
153+// textEditOf reads the plain edit out of the TextDocumentEdit's union arms.
154+func textEditOf(e any) (protocol.TextEdit, bool) {
155+ switch v := e.(type) {
156+ case *protocol.TextEdit:
157+ return *v, v != nil
158+ case protocol.TextEdit:
159+ return v, true
160+ case *protocol.AnnotatedTextEdit:
161+ if v == nil {
162+ return protocol.TextEdit{}, false
163+ }
164+ return protocol.TextEdit{Range: v.Range, NewText: v.NewText}, true
165+ }
166+ return protocol.TextEdit{}, false
167+}
new file mode 100644
@@ -0,0 +1,167 @@
1+package lsp
2+
3+import (
4+ "fmt"
5+ "os"
6+ "sort"
7+ "strings"
8+ "unicode/utf16"
9+ "unicode/utf8"
10+
11+ "go.lsp.dev/protocol"
12+ "go.lsp.dev/uri"
13+)
14+
15+// LSP positions count UTF-16 code units, not bytes or runes: "é" is one
16+// unit, "𐐀" two. The two conversions below keep the agent's 1-based
17+// rune columns and the wire's units in step, so a rename in a file with
18+// accented comments still lands on the right characters.
19+
20+// utf16Column converts a 1-based rune column on a 1-based line of text into
21+// the wire's 0-based UTF-16 offset. A column past the end of the line is an
22+// error: the model miscounted.
23+func utf16Column(text string, line, col int) (uint32, error) {
24+ lines := strings.Split(text, "\n")
25+ if line > len(lines) {
26+ return 0, fmt.Errorf("line %d is past the end of the file (%d lines)", line, len(lines))
27+ }
28+ runes := []rune(lines[line-1])
29+ if col-1 > len(runes) {
30+ return 0, fmt.Errorf("column %d is past the end of line %d (%d characters)", col, line, len(runes))
31+ }
32+ units := 0
33+ for _, r := range runes[:col-1] {
34+ units += utf16.RuneLen(r)
35+ }
36+ return uint32(units), nil
37+}
38+
39+// byteOffset converts a wire position into a byte offset in text. A position
40+// past the end of a line clamps to the line's end, past the last line to the
41+// text's end — servers do send end-of-line positions.
42+func byteOffset(text string, pos protocol.Position) int {
43+ offset := 0
44+ for l := uint32(0); l < pos.Line; l++ {
45+ nl := strings.IndexByte(text[offset:], '\n')
46+ if nl < 0 {
47+ return len(text)
48+ }
49+ offset += nl + 1
50+ }
51+ units := uint32(0)
52+ for offset < len(text) && text[offset] != '\n' && units < pos.Character {
53+ r, size := utf8.DecodeRuneInString(text[offset:])
54+ units += uint32(utf16.RuneLen(r))
55+ offset += size
56+ }
57+ return offset
58+}
59+
60+// applyTextEdits applies a server's edits to text. Edits are applied from
61+// the end of the document backwards so the earlier positions stay valid.
62+func applyTextEdits(text string, edits []protocol.TextEdit) string {
63+ sorted := append([]protocol.TextEdit(nil), edits...)
64+ sort.SliceStable(sorted, func(i, j int) bool {
65+ a, b := sorted[i].Range.Start, sorted[j].Range.Start
66+ return a.Line > b.Line || (a.Line == b.Line && a.Character > b.Character)
67+ })
68+ for _, e := range sorted {
69+ start := byteOffset(text, e.Range.Start)
70+ end := byteOffset(text, e.Range.End)
71+ if end < start {
72+ end = start
73+ }
74+ text = text[:start] + e.NewText + text[end:]
75+ }
76+ return text
77+}
78+
79+// changedFile is one file a workspace edit rewrote.
80+type changedFile struct {
81+ path string
82+ edits int
83+}
84+
85+// applyWorkspaceEdit writes a server's edits to disk, file by file, and says
86+// which files changed. Both shapes are handled — the legacy `changes` map
87+// and `documentChanges`; file create/rename/delete operations, which no
88+// rename produces, are refused rather than guessed at.
89+func applyWorkspaceEdit(edit *protocol.WorkspaceEdit) ([]changedFile, error) {
90+ perFile, err := editsPerFile(edit)
91+ if err != nil {
92+ return nil, err
93+ }
94+ paths := make([]uri.URI, 0, len(perFile))
95+ for u := range perFile {
96+ paths = append(paths, u)
97+ }
98+ sort.Slice(paths, func(i, j int) bool { return paths[i] < paths[j] })
99+
100+ var out []changedFile
101+ for _, u := range paths {
102+ changed, err := rewriteFile(u.FsPath(), perFile[u])
103+ if err != nil {
104+ return nil, err
105+ }
106+ if changed {
107+ out = append(out, changedFile{path: u.FsPath(), edits: len(perFile[u])})
108+ }
109+ }
110+ return out, nil
111+}
112+
113+// editsPerFile gathers a workspace edit's text edits by file, from both
114+// shapes the protocol allows.
115+func editsPerFile(edit *protocol.WorkspaceEdit) (map[uri.URI][]protocol.TextEdit, error) {
116+ perFile := map[uri.URI][]protocol.TextEdit{}
117+ for u, edits := range edit.Changes {
118+ perFile[u] = append(perFile[u], edits...)
119+ }
120+ for _, dc := range edit.DocumentChanges {
121+ tde, ok := dc.(*protocol.TextDocumentEdit)
122+ if !ok {
123+ return nil, fmt.Errorf("the server asked for a file operation (%T) — not applied", dc)
124+ }
125+ for _, e := range tde.Edits {
126+ te, ok := textEditOf(e)
127+ if !ok {
128+ return nil, fmt.Errorf("unsupported edit shape %T — not applied", e)
129+ }
130+ perFile[tde.TextDocument.URI] = append(perFile[tde.TextDocument.URI], te)
131+ }
132+ }
133+ return perFile, nil
134+}
135+
136+// rewriteFile applies edits to one file on disk and says whether its
137+// content moved.
138+func rewriteFile(path string, edits []protocol.TextEdit) (bool, error) {
139+ if len(edits) == 0 {
140+ return false, nil
141+ }
142+ data, err := os.ReadFile(path)
143+ if err != nil {
144+ return false, err
145+ }
146+ updated := applyTextEdits(string(data), edits)
147+ if updated == string(data) {
148+ return false, nil
149+ }
150+ return true, os.WriteFile(path, []byte(updated), 0o644)
151+}
152+
153+// textEditOf reads the plain edit out of the TextDocumentEdit's union arms.
154+func textEditOf(e any) (protocol.TextEdit, bool) {
155+ switch v := e.(type) {
156+ case *protocol.TextEdit:
157+ return *v, v != nil
158+ case protocol.TextEdit:
159+ return v, true
160+ case *protocol.AnnotatedTextEdit:
161+ if v == nil {
162+ return protocol.TextEdit{}, false
163+ }
164+ return protocol.TextEdit{Range: v.Range, NewText: v.NewText}, true
165+ }
166+ return protocol.TextEdit{}, false
167+}
added internal/lsp/format.go +152 -0
new file mode 100644
@@ -0,0 +1,152 @@
1+package lsp
2+
3+// The answers' shape: how locations, hovers, symbols and diagnostics coming
4+// off the wire become the lines the model reads.
5+
6+import (
7+ "fmt"
8+ "os"
9+ "sort"
10+ "strings"
11+
12+ "go.lsp.dev/protocol"
13+)
14+
15+// locationsOf flattens the definition result's three shapes into locations.
16+func locationsOf(res protocol.DefinitionResult) []protocol.Location {
17+ switch v := res.(type) {
18+ case *protocol.Location:
19+ if v == nil {
20+ return nil
21+ }
22+ return []protocol.Location{*v}
23+ case protocol.LocationSlice:
24+ return []protocol.Location(v)
25+ case protocol.DefinitionLinkSlice:
26+ out := make([]protocol.Location, 0, len(v))
27+ for _, l := range v {
28+ out = append(out, protocol.Location{URI: l.TargetURI, Range: l.TargetSelectionRange})
29+ }
30+ return out
31+ }
32+ return nil
33+}
34+
35+// formatLocations renders locations as `path:line:col <the line>`, sorted
36+// by path then position, duplicates removed.
37+func (c *Client) formatLocations(locs []protocol.Location) string {
38+ sort.Slice(locs, func(i, j int) bool {
39+ if locs[i].URI != locs[j].URI {
40+ return locs[i].URI < locs[j].URI
41+ }
42+ a, b := locs[i].Range.Start, locs[j].Range.Start
43+ return a.Line < b.Line || (a.Line == b.Line && a.Character < b.Character)
44+ })
45+ var b strings.Builder
46+ seen := map[string]bool{}
47+ for _, l := range locs {
48+ abs := l.URI.FsPath()
49+ head := fmt.Sprintf("%s:%d:%d", c.relative(abs), l.Range.Start.Line+1, l.Range.Start.Character+1)
50+ if seen[head] {
51+ continue
52+ }
53+ seen[head] = true
54+ if src := lineOf(abs, int(l.Range.Start.Line)); src != "" {
55+ head += " " + src
56+ }
57+ b.WriteString(head + "\n")
58+ }
59+ return strings.TrimRight(b.String(), "\n")
60+}
61+
62+// lineOf is line n (0-based) of the file, trimmed, "" when unreadable.
63+func lineOf(abs string, n int) string {
64+ data, err := os.ReadFile(abs)
65+ if err != nil {
66+ return ""
67+ }
68+ lines := strings.Split(string(data), "\n")
69+ if n < 0 || n >= len(lines) {
70+ return ""
71+ }
72+ return strings.TrimSpace(lines[n])
73+}
74+
75+// hoverText flattens the hover's four content shapes into text.
76+func hoverText(c protocol.HoverContents) string {
77+ text := ""
78+ switch v := c.(type) {
79+ case *protocol.MarkupContent:
80+ if v != nil {
81+ text = v.Value
82+ }
83+ case protocol.String:
84+ text = string(v)
85+ case *protocol.MarkedStringWithLanguage:
86+ if v != nil {
87+ text = v.Value
88+ }
89+ case protocol.MarkedStringSlice:
90+ parts := make([]string, 0, len(v))
91+ for _, m := range v {
92+ parts = append(parts, markedString(m))
93+ }
94+ text = strings.Join(parts, "\n")
95+ }
96+ return text
97+}
98+
99+// markedString is one arm of the deprecated MarkedString union.
100+func markedString(m protocol.MarkedString) string {
101+ switch v := m.(type) {
102+ case protocol.String:
103+ return string(v)
104+ case *protocol.MarkedStringWithLanguage:
105+ if v != nil {
106+ return v.Value
107+ }
108+ }
109+ return ""
110+}
111+
112+// diagnosticMessage is the message, whichever shape the server used.
113+func diagnosticMessage(d protocol.Diagnostic) string {
114+ switch v := d.Message.(type) {
115+ case protocol.String:
116+ return string(v)
117+ case *protocol.MarkupContent:
118+ if v != nil {
119+ return v.Value
120+ }
121+ }
122+ return fmt.Sprint(d.Message)
123+}
124+
125+// severityName is the LSP severity as a word.
126+func severityName(s protocol.DiagnosticSeverity) string {
127+ switch s {
128+ case protocol.DiagnosticSeverityError:
129+ return "error"
130+ case protocol.DiagnosticSeverityWarning:
131+ return "warning"
132+ case protocol.DiagnosticSeverityInformation:
133+ return "info"
134+ case protocol.DiagnosticSeverityHint:
135+ return "hint"
136+ }
137+ return "note"
138+}
139+
140+// kindName is the LSP symbol kind as a word.
141+func kindName(k protocol.SymbolKind) string {
142+ names := map[protocol.SymbolKind]string{
143+ 1: "file", 2: "module", 3: "namespace", 4: "package", 5: "class", 6: "method", 7: "property",
144+ 8: "field", 9: "constructor", 10: "enum", 11: "interface", 12: "function", 13: "variable",
145+ 14: "constant", 15: "string", 16: "number", 17: "boolean", 18: "array", 19: "object", 20: "key",
146+ 21: "null", 22: "enum member", 23: "struct", 24: "event", 25: "operator", 26: "type parameter",
147+ }
148+ if n, ok := names[k]; ok {
149+ return n
150+ }
151+ return "symbol"
152+}
new file mode 100644
@@ -0,0 +1,152 @@
1+package lsp
2+
3+// The answers' shape: how locations, hovers, symbols and diagnostics coming
4+// off the wire become the lines the model reads.
5+
6+import (
7+ "fmt"
8+ "os"
9+ "sort"
10+ "strings"
11+
12+ "go.lsp.dev/protocol"
13+)
14+
15+// locationsOf flattens the definition result's three shapes into locations.
16+func locationsOf(res protocol.DefinitionResult) []protocol.Location {
17+ switch v := res.(type) {
18+ case *protocol.Location:
19+ if v == nil {
20+ return nil
21+ }
22+ return []protocol.Location{*v}
23+ case protocol.LocationSlice:
24+ return []protocol.Location(v)
25+ case protocol.DefinitionLinkSlice:
26+ out := make([]protocol.Location, 0, len(v))
27+ for _, l := range v {
28+ out = append(out, protocol.Location{URI: l.TargetURI, Range: l.TargetSelectionRange})
29+ }
30+ return out
31+ }
32+ return nil
33+}
34+
35+// formatLocations renders locations as `path:line:col <the line>`, sorted
36+// by path then position, duplicates removed.
37+func (c *Client) formatLocations(locs []protocol.Location) string {
38+ sort.Slice(locs, func(i, j int) bool {
39+ if locs[i].URI != locs[j].URI {
40+ return locs[i].URI < locs[j].URI
41+ }
42+ a, b := locs[i].Range.Start, locs[j].Range.Start
43+ return a.Line < b.Line || (a.Line == b.Line && a.Character < b.Character)
44+ })
45+ var b strings.Builder
46+ seen := map[string]bool{}
47+ for _, l := range locs {
48+ abs := l.URI.FsPath()
49+ head := fmt.Sprintf("%s:%d:%d", c.relative(abs), l.Range.Start.Line+1, l.Range.Start.Character+1)
50+ if seen[head] {
51+ continue
52+ }
53+ seen[head] = true
54+ if src := lineOf(abs, int(l.Range.Start.Line)); src != "" {
55+ head += " " + src
56+ }
57+ b.WriteString(head + "\n")
58+ }
59+ return strings.TrimRight(b.String(), "\n")
60+}
61+
62+// lineOf is line n (0-based) of the file, trimmed, "" when unreadable.
63+func lineOf(abs string, n int) string {
64+ data, err := os.ReadFile(abs)
65+ if err != nil {
66+ return ""
67+ }
68+ lines := strings.Split(string(data), "\n")
69+ if n < 0 || n >= len(lines) {
70+ return ""
71+ }
72+ return strings.TrimSpace(lines[n])
73+}
74+
75+// hoverText flattens the hover's four content shapes into text.
76+func hoverText(c protocol.HoverContents) string {
77+ text := ""
78+ switch v := c.(type) {
79+ case *protocol.MarkupContent:
80+ if v != nil {
81+ text = v.Value
82+ }
83+ case protocol.String:
84+ text = string(v)
85+ case *protocol.MarkedStringWithLanguage:
86+ if v != nil {
87+ text = v.Value
88+ }
89+ case protocol.MarkedStringSlice:
90+ parts := make([]string, 0, len(v))
91+ for _, m := range v {
92+ parts = append(parts, markedString(m))
93+ }
94+ text = strings.Join(parts, "\n")
95+ }
96+ return text
97+}
98+
99+// markedString is one arm of the deprecated MarkedString union.
100+func markedString(m protocol.MarkedString) string {
101+ switch v := m.(type) {
102+ case protocol.String:
103+ return string(v)
104+ case *protocol.MarkedStringWithLanguage:
105+ if v != nil {
106+ return v.Value
107+ }
108+ }
109+ return ""
110+}
111+
112+// diagnosticMessage is the message, whichever shape the server used.
113+func diagnosticMessage(d protocol.Diagnostic) string {
114+ switch v := d.Message.(type) {
115+ case protocol.String:
116+ return string(v)
117+ case *protocol.MarkupContent:
118+ if v != nil {
119+ return v.Value
120+ }
121+ }
122+ return fmt.Sprint(d.Message)
123+}
124+
125+// severityName is the LSP severity as a word.
126+func severityName(s protocol.DiagnosticSeverity) string {
127+ switch s {
128+ case protocol.DiagnosticSeverityError:
129+ return "error"
130+ case protocol.DiagnosticSeverityWarning:
131+ return "warning"
132+ case protocol.DiagnosticSeverityInformation:
133+ return "info"
134+ case protocol.DiagnosticSeverityHint:
135+ return "hint"
136+ }
137+ return "note"
138+}
139+
140+// kindName is the LSP symbol kind as a word.
141+func kindName(k protocol.SymbolKind) string {
142+ names := map[protocol.SymbolKind]string{
143+ 1: "file", 2: "module", 3: "namespace", 4: "package", 5: "class", 6: "method", 7: "property",
144+ 8: "field", 9: "constructor", 10: "enum", 11: "interface", 12: "function", 13: "variable",
145+ 14: "constant", 15: "string", 16: "number", 17: "boolean", 18: "array", 19: "object", 20: "key",
146+ 21: "null", 22: "enum member", 23: "struct", 24: "event", 25: "operator", 26: "type parameter",
147+ }
148+ if n, ok := names[k]; ok {
149+ return n
150+ }
151+ return "symbol"
152+}
added internal/lsp/locate.go +110 -0
new file mode 100644
@@ -0,0 +1,110 @@
1+package lsp
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+ "unicode"
8+)
9+
10+// Pos is a 1-based line and column in a file.
11+type Pos struct{ Line, Col int }
12+
13+// Locate finds the occurrences of an identifier in a file, as the 1-based
14+// positions the symbol requests need. With line > 0 only that line is
15+// searched; with 0 the whole file is. A match is a whole word — `hello`
16+// does not match `helloWorld` — and occurrences on comment lines come last:
17+// a name is usually mentioned in the comment above its declaration, and the
18+// server has nothing to say about a comment. The caller tries the positions
19+// in order until the server answers.
20+//
21+// Small models rarely count columns right, and often not at all; naming the
22+// identifier is the call they can make reliably, so the tool accepts it in
23+// place of the position.
24+//
25+// at, err := c.Locate("main.go", 0, "hello") // [{6 6} {9 14}]
26+func (c *Client) Locate(path string, line int, symbol string) ([]Pos, error) {
27+ symbol = strings.TrimSpace(symbol)
28+ switch {
29+ case symbol == "":
30+ return nil, fmt.Errorf("symbol is empty")
31+ case path == "":
32+ return nil, fmt.Errorf("path is required")
33+ }
34+ lines, from, to, err := linesToSearch(path, line)
35+ if err != nil {
36+ return nil, err
37+ }
38+ var code, comments []Pos
39+ for i := from; i < to; i++ {
40+ for _, col := range wordColumns(lines[i], symbol) {
41+ if isCommentLine(lines[i]) {
42+ comments = append(comments, Pos{i + 1, col})
43+ } else {
44+ code = append(code, Pos{i + 1, col})
45+ }
46+ }
47+ }
48+ found := append(code, comments...)
49+ if len(found) == 0 {
50+ err = fmt.Errorf("%q not found in %s", symbol, path)
51+ if line > 0 {
52+ err = fmt.Errorf("%q not found on line %d of %s", symbol, line, path)
53+ }
54+ }
55+ return found, err
56+}
57+
58+// linesToSearch reads the file and returns its lines with the [from, to)
59+// range to search: one line, or all of them.
60+func linesToSearch(path string, line int) (lines []string, from, to int, err error) {
61+ data, err := os.ReadFile(path)
62+ if err != nil {
63+ return nil, 0, 0, err
64+ }
65+ lines = strings.Split(string(data), "\n")
66+ if line > len(lines) {
67+ return nil, 0, 0, fmt.Errorf("line %d is past the end of the file (%d lines)", line, len(lines))
68+ }
69+ if line > 0 {
70+ return lines, line - 1, line, nil
71+ }
72+ return lines, 0, len(lines), nil
73+}
74+
75+// wordColumns lists the 1-based rune columns of every whole-word occurrence
76+// of word in line.
77+func wordColumns(line, word string) []int {
78+ var out []int
79+ runes, target := []rune(line), []rune(word)
80+ for i := 0; i+len(target) <= len(runes); i++ {
81+ if string(runes[i:i+len(target)]) != word {
82+ continue
83+ }
84+ before := i == 0 || !isWordRune(runes[i-1])
85+ after := i+len(target) == len(runes) || !isWordRune(runes[i+len(target)])
86+ if before && after {
87+ out = append(out, i+1)
88+ }
89+ }
90+ return out
91+}
92+
93+// commentMarkers open a line comment in the usual languages: C-family,
94+// shell/Python/YAML, block-comment bodies, SQL/Lua, Lisp/assembly.
95+var commentMarkers = []string{"//", "#", "/*", "*", "--", ";"}
96+
97+// isCommentLine recognises a line that is only a comment. A trailing comment
98+// on a code line is not seen — the code occurrence on that line is tried
99+// first anyway.
100+func isCommentLine(line string) bool {
101+ t := strings.TrimSpace(line)
102+ for _, m := range commentMarkers {
103+ if strings.HasPrefix(t, m) {
104+ return true
105+ }
106+ }
107+ return false
108+}
109+
110+func isWordRune(r rune) bool { return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) }
new file mode 100644
@@ -0,0 +1,110 @@
1+package lsp
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+ "unicode"
8+)
9+
10+// Pos is a 1-based line and column in a file.
11+type Pos struct{ Line, Col int }
12+
13+// Locate finds the occurrences of an identifier in a file, as the 1-based
14+// positions the symbol requests need. With line > 0 only that line is
15+// searched; with 0 the whole file is. A match is a whole word — `hello`
16+// does not match `helloWorld` — and occurrences on comment lines come last:
17+// a name is usually mentioned in the comment above its declaration, and the
18+// server has nothing to say about a comment. The caller tries the positions
19+// in order until the server answers.
20+//
21+// Small models rarely count columns right, and often not at all; naming the
22+// identifier is the call they can make reliably, so the tool accepts it in
23+// place of the position.
24+//
25+// at, err := c.Locate("main.go", 0, "hello") // [{6 6} {9 14}]
26+func (c *Client) Locate(path string, line int, symbol string) ([]Pos, error) {
27+ symbol = strings.TrimSpace(symbol)
28+ switch {
29+ case symbol == "":
30+ return nil, fmt.Errorf("symbol is empty")
31+ case path == "":
32+ return nil, fmt.Errorf("path is required")
33+ }
34+ lines, from, to, err := linesToSearch(path, line)
35+ if err != nil {
36+ return nil, err
37+ }
38+ var code, comments []Pos
39+ for i := from; i < to; i++ {
40+ for _, col := range wordColumns(lines[i], symbol) {
41+ if isCommentLine(lines[i]) {
42+ comments = append(comments, Pos{i + 1, col})
43+ } else {
44+ code = append(code, Pos{i + 1, col})
45+ }
46+ }
47+ }
48+ found := append(code, comments...)
49+ if len(found) == 0 {
50+ err = fmt.Errorf("%q not found in %s", symbol, path)
51+ if line > 0 {
52+ err = fmt.Errorf("%q not found on line %d of %s", symbol, line, path)
53+ }
54+ }
55+ return found, err
56+}
57+
58+// linesToSearch reads the file and returns its lines with the [from, to)
59+// range to search: one line, or all of them.
60+func linesToSearch(path string, line int) (lines []string, from, to int, err error) {
61+ data, err := os.ReadFile(path)
62+ if err != nil {
63+ return nil, 0, 0, err
64+ }
65+ lines = strings.Split(string(data), "\n")
66+ if line > len(lines) {
67+ return nil, 0, 0, fmt.Errorf("line %d is past the end of the file (%d lines)", line, len(lines))
68+ }
69+ if line > 0 {
70+ return lines, line - 1, line, nil
71+ }
72+ return lines, 0, len(lines), nil
73+}
74+
75+// wordColumns lists the 1-based rune columns of every whole-word occurrence
76+// of word in line.
77+func wordColumns(line, word string) []int {
78+ var out []int
79+ runes, target := []rune(line), []rune(word)
80+ for i := 0; i+len(target) <= len(runes); i++ {
81+ if string(runes[i:i+len(target)]) != word {
82+ continue
83+ }
84+ before := i == 0 || !isWordRune(runes[i-1])
85+ after := i+len(target) == len(runes) || !isWordRune(runes[i+len(target)])
86+ if before && after {
87+ out = append(out, i+1)
88+ }
89+ }
90+ return out
91+}
92+
93+// commentMarkers open a line comment in the usual languages: C-family,
94+// shell/Python/YAML, block-comment bodies, SQL/Lua, Lisp/assembly.
95+var commentMarkers = []string{"//", "#", "/*", "*", "--", ";"}
96+
97+// isCommentLine recognises a line that is only a comment. A trailing comment
98+// on a code line is not seen — the code occurrence on that line is tried
99+// first anyway.
100+func isCommentLine(line string) bool {
101+ t := strings.TrimSpace(line)
102+ for _, m := range commentMarkers {
103+ if strings.HasPrefix(t, m) {
104+ return true
105+ }
106+ }
107+ return false
108+}
109+
110+func isWordRune(r rune) bool { return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) }
added internal/lsp/lsp.go +418 -0
new file mode 100644
@@ -0,0 +1,418 @@
1+// Package lsp lets the agent ask a language server what a compiler knows:
2+// where a symbol is defined, who uses it, what its type is, what is wrong in
3+// a file, and how to rename it everywhere. One Client serves a project; it
4+// starts each configured server (config `lsp.servers`) on its first use, keeps
5+// it for the session, and stops it on Close.
6+//
7+// Positions are 1-based lines and columns at this package's boundary — what
8+// `read_file numbered=true` shows — and 0-based on the wire. Every answer is
9+// plain text shaped for the model: `path:line:col <the line>` per location,
10+// so the model never has to open a file to know what a hit is.
11+//
12+// c := lsp.New(root, config.Cfg.LSP.Servers)
13+// defer c.Close()
14+// text, err := c.Definition(ctx, "internal/tools/tools.go", 120, 8)
15+package lsp
16+
17+import (
18+ "bytes"
19+ "context"
20+ "errors"
21+ "fmt"
22+ "io"
23+ "os"
24+ "os/exec"
25+ "path/filepath"
26+ "strings"
27+ "sync"
28+ "time"
29+
30+ "mm/internal/config"
31+
32+ "go.lsp.dev/jsonrpc2"
33+ "go.lsp.dev/protocol"
34+ "go.lsp.dev/uri"
35+)
36+
37+// Launcher starts a server process and returns its stdio as one stream plus
38+// a function that ends the process. Tests inject an in-memory fake.
39+type Launcher func(ctx context.Context, s config.LSPServer, root string) (io.ReadWriteCloser, func() error, error)
40+
41+// Client is one project's side of the language servers: which servers exist,
42+// which of them run, and the documents they have been told about.
43+type Client struct {
44+ root string
45+ servers []config.LSPServer
46+ launch Launcher
47+
48+ // diagWait bounds how long Diagnostics waits for the server's push
49+ // after a file was synced; a server that never publishes still answers.
50+ diagWait time.Duration
51+ // handshake bounds initialize/initialized on start.
52+ handshake time.Duration
53+
54+ mu sync.Mutex
55+ running map[string]*session
56+}
57+
58+// New returns a client for the project at root — the terminal's start
59+// directory, or the ACP session's cwd — over the given servers. Nothing is
60+// started here.
61+func New(root string, servers []config.LSPServer) *Client {
62+ return &Client{
63+ root: root,
64+ servers: servers,
65+ launch: launchProcess,
66+ diagWait: 3 * time.Second,
67+ handshake: 15 * time.Second,
68+ running: map[string]*session{},
69+ }
70+}
71+
72+// Available splits the configured servers into those whose command is on
73+// the PATH and the names of those that are not — the banner warns about the
74+// second group and the tool is built on the first.
75+//
76+// ok, missing := lsp.Available(config.Cfg.LSP.Servers)
77+func Available(servers []config.LSPServer) (ok []config.LSPServer, missing []string) {
78+ for _, s := range servers {
79+ if _, err := exec.LookPath(s.Command); err != nil {
80+ missing = append(missing, fmt.Sprintf("%s (%s)", s.Name, s.Command))
81+ continue
82+ }
83+ ok = append(ok, s)
84+ }
85+ return ok, missing
86+}
87+
88+// Describe lists the servers and their extensions in one line, for the tool
89+// description and the banner: "gopls (.go), typescript (.ts, .tsx)".
90+func Describe(servers []config.LSPServer) string {
91+ parts := make([]string, 0, len(servers))
92+ for _, s := range servers {
93+ parts = append(parts, fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Extensions, ", ")))
94+ }
95+ return strings.Join(parts, ", ")
96+}
97+
98+// Root is the project directory the servers were told about.
99+func (c *Client) Root() string { return c.root }
100+
101+// Servers is the configured list, as given to New.
102+func (c *Client) Servers() []config.LSPServer { return c.servers }
103+
104+// Close stops every running server: shutdown, exit, then the process. Safe
105+// to call twice and on a Client that started nothing.
106+func (c *Client) Close() {
107+ c.mu.Lock()
108+ defer c.mu.Unlock()
109+ for name, s := range c.running {
110+ s.stop()
111+ delete(c.running, name)
112+ }
113+}
114+
115+// serverFor picks the server by the file's extension and starts it if it is
116+// not running. The error names the extensions that ARE served, so the model
117+// can tell a wrong path from a missing server.
118+func (c *Client) serverFor(ctx context.Context, path string) (*session, error) {
119+ var picked *config.LSPServer
120+ for i := range c.servers {
121+ if c.servers[i].Serves(path) {
122+ picked = &c.servers[i]
123+ break
124+ }
125+ }
126+ if picked == nil {
127+ return nil, fmt.Errorf("no language server for %q — configured: %s", path, Describe(c.servers))
128+ }
129+ c.mu.Lock()
130+ defer c.mu.Unlock()
131+ if s, ok := c.running[picked.Name]; ok {
132+ if s.alive() {
133+ return s, nil
134+ }
135+ delete(c.running, picked.Name) // it died; start it again below
136+ }
137+ s, err := c.start(ctx, *picked)
138+ if err != nil {
139+ return nil, err
140+ }
141+ c.running[picked.Name] = s
142+ return s, nil
143+}
144+
145+// session is one running server: its connection, what it announced, and
146+// the documents it holds.
147+type session struct {
148+ name string
149+ root string
150+ conn jsonrpc2.Conn
151+ server protocol.Server
152+ caps protocol.ServerCapabilities
153+ end func() error // ends the process
154+
155+ mu sync.Mutex
156+ docs map[uri.URI]*document
157+
158+ // diagnostics as last published per file; seq counts publications so a
159+ // waiter can tell a fresh one from what was there before its sync.
160+ diagMu sync.Mutex
161+ diags map[uri.URI][]protocol.Diagnostic
162+ seq map[uri.URI]int
163+ wake chan struct{}
164+}
165+
166+// document is a file the server was told about.
167+type document struct {
168+ version int32
169+ text string
170+}
171+
172+// start launches the process and runs the LSP handshake.
173+func (c *Client) start(ctx context.Context, cfg config.LSPServer) (*session, error) {
174+ rwc, end, err := c.launch(context.Background(), cfg, c.root)
175+ if err != nil {
176+ return nil, fmt.Errorf("start %s: %w", cfg.Name, err)
177+ }
178+ s := &session{
179+ name: cfg.Name,
180+ root: c.root,
181+ end: end,
182+ docs: map[uri.URI]*document{},
183+ diags: map[uri.URI][]protocol.Diagnostic{},
184+ seq: map[uri.URI]int{},
185+ wake: make(chan struct{}, 1),
186+ }
187+ _, conn, server := protocol.NewClient(context.Background(), &receiver{s: s}, jsonrpc2.NewStream(rwc))
188+ s.conn, s.server = conn, server
189+
190+ hctx, cancel := context.WithTimeout(ctx, c.handshake)
191+ defer cancel()
192+ res, err := server.Initialize(hctx, c.initializeParams())
193+ if err != nil {
194+ s.stop()
195+ return nil, fmt.Errorf("%s: initialize failed: %w", cfg.Name, err)
196+ }
197+ s.caps = res.Capabilities
198+ if err := server.Initialized(hctx, &protocol.InitializedParams{}); err != nil {
199+ s.stop()
200+ return nil, fmt.Errorf("%s: initialized notification failed: %w", cfg.Name, err)
201+ }
202+ return s, nil
203+}
204+
205+// initializeParams is what the agent tells a server about itself: the
206+// project root as the one workspace folder, and no fancy capabilities — the
207+// six requests it makes are in the LSP baseline.
208+func (c *Client) initializeParams() *protocol.InitializeParams {
209+ pid := int32(os.Getpid())
210+ root := uri.File(c.root)
211+ p := &protocol.InitializeParams{
212+ ProcessID: &pid,
213+ ClientInfo: protocol.ClientInfo{Name: "mm"},
214+ RootURI: &root,
215+ Capabilities: protocol.ClientCapabilities{},
216+ }
217+ p.WorkspaceFolders = protocol.NewNullable([]protocol.WorkspaceFolder{{URI: root, Name: filepath.Base(c.root)}})
218+ return p
219+}
220+
221+// alive reports whether the connection still runs.
222+func (s *session) alive() bool {
223+ select {
224+ case <-s.conn.Done():
225+ return false
226+ default:
227+ return true
228+ }
229+}
230+
231+// stop ends the server politely, then for real. Errors are ignored: the
232+// server may already be gone, and nothing can be done about it here.
233+func (s *session) stop() {
234+ if s.alive() {
235+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
236+ _ = s.server.Shutdown(ctx)
237+ _ = s.server.Exit(ctx)
238+ cancel()
239+ _ = s.conn.Close()
240+ }
241+ if s.end != nil {
242+ _ = s.end()
243+ }
244+}
245+
246+// sync tells the server the file's current content: didOpen the first time,
247+// didChange (whole document) after — unless the text has not moved since
248+// the last sync. The model edits files with edit_file, not through the
249+// server, so what is on disk is the truth and is re-read every time.
250+func (s *session) sync(ctx context.Context, path string) (uri.URI, error) {
251+ abs, err := filepath.Abs(path)
252+ if err != nil {
253+ return "", err
254+ }
255+ data, err := os.ReadFile(abs)
256+ if err != nil {
257+ return "", err
258+ }
259+ text := string(data)
260+ u := uri.File(abs)
261+ s.mu.Lock()
262+ defer s.mu.Unlock()
263+ doc, opened := s.docs[u]
264+ switch {
265+ case !opened:
266+ s.docs[u] = &document{version: 1, text: text}
267+ return u, s.server.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{TextDocument: protocol.TextDocumentItem{
268+ URI: u, LanguageID: languageID(abs), Version: 1, Text: text,
269+ }})
270+ case doc.text == text:
271+ return u, nil
272+ }
273+ doc.version++
274+ doc.text = text
275+ return u, s.server.DidChange(ctx, &protocol.DidChangeTextDocumentParams{
276+ TextDocument: protocol.VersionedTextDocumentIdentifier{TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: u}, Version: doc.version},
277+ ContentChanges: []protocol.TextDocumentContentChangeEvent{&protocol.TextDocumentContentChangeWholeDocument{Text: text}},
278+ })
279+}
280+
281+// languageID is the LSP language identifier a server expects in didOpen.
282+// The common ones are spelled out; anything else is the extension, which
283+// most servers accept for the languages they own.
284+func languageID(path string) protocol.LanguageKind {
285+ ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), "."))
286+ known := map[string]string{
287+ "go": "go", "ts": "typescript", "tsx": "typescriptreact", "js": "javascript", "jsx": "javascriptreact",
288+ "py": "python", "rs": "rust", "c": "c", "h": "c", "cc": "cpp", "cpp": "cpp", "hpp": "cpp",
289+ "java": "java", "rb": "ruby", "php": "php", "cs": "csharp", "kt": "kotlin", "swift": "swift",
290+ "lua": "lua", "zig": "zig", "sh": "shellscript", "yaml": "yaml", "yml": "yaml", "json": "json", "md": "markdown",
291+ }
292+ if id, ok := known[ext]; ok {
293+ return protocol.LanguageKind(id)
294+ }
295+ return protocol.LanguageKind(ext)
296+}
297+
298+// receiver is the LSP client the server talks to. It keeps the diagnostics
299+// and ignores the rest: the agent has no window to show a message in.
300+type receiver struct {
301+ protocol.UnimplementedClient
302+ s *session
303+}
304+
305+// PublishDiagnostics stores the server's findings for a file and wakes a
306+// Diagnostics call waiting for them.
307+func (r *receiver) PublishDiagnostics(_ context.Context, p *protocol.PublishDiagnosticsParams) error {
308+ r.s.diagMu.Lock()
309+ r.s.diags[p.URI] = p.Diagnostics
310+ r.s.seq[p.URI]++
311+ r.s.diagMu.Unlock()
312+ select {
313+ case r.s.wake <- struct{}{}:
314+ default:
315+ }
316+ return nil
317+}
318+
319+// Configuration answers "no settings" for whatever section a server asks
320+// about, so servers that require an answer (gopls does) go on with their
321+// defaults instead of stalling.
322+func (r *receiver) Configuration(_ context.Context, p *protocol.ConfigurationParams) ([]protocol.LSPAny, error) {
323+ out := make([]protocol.LSPAny, len(p.Items))
324+ return out, nil
325+}
326+
327+// WorkspaceFolders is the one folder given at initialize.
328+func (r *receiver) WorkspaceFolders(context.Context) ([]protocol.WorkspaceFolder, error) {
329+ root := uri.File(r.s.root)
330+ return []protocol.WorkspaceFolder{{URI: root, Name: filepath.Base(r.s.root)}}, nil
331+}
332+
333+// RegisterCapability and its opposite are accepted silently: dynamic
334+// registration changes nothing for a client that asks the same six things.
335+func (r *receiver) RegisterCapability(context.Context, *protocol.RegistrationParams) error {
336+ return nil
337+}
338+
339+// UnregisterCapability: see RegisterCapability.
340+func (r *receiver) UnregisterCapability(context.Context, *protocol.UnregistrationParams) error {
341+ return nil
342+}
343+
344+// ShowMessageRequest picks nothing: there is no user at this end of the
345+// wire to choose.
346+func (r *receiver) ShowMessageRequest(context.Context, *protocol.ShowMessageRequestParams) (*protocol.MessageActionItem, error) {
347+ return nil, nil
348+}
349+
350+// WorkDoneProgressCreate accepts the token; the progress that follows is
351+// dropped by UnimplementedClient.
352+func (r *receiver) WorkDoneProgressCreate(context.Context, *protocol.WorkDoneProgressCreateParams) error {
353+ return nil
354+}
355+
356+// --- process launcher -----------------------------------------------------------
357+
358+// stdio joins a process's stdin and stdout into one stream.
359+type stdio struct {
360+ io.Reader
361+ io.WriteCloser
362+}
363+
364+// launchProcess is the real Launcher: the command with its arguments, in
365+// the project root, stderr kept aside for the failure message.
366+func launchProcess(ctx context.Context, s config.LSPServer, root string) (io.ReadWriteCloser, func() error, error) {
367+ cmd := exec.CommandContext(ctx, s.Command, s.Args...)
368+ cmd.Dir = root
369+ var stderr bytes.Buffer
370+ cmd.Stderr = &tail{buf: &stderr}
371+ stdin, stdout, err := startWithPipes(cmd)
372+ if err != nil {
373+ return nil, nil, err
374+ }
375+ end := func() error {
376+ _ = stdin.Close()
377+ done := make(chan error, 1)
378+ go func() { done <- cmd.Wait() }()
379+ select {
380+ case err := <-done:
381+ return err
382+ case <-time.After(2 * time.Second):
383+ _ = cmd.Process.Kill()
384+ return errors.New("killed: " + strings.TrimSpace(stderr.String()))
385+ }
386+ }
387+ return &stdio{Reader: stdout, WriteCloser: stdin}, end, nil
388+}
389+
390+// startWithPipes starts the command with its stdin and stdout piped.
391+func startWithPipes(cmd *exec.Cmd) (io.WriteCloser, io.Reader, error) {
392+ stdin, err := cmd.StdinPipe()
393+ if err != nil {
394+ return nil, nil, err
395+ }
396+ stdout, err := cmd.StdoutPipe()
397+ if err != nil {
398+ return nil, nil, err
399+ }
400+ return stdin, stdout, cmd.Start()
401+}
402+
403+// tail keeps the last few kilobytes written to it: a server's stderr can be
404+// verbose, and only its end explains a crash.
405+type tail struct {
406+ buf *bytes.Buffer
407+}
408+
409+const tailKeep = 4096
410+
411+func (t *tail) Write(p []byte) (int, error) {
412+ t.buf.Write(p)
413+ if t.buf.Len() > tailKeep {
414+ rest := t.buf.Bytes()[t.buf.Len()-tailKeep:]
415+ t.buf = bytes.NewBuffer(append([]byte(nil), rest...))
416+ }
417+ return len(p), nil
418+}
new file mode 100644
@@ -0,0 +1,418 @@
1+// Package lsp lets the agent ask a language server what a compiler knows:
2+// where a symbol is defined, who uses it, what its type is, what is wrong in
3+// a file, and how to rename it everywhere. One Client serves a project; it
4+// starts each configured server (config `lsp.servers`) on its first use, keeps
5+// it for the session, and stops it on Close.
6+//
7+// Positions are 1-based lines and columns at this package's boundary — what
8+// `read_file numbered=true` shows — and 0-based on the wire. Every answer is
9+// plain text shaped for the model: `path:line:col <the line>` per location,
10+// so the model never has to open a file to know what a hit is.
11+//
12+// c := lsp.New(root, config.Cfg.LSP.Servers)
13+// defer c.Close()
14+// text, err := c.Definition(ctx, "internal/tools/tools.go", 120, 8)
15+package lsp
16+
17+import (
18+ "bytes"
19+ "context"
20+ "errors"
21+ "fmt"
22+ "io"
23+ "os"
24+ "os/exec"
25+ "path/filepath"
26+ "strings"
27+ "sync"
28+ "time"
29+
30+ "mm/internal/config"
31+
32+ "go.lsp.dev/jsonrpc2"
33+ "go.lsp.dev/protocol"
34+ "go.lsp.dev/uri"
35+)
36+
37+// Launcher starts a server process and returns its stdio as one stream plus
38+// a function that ends the process. Tests inject an in-memory fake.
39+type Launcher func(ctx context.Context, s config.LSPServer, root string) (io.ReadWriteCloser, func() error, error)
40+
41+// Client is one project's side of the language servers: which servers exist,
42+// which of them run, and the documents they have been told about.
43+type Client struct {
44+ root string
45+ servers []config.LSPServer
46+ launch Launcher
47+
48+ // diagWait bounds how long Diagnostics waits for the server's push
49+ // after a file was synced; a server that never publishes still answers.
50+ diagWait time.Duration
51+ // handshake bounds initialize/initialized on start.
52+ handshake time.Duration
53+
54+ mu sync.Mutex
55+ running map[string]*session
56+}
57+
58+// New returns a client for the project at root — the terminal's start
59+// directory, or the ACP session's cwd — over the given servers. Nothing is
60+// started here.
61+func New(root string, servers []config.LSPServer) *Client {
62+ return &Client{
63+ root: root,
64+ servers: servers,
65+ launch: launchProcess,
66+ diagWait: 3 * time.Second,
67+ handshake: 15 * time.Second,
68+ running: map[string]*session{},
69+ }
70+}
71+
72+// Available splits the configured servers into those whose command is on
73+// the PATH and the names of those that are not — the banner warns about the
74+// second group and the tool is built on the first.
75+//
76+// ok, missing := lsp.Available(config.Cfg.LSP.Servers)
77+func Available(servers []config.LSPServer) (ok []config.LSPServer, missing []string) {
78+ for _, s := range servers {
79+ if _, err := exec.LookPath(s.Command); err != nil {
80+ missing = append(missing, fmt.Sprintf("%s (%s)", s.Name, s.Command))
81+ continue
82+ }
83+ ok = append(ok, s)
84+ }
85+ return ok, missing
86+}
87+
88+// Describe lists the servers and their extensions in one line, for the tool
89+// description and the banner: "gopls (.go), typescript (.ts, .tsx)".
90+func Describe(servers []config.LSPServer) string {
91+ parts := make([]string, 0, len(servers))
92+ for _, s := range servers {
93+ parts = append(parts, fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Extensions, ", ")))
94+ }
95+ return strings.Join(parts, ", ")
96+}
97+
98+// Root is the project directory the servers were told about.
99+func (c *Client) Root() string { return c.root }
100+
101+// Servers is the configured list, as given to New.
102+func (c *Client) Servers() []config.LSPServer { return c.servers }
103+
104+// Close stops every running server: shutdown, exit, then the process. Safe
105+// to call twice and on a Client that started nothing.
106+func (c *Client) Close() {
107+ c.mu.Lock()
108+ defer c.mu.Unlock()
109+ for name, s := range c.running {
110+ s.stop()
111+ delete(c.running, name)
112+ }
113+}
114+
115+// serverFor picks the server by the file's extension and starts it if it is
116+// not running. The error names the extensions that ARE served, so the model
117+// can tell a wrong path from a missing server.
118+func (c *Client) serverFor(ctx context.Context, path string) (*session, error) {
119+ var picked *config.LSPServer
120+ for i := range c.servers {
121+ if c.servers[i].Serves(path) {
122+ picked = &c.servers[i]
123+ break
124+ }
125+ }
126+ if picked == nil {
127+ return nil, fmt.Errorf("no language server for %q — configured: %s", path, Describe(c.servers))
128+ }
129+ c.mu.Lock()
130+ defer c.mu.Unlock()
131+ if s, ok := c.running[picked.Name]; ok {
132+ if s.alive() {
133+ return s, nil
134+ }
135+ delete(c.running, picked.Name) // it died; start it again below
136+ }
137+ s, err := c.start(ctx, *picked)
138+ if err != nil {
139+ return nil, err
140+ }
141+ c.running[picked.Name] = s
142+ return s, nil
143+}
144+
145+// session is one running server: its connection, what it announced, and
146+// the documents it holds.
147+type session struct {
148+ name string
149+ root string
150+ conn jsonrpc2.Conn
151+ server protocol.Server
152+ caps protocol.ServerCapabilities
153+ end func() error // ends the process
154+
155+ mu sync.Mutex
156+ docs map[uri.URI]*document
157+
158+ // diagnostics as last published per file; seq counts publications so a
159+ // waiter can tell a fresh one from what was there before its sync.
160+ diagMu sync.Mutex
161+ diags map[uri.URI][]protocol.Diagnostic
162+ seq map[uri.URI]int
163+ wake chan struct{}
164+}
165+
166+// document is a file the server was told about.
167+type document struct {
168+ version int32
169+ text string
170+}
171+
172+// start launches the process and runs the LSP handshake.
173+func (c *Client) start(ctx context.Context, cfg config.LSPServer) (*session, error) {
174+ rwc, end, err := c.launch(context.Background(), cfg, c.root)
175+ if err != nil {
176+ return nil, fmt.Errorf("start %s: %w", cfg.Name, err)
177+ }
178+ s := &session{
179+ name: cfg.Name,
180+ root: c.root,
181+ end: end,
182+ docs: map[uri.URI]*document{},
183+ diags: map[uri.URI][]protocol.Diagnostic{},
184+ seq: map[uri.URI]int{},
185+ wake: make(chan struct{}, 1),
186+ }
187+ _, conn, server := protocol.NewClient(context.Background(), &receiver{s: s}, jsonrpc2.NewStream(rwc))
188+ s.conn, s.server = conn, server
189+
190+ hctx, cancel := context.WithTimeout(ctx, c.handshake)
191+ defer cancel()
192+ res, err := server.Initialize(hctx, c.initializeParams())
193+ if err != nil {
194+ s.stop()
195+ return nil, fmt.Errorf("%s: initialize failed: %w", cfg.Name, err)
196+ }
197+ s.caps = res.Capabilities
198+ if err := server.Initialized(hctx, &protocol.InitializedParams{}); err != nil {
199+ s.stop()
200+ return nil, fmt.Errorf("%s: initialized notification failed: %w", cfg.Name, err)
201+ }
202+ return s, nil
203+}
204+
205+// initializeParams is what the agent tells a server about itself: the
206+// project root as the one workspace folder, and no fancy capabilities — the
207+// six requests it makes are in the LSP baseline.
208+func (c *Client) initializeParams() *protocol.InitializeParams {
209+ pid := int32(os.Getpid())
210+ root := uri.File(c.root)
211+ p := &protocol.InitializeParams{
212+ ProcessID: &pid,
213+ ClientInfo: protocol.ClientInfo{Name: "mm"},
214+ RootURI: &root,
215+ Capabilities: protocol.ClientCapabilities{},
216+ }
217+ p.WorkspaceFolders = protocol.NewNullable([]protocol.WorkspaceFolder{{URI: root, Name: filepath.Base(c.root)}})
218+ return p
219+}
220+
221+// alive reports whether the connection still runs.
222+func (s *session) alive() bool {
223+ select {
224+ case <-s.conn.Done():
225+ return false
226+ default:
227+ return true
228+ }
229+}
230+
231+// stop ends the server politely, then for real. Errors are ignored: the
232+// server may already be gone, and nothing can be done about it here.
233+func (s *session) stop() {
234+ if s.alive() {
235+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
236+ _ = s.server.Shutdown(ctx)
237+ _ = s.server.Exit(ctx)
238+ cancel()
239+ _ = s.conn.Close()
240+ }
241+ if s.end != nil {
242+ _ = s.end()
243+ }
244+}
245+
246+// sync tells the server the file's current content: didOpen the first time,
247+// didChange (whole document) after — unless the text has not moved since
248+// the last sync. The model edits files with edit_file, not through the
249+// server, so what is on disk is the truth and is re-read every time.
250+func (s *session) sync(ctx context.Context, path string) (uri.URI, error) {
251+ abs, err := filepath.Abs(path)
252+ if err != nil {
253+ return "", err
254+ }
255+ data, err := os.ReadFile(abs)
256+ if err != nil {
257+ return "", err
258+ }
259+ text := string(data)
260+ u := uri.File(abs)
261+ s.mu.Lock()
262+ defer s.mu.Unlock()
263+ doc, opened := s.docs[u]
264+ switch {
265+ case !opened:
266+ s.docs[u] = &document{version: 1, text: text}
267+ return u, s.server.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{TextDocument: protocol.TextDocumentItem{
268+ URI: u, LanguageID: languageID(abs), Version: 1, Text: text,
269+ }})
270+ case doc.text == text:
271+ return u, nil
272+ }
273+ doc.version++
274+ doc.text = text
275+ return u, s.server.DidChange(ctx, &protocol.DidChangeTextDocumentParams{
276+ TextDocument: protocol.VersionedTextDocumentIdentifier{TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: u}, Version: doc.version},
277+ ContentChanges: []protocol.TextDocumentContentChangeEvent{&protocol.TextDocumentContentChangeWholeDocument{Text: text}},
278+ })
279+}
280+
281+// languageID is the LSP language identifier a server expects in didOpen.
282+// The common ones are spelled out; anything else is the extension, which
283+// most servers accept for the languages they own.
284+func languageID(path string) protocol.LanguageKind {
285+ ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), "."))
286+ known := map[string]string{
287+ "go": "go", "ts": "typescript", "tsx": "typescriptreact", "js": "javascript", "jsx": "javascriptreact",
288+ "py": "python", "rs": "rust", "c": "c", "h": "c", "cc": "cpp", "cpp": "cpp", "hpp": "cpp",
289+ "java": "java", "rb": "ruby", "php": "php", "cs": "csharp", "kt": "kotlin", "swift": "swift",
290+ "lua": "lua", "zig": "zig", "sh": "shellscript", "yaml": "yaml", "yml": "yaml", "json": "json", "md": "markdown",
291+ }
292+ if id, ok := known[ext]; ok {
293+ return protocol.LanguageKind(id)
294+ }
295+ return protocol.LanguageKind(ext)
296+}
297+
298+// receiver is the LSP client the server talks to. It keeps the diagnostics
299+// and ignores the rest: the agent has no window to show a message in.
300+type receiver struct {
301+ protocol.UnimplementedClient
302+ s *session
303+}
304+
305+// PublishDiagnostics stores the server's findings for a file and wakes a
306+// Diagnostics call waiting for them.
307+func (r *receiver) PublishDiagnostics(_ context.Context, p *protocol.PublishDiagnosticsParams) error {
308+ r.s.diagMu.Lock()
309+ r.s.diags[p.URI] = p.Diagnostics
310+ r.s.seq[p.URI]++
311+ r.s.diagMu.Unlock()
312+ select {
313+ case r.s.wake <- struct{}{}:
314+ default:
315+ }
316+ return nil
317+}
318+
319+// Configuration answers "no settings" for whatever section a server asks
320+// about, so servers that require an answer (gopls does) go on with their
321+// defaults instead of stalling.
322+func (r *receiver) Configuration(_ context.Context, p *protocol.ConfigurationParams) ([]protocol.LSPAny, error) {
323+ out := make([]protocol.LSPAny, len(p.Items))
324+ return out, nil
325+}
326+
327+// WorkspaceFolders is the one folder given at initialize.
328+func (r *receiver) WorkspaceFolders(context.Context) ([]protocol.WorkspaceFolder, error) {
329+ root := uri.File(r.s.root)
330+ return []protocol.WorkspaceFolder{{URI: root, Name: filepath.Base(r.s.root)}}, nil
331+}
332+
333+// RegisterCapability and its opposite are accepted silently: dynamic
334+// registration changes nothing for a client that asks the same six things.
335+func (r *receiver) RegisterCapability(context.Context, *protocol.RegistrationParams) error {
336+ return nil
337+}
338+
339+// UnregisterCapability: see RegisterCapability.
340+func (r *receiver) UnregisterCapability(context.Context, *protocol.UnregistrationParams) error {
341+ return nil
342+}
343+
344+// ShowMessageRequest picks nothing: there is no user at this end of the
345+// wire to choose.
346+func (r *receiver) ShowMessageRequest(context.Context, *protocol.ShowMessageRequestParams) (*protocol.MessageActionItem, error) {
347+ return nil, nil
348+}
349+
350+// WorkDoneProgressCreate accepts the token; the progress that follows is
351+// dropped by UnimplementedClient.
352+func (r *receiver) WorkDoneProgressCreate(context.Context, *protocol.WorkDoneProgressCreateParams) error {
353+ return nil
354+}
355+
356+// --- process launcher -----------------------------------------------------------
357+
358+// stdio joins a process's stdin and stdout into one stream.
359+type stdio struct {
360+ io.Reader
361+ io.WriteCloser
362+}
363+
364+// launchProcess is the real Launcher: the command with its arguments, in
365+// the project root, stderr kept aside for the failure message.
366+func launchProcess(ctx context.Context, s config.LSPServer, root string) (io.ReadWriteCloser, func() error, error) {
367+ cmd := exec.CommandContext(ctx, s.Command, s.Args...)
368+ cmd.Dir = root
369+ var stderr bytes.Buffer
370+ cmd.Stderr = &tail{buf: &stderr}
371+ stdin, stdout, err := startWithPipes(cmd)
372+ if err != nil {
373+ return nil, nil, err
374+ }
375+ end := func() error {
376+ _ = stdin.Close()
377+ done := make(chan error, 1)
378+ go func() { done <- cmd.Wait() }()
379+ select {
380+ case err := <-done:
381+ return err
382+ case <-time.After(2 * time.Second):
383+ _ = cmd.Process.Kill()
384+ return errors.New("killed: " + strings.TrimSpace(stderr.String()))
385+ }
386+ }
387+ return &stdio{Reader: stdout, WriteCloser: stdin}, end, nil
388+}
389+
390+// startWithPipes starts the command with its stdin and stdout piped.
391+func startWithPipes(cmd *exec.Cmd) (io.WriteCloser, io.Reader, error) {
392+ stdin, err := cmd.StdinPipe()
393+ if err != nil {
394+ return nil, nil, err
395+ }
396+ stdout, err := cmd.StdoutPipe()
397+ if err != nil {
398+ return nil, nil, err
399+ }
400+ return stdin, stdout, cmd.Start()
401+}
402+
403+// tail keeps the last few kilobytes written to it: a server's stderr can be
404+// verbose, and only its end explains a crash.
405+type tail struct {
406+ buf *bytes.Buffer
407+}
408+
409+const tailKeep = 4096
410+
411+func (t *tail) Write(p []byte) (int, error) {
412+ t.buf.Write(p)
413+ if t.buf.Len() > tailKeep {
414+ rest := t.buf.Bytes()[t.buf.Len()-tailKeep:]
415+ t.buf = bytes.NewBuffer(append([]byte(nil), rest...))
416+ }
417+ return len(p), nil
418+}
added internal/lsp/lsp_test.go +343 -0
new file mode 100644
@@ -0,0 +1,343 @@
1+package lsp
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "mm/internal/config"
17+
18+ "go.lsp.dev/jsonrpc2"
19+ "go.lsp.dev/protocol"
20+ "go.lsp.dev/uri"
21+)
22+
23+// fakeServer is an in-memory language server that knows one program: a
24+// `hello` function defined on line 3 and called on line 6 of main.go. It
25+// records what the client tells it, so the tests can check the sync.
26+type fakeServer struct {
27+ protocol.UnimplementedServer
28+ mu sync.Mutex
29+ opened map[uri.URI]int32 // versions seen
30+ texts map[uri.URI]string
31+ shutdown bool
32+ publish bool // publish a diagnostic on didOpen
33+}
34+
35+func newFake() *fakeServer {
36+ return &fakeServer{opened: map[uri.URI]int32{}, texts: map[uri.URI]string{}, publish: true}
37+}
38+
39+func (f *fakeServer) Initialize(context.Context, *protocol.InitializeParams) (*protocol.InitializeResult, error) {
40+ return &protocol.InitializeResult{ServerInfo: protocol.ServerInfo{Name: "fake"}}, nil
41+}
42+func (f *fakeServer) Initialized(context.Context, *protocol.InitializedParams) error { return nil }
43+func (f *fakeServer) Shutdown(context.Context) error {
44+ f.mu.Lock()
45+ f.shutdown = true
46+ f.mu.Unlock()
47+ return nil
48+}
49+func (f *fakeServer) Exit(context.Context) error { return nil }
50+
51+func (f *fakeServer) DidOpen(ctx context.Context, p *protocol.DidOpenTextDocumentParams) error {
52+ f.mu.Lock()
53+ f.opened[p.TextDocument.URI] = p.TextDocument.Version
54+ f.texts[p.TextDocument.URI] = p.TextDocument.Text
55+ publish := f.publish
56+ f.mu.Unlock()
57+ if !publish {
58+ return nil
59+ }
60+ client, _ := protocol.ClientFromContext(ctx)
61+ go func() {
62+ _ = client.PublishDiagnostics(context.Background(), &protocol.PublishDiagnosticsParams{
63+ URI: p.TextDocument.URI,
64+ Diagnostics: []protocol.Diagnostic{{
65+ Range: protocol.Range{Start: protocol.Position{Line: 2, Character: 5}, End: protocol.Position{Line: 2, Character: 10}},
66+ Severity: protocol.DiagnosticSeverityWarning,
67+ Source: protocol.NewOptional("fake"),
68+ Message: protocol.String("unused function"),
69+ }},
70+ })
71+ }()
72+ return nil
73+}
74+
75+func (f *fakeServer) DidChange(_ context.Context, p *protocol.DidChangeTextDocumentParams) error {
76+ f.mu.Lock()
77+ defer f.mu.Unlock()
78+ f.opened[p.TextDocument.URI] = p.TextDocument.Version
79+ if whole, ok := p.ContentChanges[0].(*protocol.TextDocumentContentChangeWholeDocument); ok {
80+ f.texts[p.TextDocument.URI] = whole.Text
81+ }
82+ return nil
83+}
84+
85+func (f *fakeServer) Definition(_ context.Context, p *protocol.DefinitionParams) (protocol.DefinitionResult, error) {
86+ return protocol.LocationSlice{{URI: p.TextDocument.URI, Range: span(2, 5, 2, 10)}}, nil
87+}
88+
89+func (f *fakeServer) References(_ context.Context, p *protocol.ReferenceParams) ([]protocol.Location, error) {
90+ return []protocol.Location{
91+ {URI: p.TextDocument.URI, Range: span(5, 1, 5, 6)},
92+ {URI: p.TextDocument.URI, Range: span(2, 5, 2, 10)},
93+ }, nil
94+}
95+
96+func (f *fakeServer) Hover(context.Context, *protocol.HoverParams) (*protocol.Hover, error) {
97+ return &protocol.Hover{Contents: &protocol.MarkupContent{Kind: "markdown", Value: "```go\nfunc hello()\n```\n\nSays hello."}}, nil
98+}
99+
100+func (f *fakeServer) DocumentSymbol(context.Context, *protocol.DocumentSymbolParams) (protocol.DocumentSymbolResult, error) {
101+ detail := "func()"
102+ return protocol.DocumentSymbolSlice{
103+ {Name: "hello", Kind: 12, Detail: &detail, Range: span(2, 0, 2, 15), SelectionRange: span(2, 5, 2, 10)},
104+ {Name: "main", Kind: 12, Range: span(4, 0, 6, 1), SelectionRange: span(4, 5, 4, 9), Children: []protocol.DocumentSymbol{
105+ {Name: "x", Kind: 13, Range: span(5, 1, 5, 6), SelectionRange: span(5, 1, 5, 2)},
106+ }},
107+ }, nil
108+}
109+
110+func (f *fakeServer) Rename(_ context.Context, p *protocol.RenameParams) (*protocol.WorkspaceEdit, error) {
111+ if p.NewName == "refuse" {
112+ return nil, errors.New("invalid name")
113+ }
114+ return &protocol.WorkspaceEdit{Changes: map[uri.URI][]protocol.TextEdit{
115+ p.TextDocument.URI: {
116+ {Range: span(2, 5, 2, 10), NewText: p.NewName},
117+ {Range: span(5, 1, 5, 6), NewText: p.NewName},
118+ },
119+ }}, nil
120+}
121+
122+func span(l1, c1, l2, c2 uint32) protocol.Range {
123+ return protocol.Range{Start: protocol.Position{Line: l1, Character: c1}, End: protocol.Position{Line: l2, Character: c2}}
124+}
125+
126+const program = "package main\n\nfunc hello() {}\n\nfunc main() {\n\thello()\n}\n"
127+
128+// newClient returns a client over the fake, on a project holding main.go.
129+func newClient(t *testing.T, fake *fakeServer) (*Client, string) {
130+ t.Helper()
131+ root := t.TempDir()
132+ if err := os.WriteFile(filepath.Join(root, "main.go"), []byte(program), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ servers := []config.LSPServer{{Name: "fake", Command: "fake", Extensions: []string{".go"}}}
136+ c := New(root, servers)
137+ c.diagWait = 200 * time.Millisecond
138+ c.launch = func(ctx context.Context, _ config.LSPServer, _ string) (io.ReadWriteCloser, func() error, error) {
139+ clientEnd, serverEnd := net.Pipe()
140+ _, conn, _ := protocol.NewServer(ctx, fake, jsonrpc2.NewStream(serverEnd))
141+ return clientEnd, func() error { return conn.Close() }, nil
142+ }
143+ t.Cleanup(c.Close)
144+ // Tests run from the project root, as the agent does.
145+ wd, _ := os.Getwd()
146+ if err := os.Chdir(root); err != nil {
147+ t.Fatal(err)
148+ }
149+ t.Cleanup(func() { _ = os.Chdir(wd) })
150+ return c, root
151+}
152+
153+// The six operations, through the handshake and the file sync, answer in the
154+// text shape the model reads: relative paths, 1-based positions, the source
155+// line quoted.
156+func TestOperationsOverAFakeServer(t *testing.T) {
157+ fake := newFake()
158+ c, root := newClient(t, fake)
159+ ctx := context.Background()
160+
161+ got, err := c.Definition(ctx, "main.go", 6, 2)
162+ if err != nil || got != "main.go:3:6 func hello() {}" {
163+ t.Errorf("definition: %q, %v", got, err)
164+ }
165+ got, err = c.References(ctx, "main.go", 6, 2)
166+ if err != nil || got != "2 reference(s):\nmain.go:3:6 func hello() {}\nmain.go:6:2 hello()" {
167+ t.Errorf("references: %q, %v", got, err)
168+ }
169+ got, err = c.Hover(ctx, "main.go", 6, 2)
170+ if err != nil || !strings.Contains(got, "func hello()") || !strings.Contains(got, "Says hello.") {
171+ t.Errorf("hover: %q, %v", got, err)
172+ }
173+ got, err = c.Symbols(ctx, "main.go")
174+ if err != nil || got != "function hello func() line 3\nfunction main line 5\n variable x line 6" {
175+ t.Errorf("symbols: %q, %v", got, err)
176+ }
177+ // main.go was opened by the calls above and has not changed since: no
178+ // new publication comes, the last one is reported as such.
179+ got, err = c.Diagnostics(ctx, "main.go")
180+ if err != nil || got != "1 diagnostic(s) in main.go (as last published):\nwarning main.go:3:6 unused function [fake]" {
181+ t.Errorf("diagnostics on an open file: %q, %v", got, err)
182+ }
183+ // A file opened by the diagnostics call itself gets the fresh publication.
184+ if err := os.WriteFile(filepath.Join(root, "other.go"), []byte(program), 0o644); err != nil {
185+ t.Fatal(err)
186+ }
187+ got, err = c.Diagnostics(ctx, "other.go")
188+ if err != nil || got != "1 diagnostic(s) in other.go:\nwarning other.go:3:6 unused function [fake]" {
189+ t.Errorf("diagnostics on a fresh file: %q, %v", got, err)
190+ }
191+
192+ got, err = c.Rename(ctx, "main.go", 6, 2, "greet")
193+ if err != nil || got != "renamed to greet: 2 edit(s) in 1 file(s)\nmain.go: 2 edit(s)" {
194+ t.Errorf("rename: %q, %v", got, err)
195+ }
196+ data, _ := os.ReadFile(filepath.Join(root, "main.go"))
197+ if string(data) != "package main\n\nfunc greet() {}\n\nfunc main() {\n\tgreet()\n}\n" {
198+ t.Errorf("file after rename:\n%s", data)
199+ }
200+ fake.mu.Lock()
201+ version, text := fake.opened[uri.File(filepath.Join(root, "main.go"))], fake.texts[uri.File(filepath.Join(root, "main.go"))]
202+ fake.mu.Unlock()
203+ if version != 2 || !strings.Contains(text, "greet") {
204+ t.Errorf("server not told about the renamed file: version %d, text %q", version, text)
205+ }
206+
207+ if _, err := c.Rename(ctx, "main.go", 6, 2, "refuse"); err == nil || !strings.Contains(err.Error(), "fake: rename failed: ") {
208+ t.Errorf("refused rename: %v", err)
209+ }
210+ if _, err := c.Rename(ctx, "main.go", 6, 2, " "); err == nil {
211+ t.Error("rename without a name accepted")
212+ }
213+
214+ c.Close()
215+ fake.mu.Lock()
216+ defer fake.mu.Unlock()
217+ if !fake.shutdown {
218+ t.Error("Close did not shut the server down")
219+ }
220+}
221+
222+// The refusals the model can act on: a file no server handles (with the
223+// list of what is served), a position out of range, a missing file, a
224+// server that cannot start.
225+func TestRefusals(t *testing.T) {
226+ c, _ := newClient(t, newFake())
227+ ctx := context.Background()
228+ if _, err := c.Definition(ctx, "README.md", 1, 1); err == nil || !strings.Contains(err.Error(), `no language server for "README.md" — configured: fake (.go)`) {
229+ t.Errorf("unserved extension: %v", err)
230+ }
231+ if _, err := c.Definition(ctx, "main.go", 0, 1); err == nil || !strings.Contains(err.Error(), "1-based") {
232+ t.Errorf("line 0: %v", err)
233+ }
234+ if _, err := c.Definition(ctx, "main.go", 3, 40); err == nil || !strings.Contains(err.Error(), "past the end of line 3") {
235+ t.Errorf("column past the end: %v", err)
236+ }
237+ if _, err := c.Definition(ctx, "main.go", 99, 1); err == nil || !strings.Contains(err.Error(), "past the end of the file") {
238+ t.Errorf("line past the end: %v", err)
239+ }
240+ if _, err := c.Symbols(ctx, "nope.go"); err == nil {
241+ t.Error("missing file accepted")
242+ }
243+ if _, err := c.Symbols(ctx, ""); err == nil {
244+ t.Error("empty path accepted")
245+ }
246+
247+ broken := New(c.root, c.servers)
248+ broken.launch = func(context.Context, config.LSPServer, string) (io.ReadWriteCloser, func() error, error) {
249+ return nil, nil, errors.New("boom")
250+ }
251+ if _, err := broken.Symbols(ctx, "main.go"); err == nil || err.Error() != "start fake: boom" {
252+ t.Errorf("launch failure: %v", err)
253+ }
254+}
255+
256+// A server that publishes nothing is reported as silent, not as clean.
257+func TestDiagnosticsWhenTheServerStaysSilent(t *testing.T) {
258+ fake := newFake()
259+ fake.publish = false
260+ c, _ := newClient(t, fake)
261+ got, err := c.Diagnostics(context.Background(), "main.go")
262+ if err != nil || !strings.HasPrefix(got, "no diagnostics received for main.go within") {
263+ t.Errorf("silent server: %q, %v", got, err)
264+ }
265+}
266+
267+// Positions on the wire are UTF-16 units; the agent's are runes. Both
268+// conversions agree on a line mixing one-, two- and four-byte runes.
269+func TestUTF16Conversions(t *testing.T) {
270+ text := "x\naé𐐀b\n"
271+ units, err := utf16Column(text, 2, 4) // before "b": a(1) é(1) 𐐀(2) = 4 units
272+ if err != nil || units != 4 {
273+ t.Errorf("utf16Column = %d, %v; want 4", units, err)
274+ }
275+ if off := byteOffset(text, protocol.Position{Line: 1, Character: 4}); text[off:] != "b\n" {
276+ t.Errorf("byteOffset lands on %q, want \"b\\n\"", text[off:])
277+ }
278+ if off := byteOffset(text, protocol.Position{Line: 1, Character: 99}); text[off:] != "\n" {
279+ t.Errorf("past-the-end position clamps to %q, want the line end", text[off:])
280+ }
281+ if off := byteOffset(text, protocol.Position{Line: 9, Character: 0}); off != len(text) {
282+ t.Errorf("past-the-last-line position = %d, want %d", off, len(text))
283+ }
284+ got := applyTextEdits(text, []protocol.TextEdit{
285+ {Range: span(1, 1, 1, 2), NewText: "E"},
286+ {Range: span(1, 2, 1, 4), NewText: "_"},
287+ })
288+ if got != "x\naE_b\n" {
289+ t.Errorf("applyTextEdits = %q", got)
290+ }
291+}
292+
293+// Available keeps the servers whose command exists and names the others;
294+// Describe is the one-line summary the tool description and banner use.
295+func TestAvailableAndDescribe(t *testing.T) {
296+ servers := []config.LSPServer{
297+ {Name: "shell", Command: "sh", Extensions: []string{".sh"}},
298+ {Name: "ghost", Command: "definitely-not-a-command-mm", Extensions: []string{".ghost"}},
299+ }
300+ ok, missing := Available(servers)
301+ if len(ok) != 1 || ok[0].Name != "shell" || len(missing) != 1 || missing[0] != "ghost (definitely-not-a-command-mm)" {
302+ t.Errorf("Available: ok %+v, missing %v", ok, missing)
303+ }
304+ if got := Describe(servers); got != "shell (.sh), ghost (.ghost)" {
305+ t.Errorf("Describe: %q", got)
306+ }
307+}
308+
309+// Locate turns an identifier into the position the requests need: whole
310+// words only, the given line or the first occurrence in the file, with the
311+// misses named.
312+func TestLocate(t *testing.T) {
313+ c, root := newClient(t, newFake())
314+ // A comment naming the function above it: the code occurrences come
315+ // first, the comment's last.
316+ commented := "package main\n\n// hello says hello.\nfunc hello() {}\n\nfunc main() {\n\thello()\n}\n"
317+ if err := os.WriteFile(filepath.Join(root, "main.go"), []byte(commented), 0o644); err != nil {
318+ t.Fatal(err)
319+ }
320+ got, err := c.Locate("main.go", 0, "hello")
321+ want := []Pos{{4, 6}, {7, 2}, {3, 4}, {3, 15}} // the comment names it twice, both last
322+ if err != nil || fmt.Sprint(got) != fmt.Sprint(want) {
323+ t.Errorf("whole file: %v, %v; want %v", got, err, want)
324+ }
325+ if got, err := c.Locate("main.go", 7, "hello"); err != nil || fmt.Sprint(got) != "[{7 2}]" {
326+ t.Errorf("on line 7: %v, %v", got, err)
327+ }
328+ if _, err := c.Locate("main.go", 1, "hello"); err == nil || !strings.Contains(err.Error(), `"hello" not found on line 1`) {
329+ t.Errorf("wrong line: %v", err)
330+ }
331+ if _, err := c.Locate("main.go", 0, "hell"); err == nil || !strings.Contains(err.Error(), `"hell" not found in main.go`) {
332+ t.Errorf("partial word must not match: %v", err)
333+ }
334+ if _, err := c.Locate("main.go", 0, " "); err == nil {
335+ t.Error("empty symbol accepted")
336+ }
337+ if _, err := c.Locate("main.go", 99, "hello"); err == nil || !strings.Contains(err.Error(), "past the end") {
338+ t.Errorf("line past the end: %v", err)
339+ }
340+ if cols := wordColumns("héllo_1 héllo(héllo)", "héllo"); fmt.Sprint(cols) != "[9 15]" {
341+ t.Errorf("wordColumns skips the glued identifier: %v, want [9 15]", cols)
342+ }
343+}
new file mode 100644
@@ -0,0 +1,343 @@
1+package lsp
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "testing"
14+ "time"
15+
16+ "mm/internal/config"
17+
18+ "go.lsp.dev/jsonrpc2"
19+ "go.lsp.dev/protocol"
20+ "go.lsp.dev/uri"
21+)
22+
23+// fakeServer is an in-memory language server that knows one program: a
24+// `hello` function defined on line 3 and called on line 6 of main.go. It
25+// records what the client tells it, so the tests can check the sync.
26+type fakeServer struct {
27+ protocol.UnimplementedServer
28+ mu sync.Mutex
29+ opened map[uri.URI]int32 // versions seen
30+ texts map[uri.URI]string
31+ shutdown bool
32+ publish bool // publish a diagnostic on didOpen
33+}
34+
35+func newFake() *fakeServer {
36+ return &fakeServer{opened: map[uri.URI]int32{}, texts: map[uri.URI]string{}, publish: true}
37+}
38+
39+func (f *fakeServer) Initialize(context.Context, *protocol.InitializeParams) (*protocol.InitializeResult, error) {
40+ return &protocol.InitializeResult{ServerInfo: protocol.ServerInfo{Name: "fake"}}, nil
41+}
42+func (f *fakeServer) Initialized(context.Context, *protocol.InitializedParams) error { return nil }
43+func (f *fakeServer) Shutdown(context.Context) error {
44+ f.mu.Lock()
45+ f.shutdown = true
46+ f.mu.Unlock()
47+ return nil
48+}
49+func (f *fakeServer) Exit(context.Context) error { return nil }
50+
51+func (f *fakeServer) DidOpen(ctx context.Context, p *protocol.DidOpenTextDocumentParams) error {
52+ f.mu.Lock()
53+ f.opened[p.TextDocument.URI] = p.TextDocument.Version
54+ f.texts[p.TextDocument.URI] = p.TextDocument.Text
55+ publish := f.publish
56+ f.mu.Unlock()
57+ if !publish {
58+ return nil
59+ }
60+ client, _ := protocol.ClientFromContext(ctx)
61+ go func() {
62+ _ = client.PublishDiagnostics(context.Background(), &protocol.PublishDiagnosticsParams{
63+ URI: p.TextDocument.URI,
64+ Diagnostics: []protocol.Diagnostic{{
65+ Range: protocol.Range{Start: protocol.Position{Line: 2, Character: 5}, End: protocol.Position{Line: 2, Character: 10}},
66+ Severity: protocol.DiagnosticSeverityWarning,
67+ Source: protocol.NewOptional("fake"),
68+ Message: protocol.String("unused function"),
69+ }},
70+ })
71+ }()
72+ return nil
73+}
74+
75+func (f *fakeServer) DidChange(_ context.Context, p *protocol.DidChangeTextDocumentParams) error {
76+ f.mu.Lock()
77+ defer f.mu.Unlock()
78+ f.opened[p.TextDocument.URI] = p.TextDocument.Version
79+ if whole, ok := p.ContentChanges[0].(*protocol.TextDocumentContentChangeWholeDocument); ok {
80+ f.texts[p.TextDocument.URI] = whole.Text
81+ }
82+ return nil
83+}
84+
85+func (f *fakeServer) Definition(_ context.Context, p *protocol.DefinitionParams) (protocol.DefinitionResult, error) {
86+ return protocol.LocationSlice{{URI: p.TextDocument.URI, Range: span(2, 5, 2, 10)}}, nil
87+}
88+
89+func (f *fakeServer) References(_ context.Context, p *protocol.ReferenceParams) ([]protocol.Location, error) {
90+ return []protocol.Location{
91+ {URI: p.TextDocument.URI, Range: span(5, 1, 5, 6)},
92+ {URI: p.TextDocument.URI, Range: span(2, 5, 2, 10)},
93+ }, nil
94+}
95+
96+func (f *fakeServer) Hover(context.Context, *protocol.HoverParams) (*protocol.Hover, error) {
97+ return &protocol.Hover{Contents: &protocol.MarkupContent{Kind: "markdown", Value: "```go\nfunc hello()\n```\n\nSays hello."}}, nil
98+}
99+
100+func (f *fakeServer) DocumentSymbol(context.Context, *protocol.DocumentSymbolParams) (protocol.DocumentSymbolResult, error) {
101+ detail := "func()"
102+ return protocol.DocumentSymbolSlice{
103+ {Name: "hello", Kind: 12, Detail: &detail, Range: span(2, 0, 2, 15), SelectionRange: span(2, 5, 2, 10)},
104+ {Name: "main", Kind: 12, Range: span(4, 0, 6, 1), SelectionRange: span(4, 5, 4, 9), Children: []protocol.DocumentSymbol{
105+ {Name: "x", Kind: 13, Range: span(5, 1, 5, 6), SelectionRange: span(5, 1, 5, 2)},
106+ }},
107+ }, nil
108+}
109+
110+func (f *fakeServer) Rename(_ context.Context, p *protocol.RenameParams) (*protocol.WorkspaceEdit, error) {
111+ if p.NewName == "refuse" {
112+ return nil, errors.New("invalid name")
113+ }
114+ return &protocol.WorkspaceEdit{Changes: map[uri.URI][]protocol.TextEdit{
115+ p.TextDocument.URI: {
116+ {Range: span(2, 5, 2, 10), NewText: p.NewName},
117+ {Range: span(5, 1, 5, 6), NewText: p.NewName},
118+ },
119+ }}, nil
120+}
121+
122+func span(l1, c1, l2, c2 uint32) protocol.Range {
123+ return protocol.Range{Start: protocol.Position{Line: l1, Character: c1}, End: protocol.Position{Line: l2, Character: c2}}
124+}
125+
126+const program = "package main\n\nfunc hello() {}\n\nfunc main() {\n\thello()\n}\n"
127+
128+// newClient returns a client over the fake, on a project holding main.go.
129+func newClient(t *testing.T, fake *fakeServer) (*Client, string) {
130+ t.Helper()
131+ root := t.TempDir()
132+ if err := os.WriteFile(filepath.Join(root, "main.go"), []byte(program), 0o644); err != nil {
133+ t.Fatal(err)
134+ }
135+ servers := []config.LSPServer{{Name: "fake", Command: "fake", Extensions: []string{".go"}}}
136+ c := New(root, servers)
137+ c.diagWait = 200 * time.Millisecond
138+ c.launch = func(ctx context.Context, _ config.LSPServer, _ string) (io.ReadWriteCloser, func() error, error) {
139+ clientEnd, serverEnd := net.Pipe()
140+ _, conn, _ := protocol.NewServer(ctx, fake, jsonrpc2.NewStream(serverEnd))
141+ return clientEnd, func() error { return conn.Close() }, nil
142+ }
143+ t.Cleanup(c.Close)
144+ // Tests run from the project root, as the agent does.
145+ wd, _ := os.Getwd()
146+ if err := os.Chdir(root); err != nil {
147+ t.Fatal(err)
148+ }
149+ t.Cleanup(func() { _ = os.Chdir(wd) })
150+ return c, root
151+}
152+
153+// The six operations, through the handshake and the file sync, answer in the
154+// text shape the model reads: relative paths, 1-based positions, the source
155+// line quoted.
156+func TestOperationsOverAFakeServer(t *testing.T) {
157+ fake := newFake()
158+ c, root := newClient(t, fake)
159+ ctx := context.Background()
160+
161+ got, err := c.Definition(ctx, "main.go", 6, 2)
162+ if err != nil || got != "main.go:3:6 func hello() {}" {
163+ t.Errorf("definition: %q, %v", got, err)
164+ }
165+ got, err = c.References(ctx, "main.go", 6, 2)
166+ if err != nil || got != "2 reference(s):\nmain.go:3:6 func hello() {}\nmain.go:6:2 hello()" {
167+ t.Errorf("references: %q, %v", got, err)
168+ }
169+ got, err = c.Hover(ctx, "main.go", 6, 2)
170+ if err != nil || !strings.Contains(got, "func hello()") || !strings.Contains(got, "Says hello.") {
171+ t.Errorf("hover: %q, %v", got, err)
172+ }
173+ got, err = c.Symbols(ctx, "main.go")
174+ if err != nil || got != "function hello func() line 3\nfunction main line 5\n variable x line 6" {
175+ t.Errorf("symbols: %q, %v", got, err)
176+ }
177+ // main.go was opened by the calls above and has not changed since: no
178+ // new publication comes, the last one is reported as such.
179+ got, err = c.Diagnostics(ctx, "main.go")
180+ if err != nil || got != "1 diagnostic(s) in main.go (as last published):\nwarning main.go:3:6 unused function [fake]" {
181+ t.Errorf("diagnostics on an open file: %q, %v", got, err)
182+ }
183+ // A file opened by the diagnostics call itself gets the fresh publication.
184+ if err := os.WriteFile(filepath.Join(root, "other.go"), []byte(program), 0o644); err != nil {
185+ t.Fatal(err)
186+ }
187+ got, err = c.Diagnostics(ctx, "other.go")
188+ if err != nil || got != "1 diagnostic(s) in other.go:\nwarning other.go:3:6 unused function [fake]" {
189+ t.Errorf("diagnostics on a fresh file: %q, %v", got, err)
190+ }
191+
192+ got, err = c.Rename(ctx, "main.go", 6, 2, "greet")
193+ if err != nil || got != "renamed to greet: 2 edit(s) in 1 file(s)\nmain.go: 2 edit(s)" {
194+ t.Errorf("rename: %q, %v", got, err)
195+ }
196+ data, _ := os.ReadFile(filepath.Join(root, "main.go"))
197+ if string(data) != "package main\n\nfunc greet() {}\n\nfunc main() {\n\tgreet()\n}\n" {
198+ t.Errorf("file after rename:\n%s", data)
199+ }
200+ fake.mu.Lock()
201+ version, text := fake.opened[uri.File(filepath.Join(root, "main.go"))], fake.texts[uri.File(filepath.Join(root, "main.go"))]
202+ fake.mu.Unlock()
203+ if version != 2 || !strings.Contains(text, "greet") {
204+ t.Errorf("server not told about the renamed file: version %d, text %q", version, text)
205+ }
206+
207+ if _, err := c.Rename(ctx, "main.go", 6, 2, "refuse"); err == nil || !strings.Contains(err.Error(), "fake: rename failed: ") {
208+ t.Errorf("refused rename: %v", err)
209+ }
210+ if _, err := c.Rename(ctx, "main.go", 6, 2, " "); err == nil {
211+ t.Error("rename without a name accepted")
212+ }
213+
214+ c.Close()
215+ fake.mu.Lock()
216+ defer fake.mu.Unlock()
217+ if !fake.shutdown {
218+ t.Error("Close did not shut the server down")
219+ }
220+}
221+
222+// The refusals the model can act on: a file no server handles (with the
223+// list of what is served), a position out of range, a missing file, a
224+// server that cannot start.
225+func TestRefusals(t *testing.T) {
226+ c, _ := newClient(t, newFake())
227+ ctx := context.Background()
228+ if _, err := c.Definition(ctx, "README.md", 1, 1); err == nil || !strings.Contains(err.Error(), `no language server for "README.md" — configured: fake (.go)`) {
229+ t.Errorf("unserved extension: %v", err)
230+ }
231+ if _, err := c.Definition(ctx, "main.go", 0, 1); err == nil || !strings.Contains(err.Error(), "1-based") {
232+ t.Errorf("line 0: %v", err)
233+ }
234+ if _, err := c.Definition(ctx, "main.go", 3, 40); err == nil || !strings.Contains(err.Error(), "past the end of line 3") {
235+ t.Errorf("column past the end: %v", err)
236+ }
237+ if _, err := c.Definition(ctx, "main.go", 99, 1); err == nil || !strings.Contains(err.Error(), "past the end of the file") {
238+ t.Errorf("line past the end: %v", err)
239+ }
240+ if _, err := c.Symbols(ctx, "nope.go"); err == nil {
241+ t.Error("missing file accepted")
242+ }
243+ if _, err := c.Symbols(ctx, ""); err == nil {
244+ t.Error("empty path accepted")
245+ }
246+
247+ broken := New(c.root, c.servers)
248+ broken.launch = func(context.Context, config.LSPServer, string) (io.ReadWriteCloser, func() error, error) {
249+ return nil, nil, errors.New("boom")
250+ }
251+ if _, err := broken.Symbols(ctx, "main.go"); err == nil || err.Error() != "start fake: boom" {
252+ t.Errorf("launch failure: %v", err)
253+ }
254+}
255+
256+// A server that publishes nothing is reported as silent, not as clean.
257+func TestDiagnosticsWhenTheServerStaysSilent(t *testing.T) {
258+ fake := newFake()
259+ fake.publish = false
260+ c, _ := newClient(t, fake)
261+ got, err := c.Diagnostics(context.Background(), "main.go")
262+ if err != nil || !strings.HasPrefix(got, "no diagnostics received for main.go within") {
263+ t.Errorf("silent server: %q, %v", got, err)
264+ }
265+}
266+
267+// Positions on the wire are UTF-16 units; the agent's are runes. Both
268+// conversions agree on a line mixing one-, two- and four-byte runes.
269+func TestUTF16Conversions(t *testing.T) {
270+ text := "x\naé𐐀b\n"
271+ units, err := utf16Column(text, 2, 4) // before "b": a(1) é(1) 𐐀(2) = 4 units
272+ if err != nil || units != 4 {
273+ t.Errorf("utf16Column = %d, %v; want 4", units, err)
274+ }
275+ if off := byteOffset(text, protocol.Position{Line: 1, Character: 4}); text[off:] != "b\n" {
276+ t.Errorf("byteOffset lands on %q, want \"b\\n\"", text[off:])
277+ }
278+ if off := byteOffset(text, protocol.Position{Line: 1, Character: 99}); text[off:] != "\n" {
279+ t.Errorf("past-the-end position clamps to %q, want the line end", text[off:])
280+ }
281+ if off := byteOffset(text, protocol.Position{Line: 9, Character: 0}); off != len(text) {
282+ t.Errorf("past-the-last-line position = %d, want %d", off, len(text))
283+ }
284+ got := applyTextEdits(text, []protocol.TextEdit{
285+ {Range: span(1, 1, 1, 2), NewText: "E"},
286+ {Range: span(1, 2, 1, 4), NewText: "_"},
287+ })
288+ if got != "x\naE_b\n" {
289+ t.Errorf("applyTextEdits = %q", got)
290+ }
291+}
292+
293+// Available keeps the servers whose command exists and names the others;
294+// Describe is the one-line summary the tool description and banner use.
295+func TestAvailableAndDescribe(t *testing.T) {
296+ servers := []config.LSPServer{
297+ {Name: "shell", Command: "sh", Extensions: []string{".sh"}},
298+ {Name: "ghost", Command: "definitely-not-a-command-mm", Extensions: []string{".ghost"}},
299+ }
300+ ok, missing := Available(servers)
301+ if len(ok) != 1 || ok[0].Name != "shell" || len(missing) != 1 || missing[0] != "ghost (definitely-not-a-command-mm)" {
302+ t.Errorf("Available: ok %+v, missing %v", ok, missing)
303+ }
304+ if got := Describe(servers); got != "shell (.sh), ghost (.ghost)" {
305+ t.Errorf("Describe: %q", got)
306+ }
307+}
308+
309+// Locate turns an identifier into the position the requests need: whole
310+// words only, the given line or the first occurrence in the file, with the
311+// misses named.
312+func TestLocate(t *testing.T) {
313+ c, root := newClient(t, newFake())
314+ // A comment naming the function above it: the code occurrences come
315+ // first, the comment's last.
316+ commented := "package main\n\n// hello says hello.\nfunc hello() {}\n\nfunc main() {\n\thello()\n}\n"
317+ if err := os.WriteFile(filepath.Join(root, "main.go"), []byte(commented), 0o644); err != nil {
318+ t.Fatal(err)
319+ }
320+ got, err := c.Locate("main.go", 0, "hello")
321+ want := []Pos{{4, 6}, {7, 2}, {3, 4}, {3, 15}} // the comment names it twice, both last
322+ if err != nil || fmt.Sprint(got) != fmt.Sprint(want) {
323+ t.Errorf("whole file: %v, %v; want %v", got, err, want)
324+ }
325+ if got, err := c.Locate("main.go", 7, "hello"); err != nil || fmt.Sprint(got) != "[{7 2}]" {
326+ t.Errorf("on line 7: %v, %v", got, err)
327+ }
328+ if _, err := c.Locate("main.go", 1, "hello"); err == nil || !strings.Contains(err.Error(), `"hello" not found on line 1`) {
329+ t.Errorf("wrong line: %v", err)
330+ }
331+ if _, err := c.Locate("main.go", 0, "hell"); err == nil || !strings.Contains(err.Error(), `"hell" not found in main.go`) {
332+ t.Errorf("partial word must not match: %v", err)
333+ }
334+ if _, err := c.Locate("main.go", 0, " "); err == nil {
335+ t.Error("empty symbol accepted")
336+ }
337+ if _, err := c.Locate("main.go", 99, "hello"); err == nil || !strings.Contains(err.Error(), "past the end") {
338+ t.Errorf("line past the end: %v", err)
339+ }
340+ if cols := wordColumns("héllo_1 héllo(héllo)", "héllo"); fmt.Sprint(cols) != "[9 15]" {
341+ t.Errorf("wordColumns skips the glued identifier: %v, want [9 15]", cols)
342+ }
343+}
added internal/lsp/ops.go +201 -0
new file mode 100644
@@ -0,0 +1,201 @@
1+package lsp
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "path/filepath"
8+ "strings"
9+
10+ "go.lsp.dev/protocol"
11+ "go.lsp.dev/uri"
12+)
13+
14+// Definition answers "where is the symbol at path:line:col defined?", one
15+// location per line, the defining line quoted.
16+//
17+// text, err := c.Definition(ctx, "main.go", 42, 10)
18+// // internal/engine/engine.go:544:6 func CommandList(history []*ai.Message) []string {
19+func (c *Client) Definition(ctx context.Context, path string, line, col int) (string, error) {
20+ s, pos, err := c.at(ctx, path, line, col)
21+ if err != nil {
22+ return "", err
23+ }
24+ res, err := s.server.Definition(ctx, &protocol.DefinitionParams{TextDocumentPositionParams: pos})
25+ if err != nil {
26+ return "", s.wrap("definition", err)
27+ }
28+ locs := locationsOf(res)
29+ if len(locs) == 0 {
30+ return "", noResult("no definition found for the symbol at " + where(path, line, col))
31+ }
32+ return c.formatLocations(locs), nil
33+}
34+
35+// References lists every use of the symbol at path:line:col, declaration
36+// included, one `path:line:col <line>` per hit, sorted by file.
37+func (c *Client) References(ctx context.Context, path string, line, col int) (string, error) {
38+ s, pos, err := c.at(ctx, path, line, col)
39+ if err != nil {
40+ return "", err
41+ }
42+ locs, err := s.server.References(ctx, &protocol.ReferenceParams{
43+ TextDocumentPositionParams: pos,
44+ Context: protocol.ReferenceContext{IncludeDeclaration: true},
45+ })
46+ if err != nil {
47+ return "", s.wrap("references", err)
48+ }
49+ if len(locs) == 0 {
50+ return "", noResult("no reference found for the symbol at " + where(path, line, col))
51+ }
52+ return fmt.Sprintf("%d reference(s):\n%s", len(locs), c.formatLocations(locs)), nil
53+}
54+
55+// Hover returns what the server shows on hover — signature, type,
56+// documentation — as the server wrote it.
57+func (c *Client) Hover(ctx context.Context, path string, line, col int) (string, error) {
58+ s, pos, err := c.at(ctx, path, line, col)
59+ if err != nil {
60+ return "", err
61+ }
62+ h, err := s.server.Hover(ctx, &protocol.HoverParams{TextDocumentPositionParams: pos})
63+ if err != nil {
64+ return "", s.wrap("hover", err)
65+ }
66+ if h == nil {
67+ return "", noResult("nothing to show for the symbol at " + where(path, line, col))
68+ }
69+ text := strings.TrimSpace(hoverText(h.Contents))
70+ if text == "" {
71+ return "", noResult("nothing to show for the symbol at " + where(path, line, col))
72+ }
73+ return text, nil
74+}
75+
76+// Symbols outlines a file: one line per symbol, children indented, with the
77+// kind and the line where each starts.
78+func (c *Client) Symbols(ctx context.Context, path string) (string, error) {
79+ s, _, err := c.open(ctx, path)
80+ if err != nil {
81+ return "", err
82+ }
83+ res, err := s.server.DocumentSymbol(ctx, &protocol.DocumentSymbolParams{TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(mustAbs(path))}})
84+ if err != nil {
85+ return "", s.wrap("symbols", err)
86+ }
87+ var b strings.Builder
88+ switch v := res.(type) {
89+ case protocol.DocumentSymbolSlice:
90+ writeSymbols(&b, v, 0)
91+ case protocol.SymbolInformationSlice:
92+ for _, sym := range v {
93+ fmt.Fprintf(&b, "%s %s line %d\n", kindName(sym.Kind), sym.Name, sym.Location.Range.Start.Line+1)
94+ }
95+ }
96+ if b.Len() == 0 {
97+ return "no symbol in " + path, nil
98+ }
99+ return strings.TrimRight(b.String(), "\n"), nil
100+}
101+
102+// writeSymbols renders a symbol tree, two spaces per level.
103+func writeSymbols(b *strings.Builder, syms []protocol.DocumentSymbol, depth int) {
104+ for _, sym := range syms {
105+ detail := ""
106+ if sym.Detail != nil && *sym.Detail != "" {
107+ detail = " " + *sym.Detail
108+ }
109+ fmt.Fprintf(b, "%s%s %s%s line %d\n", strings.Repeat(" ", depth), kindName(sym.Kind), sym.Name, detail, sym.SelectionRange.Start.Line+1)
110+ writeSymbols(b, sym.Children, depth+1)
111+ }
112+}
113+
114+// --- helpers --------------------------------------------------------------------
115+
116+// open syncs the file with its server and returns both.
117+func (c *Client) open(ctx context.Context, path string) (*session, uri.URI, error) {
118+ if path == "" {
119+ return nil, "", fmt.Errorf("path is required")
120+ }
121+ s, err := c.serverFor(ctx, path)
122+ if err != nil {
123+ return nil, "", err
124+ }
125+ u, err := s.sync(ctx, path)
126+ if err != nil {
127+ return nil, "", err
128+ }
129+ return s, u, nil
130+}
131+
132+// at is open plus the position, checked and converted to the wire's
133+// 0-based form.
134+func (c *Client) at(ctx context.Context, path string, line, col int) (*session, protocol.TextDocumentPositionParams, error) {
135+ if line < 1 || col < 1 {
136+ return nil, protocol.TextDocumentPositionParams{}, fmt.Errorf("line and column are 1-based and required (got line %d, column %d)", line, col)
137+ }
138+ s, u, err := c.open(ctx, path)
139+ if err != nil {
140+ return nil, protocol.TextDocumentPositionParams{}, err
141+ }
142+ s.mu.Lock()
143+ text := s.docs[u].text
144+ s.mu.Unlock()
145+ character, err := utf16Column(text, line, col)
146+ if err != nil {
147+ return nil, protocol.TextDocumentPositionParams{}, err
148+ }
149+ return s, protocol.TextDocumentPositionParams{
150+ TextDocument: protocol.TextDocumentIdentifier{URI: u},
151+ Position: protocol.Position{Line: uint32(line - 1), Character: character},
152+ }, nil
153+}
154+
155+// wrap names the server and the request in a failure. A server that
156+// answers "no identifier here" (gopls on a comment, for one) is a miss like
157+// an empty answer, so the caller can try the next occurrence.
158+func (s *session) wrap(op string, err error) error {
159+ msg := err.Error()
160+ if strings.Contains(msg, "no identifier") || strings.Contains(msg, "no object") || strings.Contains(msg, "not an identifier") {
161+ return noResult(fmt.Sprintf("%s: %s: %s", s.name, op, msg))
162+ }
163+ return fmt.Errorf("%s: %s failed: %v", s.name, op, err)
164+}
165+
166+// ErrNoResult marks an answer that is empty rather than wrong: the position
167+// holds nothing the server can talk about. errors.Is(err, ErrNoResult).
168+var ErrNoResult = errors.New("no result")
169+
170+// Miss builds an ErrNoResult with the message to show — for fakes in other
171+// packages' tests; this package uses noResult directly.
172+func Miss(msg string) error { return noResult(msg) }
173+
174+// noResult is ErrNoResult with the message to show.
175+type noResult string
176+
177+func (e noResult) Error() string { return string(e) }
178+func (e noResult) Is(t error) bool { return t == ErrNoResult }
179+
180+// where is `path:line:col` for messages.
181+func where(path string, line, col int) string { return fmt.Sprintf("%s:%d:%d", path, line, col) }
182+
183+// mustAbs is filepath.Abs with the error dropped: the path was opened a
184+// moment ago, so it resolves.
185+func mustAbs(path string) string {
186+ abs, err := filepath.Abs(path)
187+ if err != nil {
188+ return path
189+ }
190+ return abs
191+}
192+
193+// relative shows a path from the project root when it is inside it, as the
194+// model typed it; anything else (a dependency, the standard library) stays
195+// absolute — that is useful information.
196+func (c *Client) relative(abs string) string {
197+ if rel, err := filepath.Rel(c.root, abs); err == nil && !strings.HasPrefix(rel, "..") {
198+ return rel
199+ }
200+ return abs
201+}
new file mode 100644
@@ -0,0 +1,201 @@
1+package lsp
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "path/filepath"
8+ "strings"
9+
10+ "go.lsp.dev/protocol"
11+ "go.lsp.dev/uri"
12+)
13+
14+// Definition answers "where is the symbol at path:line:col defined?", one
15+// location per line, the defining line quoted.
16+//
17+// text, err := c.Definition(ctx, "main.go", 42, 10)
18+// // internal/engine/engine.go:544:6 func CommandList(history []*ai.Message) []string {
19+func (c *Client) Definition(ctx context.Context, path string, line, col int) (string, error) {
20+ s, pos, err := c.at(ctx, path, line, col)
21+ if err != nil {
22+ return "", err
23+ }
24+ res, err := s.server.Definition(ctx, &protocol.DefinitionParams{TextDocumentPositionParams: pos})
25+ if err != nil {
26+ return "", s.wrap("definition", err)
27+ }
28+ locs := locationsOf(res)
29+ if len(locs) == 0 {
30+ return "", noResult("no definition found for the symbol at " + where(path, line, col))
31+ }
32+ return c.formatLocations(locs), nil
33+}
34+
35+// References lists every use of the symbol at path:line:col, declaration
36+// included, one `path:line:col <line>` per hit, sorted by file.
37+func (c *Client) References(ctx context.Context, path string, line, col int) (string, error) {
38+ s, pos, err := c.at(ctx, path, line, col)
39+ if err != nil {
40+ return "", err
41+ }
42+ locs, err := s.server.References(ctx, &protocol.ReferenceParams{
43+ TextDocumentPositionParams: pos,
44+ Context: protocol.ReferenceContext{IncludeDeclaration: true},
45+ })
46+ if err != nil {
47+ return "", s.wrap("references", err)
48+ }
49+ if len(locs) == 0 {
50+ return "", noResult("no reference found for the symbol at " + where(path, line, col))
51+ }
52+ return fmt.Sprintf("%d reference(s):\n%s", len(locs), c.formatLocations(locs)), nil
53+}
54+
55+// Hover returns what the server shows on hover — signature, type,
56+// documentation — as the server wrote it.
57+func (c *Client) Hover(ctx context.Context, path string, line, col int) (string, error) {
58+ s, pos, err := c.at(ctx, path, line, col)
59+ if err != nil {
60+ return "", err
61+ }
62+ h, err := s.server.Hover(ctx, &protocol.HoverParams{TextDocumentPositionParams: pos})
63+ if err != nil {
64+ return "", s.wrap("hover", err)
65+ }
66+ if h == nil {
67+ return "", noResult("nothing to show for the symbol at " + where(path, line, col))
68+ }
69+ text := strings.TrimSpace(hoverText(h.Contents))
70+ if text == "" {
71+ return "", noResult("nothing to show for the symbol at " + where(path, line, col))
72+ }
73+ return text, nil
74+}
75+
76+// Symbols outlines a file: one line per symbol, children indented, with the
77+// kind and the line where each starts.
78+func (c *Client) Symbols(ctx context.Context, path string) (string, error) {
79+ s, _, err := c.open(ctx, path)
80+ if err != nil {
81+ return "", err
82+ }
83+ res, err := s.server.DocumentSymbol(ctx, &protocol.DocumentSymbolParams{TextDocument: protocol.TextDocumentIdentifier{URI: uri.File(mustAbs(path))}})
84+ if err != nil {
85+ return "", s.wrap("symbols", err)
86+ }
87+ var b strings.Builder
88+ switch v := res.(type) {
89+ case protocol.DocumentSymbolSlice:
90+ writeSymbols(&b, v, 0)
91+ case protocol.SymbolInformationSlice:
92+ for _, sym := range v {
93+ fmt.Fprintf(&b, "%s %s line %d\n", kindName(sym.Kind), sym.Name, sym.Location.Range.Start.Line+1)
94+ }
95+ }
96+ if b.Len() == 0 {
97+ return "no symbol in " + path, nil
98+ }
99+ return strings.TrimRight(b.String(), "\n"), nil
100+}
101+
102+// writeSymbols renders a symbol tree, two spaces per level.
103+func writeSymbols(b *strings.Builder, syms []protocol.DocumentSymbol, depth int) {
104+ for _, sym := range syms {
105+ detail := ""
106+ if sym.Detail != nil && *sym.Detail != "" {
107+ detail = " " + *sym.Detail
108+ }
109+ fmt.Fprintf(b, "%s%s %s%s line %d\n", strings.Repeat(" ", depth), kindName(sym.Kind), sym.Name, detail, sym.SelectionRange.Start.Line+1)
110+ writeSymbols(b, sym.Children, depth+1)
111+ }
112+}
113+
114+// --- helpers --------------------------------------------------------------------
115+
116+// open syncs the file with its server and returns both.
117+func (c *Client) open(ctx context.Context, path string) (*session, uri.URI, error) {
118+ if path == "" {
119+ return nil, "", fmt.Errorf("path is required")
120+ }
121+ s, err := c.serverFor(ctx, path)
122+ if err != nil {
123+ return nil, "", err
124+ }
125+ u, err := s.sync(ctx, path)
126+ if err != nil {
127+ return nil, "", err
128+ }
129+ return s, u, nil
130+}
131+
132+// at is open plus the position, checked and converted to the wire's
133+// 0-based form.
134+func (c *Client) at(ctx context.Context, path string, line, col int) (*session, protocol.TextDocumentPositionParams, error) {
135+ if line < 1 || col < 1 {
136+ return nil, protocol.TextDocumentPositionParams{}, fmt.Errorf("line and column are 1-based and required (got line %d, column %d)", line, col)
137+ }
138+ s, u, err := c.open(ctx, path)
139+ if err != nil {
140+ return nil, protocol.TextDocumentPositionParams{}, err
141+ }
142+ s.mu.Lock()
143+ text := s.docs[u].text
144+ s.mu.Unlock()
145+ character, err := utf16Column(text, line, col)
146+ if err != nil {
147+ return nil, protocol.TextDocumentPositionParams{}, err
148+ }
149+ return s, protocol.TextDocumentPositionParams{
150+ TextDocument: protocol.TextDocumentIdentifier{URI: u},
151+ Position: protocol.Position{Line: uint32(line - 1), Character: character},
152+ }, nil
153+}
154+
155+// wrap names the server and the request in a failure. A server that
156+// answers "no identifier here" (gopls on a comment, for one) is a miss like
157+// an empty answer, so the caller can try the next occurrence.
158+func (s *session) wrap(op string, err error) error {
159+ msg := err.Error()
160+ if strings.Contains(msg, "no identifier") || strings.Contains(msg, "no object") || strings.Contains(msg, "not an identifier") {
161+ return noResult(fmt.Sprintf("%s: %s: %s", s.name, op, msg))
162+ }
163+ return fmt.Errorf("%s: %s failed: %v", s.name, op, err)
164+}
165+
166+// ErrNoResult marks an answer that is empty rather than wrong: the position
167+// holds nothing the server can talk about. errors.Is(err, ErrNoResult).
168+var ErrNoResult = errors.New("no result")
169+
170+// Miss builds an ErrNoResult with the message to show — for fakes in other
171+// packages' tests; this package uses noResult directly.
172+func Miss(msg string) error { return noResult(msg) }
173+
174+// noResult is ErrNoResult with the message to show.
175+type noResult string
176+
177+func (e noResult) Error() string { return string(e) }
178+func (e noResult) Is(t error) bool { return t == ErrNoResult }
179+
180+// where is `path:line:col` for messages.
181+func where(path string, line, col int) string { return fmt.Sprintf("%s:%d:%d", path, line, col) }
182+
183+// mustAbs is filepath.Abs with the error dropped: the path was opened a
184+// moment ago, so it resolves.
185+func mustAbs(path string) string {
186+ abs, err := filepath.Abs(path)
187+ if err != nil {
188+ return path
189+ }
190+ return abs
191+}
192+
193+// relative shows a path from the project root when it is inside it, as the
194+// model typed it; anything else (a dependency, the standard library) stays
195+// absolute — that is useful information.
196+func (c *Client) relative(abs string) string {
197+ if rel, err := filepath.Rel(c.root, abs); err == nil && !strings.HasPrefix(rel, "..") {
198+ return rel
199+ }
200+ return abs
201+}
modified internal/tools/files.go +27 -22
@@ -52,14 +52,19 @@ func announce(line string) {
5252 })
5353 }
5454
55-// record feeds the loop detector like `bash` does, and appends its instruction
56-// to the output when a loop is detected: the same edit refused three times in
57-// a row is the model going in circles, exactly like a failing command.
58-func record(d *detector.LoopDetector, tool, input, out string) string {
59- if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {
60- return fmt.Sprintf("%s\n\n%s", out, d.LoopError())
55+// record feeds the loop detector and appends its instruction to the output
56+// when a loop is detected: the same edit refused three times in a row is the
57+// model going in circles, exactly like a failing command. When the model has
58+// ignored that instruction as many times as the detector tolerates, the turn
59+// ends: the ONE Go error a tool returns on purpose (see internal/detector).
60+func record(d *detector.LoopDetector, tool, input, out string) (string, error) {
61+ if !d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {
62+ return out, nil
6163 }
62- return out
64+ if d.Stuck() {
65+ return "", d.StuckError(tool)
66+ }
67+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
6368 }
6469
6570 type readFileInput struct {
@@ -91,13 +96,13 @@ Use start/end for a slice of a long file, and numbered=true to get line numbers.
9196 // Information, not a crash: the message says how to fix the call.
9297 out := err.Error()
9398 spinner.Suspend(func() { echoOutput(out) })
94- return record(d, "read_file", where, out), nil
99+ return record(d, "read_file", where, out)
95100 }
96101 out := truncate(content)
97102 // Shown to the user like a command's output: "show me that file"
98103 // means show it, whatever tool read it.
99104 spinner.Suspend(func() { echoOutput(out) })
100- return record(d, "read_file", where, out), nil
105+ return record(d, "read_file", where, out)
101106 })
102107 }
103108
@@ -123,9 +128,9 @@ Returns a one-line summary and the diff against the previous content. Writing id
123128 if err != nil {
124129 out := err.Error()
125130 spinner.Suspend(func() { echoOutput(out) })
126- return record(d, "write_file", in.Path, out), nil
131+ return record(d, "write_file", in.Path, out)
127132 }
128- return report(d, "write_file", in.Path, r), nil
133+ return report(d, "write_file", in.Path, r)
129134 })
130135 }
131136
@@ -162,9 +167,9 @@ Nothing is written when any edit fails. Returns a one-line summary and the diff
162167 if err != nil {
163168 out := err.Error()
164169 spinner.Suspend(func() { echoOutput(out) })
165- return record(d, "edit_file", editKey(in), out), nil
170+ return record(d, "edit_file", editKey(in), out)
166171 }
167- return report(d, "edit_file", editKey(in), r), nil
172+ return report(d, "edit_file", editKey(in), r)
168173 })
169174 }
170175
@@ -217,11 +222,11 @@ func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, wher
217222 if err != nil {
218223 out := err.Error()
219224 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
220- return record(d, "read_file", where, out), nil
225+ return record(d, "read_file", where, out)
221226 }
222227 out := truncate(content)
223228 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
224- return record(d, "read_file", where, out), nil
229+ return record(d, "read_file", where, out)
225230 }
226231
227232 func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) {
@@ -238,7 +243,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
238243 if !s.Allow(ctx, ev) {
239244 out := "[write_file rejected by the user]"
240245 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
241- return record(d, "write_file", in.Path, out), nil
246+ return record(d, "write_file", in.Path, out)
242247 }
243248 s.ToolRunning(ev.ID)
244249
@@ -250,7 +255,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
250255 if err != nil {
251256 out := err.Error()
252257 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
253- return record(d, "write_file", in.Path, out), nil
258+ return record(d, "write_file", in.Path, out)
254259 }
255260 out := resultText(r)
256261 res := ui.ToolResult{Output: out}
@@ -258,7 +263,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
258263 res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil}
259264 }
260265 s.ToolEnd(ev.ID, res)
261- return record(d, "write_file", in.Path, truncate(out)), nil
266+ return record(d, "write_file", in.Path, truncate(out))
262267 }
263268
264269 func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) {
@@ -275,7 +280,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
275280 if !s.Allow(ctx, ev) {
276281 out := "[edit_file rejected by the user]"
277282 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
278- return record(d, "edit_file", editKey(in), out), nil
283+ return record(d, "edit_file", editKey(in), out)
279284 }
280285 s.ToolRunning(ev.ID)
281286
@@ -288,7 +293,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
288293 // model — which reads the rule and fixes its `old`.
289294 out := err.Error()
290295 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
291- return record(d, "edit_file", editKey(in), out), nil
296+ return record(d, "edit_file", editKey(in), out)
292297 }
293298 out := resultText(r)
294299 res := ui.ToolResult{Output: out}
@@ -299,7 +304,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
299304 }
300305 }
301306 s.ToolEnd(ev.ID, res)
302- return record(d, "edit_file", editKey(in), truncate(out)), nil
307+ return record(d, "edit_file", editKey(in), truncate(out))
303308 }
304309
305310 // resultText is what report() builds for the model, without the echo: the
@@ -317,7 +322,7 @@ func resultText(r fileedit.Result) string {
317322 // text to the model, capped by maxOutput. The user sees the change BEFORE the
318323 // model comments on it: what the file became no longer depends on what the
319324 // model chooses to say about it.
320-func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string {
325+func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) (string, error) {
321326 out := resultText(r)
322327 spinner.Suspend(func() { echoOutput(out) })
323328 return record(d, tool, key, truncate(out))
@@ -52,14 +52,19 @@ func announce(line string) {
52 })52 })
53 }53 }
54 54
55-// record feeds the loop detector like `bash` does, and appends its instruction55+// record feeds the loop detector and appends its instruction to the output
56-// to the output when a loop is detected: the same edit refused three times in56+// when a loop is detected: the same edit refused three times in a row is the
57-// a row is the model going in circles, exactly like a failing command.57+// model going in circles, exactly like a failing command. When the model has
58-func record(d *detector.LoopDetector, tool, input, out string) string {58+// ignored that instruction as many times as the detector tolerates, the turn
59- if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {59+// ends: the ONE Go error a tool returns on purpose (see internal/detector).
60- return fmt.Sprintf("%s\n\n%s", out, d.LoopError())60+func record(d *detector.LoopDetector, tool, input, out string) (string, error) {
61+ if !d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {
62+ return out, nil
61 }63 }
62- return out64+ if d.Stuck() {
65+ return "", d.StuckError(tool)
66+ }
67+ return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
63 }68 }
64 69
65 type readFileInput struct {70 type readFileInput struct {
@@ -91,13 +96,13 @@ Use start/end for a slice of a long file, and numbered=true to get line numbers.
91 // Information, not a crash: the message says how to fix the call.96 // Information, not a crash: the message says how to fix the call.
92 out := err.Error()97 out := err.Error()
93 spinner.Suspend(func() { echoOutput(out) })98 spinner.Suspend(func() { echoOutput(out) })
94- return record(d, "read_file", where, out), nil99+ return record(d, "read_file", where, out)
95 }100 }
96 out := truncate(content)101 out := truncate(content)
97 // Shown to the user like a command's output: "show me that file"102 // Shown to the user like a command's output: "show me that file"
98 // means show it, whatever tool read it.103 // means show it, whatever tool read it.
99 spinner.Suspend(func() { echoOutput(out) })104 spinner.Suspend(func() { echoOutput(out) })
100- return record(d, "read_file", where, out), nil105+ return record(d, "read_file", where, out)
101 })106 })
102 }107 }
103 108
@@ -123,9 +128,9 @@ Returns a one-line summary and the diff against the previous content. Writing id
123 if err != nil {128 if err != nil {
124 out := err.Error()129 out := err.Error()
125 spinner.Suspend(func() { echoOutput(out) })130 spinner.Suspend(func() { echoOutput(out) })
126- return record(d, "write_file", in.Path, out), nil131+ return record(d, "write_file", in.Path, out)
127 }132 }
128- return report(d, "write_file", in.Path, r), nil133+ return report(d, "write_file", in.Path, r)
129 })134 })
130 }135 }
131 136
@@ -162,9 +167,9 @@ Nothing is written when any edit fails. Returns a one-line summary and the diff
162 if err != nil {167 if err != nil {
163 out := err.Error()168 out := err.Error()
164 spinner.Suspend(func() { echoOutput(out) })169 spinner.Suspend(func() { echoOutput(out) })
165- return record(d, "edit_file", editKey(in), out), nil170+ return record(d, "edit_file", editKey(in), out)
166 }171 }
167- return report(d, "edit_file", editKey(in), r), nil172+ return report(d, "edit_file", editKey(in), r)
168 })173 })
169 }174 }
170 175
@@ -217,11 +222,11 @@ func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, wher
217 if err != nil {222 if err != nil {
218 out := err.Error()223 out := err.Error()
219 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})224 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
220- return record(d, "read_file", where, out), nil225+ return record(d, "read_file", where, out)
221 }226 }
222 out := truncate(content)227 out := truncate(content)
223 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})228 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
224- return record(d, "read_file", where, out), nil229+ return record(d, "read_file", where, out)
225 }230 }
226 231
227 func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) {232 func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) {
@@ -238,7 +243,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
238 if !s.Allow(ctx, ev) {243 if !s.Allow(ctx, ev) {
239 out := "[write_file rejected by the user]"244 out := "[write_file rejected by the user]"
240 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})245 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
241- return record(d, "write_file", in.Path, out), nil246+ return record(d, "write_file", in.Path, out)
242 }247 }
243 s.ToolRunning(ev.ID)248 s.ToolRunning(ev.ID)
244 249
@@ -250,7 +255,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
250 if err != nil {255 if err != nil {
251 out := err.Error()256 out := err.Error()
252 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})257 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
253- return record(d, "write_file", in.Path, out), nil258+ return record(d, "write_file", in.Path, out)
254 }259 }
255 out := resultText(r)260 out := resultText(r)
256 res := ui.ToolResult{Output: out}261 res := ui.ToolResult{Output: out}
@@ -258,7 +263,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
258 res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil}263 res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil}
259 }264 }
260 s.ToolEnd(ev.ID, res)265 s.ToolEnd(ev.ID, res)
261- return record(d, "write_file", in.Path, truncate(out)), nil266+ return record(d, "write_file", in.Path, truncate(out))
262 }267 }
263 268
264 func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) {269 func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) {
@@ -275,7 +280,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
275 if !s.Allow(ctx, ev) {280 if !s.Allow(ctx, ev) {
276 out := "[edit_file rejected by the user]"281 out := "[edit_file rejected by the user]"
277 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})282 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
278- return record(d, "edit_file", editKey(in), out), nil283+ return record(d, "edit_file", editKey(in), out)
279 }284 }
280 s.ToolRunning(ev.ID)285 s.ToolRunning(ev.ID)
281 286
@@ -288,7 +293,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
288 // model — which reads the rule and fixes its `old`.293 // model — which reads the rule and fixes its `old`.
289 out := err.Error()294 out := err.Error()
290 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})295 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
291- return record(d, "edit_file", editKey(in), out), nil296+ return record(d, "edit_file", editKey(in), out)
292 }297 }
293 out := resultText(r)298 out := resultText(r)
294 res := ui.ToolResult{Output: out}299 res := ui.ToolResult{Output: out}
@@ -299,7 +304,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
299 }304 }
300 }305 }
301 s.ToolEnd(ev.ID, res)306 s.ToolEnd(ev.ID, res)
302- return record(d, "edit_file", editKey(in), truncate(out)), nil307+ return record(d, "edit_file", editKey(in), truncate(out))
303 }308 }
304 309
305 // resultText is what report() builds for the model, without the echo: the310 // resultText is what report() builds for the model, without the echo: the
@@ -317,7 +322,7 @@ func resultText(r fileedit.Result) string {
317 // text to the model, capped by maxOutput. The user sees the change BEFORE the322 // text to the model, capped by maxOutput. The user sees the change BEFORE the
318 // model comments on it: what the file became no longer depends on what the323 // model comments on it: what the file became no longer depends on what the
319 // model chooses to say about it.324 // model chooses to say about it.
320-func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string {325+func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) (string, error) {
321 out := resultText(r)326 out := resultText(r)
322 spinner.Suspend(func() { echoOutput(out) })327 spinner.Suspend(func() { echoOutput(out) })
323 return record(d, tool, key, truncate(out))328 return record(d, tool, key, truncate(out))
added internal/tools/lsp.go +293 -0
new file mode 100644
@@ -0,0 +1,293 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "unicode"
9+
10+ "mm/internal/config"
11+ "mm/internal/detector"
12+ "mm/internal/lsp"
13+ "mm/internal/spinner"
14+ "mm/internal/ui"
15+
16+ "github.com/firebase/genkit/go/ai"
17+ "github.com/firebase/genkit/go/genkit"
18+)
19+
20+// --- lsp ------------------------------------------------------------------------
21+//
22+// One tool, six actions, backed by the project's language servers (config
23+// `lsp.servers`, package internal/lsp). Same contract as the other tools: a
24+// failure — no server for that file, a position off the end of a line, a
25+// server that refuses — comes back AS TEXT, so the model reads it and fixes
26+// its call. Only `rename` writes files, and only it asks for permission where
27+// a front end has a permission dialog.
28+
29+// LSPClient is the slice of *lsp.Client the tool uses, as an interface so
30+// the tool is testable with a canned server.
31+type LSPClient interface {
32+ Definition(ctx context.Context, path string, line, col int) (string, error)
33+ References(ctx context.Context, path string, line, col int) (string, error)
34+ Hover(ctx context.Context, path string, line, col int) (string, error)
35+ Symbols(ctx context.Context, path string) (string, error)
36+ Diagnostics(ctx context.Context, path string) (string, error)
37+ Rename(ctx context.Context, path string, line, col int, newName string) (string, error)
38+ Locate(path string, line int, symbol string) ([]lsp.Pos, error)
39+}
40+
41+// The actions the tool accepts.
42+const (
43+ lspDefinition = "definition"
44+ lspReferences = "references"
45+ lspHover = "hover"
46+ lspSymbols = "symbols"
47+ lspDiagnostics = "diagnostics"
48+ lspRename = "rename"
49+)
50+
51+type lspInput struct {
52+ Action string `json:"action" jsonschema:"enum=definition,enum=references,enum=hover,enum=symbols,enum=diagnostics,enum=rename" jsonschema_description:"What to ask the language server: definition (where is the symbol declared), references (every use of it), hover (its signature, type and documentation), symbols (the outline of a file), diagnostics (errors and warnings in a file), rename (rename the symbol everywhere and write the files)"`
53+ Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"`
54+ Symbol string `json:"symbol,omitempty" jsonschema_description:"The identifier to look at, exactly as written in the code (for example \"hello\" or \"Config\"). The simplest way to point at a symbol: its first occurrence in the file — or on the given line — is used. For definition, references, hover and rename"`
55+ Line int `json:"line,omitempty" jsonschema_description:"Line of the symbol (1-based, as read_file numbered=true shows). Optional with symbol; required with column"`
56+ Column int `json:"column,omitempty" jsonschema_description:"Column of the symbol on that line (1-based, counted in characters), pointing INSIDE the identifier. Only needed when symbol is not given"`
57+ NewName string `json:"new_name,omitempty" jsonschema_description:"The new identifier, for rename only"`
58+}
59+
60+// LSP declares the `lsp` tool over the given servers — the ones whose
61+// command exists, as main filtered them. The description names the servers
62+// and the extensions they cover, so the model knows which files it can ask
63+// about.
64+//
65+// client := lsp.New(cwd, servers)
66+// tool := tools.LSP(e.G, d, client, servers)
67+func LSP(g *genkit.Genkit, d *detector.LoopDetector, client LSPClient, servers []config.LSPServer) ai.ToolRef {
68+ description := fmt.Sprintf(`Ask the project's language server what the compiler knows about the code — faster and surer than grep.
69+Servers available: %s. Only files with these extensions can be asked about.
70+Actions: definition, references and hover need path + symbol (the identifier as written in the code; add line to pick an occurrence) — or path + line + column (1-based, from read_file numbered=true, pointing inside the identifier). symbols and diagnostics need path only. rename needs the symbol (or line + column) + new_name, renames it in every file that uses it and writes the files.
71+Answers are plain text: one "path:line:column <source line>" per location, so you rarely need to open the file afterwards.
72+Use it before editing a symbol used in several files (references), to find where something is declared (definition), to check a type or signature without reading a whole file (hover), and after an edit to see what the compiler reports (diagnostics).`, lsp.Describe(servers))
73+
74+ return genkit.DefineTool(g, "lsp", description,
75+ func(tctx *ai.ToolContext, in lspInput) (string, error) {
76+ in = normalize(in)
77+ what := lspTitle(in)
78+ if s := ui.ActiveSink(); s != nil {
79+ return lspEvented(tctx, s, d, client, in)
80+ }
81+ announce("🔎 lsp: " + what)
82+ spinner.Start("Asking the language server")
83+ out, _ := runLSP(tctx, client, in)
84+ spinner.Suspend(func() { echoOutput(out) })
85+ spinner.Start("Thinking")
86+ return record(d, "lsp", what, out)
87+ })
88+}
89+
90+// lspTitle is the one-line announcement: "definition main.go greet",
91+// "rename main.go:9:14 → greet", "symbols main.go".
92+func lspTitle(in lspInput) string {
93+ at := fmt.Sprintf("%s:%d:%d", in.Path, in.Line, in.Column)
94+ if in.Symbol != "" {
95+ at = in.Path + " " + in.Symbol
96+ if in.Line > 0 {
97+ at = fmt.Sprintf("%s:%d %s", in.Path, in.Line, in.Symbol)
98+ }
99+ }
100+ switch in.Action {
101+ case lspSymbols, lspDiagnostics:
102+ return fmt.Sprintf("%s %s", in.Action, in.Path)
103+ case lspRename:
104+ return fmt.Sprintf("%s %s → %s", in.Action, at, in.NewName)
105+ }
106+ return fmt.Sprintf("%s %s", in.Action, at)
107+}
108+
109+// positions is where a symbol request may point: the given line and column,
110+// or the occurrences of the symbol in the file when the column is missing —
111+// the call a small model gets right. Several occurrences are tried in turn
112+// (code first, comments last) until the server answers.
113+func positions(client LSPClient, path string, in lspInput) ([]lsp.Pos, error) {
114+ if in.Column > 0 {
115+ return []lsp.Pos{{Line: in.Line, Col: in.Column}}, nil
116+ }
117+ if in.Symbol == "" {
118+ return nil, fmt.Errorf("%s needs the symbol (the identifier, as written in the code) — or line and column, 1-based, pointing inside it", in.Action)
119+ }
120+ return client.Locate(path, in.Line, in.Symbol)
121+}
122+
123+// atSymbol runs a symbol action at each candidate position until one gives
124+// a result; a miss everywhere reports the first miss.
125+func atSymbol(ctx context.Context, client LSPClient, path string, in lspInput) (string, error) {
126+ at, err := positions(client, path, in)
127+ if err != nil {
128+ return "", err
129+ }
130+ var first error
131+ for _, p := range at {
132+ var out string
133+ switch in.Action {
134+ case lspDefinition:
135+ out, err = client.Definition(ctx, path, p.Line, p.Col)
136+ case lspReferences:
137+ out, err = client.References(ctx, path, p.Line, p.Col)
138+ case lspHover:
139+ out, err = client.Hover(ctx, path, p.Line, p.Col)
140+ case lspRename:
141+ out, err = client.Rename(ctx, path, p.Line, p.Col, in.NewName)
142+ }
143+ if err == nil {
144+ return out, nil
145+ }
146+ if !errors.Is(err, lsp.ErrNoResult) {
147+ return "", err
148+ }
149+ if first == nil {
150+ first = err
151+ }
152+ }
153+ return "", first
154+}
155+
156+// cleanSymbol reduces what the model sent as a symbol to the identifier it
157+// meant: quotes and stray punctuation cut away (seen: `Greet', ` — a tool
158+// call whose JSON the model mangled), and a `receiver.` or `pkg.` prefix
159+// dropped, since the language server wants the name at the position and
160+// Locate matches whole words.
161+//
162+// cleanSymbol(" Greet', ") // "Greet"
163+// cleanSymbol("`person.Greet()`") // "Greet"
164+func cleanSymbol(s string) string {
165+ runes := []rune(s)
166+ start := 0
167+ for start < len(runes) && !identRune(runes[start]) {
168+ start++
169+ }
170+ end := start
171+ for end < len(runes) && identRune(runes[end]) {
172+ end++
173+ }
174+ s = strings.Trim(string(runes[start:end]), ".")
175+ if i := strings.LastIndex(s, "."); i >= 0 {
176+ s = s[i+1:]
177+ }
178+ return s
179+}
180+
181+// normalize is what the tool does to the model's arguments before anything
182+// else — before the loop detector sees them too, so that twenty variations
183+// of the same mangled call count as the same call. The symbol and the new
184+// name are reduced to identifiers (cleanSymbol); and a rename whose symbol
185+// carries several words while new_name is empty — `"Greet 1 12 Hello"`, a
186+// 12B model's way of writing symbol, line, column and new_name into one
187+// string (seen on 2026-09-18) — takes the first identifier as the symbol
188+// and the last one as the new name.
189+//
190+// normalize(lspInput{Action: "rename", Symbol: "Greet 1 12 Hello"})
191+// // Symbol "Greet", NewName "Hello"
192+func normalize(in lspInput) lspInput {
193+ fields := identifierFields(in.Symbol)
194+ in.Symbol = cleanSymbol(in.Symbol)
195+ in.NewName = cleanSymbol(in.NewName)
196+ if in.Action == lspRename && in.NewName == "" && len(fields) >= 2 && fields[len(fields)-1] != fields[0] {
197+ in.Symbol, in.NewName = fields[0], fields[len(fields)-1]
198+ }
199+ return in
200+}
201+
202+// identifierFields lists the identifier-looking words of s, in order, numbers
203+// left out (a model's stray line and column).
204+func identifierFields(s string) []string {
205+ var out []string
206+ for _, f := range strings.FieldsFunc(s, func(r rune) bool { return !identRune(r) }) {
207+ f = cleanSymbol(f)
208+ if f == "" || strings.Trim(f, "0123456789") == "" {
209+ continue
210+ }
211+ out = append(out, f)
212+ }
213+ return out
214+}
215+
216+// identRune says whether r can be part of an identifier path (`pkg.Name_1`).
217+func identRune(r rune) bool {
218+ if r == '_' || r == '.' {
219+ return true
220+ }
221+ return unicode.IsLetter(r) || unicode.IsDigit(r)
222+}
223+
224+// runLSP performs the action and returns the text for the model; failed says
225+// whether that text is an error (for the front ends that render a status).
226+// The path is resolved against the active session's directory, as the file
227+// tools do, so an editor's project is where the files are looked for.
228+func runLSP(ctx context.Context, client LSPClient, in lspInput) (out string, failed bool) {
229+ path := ui.Resolve(in.Path)
230+ in = normalize(in)
231+ var err error
232+ switch in.Action {
233+ case lspSymbols:
234+ out, err = client.Symbols(ctx, path)
235+ case lspDiagnostics:
236+ out, err = client.Diagnostics(ctx, path)
237+ case lspRename:
238+ if strings.TrimSpace(in.NewName) == "" {
239+ return renameNeedsName(in), true
240+ }
241+ out, err = atSymbol(ctx, client, path, in)
242+ case lspDefinition, lspReferences, lspHover:
243+ out, err = atSymbol(ctx, client, path, in)
244+ default:
245+ return fmt.Sprintf("unknown action %q — use one of: %s", in.Action,
246+ strings.Join([]string{lspDefinition, lspReferences, lspHover, lspSymbols, lspDiagnostics, lspRename}, ", ")), true
247+ }
248+ if err != nil {
249+ return "[lsp: " + err.Error() + "]", true
250+ }
251+ return truncate(out), false
252+}
253+
254+// renameNeedsName is the answer to a rename without new_name: the exact
255+// call to make next, spelled out. Seen on 2026-09-18: told "rename needs
256+// new_name", a 12B model gave up on the tool and fell back to a failing
257+// edit_file loop; told what to send, it sends it.
258+func renameNeedsName(in lspInput) string {
259+ at := fmt.Sprintf(`"symbol": %q`, in.Symbol)
260+ if in.Symbol == "" {
261+ at = fmt.Sprintf(`"line": %d, "column": %d`, in.Line, in.Column)
262+ }
263+ return fmt.Sprintf(`[lsp: rename needs new_name — call lsp again with {"action": "rename", "path": %q, %s, "new_name": "<the new identifier>"}; do not use edit_file for a rename]`, in.Path, at)
264+}
265+
266+// lspEvented is the tool for an event-consuming front end (ACP, the window):
267+// navigation is a "read" that needs no permission, rename is an "edit" that
268+// asks for one, like edit_file.
269+func lspEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, client LSPClient, in lspInput) (string, error) {
270+ what := lspTitle(in)
271+ kind := "read"
272+ if in.Action == lspRename {
273+ kind = "edit"
274+ }
275+ ev := ui.ToolEvent{
276+ ID: ui.NextCallID(),
277+ Tool: "lsp",
278+ Title: "lsp: " + what,
279+ Kind: kind,
280+ Input: map[string]any{"action": in.Action, "path": in.Path, "symbol": in.Symbol, "line": in.Line, "column": in.Column, "new_name": in.NewName},
281+ Path: absolute(ui.Resolve(in.Path)),
282+ }
283+ s.ToolStart(ev)
284+ if in.Action == lspRename && !s.Allow(ctx, ev) {
285+ out := "[rename rejected by the user]"
286+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
287+ return record(d, "lsp", what, out)
288+ }
289+ s.ToolRunning(ev.ID)
290+ out, failed := runLSP(ctx, client, in)
291+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
292+ return record(d, "lsp", what, out)
293+}
new file mode 100644
@@ -0,0 +1,293 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "unicode"
9+
10+ "mm/internal/config"
11+ "mm/internal/detector"
12+ "mm/internal/lsp"
13+ "mm/internal/spinner"
14+ "mm/internal/ui"
15+
16+ "github.com/firebase/genkit/go/ai"
17+ "github.com/firebase/genkit/go/genkit"
18+)
19+
20+// --- lsp ------------------------------------------------------------------------
21+//
22+// One tool, six actions, backed by the project's language servers (config
23+// `lsp.servers`, package internal/lsp). Same contract as the other tools: a
24+// failure — no server for that file, a position off the end of a line, a
25+// server that refuses — comes back AS TEXT, so the model reads it and fixes
26+// its call. Only `rename` writes files, and only it asks for permission where
27+// a front end has a permission dialog.
28+
29+// LSPClient is the slice of *lsp.Client the tool uses, as an interface so
30+// the tool is testable with a canned server.
31+type LSPClient interface {
32+ Definition(ctx context.Context, path string, line, col int) (string, error)
33+ References(ctx context.Context, path string, line, col int) (string, error)
34+ Hover(ctx context.Context, path string, line, col int) (string, error)
35+ Symbols(ctx context.Context, path string) (string, error)
36+ Diagnostics(ctx context.Context, path string) (string, error)
37+ Rename(ctx context.Context, path string, line, col int, newName string) (string, error)
38+ Locate(path string, line int, symbol string) ([]lsp.Pos, error)
39+}
40+
41+// The actions the tool accepts.
42+const (
43+ lspDefinition = "definition"
44+ lspReferences = "references"
45+ lspHover = "hover"
46+ lspSymbols = "symbols"
47+ lspDiagnostics = "diagnostics"
48+ lspRename = "rename"
49+)
50+
51+type lspInput struct {
52+ Action string `json:"action" jsonschema:"enum=definition,enum=references,enum=hover,enum=symbols,enum=diagnostics,enum=rename" jsonschema_description:"What to ask the language server: definition (where is the symbol declared), references (every use of it), hover (its signature, type and documentation), symbols (the outline of a file), diagnostics (errors and warnings in a file), rename (rename the symbol everywhere and write the files)"`
53+ Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"`
54+ Symbol string `json:"symbol,omitempty" jsonschema_description:"The identifier to look at, exactly as written in the code (for example \"hello\" or \"Config\"). The simplest way to point at a symbol: its first occurrence in the file — or on the given line — is used. For definition, references, hover and rename"`
55+ Line int `json:"line,omitempty" jsonschema_description:"Line of the symbol (1-based, as read_file numbered=true shows). Optional with symbol; required with column"`
56+ Column int `json:"column,omitempty" jsonschema_description:"Column of the symbol on that line (1-based, counted in characters), pointing INSIDE the identifier. Only needed when symbol is not given"`
57+ NewName string `json:"new_name,omitempty" jsonschema_description:"The new identifier, for rename only"`
58+}
59+
60+// LSP declares the `lsp` tool over the given servers — the ones whose
61+// command exists, as main filtered them. The description names the servers
62+// and the extensions they cover, so the model knows which files it can ask
63+// about.
64+//
65+// client := lsp.New(cwd, servers)
66+// tool := tools.LSP(e.G, d, client, servers)
67+func LSP(g *genkit.Genkit, d *detector.LoopDetector, client LSPClient, servers []config.LSPServer) ai.ToolRef {
68+ description := fmt.Sprintf(`Ask the project's language server what the compiler knows about the code — faster and surer than grep.
69+Servers available: %s. Only files with these extensions can be asked about.
70+Actions: definition, references and hover need path + symbol (the identifier as written in the code; add line to pick an occurrence) — or path + line + column (1-based, from read_file numbered=true, pointing inside the identifier). symbols and diagnostics need path only. rename needs the symbol (or line + column) + new_name, renames it in every file that uses it and writes the files.
71+Answers are plain text: one "path:line:column <source line>" per location, so you rarely need to open the file afterwards.
72+Use it before editing a symbol used in several files (references), to find where something is declared (definition), to check a type or signature without reading a whole file (hover), and after an edit to see what the compiler reports (diagnostics).`, lsp.Describe(servers))
73+
74+ return genkit.DefineTool(g, "lsp", description,
75+ func(tctx *ai.ToolContext, in lspInput) (string, error) {
76+ in = normalize(in)
77+ what := lspTitle(in)
78+ if s := ui.ActiveSink(); s != nil {
79+ return lspEvented(tctx, s, d, client, in)
80+ }
81+ announce("🔎 lsp: " + what)
82+ spinner.Start("Asking the language server")
83+ out, _ := runLSP(tctx, client, in)
84+ spinner.Suspend(func() { echoOutput(out) })
85+ spinner.Start("Thinking")
86+ return record(d, "lsp", what, out)
87+ })
88+}
89+
90+// lspTitle is the one-line announcement: "definition main.go greet",
91+// "rename main.go:9:14 → greet", "symbols main.go".
92+func lspTitle(in lspInput) string {
93+ at := fmt.Sprintf("%s:%d:%d", in.Path, in.Line, in.Column)
94+ if in.Symbol != "" {
95+ at = in.Path + " " + in.Symbol
96+ if in.Line > 0 {
97+ at = fmt.Sprintf("%s:%d %s", in.Path, in.Line, in.Symbol)
98+ }
99+ }
100+ switch in.Action {
101+ case lspSymbols, lspDiagnostics:
102+ return fmt.Sprintf("%s %s", in.Action, in.Path)
103+ case lspRename:
104+ return fmt.Sprintf("%s %s → %s", in.Action, at, in.NewName)
105+ }
106+ return fmt.Sprintf("%s %s", in.Action, at)
107+}
108+
109+// positions is where a symbol request may point: the given line and column,
110+// or the occurrences of the symbol in the file when the column is missing —
111+// the call a small model gets right. Several occurrences are tried in turn
112+// (code first, comments last) until the server answers.
113+func positions(client LSPClient, path string, in lspInput) ([]lsp.Pos, error) {
114+ if in.Column > 0 {
115+ return []lsp.Pos{{Line: in.Line, Col: in.Column}}, nil
116+ }
117+ if in.Symbol == "" {
118+ return nil, fmt.Errorf("%s needs the symbol (the identifier, as written in the code) — or line and column, 1-based, pointing inside it", in.Action)
119+ }
120+ return client.Locate(path, in.Line, in.Symbol)
121+}
122+
123+// atSymbol runs a symbol action at each candidate position until one gives
124+// a result; a miss everywhere reports the first miss.
125+func atSymbol(ctx context.Context, client LSPClient, path string, in lspInput) (string, error) {
126+ at, err := positions(client, path, in)
127+ if err != nil {
128+ return "", err
129+ }
130+ var first error
131+ for _, p := range at {
132+ var out string
133+ switch in.Action {
134+ case lspDefinition:
135+ out, err = client.Definition(ctx, path, p.Line, p.Col)
136+ case lspReferences:
137+ out, err = client.References(ctx, path, p.Line, p.Col)
138+ case lspHover:
139+ out, err = client.Hover(ctx, path, p.Line, p.Col)
140+ case lspRename:
141+ out, err = client.Rename(ctx, path, p.Line, p.Col, in.NewName)
142+ }
143+ if err == nil {
144+ return out, nil
145+ }
146+ if !errors.Is(err, lsp.ErrNoResult) {
147+ return "", err
148+ }
149+ if first == nil {
150+ first = err
151+ }
152+ }
153+ return "", first
154+}
155+
156+// cleanSymbol reduces what the model sent as a symbol to the identifier it
157+// meant: quotes and stray punctuation cut away (seen: `Greet', ` — a tool
158+// call whose JSON the model mangled), and a `receiver.` or `pkg.` prefix
159+// dropped, since the language server wants the name at the position and
160+// Locate matches whole words.
161+//
162+// cleanSymbol(" Greet', ") // "Greet"
163+// cleanSymbol("`person.Greet()`") // "Greet"
164+func cleanSymbol(s string) string {
165+ runes := []rune(s)
166+ start := 0
167+ for start < len(runes) && !identRune(runes[start]) {
168+ start++
169+ }
170+ end := start
171+ for end < len(runes) && identRune(runes[end]) {
172+ end++
173+ }
174+ s = strings.Trim(string(runes[start:end]), ".")
175+ if i := strings.LastIndex(s, "."); i >= 0 {
176+ s = s[i+1:]
177+ }
178+ return s
179+}
180+
181+// normalize is what the tool does to the model's arguments before anything
182+// else — before the loop detector sees them too, so that twenty variations
183+// of the same mangled call count as the same call. The symbol and the new
184+// name are reduced to identifiers (cleanSymbol); and a rename whose symbol
185+// carries several words while new_name is empty — `"Greet 1 12 Hello"`, a
186+// 12B model's way of writing symbol, line, column and new_name into one
187+// string (seen on 2026-09-18) — takes the first identifier as the symbol
188+// and the last one as the new name.
189+//
190+// normalize(lspInput{Action: "rename", Symbol: "Greet 1 12 Hello"})
191+// // Symbol "Greet", NewName "Hello"
192+func normalize(in lspInput) lspInput {
193+ fields := identifierFields(in.Symbol)
194+ in.Symbol = cleanSymbol(in.Symbol)
195+ in.NewName = cleanSymbol(in.NewName)
196+ if in.Action == lspRename && in.NewName == "" && len(fields) >= 2 && fields[len(fields)-1] != fields[0] {
197+ in.Symbol, in.NewName = fields[0], fields[len(fields)-1]
198+ }
199+ return in
200+}
201+
202+// identifierFields lists the identifier-looking words of s, in order, numbers
203+// left out (a model's stray line and column).
204+func identifierFields(s string) []string {
205+ var out []string
206+ for _, f := range strings.FieldsFunc(s, func(r rune) bool { return !identRune(r) }) {
207+ f = cleanSymbol(f)
208+ if f == "" || strings.Trim(f, "0123456789") == "" {
209+ continue
210+ }
211+ out = append(out, f)
212+ }
213+ return out
214+}
215+
216+// identRune says whether r can be part of an identifier path (`pkg.Name_1`).
217+func identRune(r rune) bool {
218+ if r == '_' || r == '.' {
219+ return true
220+ }
221+ return unicode.IsLetter(r) || unicode.IsDigit(r)
222+}
223+
224+// runLSP performs the action and returns the text for the model; failed says
225+// whether that text is an error (for the front ends that render a status).
226+// The path is resolved against the active session's directory, as the file
227+// tools do, so an editor's project is where the files are looked for.
228+func runLSP(ctx context.Context, client LSPClient, in lspInput) (out string, failed bool) {
229+ path := ui.Resolve(in.Path)
230+ in = normalize(in)
231+ var err error
232+ switch in.Action {
233+ case lspSymbols:
234+ out, err = client.Symbols(ctx, path)
235+ case lspDiagnostics:
236+ out, err = client.Diagnostics(ctx, path)
237+ case lspRename:
238+ if strings.TrimSpace(in.NewName) == "" {
239+ return renameNeedsName(in), true
240+ }
241+ out, err = atSymbol(ctx, client, path, in)
242+ case lspDefinition, lspReferences, lspHover:
243+ out, err = atSymbol(ctx, client, path, in)
244+ default:
245+ return fmt.Sprintf("unknown action %q — use one of: %s", in.Action,
246+ strings.Join([]string{lspDefinition, lspReferences, lspHover, lspSymbols, lspDiagnostics, lspRename}, ", ")), true
247+ }
248+ if err != nil {
249+ return "[lsp: " + err.Error() + "]", true
250+ }
251+ return truncate(out), false
252+}
253+
254+// renameNeedsName is the answer to a rename without new_name: the exact
255+// call to make next, spelled out. Seen on 2026-09-18: told "rename needs
256+// new_name", a 12B model gave up on the tool and fell back to a failing
257+// edit_file loop; told what to send, it sends it.
258+func renameNeedsName(in lspInput) string {
259+ at := fmt.Sprintf(`"symbol": %q`, in.Symbol)
260+ if in.Symbol == "" {
261+ at = fmt.Sprintf(`"line": %d, "column": %d`, in.Line, in.Column)
262+ }
263+ return fmt.Sprintf(`[lsp: rename needs new_name — call lsp again with {"action": "rename", "path": %q, %s, "new_name": "<the new identifier>"}; do not use edit_file for a rename]`, in.Path, at)
264+}
265+
266+// lspEvented is the tool for an event-consuming front end (ACP, the window):
267+// navigation is a "read" that needs no permission, rename is an "edit" that
268+// asks for one, like edit_file.
269+func lspEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, client LSPClient, in lspInput) (string, error) {
270+ what := lspTitle(in)
271+ kind := "read"
272+ if in.Action == lspRename {
273+ kind = "edit"
274+ }
275+ ev := ui.ToolEvent{
276+ ID: ui.NextCallID(),
277+ Tool: "lsp",
278+ Title: "lsp: " + what,
279+ Kind: kind,
280+ Input: map[string]any{"action": in.Action, "path": in.Path, "symbol": in.Symbol, "line": in.Line, "column": in.Column, "new_name": in.NewName},
281+ Path: absolute(ui.Resolve(in.Path)),
282+ }
283+ s.ToolStart(ev)
284+ if in.Action == lspRename && !s.Allow(ctx, ev) {
285+ out := "[rename rejected by the user]"
286+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
287+ return record(d, "lsp", what, out)
288+ }
289+ s.ToolRunning(ev.ID)
290+ out, failed := runLSP(ctx, client, in)
291+ s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
292+ return record(d, "lsp", what, out)
293+}
added internal/tools/lsp_test.go +183 -0
new file mode 100644
@@ -0,0 +1,183 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/lsp"
11+)
12+
13+// cannedLSP answers every action with its name and the arguments it got,
14+// and fails on request.
15+type cannedLSP struct{ fail error }
16+
17+// Locate pretends every symbol sits at line 7, column 3 — or on the given
18+// line — and refuses "nowhere". "twice" has a first occurrence the server
19+// knows nothing about (line 2) before the real one.
20+func (c cannedLSP) Locate(_ string, line int, symbol string) ([]lsp.Pos, error) {
21+ switch {
22+ case symbol == "nowhere":
23+ return nil, errors.New(`"nowhere" not found in a.go`)
24+ case symbol == "twice":
25+ return []lsp.Pos{{Line: 2, Col: 4}, {Line: 7, Col: 3}}, nil
26+ case line > 0:
27+ return []lsp.Pos{{Line: line, Col: 3}}, nil
28+ }
29+ return []lsp.Pos{{Line: 7, Col: 3}}, nil
30+}
31+
32+func (c cannedLSP) reply(action, path string, rest ...any) (string, error) {
33+ if c.fail != nil {
34+ return "", c.fail
35+ }
36+ if len(rest) > 0 && rest[0] == 2 { // line 2 is the comment mention of "twice"
37+ return "", lsp.Miss("nothing at line 2")
38+ }
39+ return strings.TrimSpace(fmt.Sprintln(append([]any{action, path}, rest...)...)), nil
40+}
41+
42+func (c cannedLSP) Definition(_ context.Context, p string, l, col int) (string, error) {
43+ return c.reply("definition", p, l, col)
44+}
45+func (c cannedLSP) References(_ context.Context, p string, l, col int) (string, error) {
46+ return c.reply("references", p, l, col)
47+}
48+func (c cannedLSP) Hover(_ context.Context, p string, l, col int) (string, error) {
49+ return c.reply("hover", p, l, col)
50+}
51+func (c cannedLSP) Symbols(_ context.Context, p string) (string, error) { return c.reply("symbols", p) }
52+func (c cannedLSP) Diagnostics(_ context.Context, p string) (string, error) {
53+ return c.reply("diagnostics", p)
54+}
55+func (c cannedLSP) Rename(_ context.Context, p string, l, col int, n string) (string, error) {
56+ return c.reply("rename", p, l, col, n)
57+}
58+
59+// Each action reaches its method with its arguments; an unknown action and
60+// a server failure come back as text flagged failed, never as a Go error.
61+func TestRunLSPDispatchesAndReportsFailuresAsText(t *testing.T) {
62+ ctx := context.Background()
63+ for _, tc := range []struct {
64+ in lspInput
65+ want string
66+ }{
67+ {lspInput{Action: "definition", Path: "a.go", Line: 3, Column: 4}, "definition a.go 3 4"},
68+ {lspInput{Action: "references", Path: "a.go", Line: 3, Column: 4}, "references a.go 3 4"},
69+ {lspInput{Action: "hover", Path: "a.go", Line: 3, Column: 4}, "hover a.go 3 4"},
70+ {lspInput{Action: "symbols", Path: "a.go"}, "symbols a.go"},
71+ {lspInput{Action: "diagnostics", Path: "a.go"}, "diagnostics a.go"},
72+ {lspInput{Action: "rename", Path: "a.go", Line: 3, Column: 4, NewName: "b"}, "rename a.go 3 4 b"},
73+ // The symbol replaces the position; a line narrows it.
74+ {lspInput{Action: "definition", Path: "a.go", Symbol: "hello"}, "definition a.go 7 3"},
75+ {lspInput{Action: "references", Path: "a.go", Symbol: "hello", Line: 12}, "references a.go 12 3"},
76+ {lspInput{Action: "rename", Path: "a.go", Symbol: "hello", NewName: "b"}, "rename a.go 7 3 b"},
77+ // The first occurrence yields nothing: the next one is tried.
78+ {lspInput{Action: "definition", Path: "a.go", Symbol: "twice"}, "definition a.go 7 3"},
79+ } {
80+ out, failed := runLSP(ctx, cannedLSP{}, tc.in)
81+ if failed || out != tc.want {
82+ t.Errorf("%s: %q (failed %v), want %q", tc.in.Action, out, failed, tc.want)
83+ }
84+ }
85+ // No column and no symbol: the text says what to send.
86+ out, failed := runLSP(ctx, cannedLSP{}, lspInput{Action: "hover", Path: "a.go", Line: 9})
87+ if !failed || out != "[lsp: hover needs the symbol (the identifier, as written in the code) — or line and column, 1-based, pointing inside it]" {
88+ t.Errorf("no position: %q (failed %v)", out, failed)
89+ }
90+ out, failed = runLSP(ctx, cannedLSP{}, lspInput{Action: "hover", Path: "a.go", Symbol: "nowhere"})
91+ if !failed || out != `[lsp: "nowhere" not found in a.go]` {
92+ t.Errorf("unknown symbol: %q (failed %v)", out, failed)
93+ }
94+ // A rename without new_name gets the call to make, not a bare complaint.
95+ out, failed = runLSP(ctx, cannedLSP{}, lspInput{Action: "rename", Path: "main.go", Symbol: "Greet"})
96+ if !failed || out != `[lsp: rename needs new_name — call lsp again with {"action": "rename", "path": "main.go", "symbol": "Greet", "new_name": "<the new identifier>"}; do not use edit_file for a rename]` {
97+ t.Errorf("rename without new_name: %q (failed %v)", out, failed)
98+ }
99+ out, failed = runLSP(ctx, cannedLSP{}, lspInput{Action: "explode", Path: "a.go"})
100+ if !failed || !strings.HasPrefix(out, `unknown action "explode" — use one of: definition, references`) {
101+ t.Errorf("unknown action: %q (failed %v)", out, failed)
102+ }
103+ out, failed = runLSP(ctx, cannedLSP{fail: errors.New("gopls: hover failed: no server")}, lspInput{Action: "hover", Path: "a.go", Line: 1, Column: 1})
104+ if !failed || out != "[lsp: gopls: hover failed: no server]" {
105+ t.Errorf("server failure: %q (failed %v)", out, failed)
106+ }
107+}
108+
109+// What the model sends as a symbol is reduced to the identifier it meant.
110+func TestCleanSymbol(t *testing.T) {
111+ for in, want := range map[string]string{
112+ "Greet": "Greet",
113+ " Greet', ": "Greet",
114+ "person.Greet": "Greet",
115+ "`Greet()`": "Greet",
116+ "héllo_1": "héllo_1",
117+ "\"Config\"": "Config",
118+ "fmt.Println(": "Println",
119+ " ": "",
120+ "pkg.Type.Field": "Field",
121+ } {
122+ if got := cleanSymbol(in); got != want {
123+ t.Errorf("cleanSymbol(%q) = %q, want %q", in, got, want)
124+ }
125+ }
126+ // A mangled symbol still reaches the server as the identifier.
127+ out, failed := runLSP(context.Background(), cannedLSP{}, lspInput{Action: "definition", Path: "a.go", Symbol: "Greet', "})
128+ if failed || out != "definition a.go 7 3" {
129+ t.Errorf("mangled symbol: %q (failed %v)", out, failed)
130+ }
131+}
132+
133+// A rename whose new name the model wrote into the symbol string is
134+// recovered; the detector's key (the title) sees the normalised call, so the
135+// growing variants of one mangled call count as the same call.
136+func TestNormalizeRecoversARenameFromAMangledSymbol(t *testing.T) {
137+ for _, tc := range []struct {
138+ in lspInput
139+ symbol, name string
140+ }{
141+ {lspInput{Action: "rename", Symbol: "Greet 1 12 Hello"}, "Greet", "Hello"},
142+ {lspInput{Action: "rename", Symbol: "Greet 1 12 Hello 1 12 Hello 1 12 Hello"}, "Greet", "Hello"},
143+ {lspInput{Action: "rename", Symbol: "Greet', ", NewName: "Hello"}, "Greet", "Hello"},
144+ {lspInput{Action: "rename", Symbol: "Greet"}, "Greet", ""},
145+ {lspInput{Action: "rename", Symbol: "Greet Greet"}, "Greet", ""},
146+ {lspInput{Action: "references", Symbol: "Greet 1 12 Hello"}, "Greet", ""},
147+ {lspInput{Action: "rename", Symbol: "person.Greet", NewName: "`Hello()`"}, "Greet", "Hello"},
148+ } {
149+ got := normalize(tc.in)
150+ if got.Symbol != tc.symbol || got.NewName != tc.name {
151+ t.Errorf("normalize(%+v) = symbol %q new_name %q, want %q %q", tc.in, got.Symbol, got.NewName, tc.symbol, tc.name)
152+ }
153+ }
154+ out, failed := runLSP(context.Background(), cannedLSP{}, lspInput{Action: "rename", Path: "a.go", Symbol: "Greet 1 12 Hello"})
155+ if failed || out != "rename a.go 7 3 Hello" {
156+ t.Errorf("mangled rename: %q (failed %v)", out, failed)
157+ }
158+ a := lspTitle(normalize(lspInput{Action: "rename", Path: "main.go", Symbol: "Greet 1 12 Hello"}))
159+ b := lspTitle(normalize(lspInput{Action: "rename", Path: "main.go", Symbol: "Greet 1 12 Hello 1 12 Hello"}))
160+ if a != b || a != "rename main.go Greet → Hello" {
161+ t.Errorf("titles differ for one mangled call: %q vs %q", a, b)
162+ }
163+}
164+
165+// The announcement line names the action and what it applies to.
166+func TestLSPTitle(t *testing.T) {
167+ for _, tc := range []struct {
168+ in lspInput
169+ want string
170+ }{
171+ {lspInput{Action: "definition", Path: "main.go", Line: 9, Column: 14}, "definition main.go:9:14"},
172+ {lspInput{Action: "definition", Path: "main.go", Symbol: "greet"}, "definition main.go greet"},
173+ {lspInput{Action: "hover", Path: "main.go", Symbol: "greet", Line: 9}, "hover main.go:9 greet"},
174+ {lspInput{Action: "rename", Path: "main.go", Symbol: "hello", NewName: "greet"}, "rename main.go hello → greet"},
175+ {lspInput{Action: "symbols", Path: "main.go"}, "symbols main.go"},
176+ {lspInput{Action: "diagnostics", Path: "main.go"}, "diagnostics main.go"},
177+ {lspInput{Action: "rename", Path: "main.go", Line: 9, Column: 14, NewName: "greet"}, "rename main.go:9:14 → greet"},
178+ } {
179+ if got := lspTitle(tc.in); got != tc.want {
180+ t.Errorf("%s: %q, want %q", tc.in.Action, got, tc.want)
181+ }
182+ }
183+}
new file mode 100644
@@ -0,0 +1,183 @@
1+package tools
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/lsp"
11+)
12+
13+// cannedLSP answers every action with its name and the arguments it got,
14+// and fails on request.
15+type cannedLSP struct{ fail error }
16+
17+// Locate pretends every symbol sits at line 7, column 3 — or on the given
18+// line — and refuses "nowhere". "twice" has a first occurrence the server
19+// knows nothing about (line 2) before the real one.
20+func (c cannedLSP) Locate(_ string, line int, symbol string) ([]lsp.Pos, error) {
21+ switch {
22+ case symbol == "nowhere":
23+ return nil, errors.New(`"nowhere" not found in a.go`)
24+ case symbol == "twice":
25+ return []lsp.Pos{{Line: 2, Col: 4}, {Line: 7, Col: 3}}, nil
26+ case line > 0:
27+ return []lsp.Pos{{Line: line, Col: 3}}, nil
28+ }
29+ return []lsp.Pos{{Line: 7, Col: 3}}, nil
30+}
31+
32+func (c cannedLSP) reply(action, path string, rest ...any) (string, error) {
33+ if c.fail != nil {
34+ return "", c.fail
35+ }
36+ if len(rest) > 0 && rest[0] == 2 { // line 2 is the comment mention of "twice"
37+ return "", lsp.Miss("nothing at line 2")
38+ }
39+ return strings.TrimSpace(fmt.Sprintln(append([]any{action, path}, rest...)...)), nil
40+}
41+
42+func (c cannedLSP) Definition(_ context.Context, p string, l, col int) (string, error) {
43+ return c.reply("definition", p, l, col)
44+}
45+func (c cannedLSP) References(_ context.Context, p string, l, col int) (string, error) {
46+ return c.reply("references", p, l, col)
47+}
48+func (c cannedLSP) Hover(_ context.Context, p string, l, col int) (string, error) {
49+ return c.reply("hover", p, l, col)
50+}
51+func (c cannedLSP) Symbols(_ context.Context, p string) (string, error) { return c.reply("symbols", p) }
52+func (c cannedLSP) Diagnostics(_ context.Context, p string) (string, error) {
53+ return c.reply("diagnostics", p)
54+}
55+func (c cannedLSP) Rename(_ context.Context, p string, l, col int, n string) (string, error) {
56+ return c.reply("rename", p, l, col, n)
57+}
58+
59+// Each action reaches its method with its arguments; an unknown action and
60+// a server failure come back as text flagged failed, never as a Go error.
61+func TestRunLSPDispatchesAndReportsFailuresAsText(t *testing.T) {
62+ ctx := context.Background()
63+ for _, tc := range []struct {
64+ in lspInput
65+ want string
66+ }{
67+ {lspInput{Action: "definition", Path: "a.go", Line: 3, Column: 4}, "definition a.go 3 4"},
68+ {lspInput{Action: "references", Path: "a.go", Line: 3, Column: 4}, "references a.go 3 4"},
69+ {lspInput{Action: "hover", Path: "a.go", Line: 3, Column: 4}, "hover a.go 3 4"},
70+ {lspInput{Action: "symbols", Path: "a.go"}, "symbols a.go"},
71+ {lspInput{Action: "diagnostics", Path: "a.go"}, "diagnostics a.go"},
72+ {lspInput{Action: "rename", Path: "a.go", Line: 3, Column: 4, NewName: "b"}, "rename a.go 3 4 b"},
73+ // The symbol replaces the position; a line narrows it.
74+ {lspInput{Action: "definition", Path: "a.go", Symbol: "hello"}, "definition a.go 7 3"},
75+ {lspInput{Action: "references", Path: "a.go", Symbol: "hello", Line: 12}, "references a.go 12 3"},
76+ {lspInput{Action: "rename", Path: "a.go", Symbol: "hello", NewName: "b"}, "rename a.go 7 3 b"},
77+ // The first occurrence yields nothing: the next one is tried.
78+ {lspInput{Action: "definition", Path: "a.go", Symbol: "twice"}, "definition a.go 7 3"},
79+ } {
80+ out, failed := runLSP(ctx, cannedLSP{}, tc.in)
81+ if failed || out != tc.want {
82+ t.Errorf("%s: %q (failed %v), want %q", tc.in.Action, out, failed, tc.want)
83+ }
84+ }
85+ // No column and no symbol: the text says what to send.
86+ out, failed := runLSP(ctx, cannedLSP{}, lspInput{Action: "hover", Path: "a.go", Line: 9})
87+ if !failed || out != "[lsp: hover needs the symbol (the identifier, as written in the code) — or line and column, 1-based, pointing inside it]" {
88+ t.Errorf("no position: %q (failed %v)", out, failed)
89+ }
90+ out, failed = runLSP(ctx, cannedLSP{}, lspInput{Action: "hover", Path: "a.go", Symbol: "nowhere"})
91+ if !failed || out != `[lsp: "nowhere" not found in a.go]` {
92+ t.Errorf("unknown symbol: %q (failed %v)", out, failed)
93+ }
94+ // A rename without new_name gets the call to make, not a bare complaint.
95+ out, failed = runLSP(ctx, cannedLSP{}, lspInput{Action: "rename", Path: "main.go", Symbol: "Greet"})
96+ if !failed || out != `[lsp: rename needs new_name — call lsp again with {"action": "rename", "path": "main.go", "symbol": "Greet", "new_name": "<the new identifier>"}; do not use edit_file for a rename]` {
97+ t.Errorf("rename without new_name: %q (failed %v)", out, failed)
98+ }
99+ out, failed = runLSP(ctx, cannedLSP{}, lspInput{Action: "explode", Path: "a.go"})
100+ if !failed || !strings.HasPrefix(out, `unknown action "explode" — use one of: definition, references`) {
101+ t.Errorf("unknown action: %q (failed %v)", out, failed)
102+ }
103+ out, failed = runLSP(ctx, cannedLSP{fail: errors.New("gopls: hover failed: no server")}, lspInput{Action: "hover", Path: "a.go", Line: 1, Column: 1})
104+ if !failed || out != "[lsp: gopls: hover failed: no server]" {
105+ t.Errorf("server failure: %q (failed %v)", out, failed)
106+ }
107+}
108+
109+// What the model sends as a symbol is reduced to the identifier it meant.
110+func TestCleanSymbol(t *testing.T) {
111+ for in, want := range map[string]string{
112+ "Greet": "Greet",
113+ " Greet', ": "Greet",
114+ "person.Greet": "Greet",
115+ "`Greet()`": "Greet",
116+ "héllo_1": "héllo_1",
117+ "\"Config\"": "Config",
118+ "fmt.Println(": "Println",
119+ " ": "",
120+ "pkg.Type.Field": "Field",
121+ } {
122+ if got := cleanSymbol(in); got != want {
123+ t.Errorf("cleanSymbol(%q) = %q, want %q", in, got, want)
124+ }
125+ }
126+ // A mangled symbol still reaches the server as the identifier.
127+ out, failed := runLSP(context.Background(), cannedLSP{}, lspInput{Action: "definition", Path: "a.go", Symbol: "Greet', "})
128+ if failed || out != "definition a.go 7 3" {
129+ t.Errorf("mangled symbol: %q (failed %v)", out, failed)
130+ }
131+}
132+
133+// A rename whose new name the model wrote into the symbol string is
134+// recovered; the detector's key (the title) sees the normalised call, so the
135+// growing variants of one mangled call count as the same call.
136+func TestNormalizeRecoversARenameFromAMangledSymbol(t *testing.T) {
137+ for _, tc := range []struct {
138+ in lspInput
139+ symbol, name string
140+ }{
141+ {lspInput{Action: "rename", Symbol: "Greet 1 12 Hello"}, "Greet", "Hello"},
142+ {lspInput{Action: "rename", Symbol: "Greet 1 12 Hello 1 12 Hello 1 12 Hello"}, "Greet", "Hello"},
143+ {lspInput{Action: "rename", Symbol: "Greet', ", NewName: "Hello"}, "Greet", "Hello"},
144+ {lspInput{Action: "rename", Symbol: "Greet"}, "Greet", ""},
145+ {lspInput{Action: "rename", Symbol: "Greet Greet"}, "Greet", ""},
146+ {lspInput{Action: "references", Symbol: "Greet 1 12 Hello"}, "Greet", ""},
147+ {lspInput{Action: "rename", Symbol: "person.Greet", NewName: "`Hello()`"}, "Greet", "Hello"},
148+ } {
149+ got := normalize(tc.in)
150+ if got.Symbol != tc.symbol || got.NewName != tc.name {
151+ t.Errorf("normalize(%+v) = symbol %q new_name %q, want %q %q", tc.in, got.Symbol, got.NewName, tc.symbol, tc.name)
152+ }
153+ }
154+ out, failed := runLSP(context.Background(), cannedLSP{}, lspInput{Action: "rename", Path: "a.go", Symbol: "Greet 1 12 Hello"})
155+ if failed || out != "rename a.go 7 3 Hello" {
156+ t.Errorf("mangled rename: %q (failed %v)", out, failed)
157+ }
158+ a := lspTitle(normalize(lspInput{Action: "rename", Path: "main.go", Symbol: "Greet 1 12 Hello"}))
159+ b := lspTitle(normalize(lspInput{Action: "rename", Path: "main.go", Symbol: "Greet 1 12 Hello 1 12 Hello"}))
160+ if a != b || a != "rename main.go Greet → Hello" {
161+ t.Errorf("titles differ for one mangled call: %q vs %q", a, b)
162+ }
163+}
164+
165+// The announcement line names the action and what it applies to.
166+func TestLSPTitle(t *testing.T) {
167+ for _, tc := range []struct {
168+ in lspInput
169+ want string
170+ }{
171+ {lspInput{Action: "definition", Path: "main.go", Line: 9, Column: 14}, "definition main.go:9:14"},
172+ {lspInput{Action: "definition", Path: "main.go", Symbol: "greet"}, "definition main.go greet"},
173+ {lspInput{Action: "hover", Path: "main.go", Symbol: "greet", Line: 9}, "hover main.go:9 greet"},
174+ {lspInput{Action: "rename", Path: "main.go", Symbol: "hello", NewName: "greet"}, "rename main.go hello → greet"},
175+ {lspInput{Action: "symbols", Path: "main.go"}, "symbols main.go"},
176+ {lspInput{Action: "diagnostics", Path: "main.go"}, "diagnostics main.go"},
177+ {lspInput{Action: "rename", Path: "main.go", Line: 9, Column: 14, NewName: "greet"}, "rename main.go:9:14 → greet"},
178+ } {
179+ if got := lspTitle(tc.in); got != tc.want {
180+ t.Errorf("%s: %q, want %q", tc.in.Action, got, tc.want)
181+ }
182+ }
183+}
modified internal/tools/tools.go +9 -22
@@ -169,16 +169,9 @@ Read before you write: if you do not know a path or the content of a file, run a
169169
170170 // The loop detector sees the command AND its output: the same pair
171171 // twice in a row, and it hands the model back an instruction to
172- // change its approach (see internal/detector).
173- if d.Record(detector.Action{
174- ToolName: "bash",
175- Input: in.Command,
176- Output: out,
177- }) {
178- return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
179- }
180-
181- return out, nil
172+ // change its approach; ignored for too long, it ends the turn
173+ // (see internal/detector and record).
174+ return record(d, "bash", in.Command, out)
182175 })
183176 }
184177
@@ -207,7 +200,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
207200 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
208201 // Recorded like any other outcome: a model that keeps retrying a
209202 // command the user keeps rejecting is looping.
210- return record(d, "bash", in.Command, out), nil
203+ return record(d, "bash", in.Command, out)
211204 }
212205
213206 s.ToolRunning(ev.ID)
@@ -216,7 +209,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
216209 out, failed := runBash(s.WorkDir(), in.Command)
217210 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
218211
219- return record(d, "bash", in.Command, out), nil
212+ return record(d, "bash", in.Command, out)
220213 }
221214
222215 // --- read_skill --------------------------------------------------------------
@@ -268,10 +261,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
268261 // exactly as it would on a failing command.
269262 out := fmt.Sprintf("No skill named %q. Available skills: %s",
270263 in.Name, strings.Join(skills.Names(list), ", "))
271- if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) {
272- return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
273- }
274- return out, nil
264+ return record(d, "read_skill", in.Name, out)
275265 }
276266
277267 // The content is NOT displayed: a skill runs to dozens of lines, it
@@ -279,10 +269,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
279269 // information — which one was loaded. What they want to see are the
280270 // commands that follow from it, and those will show up.
281271 out := truncate(content)
282- if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) {
283- return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
284- }
285- return out, nil
272+ return record(d, "read_skill", in.Name, out)
286273 })
287274 }
288275
@@ -307,10 +294,10 @@ func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []sk
307294 out := fmt.Sprintf("No skill named %q. Available skills: %s",
308295 in.Name, strings.Join(skills.Names(list), ", "))
309296 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
310- return record(d, "read_skill", in.Name, out), nil
297+ return record(d, "read_skill", in.Name, out)
311298 }
312299
313300 out := truncate(content)
314301 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
315- return record(d, "read_skill", in.Name, out), nil
302+ return record(d, "read_skill", in.Name, out)
316303 }
@@ -169,16 +169,9 @@ Read before you write: if you do not know a path or the content of a file, run a
169 169
170 // The loop detector sees the command AND its output: the same pair170 // The loop detector sees the command AND its output: the same pair
171 // twice in a row, and it hands the model back an instruction to171 // twice in a row, and it hands the model back an instruction to
172- // change its approach (see internal/detector).172+ // change its approach; ignored for too long, it ends the turn
173- if d.Record(detector.Action{173+ // (see internal/detector and record).
174- ToolName: "bash",174+ return record(d, "bash", in.Command, out)
175- Input: in.Command,
176- Output: out,
177- }) {
178- return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
179- }
180-
181- return out, nil
182 })175 })
183 }176 }
184 177
@@ -207,7 +200,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
207 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})200 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
208 // Recorded like any other outcome: a model that keeps retrying a201 // Recorded like any other outcome: a model that keeps retrying a
209 // command the user keeps rejecting is looping.202 // command the user keeps rejecting is looping.
210- return record(d, "bash", in.Command, out), nil203+ return record(d, "bash", in.Command, out)
211 }204 }
212 205
213 s.ToolRunning(ev.ID)206 s.ToolRunning(ev.ID)
@@ -216,7 +209,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
216 out, failed := runBash(s.WorkDir(), in.Command)209 out, failed := runBash(s.WorkDir(), in.Command)
217 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})210 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
218 211
219- return record(d, "bash", in.Command, out), nil212+ return record(d, "bash", in.Command, out)
220 }213 }
221 214
222 // --- read_skill --------------------------------------------------------------215 // --- read_skill --------------------------------------------------------------
@@ -268,10 +261,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
268 // exactly as it would on a failing command.261 // exactly as it would on a failing command.
269 out := fmt.Sprintf("No skill named %q. Available skills: %s",262 out := fmt.Sprintf("No skill named %q. Available skills: %s",
270 in.Name, strings.Join(skills.Names(list), ", "))263 in.Name, strings.Join(skills.Names(list), ", "))
271- if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) {264+ return record(d, "read_skill", in.Name, out)
272- return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
273- }
274- return out, nil
275 }265 }
276 266
277 // The content is NOT displayed: a skill runs to dozens of lines, it267 // The content is NOT displayed: a skill runs to dozens of lines, it
@@ -279,10 +269,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
279 // information — which one was loaded. What they want to see are the269 // information — which one was loaded. What they want to see are the
280 // commands that follow from it, and those will show up.270 // commands that follow from it, and those will show up.
281 out := truncate(content)271 out := truncate(content)
282- if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) {272+ return record(d, "read_skill", in.Name, out)
283- return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil
284- }
285- return out, nil
286 })273 })
287 }274 }
288 275
@@ -307,10 +294,10 @@ func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []sk
307 out := fmt.Sprintf("No skill named %q. Available skills: %s",294 out := fmt.Sprintf("No skill named %q. Available skills: %s",
308 in.Name, strings.Join(skills.Names(list), ", "))295 in.Name, strings.Join(skills.Names(list), ", "))
309 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})296 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
310- return record(d, "read_skill", in.Name, out), nil297+ return record(d, "read_skill", in.Name, out)
311 }298 }
312 299
313 out := truncate(content)300 out := truncate(content)
314 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})301 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
315- return record(d, "read_skill", in.Name, out), nil302+ return record(d, "read_skill", in.Name, out)
316 }303 }
modified internal/tui/turn.go +3 -0
@@ -382,6 +382,9 @@ func recap(turn []*ai.Message) string {
382382 if k := engine.Skills(turn); k > 0 {
383383 line += fmt.Sprintf(" · 📖 %d skill(s)", k)
384384 }
385+ if k := engine.LSPCalls(turn); k > 0 {
386+ line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)
387+ }
385388 return line
386389 }
387390
@@ -382,6 +382,9 @@ func recap(turn []*ai.Message) string {
382 if k := engine.Skills(turn); k > 0 {382 if k := engine.Skills(turn); k > 0 {
383 line += fmt.Sprintf(" · 📖 %d skill(s)", k)383 line += fmt.Sprintf(" · 📖 %d skill(s)", k)
384 }384 }
385+ if k := engine.LSPCalls(turn); k > 0 {
386+ line += fmt.Sprintf(" · 🔎 %d lsp call(s)", k)
387+ }
385 return line388 return line
386 }389 }
387 390
modified main.go +65 -18
@@ -40,6 +40,7 @@ import (
4040 "mm/internal/config"
4141 "mm/internal/detector"
4242 "mm/internal/engine"
43+ "mm/internal/lsp"
4344 "mm/internal/skills"
4445 "mm/internal/spinner"
4546 "mm/internal/tools"
@@ -107,15 +108,22 @@ func main() {
107108 os.Exit(1)
108109 }
109110
110- // Initialize the global loop detector.
111- loopDetector := detector.NewLoopDetector(10, 3)
111+ // The loop detector: the same call with the same result three times in a
112+ // row is flagged (the model is told to change approach); three flagged
113+ // repeats more — six identical calls — and the turn is stopped, since a
114+ // model that ignored the advice three times will not read it a fourth
115+ // time (seen on 2026-09-18: 30 identical refused edits before the user
116+ // pressed Escape).
117+ loopDetector := detector.NewLoopDetector(10, 3).WithStop(3)
112118
113- agentTools, toolNames, skillCount := assembleTools(e, loopDetector)
119+ set := assembleTools(e, loopDetector)
120+ defer set.close()
121+ agentTools := set.tools
114122
115123 if path == "" {
116124 path = "built-in defaults"
117125 }
118- printBanner(ctx, e, path, toolNames, skillCount)
126+ printBanner(ctx, e, path, set)
119127
120128 // Three front ends, one wiring: everything above — config, engine, tools,
121129 // detector — is strictly identical whichever loop runs below.
@@ -158,42 +166,81 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
158166 agent.Run(ctx, e, config.Cfg.System, agentTools, d)
159167 }
160168
169+// toolSet is what assembleTools hands main: the tools, their names for the
170+// banner, the skill count, the warnings the banner must show, and what to
171+// stop at exit (the language servers).
172+type toolSet struct {
173+ tools []ai.ToolRef
174+ names []string
175+ skillCount int
176+ warnings []string
177+ close func()
178+}
179+
161180 // assembleTools declares the tools every front end gets: `bash` unless
162181 // `bashTool: false` turned it off; `read_skill` only when the skills
163182 // directory holds something to read (its
164183 // description carries the catalogue, so declaring it empty would advertise a
165184 // tool that can do nothing); the file tools behind `editTools` — the same
166185 // binary must run as part 09 did (bash + the `edit` CLI) and as this part
167-// (built-in tools), so the two can be compared on the same prompts. It
168-// returns the tools, their names for the banner, and the skill count.
169-func assembleTools(e *engine.Engine, d *detector.LoopDetector) ([]ai.ToolRef, []string, int) {
170- var agentTools []ai.ToolRef
171- var toolNames []string
186+// (built-in tools), so the two can be compared on the same prompts; and
187+// `lsp` when `lsp.servers` names at least one server whose command is on
188+// the PATH — a missing server is a warning, never a refusal to start.
189+func assembleTools(e *engine.Engine, d *detector.LoopDetector) toolSet {
190+ set := toolSet{close: func() {}}
172191 if config.Cfg.BashTool {
173- agentTools = append(agentTools, tools.Bash(e.G, d))
174- toolNames = append(toolNames, "bash")
192+ set.tools = append(set.tools, tools.Bash(e.G, d))
193+ set.names = append(set.names, "bash")
175194 }
176- skillCount := len(skills.List(config.Cfg.SkillsDir))
195+ set.skillCount = len(skills.List(config.Cfg.SkillsDir))
177196 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {
178- agentTools = append(agentTools, skillTool)
179- toolNames = append(toolNames, "read_skill")
197+ set.tools = append(set.tools, skillTool)
198+ set.names = append(set.names, "read_skill")
180199 }
181200 if config.Cfg.EditTools {
182- agentTools = append(agentTools,
201+ set.tools = append(set.tools,
183202 tools.ReadFile(e.G, d),
184203 tools.WriteFile(e.G, d),
185204 tools.EditFile(e.G, d))
186- toolNames = append(toolNames, "read_file", "write_file", "edit_file")
205+ set.names = append(set.names, "read_file", "write_file", "edit_file")
206+ }
207+ assembleLSP(e, d, &set)
208+ return set
209+}
210+
211+// assembleLSP adds the `lsp` tool over the configured servers that exist.
212+// The servers are not started here — each starts on its first use — but
213+// the client is created now so main can stop them at exit. The project root
214+// the servers get is the directory mm runs in; an editor launches mm in the
215+// project it opened.
216+func assembleLSP(e *engine.Engine, d *detector.LoopDetector, set *toolSet) {
217+ servers := config.Cfg.LSP.Servers
218+ if len(servers) == 0 {
219+ return
220+ }
221+ ok, missing := lsp.Available(servers)
222+ for _, m := range missing {
223+ set.warnings = append(set.warnings, fmt.Sprintf("lsp server %s not found on the PATH — skipped", m))
187224 }
188- return agentTools, toolNames, skillCount
225+ if len(ok) == 0 {
226+ set.warnings = append(set.warnings, "lsp: none of the configured servers is on the PATH — the lsp tool is not declared")
227+ return
228+ }
229+ cwd, _ := os.Getwd()
230+ client := lsp.New(cwd, ok)
231+ set.tools = append(set.tools, tools.LSP(e.G, d, client, ok))
232+ set.names = append(set.names, "lsp")
233+ set.close = client.Close
189234 }
190235
191236 // printBanner probes the server and prints the warnings and the one-line
192237 // banner. The probe runs before the banner so its warnings sit right under
193238 // the prompt they explain; it never stops the agent — on a demo machine the
194239 // server is often started AFTER the agent.
195-func printBanner(ctx context.Context, e *engine.Engine, path string, toolNames []string, skillCount int) {
240+func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet) {
241+ toolNames, skillCount := set.names, set.skillCount
196242 info := e.Probe(ctx)
243+ info.Warnings = append(info.Warnings, set.warnings...)
197244 ctxCol := "unknown"
198245 if info.ContextWindow > 0 {
199246 ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource)
@@ -40,6 +40,7 @@ import (
40 "mm/internal/config"40 "mm/internal/config"
41 "mm/internal/detector"41 "mm/internal/detector"
42 "mm/internal/engine"42 "mm/internal/engine"
43+ "mm/internal/lsp"
43 "mm/internal/skills"44 "mm/internal/skills"
44 "mm/internal/spinner"45 "mm/internal/spinner"
45 "mm/internal/tools"46 "mm/internal/tools"
@@ -107,15 +108,22 @@ func main() {
107 os.Exit(1)108 os.Exit(1)
108 }109 }
109 110
110- // Initialize the global loop detector.111+ // The loop detector: the same call with the same result three times in a
111- loopDetector := detector.NewLoopDetector(10, 3)112+ // row is flagged (the model is told to change approach); three flagged
113+ // repeats more — six identical calls — and the turn is stopped, since a
114+ // model that ignored the advice three times will not read it a fourth
115+ // time (seen on 2026-09-18: 30 identical refused edits before the user
116+ // pressed Escape).
117+ loopDetector := detector.NewLoopDetector(10, 3).WithStop(3)
112 118
113- agentTools, toolNames, skillCount := assembleTools(e, loopDetector)119+ set := assembleTools(e, loopDetector)
120+ defer set.close()
121+ agentTools := set.tools
114 122
115 if path == "" {123 if path == "" {
116 path = "built-in defaults"124 path = "built-in defaults"
117 }125 }
118- printBanner(ctx, e, path, toolNames, skillCount)126+ printBanner(ctx, e, path, set)
119 127
120 // Three front ends, one wiring: everything above — config, engine, tools,128 // Three front ends, one wiring: everything above — config, engine, tools,
121 // detector — is strictly identical whichever loop runs below.129 // detector — is strictly identical whichever loop runs below.
@@ -158,42 +166,81 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
158 agent.Run(ctx, e, config.Cfg.System, agentTools, d)166 agent.Run(ctx, e, config.Cfg.System, agentTools, d)
159 }167 }
160 168
169+// toolSet is what assembleTools hands main: the tools, their names for the
170+// banner, the skill count, the warnings the banner must show, and what to
171+// stop at exit (the language servers).
172+type toolSet struct {
173+ tools []ai.ToolRef
174+ names []string
175+ skillCount int
176+ warnings []string
177+ close func()
178+}
179+
161 // assembleTools declares the tools every front end gets: `bash` unless180 // assembleTools declares the tools every front end gets: `bash` unless
162 // `bashTool: false` turned it off; `read_skill` only when the skills181 // `bashTool: false` turned it off; `read_skill` only when the skills
163 // directory holds something to read (its182 // directory holds something to read (its
164 // description carries the catalogue, so declaring it empty would advertise a183 // description carries the catalogue, so declaring it empty would advertise a
165 // tool that can do nothing); the file tools behind `editTools` — the same184 // tool that can do nothing); the file tools behind `editTools` — the same
166 // binary must run as part 09 did (bash + the `edit` CLI) and as this part185 // binary must run as part 09 did (bash + the `edit` CLI) and as this part
167-// (built-in tools), so the two can be compared on the same prompts. It186+// (built-in tools), so the two can be compared on the same prompts; and
168-// returns the tools, their names for the banner, and the skill count.187+// `lsp` when `lsp.servers` names at least one server whose command is on
169-func assembleTools(e *engine.Engine, d *detector.LoopDetector) ([]ai.ToolRef, []string, int) {188+// the PATH — a missing server is a warning, never a refusal to start.
170- var agentTools []ai.ToolRef189+func assembleTools(e *engine.Engine, d *detector.LoopDetector) toolSet {
171- var toolNames []string190+ set := toolSet{close: func() {}}
172 if config.Cfg.BashTool {191 if config.Cfg.BashTool {
173- agentTools = append(agentTools, tools.Bash(e.G, d))192+ set.tools = append(set.tools, tools.Bash(e.G, d))
174- toolNames = append(toolNames, "bash")193+ set.names = append(set.names, "bash")
175 }194 }
176- skillCount := len(skills.List(config.Cfg.SkillsDir))195+ set.skillCount = len(skills.List(config.Cfg.SkillsDir))
177 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {196 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {
178- agentTools = append(agentTools, skillTool)197+ set.tools = append(set.tools, skillTool)
179- toolNames = append(toolNames, "read_skill")198+ set.names = append(set.names, "read_skill")
180 }199 }
181 if config.Cfg.EditTools {200 if config.Cfg.EditTools {
182- agentTools = append(agentTools,201+ set.tools = append(set.tools,
183 tools.ReadFile(e.G, d),202 tools.ReadFile(e.G, d),
184 tools.WriteFile(e.G, d),203 tools.WriteFile(e.G, d),
185 tools.EditFile(e.G, d))204 tools.EditFile(e.G, d))
186- toolNames = append(toolNames, "read_file", "write_file", "edit_file")205+ set.names = append(set.names, "read_file", "write_file", "edit_file")
206+ }
207+ assembleLSP(e, d, &set)
208+ return set
209+}
210+
211+// assembleLSP adds the `lsp` tool over the configured servers that exist.
212+// The servers are not started here — each starts on its first use — but
213+// the client is created now so main can stop them at exit. The project root
214+// the servers get is the directory mm runs in; an editor launches mm in the
215+// project it opened.
216+func assembleLSP(e *engine.Engine, d *detector.LoopDetector, set *toolSet) {
217+ servers := config.Cfg.LSP.Servers
218+ if len(servers) == 0 {
219+ return
220+ }
221+ ok, missing := lsp.Available(servers)
222+ for _, m := range missing {
223+ set.warnings = append(set.warnings, fmt.Sprintf("lsp server %s not found on the PATH — skipped", m))
187 }224 }
188- return agentTools, toolNames, skillCount225+ if len(ok) == 0 {
226+ set.warnings = append(set.warnings, "lsp: none of the configured servers is on the PATH — the lsp tool is not declared")
227+ return
228+ }
229+ cwd, _ := os.Getwd()
230+ client := lsp.New(cwd, ok)
231+ set.tools = append(set.tools, tools.LSP(e.G, d, client, ok))
232+ set.names = append(set.names, "lsp")
233+ set.close = client.Close
189 }234 }
190 235
191 // printBanner probes the server and prints the warnings and the one-line236 // printBanner probes the server and prints the warnings and the one-line
192 // banner. The probe runs before the banner so its warnings sit right under237 // banner. The probe runs before the banner so its warnings sit right under
193 // the prompt they explain; it never stops the agent — on a demo machine the238 // the prompt they explain; it never stops the agent — on a demo machine the
194 // server is often started AFTER the agent.239 // server is often started AFTER the agent.
195-func printBanner(ctx context.Context, e *engine.Engine, path string, toolNames []string, skillCount int) {240+func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet) {
241+ toolNames, skillCount := set.names, set.skillCount
196 info := e.Probe(ctx)242 info := e.Probe(ctx)
243+ info.Warnings = append(info.Warnings, set.warnings...)
197 ctxCol := "unknown"244 ctxCol := "unknown"
198 if info.ContextWindow > 0 {245 if info.ContextWindow > 0 {
199 ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource)246 ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource)
added skills/lsp-navigation/SKILL.md +45 -0
new file mode 100644
@@ -0,0 +1,45 @@
1+---
2+name: lsp-navigation
3+description: Navigate and change code with the `lsp` tool — find where a symbol is defined, who uses it, what its type is, what the compiler reports, and rename it everywhere.
4+keywords: lsp, definition, references, usages, callers, hover, type, signature, symbols, outline, diagnostics, errors, warnings, rename, refactor, language server
5+---
6+
7+# Navigate code with the `lsp` tool
8+
9+## When to use it
10+
11+The `lsp` tool asks the project's language server what the compiler knows. Prefer it to `grep` and to reading whole files when the question is about **symbols**: where something is declared, who uses it, what type it has, what is wrong in a file, or how to rename it safely. Its description lists the servers available and the file extensions they cover; for other files, fall back to `bash` and `read_file`.
12+
13+## How to point at a symbol
14+
15+Give the **identifier** as written in the code, in `symbol`: the tool finds its first occurrence in the file. Add `line` (1-based) when the same name appears several times and you want a particular one. This is the reliable way; never guess a column.
16+
17+Only when you have an exact position — from `read_file` with `numbered=true` — may you send `line` and `column` instead (both 1-based, the column pointing inside the identifier).
18+
19+## The actions
20+
21+| You want to know… | Call |
22+| --- | --- |
23+| Where a symbol is declared | `{"action":"definition","path":"main.go","symbol":"hello"}` |
24+| Every place that uses it | `{"action":"references","path":"main.go","symbol":"hello"}` |
25+| Its signature, type and documentation | `{"action":"hover","path":"main.go","symbol":"hello"}` |
26+| What a file contains (outline) | `{"action":"symbols","path":"main.go"}` |
27+| What the compiler reports in a file | `{"action":"diagnostics","path":"main.go"}` |
28+| Rename it everywhere, files rewritten | `{"action":"rename","path":"main.go","symbol":"hello","new_name":"greet"}` |
29+| The occurrence on one line only | add `"line": 9` to any of the calls above |
30+
31+Answers come as `path:line:column <source line>`, one per location. You rarely need to open the file afterwards.
32+
33+## Procedure for a change
34+
35+1. `symbols` on the file to find the symbol, or `read_file numbered=true` if you already know roughly where it is.
36+2. `references` on the symbol to see every file the change touches. Say how many there are.
37+3. Edit with `edit_file` (or `rename` when the change is a rename — it rewrites all the files at once).
38+4. `diagnostics` on each edited file to see what the compiler reports, then fix what it says.
39+5. Only then run the tests with `bash`.
40+
41+## Rules
42+
43+- A `[lsp: …]` answer is information, not a crash: read it. "no language server for" means the extension is not covered — use `bash`. "not found in" means the identifier is not spelled as in the code — read the file and copy it. "past the end of line" means you miscounted — use `symbol` instead.
44+- `diagnostics` right after an edit may say "as last published" or "no diagnostics received": the server is still indexing. Ask again once.
45+- `rename` writes files. It NEEDS `new_name`, the new identifier; without it the tool answers with the exact call to make — make it, do not fall back to `edit_file`. Say what it changed, and never rename to a name the file already uses for something else.
new file mode 100644
@@ -0,0 +1,45 @@
1+---
2+name: lsp-navigation
3+description: Navigate and change code with the `lsp` tool — find where a symbol is defined, who uses it, what its type is, what the compiler reports, and rename it everywhere.
4+keywords: lsp, definition, references, usages, callers, hover, type, signature, symbols, outline, diagnostics, errors, warnings, rename, refactor, language server
5+---
6+
7+# Navigate code with the `lsp` tool
8+
9+## When to use it
10+
11+The `lsp` tool asks the project's language server what the compiler knows. Prefer it to `grep` and to reading whole files when the question is about **symbols**: where something is declared, who uses it, what type it has, what is wrong in a file, or how to rename it safely. Its description lists the servers available and the file extensions they cover; for other files, fall back to `bash` and `read_file`.
12+
13+## How to point at a symbol
14+
15+Give the **identifier** as written in the code, in `symbol`: the tool finds its first occurrence in the file. Add `line` (1-based) when the same name appears several times and you want a particular one. This is the reliable way; never guess a column.
16+
17+Only when you have an exact position — from `read_file` with `numbered=true` — may you send `line` and `column` instead (both 1-based, the column pointing inside the identifier).
18+
19+## The actions
20+
21+| You want to know… | Call |
22+| --- | --- |
23+| Where a symbol is declared | `{"action":"definition","path":"main.go","symbol":"hello"}` |
24+| Every place that uses it | `{"action":"references","path":"main.go","symbol":"hello"}` |
25+| Its signature, type and documentation | `{"action":"hover","path":"main.go","symbol":"hello"}` |
26+| What a file contains (outline) | `{"action":"symbols","path":"main.go"}` |
27+| What the compiler reports in a file | `{"action":"diagnostics","path":"main.go"}` |
28+| Rename it everywhere, files rewritten | `{"action":"rename","path":"main.go","symbol":"hello","new_name":"greet"}` |
29+| The occurrence on one line only | add `"line": 9` to any of the calls above |
30+
31+Answers come as `path:line:column <source line>`, one per location. You rarely need to open the file afterwards.
32+
33+## Procedure for a change
34+
35+1. `symbols` on the file to find the symbol, or `read_file numbered=true` if you already know roughly where it is.
36+2. `references` on the symbol to see every file the change touches. Say how many there are.
37+3. Edit with `edit_file` (or `rename` when the change is a rename — it rewrites all the files at once).
38+4. `diagnostics` on each edited file to see what the compiler reports, then fix what it says.
39+5. Only then run the tests with `bash`.
40+
41+## Rules
42+
43+- A `[lsp: …]` answer is information, not a crash: read it. "no language server for" means the extension is not covered — use `bash`. "not found in" means the identifier is not spelled as in the code — read the file and copy it. "past the end of line" means you miscounted — use `symbol` instead.
44+- `diagnostics` right after an edit may say "as last published" or "no diagnostics received": the server is still indexing. Ask again once.
45+- `rename` writes files. It NEEDS `new_name`, the new identifier; without it the tool answers with the exact call to make — make it, do not fall back to `edit_file`. Say what it changed, and never rename to a name the file already uses for something else.