Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ code.
## Project overview

**code-context is local code search for AI coding agents: a CLI (`cx`) and an
MCP server over a ranked index that lives in plain files inside the repo.** It
fuses keyword (BM25) and semantic (vector) search into one ranked pass and
exposes read-only SQL over the index, so an agent answers questions about a
codebase without crawling files into the context window. The index is built
MCP server over a ranked index that lives in plain files inside the repo.** The
query surface is one door: read-only SQL whose table-valued search functions
(hybrid_search fusing keyword BM25 + semantic ranking, bm25_search keyword-only)
make finding, counting, and ranking code a single SELECT, so an agent answers
questions about a codebase without crawling files into the context window. The index is built
and queried in-process with a local embedding model: no accounts, no API
keys, no server. It is built on the [infino](https://github.com/infino-ai/infino)
engine, which runs SQL, full-text, and vector search over one copy of the
Expand All @@ -24,19 +25,23 @@ 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, two tools (`sql`, `reindex`). Each
takes an optional `path` (repo root) so one server serves multiple repos
in a session, defaulting to the startup root. The index builds eagerly at
server startup.
- `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
never-indexed repo builds the index inline, then answers on the same call
(`CX_AUTO_INDEX=0` restores the strict "index it first" error).
- `src/mcp/ensure.ts`: auto-index safety net - a `sql` query that reaches a
never-indexed repo before the eager startup build builds the index inline,
then answers on the same call (`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
(SQL + embed-placeholder plumbing), `embedder` (local model), `filestate` (incremental
sync state), `walker`, `manifest`, `config`, `context`, `output`.
- `src/commands/`: CLI command implementations (`index-cmd`, `query-cmds`).
- `src/commands/`: CLI command implementations (`index-cmd`, `query-cmds`,
and `install-cmd`, which wires the enforcement hooks into a non-plugin
client's settings).
- `test/`: vitest suites. `bench/`: the benchmark harness. `docs/`: docs.

## Build, test, gates
Expand All @@ -53,11 +58,13 @@ 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.
- Search results carry chunk content plus `path:line` ranges so answers cite
code; keep that contract when touching `searcher` or the tool descriptions.
- The MCP surface is deliberately two tools: one way to query (`sql`, with
ranked retrieval as table-valued functions), one way to stay fresh
(`reindex`). Adding near-duplicate retrieval tools worsens an agent's tool
selection; resist it.
- Query results carry chunk content plus `path`/`start_line`/`end_line` so
answers cite code; keep that contract when touching `searcher` or the tool
descriptions.

## Boundaries

Expand Down
113 changes: 80 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Install the Claude Code plugin - nothing to paste into a config:
/plugin install code-context@infino-ai
```

It registers code-context's three tools with `alwaysLoad` already set, so the
It registers code-context's two tools with `alwaysLoad` already set, so the
agent keeps them in view and reaches for the index directly instead of falling
back to plain file search.

Expand All @@ -75,10 +75,11 @@ 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
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.)
Then just ask a question about the code. The server builds the index as it
starts, so keyword search is live within seconds of the session opening and
vectors backfill in the background - the first `sql` query typically finds the
index already waiting. (A query that beats the build still triggers it inline;
`reindex` syncs after big edits.)

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
Expand Down Expand Up @@ -118,29 +119,35 @@ One index and a deliberately small tool surface for agents:

| Tool | What it does | When agents use it |
|---|---|---|
| `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. |
| `sql` | Read-only SQL over the index, with ranked retrieval as table-valued relations: `hybrid_search` fuses exact keyword matching (BM25) with semantic similarity in one ranked pass; `bm25_search` is the keyword arm alone. Rows carry the chunk content, so answers come straight from results. | Everything: how a subsystem works, code by meaning or exact term, context before a change, and any count/ranking/aggregate over the whole repo - one query language for all of it. |
| `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.
Two tools is a deliberate design: one way to query, one way to stay fresh.
Every additional near-duplicate retrieval tool worsens an agent's tool
selection - and since hybrid ranking's keyword half already ranks exact
identifier terms highly, retrieval needs no second door beside SQL.

### The SQL move
### One surface, both jobs

Search-as-a-table composes with aggregation. Ranked by relevance, tallied by
SQL, one engine pass:
Finding code is a query:

```sql
SELECT path, start_line, end_line, symbol, content
FROM hybrid_search('chunks', 'content', 'auth handling', 'embedding', {{q}}, 10)
```

and because search is a table, the same relation composes with aggregation -
ranked by relevance, tallied by SQL, one engine pass:

```sql
SELECT path, SUM(end_line - start_line + 1) AS lines, COUNT(*) AS chunks
FROM bm25_search('chunks', 'content', 'vector index quantization', 300)
GROUP BY path ORDER BY lines DESC LIMIT 15
```

`hybrid_search(...)` and `vector_search(...)` work the same way. The CLI and
MCP server embed `{{name}}` placeholders server-side, so agents never handle
raw vectors.
`vector_search(...)` works the same way when you want meaning-only ranking.
The CLI and MCP server embed `{{name}}` placeholders server-side, so agents
never handle raw vectors.

### Staged readiness

Expand All @@ -149,7 +156,9 @@ that takes under a second, so search works before any embedding model even
exists on the machine. Vectors backfill in the background with a local model
(downloaded once, no key; about two minutes for that same repo), and
hybrid/semantic ranking unlocks automatically when they land. If the vector
stage fails, keyword search stays live and the index says so honestly.
stage fails, keyword search stays live and the index says so honestly. The
MCP server kicks this build off as it starts, so the index is typically live
before an agent's first query.

The default model optimizes quality-per-minute. See
[docs/embedder-eval.md](docs/embedder-eval.md) for how it was chosen.
Expand All @@ -164,7 +173,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 (`sql`, `reindex`) become available to the
agent.

<details>
Expand All @@ -188,12 +197,28 @@ claude mcp add-json code-context -s user '{"command":"npx","args":["-y","@infino
for the index directly. In sessions with many MCP servers Claude Code defers
tool definitions behind a tool-search step; without `alwaysLoad` the agent can
miss code-context and fall back to grep/read. It's a small, always-loaded set
(three tools). Omit it (or use the shorter `claude mcp add code-context -- npx
(two tools). Omit it (or use the shorter `claude mcp add code-context -- npx
-y @infino-ai/code-context mcp`) if you'd rather leave the tools deferred.

Use *either* the plugin or the `add-json` command, not both. They register the
same `code-context` server, so running both just collides.

**Enforcement.** The plugin also ships hooks that make the index the default
way to search: once a repo's index fully covers it (vectors ready, nothing
over the file cap), the Grep tool and standalone `grep`/`rg`/`git grep`
commands are denied with a redirect to the `sql` search functions. Until the
index is fully built, grep is untouched - enforcement never pushes an agent
onto an index that can't answer yet. The deny is scoped, not absolute - grep
as a pipe filter on other command output always passes, and a grep targeting
something the index can't answer for (a gitignored file, a file over the byte
cap, a dot-path, a path outside the repo) is allowed silently. Two escape
hatches: prefix a command with `CX_GREP_FALLBACK=1` when an index search
genuinely came up short (the hook asks for approval instead of denying), and
`CX_NO_ENFORCE=1` in the environment disables enforcement entirely. Plain MCP
registration (`add-json`) gets the tools without the hooks; `cx install`
adds them (see below). Use *either* the plugin or `cx install`, not both -
both sets fire, harmlessly, but it's noise.

**For a team,** commit a project-scoped `.mcp.json` at the repo root so
everyone gets it (after the one-time project-server approval):

Expand Down Expand Up @@ -252,7 +277,7 @@ when the client's working directory is not the repo.

</details>

Tools: `search`, `sql`, `reindex` (incremental sync: an unchanged repo is
Tools: `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).

Expand All @@ -262,24 +287,46 @@ target a specific repo when a session spans more than one. One server
instance serves them all, each with its own index in its own `.infino/` -
no restart, no per-repo config.

**Enforcement without the plugin.** Enforcement - hooks that deny the Grep
tool and standalone `grep`/`rg`/`git grep` on a fully-indexed repo and
redirect to `sql`, with the full rules in the Claude Code block above - ships
inside the plugin. For every other way of reaching code-context - `npx`,
`claude mcp add-json`, Cursor, Windsurf - `cx install` wires the same hooks,
and there it's the only form that survives a client restart:

```
cx install # or: npx -y @infino-ai/code-context install
```

It copies the hook to `~/.claude/hooks/cx-deny-grep.mjs` and merges
`SessionStart` + `PreToolUse` entries into `~/.claude/settings.json`, leaving
any other hooks in the file alone. Re-running it is idempotent, `cx install
--uninstall` reverses it, and `--settings <file>` targets a different settings
file. The entries embed the absolute path of the `node` that ran the install,
because a client's process often has no `node` on `PATH` and a hook that
can't find node fails silently - which reads exactly like enforcement not
working. Start a new session for the client to pick the hooks up. Use the
plugin or this command, not both.

## Configuration

| Variable | Default | Purpose |
|---|---|---|
| `CX_INDEX_DIR` | `<repo>/.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_MAX_FILES` / `CX_MAX_FILE_BYTES` | 20000 / 1MB | indexing caps (files over the file cap are left out; `sql` then flags 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 eagerly at server startup (or inline on the first `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 |
| `CX_NO_ENFORCE` | off | `1` disables the grep-enforcement hooks entirely (however they were installed - the plugin or `cx install`) |
| `CX_GREP_FALLBACK` | - | prefix a `grep`/`rg` command with `CX_GREP_FALLBACK=1` to request an approved fallback grep after an index search came up short |

Every `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
Every `sql` result carries a **usage receipt** - a terse, local line showing
the tokens it returned, the row count, and a running session total (e.g.
`returned ~1.2k tokens | 12 rows | invoked 7x this session (~8.4k tokens
total)`). Every figure is a `~` estimate, computed in-process - nothing about
your queries or code leaves the machine.

## CLI
Expand All @@ -294,17 +341,17 @@ npm install -g @infino-ai/code-context

```
cx index [path] sync the index (incremental; --full rebuilds, --watch follows edits)
cx search <query> exact terms + meaning, one ranked pass (-k hits)
cx sql <statement> read-only SQL; --embed q="text" fills {{q}}
cx status what the index holds, how fresh, vector readiness
cx usage ledger of queries run and what each returned (-n, --all, --clear, --json)
cx mcp serve the MCP tools over stdio
cx install wire the enforcement hooks into the client (--uninstall reverses)
```

`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 `sql` query
(from the CLI or the MCP server) appends one line recording the statement and
a compact summary of what came back (row count and a preview of the rows),
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.
Expand Down
13 changes: 10 additions & 3 deletions bench/recall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Indexes THIS repo (code-context) to a temp dir with the default local
// embedder, then runs a fixed set of paraphrase queries whose gold file is
// known, and reports hit@5 / MRR@5 for vector-only ranking (isolates the
// embedder) and hybrid ranking (the `search` tool's real surface).
// embedder) and hybrid ranking (the sql tool's hybrid_search surface).
//
// It's deterministic (same model+dtype -> same vectors) and needs no network
// beyond the one-time model download, which makes it a good regression signal.
Expand Down Expand Up @@ -42,7 +42,7 @@ process.env.CX_INDEX_DIR = tmp;
const { indexRepo } = await import(`${ROOT}/dist/core/indexer.js`);
const { openForIndexing, openIndex } = await import(`${ROOT}/dist/core/context.js`);
const { createEmbedder } = await import(`${ROOT}/dist/core/embedder.js`);
const { search } = await import(`${ROOT}/dist/core/searcher.js`);
const { runSql } = await import(`${ROOT}/dist/core/searcher.js`);
const { TABLE, DEFAULT_CAPS } = await import(`${ROOT}/dist/core/config.js`);

console.log(`indexing ${ROOT}`);
Expand All @@ -69,7 +69,14 @@ for (const [q, gold] of GOLD) {
const [vec] = await embedder.embed([q]);
const vRank = rankOf(table.vectorSearch("embedding", vec, 15, { projection: ["path"] }), gold);
if (vRank >= 1 && vRank <= 5) { vHit++; vRr += 1 / vRank; }
const hRank = rankOf((await search(handle, embedder, q, 15)).hits, gold);
const esc = q.replaceAll("'", "''");
const hRows = await runSql(
handle,
embedder,
`SELECT path FROM hybrid_search('${TABLE}','content','${esc}','embedding', {{q}}, 15)`,
{ q },
);
const hRank = rankOf(hRows, gold);
if (hRank >= 1 && hRank <= 5) { hHit++; hRr += 1 / hRank; } else misses.push(`${gold} ("${q}")`);
}

Expand Down
Loading
Loading