omp-vcc 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -90,7 +90,7 @@ Additive VCC+shake is automatic. Eager chain: `chainShakeHint:true`. Explicit mo
90
90
 
91
91
  ```sh
92
92
  bunx tsc --noEmit
93
- bun test # 800 tests, 61 files, 2396 expects, 0 fail
93
+ bun test # 870 tests, 66 files, 2907 expects, 0 fail
94
94
  bun test tests/e2e --timeout 120000 # 124 E2E
95
95
  bun run smoke # 13 checks: 3 hooks + 6 cmds + 2 tools + dedup (+ pipeline)
96
96
  omp plugin link . && omp plugin doctor
@@ -20,6 +20,8 @@ import {
20
20
  registerVccStatsTool as registerVccStatsToolHook,
21
21
  registerVccStatsCommand as registerVccStatsCommandHook,
22
22
  registerVccConfigCommand as registerVccConfigCommandHook,
23
+ invalidExpandIndices,
24
+ getCompactForm,
23
25
  } from "./vcc-core/hook";
24
26
  import { searchEntriesDetailed, getTouchedFiles } from "./vcc-core/core/search-entries";
25
27
  import { formatRecallOutput, formatTouchedOutput } from "./vcc-core/core/format-recall";
@@ -141,7 +143,7 @@ export default function (pi: ExtensionAPI): void {
141
143
  const { rendered: fullMsgs } = loadAllMessages(sessionFile, true, lineageEntryIds);
142
144
  const requested = [...expandSet];
143
145
  const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
144
- const invalid = requested.filter((i) => !Number.isInteger(i) || !byIndex.has(i));
146
+ const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
145
147
  if (invalid.length > 0) {
146
148
  return {
147
149
  content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }],
@@ -179,43 +181,91 @@ export default function (pi: ExtensionAPI): void {
179
181
  // ── vcc_stats tool — stats surface for savings (paper § verification) ──
180
182
  registerVccStatsToolHook(pi);
181
183
 
182
- pi.registerCommand("omp-vcc", {
183
- description: "Compact conversation with omp-vcc structured summary (keep:N + optional focus)",
184
- handler: async (args: string, ctx: unknown) => {
185
- const c = ctx as {
186
- compact: (instructions?: string) => Promise<void>;
187
- ui: { notify: (msg: string, level?: string) => void };
188
- sessionManager?: { getSessionFile?: () => string | undefined };
189
- };
190
- const parsed = parseKeepAndPrompt(args);
191
- const keep = parsed.keepUserTurns;
192
- const followUpPrompt = parsed.followUpPrompt;
193
- const customInstructions = buildOmpCustomInstructions(keep);
184
+ // Shared /omp-vcc + /pi-vcc runner. The two hosts expose incompatible
185
+ // ctx.compact shapes, so the call form branches per live ctx:
186
+ // - omp: compact(string | CompactOptions) => Promise<void>; instructions
187
+ // ride the string (the host splits string|object and drops instructions
188
+ // from the object form), completion = awaited resolution, errors throw
189
+ // ("Compaction cancelled" / "Already compacted" / "Nothing to compact…").
190
+ // - pi: compact(CompactOptions) => void; instructions ONLY via
191
+ // options.customInstructions (a bare string reads as undefined), outcome
192
+ // arrives via onComplete/onError. `settled` keeps one outcome.
193
+ // Form detection is layered (explicit test override → getSystemPrompt
194
+ // shape module scope → legacy omp default) so bundled runtimes without
195
+ // module scope still decide correctly off the live ctx.
196
+ const runCompactCommand = async (
197
+ args: string,
198
+ c: {
199
+ compact: (arg?: unknown) => Promise<void> | void;
200
+ ui: { notify: (msg: string, level?: string) => void };
201
+ },
202
+ buildInstructions: (keep: number | null) => string,
203
+ fallbackToast: string,
204
+ preNotify: boolean,
205
+ compactForm: "object" | "string",
206
+ ): Promise<void> => {
207
+ const parsed = parseKeepAndPrompt(args);
208
+ const keep = parsed.keepUserTurns;
209
+ const followUpPrompt = parsed.followUpPrompt;
210
+ const customInstructions = buildInstructions(keep);
211
+ if (preNotify) {
194
212
  try {
195
213
  c.ui.notify(`omp-vcc: compacting with keep:${keep ?? 1}${followUpPrompt ? ` + focus` : ""}...`, "info");
196
214
  } catch {}
215
+ }
216
+ let settled = false;
217
+ const finishOk = (): void => {
218
+ if (settled) return;
219
+ settled = true;
220
+ const stats = getLastCompactionStats(pi);
221
+ if (stats) {
222
+ scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
223
+ } else {
224
+ try { c.ui.notify(fallbackToast, "info"); } catch {}
225
+ }
226
+ if (followUpPrompt) {
227
+ try {
228
+ const piAny = pi as unknown as { sendUserMessage?: (content: string) => unknown };
229
+ const sent = piAny.sendUserMessage?.(followUpPrompt) as Promise<void> | undefined;
230
+ if (sent && typeof sent.catch === "function") sent.catch(() => {});
231
+ } catch {}
232
+ }
233
+ };
234
+ const finishErr = (err: unknown): void => {
235
+ if (settled) return;
236
+ settled = true;
237
+ const msg = err instanceof Error ? err.message : String(err);
238
+ if (msg === "Compaction cancelled" || msg === "Already compacted" || msg.startsWith("Nothing to compact")) {
239
+ try { c.ui.notify("Nothing to compact", "warning"); } catch {}
240
+ } else {
241
+ try { c.ui.notify(`Compaction failed: ${msg}`, "error"); } catch {}
242
+ }
243
+ };
244
+ if (compactForm === "object") {
197
245
  try {
198
- await c.compact(customInstructions);
199
- const stats = getLastCompactionStats(pi);
200
- if (stats) {
201
- scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
202
- } else {
203
- try { c.ui.notify("Compacted with omp-vcc", "info"); } catch {}
204
- }
205
- if (followUpPrompt) {
206
- try {
207
- const piAny = pi as unknown as { sendUserMessage?: (content: string) => Promise<void> | void };
208
- if (piAny.sendUserMessage) await piAny.sendUserMessage(followUpPrompt);
209
- } catch {}
210
- }
246
+ c.compact({ customInstructions, onComplete: finishOk, onError: finishErr });
211
247
  } catch (err: unknown) {
212
- const msg = err instanceof Error ? err.message : String(err);
213
- if (msg === "Compaction cancelled" || msg === "Already compacted") {
214
- try { c.ui.notify("Nothing to compact", "warning"); } catch {}
215
- } else {
216
- try { c.ui.notify(`Compaction failed: ${msg}`, "error"); } catch {}
217
- }
248
+ finishErr(err);
218
249
  }
250
+ return;
251
+ }
252
+ try {
253
+ await c.compact(customInstructions);
254
+ finishOk();
255
+ } catch (err: unknown) {
256
+ finishErr(err);
257
+ }
258
+ };
259
+
260
+ pi.registerCommand("omp-vcc", {
261
+ description: "Compact conversation with omp-vcc structured summary (keep:N + optional focus)",
262
+ handler: async (args: string, ctx: unknown) => {
263
+ const c = ctx as {
264
+ compact: (options?: unknown) => Promise<void> | void;
265
+ ui: { notify: (msg: string, level?: string) => void };
266
+ getSystemPrompt?: () => unknown;
267
+ };
268
+ await runCompactCommand(args, c, buildOmpCustomInstructions, "Compacted with omp-vcc", true, getCompactForm(() => c.getSystemPrompt?.()));
219
269
  },
220
270
  });
221
271
 
@@ -224,30 +274,11 @@ export default function (pi: ExtensionAPI): void {
224
274
  description: "Alias for /omp-vcc (pi-vcc compat)",
225
275
  handler: async (args: string, ctx: unknown) => {
226
276
  const c = ctx as {
227
- compact: (instructions?: string) => Promise<void>;
277
+ compact: (options?: unknown) => Promise<void> | void;
228
278
  ui: { notify: (msg: string, level?: string) => void };
279
+ getSystemPrompt?: () => unknown;
229
280
  };
230
- const parsed = parseKeepAndPrompt(args);
231
- const keep = parsed.keepUserTurns;
232
- const followUpPrompt = parsed.followUpPrompt;
233
- const customInstructions = buildPiVccCustomInstructions(keep);
234
- try {
235
- await c.compact(customInstructions);
236
- const stats = getLastCompactionStats(pi);
237
- if (stats) scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
238
- else try { c.ui.notify("Compacted with pi-vcc (via omp-vcc)", "info"); } catch {}
239
- if (followUpPrompt) {
240
- try {
241
- const piAny = pi as unknown as { sendUserMessage?: (content: string) => Promise<void> | void };
242
- if (piAny.sendUserMessage) await piAny.sendUserMessage(followUpPrompt);
243
- } catch {}
244
- }
245
- } catch (err: unknown) {
246
- const msg = err instanceof Error ? err.message : String(err);
247
- const cancelled = msg === "Compaction cancelled" || msg === "Already compacted";
248
- const note = cancelled ? "Nothing to compact" : `Compaction failed: ${msg}`;
249
- try { c.ui.notify(note, cancelled ? "warning" : "error"); } catch {}
250
- }
281
+ await runCompactCommand(args, c, buildPiVccCustomInstructions, "Compacted with pi-vcc (via omp-vcc)", false, getCompactForm(() => c.getSystemPrompt?.()));
251
282
  },
252
283
  });
253
284
 
@@ -315,8 +346,7 @@ export default function (pi: ExtensionAPI): void {
315
346
  const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
316
347
  const { rendered, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
317
348
  if (!query) {
318
- const { rendered: r } = loadAllMessages(sessionFile, false, lineageEntryIds);
319
- const recent = r.slice(-DEFAULT_RECENT);
349
+ const recent = rendered.slice(-DEFAULT_RECENT);
320
350
  const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(recent);
321
351
  try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
322
352
  return;
@@ -326,6 +356,12 @@ export default function (pi: ExtensionAPI): void {
326
356
  const scopeSuffix = scope === "all" ? " (scope: all)" : "";
327
357
  const scopeArg = scope === "all" ? " scope:all" : "";
328
358
  const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
359
+ if (hits.length > 0 && page > totalPages) {
360
+ const guidance = truncated ? `Use /pi-vcc-recall ${query}${scopeArg} page:N with N between 1 and ${totalPages}.` : `Use /pi-vcc-recall ${query}${scopeArg} page:N with N between 1 and ${totalPages}, or refine your query.`;
361
+ const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
362
+ try { piAny.sendMessage?.({ customType: "vcc-recall", content: text, display: true }, { triggerTurn: false }); } catch {}
363
+ return;
364
+ }
329
365
  const start = (page - 1) * PAGE_SIZE;
330
366
  const pageResults = hits.slice(start, start + PAGE_SIZE);
331
367
  const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
@@ -340,7 +376,7 @@ export default function (pi: ExtensionAPI): void {
340
376
  registerVccConfigCommandHook(pi);
341
377
 
342
378
  }
343
- // ── Re-exports for pi-vcc test compatibility (not dead: tests import via hook directly,
344
- // but external consumers and the `vcc-recall` shim may import via main) ──
345
- export { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION, getLastCompactionStats, getCompactionHistory, formatCompactionStats, formatStatsTable, formatLastStatsDetail, scheduleCompactionStatsNotify, AUTO_CONTINUE_CUSTOM_TYPE, LEGACY_AUTO_CONTINUE_CUSTOM_TYPE, invalidExpandIndices, registerRecallTool, registerVccRecallCommand, registerPiVccCommand, registerVccStatsTool, registerVccStatsCommand, registerVccConfigCommand, clearCompactionHistoryForTests } from "./vcc-core/hook";
379
+ // ── Re-exports for test compatibility (hook-owned API only; the duplicate
380
+ // recall/pi-vcc registrars were deleted the factory is the single source) ──
381
+ export { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION, getLastCompactionStats, getCompactionHistory, formatCompactionStats, formatStatsTable, formatLastStatsDetail, scheduleCompactionStatsNotify, AUTO_CONTINUE_CUSTOM_TYPE, LEGACY_AUTO_CONTINUE_CUSTOM_TYPE, invalidExpandIndices, registerVccStatsTool, registerVccStatsCommand, registerVccConfigCommand, clearCompactionHistoryForTests } from "./vcc-core/hook";
346
382
  export { buildPiVccCustomInstructions, parseKeepAndPrompt } from "./vcc-core/core/compact-args";
@@ -126,10 +126,8 @@ const looksLikeRegex = (query: string): boolean =>
126
126
  /[|*+?{}()[\]\\^$.]/.test(query);
127
127
 
128
128
  /** Build a regex for snippet highlighting — matches first available term. */
129
- const snippetRegex = (terms: string[]): RegExp => {
130
- const alts = terms.map((t) => safeRegex(t).source);
131
- return new RegExp(alts.join("|"), "i");
132
- };
129
+ const snippetRegex = (sources: string[]): RegExp =>
130
+ new RegExp(sources.join("|"), "i");
133
131
 
134
132
  // ── Stopwords for natural language queries ──
135
133
  const STOPWORDS = new Set([
@@ -153,15 +151,29 @@ const filterStopwords = (terms: string[]): string[] => {
153
151
  return meaningful.length > 0 ? meaningful : terms;
154
152
  };
155
153
 
154
+ /** One query term with its matchers compiled once per search: `re` (/i/) for
155
+ * boolean tests, `freqRe` (/gi/) for occurrence counts via String.match
156
+ * (which resets lastIndex, so reuse across docs is state-safe). */
157
+ interface CompiledTerm {
158
+ term: string;
159
+ re: RegExp;
160
+ freqRe: RegExp;
161
+ }
162
+
163
+ const compileTerms = (terms: string[]): CompiledTerm[] =>
164
+ terms.map((t) => {
165
+ const re = safeRegex(t);
166
+ return { term: t, re, freqRe: new RegExp(re.source, "gi") };
167
+ });
168
+
156
169
  /** Count how many distinct terms match the haystack. */
157
- const countMatches = (hay: string, terms: string[]): number => {
170
+ const countMatches = (hay: string, compiled: CompiledTerm[]): number => {
158
171
  let count = 0;
159
- for (const t of terms) {
160
- if (safeRegex(t).test(hay)) count++;
172
+ for (const c of compiled) {
173
+ if (c.re.test(hay)) count++;
161
174
  }
162
175
  return count;
163
176
  };
164
-
165
177
  // ── BM25-lite scoring ──
166
178
  const BM25_K = 1.2;
167
179
  const BM25_B = 0.75;
@@ -178,18 +190,21 @@ interface BM25Context {
178
190
  df: Map<string, number>; // term -> number of docs containing it
179
191
  }
180
192
 
181
- /** Precompute IDF and avgDl across all docs. */
182
- const buildBM25Context = (docs: string[], terms: string[], checkBudget: () => void): BM25Context => {
193
+ /** Precompute IDF and avgDl across all docs. Word counts are measured once
194
+ * by the caller (`wordLens`, parallel to `docs`) instead of re-splitting
195
+ * every doc here and again in `bm25Score`. */
196
+ const buildBM25Context = (docs: string[], compiled: CompiledTerm[], wordLens: number[], checkBudget: () => void): BM25Context => {
183
197
  const n = docs.length;
184
198
  const df = new Map<string, number>();
185
199
  let totalLen = 0;
186
200
 
187
- for (const doc of docs) {
201
+ for (let d = 0; d < docs.length; d++) {
188
202
  checkBudget();
189
- totalLen += doc.split(/\s+/).length;
190
- for (const t of terms) {
191
- if (safeRegex(t).test(doc)) {
192
- df.set(t, (df.get(t) ?? 0) + 1);
203
+ const doc = docs[d];
204
+ totalLen += wordLens[d];
205
+ for (const c of compiled) {
206
+ if (c.re.test(doc)) {
207
+ df.set(c.term, (df.get(c.term) ?? 0) + 1);
193
208
  }
194
209
  }
195
210
  }
@@ -198,22 +213,20 @@ const buildBM25Context = (docs: string[], terms: string[], checkBudget: () => vo
198
213
  };
199
214
 
200
215
  /** 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 } => {
205
- const dl = doc.split(/\s+/).length;
216
+ * inputs the Bayesian posterior needs. `dl` is the doc's word count,
217
+ * measured once by the caller alongside `wordLens` no re-splitting. */
218
+ const bm25Score = (doc: string, compiled: CompiledTerm[], ctx: BM25Context, dl: number): { score: number; tf: number; distinctTerms: number; docLenRatio: number } => {
206
219
  let score = 0;
207
220
  let totalTf = 0;
208
221
  const seenTerms = new Set<string>();
209
222
 
210
- for (const t of terms) {
211
- const termTf = termFreq(doc, safeRegex(t));
223
+ for (const c of compiled) {
224
+ const termTf = termFreq(doc, c.freqRe);
212
225
  if (termTf === 0) continue;
213
226
  totalTf += termTf;
214
- seenTerms.add(t.toLowerCase());
227
+ seenTerms.add(c.term.toLowerCase());
215
228
 
216
- const docFreq = ctx.df.get(t) ?? 0;
229
+ const docFreq = ctx.df.get(c.term) ?? 0;
217
230
  // IDF: log((N - df + 0.5) / (df + 0.5) + 1)
218
231
  const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
219
232
  const tfNorm = (termTf * (BM25_K + 1)) / (termTf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
@@ -532,30 +545,37 @@ export const searchEntriesDetailed = (
532
545
  // Natural language / multi-word query: BM25 scoring
533
546
  const rawTerms = rawQuery.split(/\s+/);
534
547
  const terms = filterStopwords(rawTerms);
535
- const snipRe = snippetRegex(terms);
548
+ const compiled = compileTerms(terms);
549
+ const snipRe = snippetRegex(compiled.map((c) => c.re.source));
536
550
 
537
- // Build all docs for BM25 context
551
+ // Build all docs for BM25 context. Each message's searchable text is
552
+ // extracted once here (`texts`) and reused for snippets below; word
553
+ // counts (`wordLens`) are measured once for both context and scoring.
538
554
  const docs: string[] = [];
555
+ const texts: string[] = [];
556
+ const wordLens: number[] = [];
539
557
  for (let i = 0; i < entries.length; i++) {
540
558
  const e = entries[i];
541
559
  const msg = messages[i];
542
560
  const text = msg ? fullText(msg) : e.summary;
543
561
  const filePart = e.files?.join(" ") ?? "";
544
- docs.push(`${e.role} ${text} ${filePart}`);
562
+ const hay = `${e.role} ${text} ${filePart}`;
563
+ docs.push(hay);
564
+ texts.push(text);
565
+ wordLens.push(hay.split(/\s+/).length);
545
566
  }
546
567
 
547
- const ctx = buildBM25Context(docs, terms, checkBudget);
568
+ const ctx = buildBM25Context(docs, compiled, wordLens, checkBudget);
548
569
 
549
570
  const scored: Array<{ hit: SearchHit; score: number; tf: number; distinctTerms: number; docLenRatio: number }> = [];
550
571
  for (let i = 0; i < entries.length; i++) {
551
572
  checkBudget();
552
573
  const e = entries[i];
553
574
  const hay = docs[i];
554
- const mc = countMatches(hay, terms);
575
+ const mc = countMatches(hay, compiled);
555
576
  if (mc === 0) continue;
556
- const { score, tf, distinctTerms, docLenRatio } = bm25Score(hay, terms, ctx);
557
- const text = messages[i] ? fullText(messages[i]) : e.summary;
558
- const snip = lineSnippet(text, snipRe);
577
+ const { score, tf, distinctTerms, docLenRatio } = bm25Score(hay, compiled, ctx, wordLens[i]);
578
+ const snip = lineSnippet(texts[i], snipRe);
559
579
  scored.push({
560
580
  hit: { ...e, snippet: snip, matchCount: mc },
561
581
  score,
@@ -2,6 +2,21 @@
2
2
  export const DEFAULT_CHARS_PER_TOKEN = 4;
3
3
  export const MIN_CHARS_PER_TOKEN = 2;
4
4
  export const MAX_CHARS_PER_TOKEN = 6;
5
+ // Prior for dense machine-generated content (tool dumps, hex/uuid streams:
6
+ // measured ~2.1-2.7 cpt on cl100k_base vs ~4.4-5.2 for code/prose). Used when
7
+ // the slice/tokens ratio contradicts the Latin prior AND the content looks
8
+ // dense — forcing 4 there underestimates dense tails by up to ~1.9x.
9
+ export const DENSE_CONTENT_CHARS_PER_TOKEN = 3;
10
+ // A sample counts as dense when fewer than this fraction of its chars are
11
+ // letters/spaces (measured: dense 0.43, tool output 0.66, code 0.82, prose
12
+ // 0.97 — 0.7 separates machine output from human text).
13
+ export const DENSE_CONTENT_PROSE_FRACTION = 0.7;
14
+
15
+ export const isDenseContent = (text: string | undefined): boolean => {
16
+ if (!text) return false;
17
+ const letters = (text.match(/[A-Za-z ]/g) ?? []).length;
18
+ return letters / text.length < DENSE_CONTENT_PROSE_FRACTION;
19
+ };
5
20
 
6
21
  export type TokenEstimateMode = "heuristic" | "calibrated";
7
22
 
@@ -20,6 +35,7 @@ export const calibrateCharsPerToken = (
20
35
  sourceChars: number,
21
36
  sourceTokens: number | undefined,
22
37
  sampleText?: string,
38
+ tailSampleText?: string,
23
39
  ): TokenEstimateCalibration => {
24
40
  if (!sourceTokens || sourceTokens <= 0 || sourceChars <= 0) {
25
41
  return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
@@ -36,12 +52,18 @@ export const calibrateCharsPerToken = (
36
52
  // for Latin text — trusting it inflates every token estimate and the
37
53
  // pipeline over-trims. Conversely a high raw on CJK text means the token
38
54
  // count under-describes the slice. When the ratio contradicts the
39
- // content-class prior, fall back to that prior (reported as heuristic).
40
55
  if (sampleText) {
41
56
  const cjkChars = (sampleText.match(/[\u2E80-\u9FFF\uAC00-\uD7FF\u3000-\u303F]/g) ?? []).length;
42
57
  const cjk = sampleText.length > 0 && cjkChars / sampleText.length >= 0.2;
43
58
  if (!cjk && rawCharsPerToken < 2.5) {
44
- return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN, sourceChars, sourceTokens, rawCharsPerToken };
59
+ // Dense machine-generated content tokenizes near ~2-2.7 cpt, so a low
60
+ // raw can be truth (not system-prompt inflation). The head sample alone
61
+ // cannot tell them apart — a prose head with a dense tail is the exact
62
+ // shape that under-reported kept tails — so either end being dense
63
+ // selects the dense prior (3) over the prose prior (4).
64
+ const dense = isDenseContent(sampleText) || isDenseContent(tailSampleText);
65
+ const prior = dense ? DENSE_CONTENT_CHARS_PER_TOKEN : DEFAULT_CHARS_PER_TOKEN;
66
+ return { mode: "heuristic", charsPerToken: prior, sourceChars, sourceTokens, rawCharsPerToken };
45
67
  }
46
68
  if (cjk && rawCharsPerToken > 3) {
47
69
  return { mode: "heuristic", charsPerToken: MIN_CHARS_PER_TOKEN, sourceChars, sourceTokens, rawCharsPerToken };
@@ -140,6 +162,32 @@ export interface UsageStats {
140
162
  * against summed provider usage when present, heuristic fallback otherwise.
141
163
  */
142
164
  export const collectUsageStats = (messages: any[]): UsageStats => {
165
+ // Bounded content samples for the calibration slice/tokens guards
166
+ // (classification only needs a fraction of the text): head sample plus a
167
+ // tail sample, since a prose head with a dense tail selects the dense prior.
168
+ const head = { text: "" };
169
+ const tail = { text: "" };
170
+ const takeInto = (store: { text: string }, text: unknown) => {
171
+ if (store.text.length >= 8000 || typeof text !== "string" || !text) return;
172
+ store.text += (store.text ? "\n" : "") + text.slice(0, 8000 - store.text.length);
173
+ };
174
+ const samplePartsInto = (store: { text: string }, content: unknown) => {
175
+ if (typeof content === "string") takeInto(store, content);
176
+ else if (Array.isArray(content)) {
177
+ for (const part of content) {
178
+ if (part?.type === "text" && typeof part.text === "string") takeInto(store, part.text);
179
+ }
180
+ }
181
+ };
182
+ const sampleMessageInto = (store: { text: string }, m: any) => {
183
+ const role = typeof m?.role === "string" ? m.role : "unknown";
184
+ if (role === "bashExecution") {
185
+ takeInto(store, m?.command);
186
+ takeInto(store, m?.output);
187
+ } else {
188
+ samplePartsInto(store, m?.content);
189
+ }
190
+ };
143
191
  const byRole: Record<string, number> = {};
144
192
  const models = new Set<string>();
145
193
  let toolCallCount = 0;
@@ -147,21 +195,6 @@ export const collectUsageStats = (messages: any[]): UsageStats => {
147
195
  let outputChars = 0;
148
196
  let minTs = Infinity;
149
197
  let maxTs = -Infinity;
150
- // Bounded content sample for the calibration slice/tokens guards
151
- // (classification only needs a fraction of the text).
152
- let sample = "";
153
- const takeSample = (text: unknown) => {
154
- if (sample.length >= 8000 || typeof text !== "string" || !text) return;
155
- sample += (sample ? "\n" : "") + text.slice(0, 8000 - sample.length);
156
- };
157
- const sampleParts = (content: unknown) => {
158
- if (typeof content === "string") takeSample(content);
159
- else if (Array.isArray(content)) {
160
- for (const part of content) {
161
- if (part?.type === "text" && typeof part.text === "string") takeSample(part.text);
162
- }
163
- }
164
- };
165
198
  const usageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
166
199
  let sawUsage = false;
167
200
  for (const m of messages ?? []) {
@@ -181,23 +214,27 @@ export const collectUsageStats = (messages: any[]): UsageStats => {
181
214
  }
182
215
  if (role === "assistant") {
183
216
  outputChars += estimateMessageContentChars(m?.content);
184
- sampleParts(m?.content);
217
+ sampleMessageInto(head, m);
185
218
  if (Array.isArray(m?.content)) {
186
219
  for (const part of m.content) if (part?.type === "toolCall") toolCallCount++;
187
220
  }
188
221
  } else if (role === "bashExecution") {
189
222
  inputChars += (typeof m?.command === "string" ? m.command.length : 0) + 1
190
223
  + (typeof m?.output === "string" ? m.output.length : 0);
191
- takeSample(m?.command);
192
- takeSample(m?.output);
224
+ sampleMessageInto(head, m);
193
225
  } else {
194
226
  inputChars += estimateMessageContentChars(m?.content);
195
- sampleParts(m?.content);
227
+ sampleMessageInto(head, m);
196
228
  }
197
229
  }
230
+ // Tail sample in reverse: the last messages dominate kept-tail estimates.
231
+ const list = messages ?? [];
232
+ for (let i = list.length - 1; i >= 0 && tail.text.length < 8000; i--) {
233
+ sampleMessageInto(tail, list[i]);
234
+ }
198
235
  const totalChars = inputChars + outputChars;
199
236
  const sourceTokens = sawUsage ? usageTotals.input + usageTotals.output : undefined;
200
- const calibration = calibrateCharsPerToken(totalChars, sourceTokens, sample || undefined);
237
+ const calibration = calibrateCharsPerToken(totalChars, sourceTokens, head.text || undefined, tail.text || undefined);
201
238
  return {
202
239
  messageCount: messages?.length ?? 0,
203
240
  byRole,
@@ -8,27 +8,100 @@ import { loadSettings, loadSettingsWithSources, DEFAULT_SETTINGS, type PiVccSett
8
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
- import { loadAllMessages as _loadAllMessages } from "./core/load-messages";
12
- import { searchEntriesDetailed as _searchEntriesDetailed, getTouchedFiles as _getTouchedFiles } from "./core/search-entries";
13
- import { formatRecallOutput as _formatRecallOutput, formatTouchedOutput as _formatTouchedOutput } from "./core/format-recall";
14
- import { getActiveLineageEntryIds as _getActiveLineageEntryIds } from "./core/lineage";
15
- import { normalizeRecallScope as _normalizeRecallScope, normalizeRecallMode as _normalizeRecallMode, parseRecallScope as _parseRecallScope } from "./core/recall-scope";
16
- import { parseDrillDown as _parseDrillDown, expandEntryFile as _expandEntryFile, parseEntryRef as _parseEntryRef, expandEntry as _expandEntry } from "./core/drill-down";
17
-
18
- // convertToLlm shim: try host export, fallback to identity (preserves AgentMessage for omp compileRanked)
11
+
12
+ // convertToLlm shim: resolve the host export, fallback to identity (identity
13
+ // is fine for bashExecution/custom, which the pipeline renders natively, but
14
+ // it leaks !!-excluded spans and drops branchSummary entries — so the
15
+ // @earendil-works root (pi's canonical export path) is tried first.
16
+ const CONVERT_TO_LLM_CANDIDATES = [
17
+ "@earendil-works/pi-coding-agent",
18
+ "@oh-my-pi/pi-coding-agent",
19
+ "@oh-my-pi/pi-coding-agent/session/messages",
20
+ ] as const;
21
+ // Pure loader-driven resolver: first candidate whose module exports a
22
+ // convertToLlm function wins, else null (caller keeps identity).
23
+ export const resolveConvertToLlm = (
24
+ load: (id: string) => any,
25
+ ): ((messages: any[]) => any[]) | null => {
26
+ for (const id of CONVERT_TO_LLM_CANDIDATES) {
27
+ try {
28
+ const mod = load(id);
29
+ if (mod && typeof mod.convertToLlm === "function") return mod.convertToLlm;
30
+ } catch {}
31
+ }
32
+ return null;
33
+ };
19
34
  let convertToLlm: (messages: any[]) => any[] = (m) => m;
20
35
  try {
21
36
  const req = createRequire(import.meta.url);
22
- const mod = req("@oh-my-pi/pi-coding-agent/session/messages") as any;
23
- if (mod?.convertToLlm) convertToLlm = mod.convertToLlm;
37
+ convertToLlm = resolveConvertToLlm((id) => req(id)) ?? convertToLlm;
24
38
  } catch {}
25
- try {
26
- if (convertToLlm.length === 0 || (convertToLlm as any).toString().includes("=> m")) {
27
- const req2 = createRequire(import.meta.url);
28
- const mod2 = req2("@oh-my-pi/pi-coding-agent") as any;
29
- if (mod2?.convertToLlm) convertToLlm = mod2.convertToLlm;
39
+ // Test-only override for the module-level binding (mirrors
40
+ // clearCompactionHistoryForTests): lets suites pin convertToLlm wiring without
41
+ // stubbing node module resolution. Null resets to the identity fallback.
42
+ export const __setConvertToLlmForTests = (fn: ((messages: Array<unknown>) => Array<unknown>) | null): void => {
43
+ convertToLlm = fn ?? ((m) => m);
44
+ };
45
+ // Host-kind detection: omp and pi expose incompatible ctx.compact shapes
46
+ // (omp: (string|CompactOptions)=>Promise<void> with instructions on the
47
+ // string; pi: (CompactOptions)=>void with instructions only via
48
+ // options.customInstructions). Three layers, first hit wins:
49
+ // 1. Explicit test override (__setHostKindForTests).
50
+ // 2. Observable ctx shape — works in bundled runtimes where module
51
+ // resolution misses: pi's getSystemPrompt() returns a string, omp's
52
+ // returns string[]. Pure getters, safe to call.
53
+ // 3. Module scope — works in dev/source runtimes: @earendil-works is
54
+ // pi-exclusive (same mechanism as the convertToLlm shim above).
55
+ // Default "omp" preserves the legacy string-form call when host-free.
56
+ const HOST_KIND_CANDIDATES = ["@earendil-works/pi-coding-agent", "@oh-my-pi/pi-coding-agent"] as const;
57
+ export type VccHostKind = "pi" | "omp";
58
+ export type VccCompactForm = "object" | "string";
59
+ // Pure loader-driven resolver: first resolvable scope wins (@earendil-works
60
+ // first, mirroring CONVERT_TO_LLM_CANDIDATES), else "omp".
61
+ export const resolveHostKind = (load: (id: string) => unknown): VccHostKind => {
62
+ for (const id of HOST_KIND_CANDIDATES) {
63
+ try {
64
+ if (load(id)) return id.startsWith("@earendil-works") ? "pi" : "omp";
65
+ } catch {}
30
66
  }
67
+ return "omp";
68
+ };
69
+ let defaultHostKind: VccHostKind = "omp";
70
+ try {
71
+ defaultHostKind = resolveHostKind((id) => createRequire(import.meta.url)(id));
31
72
  } catch {}
73
+ let hostKindOverride: VccHostKind | null = null;
74
+ export const getHostKind = (): VccHostKind => hostKindOverride ?? defaultHostKind;
75
+ // Test-only override (mirrors __setConvertToLlmForTests). Null restores the
76
+ // detected default.
77
+ export const __setHostKindForTests = (kind: VccHostKind | null): void => {
78
+ hostKindOverride = kind;
79
+ };
80
+ // Layered compact-form decision for a live ctx. getSystemPrompt is read off
81
+ // the calling ctx (command or event); absent (host-free mocks) falls through
82
+ // to module scope, then the legacy default.
83
+ export const resolveCompactForm = (
84
+ load: (id: string) => unknown,
85
+ getSystemPrompt?: () => unknown,
86
+ ): VccCompactForm => {
87
+ if (hostKindOverride) return hostKindOverride === "pi" ? "object" : "string";
88
+ try {
89
+ const sp = getSystemPrompt?.();
90
+ if (typeof sp === "string") return "object";
91
+ if (Array.isArray(sp)) return "string";
92
+ } catch {}
93
+ return resolveHostKind(load) === "pi" ? "object" : "string";
94
+ };
95
+ export const getCompactForm = (getSystemPrompt?: () => unknown): VccCompactForm => {
96
+ let load: (id: string) => unknown = () => {
97
+ throw new Error("no loader");
98
+ };
99
+ try {
100
+ const req = createRequire(import.meta.url);
101
+ load = (id) => req(id);
102
+ } catch {}
103
+ return resolveCompactForm(load, getSystemPrompt);
104
+ };
32
105
 
33
106
  export { PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
34
107
  export const OMP_VCC_COMPACT_INSTRUCTION = "__omp_vcc__";
@@ -76,6 +149,38 @@ export interface CompactionStats {
76
149
 
77
150
  export type BudgetCutKind = "no_anchor" | "oversized_tail";
78
151
  export const OVERSIZED_TAIL_FACTOR = 2.5;
152
+ // Growth-guard tolerance: compacting removes N messages (freeing their
153
+ // per-message framing) and adds one summary entry (framing + details JSON).
154
+ // Char-diff is otherwise exact, but host token accounting has noise both
155
+ // ways, so the guard only fires on MATERIAL growth: the net-new summary
156
+ // content must exceed the removed prefix by more than a fixed framing
157
+ // allowance (one entry, ~128 tok) or 25% of the prefix (per-message
158
+ // overhead share) — or exceed the absolute cap (~1k tok) at any ratio, so
159
+ // large-scale drift cannot hide behind a big denominator.
160
+ export const COMPACTION_GROWTH_FIXED_MARGIN_CHARS = 512;
161
+ export const COMPACTION_GROWTH_RELATIVE_MARGIN = 0.25;
162
+ export const COMPACTION_GROWTH_ABSOLUTE_CAP_CHARS = 4096;
163
+
164
+ export interface GrowthGuardVerdict {
165
+ trip: boolean;
166
+ netGrowthChars: number;
167
+ toleranceChars: number;
168
+ }
169
+
170
+ // Pure growth-guard predicate (calibration-free). Table-driven unit tests in
171
+ // tests/compaction-growth-guard.test.ts pin every arm and edge.
172
+ export const evaluateGrowthGuard = (prefixChars: number, netNewSummaryChars: number): GrowthGuardVerdict => {
173
+ const netGrowthChars = netNewSummaryChars - prefixChars;
174
+ const toleranceChars = Math.max(
175
+ COMPACTION_GROWTH_FIXED_MARGIN_CHARS,
176
+ Math.round(prefixChars * COMPACTION_GROWTH_RELATIVE_MARGIN),
177
+ );
178
+ return {
179
+ trip: netGrowthChars > toleranceChars || netGrowthChars > COMPACTION_GROWTH_ABSOLUTE_CAP_CHARS,
180
+ netGrowthChars,
181
+ toleranceChars,
182
+ };
183
+ }
79
184
 
80
185
  let lastStats: CompactionStats | null = null;
81
186
  let lastCompactWasPiVcc = false;
@@ -368,6 +473,27 @@ const previewContent = (content: unknown): string => {
368
473
  return "";
369
474
  };
370
475
 
476
+ /** Join parts with "\n" truncated to `bound` chars — byte-identical to
477
+ * `parts.join("\n").slice(0, bound)` without materializing the full
478
+ * joined string (calibration samples join up to 50 message contents,
479
+ * any one of which can be megabytes). */
480
+ export const joinBounded = (parts: string[], bound: number): string => {
481
+ let out = "";
482
+ let rem = bound;
483
+ for (let i = 0; i < parts.length; i++) {
484
+ if (rem <= 0) break;
485
+ if (i > 0) {
486
+ out += "\n";
487
+ rem--;
488
+ if (rem <= 0) break;
489
+ }
490
+ const take = parts[i].slice(0, rem);
491
+ out += take;
492
+ rem -= take.length;
493
+ }
494
+ return out;
495
+ };
496
+
371
497
  interface EntryWithMessage {
372
498
  entry: { id: string; type: string };
373
499
  message: { role: string; content: unknown };
@@ -739,18 +865,26 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
739
865
  // Always handle explicit /pi-vcc or /omp-vcc marker.
740
866
  // Otherwise, only handle when user opted in via settings.
741
867
  const { isPiVcc, keepUserTurns, keepUserTurnsExplicit, followUpPrompt } = parseCompactionInstructions(customInstructions);
742
- setPendingFollowUpPrompt(pi, null);
743
868
  // Explicit host mode bypass: when the host signals an explicit compact mode
744
- // (e.g. /compact snapcompact or --mode shake), let the host walker handle it
745
- // even though overrideDefaultCompaction is true. This enables sequential
746
- // VCC → snapcompact/shake combinations. The event field is only present when
747
- // the optional native vcc patch is applied or a future host exposes it; when
748
- // absent this branch is no-op and the existing override semantics remain.
869
+ // via an event field, let the host walker handle it even though
870
+ // overrideDefaultCompaction is true. This enables sequential VCC →
871
+ // snapcompact/shake combinations. No shipped host exposes such a field
872
+ // today omp carries the mode in the compact() options (never the event)
873
+ // and pi has no modes (its /compact text is raw focus instructions, so
874
+ // lone mode words must NEVER bypass: on pi `/compact shake` means
875
+ // "focus on shake"). The branch stays as the contract for the optional
876
+ // native patch / future hosts; unpatched, override:true serves explicit
877
+ // omp modes via VCC (use override:false for native modes).
749
878
  const explicitMode = (event as any).compactMode ?? (event as any).explicitMode ?? (event as any).mode;
750
879
  if (!isPiVcc && typeof explicitMode === "string" && explicitMode) {
751
880
  const m = explicitMode.toLowerCase();
752
881
  if (m === "snapcompact" || m === "shake" || m === "soft" || m === "remote" || m === "handoff") return;
753
882
  }
883
+ // Chain-shake yield: while a {mode:"shake"} chain is in flight (see
884
+ // session_compact below), let the host run it — otherwise VCC would
885
+ // swallow the modeless call into a second VCC pass. Sentinel compactions
886
+ // still handled (isPiVcc path falls through below).
887
+ if (!isPiVcc && pendingChainShake.has(pi as unknown as object)) return;
754
888
  if (!isPiVcc && !settings.overrideDefaultCompaction) return;
755
889
 
756
890
  const calibrationCut = buildOwnCut(branchEntries as any[], 0);
@@ -763,20 +897,29 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
763
897
  const calibrationSummaryChars = typeof preparation.previousSummary === "string"
764
898
  ? preparation.previousSummary.length
765
899
  : 0;
766
- // Content sample for the slice/tokens mismatch guards: slice text when the
767
- // calibration cut has any, else the previous summary. Bounded (first 50
768
- // string contents, 8k chars) classification only needs a fraction.
769
- const calibrationSample = calibrationCut.ok
770
- ? calibrationCut.messages
771
- .slice(0, 50)
772
- .map((message: any) => (typeof message.content === "string" ? message.content : ""))
773
- .join("\n")
774
- .slice(0, 8000)
775
- : "";
900
+ // Content samples for the mismatch guards: head text (first 50 mapped
901
+ // contents string content or "" per message) plus tail text (last 50).
902
+ // A prose head with a dense tail is the exact shape that under-reported
903
+ // kept tails, so density is checked on both ends. Bounded (8k chars each):
904
+ // only the sampled windows are joined, never the whole transcript.
905
+ const calibrationMsgs = calibrationCut.ok ? calibrationCut.messages : [];
906
+ const headContents: string[] = [];
907
+ for (let i = 0; i < calibrationMsgs.length && headContents.length < 50; i++) {
908
+ const c = (calibrationMsgs[i] as any).content;
909
+ headContents.push(typeof c === "string" ? c : "");
910
+ }
911
+ const tailContents: string[] = [];
912
+ for (let i = Math.max(0, calibrationMsgs.length - 50); i < calibrationMsgs.length; i++) {
913
+ const c = (calibrationMsgs[i] as any).content;
914
+ tailContents.push(typeof c === "string" ? c : "");
915
+ }
916
+ const calibrationSample = joinBounded(headContents, 8000);
917
+ const calibrationTailSample = joinBounded(tailContents, 8000);
776
918
  const tokenEstimate = calibrateCharsPerToken(
777
919
  calibrationMessageChars + calibrationSummaryChars,
778
920
  preparation.tokensBefore,
779
921
  calibrationSample || (typeof preparation.previousSummary === "string" ? preparation.previousSummary.slice(0, 8000) : undefined),
922
+ calibrationTailSample || undefined,
780
923
  );
781
924
 
782
925
  // Smart keep-tail: boost default keep when the tail is small.
@@ -951,8 +1094,53 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
951
1094
  return { cancel: true };
952
1095
  }
953
1096
 
954
- const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
1097
+ // Growth guard: never emit a summary that materially adds more than it
1098
+ // removes. tokensAfter - tokensBefore ≈ (netNew - prefix) / cpt: the kept
1099
+ // tail cancels out, so the comparison is calibration-independent and
1100
+ // exact in chars (plan-mode "compact and execute" grew 85K→87K: a 2-msg
1101
+ // prefix replaced by a fixed-cost summary + brief floor). Compare the
1102
+ // net-new content (summary minus carried-forward previous summary, which
1103
+ // is already counted in the live context) against the removed prefix.
1104
+ // Fires only on material growth — a fixed framing allowance or 25% of the
1105
+ // prefix (host accounting noise), or the absolute cap (~1k tok) at any
1106
+ // ratio. On overflow/willRetry the window is exhausted and SOME compaction
1107
+ // must happen: abstain (host default proceeds) instead of cancelling.
955
1108
  const summaryChars = summary.length;
1109
+ const prefixChars = agentMessages.reduce(
1110
+ (sum: number, message: any) => sum + estimateMessageContentChars(message.content),
1111
+ 0,
1112
+ );
1113
+ const prevSummaryChars = typeof preparation.previousSummary === "string"
1114
+ ? preparation.previousSummary.length
1115
+ : 0;
1116
+ const netNewSummaryChars = summaryChars - Math.min(summaryChars, prevSummaryChars);
1117
+ const guard = evaluateGrowthGuard(prefixChars, netNewSummaryChars);
1118
+ const { netGrowthChars, toleranceChars } = guard;
1119
+ if (guard.trip) {
1120
+ const prefixTok = estimateTokensFromChars(prefixChars, tokenEstimate.charsPerToken);
1121
+ const netNewTok = estimateTokensFromChars(netNewSummaryChars, tokenEstimate.charsPerToken);
1122
+ dbg(settings, {
1123
+ growthGuard: true,
1124
+ cancelled: reason !== "overflow" && !willRetry,
1125
+ fallbackToCore: reason === "overflow" || willRetry,
1126
+ compaction: { reason, willRetry },
1127
+ prefixChars,
1128
+ prevSummaryChars,
1129
+ netNewSummaryChars,
1130
+ netGrowthChars,
1131
+ toleranceChars,
1132
+ });
1133
+ try {
1134
+ ctx?.ui?.notify?.(
1135
+ `omp-vcc: compaction would grow context (prefix ~${formatTokens(prefixTok)} tok, summary adds ~${formatTokens(netNewTok)} tok) — ${reason === "overflow" || willRetry ? "deferring to host compaction" : "cancelled"}`,
1136
+ "info",
1137
+ );
1138
+ } catch {}
1139
+ if (reason === "overflow" || willRetry) return;
1140
+ return { cancel: true };
1141
+ }
1142
+
1143
+ const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
956
1144
  const summaryTokensEst = estimateTokensFromChars(summaryChars, tokenEstimate.charsPerToken);
957
1145
  const tokensAfterEst = summaryTokensEst + keptTokensEst;
958
1146
  const tokensSavedEst = tokensBefore > 0 ? Math.max(0, tokensBefore - tokensAfterEst) : 0;
@@ -1102,13 +1290,21 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1102
1290
  const isLargeCompaction = (stats.summarized > 10) || (stats.kept > 5) || (stats.keptTokensEst > 2000);
1103
1291
  const shouldContinueAfterAutoCompact = (reason === "threshold" || reason === "overflow" || (reason == null && isLargeCompaction)) && loadSettings(ctx).continueAfterThresholdCompact;
1104
1292
  scheduleCompactionStatsNotify(ctx, stats);
1105
- // Eager post-VCC shake chain (chainShakeHint). Host rescue already handles
1106
- // dead-end; this forces a second shake entry even when headroom was made.
1293
+ // Eager post-VCC shake chain (chainShakeHint, omp only). {mode:"shake"}
1294
+ // is the omp native spelling; the before handler above yields while
1295
+ // pendingChainShake is set so the host actually runs shake instead of
1296
+ // VCC swallowing the modeless call. pi's CompactOptions has no mode key
1297
+ // (the call would trigger a spurious default compaction), so pi never
1298
+ // chains — host rescue remains the only shake path there.
1107
1299
  try {
1108
1300
  const cfgChain = loadSettings(ctx);
1109
1301
  const ctxMaybe = ctx as unknown as Record<string, unknown>;
1110
1302
  const compactFn = ctxMaybe["compact"];
1111
- if (cfgChain.chainShakeHint && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry && !isPiVccLast) {
1303
+ const promptOf = ctxMaybe["getSystemPrompt"] as ((this: unknown) => unknown) | undefined;
1304
+ // Form is detected off the live ctx so bundled runtimes (no module
1305
+ // scope) still decide correctly.
1306
+ const chainForm = getCompactForm(() => promptOf?.call(ctx));
1307
+ if (cfgChain.chainShakeHint && chainForm === "string" && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry && !isPiVccLast) {
1112
1308
  pendingChainShake.add(pi as unknown as object);
1113
1309
  const maybePromise = (compactFn as unknown as (o: unknown) => Promise<void>).call(ctx, { mode: "shake" } as unknown);
1114
1310
  const asPromise = maybePromise as unknown as Promise<void> | void;
@@ -1122,8 +1318,12 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1122
1318
  }
1123
1319
  } catch {}
1124
1320
  if (followUpPrompt) {
1321
+ // Fire-and-forget: pi's sendUserMessage returns void, omp's returns a
1322
+ // promise — never await either, but swallow async rejections so a
1323
+ // failed redelivery cannot surface as an unhandled rejection.
1125
1324
  try {
1126
- await pi.sendUserMessage(followUpPrompt);
1325
+ const sent = (pi as any).sendUserMessage?.(followUpPrompt) as Promise<void> | undefined;
1326
+ if (sent && typeof sent.catch === "function") sent.catch(() => {});
1127
1327
  } catch {}
1128
1328
  } else if (shouldContinueAfterAutoCompact) {
1129
1329
  scheduleAutoContinueForPi(pi);
@@ -1136,172 +1336,6 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1136
1336
  export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
1137
1337
  requested.filter((i) => !Number.isInteger(i) || !available.has(i));
1138
1338
 
1139
- const DEFAULT_RECENT = 25;
1140
- const PAGE_SIZE = 5;
1141
-
1142
- export const registerRecallTool = (pi: any) => {
1143
- const schema = pi?.zod?.object
1144
- ? pi.zod.object({
1145
- query: pi.zod.string().optional(),
1146
- expand: pi.zod.array(pi.zod.number()).optional(),
1147
- page: pi.zod.number().optional(),
1148
- scope: pi.zod.enum(["lineage", "all", "active"]).optional(),
1149
- mode: pi.zod.enum(["hybrid", "touched"]).optional(),
1150
- })
1151
- : {};
1152
- pi.registerTool({
1153
- name: "vcc_recall",
1154
- label: "VCC Recall",
1155
- description: "Recall earlier parts of the current session",
1156
- approval: "read",
1157
- parameters: schema,
1158
- async execute(_toolCallId: string, params: any, _signal: unknown, _onUpdate: unknown, ctx: any) {
1159
- const sessionFile = ctx?.sessionManager?.getSessionFile?.();
1160
- if (!sessionFile) return { content: [{ type: "text", text: "No session file available." }] };
1161
- const scope = _normalizeRecallScope(params.scope === "active" ? "lineage" : params.scope);
1162
- const lineageEntryIds = scope === "lineage" ? _getActiveLineageEntryIds(ctx.sessionManager) : undefined;
1163
- const q = params.query?.trim();
1164
- if (q && _parseEntryRef(q)) {
1165
- const ref = _parseEntryRef(q)!;
1166
- if (lineageEntryIds) {
1167
- const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1168
- if (!rendered.some((m) => m.index === ref.index)) {
1169
- return { content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${ref.index}. Use scope:'all' to reach other branches.` }] };
1170
- }
1171
- }
1172
- const text = _expandEntry(sessionFile, ref.index, ref.full, ref.offset, ref.limit);
1173
- return { content: [{ type: "text", text }] };
1174
- }
1175
- if (q && _parseDrillDown(q)) {
1176
- const parsed = _parseDrillDown(q)!;
1177
- if (lineageEntryIds) {
1178
- const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1179
- if (!rendered.some((m) => m.index === parsed.index)) {
1180
- return { content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${parsed.index}. Use scope:'all' to reach other branches.` }] };
1181
- }
1182
- }
1183
- const text = _expandEntryFile(sessionFile, parsed.index, parsed.pathPattern, parsed.full, parsed.offset, parsed.limit);
1184
- return { content: [{ type: "text", text }] };
1185
- }
1186
- if (_normalizeRecallMode(params.mode) === "touched") {
1187
- const { rendered, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1188
- const touched = _getTouchedFiles(rawMessages as any, rendered);
1189
- const text = _formatTouchedOutput(touched, params.page);
1190
- return { content: [{ type: "text", text }] };
1191
- }
1192
- const expandSet = new Set(params.expand ?? []);
1193
- if (expandSet.size > 0) {
1194
- const { rendered: fullMsgs } = _loadAllMessages(sessionFile, true, lineageEntryIds);
1195
- const requested = [...expandSet];
1196
- const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
1197
- const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
1198
- if (invalid.length > 0) return { content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }] };
1199
- const expanded = requested.map((i) => byIndex.get(i)).filter(Boolean) as any[];
1200
- const output = (scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(expanded);
1201
- return { content: [{ type: "text", text: output }] };
1202
- }
1203
- const { rendered: msgs, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1204
- if (q) {
1205
- const { hits, totalBeforeCap, truncated } = _searchEntriesDetailed(msgs, rawMessages as any, q);
1206
- const page = Math.max(1, params.page ?? 1);
1207
- const totalPages = Math.ceil(hits.length / PAGE_SIZE);
1208
- const scopeSuffix = scope === "all" ? " (scope: all)" : "";
1209
- const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
1210
- if (hits.length > 0 && page > totalPages) {
1211
- const guidance = truncated ? `Use a page between 1 and ${totalPages}.` : `Use a page between 1 and ${totalPages}, or refine your query.`;
1212
- const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
1213
- return { content: [{ type: "text", text }] };
1214
- }
1215
- const start = (page - 1) * PAGE_SIZE;
1216
- const pageResults = hits.slice(start, start + PAGE_SIZE);
1217
- const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
1218
- const footer = page < totalPages ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---` : "";
1219
- const output = _formatRecallOutput(pageResults, q, header, { truncated, totalBeforeCap }) + footer;
1220
- return { content: [{ type: "text", text: output }] };
1221
- }
1222
- const output = (scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(msgs.slice(-DEFAULT_RECENT), q);
1223
- return { content: [{ type: "text", text: output }] };
1224
- },
1225
- });
1226
- };
1227
-
1228
- export const registerVccRecallCommand = (pi: any) => {
1229
- pi.registerCommand("pi-vcc-recall", {
1230
- description: "Recall earlier parts of this session",
1231
- handler: async (args: string, ctx: any) => {
1232
- const sessionFile = ctx?.sessionManager?.getSessionFile?.();
1233
- if (!sessionFile) { try { ctx.ui.notify("No session file available.", "error"); } catch {} return; }
1234
- const raw = args.trim();
1235
- const parsed = _parseRecallScope(raw);
1236
- const lineageEntryIds = parsed.scope === "lineage" ? _getActiveLineageEntryIds(ctx.sessionManager) : undefined;
1237
- if (!parsed.text) {
1238
- const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1239
- const recent = rendered.slice(-DEFAULT_RECENT);
1240
- const output = (parsed.scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(recent);
1241
- try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
1242
- return;
1243
- }
1244
- const pageMatch = parsed.text.match(/\bpage:(\d+)\b/i);
1245
- const page = pageMatch ? Math.max(1, parseInt(pageMatch[1], 10)) : 1;
1246
- const query = parsed.text.replace(/\bpage:\d+\b/i, "").trim();
1247
- if (!query) {
1248
- const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1249
- const recent = rendered.slice(-DEFAULT_RECENT);
1250
- const output = (parsed.scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(recent);
1251
- try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
1252
- return;
1253
- }
1254
- const { rendered, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1255
- const { hits, totalBeforeCap, truncated } = _searchEntriesDetailed(rendered, rawMessages as any, query);
1256
- const totalPages = Math.ceil(hits.length / PAGE_SIZE);
1257
- const scopeSuffix = parsed.scope === "all" ? " (scope: all)" : "";
1258
- const scopeArg = parsed.scope === "all" ? " scope:all" : "";
1259
- const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
1260
- if (hits.length > 0 && page > totalPages) {
1261
- const guidance = truncated ? `Use /pi-vcc-recall ${query}${scopeArg} page:N with N between 1 and ${totalPages}.` : `Use /pi-vcc-recall ${query}${scopeArg} page:N with N between 1 and ${totalPages}, or refine your query.`;
1262
- const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
1263
- try { pi.sendMessage?.({ customType: "vcc-recall", content: text, display: true }, { triggerTurn: false }); } catch {}
1264
- return;
1265
- }
1266
- const start = (page - 1) * PAGE_SIZE;
1267
- const pageResults = hits.slice(start, start + PAGE_SIZE);
1268
- const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
1269
- const footer = page < totalPages ? `\n--- /pi-vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
1270
- const output = _formatRecallOutput(pageResults, query, header, { truncated, totalBeforeCap }) + footer;
1271
- try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
1272
- },
1273
- });
1274
- };
1275
-
1276
- export const registerPiVccCommand = (pi: any) => {
1277
- pi.registerCommand("pi-vcc", {
1278
- description: "Compact conversation with pi-vcc structured summary",
1279
- handler: async (args: string, ctx: any) => {
1280
- const { followUpPrompt, keepUserTurns } = parseKeepAndPrompt(args);
1281
- ctx.compact({
1282
- customInstructions: buildPiVccCustomInstructions(keepUserTurns),
1283
- onComplete: () => {
1284
- const stats = getLastCompactionStats(pi);
1285
- if (stats) {
1286
- scheduleCompactionStatsNotify(ctx, stats);
1287
- } else {
1288
- ctx.ui.notify("Compacted with pi-vcc", "info");
1289
- }
1290
- if (followUpPrompt) {
1291
- try {
1292
- void Promise.resolve(pi.sendUserMessage(followUpPrompt)).catch(() => {});
1293
- } catch {}
1294
- }
1295
- },
1296
- onError: (err) => {
1297
- const cancelled = err.message === "Compaction cancelled" || err.message === "Already compacted";
1298
- const msg = cancelled ? "Nothing to compact" : `Compaction failed: ${err.message}`;
1299
- ctx.ui.notify(msg, cancelled ? "warning" : "error");
1300
- },
1301
- });
1302
- },
1303
- });
1304
- };
1305
1339
  export const registerVccStatsTool = (pi: any) => {
1306
1340
  const hasBoolean = typeof pi?.zod?.boolean === "function";
1307
1341
  const schema = pi?.zod?.object && hasBoolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-vcc",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "description": "Algorithmic VCC compaction for omp - fast lossless no-LLM",
6
6
  "author": "Zhu Lin <zhulin@czl.my>",
package/scripts/smoke.ts CHANGED
@@ -59,6 +59,14 @@ try {
59
59
  "vcc_stats registered",
60
60
  tools.some((t) => t.name === "vcc_stats"),
61
61
  );
62
+ check(
63
+ "vcc_recall schema has query/expand/page/scope/mode",
64
+ (() => {
65
+ const t = tools.find((t) => t.name === "vcc_recall");
66
+ const keys = t?.parameters ? Object.keys(t.parameters) : [];
67
+ return ["query", "expand", "page", "scope", "mode"].every((k) => keys.includes(k));
68
+ })(),
69
+ );
62
70
  check(
63
71
  "omp-vcc command registered",
64
72
  commands.some((c) => c.name === "omp-vcc"),
package/types.d.ts CHANGED
@@ -14,13 +14,13 @@ declare module "@oh-my-pi/pi-coding-agent" {
14
14
  getBranch(fromId?: string): any[];
15
15
  getEntries(): any[];
16
16
  };
17
- compact(instructionsOrOptions?: string | any): Promise<void>;
17
+ compact(instructionsOrOptions?: string | any): Promise<void> | void;
18
18
  sendMessage?: any;
19
19
  sendUserMessage?: any;
20
20
  [key: string]: unknown;
21
21
  }
22
22
  export interface ExtensionCommandContext extends ExtensionContext {
23
- compact(instructionsOrOptions?: string | any): Promise<void>;
23
+ compact(instructionsOrOptions?: string | any): Promise<void> | void;
24
24
  }
25
25
  export interface ExtensionAPI {
26
26
  registerTool(tool: unknown): void;
@@ -1,2 +0,0 @@
1
- // @ts-nocheck
2
- export { registerVccRecallCommand } from "../hook";