omp-vcc 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,30 @@
1
1
  // @ts-nocheck
2
2
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
3
3
  import { createRequire } from "node:module";
4
- import { writeFileSync } from "fs";
5
- import { compileRanked } from "./core/summarize";
4
+ import { appendFileSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
5
+ import { dirname, join } from "path";
6
+ import { compileRanked, compileSegment } from "./core/summarize";
7
+ import { buildGlobalIndex, type PersistedSessionEntry } from "./core/global-indices";
8
+ import { scanSessionEntries } from "./core/session-lines";
9
+ import {
10
+ buildAppendOnlyDetails,
11
+ collectActiveSegments,
12
+ compactionThresholds,
13
+ coverageForMessages,
14
+ decideAppendMode,
15
+ estimateChainTokens,
16
+ isPiVccAppendDetails,
17
+ projectAppendOnlyContext,
18
+ } from "./core/compaction-chain";
19
+ import {
20
+ applyRetainedToolOutputProjection,
21
+ buildRetainedToolOutputProjection,
22
+ type RetainedToolOutputProjection,
23
+ } from "./core/tool-output-budget";
6
24
  import { buildPiVccCustomInstructions, parseKeepAndPrompt, PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
7
- import { loadSettings, loadSettingsWithSources, DEFAULT_SETTINGS, type PiVccSettings, type VccConfigView } from "./core/settings";
8
- import { calibrateCharsPerToken, estimateMessageContentChars, estimateMessageContentTokens, estimateTokensFromChars, collectUsageStats } from "./core/token-estimate";
25
+ import { loadSettings, loadSettingsWithPluginOverlay, loadSettingsWithSourcesAsync, getSettingsPath, DEFAULT_SETTINGS, type PiVccSettings, type VccConfigView } from "./core/settings";
26
+ import { calibrateCharsPerToken, estimateMessageContentChars, estimateScriptAwareTokens, estimateScriptAwareMessageContentTokens, collectUsageStats } from "./core/token-estimate";
27
+ import { sanitize } from "./core/sanitize";
9
28
  import type { PiVccCompactionDetails } from "./details";
10
29
  import type { CompactionReason } from "./types";
11
30
 
@@ -185,33 +204,60 @@ export const evaluateGrowthGuard = (prefixChars: number, netNewSummaryChars: num
185
204
  let lastStats: CompactionStats | null = null;
186
205
  let lastCompactWasPiVcc = false;
187
206
  let pendingFollowUpPrompt: string | null = null;
188
- let pendingAutoContinueTimer: any = null;
207
+ let pendingAutoContinueTimer: unknown = null;
189
208
  let globalHistory: CompactionStats[] = [];
190
- // Per-pi state to avoid cross-session pollution when multiple sessions share the
191
- // same ESM module singleton (e.g. main + subagents). Module globals remain as
192
- // fallback for host-free tests that call getLastCompactionStats() without a pi.
193
- const perPi = new WeakMap<any, { lastStats: CompactionStats | null; lastCompactWasPiVcc: boolean; pendingFollowUpPrompt: string | null; pendingAutoContinueTimer: any; statsHistory: CompactionStats[] }>();
194
- // Track strong refs for test helper clearCompactionHistoryForTests: WeakMap keys
195
- // cannot be enumerated, so keep a Set for test-only cleanup.
209
+
210
+ interface PerPiState {
211
+ lastStats: CompactionStats | null;
212
+ lastCompactWasPiVcc: boolean;
213
+ pendingFollowUpPrompt: string | null;
214
+ pendingAutoContinueTimer: unknown;
215
+ statsHistory: CompactionStats[];
216
+ generation: number;
217
+ sessionId?: string;
218
+ timers: Set<unknown>;
219
+ pendingDisplay?: { text: string; sourceEntryId?: string; truncated: boolean };
220
+ autoCompaction?: { generation: number; sessionId?: string; reason: string; action: string; willRetry: boolean };
221
+ pendingCompactionFingerprint?: string;
222
+ pendingPreviousStats?: CompactionStats | null;
223
+ pendingStatsHistoryLength?: number;
224
+ lastSettings?: PiVccSettings;
225
+ }
226
+
227
+ const perPi = new WeakMap<any, PerPiState>();
196
228
  const perPiKeys = new Set<any>();
197
- // Guard eager chainShakeHint to avoid recursion: tracks pis currently chaining.
198
229
  const pendingChainShake = new WeakSet<object>();
199
- const getPerPi = (pi: any) => {
230
+ const getPerPi = (pi: any): PerPiState | null => {
200
231
  if (!pi || typeof pi !== "object") return null;
201
- let s = perPi.get(pi);
202
- if (!s) { s = { lastStats: null, lastCompactWasPiVcc: false, pendingFollowUpPrompt: null, pendingAutoContinueTimer: null, statsHistory: [] }; perPi.set(pi, s); perPiKeys.add(pi); }
203
- if (!s.statsHistory) s.statsHistory = [];
204
- return s;
232
+ let state = perPi.get(pi);
233
+ if (!state) {
234
+ state = {
235
+ lastStats: null,
236
+ lastCompactWasPiVcc: false,
237
+ pendingFollowUpPrompt: null,
238
+ pendingAutoContinueTimer: null,
239
+ statsHistory: [],
240
+ generation: 0,
241
+ timers: new Set<unknown>(),
242
+ pendingDisplay: undefined,
243
+ };
244
+ perPi.set(pi, state);
245
+ perPiKeys.add(pi);
246
+ }
247
+ if (!state.statsHistory) state.statsHistory = [];
248
+ if (!state.timers) state.timers = new Set<unknown>();
249
+ if (!state.pendingDisplay) state.pendingDisplay = undefined;
250
+ return state;
205
251
  };
206
252
  const setLastStats = (pi: any, v: CompactionStats | null) => {
207
253
  if (v && v.timestamp == null) v.timestamp = Date.now();
208
254
  lastStats = v;
209
- const s = getPerPi(pi);
210
- if (s) {
211
- s.lastStats = v;
255
+ const state = getPerPi(pi);
256
+ if (state) {
257
+ state.lastStats = v;
212
258
  if (v) {
213
- s.statsHistory.push(v);
214
- if (s.statsHistory.length > 50) s.statsHistory.shift();
259
+ state.statsHistory.push(v);
260
+ if (state.statsHistory.length > 50) state.statsHistory.shift();
215
261
  }
216
262
  }
217
263
  if (v) {
@@ -219,26 +265,103 @@ const setLastStats = (pi: any, v: CompactionStats | null) => {
219
265
  if (globalHistory.length > 50) globalHistory.shift();
220
266
  }
221
267
  };
222
- const setLastCompactWasPiVcc = (pi: any, v: boolean) => { lastCompactWasPiVcc = v; const s = getPerPi(pi); if (s) s.lastCompactWasPiVcc = v; };
223
- const setPendingFollowUpPrompt = (pi: any, v: string | null) => { pendingFollowUpPrompt = v; const s = getPerPi(pi); if (s) s.pendingFollowUpPrompt = v; };
224
- const getPendingFollowUpPrompt = (pi: any) => { const s = getPerPi(pi); return s ? s.pendingFollowUpPrompt : pendingFollowUpPrompt; };
225
- const clearPendingAutoContinueForPi = (pi: any) => {
226
- const s = getPerPi(pi);
227
- clearTimeout(s ? s.pendingAutoContinueTimer as any : pendingAutoContinueTimer as any);
228
- clearTimeout(pendingAutoContinueTimer as any);
268
+ const setLastCompactWasPiVcc = (pi: any, v: boolean) => {
269
+ lastCompactWasPiVcc = v;
270
+ const state = getPerPi(pi);
271
+ if (state) state.lastCompactWasPiVcc = v;
272
+ };
273
+ const setPendingFollowUpPrompt = (pi: any, v: string | null) => {
274
+ pendingFollowUpPrompt = v;
275
+ const state = getPerPi(pi);
276
+ if (state) state.pendingFollowUpPrompt = v;
277
+ };
278
+ const getPendingFollowUpPrompt = (pi: any) => {
279
+ const state = getPerPi(pi);
280
+ return state ? state.pendingFollowUpPrompt : pendingFollowUpPrompt;
281
+ };
282
+ const sessionIdOf = (ctx: any): string | undefined => {
283
+ try {
284
+ const id = ctx?.sessionManager?.getSessionId?.();
285
+ return typeof id === "string" ? id : undefined;
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ };
290
+ const isCurrentGeneration = (pi: any, ctx: any, generation: number, sessionId: string | undefined): boolean => {
291
+ const state = getPerPi(pi);
292
+ if (!state || state.generation !== generation) return false;
293
+ return (state.sessionId ?? sessionIdOf(ctx)) === sessionId;
294
+ };
295
+ const clearTimerHandle = (ctx: any, timer: unknown): void => {
296
+ if (timer == null) return;
297
+ try {
298
+ if (typeof ctx?.clearTimer === "function") ctx.clearTimer(timer);
299
+ else clearTimeout(timer as Parameters<typeof clearTimeout>[0]);
300
+ } catch {}
301
+ };
302
+ const scheduleManaged = (
303
+ pi: any,
304
+ ctx: any,
305
+ callback: () => void,
306
+ delay: number,
307
+ kind: string,
308
+ ): unknown => {
309
+ const state = getPerPi(pi);
310
+ const generation = state?.generation ?? 0;
311
+ const sessionId = state?.sessionId ?? sessionIdOf(ctx);
312
+ let handle: unknown;
313
+ const guarded = () => {
314
+ if (state) state.timers.delete(handle);
315
+ if (state && (state.generation !== generation || state.sessionId !== sessionId)) {
316
+ logMetrics(loadSettings(ctx), { event: "stale-callback", kind, generation, sessionId });
317
+ return;
318
+ }
319
+ try { callback(); } catch (error) { throw error; }
320
+ };
321
+ handle = typeof ctx?.setTimeout === "function" ? ctx.setTimeout(guarded, delay) : setTimeout(guarded, delay);
322
+ state?.timers.add(handle);
323
+ return handle;
324
+ };
325
+ const advanceSessionGeneration = (pi: any, ctx: any): void => {
326
+ const state = getPerPi(pi);
327
+ if (!state) return;
328
+ for (const timer of state.timers) clearTimerHandle(ctx, timer);
329
+ state.timers.clear();
330
+ state.generation++;
331
+ state.sessionId = sessionIdOf(ctx);
332
+ state.lastStats = null;
333
+ state.pendingCompactionFingerprint = undefined;
334
+ state.pendingPreviousStats = undefined;
335
+ state.pendingStatsHistoryLength = undefined;
336
+ state.lastSettings = undefined;
337
+ state.pendingAutoContinueTimer = null;
338
+ state.statsHistory = [];
339
+ state.pendingDisplay = undefined;
340
+ state.autoCompaction = undefined;
341
+ pendingFollowUpPrompt = null;
229
342
  pendingAutoContinueTimer = null;
230
- if (s) s.pendingAutoContinueTimer = null;
343
+ lastCompactWasPiVcc = false;
344
+ pendingChainShake.delete(pi);
231
345
  };
232
- const scheduleAutoContinueForPi = (pi: any) => {
233
- clearPendingAutoContinueForPi(pi);
234
- const s = getPerPi(pi);
235
- const timer: any = setTimeout(() => {
346
+ const clearPendingAutoContinueForPi = (pi: any, ctx?: any): void => {
347
+ const state = getPerPi(pi);
348
+ const timer = state ? state.pendingAutoContinueTimer : pendingAutoContinueTimer;
349
+ clearTimerHandle(ctx, timer);
350
+ if (state) {
351
+ state.timers.delete(timer);
352
+ state.pendingAutoContinueTimer = null;
353
+ } else {
236
354
  pendingAutoContinueTimer = null;
237
- if (s) s.pendingAutoContinueTimer = null;
355
+ }
356
+ };
357
+ const scheduleAutoContinueForPi = (pi: any, ctx?: any): void => {
358
+ clearPendingAutoContinueForPi(pi, ctx);
359
+ const state = getPerPi(pi);
360
+ const timer = scheduleManaged(pi, ctx, () => {
361
+ if (state) state.pendingAutoContinueTimer = null;
238
362
  try { triggerInvisibleContinue(pi); } catch {}
239
- }, 0);
240
- pendingAutoContinueTimer = timer;
241
- if (s) s.pendingAutoContinueTimer = timer;
363
+ }, 0, "auto-continue");
364
+ if (state) state.pendingAutoContinueTimer = timer;
242
365
  };
243
366
  // the LLM context with a user-visible continue prompt. triggerInvisibleContinue
244
367
  // sends a custom message marked with a dedicated customType (content:[],
@@ -323,22 +446,19 @@ export const clearCompactionHistoryForTests = () => {
323
446
  lastStats = null;
324
447
  lastCompactWasPiVcc = false;
325
448
  pendingFollowUpPrompt = null;
326
- clearTimeout(pendingAutoContinueTimer as any);
449
+ clearTimerHandle(undefined, pendingAutoContinueTimer);
327
450
  pendingAutoContinueTimer = null;
328
451
  for (const pi of perPiKeys) {
329
- const s = perPi.get(pi);
330
- if (s) {
331
- s.statsHistory = [];
332
- s.lastStats = null;
333
- s.lastCompactWasPiVcc = false;
334
- s.pendingFollowUpPrompt = null;
335
- clearTimeout(s.pendingAutoContinueTimer as any);
336
- s.pendingAutoContinueTimer = null;
452
+ const state = perPi.get(pi);
453
+ if (state) {
454
+ for (const timer of state.timers) clearTimerHandle(undefined, timer);
455
+ state.timers.clear();
456
+ state.statsHistory = [];
457
+ state.lastStats = null;
458
+ state.lastCompactWasPiVcc = false;
459
+ state.pendingFollowUpPrompt = null;
460
+ state.pendingAutoContinueTimer = null;
337
461
  }
338
- // Remove strong ref so pi can be GC'd and WeakMap entry cleared; fresh
339
- // getPerPi(pi) will recreate if this pi is reused, but tests create fresh
340
- // pi objects each time, so clearing prevents unbounded Set growth across
341
- // the 377-test suite.
342
462
  perPi.delete(pi);
343
463
  }
344
464
  perPiKeys.clear();
@@ -400,17 +520,189 @@ const readCompactionEventContext = (event: unknown): { reason?: CompactionReason
400
520
  : undefined;
401
521
  return { reason, willRetry: raw.willRetry === true };
402
522
  };
523
+ const resolveGlobalIndex = (ctx: any): Map<string, number> | undefined => {
524
+ try {
525
+ const manager = ctx?.sessionManager;
526
+ if (typeof manager?.getEntries === "function") {
527
+ const entries = manager.getEntries();
528
+ if (Array.isArray(entries)) return buildGlobalIndex(entries as PersistedSessionEntry[]).indexById;
529
+ }
530
+ const sessionFile = typeof manager?.getSessionFile === "function" ? manager.getSessionFile() : undefined;
531
+ if (typeof sessionFile !== "string") return undefined;
532
+ const entries: PersistedSessionEntry[] = [];
533
+ const scan = scanSessionEntries(sessionFile, (entry) => entries.push(entry as PersistedSessionEntry));
534
+ if (scan.missing) return undefined;
535
+ return buildGlobalIndex(entries).indexById;
536
+ } catch {
537
+ return undefined;
538
+ }
539
+ };
540
+
541
+ const trustedFullContextTokens = (branchEntries: any[], preparation: any, ctx: any): number | undefined => {
542
+ let boundary = -1;
543
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
544
+ if (branchEntries[i]?.type === "compaction" || branchEntries[i]?.type === "reset_boundary") {
545
+ boundary = i;
546
+ break;
547
+ }
548
+ }
549
+ for (let i = branchEntries.length - 1; i > boundary; i--) {
550
+ const entry = branchEntries[i];
551
+ const message = entry?.type === "message" ? entry.message : undefined;
552
+ if (message?.role !== "assistant" || message.stopReason === "error" || message.stopReason === "aborted") continue;
553
+ const usage = message.usage;
554
+ if (!usage || typeof usage !== "object") continue;
555
+ const expectedModel = ctx?.model?.id;
556
+ const actualModel = typeof message.model === "string" ? message.model : undefined;
557
+ if (expectedModel && actualModel && expectedModel !== actualModel) continue;
558
+ const authoritative = typeof preparation?.tokensBefore === "number" && preparation.tokensBefore > 0
559
+ ? preparation.tokensBefore
560
+ : typeof usage.contextTokens === "number" && Number.isFinite(usage.contextTokens) && usage.contextTokens > 0
561
+ ? usage.contextTokens
562
+ : undefined;
563
+ if (authoritative === undefined) continue;
564
+ let postAnchor = 0;
565
+ for (let j = i + 1; j < branchEntries.length; j++) {
566
+ const post = branchEntries[j];
567
+ if (post?.type !== "message") continue;
568
+ postAnchor += estimateScriptAwareMessageContentTokens(post.message?.content);
569
+ }
570
+ return Math.max(0, authoritative + postAnchor);
571
+ }
572
+ return undefined;
573
+ };
574
+ const sourceIndicesFor = (selectedIds: Array<string | undefined>, indexById?: Map<string, number>): Array<number | undefined> =>
575
+ selectedIds.map((id) => id && indexById ? indexById.get(id) : undefined);
576
+
577
+ const convertSelectedMessages = (
578
+ selectedMessages: any[],
579
+ selectedIds: Array<string | undefined>,
580
+ sourceIndices: Array<number | undefined>,
581
+ ): { messages: any[]; sourceIndices: Array<number | undefined> } => {
582
+ const messages: any[] = [];
583
+ const aligned: Array<number | undefined> = [];
584
+ for (let i = 0; i < selectedMessages.length; i++) {
585
+ const converted = convertToLlm([selectedMessages[i]]);
586
+ for (const message of converted) {
587
+ messages.push(message);
588
+ aligned.push(sourceIndices[i]);
589
+ }
590
+ }
591
+ return { messages, sourceIndices: aligned };
592
+ };
593
+ export const __convertSelectedMessagesForTests = convertSelectedMessages;
594
+ const nativeMemoryQuery = (branchEntries: any[]): string => {
595
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
596
+ const entry = branchEntries[i];
597
+ const message = entry?.type === "message" ? entry.message : undefined;
598
+ if (message?.role !== "user") continue;
599
+ let text = "";
600
+ if (typeof message.content === "string") text = message.content;
601
+ else if (Array.isArray(message.content)) {
602
+ text = message.content
603
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
604
+ .map((part: any) => part.text)
605
+ .join("\n");
606
+ }
607
+ if (text.trim()) return text.trim().slice(0, 2_000);
608
+ }
609
+ return "";
610
+ };
611
+
612
+ const nativeMemoryBlock = (ctx: any, event: any, branchEntries: any[], settings: PiVccSettings): string | Promise<string> => {
613
+ if (!settings.nativeMemory || !ctx?.memory || typeof ctx.memory.search !== "function") return "";
614
+ if (event?.signal?.aborted) return "";
615
+ const query = nativeMemoryQuery(branchEntries);
616
+ if (!query) return "";
617
+ const format = (result: any): string => {
618
+ const root = result as any;
619
+ const items = Array.isArray(result) ? result : Array.isArray(root?.items) ? root.items : Array.isArray(root?.results) ? root.results : [];
620
+ const seen = new Set<string>();
621
+ const lines: string[] = [];
622
+ for (const raw of items) {
623
+ if (lines.length >= 8) break;
624
+ const item = raw as any;
625
+ const content = typeof item?.content === "string" ? item.content : typeof item?.text === "string" ? item.text : "";
626
+ if (!content) continue;
627
+ const id = typeof item?.id === "string" ? item.id : undefined;
628
+ const source = typeof item?.source === "string" ? item.source : undefined;
629
+ const key = id ?? `${source ?? ""}\u0000${content}`;
630
+ if (seen.has(key)) continue;
631
+ seen.add(key);
632
+ const metadata = [id ? `id=${id}` : undefined, source ? `source=${source}` : undefined].filter(Boolean).join(" ");
633
+ lines.push(`- ${metadata ? `${metadata}: ` : ""}${content.slice(0, 500)}`);
634
+ }
635
+ if (lines.length === 0) return "";
636
+ return `[Host Memory]\n${lines.join("\n")}`.slice(0, 4_000);
637
+ };
638
+ const fail = (error: unknown): string => {
639
+ dbg(settings, { nativeMemory: "error", errorClass: error instanceof Error ? error.name : typeof error });
640
+ logMetrics(settings, { event: "native-memory", status: "error", errorClass: error instanceof Error ? error.name : typeof error });
641
+ return "";
642
+ };
643
+ try {
644
+ const result = ctx.memory.search(query, { limit: 8, signal: event.signal });
645
+ return result && typeof result.then === "function" ? Promise.resolve(result).then(format, fail) : format(result);
646
+ } catch (error) {
647
+ return fail(error);
648
+ }
649
+ };
650
+
651
+ const injectBeforeRecallNote = (summary: string, memoryBlock: string): string => {
652
+ if (!memoryBlock) return summary;
653
+ const marker = summary.lastIndexOf("\n\n---\n\n");
654
+ return marker >= 0 ? `${summary.slice(0, marker)}\n\n${memoryBlock}${summary.slice(marker)}` : `${summary}\n\n${memoryBlock}`;
655
+ };
656
+
657
+ const clipUtf8 = (text: string, maxBytes: number): { text: string; truncated: boolean } => {
658
+ let out = "";
659
+ let bytes = 0;
660
+ for (const character of text) {
661
+ const size = Buffer.byteLength(character, "utf8");
662
+ if (bytes + size > maxBytes) return { text: out, truncated: true };
663
+ out += character;
664
+ bytes += size;
665
+ }
666
+ return { text: out, truncated: false };
667
+ };
668
+
669
+
670
+ const capturePreCompactionDisplay = (pi: any, selectedMessages: any[], selectedIds: Array<string | undefined>): void => {
671
+ for (let i = selectedMessages.length - 1; i >= 0; i--) {
672
+ const message = selectedMessages[i];
673
+ if (message?.role !== "assistant") continue;
674
+ let text = "";
675
+ if (typeof message.content === "string") text = message.content;
676
+ else if (Array.isArray(message.content)) {
677
+ text = message.content
678
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
679
+ .map((part: any) => part.text)
680
+ .join("\n");
681
+ }
682
+ text = sanitize(text).replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "").replace(/[\u0080-\u009f]/g, "");
683
+ if (!text) continue;
684
+ const clipped = clipUtf8(text, 16 * 1024);
685
+ const state = getPerPi(pi);
686
+ if (state) state.pendingDisplay = { text: clipped.text, sourceEntryId: selectedIds[i], truncated: clipped.truncated };
687
+ return;
688
+ }
689
+ };
403
690
 
404
- export const scheduleCompactionStatsNotify = (ctx: any, stats: CompactionStats) => {
405
- setTimeout(() => {
691
+ export function scheduleCompactionStatsNotify(pi: any, ctx: any, stats: CompactionStats): void;
692
+ export function scheduleCompactionStatsNotify(ctx: any, stats: CompactionStats): void;
693
+ export function scheduleCompactionStatsNotify(piOrCtx: any, ctxOrStats: any, maybeStats?: CompactionStats): void {
694
+ const hasManagedContext = maybeStats !== undefined;
695
+ const pi = hasManagedContext ? piOrCtx : undefined;
696
+ const ctx = hasManagedContext ? ctxOrStats : piOrCtx;
697
+ const stats: CompactionStats = maybeStats ?? ctxOrStats;
698
+ const notify = () => {
406
699
  try {
407
- ctx?.ui?.notify?.(
408
- formatCompactionStats(stats),
409
- "info",
410
- );
700
+ ctx?.ui?.notify?.(formatCompactionStats(stats), "info");
411
701
  } catch {}
412
- }, 500);
413
- };
702
+ };
703
+ if (hasManagedContext) scheduleManaged(pi, ctx, notify, 500, "stats");
704
+ else setTimeout(notify, 500);
705
+ }
414
706
 
415
707
  const parseCompactionInstructions = (customInstructions?: string): {
416
708
  isPiVcc: boolean;
@@ -455,6 +747,22 @@ const dbg = (settings: PiVccSettings, data: Record<string, unknown>) => {
455
747
  try { writeFileSync("/tmp/omp-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
456
748
  try { writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
457
749
  };
750
+ const METRICS_MAX_BYTES = 10 * 1024 * 1024;
751
+ const logMetrics = (settings: PiVccSettings, data: Record<string, unknown>): void => {
752
+ if (!settings.debugLog) return;
753
+ try {
754
+ const path = join(dirname(getSettingsPath()), "debug-metrics.jsonl");
755
+ mkdirSync(dirname(path), { recursive: true });
756
+ const line = `${JSON.stringify({ timestamp: Date.now(), ...data })}\n`;
757
+ let size = 0;
758
+ try { size = statSync(path).size; } catch {}
759
+ if (size > 0 && size + Buffer.byteLength(line, "utf8") > METRICS_MAX_BYTES) {
760
+ try { rmSync(`${path}.1`, { force: true }); } catch {}
761
+ renameSync(path, `${path}.1`);
762
+ }
763
+ appendFileSync(path, line, "utf8");
764
+ } catch {}
765
+ };
458
766
 
459
767
  const previewContent = (content: unknown): string => {
460
768
  if (typeof content === "string") return content.slice(0, 300);
@@ -498,6 +806,9 @@ interface EntryWithMessage {
498
806
  entry: { id: string; type: string };
499
807
  message: { role: string; content: unknown };
500
808
  }
809
+ const selectedEntryId = (entry: { id?: unknown }): string | undefined =>
810
+ typeof entry.id === "string" && entry.id.length > 0 ? entry.id : undefined;
811
+
501
812
 
502
813
  // Convert a non-message entry that carries LLM-context text (custom_message /
503
814
  // branch_summary) into its agent-message form, mirroring pi-core's
@@ -541,6 +852,7 @@ export type OwnCutResult =
541
852
  requestedKeepUserTurns: number;
542
853
  keepFallbackToCompactAll: boolean;
543
854
  budgetCut?: BudgetCutKind;
855
+ selectedIds: Array<string | undefined>;
544
856
  }
545
857
  | { ok: false; reason: OwnCutCancelReason };
546
858
 
@@ -625,6 +937,7 @@ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1, explicitKee
625
937
  const compactAll = (keepFallbackToCompactAll: boolean) => ({
626
938
  ok: true as const,
627
939
  messages: liveMessages.map((e) => e.message),
940
+ selectedIds: liveMessages.map((e) => selectedEntryId(e.entry)),
628
941
  firstKeptEntryId: "",
629
942
  compactAll: true,
630
943
  keptUserTurns: 0,
@@ -651,6 +964,7 @@ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1, explicitKee
651
964
  return {
652
965
  ok: true,
653
966
  messages: liveMessages.slice(0, firstUserIdx).map((e) => e.message),
967
+ selectedIds: liveMessages.slice(0, firstUserIdx).map((e) => selectedEntryId(e.entry)),
654
968
  firstKeptEntryId: liveMessages[firstUserIdx].entry.id,
655
969
  compactAll: false,
656
970
  keptUserTurns: userIndices.length,
@@ -666,6 +980,7 @@ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1, explicitKee
666
980
  return {
667
981
  ok: true,
668
982
  messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
983
+ selectedIds: liveMessages.slice(0, cutIdx).map((e) => selectedEntryId(e.entry)),
669
984
  firstKeptEntryId: liveMessages[cutIdx].entry.id,
670
985
  compactAll: false,
671
986
  keptUserTurns: userIndices.length - targetUserIdx,
@@ -687,7 +1002,7 @@ export const findBudgetCutIndex = (
687
1002
  let acc = 0;
688
1003
  let crossed = -1;
689
1004
  for (let i = live.length - 1; i >= 0; i--) {
690
- acc += estimateMessageContentTokens(live[i].message.content, charsPerToken);
1005
+ acc += estimateScriptAwareMessageContentTokens(live[i].message.content);
691
1006
  if (acc >= maxTokens) {
692
1007
  crossed = i;
693
1008
  break;
@@ -714,6 +1029,7 @@ export const applyTailBudget = (
714
1029
  const budgetResult = (idx: number, budgetCut: BudgetCutKind): OwnCutResult => ({
715
1030
  ok: true,
716
1031
  messages: live.slice(0, idx).map((m) => m.message),
1032
+ selectedIds: live.slice(0, idx).map((m) => selectedEntryId(m.entry)),
717
1033
  firstKeptEntryId: live[idx].entry.id,
718
1034
  compactAll: false,
719
1035
  keptUserTurns: live.slice(idx).filter((m) => m.message.role === "user").length,
@@ -737,7 +1053,7 @@ export const applyTailBudget = (
737
1053
  const tailStart = cut.messages.length; // equals the cut index in the live window
738
1054
  let tailTokens = 0;
739
1055
  for (let i = tailStart; i < live.length; i++) {
740
- tailTokens += estimateMessageContentTokens(live[i].message.content, opts.charsPerToken);
1056
+ tailTokens += estimateScriptAwareMessageContentTokens(live[i].message.content);
741
1057
  }
742
1058
  if (tailTokens <= maxTokens * factor) return cut;
743
1059
  const idx = findBudgetCutIndex(live, maxTokens, opts.charsPerToken);
@@ -790,11 +1106,10 @@ const tailTokensForKeep = (branchEntries: any[], keepUserTurns: number, charsPer
790
1106
  const live = collectLiveMessages(branchEntries);
791
1107
  const keptIdx = live.findIndex((e) => e.entry.id === cut.firstKeptEntryId);
792
1108
  if (keptIdx < 0) return null;
793
- const chars = live.slice(keptIdx).reduce(
794
- (sum: number, e) => sum + estimateMessageContentChars(e.message?.content),
1109
+ return live.slice(keptIdx).reduce(
1110
+ (sum: number, e) => sum + estimateScriptAwareMessageContentTokens(e.message?.content),
795
1111
  0,
796
1112
  );
797
- return estimateTokensFromChars(chars, charsPerToken);
798
1113
  };
799
1114
 
800
1115
  /**
@@ -844,23 +1159,103 @@ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
844
1159
  export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
845
1160
  // Filter our invisible-continue marker out of the LLM context payload so the
846
1161
  // model just continues from the compaction summary (matched by customType ONLY).
847
- pi.on("context", (event) => {
848
- const messages = event.messages.filter((message) => {
1162
+ pi.on("context", (event, ctx) => {
1163
+ let messages = event.messages;
1164
+ const filtered = event.messages.filter((message) => {
849
1165
  if (message.role !== "custom") return true;
850
1166
  return message.customType !== AUTO_CONTINUE_CUSTOM_TYPE && message.customType !== LEGACY_AUTO_CONTINUE_CUSTOM_TYPE;
851
1167
  });
852
- if (messages.length !== event.messages.length) return { messages };
1168
+ if (filtered.length !== event.messages.length) messages = filtered;
1169
+ let entries: any[] = [];
1170
+ try {
1171
+ const branch = ctx?.sessionManager?.getBranch?.();
1172
+ if (Array.isArray(branch)) entries = branch;
1173
+ else {
1174
+ const value = ctx?.sessionManager?.getEntries?.();
1175
+ if (Array.isArray(value)) entries = value;
1176
+ }
1177
+ } catch {}
1178
+ const latest = [...entries].reverse().find((entry) => entry?.type === "compaction");
1179
+ if (latest && typeof latest.summary === "string") {
1180
+ if (isPiVccAppendDetails(latest.details)) {
1181
+ const chain = collectActiveSegments(entries, { fallbackSummary: latest.summary });
1182
+ if (chain) {
1183
+ const projected = projectAppendOnlyContext({ messages, chain, fallbackSummary: latest.summary });
1184
+ if (projected !== messages) messages = projected;
1185
+ }
1186
+ }
1187
+ const projection: RetainedToolOutputProjection | undefined = latest.details?.retainedToolOutputProjection;
1188
+ if (projection) {
1189
+ const serializedByEntryId: Record<string, string> = {};
1190
+ const omissionToolCallIds: Record<string, string> = {};
1191
+ for (const entry of entries) {
1192
+ if (entry?.type !== "message" || typeof entry.id !== "string") continue;
1193
+ try { serializedByEntryId[entry.id] = JSON.stringify(entry.message); } catch {}
1194
+ if (typeof entry.message?.toolCallId === "string") omissionToolCallIds[entry.id] = entry.message.toolCallId;
1195
+ }
1196
+ const projected = applyRetainedToolOutputProjection(messages, projection, { serializedByEntryId, omissionToolCallIds });
1197
+ if (projected !== messages) messages = projected;
1198
+ }
1199
+ }
1200
+ if (messages !== event.messages) return { messages };
1201
+ });
1202
+
1203
+ for (const eventName of ["session_start", "session_switch", "session_branch", "session_shutdown"]) {
1204
+ pi.on(eventName, (_event, ctx) => advanceSessionGeneration(pi, ctx));
1205
+ }
1206
+ pi.on("auto_compaction_start", (event, ctx) => {
1207
+ const state = getPerPi(pi);
1208
+ if (!state) return;
1209
+ state.autoCompaction = {
1210
+ generation: state.generation,
1211
+ sessionId: state.sessionId ?? sessionIdOf(ctx),
1212
+ reason: typeof (event as any)?.reason === "string" ? (event as any).reason : "unknown",
1213
+ action: typeof (event as any)?.action === "string" ? (event as any).action : "unknown",
1214
+ willRetry: false,
1215
+ };
1216
+ });
1217
+ pi.on("auto_compaction_end", (event, ctx) => {
1218
+ const state = getPerPi(pi);
1219
+ if (!state) return;
1220
+ const auto = state.autoCompaction;
1221
+ state.autoCompaction = undefined;
1222
+ logMetrics(loadSettings(ctx), {
1223
+ event: "auto-compaction-end",
1224
+ action: (event as any)?.action,
1225
+ aborted: (event as any)?.aborted === true,
1226
+ willRetry: (event as any)?.willRetry === true,
1227
+ generation: auto?.generation,
1228
+ });
853
1229
  });
854
1230
 
855
- pi.on("before_agent_start", () => {
856
- clearPendingAutoContinueForPi(pi);
1231
+ pi.on("before_agent_start", (_event, ctx) => {
1232
+ clearPendingAutoContinueForPi(pi, ctx);
857
1233
  });
858
1234
 
859
1235
  pi.on("session_before_compact", (event, ctx) => {
860
- const { preparation, branchEntries, customInstructions } = event;
861
- const { reason, willRetry } = readCompactionEventContext(event);
862
- const settings = loadSettings(ctx);
863
- if (!settings.vccEnabled) return;
1236
+ const attemptState = getPerPi(pi);
1237
+ const attemptGeneration = attemptState?.generation ?? 0;
1238
+ const attemptSessionId = sessionIdOf(ctx);
1239
+ const attemptCurrent = (): boolean => !event?.signal?.aborted && isCurrentGeneration(pi, ctx, attemptGeneration, attemptSessionId);
1240
+ const settingsResult = loadSettingsWithPluginOverlay(ctx);
1241
+ const runBefore = (settings: PiVccSettings) => {
1242
+ if (!attemptCurrent()) return;
1243
+ if (attemptState) {
1244
+ attemptState.pendingCompactionFingerprint = undefined;
1245
+ attemptState.pendingPreviousStats = attemptState.lastStats;
1246
+ attemptState.pendingStatsHistoryLength = attemptState.statsHistory.length;
1247
+ }
1248
+ if (attemptState) {
1249
+ attemptState.pendingDisplay = undefined;
1250
+ attemptState.lastSettings = settings;
1251
+ }
1252
+ const { preparation, branchEntries, customInstructions } = event;
1253
+ const eventContext = readCompactionEventContext(event);
1254
+ const auto = attemptState?.autoCompaction;
1255
+ const autoReason = auto?.reason === "threshold" || auto?.reason === "overflow" || auto?.reason === "manual" ? auto.reason : undefined;
1256
+ const reason = eventContext.reason ?? autoReason;
1257
+ const willRetry = eventContext.willRetry || auto?.willRetry === true;
1258
+ if (!settings.vccEnabled) return;
864
1259
 
865
1260
  // Always handle explicit /pi-vcc or /omp-vcc marker.
866
1261
  // Otherwise, only handle when user opted in via settings.
@@ -886,6 +1281,9 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
886
1281
  // still handled (isPiVcc path falls through below).
887
1282
  if (!isPiVcc && pendingChainShake.has(pi as unknown as object)) return;
888
1283
  if (!isPiVcc && !settings.overrideDefaultCompaction) return;
1284
+ const memoryResult = nativeMemoryBlock(ctx, event, branchEntries as any[], settings);
1285
+ function runBody(memoryBlock: string) {
1286
+ if (!attemptCurrent()) return;
889
1287
 
890
1288
  const calibrationCut = buildOwnCut(branchEntries as any[], 0);
891
1289
  const calibrationMessageChars = calibrationCut.ok
@@ -1036,18 +1434,21 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1036
1434
  setPendingFollowUpPrompt(pi, followUpPrompt);
1037
1435
  const agentMessages = ownCut.messages;
1038
1436
  const firstKeptEntryId = ownCut.firstKeptEntryId;
1039
- const messages = convertToLlm(agentMessages);
1437
+ const globalIndexById = resolveGlobalIndex(ctx);
1438
+ const selectedSourceIndices = sourceIndicesFor(ownCut.selectedIds, globalIndexById);
1439
+ const converted = convertSelectedMessages(agentMessages, ownCut.selectedIds, selectedSourceIndices);
1440
+ const messages = converted.messages;
1441
+ const sourceIndices = converted.sourceIndices;
1040
1442
 
1041
- // Count kept messages and estimate tokens
1443
+ // Count kept messages and estimate tokens with the script-aware estimator.
1042
1444
  const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId);
1043
1445
  const keptEntries = keptIdx >= 0
1044
1446
  ? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message")
1045
1447
  : [];
1046
- const keptChars = keptEntries.reduce(
1047
- (sum: number, e: any) => sum + estimateMessageContentChars(e.message?.content),
1448
+ const keptTokensEst = keptEntries.reduce(
1449
+ (sum: number, entry: any) => sum + estimateScriptAwareMessageContentTokens(entry.message?.content),
1048
1450
  0,
1049
1451
  );
1050
- const keptTokensEst = estimateTokensFromChars(keptChars, tokenEstimate.charsPerToken);
1051
1452
  const config = settings;
1052
1453
 
1053
1454
  // Ranked compaction: keep the highest-signal blocks under a token budget
@@ -1069,8 +1470,9 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1069
1470
  const RANKED_BRIEF_BUDGET_TOKENS = 1100;
1070
1471
  const RANKED_BRIEF_CEILING_TOKENS = 2000;
1071
1472
  const RANKED_BRIEF_TOKENS_PER_BLOCK = 15;
1072
- const summary = compileRanked({
1473
+ let summary = compileRanked({
1073
1474
  messages,
1475
+ sourceIndices,
1074
1476
  previousSummary: preparation.previousSummary,
1075
1477
  fileOps: {
1076
1478
  readFiles: [...preparation.fileOps.read],
@@ -1082,6 +1484,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1082
1484
  briefCharsPerBlock: Math.round(RANKED_BRIEF_TOKENS_PER_BLOCK * tokenEstimate.charsPerToken),
1083
1485
  },
1084
1486
  });
1487
+ summary = injectBeforeRecallNote(summary, memoryBlock);
1085
1488
 
1086
1489
  // Keep-all cut with an empty prefix and no previous summary yields nothing
1087
1490
  // new to summarize. Never hand the host an empty summary — cancel and keep
@@ -1117,8 +1520,11 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1117
1520
  const guard = evaluateGrowthGuard(prefixChars, netNewSummaryChars);
1118
1521
  const { netGrowthChars, toleranceChars } = guard;
1119
1522
  if (guard.trip) {
1120
- const prefixTok = estimateTokensFromChars(prefixChars, tokenEstimate.charsPerToken);
1121
- const netNewTok = estimateTokensFromChars(netNewSummaryChars, tokenEstimate.charsPerToken);
1523
+ const prefixTok = agentMessages.reduce(
1524
+ (sum: number, message: any) => sum + estimateScriptAwareMessageContentTokens(message.content),
1525
+ 0,
1526
+ );
1527
+ const netNewTok = estimateScriptAwareTokens(String(Math.max(0, netNewSummaryChars)));
1122
1528
  dbg(settings, {
1123
1529
  growthGuard: true,
1124
1530
  cancelled: reason !== "overflow" && !willRetry,
@@ -1141,7 +1547,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1141
1547
  }
1142
1548
 
1143
1549
  const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
1144
- const summaryTokensEst = estimateTokensFromChars(summaryChars, tokenEstimate.charsPerToken);
1550
+ const summaryTokensEst = estimateScriptAwareTokens(summary);
1145
1551
  const tokensAfterEst = summaryTokensEst + keptTokensEst;
1146
1552
  const tokensSavedEst = tokensBefore > 0 ? Math.max(0, tokensBefore - tokensAfterEst) : 0;
1147
1553
  const savedPercentEst = tokensBefore > 0 && tokensSavedEst > 0 ? Math.round((tokensSavedEst / tokensBefore) * 100) : 0;
@@ -1178,6 +1584,8 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1178
1584
  preview: e.type === "message" ? previewContent(e.message?.content) : undefined,
1179
1585
  }))
1180
1586
  : [];
1587
+ const retainedCandidates = collectLiveMessages(branchEntries as any[]).map(({ entry, message }) => ({ id: entry.id, type: entry.type, message }));
1588
+ const retainedProjection = buildRetainedToolOutputProjection(retainedCandidates, settings.retainedToolOutputMaxTokens, globalIndexById);
1181
1589
 
1182
1590
  const KNOWN_SECTIONS = new Set(["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"]);
1183
1591
  const extractKnownSections = (text: string) =>
@@ -1208,13 +1616,100 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1208
1616
  savedPercentEst,
1209
1617
  },
1210
1618
  });
1619
+ const appendMode = settings.compactionSummaryMode === "append";
1620
+ const latestCompaction = [...branchEntries].reverse().find((entry: any) => entry?.type === "compaction");
1621
+ const hasPriorCompaction = latestCompaction !== undefined;
1622
+ const previousChain = appendMode && hasPriorCompaction && typeof preparation.previousSummary === "string"
1623
+ ? collectActiveSegments(branchEntries, { fallbackSummary: preparation.previousSummary })
1624
+ : null;
1625
+ const legacyRewriteBase = isPiVcc && hasPriorCompaction
1626
+ && (latestCompaction?.details?.compactor === "omp-vcc" || latestCompaction?.details?.compactor === "pi-vcc")
1627
+ && latestCompaction?.details?.version === 2
1628
+ && typeof preparation.previousSummary === "string"
1629
+ && latestCompaction.summary === preparation.previousSummary;
1630
+ const appendEligible = appendMode && (!hasPriorCompaction || previousChain !== null || legacyRewriteBase);
1631
+ const freshSummary = appendEligible
1632
+ ? compileSegment({
1633
+ messages,
1634
+ sourceIndices,
1635
+ fileOps: {
1636
+ readFiles: [...preparation.fileOps.read],
1637
+ modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited],
1638
+ },
1639
+ })
1640
+ : "";
1641
+ const appendCoverage = appendEligible
1642
+ ? coverageForMessages({ selectedIds: ownCut.selectedIds, firstKeptEntryId, sourceMessageCount: agentMessages.length })
1643
+ : null;
1644
+ const contextWindow = typeof ctx?.model?.contextWindow === "number" && Number.isFinite(ctx.model.contextWindow) && ctx.model.contextWindow > 0
1645
+ ? ctx.model.contextWindow
1646
+ : undefined;
1647
+ const reserveTokens = typeof preparation.settings?.reserveTokens === "number" ? preparation.settings.reserveTokens : undefined;
1648
+ const chainTokens = (previousChain ? estimateChainTokens(previousChain) : 0) + estimateScriptAwareTokens(freshSummary);
1649
+ const rebaseChainTokens = estimateScriptAwareTokens(summary);
1650
+ const thresholds = compactionThresholds(contextWindow, reserveTokens);
1651
+ const fullContextTokens = trustedFullContextTokens(branchEntries, preparation, ctx);
1652
+ const pressure = chainTokens >= thresholds.chainThreshold
1653
+ || (thresholds.contextThreshold !== undefined && fullContextTokens !== undefined && fullContextTokens >= thresholds.contextThreshold)
1654
+ || (thresholds.capacity !== undefined && fullContextTokens !== undefined && fullContextTokens > thresholds.capacity);
1655
+ const decision = decideAppendMode({
1656
+ manual: isPiVcc,
1657
+ overflow: reason === "overflow",
1658
+ willRetry,
1659
+ pressure,
1660
+ chainTokens,
1661
+ rebaseChainTokens,
1662
+ contextWindow,
1663
+ reserveTokens,
1664
+ fullContextTokens,
1665
+ });
1666
+ const appendDetails = appendEligible && appendCoverage && freshSummary
1667
+ ? buildAppendOnlyDetails({
1668
+ segment: { summary: freshSummary, coverage: appendCoverage, tokensBefore },
1669
+ chainStart: !previousChain || decision.mode === "rebase",
1670
+ trailingSummary: summary,
1671
+ sections: extractKnownSections(summary),
1672
+ sourceMessageCount: agentMessages.length,
1673
+ previousSummaryUsed: Boolean(previousChain) || legacyRewriteBase,
1674
+ previous: decision.mode === "rebase" ? null : previousChain,
1675
+ retainedToolOutputProjection: retainedProjection,
1676
+ })
1677
+ : null;
1678
+ if (appendDetails) {
1679
+ Object.assign(appendDetails, {
1680
+ reason,
1681
+ willRetry,
1682
+ savings: {
1683
+ tokensBefore,
1684
+ summaryChars,
1685
+ summaryTokensEst,
1686
+ keptTokensEst,
1687
+ tokensAfterEst,
1688
+ tokensSavedEst,
1689
+ savedPercentEst,
1690
+ },
1691
+ });
1692
+ }
1693
+ logMetrics(settings, {
1694
+ event: "append-decision",
1695
+ mode: decision.mode,
1696
+ chainStart: !previousChain || decision.mode === "rebase",
1697
+ pressure,
1698
+ chainTokens,
1699
+ rebaseChainTokens,
1700
+ retainedTokens: retainedProjection?.retainedTokens ?? 0,
1701
+ omittedTokens: retainedProjection?.omittedTokens ?? 0,
1702
+ pendingCount: retainedProjection?.pendingCount ?? 0,
1703
+ });
1211
1704
 
1212
- const details: PiVccCompactionDetails = {
1705
+
1706
+ const details = appendDetails ?? {
1213
1707
  compactor: "omp-vcc",
1214
1708
  version: 2,
1215
1709
  sections: extractKnownSections(summary),
1216
1710
  sourceMessageCount: agentMessages.length,
1217
1711
  previousSummaryUsed: Boolean(preparation.previousSummary),
1712
+ retainedToolOutputProjection: retainedProjection,
1218
1713
  reason,
1219
1714
  willRetry,
1220
1715
  savings: {
@@ -1227,34 +1722,79 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1227
1722
  savedPercentEst,
1228
1723
  },
1229
1724
  };
1725
+ capturePreCompactionDisplay(pi, agentMessages, ownCut.selectedIds);
1230
1726
 
1231
1727
  setLastCompactWasPiVcc(pi, isPiVcc);
1232
1728
 
1233
- return {
1234
- compaction: {
1235
- summary,
1236
- details,
1237
- tokensBefore: preparation.tokensBefore,
1238
- firstKeptEntryId,
1239
- },
1729
+ const compaction = {
1730
+ summary,
1731
+ details,
1732
+ tokensBefore: preparation.tokensBefore,
1733
+ firstKeptEntryId,
1240
1734
  };
1735
+ if (attemptState) {
1736
+ attemptState.pendingCompactionFingerprint = JSON.stringify({
1737
+ summary: compaction.summary,
1738
+ firstKeptEntryId: compaction.firstKeptEntryId,
1739
+ details: compaction.details,
1740
+ });
1741
+ }
1742
+ return { compaction };
1743
+ };
1744
+ if (typeof memoryResult !== "string") return memoryResult.then((memoryBlock) => {
1745
+ if (!attemptCurrent()) return;
1746
+ return runBody(memoryBlock);
1747
+ });
1748
+ return runBody(memoryResult);
1749
+ };
1750
+ if (settingsResult && typeof (settingsResult as any).then === "function") {
1751
+ return settingsResult.then((settings) => {
1752
+ if (!attemptCurrent()) return;
1753
+ return runBefore(settings);
1754
+ });
1755
+ }
1756
+ return runBefore(settingsResult as PiVccSettings);
1241
1757
  });
1242
1758
  pi.on("session_compact", async (event, ctx) => {
1243
- const { reason, willRetry } = readCompactionEventContext(event);
1244
- if (!event.fromExtension) return;
1759
+ const per = getPerPi(pi);
1760
+ const generation = per?.generation ?? 0;
1761
+ const sessionId = per?.sessionId ?? sessionIdOf(ctx);
1762
+ const isCurrent = () => isCurrentGeneration(pi, ctx, generation, sessionId);
1763
+ const settings = await loadSettingsWithPluginOverlay(ctx);
1764
+ if (!isCurrent()) return;
1765
+ const entry: any = (event as any).compactionEntry;
1766
+ const committedFingerprint = entry
1767
+ ? JSON.stringify({ summary: entry.summary, firstKeptEntryId: entry.firstKeptEntryId, details: entry.details })
1768
+ : undefined;
1769
+ const pendingFingerprint = per?.pendingCompactionFingerprint;
1770
+ const legacyCompletionShape = !entry || (entry.summary === undefined && entry.details === undefined);
1771
+ const ownsCompaction = event.fromExtension === true
1772
+ && (!pendingFingerprint || committedFingerprint === pendingFingerprint || legacyCompletionShape);
1773
+ const pendingDisplay = per?.pendingDisplay;
1245
1774
  const followUpPrompt = getPendingFollowUpPrompt(pi);
1775
+ if (per) {
1776
+ if (!ownsCompaction && pendingFingerprint && per.pendingStatsHistoryLength !== undefined) {
1777
+ per.statsHistory.length = per.pendingStatsHistoryLength;
1778
+ per.lastStats = per.pendingPreviousStats;
1779
+ }
1780
+ per.pendingDisplay = undefined;
1781
+ per.pendingCompactionFingerprint = undefined;
1782
+ per.pendingPreviousStats = undefined;
1783
+ per.pendingStatsHistoryLength = undefined;
1784
+ }
1246
1785
  setPendingFollowUpPrompt(pi, null);
1247
- const per = getPerPi(pi);
1786
+ if (!ownsCompaction) return;
1787
+ if (pendingDisplay && settings.showPreCompactionMessage) {
1788
+ try { ctx?.ui?.notify?.(`[Previous output — display only]\n${pendingDisplay.text}`, "info"); } catch {}
1789
+ }
1248
1790
  const stats = per ? per.lastStats : lastStats;
1249
1791
  if (!stats) return;
1250
- // Enrich with authoritative tokensAfter from host if available (even for pi-vcc manual, before early return)
1251
- const entry: any = (event as any).compactionEntry;
1252
1792
  if (entry && typeof entry.tokensAfter === "number" && typeof entry.tokensBefore === "number") {
1253
1793
  const before = entry.tokensBefore;
1254
1794
  const after = entry.tokensAfter;
1255
1795
  const saved = Math.max(0, before - after);
1256
1796
  const percent = before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0;
1257
- if (per && per.lastStats) {
1797
+ if (per?.lastStats) {
1258
1798
  per.lastStats.tokensAfter = after;
1259
1799
  per.lastStats.tokensSaved = saved;
1260
1800
  per.lastStats.savedPercent = percent;
@@ -1271,9 +1811,8 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1271
1811
  (stats as any).savedPercent = percent;
1272
1812
  (stats as any).tokensBefore = before;
1273
1813
  try {
1274
- const cfg = loadSettings(ctx);
1275
- if (cfg.debug) {
1276
- dbg(cfg, {
1814
+ if (settings.debug) {
1815
+ dbg(settings, {
1277
1816
  authoritativeSavings: { tokensBefore: before, tokensAfter: after, tokensSaved: saved, savedPercent: percent },
1278
1817
  eventEntry: { id: entry.id, tokensBefore: entry.tokensBefore, tokensAfter: entry.tokensAfter },
1279
1818
  });
@@ -1281,52 +1820,54 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1281
1820
  } catch {}
1282
1821
  }
1283
1822
  const isPiVccLast = per ? per.lastCompactWasPiVcc : lastCompactWasPiVcc;
1284
- if (isPiVccLast) return; // /pi-vcc handles its own toast via onComplete
1285
- if (willRetry) return;
1286
- // omp's SessionCompactEvent is {compactionEntry, fromExtension} only
1287
- // (shared-events.ts:84-89); reason/willRetry are always undefined/false
1288
- // under real omp runs. Treat undefined as auto (threshold/overflow) when
1289
- // the compaction was sizable, otherwise manual /compact should not auto-continue.
1823
+ if (isPiVccLast) {
1824
+ if (per) per.lastCompactWasPiVcc = false;
1825
+ else lastCompactWasPiVcc = false;
1826
+ return;
1827
+ }
1828
+ const auto = per?.autoCompaction;
1829
+ const hostOwnsContinuation = auto?.generation === generation && (auto.sessionId ?? sessionIdOf(ctx)) === sessionId;
1830
+ const eventContext = readCompactionEventContext(event);
1831
+ const autoReason = auto?.reason === "threshold" || auto?.reason === "overflow" ? auto.reason : undefined;
1832
+ const reason = eventContext.reason ?? autoReason;
1833
+ const willRetry = eventContext.willRetry || auto?.willRetry === true;
1290
1834
  const isLargeCompaction = (stats.summarized > 10) || (stats.kept > 5) || (stats.keptTokensEst > 2000);
1291
- const shouldContinueAfterAutoCompact = (reason === "threshold" || reason === "overflow" || (reason == null && isLargeCompaction)) && loadSettings(ctx).continueAfterThresholdCompact;
1292
- scheduleCompactionStatsNotify(ctx, stats);
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.
1835
+ const shouldContinueAfterAutoCompact = !hostOwnsContinuation
1836
+ && (reason === "threshold" || reason === "overflow" || (reason == null && isLargeCompaction))
1837
+ && settings.continueAfterThresholdCompact;
1838
+ if (willRetry) return;
1839
+ scheduleCompactionStatsNotify(pi, ctx, stats);
1840
+ if (hostOwnsContinuation) return;
1299
1841
  try {
1300
- const cfgChain = loadSettings(ctx);
1301
1842
  const ctxMaybe = ctx as unknown as Record<string, unknown>;
1302
1843
  const compactFn = ctxMaybe["compact"];
1303
1844
  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
1845
  const chainForm = getCompactForm(() => promptOf?.call(ctx));
1307
- if (cfgChain.chainShakeHint && chainForm === "string" && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry && !isPiVccLast) {
1846
+ if (settings.chainShakeHint && chainForm === "string" && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry) {
1308
1847
  pendingChainShake.add(pi as unknown as object);
1309
- const maybePromise = (compactFn as unknown as (o: unknown) => Promise<void>).call(ctx, { mode: "shake" } as unknown);
1310
- const asPromise = maybePromise as unknown as Promise<void> | void;
1311
- if (asPromise && typeof (asPromise as unknown as Promise<void>).catch === "function") {
1312
- (asPromise as unknown as Promise<void>).catch(() => {}).finally(() => {
1313
- setTimeout(() => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000);
1314
- });
1315
- } else {
1316
- setTimeout(() => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000);
1317
- }
1848
+ const startShake = () => {
1849
+ try {
1850
+ const maybePromise = (compactFn as unknown as (o: unknown) => Promise<void>).call(ctx, { mode: "shake" } as unknown);
1851
+ const asPromise = maybePromise as unknown as Promise<void> | void;
1852
+ if (asPromise && typeof (asPromise as unknown as Promise<void>).catch === "function") {
1853
+ (asPromise as unknown as Promise<void>).catch(() => { pendingChainShake.delete(pi as unknown as object); });
1854
+ }
1855
+ } catch {
1856
+ pendingChainShake.delete(pi as unknown as object);
1857
+ }
1858
+ };
1859
+ if (typeof ctx?.setTimeout === "function") scheduleManaged(pi, ctx, startShake, 5, "chain-shake-start");
1860
+ else startShake();
1861
+ scheduleManaged(pi, ctx, () => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000, "chain-shake-cleanup");
1318
1862
  }
1319
1863
  } catch {}
1320
1864
  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.
1324
1865
  try {
1325
1866
  const sent = (pi as any).sendUserMessage?.(followUpPrompt) as Promise<void> | undefined;
1326
1867
  if (sent && typeof sent.catch === "function") sent.catch(() => {});
1327
1868
  } catch {}
1328
1869
  } else if (shouldContinueAfterAutoCompact) {
1329
- scheduleAutoContinueForPi(pi);
1870
+ scheduleAutoContinueForPi(pi, ctx);
1330
1871
  }
1331
1872
  });
1332
1873
  };
@@ -1405,16 +1946,18 @@ export const formatVccConfigCard = (view: VccConfigView): string => {
1405
1946
  : view.readPath === view.path
1406
1947
  ? `Source: file ${view.readPath}`
1407
1948
  : `Source: fallback file ${view.readPath}`;
1408
- const lines = (Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]).map(
1409
- (k) => `- ${k}: ${view.values[k] ? "on" : "off"} (${view.sources[k] === "overlay" ? "host overlay" : view.sources[k]})`,
1410
- );
1949
+ const lines = (Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]).map((key) => {
1950
+ const value = view.values[key];
1951
+ const display = typeof value === "number" ? String(value) : typeof value === "string" ? value : value ? "on" : "off";
1952
+ return `- ${key}: ${display} (${view.sources[key] === "overlay" ? "host overlay" : view.sources[key]})`;
1953
+ });
1411
1954
  return [header, status, ...lines].join("\n");
1412
1955
  };
1413
1956
 
1414
1957
  export const registerVccConfigCommand = (pi: any) => {
1415
1958
  const handler = async (_args: string, ctx: any) => {
1416
1959
  // args deliberately ignored — always show the effective config
1417
- const view = loadSettingsWithSources(ctx);
1960
+ const view = await loadSettingsWithSourcesAsync(ctx);
1418
1961
  const output = formatVccConfigCard(view);
1419
1962
  const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
1420
1963
  try { piAny.sendMessage?.({ customType: "vcc-config", content: output, display: true }, { triggerTurn: false }); } catch {}