@maxgfr/codeindex 2.17.1 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -3
- package/docs/MIGRATION.md +127 -0
- package/package.json +1 -1
- package/scripts/engine.d.mts +242 -18
- package/scripts/engine.mjs +2274 -1084
- package/src/ast/extract.ts +180 -142
- package/src/coupling.ts +0 -0
- package/src/delta.ts +417 -0
- package/src/derived.ts +17 -0
- package/src/engine-cli.ts +145 -24
- package/src/engine.ts +31 -4
- package/src/extract/code.ts +4 -1
- package/src/graph.ts +0 -0
- package/src/mcp/protocol.ts +176 -0
- package/src/mcp/session.ts +287 -0
- package/src/mcp/tools.ts +541 -0
- package/src/mcp.ts +176 -668
- package/src/pool.ts +271 -0
- package/src/preload.ts +159 -0
- package/src/render/scip.ts +80 -20
- package/src/render/symbols-json.ts +3 -2
- package/src/scan.ts +142 -17
- package/src/traverse.ts +164 -0
- package/src/types.ts +1 -1
- package/src/viz.ts +91 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Everything the long-lived server memoizes: the scan/artifacts LRU keyed on
|
|
2
|
+
// (repo, scan options), the embedding corpus index, the static embed model, and
|
|
3
|
+
// the per-call grammar warm.
|
|
4
|
+
//
|
|
5
|
+
// These are the pieces that make a second tool call cheap. They are stateful by
|
|
6
|
+
// nature — which is exactly why they belong in one file with the invariants
|
|
7
|
+
// written down, rather than scattered through the request handler.
|
|
8
|
+
import { statSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { buildArtifactsFromScan, type IndexArtifacts } from "../pipeline.js";
|
|
11
|
+
import { scanRepo, scanSummary, type RepoScan, type ScanOptions, type ScanSummary } from "../scan.js";
|
|
12
|
+
import { preloadSession, toCacheMap, type PersistedCacheEntry, type PersistedCacheMap } from "../preload.js";
|
|
13
|
+
import { walk, type WalkResult } from "../walk.js";
|
|
14
|
+
import { ensureGrammars, grammarKeysForExts } from "../ast/loader.js";
|
|
15
|
+
import { resolveEmbedModelDir, loadEmbedModel, type StaticEmbedModel } from "../embed/model.js";
|
|
16
|
+
import { type EmbeddingIndex } from "../embed/index.js";
|
|
17
|
+
import { sha1 } from "../hash.js";
|
|
18
|
+
|
|
19
|
+
// --- embedding index memoization --------------------------------------------
|
|
20
|
+
// The MCP server process is long-lived, but every `search` call used to redo
|
|
21
|
+
// the FULL corpus embedding build — N `buildEndpointIndex` HTTP round-trips,
|
|
22
|
+
// or a full `buildEmbeddingIndex` re-encode pass — even when nothing in the
|
|
23
|
+
// repo changed between requests. Memoize the last build behind a fingerprint
|
|
24
|
+
// of the scan contents plus the tier's identity, so an unchanged repo reuses
|
|
25
|
+
// the cached index and any file add/edit/remove (or a switch of endpoint/model)
|
|
26
|
+
// still rebuilds. RepoScan carries no fingerprint of its own (checked
|
|
27
|
+
// scan.ts/types.ts) — every FileRecord already carries a content hash, so
|
|
28
|
+
// hashing the (rel, hash) pairs is the staleness oracle scan.ts itself uses.
|
|
29
|
+
export function scanFingerprint(scan: RepoScan): string {
|
|
30
|
+
return sha1(scan.files.map((f) => `${f.rel}:${f.hash}`).join("\n"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EmbeddingIndexCacheKey {
|
|
34
|
+
mode: "endpoint" | "static";
|
|
35
|
+
// Distinguishes cache entries across configs sharing the same scan: the
|
|
36
|
+
// endpoint URL, or the model dir + modelId for the static tier.
|
|
37
|
+
identity: string;
|
|
38
|
+
scan: RepoScan;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A SINGLE entry — never an unbounded map — holding the most recent build.
|
|
42
|
+
let embeddingIndexCache: { key: string; index: EmbeddingIndex } | undefined;
|
|
43
|
+
|
|
44
|
+
// Reuse the cached index when (mode, identity, scanFingerprint) matches the
|
|
45
|
+
// last build; otherwise call `build` and cache its result. A failed build is
|
|
46
|
+
// NEVER cached (matches today's per-call error behavior: the next request
|
|
47
|
+
// retries from scratch, and a still-valid previous entry — under a different
|
|
48
|
+
// key — is left untouched).
|
|
49
|
+
export async function memoizedEmbeddingIndex(
|
|
50
|
+
key: EmbeddingIndexCacheKey,
|
|
51
|
+
build: () => Promise<EmbeddingIndex> | EmbeddingIndex,
|
|
52
|
+
): Promise<EmbeddingIndex> {
|
|
53
|
+
const cacheKey = `${key.mode}:${key.identity}:${scanFingerprint(key.scan)}`;
|
|
54
|
+
if (embeddingIndexCache && embeddingIndexCache.key === cacheKey) return embeddingIndexCache.index;
|
|
55
|
+
const index = await build();
|
|
56
|
+
embeddingIndexCache = { key: cacheKey, index };
|
|
57
|
+
return index;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A SINGLE entry — never an unbounded map — holding the most recent parse.
|
|
61
|
+
let embedModelCache: { key: string; model: StaticEmbedModel } | undefined;
|
|
62
|
+
|
|
63
|
+
// model.json is 10-30 MB with the real asset; reading + JSON.parsing it on
|
|
64
|
+
// EVERY request dominates static-tier latency, so the parsed model is memoized
|
|
65
|
+
// across requests. One statSync per request keys the cache on
|
|
66
|
+
// (dir, mtimeMs, size) so an in-place re-pull invalidates on the next call.
|
|
67
|
+
// Same discipline as memoizedEmbeddingIndex: a failed load is NEVER cached —
|
|
68
|
+
// the throw propagates and the cache is left as it was, so the next request
|
|
69
|
+
// retries from scratch. A missing model.json returns undefined (the
|
|
70
|
+
// not-present case, exactly like loadEmbedModel).
|
|
71
|
+
export function memoizedEmbedModel(modelDir: string): StaticEmbedModel | undefined {
|
|
72
|
+
let stat;
|
|
73
|
+
try {
|
|
74
|
+
stat = statSync(join(modelDir, "model.json"));
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const key = `${modelDir}:${stat.mtimeMs}:${stat.size}`;
|
|
79
|
+
if (embedModelCache && embedModelCache.key === key) return embedModelCache.model;
|
|
80
|
+
const model = loadEmbedModel(modelDir);
|
|
81
|
+
if (model) embedModelCache = { key, model };
|
|
82
|
+
return model;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- session-level scan + artifacts memoization ------------------------------
|
|
86
|
+
// Same single-entry discipline as the embedding caches above: the MCP server
|
|
87
|
+
// process is long-lived, but every tool call used to redo a FULL scanRepo walk
|
|
88
|
+
// + read + hash + extraction pass (and, for graph-shaped tools, the whole
|
|
89
|
+
// pipeline) even when nothing in the repo changed between requests. Cache the
|
|
90
|
+
// last (repo, scan-opts) scan and feed its records back to scanRepo as `cache`
|
|
91
|
+
// on the next call — scan.ts's EXISTING stat-fastpath + exact-hash machinery
|
|
92
|
+
// is the freshness oracle, so a cache hit costs one walk + per-file stats, not
|
|
93
|
+
// reads. When the oracle proves the content unchanged the SAME RepoScan object
|
|
94
|
+
// is returned, which keeps the per-scan WeakMap of derived structures
|
|
95
|
+
// (src/derived.ts) warm across requests. Artifacts are memoized on scan object
|
|
96
|
+
// identity. Rendered strings are NEVER memoized — a big repo's graph.json runs
|
|
97
|
+
// tens of MB, so renders stay per-call while the expensive structures behind
|
|
98
|
+
// them are reused.
|
|
99
|
+
//
|
|
100
|
+
// Determinism: reused records come from scan.ts's own reuse paths (stat
|
|
101
|
+
// fastpath / exact content-hash match), which produce records value-identical
|
|
102
|
+
// to a from-scratch scan — artifacts stay byte-identical; only repeated work
|
|
103
|
+
// disappears.
|
|
104
|
+
|
|
105
|
+
// The scan options a session entry is keyed on. `cache`/`precomputedWalk` are
|
|
106
|
+
// excluded from the contract: the session cache OWNS the cache it feeds back,
|
|
107
|
+
// and a caller-supplied stale walk would desynchronize the freshness oracle.
|
|
108
|
+
export type SessionScanOptions = Omit<ScanOptions, "cache" | "precomputedWalk">;
|
|
109
|
+
|
|
110
|
+
type SessionCacheEntry = PersistedCacheEntry;
|
|
111
|
+
type SessionCacheMap = PersistedCacheMap;
|
|
112
|
+
|
|
113
|
+
interface SessionEntry {
|
|
114
|
+
key: string;
|
|
115
|
+
scan: RepoScan;
|
|
116
|
+
cacheMap: SessionCacheMap;
|
|
117
|
+
arts?: IndexArtifacts;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A SMALL bounded LRU — never an unbounded map.
|
|
121
|
+
//
|
|
122
|
+
// This was a single entry, which made two entirely normal agent behaviours
|
|
123
|
+
// pathological: alternating between two repos, and alternating between two
|
|
124
|
+
// `scope` values on one repo. Either one evicted the other on every call, so
|
|
125
|
+
// every call paid a full cold rebuild. Four entries covers those patterns while
|
|
126
|
+
// keeping the memory story the same order of magnitude as before.
|
|
127
|
+
const SESSION_CACHE_MAX = 4;
|
|
128
|
+
const sessionCaches: SessionEntry[] = []; // most-recently-used first
|
|
129
|
+
|
|
130
|
+
function sessionGet(key: string): SessionEntry | undefined {
|
|
131
|
+
const i = sessionCaches.findIndex((e) => e.key === key);
|
|
132
|
+
if (i < 0) return undefined;
|
|
133
|
+
const [entry] = sessionCaches.splice(i, 1);
|
|
134
|
+
sessionCaches.unshift(entry!);
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sessionPut(entry: SessionEntry): SessionEntry {
|
|
139
|
+
const i = sessionCaches.findIndex((e) => e.key === entry.key);
|
|
140
|
+
if (i >= 0) sessionCaches.splice(i, 1);
|
|
141
|
+
sessionCaches.unshift(entry);
|
|
142
|
+
sessionCaches.length = Math.min(sessionCaches.length, SESSION_CACHE_MAX);
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Drop every entry. Used by the symbolic-edit tools: an edit landing in the same
|
|
147
|
+
// mtime tick with the same byte count would pass the (size, mtimeMs) fastpath
|
|
148
|
+
// and serve a stale scan.
|
|
149
|
+
export function sessionClear(): void {
|
|
150
|
+
sessionCaches.length = 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fixed property order (and JSON.stringify dropping undefined) keeps the key
|
|
154
|
+
// deterministic regardless of how the caller assembled the options object.
|
|
155
|
+
export function sessionKey(repo: string, opts: SessionScanOptions): string {
|
|
156
|
+
return (
|
|
157
|
+
repo +
|
|
158
|
+
"\0" +
|
|
159
|
+
JSON.stringify({
|
|
160
|
+
scope: opts.scope,
|
|
161
|
+
include: opts.include,
|
|
162
|
+
exclude: opts.exclude,
|
|
163
|
+
gitignore: opts.gitignore,
|
|
164
|
+
ignoreDirs: opts.ignoreDirs,
|
|
165
|
+
maxBytes: opts.maxBytes,
|
|
166
|
+
maxFiles: opts.maxFiles,
|
|
167
|
+
maxCallsPerFile: opts.maxCallsPerFile,
|
|
168
|
+
out: opts.out,
|
|
169
|
+
fullHash: opts.fullHash,
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Re-exported for tests and for consumers that imported it from here before the
|
|
175
|
+
// preload machinery moved into src/preload.ts.
|
|
176
|
+
export { toCacheMap };
|
|
177
|
+
|
|
178
|
+
// The memoizing replacement for scanRepo inside callTool. Exported for tests.
|
|
179
|
+
export function getScan(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): RepoScan {
|
|
180
|
+
const key = sessionKey(repo, opts);
|
|
181
|
+
const hit = sessionGet(key);
|
|
182
|
+
if (hit) {
|
|
183
|
+
const fresh = scanRepo(repo, { ...opts, cache: hit.cacheMap, precomputedWalk: walked });
|
|
184
|
+
if (fresh.contentUnchanged) {
|
|
185
|
+
// Content proven identical → return the SAME object (object identity is
|
|
186
|
+
// what keeps derived.ts's WeakMap and the memoized artifacts warm). A
|
|
187
|
+
// stat-only drift (e.g. a bare touch) still refreshes the cache map so
|
|
188
|
+
// the next call's stat fastpath keys on the new (size, mtimeMs).
|
|
189
|
+
if (fresh.cacheDirty) hit.cacheMap = toCacheMap(fresh);
|
|
190
|
+
// `commit` (headCommit(root)) is NOT part of the stat/hash freshness
|
|
191
|
+
// oracle: a git HEAD move that leaves the worktree untouched — commit /
|
|
192
|
+
// commit --amend / reset --soft / checkout to an identical-tree branch —
|
|
193
|
+
// changes headCommit without altering any file's size or mtime, so
|
|
194
|
+
// contentUnchanged stays true while the cached scan's commit went stale.
|
|
195
|
+
// `fresh` recomputed it just now (exactly what a cold process reports), so
|
|
196
|
+
// sync it onto the returned object; otherwise scan_summary would emit the
|
|
197
|
+
// OLD commit a from-scratch scanRepo never would. Mutate the SAME object
|
|
198
|
+
// rather than clone — cloning would forfeit the identity the artifacts and
|
|
199
|
+
// derived.ts WeakMap key on. Safe: no artifact carries commit (graph /
|
|
200
|
+
// symbols render byte-identically regardless), so nothing memoized here
|
|
201
|
+
// depends on this field.
|
|
202
|
+
if (hit.scan.commit !== fresh.commit) hit.scan.commit = fresh.commit;
|
|
203
|
+
return hit.scan;
|
|
204
|
+
}
|
|
205
|
+
sessionPut({ key, scan: fresh, cacheMap: toCacheMap(fresh) });
|
|
206
|
+
return fresh;
|
|
207
|
+
}
|
|
208
|
+
// First touch of this (repo, opts): try the persisted-index preload before a
|
|
209
|
+
// cold scan. A present, version-compatible .codeindex/cache.json seeds the
|
|
210
|
+
// scan (and, when the guard holds, the artifacts); absent it, fall through to
|
|
211
|
+
// the cold path EXACTLY as before.
|
|
212
|
+
const preloaded = preloadSession(repo, { ...opts, precomputedWalk: walked });
|
|
213
|
+
if (preloaded) {
|
|
214
|
+
sessionPut({ key, scan: preloaded.scan, cacheMap: preloaded.cacheMap, arts: preloaded.arts });
|
|
215
|
+
return preloaded.scan;
|
|
216
|
+
}
|
|
217
|
+
const scan = scanRepo(repo, { ...opts, precomputedWalk: walked });
|
|
218
|
+
sessionPut({ key, scan, cacheMap: toCacheMap(scan) });
|
|
219
|
+
return scan;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The scan_summary numbers, without paying for a scan.
|
|
223
|
+
//
|
|
224
|
+
// A file count and a language histogram come from the walk plus the path-based
|
|
225
|
+
// classifiers — no read, no hash, no tree-sitter. When this session already
|
|
226
|
+
// holds a scan for the same (repo, opts) we derive from it instead (identical
|
|
227
|
+
// numbers, and it keeps a warm session warm); otherwise scanSummary walks once.
|
|
228
|
+
// The summary is NEVER written into the session cache: it carries no
|
|
229
|
+
// FileRecords, so caching it would starve every record-shaped tool that ran next.
|
|
230
|
+
export function getScanSummary(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): ScanSummary {
|
|
231
|
+
if (sessionCaches.some((e) => e.key === sessionKey(repo, opts))) {
|
|
232
|
+
const scan = getScan(repo, opts, walked);
|
|
233
|
+
return {
|
|
234
|
+
root: scan.root,
|
|
235
|
+
commit: scan.commit,
|
|
236
|
+
fileCount: scan.files.length,
|
|
237
|
+
languages: scan.languages,
|
|
238
|
+
capped: scan.capped,
|
|
239
|
+
excluded: scan.excluded,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return scanSummary(repo, { ...opts, precomputedWalk: walked });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Lazy pipeline memoized on scan OBJECT IDENTITY: graph-shaped tools reuse the
|
|
246
|
+
// artifacts exactly as long as getScan keeps returning the same scan object.
|
|
247
|
+
// Exported for tests.
|
|
248
|
+
export function getArtifacts(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): IndexArtifacts {
|
|
249
|
+
const scan = getScan(repo, opts, walked);
|
|
250
|
+
const entry = sessionCaches.find((e) => e.scan === scan);
|
|
251
|
+
if (entry) return (entry.arts ??= buildArtifactsFromScan(scan, opts));
|
|
252
|
+
// Defensive fallback (getScan always leaves an entry holding `scan`).
|
|
253
|
+
return buildArtifactsFromScan(scan, opts);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Warm the grammars for the languages CURRENTLY present in `repo`, re-derived on
|
|
257
|
+
// EVERY scan-needing call — never frozen on first touch. The server no longer
|
|
258
|
+
// warms every committed grammar at startup; most sessions touch one repo and a
|
|
259
|
+
// handful of languages, so each scan-needing tool warms the walk-derived set
|
|
260
|
+
// itself. It MUST re-derive per call because the session cache (getScan) is built
|
|
261
|
+
// to pick up mid-session file adds/edits/removes: a language whose first file
|
|
262
|
+
// appears only AFTER the initial scan-needing call must still get its grammar
|
|
263
|
+
// warmed, or that file falls to the regex tier and its symbols diverge from a
|
|
264
|
+
// cold build on the identical on-disk state — a byte-identity break. (A per-
|
|
265
|
+
// repo-path memo froze the grammar set at first touch and silently missed
|
|
266
|
+
// exactly this case.) ensureGrammars is idempotent and near-free once a grammar
|
|
267
|
+
// is loaded — it warms only newly-seen keys — so the sole repeated cost is the
|
|
268
|
+
// walk; the wasm for a given language loads at most once. Determinism: the walk's
|
|
269
|
+
// extension set is a superset of what scanRepo keeps (scope/include/exclude only
|
|
270
|
+
// filter further), so every extracted file has its grammar loaded; Language.load
|
|
271
|
+
// calls are independent, so warming fewer grammars cannot alter the parse of a
|
|
272
|
+
// loaded one.
|
|
273
|
+
export async function warmGrammarsForRepo(repo: string): Promise<void> {
|
|
274
|
+
await warmGrammarsForWalk(walk(repo, {}));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The same warm, against a walk the caller already has.
|
|
278
|
+
//
|
|
279
|
+
// callTool used to walk TWICE per scan-needing call: once here to derive the
|
|
280
|
+
// present languages, then again inside scanRepo. On a large repo that fixed cost
|
|
281
|
+
// dominated every response (the project's own benchmark shows find-symbol,
|
|
282
|
+
// references and file-overview all landing within a few ms of each other on a
|
|
283
|
+
// 20k-file monorepo — the signature of a per-call constant, not of the query).
|
|
284
|
+
// One walk now feeds both.
|
|
285
|
+
export async function warmGrammarsForWalk(walked: WalkResult): Promise<void> {
|
|
286
|
+
await ensureGrammars(grammarKeysForExts(walked.files.map((f) => f.ext)));
|
|
287
|
+
}
|