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