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,462 @@
|
|
|
1
|
+
import { byCodeUnit } from "./order.js";
|
|
2
|
+
const DEFAULT_STOP_WORDS = new Set("a an and are as at be by for from in is it of on or the to with".split(" "));
|
|
3
|
+
const tokenise = (value) => value
|
|
4
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.match(/[A-Za-z0-9_]+/g)
|
|
7
|
+
?.flatMap((part) => part.split("_"))
|
|
8
|
+
.filter(Boolean) ?? [];
|
|
9
|
+
const fields = (page) => [page.title, page.content, page.summary, page.target_path, page.body ?? "", page.lexical_vocabulary ?? ""].join(" ");
|
|
10
|
+
/** Parse the candidate-only vocabulary weight; malformed values fail closed. */
|
|
11
|
+
export function vocabularyWeightFromEnv(raw = process.env.KEEL_RETRIEVAL_VOCABULARY) {
|
|
12
|
+
if (!raw?.trim())
|
|
13
|
+
return 0;
|
|
14
|
+
const value = Number(raw.trim());
|
|
15
|
+
return Number.isFinite(value) && value > 0 && value <= 3 ? value : 0;
|
|
16
|
+
}
|
|
17
|
+
/** Length-normalisation strength per field, the usual BM25 `b`. */
|
|
18
|
+
const FIELD_B = 0.75;
|
|
19
|
+
/**
|
|
20
|
+
* All ones: exactly today's behaviour, and that is the point.
|
|
21
|
+
*
|
|
22
|
+
* Under this weighting every field is counted once, which is what
|
|
23
|
+
* `fields().join(" ")` did. The ablation baseline is therefore the same code
|
|
24
|
+
* path as the treatment rather than a different one, so a measured difference
|
|
25
|
+
* cannot be an artefact of which branch ran.
|
|
26
|
+
*/
|
|
27
|
+
export const IDENTITY_FIELD_WEIGHTS = { path: 1, facts: 1, summary: 1, body: 1, norm: "global" };
|
|
28
|
+
/**
|
|
29
|
+
* Measured 28 Aug 2026 over four epochs, and the identity weighting is staying
|
|
30
|
+
* the default. Recorded here because the shape of the failure is not obvious.
|
|
31
|
+
*
|
|
32
|
+
* path=4,facts=3 with global normalisation
|
|
33
|
+
* recall@10 +0.0069 holdout, cluster CI [+0.0023, +0.0120]
|
|
34
|
+
* read tokens +5% to +41% per instance, CI clearing zero on three epochs
|
|
35
|
+
*
|
|
36
|
+
* path=4,facts=3 with textbook per-field normalisation
|
|
37
|
+
* recall@10 +0.0000 holdout, cluster CI [-0.0082, +0.0077]
|
|
38
|
+
* read tokens +8.9% train, cluster CI [+1.5%, +14.6%]
|
|
39
|
+
*
|
|
40
|
+
* The first looks like a small win. It is very likely a size artefact: adding
|
|
41
|
+
* `weight x len(field)` to one document length penalises whichever documents
|
|
42
|
+
* those short fields are a large share of — the small files — so up-weighting
|
|
43
|
+
* `path` quietly promoted big ones. The second removes exactly that bias, and
|
|
44
|
+
* the recall gain disappears with it while the token cost does not.
|
|
45
|
+
*
|
|
46
|
+
* A degenerate baseline (rule 2) makes the confounder concrete. Serving the ten
|
|
47
|
+
* largest files in the corpus, ignoring the query completely, scores recall@10
|
|
48
|
+
* of 0.1596 on keel200 and 0.0793 on pw1200 — far below real retrieval, but
|
|
49
|
+
* three to twenty-five times a same-sized random pick. File size genuinely
|
|
50
|
+
* correlates with being edited, so any mechanism whose gain arrives together
|
|
51
|
+
* with a file-size increase has to be assumed guilty until separated.
|
|
52
|
+
*
|
|
53
|
+
* So the parent hypothesis — that distinguishing fields would let identifiers
|
|
54
|
+
* and paths outweigh incidental body text — is not supported on its own. The
|
|
55
|
+
* machinery stays because it is how the *other* half of that item (a richer
|
|
56
|
+
* extracted vocabulary) would be tested, and that is a different mechanism:
|
|
57
|
+
* new terms rather than reweighted ones.
|
|
58
|
+
*/
|
|
59
|
+
/** Fixed iteration order, so an index build is deterministic across runs. */
|
|
60
|
+
const FIELD_ORDER = ["path", "facts", "summary", "body"];
|
|
61
|
+
/**
|
|
62
|
+
* Parse `path=4,facts=3,summary=1,body=1` from the environment.
|
|
63
|
+
*
|
|
64
|
+
* One build serves every weighting, so a sweep cannot be confounded with
|
|
65
|
+
* anything else that changed between two builds — the same reason `KEEL_CHUNK`
|
|
66
|
+
* and `KEEL_NO_GRAPH` are switches rather than branches.
|
|
67
|
+
*
|
|
68
|
+
* Anything unparseable falls back to the identity weighting rather than
|
|
69
|
+
* throwing. A malformed weight string in a benchmark harness should produce the
|
|
70
|
+
* documented default, not abort a run that has already paid for its index.
|
|
71
|
+
*/
|
|
72
|
+
export function fieldWeightsFromEnv(raw = process.env.KEEL_FIELD_WEIGHTS ?? process.env.CHARTER_FIELD_WEIGHTS) {
|
|
73
|
+
if (!raw)
|
|
74
|
+
return IDENTITY_FIELD_WEIGHTS;
|
|
75
|
+
const weights = { ...IDENTITY_FIELD_WEIGHTS };
|
|
76
|
+
for (const part of raw.split(",")) {
|
|
77
|
+
const [name, value] = part.split("=").map((piece) => piece.trim());
|
|
78
|
+
if (name === "norm") {
|
|
79
|
+
if (value === "field" || value === "global")
|
|
80
|
+
weights.norm = value;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const parsed = Number(value);
|
|
84
|
+
if (!FIELD_ORDER.includes(name) || !Number.isInteger(parsed) || parsed < 0)
|
|
85
|
+
continue;
|
|
86
|
+
weights[name] = parsed;
|
|
87
|
+
}
|
|
88
|
+
return weights;
|
|
89
|
+
}
|
|
90
|
+
/** A page split into the fields the ranker weighs separately. */
|
|
91
|
+
const fieldTexts = (page) => ({
|
|
92
|
+
// Title and target_path are one field, not two. A file page's title *is* its
|
|
93
|
+
// path, so counting them separately would silently double the path's weight
|
|
94
|
+
// and make the configured number mean something other than it says.
|
|
95
|
+
path: `${page.title} ${page.target_path}`,
|
|
96
|
+
facts: page.content,
|
|
97
|
+
summary: page.summary,
|
|
98
|
+
body: page.body ?? "",
|
|
99
|
+
});
|
|
100
|
+
const signalOrder = ["phrase", "path", "identifier", "action", "term"];
|
|
101
|
+
const signalWeight = { phrase: 3, path: 2.5, identifier: 2.2, action: 0.8, term: 1 };
|
|
102
|
+
const actions = new Set(["add", "align", "change", "delete", "fix", "implement", "move", "refactor", "remove", "rename", "simplify", "update"]);
|
|
103
|
+
/** Split an issue into independent deterministic search signals. */
|
|
104
|
+
export function decomposeQuery(query) {
|
|
105
|
+
const signals = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const add = (kind, raw) => {
|
|
108
|
+
const value = raw.trim();
|
|
109
|
+
if (!value)
|
|
110
|
+
return;
|
|
111
|
+
const key = `${kind}\0${value.toLowerCase()}`;
|
|
112
|
+
if (seen.has(key))
|
|
113
|
+
return;
|
|
114
|
+
seen.add(key);
|
|
115
|
+
signals.push({ kind, query: value, weight: signalWeight[kind] });
|
|
116
|
+
};
|
|
117
|
+
for (const match of query.matchAll(/["“”']([^"“”']{2,})["“”']/g))
|
|
118
|
+
add("phrase", match[1]);
|
|
119
|
+
const tokens = query.match(/[A-Za-z0-9_./-]+/g) ?? [];
|
|
120
|
+
for (const token of tokens) {
|
|
121
|
+
if (token.includes("/") || /\.[A-Za-z0-9_-]+$/.test(token))
|
|
122
|
+
add("path", token);
|
|
123
|
+
else if (actions.has(token.toLowerCase()))
|
|
124
|
+
add("action", token.toLowerCase());
|
|
125
|
+
else if (/[A-Z]/.test(token) || token.includes("_") || /\.[A-Za-z_$]/.test(token))
|
|
126
|
+
add("identifier", token);
|
|
127
|
+
}
|
|
128
|
+
const terms = [...new Set(tokenise(query).filter((term) => term.length >= 3 && !DEFAULT_STOP_WORDS.has(term)))];
|
|
129
|
+
if (terms.length)
|
|
130
|
+
add("term", terms.join(" "));
|
|
131
|
+
if (!signals.length && query.trim())
|
|
132
|
+
add("term", query);
|
|
133
|
+
return signals.sort((a, b) => signalOrder.indexOf(a.kind) - signalOrder.indexOf(b.kind) || byCodeUnit(a.query, b.query));
|
|
134
|
+
}
|
|
135
|
+
const strongIdentifier = (token) => token.includes("_") || token.includes(".") || /[a-z][A-Z]/.test(token) || /^_/.test(token);
|
|
136
|
+
const uniqueQueries = (queries, max = 4) => {
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
const output = [];
|
|
139
|
+
for (const query of queries) {
|
|
140
|
+
const value = query.trim();
|
|
141
|
+
const key = value.toLowerCase();
|
|
142
|
+
if (!value || seen.has(key))
|
|
143
|
+
continue;
|
|
144
|
+
seen.add(key);
|
|
145
|
+
output.push(value);
|
|
146
|
+
if (output.length >= max)
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
return output;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Route one issue into leg-specific query variants.
|
|
153
|
+
*
|
|
154
|
+
* Paths and code identifiers are reliable lexical/symbol anchors but are
|
|
155
|
+
* poor semantic text. Actions describe the requested change, not the file's
|
|
156
|
+
* subject. The semantic leg therefore receives the raw question plus a
|
|
157
|
+
* bounded concept variant with path/identifier/action noise removed. Keeping
|
|
158
|
+
* the raw question first preserves the original ranking as the primary
|
|
159
|
+
* semantic signal while the variants recover paraphrased concepts.
|
|
160
|
+
*/
|
|
161
|
+
export function routeQuery(query) {
|
|
162
|
+
const rawTokens = query.match(/[A-Za-z0-9_./-]+/g) ?? [];
|
|
163
|
+
const pathTokens = rawTokens.filter((token) => token.includes("/") || /\.[A-Za-z0-9_-]+$/.test(token));
|
|
164
|
+
const identifierTokens = rawTokens.filter((token) => !pathTokens.includes(token) && strongIdentifier(token));
|
|
165
|
+
const pathTerms = new Set(pathTokens.flatMap((token) => tokenise(token)));
|
|
166
|
+
const identifierTerms = new Set(identifierTokens.flatMap((token) => tokenise(token)));
|
|
167
|
+
const concepts = [...new Set(tokenise(query).filter((term) => term.length >= 3 &&
|
|
168
|
+
!DEFAULT_STOP_WORDS.has(term) &&
|
|
169
|
+
!actions.has(term) &&
|
|
170
|
+
!pathTerms.has(term) &&
|
|
171
|
+
!identifierTerms.has(term)))];
|
|
172
|
+
const conceptQuery = concepts.join(" ");
|
|
173
|
+
const phrases = decomposeQuery(query).filter((signal) => signal.kind === "phrase").map((signal) => signal.query);
|
|
174
|
+
const symbolQueries = uniqueQueries(identifierTokens.length ? identifierTokens : [query]);
|
|
175
|
+
const semanticQueries = uniqueQueries([query, ...phrases, conceptQuery]);
|
|
176
|
+
return {
|
|
177
|
+
raw_query: query,
|
|
178
|
+
lexical_query: query,
|
|
179
|
+
symbol_queries: symbolQueries,
|
|
180
|
+
semantic_queries: semanticQueries,
|
|
181
|
+
path_queries: uniqueQueries(pathTokens),
|
|
182
|
+
concept_queries: conceptQuery ? [conceptQuery] : [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** A reusable, dependency-free BM25 index for a stable page corpus. */
|
|
186
|
+
export class LexicalIndex {
|
|
187
|
+
pages;
|
|
188
|
+
postings;
|
|
189
|
+
vocabularyPostings;
|
|
190
|
+
vocabulary;
|
|
191
|
+
lengths;
|
|
192
|
+
vocabularyLengths;
|
|
193
|
+
averageLength;
|
|
194
|
+
averageVocabularyLength;
|
|
195
|
+
weights;
|
|
196
|
+
fieldNorm;
|
|
197
|
+
/**
|
|
198
|
+
* Mean token count of each field across the corpus.
|
|
199
|
+
*
|
|
200
|
+
* Only the `field` normalisation mode needs these, and only as denominators,
|
|
201
|
+
* so a field nobody weighs is never tokenised.
|
|
202
|
+
*/
|
|
203
|
+
averageFieldLengths(pages) {
|
|
204
|
+
const totals = FIELD_ORDER.map(() => 0);
|
|
205
|
+
for (const page of pages) {
|
|
206
|
+
const texts = fieldTexts(page);
|
|
207
|
+
FIELD_ORDER.forEach((field, slot) => {
|
|
208
|
+
if (this.weights[field] > 0)
|
|
209
|
+
totals[slot] += tokenise(texts[field]).length;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return totals.map((total) => total / Math.max(1, pages.length));
|
|
213
|
+
}
|
|
214
|
+
constructor(allPages, weights = fieldWeightsFromEnv()) {
|
|
215
|
+
this.weights = weights;
|
|
216
|
+
this.fieldNorm = weights.norm ?? "global";
|
|
217
|
+
// Chunks exist so the vector leg can read a whole file; they carry their
|
|
218
|
+
// file's `target_path`, so indexing them here would enter every oversized
|
|
219
|
+
// file several times over. That corrupts document frequency and average
|
|
220
|
+
// length for the entire corpus, and it puts duplicate paths in a response
|
|
221
|
+
// — which is how this was found, as a recall@10 of 1.03 against a gold set
|
|
222
|
+
// where 1.0 is the ceiling.
|
|
223
|
+
//
|
|
224
|
+
// Enforced here rather than at the call site because `retrieve()` accepts a
|
|
225
|
+
// prebuilt index, so a filter upstream protects only the callers that do
|
|
226
|
+
// not supply one. The benchmark harness supplied one.
|
|
227
|
+
const pages = allPages.filter((page) => page.page_type !== "chunk");
|
|
228
|
+
this.pages = pages;
|
|
229
|
+
const builders = new Map();
|
|
230
|
+
const vocabularyBuilders = new Map();
|
|
231
|
+
this.lengths = [];
|
|
232
|
+
this.vocabularyLengths = [];
|
|
233
|
+
let totalLength = 0;
|
|
234
|
+
let totalVocabularyLength = 0;
|
|
235
|
+
// Per-field average lengths, needed only by the "field" mode — so the
|
|
236
|
+
// default path stays a single pass over the corpus. `body` is the file's
|
|
237
|
+
// whole text and tokenising is the expensive part of an index build; making
|
|
238
|
+
// every caller pay twice for a mode they are not using would be a real
|
|
239
|
+
// regression for an experiment.
|
|
240
|
+
const fieldAverages = this.fieldNorm === "field" ? this.averageFieldLengths(pages) : null;
|
|
241
|
+
for (let index = 0; index < pages.length; index += 1) {
|
|
242
|
+
const texts = fieldTexts(pages[index]);
|
|
243
|
+
const counts = new Map();
|
|
244
|
+
let length = 0;
|
|
245
|
+
FIELD_ORDER.forEach((field, slot) => {
|
|
246
|
+
const weight = this.weights[field];
|
|
247
|
+
if (weight <= 0)
|
|
248
|
+
return;
|
|
249
|
+
const tokens = tokenise(texts[field]);
|
|
250
|
+
length += tokens.length * weight;
|
|
251
|
+
// Two ways to combine fields, and the difference is the whole reason
|
|
252
|
+
// the first attempt at this cost tokens instead of saving them.
|
|
253
|
+
//
|
|
254
|
+
// "global" is the original behaviour: sum the weighted frequencies,
|
|
255
|
+
// sum the weighted lengths, and normalise once at scoring time against
|
|
256
|
+
// one corpus-wide average. Under identity weights that is exactly the
|
|
257
|
+
// concatenated bag this class has always used, which is what makes it
|
|
258
|
+
// an honest ablation baseline.
|
|
259
|
+
//
|
|
260
|
+
// It is also subtly wrong as soon as the weights are not all one.
|
|
261
|
+
// Adding `weight x len(field)` to a single document length penalises
|
|
262
|
+
// the documents those short fields are a large *share* of — the small
|
|
263
|
+
// files. A large file's length is dominated by its body, so weighting
|
|
264
|
+
// `path` up barely moves its normalised length, while a small file's
|
|
265
|
+
// grows proportionally. Measured over four epochs: recall rose
|
|
266
|
+
// (+0.0069 holdout) and read tokens rose with it, +5% to +41% per
|
|
267
|
+
// instance, because the ranking had quietly begun preferring larger
|
|
268
|
+
// files. That is the chunking trade again — real recall bought with
|
|
269
|
+
// tokens — and here it is an artefact of the formulation, not of the
|
|
270
|
+
// idea being tested.
|
|
271
|
+
//
|
|
272
|
+
// "field" is textbook BM25F: normalise each field against its own
|
|
273
|
+
// average length *before* weighting, so a field is only ever compared
|
|
274
|
+
// with the same field in other documents and a short path is not
|
|
275
|
+
// evidence that the document is short. Saturation then applies once to
|
|
276
|
+
// the combined value, which is why scoring skips its length term in
|
|
277
|
+
// this mode.
|
|
278
|
+
const factor = fieldAverages
|
|
279
|
+
? weight / (1 - FIELD_B + FIELD_B * (tokens.length / Math.max(1, fieldAverages[slot])))
|
|
280
|
+
: weight;
|
|
281
|
+
for (const token of tokens)
|
|
282
|
+
counts.set(token, (counts.get(token) ?? 0) + factor);
|
|
283
|
+
});
|
|
284
|
+
this.lengths.push(length);
|
|
285
|
+
totalLength += length;
|
|
286
|
+
const vocabularyCounts = new Map();
|
|
287
|
+
for (const token of tokenise(pages[index].lexical_vocabulary ?? "")) {
|
|
288
|
+
vocabularyCounts.set(token, (vocabularyCounts.get(token) ?? 0) + 1);
|
|
289
|
+
}
|
|
290
|
+
const vocabularyLength = [...vocabularyCounts.values()].reduce((total, count) => total + count, 0);
|
|
291
|
+
this.vocabularyLengths.push(vocabularyLength);
|
|
292
|
+
totalVocabularyLength += vocabularyLength;
|
|
293
|
+
for (const [term, count] of vocabularyCounts) {
|
|
294
|
+
const posting = vocabularyBuilders.get(term) ?? [];
|
|
295
|
+
posting.push(index, count);
|
|
296
|
+
vocabularyBuilders.set(term, posting);
|
|
297
|
+
}
|
|
298
|
+
for (const [term, count] of counts) {
|
|
299
|
+
const posting = builders.get(term) ?? [];
|
|
300
|
+
// Flat [document index, term frequency] pairs are materially smaller
|
|
301
|
+
// than one object per posting on an 80k-file corpus. Float32 rather
|
|
302
|
+
// than Uint32 because field normalisation produces fractional
|
|
303
|
+
// frequencies; it is the same four bytes per element, and a document
|
|
304
|
+
// index stays exactly representable well past any corpus this indexes.
|
|
305
|
+
posting.push(index, count);
|
|
306
|
+
builders.set(term, posting);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
this.postings = new Map([...builders].map(([term, posting]) => [term, Float32Array.from(posting)]));
|
|
310
|
+
this.vocabularyPostings = new Map([...vocabularyBuilders].map(([term, posting]) => [term, Float32Array.from(posting)]));
|
|
311
|
+
this.vocabulary = [...new Set([...this.postings.keys(), ...this.vocabularyPostings.keys()])].sort(byCodeUnit);
|
|
312
|
+
this.averageLength = totalLength / Math.max(1, pages.length);
|
|
313
|
+
this.averageVocabularyLength = totalVocabularyLength / Math.max(1, pages.length);
|
|
314
|
+
}
|
|
315
|
+
documentFrequency(term, includeVocabulary = false) {
|
|
316
|
+
const posting = this.postings.get(term);
|
|
317
|
+
if (!includeVocabulary)
|
|
318
|
+
return (posting?.length ?? 0) / 2;
|
|
319
|
+
const documents = new Set();
|
|
320
|
+
for (const current of [posting, this.vocabularyPostings.get(term)]) {
|
|
321
|
+
for (let i = 0; i < (current?.length ?? 0); i += 2)
|
|
322
|
+
documents.add(current[i]);
|
|
323
|
+
}
|
|
324
|
+
return documents.size;
|
|
325
|
+
}
|
|
326
|
+
matchingPostings(term, vocabularyWeight = 0) {
|
|
327
|
+
const matches = new Map();
|
|
328
|
+
const add = (posting, multiplier = 1) => {
|
|
329
|
+
for (let i = 0; i < posting.length; i += 2) {
|
|
330
|
+
const document = posting[i];
|
|
331
|
+
matches.set(document, (matches.get(document) ?? 0) + posting[i + 1] * multiplier);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const addMatching = (termToMatch) => {
|
|
335
|
+
const exact = this.postings.get(termToMatch);
|
|
336
|
+
if (exact)
|
|
337
|
+
add(exact);
|
|
338
|
+
if (vocabularyWeight > 0) {
|
|
339
|
+
const structural = this.vocabularyPostings.get(termToMatch);
|
|
340
|
+
if (structural)
|
|
341
|
+
add(structural, vocabularyWeight);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
if (term.length < 4) {
|
|
345
|
+
addMatching(term);
|
|
346
|
+
return matches;
|
|
347
|
+
}
|
|
348
|
+
// Vocabulary is sorted by code unit, so the first lower-bound match and
|
|
349
|
+
// a contiguous prefix scan replace a full page scan while preserving the
|
|
350
|
+
// original `token.startsWith(term)` behavior.
|
|
351
|
+
let low = 0;
|
|
352
|
+
let high = this.vocabulary.length;
|
|
353
|
+
while (low < high) {
|
|
354
|
+
const middle = (low + high) >>> 1;
|
|
355
|
+
if (byCodeUnit(this.vocabulary[middle], term) < 0)
|
|
356
|
+
low = middle + 1;
|
|
357
|
+
else
|
|
358
|
+
high = middle;
|
|
359
|
+
}
|
|
360
|
+
for (let i = low; i < this.vocabulary.length && this.vocabulary[i].startsWith(term); i += 1) {
|
|
361
|
+
addMatching(this.vocabulary[i]);
|
|
362
|
+
}
|
|
363
|
+
return matches;
|
|
364
|
+
}
|
|
365
|
+
search(query, options = {}) {
|
|
366
|
+
const stopWords = options.stopWords ?? DEFAULT_STOP_WORDS;
|
|
367
|
+
const limit = options.limit ?? 10;
|
|
368
|
+
const ceiling = options.documentFrequencyCeiling ?? 0.2;
|
|
369
|
+
const vocabularyWeight = Number.isFinite(options.vocabularyWeight) && (options.vocabularyWeight ?? 0) > 0
|
|
370
|
+
? Math.min(3, options.vocabularyWeight)
|
|
371
|
+
: 0;
|
|
372
|
+
const includeVocabulary = vocabularyWeight > 0;
|
|
373
|
+
const rawTerms = tokenise(query).filter((term) => !stopWords.has(term));
|
|
374
|
+
const rankedTerms = [...new Set(rawTerms)]
|
|
375
|
+
.sort((a, b) => this.documentFrequency(a, includeVocabulary) - this.documentFrequency(b, includeVocabulary) || byCodeUnit(a, b));
|
|
376
|
+
const rareTerms = rankedTerms.filter((term) => this.documentFrequency(term, includeVocabulary) / Math.max(1, this.pages.length) <= ceiling);
|
|
377
|
+
// `rankedTerms` is ascending by document frequency, so its head is the most
|
|
378
|
+
// discriminative part of the query. Always score it, even when the ceiling
|
|
379
|
+
// would reject it.
|
|
380
|
+
//
|
|
381
|
+
// The ceiling alone was safe only while pages were structural cards. Once a
|
|
382
|
+
// page carries the file's own text, an identifier appears both where it is
|
|
383
|
+
// defined and at every call site, so the very terms that locate a file are
|
|
384
|
+
// the ones whose document frequency climbs past the ceiling — and dropping
|
|
385
|
+
// them left the query matching on its vaguest words. BM25 already discounts
|
|
386
|
+
// common terms through IDF; discarding them outright double-counts that and
|
|
387
|
+
// loses the signal instead of weighting it.
|
|
388
|
+
const floor = rankedTerms.slice(0, Math.min(3, rankedTerms.length));
|
|
389
|
+
const terms = [...new Set([...rareTerms, ...floor])];
|
|
390
|
+
const score = (wanted) => {
|
|
391
|
+
const matchedByDocument = new Map();
|
|
392
|
+
for (const term of wanted) {
|
|
393
|
+
for (const [document, count] of this.matchingPostings(term, vocabularyWeight)) {
|
|
394
|
+
const current = matchedByDocument.get(document) ?? { matched: [], counts: new Map() };
|
|
395
|
+
current.matched.push(term);
|
|
396
|
+
current.counts.set(term, count);
|
|
397
|
+
matchedByDocument.set(document, current);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const hits = [];
|
|
401
|
+
for (const [document, match] of matchedByDocument) {
|
|
402
|
+
const k1 = 1.2;
|
|
403
|
+
const b = 0.75;
|
|
404
|
+
let total = 0;
|
|
405
|
+
for (const term of match.matched) {
|
|
406
|
+
const count = match.counts.get(term) ?? 0;
|
|
407
|
+
const documentFrequency = this.documentFrequency(term, includeVocabulary);
|
|
408
|
+
const inverse = Math.log(1 + (this.pages.length - documentFrequency + 0.5) / (documentFrequency + 0.5));
|
|
409
|
+
// In `field` mode the length term is already inside `count` — each
|
|
410
|
+
// field was divided by its own average before being weighted and
|
|
411
|
+
// summed — so applying a second, document-wide normalisation here
|
|
412
|
+
// would normalise twice and undo the thing the mode exists to fix.
|
|
413
|
+
const normalisation = this.fieldNorm === "field"
|
|
414
|
+
? 1
|
|
415
|
+
: 1 - b + b * (this.lengths[document] + vocabularyWeight * this.vocabularyLengths[document]) /
|
|
416
|
+
Math.max(1, this.averageLength + vocabularyWeight * this.averageVocabularyLength);
|
|
417
|
+
total += inverse * ((count * (k1 + 1)) / (count + k1 * normalisation));
|
|
418
|
+
}
|
|
419
|
+
hits.push({ page: this.pages[document], score: total, matched_terms: match.matched });
|
|
420
|
+
}
|
|
421
|
+
return hits.sort((a, b) => b.score - a.score || byCodeUnit(a.page.page_id, b.page.page_id));
|
|
422
|
+
};
|
|
423
|
+
let hits = score(terms);
|
|
424
|
+
const widened = hits.length === 0 && terms.length !== rankedTerms.length;
|
|
425
|
+
if (widened)
|
|
426
|
+
hits = score(rankedTerms);
|
|
427
|
+
return { query, terms, widened, hits: hits.slice(0, limit) };
|
|
428
|
+
}
|
|
429
|
+
searchMulti(query, options = {}) {
|
|
430
|
+
const limit = options.limit ?? 10;
|
|
431
|
+
const signals = decomposeQuery(query);
|
|
432
|
+
const candidateLimit = Math.min(100, Math.max(limit * 3, 20));
|
|
433
|
+
const byPage = new Map();
|
|
434
|
+
for (const signal of signals) {
|
|
435
|
+
const result = this.search(signal.query, { ...options, limit: candidateLimit });
|
|
436
|
+
result.hits.forEach((hit, rank) => {
|
|
437
|
+
const current = byPage.get(hit.page.page_id) ?? { page: hit.page, score: 0, terms: new Set(), kinds: new Set() };
|
|
438
|
+
current.score += signal.weight / (rank + 1);
|
|
439
|
+
if (signal.kind === "phrase" && fields(hit.page).toLowerCase().includes(signal.query.toLowerCase()))
|
|
440
|
+
current.score += 1;
|
|
441
|
+
hit.matched_terms.forEach((term) => current.terms.add(term));
|
|
442
|
+
current.kinds.add(signal.kind);
|
|
443
|
+
byPage.set(hit.page.page_id, current);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const hits = [...byPage.values()]
|
|
447
|
+
.map((hit) => ({ page: hit.page, score: hit.score, matched_terms: [...hit.terms].sort(byCodeUnit), signal_kinds: [...hit.kinds].sort((a, b) => signalOrder.indexOf(a) - signalOrder.indexOf(b)) }))
|
|
448
|
+
.sort((a, b) => b.score - a.score || b.signal_kinds.length - a.signal_kinds.length || byCodeUnit(a.page.page_id, b.page.page_id));
|
|
449
|
+
return { query, terms: signals.map((signal) => signal.query), widened: false, hits: hits.slice(0, limit) };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
export function buildLexicalIndex(pages, weights) {
|
|
453
|
+
return new LexicalIndex(pages, weights);
|
|
454
|
+
}
|
|
455
|
+
/** Search retrieval pages with a dependency-free BM25-style ranker. */
|
|
456
|
+
export function searchLexical(pages, query, options = {}) {
|
|
457
|
+
return buildLexicalIndex(pages).search(query, options);
|
|
458
|
+
}
|
|
459
|
+
/** Search a fresh lexical index using decomposed phrase/path/identifier signals. */
|
|
460
|
+
export function searchLexicalMulti(pages, query, options = {}) {
|
|
461
|
+
return buildLexicalIndex(pages).searchMulti(query, options);
|
|
462
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one comparator the index is allowed to sort with.
|
|
3
|
+
*
|
|
4
|
+
* This module exists because of a bug that only appeared the first time the
|
|
5
|
+
* pipeline read a real repository. `file-index.ts` ordered files with the
|
|
6
|
+
* default `Array.prototype.sort`, while `incremental.ts` ordered them with
|
|
7
|
+
* `localeCompare`. Those are different collations: default sort compares UTF-16
|
|
8
|
+
* code units, so "README.md" precedes "package-lock.json" because 'R' is 82
|
|
9
|
+
* and 'p' is 112; `localeCompare` folds case and orders them the other way.
|
|
10
|
+
*
|
|
11
|
+
* The result was an incremental index that held exactly the same files with
|
|
12
|
+
* exactly the same fingerprint in a different order — which broke the
|
|
13
|
+
* byte-identity invariant that the whole incremental design rests on. Every
|
|
14
|
+
* synthetic test passed, because fixtures were named a.ts and b.ts and the two
|
|
15
|
+
* collations agree on those.
|
|
16
|
+
*
|
|
17
|
+
* The deeper hazard is worse than the mismatch. `localeCompare` is
|
|
18
|
+
* locale-dependent: its result can differ between two machines with different
|
|
19
|
+
* ICU data or a different default locale. A subsystem whose entire purpose is
|
|
20
|
+
* letting two machines agree on what they are looking at must never order
|
|
21
|
+
* anything by a rule that varies between machines. That it happened to produce
|
|
22
|
+
* a stable fingerprint here is luck — `indexFingerprint` sorts its records with
|
|
23
|
+
* the default comparator — not design.
|
|
24
|
+
*
|
|
25
|
+
* So: code-unit ordering, everywhere, by one function nobody has to remember
|
|
26
|
+
* to choose. `localeCompare` is banned from this directory, and
|
|
27
|
+
* local-order.test.ts enforces that with a grep.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Compare two strings by UTF-16 code unit.
|
|
31
|
+
*
|
|
32
|
+
* Identical on every machine, in every locale, forever. Not the right
|
|
33
|
+
* comparator for showing a list to a human — "Zebra" sorts before "apple" —
|
|
34
|
+
* and that is an acceptable trade, because these orderings exist to make bytes
|
|
35
|
+
* reproducible rather than to be read.
|
|
36
|
+
*/
|
|
37
|
+
export function byCodeUnit(a, b) {
|
|
38
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
39
|
+
}
|
|
40
|
+
/** Order objects by one string field, by code unit. */
|
|
41
|
+
export function byField(select) {
|
|
42
|
+
return (a, b) => byCodeUnit(select(a), select(b));
|
|
43
|
+
}
|