pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
231 lines (216 loc) • 15.5 kB
Markdown
# Agent-Facing Tools
pi-lens registers the following tools with the pi agent. Most are also exposed
through the MCP mirror (`clients/lens-engine.ts` is the seam both adapters
share) — current exception: `ast_grep_outline`
(module_report supersedes them for discovery), and `lens_diagnostic_mark`
(pi-lens-internal for now). `read_enclosing` gained MCP parity
(`pilens_read_enclosing`) as of #536, closing #522 item 1. The standalone
`lsp_diagnostics` tool was folded into `lens_diagnostics` (`source=lsp`,
#2860). The retired MCP name remains a one-release compatibility redirect to
`pilens_diagnostics` with `source=lsp` and `scope=paths` (`mcp/server.ts`),
logging one `lsp-diagnostics-compatibility` degradation per session. Callers
should move to `pilens_diagnostics`.
**Dynamic tooling.** Five tools stay always-active: `lens_diagnostics`,
`module_report`, `read_symbol`, `read_enclosing`,
`symbol_search`. Five situational tools — `ast_grep_search`, `ast_grep_replace`,
`ast_grep_outline`, `lsp_navigation`, `lens_diagnostic_mark` —
are registered but
inactive by default; the model activates the ones it needs via the always-active
loader tool `pi_lens_activate_tools`, per pi's dynamic-tool-loading API
(`pi.setActiveTools`/`pi.getActiveTools`). The loader explicitly reports
"Available starting next turn"; do not retry the tool in the same turn.
Feature-detected: on hosts without that API, the five situational tools fall back
to being statically active, exactly as before (`tools/activate-tools.ts`, wired
in `index.ts`).
Tool descriptions contain the contract sentence and one example. Operational
guidance, including cache state, scan scope, safety details, and lifecycle
results, belongs in the returned result so it is paid only when the tool runs.
**Result contract.** One post-result gate per surface — `finalizeToolResult` /
`finalizeToolResultWithDelivery` in `tools/render-compact.ts`, wired into every
pi tool's `execute` wrapper in `index.ts` and into the MCP `tools/call`
dispatcher in `mcp/server.ts` — bounds every delivered result to 40 KiB
(`MAX_RESULT_BYTES`) and stamps a trailing usage footer describing what was
actually sent: `usage tokens=<n> elapsed-ms=<n> bytes=<n> truncated=<true|false>`.
A rejected call (e.g. MCP's "Unknown or disabled tool") is rendered through the
same gate rather than bypassing it.
**MCP argument handling (#3749).** The MCP `tools/call` dispatcher compares the
call's argument keys with the `inputSchema` the tool advertises in `tools/list`
(`findIgnoredArguments`, `mcp/tool-arguments.ts`), once, for every tool. An
undeclared top-level key is never silently dropped (keys inside a nested
object argument such as `callHierarchyItem`, `rule` or `flags` are not checked):
- the result's first line is ``Ignored unknown argument(s) for <tool>: `key`
(did you mean `nearest`?). They had no effect on this call.`` (the suggestion
appears only when a declared key is near), followed by the tool's own result;
- the result carries `structuredContent: { ignoredArguments: [...],
ignoredArgumentCount: N }` (at most 8 keys, each cut at 64 characters; the
count is exact);
- when an ignored key leaves the call without something it needed, the tool
does not run and the result is an error whose first line is the same
sentence, followed by `Not run: ...`. That is the case when a
schema-`required` input is missing (``Not run: required argument(s) `file`
missing.``), or when the ignored key is a declared parameter the call did not
send, written another way: a case or punctuation variant, a plural, or a key
whose last word is the parameter (`filePath`, `file_path` and `Path` for the
optional `path` of `pilens_diagnostics`: ``Not run: `filePath` looks like a
mistyped `path`, which was not sent.``). The exact predicate is named in
[public-api-stability.md](public-api-stability.md). Anything looser (a typo,
an abbreviation, `files` for `maxLspFiles`, or a count, flag or output key
such as `maxFiles`, `includeFiles`, `outFile` or `cwdPath` where `cwd` is
declared) stays a warning;
- each such call adds one count to the `mcp-ignored-arguments` degradation
group (subject: the tool name), visible in `pilens_health`.
Unknown keys are still not rejected outright: a caller that passes an extra key
that is not a declared parameter written another way (or that comes with the
parameter it resembles) keeps getting its answer, with the report above. Hard rejection is a change to the
public MCP input contract and is owned by the stability policy
([public-api-stability.md](public-api-stability.md)). A schema declares only
what the tool reads, so `cwd` on a tool whose handler never reads it
(`pilens_health`, `pilens_latency`, `pilens_session_end`, `pilens_rebuild`;
the dispatcher uses it only to decide whether the tool is enabled) is reported
as an ignored key, a warning, because nothing it resembles is missing. The
retired `pilens_lsp_diagnostics` name is checked against `pilens_diagnostics`'s
schema; the retired `pilens_ast_grep_dump` name has no schema and is not
checked. A non-object `arguments` is a JSON-RPC `-32602` error; absent, `null`
and `[]` mean no arguments.
## Per-edit
- **`lens_diagnostics`** — Session-cache or LSP-probe diagnostic state, selected
by `source` (`session` default, or `lsp`) and `scope` (`paths` or
`workspace`; explicit `paths` always win over `scope`). `severity` is a
threshold, not an exact filter: `error` shows only errors; `warning` adds
warnings; `information` adds information; `hint`/`all` (default) show every
tier. `source=lsp` reads a file's on-disk content and will not sync it to a
language server past **2 MiB or 5000 lines** (`clients/runtime-config.ts`
`pipeline.lspMaxFileBytes`/`lspMaxFileLines`, checked in
`clients/lsp/content-limits.ts`) — not env-tunable today. A file over that
bound gets an explicit `file too large for LSP diagnostics (<N> bytes/lines
> <limit>)` result instead of stale or partial diagnostics. This is a
*different* 2 MiB from the auto-fix full-content attachment cap described in
[agent-guide.md](agent-guide.md), section 6 ("Auto-format / auto-fix
timing") — the two caps currently share one constant
(`RUNTIME_CONFIG.pipeline.lspMaxFileBytes`) but gate unrelated behaviors
(LSP sync vs. tool-result content attachment) and can diverge independently
in the future. Separately, every save-triggered edit now sends the server a
`textDocument/didSave` when it declared `textDocumentSync.save` — with the
file text when the server asked for it (`includeText`) and the file is
within the same bound, otherwise the save is still sent but without `text`
(never dropped outright); this is what makes save-triggered servers like
Expert (Elixir) re-diagnose an edit made through pi-lens (refs #3405,
#3408). Legacy `mode`: `delta` (current turn), `all` (resurfaces stale
blockers dropped from turn context), `full` (project-wide scan).
- **`lens_diagnostic_mark`** — Triage a diagnostic: `false-positive` /
`suppress` (writes an inline `pi-lens-ignore` comment) / `defer`
(session-only) / `flagged` (persists, rendered `📌 flagged-to-fix`).
Content-anchored so marks survive edits; every mark is logged and published
on the bus. See [dispositions.md](dispositions.md).
- **`lsp_navigation`** — IDE-style navigation, 19 operations: `definition`,
`typeDefinition`, `declaration`, `references`, `hover`, `signatureHelp`,
`documentSymbol`, `findSymbol`, `workspaceSymbol`, `codeAction`, `rename`,
`rename_file`, `implementation`, `prepareCallHierarchy`, `incomingCalls`,
`outgoingCalls`, `executeCommand`, `workspaceDiagnostics`, and `capabilities`
(`tools/lsp-navigation.ts` operation description). Position-based operations
accept a `path`/`line`/`character` triple. `documentSymbol` accepts a `kinds`
filter (e.g. `function`, `class`) and a `maxResults` cap (default 20, max 100)
to keep large files bounded. Full per-operation parameter reference:
[skills/pi-lens-lsp-navigation/SKILL.md](../skills/pi-lens-lsp-navigation/SKILL.md).
- **`ast_grep_search`** — AST-aware structural search across ~40 languages via
the `sg` CLI. Supports metavariables (`$VAR`, `$$$ARGS`), `strictness`
modes (`smart`, `relaxed`, `ast`, `cst`, `signature`, `template`), structural
constraints (`insideKind`, `hasKind`, `hasDescendantKind`, `follows`,
`precedes`), raw YAML `rule` passthrough, `validateOnly` for compile/shape
checks without scanning project files, and pagination via `skip` / `maxMatches`
(per-call cap, default 50, max 200; also sets the pagination step).
`nodeKind` is an expert grammar-specific escape hatch: it finds every node of
the exact kind used by the target grammar. Node kinds are not universal across
languages; use `dump=true` with the representative snippet in `pattern` to discover the kind in the target language. It is mutually exclusive with `rule`.
`hasKind` retains ast-grep's immediate-child semantics; use
`hasDescendantKind` for an explicit recursive descendant search. A future
canonical `find`/`query` facade (call/function/import/etc.) should map to
this same synthesized YAML path, with per-language templates and a clear
unsupported-concept error — it is intentionally not a second search engine
in this patch.
`groupByFile: true` renders a compact one-line-per-file distribution
(`L<line>:<col>` locations) instead of each match body — for high-volume
searches. `pattern` is optional when a `rule` or `nodeKind` is given.
Results include `details.matchLocations[]` — each hit carries a ready
`readSlice` (`path`/`offset`/`limit`) for a bounded context read; zero-match
results include a `suggestedDump` hint pointing at `ast_grep_search` with `dump=true`.
- **`ast_grep_replace`** — AST-aware structural replace. Re-validates the pattern
against the current file before writing and reports a clear error if the
file changed since the preview.
- **`ast_grep_search` with `dump=true`** — Dumps the raw tree-sitter AST for a source snippet. Use
this when an `ast_grep_search` or `ast_grep_replace` pattern returns zero
matches and the correct node kind or field name is unknown. `includeAnonymous`
shows punctuation/CST nodes.
- **`ast_grep_outline`** — Syntax-only code structure (symbols, imports, exports,
members) for files or directories via `ast-grep outline`. Fast, local, no
index/LSP/cross-file semantics. Supports `items`/`view`/`type`/`match`/
`pubMembers`/`globs`; returns per-file `items[]` (nested `members[]`) with
ready `read` args. Prefer `module_report` for pi-lens-aware navigation; reach
for this when the syntax tree's own view is enough or pi-lens's extractor is
weak for a language.
## Project intelligence
- **`symbol_search`** — Ranked identifier search over the persisted word index
(BM25 + priors demoting tests/vendor/docs, optional graph-centrality boost).
Answers "which files are most relevant to `<query>`" by identifier — the
first step of the discovery funnel: `symbol_search` finds candidates,
`module_report` explains one, `read_symbol` reads its body. Complements
`grep` (raw substrings) and `lsp_navigation` (exact references). Returns
`available: false` with a retry hint when the index isn't built yet — it
self-builds in the background and never blocks the call. See
[docs/word-index.md](word-index.md) for how the index is built and kept
warm.
- **`module_report`** — Navigable outline of a file: every symbol's name/kind/
startLine/endLine/signature, exported vs internal split, class/interface
member nesting, who-uses-this, fanout/complexity risk flags, and a
`recommendedReads` top-3 ranked by usage + complexity. No per-symbol `read`
block (#512) — `offset`/`limit` are pure derivations of `startLine`/`endLine`
on the report's own `path`; to read a symbol call `read`/`read_symbol` with
`offset=startLine, limit=endLine-startLine+1` on that path. Cross-file
entries (`blastRadius.files[].read`, `usedBy[].file`) keep their own path.
Each entry also carries a first-line `doc` summary (whitespace-collapsed,
~120 chars) extracted from an attached doc comment when the language uses
tree-sitter's conventional `comment` node — JS/TS is the primary target;
languages sharing that node shape (confirmed: Python, and any grammar whose
comment nodes are plain preceding siblings) get it for free with no
per-grammar query work. `exported` stays a boolean field only — it is NOT
also repeated inside `flags` (#512); `flags` carries only non-derivable
signals (`async`, `high fanout`, `high complexity`, `boundary wrapper`).
Each entry carries a `decorators[]` array — the declaration's
decorators/attributes/annotations (`.get("/x")`, `#[tokio::main]`,
``) — so the agent reads a symbol's role (route/test/fixture/
entrypoint) without opening the body. Also emits a `callbacks[]` section for
high-signal inline executables (event handlers, timers, promise callbacks,
object/dict function props, assigned closures) with stable synthetic
handles and flags. The optional `focus` string re-ranks `recommendedReads`
without expanding scope. `callbackSupport` (`tuned`/`generic`) reports
whether language-specific callback rules applied — the callback *node
kinds* are language-uniform, but the semantics are per-language (JS/TS-tuned
by default, plus Go goroutine/defer, Python scheduler/future lambda, Rust
spawn/move-closure, Swift weak/strong-self capture, C++ by-reference-
capture, Kotlin coroutine-builder, Java thread/executor/listener, and C#
Task.Run/event-`+=` slices); named symbols span all tree-sitter
`SYMBOL_QUERIES` languages. Pass `view: "summary"` for a smaller orientation
payload (top-level entries + recommendations, heavy callback/usedBy/
blast-radius payloads omitted); pass `view: "compact"` for a line-oriented
TEXT rendering of the full report (cheapest option — roughly a quarter of
the JSON cost, same underlying data). Reports section-level `provenance`
for syntax, cached-graph, and heuristic sections. Pass `blastRadius: true`
for cross-file transitive dependents (read-only over the cached graph).
- **`read_symbol`** — One symbol's verbatim source body, by name or by a
`module_report` callback handle. Returned body is recorded as genuine
read-guard coverage for that symbol/callback's line range.
- **`read_enclosing`** — Maps a `path` + `line` (from `ast_grep_search`,
diagnostics, or LSP locations) to the verbatim body of the smallest enclosing
symbol or callback. Tree-sitter only — no LSP or graph build. Optional `kinds`
filter and `maxLines` cap; records read-guard coverage for the returned range.
When the body exceeds `maxLines`, `onOversize` controls the fallback: `error`
(default) returns metadata only, `slice` returns a bounded partial read around
the line (size via `aroundLine`, default `maxLines` then 80), and `outline`
returns the nested symbols/callbacks with read handles.
## Session
- **`lens_health`** — Runtime health, latency telemetry, and current LSP
status.
- **`lens_project_scan`** — Cheap project-wide scans (knip, jscpd, duplicates).
- **`lens_diagnostics mode=full`** — full project-wide review: LSP + the cached
heavyweight analyzers (jscpd, madge, gitleaks, govulncheck, trivy, dead-code)
folded in via `refreshRunners`.