omp-vcc 0.1.13 → 0.1.14

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.
@@ -3,6 +3,14 @@ import type { Message } from "@oh-my-pi/pi-ai";
3
3
  import type { RenderedEntry } from "./render-entries";
4
4
  import { textOf, thinkingOf, isContentBearing, extractToolCallText, extractToolCallArgsText, clip } from "./content";
5
5
  import { scoreToProbability, estimateLikelihoodParams } from "./bayesian-probability.ts";
6
+ import type { RecallMode } from "./recall-scope";
7
+
8
+ export interface FileMatch {
9
+ path: string;
10
+ toolName: string;
11
+ lineCount: number;
12
+ snippet: string;
13
+ }
6
14
 
7
15
  export interface SearchHit extends RenderedEntry {
8
16
  /** Context snippet around the first matched term (only when query provided) */
@@ -11,6 +19,8 @@ export interface SearchHit extends RenderedEntry {
11
19
  matchCount?: number;
12
20
  /** Calibrated P(relevance) from the Bayesian transform (BM25 path only) */
13
21
  probability?: number;
22
+ /** Matching content-bearing file calls, present in file-only mode. */
23
+ fileMatches?: FileMatch[];
14
24
  }
15
25
 
16
26
  /**
@@ -121,9 +131,60 @@ const startBudget = (): (() => void) => {
121
131
  };
122
132
  };
123
133
 
124
- /** Detect if the query looks like a single regex pattern (contains regex metacharacters). */
134
+ /** Detect an operator-bearing regex pattern. Dots are intentionally excluded:
135
+ * ordinary dotted filenames remain literal, while patterns containing other
136
+ * regex operators (or a standalone dot) still use the regex path. */
125
137
  const looksLikeRegex = (query: string): boolean =>
126
- /[|*+?{}()[\]\\^$.]/.test(query);
138
+ /[|*+?{}()[\]\\^$]/.test(query) || query === ".";
139
+
140
+ const CJK_RE = /[\u1100-\u11ff\u3040-\u30ff\u3100-\u312f\u31a0-\u31bf\u3400-\u9fff\ua960-\ua97f\uac00-\ud7af\ud7b0-\ud7ff\uf900-\ufaff\ufe30-\ufe4f\uff01-\uff60\uffe0-\uffe6\u{20000}-\u{2ffff}]/u;
141
+ const segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
142
+ ? new Intl.Segmenter(undefined, { granularity: "word" })
143
+ : null;
144
+
145
+ const fallbackCjkSegments = (word: string): string[] => {
146
+ const segments: string[] = [];
147
+ let current = "";
148
+ for (const char of word) {
149
+ if (CJK_RE.test(char)) {
150
+ if (current) segments.push(current);
151
+ current = char;
152
+ } else current += char;
153
+ }
154
+ if (current) segments.push(current);
155
+ return segments;
156
+ };
157
+
158
+ const queryTerms = (query: string): string[] => {
159
+ const words = query.trim().split(/\s+/).filter(Boolean);
160
+ const terms: string[] = [];
161
+ for (const word of words) {
162
+ if (CJK_RE.test(word) && !looksLikeRegex(word)) {
163
+ if (segmenter) {
164
+ for (const part of segmenter.segment(word)) {
165
+ if (part.isWordLike) terms.push(part.segment);
166
+ }
167
+ } else terms.push(...fallbackCjkSegments(word));
168
+ } else terms.push(word);
169
+ }
170
+ return terms;
171
+ };
172
+
173
+ const scriptWordCount = (text: string): number => {
174
+ if (segmenter) {
175
+ let count = 0;
176
+ for (const part of segmenter.segment(text)) if (part.isWordLike) count++;
177
+ return count;
178
+ }
179
+ let count = 0;
180
+ for (const token of text.split(/\s+/)) {
181
+ if (!token) continue;
182
+ let cjk = 0;
183
+ for (const char of token) if (CJK_RE.test(char)) cjk++;
184
+ count += Math.max(1, cjk || token.length);
185
+ }
186
+ return count;
187
+ };
127
188
 
128
189
  /** Build a regex for snippet highlighting — matches first available term. */
129
190
  const snippetRegex = (sources: string[]): RegExp =>
@@ -144,9 +205,8 @@ const STOPWORDS = new Set([
144
205
  "this", "what", "which", "who", "whom", "these", "those",
145
206
  ]);
146
207
 
147
- /** Remove stopwords, keep meaningful terms. */
148
208
  const filterStopwords = (terms: string[]): string[] => {
149
- const meaningful = terms.filter((t) => !STOPWORDS.has(t.toLowerCase()) && t.length > 1);
209
+ const meaningful = terms.filter((t) => !STOPWORDS.has(t.toLowerCase()) && (t.length > 1 || CJK_RE.test(t)));
150
210
  // If all terms were stopwords, return original (don't lose everything)
151
211
  return meaningful.length > 0 ? meaningful : terms;
152
212
  };
@@ -162,7 +222,7 @@ interface CompiledTerm {
162
222
 
163
223
  const compileTerms = (terms: string[]): CompiledTerm[] =>
164
224
  terms.map((t) => {
165
- const re = safeRegex(t);
225
+ const re = looksLikeRegex(t) ? safeRegex(t) : new RegExp(escapeRegex(t), "i");
166
226
  return { term: t, re, freqRe: new RegExp(re.source, "gi") };
167
227
  });
168
228
 
@@ -212,9 +272,6 @@ const buildBM25Context = (docs: string[], compiled: CompiledTerm[], wordLens: nu
212
272
  return { n, avgDl: totalLen / Math.max(n, 1), df };
213
273
  };
214
274
 
215
- /** BM25 score for a single doc against query terms, plus the calibration
216
- * inputs the Bayesian posterior needs. `dl` is the doc's word count,
217
- * measured once by the caller alongside `wordLens` — no re-splitting. */
218
275
  const bm25Score = (doc: string, compiled: CompiledTerm[], ctx: BM25Context, dl: number): { score: number; tf: number; distinctTerms: number; docLenRatio: number } => {
219
276
  let score = 0;
220
277
  let totalTf = 0;
@@ -227,7 +284,6 @@ const bm25Score = (doc: string, compiled: CompiledTerm[], ctx: BM25Context, dl:
227
284
  seenTerms.add(c.term.toLowerCase());
228
285
 
229
286
  const docFreq = ctx.df.get(c.term) ?? 0;
230
- // IDF: log((N - df + 0.5) / (df + 0.5) + 1)
231
287
  const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
232
288
  const tfNorm = (termTf * (BM25_K + 1)) / (termTf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
233
289
  score += idf * tfNorm;
@@ -240,9 +296,12 @@ const bm25Score = (doc: string, compiled: CompiledTerm[], ctx: BM25Context, dl:
240
296
  const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | undefined => {
241
297
  const lines = text.split("\n");
242
298
  let matchIdx = -1;
299
+ let matchedLine: { index: number; length: number } | undefined;
243
300
  for (let i = 0; i < lines.length; i++) {
244
- if (regex.test(lines[i])) {
301
+ const match = lines[i].match(regex);
302
+ if (match?.index !== undefined) {
245
303
  matchIdx = i;
304
+ matchedLine = { index: match.index, length: match[0].length };
246
305
  break;
247
306
  }
248
307
  }
@@ -250,15 +309,120 @@ const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | un
250
309
 
251
310
  const start = Math.max(0, matchIdx - contextLines);
252
311
  const end = Math.min(lines.length, matchIdx + contextLines + 1);
253
- const slice = lines.slice(start, end);
254
-
255
312
  const parts: string[] = [];
256
313
  if (start > 0) parts.push(`...(${start} lines above)`);
257
- parts.push(...slice);
314
+ for (let i = start; i < end; i++) {
315
+ parts.push(clipLineAroundMatch(lines[i], i === matchIdx ? matchedLine : undefined));
316
+ }
258
317
  if (end < lines.length) parts.push(`...(${lines.length - end} lines below)`);
259
318
  return parts.join("\n");
260
319
  };
261
320
 
321
+ const LINE_SNIPPET_MAX_CHARS = 2_000;
322
+
323
+ /** Clip an overlong line while keeping the first match near the center. */
324
+ const clipLineAroundMatch = (
325
+ line: string,
326
+ match?: { index: number; length: number },
327
+ ): string => {
328
+ if (line.length <= LINE_SNIPPET_MAX_CHARS) return line;
329
+ const markerBudget = 64;
330
+ const contentBudget = LINE_SNIPPET_MAX_CHARS - markerBudget;
331
+ const matchIndex = match?.index ?? 0;
332
+ const matchLength = match?.length ?? 0;
333
+ const retainedMatch = Math.min(matchLength, contentBudget);
334
+ const contextBudget = Math.max(1, Math.floor((contentBudget - retainedMatch) / 2));
335
+ let start = Math.max(0, matchIndex - contextBudget);
336
+ let end = Math.min(line.length, start + contentBudget);
337
+ if (end < start + contentBudget) start = Math.max(0, end - contentBudget);
338
+
339
+ // Keep UTF-16 slice boundaries on code-point boundaries.
340
+ if (start > 0 && start < line.length) {
341
+ const code = line.charCodeAt(start);
342
+ if (code >= 0xdc00 && code <= 0xdfff) start++;
343
+ }
344
+ if (end > start && end < line.length) {
345
+ const code = line.charCodeAt(end - 1);
346
+ if (code >= 0xd800 && code <= 0xdbff) end--;
347
+ }
348
+
349
+ const before = start;
350
+ const after = line.length - end;
351
+ const beforeMarker = before > 0 ? `...(${before} chars before)` : "";
352
+ const afterMarker = after > 0 ? `...(${after} chars after)` : "";
353
+ return `${beforeMarker}${line.slice(start, end)}${afterMarker}`;
354
+ };
355
+
356
+ const filePathFromArgs = (args: Record<string, unknown>): string | undefined =>
357
+ ["path", "filePath", "file_path", "file"]
358
+ .map((key) => args[key])
359
+ .find((value): value is string => typeof value === "string");
360
+
361
+ /** Path plus content-bearing fields for one file tool call. */
362
+ const fileToolPartText = (part: Record<string, unknown>): { path?: string; text: string } => {
363
+ const args = part.arguments as Record<string, unknown>;
364
+ if (!isContentBearing(args)) return {};
365
+ const path = filePathFromArgs(args);
366
+ const content = extractToolCallText(args);
367
+ return { path, text: [path, content].filter(Boolean).join("\n") };
368
+ };
369
+
370
+ /**
371
+ * Extract only content-bearing file tool-call arguments for mode:file.
372
+ * Shell execution and ordinary prose are intentionally not indexed.
373
+ */
374
+ const fileToolText = (msg: Message): string => {
375
+ if (msg?.role === "bashExecution" || !Array.isArray(msg?.content)) return "";
376
+ const pieces: string[] = [];
377
+ for (const part of msg.content as Record<string, unknown>[]) {
378
+ if (!part || part.type !== "toolCall") continue;
379
+ if (String(part.name ?? "").toLowerCase() === "bashexecution") continue;
380
+ const { text } = fileToolPartText(part);
381
+ if (text) pieces.push(text);
382
+ }
383
+ return clip(pieces.join("\n"), TOOL_ARGS_BUDGET);
384
+ };
385
+
386
+ const fileMatchesFor = (msg: Message, regex: RegExp): FileMatch[] => {
387
+ if (msg?.role === "bashExecution" || !Array.isArray(msg?.content)) return [];
388
+ const matches: FileMatch[] = [];
389
+ for (const part of msg.content as Record<string, unknown>[]) {
390
+ if (!part || part.type !== "toolCall") continue;
391
+ if (String(part.name ?? "").toLowerCase() === "bashexecution") continue;
392
+ const args = part.arguments as Record<string, unknown>;
393
+ if (!isContentBearing(args)) continue;
394
+ const { path, text: pathAndContent } = fileToolPartText(part);
395
+ if (!path || !pathAndContent || !regex.test(pathAndContent)) continue;
396
+ const text = extractToolCallText(args);
397
+ const matchingLines = [path, ...text.split("\n")]
398
+ .filter((line) => line.trim().length > 0 && regex.test(line));
399
+ matches.push({
400
+ path,
401
+ toolName: String(part.name ?? ""),
402
+ lineCount: matchingLines.length,
403
+ snippet: clip(lineSnippet(pathAndContent, regex, 1) ?? path, 2_000),
404
+ });
405
+ }
406
+ return matches;
407
+ };
408
+
409
+ const fileMatchesWithoutQuery = (msg: Message): FileMatch[] => {
410
+ if (msg?.role === "bashExecution" || !Array.isArray(msg?.content)) return [];
411
+ const matches: FileMatch[] = [];
412
+ for (const part of msg.content as Record<string, unknown>[]) {
413
+ if (!part || part.type !== "toolCall") continue;
414
+ if (String(part.name ?? "").toLowerCase() === "bashexecution") continue;
415
+ const { path, text } = fileToolPartText(part);
416
+ if (!path || !text) continue;
417
+ const content = extractToolCallText(part.arguments as Record<string, unknown>);
418
+ const lineCount = content.split("\n").filter((line) => line.trim().length > 0).length;
419
+ matches.push({ path, toolName: String(part.name ?? ""), lineCount, snippet: clip(text, 2_000) });
420
+ }
421
+ return matches;
422
+ };
423
+
424
+ const fileText = fileToolText;
425
+
262
426
  /**
263
427
  * Aggregate character budget for ALL toolCall arguments appended to one
264
428
  * message's searchable text — a single shared budget across every toolCall
@@ -455,6 +619,7 @@ const SEARCH_RESULT_CAP = 50;
455
619
  export interface SearchTuning {
456
620
  probabilityFloor?: number;
457
621
  cap?: number;
622
+ mode?: RecallMode;
458
623
  }
459
624
 
460
625
  /** Drop scored hits that are BOTH below the absolute posterior `floor` AND
@@ -506,24 +671,25 @@ export const searchEntriesDetailed = (
506
671
  query?: string,
507
672
  tuning?: SearchTuning,
508
673
  ): SearchResult => {
509
- if (!query?.trim()) return { hits: entries, totalBeforeCap: entries.length, truncated: false };
510
-
511
674
  const probabilityFloor = tuning?.probabilityFloor ?? BAYESIAN_PROBABILITY_FLOOR;
512
675
  const cap = tuning?.cap ?? SEARCH_RESULT_CAP;
676
+ const mode = tuning?.mode ?? "hybrid";
677
+ if (!query?.trim()) {
678
+ if (mode !== "file") return { hits: entries, totalBeforeCap: entries.length, truncated: false };
679
+ const hits: SearchHit[] = [];
680
+ for (let i = 0; i < entries.length; i++) {
681
+ const message = messages[i];
682
+ const fileMatches = fileMatchesWithoutQuery(message);
683
+ if (fileMatches.length > 0) hits.push({ ...entries[i], fileMatches });
684
+ }
685
+ return capHits(hits, cap);
686
+ }
513
687
  const rawQuery = query.trim();
514
688
  const checkBudget = startBudget();
515
689
 
516
690
  // If the query looks like a single regex pattern (contains metacharacters),
517
- // treat the whole thing as one pattern — don't split into terms.
518
- //
519
- // The detection is deliberately loose, so ordinary prose trips it: a trailing
520
- // "?" or "." turns the whole sentence into one pattern that must match
521
- // verbatim. On real sessions that path returned nothing 47.5% of the time
522
- // versus 1.1% for term search. Mode detection must never silently lose
523
- // results, so an empty regex result falls through to term search below.
524
- //
525
- // No posterior-gate filtering here: regex matches are boolean (matched or
526
- // not), there's no probability to threshold. Only the hard cap applies.
691
+ // treat the whole thing as one pattern — don't split into terms. Dotted
692
+ // filenames are excluded from operator detection and stay literal.
527
693
  if (looksLikeRegex(rawQuery)) {
528
694
  const regex = safeRegex(rawQuery);
529
695
  const hits: SearchHit[] = [];
@@ -531,38 +697,36 @@ export const searchEntriesDetailed = (
531
697
  checkBudget();
532
698
  const e = entries[i];
533
699
  const msg = messages[i];
534
- const text = msg ? fullText(msg) : e.summary;
700
+ const text = msg ? (mode === "file" ? fileText(msg) : fullText(msg)) : (mode === "file" ? "" : e.summary);
535
701
  const filePart = e.files?.join(" ") ?? "";
536
- const hay = `${e.role} ${text} ${filePart}`;
702
+ const hay = mode === "file" ? text : `${e.role} ${text} ${filePart}`;
537
703
  if (regex.test(hay)) {
538
704
  const snip = lineSnippet(text, regex);
539
- hits.push({ ...e, snippet: snip, matchCount: 1 });
705
+ const fileMatches = mode === "file" && msg ? fileMatchesFor(msg, regex) : undefined;
706
+ hits.push({ ...e, snippet: snip, matchCount: 1, ...(fileMatches?.length ? { fileMatches } : {}) });
540
707
  }
541
708
  }
542
709
  if (hits.length > 0) return capHits(hits, cap);
543
710
  }
544
711
 
545
712
  // Natural language / multi-word query: BM25 scoring
546
- const rawTerms = rawQuery.split(/\s+/);
713
+ const rawTerms = queryTerms(rawQuery);
547
714
  const terms = filterStopwords(rawTerms);
548
715
  const compiled = compileTerms(terms);
549
716
  const snipRe = snippetRegex(compiled.map((c) => c.re.source));
550
717
 
551
- // Build all docs for BM25 context. Each message's searchable text is
552
- // extracted once here (`texts`) and reused for snippets below; word
553
- // counts (`wordLens`) are measured once for both context and scoring.
554
718
  const docs: string[] = [];
555
719
  const texts: string[] = [];
556
720
  const wordLens: number[] = [];
557
721
  for (let i = 0; i < entries.length; i++) {
558
722
  const e = entries[i];
559
723
  const msg = messages[i];
560
- const text = msg ? fullText(msg) : e.summary;
724
+ const text = msg ? (mode === "file" ? fileText(msg) : fullText(msg)) : (mode === "file" ? "" : e.summary);
561
725
  const filePart = e.files?.join(" ") ?? "";
562
- const hay = `${e.role} ${text} ${filePart}`;
726
+ const hay = mode === "file" ? text : `${e.role} ${text} ${filePart}`;
563
727
  docs.push(hay);
564
728
  texts.push(text);
565
- wordLens.push(hay.split(/\s+/).length);
729
+ wordLens.push(scriptWordCount(hay));
566
730
  }
567
731
 
568
732
  const ctx = buildBM25Context(docs, compiled, wordLens, checkBudget);
@@ -576,8 +740,9 @@ export const searchEntriesDetailed = (
576
740
  if (mc === 0) continue;
577
741
  const { score, tf, distinctTerms, docLenRatio } = bm25Score(hay, compiled, ctx, wordLens[i]);
578
742
  const snip = lineSnippet(texts[i], snipRe);
743
+ const fileMatches = mode === "file" && messages[i] ? fileMatchesFor(messages[i], snipRe) : undefined;
579
744
  scored.push({
580
- hit: { ...e, snippet: snip, matchCount: mc },
745
+ hit: { ...e, snippet: snip, matchCount: mc, ...(fileMatches?.length ? { fileMatches } : {}) },
581
746
  score,
582
747
  tf,
583
748
  distinctTerms,
@@ -0,0 +1,81 @@
1
+ // @ts-nocheck
2
+ import { closeSync, openSync, readSync } from "fs";
3
+ import { StringDecoder } from "string_decoder";
4
+
5
+ const READ_BUFFER_BYTES = 64 * 1024;
6
+
7
+ export interface ScanSessionEntriesResult<T> {
8
+ /** True only when opening the file failed with ENOENT. */
9
+ missing: boolean;
10
+ /** Number of malformed, nonblank JSONL lines encountered. */
11
+ parseErrors: number;
12
+ /** Present when no per-line callback was supplied. */
13
+ entries?: T[];
14
+ }
15
+
16
+ export type SessionEntryCallback<T> = (entry: T) => void;
17
+
18
+ /**
19
+ * Stream persisted JSONL entries without reading the session as one string.
20
+ * Blank lines are ignored. Malformed nonblank lines are counted and skipped;
21
+ * filesystem errors other than a missing file propagate to the caller.
22
+ */
23
+ export const scanSessionEntries = <T = unknown>(
24
+ filePath: string,
25
+ onEntry?: SessionEntryCallback<T>,
26
+ ): ScanSessionEntriesResult<T> => {
27
+ let fd: number;
28
+ try {
29
+ fd = openSync(filePath, "r");
30
+ } catch (error) {
31
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
32
+ return { missing: true, parseErrors: 0 };
33
+ }
34
+ throw error;
35
+ }
36
+
37
+ const entries = onEntry ? undefined : ([] as T[]);
38
+ let parseErrors = 0;
39
+ let remainder = "";
40
+ const decoder = new StringDecoder("utf8");
41
+ const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
42
+ let position = 0;
43
+
44
+ const consumeLine = (rawLine: string) => {
45
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
46
+ if (!line.trim()) return;
47
+
48
+ let entry: T;
49
+ try {
50
+ entry = JSON.parse(line) as T;
51
+ } catch {
52
+ parseErrors++;
53
+ return;
54
+ }
55
+ if (onEntry) onEntry(entry);
56
+ else entries.push(entry);
57
+ };
58
+
59
+ try {
60
+ let bytesRead: number;
61
+ while ((bytesRead = readSync(fd, buffer, 0, buffer.length, position)) > 0) {
62
+ position += bytesRead;
63
+ const chunk = decoder.write(buffer.subarray(0, bytesRead));
64
+ let cursor = 0;
65
+ let newline = chunk.indexOf("\n");
66
+ while (newline !== -1) {
67
+ consumeLine(remainder + chunk.slice(cursor, newline));
68
+ remainder = "";
69
+ cursor = newline + 1;
70
+ newline = chunk.indexOf("\n", cursor);
71
+ }
72
+ remainder += chunk.slice(cursor);
73
+ }
74
+ remainder += decoder.end();
75
+ if (remainder) consumeLine(remainder);
76
+ } finally {
77
+ closeSync(fd);
78
+ }
79
+
80
+ return { missing: false, parseErrors, ...(entries ? { entries } : {}) };
81
+ };