diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6fac6d5..454447b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "code-context", "description": "Local hybrid keyword + semantic search and SQL over your codebase, for coding agents.", - "version": "0.4.0", + "version": "0.5.0", "author": { "name": "Infino AI" }, "homepage": "https://github.com/infino-ai/code-context" } diff --git a/.devin/wiki.json b/.devin/wiki.json index d17f1ca..0c552ad 100644 --- a/.devin/wiki.json +++ b/.devin/wiki.json @@ -1,13 +1,13 @@ { "repo_notes": [ { - "content": "code-context is local code search for AI coding agents: a CLI (bins `code-context` and `cx`) and an MCP server over a ranked index that lives in plain files inside the repo, under `.infino/`. It fuses exact keyword matching (BM25) with semantic similarity (vectors) into one ranked pass, and exposes read-only SQL over the index so search composes with aggregation (`GROUP BY`). Drop it into Claude Code with `claude mcp add code-context -- npx -y @infino-ai/code-context mcp`, or install the CLI with `npm install -g @infino-ai/code-context` then `cx index` in a repo. Document it as a tool developers and coding agents use to answer questions about a codebase without crawling files into the context window: lead with the MCP setup, the three MCP tools (search, sql, reindex), and how the index stays fresh." + "content": "code-context is local code search for AI coding agents: a CLI (bins `code-context` and `cx`) and an MCP server over a ranked index that lives in plain files inside the repo, under `.infino/`. It fuses exact keyword matching (BM25) with semantic similarity (vectors) into one ranked pass, and exposes read-only SQL over the index so search composes with aggregation (`GROUP BY`). Drop it into Claude Code with `claude mcp add code-context -- npx -y @infino-ai/code-context mcp`, or install the CLI with `npm install -g @infino-ai/code-context` then `cx index` in a repo. Document it as a tool developers and coding agents use to answer questions about a codebase without crawling files into the context window: lead with the MCP setup, the three MCP tools (find, search, sql), and how the index stays fresh on its own (first query builds it, every query re-syncs it; there is no reindex tool)." }, { - "content": "Common misconceptions to avoid when documenting code-context, with the corrected statements: (1) It is NOT a cloud service and needs NO account, API key, or database server. It runs locally; embedding uses a small local model downloaded once, and code never leaves the machine. (2) It is NOT semantic/vector search only. Search is hybrid: BM25 and vector rankings fuse in one pass, and until vectors finish backfilling it degrades to keyword-only rather than failing. (3) The index is NOT a proprietary database tier. It is plain files in `.infino/` that you can copy, cache in CI, or gitignore. (4) It is NOT a structural code-intelligence tool. It does ranked content retrieval and content-relevance aggregation, not call-graph tracing, dead-code detection, or type resolution; graph/LSP tools are complementary and stack alongside it. (5) It is for questions that span the repo (understanding a subsystem, ranking or aggregating across files); for jumping to one known symbol a plain grep is already cheap." + "content": "Common misconceptions to avoid when documenting code-context, with the corrected statements: (1) It is NOT a cloud service and needs NO account, API key, or database server. It runs locally; embedding uses a small local model downloaded once, and code never leaves the machine. (2) It is NOT semantic/vector search only. Search is hybrid: BM25 and vector rankings fuse in one pass, and until vectors finish backfilling it degrades to keyword-only rather than failing. (3) The index is NOT a proprietary database tier. It is plain files in `.infino/` that you can copy, cache in CI, or gitignore. (4) It is NOT a structural code-intelligence tool. It does ranked content retrieval and content-relevance aggregation, not call-graph tracing, dead-code detection, or type resolution; graph/LSP tools are complementary and stack alongside it. (5) It is NOT only for questions that span the repo. Those (understanding a subsystem, ranking or aggregating across files) are where it saves the most, but the grep case - every occurrence of a known symbol or literal - is served by the find tool from the same index, complete and unranked, with no file scanned." }, { - "content": "How it works: files are chunked at definition boundaries with tree-sitter (WASM, no native compile) for common languages, with a fixed-window fallback for the rest; every chunk carries path, start_line, end_line, lang, and content. Chunks are indexed into one table named `chunks` in `.infino/`, with a BM25 full-text index and an IVF vector index, queried in-process through the infino engine's Node binding (no server). Readiness is staged: the keyword index commits in seconds so search is live immediately, while vectors backfill in the background and hybrid ranking unlocks automatically when they land. Sync is incremental: a per-file state map (size/mtime prefilter, then content hash) re-chunks and re-embeds only changed files, and the MCP server auto-syncs in the background as queries arrive. The MCP surface is exactly three tools by design: search (hybrid ranked retrieval), sql (read-only SELECT/WITH, with the ranked search functions bm25_search/hybrid_search usable as table-valued relations so search composes with GROUP BY aggregation), and reindex (incremental sync). code-context is built on the infino engine (https://github.com/infino-ai/infino), whose same index format also serves logs, docs, and agent memory." + "content": "How it works: files are chunked at definition boundaries with tree-sitter (WASM, no native compile) for common languages, with a fixed-window fallback for the rest; every chunk carries path, start_line, end_line, lang, and content. Chunks are indexed into one table named `chunks` in `.infino/`, with a BM25 full-text index and an IVF vector index, queried in-process through the infino engine's Node binding (no server). Readiness is staged: the keyword index commits in seconds so search is live immediately, while vectors backfill in the background and hybrid ranking unlocks automatically when they land. Sync is incremental: a per-file state map (size/mtime prefilter, then content hash) re-chunks and re-embeds only changed files, and the MCP server auto-syncs in the background as queries arrive. The MCP surface is exactly three tools by design: find (every line containing an exact string, cited path:line; the index's token match picks candidate chunks and each line is checked for the literal, so it is complete and unranked like grep -n), search (hybrid ranked retrieval), and sql (read-only SELECT/WITH, with the ranked search functions bm25_search/hybrid_search usable as table-valued relations so search composes with GROUP BY aggregation). Freshness is not a tool: the first query builds the index and every query re-syncs it; cx index --full rebuilds from a shell. code-context is built on the infino engine (https://github.com/infino-ai/infino), whose same index format also serves logs, docs, and agent memory." } ] } diff --git a/.mcp.json b/.mcp.json index ccce499..a29c02f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "code-context": { "command": "npx", - "args": ["-y", "@infino-ai/code-context@0.4.0", "mcp"], + "args": ["-y", "@infino-ai/code-context@0.5.0", "mcp"], "alwaysLoad": true } } diff --git a/AGENTS.md b/AGENTS.md index 5b02689..e8c18a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,9 +24,10 @@ the honest limits in [docs/tradeoffs.md](docs/tradeoffs.md). ## Repo map - `src/cli.ts`: the `cx` / `code-context` command entry (commander). -- `src/mcp/server.ts`: the MCP server, three tools (`search`, `sql`, - `reindex`). Each takes an optional `path` (repo root) so one server serves - multiple repos in a session, defaulting to the startup root. +- `src/mcp/server.ts`: the MCP server, three tools (`find`, `search`, `sql`). + Each takes an optional `path` (repo root) so one server serves multiple + repos in a session, defaulting to the startup root. Freshness is not a + tool: the first query builds the index and every query re-syncs it. - `src/mcp/repos.ts`: the per-repo registry - resolves and validates a requested root, one engine connection per repo, LRU-capped. - `src/mcp/ensure.ts`: auto-index on first query - a `search`/`sql` on a @@ -34,7 +35,7 @@ the honest limits in [docs/tradeoffs.md](docs/tradeoffs.md). (`CX_AUTO_INDEX=0` restores the strict "index it first" error). - `src/core/`: the engine-facing core. `chunker` (tree-sitter chunking), `indexer` (build + staged readiness + incremental sync), `searcher` - (hybrid search + SQL), `embedder` (local model), `filestate` (incremental + (find, hybrid search, SQL), `embedder` (local model), `filestate` (incremental sync state), `walker`, `manifest`, `config`, `context`, `output`. - `src/commands/`: CLI command implementations (`index-cmd`, `query-cmds`). - `test/`: vitest suites. `bench/`: the benchmark harness. `docs/`: docs. @@ -53,11 +54,20 @@ before opening a PR. ## Conventions - TypeScript, ES modules. Every source file carries an SPDX header. -- The MCP surface is deliberately three tools: one way to find (`search`), - one way to count (`sql`), one way to stay fresh (`reindex`). Adding - near-duplicate retrieval tools worsens an agent's tool selection; resist it. +- The MCP surface is deliberately three tools, one per question: where does + this exact text occur (`find`, unranked and complete - the grep + replacement), what is most relevant (`search`, ranked top-k), how much of + what is where (`sql`). Adding near-duplicate retrieval tools worsens an + agent's tool selection; resist it. A new tool must answer a question none + of these three does. A `reindex` tool was the fourth until it was measured + (docs/benchmark.md, "The tool surface"): no Sonnet run called it, Haiku + called it where it hurt, and auto-sync already does the job. - Search results carry chunk content plus `path:line` ranges so answers cite code; keep that contract when touching `searcher` or the tool descriptions. +- Tool descriptions and server instructions are prompt text on every turn + and were measured to steer tool selection sentence by sentence. Change + them with the bench (`bench/`, the four question sets and + `compare-builds.mjs`), not by taste. ## Boundaries diff --git a/README.md b/README.md index f44baa3..5d3e7fb 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,10 @@ servers - where clients defer tool definitions behind a tool-search step - the agent doesn't miss the index and fall back to plain file search. (Use *either* the plugin or this command, not both.) -Then just ask a question about the code. The first `search` or `sql` on an -unindexed repo builds the index inline and answers on the same call: keyword +Then just ask a question about the code. The first `find`, `search`, or `sql` +on an unindexed repo builds the index inline and answers on the same call: keyword search is live in seconds, and vectors backfill in the background. (Prefer to -kick it off yourself? The `reindex` tool does the same build on demand.) +kick it off yourself? `cx index` does the same build from a shell.) CI-tested on Linux x64 (glibc) and macOS arm64; linux-arm64, musl, and Windows-via-WSL are expected to work through the engine's prebuilt bindings @@ -108,6 +108,20 @@ explore less efficiently, so the savings tend to be **larger** there. On pinpoint symbol lookup, where a single grep is already cheap, an index matches file tools rather than beating them. +Adding `find` was measured the same way, against the three-tool build on the +same repo, questions, model, and a blind judge: answer quality level (judge +29 / 22 / 13 main / find / tie over 64 pairs, no out-of-bounds citation in +128 answers), exact-lookup questions **-35% tokens, -17% dollars, -38% tool +calls**, the shipped question set flat (-3% tokens, +1% dollars), and about a +thousand tokens per turn of added prompt for the fourth tool. + +The tool surface itself was then measured lever by lever - names, result +shapes, and every sentence of description - on two models with a blind +judge: the shipped text is the one that kept selection where it was, cut the +per-turn prompt cost of the tool definitions by more than half, and judged +51 to 33 over the previous surface. That run is also why there are three +tools and not four. + Full methodology and per-question tables are in [docs/benchmark.md](docs/benchmark.md), with the harness in [`bench/`](bench/) so you can run the same lanes on your own repo. @@ -118,14 +132,18 @@ One index and a deliberately small tool surface for agents: | Tool | What it does | When agents use it | |---|---|---| +| `find` | Every line containing an exact string, cited `path:line` like `grep -n`, plus matching lines per file like `grep -c`. Complete and unranked: the index's token match picks the candidate chunks, then each line is checked for the literal, so no file is scanned and every hit is a real occurrence. | Where an agent would grep: every use or definition of an identifier, an error message, a config key. | | `search` | One ranked pass fusing exact keyword matching (BM25) with semantic similarity (reciprocal-rank fusion). Hits carry the chunk content, so answers come straight from results. | A strong default for finding and understanding code: how a subsystem works, code by meaning or exact term, context before a change, similar implementations - exact identifiers and paraphrases in the same call. | | `sql` | Read-only SQL over the index, with the ranked search functions (`bm25_search`/`hybrid_search`) usable as table-valued relations. | Counts, rankings, aggregates over the whole repo in one query. | -| `reindex` | Incremental sync (the server also auto-syncs in the background). | After significant edits. | -Three tools is a deliberate design: one way to find, one way to count, one -way to stay fresh. Every additional near-duplicate retrieval tool worsens an -agent's tool selection, and hybrid search's keyword half already ranks -exact identifier terms highly, so a separate lexical tool has no job left. +Three tools, each a different question: where does this exact text occur, +what is most relevant to this, how much of what is where. Freshness is not +a tool: the first query on an unindexed repo builds the index, every query +re-syncs it against the working tree, and `cx index --full` rebuilds from a +shell. There are no near-duplicate retrieval tools, because those worsen an +agent's tool selection: `find` is unranked and complete where `search` is +ranked and top-k, and hybrid search's keyword half already ranks exact +identifier terms highly, so no separate lexical *ranking* tool exists. ### The SQL move @@ -164,7 +182,7 @@ export and pass around. ## Setup for agents code-context is an MCP server over stdio, so any MCP client works. Register -it once and the tools (`search`, `sql`, `reindex`) become available to the +it once and the tools (`find`, `search`, `sql`) become available to the agent.
@@ -252,9 +270,9 @@ when the client's working directory is not the repo.
-Tools: `search`, `sql`, `reindex` (incremental sync: an unchanged repo is -a fast no-op, and the server also auto-syncs in the background as queries -arrive, so results track your edits without anyone asking). +Tools: `find`, `search`, `sql`. The server auto-syncs in the background as +queries arrive (an unchanged repo is a fast no-op), so results track your +edits without anyone asking; `cx index --full` from a shell forces a rebuild. **Multiple repos in one session.** Each tool takes an optional `path` (an absolute repo root). Omit it and the server uses its startup root; set it to @@ -268,15 +286,16 @@ no restart, no per-repo config. |---|---|---| | `CX_INDEX_DIR` | `/.infino` | where the index lives | | `CX_SEARCH_K` | 10 | default number of hits `search` returns (also settable per call and via the CLI `-k` flag) | -| `CX_MAX_FILES` / `CX_MAX_FILE_BYTES` | 20000 / 1MB | indexing caps (files over the file cap are left out; `search`/`sql` then flag the index as partial so an absence isn't read as proof) | +| `CX_FIND_LIMIT` | 500 | default number of matching lines `find` returns, which is also the hard cap, so it only bites on a flood (also settable per call and via the CLI `--limit` flag); `total` and `byFile` are complete either way | +| `CX_MAX_FILES` / `CX_MAX_FILE_BYTES` | 20000 / 1MB | indexing caps (files over the file cap are left out; `find`/`search`/`sql` then flag the index as partial so an absence isn't read as proof) | | `CX_ROOT` | current directory | default repo root for the MCP server / CLI when not run from the repo (each tool call can override it with a `path` argument) | -| `CX_AUTO_INDEX` | on | `0` makes a query on an unindexed repo error instead of building the index inline on the first `search`/`sql` | +| `CX_AUTO_INDEX` | on | `0` makes a query on an unindexed repo error instead of building the index inline on the first `find`/`search`/`sql` | | `CX_AUTO_SYNC` | on | `0` disables the MCP server's background staleness sync | | `CX_SYNC_INTERVAL_SECS` | 30 | auto-sync debounce between staleness checks | | `CX_NO_EMBED` | off | keyword-only mode for the MCP server (skip the vector stage) | | `CX_NO_RECEIPT` | off | `1` turns off usage accounting - the per-call receipt on results and the `cx usage` ledger | -Every `search` / `sql` result carries a **usage receipt** - a terse, local line +Every `find` / `search` / `sql` result carries a **usage receipt** - a terse, local line showing the tokens it returned, the files it spanned, and a running session total (e.g. `returned ~1.2k tokens | 4 chunks / 3 files | session ~8.4k over 7 queries`). Every figure is a `~` estimate, computed in-process - nothing about @@ -294,6 +313,7 @@ npm install -g @infino-ai/code-context ``` cx index [path] sync the index (incremental; --full rebuilds, --watch follows edits) +cx find every line containing the exact text, path:line (-i, -c per-file counts, --limit) cx search exact terms + meaning, one ranked pass (-k hits) cx sql read-only SQL; --embed q="text" fills {{q}} cx status what the index holds, how fresh, vector readiness @@ -301,10 +321,11 @@ cx usage ledger of queries run and what each returned (-n, --a cx mcp serve the MCP tools over stdio ``` -`cx usage` reads the local ledger at `.infino/usage.jsonl` - every `search` / -`sql` (from the CLI or the MCP server) appends one line recording the query and -a compact summary of what came back (paths and line ranges for search, row -count for sql), plus the token figures from the receipt. It's a deterministic, +`cx usage` reads the local ledger at `.infino/usage.jsonl` - every `find` / +`search` / `sql` (from the CLI or the MCP server) appends one line recording the +query and a compact summary of what came back (`path:line` for find, paths and +line ranges for search, row count for sql), plus the token figures from the +receipt. It's a deterministic, model-independent view of what went through the index - no running server or agent needed to read it back. `CX_NO_RECEIPT=1` turns off both the inline receipt and this ledger. @@ -335,6 +356,14 @@ to your Claude Code settings (`~/.claude/settings.json` or a project and prints nothing. If you run code-context via `npx`, use `npx -y @infino-ai/code-context usage --hook` as the command. +The same tally breaks the calls down by tool (`by tool: find 4 · search 2 · +sql 1`) and records which tool the agent reached for first in each prompt +(`first tool of a prompt: find 4 · Grep 2`), which is what tells you whether +the tool surface steers as intended. With the matcher above only +code-context's own tools are forwarded, so the first-tool line names them +alone; set the `PostToolUse` matcher to `.*` to see Grep, Read, and the rest +in that line too, at the cost of one hook process per tool call. + ## What it is, and what it isn't code-context's lane is ranked **content** retrieval and content-relevance diff --git a/bench/README.md b/bench/README.md index 3942ffa..a95b52b 100644 --- a/bench/README.md +++ b/bench/README.md @@ -49,3 +49,24 @@ Lane design notes (they matter for fairness): - Token totals count input + cache writes + cache reads + output; cost uses the API's per-run accounting. - Model is set in `lanes.mjs` (`BENCH_MODEL`, default `claude-sonnet-4-6`). +- The MCP lanes run this checkout's `dist/cli.js`. To compare two builds of + the server (a tool-surface variant against main), point a run at another + build with `CX_BENCH_CLI=/path/to/other/dist/cli.js` and label it with + `CX_BENCH_BUILD=`; both land on every result row, so one + `questions.jsonl` can hold every variant. + +Reading a multi-build results file (all default to `.work/results/questions.jsonl`; +a build is its `CX_BENCH_BUILD` label, or `since..until` ISO timestamps for rows +recorded before the label existed): + +```bash +node compare-builds.mjs "" V0,V3 # per set: tokens, cost, calls, first tool; CX_MD=1 for markdown, CX_DETAIL=1 per question +node cite-check.mjs /path/to/repo "" V0,V3 # every cited path:line exists, is in bounds, and names an identifier found nearby +node judge.mjs /path/to/repo V0 V3 # blind pairwise judge (claude-opus-5, Read/Grep/Glob on the repo) -> .work/results/judge.jsonl +node judge-report.mjs # wins, ties, unsupported claims and confidence per set for each judged pair +``` + +The judge sees both answers in random order and returns a winner, a +confidence, and how many claims each answer makes that the code does not +support; `JUDGE_LIMIT=n` caps the pairs for a smoke run. Judging costs about +a quarter of a dollar per pair. diff --git a/bench/cite-check.mjs b/bench/cite-check.mjs new file mode 100644 index 0000000..61395fa --- /dev/null +++ b/bench/cite-check.mjs @@ -0,0 +1,143 @@ +// Mechanical citation check over the answers in a results file: every +// `path:line` or `path:start-end` an answer cites must name a file in the repo +// with the range inside it, and an identifier written beside the citation +// (in backticks, within the same sentence) must appear within a few lines of +// the cited range. Reports per build so variants can be compared. +// +// Usage: node cite-check.mjs [results=questions.jsonl] [builds] +// builds comma-separated build labels, or `since..until` windows for rows +// without a label; default: every build in the file (plus `V0` for +// unlabelled rows when CX_V0_WINDOW=since..until is set) +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { RESULTS } from "./lanes.mjs"; + +const [repoArg, resultsArg, buildsArg] = process.argv.slice(2); +if (!repoArg) { + console.error("usage: node cite-check.mjs [results.jsonl] [builds]"); + process.exit(1); +} +const repoDir = resolve(repoArg); +const resultsFile = resultsArg ? resolve(resultsArg) : join(RESULTS, "questions.jsonl"); + +/** How far from the cited range an identifier may sit and still count as + * anchored: a citation to a signature line often names the body's symbol. */ +const ANCHOR_SLACK_LINES = 5; +/** Characters of answer text before a citation searched for its identifier. */ +const CITATION_CONTEXT_CHARS = 160; + +const CITATION = /(? JSON.parse(l)) + .filter((r) => r.lane === "combo" && !r.error && r.answer); + +function selector(spec) { + const m = /^(\S+)\.\.(\S+)$/.exec(spec); + if (m) return (r) => !r.build && r.ts >= m[1] && r.ts < m[2]; + return (r) => r.build === spec; +} +let builds; +if (buildsArg) builds = buildsArg.split(","); +else { + builds = [...new Set(rows.map((r) => r.build).filter(Boolean))]; + if (process.env.CX_V0_WINDOW) builds.unshift(process.env.CX_V0_WINDOW); +} + +const fileLines = new Map(); +function linesOf(path) { + if (!fileLines.has(path)) { + const full = join(repoDir, path); + fileLines.set(path, existsSync(full) ? readFileSync(full, "utf8").split("\n") : null); + } + return fileLines.get(path); +} + +/** Every file in the repo by basename, for citations written as a bare file + * name (`mod.rs:842` in a table whose full path stands nearby). Built once. */ +const SKIP_DIRS = new Set([".git", ".infino", "target", "node_modules"]); +const byBasename = new Map(); +(function walk(rel) { + for (const entry of readdirSync(join(repoDir, rel), { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walk(rel ? `${rel}/${entry.name}` : entry.name); + } else { + const list = byBasename.get(entry.name) ?? []; + list.push(rel ? `${rel}/${entry.name}` : entry.name); + byBasename.set(entry.name, list); + } + } +})(""); + +/** Resolve a cited path: as written when it has a directory; a bare basename + * to the one repo file of that name, or to the full path the same answer + * writes for it elsewhere; null when neither settles it. */ +function resolveCited(path, answer) { + const clean = path.replace(/^\.\//, ""); + if (existsSync(join(repoDir, clean))) return clean; + // A short form: a bare basename, or a trailing part of the path + // (`manifest/mod.rs`). Resolve against the repo's files of that basename. + const base = clean.slice(clean.lastIndexOf("/") + 1); + const candidates = (byBasename.get(base) ?? []).filter((c) => c === clean || c.endsWith(`/${clean}`)); + if (candidates.length === 1) return candidates[0]; + const inAnswer = candidates.filter((c) => answer.includes(c)); + return inAnswer.length === 1 ? inAnswer[0] : null; +} + +function check(answer) { + const out = { citations: 0, missingFile: 0, outOfBounds: 0, anchored: 0, anchoredOk: 0, examples: [] }; + for (const m of answer.matchAll(CITATION)) { + const [, path, s, e] = m; + const start = Number(s); + const end = e ? Number(e) : start; + out.citations++; + const resolved = resolveCited(path, answer); + const lines = resolved ? linesOf(resolved) : null; + if (!lines) { + out.missingFile++; + // A name the repo has more than once with no full path nearby is + // unresolved rather than absent; both count against the answer. + const known = byBasename.has(path.slice(path.lastIndexOf("/") + 1)); + if (out.examples.length < 3) out.examples.push(`${known ? "unresolved" : "missing"} ${path}`); + continue; + } + if (start < 1 || end > lines.length || end < start) { + out.outOfBounds++; + if (out.examples.length < 3) out.examples.push(`out of bounds ${path}:${s}${e ? "-" + e : ""} (${lines.length} lines)`); + continue; + } + // The identifier named beside the citation: the nearest backticked name + // in the preceding sentence fragment. + const before = answer.slice(Math.max(0, m.index - CITATION_CONTEXT_CHARS), m.index); + const frag = before.slice(Math.max(before.lastIndexOf(". "), before.lastIndexOf("\n")) + 1); + const ids = [...frag.matchAll(IDENTIFIER)].map((x) => x[1]).filter((id) => !id.includes("/") && !/\.[a-z]{1,6}$/.test(id)); + if (ids.length === 0) continue; + const id = ids[ids.length - 1].split("::").pop().split(".").pop().replace(/[()#]/g, ""); + out.anchored++; + const lo = Math.max(0, start - 1 - ANCHOR_SLACK_LINES); + const hi = Math.min(lines.length, end + ANCHOR_SLACK_LINES); + if (lines.slice(lo, hi).some((l) => l.includes(id))) out.anchoredOk++; + else if (out.examples.length < 3) out.examples.push(`unanchored ${id} at ${path}:${s}`); + } + return out; +} + +console.log("build runs citations per answer missing file out of bounds anchored ok / anchored"); +for (const spec of builds) { + const rs = rows.filter(selector(spec)); + const tot = { citations: 0, missingFile: 0, outOfBounds: 0, anchored: 0, anchoredOk: 0 }; + const examples = []; + for (const r of rs) { + const c = check(r.answer); + for (const k of Object.keys(tot)) tot[k] += c[k]; + for (const ex of c.examples) if (examples.length < 6) examples.push(`${r.cat} Q${r.q}: ${ex}`); + } + const label = spec.includes("..") ? "V0" : spec; + console.log( + `${label.padEnd(15)} ${String(rs.length).padStart(4)} ${String(tot.citations).padStart(9)} ${(rs.length ? tot.citations / rs.length : 0).toFixed(1).padStart(10)} ${String(tot.missingFile).padStart(12)} ${String(tot.outOfBounds).padStart(13)} ${`${tot.anchoredOk} / ${tot.anchored}`.padStart(22)}`, + ); + for (const ex of examples) console.log(` ${ex}`); +} diff --git a/bench/compare-builds.mjs b/bench/compare-builds.mjs new file mode 100644 index 0000000..7e1eab0 --- /dev/null +++ b/bench/compare-builds.mjs @@ -0,0 +1,155 @@ +// Compare server builds recorded in one results file, the tool-surface +// report: per category and build, the sum over questions of the median (over +// repeats) tokens, cost and tool calls; how often a code-context tool was the +// first call and was used at all; and the first tool of every run by name. +// +// Usage: node compare-builds.mjs [results=questions.jsonl] [builds] [lane=combo] +// builds comma-separated build labels as recorded on the rows (`build`), or +// `since..until` ISO windows for rows written before the label +// existed (they print as the window). Default: every label in the +// file, with CX_V0_WINDOW=since..until prepended when set. +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { RESULTS } from "./lanes.mjs"; + +const [resultsArg, buildsArg, laneWanted = "combo"] = process.argv.slice(2); +const resultsFile = resultsArg ? resolve(resultsArg) : join(RESULTS, "questions.jsonl"); +const all = readFileSync(resultsFile, "utf8") + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)) + .filter((r) => r.lane === laneWanted); + +function selector(spec) { + const m = /^(\S+)\.\.(\S+)$/.exec(spec); + if (m) return (r) => !r.build && r.ts >= m[1] && r.ts < m[2]; + return (r) => r.build === spec; +} +let builds; +if (buildsArg) builds = buildsArg.split(","); +else { + builds = [...new Set(all.map((r) => r.build).filter(Boolean))]; + if (process.env.CX_V0_WINDOW) builds.unshift(process.env.CX_V0_WINDOW); +} +const label = (spec) => (spec.includes("..") ? "V0" : spec); + +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length === 0 ? 0 : s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; +const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(Math.round(n))); + +/** Per-category sums of per-question medians, plus selection counts. */ +function summarize(rows) { + const byQ = new Map(); + for (const r of rows) { + const key = `${r.cat} ${r.q}`; + if (!byQ.has(key)) byQ.set(key, { cat: r.cat, runs: [] }); + byQ.get(key).runs.push(r); + } + const cats = {}; + for (const g of byQ.values()) { + const c = (cats[g.cat] ??= { tok: 0, cost: 0, calls: 0, runs: 0, cxFirst: 0, cxAny: 0, first: {}, errors: 0 }); + c.tok += median(g.runs.map((r) => r.tokens)); + c.cost += median(g.runs.map((r) => r.costUsd ?? 0)); + c.calls += median(g.runs.map((r) => r.calls)); + for (const r of g.runs) { + c.runs++; + if (r.error) c.errors++; + const f = r.toolCalls?.[0] ?? "(none)"; + c.first[f] = (c.first[f] ?? 0) + 1; + if (f.startsWith("cx:")) c.cxFirst++; + if ((r.toolCalls ?? []).some((t) => t.startsWith("cx:"))) c.cxAny++; + // Every call by tool name over the category's runs: where a build's + // round-trips went (a tiered result that costs Reads shows up here). + c.mix ??= {}; + for (const t of r.toolCalls ?? []) c.mix[t] = (c.mix[t] ?? 0) + 1; + } + } + return cats; +} + +const summaries = builds.map((spec) => ({ spec, cats: summarize(all.filter(selector(spec))) })); +const catOrder = [...new Set(summaries.flatMap((s) => Object.keys(s.cats)))]; +const fmtFirst = (o) => Object.entries(o).sort((a, b) => b[1] - a[1]).map(([t, n]) => `${t} ${n}`).join(", "); + +console.log(`lane=${laneWanted} builds: ${summaries.map((s) => `${label(s.spec)}=${all.filter(selector(s.spec)).length} runs`).join(", ")}`); +console.log(""); +console.log("category build tokens cost calls cx-first cx-any errors first tool of each run"); +for (const cat of catOrder) { + for (const s of summaries) { + const c = s.cats[cat]; + if (!c) continue; + console.log( + `${cat.padEnd(15)} ${label(s.spec).padEnd(5)} ${k(c.tok).padStart(7)} ${("$" + c.cost.toFixed(2)).padStart(5)} ${String(c.calls).padStart(5)} ${`${c.cxFirst}/${c.runs}`.padStart(8)} ${`${c.cxAny}/${c.runs}`.padStart(6)} ${String(c.errors).padStart(6)} ${fmtFirst(c.first)}`, + ); + } + console.log(""); +} +console.log("build tokens cost calls cx-first runs errors"); +for (const s of summaries) { + const cs = Object.values(s.cats); + const sum = (f) => cs.reduce((a, c) => a + f(c), 0); + console.log( + `${label(s.spec).padEnd(5)} ${k(sum((c) => c.tok)).padStart(7)} ${("$" + sum((c) => c.cost).toFixed(2)).padStart(5)} ${String(sum((c) => c.calls)).padStart(5)} ${`${sum((c) => c.cxFirst)}/${sum((c) => c.runs)}`.padStart(8)} ${String(sum((c) => c.runs)).padStart(4)} ${String(sum((c) => c.errors)).padStart(6)}`, + ); +} + +// CX_MD=1 prints the same summary as markdown tables for docs/benchmark.md: +// one per category with a row per build, plus the blended table. "Right +// first" counts runs whose first call was the tool the question shape is for. +const INTENDED = { + aggregation: (t) => t === "cx:sql", + comprehension: (t) => t === "cx:search" || t === "cx:context", + "by-meaning": (t) => t === "cx:search" || t === "cx:context", + pinpoint: (t) => t === "cx:find", + "known-file": (t) => t === "Read", +}; +if (process.env.CX_MD) { + const rightFirst = (cat, first) => + Object.entries(first).reduce((n, [t, c]) => n + ((INTENDED[cat] ?? (() => false))(t) ? c : 0), 0); + for (const cat of catOrder) { + console.log(`\n**${cat}**\n`); + console.log("| build | tokens | cost | calls | right first | first tool of each run |"); + console.log("|---|---|---|---|---|---|"); + for (const s of summaries) { + const c = s.cats[cat]; + if (!c) continue; + console.log(`| ${label(s.spec)} | ${k(c.tok)} | $${c.cost.toFixed(2)} | ${c.calls} | ${rightFirst(cat, c.first)}/${c.runs} | ${fmtFirst(c.first)} |`); + } + } + console.log(`\n**blended**\n`); + console.log("| build | tokens | cost | calls | right first |"); + console.log("|---|---|---|---|---|"); + for (const s of summaries) { + const cs = Object.entries(s.cats); + const sum = (f) => cs.reduce((a, [, c]) => a + f(c), 0); + const right = cs.reduce((a, [cat, c]) => a + rightFirst(cat, c.first), 0); + console.log(`| ${label(s.spec)} | ${k(sum((c) => c.tok))} | $${sum((c) => c.cost).toFixed(2)} | ${sum((c) => c.calls)} | ${right}/${sum((c) => c.runs)} |`); + } +} + +// CX_DETAIL=1 adds the per-question view: median tokens and calls per build, +// and the tool sequence of every run, so a category-level move can be traced +// to the questions that carry it. +if (process.env.CX_DETAIL) { + const cx = (calls) => calls.filter((t) => t.startsWith("cx:")).map((t) => t.slice(3)).join("+") || "-"; + for (const cat of catOrder) { + console.log(`\n=== ${cat} ===`); + for (const s of summaries) { + const c = s.cats[cat]; + if (c) console.log(`${label(s.spec).padEnd(8)} all calls: ${fmtFirst(c.mix ?? {})}`); + } + const qs = [...new Set(all.filter((r) => r.cat === cat).map((r) => r.q))].sort((a, b) => a - b); + for (const q of qs) { + for (const s of summaries) { + const runs = all.filter(selector(s.spec)).filter((r) => r.cat === cat && r.q === q); + if (runs.length === 0) continue; + console.log( + `Q${String(q).padEnd(2)} ${label(s.spec).padEnd(8)} ${k(median(runs.map((r) => r.tokens))).padStart(6)} tok ${String(median(runs.map((r) => r.calls))).padStart(4)} calls first: ${runs.map((r) => r.toolCalls?.[0] ?? "(none)").join(", ").padEnd(36)} cx: ${runs.map((r) => cx(r.toolCalls ?? [])).join(" | ")}`, + ); + } + } + } +} diff --git a/bench/judge-report.mjs b/bench/judge-report.mjs new file mode 100644 index 0000000..0f5e135 --- /dev/null +++ b/bench/judge-report.mjs @@ -0,0 +1,42 @@ +// Summarize the judge's verdicts (bench/.work/results/judge.jsonl) per +// baseline/candidate pair and category: pairs, wins each way, ties, the +// unsupported-claim totals, and the median confidence. A pair judged more than +// once (a smoke run before the full one) counts its latest verdict only. +// Usage: node judge-report.mjs [judge.jsonl] +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { RESULTS } from "./lanes.mjs"; + +const file = process.argv[2] ? resolve(process.argv[2]) : join(RESULTS, "judge.jsonl"); +const latest = new Map(); +for (const line of readFileSync(file, "utf8").split("\n").filter(Boolean)) { + const v = JSON.parse(line); + latest.set(`${v.baseline} ${v.candidate} ${v.cat} ${v.q} ${v.rep}`, v); +} +const verdicts = [...latest.values()]; + +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length === 0 ? 0 : s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; +const label = (spec) => (spec.includes("..") ? "V0" : spec); + +const comparisons = [...new Set(verdicts.map((v) => `${v.baseline}\t${v.candidate}`))]; +for (const cmp of comparisons) { + const [baseline, candidate] = cmp.split("\t"); + const vs = verdicts.filter((v) => v.baseline === baseline && v.candidate === candidate); + const failed = vs.filter((v) => !v.winner).length; + console.log(`${label(baseline)} vs ${label(candidate)} judge=${vs[0]?.judge} pairs=${vs.length}${failed ? ` no verdict: ${failed}` : ""} cost $${vs.reduce((a, v) => a + (v.costUsd ?? 0), 0).toFixed(2)}`); + console.log(`category pairs ${label(baseline).padStart(8)} ${label(candidate).padStart(9)} ties unsupported ${label(baseline)} / ${label(candidate)} conf(med)`); + const cats = [...new Set(vs.map((v) => v.cat))]; + for (const cat of [...cats, "all"]) { + const rows = vs.filter((v) => (cat === "all" || v.cat === cat) && v.winner); + const n = (w) => rows.filter((v) => v.winner === w).length; + const sum = (f) => rows.reduce((a, v) => a + (f(v) ?? 0), 0); + console.log( + `${cat.padEnd(15)} ${String(rows.length).padStart(5)} ${String(n("baseline")).padStart(8)} ${String(n("candidate")).padStart(9)} ${String(n("tie")).padStart(4)} ${`${sum((v) => v.unsupportedBaseline)} / ${sum((v) => v.unsupportedCandidate)}`.padStart(22)} ${median(rows.map((v) => v.confidence ?? 0)).toFixed(2).padStart(9)}`, + ); + } + console.log(""); +} diff --git a/bench/judge.mjs b/bench/judge.mjs new file mode 100644 index 0000000..b10b64b --- /dev/null +++ b/bench/judge.mjs @@ -0,0 +1,186 @@ +// Blind pairwise judge over two builds' answers in a results file: for every +// question and repeat, a stronger model with Read/Grep/Glob on the repo sees +// both answers in random order, checks their claims against the code, and +// returns a winner, a confidence, and how many claims each answer makes that +// the code does not support. Verdicts append to bench/.work/results/judge.jsonl. +// +// Usage: node judge.mjs [results=questions.jsonl] [cats] +// baseline / candidate build labels as recorded on the rows (`build`), or a +// `since..until` ISO window for rows written before the +// label existed (e.g. 2026-09-04T12:05:36Z..2026-09-04T12:30:00Z) +// cats comma-separated categories to judge (default all) +// Model: JUDGE_MODEL (default claude-opus-5). Concurrency: CX_BENCH_CONCURRENCY. +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { RESULTS, record } from "./lanes.mjs"; + +const [repoArg, baselineArg, candidateArg, resultsArg, catsArg] = process.argv.slice(2); +if (!repoArg || !baselineArg || !candidateArg) { + console.error("usage: node judge.mjs [results.jsonl] [cats]"); + process.exit(1); +} +const repoDir = resolve(repoArg); +const resultsFile = resultsArg ? resolve(resultsArg) : join(RESULTS, "questions.jsonl"); +const cats = catsArg ? new Set(catsArg.split(",")) : null; +const JUDGE_MODEL = process.env.JUDGE_MODEL ?? "claude-opus-5"; +const CONC = Number(process.env.CX_BENCH_CONCURRENCY ?? 4); + +/** Rows of one build: by label, or by a `since..until` timestamp window. */ +function selector(spec) { + const m = /^(\S+)\.\.(\S+)$/.exec(spec); + if (m) return (r) => !r.build && r.ts >= m[1] && r.ts < m[2]; + return (r) => r.build === spec; +} + +const rows = readFileSync(resultsFile, "utf8") + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)) + .filter((r) => r.lane === "combo" && !r.error && r.answer && (!cats || cats.has(r.cat))); + +/** Runs of one build grouped by question, in the order they were recorded. */ +function byQuestion(pick) { + const out = new Map(); + for (const r of rows.filter(pick)) { + const key = `${r.cat} ${r.q}`; + if (!out.has(key)) out.set(key, { cat: r.cat, q: r.q, text: r.question, runs: [] }); + out.get(key).runs.push(r); + } + return out; +} + +const base = byQuestion(selector(baselineArg)); +const cand = byQuestion(selector(candidateArg)); + +// Question text comes from the question files; the result rows carry only the +// index. Load every shipped set once and look the text up by (cat, q). +const questionText = new Map(); +for (const file of ["infino.json", "infino-pinpoint.json", "infino-known-file.json", "infino-by-meaning.json"]) { + const items = JSON.parse(readFileSync(join(RESULTS, "..", "..", "questions", file), "utf8")); + items.forEach((item, i) => questionText.set(`${item.cat} ${i + 1}`, item.q)); +} + +const pairs = []; +for (const [key, b] of base) { + const c = cand.get(key); + if (!c) continue; + const n = Math.min(b.runs.length, c.runs.length); + for (let i = 0; i < n; i++) { + pairs.push({ cat: b.cat, q: b.q, rep: i + 1, question: questionText.get(key) ?? "", base: b.runs[i], cand: c.runs[i] }); + } +} +// JUDGE_LIMIT caps the pairs, for a smoke run before spending on the whole set. +if (process.env.JUDGE_LIMIT) pairs.length = Math.min(pairs.length, Number(process.env.JUDGE_LIMIT)); +console.log(`judge=${JUDGE_MODEL} baseline=${baselineArg} candidate=${candidateArg} pairs=${pairs.length}`); + +const system = + `You are judging two answers to a question about the repository checked out at ${repoDir}. ` + + `Use Read, Grep and Glob on that checkout to verify what each answer claims (file paths, line numbers, ` + + `identifiers, counts, behaviour). Judge correctness and how well each claim is supported by the code; ` + + `do not reward length or formatting. Finish with a single JSON object and nothing after it: ` + + `{"winner":"A"|"B"|"tie","confidence":<0..1>,"unsupported_a":,"unsupported_b":,"reason":""} ` + + `where unsupported_* counts the claims in that answer the code does not support.`; + +function parseVerdict(text) { + const start = text.lastIndexOf("{"); + const end = text.lastIndexOf("}"); + if (start < 0 || end < start) return null; + try { + return JSON.parse(text.slice(start, end + 1)); + } catch { + return null; + } +} + +async function judge(pair) { + const swap = Math.random() < 0.5; + const A = swap ? pair.cand : pair.base; + const B = swap ? pair.base : pair.cand; + const prompt = + `Question:\n${pair.question}\n\n=== Answer A ===\n${A.answer}\n\n=== Answer B ===\n${B.answer}\n\n` + + `Verify the claims against the repository, then give the JSON verdict.`; + const t0 = performance.now(); + let text = ""; + let costUsd = null; + let usage = null; + let error = null; + try { + for await (const m of query({ + prompt, + options: { + model: JUDGE_MODEL, + maxTurns: 30, + systemPrompt: system, + permissionMode: "bypassPermissions", + env: { ...process.env, IS_SANDBOX: "1" }, + cwd: repoDir, + settingSources: [], + strictMcpConfig: true, + tools: ["Read", "Grep", "Glob"], + }, + })) { + if (m.type === "result") { + usage = m.usage ?? null; + costUsd = m.total_cost_usd ?? null; + if (m.result) text = m.result; + } + } + } catch (err) { + error = String(err?.message ?? err).slice(0, 300); + } + const v = parseVerdict(text); + const toSide = (w) => (w === "tie" ? "tie" : (w === "A") === !swap ? "baseline" : "candidate"); + const u = usage ?? {}; + return { + cat: pair.cat, + q: pair.q, + rep: pair.rep, + baseline: baselineArg, + candidate: candidateArg, + judge: JUDGE_MODEL, + winner: v ? toSide(v.winner) : null, + confidence: v?.confidence ?? null, + unsupportedBaseline: v ? (swap ? v.unsupported_b : v.unsupported_a) : null, + unsupportedCandidate: v ? (swap ? v.unsupported_a : v.unsupported_b) : null, + reason: v?.reason ?? null, + swapped: swap, + costUsd, + tokens: (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.output_tokens ?? 0), + wallMs: Math.round(performance.now() - t0), + error: error ?? (v ? null : `no verdict in: ${text.slice(-200)}`), + ts: new Date().toISOString(), + }; +} + +const verdicts = []; +let cursor = 0; +async function worker() { + while (cursor < pairs.length) { + const p = pairs[cursor++]; + const v = await judge(p); + record("judge.jsonl", v); + verdicts.push(v); + console.log( + `(${verdicts.length}/${pairs.length}) ${p.cat} Q${p.q} r${p.rep}: ${v.winner ?? "ERR"} ${v.confidence ?? ""} ` + + `unsupported ${v.unsupportedBaseline ?? "?"}/${v.unsupportedCandidate ?? "?"} $${(v.costUsd ?? 0).toFixed(2)}${v.error ? " ERR " + v.error : ""}`, + ); + } +} +await Promise.all(Array.from({ length: CONC }, () => worker())); + +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length === 0 ? 0 : s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; +console.log("\ncategory pairs baseline candidate ties unsupported b/c conf(med) cost"); +const catsSeen = [...new Set(verdicts.map((v) => v.cat))]; +for (const cat of [...catsSeen, "all"]) { + const vs = verdicts.filter((v) => (cat === "all" || v.cat === cat) && v.winner); + const n = (w) => vs.filter((v) => v.winner === w).length; + const sum = (f) => vs.reduce((a, v) => a + (f(v) ?? 0), 0); + console.log( + `${cat.padEnd(15)} ${String(vs.length).padStart(5)} ${String(n("baseline")).padStart(8)} ${String(n("candidate")).padStart(9)} ${String(n("tie")).padStart(4)} ${`${sum((v) => v.unsupportedBaseline)} / ${sum((v) => v.unsupportedCandidate)}`.padStart(15)} ${median(vs.map((v) => v.confidence ?? 0)).toFixed(2).padStart(9)} $${sum((v) => v.costUsd).toFixed(2)}`, + ); +} diff --git a/bench/lanes.mjs b/bench/lanes.mjs index 8243e82..f8515cf 100644 --- a/bench/lanes.mjs +++ b/bench/lanes.mjs @@ -8,7 +8,13 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; export const BENCH = dirname(fileURLToPath(import.meta.url)); export const WORK = join(BENCH, ".work"); export const RESULTS = join(WORK, "results"); -export const CX = resolve(BENCH, "..", "dist", "cli.js"); +/** The server build the MCP lanes run: this checkout's `dist/cli.js`, or + * `CX_BENCH_CLI` to point the same harness at another build (a variant of the + * tool surface in a sibling worktree), so lanes differ in the server alone. */ +export const CX = process.env.CX_BENCH_CLI ? resolve(process.env.CX_BENCH_CLI) : resolve(BENCH, "..", "dist", "cli.js"); +/** A label for the build under test, recorded on every result so runs from + * different variants can be told apart in one results file. */ +export const BUILD = process.env.CX_BENCH_BUILD ?? null; export const MODEL = process.env.BENCH_MODEL ?? "claude-sonnet-4-6"; /** Lane options: identical hermetic base, only the toolset differs. @@ -82,6 +88,8 @@ export async function runLane({ lane, prompt, system, repoDir, indexDir, maxTurn return { lane, model: MODEL, + build: BUILD, + cli: CX, tokens, usage: u, costUsd, diff --git a/bench/questions/infino-by-meaning.json b/bench/questions/infino-by-meaning.json new file mode 100644 index 0000000..0d6484a --- /dev/null +++ b/bench/questions/infino-by-meaning.json @@ -0,0 +1,8 @@ +[ + { "cat": "by-meaning", "q": "Where is the decision made that a set of small files in a table should be merged into a larger one?" }, + { "cat": "by-meaning", "q": "Where does an append become durable, and what is the last step before other readers can see the new rows?" }, + { "cat": "by-meaning", "q": "What stops two writers from committing to the same table at the same time?" }, + { "cat": "by-meaning", "q": "Where are rows that have been deleted filtered out of search results?" }, + { "cat": "by-meaning", "q": "How does a vector search decide how many partitions of the index to visit for a query?" }, + { "cat": "by-meaning", "q": "Where does the code decide whether to read a file from the local disk cache or fetch it from object storage?" } +] diff --git a/bench/questions/infino-known-file.json b/bench/questions/infino-known-file.json new file mode 100644 index 0000000..d8951cf --- /dev/null +++ b/bench/questions/infino-known-file.json @@ -0,0 +1,8 @@ +[ + { "cat": "known-file", "q": "Which Rust toolchain channel does rust-toolchain.toml pin?" }, + { "cat": "known-file", "q": "What are the crate's name, version, and edition in Cargo.toml?" }, + { "cat": "known-file", "q": "Which [[test]] binaries does Cargo.toml declare, and what path does each point at?" }, + { "cat": "known-file", "q": "What does the Makefile's ci target run, step by step?" }, + { "cat": "known-file", "q": "According to docs/architecture/supertable.md, what is the manifest pointer and how does a commit become visible to readers?" }, + { "cat": "known-file", "q": "What commands does CONTRIBUTING.md give for running a single integration test crate?" } +] diff --git a/bench/questions/infino-pinpoint.json b/bench/questions/infino-pinpoint.json new file mode 100644 index 0000000..9784c54 --- /dev/null +++ b/bench/questions/infino-pinpoint.json @@ -0,0 +1,10 @@ +[ + { "cat": "pinpoint", "q": "Where is the constant RRF_K defined, and what is its value?" }, + { "cat": "pinpoint", "q": "List every call site of reconcile_tombstone_seqs, with file and line." }, + { "cat": "pinpoint", "q": "Which file defines InfinoError, and how many variants does the enum have?" }, + { "cat": "pinpoint", "q": "List every place the code reads an environment variable through std::env::var, with the variable name and file:line for each." }, + { "cat": "pinpoint", "q": "Where is pointer_refresh_due defined, and where is it called?" }, + { "cat": "pinpoint", "q": "What are the crate version and the arrow and datafusion dependency versions in Cargo.toml?" }, + { "cat": "pinpoint", "q": "For each file under src/supertable/, how many #[tokio::test] tests does it contain?" }, + { "cat": "pinpoint", "q": "List the tracing call sites in src/compaction/ with the message each one logs." } +] diff --git a/context7.json b/context7.json index bf12e6a..758925b 100644 --- a/context7.json +++ b/context7.json @@ -15,7 +15,7 @@ "code-context runs locally: install with 'npm install -g @infino-ai/code-context' (bins: code-context and cx), or zero-install into Claude Code with 'claude mcp add code-context -- npx -y @infino-ai/code-context mcp'.", "Index a repo with 'cx index' from the repo root. Keyword (BM25) search is live seconds after indexing starts; vectors backfill in the background and hybrid ranking unlocks automatically when they land.", "'cx index' is incremental by default: only files that changed since the last index re-chunk and re-embed. Use --full to rebuild from scratch and --watch to sync on file events.", - "The MCP server ('cx mcp', stdio) exposes exactly three tools: search (exact terms AND meaning in one ranked pass, hits carry chunk content with path:line ranges), sql (read-only SELECT/WITH over the index), and reindex (incremental sync).", + "The MCP server ('cx mcp', stdio) exposes exactly three tools: find (every line containing an exact string, cited path:line like grep -n; complete and unranked - the grep replacement), search (exact terms AND meaning in one ranked pass, hits carry chunk content with path:line ranges), and sql (read-only SELECT/WITH over the index). There is no reindex tool: the first query builds the index, every query re-syncs it, and cx index --full rebuilds from a shell.", "The index is one table named chunks(path, start_line, end_line, lang, content[, embedding]) living in .infino/ inside the repo - plain files you can copy, cache in CI, or gitignore (add .infino/ to your .gitignore).", "The engine's search functions are SQL table functions, so one query can rank AND aggregate: SELECT path, SUM(end_line - start_line + 1) AS lines FROM bm25_search('chunks','content','', 300) GROUP BY path ORDER BY lines DESC.", "vector_search and hybrid_search table functions take a query vector via a {{name}} placeholder: pass an embed map like {\"q\": \"query text\"} and the server embeds it locally and substitutes the vector.", diff --git a/docs/architecture.png b/docs/architecture.png index 01598a1..f6a3dda 100644 Binary files a/docs/architecture.png and b/docs/architecture.png differ diff --git a/docs/architecture.svg b/docs/architecture.svg index de78b40..eca13e2 100644 --- a/docs/architecture.svg +++ b/docs/architecture.svg @@ -28,7 +28,7 @@ CLI (cx) + MCP server - tools: search · sql · reindex + tools: find · search · sql diff --git a/docs/benchmark.md b/docs/benchmark.md index d37491d..f4360e1 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -88,16 +88,384 @@ time follows (13%). The biggest wins land on the questions where the baseline reads the most (one dropped from 861k tokens to 292k). Every answer carries `path:start-end` citations, since hits arrive as ranked chunks with content. -## Where it does not help +## Where ranked search does not help Pinpoint symbol lookup - "jump to this one known identifier" - is a single -grep's home turf, and an index does not beat it there: ranked search returns +grep's home turf, and ranked search does not beat it there: it returns chunks that carry their content, which is dead weight when all you need is one path. That same content is exactly what lets the comprehension answers quote code without opening the file. Same mechanism, opposite sign, depending on whether the question is "where is this exact name" or "how does this work". -The tool descriptions say as much, so the agent still uses a plain grep for -pinpoint lookups. +The `find` tool exists for that first question; the next section measures it. + +## `find`, the grep replacement + +Does adding `find` - every line containing an exact string, cited +`path:line` - change what an agent answers, or what it costs? Measured +2026-09-04 against the three-tool build, on the same repo, questions, model +and judge. + +**Setup.** Two hermetic lanes from `bench/run-questions.mjs`, differing only +in the server build: *main* (`search`, `sql`, `reindex`, from `main @ eec2fe7`) +and *find* (the same plus `find`, from `feat/find-tool @ dc713da`). Both +lanes keep the stock file tools including Grep; nothing is restricted. Repo +under test: [infino](https://github.com/infino-ai/infino) pinned at +`ed4e020` (402 files, 5,528 chunks), indexed once from that clone with +auto-sync off, so every run sees the same index. Agent `claude-sonnet-4-6`, +50-turn cap, fresh conversation per question. Two question sets: the shipped +16 (10 aggregation, 6 comprehension; 3 repeats), and 8 pinpoint lookups +written to be grep's home ground and phrased without naming a tool +(`bench/questions/infino-pinpoint.json`; 2 repeats). 128 agent runs, 64 +judged pairs. Quality was measured two ways: a mechanical citation check +(every cited file exists, every line range is in bounds, the identifier named +beside a citation appears within five lines of it), and a blind pairwise +judge (`claude-opus-5`, a different model from the agent) that sees both +answers in random order with Read/Grep/Glob on the clone and returns a +winner, a confidence, and unsupported-claim counts. Tokens are input + cache +writes + cache reads + output; cost is the API's accounting; per-question +figures are medians over repeats and totals sum the medians. + +### Answer quality: level + +| Blind judge | Pairs | main wins | find wins | Ties | Unsupported claims main / find | Median confidence | +|---|---|---|---|---|---|---| +| shipped, aggregation | 30 | 15 | 9 | 6 | 109 / 97 | 0.62 | +| shipped, comprehension | 18 | 7 | 8 | 3 | 37 / 29 | 0.68 | +| pinpoint | 16 | 7 | 5 | 4 | 13 / 17 | 0.84 | +| **all** | 64 | 29 | 22 | 13 | 159 / 143 | 0.70 | + +The signals point both ways, which is what no effect looks like: main takes +more wins, `find` has fewer unsupported claims on the shipped set and a few +more on the pinpoint set, and where the judge is confident (pinpoint, 0.84) +the split is 7 to 5 with 4 ties. No answer in either lane cited a line +outside its file. The one change is in the form of the answers: on the +pinpoint set, `find` answers carried 111 line-ranged citations across 16 +answers against main's 22, all 105 identifier-anchored ones anchored +correctly. Same correctness, more of it shown. + +### Cost: exact lookups a third cheaper, everything else flat + +Pinpoint set, medians over repeats: + +| Q | Question | main tokens | find tokens | Tokens | Calls main → find | How find answered | +|---|---|---|---|---|---|---| +| 1 | Where `RRF_K` is defined and its value | 49k | 17k | -66% | 2.5 → 1 | one find | +| 2 | Every call site of `reconcile_tombstone_seqs` | 20k | 17k | -14% | 1.5 → 1 | one find | +| 3 | File defining `InfinoError` and its variant count | 108k | 45k | -58% | 5.5 → 2.5 | find, then Read | +| 4 | Every `std::env::var` read, with name and file:line | 203k | 92k | -55% | 13.5 → 3 | two or three finds | +| 5 | Where `pointer_refresh_due` is defined and called | 24k | 17k | -30% | 2 → 1 | one find | +| 6 | Crate, arrow, datafusion versions in Cargo.toml | 22k | 49k | +121% | 1 → 3.5 | three finds, then Read; main did one Read | +| 7 | `#[tokio::test]` counts per file under src/supertable/ | 16k | 17k | +6% | 1 → 1 | Grep, same as main | +| 8 | tracing call sites in src/compaction/ with messages | 135k | 121k | -11% | 8.5 → 9 | mixed find, Grep, Read | +| | **pinpoint (8), tokens** | 578k | 374k | **-35%** | 35.5 → 22 (**-38%**) | | +| | **pinpoint (8), cost per pass** | $0.68 | $0.56 | **-17%** | | | + +Where it fits, it collapses the search: definitions, call sites and +repo-wide inventories (Q1 to Q5) go from a grep-then-read sequence to one to +three `find` calls. The env-var inventory is the clearest case - main's worst +run was 23 calls and 380k tokens, `find`'s best was 2 calls and 57k, with +every cited line verified by the judge. Where it does not fit, the agent +sometimes uses it anyway: Q6 asks for a few lines of one known file, main did +one Read, and the `find` lane ran three finds and then read the file (this is +what the tool description's "a known file is a Read" sentence is for). The +agent opened with `find` in 12 of 16 lookup runs; on main the first call was +Grep in 8 of 16. + +Shipped set, by category: + +| Category | main tokens | find tokens | Tokens | Cost main → find | Calls main → find | +|---|---|---|---|---|---| +| aggregation (10) | 186k | 244k | +31% | $0.28 → $0.33 (+20%) | 11 → 16 | +| comprehension (6) | 719k | 635k | -12% | $1.27 → $1.24 (-3%) | 30 → 27 | +| **blended (16)** | 904k | 879k | **-3%** | $1.55 → $1.57 (**+1%**) | 41 → 43 | + +Flat overall, with the two categories moving in opposite directions by +amounts inside the repeat-to-repeat spread. Two small effects are real. Seven +of the ten aggregation questions are one `sql` call in both lanes, and each +moved from 17k to 18k tokens uniformly: that is the fourth tool's schema and +description in every turn's prompt, roughly a thousand tokens. And on two +aggregation questions the agent tried `find` before falling back to `sql`. +The agent used `find` in 5 of 48 shipped-set runs; these are not the +questions it exists for. + +### What the run found, and what changed after it + +- Quality unchanged; cost down by a third in tokens and a sixth in dollars + where the tool applies; a standing cost of about a thousand prompt tokens + per turn plus occasional mis-selection. +- One grep-shaped question was not covered: per-file counts (Q7). `find` + returned lines with a total, capped at 100 by default in that build, where + the true answer was about 300 lines across 27 files, so the agent used Grep + instead. Both lanes answered that question identically, and identically + wrong (13 files where the tree has 27). The build measured here was + `dc713da`; the commits after it added `byFile` - matching lines per file + over every match, never cut - the "known file is a Read" steer, and a + default limit of 500 (the cap), none of which is measured above. +- Steering is by description only and mostly works: nothing restricts Grep, + and the agent still preferred `find` on lookups. + +**Caveats.** Sonnet 4.6 is the bench default, and tool-selection behaviour - +which drives both the gains and the mis-selection cost - is model-dependent. +Repeats are 3 on the shipped set and 2 on pinpoint; per-question deltas under +about ±50% are noise, so read the totals (main's own pinpoint total varied by +50% between this run and one two days earlier on the same commit). A first +run was discarded because the working tree under test had moved two versions +ahead of its index, so `find` returned line numbers right for the indexed tree +and wrong for the live one: exact-looking, no signal. The MCP server's +auto-sync exists for exactly that; the bench disables it to keep the index +identical across runs, so it must pin the tree instead. Spend for the two +lanes: about $14 of agent runs and $14 of judging. + +## The tool surface: names, shapes, and prose + +The `find` run above showed an agent choosing tools on nothing but a name, a +description, and a result shape. This run measures how much each of those +steers, one lever at a time, and what the steering costs per turn. Measured +2026-09-04; the design and the decisions are in plan 101. + +**Setup.** Same pinned clone (infino @ `ed4e020`, 401 files, 5,527 chunks, +indexed once, auto-sync off), same hermetic lane (stock file tools including +Grep, plus the code-context build under test), `claude-sonnet-4-6`, fresh +conversation per question. Four question sets, 36 questions: the shipped 16 +(10 aggregation, 6 comprehension), the 8 pinpoint lookups, 6 *known-file* +questions whose right first call is Read ("what does `Cargo.toml` pin for +arrow"), and 6 *by-meaning* questions that name no identifier ("which code +decides when a superfile is compacted"). Three repeats, so 108 runs per +build; V0 and V3 were run twice over to measure the spread directly. Haiku +4.5 ran V0, V3, V6, V7 and V8, two passes each. Quality: the same blind pairwise +judge as the `find` run (`claude-opus-5` with Read/Grep/Glob on the clone; +winner, confidence, unsupported-claim counts), each variant against V0, and +the mechanical citation check. Selection is the primary metric: the first +tool called, against the tool the question shape is for. + +The variants, each one change on the last, with the prose the model pays for +on every turn (chars / 4; JSON schema framing comes on top): + +| build | change | tool text | instructions | +|---|---|---|---| +| V0 | the surface after `find` (`byFile`, limit 500) | ~1,280 tok | ~545 tok | +| V1 | descriptions cut to question shape, not-for, result shape; instructions cut to a routing table | ~790 | ~175 | +| V2 | V1 without the "show the usage line" sentence in three descriptions | ~650 | ~160 | +| V3 | V2 with `reindex` off the tool list | ~570 | ~160 | +| V4a | V3 with `search` renamed `context` | ~575 | ~160 | +| V5 | V3 with a tiered `search` result: content on the top 3 hits, a one-line excerpt below | ~590 | ~170 | +| V6 | V3 plus two sentences: answer from a hit without re-reading (in the instructions), and what `lang` holds | ~580 | ~185 | +| V7 | V6 with the answer-from-a-hit sentence in the `search` description as well | ~610 | ~185 | +| V8 | V7 with one sentence per description saying what the `usage` receipt reports, no request to show it | ~680 | ~185 | + +### Selection: the prose was not doing the steering + +Every build, every pass: `sql` first on 30 of 30 aggregation runs, `search` +first on 18 of 18 comprehension and 18 of 18 by-meaning runs. Cutting the +descriptions to a third and the instructions to a quarter moved none of it. +The known-file tripwire did not move either: Read was the first call on 3 +to 5 of 18 runs on every build, `find` on 6 to 13, Glob on the rest. + +Pinpoint moved, and how it moved is the finding. `find` was the first call +on 19 and 18 of 24 runs in the two V0 passes and on 14 and 15 of 24 in the +two V3 passes. Two questions account for it: the env-var inventory ("every +`std::env::var` read, with name and file:line") and the per-file test +count. The inventory opened with `find` in 6 of 6 V0 runs and 3 of 3 V1 +runs, then with Grep in 9 of 9 runs under V2, V3 and V5 - and back to +`find` in 3 of 3 under V4a, whose only difference from V3 is the *other* +tool's name. A six-pass A/B on the pinpoint set alone pinned it: V1 opened +the inventory with `find` 6 of 6 and the test count with `find` or `sql` 6 +of 6; V2, which differs from V1 only by the dropped receipt sentence ("the +result includes a 'usage' field - a one-line receipt (tokens returned, +matches/files, session total); after you answer, show it verbatim"), opened +them with Grep 6 of 6 and 5 of 6. Across everything run, that question is +`find` 12 of 12 with the sentence and Grep 15 of 15 without it. Two +lessons. A borderline question gets a stable choice for a given prompt text +and flips on wording that has nothing to do with it, so a 3-of-3 flip on +one question is not a signal; set totals are. And a sentence written to +make the model show a receipt was steering its tool choice. + +The cost of the flip is real where it lands: the Grep runs on that question +took 15 to 24 calls and 135k to 192k tokens against 2 to 5 calls and 32k to +96k for the `find` runs, for the same inventory. + +Which half of the sentence steered? V8 keeps a description of the field +("the result includes a 'usage' field, a one-line receipt of tokens +returned, matches and files") and drops the request to show it. Six +pinpoint passes: the inventory back to `find` 5 of 6, the test count to +`sql` 4 of 6, 39 of 48 first calls on a code-context tool against V1's 42 +and V2's 31, at the lowest tokens of the three (191k against 232k and +302k). Telling the model what the result reports is what steers; asking it +to relay the receipt was never the active part. + +### Cost: ten percent fewer tokens, reproducibly + +Sums over questions of the per-question median; two passes each of V0 and V3: + +| set | V0 | V0 again | V3 | V3 again | +|---|---|---|---|---| +| aggregation (10) | 138k | 146k | 157k | 146k | +| comprehension (6) | 746k | 774k | 669k | 738k | +| pinpoint (8) | 227k | 205k | 258k | 243k | +| known-file (6) | 131k | 131k | 113k | 107k | +| by-meaning (6) | 524k | 586k | 380k | 383k | +| **blended (36)** | **1,766k** | **1,842k** | **1,576k** | **1,617k** | +| cost per pass | $3.03 | $3.23 | $2.94 | $2.95 | +| tool calls | 103 | 105 | 119 | 124 | + +Pass-to-pass spread on one build is 4 to 12% per set, so: by-meaning down +27 to 35% is real, blended down 10 to 12% is real, comprehension down 3 to +10% is at the edge, and aggregation up 6 to 14% is real and has a cause. +On the two aggregation questions that ask for "reasons", every trimmed +build followed the `sql` call with a `search` (9 of 9 runs against 1 of 6 +under V0), and the "largest Rust files" question paid a three-call detour +(`lang = 'rust'`, no rows, list the languages, retry with `rs`) that nothing +in the description prevented. The judge rewarded the extra `search` (below); +V6 fixes the detour with eight words. + +Single-call questions show the per-turn saving directly: an aggregation +question answered in one `sql` call costs 13k tokens under V0 and 11k under +V3, the 1.5k-token prompt difference, every turn of every conversation. + +### Quality: better, not just level + +Blind judge, each variant against V0, same question and repeat paired: + +| variant | pairs | V0 wins | variant wins | ties | unsupported claims V0 / variant | median confidence | +|---|---|---|---|---|---|---| +| V1 | 107 | 39 | 46 | 22 | 185 / 151 | 0.70 | +| V3 | 108 | 31 | 55 | 22 | 283 / 192 | 0.72 | +| V5 | 107 | 35 | 47 | 25 | 243 / 201 | 0.72 | +| V8 | 107 | 33 | 51 | 23 | 302 / 236 | 0.68 | + +V3 wins on every set (aggregation 21 to 8, by-meaning 10 to 7, comprehension +9 to 6, pinpoint 8 to 5 with 11 ties, known-file 7 to 5) and makes a third +fewer claims the code does not support. The aggregation gap is the second +`search` call: a "ranked list with a short reason each" answered from one +`sql` result invents its reasons; answered after a `search` it cites them. +V8, the shipping candidate, holds that: aggregation 18 to 10, by-meaning 11 +to 6, pinpoint 9 to 7 with 8 ties, comprehension 7 to 7, and on the +known-file set 6 to 3 with 2 unsupported claims against 7 - the extra +`find`-first runs there did not cost the answers anything. No build cited a +line outside its file except one V4a answer; the trimmed builds' answers +carry more citations than V0's (0.7 to 0.8 against 0.5 per answer). + +### The rename and the shape + +`context` for `search` (V4a) changed nothing the rename was for: 18 of 18 +first calls on both comprehension and by-meaning under either name, and +by-meaning tokens higher than V3 (452k against 380k). The name stays. + +The tiered result (V5) trades dollars for round trips. Blended cost $2.61 +against V3's $2.94, tokens level (1,640k), but 161 tool calls against 119: +Reads went from 49 to 86 on comprehension and from 10 to 54 on by-meaning, +because the agent reads the hits whose content it no longer has. Cheaper on +the bill because the Reads hit the prompt cache; slower because each is a +round trip. The judge calls it level with V0 on the sets the shape is for +(comprehension 8 to 8, by-meaning 9 to 8) where V3 wins them, and the +excerpt tier lost a little on known-file (3 to 6). A shape that costs a +third more round trips to arrive at the same answers does not ship as the +default; the branch stays for a client that pays per token and not per +round trip. + +### The confirmation builds + +V6 adds two sentences to V3: the instructions say again that a hit is +answered from without re-reading the file, and the `sql` description says +`lang` is the file extension. V7 puts the first sentence in the `search` +description as well. V8 adds the receipt-field sentence from the A/B above +to each description. Sonnet, tokens per set, alongside both passes of V0 +and V3: + +| set | V0 | V0 again | V3 | V3 again | V6 | V7 | V8 | +|---|---|---|---|---|---|---|---| +| aggregation | 138k | 146k | 157k | 146k | 142k | 146k | 136k | +| comprehension | 746k | 774k | 669k | 738k | 519k | 572k | 578k | +| pinpoint | 227k | 205k | 258k | 243k | 253k | 203k | 204k | +| known-file | 131k | 131k | 113k | 107k | 121k | 112k | 114k | +| by-meaning | 524k | 586k | 380k | 383k | 539k | 433k | 435k | +| **blended** | **1,766k** | **1,842k** | **1,576k** | **1,617k** | **1,574k** | **1,465k** | **1,468k** | +| cost per pass | $3.03 | $3.23 | $2.94 | $2.95 | $2.93 | $2.87 | $2.77 | +| tool calls | 103 | 105 | 119 | 124 | 118 | 105 | 107 | +| first call on a code-context tool | 94/108 | 94/108 | 88/108 | 87/108 | 90/108 | 94/108 | 97/108 | + +The `lang` sentence removed the detour (the largest-Rust-files question is +one `sql` call again). The restored steer brought comprehension to 519k to +578k, under every V0 and V3 pass, with Reads at 33 against 49 to 53. V7 and +V8 are the first trimmed builds to match V0's round trips and first-call +count while spending 17 to 19% fewer tokens; V8 adds the pinpoint steer +(`find` or `sql` first on 18 of 24 against V7's 17 and V3's 14 to 15) and +the highest code-context first-call count of the run. Its one soft spot is +the tripwire set: `find` was the first call on 13 of 18 known-file runs +against V0's 9 and 10, though at 114k tokens against 131k, because a `find` +that lands on a file the agent should have Read costs one cheap call, not a +detour, and the judge preferred V8's known-file answers 6 to 3. + +### Haiku 4.5 + +Two passes of each build, tokens per set: + +| set | V0 | V0 again | V3 | V3 again | V6 | V6 again | V7 | V7 again | V8 | V8 again | +|---|---|---|---|---|---|---|---|---|---|---| +| aggregation | 314k | 182k | 190k | 154k | 194k | 262k | 163k | 147k | 183k | 170k | +| comprehension | 889k | 904k | 1,402k | 1,290k | 1,412k | 847k | 864k | 797k | 1,186k | 794k | +| pinpoint | 366k | 323k | 387k | 328k | 287k | 251k | 392k | 411k | 365k | 524k | +| known-file | 126k | 126k | 103k | 111k | 98k | 109k | 109k | 109k | 110k | 110k | +| by-meaning | 866k | 896k | 853k | 896k | 780k | 605k | 622k | 644k | 627k | 726k | +| **blended** | **2,560k** | **2,432k** | **2,935k** | **2,779k** | **2,772k** | **2,074k** | **2,150k** | **2,108k** | **2,471k** | **2,326k** | + +Selection on Haiku is the same story as Sonnet: `sql` first on 27 to 29 of +30 aggregation runs and `search` first on 16 to 18 of 18 on every build, +`find` first on 13 to 16 of 24 pinpoint runs, and never a code-context +tool on the known-file set (Bash or Read, then Read). Haiku is where the +fourth tool cost something: on "break down the crate by top-level module" +it called `reindex` in 2 of 3 V0 runs, once as its first call, and the two +V0 aggregation passes swing 314k to 182k largely on that question. With +`reindex` hidden the set sits between 147k and 262k. + +Haiku is also where the trimmed text lost something Sonnet did not need. +Comprehension went from about 900k tokens under V0 to 1.3M and 1.4M under +V3, with Reads doubled (82 and 97 against 40 and 45) and Grep up (13 and 17 +against 2 and 6): V0's `search` description said to answer from a hit +without re-confirming it by opening the file, the trimmed one said "answer +and cite from the hits", and Haiku went back to reading. Putting the +sentence back in the server instructions (V6) split the passes, 1.4M and +847k; putting it in the `search` description as well (V7), where the model +reads it as the hits arrive, brought both passes to 864k and 797k, under +V0. V8, the same text plus the receipt-field sentence, split again (1,186k +and 794k). Haiku's spread on this six-question set is wide - the same +build lands 65% apart - so the honest reading is: the trimmed text without +the sentence was over V0 in all four passes, and with it in the `search` +description it was at or under V0 in three of four. Haiku also invented a +tool name (`mcp__code_context__search`, underscores for the hyphen) in 9 +of 648 runs on the trimmed builds and never in 216 under V0; each such +call fails and the run falls back to Grep and Read. + +### What this run decides + +- **`search` keeps its name and its shape.** The rename moved nothing it + was meant to move; the tiered result buys dollars with round trips and + answers no better. +- **The descriptions and instructions ship trimmed**, at about 680 tokens + of tool text and 185 of instructions per turn against 1,280 and 545, + with three sentences the trim had cut and the runs showed were + load-bearing: answer from a hit without re-opening the file (in the + `search` description, for Haiku), what the `usage` receipt reports (in + each description, for the two lookups on Sonnet), and what `lang` holds. +- **The "show the usage line" request goes.** It was paid three times per + turn, and the part of the sentence that steered was the description of + the field, which stays. +- **`reindex` leaves the tool list.** Nothing on Sonnet called it in 216 + runs; Haiku called it where it hurt. Auto-sync and auto-index cover the + job in a session and `cx index --full` on the command line. +- **Prose is measured from now on.** Every sentence in a description costs + every turn; this run found two that steered selection and one that + steered how many files got opened, none of them written for that. The + question sets and the comparison scripts in `bench/` are the harness for + the next change. + +**Caveats.** Sonnet's pass-to-pass spread is 4 to 12% per set; Haiku's is +wider (aggregation 314k against 182k on V0). Per-question first-call counts +are stable per prompt and flip on unrelated wording, so only set totals are +read. The pinned clone and the disabled auto-sync are the same discipline as +the `find` run. Spend: about $50 of agent runs (11 Sonnet passes over the +four sets, three pinpoint-only passes, 10 Haiku passes) and about $107 of +judging, four variants at a quarter of a dollar a pair. ## Indexing at scale @@ -130,7 +498,11 @@ node bench/run-questions.mjs /path/to/repo # default question set ``` Question sets are `bench/questions/*.json` (`{cat, q}` arrays) - point it at -your own repo and questions with no code changes. Wall-clock is only clean +your own repo and questions with no code changes. `infino-pinpoint.json` is +the eight-lookup set from the `find` comparison above; `infino-known-file.json` +(questions whose right first call is Read) and `infino-by-meaning.json` +(questions that name no identifier) measure tool selection for the surface +comparison that follows it. Wall-clock is only clean run sequentially (`CX_BENCH_CONCURRENCY=1`); tool-call count is the concurrency-independent latency proxy. Expect the aggregation multiple to grow with repo size, and the whole gap to grow on weaker models. diff --git a/docs/concepts/code-search-for-coding-agents.md b/docs/concepts/code-search-for-coding-agents.md index 1e94107..8fa06fd 100644 --- a/docs/concepts/code-search-for-coding-agents.md +++ b/docs/concepts/code-search-for-coding-agents.md @@ -33,13 +33,15 @@ files read one at a time. paraphrases, so "where is auth handled" works without knowing the exact identifier. -## Where crawling still wins +## Pinpoint lookups Jumping to one known symbol or literal string is a single grep's job, and -there an index does not save tokens: the grep returns one line, while ranked -search returns content the agent did not need for a path. code-context routes -this correctly (its tool descriptions tell an agent to prefer native grep for -pinpoint lookups) and reaches for the index when a question spans files. +ranked search does not save tokens there: it returns content the agent did +not need for a path. code-context answers that question with `find` instead: +every line containing the exact text, cited `path:line`, from the index's +token match plus a per-line check, so it returns grep's one-line-per-match +shape without scanning a file. The ranked tools are reached for when a +question spans files. ## Hybrid, not just semantic diff --git a/docs/faq.md b/docs/faq.md index 6a42a0e..d4f79f8 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -13,8 +13,9 @@ file by file. The rule of thumb: the more a question spans the repo, the more the index saves. Use it for understanding how a subsystem works, finding code by meaning when you do not know the identifier, and ranking or aggregating -across the whole repo. For jumping to one known symbol or literal string, a -plain grep is already cheap and there is no need for an index. +across the whole repo. For the grep case itself - every occurrence of a known +symbol or literal string - `find` answers from the same index: every matching +line, cited `path:line`, complete and unranked, with no file scanned. ### Does my code leave the machine? @@ -32,9 +33,9 @@ reports that honestly rather than failing. ### Do I have to index before I can search? -No. The first `search` or `sql` on a repo that has never been indexed builds -the index inline and answers on that same call - keyword search is live in -seconds, vectors backfill behind it. Call `reindex` first if you'd rather +No. The first `find`, `search`, or `sql` on a repo that has never been indexed +builds the index inline and answers on that same call - keyword search is live in +seconds, vectors backfill behind it. Run `cx index` first if you'd rather kick the build off explicitly, or set `CX_AUTO_INDEX=0` to make an unindexed query return a "index it first" error instead of building. @@ -63,7 +64,7 @@ no-op. The MCP server also auto-syncs in the background as queries arrive. Indexing caps how many files it takes (`CX_MAX_FILES`, default 20,000); files past the cap are left out. When that happens the index is marked partial: -every `search` and `sql` result carries a `partial` note with how many files +every `find`, `search`, and `sql` result carries a `partial` note with how many files were skipped and the cap in effect, so an agent treats a missing match as "maybe not indexed" rather than "not in the repo." `cx status` shows the same, and `cx search` prints a warning. Raise `CX_MAX_FILES` (CLI: `--max-files`) @@ -71,12 +72,19 @@ and re-index for full coverage. ### What tools does the MCP server expose? -Three, by design: `search` (hybrid keyword + semantic retrieval, one ranked -pass, hits carry chunk content with `path:line` ranges), `sql` (read-only +Three, by design, one per question: `find` (every line containing an exact +string, cited `path:line` like `grep -n`; complete and unranked, the grep +replacement), `search` (hybrid keyword + semantic retrieval, one ranked pass, +hits carry chunk content with `path:line` ranges), and `sql` (read-only `SELECT`/`WITH` over the index, with the ranked search functions usable as -table-valued relations so search composes with `GROUP BY`), and `reindex` -(incremental sync). Every additional near-duplicate retrieval tool worsens an -agent's tool selection, so the surface is kept deliberately small. +table-valued relations so search composes with `GROUP BY`). Every additional +near-duplicate retrieval tool worsens an agent's tool selection, so the +surface is kept deliberately small: `find` and `search` are not duplicates, +one is complete and unranked, the other ranked and top-k. There used to be a +fourth, `reindex`; measured, no Sonnet run ever called it, Haiku called it +where it hurt, and every tool in the list is prompt text on every turn. The +first query builds the index, every query re-syncs it, and `cx index --full` +rebuilds from a shell. ### How is SQL over code useful? diff --git a/docs/tradeoffs.md b/docs/tradeoffs.md index 38d992f..ef434a1 100644 --- a/docs/tradeoffs.md +++ b/docs/tradeoffs.md @@ -10,15 +10,22 @@ symbol-precise references. It ranks and retrieves content and aggregates by relevance. Tools that resolve structure (LSP servers, graph indexes) are complementary: MCP servers stack, so run both when you need both. -### It does not beat grep on pinpoint lookups - -Naming the one file a known symbol lives in is a single grep's job. There the -index does not save tokens: a grep returns one matching line, while ranked -search returns chunks that carry their content. That content is what pays off -on "how does X work" and whole-repo questions, and it is dead weight when all -you need is a path. Adding code-context does not reduce accuracy on -localization; it just does not win on cost there. Both are measured in the -[benchmark](benchmark.md). +### Pinpoint lookups are the smaller win + +Naming the one file a known symbol lives in is a single grep's job, and +`find` does that job from the index: every matching line as `path:line`, per +file counts, no file scanned. A grep hit still needs a follow-up read before +it is a cited line; a `find` hit already is one, so on exact lookups the +saving is the reads that never happen, not a change in what gets found. +Measured against the grep path on eight such questions: -35% tokens, -17% +dollars, -38% tool calls, with answer quality level under a blind judge (see +the [benchmark](benchmark.md#find-the-grep-replacement)). Ranked `search` is +the wrong tool there: it returns chunks that carry their content, which is +what pays off on "how does X work" and whole-repo questions and is dead +weight when all you need is a path. The large savings are still on questions +that span the repo, and a fourth tool has a standing cost of about a thousand +prompt tokens per turn plus the occasional wrong pick; the benchmark records +both. ### The first index of a repo pays a one-time vector cost diff --git a/llms.txt b/llms.txt index 8aef780..43eae5b 100644 --- a/llms.txt +++ b/llms.txt @@ -26,19 +26,21 @@ logs, docs, and agent memory. view when many MCP servers are configured): `claude mcp add-json code-context -s user '{"command":"npx","args":["-y","@infino-ai/code-context","mcp"],"alwaysLoad":true}'`. A Claude Code plugin (`/plugin marketplace add infino-ai/code-context`) bakes the same config in. -- MCP tools (stdio): `search` (exact terms AND meaning, one ranked pass, - hits carry chunk content with path:line ranges), `sql` (read-only - SELECT/WITH over `chunks(path, start_line, end_line, lang, content)`, - ranked search functions (bm25_search/hybrid_search) usable as table-valued - relations so search composes with GROUP BY), `reindex` (incremental sync; - the server also auto-syncs in the - background). A `search`/`sql` on a never-indexed repo builds the index - inline and answers on the same call (keyword live in seconds, vectors - backfilling); `CX_AUTO_INDEX=0` restores a strict "index it first" error. - Each tool takes an optional `path` (absolute repo root) so one server serves - multiple repos in a session; omit it for the startup root. -- CLI: `cx index` (incremental; `--full`, `--watch`), `cx search`, - `cx sql`, `cx status`, `cx mcp`. +- MCP tools (stdio): `find` (every line containing an exact string, cited + path:line like grep -n; complete and unranked - the grep replacement), + `search` (exact terms AND meaning, one ranked pass, hits carry chunk + content with path:line ranges), `sql` (read-only SELECT/WITH over + `chunks(path, start_line, end_line, lang, content)`, ranked search + functions (bm25_search/hybrid_search) usable as table-valued relations so + search composes with GROUP BY). No reindex tool: the server auto-syncs in + the background as queries arrive, and a `find`/`search`/`sql` on a + never-indexed repo builds the index inline and answers on the same call + (keyword live in seconds, vectors backfilling); `CX_AUTO_INDEX=0` restores + a strict "index it first" error. Each tool takes an optional `path` + (absolute repo root) so one server serves multiple repos in a session; + omit it for the startup root. +- CLI: `cx index` (incremental; `--full`, `--watch`), `cx find`, + `cx search`, `cx sql`, `cx status`, `cx mcp`. ## Evidence diff --git a/package.json b/package.json index f0db677..2b3c484 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@infino-ai/code-context", - "version": "0.4.0", + "version": "0.5.0", "mcpName": "io.github.infino-ai/code-context", "description": "Local code search for AI coding agents: a CLI and MCP server with hybrid keyword + semantic search and SQL relevance-ranked aggregation over an index that lives in plain files. No accounts, no keys, no server.", "license": "Apache-2.0", diff --git a/server.json b/server.json index c715a81..4548815 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/infino-ai/code-context", "source": "github" }, - "version": "0.1.4", + "version": "0.5.0", "packages": [ { "registryType": "npm", "identifier": "@infino-ai/code-context", - "version": "0.1.4", + "version": "0.5.0", "transport": { "type": "stdio" }, diff --git a/skills/code-context/SKILL.md b/skills/code-context/SKILL.md index d8da38c..18ece30 100644 --- a/skills/code-context/SKILL.md +++ b/skills/code-context/SKILL.md @@ -1,36 +1,62 @@ --- name: code-context description: > - How to answer codebase questions with the code-context MCP tools (search, - sql, reindex): ranked hybrid keyword+semantic search, relevance-ranked SQL - aggregation over the index, and index lifecycle. Use when a question spans - many files ("how does X work", "where is Y handled"), when ranking or - counting code by topic across a repo, or when the code-context tools are - present but deferred and need loading before use. Not needed for jumping - to one known identifier - plain grep is fine there. + How to answer codebase questions with the code-context MCP tools (find, + search, sql): exact-text lookup that replaces grep, ranked hybrid + keyword+semantic search, relevance-ranked SQL aggregation over the index, + and index lifecycle. Use when you would grep for an identifier or literal, + when a question spans many files ("how does X work", "where is Y + handled"), when ranking or counting code by topic across a repo, or when + the code-context tools are present but deferred and need loading before + use. --- -# code-context: ranked search over the repository +# code-context: search over the repository code-context maintains a local index of the repository (in `.infino/` at the -repo root) and exposes three MCP tools. The more a question spans the repo, -the more one ranked pass beats crawling files into context. +repo root) and exposes three MCP tools. Every lookup an agent would otherwise +do with grep or by crawling files runs against the index instead: `find` for +the exact-text case, one ranked pass for everything that spans the repo. ## If the tools are deferred When the tool names appear in a deferred-tools listing but their schemas are not loaded, load all three in ONE ToolSearch call before the first use, e.g. -query `+code-context search sql reindex` (or `select:` with the exact -listed names, comma-separated). Never load them one call at a time. +query `+code-context find search sql` (or `select:` with the exact listed +names, comma-separated). Never load them one call at a time. ## Choosing the right tool | Situation | Use | | --- | --- | -| One known identifier, literal string, or file | plain grep / file tools | +| Every occurrence of an exact identifier, string, or key (where you would grep) | `find` | +| A file you already know the path of | Read | | "How does X work", "where is Y handled", concept without exact name | `search` | | Counts, rankings, GROUP BY across the repo ("which files have the most code about X") | `sql` | -| Working tree changed a lot mid-session | `reindex` (usually unnecessary - see lifecycle) | +| Working tree changed a lot mid-session | nothing - the next query re-syncs (see lifecycle) | + +## find + +- Pass the exact text as it appears in the code: an identifier, an error + message, a config key. Literal, not a regex; within one line; + case-sensitive unless `ignoreCase`. +- Complete, not ranked: every matching line comes back as `path`, `line`, + and the line's `text` (plus the enclosing definition's `symbol` when + known), in path order, up to `limit` (default and cap 500, so it only + bites on a flood; pass a smaller `limit` when you want fewer). `total` is + the repo-wide count either way, `byFile` lists matching lines per file + over every match (the `grep -c` answer, never cut), and `truncated` says + when the line list was cut - narrow the text. +- Not for a file you already know the path of: Read it. `find` locates + occurrences across the repo; pulling a few lines out of one known file + is a Read. +- The index's token match picks the candidate chunks and each line is then + checked for the literal, so a hit is always a real occurrence and no file + is scanned. The index stores identifiers as tokens (`parse_config` is + `parse` and `config`), but that only widens the candidates: `find` returns + only lines containing the exact text you gave. +- Read `path:line` (a few lines around it) when you need the surrounding + code; most grep-shaped questions are answered by the list itself. ## search @@ -77,8 +103,9 @@ GROUP BY path ORDER BY lines DESC LIMIT 15 while vectors backfill in the background. Do not pre-emptively reindex. - **Later queries auto-sync**: the server re-chunks only files that changed since the last index. An unchanged tree is a fast no-op. -- Call `reindex` explicitly only after sweeping working-tree changes you - want reflected immediately, or `full: true` to force a rebuild. +- There is no reindex tool. If the index is actually wrong (not merely + behind an edit the next query will pick up), `cx index --full` from a + shell rebuilds it. - Each repo's index is keyed to its own root directory: a fresh git worktree is a new root and builds its own index on first query (the main checkout's index does not carry over). @@ -88,9 +115,10 @@ GROUP BY path ORDER BY lines DESC LIMIT 15 - A result carrying a `partial` marker means the repo exceeded the index's file cap and some files were left out: treat a missing match as possibly-unindexed, not as proof the code doesn't exist. -- Search and sql results carry a one-line `usage` receipt (tokens returned, - chunks/files, session running total), computed locally. End your reply by - showing that line to the user verbatim. +- Find, search, and sql results carry a one-line `usage` receipt (tokens + returned, matches or chunks / files, session running total), computed + locally. It is there for the user who asks what a lookup cost; `cx usage` + keeps the ledger. ## Multi-repo sessions @@ -99,9 +127,9 @@ different repository than the one the server started in. ## Cost awareness -- `search`/`sql` calls are cheap (local, milliseconds). +- `find`/`search`/`sql` calls are cheap (local, milliseconds). - The first index of a repo and the vector backfill are the expensive part (CPU for the local embedding model, proportional to repo size). Avoid - forcing `full: true` rebuilds unless the index is actually wrong, and + forcing `cx index --full` rebuilds unless the index is actually wrong, and avoid triggering first-time indexing of large repos that the task does not need. diff --git a/src/cli.ts b/src/cli.ts index e51eb45..edc8236 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,8 +6,8 @@ import { Command } from "commander"; import { indexCmd } from "./commands/index-cmd.js"; -import { searchCmd, sqlCmd, statusCmd, usageCmd } from "./commands/query-cmds.js"; -import { DEFAULT_SEARCH_K } from "./core/config.js"; +import { findCmd, searchCmd, sqlCmd, statusCmd, usageCmd } from "./commands/query-cmds.js"; +import { DEFAULT_SEARCH_K, DEFAULT_FIND_LIMIT, MAX_FIND_LIMIT } from "./core/config.js"; const program = new Command(); @@ -18,20 +18,32 @@ program "Keyword search seconds after `cx index`; semantic and hybrid search when vectors\n" + "finish backfilling; SQL with relevance-ranked aggregation over the whole repo.", ) - .version("0.1.4") + .version("0.5.0") .addHelpText( "after", ` Examples: cx index index the current repo (keyword search is live in seconds) + cx find "parse_config" every line containing it, path:line - like grep -n cx search "parse_config" exact terms and meaning, one ranked pass cx search "where is auth handled" works when you don't know the words cx sql "SELECT path, SUM(end_line - start_line + 1) AS lines \\ FROM bm25_search('chunks','content','vector index', 300) \\ GROUP BY path ORDER BY lines DESC LIMIT 10" - cx mcp serve the MCP tools (search/sql/reindex) over stdio`, + cx mcp serve the MCP tools (find/search/sql) over stdio`, ); +program + .command("find") + .description("every line containing an exact string, like grep -n: complete and unranked") + .argument("", "the exact text to find, as it appears in the code") + .option("-i, --ignore-case", "match regardless of letter case") + .option("-c, --count", "print matching lines per file instead of the lines, like grep -c") + .option("--limit ", `maximum matching lines to print (default ${DEFAULT_FIND_LIMIT}, max ${MAX_FIND_LIMIT})`) + .option("--json", "machine-readable output") + .option("-C, --path ", "repo root (default: current directory)") + .action(findCmd); + program .command("index") .description("bring the index up to date (incremental; full build on first run)") @@ -87,7 +99,7 @@ program program .command("mcp") - .description("serve the MCP tools (search / sql / reindex) over stdio") + .description("serve the MCP tools (find / search / sql) over stdio") .option("-C, --path ", "repo root (default: current directory)") .action(async (opts: { path?: string }) => { const { serveMcp } = await import("./mcp/server.js"); diff --git a/src/commands/query-cmds.ts b/src/commands/query-cmds.ts index fe72707..fffb0cb 100644 --- a/src/commands/query-cmds.ts +++ b/src/commands/query-cmds.ts @@ -1,14 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Infino Authors // -// `cx search` / `cx sql` / `cx status` - the query commands. +// `cx find` / `cx search` / `cx sql` / `cx status` / `cx usage` - the query commands. import { openIndex, NoIndexError } from "../core/context.js"; import { indexDir, resolveRoot } from "../core/config.js"; import { createEmbedder, embedderInfo } from "../core/embedder.js"; -import { search, runSql, jsonify } from "../core/searcher.js"; +import { find, search, runSql, jsonify } from "../core/searcher.js"; import { receiptEnabled, + findEntry, searchEntry, sqlEntry, formatReceipt, @@ -27,6 +28,50 @@ function die(err: unknown): never { process.exit(1); } +export interface FindCmdOptions { + ignoreCase?: boolean; + /** Per-file counts instead of the matching lines, like `grep -c`. */ + count?: boolean; + limit?: string; + json?: boolean; + path?: string; +} + +/** `cx find` - every line containing the exact text, printed `path:line: text` + * the way `grep -n` does, so it drops into the same habits and pipelines. */ +export function findCmd(text: string, opts: FindCmdOptions): void { + try { + const handle = openIndex(opts.path); + // `find` rejects a non-integer, so `--limit abc` is an error rather than an + // empty listing; the raw string is converted here and validated there. + const result = find(handle, text, { + ignoreCase: opts.ignoreCase, + limit: opts.limit === undefined ? undefined : Number(opts.limit), + }); + if (receiptEnabled()) { + const entry = findEntry(result); + recordUsage(handle.dir, entry); + console.error(dim(formatReceipt(entry))); + } + if (opts.json) { + console.log(jsonify(result, true)); + return; + } + if (result.partial) console.error(yellow(`warning: ${result.partial.note}`)); + if (opts.count) { + for (const f of result.byFile) console.log(`${cyan(f.path)}${dim(":")} ${f.count}`); + } else { + for (const m of result.matches) console.log(`${cyan(m.path)}${dim(`:${m.line}:`)} ${m.text}`); + if (result.truncated) { + console.error(yellow(`showing ${result.matches.length} of ${result.total} matches - raise --limit to see more`)); + } + } + if (result.total === 0) console.error(yellow("no matches")); + } catch (err) { + die(err); + } +} + export interface SearchCmdOptions { k: string; json?: boolean; @@ -111,7 +156,7 @@ export function statusCmd(opts: StatusCmdOptions): void { console.log( `code-context index: ${fmtCount(m.chunks)} chunks from ${fmtCount(m.files)} files, ` + `vectors ${m.vectors}, indexed ${fmtAge(m.indexedAt)}. ` + - `MCP tools: search (terms + meaning), sql (aggregation), reindex (after big edits).`, + `MCP tools: find (exact text, every occurrence), search (terms + meaning), sql (aggregation); the index re-syncs on every query.`, ); return; } @@ -152,6 +197,15 @@ export interface UsageCmdOptions { const truncate = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 3) + "..." : s); +/** `find 4 · Grep 2 · Read 1`, most first; empty string when there is nothing. */ +function countsLine(counts: Record | undefined): string { + if (!counts) return ""; + return Object.entries(counts) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([name, n]) => `${name} ${n}`) + .join(" · "); +} + /** `cx usage` - the local ledger of what queries went through the index and a * compact summary of what each returned. Read straight off `.infino/usage.jsonl`, * so it's deterministic and needs no running server or model. */ @@ -187,7 +241,7 @@ export async function usageCmd(opts: UsageCmdOptions): Promise { return; } if (entries.length === 0 && (!session || session.prompts === 0)) { - console.error(yellow("no usage recorded yet - run `cx search`/`cx sql` here, or query via the MCP server")); + console.error(yellow("no usage recorded yet - run `cx find`/`cx search`/`cx sql` here, or query via the MCP server")); return; } @@ -202,6 +256,12 @@ export async function usageCmd(opts: UsageCmdOptions): Promise { const used = Math.min(session.promptsWithCx, session.prompts); const calls = `${session.cxCalls} call${session.cxCalls === 1 ? "" : "s"}`; console.log(dim(` this session: code-context used in ${used} of ${session.prompts} prompts (${calls})`)); + // Which door, and what the agent reached for first: the two numbers that + // say whether the tool surface steers as intended. + const byTool = countsLine(session.cxCallsByTool); + if (byTool) console.log(dim(` by tool: ${byTool}`)); + const first = countsLine(session.firstToolByPrompt); + if (first) console.log(dim(` first tool of a prompt: ${first}`)); } console.log(""); @@ -218,6 +278,16 @@ export async function usageCmd(opts: UsageCmdOptions): Promise { ); const locs = hits.slice(0, 5).map((h) => `${h.path}:${h.startLine}-${h.endLine}`); if (locs.length) console.log(green(` ${locs.join(" ")}${hits.length > 5 ? dim(` (+${hits.length - 5} more)`) : ""}`)); + } else if (e.tool === "find") { + // A find match is one line, so cite it as path:line; the count is the + // repo-wide total, which can exceed the lines that were returned. + const hits = e.hits ?? []; + const files = new Set(hits.map((h) => h.path)).size; + console.log( + `${dim(clock)} ${bold(tool)} ${q} ${dim(`-> ${e.matches ?? hits.length} matches / ${files} files | ~${fmtTokens(e.returnedTokens)} tok`)}`, + ); + const locs = hits.slice(0, 5).map((h) => `${h.path}:${h.startLine}`); + if (locs.length) console.log(green(` ${locs.join(" ")}${hits.length > 5 ? dim(` (+${hits.length - 5} more)`) : ""}`)); } else { console.log( `${dim(clock)} ${bold(tool)} ${q} ${dim(`-> ${e.rows ?? 0} rows | ~${fmtTokens(e.returnedTokens)} tok`)}`, diff --git a/src/core/config.ts b/src/core/config.ts index 810a4ab..e48fca6 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -57,3 +57,17 @@ export const EMBED_MAX_CHARS = Number(process.env.CX_EMBED_MAX_CHARS ?? 8000); /** Default number of search hits. Configurable per call (the `k` tool param / * CLI `-k`) and via CX_SEARCH_K for config/CI-level defaults. */ export const DEFAULT_SEARCH_K = Number(process.env.CX_SEARCH_K ?? 10); + +/** Hard cap on matching lines in one `find` result. At roughly fifty tokens + * per returned line (path, line number, text) this is about 25k tokens: a + * large tool result, but one a session survives, where an unbounded find of + * a ubiquitous term could return a hundred thousand lines. A cut list still + * carries the full total and the per-file counts. */ +export const MAX_FIND_LIMIT = 500; + +/** Default number of matching lines `find` returns when the caller passes no + * limit: the cap itself, so the cut only ever lands on a flood, never on a + * real answer (the largest measured lookup needed about 300 lines). The + * result's `total` and `byFile` are complete either way. Configurable per call + * (the `limit` tool param / CLI `--limit`) and via CX_FIND_LIMIT. */ +export const DEFAULT_FIND_LIMIT = Number(process.env.CX_FIND_LIMIT ?? MAX_FIND_LIMIT); diff --git a/src/core/searcher.ts b/src/core/searcher.ts index 0b8f9b7..dd6d059 100644 --- a/src/core/searcher.ts +++ b/src/core/searcher.ts @@ -1,8 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Infino Authors // -// The two retrieval doors, shared by the CLI and the MCP server: +// The three retrieval doors, shared by the CLI and the MCP server: // +// find - the grep door: every line containing an exact string, cited +// path:line. Complete and unranked - "every place this appears" +// is a different question from "the chunks most about it". // search - the finding door: one ranked pass fuses exact keyword matching // (BM25) with semantic similarity (vectors, RRF) once vectors are // ready; ranked keyword search until then. Hits carry chunk @@ -13,7 +16,7 @@ // vector functions. import type { IndexHandle } from "./context.js"; -import { TABLE, DEFAULT_SEARCH_K } from "./config.js"; +import { TABLE, DEFAULT_SEARCH_K, DEFAULT_FIND_LIMIT, MAX_FIND_LIMIT } from "./config.js"; import type { Embedder } from "./embedder.js"; import type { Manifest } from "./manifest.js"; @@ -131,6 +134,197 @@ export async function search( }; } +// --- find ------------------------------------------------------------------- +// +// Two steps. The index's token match narrows to the chunks that contain every +// token of the query - an inverted-list intersection, no scoring, no top-k - +// then the literal is verified line by line inside those chunks. The analyzer +// splits identifiers (`parse_config` indexes as `parse` and `config`), so the +// first step alone would over-match; the second makes every hit a real +// occurrence of the exact text, and grep's line-based, case-sensitive +// semantics fall out of it. + +export interface FindMatch { + path: string; + /** 1-based line number of the matching line. */ + line: number; + /** The matching line; a line longer than FIND_LINE_CAP is cut to a window + * around the match, with `...` marking each cut end. */ + text: string; + /** Definition name(s) of the enclosing chunk (e.g. "parseConfig"), when known. */ + symbol?: string; +} + +/** Matching lines in one file - the `grep -c` view. */ +export interface FindFileCount { + path: string; + count: number; +} + +export interface FindResult { + query: string; + ignoreCase: boolean; + /** Matching lines in path then line order, cut at the limit. */ + matches: FindMatch[]; + /** Matching lines across the repo before the limit was applied. */ + total: number; + /** Distinct files with at least one match, before the limit. */ + files: number; + /** Matching lines per file, before the limit, most matches first. Always + * complete even when `matches` is cut, so "how many and where" never needs a + * second call. */ + byFile: FindFileCount[]; + /** Set when `total` exceeded the limit and `matches` was cut. */ + truncated?: boolean; + /** Present when the index omitted files over the cap - results may be incomplete. */ + partial?: PartialIndex; +} + +export interface FindOptions { + /** Match regardless of letter case. Default false: case-sensitive, like grep. */ + ignoreCase?: boolean; + /** Maximum matches returned: a positive integer, clamped to MAX_FIND_LIMIT. */ + limit?: number; +} + +/** Per-line cap so one minified or generated line cannot flood the result. */ +const FIND_LINE_CAP = 240; + +/** Characters kept ahead of the match when a long line is cut to a window, so + * the excerpt shows what leads into the match rather than starting on it. */ +const FIND_EXCERPT_LEAD = 60; + +/** Columns a find reads: no `end_line` (each match cites its own line) and no + * `score` (there is none - matches are unranked). */ +const FIND_PROJECTION = ["path", "start_line", "symbol", "content"]; + +/** First code point outside ASCII. The default analyzer extends a token run + * across such characters and then drops the whole run. */ +const NON_ASCII_MIN = 0x80; + +/** The tokens the index's default analyzer (`ascii_lower`) produces for a + * string: runs of `[A-Za-z0-9]`, lowercased, and a run that touches non-ASCII + * text is dropped whole. Mirrored here so the candidate lookup asks the index + * for exactly the tokens it holds - a different split would miss chunks that + * do contain the literal. Duplicates are dropped; the intersection is the same. */ +export function analyzerTokens(text: string): string[] { + const out = new Set(); + let run = ""; + let nonAscii = false; + const flush = () => { + if (run && !nonAscii) out.add(run.toLowerCase()); + run = ""; + nonAscii = false; + }; + for (const ch of text) { + if ((ch.codePointAt(0) ?? 0) >= NON_ASCII_MIN) { + run += ch; + nonAscii = true; + } else if (/[A-Za-z0-9]/.test(ch)) { + run += ch; + } else { + flush(); + } + } + flush(); + return [...out]; +} + +/** The lines of `content` (whose first line is 1-based `startLine`) that + * contain `query` literally, each with its repo line number and the 0-based + * column of the first occurrence. */ +export function matchLines( + content: string, + startLine: number, + query: string, + ignoreCase: boolean, +): Array<{ line: number; text: string; at: number }> { + const needle = ignoreCase ? query.toLowerCase() : query; + const out: Array<{ line: number; text: string; at: number }> = []; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const text = lines[i].replace(/\r$/, ""); + const at = (ignoreCase ? text.toLowerCase() : text).indexOf(needle); + if (at >= 0) out.push({ line: startLine + i, text, at }); + } + return out; +} + +/** `text` cut to at most FIND_LINE_CAP characters around the match at `at` + * (of `needleLength` characters), with `...` on each end that was cut. A short + * line comes back whole. The match always survives the cut: a hit whose text + * did not contain the query would read as the tool being wrong. */ +export function excerpt(text: string, at: number, needleLength: number): string { + if (text.length <= FIND_LINE_CAP) return text; + const lead = Math.min(FIND_EXCERPT_LEAD, Math.max(0, FIND_LINE_CAP - needleLength)); + const start = Math.max(0, Math.min(at - lead, text.length - FIND_LINE_CAP)); + const end = Math.min(text.length, start + FIND_LINE_CAP); + return `${start > 0 ? "..." : ""}${text.slice(start, end)}${end < text.length ? "..." : ""}`; +} + +export function find(handle: IndexHandle, query: string, opts: FindOptions = {}): FindResult { + if (query.length === 0) throw new Error("find needs a non-empty string to look for"); + if (/[\r\n]/.test(query)) { + throw new Error("find matches within a single line - the query must not contain a newline"); + } + const tokens = analyzerTokens(query); + if (tokens.length === 0) { + throw new Error( + "find needs at least one run of ASCII letters or digits to look up in the index; a query of " + + "only punctuation or non-ASCII text cannot use it - try search, or sql with regexp_like(content, ...)", + ); + } + // Reject rather than clamp a malformed limit: NaN would slice to nothing and + // report nothing, which reads as "no matches". + if (opts.limit !== undefined && (!Number.isInteger(opts.limit) || opts.limit < 1)) { + throw new Error(`limit must be a positive integer, got ${opts.limit}`); + } + const ignoreCase = opts.ignoreCase ?? false; + const limit = Math.min(opts.limit ?? DEFAULT_FIND_LIMIT, MAX_FIND_LIMIT); + + const table = handle.db.openTable(TABLE); + const candidates = table.tokenMatch("content", tokens.join(" "), { mode: "and", projection: FIND_PROJECTION }); + + // Fixed-window chunks overlap, so one line can arrive in two chunks; key by path:line. + const seen = new Set(); + const all: FindMatch[] = []; + for (const row of candidates) { + const path = String(row.path); + const symbol = row.symbol ? String(row.symbol) : undefined; + for (const m of matchLines(String(row.content), Number(row.start_line), query, ignoreCase)) { + const key = `${path}${m.line}`; + if (seen.has(key)) continue; + seen.add(key); + all.push({ + path, + line: m.line, + text: excerpt(m.text, m.at, query.length), + ...(symbol ? { symbol } : {}), + }); + } + } + all.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line)); + + // Per-file counts over every match, not the cut list: `grep -c` in one call. + const counts = new Map(); + for (const m of all) counts.set(m.path, (counts.get(m.path) ?? 0) + 1); + const byFile: FindFileCount[] = [...counts] + .map(([path, count]) => ({ path, count })) + .sort((a, b) => b.count - a.count || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + + const partial = partialIndex(handle.manifest); + return { + query, + ignoreCase, + matches: all.slice(0, limit), + total: all.length, + files: byFile.length, + byFile, + ...(all.length > limit ? { truncated: true } : {}), + ...(partial ? { partial } : {}), + }; +} + // --- sql -------------------------------------------------------------------- const PLACEHOLDER = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g; diff --git a/src/core/usage.ts b/src/core/usage.ts index 4d0b67c..7302c15 100644 --- a/src/core/usage.ts +++ b/src/core/usage.ts @@ -9,7 +9,7 @@ import { appendFileSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { jsonify, type SearchResult } from "./searcher.js"; +import { jsonify, type FindResult, type SearchResult } from "./searcher.js"; /** Rough tokens-per-char - the standard heuristic for English + code. Kept * deliberately simple: usage reports `~` figures, not a billed count. */ @@ -38,14 +38,17 @@ export const receiptEnabled = (): boolean => * it doesn't duplicate the repo. */ export interface UsageEntry { ts: string; - tool: "search" | "sql"; + tool: "find" | "search" | "sql"; query: string; returnedTokens: number; /** search only: whole-file size of the distinct files the hits came from. */ wholeFileTokens?: number | null; ranking?: "hybrid" | "keyword"; - /** search only: the response, as the regions you'd jump to. */ + /** search and find: the response, as the regions you'd jump to (a find + * match is a single line, so its start and end are the same). */ hits?: Array<{ path: string; startLine: number; endLine: number }>; + /** find only: matching lines across the repo, before the limit. */ + matches?: number; /** sql only. */ rows?: number; /** sql only: a truncated preview of the returned rows (the answer itself). */ @@ -84,6 +87,20 @@ export function searchEntry(result: SearchResult, root: string): UsageEntry { }; } +/** A find returns one line per match, so what it cost is the serialized + * matches themselves; the whole-file counterfactual is search's and does not + * apply - grep never read the files whole either. */ +export function findEntry(result: FindResult): UsageEntry { + return { + ts: new Date().toISOString(), + tool: "find", + query: result.query, + returnedTokens: estTokens(jsonify(result.matches)), + hits: result.matches.map((m) => ({ path: m.path, startLine: m.line, endLine: m.line })), + matches: result.total, + }; +} + const ROWS_PREVIEW_CAP = 2000; export function sqlEntry(query: string, rows: Array>): UsageEntry { @@ -119,6 +136,12 @@ export function formatReceipt(entry: UsageEntry, session?: SessionUsage): string // it after every response. The raw wholeFileTokens still lives in the entry // for anyone who wants to reason about it from the ledger. parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(hits.length, "chunk", "chunks")} / ${plural(files, "file", "files")}`); + } else if (entry.tool === "find") { + const hits = entry.hits ?? []; + const files = new Set(hits.map((h) => h.path)).size; + // The repo-wide count, not just the lines returned: a cut result still + // tells the reader how many matches exist. + parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(entry.matches ?? hits.length, "match", "matches")} / ${plural(files, "file", "files")}`); } else { parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(entry.rows ?? 0, "row", "rows")}`); } @@ -197,6 +220,20 @@ export interface PromptStats { promptsWithCx: number; /** transient: has the current prompt already used code-context. */ curPromptUsedCx: boolean; + /** code-context invocations by tool (`find`, `search`, `sql`): which door + * the agent actually walks through. Absent on stats files written before + * it was recorded. */ + cxCallsByTool?: Record; + /** The first tool the agent called in each prompt, counted by name - a + * code-context tool by its short name, anything else (Grep, Read, Bash) + * by the name the hook delivered. This is the selection signal: whether a + * grep-shaped prompt opens with `find` or with Grep. Only the tools the + * PostToolUse hook is configured to forward are visible, so with the + * default `mcp__code-context.*` matcher it records code-context tools + * only; widen the matcher to see the rest. Absent on older stats files. */ + firstToolByPrompt?: Record; + /** transient: has the current prompt's first tool call been recorded. */ + curPromptFirstToolSeen?: boolean; } /** The shape Claude Code delivers to a hook command on stdin (subset we use). */ @@ -211,6 +248,21 @@ export interface HookPayload { * default server and any renamed variant (e.g. code-context-local). */ const isCodeContextTool = (name?: string): boolean => !!name && /^mcp__code[-_]?context/i.test(name); +/** The short tool name inside a code-context MCP tool id: + * `mcp__code-context__find` -> `find`, `mcp__code-context-dev__sql` -> `sql`. + * The server segment may carry a suffix, so the split is on the last `__`. */ +export function codeContextToolName(name: string): string { + const at = name.lastIndexOf("__"); + return at >= 0 ? name.slice(at + 2) : name; +} + +/** Add one to `counts[key]`, creating the map or the key as needed. */ +function bump(counts: Record | undefined, key: string): Record { + const out = counts ?? {}; + out[key] = (out[key] ?? 0) + 1; + return out; +} + const MAX_SESSIONS = 25; function loadPromptStats(indexDir: string): Record { @@ -235,10 +287,14 @@ function savePromptStats(indexDir: string, all: Record): vo } } -/** Fold one Claude Code hook event into the local counters. Best-effort. */ +/** Fold one Claude Code hook event into the local counters. Best-effort. + * Every PostToolUse event counts toward the first-tool-per-prompt tally + * (whatever tools the hook matcher forwards); only code-context's own tools + * count as invocations. */ export function recordHookEvent(indexDir: string, payload: HookPayload): void { const event = payload.hook_event_name ?? ""; - const tracked = event === "UserPromptSubmit" || (event === "PostToolUse" && isCodeContextTool(payload.tool_name)); + const isCx = event === "PostToolUse" && isCodeContextTool(payload.tool_name); + const tracked = event === "UserPromptSubmit" || (event === "PostToolUse" && !!payload.tool_name); if (!tracked) return; const sid = payload.session_id ?? "unknown"; @@ -250,11 +306,23 @@ export function recordHookEvent(indexDir: string, payload: HookPayload): void { if (event === "UserPromptSubmit") { s.prompts++; s.curPromptUsedCx = false; + s.curPromptFirstToolSeen = false; } else { - s.cxCalls++; - if (!s.curPromptUsedCx && s.prompts > 0) { - s.promptsWithCx++; - s.curPromptUsedCx = true; + const rawName = payload.tool_name ?? ""; + const label = isCx ? codeContextToolName(rawName) : rawName; + // The first tool of a prompt is the selection signal; a call that lands + // before any prompt was seen has no prompt to belong to and is not counted. + if (!s.curPromptFirstToolSeen && s.prompts > 0) { + s.firstToolByPrompt = bump(s.firstToolByPrompt, label); + s.curPromptFirstToolSeen = true; + } + if (isCx) { + s.cxCalls++; + s.cxCallsByTool = bump(s.cxCallsByTool, label); + if (!s.curPromptUsedCx && s.prompts > 0) { + s.promptsWithCx++; + s.curPromptUsedCx = true; + } } } s.lastAt = now; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 01f7f72..6f36af2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,27 +3,54 @@ // // The dedicated MCP server: three tools over one code index. // +// find - the grep door: every line containing an exact string, cited +// path:line - complete and unranked // search - find code: exact terms AND meaning in one ranked pass // sql - the power door: relevance-ranked aggregation over the search // table functions (bm25_search / hybrid_search + GROUP BY) -// reindex - sync from the working tree; replies the moment keyword -// search is live and backfills vectors in-process // -// Three tools, deliberately: one way to find, one way to count, one way to -// stay fresh - every additional near-duplicate retrieval tool worsens the -// agent's tool selection. Results carry took_ms - server-side time for -// the call (query embedding included where one happens; no transport). +// Three tools, each a different question: where does this exact text occur, +// what is most relevant to this, how much of what is where. Freshness is not +// a tool: the first query on an unindexed repo builds the index, and every +// query re-syncs it against the working tree (auto-sync, below). A reindex +// tool used to be the fourth; measured, no Sonnet run ever called it and +// Haiku called it where it hurt, and every tool in the list is prompt text +// on every turn. `cx index --full` is the forced rebuild. +// No near-duplicate retrieval tools - those worsen the agent's tool +// selection - so find is unranked and complete where search is ranked and +// top-k, and hybrid search's keyword half already ranks exact identifiers. +// Every sentence in the descriptions below is paid for on every turn and +// was measured to steer selection (docs/benchmark.md, "The tool surface"): +// change them with the bench, not by taste. +// Results carry took_ms - server-side time for the call (query embedding +// included where one happens; no transport). import { existsSync } from "node:fs"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { connect } from "@infino-ai/infino"; -import { indexDir, resolveRoot, TABLE, DEFAULT_CAPS, DEFAULT_SEARCH_K } from "../core/config.js"; +import { + indexDir, + resolveRoot, + TABLE, + DEFAULT_CAPS, + DEFAULT_SEARCH_K, + DEFAULT_FIND_LIMIT, + MAX_FIND_LIMIT, +} from "../core/config.js"; import { readManifest, type Manifest } from "../core/manifest.js"; import type { IndexHandle } from "../core/context.js"; -import { search, runSql, jsonify, partialIndex } from "../core/searcher.js"; -import { newSession, receiptEnabled, searchEntry, sqlEntry, formatReceipt, recordUsage } from "../core/usage.js"; +import { find, search, runSql, jsonify, partialIndex } from "../core/searcher.js"; +import { + newSession, + receiptEnabled, + findEntry, + searchEntry, + sqlEntry, + formatReceipt, + recordUsage, +} from "../core/usage.js"; import { indexRepoStaged, syncRepo, @@ -157,7 +184,7 @@ export async function serveMcp(rootPath?: string): Promise { isError: true, }); const noIndex = (root: string) => - fail(`no index for ${root} yet - call the reindex tool once (keyword search is live in seconds).`); + fail(`no index for ${root} yet - run \`cx index\` there once (keyword search is live in seconds).`); /** Marker attached to a query result when this call built the index. */ const autoIndexNote = (stats: IndexStats) => ({ @@ -178,31 +205,16 @@ export async function serveMcp(rootPath?: string): Promise { { name: "code-context", version: "0.1.2" }, { instructions: - "code-context is a local ranked index over this repository - semantic + keyword search and " + - "SQL over the whole codebase. Reach for it whenever you need to understand or find code: " + - "understanding how a subsystem works, finding code by meaning or by exact term, gathering " + - "context before an edit, locating a bug or the code behind a behaviour, reviewing existing " + - "patterns, planning a refactor, understanding the architecture for feature work, or spotting " + - "similar/duplicate implementations. It is the primary tool for finding and understanding " + - "code here, for almost any question about this codebase. Three tools:\n" + - "- search - find code by meaning or terms across files and understand how something works, " + - "in one ranked pass.\n" + - "- sql - counts, rankings, and aggregates over the whole repo in one query, including " + - "relevance-ranked aggregation ('which files have the most code about X') that file tools " + - "cannot express at any budget.\n" + - "- reindex - sync the index after the working tree changes.\n" + - "Treat a hit's content as authoritative: when it answers the question, answer from it and " + - "cite path plus line range - you don't need to re-confirm with grep or by opening the file. " + - "Read a file only for a hit marked truncated (its cited range), or when the results genuinely " + - "don't cover the question. When one search isn't enough, refine the query and search again - " + - "the ranked hits are already the relevant regions.\n" + - "Every tool takes an optional 'path' (an absolute repo root): omit it for the default repo, " + - "or set it to target a specific one when you're working across more than one repo in a session.\n" + - "If a result carries a 'partial' marker, the repo exceeded the index's file cap and some files " + - "were left out: treat a missing match as possibly-unindexed, not proof it's absent.\n" + - "Each result carries a 'usage' receipt (tokens returned, chunks/files, and a session running " + - "total) - computed locally, nothing leaves the machine. Surface it to the user so they can see " + - "how much context the ranked results put into the conversation.", + "code-context is a local index of this repository. Which tool for which question:\n" + + "- find - every line containing an exact string, where you would grep.\n" + + "- search - how does X work, where is Y handled, code by meaning.\n" + + "- sql - counts, rankings, and aggregates across the repo.\n" + + "Hits carry the code: when a hit answers the question, answer from it and cite path:line " + + "without re-reading the file or re-checking with grep; Read a file only for a hit marked " + + "truncated. " + + "Every tool takes an optional 'path' (an absolute repo root) to target another repository. " + + "A 'partial' marker means files over the index cap were left out, so a missing match is not " + + "proof of absence.", }, ); @@ -211,23 +223,14 @@ export async function serveMcp(rootPath?: string): Promise { { title: "Code search (exact terms + meaning)", description: - "Semantic + keyword code search over the indexed repository - a strong default for finding " + - "and understanding code. Use it to: understand how a subsystem or feature works, find code " + - "by meaning when you don't know the exact name, locate the code behind a behaviour or bug, " + - "gather context before making a change, review existing implementations and patterns, find " + - "everything a refactor would touch, understand the architecture for feature work, or spot " + - "similar/duplicate code. One pass fuses exact keyword matching (BM25: identifiers, error " + - "strings, function names, stemmed and scored) with semantic similarity (renamed symbols, " + - "paraphrases, 'where is X handled'), so it works whether or not you know the words. Each hit " + - "carries path, line range, and the chunk content with a relevance score - treat it as " + - "authoritative and answer directly from it, citing path plus line range; you don't need to " + - "re-confirm a hit with grep or by opening the file. When one search isn't enough, refine the " + - "query and search again - the index has already ranked the relevant regions. Read a file only " + - "for a hit marked truncated (its cited start-end range via offset/limit), or when results " + - "genuinely don't cover the question. (Until the index's vector stage finishes, results are " + - "keyword-ranked and say so.) The result includes a 'usage' field - a one-line receipt " + - "(tokens returned, chunks/files, session total). After you " + - "answer, end your reply by showing that 'usage' line to the user verbatim.", + "Ranked code search fusing exact keyword matching with semantic similarity, so it works " + + "whether or not you know the words. Use it for 'how does X work', 'where is Y handled', code " + + "by meaning, context before a change, similar implementations. Each hit carries path, line " + + "range, and the chunk content: answer and cite from the hits without re-confirming them with " + + "grep or by opening the file; Read a file only for one marked truncated. When one search is " + + "not enough, refine the query and search again. For every occurrence of an exact string use " + + "find; for counts and rankings use sql. The result includes a 'usage' field, a one-line " + + "receipt of tokens returned, chunks and files.", inputSchema: { query: z.string().describe("What you're looking for - terms, a phrase, or a description."), k: z.number().int().positive().max(50).default(DEFAULT_SEARCH_K).describe("Maximum hits."), @@ -278,42 +281,43 @@ export async function serveMcp(rootPath?: string): Promise { ); server.registerTool( - "sql", + "find", { - title: "SQL over the code index", + title: "Find exact text (every occurrence, like grep -n)", description: - "Whole-repo analytical questions that file tools cannot express at any budget: counts, " + - "rankings, GROUP BY across the codebase in one query, " + - `on table ${TABLE}(path, start_line, end_line, lang, content[, embedding]). ` + - "Search functions are callable as table-valued relations, so one query can rank AND " + - "aggregate: bm25_search('" + TABLE + "','content','terms', k) needs no embedding; " + - "hybrid_search('" + TABLE + "','content','terms','embedding', {{q}}, k) and " + - "vector_search('" + TABLE + "','embedding', {{q}}, k) take a {{name}} placeholder with an " + - 'embed map: {"q":"query text"}. The canonical move - "which files have the most code about ' + - 'X": SELECT path, SUM(end_line - start_line + 1) AS lines FROM ' + - `bm25_search('${TABLE}','content','', 300) GROUP BY path ORDER BY lines DESC LIMIT 15. ` + - "Build queries on bm25_search/hybrid_search so results are ranked by relevance to the topic, " + - "not on a raw scan of the whole table. Read-only, single statement. The result includes a " + - "'usage' field - a one-line receipt (tokens returned, rows, session total). After " + - "you answer, end your reply by showing that 'usage' line to the user verbatim.", + "Every line in the repository containing an exact string, like grep -n: complete and " + + "unranked, with the repo-wide total and per-file counts (byFile, the grep -c answer). " + + "Literal text within one line, case-sensitive unless ignoreCase. Use it where you would " + + "grep: every use or definition of an identifier, an error message, a config key. Not for a " + + "file you already know - Read that file. For meaning or 'how does X work' use search; for " + + "rankings use sql. The result includes a 'usage' field, a one-line receipt of tokens " + + "returned, matches and files.", inputSchema: { query: z .string() - .describe("A single read-only SELECT or WITH statement. May use search table functions and {{name}} vector placeholders."), - embed: z - .record(z.string(), z.string()) + .min(1) + .describe("The exact text to find, as it appears in the code - an identifier, a string, a key."), + ignoreCase: z + .boolean() .optional() - .describe('Map of placeholder name → query text, embedded server-side. E.g. {"q":"vector indexing"} fills {{q}}.'), + .describe("Match regardless of letter case. Default false: case-sensitive, like grep."), + limit: z + .number() + .int() + .positive() + .max(MAX_FIND_LIMIT) + .default(DEFAULT_FIND_LIMIT) + .describe("Maximum matching lines to return; the result reports the total either way."), path: z .string() .optional() .describe( - "Absolute path to the repository root to query. Defaults to the server's configured root; " + + "Absolute path to the repository root to search. Defaults to the server's configured root; " + "set it to target a specific repo when a session spans more than one.", ), }, }, - async ({ query, embed, path }) => { + async ({ query, ignoreCase, limit, path }) => { let ctx: RepoCtx; try { ctx = repoFor(path); @@ -330,94 +334,92 @@ export async function serveMcp(rootPath?: string): Promise { const { handle, autoIndexed } = ensured; if (!autoIndexed) maybeAutoSync(ctx); // a fresh build is already current try { - const t0 = performance.now(); - const rows = await runSql(handle, getEmbedder(), query, embed as Record | undefined); - const partial = partialIndex(handle.manifest); + const { value: result, tookMs } = timed(() => find(handle, query, { ignoreCase, limit })); let usage: string | undefined; if (receiptOn) { - const entry = sqlEntry(query, rows); + const entry = findEntry(result); recordUsage(ctx.dir, entry); usage = formatReceipt(entry, session); } return ok({ - rows, - ...(partial ? { partial } : {}), + ...result, ...(autoIndexed ? { auto_indexed: autoIndexNote(autoIndexed) } : {}), - took_ms: Math.round((performance.now() - t0) * 1000) / 1000, + took_ms: tookMs, ...(usage ? { usage } : {}), }); } catch (err) { - return fail(`sql failed: ${(err as Error).message}`); + return fail(`find failed: ${(err as Error).message}`); } }, ); server.registerTool( - "reindex", + "sql", { - title: "Sync the code index", + title: "SQL over the code index", description: - "Bring the index up to date with the working tree. Incremental by default: only files that " + - "changed since the last index are re-chunked and re-embedded, and an unchanged tree is a " + - "fast no-op, so call this freely after edits. The server also auto-syncs in the background as " + - "queries arrive. On a repo that has never been indexed this builds the index from scratch, " + - "replying as soon as keyword search is live (seconds) while vectors backfill behind it. " + - "Pass full=true to force a rebuild from scratch. Returns what changed plus index status.", + "Read-only SQL, one SELECT or WITH, over " + + `${TABLE}(path, start_line, end_line, lang, symbol, content[, embedding]) - lang is the ` + + "file extension, e.g. 'rs' - for counts, rankings, and GROUP BY across the whole repo. " + + "Search functions are table-valued: " + + `bm25_search('${TABLE}','content','terms', k) needs no embedding; ` + + `hybrid_search('${TABLE}','content','terms','embedding', {{q}}, k) and ` + + `vector_search('${TABLE}','embedding', {{q}}, k) take a {{name}} placeholder filled from ` + + "the embed map. Canonical: SELECT path, SUM(end_line - start_line + 1) AS lines FROM " + + `bm25_search('${TABLE}','content','', 300) GROUP BY path ORDER BY lines DESC LIMIT 15. ` + + "The result includes a 'usage' field, a one-line receipt of tokens returned and rows.", inputSchema: { - full: z.boolean().optional().describe("Force a full rebuild instead of an incremental sync."), + query: z + .string() + .describe("A single read-only SELECT or WITH statement. May use search table functions and {{name}} vector placeholders."), + embed: z + .record(z.string(), z.string()) + .optional() + .describe('Map of placeholder name → query text, embedded server-side. E.g. {"q":"vector indexing"} fills {{q}}.'), path: z .string() .optional() .describe( - "Absolute path to the repository root to index. Defaults to the server's configured root; " + + "Absolute path to the repository root to query. Defaults to the server's configured root; " + "set it to target a specific repo when a session spans more than one.", ), }, }, - async ({ full, path }) => { + async ({ query, embed, path }) => { let ctx: RepoCtx; try { ctx = repoFor(path); } catch (err) { return fail((err as Error).message); } + let ensured: EnsureResult; try { - const runFull = async () => { - const emb = buildEmbedder(); - const run = await indexRepoStaged({ - root: ctx.root, - db: ctx.db, - indexDirPath: ctx.dir, - embedder: emb, - caps: DEFAULT_CAPS, - }); - backfill(run, emb); - return ok({ status: "rebuilt - keyword search live; vectors backfilling", ...run.text }); - }; - const result = exclusive(ctx, async () => { - if (full) return runFull(); - const outcome = await syncRepo({ - root: ctx.root, - db: ctx.db, - indexDirPath: ctx.dir, - embedder: process.env.CX_NO_EMBED ? undefined : getEmbedder(), - caps: DEFAULT_CAPS, - }); - if (outcome.action === "rebuild-required") { - if (outcome.reason === "vector backfill in progress") { - return ok({ status: "index build already in progress - search is available meanwhile" }); - } - return runFull(); - } - return ok({ - status: outcome.action === "noop" ? "index already up to date" : "synced", - ...outcome, - }); + ensured = await ensureIndexed(ctx, { autoIndexEnabled, getHandle, build: buildIndex }); + } catch (err) { + return fail(`indexing failed: ${(err as Error).message}`); + } + if ("needsIndex" in ensured) return noIndex(ctx.root); + const { handle, autoIndexed } = ensured; + if (!autoIndexed) maybeAutoSync(ctx); // a fresh build is already current + try { + const t0 = performance.now(); + const rows = await runSql(handle, getEmbedder(), query, embed as Record | undefined); + const partial = partialIndex(handle.manifest); + let usage: string | undefined; + if (receiptOn) { + const entry = sqlEntry(query, rows); + recordUsage(ctx.dir, entry); + usage = formatReceipt(entry, session); + } + return ok({ + rows, + ...(partial ? { partial } : {}), + ...(autoIndexed ? { auto_indexed: autoIndexNote(autoIndexed) } : {}), + took_ms: Math.round((performance.now() - t0) * 1000) / 1000, + ...(usage ? { usage } : {}), }); - if (!result) return ok({ status: "a sync is already running - search is available meanwhile" }); - return await result; } catch (err) { - return fail(`reindex failed: ${(err as Error).message}`); + return fail(`sql failed: ${(err as Error).message}`); } }, ); diff --git a/test/integration.test.ts b/test/integration.test.ts index fc54725..069c05b 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -7,7 +7,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { connect } from "@infino-ai/infino"; import { indexRepo, indexRepoStaged } from "../src/core/indexer.js"; import { readManifest } from "../src/core/manifest.js"; -import { runSql, search } from "../src/core/searcher.js"; +import { analyzerTokens, find, runSql, search } from "../src/core/searcher.js"; +import { TABLE } from "../src/core/config.js"; import type { IndexHandle } from "../src/core/context.js"; import type { Embedder } from "../src/core/embedder.js"; @@ -51,6 +52,15 @@ export function replayLog(): number { return 42; } `, ); writeFileSync(join(root, "README.md"), "# Fixture\n\nA tiny repo about sessions and commit logs.\n"); + // A plain-text file long enough to chunk as fixed windows (60 lines, 10 + // overlapping), so a line in the overlap lives in two chunks. Line 55 is in + // windows 1-60 and 51-110; line 20 carries analyzer edge cases; line 130 is + // longer than the excerpt cap with its marker past the cap. + const notes = Array.from({ length: 130 }, (_, i) => `filler ${i + 1}`); + notes[19] = "parse_config(Path) ABC-123 x.y Süd ok"; + notes[54] = "OVERLAP_MARK sits in two windows"; + notes[129] = "z".repeat(600) + " FAR_MARK " + "z".repeat(300); + writeFileSync(join(root, "notes.txt"), notes.join("\n") + "\n"); writeFileSync(join(root, ".gitignore"), "ignored.ts\n"); writeFileSync(join(root, "ignored.ts"), "export const SHOULD_NOT_APPEAR = 1;\n"); @@ -67,7 +77,7 @@ afterAll(() => { describe("indexing", () => { it("indexes the fixture and honors .gitignore", () => { const m = handle.manifest; - expect(m.files).toBe(3); // auth.ts, storage.ts, README.md (.gitignore is not indexable) + expect(m.files).toBe(4); // auth.ts, storage.ts, README.md, notes.txt (.gitignore is not indexable) expect(m.vectors).toBe("ready"); expect(m.embedder?.dim).toBe(16); const rows = handle.db.querySql("SELECT DISTINCT path FROM chunks ORDER BY path") as Array<{ path: string }>; @@ -106,6 +116,113 @@ describe("search", () => { }); }); +describe("find", () => { + it("returns every line containing the literal, cited path:line, in file order", () => { + // `verifySession(` on line 2 and `revokeSession(` on line 5 of auth.ts; the + // header comment's "Session tokens" has no "(" and must not match. + const r = find(handle, "Session("); + expect(r.matches.map((m) => `${m.path}:${m.line}`)).toEqual(["src/auth.ts:2", "src/auth.ts:5"]); + expect(r.matches[0].text).toContain("export function verifySession(token: string)"); + expect(r.matches[0].symbol).toContain("verifySession"); + expect(r.total).toBe(2); + expect(r.files).toBe(1); + expect(r.byFile).toEqual([{ path: "src/auth.ts", count: 2 }]); + expect(r.truncated).toBeUndefined(); + expect(r.ignoreCase).toBe(false); + }); + + it("counts matches per file over every match, most first, even when the list is cut", () => { + // `export function` twice in each of auth.ts and storage.ts; the tie + // breaks on path. The cut list is one line, the counts are still whole. + const r = find(handle, "export function", { limit: 1 }); + expect(r.matches.length).toBe(1); + expect(r.total).toBe(4); + expect(r.byFile).toEqual([ + { path: "src/auth.ts", count: 2 }, + { path: "src/storage.ts", count: 2 }, + ]); + }); + + it("reports a line that lives in two overlapping chunks once", () => { + const r = find(handle, "OVERLAP_MARK"); + expect(r.matches.map((m) => `${m.path}:${m.line}`)).toEqual(["notes.txt:55"]); + expect(r.total).toBe(1); + // The overlap is real: the line is in two indexed chunks. + const rows = handle.db.querySql( + `SELECT start_line FROM ${TABLE} WHERE path = 'notes.txt' AND start_line <= 55 AND end_line >= 55`, + ); + expect(rows.length).toBe(2); + }); + + it("windows a long line around the match so the cited text contains it", () => { + const r = find(handle, "FAR_MARK"); + expect(r.matches.length).toBe(1); + expect(r.matches[0].line).toBe(130); + expect(r.matches[0].text).toContain("FAR_MARK"); + expect(r.matches[0].text.startsWith("...")).toBe(true); + }); + + it("agrees with the engine's analyzer on which chunks are candidates", () => { + // The client-side token mirror must produce the same candidate set as + // handing the raw text to the engine, or a literal the repo does contain + // could be missed. Checked on strings with the analyzer's edge cases: + // underscores and punctuation as separators, digits, a dropped non-ASCII run. + const table = handle.db.openTable(TABLE); + const chunks = (rows: Array>) => + rows.map((r) => `${r.path}:${r.start_line}`).sort(); + for (const text of ["parse_config(Path)", "ABC-123 x.y", "Süd ok", "Session(", "session record"]) { + const viaEngine = table.tokenMatch("content", text, { mode: "and", projection: ["path", "start_line"] }); + const viaMirror = table.tokenMatch("content", analyzerTokens(text).join(" "), { + mode: "and", + projection: ["path", "start_line"], + }); + expect(viaEngine.length, text).toBeGreaterThan(0); + expect(chunks(viaMirror), text).toEqual(chunks(viaEngine)); + } + }); + + it("carries the partial-index marker like search does", () => { + const partial = { ...handle, manifest: { ...handle.manifest, truncatedFiles: 3, maxFiles: 10 } }; + expect(find(partial, "Session(").partial?.filesSkipped).toBe(3); + expect(find(handle, "Session(").partial).toBeUndefined(); + }); + + it("rejects a malformed limit instead of returning nothing", () => { + expect(() => find(handle, "Session(", { limit: Number.NaN })).toThrow(/positive integer/); + expect(() => find(handle, "Session(", { limit: 0 })).toThrow(/positive integer/); + expect(() => find(handle, "Session(", { limit: 2.5 })).toThrow(/positive integer/); + // Over the hard cap clamps rather than errors: a big number is a valid wish. + expect(find(handle, "Session(", { limit: 10_000 }).total).toBe(2); + }); + + it("matches the literal, not just its tokens", () => { + // The comment says "session record"; "record session" has the same tokens + // in the same chunk and occurs nowhere. + expect(find(handle, "session record").total).toBe(1); + expect(find(handle, "record session").total).toBe(0); + }); + + it("is case-sensitive unless asked otherwise", () => { + expect(find(handle, "verifysession").total).toBe(0); + const r = find(handle, "verifysession", { ignoreCase: true }); + expect(r.matches.map((m) => m.line)).toEqual([2]); + expect(r.ignoreCase).toBe(true); + }); + + it("caps the matches and still reports the repo-wide total", () => { + const r = find(handle, "Session(", { limit: 1 }); + expect(r.matches.length).toBe(1); + expect(r.total).toBe(2); + expect(r.truncated).toBe(true); + }); + + it("refuses a query the index cannot look up", () => { + expect(() => find(handle, "->")).toThrow(/ASCII/); + expect(() => find(handle, "a\nb")).toThrow(/newline/); + expect(() => find(handle, "")).toThrow(/non-empty/); + }); +}); + describe("sql", () => { it("ranked aggregation through the search table function", async () => { const rows = await runSql( diff --git a/test/searcher.test.ts b/test/searcher.test.ts index 6ae073c..4bb9554 100644 --- a/test/searcher.test.ts +++ b/test/searcher.test.ts @@ -1,7 +1,75 @@ import { describe, expect, it } from "vitest"; -import { applyEmbeds, guardSql } from "../src/core/searcher.js"; +import { analyzerTokens, applyEmbeds, excerpt, guardSql, matchLines } from "../src/core/searcher.js"; import type { Embedder } from "../src/core/embedder.js"; +describe("analyzerTokens", () => { + it("splits on anything outside [A-Za-z0-9] and lowercases, like the index analyzer", () => { + // `parse_config` indexes as two tokens: the underscore is a separator. + expect(analyzerTokens("parse_config(Path)")).toEqual(["parse", "config", "path"]); + }); + + it("dedupes repeated tokens", () => { + expect(analyzerTokens("a.a A")).toEqual(["a"]); + }); + + it("drops a run that touches non-ASCII text, as the analyzer does", () => { + expect(analyzerTokens("Süd ok")).toEqual(["ok"]); + // The non-ASCII character extends the run rather than splitting it, so + // the ASCII neighbours go with it. + expect(analyzerTokens("abcédef ghi")).toEqual(["ghi"]); + }); + + it("yields nothing for punctuation-only text", () => { + expect(analyzerTokens("->")).toEqual([]); + expect(analyzerTokens("")).toEqual([]); + }); +}); + +describe("matchLines", () => { + const content = "let parse_config = 1;\nparse config\nPARSE_CONFIG"; + + it("cites 1-based lines offset from the chunk start and matches the literal, not its tokens", () => { + // Line 2 has both tokens but not the literal. + expect(matchLines(content, 10, "parse_config", false)).toEqual([{ line: 10, text: "let parse_config = 1;", at: 4 }]); + }); + + it("is case-sensitive unless asked otherwise", () => { + expect(matchLines(content, 10, "parse_config", true).map((m) => m.line)).toEqual([10, 12]); + }); + + it("strips a CRLF file's carriage return from the cited text", () => { + expect(matchLines("x = 1;\r\ny = 2;\r\n", 1, "y =", false)).toEqual([{ line: 2, text: "y = 2;", at: 0 }]); + }); +}); + +describe("excerpt", () => { + it("returns a short line whole", () => { + expect(excerpt("let x = needle;", 8, 6)).toBe("let x = needle;"); + }); + + it("windows a long line around the match and marks both cut ends", () => { + // A match past column 240 must still be in the cited text, or the hit + // reads as wrong. Some lead-in is kept so the excerpt shows what the match + // sits in, and both cuts are marked. + const line = "a".repeat(600) + "NEEDLE" + "b".repeat(300); + const out = excerpt(line, 600, "NEEDLE".length); + expect(out).toContain("NEEDLE"); + expect(out.startsWith("...")).toBe(true); + expect(out.endsWith("...")).toBe(true); + expect(out.length).toBeLessThanOrEqual(240 + "......".length); + expect(out.indexOf("NEEDLE")).toBeGreaterThan("...".length); + }); + + it("marks only the end that was cut", () => { + const head = excerpt("NEEDLE" + "b".repeat(600), 0, 6); + expect(head.startsWith("NEEDLE")).toBe(true); + expect(head.endsWith("...")).toBe(true); + const tail = excerpt("a".repeat(600) + "NEEDLE", 600, 6); + expect(tail.startsWith("...")).toBe(true); + expect(tail.endsWith("NEEDLE")).toBe(true); + }); +}); + describe("guardSql", () => { it("accepts a single SELECT / WITH statement and strips the trailing semicolon", () => { expect(guardSql("SELECT 1;")).toBe("SELECT 1"); diff --git a/test/usage.test.ts b/test/usage.test.ts index 2d50e87..8698950 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { estTokens, newSession, + findEntry, searchEntry, sqlEntry, formatReceipt, @@ -14,6 +15,8 @@ import { usageLogPath, recordHookEvent, currentSessionStats, + codeContextToolName, + promptStatsPath, } from "../src/core/usage.js"; import type { SearchResult, SearchHit } from "../src/core/searcher.js"; @@ -76,6 +79,37 @@ describe("search receipt", () => { }); }); +describe("find receipt", () => { + const found = { + query: "x", + ignoreCase: false, + total: 3, + files: 1, + byFile: [{ path: "a.ts", count: 3 }], + truncated: true, + matches: [ + { path: "a.ts", line: 3, text: "x = 1" }, + { path: "a.ts", line: 9, text: "x = 2" }, + ], + }; + + it("reports the repo-wide match count and the files, not just the lines returned", () => { + const line = formatReceipt(findEntry(found)); + expect(line).toMatch(/^returned ~\d+ tokens \| 3 matches \/ 1 file$/); + }); + + it("records each match as a one-line region in the ledger", () => { + const entry = findEntry(found); + expect(entry.tool).toBe("find"); + expect(entry.hits).toEqual([ + { path: "a.ts", startLine: 3, endLine: 3 }, + { path: "a.ts", startLine: 9, endLine: 9 }, + ]); + expect(entry.matches).toBe(3); + expect(entry.wholeFileTokens).toBeUndefined(); + }); +}); + describe("sql receipt", () => { it("reports row count and token estimate of the rows", () => { const line = formatReceipt(sqlEntry("SELECT 1", [{ path: "a.ts", lines: 12 }, { path: "b.ts", lines: 8 }])); @@ -166,4 +200,57 @@ describe("prompt telemetry (hooks)", () => { it("returns null when nothing is recorded", () => { expect(currentSessionStats(dir)).toBeNull(); }); + + it("counts code-context calls by tool and the first tool of each prompt", () => { + // Prompt 1 opens with find and also uses sql; prompt 2 opens with Grep + // (a non-code-context tool, forwarded by a widened matcher) and then + // reaches search; prompt 3 opens with find. Selection is the first call. + submit("s1"); + recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: "s1", tool_name: "mcp__code-context__find" }); + recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: "s1", tool_name: "mcp__code-context__sql" }); + submit("s1"); + otherCall("s1"); // Grep + cxCall("s1"); // search + submit("s1"); + recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: "s1", tool_name: "mcp__code-context__find" }); + const s = currentSessionStats(dir); + expect(s?.cxCallsByTool).toEqual({ find: 2, sql: 1, search: 1 }); + expect(s?.firstToolByPrompt).toEqual({ find: 2, Grep: 1 }); + // The non-code-context call is not an invocation, and the prompt it + // opened still counts as one that used code-context (search came later). + expect(s?.cxCalls).toBe(4); + expect(s?.promptsWithCx).toBe(3); + }); + + it("names a tool from a renamed server variant by its short name", () => { + submit("s1"); + recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: "s1", tool_name: "mcp__code-context-dev__search" }); + expect(currentSessionStats(dir)?.cxCallsByTool).toEqual({ search: 1 }); + expect(currentSessionStats(dir)?.firstToolByPrompt).toEqual({ search: 1 }); + }); + + it("loads a stats file written before the per-tool fields existed", () => { + writeFileSync( + promptStatsPath(dir), + JSON.stringify({ + old: { sessionId: "old", startedAt: "2026-01-01T00:00:00Z", lastAt: "2026-01-01T00:00:00Z", prompts: 2, cxCalls: 1, promptsWithCx: 1, curPromptUsedCx: false }, + }), + ); + // A new event on the old session adds the fields rather than tripping on their absence. + recordHookEvent(dir, { hook_event_name: "UserPromptSubmit", session_id: "old" }); + cxCall("old"); + const s = currentSessionStats(dir); + expect(s?.prompts).toBe(3); + expect(s?.cxCallsByTool).toEqual({ search: 1 }); + expect(s?.firstToolByPrompt).toEqual({ search: 1 }); + }); +}); + +describe("codeContextToolName", () => { + it("strips the server prefix however the server was named", () => { + expect(codeContextToolName("mcp__code-context__find")).toBe("find"); + expect(codeContextToolName("mcp__code-context-dev__sql")).toBe("sql"); + expect(codeContextToolName("mcp__code_context_local__search")).toBe("search"); + expect(codeContextToolName("Grep")).toBe("Grep"); + }); });