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,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
|
+
}
|