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.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/commands/omp-vcc.md +19 -0
- package/commands/vcc-recall.md +21 -0
- package/extensions/main.ts +319 -0
- package/extensions/vcc-core/commands/vcc-recall.ts +2 -0
- package/extensions/vcc-core/core/brief.ts +404 -0
- package/extensions/vcc-core/core/build-sections.ts +77 -0
- package/extensions/vcc-core/core/compact-args.ts +46 -0
- package/extensions/vcc-core/core/content.ts +157 -0
- package/extensions/vcc-core/core/drill-down.ts +299 -0
- package/extensions/vcc-core/core/filter-noise.ts +42 -0
- package/extensions/vcc-core/core/format-recall.ts +101 -0
- package/extensions/vcc-core/core/format.ts +82 -0
- package/extensions/vcc-core/core/lineage.ts +27 -0
- package/extensions/vcc-core/core/load-messages.ts +44 -0
- package/extensions/vcc-core/core/normalize.ts +66 -0
- package/extensions/vcc-core/core/rank.ts +284 -0
- package/extensions/vcc-core/core/recall-scope.ts +31 -0
- package/extensions/vcc-core/core/render-entries.ts +55 -0
- package/extensions/vcc-core/core/report.ts +233 -0
- package/extensions/vcc-core/core/sanitize.ts +6 -0
- package/extensions/vcc-core/core/search-entries.ts +576 -0
- package/extensions/vcc-core/core/settings.ts +151 -0
- package/extensions/vcc-core/core/skill-collapse.ts +36 -0
- package/extensions/vcc-core/core/summarize.ts +208 -0
- package/extensions/vcc-core/core/token-estimate.ts +101 -0
- package/extensions/vcc-core/core/tool-args.ts +17 -0
- package/extensions/vcc-core/details.ts +12 -0
- package/extensions/vcc-core/extract/commits.ts +70 -0
- package/extensions/vcc-core/extract/files.ts +88 -0
- package/extensions/vcc-core/extract/goals.ts +80 -0
- package/extensions/vcc-core/extract/preferences.ts +56 -0
- package/extensions/vcc-core/hook.ts +1017 -0
- package/extensions/vcc-core/sections.ts +9 -0
- package/extensions/vcc-core/types.ts +17 -0
- package/package.json +104 -0
- package/scripts/smoke.ts +116 -0
- package/scripts/uninstall-reset.js +73 -0
- package/skills/omp-vcc/SKILL.md +35 -0
- 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;
|