@maxgfr/codeindex 2.18.0 → 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 +7 -1
- package/docs/MIGRATION.md +20 -0
- package/package.json +1 -1
- package/scripts/engine.d.mts +2 -2
- package/scripts/engine.mjs +819 -638
- 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 +65 -814
- package/src/types.ts +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// MCP wire concerns: protocol-version negotiation, argument validation, and the
|
|
2
|
+
// response-size guard. Everything here is a pure function of its inputs — no
|
|
3
|
+
// scan, no filesystem beyond checking whether a persisted artifact exists — so
|
|
4
|
+
// it is unit-testable without standing up a server.
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { INDEX_DIR } from "../preload.js";
|
|
9
|
+
|
|
10
|
+
// --- protocol versions -------------------------------------------------------
|
|
11
|
+
// The server announced "2024-11-05" hard-coded and never even read the version
|
|
12
|
+
// the client asked for. Three revisions have shipped since.
|
|
13
|
+
//
|
|
14
|
+
// Negotiation is what makes moving forward non-breaking: a client that asks for
|
|
15
|
+
// an old revision gets that revision, and every field introduced later is
|
|
16
|
+
// withheld — so its responses are exactly the bytes it received before. Newer
|
|
17
|
+
// clients opt themselves in simply by asking.
|
|
18
|
+
//
|
|
19
|
+
// Dates sort lexicographically, so `>=` on the strings is a version comparison.
|
|
20
|
+
export const PROTOCOL_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const;
|
|
21
|
+
const LATEST_PROTOCOL = PROTOCOL_VERSIONS[PROTOCOL_VERSIONS.length - 1]!;
|
|
22
|
+
|
|
23
|
+
// Feature floors, by the revision that introduced them.
|
|
24
|
+
export const ANNOTATIONS_SINCE = "2025-03-26"; // tool behaviour hints
|
|
25
|
+
export const RICH_TOOLS_SINCE = "2025-06-18"; // Tool.title, resource_link content
|
|
26
|
+
|
|
27
|
+
// Validate `arguments` against the tool's declared inputSchema.
|
|
28
|
+
//
|
|
29
|
+
// There was no validation at all beyond presence checks, and the readers failed
|
|
30
|
+
// silently in both directions: str() returns undefined for a non-string, so a
|
|
31
|
+
// number where a path belongs became "missing", and every boolean was `=== true`,
|
|
32
|
+
// so `"false"` and `1` alike read as false. The caller saw its option ignored
|
|
33
|
+
// with no way to tell why.
|
|
34
|
+
//
|
|
35
|
+
// Only the shapes these schemas actually use are checked (string / number /
|
|
36
|
+
// boolean / array-of-string) — this is a guard against silent misreads, not a
|
|
37
|
+
// JSON Schema implementation. The spec (2025-11-25) is explicit that input
|
|
38
|
+
// validation failures belong in a Tool Execution Error, not a protocol error,
|
|
39
|
+
// precisely so the model can read the message and retry.
|
|
40
|
+
// Required-ness stays with callTool, which raises tool-specific messages
|
|
41
|
+
// ("`rules` (or `configPath`) is required"); duplicating it here would only let
|
|
42
|
+
// the two drift.
|
|
43
|
+
export function validateArgs(
|
|
44
|
+
schema: { properties?: Record<string, unknown> },
|
|
45
|
+
args: Record<string, unknown>,
|
|
46
|
+
): string | undefined {
|
|
47
|
+
const props = (schema.properties ?? {}) as Record<string, { type?: string; items?: { type?: string } }>;
|
|
48
|
+
for (const [key, value] of Object.entries(args)) {
|
|
49
|
+
if (value === undefined || value === null) continue;
|
|
50
|
+
const spec = props[key];
|
|
51
|
+
if (!spec?.type) continue; // undeclared extras stay tolerated
|
|
52
|
+
const actual = Array.isArray(value) ? "array" : typeof value;
|
|
53
|
+
if (spec.type === "number") {
|
|
54
|
+
// A numeric string is accepted (num() coerces it); anything else is not.
|
|
55
|
+
if (actual === "number") continue;
|
|
56
|
+
if (actual === "string" && Number.isFinite(Number(value as string)) && (value as string).trim() !== "") continue;
|
|
57
|
+
return `\`${key}\` must be a number, got ${actual === "string" ? JSON.stringify(value) : actual}`;
|
|
58
|
+
}
|
|
59
|
+
if (spec.type === "array") {
|
|
60
|
+
if (actual !== "array") return `\`${key}\` must be an array of strings, got ${actual}`;
|
|
61
|
+
if (spec.items?.type === "string" && !(value as unknown[]).every((x) => typeof x === "string")) {
|
|
62
|
+
return `\`${key}\` must be an array of strings`;
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (actual !== spec.type) return `\`${key}\` must be a ${spec.type}, got ${actual}`;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The structuredContent for a tool response, or undefined when there must not
|
|
72
|
+
// be one.
|
|
73
|
+
//
|
|
74
|
+
// Emitted only when ALL of these hold, because the spec requires a declared
|
|
75
|
+
// outputSchema to be honoured by every structured result:
|
|
76
|
+
// * the tool declares an outputSchema (see OUTPUT_SCHEMAS),
|
|
77
|
+
// * the response was NOT replaced by the size guard — the truncation notice
|
|
78
|
+
// is a different shape and would not conform,
|
|
79
|
+
// * the text parses to a JSON object (never an array: structuredContent is
|
|
80
|
+
// specified as an object).
|
|
81
|
+
// The text block is left exactly as it was, so this is purely additive and
|
|
82
|
+
// content stays the serialization of structuredContent, as the spec asks.
|
|
83
|
+
export function structuredContentFor(text: string, capped: boolean, hasSchema: boolean): Record<string, unknown> | undefined {
|
|
84
|
+
if (capped || !hasSchema) return undefined;
|
|
85
|
+
let parsed: unknown;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(text);
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
92
|
+
return parsed as Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function negotiateProtocol(requested: unknown): string {
|
|
96
|
+
return typeof requested === "string" && (PROTOCOL_VERSIONS as readonly string[]).includes(requested)
|
|
97
|
+
? requested
|
|
98
|
+
: LATEST_PROTOCOL;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- response size guard -----------------------------------------------------
|
|
102
|
+
// Several tools returned unbounded payloads. On facebook/react (7091 files):
|
|
103
|
+
// graph 9.4 MB, symbols 6.3 MB, callers 6.0 MB, dead_code 771 KB — roughly
|
|
104
|
+
// 2.35M, 1.57M, 1.51M and 193k tokens. A single `graph` call does not merely
|
|
105
|
+
// bloat an agent's context, it exceeds what any MCP client can accept, so the
|
|
106
|
+
// call fails and the turn is wasted.
|
|
107
|
+
//
|
|
108
|
+
// The guard is deliberately NOT a default page size: below the limit a response
|
|
109
|
+
// is byte-identical to what it always was. Above it, the response could not be
|
|
110
|
+
// consumed by any client anyway, so replacing it with something actionable
|
|
111
|
+
// cannot regress a working call — it converts a hard failure into a usable
|
|
112
|
+
// answer that says how big the payload is, where the artifact already sits on
|
|
113
|
+
// disk, and which narrower tool answers the question.
|
|
114
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 1_000_000;
|
|
115
|
+
|
|
116
|
+
// What to steer a caller toward when their whole-repo request is too large.
|
|
117
|
+
const NARROWER: Record<string, string> = {
|
|
118
|
+
graph: "pass `scope` to a subdirectory, or use repo_map / mermaid for an overview",
|
|
119
|
+
symbols: "pass `name` to look up one symbol, or use find_symbol / symbols_overview",
|
|
120
|
+
callers: "pass `name` to look up one symbol's call sites",
|
|
121
|
+
dead_code: "pass `scope` to a subdirectory",
|
|
122
|
+
find_references: "the symbol is referenced very widely — narrow with `scope` on a graph query",
|
|
123
|
+
check_rules: "narrow the rule set, or pass `scope` to a subdirectory",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// The persisted artifact backing a tool, when a `codeindex index` already wrote
|
|
127
|
+
// one — far more useful to hand back than a truncated blob.
|
|
128
|
+
const ARTIFACT_FOR: Record<string, string> = { graph: "graph.json", symbols: "symbols.json" };
|
|
129
|
+
|
|
130
|
+
export function capResponse(text: string, tool: string, repo: string, maxBytes: number): string {
|
|
131
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
132
|
+
if (bytes <= maxBytes) return text;
|
|
133
|
+
const artifact = ARTIFACT_FOR[tool] ? join(repo, INDEX_DIR, ARTIFACT_FOR[tool]!) : undefined;
|
|
134
|
+
return (
|
|
135
|
+
JSON.stringify(
|
|
136
|
+
{
|
|
137
|
+
truncated: true,
|
|
138
|
+
tool,
|
|
139
|
+
bytes,
|
|
140
|
+
maxBytes,
|
|
141
|
+
reason:
|
|
142
|
+
"This response exceeds the configured limit and was withheld rather than sent as an unusable partial payload.",
|
|
143
|
+
narrower: NARROWER[tool] ?? "narrow the request with `scope`, `include`/`exclude`, or a `limit`",
|
|
144
|
+
...(artifact && existsSync(artifact)
|
|
145
|
+
? { artifact, artifactNote: "The full result is already on disk here — read it directly if you need all of it." }
|
|
146
|
+
: artifact
|
|
147
|
+
? { artifactNote: `Run \`codeindex index --repo ${repo} --out ${join(repo, INDEX_DIR)}\` to get this as a file.` }
|
|
148
|
+
: {}),
|
|
149
|
+
},
|
|
150
|
+
null,
|
|
151
|
+
2,
|
|
152
|
+
) + "\n"
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// When capResponse withheld a payload AND the artifact is on disk, hand the
|
|
157
|
+
// client a resource_link to it. Returns undefined for every normal response —
|
|
158
|
+
// this only ever adds a second content block to a capped one.
|
|
159
|
+
export function resourceLinkFor(text: string, tool: string): Record<string, unknown> | undefined {
|
|
160
|
+
const artifactName = ARTIFACT_FOR[tool];
|
|
161
|
+
if (!artifactName) return undefined;
|
|
162
|
+
let parsed: { truncated?: boolean; artifact?: string };
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(text) as typeof parsed;
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined; // a normal (non-JSON, or non-capped) response
|
|
167
|
+
}
|
|
168
|
+
if (parsed.truncated !== true || typeof parsed.artifact !== "string") return undefined;
|
|
169
|
+
return {
|
|
170
|
+
type: "resource_link",
|
|
171
|
+
uri: pathToFileURL(parsed.artifact).href,
|
|
172
|
+
name: artifactName,
|
|
173
|
+
description: `The full ${tool} result this call was too large to inline.`,
|
|
174
|
+
mimeType: "application/json",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -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
|
+
}
|