opencode-blackhole 0.0.0-stage → 0.1.1

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 (50) hide show
  1. package/LICENSE +35 -0
  2. package/LICENSE.vendor +21 -0
  3. package/README.md +377 -2
  4. package/dist/server.js +5025 -0
  5. package/index.ts +1 -0
  6. package/package.json +57 -4
  7. package/src/adapter/opencode-messages.ts +270 -0
  8. package/src/compaction/build.ts +264 -0
  9. package/src/compaction/handoff.ts +421 -0
  10. package/src/config.ts +316 -0
  11. package/src/index.ts +714 -0
  12. package/src/memory/ledger.ts +254 -0
  13. package/src/memory/manager.ts +659 -0
  14. package/src/memory/models.ts +172 -0
  15. package/src/memory/prompts.ts +145 -0
  16. package/src/recall/recall.ts +327 -0
  17. package/src/state/paths.ts +42 -0
  18. package/src/store/capture.ts +84 -0
  19. package/src/store/transcript.ts +279 -0
  20. package/src/vcc/README.md +51 -0
  21. package/src/vcc/core/brief.ts +407 -0
  22. package/src/vcc/core/build-sections.ts +81 -0
  23. package/src/vcc/core/compact-args.ts +45 -0
  24. package/src/vcc/core/content.ts +156 -0
  25. package/src/vcc/core/drill-down.ts +302 -0
  26. package/src/vcc/core/filter-noise.ts +41 -0
  27. package/src/vcc/core/format-recall.ts +100 -0
  28. package/src/vcc/core/format.ts +104 -0
  29. package/src/vcc/core/lineage.ts +26 -0
  30. package/src/vcc/core/normalize.ts +76 -0
  31. package/src/vcc/core/rank.ts +283 -0
  32. package/src/vcc/core/recall-request.ts +160 -0
  33. package/src/vcc/core/recall-scope.ts +30 -0
  34. package/src/vcc/core/render-entries.ts +55 -0
  35. package/src/vcc/core/sanitize.ts +5 -0
  36. package/src/vcc/core/search-entries.ts +594 -0
  37. package/src/vcc/core/skill-collapse.ts +35 -0
  38. package/src/vcc/core/summarize.ts +289 -0
  39. package/src/vcc/core/token-estimate.ts +100 -0
  40. package/src/vcc/core/tool-args.ts +16 -0
  41. package/src/vcc/details.ts +11 -0
  42. package/src/vcc/extract/commits.ts +96 -0
  43. package/src/vcc/extract/files.ts +87 -0
  44. package/src/vcc/extract/goals.ts +79 -0
  45. package/src/vcc/extract/preferences.ts +55 -0
  46. package/src/vcc/extract/tracked-commands.ts +232 -0
  47. package/src/vcc/load-messages.ts +42 -0
  48. package/src/vcc/pi-types.ts +53 -0
  49. package/src/vcc/sections.ts +12 -0
  50. package/src/vcc/types.ts +16 -0
@@ -0,0 +1,594 @@
1
+ import type { Message, ToolCall } from "../pi-types";
2
+ import type { RenderedEntry } from "./render-entries";
3
+ import { textOf, isContentBearing, extractToolCallText, extractToolCallArgsText, clip } from "./content";
4
+
5
+ export interface SearchHit extends RenderedEntry {
6
+ /** Context snippet around the first matched term (only when query provided) */
7
+ snippet?: string;
8
+ /** Number of query terms matched (for ranking) */
9
+ matchCount?: number;
10
+ }
11
+
12
+ /**
13
+ * Result of a search, with enough metadata for a caller to report truncation
14
+ * honestly (see `searchEntriesDetailed`). `searchEntries` stays `SearchHit[]`
15
+ * for existing call sites that only need the hits themselves.
16
+ */
17
+ export interface SearchResult {
18
+ hits: SearchHit[];
19
+ /** Genuine matches found before the hard cap was applied (after any
20
+ * relative-floor noise filtering). May exceed `hits.length`. */
21
+ totalBeforeCap: number;
22
+ /** True when the hard cap discarded matches (`totalBeforeCap > hits.length`). */
23
+ truncated: boolean;
24
+ }
25
+ /** A file touched in one entry — used by mode:touched aggregation. */
26
+ export interface FileTouch {
27
+ index: number;
28
+ toolName: string;
29
+ }
30
+
31
+ /** Aggregated view of a file touched across multiple entries. */
32
+ export interface TouchedFile {
33
+ path: string;
34
+ entries: FileTouch[];
35
+ }
36
+ const escapeRegex = (s: string): string =>
37
+ s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
+
39
+ /** Quantifier starting at `i`, if any. Only unbounded forms (+, *, {n,}) can
40
+ * drive catastrophic backtracking. */
41
+ const quantifierAt = (p: string, i: number): { len: number; unbounded: boolean } => {
42
+ const c = p[i];
43
+ if (c === "+" || c === "*") return { len: 1, unbounded: true };
44
+ if (c === "{") {
45
+ const end = p.indexOf("}", i);
46
+ const body = end === -1 ? "" : p.slice(i + 1, end);
47
+ if (/^\d+(,\d*)?$/.test(body)) return { len: end - i + 1, unbounded: body.endsWith(",") };
48
+ }
49
+ return { len: 0, unbounded: false };
50
+ };
51
+
52
+ /**
53
+ * Detect an unbounded quantifier applied to a group that already contains one,
54
+ * e.g. `(a+)+` or `(\w*)*`. That shape makes the engine explore exponentially
55
+ * many splits on a non-matching input. Alternation overlap like `(a|a)+` is not
56
+ * covered here; the search budget in `searchEntries` is the backstop.
57
+ */
58
+ const hasNestedQuantifier = (pattern: string): boolean => {
59
+ const groups: boolean[] = []; // per open group: contains an unbounded quantifier
60
+ let inClass = false;
61
+ for (let i = 0; i < pattern.length; i++) {
62
+ const c = pattern[i];
63
+ if (c === "\\") { i++; continue; }
64
+ if (inClass) { if (c === "]") inClass = false; continue; }
65
+ if (c === "[") { inClass = true; continue; }
66
+ if (c === "(") { groups.push(false); continue; }
67
+ if (c === ")") {
68
+ const inner = groups.pop() ?? false;
69
+ const q = quantifierAt(pattern, i + 1);
70
+ if (inner && q.unbounded) return true;
71
+ if (groups.length) groups[groups.length - 1] ||= inner || q.unbounded;
72
+ i += q.len;
73
+ continue;
74
+ }
75
+ const q = quantifierAt(pattern, i);
76
+ if (q.unbounded && groups.length) {
77
+ groups[groups.length - 1] = true;
78
+ i += q.len - 1;
79
+ }
80
+ }
81
+ return false;
82
+ };
83
+
84
+ /** Try to compile as regex; fall back to escaped literal. Patterns with nested
85
+ * unbounded quantifiers are treated as literals rather than compiled. */
86
+ const safeRegex = (pattern: string): RegExp => {
87
+ if (hasNestedQuantifier(pattern)) return new RegExp(escapeRegex(pattern), "i");
88
+ try {
89
+ return new RegExp(pattern, "i");
90
+ } catch {
91
+ return new RegExp(escapeRegex(pattern), "i");
92
+ }
93
+ };
94
+
95
+ /**
96
+ * Wall-clock budget for one search. A normal query over 400 entries takes ~10ms,
97
+ * so this only trips on pathological patterns that survive `hasNestedQuantifier`.
98
+ * Aborting loudly beats returning a silently truncated match count.
99
+ *
100
+ * This is a per-entry checkpoint, not a hard per-call ceiling: JavaScript cannot
101
+ * interrupt a running `RegExp.test`, so a single pathological entry still runs to
102
+ * completion and the overshoot is caught on the next iteration. That bounds the
103
+ * damage to one entry instead of the whole corpus, which is the point — the
104
+ * unbounded case was N entries multiplied by the per-entry cost.
105
+ */
106
+ const SEARCH_BUDGET_MS = 3000;
107
+
108
+ const startBudget = (): (() => void) => {
109
+ const deadline = Date.now() + SEARCH_BUDGET_MS;
110
+ return () => {
111
+ if (Date.now() > deadline) {
112
+ throw new Error(
113
+ `Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms. Use fewer or more specific words; ` +
114
+ "for a regex, avoid nested quantifiers such as (a+)+.",
115
+ );
116
+ }
117
+ };
118
+ };
119
+
120
+ /** Detect if the query looks like a single regex pattern (contains regex metacharacters). */
121
+ const looksLikeRegex = (query: string): boolean =>
122
+ /[|*+?{}()[\]\\^$.]/.test(query);
123
+
124
+ /** Build a regex for snippet highlighting — matches first available term. */
125
+ const snippetRegex = (terms: string[]): RegExp => {
126
+ const alts = terms.map((t) => safeRegex(t).source);
127
+ return new RegExp(alts.join("|"), "i");
128
+ };
129
+
130
+ // ── Stopwords for natural language queries ──
131
+ const STOPWORDS = new Set([
132
+ // English
133
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
134
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
135
+ "should", "may", "might", "can", "shall", "of", "in", "to", "for",
136
+ "with", "on", "at", "from", "by", "as", "into", "through", "during",
137
+ "before", "after", "above", "below", "between", "out", "off", "over",
138
+ "under", "again", "further", "then", "once", "here", "there", "when",
139
+ "where", "why", "how", "all", "both", "each", "few", "more", "most",
140
+ "other", "some", "such", "no", "nor", "not", "only", "own", "same",
141
+ "so", "than", "too", "very", "just", "about", "it", "its", "that",
142
+ "this", "what", "which", "who", "whom", "these", "those",
143
+ ]);
144
+
145
+ /** Remove stopwords, keep meaningful terms. */
146
+ const filterStopwords = (terms: string[]): string[] => {
147
+ const meaningful = terms.filter((t) => !STOPWORDS.has(t.toLowerCase()) && t.length > 1);
148
+ // If all terms were stopwords, return original (don't lose everything)
149
+ return meaningful.length > 0 ? meaningful : terms;
150
+ };
151
+
152
+ // ── BM25-lite scoring ──
153
+ const BM25_K = 1.2;
154
+ const BM25_B = 0.75;
155
+
156
+ /**
157
+ * One query term, compiled once per search. Building the RegExp per term per
158
+ * doc cost ~1M compilations on a 17k-message session and was most of the
159
+ * search time.
160
+ */
161
+ interface Term {
162
+ term: string;
163
+ re: RegExp; // safeRegex(term): case-insensitive, first match
164
+ global: RegExp; // same source with "gi", for counting occurrences
165
+ }
166
+
167
+ const compileTerms = (terms: string[]): Term[] =>
168
+ terms.map((term) => {
169
+ const re = safeRegex(term);
170
+ return { term, re, global: new RegExp(re.source, "gi") };
171
+ });
172
+
173
+ /** Count occurrences of a term in text. */
174
+ const termFreq = (text: string, t: Term): number => {
175
+ const matches = text.match(t.global);
176
+ return matches ? matches.length : 0;
177
+ };
178
+
179
+ const wordCount = (doc: string): number => doc.split(/\s+/).length;
180
+
181
+ interface BM25Context {
182
+ n: number; // total docs
183
+ avgDl: number; // average doc length (words)
184
+ df: Map<string, number>; // term -> number of docs containing it
185
+ /** Per doc, the terms it contains: found once here, reused for matchCount and scoring. */
186
+ matched: Term[][];
187
+ }
188
+
189
+ /** Precompute IDF and avgDl across all docs. */
190
+ const buildBM25Context = (docs: string[], docLens: number[], terms: Term[], checkBudget: () => void): BM25Context => {
191
+ const n = docs.length;
192
+ const df = new Map<string, number>();
193
+ const matched: Term[][] = [];
194
+ let totalLen = 0;
195
+
196
+ for (let i = 0; i < n; i++) {
197
+ checkBudget();
198
+ totalLen += docLens[i];
199
+ const here: Term[] = [];
200
+ for (const t of terms) {
201
+ if (t.re.test(docs[i])) {
202
+ df.set(t.term, (df.get(t.term) ?? 0) + 1);
203
+ here.push(t);
204
+ }
205
+ }
206
+ matched.push(here);
207
+ }
208
+
209
+ return { n, avgDl: totalLen / Math.max(n, 1), df, matched };
210
+ };
211
+
212
+ /** BM25 score for a single doc against query terms. */
213
+ const bm25Score = (doc: string, dl: number, terms: Term[], ctx: BM25Context): number => {
214
+ let score = 0;
215
+
216
+ for (const t of terms) {
217
+ const tf = termFreq(doc, t);
218
+ if (tf === 0) continue;
219
+
220
+ const docFreq = ctx.df.get(t.term) ?? 0;
221
+ // IDF: log((N - df + 0.5) / (df + 0.5) + 1)
222
+ const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
223
+ // TF saturation with length normalization
224
+ const tfNorm = (tf * (BM25_K + 1)) / (tf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
225
+ score += idf * tfNorm;
226
+ }
227
+
228
+ return score;
229
+ };
230
+
231
+ /** Line-based snippet: ±contextLines around first regex match. */
232
+ const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | undefined => {
233
+ const lines = text.split("\n");
234
+ let matchIdx = -1;
235
+ for (let i = 0; i < lines.length; i++) {
236
+ if (regex.test(lines[i])) {
237
+ matchIdx = i;
238
+ break;
239
+ }
240
+ }
241
+ if (matchIdx === -1) return undefined;
242
+
243
+ const start = Math.max(0, matchIdx - contextLines);
244
+ const end = Math.min(lines.length, matchIdx + contextLines + 1);
245
+ const slice = lines.slice(start, end);
246
+
247
+ const parts: string[] = [];
248
+ if (start > 0) parts.push(`...(${start} lines above)`);
249
+ parts.push(...slice);
250
+ if (end < lines.length) parts.push(`...(${lines.length - end} lines below)`);
251
+ return parts.join("\n");
252
+ };
253
+
254
+ /**
255
+ * Aggregate character budget for ALL toolCall arguments appended to one
256
+ * message's searchable text — a single shared budget across every toolCall
257
+ * in the message, not per call, so N toolCalls can't multiply the bound and
258
+ * make one message's contribution to the BM25 doc corpus unbounded.
259
+ *
260
+ * Head-only cap: content past this budget is not indexed via toolCall
261
+ * arguments at all. This is an honest tradeoff, not a proxy for full
262
+ * coverage — a Write/Edit tool result commonly only acknowledges success
263
+ * (e.g. "wrote 400 lines"), so a fact buried past the cap in a giant
264
+ * argument is not guaranteed to be searchable elsewhere either.
265
+ */
266
+ const TOOL_ARGS_BUDGET = 2000;
267
+
268
+ /**
269
+ * Tool name of the recall tool itself (src/tools/recall.ts). A search
270
+ * operation must not match its own query or its own prior output: the
271
+ * vcc_recall invocation is persisted as an ordinary assistant toolCall (its
272
+ * `{ query }` argument, excluded below in `toolCallArgsText`) followed by an
273
+ * ordinary toolResult message (its `N matches for "<query>"` text, excluded
274
+ * in `fullText`) — without both exclusions a repeated query keeps matching
275
+ * its own prior invocation/output and the hit count grows on every search.
276
+ * This is a targeted introspection invariant for one named tool, not a
277
+ * general allowlist/blocklist over tool names or tool results.
278
+ */
279
+ const RECALL_TOOL_NAME = "vcc_recall";
280
+
281
+ /** Text of every toolCall's arguments in a message's content, for search —
282
+ * bounded once, in aggregate, by TOOL_ARGS_BUDGET. Excludes the recall
283
+ * tool's own arguments (see RECALL_TOOL_NAME). */
284
+ const toolCallArgsText = (content: Message["content"]): string => {
285
+ if (!content || typeof content === "string") return "";
286
+ const raw = (content as { type: string }[])
287
+ .filter((part): part is ToolCall => part.type === "toolCall")
288
+ .filter((part) => part.name?.toLowerCase() !== RECALL_TOOL_NAME)
289
+ .map((part) => extractToolCallArgsText(part.arguments))
290
+ .filter(Boolean)
291
+ .join("\n");
292
+ return clip(raw, TOOL_ARGS_BUDGET);
293
+ };
294
+
295
+ /**
296
+ * Build full searchable text for a message: text parts plus toolCall
297
+ * arguments (bash command, Write/Edit content, etc.) so a match that only
298
+ * exists in a tool call's arguments is still findable and its snippet is
299
+ * derived from the same text.
300
+ *
301
+ * The recall tool's own toolResult is excluded (searchable text ""): it
302
+ * echoes back `N matches for "<query>"` from the *previous* recall call, so
303
+ * indexing it would make a repeated query self-match and grow with every
304
+ * search. This only affects search indexing — browse/no-query returns
305
+ * entries before fullText runs (see `searchEntries`), and #N expand reads
306
+ * the message/entry directly, not through this function, so the toolResult
307
+ * is still fully visible there.
308
+ */
309
+ const fullText = (msg: Message): string => {
310
+ if ((msg as any).role === "bashExecution") {
311
+ return `${(msg as any).command ?? ""} ${(msg as any).output ?? ""}`;
312
+ }
313
+ if (msg.role === "toolResult" && msg.toolName?.toLowerCase() === RECALL_TOOL_NAME) {
314
+ return "";
315
+ }
316
+ const text = textOf(msg.content);
317
+ const argsText = toolCallArgsText(msg.content);
318
+ return argsText ? `${text}\n${argsText}` : text;
319
+ };
320
+
321
+ /**
322
+ * Compute file indicators from a message, counting non-empty content lines
323
+ * per content-bearing file call. Shape-based — no tool-name allowlist.
324
+ *
325
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
326
+ * k0valik — a pi-vcc derivative.
327
+ */
328
+ export function getFileIndicators(msg: Message): { toolName: string; path: string; lineCount: number }[] {
329
+ if (!msg?.content || typeof msg.content === "string") return [];
330
+ const indicators: { toolName: string; path: string; lineCount: number }[] = [];
331
+ for (const part of msg.content) {
332
+ if (!part || typeof part !== "object" || part.type !== "toolCall") continue;
333
+ const args = part.arguments as Record<string, unknown>;
334
+ if (!isContentBearing(args)) continue;
335
+ const path = ["path", "filePath", "file_path", "file"]
336
+ .map((k) => args[k])
337
+ .find((v): v is string => typeof v === "string")!;
338
+ const totalText = extractToolCallText(args);
339
+ const nonEmpty = totalText.split("\n").filter((l) => l.trim().length > 0);
340
+ indicators.push({
341
+ toolName: part.name || "",
342
+ path,
343
+ lineCount: nonEmpty.length,
344
+ });
345
+ }
346
+ return indicators;
347
+ }
348
+
349
+ /**
350
+ * Aggregate file operations across all entries for mode:touched.
351
+ *
352
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
353
+ * k0valik — a pi-vcc derivative.
354
+ */
355
+ export function getTouchedFiles(
356
+ messages: Message[],
357
+ rendered: RenderedEntry[],
358
+ ): TouchedFile[] {
359
+ const map = new Map<string, TouchedFile>();
360
+ for (let i = 0; i < messages.length; i++) {
361
+ const msg = messages[i];
362
+ const indicators = getFileIndicators(msg);
363
+ for (const fm of indicators) {
364
+ const index = rendered[i]?.index ?? i;
365
+ if (!map.has(fm.path)) {
366
+ map.set(fm.path, { path: fm.path, entries: [] });
367
+ }
368
+ (map.get(fm.path) as TouchedFile).entries.push({ index, toolName: fm.toolName });
369
+ }
370
+ }
371
+ return Array.from(map.values());
372
+ }
373
+
374
+ /**
375
+ * Relative BM25 noise floor for MULTI-TERM natural-language queries only:
376
+ * after sorting by score, drop hits scoring below this fraction of the top
377
+ * score. Relative (not absolute) because BM25 magnitudes vary with corpus
378
+ * size and document length, so a fixed score threshold would behave
379
+ * inconsistently across short vs. long sessions.
380
+ *
381
+ * Applied only when the query has >=2 DISTINCT effective terms after
382
+ * stopword filtering and case/duplicate normalization (see the
383
+ * `effectiveTermCount >= 2` gate below `searchEntriesDetailed` uses before
384
+ * calling `applyRelativeFloor`). Distinct, not raw count: "auth auth" or
385
+ * "Auth AUTH" is semantically a single-term query and must bypass the floor
386
+ * like any other single term — repeating or casing a word doesn't turn it
387
+ * into the multi-term OR-tail noise this floor targets. The normalization is
388
+ * gate-only; it doesn't change `terms` or the BM25 scoring itself, which
389
+ * already matches case-insensitively. For a genuine single term, every hit's
390
+ * occurrence already satisfies the whole query — its BM25 score differences
391
+ * reflect term frequency and document length, not multi-term OR-tail noise,
392
+ * so filtering by it there risks real matches for no corresponding noise
393
+ * reduction. Evidence below confirmed this rather than assuming it.
394
+ *
395
+ * Evidence (scripts/benchmark-recall-quality.ts, run through this exact
396
+ * production function via its `tuning` override — not a duplicate scoring
397
+ * implementation). Two independent runs against real session corpora (23
398
+ * sessions/161 queries and 31 sessions/222 queries; exact counts vary with
399
+ * whatever real sessions are available locally, so both are reported rather
400
+ * than treating one as a fixed target):
401
+ * - floor=0.20 on multi-term queries: median result count 49→23 (run 1,
402
+ * n=69) and 60.5→23.5 (run 2, n=98); p90 142.8→81 and 126.2→75.3.
403
+ * Zero-hit count stayed 0 in both runs, top-1 never changed (0/69,
404
+ * 0/98). Top-5 membership shifted in 5/69 (7%) and 5/98 (5%).
405
+ * - floor=0.10 was too weak to "meaningfully" remove the tail (multi-term
406
+ * median only 49→39 / 60.5→42); floor=0.25 removed more but roughly
407
+ * doubled the multi-term top-5 disruption (8/69, run 1) for little extra
408
+ * median gain over 0.20. 0.20 is the least aggressive setting that
409
+ * meaningfully thinned the tail.
410
+ * - Single-term queries with the floor gated off: every floor candidate
411
+ * (0, 0.10, 0.20, 0.25) produced byte-identical results — 0/92 and
412
+ * 0/124 top-5 changes in both runs, confirming the gate is a true no-op
413
+ * rather than an untested assumption. Before this gate existed, applying
414
+ * 0.20 unconditionally still changed single-term top-5 in a small but
415
+ * non-zero fraction of queries (1/140 in this repo's own rerun, 1/124 in
416
+ * an independent reviewer rerun) for negligible median movement — real
417
+ * false-negative risk for no real noise benefit, which is why the gate
418
+ * exists.
419
+ *
420
+ * The top-scoring hit always survives by construction, independent of the
421
+ * evidence above: its own score always satisfies `score >= topScore * floor`
422
+ * for any floor <= 1, so a non-empty scored[] can never be filtered to zero.
423
+ */
424
+ const BM25_RELATIVE_FLOOR = 0.2;
425
+
426
+ /**
427
+ * Hard cap on total SEARCH results, applied to both the natural-language
428
+ * (post-floor) and regex result paths so pagination stays bounded regardless
429
+ * of how noisy or broad a query is.
430
+ *
431
+ * Evidence (same bench/corpora as BM25_RELATIVE_FLOOR, floor disabled to
432
+ * isolate the cap's effect; run 1 = 161 queries, run 2 = 222 queries):
433
+ * uncapped result counts ranged up to 380 (median 32 / 29.5, p90 119 /
434
+ * 115.9). cap=50 sits ABOVE the corpus's own median in both runs but BELOW
435
+ * its p90 — it leaves the typical (median) query unclipped while still
436
+ * bounding the long tail: only 46/161 (29%) and 66/222 (30%) of queries were
437
+ * truncated by it, versus 90/161 (56%) and 124/222 (56%) for cap=25, which
438
+ * would also clip plenty of unremarkable ~30-match queries well under what
439
+ * "noisy" implies. cap=50 also bounds the worst case (380) down by 87%.
440
+ * Combined with the floor (production policy: floor=0.20 multi-term-only,
441
+ * cap=50, vs. no filtering at all): 0 zero-hit regressions and 0 top-1
442
+ * changes in both runs; top-5 changed in 5/161 (3%) and 5/222 (2%); median
443
+ * result count 32→18 and 29.5→20; p90 119→50 and 115.9→50.
444
+ */
445
+ const SEARCH_RESULT_CAP = 50;
446
+
447
+ /**
448
+ * Tuning overrides for `searchEntriesDetailed`. Exists only so the offline
449
+ * bench (scripts/benchmark-recall-quality.ts) and targeted tests can
450
+ * exercise the real scoring/capping pipeline against candidate constants —
451
+ * production call sites (`searchEntries`, the recall tool) never pass this
452
+ * and always get `BM25_RELATIVE_FLOOR`/`SEARCH_RESULT_CAP`.
453
+ */
454
+ export interface SearchTuning {
455
+ relativeFloor?: number;
456
+ cap?: number;
457
+ }
458
+
459
+ /** Drop scored hits below `floor` of the top score. The top hit's own score
460
+ * always passes (score >= score * floor for floor <= 1), so this can never
461
+ * turn a non-empty `scored` into an empty result. */
462
+ const applyRelativeFloor = (
463
+ scored: Array<{ hit: SearchHit; score: number }>,
464
+ floor: number,
465
+ ): Array<{ hit: SearchHit; score: number }> => {
466
+ if (scored.length === 0) return scored;
467
+ const topScore = scored[0].score;
468
+ if (topScore <= 0) return scored;
469
+ return scored.filter((s) => s.score >= topScore * floor);
470
+ };
471
+
472
+ /**
473
+ * Bound `hits` to `cap` entries, reporting the pre-cap count so callers can
474
+ * signal truncation honestly instead of understating "total matches".
475
+ *
476
+ * Order preserved, never re-sorted: for the BM25 path that's already
477
+ * highest-score-first, so `slice(0, cap)` keeps the top `cap` hits. For the
478
+ * regex path there is no score — hits are collected in entry/chronological
479
+ * iteration order, so `slice(0, cap)` keeps the OLDEST `cap` matches, not
480
+ * the most recent or most relevant ones. That is an explicit, documented
481
+ * preservation choice for this change, not a new selection/ranking policy —
482
+ * changing which matches a truncated regex search keeps (e.g. newest-first)
483
+ * is a separate decision, out of scope here.
484
+ */
485
+ const capHits = (hits: SearchHit[], cap: number): SearchResult => {
486
+ const totalBeforeCap = hits.length;
487
+ const capped = totalBeforeCap > cap ? hits.slice(0, cap) : hits;
488
+ return { hits: capped, totalBeforeCap, truncated: capped.length < totalBeforeCap };
489
+ };
490
+
491
+ /**
492
+ * Full search with truncation metadata. `searchEntries` below is a thin
493
+ * `.hits`-only wrapper kept for existing call sites; use this directly when
494
+ * a caller (the recall tool) needs to report a capped result set honestly.
495
+ */
496
+ export const searchEntriesDetailed = (
497
+ entries: RenderedEntry[],
498
+ messages: Message[],
499
+ query?: string,
500
+ tuning?: SearchTuning,
501
+ ): SearchResult => {
502
+ if (!query?.trim()) return { hits: entries, totalBeforeCap: entries.length, truncated: false };
503
+
504
+ const relativeFloor = tuning?.relativeFloor ?? BM25_RELATIVE_FLOOR;
505
+ const cap = tuning?.cap ?? SEARCH_RESULT_CAP;
506
+ const rawQuery = query.trim();
507
+ const checkBudget = startBudget();
508
+
509
+ // If the query looks like a single regex pattern (contains metacharacters),
510
+ // treat the whole thing as one pattern — don't split into terms.
511
+ //
512
+ // The detection is deliberately loose, so ordinary prose trips it: a trailing
513
+ // "?" or "." turns the whole sentence into one pattern that must match
514
+ // verbatim. On real sessions that path returned nothing 47.5% of the time
515
+ // versus 1.1% for term search. Mode detection must never silently lose
516
+ // results, so an empty regex result falls through to term search below.
517
+ //
518
+ // No relative-floor filtering here: regex matches are boolean (matched or
519
+ // not), there's no score to be relative to. Only the hard cap applies.
520
+ if (looksLikeRegex(rawQuery)) {
521
+ const regex = safeRegex(rawQuery);
522
+ const hits: SearchHit[] = [];
523
+ for (let i = 0; i < entries.length; i++) {
524
+ checkBudget();
525
+ const e = entries[i];
526
+ const msg = messages[i];
527
+ const text = msg ? fullText(msg) : e.summary;
528
+ const filePart = e.files?.join(" ") ?? "";
529
+ const hay = `${e.role} ${text} ${filePart}`;
530
+ if (regex.test(hay)) {
531
+ const snip = lineSnippet(text, regex);
532
+ hits.push({ ...e, snippet: snip, matchCount: 1 });
533
+ }
534
+ }
535
+ if (hits.length > 0) return capHits(hits, cap);
536
+ }
537
+
538
+ // Natural language / multi-word query: BM25 scoring
539
+ const rawTerms = rawQuery.split(/\s+/);
540
+ const terms = filterStopwords(rawTerms);
541
+ const snipRe = snippetRegex(terms);
542
+
543
+ // Build all docs for BM25 context
544
+ const docs: string[] = [];
545
+ for (let i = 0; i < entries.length; i++) {
546
+ const e = entries[i];
547
+ const msg = messages[i];
548
+ const text = msg ? fullText(msg) : e.summary;
549
+ const filePart = e.files?.join(" ") ?? "";
550
+ docs.push(`${e.role} ${text} ${filePart}`);
551
+ }
552
+
553
+ const compiled = compileTerms(terms);
554
+ const docLens = docs.map(wordCount);
555
+ const ctx = buildBM25Context(docs, docLens, compiled, checkBudget);
556
+
557
+ const scored: Array<{ hit: SearchHit; score: number }> = [];
558
+ for (let i = 0; i < entries.length; i++) {
559
+ checkBudget();
560
+ const e = entries[i];
561
+ const hay = docs[i];
562
+ const here = ctx.matched[i];
563
+ const mc = here.length;
564
+ if (mc === 0) continue;
565
+ // Terms absent from the doc add 0, so scoring only the matched ones gives the same sum in the same order.
566
+ const score = bm25Score(hay, docLens[i], here, ctx);
567
+ const text = messages[i] ? fullText(messages[i]) : e.summary;
568
+ const snip = lineSnippet(text, snipRe);
569
+ scored.push({
570
+ hit: { ...e, snippet: snip, matchCount: mc },
571
+ score,
572
+ });
573
+ }
574
+
575
+ // Sort by BM25 score desc, then drop the noisy long tail relative to the
576
+ // top score (multi-term queries only — see BM25_RELATIVE_FLOOR), then
577
+ // apply the hard cap.
578
+ scored.sort((a, b) => b.score - a.score);
579
+ // Gate on DISTINCT normalized terms, not raw term count: "auth auth" or
580
+ // "Auth AUTH" is semantically a single-term query and must bypass the
581
+ // floor like any other single term — repeating/casing a word doesn't turn
582
+ // it into the multi-term OR-tail noise this floor targets. This is a
583
+ // gate-only normalization; it does not change `terms` itself or the BM25
584
+ // scoring above, which already matches case-insensitively.
585
+ const effectiveTermCount = new Set(terms.map((t) => t.toLowerCase())).size;
586
+ const floored = effectiveTermCount >= 2 ? applyRelativeFloor(scored, relativeFloor) : scored;
587
+ return capHits(floored.map((s) => s.hit), cap);
588
+ };
589
+
590
+ export const searchEntries = (
591
+ entries: RenderedEntry[],
592
+ messages: Message[],
593
+ query?: string,
594
+ ): SearchHit[] => searchEntriesDetailed(entries, messages, query).hits;
@@ -0,0 +1,35 @@
1
+ /** Shared skill-tag collapse utilities */
2
+
3
+ const SKILL_TAG_RE = /^-?\s*<skill\s+name="([^"]+)"/;
4
+ const SKILL_CLOSE_RE = /^-?\s*<\/skill>/;
5
+
6
+ /** Collapse skill tags in an array of lines — dedup by name, drop all content inside block */
7
+ export const collapseSkillLines = (lines: string[]): string[] => {
8
+ const result: string[] = [];
9
+ const seenSkills = new Set<string>();
10
+ let insideSkill = false;
11
+
12
+ for (const line of lines) {
13
+ const skillMatch = line.match(SKILL_TAG_RE);
14
+ if (skillMatch) {
15
+ insideSkill = true;
16
+ const name = skillMatch[1];
17
+ if (!seenSkills.has(name)) {
18
+ seenSkills.add(name);
19
+ result.push(`[skill: ${name}]`);
20
+ }
21
+ continue;
22
+ }
23
+ if (insideSkill) {
24
+ if (SKILL_CLOSE_RE.test(line)) insideSkill = false;
25
+ continue;
26
+ }
27
+ result.push(line);
28
+ }
29
+ return result;
30
+ };
31
+
32
+ /** Collapse <skill name="X" ...>...</skill> blocks in raw text */
33
+ const SKILL_BLOCK_RE = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?(?:<\/skill>|$)/g;
34
+ export const collapseSkillText = (text: string): string =>
35
+ text.replace(SKILL_BLOCK_RE, (_, name) => `[skill: ${name}]`);