modified
.tickets/issues/0017-add-lsp-support-to-mini-me.yaml +88 -4 | @@ -1,16 +1,100 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 17 |
| 3 | 3 | title: Add LSP support to Mini Me |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-17T13:06:27.245Z |
| 9 | -updatedAt: 2026-09-17T13:36:09.722Z | |
| 9 | +updatedAt: 2026-09-18T18:07:46.008Z | |
| 10 | 10 | labels: |
| 11 | 11 | - priority::urgent |
| 12 | 12 | - builtin tool |
| 13 | 13 | 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 | |
| 16 | 100 | 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: 17 | 2 | id: 17 |
| 3 | title: Add LSP support to Mini Me | 3 | title: Add LSP support to Mini Me |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-17T13:06:27.245Z | 8 | createdAt: 2026-09-17T13:06:27.245Z |
| 9 | -updatedAt: 2026-09-17T13:36:09.722Z | 9 | +updatedAt: 2026-09-18T18:07:46.008Z |
| 10 | labels: | 10 | labels: |
| 11 | - priority::urgent | 11 | - priority::urgent |
| 12 | - builtin tool | 12 | - builtin tool |
| 13 | milestone: v0.0.2 | 13 | 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 @@ | ||
| 59 | 59 | "ui.semanticTokens": true, |
| 60 | 60 | "ui.completion.usePlaceholders": true |
| 61 | 61 | }, |
| 62 | - "issuespec.groupBy": "label" | |
| 62 | + "issuespec.groupBy": "milestone" | |
| 63 | 63 | } |
| \ No newline at end of file | ||
| @@ -59,5 +59,5 @@ | |||
| 59 | "ui.semanticTokens": true, | 59 | "ui.semanticTokens": true, |
| 60 | "ui.completion.usePlaceholders": true | 60 | "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 | ||
| 12 | 12 | |
| 13 | 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 | 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 | 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 | 21 | ## 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 | 25 | ```bash |
| 26 | 26 | 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 | 98 | - [Documentation](docs/README.md) — tutorials, how-to guides, reference and explanations, in English and French. |
| 99 | 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). | |
| @@ -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 | ## Install | 21 | ## 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 | ```bash | 25 | ```bash |
| 26 | git clone https://git.rickub.com/bots-garden/mini-me.git | 26 | 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 | ||
| 27 | 27 | # The built-in file tools: read_file, write_file, edit_file. |
| 28 | 28 | 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 | 50 | # Where the `read_skill` tool looks for its markdown procedures. |
| 31 | 51 | # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills. |
| 32 | 52 | 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: true | 28 | 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: skills | 52 | skillsDir: skills |
modified
agent.llamacpp.yaml +20 -0 | @@ -23,6 +23,26 @@ bashTool: true | ||
| 23 | 23 | # The built-in file tools: read_file, write_file, edit_file. |
| 24 | 24 | 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 | 46 | # How many tokens the server actually serves. |
| 27 | 47 | # 0 = ask the server (llama-server tells it on /props) |
| 28 | 48 | 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: true | 24 | 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: 0 | 48 | 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></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) | ||
| 18 | 18 | - [Point the model at a file with `@path`](how-to/attach-a-file.md) |
| 19 | 19 | - [Keep sessions and resume them](how-to/keep-sessions.md) |
| 20 | 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 | 23 | ## Reference — look it up |
| 23 | 24 | |
| @@ -34,6 +35,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 34 | 35 | - [Providers: one implementation per protocol](explanation/providers.md) |
| 35 | 36 | - [Context compression](explanation/context-compression.md) |
| 36 | 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 | 39 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 38 | 40 | |
| 39 | 41 | ## 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 up | 23 | ## 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 | ## Scope | 41 | ## 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 | ||
| 12 | 12 | | `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. | |
| 13 | 13 | | `tools` | The built-in tools the model can call. | |
| 14 | 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 | 16 | | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. | |
| 16 | 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 | 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 | 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 | 51 | ## Rejected alternatives |
| 51 | 52 | |
| @@ -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 alternatives | 51 | ## 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. | ||
modified
docs/en/reference/configuration.md +30 -0 | @@ -24,6 +24,7 @@ | ||
| 24 | 24 | | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | |
| 25 | 25 | | `context` | map | see below | Automatic history compression. | |
| 26 | 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 | 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 | 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 | 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 | 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 | 74 | ## Provider defaults |
| 50 | 75 | |
| 51 | 76 | | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required | |
| @@ -70,6 +95,11 @@ Reported as `[config error: <path>: <message>]`, exit code 1. | ||
| 70 | 95 | | `context.summaryMaxTokens must be > 0` | zero or negative | |
| 71 | 96 | | `context.maxMessages must be >= 0` | negative value | |
| 72 | 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 | 103 | | `permissions must be "auto" or "ask"` | any other value | |
| 74 | 104 | | `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. | |
| 75 | 105 | |
| @@ -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 defaults | 74 | ## 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`. | ||
| 85 | 85 | |
| 86 | 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 | 89 | | Two edits must not overlap, nest or repeat | Refused. | |
| 90 | 90 | | Edits are resolved against the original text, never in cascade | — | |
| 91 | 91 | | Any refused edit | Nothing is written. | |
| @@ -95,6 +95,48 @@ Declared when `editTools: true`. | ||
| 95 | 95 | | Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. | |
| 96 | 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 | 140 | ## Counters shown after a turn |
| 99 | 141 | |
| 100 | 142 | | Counter | Counts | |
| @@ -102,3 +144,4 @@ Declared when `editTools: true`. | ||
| 102 | 144 | | `⚙ N command(s)` | `bash` tool responses in the turn. | |
| 103 | 145 | | `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. | |
| 104 | 146 | | `📖 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 turn | 140 | ## 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 | ||
| 4 | 4 | |
| 5 | 5 | ## 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 | 8 | - Docker Desktop with Docker Model Runner enabled and reachable on `http://localhost:12434`. |
| 9 | 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 | 10 | - 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 | ## Prerequisites | 5 | ## 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: | ||
| 18 | 18 | - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md) |
| 19 | 19 | - [Conserver les sessions et les reprendre](how-to/keep-sessions.md) |
| 20 | 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 | 23 | ## Référence — consulter |
| 23 | 24 | |
| @@ -34,6 +35,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 34 | 35 | - [Fournisseurs : une implémentation par protocole](explanation/providers.md) |
| 35 | 36 | - [Compression du contexte](explanation/context-compression.md) |
| 36 | 37 | - [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 | 39 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 38 | 40 | |
| 39 | 41 | ## 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 — consulter | 23 | ## 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 agent | 37 | - [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ètre | 41 | ## 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 | ||
| 12 | 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 | 13 | | `tools` | Les outils intégrés que le modèle peut appeler. | |
| 14 | 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 | 16 | | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. | |
| 16 | 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 | 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 | 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 | 51 | ## Alternatives écartées |
| 51 | 52 | |
| @@ -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ées | 51 | ## 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. | ||
modified
docs/fr/reference/configuration.md +30 -0 | @@ -24,6 +24,7 @@ | ||
| 24 | 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 | 25 | | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. | |
| 26 | 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 | 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 | 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 | 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 | 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 | 74 | ## Valeurs par défaut des fournisseurs |
| 50 | 75 | |
| 51 | 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 | 95 | | `context.summaryMaxTokens must be > 0` | zéro ou négatif | |
| 71 | 96 | | `context.maxMessages must be >= 0` | valeur négative | |
| 72 | 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 | 103 | | `permissions must be "auto" or "ask"` | toute autre valeur | |
| 74 | 104 | | `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. | |
| 75 | 105 | |
| @@ -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 fournisseurs | 74 | ## 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`. | ||
| 85 | 85 | |
| 86 | 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 | 89 | | Deux éditions ne doivent ni se chevaucher, ni s'imbriquer, ni se répéter | Refus. | |
| 90 | 90 | | Les éditions sont résolues sur le texte original, jamais en cascade | — | |
| 91 | 91 | | Toute édition refusée | Rien n'est écrit. | |
| @@ -95,6 +95,48 @@ Déclaré quand `editTools: true`. | ||
| 95 | 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 | 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 | 140 | ## Compteurs affichés après un tour |
| 99 | 141 | |
| 100 | 142 | | Compteur | Compte | |
| @@ -102,3 +144,4 @@ Déclaré quand `editTools: true`. | ||
| 102 | 144 | | `⚙ N command(s)` | Réponses de l'outil `bash` dans le tour. | |
| 103 | 145 | | `📝 N file op(s)` | Réponses de `read_file`, `write_file`, `edit_file`. | |
| 104 | 146 | | `📖 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 tour | 140 | ## 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 @@ | ||
| 4 | 4 | |
| 5 | 5 | ## 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 | 8 | - Docker Desktop avec Docker Model Runner activé et joignable sur `http://localhost:12434`. |
| 9 | 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 | 10 | - 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érequis | 5 | ## 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 @@ | ||
| 1 | 1 | module mm |
| 2 | 2 | |
| 3 | -go 1.25.0 | |
| 3 | +go 1.26 | |
| 4 | 4 | |
| 5 | 5 | require ( |
| 6 | 6 | github.com/firebase/genkit/go v1.10.0 |
| 7 | 7 | github.com/gdamore/tcell/v2 v2.13.10 |
| 8 | 8 | 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 | 12 | gopkg.in/yaml.v3 v3.0.1 |
| 10 | 13 | ) |
| 11 | 14 | |
| 12 | 15 | require ( |
| 13 | 16 | 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 | |
| 15 | 19 | 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 | |
| 18 | 22 | ) |
| 19 | 23 | |
| 20 | 24 | require ( |
| @@ -44,5 +48,5 @@ require ( | ||
| 44 | 48 | go.opentelemetry.io/otel/metric v1.36.0 // indirect |
| 45 | 49 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect |
| 46 | 50 | 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 | |
| 48 | 52 | ) |
| @@ -1,20 +1,24 @@ | |||
| 1 | module mm | 1 | module mm |
| 2 | 2 | ||
| 3 | -go 1.25.0 | 3 | +go 1.26 |
| 4 | 4 | ||
| 5 | require ( | 5 | require ( |
| 6 | github.com/firebase/genkit/go v1.10.0 | 6 | github.com/firebase/genkit/go v1.10.0 |
| 7 | github.com/gdamore/tcell/v2 v2.13.10 | 7 | github.com/gdamore/tcell/v2 v2.13.10 |
| 8 | github.com/openai/openai-go v1.8.2 | 8 | 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.1 | 12 | gopkg.in/yaml.v3 v3.0.1 |
| 10 | ) | 13 | ) |
| 11 | 14 | ||
| 12 | require ( | 15 | require ( |
| 13 | github.com/gdamore/encoding v1.0.1 // indirect | 16 | 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 | ||
| 15 | github.com/rivo/uniseg v0.4.7 // indirect | 19 | github.com/rivo/uniseg v0.4.7 // indirect |
| 16 | - golang.org/x/term v0.37.0 // indirect | 20 | + golang.org/x/term v0.39.0 // indirect |
| 17 | - golang.org/x/text v0.31.0 // indirect | 21 | + 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 // indirect | 48 | go.opentelemetry.io/otel/metric v1.36.0 // indirect |
| 45 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect | 49 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect |
| 46 | go.opentelemetry.io/otel/trace v1.36.0 // indirect | 50 | 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 |
| 48 | ) | 52 | ) |
modified
go.sum +20 -12 | @@ -15,6 +15,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh | ||
| 15 | 15 | github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= |
| 16 | 16 | github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= |
| 17 | 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 | 20 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= |
| 19 | 21 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= |
| 20 | 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 | 36 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= |
| 35 | 37 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= |
| 36 | 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= | |
| 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= | |
| 39 | 41 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= |
| 40 | 42 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= |
| 41 | 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 | 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| 47 | 49 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= |
| 48 | 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= | |
| 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= | |
| 51 | 53 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
| 52 | 54 | 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= | |
| 55 | 57 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= |
| 56 | 58 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= |
| 57 | 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 | 76 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= |
| 75 | 77 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= |
| 76 | 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 | 85 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= |
| 78 | 86 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= |
| 79 | 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 | 111 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 104 | 112 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 105 | 113 | 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= | |
| 108 | 116 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
| 109 | 117 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= |
| 110 | 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= | |
| 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= | |
| 113 | 121 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
| 114 | 122 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
| 115 | 123 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= |
| 116 | 124 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= |
| 117 | 125 | 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= | |
| 120 | 128 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
| 121 | 129 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= |
| 122 | 130 | 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 | ||
| 320 | 320 | // every tool call, but the trail is what lets `dev: open acp logs` say at a |
| 321 | 321 | // glance whether the model worked or improvised. |
| 322 | 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", | |
| 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)) | |
| 325 | 325 | f.persist(p.SessionId, s) |
| 326 | 326 | |
| 327 | 327 | 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 a | 320 | // 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 | ||
| 254 | 254 | if k := engine.Skills(turn); k > 0 { |
| 255 | 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 | 260 | if spinner.Styled() { |
| 258 | 261 | fmt.Printf("\033[2m%s\033[0m\n", line) |
| 259 | 262 | } 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 ( | ||
| 11 | 11 | "fmt" |
| 12 | 12 | "io/fs" |
| 13 | 13 | "os" |
| 14 | + "path/filepath" | |
| 15 | + "strings" | |
| 14 | 16 | "time" |
| 15 | 17 | |
| 16 | 18 | "gopkg.in/yaml.v3" |
| @@ -109,6 +111,13 @@ type Config struct { | ||
| 109 | 111 | // Falls back to the line mode when stdout is not a terminal. |
| 110 | 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 | 121 | // Permissions: whether the window mode asks before `bash` and the file |
| 113 | 122 | // tools run — "ask" shows a/s/r in the box, "auto" (default) runs them as |
| 114 | 123 | // the terminal always has. Only the window mode reads it today; the |
| @@ -123,6 +132,46 @@ const ( | ||
| 123 | 132 | ) |
| 124 | 133 | |
| 125 | 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 | 175 | type SessionsConfig struct { |
| 127 | 176 | // Enabled turns the persistence on. Without it nothing is written and |
| 128 | 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 | 396 | // validate catches the settings that would break the agent at runtime. |
| 302 | 397 | func (c Config) validate() error { |
| 303 | 398 | switch { |
| @@ -329,5 +424,5 @@ func (c Config) validate() error { | ||
| 329 | 424 | // `enabled: true` with no window and no maxMessages would never trigger — |
| 330 | 425 | // but the window may still come from the server's probe at start-up, so |
| 331 | 426 | // that case is a warning in main, not an error here. |
| 332 | - return nil | |
| 427 | + return c.LSP.validate() | |
| 333 | 428 | } |
| @@ -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 file | 121 | // 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 as | 122 | // 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; the | 123 | // 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 and | 176 | // 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, so | 425 | // 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 nil | 427 | + return c.LSP.validate() |
| 333 | } | 428 | } |
modified
internal/config/config_test.go +55 -0 | @@ -108,3 +108,58 @@ func TestBashToolConfig(t *testing.T) { | ||
| 108 | 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 | +} | |
| @@ -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 @@ | ||
| 1 | 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 | 18 | package detector |
| 3 | 19 | |
| 4 | 20 | import ( |
| 21 | + "fmt" | |
| 5 | 22 | "sync" |
| 6 | 23 | ) |
| 7 | 24 | |
| @@ -18,6 +35,21 @@ type LoopDetector struct { | ||
| 18 | 35 | historyMu sync.Mutex |
| 19 | 36 | maxHistory int |
| 20 | 37 | 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 | 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 | 74 | // Record adds an action to the history and returns true if a loop is detected. |
| 33 | 75 | func (d *LoopDetector) Record(action Action) bool { |
| 34 | 76 | d.historyMu.Lock() |
| @@ -42,12 +84,14 @@ func (d *LoopDetector) Record(action Action) bool { | ||
| 42 | 84 | d.history[i].Output == action.Output { |
| 43 | 85 | count++ |
| 44 | 86 | if count >= d.threshold { |
| 87 | + d.flagged++ | |
| 45 | 88 | return true |
| 46 | 89 | } |
| 47 | 90 | } else { |
| 48 | 91 | break |
| 49 | 92 | } |
| 50 | 93 | } |
| 94 | + d.flagged = 0 | |
| 51 | 95 | |
| 52 | 96 | // Append the new action. |
| 53 | 97 | d.history = append(d.history, action) |
| @@ -58,6 +102,22 @@ func (d *LoopDetector) Record(action Action) bool { | ||
| 58 | 102 | 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 | 121 | // Reset forgets every recorded action and keeps the settings. It is what the |
| 62 | 122 | // /new command calls: a new session is a new task, and a command the model |
| 63 | 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 | 129 | d.historyMu.Lock() |
| 70 | 130 | defer d.historyMu.Unlock() |
| 71 | 131 | d.history = d.history[:0] |
| 132 | + d.flagged = 0 | |
| 72 | 133 | } |
| 73 | 134 | |
| 74 | 135 | // 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 detector | 18 | 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.Mutex | 35 | historyMu sync.Mutex |
| 19 | maxHistory int | 36 | maxHistory int |
| 20 | threshold int | 37 | 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 true | 88 | return true |
| 46 | } | 89 | } |
| 47 | } else { | 90 | } else { |
| 48 | break | 91 | 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 false | 102 | 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 the | 121 | // 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 model | 122 | // /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 @@ | ||
| 1 | 1 | package detector |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "errors" | |
| 4 | 5 | "fmt" |
| 6 | + "strings" | |
| 5 | 7 | "testing" |
| 6 | 8 | ) |
| 7 | 9 | |
| @@ -55,3 +57,46 @@ func ExampleLoopDetector_Reset() { | ||
| 55 | 57 | // true |
| 56 | 58 | // 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 | +} | |
| @@ -1,7 +1,9 @@ | |||
| 1 | package detector | 1 | 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 | // true | 57 | // true |
| 56 | // false | 58 | // 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 | ||
| 12 | 12 | import ( |
| 13 | 13 | "context" |
| 14 | 14 | "encoding/json" |
| 15 | + "errors" | |
| 15 | 16 | "fmt" |
| 16 | 17 | "maps" |
| 17 | 18 | "slices" |
| @@ -125,6 +126,11 @@ func (e *Engine) ForgetInputTokens() { e.lastInput.Store(0) } | ||
| 125 | 126 | // Explain turns a transport error into the one-line, actionable message the |
| 126 | 127 | // REPL prints between brackets. See Provider.Explain for the rationale. |
| 127 | 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 | 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 | 325 | spinner.Start("Thinking") |
| 320 | 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 | 332 | resp, err := genkit.Generate(genCtx, e.G, append(opts, |
| 323 | 333 | ai.WithStreaming(func(_ context.Context, chunk *ai.ModelResponseChunk) error { |
| 324 | 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 | 342 | emit(chunk.Text()) |
| 326 | 343 | 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 | 350 | // `full` grows as soon as a tool turn has happened: it is the witness that |
| 331 | 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 | 372 | // happens AFTER commands have run, and replaying the turn would replay them: |
| 353 | 373 | // measured, an `echo >>` wrote itself twice. `full` grows as soon as a tool |
| 354 | 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 | 378 | spinner.Start("Retrying without streaming") |
| 357 | 379 | if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil { |
| 358 | 380 | emit(resp.Text()) |
| @@ -462,6 +484,7 @@ const ( | ||
| 462 | 484 | toolRead = "read_file" |
| 463 | 485 | toolWrite = "write_file" |
| 464 | 486 | toolEdit = "edit_file" |
| 487 | + toolLSP = "lsp" | |
| 465 | 488 | ) |
| 466 | 489 | |
| 467 | 490 | // 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 | 643 | func Skills(history []*ai.Message) int { |
| 621 | 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 | +} | |
| @@ -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 the | 126 | // 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 nil | 343 | 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 that | 350 | // `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 tool | 373 | // 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 that | 490 | // 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 ( | ||
| 17 | 17 | "testing" |
| 18 | 18 | |
| 19 | 19 | "mm/internal/config" |
| 20 | + "mm/internal/detector" | |
| 20 | 21 | |
| 21 | 22 | "github.com/firebase/genkit/go/ai" |
| 22 | 23 | "github.com/firebase/genkit/go/genkit" |
| @@ -360,3 +361,55 @@ func TestFileOpsKeepsTheOrder(t *testing.T) { | ||
| 360 | 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 | +} | |
| @@ -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 ( | ||
| 8 | 8 | "strconv" |
| 9 | 9 | "strings" |
| 10 | 10 | |
| 11 | + "mm/internal/detector" | |
| 12 | + | |
| 11 | 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 | 35 | // statusInText recovers the HTTP status from the OpenAI client's error text |
| 15 | 36 | // (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors |
| 16 | 37 | // 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 text | 35 | // statusInText recovers the HTTP status from the OpenAI client's error text |
| 15 | // (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors | 36 | // (`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 | ||
| 3 | 3 | import ( |
| 4 | 4 | "fmt" |
| 5 | 5 | "sort" |
| 6 | + "strconv" | |
| 6 | 7 | "strings" |
| 7 | 8 | ) |
| 8 | 9 | |
| @@ -88,14 +89,30 @@ func locate(body string, e Edit, i int) (span, error) { | ||
| 88 | 89 | i+1, quote(e.Old)) |
| 89 | 90 | case n > 1: |
| 90 | 91 | 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)) | |
| 93 | 94 | } |
| 94 | 95 | |
| 95 | 96 | start := strings.Index(body, e.Old) |
| 96 | 97 | 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 | 116 | // lineOf returns the 1-based line number of an offset in the body. |
| 100 | 117 | func lineOf(body string, offset int) int { |
| 101 | 118 | 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}, nil | 97 | 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) { | ||
| 75 | 75 | says string |
| 76 | 76 | }{ |
| 77 | 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 | 79 | {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n", |
| 80 | 80 | []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}}, |
| 81 | 81 | "overlap"}, |
| @@ -75,7 +75,7 @@ func TestApplyRefuses(t *testing.T) { | |||
| 75 | says string | 75 | 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) { | ||
| 52 | 52 | }) |
| 53 | 53 | } |
| 54 | 54 | |
| 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 | |
| 61 | 63 | } |
| 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 | |
| 63 | 68 | } |
| 64 | 69 | |
| 65 | 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 | 96 | // Information, not a crash: the message says how to fix the call. |
| 92 | 97 | out := err.Error() |
| 93 | 98 | spinner.Suspend(func() { echoOutput(out) }) |
| 94 | - return record(d, "read_file", where, out), nil | |
| 99 | + return record(d, "read_file", where, out) | |
| 95 | 100 | } |
| 96 | 101 | out := truncate(content) |
| 97 | 102 | // Shown to the user like a command's output: "show me that file" |
| 98 | 103 | // means show it, whatever tool read it. |
| 99 | 104 | spinner.Suspend(func() { echoOutput(out) }) |
| 100 | - return record(d, "read_file", where, out), nil | |
| 105 | + 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 | 128 | if err != nil { |
| 124 | 129 | out := err.Error() |
| 125 | 130 | spinner.Suspend(func() { echoOutput(out) }) |
| 126 | - return record(d, "write_file", in.Path, out), nil | |
| 131 | + return record(d, "write_file", in.Path, out) | |
| 127 | 132 | } |
| 128 | - return report(d, "write_file", in.Path, r), nil | |
| 133 | + 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 | 167 | if err != nil { |
| 163 | 168 | out := err.Error() |
| 164 | 169 | spinner.Suspend(func() { echoOutput(out) }) |
| 165 | - return record(d, "edit_file", editKey(in), out), nil | |
| 170 | + return record(d, "edit_file", editKey(in), out) | |
| 166 | 171 | } |
| 167 | - return report(d, "edit_file", editKey(in), r), nil | |
| 172 | + 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 | 222 | if err != nil { |
| 218 | 223 | out := err.Error() |
| 219 | 224 | 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) | |
| 221 | 226 | } |
| 222 | 227 | out := truncate(content) |
| 223 | 228 | 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) | |
| 225 | 230 | } |
| 226 | 231 | |
| 227 | 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 | 243 | if !s.Allow(ctx, ev) { |
| 239 | 244 | out := "[write_file rejected by the user]" |
| 240 | 245 | 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) | |
| 242 | 247 | } |
| 243 | 248 | s.ToolRunning(ev.ID) |
| 244 | 249 | |
| @@ -250,7 +255,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, | ||
| 250 | 255 | if err != nil { |
| 251 | 256 | out := err.Error() |
| 252 | 257 | 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) | |
| 254 | 259 | } |
| 255 | 260 | out := resultText(r) |
| 256 | 261 | res := ui.ToolResult{Output: out} |
| @@ -258,7 +263,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, | ||
| 258 | 263 | res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil} |
| 259 | 264 | } |
| 260 | 265 | 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)) | |
| 262 | 267 | } |
| 263 | 268 | |
| 264 | 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 | 280 | if !s.Allow(ctx, ev) { |
| 276 | 281 | out := "[edit_file rejected by the user]" |
| 277 | 282 | 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) | |
| 279 | 284 | } |
| 280 | 285 | s.ToolRunning(ev.ID) |
| 281 | 286 | |
| @@ -288,7 +293,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i | ||
| 288 | 293 | // model — which reads the rule and fixes its `old`. |
| 289 | 294 | out := err.Error() |
| 290 | 295 | 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) | |
| 292 | 297 | } |
| 293 | 298 | out := resultText(r) |
| 294 | 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 | 306 | 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)) | |
| 303 | 308 | } |
| 304 | 309 | |
| 305 | 310 | // resultText is what report() builds for the model, without the echo: the |
| @@ -317,7 +322,7 @@ func resultText(r fileedit.Result) string { | ||
| 317 | 322 | // text to the model, capped by maxOutput. The user sees the change BEFORE the |
| 318 | 323 | // model comments on it: what the file became no longer depends on what the |
| 319 | 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 | 326 | out := resultText(r) |
| 322 | 327 | spinner.Suspend(func() { echoOutput(out) }) |
| 323 | 328 | 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 instruction | 55 | +// 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 in | 56 | +// 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 out | 64 | + 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), nil | 99 | + 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), nil | 105 | + 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), nil | 131 | + return record(d, "write_file", in.Path, out) |
| 127 | } | 132 | } |
| 128 | - return report(d, "write_file", in.Path, r), nil | 133 | + 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), nil | 170 | + return record(d, "edit_file", editKey(in), out) |
| 166 | } | 171 | } |
| 167 | - return report(d, "edit_file", editKey(in), r), nil | 172 | + 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), nil | 225 | + 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), nil | 229 | + 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), nil | 246 | + 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), nil | 258 | + 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)), nil | 266 | + 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), nil | 283 | + 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), nil | 296 | + 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)), nil | 307 | + 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: the | 310 | // 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 the | 322 | // 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 the | 323 | // 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 | ||
| 169 | 169 | |
| 170 | 170 | // The loop detector sees the command AND its output: the same pair |
| 171 | 171 | // 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) | |
| 182 | 175 | }) |
| 183 | 176 | } |
| 184 | 177 | |
| @@ -207,7 +200,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba | ||
| 207 | 200 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 208 | 201 | // Recorded like any other outcome: a model that keeps retrying a |
| 209 | 202 | // command the user keeps rejecting is looping. |
| 210 | - return record(d, "bash", in.Command, out), nil | |
| 203 | + return record(d, "bash", in.Command, out) | |
| 211 | 204 | } |
| 212 | 205 | |
| 213 | 206 | s.ToolRunning(ev.ID) |
| @@ -216,7 +209,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba | ||
| 216 | 209 | out, failed := runBash(s.WorkDir(), in.Command) |
| 217 | 210 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) |
| 218 | 211 | |
| 219 | - return record(d, "bash", in.Command, out), nil | |
| 212 | + return record(d, "bash", in.Command, out) | |
| 220 | 213 | } |
| 221 | 214 | |
| 222 | 215 | // --- read_skill -------------------------------------------------------------- |
| @@ -268,10 +261,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe | ||
| 268 | 261 | // exactly as it would on a failing command. |
| 269 | 262 | out := fmt.Sprintf("No skill named %q. Available skills: %s", |
| 270 | 263 | 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) | |
| 275 | 265 | } |
| 276 | 266 | |
| 277 | 267 | // 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 | 269 | // information — which one was loaded. What they want to see are the |
| 280 | 270 | // commands that follow from it, and those will show up. |
| 281 | 271 | 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) | |
| 286 | 273 | }) |
| 287 | 274 | } |
| 288 | 275 | |
| @@ -307,10 +294,10 @@ func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []sk | ||
| 307 | 294 | out := fmt.Sprintf("No skill named %q. Available skills: %s", |
| 308 | 295 | in.Name, strings.Join(skills.Names(list), ", ")) |
| 309 | 296 | 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) | |
| 311 | 298 | } |
| 312 | 299 | |
| 313 | 300 | out := truncate(content) |
| 314 | 301 | 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) | |
| 316 | 303 | } |
| @@ -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 pair | 170 | // 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 to | 171 | // 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 a | 201 | // 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), nil | 203 | + 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), nil | 212 | + 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, it | 267 | // 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 the | 269 | // 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), nil | 297 | + 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), nil | 302 | + 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 { | ||
| 382 | 382 | if k := engine.Skills(turn); k > 0 { |
| 383 | 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 | 388 | return line |
| 386 | 389 | } |
| 387 | 390 | |
| @@ -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 line | 388 | return line |
| 386 | } | 389 | } |
| 387 | 390 | ||
modified
main.go +65 -18 | @@ -40,6 +40,7 @@ import ( | ||
| 40 | 40 | "mm/internal/config" |
| 41 | 41 | "mm/internal/detector" |
| 42 | 42 | "mm/internal/engine" |
| 43 | + "mm/internal/lsp" | |
| 43 | 44 | "mm/internal/skills" |
| 44 | 45 | "mm/internal/spinner" |
| 45 | 46 | "mm/internal/tools" |
| @@ -107,15 +108,22 @@ func main() { | ||
| 107 | 108 | os.Exit(1) |
| 108 | 109 | } |
| 109 | 110 | |
| 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) | |
| 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 | 123 | if path == "" { |
| 116 | 124 | path = "built-in defaults" |
| 117 | 125 | } |
| 118 | - printBanner(ctx, e, path, toolNames, skillCount) | |
| 126 | + printBanner(ctx, e, path, set) | |
| 119 | 127 | |
| 120 | 128 | // Three front ends, one wiring: everything above — config, engine, tools, |
| 121 | 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 | 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 | 180 | // assembleTools declares the tools every front end gets: `bash` unless |
| 162 | 181 | // `bashTool: false` turned it off; `read_skill` only when the skills |
| 163 | 182 | // directory holds something to read (its |
| 164 | 183 | // description carries the catalogue, so declaring it empty would advertise a |
| 165 | 184 | // tool that can do nothing); the file tools behind `editTools` — the same |
| 166 | 185 | // 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() {}} | |
| 172 | 191 | 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") | |
| 175 | 194 | } |
| 176 | - skillCount := len(skills.List(config.Cfg.SkillsDir)) | |
| 195 | + set.skillCount = len(skills.List(config.Cfg.SkillsDir)) | |
| 177 | 196 | 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") | |
| 180 | 199 | } |
| 181 | 200 | if config.Cfg.EditTools { |
| 182 | - agentTools = append(agentTools, | |
| 201 | + set.tools = append(set.tools, | |
| 183 | 202 | tools.ReadFile(e.G, d), |
| 184 | 203 | tools.WriteFile(e.G, d), |
| 185 | 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, 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 | |
| 189 | 234 | } |
| 190 | 235 | |
| 191 | 236 | // printBanner probes the server and prints the warnings and the one-line |
| 192 | 237 | // banner. The probe runs before the banner so its warnings sit right under |
| 193 | 238 | // the prompt they explain; it never stops the agent — on a demo machine the |
| 194 | 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 | 242 | info := e.Probe(ctx) |
| 243 | + info.Warnings = append(info.Warnings, set.warnings...) | |
| 197 | 244 | ctxCol := "unknown" |
| 198 | 245 | if info.ContextWindow > 0 { |
| 199 | 246 | 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` unless | 180 | // assembleTools declares the tools every front end gets: `bash` unless |
| 162 | // `bashTool: false` turned it off; `read_skill` only when the skills | 181 | // `bashTool: false` turned it off; `read_skill` only when the skills |
| 163 | // directory holds something to read (its | 182 | // directory holds something to read (its |
| 164 | // description carries the catalogue, so declaring it empty would advertise a | 183 | // description carries the catalogue, so declaring it empty would advertise a |
| 165 | // tool that can do nothing); the file tools behind `editTools` — the same | 184 | // 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 part | 185 | // 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 | 186 | +// (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.ToolRef | 189 | +func assembleTools(e *engine.Engine, d *detector.LoopDetector) toolSet { |
| 171 | - var toolNames []string | 190 | + 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, 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 | ||
| 189 | } | 234 | } |
| 190 | 235 | ||
| 191 | // printBanner probes the server and prints the warnings and the one-line | 236 | // 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 under | 237 | // 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 the | 238 | // 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) |