@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.
@@ -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
+ }