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.
- package/extensions/main.ts +92 -29
- package/extensions/vcc-core/core/compaction-chain.ts +301 -0
- package/extensions/vcc-core/core/drill-down.ts +11 -4
- package/extensions/vcc-core/core/format-recall.ts +18 -2
- package/extensions/vcc-core/core/global-indices.ts +46 -0
- package/extensions/vcc-core/core/load-messages.ts +116 -21
- package/extensions/vcc-core/core/normalize.ts +13 -13
- package/extensions/vcc-core/core/recall-budget.ts +107 -0
- package/extensions/vcc-core/core/recall-scope.ts +16 -9
- package/extensions/vcc-core/core/search-entries.ts +201 -36
- package/extensions/vcc-core/core/session-lines.ts +81 -0
- package/extensions/vcc-core/core/settings.ts +239 -99
- package/extensions/vcc-core/core/summarize.ts +8 -2
- package/extensions/vcc-core/core/token-estimate.ts +55 -0
- package/extensions/vcc-core/core/tool-output-budget.ts +217 -0
- package/extensions/vcc-core/details.ts +35 -0
- package/extensions/vcc-core/hook.ts +677 -134
- package/package.json +77 -1
- package/types.d.ts +8 -0
|
@@ -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
|
|
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
|
-
/[|*+?{}()[\]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
};
|