syncstaff-mcp 0.2.3

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.
Files changed (52) hide show
  1. package/README.md +86 -0
  2. package/dist/lib/agent-state.js +119 -0
  3. package/dist/lib/blast.js +462 -0
  4. package/dist/lib/client-config.js +81 -0
  5. package/dist/lib/env-compat.js +66 -0
  6. package/dist/lib/globs.js +0 -0
  7. package/dist/lib/ids.js +24 -0
  8. package/dist/lib/index/aliases.js +244 -0
  9. package/dist/lib/index/call-sites.js +178 -0
  10. package/dist/lib/index/checker-resolver.js +257 -0
  11. package/dist/lib/index/context-card.js +140 -0
  12. package/dist/lib/index/coverage.js +218 -0
  13. package/dist/lib/index/delivery.js +66 -0
  14. package/dist/lib/index/discovery.js +90 -0
  15. package/dist/lib/index/embedding.js +110 -0
  16. package/dist/lib/index/file-index.js +222 -0
  17. package/dist/lib/index/fingerprint.js +0 -0
  18. package/dist/lib/index/git-history.js +136 -0
  19. package/dist/lib/index/graph.js +234 -0
  20. package/dist/lib/index/impact.js +174 -0
  21. package/dist/lib/index/incremental.js +332 -0
  22. package/dist/lib/index/lexical.js +462 -0
  23. package/dist/lib/index/order.js +43 -0
  24. package/dist/lib/index/pages.js +357 -0
  25. package/dist/lib/index/persistence.js +233 -0
  26. package/dist/lib/index/pipeline.js +527 -0
  27. package/dist/lib/index/registry.js +106 -0
  28. package/dist/lib/index/resolve.js +280 -0
  29. package/dist/lib/index/semantic.js +381 -0
  30. package/dist/lib/index/surfaces.js +27 -0
  31. package/dist/lib/index/symbols.js +426 -0
  32. package/dist/lib/index/transformers-embedder.js +73 -0
  33. package/dist/lib/index/typescript-parser.js +532 -0
  34. package/dist/lib/index/vector-cache.js +176 -0
  35. package/dist/lib/index/verification.js +58 -0
  36. package/dist/lib/mcp-compaction.js +241 -0
  37. package/dist/lib/model-roles.js +206 -0
  38. package/dist/lib/path-warnings.js +90 -0
  39. package/dist/lib/protocol.js +95 -0
  40. package/dist/lib/types.js +69 -0
  41. package/dist/lib/version.js +21 -0
  42. package/dist/lib/worktree.js +211 -0
  43. package/dist/mcp/approval.js +0 -0
  44. package/dist/mcp/cloud-connector.js +99 -0
  45. package/dist/mcp/daemon-client.js +156 -0
  46. package/dist/mcp/daemon-protocol.js +100 -0
  47. package/dist/mcp/escalation-waiter.js +183 -0
  48. package/dist/mcp/graph-ops.js +169 -0
  49. package/dist/mcp/index.js +1151 -0
  50. package/dist/mcp/login.js +169 -0
  51. package/dist/mcp/setup.js +90 -0
  52. package/package.json +42 -0
@@ -0,0 +1,527 @@
1
+ import { answerRetrieval } from "./delivery.js";
2
+ import { verifyCandidates } from "./verification.js";
3
+ import { createTransformersEmbedder } from "./transformers-embedder.js";
4
+ import { buildLexicalIndex, routeQuery, vocabularyWeightFromEnv } from "./lexical.js";
5
+ import { cascadeRetrieval } from "./semantic.js";
6
+ import { searchSymbols } from "./symbols.js";
7
+ import { buildDependencyGraph } from "./graph.js";
8
+ import { buildSymbolTable, resolveCallEdges } from "./symbols.js";
9
+ import { byCodeUnit } from "./order.js";
10
+ /** Parse the local rollout switch. Unknown values fail closed to stable. */
11
+ export function retrievalModeFromEnv(value = process.env.KEEL_RETRIEVAL_MODE) {
12
+ const normalized = value?.trim().toLowerCase();
13
+ return normalized === "candidate" || normalized === "shadow" ? normalized : "stable";
14
+ }
15
+ const VERIFICATION_CANDIDATE_MULTIPLIER = 3;
16
+ const MIN_VERIFIED_CANDIDATES = 3;
17
+ const MAX_ITERATION_CANDIDATES = 100;
18
+ /** Candidate headroom: how far past the served limit the legs retrieve. */
19
+ const CANDIDATE_POOL_MULTIPLIER = 5;
20
+ const MAX_RELATED_PATHS = 8;
21
+ /** How many of the first-pass hits are used as expansion seeds. */
22
+ const GRAPH_SEED_COUNT = 5;
23
+ /** Ceiling on what one expansion may add, so topology cannot flood the pool. */
24
+ const GRAPH_EXPANSION_LIMIT = 20;
25
+ /**
26
+ * A call edge is stronger evidence than an import edge.
27
+ *
28
+ * Importing a module says this file depends on it; calling a function in it
29
+ * says this file exercises it. Both are real, and the second is closer to what
30
+ * "you will need to change this too" means.
31
+ */
32
+ const GRAPH_EDGE_WEIGHT = { call: 1, import: 0.6 };
33
+ /** How many first-pass files can seed the historical co-change expansion. */
34
+ const COCHANGE_SEED_COUNT = 5;
35
+ /** Ceiling on what historical coupling may add to the candidate pool. */
36
+ const COCHANGE_EXPANSION_LIMIT = 20;
37
+ /**
38
+ * Ignore the very top historical neighbours: broad hubs are often co-touched
39
+ * for repository plumbing and otherwise displace query-specific candidates.
40
+ * The cutoff is deliberately conservative; deeper ranked neighbours retain
41
+ * the hidden-coupling signal while the lexical leg remains the source of the
42
+ * initial candidate set.
43
+ */
44
+ const COCHANGE_MIN_RELATED_RANK = 2;
45
+ /**
46
+ * Topology is useful only after retrieval has found a concrete anchor.
47
+ *
48
+ * A vector neighbour is deliberately not enough: cosine similarity is a
49
+ * recall signal, not proof that a file is connected to the request. Requiring
50
+ * either a symbol hit, a high-information lexical signal, or two independent
51
+ * lexical terms keeps graph expansion from turning an off-topic vector result
52
+ * into a confident-looking dependency walk.
53
+ */
54
+ const GRAPH_HIGH_CONFIDENCE_SEEDS = 5;
55
+ const hasHighConfidenceSeed = (path, lexicalHits, symbolHits) => {
56
+ if (symbolHits.some((hit) => hit.path === path))
57
+ return true;
58
+ const lexicalRank = lexicalHits.findIndex((hit) => hit.page.target_path === path);
59
+ if (lexicalRank < 0 || lexicalRank >= GRAPH_HIGH_CONFIDENCE_SEEDS)
60
+ return false;
61
+ const hit = lexicalHits[lexicalRank];
62
+ return (hit.signal_kinds ?? []).some((kind) => kind === "phrase" || kind === "path" || kind === "identifier")
63
+ || hit.matched_terms.length >= 2;
64
+ };
65
+ /**
66
+ * Languages whose topology is allowed to move the ranking.
67
+ *
68
+ * Two-stage fusion was adopted on a measurement, not on an argument: +0.0223
69
+ * recall, CI [0.0116, 0.0330], on TypeScript. The same experiment ran on Python
70
+ * and failed all three criteria that had been locked before it — the CI lower
71
+ * bound fell below zero, precision missed its 12% floor, and net rescues stalled
72
+ * at +1. Precision did not *degrade*; it stayed pinned at 7.0%. The leg simply
73
+ * never established that it was worth running.
74
+ *
75
+ * The resolver that produces those Python edges is not what failed and is not
76
+ * what this gates. Blast radius, `sync_impact`, lease widening and the codemap
77
+ * all consume them. What failed was fusing them into a *ranking*, and that is
78
+ * the only thing switched off here.
79
+ *
80
+ * A corpus is rarely one language, so the gate is per edge rather than per
81
+ * repository: a TypeScript file in a mixed tree keeps the topology that was
82
+ * measured, and a Python file contributes nothing to the ranking either way.
83
+ */
84
+ const GRAPH_RANKING_LANGUAGES = new Set(["typescript"]);
85
+ const evidenceSnippets = (page, terms) => {
86
+ const wanted = terms.map((term) => term.toLowerCase()).filter(Boolean);
87
+ const lines = page.content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
88
+ const matching = lines.filter((line) => {
89
+ const lower = line.toLowerCase();
90
+ return wanted.some((term) => lower.includes(term));
91
+ });
92
+ const source = matching.length ? matching : lines;
93
+ return [...new Set(source)].slice(0, 3).map((line) => line.slice(0, 320));
94
+ };
95
+ const relatedPathsFor = (path, index, graph, calls) => {
96
+ const related = [];
97
+ for (const edge of graph.edges) {
98
+ if (edge.from === path)
99
+ related.push({ path: edge.to, relation: "imports", evidence: `${path} imports ${edge.names.join(", ") || "this module"}` });
100
+ if (edge.to === path)
101
+ related.push({ path: edge.from, relation: "imported_by", evidence: `${edge.from} imports this file` });
102
+ }
103
+ for (const edge of calls.edges) {
104
+ if (edge.from === path && edge.to !== path)
105
+ related.push({ path: edge.to, relation: "calls", evidence: `${path} calls ${edge.symbol}()` });
106
+ if (edge.to === path && edge.from !== path)
107
+ related.push({ path: edge.from, relation: "called_by", evidence: `${edge.from} calls ${edge.symbol}()` });
108
+ }
109
+ const filename = path.split("/").pop() ?? path;
110
+ const stem = filename.replace(/\.(?:test|spec)\.[^.]+$/, "").replace(/\.[^.]+$/, "");
111
+ if (stem) {
112
+ const directory = path.slice(0, Math.max(0, path.length - filename.length));
113
+ for (const file of index.files) {
114
+ const candidate = file.path.split("/").pop() ?? file.path;
115
+ if (file.path === path || !file.path.startsWith(directory))
116
+ continue;
117
+ if (candidate.startsWith(`${stem}.test.`) || candidate.startsWith(`${stem}.spec.`)) {
118
+ related.push({ path: file.path, relation: "test_sibling", evidence: `${file.path} tests ${path}` });
119
+ }
120
+ }
121
+ }
122
+ const rank = new Map([["called_by", 0], ["calls", 1], ["imported_by", 2], ["imports", 3], ["test_sibling", 4]]);
123
+ const unique = new Map();
124
+ for (const item of related)
125
+ unique.set(`${item.relation}\0${item.path}`, item);
126
+ return [...unique.values()]
127
+ .sort((a, b) => (rank.get(a.relation) - rank.get(b.relation)) || byCodeUnit(a.path, b.path))
128
+ .slice(0, MAX_RELATED_PATHS);
129
+ };
130
+ /** One client-side call from a natural-language question to an actionable answer. */
131
+ export async function retrieve(pages, query, options = {}) {
132
+ const retrievalMode = options.retrievalMode ?? retrievalModeFromEnv();
133
+ if (retrievalMode === "shadow") {
134
+ const [stable, candidate] = await Promise.all([
135
+ retrieve(pages, query, {
136
+ ...options,
137
+ retrievalMode: "stable",
138
+ coChangeIndex: undefined,
139
+ onShadowComparison: undefined,
140
+ }),
141
+ retrieve(pages, query, {
142
+ ...options,
143
+ retrievalMode: "candidate",
144
+ onShadowComparison: undefined,
145
+ }),
146
+ ]);
147
+ const stablePaths = stable.results.map((result) => result.page.target_path);
148
+ const candidatePaths = candidate.results.map((result) => result.page.target_path);
149
+ const stableSet = new Set(stablePaths);
150
+ const candidateSet = new Set(candidatePaths);
151
+ await options.onShadowComparison?.({
152
+ stable_paths: stablePaths,
153
+ candidate_paths: candidatePaths,
154
+ candidate_only: candidatePaths.filter((path) => !stableSet.has(path)).length,
155
+ stable_only: stablePaths.filter((path) => !candidateSet.has(path)).length,
156
+ });
157
+ return stable;
158
+ }
159
+ const limit = options.limit ?? 10;
160
+ const offset = Math.max(0, Math.floor(options.offset ?? 0));
161
+ const excludedCount = options.excludePaths?.size ?? 0;
162
+ const requestedPool = offset + limit + excludedCount;
163
+ // Fetch more candidates than we serve. Without headroom the pool is exactly
164
+ // the response: every candidate retrieved is delivered, so the fusion,
165
+ // symbol and verification legs can only reorder the lexical top-N and can
166
+ // never rescue a file lexical ranked just past the cut. It also made every
167
+ // delivery-layer filter a no-op, because there was nothing to promote into
168
+ // the slot a filtered candidate vacated.
169
+ const candidateLimit = Math.min(MAX_ITERATION_CANDIDATES, Math.max(limit * (options.verify ? VERIFICATION_CANDIDATE_MULTIPLIER : 1) * CANDIDATE_POOL_MULTIPLIER, requestedPool));
170
+ // Chunks exist only so the vector leg can read a whole file. Letting them
171
+ // into BM25 would index every oversized file several times over, changing
172
+ // document frequencies and doc lengths for the entire corpus — the lexical
173
+ // leg must be byte-identical to its pre-chunking behaviour or the two legs
174
+ // cannot be told apart in a result.
175
+ const searchablePages = pages.filter((page) => page.page_type !== "chunk");
176
+ const lexicalIndex = options.lexicalIndex ?? buildLexicalIndex(searchablePages);
177
+ const configuredVocabularyWeight = options.lexicalVocabularyWeight ?? vocabularyWeightFromEnv();
178
+ const lexicalVocabularyWeight = retrievalMode === "stable" ? 0 : configuredVocabularyWeight;
179
+ const lexical = lexicalIndex.searchMulti(query, { limit: candidateLimit, vocabularyWeight: lexicalVocabularyWeight });
180
+ const lexicalLeg = { name: "lexical", status: "ok", hits: lexical.hits.map((hit) => ({ page: hit.page, score: hit.score })) };
181
+ const pagesByPath = new Map(pages.filter((page) => page.page_type === "file").map((page) => [page.target_path, page]));
182
+ // The vector leg reads chunks where a file has them and the file page where
183
+ // it does not, so a small file is never embedded twice.
184
+ const chunkedPaths = new Set(pages.filter((page) => page.page_type === "chunk").map((page) => page.target_path));
185
+ const vectorPages = pages.filter((page) => page.page_type === "chunk" || !chunkedPaths.has(page.target_path));
186
+ // Kept, not just ranked. These are the symbols the request implicated, and
187
+ // R9.1 verifies candidates *against them* — so the names have to survive the
188
+ // leg that found them rather than being reduced to scores.
189
+ const queryPlan = retrievalMode === "stable" || !options.queryRouting ? undefined : routeQuery(query);
190
+ const symbolQueries = queryPlan?.symbol_queries ?? [query];
191
+ const symbolHits = options.symbolIndex
192
+ ? (() => {
193
+ const bySymbol = new Map();
194
+ for (const symbolQuery of symbolQueries) {
195
+ for (const hit of searchSymbols(options.symbolIndex, symbolQuery, { limit: 20, maxFiles: 8 })) {
196
+ const key = `${hit.path}\0${hit.symbol}`;
197
+ const current = bySymbol.get(key) ?? { path: hit.path, symbol: hit.symbol, score: 0, matched_terms: new Set() };
198
+ current.score = Math.max(current.score, hit.score);
199
+ hit.matched_terms.forEach((term) => current.matched_terms.add(term));
200
+ bySymbol.set(key, current);
201
+ }
202
+ }
203
+ return [...bySymbol.values()]
204
+ .map((hit) => ({ ...hit, matched_terms: [...hit.matched_terms].sort(byCodeUnit) }))
205
+ .sort((a, b) => b.score - a.score || byCodeUnit(a.path, b.path) || byCodeUnit(a.symbol, b.symbol))
206
+ .slice(0, 20);
207
+ })()
208
+ : [];
209
+ const symbolLeg = options.symbolIndex
210
+ ? {
211
+ name: "symbol",
212
+ status: "ok",
213
+ hits: symbolHits.flatMap((hit) => {
214
+ const page = pagesByPath.get(hit.path);
215
+ return page ? [{ page, score: hit.score, raw_score: hit.score }] : [];
216
+ }),
217
+ }
218
+ : undefined;
219
+ // `undefined` means "no preference", not "no vector leg" — see RetrievalOptions.
220
+ // Do not initialize the default model when the caller supplied an explicit
221
+ // embedder (including `null` to disable vectors). Explicit local providers
222
+ // must pay only for their own model, and tests/benchmarks should not load an
223
+ // unrelated Transformers model once per query.
224
+ //
225
+ // The default model is also opt-in even when the caller expresses no
226
+ // preference: loading ~200-300MB of ONNX weights on every retrieve() call
227
+ // is exactly the duplicate-memory cost tsk_01M1MQ01E1D7JSEHFVE2RCK8DJ
228
+ // flagged across concurrent agent processes. KEEL_RETRIEVAL_SEMANTIC=1
229
+ // opts back in; lexical + symbol stays the default high-precision path.
230
+ const semanticOptIn = process.env.KEEL_RETRIEVAL_SEMANTIC === "1";
231
+ const embedder = options.embedder === undefined
232
+ ? (semanticOptIn ? await createTransformersEmbedder() : null)
233
+ : options.embedder;
234
+ /**
235
+ * Stage two of the two-stage fusion: what is connected to the current best
236
+ * guesses.
237
+ *
238
+ * Scored by three things multiplied together.
239
+ *
240
+ * Seed rank — a neighbour of the top hit is better evidence than a neighbour
241
+ * of the fifth.
242
+ *
243
+ * Edge kind — calls outrank imports, because exercising a symbol is closer to
244
+ * needing to change it than merely depending on the module.
245
+ *
246
+ * **Inverse degree** — and this one is load-bearing. A logger, a types
247
+ * barrel, or a shared util sits one hop from most of the repository, so
248
+ * without a penalty it would surface on every query and drown the actual
249
+ * request. Dividing by the square root of degree means a file connected to
250
+ * four hundred others must be reached from many independent seeds before it
251
+ * outranks one connected to three. This is the mechanism that decides whether
252
+ * topological fusion helps or invents a new way to ruin the ranking.
253
+ */
254
+ const expandFromSeeds = (graph, calls, byPath, rankable) => {
255
+ // Filtered once, before anything is counted. Degree is the inverse penalty
256
+ // that decides whether a well-connected file can flood the expansion, so it
257
+ // has to be computed over the same edges the ranking may traverse — a file
258
+ // penalised for imports that can never reach it would be scored on a graph
259
+ // it is not being ranked in.
260
+ //
261
+ // On a TypeScript-only corpus every edge survives this and the arm is
262
+ // byte-identical to the one that measured +0.0223. That is the point: a
263
+ // gate that perturbs the configuration it protects has swapped one
264
+ // unmeasured setup for another.
265
+ const eligible = (edge) => rankable.has(edge.from) && rankable.has(edge.to);
266
+ const importEdges = graph.edges.filter(eligible);
267
+ const callEdges = calls.edges.filter(eligible);
268
+ const degree = new Map();
269
+ const bump = (path) => degree.set(path, (degree.get(path) ?? 0) + 1);
270
+ for (const edge of importEdges) {
271
+ bump(edge.from);
272
+ bump(edge.to);
273
+ }
274
+ for (const edge of callEdges) {
275
+ bump(edge.from);
276
+ bump(edge.to);
277
+ }
278
+ const neighboursOf = (path) => {
279
+ const out = [];
280
+ for (const edge of importEdges) {
281
+ if (edge.from === path)
282
+ out.push({ path: edge.to, weight: GRAPH_EDGE_WEIGHT.import });
283
+ else if (edge.to === path)
284
+ out.push({ path: edge.from, weight: GRAPH_EDGE_WEIGHT.import });
285
+ }
286
+ for (const edge of callEdges) {
287
+ if (edge.from === path && edge.to !== path)
288
+ out.push({ path: edge.to, weight: GRAPH_EDGE_WEIGHT.call });
289
+ else if (edge.to === path && edge.from !== path)
290
+ out.push({ path: edge.from, weight: GRAPH_EDGE_WEIGHT.call });
291
+ }
292
+ return out;
293
+ };
294
+ return (seeds) => {
295
+ // Nothing rankable to traverse — a Python-only corpus, say. Report the
296
+ // leg as absent rather than as an empty success, so a run file records
297
+ // that topology was deliberately not consulted instead of implying it
298
+ // was consulted and found nothing.
299
+ if (!importEdges.length && !callEdges.length)
300
+ return null;
301
+ const confidentSeeds = seeds.filter((seed) => hasHighConfidenceSeed(seed.page.target_path, lexical.hits, symbolHits));
302
+ // Never let a weak vector neighbour become a graph seed. Returning null
303
+ // is observable as an absent leg, which keeps the answer honest about
304
+ // whether topology was actually consulted.
305
+ if (confidentSeeds.length === 0)
306
+ return null;
307
+ const scores = new Map();
308
+ confidentSeeds.slice(0, GRAPH_SEED_COUNT).forEach((seed, rank) => {
309
+ const seedWeight = 1 / (rank + 1);
310
+ for (const neighbour of neighboursOf(seed.page.target_path)) {
311
+ const connectedness = Math.sqrt(Math.max(1, degree.get(neighbour.path) ?? 1));
312
+ scores.set(neighbour.path, (scores.get(neighbour.path) ?? 0) + (seedWeight * neighbour.weight) / connectedness);
313
+ }
314
+ });
315
+ const hits = [...scores]
316
+ .flatMap(([path, score]) => {
317
+ const page = byPath.get(path);
318
+ return page ? [{ page, score, raw_score: score }] : [];
319
+ })
320
+ .sort((a, b) => b.score - a.score || byCodeUnit(a.page.page_id, b.page.page_id))
321
+ .slice(0, GRAPH_EXPANSION_LIMIT);
322
+ return { name: "graph", status: "ok", hits };
323
+ };
324
+ };
325
+ /**
326
+ * Expand only from files the current query already surfaced. Historical
327
+ * coupling has no query opinion of its own, so it cannot invent a result
328
+ * from a popular file or from a repository-wide hotspot. The co-change
329
+ * index is local and cutoff-bound; only its bounded pair evidence crosses
330
+ * into this in-memory ranking callback.
331
+ */
332
+ const cochangeIndex = retrievalMode === "candidate" ? options.coChangeIndex : undefined;
333
+ const cochangeExpander = cochangeIndex
334
+ ? (seeds) => {
335
+ const scores = new Map();
336
+ seeds.slice(0, COCHANGE_SEED_COUNT).forEach((seed, rank) => {
337
+ const seedWeight = 1 / (rank + 1);
338
+ for (const [relatedRank, related] of cochangeIndex
339
+ .related(seed.page.target_path, COCHANGE_EXPANSION_LIMIT)
340
+ .entries()) {
341
+ if (relatedRank < COCHANGE_MIN_RELATED_RANK)
342
+ continue;
343
+ if (!cochangeIndex.files.has(related.path))
344
+ continue;
345
+ const current = scores.get(related.path) ?? { score: 0, co_change_count: 0 };
346
+ current.score += related.score * seedWeight;
347
+ current.co_change_count += related.co_change_count;
348
+ scores.set(related.path, current);
349
+ }
350
+ });
351
+ const hits = [...scores]
352
+ .flatMap(([path, evidence]) => {
353
+ const page = pagesByPath.get(path);
354
+ return page ? [{ page, score: evidence.score, raw_score: evidence.score }] : [];
355
+ })
356
+ .sort((a, b) => b.score - a.score || byCodeUnit(a.page.page_id, b.page.page_id))
357
+ .slice(0, COCHANGE_EXPANSION_LIMIT);
358
+ return { name: "cochange", status: "ok", hits };
359
+ }
360
+ : undefined;
361
+ const graphArtifacts = options.symbolIndex
362
+ ? { graph: buildDependencyGraph(options.symbolIndex), calls: resolveCallEdges(options.symbolIndex, buildSymbolTable(options.symbolIndex)) }
363
+ : undefined;
364
+ // The index's own answer, not a guess from the file extension. A backend
365
+ // registers the language it parsed a file as, so this stays correct when a
366
+ // new backend is added and cannot drift from what actually produced the edges.
367
+ const graphRankablePaths = new Set((options.symbolIndex?.files ?? [])
368
+ .filter((file) => file.language !== null && GRAPH_RANKING_LANGUAGES.has(file.language))
369
+ .map((file) => file.path));
370
+ const outcomes = new Map();
371
+ const fused = await cascadeRetrieval(lexicalLeg, vectorPages, query, embedder, {
372
+ canonicalPages: pagesByPath,
373
+ // Ablation switch. A corpus measured for the first time has no pre-graph
374
+ // baseline to compare against, and producing one from an older build would
375
+ // confound the comparison with everything else that changed in between —
376
+ // the same reason KEEL_CHUNK exists.
377
+ ...(graphArtifacts && process.env.KEEL_NO_GRAPH !== "1" && process.env.CHARTER_NO_GRAPH !== "1"
378
+ ? { expandFromSeeds: expandFromSeeds(graphArtifacts.graph, graphArtifacts.calls, pagesByPath, graphRankablePaths) }
379
+ : {}),
380
+ ...(cochangeExpander ? { expandFromCoChange: cochangeExpander } : {}),
381
+ limit: candidateLimit,
382
+ ...(queryPlan && queryPlan.semantic_queries.length > 1 ? { vectorQueries: queryPlan.semantic_queries } : {}),
383
+ vectorWeight: options.vectorWeight,
384
+ vectorCache: options.vectorCache,
385
+ symbolLeg,
386
+ // A supplied cache is enough to enable document-vector lookup. The cache
387
+ // keys by embedder and summary identity, so a symbolic index fingerprint is
388
+ // optional for callers that provide only retrieval pages.
389
+ fingerprint: options.symbolIndex?.fingerprint ?? (options.vectorCache ? "retrieval-pages" : undefined),
390
+ onLeg: (leg) => outcomes.set(leg.name, {
391
+ status: leg.status ?? "ok",
392
+ hits: leg.hits.length,
393
+ ...(leg.embedder_id ? { embedder_id: leg.embedder_id } : {}),
394
+ ...(leg.error ? { error: leg.error } : {}),
395
+ }),
396
+ });
397
+ // R9.1: verify the widened pool, rather than asking the checker to invent
398
+ // candidates. Delivery narrows to the evidence-backed subset only when the
399
+ // run produced enough promotions to stand alone; otherwise the full ranking
400
+ // remains intact with its verification caveat.
401
+ const visibleFused = options.excludePaths
402
+ ? fused.filter((hit) => !options.excludePaths.has(hit.page.target_path))
403
+ : fused;
404
+ const verdicts = await verifyFused(visibleFused, symbolHits, options.verify);
405
+ const candidates = visibleFused.map((hit) => {
406
+ const verdict = verdicts.byPath.get(hit.page.target_path);
407
+ const lexicalEvidence = lexical.hits.find((candidate) => candidate.page.page_id === hit.page.page_id);
408
+ const symbolEvidence = symbolHits.filter((candidate) => candidate.path === hit.page.target_path);
409
+ const symbols = symbolEvidence.map((candidate) => candidate.symbol);
410
+ const matchedTerms = [...new Set([
411
+ ...(lexicalEvidence?.matched_terms ?? []),
412
+ ...symbolEvidence.flatMap((candidate) => candidate.matched_terms),
413
+ ])].sort();
414
+ const snippets = evidenceSnippets(hit.page, matchedTerms.length ? matchedTerms : symbols);
415
+ const agreement = hit.agreement ?? 1;
416
+ const confidence = verdict || (agreement >= 2 && symbols.length > 0)
417
+ ? "high"
418
+ : agreement >= 2 || symbols.length > 0 || (hit.leg_ranks.lexical ?? Number.POSITIVE_INFINITY) <= 2
419
+ ? "medium"
420
+ : "low";
421
+ const relatedPaths = graphArtifacts ? relatedPathsFor(hit.page.target_path, options.symbolIndex, graphArtifacts.graph, graphArtifacts.calls) : [];
422
+ const signalKinds = lexicalEvidence?.signal_kinds ?? [];
423
+ return {
424
+ page: hit.page,
425
+ score: hit.score,
426
+ verified: Boolean(verdict),
427
+ ...(verdict ? { evidence: verdict.evidence, relation: verdict.relation } : {}),
428
+ reason: verdict
429
+ // Says which symbol, and on how many call sites — a promotion the reader
430
+ // can check rather than take on trust (R9.2).
431
+ ? `compiler-verified: ${verdict.relation} ${verdict.symbol} (${verdict.evidence.length} call site${verdict.evidence.length === 1 ? "" : "s"})`
432
+ : hit.agreement > 1
433
+ ? `${Object.keys(hit.leg_ranks).sort().join("/")} agreement`
434
+ : `${Object.keys(hit.leg_ranks)[0] ?? "unknown"} retrieval`,
435
+ snippets,
436
+ ...(matchedTerms.length ? { matched_terms: matchedTerms } : {}),
437
+ ...(symbols.length ? { symbols: [...new Set(symbols)].sort() } : {}),
438
+ confidence,
439
+ ...(signalKinds.length ? { signal_kinds: signalKinds } : {}),
440
+ ...(relatedPaths.length ? { related_paths: relatedPaths } : {}),
441
+ // Carried, not recomputed. Dropping these here is what made "which legs
442
+ // ran" unanswerable from an answer — see DeliveryCandidate.leg_ranks.
443
+ leg_ranks: hit.leg_ranks,
444
+ // Carried for the same reason as leg_ranks, and it was missed when they
445
+ // were. The fused score is a function of rank alone — a top hit with
446
+ // three-leg agreement lands near 0.0247 whether the query found exactly
447
+ // the right file or nothing relevant — so no threshold on it can tell a
448
+ // good answer from a hopeless one. Only a raw BM25 or cosine value can,
449
+ // and 41.7% of benchmark instances never reach gold while paying for ten
450
+ // files. Deciding to return none of them needs this field.
451
+ raw_scores: hit.raw_scores,
452
+ agreement: hit.agreement,
453
+ leg_statuses: hit.leg_statuses,
454
+ embedder_ids: hit.embedder_ids,
455
+ };
456
+ });
457
+ const answerCandidates = verdicts.serveVerified
458
+ ? candidates.filter((candidate) => candidate.verified)
459
+ : candidates;
460
+ const answer = answerRetrieval(query, answerCandidates, {
461
+ limit,
462
+ offset,
463
+ excludePaths: options.excludePaths,
464
+ leasedPaths: options.leasedPaths,
465
+ });
466
+ return {
467
+ ...answer,
468
+ leg_outcomes: Object.fromEntries(outcomes),
469
+ ...(verdicts.report ? { verification: verdicts.report } : {}),
470
+ };
471
+ }
472
+ /**
473
+ * Ask the checker which fused candidates genuinely reference the implicated
474
+ * symbols, within the R9.3 budget.
475
+ *
476
+ * Returns no report at all when verification was not requested, so an answer
477
+ * never carries a `verification` field claiming a pass that nobody asked for.
478
+ * When it *was* requested and could not run, or did not produce enough
479
+ * promotions to stand alone, the report says so and every candidate stays
480
+ * unverified — the ranking is returned unchanged, with the caveat attached.
481
+ */
482
+ async function verifyFused(fused, symbolHits, verify) {
483
+ const byPath = new Map();
484
+ if (!verify)
485
+ return { byPath, serveVerified: false };
486
+ if (fused.length === 0 || symbolHits.length === 0) {
487
+ return { byPath, serveVerified: false, report: { ran: false, caveat: "no implicated symbols to verify against", elapsed_ms: 0, promoted: 0 } };
488
+ }
489
+ // Every candidate against every implicated symbol: the checker builds its
490
+ // program once, so the cost is in the parse, not in the pairs.
491
+ const candidates = fused.flatMap((hit) => symbolHits.map((symbol) => ({ path: hit.page.target_path, symbol: symbol.symbol, declared_in: symbol.path })));
492
+ const result = await verifyCandidates(verify.root, candidates, { budgetMs: verify.budgetMs });
493
+ if (!result.verified) {
494
+ return { byPath, serveVerified: false, report: { ran: false, caveat: result.caveat, elapsed_ms: result.elapsed_ms, promoted: 0 } };
495
+ }
496
+ for (const candidate of result.candidates) {
497
+ if (!candidate.verified || !candidate.relation)
498
+ continue;
499
+ const existing = byPath.get(candidate.path);
500
+ // A page can reference several implicated symbols. Keep the best-evidenced
501
+ // one, preferring a reference over a declaration: "this file calls it" is
502
+ // the more useful answer to "where do I edit".
503
+ const better = !existing ||
504
+ (existing.relation !== "references" && candidate.relation === "references") ||
505
+ (existing.relation === candidate.relation && candidate.evidence.length > existing.evidence.length);
506
+ if (better)
507
+ byPath.set(candidate.path, { symbol: candidate.symbol, relation: candidate.relation, evidence: candidate.evidence });
508
+ }
509
+ const promoted = byPath.size;
510
+ if (promoted < MIN_VERIFIED_CANDIDATES) {
511
+ return {
512
+ byPath: new Map(),
513
+ serveVerified: false,
514
+ report: {
515
+ ran: true,
516
+ caveat: `checker promoted only ${promoted} candidate${promoted === 1 ? "" : "s"}; retaining the full unverified ranking`,
517
+ elapsed_ms: result.elapsed_ms,
518
+ promoted,
519
+ },
520
+ };
521
+ }
522
+ return {
523
+ byPath,
524
+ serveVerified: true,
525
+ report: { ran: true, caveat: null, elapsed_ms: result.elapsed_ms, promoted },
526
+ };
527
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The language/parser contract for Keel's local index.
3
+ *
4
+ * Everything here runs on the agent's machine, beside the checkout, per
5
+ * inv_01KZWPAM9QCZ0DD7789N55X647 and ADR 002: the central server routes
6
+ * metadata and never sees code.
7
+ *
8
+ * The design principle, stated once because every type below follows from it:
9
+ *
10
+ * A confident wrong answer is worse than an admitted gap.
11
+ *
12
+ * Keel already has the counter-example in production. `grep-v1` resolves call
13
+ * sites by matching a bare symbol name across the repository, so declaring a
14
+ * TypeScript method named `check` matched a Python comment reading
15
+ * "# side of the ORPHAN check" and sixteen other files in an unrelated tree.
16
+ * It reported 38 call sites with no indication that most were noise, the blast
17
+ * radius came back "wide", and an agent was blocked pending a human decision
18
+ * that existed only because the analyzer could not tell a call from a comment.
19
+ *
20
+ * Nothing here is allowed to do that. Every result carries what produced it
21
+ * and how much of the input it actually understood, and a file nobody can
22
+ * parse is reported as unparsed rather than quietly contributing nothing.
23
+ */
24
+ /**
25
+ * Which parser handles which file.
26
+ *
27
+ * Backends are injected rather than imported. The registry deliberately
28
+ * depends on no parser: tree-sitter's native bindings need a compiler
29
+ * toolchain at install time, and this package is meant to be runnable with
30
+ * `npx` by someone who has never built a native module. A registry that
31
+ * cannot be constructed without node-gyp would push that cost onto every
32
+ * consumer, including the ones who only ever wanted lease coordination. So
33
+ * the packaging decision belongs to whoever assembles the adapter, and this
34
+ * file states the contract they must satisfy.
35
+ */
36
+ export class LanguageRegistry {
37
+ byExtension = new Map();
38
+ parsers = [];
39
+ /** Register a backend. Later registrations win, so a host can override. */
40
+ register(parser) {
41
+ for (const extension of parser.extensions) {
42
+ const normalized = extension.toLowerCase();
43
+ if (!normalized.startsWith(".")) {
44
+ throw new Error(`extension must include the dot: ${JSON.stringify(extension)}`);
45
+ }
46
+ this.byExtension.set(normalized, parser);
47
+ }
48
+ this.parsers.push(parser);
49
+ return this;
50
+ }
51
+ parserFor(path) {
52
+ const dot = path.lastIndexOf(".");
53
+ if (dot < 0)
54
+ return undefined;
55
+ return this.byExtension.get(path.slice(dot).toLowerCase());
56
+ }
57
+ supports(path) {
58
+ return this.parserFor(path) !== undefined;
59
+ }
60
+ /**
61
+ * A stable description of the registered backends, for the fingerprint.
62
+ * Sorted, because registration order is a property of the host's wiring and
63
+ * not of the index it produces.
64
+ */
65
+ signature() {
66
+ return [...new Set(this.parsers.map((p) => `${p.id}@${p.version}`))].sort().join(",");
67
+ }
68
+ /**
69
+ * Parse one file, converting any thrown error into a `failed` result.
70
+ *
71
+ * Isolation is the point. One unparseable file in a repository of thousands
72
+ * must cost exactly that file, and a backend that throws on malformed input
73
+ * — which every real parser eventually does — must not be able to abort an
74
+ * index build halfway and leave the caller with a partial answer it believes
75
+ * is complete.
76
+ */
77
+ parse(path, source) {
78
+ const parser = this.parserFor(path);
79
+ if (!parser) {
80
+ return {
81
+ status: "unsupported",
82
+ imports: [],
83
+ exports: [],
84
+ diagnostics: [
85
+ { code: "unsupported", message: `no parser registered for ${path.slice(path.lastIndexOf("."))}` },
86
+ ],
87
+ };
88
+ }
89
+ try {
90
+ return parser.parse(source, path);
91
+ }
92
+ catch (error) {
93
+ return {
94
+ status: "failed",
95
+ imports: [],
96
+ exports: [],
97
+ diagnostics: [
98
+ {
99
+ code: "parser-threw",
100
+ message: `${parser.id} threw: ${error instanceof Error ? error.message : String(error)}`,
101
+ },
102
+ ],
103
+ };
104
+ }
105
+ }
106
+ }