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.
- package/README.md +86 -0
- package/dist/lib/agent-state.js +119 -0
- package/dist/lib/blast.js +462 -0
- package/dist/lib/client-config.js +81 -0
- package/dist/lib/env-compat.js +66 -0
- package/dist/lib/globs.js +0 -0
- package/dist/lib/ids.js +24 -0
- package/dist/lib/index/aliases.js +244 -0
- package/dist/lib/index/call-sites.js +178 -0
- package/dist/lib/index/checker-resolver.js +257 -0
- package/dist/lib/index/context-card.js +140 -0
- package/dist/lib/index/coverage.js +218 -0
- package/dist/lib/index/delivery.js +66 -0
- package/dist/lib/index/discovery.js +90 -0
- package/dist/lib/index/embedding.js +110 -0
- package/dist/lib/index/file-index.js +222 -0
- package/dist/lib/index/fingerprint.js +0 -0
- package/dist/lib/index/git-history.js +136 -0
- package/dist/lib/index/graph.js +234 -0
- package/dist/lib/index/impact.js +174 -0
- package/dist/lib/index/incremental.js +332 -0
- package/dist/lib/index/lexical.js +462 -0
- package/dist/lib/index/order.js +43 -0
- package/dist/lib/index/pages.js +357 -0
- package/dist/lib/index/persistence.js +233 -0
- package/dist/lib/index/pipeline.js +527 -0
- package/dist/lib/index/registry.js +106 -0
- package/dist/lib/index/resolve.js +280 -0
- package/dist/lib/index/semantic.js +381 -0
- package/dist/lib/index/surfaces.js +27 -0
- package/dist/lib/index/symbols.js +426 -0
- package/dist/lib/index/transformers-embedder.js +73 -0
- package/dist/lib/index/typescript-parser.js +532 -0
- package/dist/lib/index/vector-cache.js +176 -0
- package/dist/lib/index/verification.js +58 -0
- package/dist/lib/mcp-compaction.js +241 -0
- package/dist/lib/model-roles.js +206 -0
- package/dist/lib/path-warnings.js +90 -0
- package/dist/lib/protocol.js +95 -0
- package/dist/lib/types.js +69 -0
- package/dist/lib/version.js +21 -0
- package/dist/lib/worktree.js +211 -0
- package/dist/mcp/approval.js +0 -0
- package/dist/mcp/cloud-connector.js +99 -0
- package/dist/mcp/daemon-client.js +156 -0
- package/dist/mcp/daemon-protocol.js +100 -0
- package/dist/mcp/escalation-waiter.js +183 -0
- package/dist/mcp/graph-ops.js +169 -0
- package/dist/mcp/index.js +1151 -0
- package/dist/mcp/login.js +169 -0
- package/dist/mcp/setup.js +90 -0
- 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
|
+
}
|