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.
- package/README.md +67 -59
- package/extensions/main.ts +20 -8
- package/extensions/vcc-core/core/migrate-stale.ts +162 -0
- package/extensions/vcc-core/core/settings.ts +11 -0
- package/extensions/vcc-core/details.ts +10 -1
- package/extensions/vcc-core/hook.ts +336 -30
- package/package.json +14 -10
- package/scripts/e2e.ts +131 -0
- package/scripts/smoke.ts +21 -1
- package/scripts/uninstall-reset.js +251 -22
- package/skills/omp-vcc/SKILL.md +101 -20
- package/commands/omp-vcc.md +0 -19
- package/commands/vcc-recall.md +0 -21
|
@@ -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) => {
|
|
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 = () =>
|
|
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
|
-
|
|
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 ${
|
|
212
|
+
const notes: string[] = [`summarized ${summarized}`];
|
|
151
213
|
if (stats.smartKeepAdjusted) {
|
|
152
214
|
notes.push("smart-keep");
|
|
153
215
|
}
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
977
|
+
tokensBefore,
|
|
801
978
|
tokenEstimate,
|
|
802
979
|
summaryLength: summary.length,
|
|
803
980
|
summaryPreview: summary.slice(0, 500),
|
|
804
|
-
sections:
|
|
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:
|
|
810
|
-
sections:
|
|
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.
|
|
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
|
+
}
|