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.
@@ -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 ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
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 #ref)
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 ref = line.match(/\(#(\d+)\)$/)?.[1] ?? "";
331
- const base = ref ? line.slice(0, -(ref.length + 3)).trimEnd() : line;
332
- const last = out.length > 0 ? out[out.length - 1] : "";
333
- const m = last.match(/^(.*) \((#[\d, #]+)\) x(\d+)$/);
334
- if (m && m[1] === base) {
335
- out[out.length - 1] = `${base} (${m[2]}, #${ref}) x${parseInt(m[3]) + 1}`;
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
- return `${header}\n\n${lines.join("\n\n")}`;
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
- * relative-floor noise filtering). May exceed `hits.length`. */
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
- const bm25Score = (doc: string, terms: string[], ctx: BM25Context): number => {
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 tf = termFreq(doc, safeRegex(t));
204
- if (tf === 0) continue;
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
- // TF saturation with length normalization
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 argsText ? `${text}\n${argsText}` : text;
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
- * Relative BM25 noise floor for MULTI-TERM natural-language queries only:
362
- * after sorting by score, drop hits scoring below this fraction of the top
363
- * score. Relative (not absolute) because BM25 magnitudes vary with corpus
364
- * size and document length, so a fixed score threshold would behave
365
- * inconsistently across short vs. long sessions.
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 below `searchEntriesDetailed` uses before
370
- * calling `applyRelativeFloor`). Distinct, not raw count: "auth auth" or
371
- * "Auth AUTH" is semantically a single-term query and must bypass the floor
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 floor targets. The normalization is
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. Evidence below confirmed this rather than assuming it.
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
- * Evidence (scripts/benchmark-recall-quality.ts, run through this exact
382
- * production function via its `tuning` override not a duplicate scoring
383
- * implementation). Two independent runs against real session corpora (23
384
- * sessions/161 queries and 31 sessions/222 queries; exact counts vary with
385
- * whatever real sessions are available locally, so both are reported rather
386
- * than treating one as a fixed target):
387
- * - floor=0.20 on multi-term queries: median result count 49→23 (run 1,
388
- * n=69) and 60.5→23.5 (run 2, n=98); p90 142.8→81 and 126.2→75.3.
389
- * Zero-hit count stayed 0 in both runs, top-1 never changed (0/69,
390
- * 0/98). Top-5 membership shifted in 5/69 (7%) and 5/98 (5%).
391
- * - floor=0.10 was too weak to "meaningfully" remove the tail (multi-term
392
- * median only 49→39 / 60.5→42); floor=0.25 removed more but roughly
393
- * doubled the multi-term top-5 disruption (8/69, run 1) for little extra
394
- * median gain over 0.20. 0.20 is the least aggressive setting that
395
- * meaningfully thinned the tail.
396
- * - Single-term queries with the floor gated off: every floor candidate
397
- * (0, 0.10, 0.20, 0.25) produced byte-identical results — 0/92 and
398
- * 0/124 top-5 changes in both runs, confirming the gate is a true no-op
399
- * rather than an untested assumption. Before this gate existed, applying
400
- * 0.20 unconditionally still changed single-term top-5 in a small but
401
- * non-zero fraction of queries (1/140 in this repo's own rerun, 1/124 in
402
- * an independent reviewer rerun) for negligible median movement — real
403
- * false-negative risk for no real noise benefit, which is why the gate
404
- * exists.
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
- * The top-scoring hit always survives by construction, independent of the
407
- * evidence above: its own score always satisfies `score >= topScore * floor`
408
- * for any floor <= 1, so a non-empty scored[] can never be filtered to zero.
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 BM25_RELATIVE_FLOOR = 0.2;
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-floor) and regex result paths so pagination stays bounded regardless
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 BM25_RELATIVE_FLOOR, floor disabled to
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
- * Combined with the floor (production policy: floor=0.20 multi-term-only,
427
- * cap=50, vs. no filtering at all): 0 zero-hit regressions and 0 top-1
428
- * changes in both runs; top-5 changed in 5/161 (3%) and 5/222 (2%); median
429
- * result count 32→18 and 29.5→20; p90 119→50 and 115.9→50.
430
+ * 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 the offline
435
- * bench (scripts/benchmark-recall-quality.ts) and targeted tests can
436
- * exercise the real scoring/capping pipeline against candidate constants
437
- * production call sites (`searchEntries`, the recall tool) never pass this
438
- * and always get `BM25_RELATIVE_FLOOR`/`SEARCH_RESULT_CAP`.
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
- relativeFloor?: number;
443
+ probabilityFloor?: number;
442
444
  cap?: number;
443
445
  }
444
446
 
445
- /** Drop scored hits below `floor` of the top score. The top hit's own score
446
- * always passes (score >= score * floor for floor <= 1), so this can never
447
- * turn a non-empty `scored` into an empty result. */
448
- const applyRelativeFloor = (
449
- scored: Array<{ hit: SearchHit; score: number }>,
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
- ): Array<{ hit: SearchHit; score: number }> => {
460
+ maxCoverage: number,
461
+ ): Array<{ hit: SearchHit; score: number; probability: number; distinctTerms: number }> => {
452
462
  if (scored.length === 0) return scored;
453
- const topScore = scored[0].score;
454
- if (topScore <= 0) return scored;
455
- return scored.filter((s) => s.score >= topScore * floor);
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 relativeFloor = tuning?.relativeFloor ?? BM25_RELATIVE_FLOOR;
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 relative-floor filtering here: regex matches are boolean (matched or
505
- // not), there's no score to be relative to. Only the hard cap applies.
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
- // Sort by BM25 score desc, then drop the noisy long tail relative to the
558
- // top score (multi-term queries only see BM25_RELATIVE_FLOOR), then
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
- scored.sort((a, b) => b.score - a.score);
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
- // floor like any other single term — repeating/casing a word doesn't turn
564
- // it into the multi-term OR-tail noise this floor targets. This is a
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 floored = effectiveTermCount >= 2 ? applyRelativeFloor(scored, relativeFloor) : scored;
569
- return capHits(floored.map((s) => s.hit), cap);
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-vcc",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Algorithmic VCC compaction for omp - fast lossless no-LLM",
6
6
  "author": "Zhu Lin <zhulin@czl.my>",