omp-vcc 0.1.0

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