omp-vcc 0.1.2 → 0.1.5

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.
@@ -52,6 +52,26 @@ export interface CompactionStats {
52
52
  smartFromKeep?: number;
53
53
  reason?: CompactionReason;
54
54
  willRetry?: boolean;
55
+ /** Tokens before compaction (from preparation). */
56
+ tokensBefore?: number;
57
+ /** Summary char length */
58
+ summaryChars?: number;
59
+ /** Summary tokens estimate via calibrated cpt */
60
+ summaryTokensEst?: number;
61
+ /** Estimated tokens after = summaryTokensEst + keptTokensEst */
62
+ tokensAfterEst?: number;
63
+ /** Authoritative tokensAfter from host (compactionEntry) */
64
+ tokensAfter?: number;
65
+ /** Estimated saved = tokensBefore - tokensAfterEst */
66
+ tokensSavedEst?: number;
67
+ /** Authoritative saved */
68
+ tokensSaved?: number;
69
+ /** Estimated percent 0-100 */
70
+ savedPercentEst?: number;
71
+ /** Authoritative percent */
72
+ savedPercent?: number;
73
+ /** When compaction occurred */
74
+ timestamp?: number;
55
75
  }
56
76
 
57
77
  export type BudgetCutKind = "no_anchor" | "oversized_tail";
@@ -61,17 +81,39 @@ let lastStats: CompactionStats | null = null;
61
81
  let lastCompactWasPiVcc = false;
62
82
  let pendingFollowUpPrompt: string | null = null;
63
83
  let pendingAutoContinueTimer: any = null;
84
+ let globalHistory: CompactionStats[] = [];
64
85
  // Per-pi state to avoid cross-session pollution when multiple sessions share the
65
86
  // same ESM module singleton (e.g. main + subagents). Module globals remain as
66
87
  // fallback for host-free tests that call getLastCompactionStats() without a pi.
67
- const perPi = new WeakMap<any, { lastStats: CompactionStats | null; lastCompactWasPiVcc: boolean; pendingFollowUpPrompt: string | null; pendingAutoContinueTimer: any }>();
88
+ const perPi = new WeakMap<any, { lastStats: CompactionStats | null; lastCompactWasPiVcc: boolean; pendingFollowUpPrompt: string | null; pendingAutoContinueTimer: any; statsHistory: CompactionStats[] }>();
89
+ // Track strong refs for test helper clearCompactionHistoryForTests: WeakMap keys
90
+ // cannot be enumerated, so keep a Set for test-only cleanup.
91
+ const perPiKeys = new Set<any>();
92
+ // Guard eager chainShakeHint to avoid recursion: tracks pis currently chaining.
93
+ const pendingChainShake = new WeakSet<object>();
68
94
  const getPerPi = (pi: any) => {
69
95
  if (!pi || typeof pi !== "object") return null;
70
96
  let s = perPi.get(pi);
71
- if (!s) { s = { lastStats: null, lastCompactWasPiVcc: false, pendingFollowUpPrompt: null, pendingAutoContinueTimer: null }; perPi.set(pi, s); }
97
+ if (!s) { s = { lastStats: null, lastCompactWasPiVcc: false, pendingFollowUpPrompt: null, pendingAutoContinueTimer: null, statsHistory: [] }; perPi.set(pi, s); perPiKeys.add(pi); }
98
+ if (!s.statsHistory) s.statsHistory = [];
72
99
  return s;
73
100
  };
74
- const setLastStats = (pi: any, v: CompactionStats | null) => { lastStats = v; const s = getPerPi(pi); if (s) s.lastStats = v; };
101
+ const setLastStats = (pi: any, v: CompactionStats | null) => {
102
+ if (v && v.timestamp == null) v.timestamp = Date.now();
103
+ lastStats = v;
104
+ const s = getPerPi(pi);
105
+ if (s) {
106
+ s.lastStats = v;
107
+ if (v) {
108
+ s.statsHistory.push(v);
109
+ if (s.statsHistory.length > 50) s.statsHistory.shift();
110
+ }
111
+ }
112
+ if (v) {
113
+ globalHistory.push(v);
114
+ if (globalHistory.length > 50) globalHistory.shift();
115
+ }
116
+ };
75
117
  const setLastCompactWasPiVcc = (pi: any, v: boolean) => { lastCompactWasPiVcc = v; const s = getPerPi(pi); if (s) s.lastCompactWasPiVcc = v; };
76
118
  const setPendingFollowUpPrompt = (pi: any, v: string | null) => { pendingFollowUpPrompt = v; const s = getPerPi(pi); if (s) s.pendingFollowUpPrompt = v; };
77
119
  const getPendingFollowUpPrompt = (pi: any) => { const s = getPerPi(pi); return s ? s.pendingFollowUpPrompt : pendingFollowUpPrompt; };
@@ -135,25 +177,131 @@ const scheduleAutoContinue = (pi: any) => {
135
177
  }, 0);
136
178
  };
137
179
 
138
- export const getLastCompactionStats = () => lastStats;
139
-
180
+ export const getLastCompactionStats = (pi?: any) => {
181
+ if (pi) {
182
+ const s = getPerPi(pi);
183
+ return s?.lastStats ?? null;
184
+ }
185
+ return lastStats;
186
+ };
140
187
  const formatTokens = (n: number): string => {
141
188
  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
142
189
  return String(n);
143
190
  };
144
191
 
145
192
  export const formatCompactionStats = (stats: CompactionStats): string => {
193
+ const before = stats.tokensBefore ?? 0;
194
+ const after = stats.tokensAfter ?? stats.tokensAfterEst ?? 0;
195
+ const savedRaw = stats.tokensSaved ?? stats.tokensSavedEst;
196
+ const saved = typeof savedRaw === "number" ? savedRaw : (before > 0 && after > 0 ? Math.max(0, before - after) : 0);
197
+ const percentRaw = stats.savedPercent ?? stats.savedPercentEst;
198
+ const percent = typeof percentRaw === "number" ? percentRaw : (before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0);
199
+ const hasSavings = before > 0 && after > 0 && before > after && saved > 0 && percent > 0;
200
+ const savingsPrefix = hasSavings ? `${formatTokens(before)}→${formatTokens(after)} (${percent}% saved, ~${formatTokens(saved)}) · ` : "";
201
+ const keptTokens = stats.keptTokensEst ?? 0;
202
+ const summarized = stats.summarized ?? 0;
203
+ const keptTurns = stats.keptUserTurns ?? 0;
204
+ const totalTurns = stats.totalUserTurns ?? 0;
146
205
  if (stats.budgetCut) {
147
206
  const reason = stats.budgetCut === "no_anchor" ? "no user anchor" : "oversized tail";
148
- return `omp-vcc: kept ~${formatTokens(stats.keptTokensEst)} tok tail (mid-turn cut, ${reason}), summarized ${stats.summarized}.`;
207
+ if (savingsPrefix) {
208
+ return `omp-vcc: ${savingsPrefix}kept ~${formatTokens(keptTokens)} tok tail (mid-turn cut, ${reason}), summarized ${summarized}.`;
209
+ }
210
+ return `omp-vcc: kept ~${formatTokens(keptTokens)} tok tail (mid-turn cut, ${reason}), summarized ${summarized}.`;
149
211
  }
150
- const notes: string[] = [`summarized ${stats.summarized}`];
212
+ const notes: string[] = [`summarized ${summarized}`];
151
213
  if (stats.smartKeepAdjusted) {
152
214
  notes.push("smart-keep");
153
215
  }
154
- return `omp-vcc: kept ${stats.keptUserTurns}/${stats.totalUserTurns} turns, ~${formatTokens(stats.keptTokensEst)} tok (${notes.join(", ")}).`;
216
+ if (savingsPrefix) {
217
+ return `omp-vcc: ${savingsPrefix}kept ${keptTurns}/${totalTurns} turns, ~${formatTokens(keptTokens)} tok (${notes.join(", ")}).`;
218
+ }
219
+ return `omp-vcc: kept ${keptTurns}/${totalTurns} turns, ~${formatTokens(keptTokens)} tok (${notes.join(", ")}).`;
220
+ };
221
+
222
+ export const getCompactionHistory = (pi?: any): CompactionStats[] => {
223
+ if (pi) {
224
+ const s = getPerPi(pi);
225
+ if (s?.statsHistory) return [...s.statsHistory];
226
+ }
227
+ return [...globalHistory];
228
+ };
229
+
230
+ export const clearCompactionHistoryForTests = () => {
231
+ globalHistory = [];
232
+ lastStats = null;
233
+ lastCompactWasPiVcc = false;
234
+ pendingFollowUpPrompt = null;
235
+ clearTimeout(pendingAutoContinueTimer as any);
236
+ pendingAutoContinueTimer = null;
237
+ for (const pi of perPiKeys) {
238
+ const s = perPi.get(pi);
239
+ if (s) {
240
+ s.statsHistory = [];
241
+ s.lastStats = null;
242
+ s.lastCompactWasPiVcc = false;
243
+ s.pendingFollowUpPrompt = null;
244
+ clearTimeout(s.pendingAutoContinueTimer as any);
245
+ s.pendingAutoContinueTimer = null;
246
+ }
247
+ // Remove strong ref so pi can be GC'd and WeakMap entry cleared; fresh
248
+ // getPerPi(pi) will recreate if this pi is reused, but tests create fresh
249
+ // pi objects each time, so clearing prevents unbounded Set growth across
250
+ // the 377-test suite.
251
+ perPi.delete(pi);
252
+ }
253
+ perPiKeys.clear();
254
+ };
255
+
256
+ export const formatStatsTable = (history: CompactionStats[]): string => {
257
+ if (!history || history.length === 0) return "No compactions yet.";
258
+ const header = "| # | Before → After | Saved | Kept | Summarized | When |";
259
+ const sep = "|---|---|---|---|---|---|---|";
260
+ const rows = history.map((s, idx) => {
261
+ const before = s.tokensBefore ?? 0;
262
+ const after = s.tokensAfter ?? s.tokensAfterEst ?? 0;
263
+ const saved = s.tokensSaved ?? s.tokensSavedEst ?? (before > 0 && after > 0 ? Math.max(0, before - after) : 0);
264
+ const percent = s.savedPercent ?? s.savedPercentEst ?? (before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0);
265
+ const beforeAfter = before > 0 && after > 0 ? `${formatTokens(before)}→${formatTokens(after)}` : `${formatTokens(before)}→${formatTokens(after)}`;
266
+ const savedStr = saved > 0 ? `${formatTokens(saved)} (${percent}%)` : "—";
267
+ const keptTurns = s.keptUserTurns ?? 0;
268
+ const totalTurns = s.totalUserTurns ?? 0;
269
+ const keptTok = s.keptTokensEst ?? 0;
270
+ const summarized = s.summarized ?? 0;
271
+ const keptStr = `${keptTurns}/${totalTurns} turns, ~${formatTokens(keptTok)} tok${s.budgetCut ? ` (${s.budgetCut})` : ""}`;
272
+ const when = s.timestamp ? new Date(s.timestamp).toISOString().slice(0, 19).replace("T", " ") : "—";
273
+ return `| ${idx + 1} | ${beforeAfter} | ${savedStr} | ${keptStr} | ${summarized} | ${when} |`;
274
+ });
275
+ return [header, sep, ...rows].join("\n");
276
+ };
277
+
278
+ export const formatLastStatsDetail = (stats: CompactionStats | null): string => {
279
+ if (!stats) return "No compaction has run yet.";
280
+ const before = stats.tokensBefore ?? 0;
281
+ const after = stats.tokensAfter ?? stats.tokensAfterEst ?? 0;
282
+ const saved = stats.tokensSaved ?? stats.tokensSavedEst ?? (before > 0 && after > 0 ? Math.max(0, before - after) : 0);
283
+ const percent = stats.savedPercent ?? stats.savedPercentEst ?? (before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0);
284
+ const kept = stats.kept ?? 0;
285
+ const keptTurns = stats.keptUserTurns ?? 0;
286
+ const totalTurns = stats.totalUserTurns ?? 0;
287
+ const keptTok = stats.keptTokensEst ?? 0;
288
+ const summaryTok = stats.summaryTokensEst ?? 0;
289
+ const summaryChars = stats.summaryChars ?? 0;
290
+ const summarized = stats.summarized ?? 0;
291
+ const lines = [
292
+ `**Last compaction** ${stats.timestamp ? new Date(stats.timestamp).toISOString() : ""}`,
293
+ `- Before → After: **${formatTokens(before)} → ${formatTokens(after)}** (${percent}% saved, ~${formatTokens(saved)})`,
294
+ `- Summary: ~${formatTokens(summaryTok)} tok (${summaryChars} chars), kept tail ~${formatTokens(keptTok)} tok (${kept} msgs, ${keptTurns}/${totalTurns} turns)`,
295
+ `- Summarized: ${summarized} messages${stats.smartKeepAdjusted ? ` (smart-keep ${stats.smartFromKeep}→${keptTurns})` : ""}${stats.budgetCut ? ` · budgetCut:${stats.budgetCut}` : ""}`,
296
+ `- Details: ${stats.reason ? `reason=${stats.reason}` : "reason=auto"}${stats.willRetry ? " willRetry=true" : ""}`,
297
+ ];
298
+ if (stats.tokensAfter != null && stats.tokensAfterEst != null && stats.tokensAfter !== stats.tokensAfterEst) {
299
+ lines.push(`- Note: est after ${formatTokens(stats.tokensAfterEst)} vs authoritative ${formatTokens(stats.tokensAfter)}`);
300
+ }
301
+ return lines.join("\n");
155
302
  };
156
303
 
304
+
157
305
  const readCompactionEventContext = (event: unknown): { reason?: CompactionReason; willRetry: boolean } => {
158
306
  const raw = event as { reason?: unknown; willRetry?: unknown };
159
307
  const reason = raw.reason === "manual" || raw.reason === "threshold" || raw.reason === "overflow"
@@ -584,6 +732,17 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
584
732
  // Otherwise, only handle when user opted in via settings.
585
733
  const { isPiVcc, keepUserTurns, keepUserTurnsExplicit, followUpPrompt } = parseCompactionInstructions(customInstructions);
586
734
  setPendingFollowUpPrompt(pi, null);
735
+ // Explicit host mode bypass: when the host signals an explicit compact mode
736
+ // (e.g. /compact snapcompact or --mode shake), let the host walker handle it
737
+ // even though overrideDefaultCompaction is true. This enables sequential
738
+ // VCC → snapcompact/shake combinations. The event field is only present when
739
+ // the optional native vcc patch is applied or a future host exposes it; when
740
+ // absent this branch is no-op and the existing override semantics remain.
741
+ const explicitMode = (event as any).compactMode ?? (event as any).explicitMode ?? (event as any).mode;
742
+ if (!isPiVcc && typeof explicitMode === "string" && explicitMode) {
743
+ const m = explicitMode.toLowerCase();
744
+ if (m === "snapcompact" || m === "shake" || m === "soft" || m === "remote" || m === "handoff") return;
745
+ }
587
746
  if (!isPiVcc && !settings.overrideDefaultCompaction) return;
588
747
 
589
748
  const calibrationCut = buildOwnCut(branchEntries as any[], 0);
@@ -726,21 +885,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
726
885
  (sum: number, e: any) => sum + estimateMessageContentChars(e.message?.content),
727
886
  0,
728
887
  );
729
- setLastStats(pi, {
730
- summarized: agentMessages.length,
731
- kept: keptEntries.length,
732
- keptUserTurns: ownCut.keptUserTurns,
733
- totalUserTurns: ownCut.totalUserTurns,
734
- requestedKeepUserTurns: ownCut.requestedKeepUserTurns,
735
- keepUserTurnsExplicit,
736
- keepFallbackToCompactAll: ownCut.keepFallbackToCompactAll,
737
- keptTokensEst: estimateTokensFromChars(keptChars, tokenEstimate.charsPerToken),
738
- smartKeepAdjusted: smartKeep.smartAdjusted,
739
- smartFromKeep: smartKeep.fromKeep,
740
- budgetCut: ownCut.ok ? ownCut.budgetCut : undefined,
741
- reason,
742
- willRetry,
743
- });
888
+ const keptTokensEst = estimateTokensFromChars(keptChars, tokenEstimate.charsPerToken);
744
889
  const config = settings;
745
890
 
746
891
  // Ranked compaction: keep the highest-signal blocks under a token budget
@@ -776,6 +921,35 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
776
921
  },
777
922
  });
778
923
 
924
+ const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
925
+ const summaryChars = summary.length;
926
+ const summaryTokensEst = estimateTokensFromChars(summaryChars, tokenEstimate.charsPerToken);
927
+ const tokensAfterEst = summaryTokensEst + keptTokensEst;
928
+ const tokensSavedEst = tokensBefore > 0 ? Math.max(0, tokensBefore - tokensAfterEst) : 0;
929
+ const savedPercentEst = tokensBefore > 0 && tokensSavedEst > 0 ? Math.round((tokensSavedEst / tokensBefore) * 100) : 0;
930
+
931
+ setLastStats(pi, {
932
+ summarized: agentMessages.length,
933
+ kept: keptEntries.length,
934
+ keptUserTurns: ownCut.keptUserTurns,
935
+ totalUserTurns: ownCut.totalUserTurns,
936
+ requestedKeepUserTurns: ownCut.requestedKeepUserTurns,
937
+ keepUserTurnsExplicit,
938
+ keepFallbackToCompactAll: ownCut.keepFallbackToCompactAll,
939
+ keptTokensEst,
940
+ smartKeepAdjusted: smartKeep.smartAdjusted,
941
+ smartFromKeep: smartKeep.fromKeep,
942
+ budgetCut: ownCut.ok ? ownCut.budgetCut : undefined,
943
+ reason,
944
+ willRetry,
945
+ tokensBefore,
946
+ summaryChars,
947
+ summaryTokensEst,
948
+ tokensAfterEst,
949
+ tokensSavedEst,
950
+ savedPercentEst,
951
+ });
952
+
779
953
  const branchIds = branchEntries.map((e: any) => e.id);
780
954
  const cutIdx = branchIds.indexOf(firstKeptEntryId);
781
955
  const cutWindow = cutIdx >= 0
@@ -787,6 +961,9 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
787
961
  }))
788
962
  : [];
789
963
 
964
+ const KNOWN_SECTIONS = new Set(["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"]);
965
+ const extractKnownSections = (text: string) =>
966
+ [...text.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]).filter((h) => KNOWN_SECTIONS.has(h));
790
967
  dbg(config, {
791
968
  usedOwnCut: true,
792
969
  budgetCut: ownCut.budgetCut,
@@ -797,21 +974,39 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
797
974
  convertedMessages: messages.length,
798
975
  firstKeptEntryId,
799
976
  cutWindow,
800
- tokensBefore: preparation.tokensBefore,
977
+ tokensBefore,
801
978
  tokenEstimate,
802
979
  summaryLength: summary.length,
803
980
  summaryPreview: summary.slice(0, 500),
804
- sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
981
+ sections: extractKnownSections(summary),
982
+ savings: {
983
+ tokensBefore,
984
+ summaryChars,
985
+ summaryTokensEst,
986
+ keptTokensEst,
987
+ tokensAfterEst,
988
+ tokensSavedEst,
989
+ savedPercentEst,
990
+ },
805
991
  });
806
992
 
807
993
  const details: PiVccCompactionDetails = {
808
994
  compactor: "omp-vcc",
809
- version: 1,
810
- sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
995
+ version: 2,
996
+ sections: extractKnownSections(summary),
811
997
  sourceMessageCount: agentMessages.length,
812
998
  previousSummaryUsed: Boolean(preparation.previousSummary),
813
999
  reason,
814
1000
  willRetry,
1001
+ savings: {
1002
+ tokensBefore,
1003
+ summaryChars,
1004
+ summaryTokensEst,
1005
+ keptTokensEst,
1006
+ tokensAfterEst,
1007
+ tokensSavedEst,
1008
+ savedPercentEst,
1009
+ },
815
1010
  };
816
1011
 
817
1012
  setLastCompactWasPiVcc(pi, isPiVcc);
@@ -831,11 +1026,44 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
831
1026
  const followUpPrompt = getPendingFollowUpPrompt(pi);
832
1027
  setPendingFollowUpPrompt(pi, null);
833
1028
  const per = getPerPi(pi);
1029
+ const stats = per ? per.lastStats : lastStats;
1030
+ if (!stats) return;
1031
+ // Enrich with authoritative tokensAfter from host if available (even for pi-vcc manual, before early return)
1032
+ const entry: any = (event as any).compactionEntry;
1033
+ if (entry && typeof entry.tokensAfter === "number" && typeof entry.tokensBefore === "number") {
1034
+ const before = entry.tokensBefore;
1035
+ const after = entry.tokensAfter;
1036
+ const saved = Math.max(0, before - after);
1037
+ const percent = before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0;
1038
+ if (per && per.lastStats) {
1039
+ per.lastStats.tokensAfter = after;
1040
+ per.lastStats.tokensSaved = saved;
1041
+ per.lastStats.savedPercent = percent;
1042
+ per.lastStats.tokensBefore = before;
1043
+ }
1044
+ if (lastStats) {
1045
+ lastStats.tokensAfter = after;
1046
+ lastStats.tokensSaved = saved;
1047
+ lastStats.savedPercent = percent;
1048
+ lastStats.tokensBefore = before;
1049
+ }
1050
+ (stats as any).tokensAfter = after;
1051
+ (stats as any).tokensSaved = saved;
1052
+ (stats as any).savedPercent = percent;
1053
+ (stats as any).tokensBefore = before;
1054
+ try {
1055
+ const cfg = loadSettings(ctx);
1056
+ if (cfg.debug) {
1057
+ dbg(cfg, {
1058
+ authoritativeSavings: { tokensBefore: before, tokensAfter: after, tokensSaved: saved, savedPercent: percent },
1059
+ eventEntry: { id: entry.id, tokensBefore: entry.tokensBefore, tokensAfter: entry.tokensAfter },
1060
+ });
1061
+ }
1062
+ } catch {}
1063
+ }
834
1064
  const isPiVccLast = per ? per.lastCompactWasPiVcc : lastCompactWasPiVcc;
835
1065
  if (isPiVccLast) return; // /pi-vcc handles its own toast via onComplete
836
1066
  if (willRetry) return;
837
- const stats = per ? per.lastStats : lastStats;
838
- if (!stats) return;
839
1067
  // omp's SessionCompactEvent is {compactionEntry, fromExtension} only
840
1068
  // (shared-events.ts:84-89); reason/willRetry are always undefined/false
841
1069
  // under real omp runs. Treat undefined as auto (threshold/overflow) when
@@ -843,6 +1071,25 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
843
1071
  const isLargeCompaction = (stats.summarized > 10) || (stats.kept > 5) || (stats.keptTokensEst > 2000);
844
1072
  const shouldContinueAfterAutoCompact = (reason === "threshold" || reason === "overflow" || (reason == null && isLargeCompaction)) && loadSettings(ctx).continueAfterThresholdCompact;
845
1073
  scheduleCompactionStatsNotify(ctx, stats);
1074
+ // Eager post-VCC shake chain (chainShakeHint). Host rescue already handles
1075
+ // dead-end; this forces a second shake entry even when headroom was made.
1076
+ try {
1077
+ const cfgChain = loadSettings(ctx);
1078
+ const ctxMaybe = ctx as unknown as Record<string, unknown>;
1079
+ const compactFn = ctxMaybe["compact"];
1080
+ if (cfgChain.chainShakeHint && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry && !isPiVccLast) {
1081
+ pendingChainShake.add(pi as unknown as object);
1082
+ const maybePromise = (compactFn as unknown as (o: unknown) => Promise<void>).call(ctx, { mode: "shake" } as unknown);
1083
+ const asPromise = maybePromise as unknown as Promise<void> | void;
1084
+ if (asPromise && typeof (asPromise as unknown as Promise<void>).catch === "function") {
1085
+ (asPromise as unknown as Promise<void>).catch(() => {}).finally(() => {
1086
+ setTimeout(() => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000);
1087
+ });
1088
+ } else {
1089
+ setTimeout(() => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000);
1090
+ }
1091
+ }
1092
+ } catch {}
846
1093
  if (followUpPrompt) {
847
1094
  try {
848
1095
  await pi.sendUserMessage(followUpPrompt);
@@ -992,7 +1239,7 @@ export const registerPiVccCommand = (pi: any) => {
992
1239
  ctx.compact({
993
1240
  customInstructions: buildPiVccCustomInstructions(keepUserTurns),
994
1241
  onComplete: () => {
995
- const stats = getLastCompactionStats();
1242
+ const stats = getLastCompactionStats(pi);
996
1243
  if (stats) {
997
1244
  scheduleCompactionStatsNotify(ctx, stats);
998
1245
  } else {
@@ -1014,4 +1261,63 @@ export const registerPiVccCommand = (pi: any) => {
1014
1261
  });
1015
1262
  },
1016
1263
  });
1264
+ };
1265
+ export const registerVccStatsTool = (pi: any) => {
1266
+ const hasBoolean = typeof pi?.zod?.boolean === "function";
1267
+ const schema = pi?.zod?.object && hasBoolean
1268
+ ? pi.zod.object({
1269
+ history: pi.zod.boolean().optional().describe("Include full history table of all compactions in this session"),
1270
+ })
1271
+ : {};
1272
+ pi.registerTool({
1273
+ name: "vcc_stats",
1274
+ label: "VCC Stats",
1275
+ description: "Show omp-vcc compaction savings — last compaction before→after, tokens saved, percent, and optional history of all compactions in this session. Divider in transcript already shows 256K→20K; this tool surfaces the same numbers with kept/summarized details.",
1276
+ approval: "read",
1277
+ parameters: schema,
1278
+ async execute(_toolCallId: string, params: any, _signal: unknown, _onUpdate: unknown, _ctx: any) {
1279
+ const history = getCompactionHistory(pi);
1280
+ const last = getLastCompactionStats(pi);
1281
+ const wantHistory = params?.history === true;
1282
+ if (!last && history.length === 0) {
1283
+ return { content: [{ type: "text", text: "No compactions yet in this session." }], details: undefined };
1284
+ }
1285
+ if (wantHistory) {
1286
+ const table = formatStatsTable(history);
1287
+ const detail = last ? `\n\n${formatLastStatsDetail(last)}` : "";
1288
+ return { content: [{ type: "text", text: `${table}${detail}` }], details: undefined };
1289
+ }
1290
+ const detail = formatLastStatsDetail(last);
1291
+ const table = history.length > 1 ? `\n\nHistory:\n${formatStatsTable(history)}` : "";
1292
+ return { content: [{ type: "text", text: `${detail}${table}` }], details: undefined };
1293
+ },
1294
+ } as unknown as Parameters<(typeof pi)["registerTool"]>[0]);
1295
+ };
1296
+
1297
+ export const registerVccStatsCommand = (pi: any) => {
1298
+ const handler = async (args: string, ctx: any) => {
1299
+ const raw = (args || "").trim().toLowerCase();
1300
+ const wantHistory = raw.includes("history") || raw.includes("--history") || raw.includes("all");
1301
+ const history = getCompactionHistory(pi);
1302
+ const last = getLastCompactionStats(pi);
1303
+ const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
1304
+ if (!last && history.length === 0) {
1305
+ try { piAny.sendMessage?.({ customType: "vcc-stats", content: "No compactions yet in this session.", display: true }, { triggerTurn: false }); } catch {}
1306
+ try { ctx?.ui?.notify?.("No compactions yet.", "info"); } catch {}
1307
+ return;
1308
+ }
1309
+ let output: string;
1310
+ if (wantHistory) {
1311
+ const table = formatStatsTable(history);
1312
+ const detail = last ? `\n\n${formatLastStatsDetail(last)}` : "";
1313
+ output = `${table}${detail}`;
1314
+ } else {
1315
+ const detail = formatLastStatsDetail(last);
1316
+ const table = history.length > 1 ? `\n\nHistory (${history.length} compactions):\n${formatStatsTable(history)}` : "";
1317
+ output = `${detail}${table}`;
1318
+ }
1319
+ try { piAny.sendMessage?.({ customType: "vcc-stats", content: output, display: true }, { triggerTurn: false }); } catch {}
1320
+ try { ctx?.ui?.notify?.(`vcc_stats: ${history.length} compaction(s)`, "info"); } catch {}
1321
+ };
1322
+ pi.registerCommand("vcc-stats", { description: "Show omp-vcc compaction savings (last + history)", handler });
1017
1323
  };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "omp-vcc",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
+ "type": "module",
4
5
  "description": "Algorithmic VCC compaction for omp - fast lossless no-LLM",
5
6
  "author": "Zhu Lin <zhulin@czl.my>",
6
7
  "repository": {
@@ -25,7 +26,6 @@
25
26
  "files": [
26
27
  "extensions",
27
28
  "skills",
28
- "commands",
29
29
  "scripts",
30
30
  "types.d.ts"
31
31
  ],
@@ -34,10 +34,6 @@
34
34
  "extensions": [
35
35
  "./extensions/main.ts"
36
36
  ],
37
- "commands": [
38
- "./commands/omp-vcc.md",
39
- "./commands/vcc-recall.md"
40
- ],
41
37
  "settings": {
42
38
  "vccEnabled": {
43
39
  "type": "boolean",
@@ -63,6 +59,11 @@
63
59
  "type": "boolean",
64
60
  "default": false,
65
61
  "description": "Write debug snapshot to /tmp/omp-vcc-debug.json"
62
+ },
63
+ "chainShakeHint": {
64
+ "type": "boolean",
65
+ "default": false,
66
+ "description": "Eager post-VCC shake chain (host rescue is automatic; this forces a second shake even when VCC created headroom)"
66
67
  }
67
68
  }
68
69
  },
@@ -71,10 +72,6 @@
71
72
  "extensions": [
72
73
  "./extensions/main.ts"
73
74
  ],
74
- "commands": [
75
- "./commands/omp-vcc.md",
76
- "./commands/vcc-recall.md"
77
- ],
78
75
  "settings": {
79
76
  "vccEnabled": {
80
77
  "type": "boolean",
@@ -100,6 +97,11 @@
100
97
  "type": "boolean",
101
98
  "default": false,
102
99
  "description": "Write debug snapshot to /tmp/omp-vcc-debug.json"
100
+ },
101
+ "chainShakeHint": {
102
+ "type": "boolean",
103
+ "default": false,
104
+ "description": "Eager post-VCC shake chain (host rescue is automatic; this forces a second shake even when VCC created headroom)"
103
105
  }
104
106
  }
105
107
  },
@@ -107,6 +109,8 @@
107
109
  "typecheck": "bunx tsc --noEmit",
108
110
  "test": "bun test",
109
111
  "smoke": "bun run scripts/smoke.ts",
112
+ "e2e": "bun run scripts/e2e.ts",
113
+ "e2e:direct": "bun test tests/e2e --timeout 120000",
110
114
  "postuninstall": "node scripts/uninstall-reset.js || true",
111
115
  "prepublishOnly": "npm run typecheck && npm test && npm run smoke"
112
116
  }
package/scripts/e2e.ts ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bun
2
+ // @ts-nocheck
3
+ // Usage: bun run e2e or bun run scripts/e2e.ts [--timeout 120000]
4
+
5
+ import { mkdtempSync, existsSync, mkdirSync, cpSync, rmSync } from "fs";
6
+ import { tmpdir } from "os";
7
+ import { join } from "path";
8
+
9
+ const timeoutArg = process.argv.find((a) => a.startsWith("--timeout"));
10
+ const timeout = timeoutArg ? Number(timeoutArg.split("=")[1] ?? 120000) : 120000;
11
+ const verbose = process.argv.includes("--verbose");
12
+
13
+ console.log("== omp-vcc E2E runner ==");
14
+
15
+ const ompDir = mkdtempSync(join(tmpdir(), "omp-vcc-e2e-runner-"));
16
+ const configPath = join(ompDir, "config.json");
17
+ console.log(`OMP_DIR=${ompDir}`);
18
+ console.log(`OMP_VCC_CONFIG_PATH=${configPath}`);
19
+
20
+ let failures = 0;
21
+
22
+ async function probeOmp(): Promise<void> {
23
+ try {
24
+ const proc = Bun.spawn(["omp", "--help"], { stdout: "pipe", stderr: "pipe" });
25
+ await proc.exited;
26
+ const out = await new Response(proc.stdout).text().catch(() => "");
27
+ const err = await new Response(proc.stderr).text().catch(() => "");
28
+ const help = out + err;
29
+ if (verbose) console.log(help.slice(0, 2000));
30
+ const hasPrint = /--print\b/.test(help);
31
+ const hasExtension = /--extension\b|-e\b/.test(help);
32
+ const hasPlugin = /\bplugin\b/.test(help);
33
+ console.log(`probe omp --help: hasPrint=${hasPrint} hasExtension=${hasExtension} hasPlugin=${hasPlugin}`);
34
+ if (!hasPlugin) console.log("note: omp plugin subcommand not found — isolated plugin link test will be skipped");
35
+ } catch (e) {
36
+ console.log(`probe failed (omp not on PATH?): ${e}`);
37
+ }
38
+ }
39
+
40
+ await probeOmp();
41
+
42
+ // Try plugin link in isolated dir (best effort, skip if omp missing)
43
+ try {
44
+ const linkProc = Bun.spawn(["omp", "plugin", "link", process.cwd()], {
45
+ env: { ...process.env, OMP_DIR: ompDir, PI_CODING_AGENT_DIR: ompDir, OMP_VCC_CONFIG_PATH: configPath, PI_VCC_CONFIG_PATH: configPath },
46
+ stdout: "pipe",
47
+ stderr: "pipe",
48
+ });
49
+ await linkProc.exited;
50
+ const code = linkProc.exitCode ?? 0;
51
+ if (code === 0) {
52
+ console.log("omp plugin link ok (isolated)");
53
+ const doctor = Bun.spawn(["omp", "plugin", "doctor"], {
54
+ env: { ...process.env, OMP_DIR: ompDir, PI_CODING_AGENT_DIR: ompDir, OMP_VCC_CONFIG_PATH: configPath },
55
+ stdout: "pipe",
56
+ stderr: "pipe",
57
+ });
58
+ await doctor.exited;
59
+ const out = await new Response(doctor.stdout).text().catch(() => "");
60
+ console.log(out.slice(0, 1500));
61
+ } else {
62
+ const err = await new Response(linkProc.stderr).text().catch(() => "");
63
+ console.log(`omp plugin link skipped or failed (code ${code}): ${err.slice(0, 500)}`);
64
+ }
65
+ } catch (e) {
66
+ console.log(`omp plugin link probe skipped: ${e}`);
67
+ }
68
+
69
+ console.log(`\n== running bun test tests/e2e --timeout ${timeout} ==`);
70
+ const env = {
71
+ ...process.env,
72
+ OMP_DIR: ompDir,
73
+ PI_CODING_AGENT_DIR: ompDir,
74
+ OMP_VCC_CONFIG_PATH: configPath,
75
+ PI_VCC_CONFIG_PATH: configPath,
76
+ };
77
+ const testProc = Bun.spawn(["bun", "test", "tests/e2e", "--timeout", String(timeout)], {
78
+ env,
79
+ stdout: "pipe",
80
+ stderr: "pipe",
81
+ });
82
+ const stdoutChunks: string[] = [];
83
+ const stderrChunks: string[] = [];
84
+ // Stream
85
+ const outReader = testProc.stdout.getReader();
86
+ const errReader = testProc.stderr.getReader();
87
+ async function drain(reader: ReadableStreamDefaultReader<Uint8Array>, store: string[], isErr: boolean) {
88
+ try {
89
+ while (true) {
90
+ const { done, value } = await reader.read();
91
+ if (done) break;
92
+ const text = new TextDecoder().decode(value);
93
+ store.push(text);
94
+ if (isErr) process.stderr.write(text);
95
+ else process.stdout.write(text);
96
+ }
97
+ } catch {}
98
+ }
99
+ await Promise.all([drain(outReader as any, stdoutChunks, false), drain(errReader as any, stderrChunks, true)]);
100
+ const exitCode = await testProc.exited;
101
+
102
+ console.log(`\n== bun test exit code: ${exitCode} ==`);
103
+
104
+ // collect debug artifacts
105
+ const artifactsDir = join(process.cwd(), "artifacts", "e2e-debug");
106
+ try {
107
+ mkdirSync(artifactsDir, { recursive: true });
108
+ for (const p of ["/tmp/omp-vcc-debug.json", "/tmp/pi-vcc-debug.json"]) {
109
+ if (existsSync(p)) {
110
+ const dest = join(artifactsDir, p.split("/").pop()!);
111
+ cpSync(p, dest);
112
+ console.log(`artifact collected: ${p} -> ${dest}`);
113
+ }
114
+ }
115
+ if (existsSync(configPath)) {
116
+ cpSync(configPath, join(artifactsDir, "isolated-config.json"));
117
+ }
118
+ } catch (e) {
119
+ console.log(`artifact collection warning: ${e}`);
120
+ }
121
+
122
+ // cleanup isolated dir (keep artifacts)
123
+ try { rmSync(ompDir, { recursive: true, force: true }); } catch {}
124
+ console.log(`isolated OMP_DIR removed: ${ompDir}`);
125
+
126
+ if (exitCode !== 0) {
127
+ console.log("\nE2E FAILED");
128
+ process.exit(exitCode ?? 1);
129
+ } else {
130
+ console.log("\nAll E2E checks passed.");
131
+ }