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,280 @@
1
+ /**
2
+ * Turning an import specifier into a file in this repository, or admitting it
3
+ * cannot be done.
4
+ *
5
+ * Resolution is where a dependency graph either earns trust or quietly loses
6
+ * it. Every specifier that cannot be resolved is a fork: guess, and the graph
7
+ * grows an edge that may not exist; drop it silently, and the graph claims a
8
+ * completeness it does not have. Both produce a graph that looks finished.
9
+ *
10
+ * So there is no third option here — every specifier ends up either resolved
11
+ * to a real indexed path or recorded as unresolved with a reason. `external`
12
+ * is not a failure and is tracked separately: a bare "node:fs" or "@scope/pkg"
13
+ * is correctly outside the repository, and filing it next to genuine
14
+ * resolution failures would bury the ones worth looking at.
15
+ */
16
+ import { ruleGoverns } from "./aliases.js";
17
+ /**
18
+ * Extension candidates, in the order TypeScript itself would try them.
19
+ *
20
+ * `.js` first is deliberate and counter-intuitive. This codebase is ESM
21
+ * TypeScript, where `import "./db.js"` refers to `db.ts` on disk — the
22
+ * specifier names the emitted file, not the source. A resolver that tried
23
+ * `.ts` first would still land correctly here, but one that does not
24
+ * understand the rewrite at all resolves nothing in the entire repository.
25
+ */
26
+ const EXTENSION_CANDIDATES = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
27
+ const INDEX_CANDIDATES = EXTENSION_CANDIDATES.map((extension) => `index${extension}`);
28
+ /**
29
+ * Python candidates, kept separate and applied only to Python importers.
30
+ *
31
+ * Not merged into the lists above, because a differential oracle holds this
32
+ * resolver to the TypeScript compiler's own answers: we may resolve fewer
33
+ * specifiers than `tsc`, never a different file. A global `.py` candidate could
34
+ * bind a TypeScript `./foo` to `foo.py` where the compiler binds nothing, which
35
+ * is precisely the class of invented edge that gate exists to catch.
36
+ */
37
+ const PYTHON_EXTENSIONS = [".py", ".pyi"];
38
+ const PYTHON_INDEX_CANDIDATES = ["__init__.py", "__init__.pyi"];
39
+ const isPython = (path) => PYTHON_EXTENSIONS.some((extension) => path.endsWith(extension));
40
+ /**
41
+ * Directories an absolute Python import is resolved against.
42
+ *
43
+ * `import pydantic_core.core_schema` names a module, not a path: it is looked
44
+ * up from whichever directory is on the import path, which for a repository is
45
+ * the parent of the outermost package. A package is a directory holding
46
+ * `__init__.py`, so walking up from each one until the chain breaks gives the
47
+ * roots — `pydantic-core/python/pydantic_core/__init__.py` yields the root
48
+ * `pydantic-core/python`.
49
+ *
50
+ * Cached against the identity of the `known` set. It is the same set for every
51
+ * specifier in an index build, and rebuilding it per call would turn resolution
52
+ * into a repeated full scan of the repository.
53
+ */
54
+ const importRootCache = new WeakMap();
55
+ function pythonImportRoots(known) {
56
+ const cached = importRootCache.get(known);
57
+ if (cached)
58
+ return cached;
59
+ const packages = new Set();
60
+ for (const path of known) {
61
+ for (const index of PYTHON_INDEX_CANDIDATES) {
62
+ if (path.endsWith(`/${index}`))
63
+ packages.add(path.slice(0, path.length - index.length - 1));
64
+ }
65
+ }
66
+ // The repository root is always a candidate: a flat script layout has no
67
+ // packages at all and still imports its siblings by bare name.
68
+ const roots = new Set([""]);
69
+ for (const directory of packages) {
70
+ let current = directory;
71
+ for (;;) {
72
+ const parent = current.includes("/") ? current.slice(0, current.lastIndexOf("/")) : "";
73
+ if (parent && packages.has(parent)) {
74
+ current = parent;
75
+ continue;
76
+ }
77
+ roots.add(parent);
78
+ break;
79
+ }
80
+ }
81
+ const list = [...roots];
82
+ importRootCache.set(known, list);
83
+ return list;
84
+ }
85
+ /**
86
+ * Try the alias rules that govern `fromPath`, longest prefix first.
87
+ *
88
+ * Returns undefined when no rule matches — the caller then treats the
89
+ * specifier as external, which is the right answer for a real package.
90
+ *
91
+ * Returns `unresolved` in two cases worth distinguishing. A rule matched but
92
+ * no candidate exists on disk: the alias is configured and the target is
93
+ * missing, which is a broken import rather than a package. And a rule matched
94
+ * with SEVERAL candidates present: TypeScript would pick by its own ordering
95
+ * rules, and picking here would be a guess dressed as a fact. Naming both is
96
+ * more useful than choosing one, because the ambiguity is the finding.
97
+ */
98
+ function resolveAlias(specifier, fromPath, known, aliases) {
99
+ // Rules arrive sorted longest-prefix-first. Among rules that match, prefer
100
+ // the one whose scope is deepest: web/tsconfig.json governs web/ more
101
+ // specifically than a root config does.
102
+ const matching = aliases
103
+ .filter((rule) => ruleGoverns(rule, fromPath))
104
+ .filter((rule) => rule.wildcard ? specifier.startsWith(rule.prefix) : specifier === rule.prefix.replace(/\/$/, ""));
105
+ if (matching.length === 0)
106
+ return undefined;
107
+ const best = matching.reduce((a, b) => b.scope.length > a.scope.length || (b.scope.length === a.scope.length && b.prefix.length > a.prefix.length)
108
+ ? b
109
+ : a);
110
+ const suffix = best.wildcard ? specifier.slice(best.prefix.length) : "";
111
+ const hits = [];
112
+ for (const target of best.targets) {
113
+ const base = normalize(suffix ? `${target}/${suffix}` : target);
114
+ const hit = firstExisting(base, known);
115
+ if (hit && !hits.includes(hit))
116
+ hits.push(hit);
117
+ }
118
+ if (hits.length === 1)
119
+ return { kind: "internal", path: hits[0], via: "alias" };
120
+ if (hits.length > 1) {
121
+ return {
122
+ kind: "unresolved",
123
+ reason: `"${specifier}" is ambiguous under ${best.source}: matches ${hits.join(" and ")}`,
124
+ };
125
+ }
126
+ return {
127
+ kind: "unresolved",
128
+ reason: `"${specifier}" matches an alias in ${best.source} but no indexed file exists at ${best.targets.join(" or ")}`,
129
+ };
130
+ }
131
+ /** The first indexed file for a base path, trying it bare then by extension. */
132
+ function firstExisting(base, known) {
133
+ if (known.has(base))
134
+ return base;
135
+ const dot = base.lastIndexOf(".");
136
+ const slash = base.lastIndexOf("/");
137
+ if (dot > slash) {
138
+ const stem = base.slice(0, dot);
139
+ for (const extension of EXTENSION_CANDIDATES) {
140
+ if (known.has(`${stem}${extension}`))
141
+ return `${stem}${extension}`;
142
+ }
143
+ }
144
+ for (const extension of EXTENSION_CANDIDATES) {
145
+ if (known.has(`${base}${extension}`))
146
+ return `${base}${extension}`;
147
+ }
148
+ for (const index of INDEX_CANDIDATES) {
149
+ if (known.has(`${base}/${index}`))
150
+ return `${base}/${index}`;
151
+ }
152
+ return null;
153
+ }
154
+ /** POSIX-normalise "a/./b" and "a/b/../c" without touching the filesystem. */
155
+ function normalize(path) {
156
+ const parts = [];
157
+ for (const segment of path.split("/")) {
158
+ if (segment === "" || segment === ".")
159
+ continue;
160
+ if (segment === "..") {
161
+ if (parts.length === 0)
162
+ return path; // escapes the root; leave it alone
163
+ parts.pop();
164
+ continue;
165
+ }
166
+ parts.push(segment);
167
+ }
168
+ return parts.join("/");
169
+ }
170
+ function dirnameOf(path) {
171
+ const slash = path.lastIndexOf("/");
172
+ return slash < 0 ? "" : path.slice(0, slash);
173
+ }
174
+ /**
175
+ * Bind a dotted Python module name to a file.
176
+ *
177
+ * `a.b.c` is tried against every import root as `a/b/c.py` and then as the
178
+ * package `a/b/c/__init__.py`. If neither exists the last segment may be a
179
+ * symbol rather than a module — `from a.b import c` where `c` is a class in
180
+ * `a/b.py` — so the trailing segment is dropped and the parent retried.
181
+ *
182
+ * Returns undefined rather than `unresolved` when nothing matches, because at
183
+ * that point "external" is the honest answer: `import logging` looks exactly
184
+ * like `import mypackage` and only the file listing can tell them apart.
185
+ */
186
+ export function resolvePythonModule(specifier, known, options = {}) {
187
+ const segments = specifier.split(".").filter(Boolean);
188
+ if (!segments.length)
189
+ return undefined;
190
+ const roots = pythonImportRoots(known);
191
+ // Full path first, then the parent module — a longer match is more specific
192
+ // and should win over treating the tail as a symbol.
193
+ const attempts = options.parentFallback === false ? [segments] : [segments, segments.slice(0, -1)];
194
+ for (const parts of attempts) {
195
+ if (!parts.length)
196
+ continue;
197
+ const relative = parts.join("/");
198
+ for (const root of roots) {
199
+ const base = root ? `${root}/${relative}` : relative;
200
+ for (const extension of PYTHON_EXTENSIONS) {
201
+ const candidate = `${base}${extension}`;
202
+ if (known.has(candidate))
203
+ return { kind: "internal", path: candidate, via: "extension" };
204
+ }
205
+ for (const index of PYTHON_INDEX_CANDIDATES) {
206
+ const candidate = `${base}/${index}`;
207
+ if (known.has(candidate))
208
+ return { kind: "internal", path: candidate, via: "directory-index" };
209
+ }
210
+ }
211
+ }
212
+ return undefined;
213
+ }
214
+ /**
215
+ * Resolve one specifier as written in `fromPath`.
216
+ *
217
+ * `known` is the set of indexed repo-root-relative paths — the resolver only
218
+ * ever claims an edge to a file the index actually saw. That is what keeps the
219
+ * graph closed: an edge can never point somewhere the index cannot describe.
220
+ */
221
+ export function resolveSpecifier(specifier, fromPath, known, aliases = []) {
222
+ const trimmed = specifier.trim();
223
+ if (!trimmed)
224
+ return { kind: "unresolved", reason: "empty specifier" };
225
+ // A bare specifier is usually a package — but not if a config renamed a
226
+ // local directory to look like one. Aliases are checked before concluding
227
+ // "external", because concluding it wrongly deletes every internal edge in
228
+ // an application that uses them.
229
+ if (!trimmed.startsWith(".") && !trimmed.startsWith("/")) {
230
+ const aliased = resolveAlias(trimmed, fromPath, known, aliases);
231
+ if (aliased)
232
+ return aliased;
233
+ // `import a.b.c` is a module path, not a package name, so a Python importer
234
+ // gets one more chance before this is filed as external. Without it every
235
+ // absolute import in a Python repository is discarded: pydantic declares
236
+ // 2,652 imports and this resolver bound none of them.
237
+ if (isPython(fromPath)) {
238
+ const dotted = resolvePythonModule(trimmed, known);
239
+ if (dotted)
240
+ return dotted;
241
+ }
242
+ return { kind: "external", module: trimmed };
243
+ }
244
+ const base = trimmed.startsWith("/")
245
+ ? normalize(trimmed.slice(1))
246
+ : normalize(`${dirnameOf(fromPath)}/${trimmed}`);
247
+ if (known.has(base))
248
+ return { kind: "internal", path: base, via: "exact" };
249
+ // "./db.js" naming db.ts: strip the emitted extension and try the sources.
250
+ const dot = base.lastIndexOf(".");
251
+ const slash = base.lastIndexOf("/");
252
+ if (dot > slash) {
253
+ const stem = base.slice(0, dot);
254
+ for (const extension of EXTENSION_CANDIDATES) {
255
+ const candidate = `${stem}${extension}`;
256
+ if (known.has(candidate))
257
+ return { kind: "internal", path: candidate, via: "extension" };
258
+ }
259
+ }
260
+ // The Python parser normalises `from . import x` and `from ..pkg import y`
261
+ // into path form (`./x`, `../pkg`), so relative Python imports arrive here
262
+ // already looking like paths. They failed for one reason: `.py` was not a
263
+ // candidate extension and `__init__.py` was not a directory index.
264
+ const extensions = isPython(fromPath) ? [...EXTENSION_CANDIDATES, ...PYTHON_EXTENSIONS] : EXTENSION_CANDIDATES;
265
+ const indexes = isPython(fromPath) ? [...INDEX_CANDIDATES, ...PYTHON_INDEX_CANDIDATES] : INDEX_CANDIDATES;
266
+ for (const extension of extensions) {
267
+ const candidate = `${base}${extension}`;
268
+ if (known.has(candidate))
269
+ return { kind: "internal", path: candidate, via: "extension" };
270
+ }
271
+ for (const index of indexes) {
272
+ const candidate = `${base}/${index}`;
273
+ if (known.has(candidate))
274
+ return { kind: "internal", path: candidate, via: "directory-index" };
275
+ }
276
+ // A relative specifier that matches nothing indexed. Could be a file the
277
+ // index skipped, a path alias this resolver does not implement, or a broken
278
+ // import. All three are worth surfacing and none are worth guessing at.
279
+ return { kind: "unresolved", reason: `no indexed file matches "${specifier}" from ${fromPath}` };
280
+ }
@@ -0,0 +1,381 @@
1
+ import { byCodeUnit } from "./order.js";
2
+ import { defaultVectorCache, documentText } from "./vector-cache.js";
3
+ const rrfK = 60;
4
+ const symbolRrfK = 180;
5
+ /**
6
+ * Small on purpose, and it is the constant that decides whether topology can
7
+ * rescue anything.
8
+ *
9
+ * At k=240 and weight 0.5 a graph-only file scored 0.5/240 = 0.0021 while a
10
+ * lexical hit sitting at rank 50 scored 1/110 = 0.0091 — four times higher. No
11
+ * file could enter the answer on topology alone however well connected it was
12
+ * to the seeds, so the leg could only re-order files the query had already
13
+ * found. Measured on Playwright: 302 graph-ranked results, none of them
14
+ * introduced by the graph, and a recall effect of +0.006 that came entirely
15
+ * from reshuffling.
16
+ *
17
+ * The obvious fix — raise the weight, lower k to ~80 — is wrong for a subtler
18
+ * reason. RRF flattens as k grows: at k=80 all twenty expansion hits land
19
+ * between 0.0125 and 0.0101, a 24% spread, so the whole expansion arrives at
20
+ * the serve threshold together. That floods rather than rescues, and the
21
+ * degree ranking still decides nothing.
22
+ *
23
+ * A small k makes the leg's own ordering matter. At k=6, weight 0.075:
24
+ *
25
+ * rank 0 0.0125 clears a rank-50 lexical hit (0.0091) — can rescue
26
+ * rank 2 0.0094 marginal
27
+ * rank 5 0.0068 below the threshold
28
+ * rank 19 0.0030 irrelevant, but still contributes on agreement
29
+ *
30
+ * So roughly the top two neighbours can enter on topology alone, and which two
31
+ * is decided by seed rank, edge kind and inverse degree. That is what puts the
32
+ * hub defence under load: if `1/sqrt(degree)` is too shallow, a utils barrel
33
+ * takes those slots on every query and the failure is immediate and visible.
34
+ */
35
+ const graphRrfK = 6;
36
+ /** Co-change has a query-conditioned ranking, so its top neighbours may rescue a weak lexical hit. */
37
+ const cochangeRrfK = 6;
38
+ /** Conservative default from the sealed retrieval comparison: vector evidence is useful for recall, but should not outrank concrete lexical evidence by itself. */
39
+ export const DEFAULT_VECTOR_WEIGHT = 0.65;
40
+ /**
41
+ * Scaled against `graphRrfK` so a top neighbour lands just above a weak lexical
42
+ * hit and the fifth lands well below it. See the note there — the pair is one
43
+ * calibration, not two knobs.
44
+ */
45
+ export const DEFAULT_GRAPH_WEIGHT = 0.075;
46
+ /** Conservative starting weight; the benchmark must earn making this default-on. */
47
+ export const DEFAULT_COCHANGE_WEIGHT = 0.075;
48
+ const cosine = (a, b) => {
49
+ let dot = 0, aa = 0, bb = 0;
50
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
51
+ dot += a[i] * b[i];
52
+ aa += a[i] ** 2;
53
+ bb += b[i] ** 2;
54
+ }
55
+ return aa && bb ? dot / Math.sqrt(aa * bb) : 0;
56
+ };
57
+ /** Fuse independent ranked legs without pretending their raw scores share a scale. */
58
+ export function fuseRetrieval(legs, limit = 10, weights = {}) {
59
+ const byId = new Map();
60
+ for (const leg of legs) {
61
+ const legK = leg.name === "symbol" ? symbolRrfK : leg.name === "graph" ? graphRrfK : leg.name === "cochange" ? cochangeRrfK : rrfK;
62
+ const weight = weights[leg.name]
63
+ ?? (leg.name === "vector" ? DEFAULT_VECTOR_WEIGHT
64
+ : leg.name === "graph" ? DEFAULT_GRAPH_WEIGHT
65
+ : leg.name === "cochange" ? DEFAULT_COCHANGE_WEIGHT
66
+ : 1);
67
+ leg.hits.forEach((hit, rank) => {
68
+ const current = byId.get(hit.page.page_id) ?? {
69
+ page: hit.page, score: 0, agreement: 0, leg_ranks: {}, raw_scores: {}, leg_statuses: {}, embedder_ids: {},
70
+ };
71
+ current.score += weight / (rank + legK);
72
+ current.agreement += 1;
73
+ current.leg_ranks[leg.name] = rank;
74
+ current.raw_scores[leg.name] = hit.raw_score ?? hit.score;
75
+ if (leg.status)
76
+ current.leg_statuses[leg.name] = leg.status;
77
+ if (leg.embedder_id)
78
+ current.embedder_ids[leg.name] = leg.embedder_id;
79
+ byId.set(hit.page.page_id, current);
80
+ });
81
+ }
82
+ return [...byId.values()].sort((a, b) => b.score - a.score || b.agreement - a.agreement || byCodeUnit(a.page.page_id, b.page.page_id)).slice(0, limit);
83
+ }
84
+ /**
85
+ * The raw-score floor for the vector leg (R4.5).
86
+ *
87
+ * A cosine leg always returns a ranked list, even for a query about something
88
+ * the corpus does not contain — the nearest neighbours of an off-topic
89
+ * question are simply the least-unrelated pages, and they enter fusion at
90
+ * rank 0 looking exactly like a confident hit. R4.5 is explicit that the floor
91
+ * must apply to the RAW retriever score, because a fused RRF score sits above
92
+ * any floor for every hit by construction.
93
+ *
94
+ * Set where an unrelated pair lands rather than where a good match does: with
95
+ * a signed-hash embedder unrelated documents cluster near zero, so this admits
96
+ * weak-but-real overlap and rejects noise. It is a judgement call, not a
97
+ * fitted value, and it is the number to revisit first if the vector leg turns
98
+ * out to contribute nothing.
99
+ */
100
+ const DEFAULT_MIN_RAW_SCORE = 0.12;
101
+ export async function vectorSearch(pages, query, embedder, limit = 10, timeoutMs = 250, minRawScore = DEFAULT_MIN_RAW_SCORE, cacheContext) {
102
+ try {
103
+ const queryVector = await Promise.race([
104
+ (embedder.embedQuery ?? embedder.embed).call(embedder, query),
105
+ new Promise((_, reject) => setTimeout(() => reject(new Error("embedding timeout")), timeoutMs)),
106
+ ]);
107
+ const documentVectors = cacheContext
108
+ ? await (cacheContext.cache ?? defaultVectorCache).documentVectors(cacheContext.fingerprint, pages, embedder)
109
+ : null;
110
+ const embedDocument = embedder.embedDocument ?? embedder.embed;
111
+ const hits = await Promise.all(pages.map(async (page) => ({
112
+ page,
113
+ score: cosine(queryVector, documentVectors?.get(page.page_id) ?? await embedDocument.call(embedder, documentText(page))),
114
+ })));
115
+ return {
116
+ name: "vector",
117
+ status: hits.length ? (embedder.id ? "ok" : "keyless") : (embedder.id ? "ok" : "keyless"),
118
+ ...(embedder.id ? { embedder_id: embedder.id } : {}),
119
+ hits: hits
120
+ // Rank first, then floor. Filtering before the sort would be
121
+ // equivalent here, but R4.5 says rank position for RRF is unaffected
122
+ // by the floor, and keeping the order in that sentence makes the
123
+ // property obvious rather than incidental.
124
+ .sort((a, b) => b.score - a.score || byCodeUnit(a.page.page_id, b.page.page_id))
125
+ .filter((hit) => hit.score >= minRawScore)
126
+ .slice(0, limit),
127
+ };
128
+ }
129
+ catch (error) {
130
+ const timeout = error instanceof Error && error.message === "embedding timeout";
131
+ return {
132
+ name: "vector",
133
+ status: timeout ? "timeout" : "error",
134
+ ...(embedder.id ? { embedder_id: embedder.id } : {}),
135
+ error: timeout ? "embedding timeout" : "embedding failed",
136
+ hits: [],
137
+ };
138
+ }
139
+ }
140
+ /**
141
+ * Run a small routed query set against one document-vector pass.
142
+ *
143
+ * The query variants are deliberately fused inside the vector leg. Treating
144
+ * each variant as a separate top-level leg would over-count agreement and let
145
+ * a three-variant query outweigh lexical evidence merely because it had more
146
+ * strings. Averaging rank-reciprocal scores keeps this leg on the same scale
147
+ * as the single-query vector baseline, while the maximum raw cosine remains
148
+ * available for floors and diagnostics.
149
+ */
150
+ export async function vectorSearchMulti(pages, queries, embedder, limit = 10, timeoutMs = 250, minRawScore = DEFAULT_MIN_RAW_SCORE, cacheContext) {
151
+ const variants = [...new Set(queries.map((query) => query.trim()).filter(Boolean))].slice(0, 4);
152
+ if (variants.length <= 1) {
153
+ return vectorSearch(pages, variants[0] ?? "", embedder, limit, timeoutMs, minRawScore, cacheContext);
154
+ }
155
+ let documentVectors = null;
156
+ try {
157
+ documentVectors = cacheContext
158
+ ? await (cacheContext.cache ?? defaultVectorCache).documentVectors(cacheContext.fingerprint, pages, embedder)
159
+ : null;
160
+ const embedDocument = embedder.embedDocument ?? embedder.embed;
161
+ const uncachedDocuments = documentVectors
162
+ ? null
163
+ : await Promise.all(pages.map((page) => embedDocument.call(embedder, documentText(page))));
164
+ const queryResults = await Promise.all(variants.map(async (query) => {
165
+ try {
166
+ const queryVector = await Promise.race([
167
+ (embedder.embedQuery ?? embedder.embed).call(embedder, query),
168
+ new Promise((_, reject) => setTimeout(() => reject(new Error("embedding timeout")), timeoutMs)),
169
+ ]);
170
+ const hits = await Promise.all(pages.map(async (page, index) => ({
171
+ page,
172
+ score: cosine(queryVector, documentVectors?.get(page.page_id) ?? uncachedDocuments[index]),
173
+ })));
174
+ return {
175
+ status: "ok",
176
+ hits: hits
177
+ .sort((a, b) => b.score - a.score || byCodeUnit(a.page.page_id, b.page.page_id))
178
+ .filter((hit) => hit.score >= minRawScore)
179
+ .slice(0, limit),
180
+ };
181
+ }
182
+ catch (error) {
183
+ const timeout = error instanceof Error && error.message === "embedding timeout";
184
+ return { status: timeout ? "timeout" : "error", hits: [] };
185
+ }
186
+ }));
187
+ const successful = queryResults.filter((result) => result.status === "ok");
188
+ if (!successful.length) {
189
+ const status = queryResults.some((result) => result.status === "timeout") ? "timeout" : "error";
190
+ return {
191
+ name: "vector",
192
+ status,
193
+ ...(embedder.id ? { embedder_id: embedder.id } : {}),
194
+ error: status === "timeout" ? "embedding timeout" : "embedding failed",
195
+ hits: [],
196
+ };
197
+ }
198
+ const byPage = new Map();
199
+ for (const result of successful) {
200
+ result.hits.forEach((hit, rank) => {
201
+ const current = byPage.get(hit.page.page_id) ?? { page: hit.page, score: 0, raw_score: hit.score };
202
+ current.score += 1 / (variants.length * (rank + rrfK));
203
+ current.raw_score = Math.max(current.raw_score, hit.score);
204
+ byPage.set(hit.page.page_id, current);
205
+ });
206
+ }
207
+ return {
208
+ name: "vector",
209
+ status: embedder.id ? "ok" : "keyless",
210
+ ...(embedder.id ? { embedder_id: embedder.id } : {}),
211
+ hits: [...byPage.values()]
212
+ .sort((a, b) => b.score - a.score || byCodeUnit(a.page.page_id, b.page.page_id))
213
+ .slice(0, limit),
214
+ };
215
+ }
216
+ catch (error) {
217
+ return {
218
+ name: "vector",
219
+ status: "error",
220
+ ...(embedder.id ? { embedder_id: embedder.id } : {}),
221
+ error: "embedding failed",
222
+ hits: [],
223
+ };
224
+ }
225
+ }
226
+ /** How far past `limit` to look when chunk hits will collapse into fewer files. */
227
+ const CHUNK_OVERFETCH = 4;
228
+ /**
229
+ * Reduce chunk hits to one hit per file, keeping each file's best chunk.
230
+ *
231
+ * Max-passage aggregation: the vector leg scores spans, but everything after
232
+ * it — fusion, delivery, the gold set — speaks in files. Collapsing here keeps
233
+ * that boundary in one place, so a chunk id can never escape into a response
234
+ * or be compared against file-level gold.
235
+ *
236
+ * A file's score is its best chunk rather than its mean: a long file with one
237
+ * highly relevant function is a good answer, and averaging would punish it for
238
+ * the other twenty functions that are irrelevant to this query.
239
+ */
240
+ function collapseToFiles(leg, canonical, limit) {
241
+ if (!leg.hits.some((hit) => hit.page.page_type === "chunk"))
242
+ return leg;
243
+ const scoresByPath = new Map();
244
+ for (const hit of leg.hits) {
245
+ const list = scoresByPath.get(hit.page.target_path) ?? [];
246
+ list.push(hit.score);
247
+ scoresByPath.set(hit.page.target_path, list);
248
+ }
249
+ const best = new Map();
250
+ for (const hit of leg.hits) {
251
+ const path = hit.page.target_path;
252
+ const scores = scoresByPath.get(path) ?? [hit.score];
253
+ // Correct the lottery-ticket effect. A file split into many chunks gets
254
+ // many independent draws at a high score, and the expected maximum of N
255
+ // draws rises with N — so taking the raw max ranked files by how large
256
+ // they were, not how relevant. Measured: files opened before reaching gold
257
+ // were unchanged in number but roughly twice the size.
258
+ //
259
+ // The correction is the Gumbel form for the expected maximum of N samples,
260
+ // scaled by this file's own spread, so there is no constant to fit: one
261
+ // chunk gets no correction at all, and a file only keeps a high rank if its
262
+ // best chunk beats what its own variance would produce by chance.
263
+ const mean = scores.reduce((sum, value) => sum + value, 0) / scores.length;
264
+ const variance = scores.reduce((sum, value) => sum + (value - mean) ** 2, 0) / scores.length;
265
+ const expectedMax = Math.sqrt(2 * Math.log(Math.max(scores.length, 1))) * Math.sqrt(variance);
266
+ const adjusted = hit.score - expectedMax;
267
+ const existing = best.get(path);
268
+ if (existing && existing.score >= adjusted)
269
+ continue;
270
+ // Rank as the file, but remember which chunk won. Delivery needs the span
271
+ // to hand back a fragment instead of the whole file, and dropping it here
272
+ // is what made the chunker's precision unrecoverable downstream.
273
+ const file = canonical?.get(path) ?? hit.page;
274
+ best.set(path, {
275
+ // The chunk's own text travels with its bounds. Taking the file page's
276
+ // body here would deliver the whole file under a span label, which is the
277
+ // exact cost the span exists to avoid.
278
+ page: hit.page.span ? { ...file, span: hit.page.span, body: hit.page.body } : file,
279
+ score: adjusted,
280
+ });
281
+ }
282
+ return {
283
+ ...leg,
284
+ hits: [...best.values()]
285
+ .sort((a, b) => b.score - a.score || byCodeUnit(a.page.page_id, b.page.page_id))
286
+ .slice(0, limit),
287
+ };
288
+ }
289
+ /**
290
+ * Run every available leg and fuse them by rank.
291
+ *
292
+ * This was a lexical-first cascade (R4.7), skipping the vector leg when the
293
+ * lexical leg looked confident. That requirement is withdrawn: the gate stopped
294
+ * firing once pages carried file text, and the leg it declined to run is worth
295
+ * more recall than the latency it saved. See the note at the removal site.
296
+ */
297
+ export async function cascadeRetrieval(lexical, pages, query, embedder, options = {}) {
298
+ const limit = options.limit ?? 10;
299
+ const extra = options.symbolLeg ? [options.symbolLeg] : [];
300
+ options.onLeg?.(lexical);
301
+ for (const leg of extra)
302
+ options.onLeg?.(leg);
303
+ if (!embedder) {
304
+ // Still expand. The graph leg does not need an embedder — it needs seeds,
305
+ // and the lexical and symbol legs have already produced them. Returning
306
+ // here skipped stage two entirely whenever vectors were off.
307
+ options.onLeg?.({ name: "vector", status: "absent", hits: [] });
308
+ const noVector = { symbol: options.symbolWeight, graph: options.graphWeight, cochange: options.cochangeWeight };
309
+ return refuseWithGraph([lexical, ...extra], fuseRetrieval([lexical, ...extra], limit, noVector), limit, options, noVector);
310
+ }
311
+ // R4.7's confidence gate used to skip the vector leg when lexical looked sure
312
+ // of itself. It is gone, for two reasons.
313
+ //
314
+ // It stopped firing. Confidence was `1 - second/top`, needing a 4:1 gap
315
+ // between the top two BM25 scores. That was reachable when a page was a
316
+ // structural card of a few dozen tokens; once pages carried the file's own
317
+ // text every page gained matchable content, scores compressed, and the gate
318
+ // went silent — 389 of 389 benchmark instances ran the vector leg, across
319
+ // four corpora, without a single skip.
320
+ //
321
+ // And skipping was the wrong trade anyway. The vector leg is worth +0.0574
322
+ // recall on holdout with a CI clearing zero, so declining to run it bought
323
+ // latency at the cost of accuracy. Recalibrating the threshold would have
324
+ // restored a saving nobody should want.
325
+ // Chunks are scored, files are ranked. Ask for more than `limit` because
326
+ // several chunks of one file can occupy the head of the list and collapse
327
+ // into a single hit; taking `limit` first would silently return fewer files
328
+ // than requested for exactly the files the leg is most confident about.
329
+ const chunked = pages.some((page) => page.page_type === "chunk");
330
+ const vector = collapseToFiles(await (options.vectorQueries?.length
331
+ ? vectorSearchMulti(pages, options.vectorQueries, embedder, chunked ? limit * CHUNK_OVERFETCH : limit, 250, DEFAULT_MIN_RAW_SCORE, options.fingerprint ? { fingerprint: options.fingerprint, cache: options.vectorCache } : undefined)
332
+ : vectorSearch(pages, query, embedder, chunked ? limit * CHUNK_OVERFETCH : limit, 250, DEFAULT_MIN_RAW_SCORE, options.fingerprint ? { fingerprint: options.fingerprint, cache: options.vectorCache } : undefined)), options.canonicalPages, limit);
333
+ options.onLeg?.(vector);
334
+ const weights = { vector: options.vectorWeight, symbol: options.symbolWeight, graph: options.graphWeight, cochange: options.cochangeWeight };
335
+ const seeded = fuseRetrieval([lexical, vector, ...extra], limit, weights);
336
+ return refuseWithGraph([lexical, vector, ...extra], seeded, limit, options, weights);
337
+ }
338
+ /**
339
+ * Stage two of two: expand the ranking along compiler-resolved edges and into
340
+ * the members of the directories it matched, then fuse those expansions back in
341
+ * as further legs.
342
+ *
343
+ * The graph is exact where the other legs are probabilistic — it knows who
344
+ * imports what — but it cannot be a parallel leg, because RRF fuses ranked
345
+ * lists and the graph produces edges from a seed, not a ranking from a query.
346
+ * So it runs on the output of the first fuse and the result is fused again.
347
+ *
348
+ * A file that both matched the query and sits one hop from another match gains
349
+ * on agreement, which is what RRF already does well. A file the query never
350
+ * mentioned can still enter on topology alone, which is the point: that is the
351
+ * load-bearing dependency nobody thought to search for.
352
+ */
353
+ function refuseWithGraph(legs, seeded, limit, options, weights) {
354
+ // Every expander reads the *same* first-pass ranking and they are fused back
355
+ // in one call. Chaining them — expanding the graph, refusing, then expanding
356
+ // co-change over the result — would let the graph leg choose the co-change
357
+ // leg's seeds, so a topological neighbour would drag in everything its own
358
+ // history touches. Two expansions of one ranking is a wider net; an expansion
359
+ // of an expansion is a second hop nobody measured.
360
+ const expansions = [];
361
+ const run = (name, expand) => {
362
+ if (!expand)
363
+ return;
364
+ const leg = expand(seeded);
365
+ // `null` is "there was nothing to traverse", which is not the same as
366
+ // "traversed and found nothing" — a run file has to be able to tell a leg
367
+ // that was deliberately not consulted from one that came back empty.
368
+ if (!leg) {
369
+ options.onLeg?.({ name, status: "absent", hits: [] });
370
+ return;
371
+ }
372
+ options.onLeg?.(leg);
373
+ if (leg.hits.length)
374
+ expansions.push(leg);
375
+ };
376
+ run("graph", options.expandFromSeeds);
377
+ run("cochange", options.expandFromCoChange);
378
+ if (!expansions.length)
379
+ return seeded;
380
+ return fuseRetrieval([...legs, ...expansions], limit, weights);
381
+ }