omp-vcc 0.1.6 → 0.1.7
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 +23 -4
- package/extensions/vcc-core/core/bayesian-probability.ts +93 -0
- package/extensions/vcc-core/core/brief.ts +40 -11
- package/extensions/vcc-core/core/content.ts +11 -0
- package/extensions/vcc-core/core/drill-down.ts +85 -0
- package/extensions/vcc-core/core/format-recall.ts +9 -1
- package/extensions/vcc-core/core/normalize.ts +3 -0
- package/extensions/vcc-core/core/render-entries.ts +5 -1
- package/extensions/vcc-core/core/search-entries.ts +109 -82
- package/extensions/vcc-core/core/token-estimate.ts +77 -0
- package/extensions/vcc-core/hook.ts +16 -4
- package/extensions/vcc-core/types.ts +1 -0
- package/package.json +1 -1
package/extensions/main.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { searchEntriesDetailed, getTouchedFiles } from "./vcc-core/core/search-e
|
|
|
24
24
|
import { formatRecallOutput, formatTouchedOutput } from "./vcc-core/core/format-recall";
|
|
25
25
|
import { getActiveLineageEntryIds } from "./vcc-core/core/lineage";
|
|
26
26
|
import { normalizeRecallScope, normalizeRecallMode, parseRecallScope } from "./vcc-core/core/recall-scope";
|
|
27
|
-
import { parseDrillDown, expandEntryFile } from "./vcc-core/core/drill-down";
|
|
27
|
+
import { parseDrillDown, expandEntryFile, parseEntryRef, expandEntry } from "./vcc-core/core/drill-down";
|
|
28
28
|
import { buildPiVccCustomInstructions, parseKeepAndPrompt } from "./vcc-core/core/compact-args";
|
|
29
29
|
|
|
30
30
|
// Build omp sentinel instructions; keep pi sentinel for backward compat in hook
|
|
@@ -93,6 +93,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
93
93
|
|
|
94
94
|
const q = p.query?.trim();
|
|
95
95
|
|
|
96
|
+
if (q && parseEntryRef(q)) {
|
|
97
|
+
const ref = parseEntryRef(q);
|
|
98
|
+
if (!ref) {
|
|
99
|
+
return { content: [{ type: "text", text: "Invalid entry ref query." }], details: undefined };
|
|
100
|
+
}
|
|
101
|
+
if (lineageEntryIds) {
|
|
102
|
+
const { rendered } = loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
103
|
+
const exists = rendered.some((m) => m.index === ref.index);
|
|
104
|
+
if (!exists) {
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${ref.index}. Use scope:'all' to reach other branches.` }],
|
|
107
|
+
details: undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const text = expandEntry(sessionFile, ref.index, ref.full, ref.offset, ref.limit);
|
|
112
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
if (q && parseDrillDown(q)) {
|
|
97
116
|
const parsed = parseDrillDown(q);
|
|
98
117
|
if (!parsed) {
|
|
@@ -153,7 +172,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
153
172
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
154
173
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
155
174
|
const footer = page < totalPages ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---` : "";
|
|
156
|
-
const output = formatRecallOutput(pageResults, q, header) + footer;
|
|
175
|
+
const output = formatRecallOutput(pageResults, q, header, { truncated, totalBeforeCap }) + footer;
|
|
157
176
|
return { content: [{ type: "text", text: output }], details: undefined };
|
|
158
177
|
}
|
|
159
178
|
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(msgs.slice(-DEFAULT_RECENT), q);
|
|
@@ -277,7 +296,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
277
296
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
278
297
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
279
298
|
const footer = page < totalPages ? `\n--- /vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
|
|
280
|
-
const output = formatRecallOutput(pageResults, query, header) + footer;
|
|
299
|
+
const output = formatRecallOutput(pageResults, query, header, { truncated, totalBeforeCap }) + footer;
|
|
281
300
|
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
282
301
|
try { c.ui.notify(`vcc_recall: ${hits.length} hits`, "info"); } catch {}
|
|
283
302
|
},
|
|
@@ -316,7 +335,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
316
335
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
317
336
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
318
337
|
const footer = page < totalPages ? `\n--- /pi-vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
|
|
319
|
-
const output = formatRecallOutput(pageResults, query, header) + footer;
|
|
338
|
+
const output = formatRecallOutput(pageResults, query, header, { truncated, totalBeforeCap }) + footer;
|
|
320
339
|
|
|
321
340
|
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
322
341
|
},
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
//
|
|
3
|
+
// Minimal port of the Bayesian probability transform from Cognica's
|
|
4
|
+
// `bayesian-bm25-js` (Apache-2.0), itself the reference implementation of
|
|
5
|
+
// Jeong 2026a "Bayesian BM25: A Probabilistic Framework for Hybrid Text and
|
|
6
|
+
// Vector Search" (DOI 10.5281/zenodo.18414940).
|
|
7
|
+
//
|
|
8
|
+
// Only the score→probability pipeline is ported: sigmoid likelihood (Eq. 20),
|
|
9
|
+
// term-frequency prior (Eq. 25), document-length prior (Eq. 26), composite
|
|
10
|
+
// prior (Eq. 27), and the Bayesian posterior without base-rate correction
|
|
11
|
+
// (Eq. 22). Deliberately NOT ported: the BM25 scorer itself (this repo keeps
|
|
12
|
+
// its own BM25-lite), parameter fitting, online updates, multi-signal fusion,
|
|
13
|
+
// vector calibration, and WAND bounds — none has a consumer here. If a ported
|
|
14
|
+
// formula ever contradicts the paper, the paper wins.
|
|
15
|
+
//
|
|
16
|
+
// Converts unbounded BM25 scores into calibrated P(relevance) in [0,1] so a
|
|
17
|
+
// single ABSOLUTE cutoff behaves consistently across sessions of different
|
|
18
|
+
// sizes — the relative-floor heuristic this replaces existed only because raw
|
|
19
|
+
// BM25 magnitudes are not comparable across corpora.
|
|
20
|
+
|
|
21
|
+
/** Clamp floor/ceiling for probabilities (upstream EPSILON). */
|
|
22
|
+
const EPSILON = 1e-10;
|
|
23
|
+
|
|
24
|
+
/** Clamp `p` into [EPSILON, 1 - EPSILON]. */
|
|
25
|
+
export const clampProbability = (p: number): number =>
|
|
26
|
+
Math.max(EPSILON, Math.min(1.0 - EPSILON, p));
|
|
27
|
+
|
|
28
|
+
/** Numerically stable sigmoid (upstream branch: avoids exp overflow). */
|
|
29
|
+
export const sigmoid = (x: number): number => {
|
|
30
|
+
if (x >= 0) return 1.0 / (1.0 + Math.exp(-x));
|
|
31
|
+
const expX = Math.exp(x);
|
|
32
|
+
return expX / (1.0 + expX);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Sigmoid likelihood of relevance given a BM25 score (Eq. 20):
|
|
36
|
+
* sigma(alpha * (score - beta)). */
|
|
37
|
+
export const scoreLikelihood = (score: number, alpha: number, beta: number): number =>
|
|
38
|
+
sigmoid(alpha * (score - beta));
|
|
39
|
+
|
|
40
|
+
/** Term-frequency prior (Eq. 25): 0.2 + 0.7 * min(1, tf / 10). */
|
|
41
|
+
export const tfPrior = (tf: number): number =>
|
|
42
|
+
0.2 + 0.7 * Math.min(1.0, tf / 10.0);
|
|
43
|
+
|
|
44
|
+
/** Document-length normalisation prior (Eq. 26): peaks at half the average
|
|
45
|
+
* document length, decaying for very short or very long documents. */
|
|
46
|
+
export const normPrior = (docLenRatio: number): number =>
|
|
47
|
+
0.3 + 0.6 * (1.0 - Math.min(1.0, Math.abs(docLenRatio - 0.5) * 2.0));
|
|
48
|
+
|
|
49
|
+
/** Composite prior (Eq. 27): clamp(0.7 * P_tf + 0.3 * P_norm, 0.1, 0.9).
|
|
50
|
+
* Note the bounds are plain 0.1/0.9, not `clampProbability`. */
|
|
51
|
+
export const compositePrior = (tf: number, docLenRatio: number): number =>
|
|
52
|
+
Math.max(0.1, Math.min(0.9, 0.7 * tfPrior(tf) + 0.3 * normPrior(docLenRatio)));
|
|
53
|
+
|
|
54
|
+
/** Bayesian posterior without base-rate correction (Eq. 22, first step):
|
|
55
|
+
* L*p / (L*p + (1-L)*(1-p)). Base rate stays null: its estimators need
|
|
56
|
+
* pseudo-query sampling over a corpus with relevance labels, which a live
|
|
57
|
+
* recall query cannot provide. */
|
|
58
|
+
export const posterior = (likelihood: number, prior: number): number => {
|
|
59
|
+
const numerator = likelihood * prior;
|
|
60
|
+
return clampProbability(numerator / (numerator + (1.0 - likelihood) * (1.0 - prior)));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Full pipeline: BM25 score -> calibrated P(relevance).
|
|
64
|
+
*
|
|
65
|
+
* Likelihood from the score, composite prior from tf and doc-length ratio,
|
|
66
|
+
* combined by the Bayesian posterior. This is a per-document aggregate
|
|
67
|
+
* transform (total score, total tf), an approximation of per-term posterior
|
|
68
|
+
* fusion — sufficient for a noise gate, not a ranking signal. */
|
|
69
|
+
export const scoreToProbability = (
|
|
70
|
+
score: number,
|
|
71
|
+
tf: number,
|
|
72
|
+
docLenRatio: number,
|
|
73
|
+
alpha: number,
|
|
74
|
+
beta: number,
|
|
75
|
+
): number =>
|
|
76
|
+
posterior(scoreLikelihood(score, alpha, beta), compositePrior(tf, docLenRatio));
|
|
77
|
+
|
|
78
|
+
/** Estimate sigmoid midpoint/shift from the query's own nonzero BM25 scores:
|
|
79
|
+
* beta = median, alpha = 1/std (std = 0 -> alpha = 1.0). Mirrors
|
|
80
|
+
* `BayesianBM25Scorer._estimateParameters` without its seeded pseudo-query
|
|
81
|
+
* sampling step — deterministic, one O(n log n) pass over scores already
|
|
82
|
+
* computed. Returns null when there is nothing to calibrate. */
|
|
83
|
+
export const estimateLikelihoodParams = (scores: number[]): { alpha: number; beta: number } | null => {
|
|
84
|
+
const nonzero = scores.filter((s) => s > 0);
|
|
85
|
+
if (nonzero.length === 0) return null;
|
|
86
|
+
const sorted = [...nonzero].sort((a, b) => a - b);
|
|
87
|
+
const mid = Math.floor(sorted.length / 2);
|
|
88
|
+
const beta = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
89
|
+
const mean = nonzero.reduce((a, b) => a + b, 0) / nonzero.length;
|
|
90
|
+
const variance = nonzero.reduce((a, b) => a + (b - mean) ** 2, 0) / nonzero.length;
|
|
91
|
+
const std = Math.sqrt(variance);
|
|
92
|
+
return { alpha: std > 0 ? 1.0 / std : 1.0, beta };
|
|
93
|
+
};
|
|
@@ -237,6 +237,20 @@ const toolOneLiner = (name: string, args: Record<string, unknown>): string => {
|
|
|
237
237
|
return `* ${name}`;
|
|
238
238
|
};
|
|
239
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Source index of the result for the tool_call at `from`: the first following
|
|
242
|
+
* tool_result block with the same tool name, stopping at the next
|
|
243
|
+
* tool_call/user boundary. Null when the call has no visible result.
|
|
244
|
+
*/
|
|
245
|
+
const findToolResultIndex = (blocks: NormalizedBlock[], from: number, name: string): number | null => {
|
|
246
|
+
for (let i = from + 1; i < blocks.length; i++) {
|
|
247
|
+
const n = blocks[i];
|
|
248
|
+
if (n.kind === "tool_result" && n.name === name) return n.sourceIndex ?? null;
|
|
249
|
+
if (n.kind === "tool_call" || n.kind === "user") break;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
|
|
240
254
|
export interface BriefLine {
|
|
241
255
|
/** Section header like "[user]" or "[assistant]" */
|
|
242
256
|
header: string;
|
|
@@ -310,32 +324,47 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
|
310
324
|
case "tool_call": {
|
|
311
325
|
// Skip malformed tool calls from streaming providers (empty name / fragmented args).
|
|
312
326
|
if (!b.name || b.name.trim() === "") break;
|
|
313
|
-
const
|
|
327
|
+
const resultIdx = findToolResultIndex(blocks, blockIndex, b.name);
|
|
328
|
+
const ref = b.sourceIndex != null
|
|
329
|
+
? (resultIdx != null ? ` (#${b.sourceIndex}, result #${resultIdx})` : ` (#${b.sourceIndex})`)
|
|
330
|
+
: (resultIdx != null ? ` (result #${resultIdx})` : "");
|
|
314
331
|
const summary = toolOneLiner(b.name, b.args) + ref;
|
|
315
332
|
push("[assistant]", summary);
|
|
316
333
|
break;
|
|
317
334
|
}
|
|
335
|
+
case "thinking":
|
|
336
|
+
// Searchable via recall, elided from the brief (reference lower_brief parity).
|
|
337
|
+
break;
|
|
318
338
|
case "tool_result":
|
|
319
339
|
// Tool result bodies are intentionally omitted from compact briefs.
|
|
320
340
|
break;
|
|
321
341
|
}
|
|
322
342
|
}
|
|
323
343
|
|
|
324
|
-
// Collapse consecutive identical tool lines (same text, different
|
|
344
|
+
// Collapse consecutive identical tool lines (same text, different refs).
|
|
345
|
+
// Ref tokens are `#N` (call) or `result #N`; a merge accumulates tokens in
|
|
346
|
+
// order and sums repeat counts, e.g. `* Read "a" (#1, result #2)` followed
|
|
347
|
+
// by `* Read "a" (#3, result #4)` becomes
|
|
348
|
+
// `* Read "a" (#1, result #2, #3, result #4) x2`.
|
|
349
|
+
const isRefToken = (t: string): boolean => /^#\d+$/.test(t) || /^result #\d+$/.test(t);
|
|
350
|
+
const splitToolLine = (line: string): { base: string; refs: string[]; count: number } | null => {
|
|
351
|
+
const m = line.match(/^(.*?) \(([^()]*)\)(?: x(\d+))?$/);
|
|
352
|
+
if (!m) return null;
|
|
353
|
+
const refs = m[2].split(",").map((s) => s.trim()).filter(Boolean);
|
|
354
|
+
if (refs.length === 0 || !refs.every(isRefToken)) return null;
|
|
355
|
+
return { base: m[1], refs, count: m[3] ? parseInt(m[3]) : 1 };
|
|
356
|
+
};
|
|
325
357
|
for (const sec of sections) {
|
|
326
358
|
if (sec.header !== "[assistant]") continue;
|
|
327
359
|
const out: string[] = [];
|
|
328
360
|
for (const line of sec.lines) {
|
|
329
361
|
if (!line.startsWith("* ")) { out.push(line); continue; }
|
|
330
|
-
const
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
out[out.length - 1] = `${base} (${
|
|
336
|
-
} else if (last.match(/\(#\d+\)$/) && last.replace(/\s*\(#\d+\)$/, "") === base) {
|
|
337
|
-
const prevRef = last.match(/\(#(\d+)\)$/)?.[1];
|
|
338
|
-
out[out.length - 1] = `${base} (#${prevRef}, #${ref}) x2`;
|
|
362
|
+
const cur = splitToolLine(line);
|
|
363
|
+
const last = out.length > 0 ? splitToolLine(out[out.length - 1]) : null;
|
|
364
|
+
if (cur && last && cur.base === last.base) {
|
|
365
|
+
const refs = [...last.refs];
|
|
366
|
+
for (const r of cur.refs) if (!refs.includes(r)) refs.push(r);
|
|
367
|
+
out[out.length - 1] = `${cur.base} (${refs.join(", ")}) x${last.count + cur.count}`;
|
|
339
368
|
} else {
|
|
340
369
|
out.push(line);
|
|
341
370
|
}
|
|
@@ -50,6 +50,17 @@ export const textParts = (content: Message["content"]): string[] => {
|
|
|
50
50
|
export const textOf = (content: Message["content"]): string =>
|
|
51
51
|
textParts(content).join("\n");
|
|
52
52
|
|
|
53
|
+
export const thinkingParts = (content: Message["content"]): string[] => {
|
|
54
|
+
if (!content || typeof content === "string") return [];
|
|
55
|
+
return content
|
|
56
|
+
.filter((part) => part.type === "thinking")
|
|
57
|
+
.map((part) => (part.thinking ?? part.text ?? "") as string)
|
|
58
|
+
.filter((t) => typeof t === "string" && t.length > 0);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const thinkingOf = (content: Message["content"]): string =>
|
|
62
|
+
thinkingParts(content).join("\n");
|
|
63
|
+
|
|
53
64
|
/**
|
|
54
65
|
* Check if tool call arguments contain content-bearing data.
|
|
55
66
|
*
|
|
@@ -296,4 +296,89 @@ Use #${entryIndex}:<more-specific-path> to drill into a specific file.`;
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
return formatToolCallContent(matched[0], entryIndex, { full, offset, limit });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Bare entry refs: #N, #N:full, #N:offset, #N:offset:limit ────────────────
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Pattern: #N, #N:full, #N:offset, or #N:offset:limit — full entry text.
|
|
305
|
+
* Checked BEFORE parseDrillDown in dispatch: a purely numeric path segment
|
|
306
|
+
* (`#42:30`) reads as entry 42 at line offset 30, consistent with the
|
|
307
|
+
* trailing-number-means-offset convention of #N:path:offset.
|
|
308
|
+
*/
|
|
309
|
+
const ENTRYREF_PATTERN = /^#(\d+)(?::(full|\d+(?::\d+)?))?$/;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Parse a bare entry ref like #42, #42:full, #42:30, or #42:30:20.
|
|
313
|
+
* Returns null unless the whole query is the ref pattern (same ^$ anchoring
|
|
314
|
+
* contract as parseDrillDown).
|
|
315
|
+
*/
|
|
316
|
+
export function parseEntryRef(query: string): {
|
|
317
|
+
index: number;
|
|
318
|
+
full: boolean;
|
|
319
|
+
offset?: number;
|
|
320
|
+
limit?: number;
|
|
321
|
+
} | null {
|
|
322
|
+
const match = query.match(ENTRYREF_PATTERN);
|
|
323
|
+
if (!match) return null;
|
|
324
|
+
const index = parseInt(match[1], 10);
|
|
325
|
+
const suffix = match[2];
|
|
326
|
+
if (suffix === "full") return { index, full: true, offset: undefined, limit: undefined };
|
|
327
|
+
if (suffix !== undefined) {
|
|
328
|
+
const parts = suffix.split(":");
|
|
329
|
+
const offset = parseInt(parts[0], 10);
|
|
330
|
+
const limit = parts[1] !== undefined ? parseInt(parts[1], 10) : undefined;
|
|
331
|
+
if (!Number.isNaN(offset)) return { index, full: false, offset, limit };
|
|
332
|
+
}
|
|
333
|
+
return { index, full: false, offset: undefined, limit: undefined };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const ENTRY_PREVIEW_LIMIT = 30;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Expand a bare entry ref (#N) to the entry's full rendered text — the
|
|
340
|
+
* inline-architecture counterpart of resolving a brief pointer into the
|
|
341
|
+
* lossless full view. The :full body is the renderMessage(msg, N, true)
|
|
342
|
+
* summary verbatim; the default preview and offset/limit windows mirror
|
|
343
|
+
* formatToolCallContent's contract (30-line preview, "Lines X-Y (of Z)").
|
|
344
|
+
*/
|
|
345
|
+
export function expandEntry(
|
|
346
|
+
sessionFile: string,
|
|
347
|
+
entryIndex: number,
|
|
348
|
+
full = false,
|
|
349
|
+
offset?: number,
|
|
350
|
+
limit?: number,
|
|
351
|
+
): string {
|
|
352
|
+
const { rendered } = loadAllMessages(sessionFile, true);
|
|
353
|
+
if (entryIndex < 0 || entryIndex >= rendered.length) {
|
|
354
|
+
return `Entry #${entryIndex} not found in session history.`;
|
|
355
|
+
}
|
|
356
|
+
const e = rendered[entryIndex];
|
|
357
|
+
const header = `#${entryIndex} [${e.role}]`;
|
|
358
|
+
const body = e.summary;
|
|
359
|
+
if (full) return `${header}\n\n${body}`;
|
|
360
|
+
const allLines = body.split("\n");
|
|
361
|
+
const totalLines = allLines.length;
|
|
362
|
+
if (offset !== undefined) {
|
|
363
|
+
const startLine = Math.max(0, offset);
|
|
364
|
+
const maxLines = limit ?? ENTRY_PREVIEW_LIMIT;
|
|
365
|
+
const endLine = Math.min(startLine + maxLines, totalLines);
|
|
366
|
+
const visible = allLines.slice(startLine, endLine);
|
|
367
|
+
const displayStart = startLine + 1; // 1-indexed for user display
|
|
368
|
+
if (visible.length === 0) {
|
|
369
|
+
return `Offset ${startLine} is beyond entry length ${totalLines}. Use #${entryIndex} for the first ${ENTRY_PREVIEW_LIMIT} lines.`;
|
|
370
|
+
}
|
|
371
|
+
let result = `${header}\nLines ${displayStart}-${endLine} (of ${totalLines}):\n\n${visible.join("\n")}`;
|
|
372
|
+
if (endLine < totalLines) {
|
|
373
|
+
result += `\n\n--- Use #${entryIndex}:${endLine} or #${entryIndex}:${endLine}:${maxLines} for next ${maxLines} lines, #${entryIndex}:full for complete ---`;
|
|
374
|
+
} else if (offset > 0) {
|
|
375
|
+
result += `\n\n(End of entry)`;
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
if (totalLines > ENTRY_PREVIEW_LIMIT) {
|
|
380
|
+
const preview = allLines.slice(0, ENTRY_PREVIEW_LIMIT).join("\n");
|
|
381
|
+
return `${header}\n\n${preview}\n\n...(${totalLines - ENTRY_PREVIEW_LIMIT} more lines — use #${entryIndex}:full for complete content, or #${entryIndex}:${ENTRY_PREVIEW_LIMIT} for next ${ENTRY_PREVIEW_LIMIT} lines)`;
|
|
382
|
+
}
|
|
383
|
+
return `${header}\n\n${body}`;
|
|
299
384
|
}
|
|
@@ -78,6 +78,7 @@ export const formatRecallOutput = (
|
|
|
78
78
|
entries: SearchHit[],
|
|
79
79
|
query?: string,
|
|
80
80
|
headerOverride?: string,
|
|
81
|
+
opts?: { truncated?: boolean; totalBeforeCap?: number },
|
|
81
82
|
): string => {
|
|
82
83
|
if (entries.length === 0) {
|
|
83
84
|
return query
|
|
@@ -97,5 +98,12 @@ export const formatRecallOutput = (
|
|
|
97
98
|
return `#${e.index} [${e.role}]${fileSuffix} ${body}`;
|
|
98
99
|
});
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
const body = `${header}\n\n${lines.join("\n\n")}`;
|
|
102
|
+
// Every hit ref resolves: #N expands the full entry (see expandEntry in
|
|
103
|
+
// drill-down.ts). Surface the hint when results are capped or clipped.
|
|
104
|
+
const clipped = entries.some((e) => e.snippet?.includes("...("));
|
|
105
|
+
if (opts?.truncated || clipped) {
|
|
106
|
+
return `${body}\n\n--- Use #N for full entry text ---`;
|
|
107
|
+
}
|
|
108
|
+
return body;
|
|
101
109
|
};
|
|
@@ -45,6 +45,9 @@ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
|
45
45
|
for (const part of msg.content) {
|
|
46
46
|
if (part.type === "text") {
|
|
47
47
|
blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex: msgIndex });
|
|
48
|
+
} else if (part.type === "thinking") {
|
|
49
|
+
const thinkingText = sanitize(part.text ?? part.thinking ?? "");
|
|
50
|
+
if (thinkingText) blocks.push({ kind: "thinking", text: thinkingText, sourceIndex: msgIndex });
|
|
48
51
|
} else if (part.type === "toolCall") {
|
|
49
52
|
blocks.push({
|
|
50
53
|
kind: "tool_call",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
3
|
-
import { clip, textOf } from "./content";
|
|
3
|
+
import { clip, textOf, thinkingOf } from "./content";
|
|
4
4
|
import { summarizeToolArgs } from "./tool-args";
|
|
5
5
|
import { extractPath } from "./tool-args";
|
|
6
6
|
|
|
@@ -45,9 +45,13 @@ export const renderMessage = (msg: Message, index: number, full = false): Render
|
|
|
45
45
|
const text = full ? `$ ${cmd}\n${out}` : clip(`$ ${cmd}\n${out}`, 300);
|
|
46
46
|
return { index, role: "bash", summary: text };
|
|
47
47
|
}
|
|
48
|
+
const thinking = thinkingOf(msg.content);
|
|
48
49
|
const text = full ? textOf(msg.content) : clip(textOf(msg.content), 300);
|
|
49
50
|
const tools = toolCalls(msg.content);
|
|
50
51
|
const files = extractFilesFromContent(msg.content);
|
|
52
|
+
if (!text && !tools && thinking) {
|
|
53
|
+
return { index, role: "thinking", summary: full ? thinking : clip(thinking, 300) };
|
|
54
|
+
}
|
|
51
55
|
const summary = tools ? `${tools}\n${text}` : text;
|
|
52
56
|
return { index, role: "assistant", summary, ...(files.length > 0 && { files }) };
|
|
53
57
|
};
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
3
3
|
import type { RenderedEntry } from "./render-entries";
|
|
4
|
-
import { textOf, isContentBearing, extractToolCallText, extractToolCallArgsText, clip } from "./content";
|
|
4
|
+
import { textOf, thinkingOf, isContentBearing, extractToolCallText, extractToolCallArgsText, clip } from "./content";
|
|
5
|
+
import { scoreToProbability, estimateLikelihoodParams } from "./bayesian-probability.ts";
|
|
5
6
|
|
|
6
7
|
export interface SearchHit extends RenderedEntry {
|
|
7
8
|
/** Context snippet around the first matched term (only when query provided) */
|
|
8
9
|
snippet?: string;
|
|
9
10
|
/** Number of query terms matched (for ranking) */
|
|
10
11
|
matchCount?: number;
|
|
12
|
+
/** Calibrated P(relevance) from the Bayesian transform (BM25 path only) */
|
|
13
|
+
probability?: number;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -18,7 +21,7 @@ export interface SearchHit extends RenderedEntry {
|
|
|
18
21
|
export interface SearchResult {
|
|
19
22
|
hits: SearchHit[];
|
|
20
23
|
/** Genuine matches found before the hard cap was applied (after any
|
|
21
|
-
*
|
|
24
|
+
* posterior-gate noise filtering). May exceed `hits.length`. */
|
|
22
25
|
totalBeforeCap: number;
|
|
23
26
|
/** True when the hard cap discarded matches (`totalBeforeCap > hits.length`). */
|
|
24
27
|
truncated: boolean;
|
|
@@ -194,24 +197,30 @@ const buildBM25Context = (docs: string[], terms: string[], checkBudget: () => vo
|
|
|
194
197
|
return { n, avgDl: totalLen / Math.max(n, 1), df };
|
|
195
198
|
};
|
|
196
199
|
|
|
197
|
-
/** BM25 score for a single doc against query terms
|
|
198
|
-
|
|
200
|
+
/** BM25 score for a single doc against query terms, plus the calibration
|
|
201
|
+
* inputs the Bayesian posterior needs: total term frequency across terms,
|
|
202
|
+
* distinct normalized matched terms (coverage parity), and the doc-length
|
|
203
|
+
* ratio. Same pass — no re-scanning. */
|
|
204
|
+
const bm25Score = (doc: string, terms: string[], ctx: BM25Context): { score: number; tf: number; distinctTerms: number; docLenRatio: number } => {
|
|
199
205
|
const dl = doc.split(/\s+/).length;
|
|
200
206
|
let score = 0;
|
|
207
|
+
let totalTf = 0;
|
|
208
|
+
const seenTerms = new Set<string>();
|
|
201
209
|
|
|
202
210
|
for (const t of terms) {
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
211
|
+
const termTf = termFreq(doc, safeRegex(t));
|
|
212
|
+
if (termTf === 0) continue;
|
|
213
|
+
totalTf += termTf;
|
|
214
|
+
seenTerms.add(t.toLowerCase());
|
|
205
215
|
|
|
206
216
|
const docFreq = ctx.df.get(t) ?? 0;
|
|
207
217
|
// IDF: log((N - df + 0.5) / (df + 0.5) + 1)
|
|
208
218
|
const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
|
|
209
|
-
|
|
210
|
-
const tfNorm = (tf * (BM25_K + 1)) / (tf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
|
|
219
|
+
const tfNorm = (termTf * (BM25_K + 1)) / (termTf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
|
|
211
220
|
score += idf * tfNorm;
|
|
212
221
|
}
|
|
213
222
|
|
|
214
|
-
return score;
|
|
223
|
+
return { score, tf: totalTf, distinctTerms: seenTerms.size, docLenRatio: ctx.avgDl > 0 ? dl / ctx.avgDl : 1 };
|
|
215
224
|
};
|
|
216
225
|
|
|
217
226
|
/** Line-based snippet: ±contextLines around first regex match. */
|
|
@@ -300,8 +309,9 @@ const fullText = (msg: Message): string => {
|
|
|
300
309
|
return "";
|
|
301
310
|
}
|
|
302
311
|
const text = textOf(msg.content);
|
|
312
|
+
const thinking = thinkingOf(msg.content);
|
|
303
313
|
const argsText = toolCallArgsText(msg.content);
|
|
304
|
-
return
|
|
314
|
+
return [text, thinking, argsText].filter(Boolean).join("\n");
|
|
305
315
|
};
|
|
306
316
|
|
|
307
317
|
/**
|
|
@@ -358,63 +368,57 @@ export function getTouchedFiles(
|
|
|
358
368
|
}
|
|
359
369
|
|
|
360
370
|
/**
|
|
361
|
-
*
|
|
362
|
-
* after sorting by score, drop hits
|
|
363
|
-
*
|
|
364
|
-
* size and document length
|
|
365
|
-
*
|
|
371
|
+
* Absolute Bayesian posterior floor for MULTI-TERM natural-language queries
|
|
372
|
+
* only: after sorting by BM25 score, drop hits whose calibrated P(relevance)
|
|
373
|
+
* is below this threshold. Absolute (not relative) because the posterior
|
|
374
|
+
* already normalizes away corpus size and document length — that is the
|
|
375
|
+
* point of the score→probability transform (`bayesian-probability.ts`).
|
|
376
|
+
* Raw BM25 magnitudes vary across sessions, so no fixed score threshold
|
|
377
|
+
* behaves consistently; posteriors are comparable, which is also what the
|
|
378
|
+
* cross-session merge needs.
|
|
366
379
|
*
|
|
367
380
|
* Applied only when the query has >=2 DISTINCT effective terms after
|
|
368
381
|
* stopword filtering and case/duplicate normalization (see the
|
|
369
|
-
* `effectiveTermCount >= 2` gate
|
|
370
|
-
*
|
|
371
|
-
* "Auth AUTH" is semantically a single-term query and must bypass the
|
|
382
|
+
* `effectiveTermCount >= 2` gate `searchEntriesDetailed` uses before calling
|
|
383
|
+
* `applyProbabilityFloor`). Distinct, not raw count: "auth auth" or
|
|
384
|
+
* "Auth AUTH" is semantically a single-term query and must bypass the gate
|
|
372
385
|
* like any other single term — repeating or casing a word doesn't turn it
|
|
373
|
-
* into the multi-term OR-tail noise this
|
|
386
|
+
* into the multi-term OR-tail noise this gate targets. The normalization is
|
|
374
387
|
* gate-only; it doesn't change `terms` or the BM25 scoring itself, which
|
|
375
388
|
* already matches case-insensitively. For a genuine single term, every hit's
|
|
376
389
|
* occurrence already satisfies the whole query — its BM25 score differences
|
|
377
390
|
* reflect term frequency and document length, not multi-term OR-tail noise,
|
|
378
391
|
* so filtering by it there risks real matches for no corresponding noise
|
|
379
|
-
* reduction.
|
|
392
|
+
* reduction. (The bypass is structural, pinned by the single-term tests in
|
|
393
|
+
* `tests/search-entries.test.ts`; a seeded bench of 540 trials against the
|
|
394
|
+
* prior relative floor — old module resurrected from git, planted relevant
|
|
395
|
+
* docs in OR-tail noise — confirmed 1.0 planted recall for both filters with
|
|
396
|
+
* strictly less noise kept under the new gate, and top-1 == ungated in all
|
|
397
|
+
* 540 trials. See `docs/bayesian-recall-gate.md` §Evidence.)
|
|
380
398
|
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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.
|
|
399
|
+
* The top-scoring hit always survives by construction:
|
|
400
|
+
* `applyProbabilityFloor` keeps the first entry unconditionally, so a
|
|
401
|
+
* non-empty scored[] can never be filtered to zero even if a tuning
|
|
402
|
+
* override raises the threshold above the top hit's posterior. (Note
|
|
403
|
+
* posterior(L, p) = p at L = 0.5, so a top likelihood >= 0.5 alone does
|
|
404
|
+
* NOT imply posterior >= 0.5 — the unconditional keep-first is the real
|
|
405
|
+
* guarantee, not the calibration.)
|
|
405
406
|
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
407
|
+
* Coverage parity is the second survival rule (see `applyProbabilityFloor`):
|
|
408
|
+
* a doc matching as many distinct query terms as the best hit survives even
|
|
409
|
+
* when its posterior sits below the cutoff. Without this, a uniformly good
|
|
410
|
+
* result set — every doc matches every term, all posteriors lukewarm under
|
|
411
|
+
* median-anchored calibration — would collapse to the top hit alone, worse
|
|
412
|
+
* than the relative floor this gate replaces.
|
|
409
413
|
*/
|
|
410
|
-
const
|
|
414
|
+
const BAYESIAN_PROBABILITY_FLOOR = 0.5;
|
|
411
415
|
|
|
412
416
|
/**
|
|
413
417
|
* Hard cap on total SEARCH results, applied to both the natural-language
|
|
414
|
-
* (post-
|
|
418
|
+
* (post-gate) and regex result paths so pagination stays bounded regardless
|
|
415
419
|
* of how noisy or broad a query is.
|
|
416
420
|
*
|
|
417
|
-
* Evidence (same bench/corpora as
|
|
421
|
+
* Evidence (same bench/corpora as the tail gate, gate disabled to
|
|
418
422
|
* isolate the cap's effect; run 1 = 161 queries, run 2 = 222 queries):
|
|
419
423
|
* uncapped result counts ranged up to 380 (median 32 / 29.5, p90 119 /
|
|
420
424
|
* 115.9). cap=50 sits ABOVE the corpus's own median in both runs but BELOW
|
|
@@ -423,36 +427,40 @@ const BM25_RELATIVE_FLOOR = 0.2;
|
|
|
423
427
|
* truncated by it, versus 90/161 (56%) and 124/222 (56%) for cap=25, which
|
|
424
428
|
* would also clip plenty of unremarkable ~30-match queries well under what
|
|
425
429
|
* "noisy" implies. cap=50 also bounds the worst case (380) down by 87%.
|
|
426
|
-
*
|
|
427
|
-
* cap
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
+
* Those runs measured the prior relative floor (0.20 multi-term-only); the
|
|
431
|
+
* cap applies post-gate either way, so the basis stands unchanged. Under the
|
|
432
|
+
* new absolute gate a seeded 540-trial bench likewise shows 0 zero-hit
|
|
433
|
+
* regressions and top-1 == ungated in all 540 trials.
|
|
430
434
|
*/
|
|
431
435
|
const SEARCH_RESULT_CAP = 50;
|
|
432
|
-
|
|
433
436
|
/**
|
|
434
|
-
* Tuning overrides for `searchEntriesDetailed`. Exists only so
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* and always get `BM25_RELATIVE_FLOOR`/`SEARCH_RESULT_CAP`.
|
|
437
|
+
* Tuning overrides for `searchEntriesDetailed`. Exists only so targeted
|
|
438
|
+
* tests can exercise the real scoring/capping pipeline against candidate
|
|
439
|
+
* constants — production call sites (`searchEntries`, the recall tool)
|
|
440
|
+
* never pass this and always get `BAYESIAN_PROBABILITY_FLOOR`/`SEARCH_RESULT_CAP`.
|
|
439
441
|
*/
|
|
440
442
|
export interface SearchTuning {
|
|
441
|
-
|
|
443
|
+
probabilityFloor?: number;
|
|
442
444
|
cap?: number;
|
|
443
445
|
}
|
|
444
446
|
|
|
445
|
-
/** Drop scored hits
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
|
|
449
|
-
|
|
447
|
+
/** Drop scored hits that are BOTH below the absolute posterior `floor` AND
|
|
448
|
+
* cover fewer distinct query terms than the best hit (`coverage < maxCoverage`).
|
|
449
|
+
* Either disjunct keeps a hit: high posterior (absolute relevance) or full
|
|
450
|
+
* query coverage — a doc matching every term is never OR-tail, even in a
|
|
451
|
+
* lukewarm homogeneous corpus where median-anchored calibration puts every
|
|
452
|
+
* posterior below the cutoff. The top hit (index 0, highest BM25 score)
|
|
453
|
+
* always passes unconditionally, so this can never turn a non-empty
|
|
454
|
+
* `scored` into an empty result. Sort stays by raw BM25 score, never by
|
|
455
|
+
* posterior: the composite prior varies per doc, so posterior order can
|
|
456
|
+
* differ from BM25 order, and rank assertions pin BM25 order. */
|
|
457
|
+
const applyProbabilityFloor = (
|
|
458
|
+
scored: Array<{ hit: SearchHit; score: number; probability: number; distinctTerms: number }>,
|
|
450
459
|
floor: number,
|
|
451
|
-
|
|
460
|
+
maxCoverage: number,
|
|
461
|
+
): Array<{ hit: SearchHit; score: number; probability: number; distinctTerms: number }> => {
|
|
452
462
|
if (scored.length === 0) return scored;
|
|
453
|
-
|
|
454
|
-
if (topScore <= 0) return scored;
|
|
455
|
-
return scored.filter((s) => s.score >= topScore * floor);
|
|
463
|
+
return scored.filter((s, i) => i === 0 || s.probability >= floor || s.distinctTerms >= maxCoverage);
|
|
456
464
|
};
|
|
457
465
|
|
|
458
466
|
/**
|
|
@@ -487,7 +495,7 @@ export const searchEntriesDetailed = (
|
|
|
487
495
|
): SearchResult => {
|
|
488
496
|
if (!query?.trim()) return { hits: entries, totalBeforeCap: entries.length, truncated: false };
|
|
489
497
|
|
|
490
|
-
const
|
|
498
|
+
const probabilityFloor = tuning?.probabilityFloor ?? BAYESIAN_PROBABILITY_FLOOR;
|
|
491
499
|
const cap = tuning?.cap ?? SEARCH_RESULT_CAP;
|
|
492
500
|
const rawQuery = query.trim();
|
|
493
501
|
const checkBudget = startBudget();
|
|
@@ -501,8 +509,8 @@ export const searchEntriesDetailed = (
|
|
|
501
509
|
// versus 1.1% for term search. Mode detection must never silently lose
|
|
502
510
|
// results, so an empty regex result falls through to term search below.
|
|
503
511
|
//
|
|
504
|
-
// No
|
|
505
|
-
// not), there's no
|
|
512
|
+
// No posterior-gate filtering here: regex matches are boolean (matched or
|
|
513
|
+
// not), there's no probability to threshold. Only the hard cap applies.
|
|
506
514
|
if (looksLikeRegex(rawQuery)) {
|
|
507
515
|
const regex = safeRegex(rawQuery);
|
|
508
516
|
const hits: SearchHit[] = [];
|
|
@@ -538,35 +546,54 @@ export const searchEntriesDetailed = (
|
|
|
538
546
|
|
|
539
547
|
const ctx = buildBM25Context(docs, terms, checkBudget);
|
|
540
548
|
|
|
541
|
-
const scored: Array<{ hit: SearchHit; score: number }> = [];
|
|
549
|
+
const scored: Array<{ hit: SearchHit; score: number; tf: number; distinctTerms: number; docLenRatio: number }> = [];
|
|
542
550
|
for (let i = 0; i < entries.length; i++) {
|
|
543
551
|
checkBudget();
|
|
544
552
|
const e = entries[i];
|
|
545
553
|
const hay = docs[i];
|
|
546
554
|
const mc = countMatches(hay, terms);
|
|
547
555
|
if (mc === 0) continue;
|
|
548
|
-
const score = bm25Score(hay, terms, ctx);
|
|
556
|
+
const { score, tf, distinctTerms, docLenRatio } = bm25Score(hay, terms, ctx);
|
|
549
557
|
const text = messages[i] ? fullText(messages[i]) : e.summary;
|
|
550
558
|
const snip = lineSnippet(text, snipRe);
|
|
551
559
|
scored.push({
|
|
552
560
|
hit: { ...e, snippet: snip, matchCount: mc },
|
|
553
561
|
score,
|
|
562
|
+
tf,
|
|
563
|
+
distinctTerms,
|
|
564
|
+
docLenRatio,
|
|
554
565
|
});
|
|
555
566
|
}
|
|
556
567
|
|
|
557
|
-
//
|
|
558
|
-
//
|
|
568
|
+
// Calibrate: sigmoid midpoint/shift from this query's own score spread,
|
|
569
|
+
// then one posterior per doc. The per-doc single transform is an
|
|
570
|
+
// approximation of per-term posterior fusion — sufficient for a noise
|
|
571
|
+
// gate, never used for ranking (the sort key below stays raw BM25).
|
|
572
|
+
const params = estimateLikelihoodParams(scored.map((s) => s.score)) ?? { alpha: 1, beta: 0 };
|
|
573
|
+
const calibrated = scored.map((s) => ({
|
|
574
|
+
...s,
|
|
575
|
+
probability: scoreToProbability(s.score, s.tf, s.docLenRatio, params.alpha, params.beta),
|
|
576
|
+
}));
|
|
577
|
+
for (const s of calibrated) s.hit.probability = s.probability;
|
|
578
|
+
// Coverage parity bar: the most distinct query terms any hit matches. Docs
|
|
579
|
+
// covering the query as fully as the best doc are never tail, even when
|
|
580
|
+
// their posterior sits below the absolute cutoff (homogeneous corpora).
|
|
581
|
+
let maxCoverage = 0;
|
|
582
|
+
for (const s of calibrated) if (s.distinctTerms > maxCoverage) maxCoverage = s.distinctTerms;
|
|
583
|
+
|
|
584
|
+
// Sort by BM25 score desc, then drop the noisy low-probability tail
|
|
585
|
+
// (multi-term queries only — see BAYESIAN_PROBABILITY_FLOOR), then
|
|
559
586
|
// apply the hard cap.
|
|
560
|
-
|
|
587
|
+
calibrated.sort((a, b) => b.score - a.score);
|
|
561
588
|
// Gate on DISTINCT normalized terms, not raw term count: "auth auth" or
|
|
562
589
|
// "Auth AUTH" is semantically a single-term query and must bypass the
|
|
563
|
-
//
|
|
564
|
-
// it into the multi-term OR-tail noise this
|
|
590
|
+
// gate like any other single term — repeating or casing a word doesn't
|
|
591
|
+
// turn it into the multi-term OR-tail noise this gate targets. This is a
|
|
565
592
|
// gate-only normalization; it does not change `terms` itself or the BM25
|
|
566
593
|
// scoring above, which already matches case-insensitively.
|
|
567
594
|
const effectiveTermCount = new Set(terms.map((t) => t.toLowerCase())).size;
|
|
568
|
-
const
|
|
569
|
-
return capHits(
|
|
595
|
+
const gated = effectiveTermCount >= 2 ? applyProbabilityFloor(calibrated, probabilityFloor, maxCoverage) : calibrated;
|
|
596
|
+
return capHits(gated.map((s) => s.hit), cap);
|
|
570
597
|
};
|
|
571
598
|
|
|
572
599
|
export const searchEntries = (
|
|
@@ -99,3 +99,80 @@ export const estimateMessageContentTokens = (
|
|
|
99
99
|
content: unknown,
|
|
100
100
|
charsPerToken = DEFAULT_CHARS_PER_TOKEN,
|
|
101
101
|
): number => estimateTokensFromChars(estimateMessageContentChars(content), charsPerToken);
|
|
102
|
+
|
|
103
|
+
export interface UsageStats {
|
|
104
|
+
messageCount: number;
|
|
105
|
+
byRole: Record<string, number>;
|
|
106
|
+
toolCallCount: number;
|
|
107
|
+
models: string[];
|
|
108
|
+
/** Wall-clock span (ms) from message timestamps, null when unavailable. */
|
|
109
|
+
spanMs: number | null;
|
|
110
|
+
inputChars: number;
|
|
111
|
+
outputChars: number;
|
|
112
|
+
inputTokensEst: number;
|
|
113
|
+
outputTokensEst: number;
|
|
114
|
+
/** Summed provider usage counters when messages carry them. */
|
|
115
|
+
usageTotals: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
116
|
+
calibration: TokenEstimateCalibration;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reference `_collect_stats` equivalent for the debug snapshot: per-compaction
|
|
121
|
+
* usage/timing/model block. Assistant content counts as output; user text,
|
|
122
|
+
* tool results, and bash executions count as input. Calibrates chars/token
|
|
123
|
+
* against summed provider usage when present, heuristic fallback otherwise.
|
|
124
|
+
*/
|
|
125
|
+
export const collectUsageStats = (messages: any[]): UsageStats => {
|
|
126
|
+
const byRole: Record<string, number> = {};
|
|
127
|
+
const models = new Set<string>();
|
|
128
|
+
let toolCallCount = 0;
|
|
129
|
+
let inputChars = 0;
|
|
130
|
+
let outputChars = 0;
|
|
131
|
+
let minTs = Infinity;
|
|
132
|
+
let maxTs = -Infinity;
|
|
133
|
+
const usageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
134
|
+
let sawUsage = false;
|
|
135
|
+
for (const m of messages ?? []) {
|
|
136
|
+
const role = typeof m?.role === "string" ? m.role : "unknown";
|
|
137
|
+
byRole[role] = (byRole[role] ?? 0) + 1;
|
|
138
|
+
if (typeof m?.model === "string" && m.model) models.add(m.model);
|
|
139
|
+
if (typeof m?.timestamp === "number" && Number.isFinite(m.timestamp)) {
|
|
140
|
+
if (m.timestamp < minTs) minTs = m.timestamp;
|
|
141
|
+
if (m.timestamp > maxTs) maxTs = m.timestamp;
|
|
142
|
+
}
|
|
143
|
+
const u = m?.usage;
|
|
144
|
+
if (u && typeof u === "object") {
|
|
145
|
+
sawUsage = true;
|
|
146
|
+
for (const k of ["input", "output", "cacheRead", "cacheWrite"] as const) {
|
|
147
|
+
if (typeof u[k] === "number" && Number.isFinite(u[k])) usageTotals[k] += u[k];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (role === "assistant") {
|
|
151
|
+
outputChars += estimateMessageContentChars(m?.content);
|
|
152
|
+
if (Array.isArray(m?.content)) {
|
|
153
|
+
for (const part of m.content) if (part?.type === "toolCall") toolCallCount++;
|
|
154
|
+
}
|
|
155
|
+
} else if (role === "bashExecution") {
|
|
156
|
+
inputChars += (typeof m?.command === "string" ? m.command.length : 0) + 1
|
|
157
|
+
+ (typeof m?.output === "string" ? m.output.length : 0);
|
|
158
|
+
} else {
|
|
159
|
+
inputChars += estimateMessageContentChars(m?.content);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const totalChars = inputChars + outputChars;
|
|
163
|
+
const sourceTokens = sawUsage ? usageTotals.input + usageTotals.output : undefined;
|
|
164
|
+
const calibration = calibrateCharsPerToken(totalChars, sourceTokens);
|
|
165
|
+
return {
|
|
166
|
+
messageCount: messages?.length ?? 0,
|
|
167
|
+
byRole,
|
|
168
|
+
toolCallCount,
|
|
169
|
+
models: [...models],
|
|
170
|
+
spanMs: minTs <= maxTs ? maxTs - minTs : null,
|
|
171
|
+
inputChars,
|
|
172
|
+
outputChars,
|
|
173
|
+
inputTokensEst: estimateTokensFromChars(inputChars, calibration.charsPerToken),
|
|
174
|
+
outputTokensEst: estimateTokensFromChars(outputChars, calibration.charsPerToken),
|
|
175
|
+
usageTotals,
|
|
176
|
+
calibration,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
@@ -5,7 +5,7 @@ import { writeFileSync } from "fs";
|
|
|
5
5
|
import { compileRanked } from "./core/summarize";
|
|
6
6
|
import { buildPiVccCustomInstructions, parseKeepAndPrompt, PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
|
|
7
7
|
import { loadSettings, type PiVccSettings } from "./core/settings";
|
|
8
|
-
import { calibrateCharsPerToken, estimateMessageContentChars, estimateMessageContentTokens, estimateTokensFromChars } from "./core/token-estimate";
|
|
8
|
+
import { calibrateCharsPerToken, estimateMessageContentChars, estimateMessageContentTokens, estimateTokensFromChars, collectUsageStats } from "./core/token-estimate";
|
|
9
9
|
import type { PiVccCompactionDetails } from "./details";
|
|
10
10
|
import type { CompactionReason } from "./types";
|
|
11
11
|
import { loadAllMessages as _loadAllMessages } from "./core/load-messages";
|
|
@@ -13,7 +13,7 @@ import { searchEntriesDetailed as _searchEntriesDetailed, getTouchedFiles as _ge
|
|
|
13
13
|
import { formatRecallOutput as _formatRecallOutput, formatTouchedOutput as _formatTouchedOutput } from "./core/format-recall";
|
|
14
14
|
import { getActiveLineageEntryIds as _getActiveLineageEntryIds } from "./core/lineage";
|
|
15
15
|
import { normalizeRecallScope as _normalizeRecallScope, normalizeRecallMode as _normalizeRecallMode, parseRecallScope as _parseRecallScope } from "./core/recall-scope";
|
|
16
|
-
import { parseDrillDown as _parseDrillDown, expandEntryFile as _expandEntryFile } from "./core/drill-down";
|
|
16
|
+
import { parseDrillDown as _parseDrillDown, expandEntryFile as _expandEntryFile, parseEntryRef as _parseEntryRef, expandEntry as _expandEntry } from "./core/drill-down";
|
|
17
17
|
|
|
18
18
|
// convertToLlm shim: try host export, fallback to identity (preserves AgentMessage for omp compileRanked)
|
|
19
19
|
let convertToLlm: (messages: any[]) => any[] = (m) => m;
|
|
@@ -972,6 +972,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
972
972
|
messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
|
|
973
973
|
messagesPreviewTail: agentMessages.slice(-3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
|
|
974
974
|
convertedMessages: messages.length,
|
|
975
|
+
usage: collectUsageStats(agentMessages),
|
|
975
976
|
firstKeptEntryId,
|
|
976
977
|
cutWindow,
|
|
977
978
|
tokensBefore,
|
|
@@ -1130,6 +1131,17 @@ export const registerRecallTool = (pi: any) => {
|
|
|
1130
1131
|
const scope = _normalizeRecallScope(params.scope === "active" ? "lineage" : params.scope);
|
|
1131
1132
|
const lineageEntryIds = scope === "lineage" ? _getActiveLineageEntryIds(ctx.sessionManager) : undefined;
|
|
1132
1133
|
const q = params.query?.trim();
|
|
1134
|
+
if (q && _parseEntryRef(q)) {
|
|
1135
|
+
const ref = _parseEntryRef(q)!;
|
|
1136
|
+
if (lineageEntryIds) {
|
|
1137
|
+
const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
1138
|
+
if (!rendered.some((m) => m.index === ref.index)) {
|
|
1139
|
+
return { content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${ref.index}. Use scope:'all' to reach other branches.` }] };
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const text = _expandEntry(sessionFile, ref.index, ref.full, ref.offset, ref.limit);
|
|
1143
|
+
return { content: [{ type: "text", text }] };
|
|
1144
|
+
}
|
|
1133
1145
|
if (q && _parseDrillDown(q)) {
|
|
1134
1146
|
const parsed = _parseDrillDown(q)!;
|
|
1135
1147
|
if (lineageEntryIds) {
|
|
@@ -1174,7 +1186,7 @@ export const registerRecallTool = (pi: any) => {
|
|
|
1174
1186
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
1175
1187
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
1176
1188
|
const footer = page < totalPages ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---` : "";
|
|
1177
|
-
const output = _formatRecallOutput(pageResults, q, header) + footer;
|
|
1189
|
+
const output = _formatRecallOutput(pageResults, q, header, { truncated, totalBeforeCap }) + footer;
|
|
1178
1190
|
return { content: [{ type: "text", text: output }] };
|
|
1179
1191
|
}
|
|
1180
1192
|
const output = (scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(msgs.slice(-DEFAULT_RECENT), q);
|
|
@@ -1225,7 +1237,7 @@ export const registerVccRecallCommand = (pi: any) => {
|
|
|
1225
1237
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
1226
1238
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
1227
1239
|
const footer = page < totalPages ? `\n--- /pi-vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
|
|
1228
|
-
const output = _formatRecallOutput(pageResults, query, header) + footer;
|
|
1240
|
+
const output = _formatRecallOutput(pageResults, query, header, { truncated, totalBeforeCap }) + footer;
|
|
1229
1241
|
try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
1230
1242
|
},
|
|
1231
1243
|
});
|
|
@@ -12,6 +12,7 @@ export interface FileOps {
|
|
|
12
12
|
export type NormalizedBlock =
|
|
13
13
|
| { kind: "user"; text: string; sourceIndex?: number }
|
|
14
14
|
| { kind: "assistant"; text: string; sourceIndex?: number }
|
|
15
|
+
| { kind: "thinking"; text: string; sourceIndex?: number }
|
|
15
16
|
| { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
|
|
16
17
|
| { kind: "tool_result"; name: string; text: string; sourceIndex?: number }
|
|
17
18
|
| { kind: "bash"; command: string; output: string; exitCode: number | undefined; sourceIndex?: number };
|