omp-vcc 0.1.12 → 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,28 +1,126 @@
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
 
12
- // convertToLlm shim: try host export, fallback to identity (preserves AgentMessage for omp compileRanked)
31
+ // convertToLlm shim: resolve the host export, fallback to identity (identity
32
+ // is fine for bashExecution/custom, which the pipeline renders natively, but
33
+ // it leaks !!-excluded spans and drops branchSummary entries — so the
34
+ // @earendil-works root (pi's canonical export path) is tried first.
35
+ const CONVERT_TO_LLM_CANDIDATES = [
36
+ "@earendil-works/pi-coding-agent",
37
+ "@oh-my-pi/pi-coding-agent",
38
+ "@oh-my-pi/pi-coding-agent/session/messages",
39
+ ] as const;
40
+ // Pure loader-driven resolver: first candidate whose module exports a
41
+ // convertToLlm function wins, else null (caller keeps identity).
42
+ export const resolveConvertToLlm = (
43
+ load: (id: string) => any,
44
+ ): ((messages: any[]) => any[]) | null => {
45
+ for (const id of CONVERT_TO_LLM_CANDIDATES) {
46
+ try {
47
+ const mod = load(id);
48
+ if (mod && typeof mod.convertToLlm === "function") return mod.convertToLlm;
49
+ } catch {}
50
+ }
51
+ return null;
52
+ };
13
53
  let convertToLlm: (messages: any[]) => any[] = (m) => m;
14
54
  try {
15
55
  const req = createRequire(import.meta.url);
16
- const mod = req("@oh-my-pi/pi-coding-agent/session/messages") as any;
17
- if (mod?.convertToLlm) convertToLlm = mod.convertToLlm;
56
+ convertToLlm = resolveConvertToLlm((id) => req(id)) ?? convertToLlm;
18
57
  } catch {}
19
- try {
20
- if (convertToLlm.length === 0 || (convertToLlm as any).toString().includes("=> m")) {
21
- const req2 = createRequire(import.meta.url);
22
- const mod2 = req2("@oh-my-pi/pi-coding-agent") as any;
23
- if (mod2?.convertToLlm) convertToLlm = mod2.convertToLlm;
58
+ // Test-only override for the module-level binding (mirrors
59
+ // clearCompactionHistoryForTests): lets suites pin convertToLlm wiring without
60
+ // stubbing node module resolution. Null resets to the identity fallback.
61
+ export const __setConvertToLlmForTests = (fn: ((messages: Array<unknown>) => Array<unknown>) | null): void => {
62
+ convertToLlm = fn ?? ((m) => m);
63
+ };
64
+ // Host-kind detection: omp and pi expose incompatible ctx.compact shapes
65
+ // (omp: (string|CompactOptions)=>Promise<void> with instructions on the
66
+ // string; pi: (CompactOptions)=>void with instructions only via
67
+ // options.customInstructions). Three layers, first hit wins:
68
+ // 1. Explicit test override (__setHostKindForTests).
69
+ // 2. Observable ctx shape — works in bundled runtimes where module
70
+ // resolution misses: pi's getSystemPrompt() returns a string, omp's
71
+ // returns string[]. Pure getters, safe to call.
72
+ // 3. Module scope — works in dev/source runtimes: @earendil-works is
73
+ // pi-exclusive (same mechanism as the convertToLlm shim above).
74
+ // Default "omp" preserves the legacy string-form call when host-free.
75
+ const HOST_KIND_CANDIDATES = ["@earendil-works/pi-coding-agent", "@oh-my-pi/pi-coding-agent"] as const;
76
+ export type VccHostKind = "pi" | "omp";
77
+ export type VccCompactForm = "object" | "string";
78
+ // Pure loader-driven resolver: first resolvable scope wins (@earendil-works
79
+ // first, mirroring CONVERT_TO_LLM_CANDIDATES), else "omp".
80
+ export const resolveHostKind = (load: (id: string) => unknown): VccHostKind => {
81
+ for (const id of HOST_KIND_CANDIDATES) {
82
+ try {
83
+ if (load(id)) return id.startsWith("@earendil-works") ? "pi" : "omp";
84
+ } catch {}
24
85
  }
86
+ return "omp";
87
+ };
88
+ let defaultHostKind: VccHostKind = "omp";
89
+ try {
90
+ defaultHostKind = resolveHostKind((id) => createRequire(import.meta.url)(id));
25
91
  } catch {}
92
+ let hostKindOverride: VccHostKind | null = null;
93
+ export const getHostKind = (): VccHostKind => hostKindOverride ?? defaultHostKind;
94
+ // Test-only override (mirrors __setConvertToLlmForTests). Null restores the
95
+ // detected default.
96
+ export const __setHostKindForTests = (kind: VccHostKind | null): void => {
97
+ hostKindOverride = kind;
98
+ };
99
+ // Layered compact-form decision for a live ctx. getSystemPrompt is read off
100
+ // the calling ctx (command or event); absent (host-free mocks) falls through
101
+ // to module scope, then the legacy default.
102
+ export const resolveCompactForm = (
103
+ load: (id: string) => unknown,
104
+ getSystemPrompt?: () => unknown,
105
+ ): VccCompactForm => {
106
+ if (hostKindOverride) return hostKindOverride === "pi" ? "object" : "string";
107
+ try {
108
+ const sp = getSystemPrompt?.();
109
+ if (typeof sp === "string") return "object";
110
+ if (Array.isArray(sp)) return "string";
111
+ } catch {}
112
+ return resolveHostKind(load) === "pi" ? "object" : "string";
113
+ };
114
+ export const getCompactForm = (getSystemPrompt?: () => unknown): VccCompactForm => {
115
+ let load: (id: string) => unknown = () => {
116
+ throw new Error("no loader");
117
+ };
118
+ try {
119
+ const req = createRequire(import.meta.url);
120
+ load = (id) => req(id);
121
+ } catch {}
122
+ return resolveCompactForm(load, getSystemPrompt);
123
+ };
26
124
 
27
125
  export { PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
28
126
  export const OMP_VCC_COMPACT_INSTRUCTION = "__omp_vcc__";
@@ -106,33 +204,60 @@ export const evaluateGrowthGuard = (prefixChars: number, netNewSummaryChars: num
106
204
  let lastStats: CompactionStats | null = null;
107
205
  let lastCompactWasPiVcc = false;
108
206
  let pendingFollowUpPrompt: string | null = null;
109
- let pendingAutoContinueTimer: any = null;
207
+ let pendingAutoContinueTimer: unknown = null;
110
208
  let globalHistory: CompactionStats[] = [];
111
- // Per-pi state to avoid cross-session pollution when multiple sessions share the
112
- // same ESM module singleton (e.g. main + subagents). Module globals remain as
113
- // fallback for host-free tests that call getLastCompactionStats() without a pi.
114
- const perPi = new WeakMap<any, { lastStats: CompactionStats | null; lastCompactWasPiVcc: boolean; pendingFollowUpPrompt: string | null; pendingAutoContinueTimer: any; statsHistory: CompactionStats[] }>();
115
- // Track strong refs for test helper clearCompactionHistoryForTests: WeakMap keys
116
- // 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>();
117
228
  const perPiKeys = new Set<any>();
118
- // Guard eager chainShakeHint to avoid recursion: tracks pis currently chaining.
119
229
  const pendingChainShake = new WeakSet<object>();
120
- const getPerPi = (pi: any) => {
230
+ const getPerPi = (pi: any): PerPiState | null => {
121
231
  if (!pi || typeof pi !== "object") return null;
122
- let s = perPi.get(pi);
123
- if (!s) { s = { lastStats: null, lastCompactWasPiVcc: false, pendingFollowUpPrompt: null, pendingAutoContinueTimer: null, statsHistory: [] }; perPi.set(pi, s); perPiKeys.add(pi); }
124
- if (!s.statsHistory) s.statsHistory = [];
125
- 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;
126
251
  };
127
252
  const setLastStats = (pi: any, v: CompactionStats | null) => {
128
253
  if (v && v.timestamp == null) v.timestamp = Date.now();
129
254
  lastStats = v;
130
- const s = getPerPi(pi);
131
- if (s) {
132
- s.lastStats = v;
255
+ const state = getPerPi(pi);
256
+ if (state) {
257
+ state.lastStats = v;
133
258
  if (v) {
134
- s.statsHistory.push(v);
135
- if (s.statsHistory.length > 50) s.statsHistory.shift();
259
+ state.statsHistory.push(v);
260
+ if (state.statsHistory.length > 50) state.statsHistory.shift();
136
261
  }
137
262
  }
138
263
  if (v) {
@@ -140,26 +265,103 @@ const setLastStats = (pi: any, v: CompactionStats | null) => {
140
265
  if (globalHistory.length > 50) globalHistory.shift();
141
266
  }
142
267
  };
143
- const setLastCompactWasPiVcc = (pi: any, v: boolean) => { lastCompactWasPiVcc = v; const s = getPerPi(pi); if (s) s.lastCompactWasPiVcc = v; };
144
- const setPendingFollowUpPrompt = (pi: any, v: string | null) => { pendingFollowUpPrompt = v; const s = getPerPi(pi); if (s) s.pendingFollowUpPrompt = v; };
145
- const getPendingFollowUpPrompt = (pi: any) => { const s = getPerPi(pi); return s ? s.pendingFollowUpPrompt : pendingFollowUpPrompt; };
146
- const clearPendingAutoContinueForPi = (pi: any) => {
147
- const s = getPerPi(pi);
148
- clearTimeout(s ? s.pendingAutoContinueTimer as any : pendingAutoContinueTimer as any);
149
- 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;
150
342
  pendingAutoContinueTimer = null;
151
- if (s) s.pendingAutoContinueTimer = null;
343
+ lastCompactWasPiVcc = false;
344
+ pendingChainShake.delete(pi);
152
345
  };
153
- const scheduleAutoContinueForPi = (pi: any) => {
154
- clearPendingAutoContinueForPi(pi);
155
- const s = getPerPi(pi);
156
- 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 {
157
354
  pendingAutoContinueTimer = null;
158
- 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;
159
362
  try { triggerInvisibleContinue(pi); } catch {}
160
- }, 0);
161
- pendingAutoContinueTimer = timer;
162
- if (s) s.pendingAutoContinueTimer = timer;
363
+ }, 0, "auto-continue");
364
+ if (state) state.pendingAutoContinueTimer = timer;
163
365
  };
164
366
  // the LLM context with a user-visible continue prompt. triggerInvisibleContinue
165
367
  // sends a custom message marked with a dedicated customType (content:[],
@@ -244,22 +446,19 @@ export const clearCompactionHistoryForTests = () => {
244
446
  lastStats = null;
245
447
  lastCompactWasPiVcc = false;
246
448
  pendingFollowUpPrompt = null;
247
- clearTimeout(pendingAutoContinueTimer as any);
449
+ clearTimerHandle(undefined, pendingAutoContinueTimer);
248
450
  pendingAutoContinueTimer = null;
249
451
  for (const pi of perPiKeys) {
250
- const s = perPi.get(pi);
251
- if (s) {
252
- s.statsHistory = [];
253
- s.lastStats = null;
254
- s.lastCompactWasPiVcc = false;
255
- s.pendingFollowUpPrompt = null;
256
- clearTimeout(s.pendingAutoContinueTimer as any);
257
- 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;
258
461
  }
259
- // Remove strong ref so pi can be GC'd and WeakMap entry cleared; fresh
260
- // getPerPi(pi) will recreate if this pi is reused, but tests create fresh
261
- // pi objects each time, so clearing prevents unbounded Set growth across
262
- // the 377-test suite.
263
462
  perPi.delete(pi);
264
463
  }
265
464
  perPiKeys.clear();
@@ -321,17 +520,189 @@ const readCompactionEventContext = (event: unknown): { reason?: CompactionReason
321
520
  : undefined;
322
521
  return { reason, willRetry: raw.willRetry === true };
323
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
+ };
324
650
 
325
- export const scheduleCompactionStatsNotify = (ctx: any, stats: CompactionStats) => {
326
- setTimeout(() => {
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
+ };
690
+
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 = () => {
327
699
  try {
328
- ctx?.ui?.notify?.(
329
- formatCompactionStats(stats),
330
- "info",
331
- );
700
+ ctx?.ui?.notify?.(formatCompactionStats(stats), "info");
332
701
  } catch {}
333
- }, 500);
334
- };
702
+ };
703
+ if (hasManagedContext) scheduleManaged(pi, ctx, notify, 500, "stats");
704
+ else setTimeout(notify, 500);
705
+ }
335
706
 
336
707
  const parseCompactionInstructions = (customInstructions?: string): {
337
708
  isPiVcc: boolean;
@@ -376,6 +747,22 @@ const dbg = (settings: PiVccSettings, data: Record<string, unknown>) => {
376
747
  try { writeFileSync("/tmp/omp-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
377
748
  try { writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
378
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
+ };
379
766
 
380
767
  const previewContent = (content: unknown): string => {
381
768
  if (typeof content === "string") return content.slice(0, 300);
@@ -419,6 +806,9 @@ interface EntryWithMessage {
419
806
  entry: { id: string; type: string };
420
807
  message: { role: string; content: unknown };
421
808
  }
809
+ const selectedEntryId = (entry: { id?: unknown }): string | undefined =>
810
+ typeof entry.id === "string" && entry.id.length > 0 ? entry.id : undefined;
811
+
422
812
 
423
813
  // Convert a non-message entry that carries LLM-context text (custom_message /
424
814
  // branch_summary) into its agent-message form, mirroring pi-core's
@@ -462,6 +852,7 @@ export type OwnCutResult =
462
852
  requestedKeepUserTurns: number;
463
853
  keepFallbackToCompactAll: boolean;
464
854
  budgetCut?: BudgetCutKind;
855
+ selectedIds: Array<string | undefined>;
465
856
  }
466
857
  | { ok: false; reason: OwnCutCancelReason };
467
858
 
@@ -546,6 +937,7 @@ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1, explicitKee
546
937
  const compactAll = (keepFallbackToCompactAll: boolean) => ({
547
938
  ok: true as const,
548
939
  messages: liveMessages.map((e) => e.message),
940
+ selectedIds: liveMessages.map((e) => selectedEntryId(e.entry)),
549
941
  firstKeptEntryId: "",
550
942
  compactAll: true,
551
943
  keptUserTurns: 0,
@@ -572,6 +964,7 @@ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1, explicitKee
572
964
  return {
573
965
  ok: true,
574
966
  messages: liveMessages.slice(0, firstUserIdx).map((e) => e.message),
967
+ selectedIds: liveMessages.slice(0, firstUserIdx).map((e) => selectedEntryId(e.entry)),
575
968
  firstKeptEntryId: liveMessages[firstUserIdx].entry.id,
576
969
  compactAll: false,
577
970
  keptUserTurns: userIndices.length,
@@ -587,6 +980,7 @@ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1, explicitKee
587
980
  return {
588
981
  ok: true,
589
982
  messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
983
+ selectedIds: liveMessages.slice(0, cutIdx).map((e) => selectedEntryId(e.entry)),
590
984
  firstKeptEntryId: liveMessages[cutIdx].entry.id,
591
985
  compactAll: false,
592
986
  keptUserTurns: userIndices.length - targetUserIdx,
@@ -608,7 +1002,7 @@ export const findBudgetCutIndex = (
608
1002
  let acc = 0;
609
1003
  let crossed = -1;
610
1004
  for (let i = live.length - 1; i >= 0; i--) {
611
- acc += estimateMessageContentTokens(live[i].message.content, charsPerToken);
1005
+ acc += estimateScriptAwareMessageContentTokens(live[i].message.content);
612
1006
  if (acc >= maxTokens) {
613
1007
  crossed = i;
614
1008
  break;
@@ -635,6 +1029,7 @@ export const applyTailBudget = (
635
1029
  const budgetResult = (idx: number, budgetCut: BudgetCutKind): OwnCutResult => ({
636
1030
  ok: true,
637
1031
  messages: live.slice(0, idx).map((m) => m.message),
1032
+ selectedIds: live.slice(0, idx).map((m) => selectedEntryId(m.entry)),
638
1033
  firstKeptEntryId: live[idx].entry.id,
639
1034
  compactAll: false,
640
1035
  keptUserTurns: live.slice(idx).filter((m) => m.message.role === "user").length,
@@ -658,7 +1053,7 @@ export const applyTailBudget = (
658
1053
  const tailStart = cut.messages.length; // equals the cut index in the live window
659
1054
  let tailTokens = 0;
660
1055
  for (let i = tailStart; i < live.length; i++) {
661
- tailTokens += estimateMessageContentTokens(live[i].message.content, opts.charsPerToken);
1056
+ tailTokens += estimateScriptAwareMessageContentTokens(live[i].message.content);
662
1057
  }
663
1058
  if (tailTokens <= maxTokens * factor) return cut;
664
1059
  const idx = findBudgetCutIndex(live, maxTokens, opts.charsPerToken);
@@ -711,11 +1106,10 @@ const tailTokensForKeep = (branchEntries: any[], keepUserTurns: number, charsPer
711
1106
  const live = collectLiveMessages(branchEntries);
712
1107
  const keptIdx = live.findIndex((e) => e.entry.id === cut.firstKeptEntryId);
713
1108
  if (keptIdx < 0) return null;
714
- const chars = live.slice(keptIdx).reduce(
715
- (sum: number, e) => sum + estimateMessageContentChars(e.message?.content),
1109
+ return live.slice(keptIdx).reduce(
1110
+ (sum: number, e) => sum + estimateScriptAwareMessageContentTokens(e.message?.content),
716
1111
  0,
717
1112
  );
718
- return estimateTokensFromChars(chars, charsPerToken);
719
1113
  };
720
1114
 
721
1115
  /**
@@ -765,40 +1159,131 @@ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
765
1159
  export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
766
1160
  // Filter our invisible-continue marker out of the LLM context payload so the
767
1161
  // model just continues from the compaction summary (matched by customType ONLY).
768
- pi.on("context", (event) => {
769
- const messages = event.messages.filter((message) => {
1162
+ pi.on("context", (event, ctx) => {
1163
+ let messages = event.messages;
1164
+ const filtered = event.messages.filter((message) => {
770
1165
  if (message.role !== "custom") return true;
771
1166
  return message.customType !== AUTO_CONTINUE_CUSTOM_TYPE && message.customType !== LEGACY_AUTO_CONTINUE_CUSTOM_TYPE;
772
1167
  });
773
- 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
+ });
774
1229
  });
775
1230
 
776
- pi.on("before_agent_start", () => {
777
- clearPendingAutoContinueForPi(pi);
1231
+ pi.on("before_agent_start", (_event, ctx) => {
1232
+ clearPendingAutoContinueForPi(pi, ctx);
778
1233
  });
779
1234
 
780
1235
  pi.on("session_before_compact", (event, ctx) => {
781
- const { preparation, branchEntries, customInstructions } = event;
782
- const { reason, willRetry } = readCompactionEventContext(event);
783
- const settings = loadSettings(ctx);
784
- 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;
785
1259
 
786
1260
  // Always handle explicit /pi-vcc or /omp-vcc marker.
787
1261
  // Otherwise, only handle when user opted in via settings.
788
1262
  const { isPiVcc, keepUserTurns, keepUserTurnsExplicit, followUpPrompt } = parseCompactionInstructions(customInstructions);
789
- setPendingFollowUpPrompt(pi, null);
790
1263
  // Explicit host mode bypass: when the host signals an explicit compact mode
791
- // (e.g. /compact snapcompact or --mode shake), let the host walker handle it
792
- // even though overrideDefaultCompaction is true. This enables sequential
793
- // VCC → snapcompact/shake combinations. The event field is only present when
794
- // the optional native vcc patch is applied or a future host exposes it; when
795
- // absent this branch is no-op and the existing override semantics remain.
1264
+ // via an event field, let the host walker handle it even though
1265
+ // overrideDefaultCompaction is true. This enables sequential VCC →
1266
+ // snapcompact/shake combinations. No shipped host exposes such a field
1267
+ // today — omp carries the mode in the compact() options (never the event)
1268
+ // and pi has no modes (its /compact text is raw focus instructions, so
1269
+ // lone mode words must NEVER bypass: on pi `/compact shake` means
1270
+ // "focus on shake"). The branch stays as the contract for the optional
1271
+ // native patch / future hosts; unpatched, override:true serves explicit
1272
+ // omp modes via VCC (use override:false for native modes).
796
1273
  const explicitMode = (event as any).compactMode ?? (event as any).explicitMode ?? (event as any).mode;
797
1274
  if (!isPiVcc && typeof explicitMode === "string" && explicitMode) {
798
1275
  const m = explicitMode.toLowerCase();
799
1276
  if (m === "snapcompact" || m === "shake" || m === "soft" || m === "remote" || m === "handoff") return;
800
1277
  }
1278
+ // Chain-shake yield: while a {mode:"shake"} chain is in flight (see
1279
+ // session_compact below), let the host run it — otherwise VCC would
1280
+ // swallow the modeless call into a second VCC pass. Sentinel compactions
1281
+ // still handled (isPiVcc path falls through below).
1282
+ if (!isPiVcc && pendingChainShake.has(pi as unknown as object)) return;
801
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;
802
1287
 
803
1288
  const calibrationCut = buildOwnCut(branchEntries as any[], 0);
804
1289
  const calibrationMessageChars = calibrationCut.ok
@@ -949,18 +1434,21 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
949
1434
  setPendingFollowUpPrompt(pi, followUpPrompt);
950
1435
  const agentMessages = ownCut.messages;
951
1436
  const firstKeptEntryId = ownCut.firstKeptEntryId;
952
- 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;
953
1442
 
954
- // Count kept messages and estimate tokens
1443
+ // Count kept messages and estimate tokens with the script-aware estimator.
955
1444
  const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId);
956
1445
  const keptEntries = keptIdx >= 0
957
1446
  ? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message")
958
1447
  : [];
959
- const keptChars = keptEntries.reduce(
960
- (sum: number, e: any) => sum + estimateMessageContentChars(e.message?.content),
1448
+ const keptTokensEst = keptEntries.reduce(
1449
+ (sum: number, entry: any) => sum + estimateScriptAwareMessageContentTokens(entry.message?.content),
961
1450
  0,
962
1451
  );
963
- const keptTokensEst = estimateTokensFromChars(keptChars, tokenEstimate.charsPerToken);
964
1452
  const config = settings;
965
1453
 
966
1454
  // Ranked compaction: keep the highest-signal blocks under a token budget
@@ -982,8 +1470,9 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
982
1470
  const RANKED_BRIEF_BUDGET_TOKENS = 1100;
983
1471
  const RANKED_BRIEF_CEILING_TOKENS = 2000;
984
1472
  const RANKED_BRIEF_TOKENS_PER_BLOCK = 15;
985
- const summary = compileRanked({
1473
+ let summary = compileRanked({
986
1474
  messages,
1475
+ sourceIndices,
987
1476
  previousSummary: preparation.previousSummary,
988
1477
  fileOps: {
989
1478
  readFiles: [...preparation.fileOps.read],
@@ -995,6 +1484,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
995
1484
  briefCharsPerBlock: Math.round(RANKED_BRIEF_TOKENS_PER_BLOCK * tokenEstimate.charsPerToken),
996
1485
  },
997
1486
  });
1487
+ summary = injectBeforeRecallNote(summary, memoryBlock);
998
1488
 
999
1489
  // Keep-all cut with an empty prefix and no previous summary yields nothing
1000
1490
  // new to summarize. Never hand the host an empty summary — cancel and keep
@@ -1030,8 +1520,11 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1030
1520
  const guard = evaluateGrowthGuard(prefixChars, netNewSummaryChars);
1031
1521
  const { netGrowthChars, toleranceChars } = guard;
1032
1522
  if (guard.trip) {
1033
- const prefixTok = estimateTokensFromChars(prefixChars, tokenEstimate.charsPerToken);
1034
- 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)));
1035
1528
  dbg(settings, {
1036
1529
  growthGuard: true,
1037
1530
  cancelled: reason !== "overflow" && !willRetry,
@@ -1054,7 +1547,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1054
1547
  }
1055
1548
 
1056
1549
  const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
1057
- const summaryTokensEst = estimateTokensFromChars(summaryChars, tokenEstimate.charsPerToken);
1550
+ const summaryTokensEst = estimateScriptAwareTokens(summary);
1058
1551
  const tokensAfterEst = summaryTokensEst + keptTokensEst;
1059
1552
  const tokensSavedEst = tokensBefore > 0 ? Math.max(0, tokensBefore - tokensAfterEst) : 0;
1060
1553
  const savedPercentEst = tokensBefore > 0 && tokensSavedEst > 0 ? Math.round((tokensSavedEst / tokensBefore) * 100) : 0;
@@ -1091,6 +1584,8 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1091
1584
  preview: e.type === "message" ? previewContent(e.message?.content) : undefined,
1092
1585
  }))
1093
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);
1094
1589
 
1095
1590
  const KNOWN_SECTIONS = new Set(["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"]);
1096
1591
  const extractKnownSections = (text: string) =>
@@ -1121,13 +1616,100 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1121
1616
  savedPercentEst,
1122
1617
  },
1123
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
+ });
1124
1704
 
1125
- const details: PiVccCompactionDetails = {
1705
+
1706
+ const details = appendDetails ?? {
1126
1707
  compactor: "omp-vcc",
1127
1708
  version: 2,
1128
1709
  sections: extractKnownSections(summary),
1129
1710
  sourceMessageCount: agentMessages.length,
1130
1711
  previousSummaryUsed: Boolean(preparation.previousSummary),
1712
+ retainedToolOutputProjection: retainedProjection,
1131
1713
  reason,
1132
1714
  willRetry,
1133
1715
  savings: {
@@ -1140,34 +1722,79 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1140
1722
  savedPercentEst,
1141
1723
  },
1142
1724
  };
1725
+ capturePreCompactionDisplay(pi, agentMessages, ownCut.selectedIds);
1143
1726
 
1144
1727
  setLastCompactWasPiVcc(pi, isPiVcc);
1145
1728
 
1146
- return {
1147
- compaction: {
1148
- summary,
1149
- details,
1150
- tokensBefore: preparation.tokensBefore,
1151
- firstKeptEntryId,
1152
- },
1729
+ const compaction = {
1730
+ summary,
1731
+ details,
1732
+ tokensBefore: preparation.tokensBefore,
1733
+ firstKeptEntryId,
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);
1153
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);
1154
1757
  });
1155
1758
  pi.on("session_compact", async (event, ctx) => {
1156
- const { reason, willRetry } = readCompactionEventContext(event);
1157
- 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;
1158
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
+ }
1159
1785
  setPendingFollowUpPrompt(pi, null);
1160
- 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
+ }
1161
1790
  const stats = per ? per.lastStats : lastStats;
1162
1791
  if (!stats) return;
1163
- // Enrich with authoritative tokensAfter from host if available (even for pi-vcc manual, before early return)
1164
- const entry: any = (event as any).compactionEntry;
1165
1792
  if (entry && typeof entry.tokensAfter === "number" && typeof entry.tokensBefore === "number") {
1166
1793
  const before = entry.tokensBefore;
1167
1794
  const after = entry.tokensAfter;
1168
1795
  const saved = Math.max(0, before - after);
1169
1796
  const percent = before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0;
1170
- if (per && per.lastStats) {
1797
+ if (per?.lastStats) {
1171
1798
  per.lastStats.tokensAfter = after;
1172
1799
  per.lastStats.tokensSaved = saved;
1173
1800
  per.lastStats.savedPercent = percent;
@@ -1184,9 +1811,8 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1184
1811
  (stats as any).savedPercent = percent;
1185
1812
  (stats as any).tokensBefore = before;
1186
1813
  try {
1187
- const cfg = loadSettings(ctx);
1188
- if (cfg.debug) {
1189
- dbg(cfg, {
1814
+ if (settings.debug) {
1815
+ dbg(settings, {
1190
1816
  authoritativeSavings: { tokensBefore: before, tokensAfter: after, tokensSaved: saved, savedPercent: percent },
1191
1817
  eventEntry: { id: entry.id, tokensBefore: entry.tokensBefore, tokensAfter: entry.tokensAfter },
1192
1818
  });
@@ -1194,40 +1820,54 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1194
1820
  } catch {}
1195
1821
  }
1196
1822
  const isPiVccLast = per ? per.lastCompactWasPiVcc : lastCompactWasPiVcc;
1197
- if (isPiVccLast) return; // /pi-vcc handles its own toast via onComplete
1198
- if (willRetry) return;
1199
- // omp's SessionCompactEvent is {compactionEntry, fromExtension} only
1200
- // (shared-events.ts:84-89); reason/willRetry are always undefined/false
1201
- // under real omp runs. Treat undefined as auto (threshold/overflow) when
1202
- // 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;
1203
1834
  const isLargeCompaction = (stats.summarized > 10) || (stats.kept > 5) || (stats.keptTokensEst > 2000);
1204
- const shouldContinueAfterAutoCompact = (reason === "threshold" || reason === "overflow" || (reason == null && isLargeCompaction)) && loadSettings(ctx).continueAfterThresholdCompact;
1205
- scheduleCompactionStatsNotify(ctx, stats);
1206
- // Eager post-VCC shake chain (chainShakeHint). Host rescue already handles
1207
- // dead-end; this forces a second shake entry even when headroom was made.
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;
1208
1841
  try {
1209
- const cfgChain = loadSettings(ctx);
1210
1842
  const ctxMaybe = ctx as unknown as Record<string, unknown>;
1211
1843
  const compactFn = ctxMaybe["compact"];
1212
- if (cfgChain.chainShakeHint && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry && !isPiVccLast) {
1844
+ const promptOf = ctxMaybe["getSystemPrompt"] as ((this: unknown) => unknown) | undefined;
1845
+ const chainForm = getCompactForm(() => promptOf?.call(ctx));
1846
+ if (settings.chainShakeHint && chainForm === "string" && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry) {
1213
1847
  pendingChainShake.add(pi as unknown as object);
1214
- const maybePromise = (compactFn as unknown as (o: unknown) => Promise<void>).call(ctx, { mode: "shake" } as unknown);
1215
- const asPromise = maybePromise as unknown as Promise<void> | void;
1216
- if (asPromise && typeof (asPromise as unknown as Promise<void>).catch === "function") {
1217
- (asPromise as unknown as Promise<void>).catch(() => {}).finally(() => {
1218
- setTimeout(() => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000);
1219
- });
1220
- } else {
1221
- setTimeout(() => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000);
1222
- }
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");
1223
1862
  }
1224
1863
  } catch {}
1225
1864
  if (followUpPrompt) {
1226
1865
  try {
1227
- await pi.sendUserMessage(followUpPrompt);
1866
+ const sent = (pi as any).sendUserMessage?.(followUpPrompt) as Promise<void> | undefined;
1867
+ if (sent && typeof sent.catch === "function") sent.catch(() => {});
1228
1868
  } catch {}
1229
1869
  } else if (shouldContinueAfterAutoCompact) {
1230
- scheduleAutoContinueForPi(pi);
1870
+ scheduleAutoContinueForPi(pi, ctx);
1231
1871
  }
1232
1872
  });
1233
1873
  };
@@ -1306,16 +1946,18 @@ export const formatVccConfigCard = (view: VccConfigView): string => {
1306
1946
  : view.readPath === view.path
1307
1947
  ? `Source: file ${view.readPath}`
1308
1948
  : `Source: fallback file ${view.readPath}`;
1309
- const lines = (Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]).map(
1310
- (k) => `- ${k}: ${view.values[k] ? "on" : "off"} (${view.sources[k] === "overlay" ? "host overlay" : view.sources[k]})`,
1311
- );
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
+ });
1312
1954
  return [header, status, ...lines].join("\n");
1313
1955
  };
1314
1956
 
1315
1957
  export const registerVccConfigCommand = (pi: any) => {
1316
1958
  const handler = async (_args: string, ctx: any) => {
1317
1959
  // args deliberately ignored — always show the effective config
1318
- const view = loadSettingsWithSources(ctx);
1960
+ const view = await loadSettingsWithSourcesAsync(ctx);
1319
1961
  const output = formatVccConfigCard(view);
1320
1962
  const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
1321
1963
  try { piAny.sendMessage?.({ customType: "vcc-config", content: output, display: true }, { triggerTurn: false }); } catch {}