@monotykamary/pi-vcc 0.3.16

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.
Files changed (76) hide show
  1. package/README.md +688 -0
  2. package/__tests__/helpers.ts +135 -0
  3. package/__tests__/root-causes.test.ts +858 -0
  4. package/demo.gif +0 -0
  5. package/index.ts +21 -0
  6. package/knip.json +4 -0
  7. package/npm-shrinkwrap.json +3001 -0
  8. package/package.json +36 -0
  9. package/scripts/audit-sessions.ts +88 -0
  10. package/scripts/benchmark-real-sessions.ts +25 -0
  11. package/scripts/compare-before-after.ts +36 -0
  12. package/scripts/dump-branch-output.ts +20 -0
  13. package/src/commands/pi-vcc.ts +36 -0
  14. package/src/commands/vcc-recall.ts +65 -0
  15. package/src/core/brief.ts +497 -0
  16. package/src/core/build-sections.ts +431 -0
  17. package/src/core/content.ts +71 -0
  18. package/src/core/filter-noise.ts +42 -0
  19. package/src/core/format-recall.ts +177 -0
  20. package/src/core/format.ts +98 -0
  21. package/src/core/invisible-continue.ts +172 -0
  22. package/src/core/lineage.ts +26 -0
  23. package/src/core/load-messages.ts +40 -0
  24. package/src/core/normalize.ts +73 -0
  25. package/src/core/recall-scope.ts +49 -0
  26. package/src/core/render-entries.ts +58 -0
  27. package/src/core/report.ts +237 -0
  28. package/src/core/sanitize.ts +5 -0
  29. package/src/core/search-entries.ts +375 -0
  30. package/src/core/settings.ts +190 -0
  31. package/src/core/skill-collapse.ts +35 -0
  32. package/src/core/summarize.ts +179 -0
  33. package/src/core/tool-args.ts +14 -0
  34. package/src/details.ts +19 -0
  35. package/src/extract/commits.ts +69 -0
  36. package/src/extract/goals.ts +79 -0
  37. package/src/extract/preferences.ts +55 -0
  38. package/src/extract/shared-symbols.ts +357 -0
  39. package/src/hooks/before-compact.ts +564 -0
  40. package/src/hooks/proactive-threshold.ts +131 -0
  41. package/src/sections.ts +23 -0
  42. package/src/tools/recall.ts +204 -0
  43. package/src/types.ts +20 -0
  44. package/tests/australian-government.test.ts +479 -0
  45. package/tests/before-compact-hook.test.ts +185 -0
  46. package/tests/before-compact.test.ts +227 -0
  47. package/tests/brief.test.ts +215 -0
  48. package/tests/build-sections.test.ts +59 -0
  49. package/tests/compile.test.ts +89 -0
  50. package/tests/content.test.ts +66 -0
  51. package/tests/deep-extraction.test.ts +313 -0
  52. package/tests/extract-goals.test.ts +86 -0
  53. package/tests/extract-preferences.test.ts +30 -0
  54. package/tests/filter-noise.test.ts +61 -0
  55. package/tests/fixtures.ts +61 -0
  56. package/tests/format-recall.test.ts +87 -0
  57. package/tests/format.test.ts +73 -0
  58. package/tests/government-domain.test.ts +542 -0
  59. package/tests/lineage.test.ts +33 -0
  60. package/tests/load-messages.test.ts +49 -0
  61. package/tests/model-threshold-hook.test.ts +377 -0
  62. package/tests/model-threshold.test.ts +223 -0
  63. package/tests/normalize.test.ts +104 -0
  64. package/tests/proactive-threshold.test.ts +490 -0
  65. package/tests/real-sessions.test.ts +38 -0
  66. package/tests/recall-expand.test.ts +15 -0
  67. package/tests/recall-scope.test.ts +32 -0
  68. package/tests/recall-tool-scope.test.ts +67 -0
  69. package/tests/render-entries.test.ts +92 -0
  70. package/tests/report.test.ts +58 -0
  71. package/tests/sanitize.test.ts +24 -0
  72. package/tests/search-entries.test.ts +233 -0
  73. package/tests/support/load-session.ts +23 -0
  74. package/tests/support/real-sessions.ts +51 -0
  75. package/tests/threshold-integration.test.ts +359 -0
  76. package/tsconfig.json +20 -0
@@ -0,0 +1,564 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { writeFileSync } from "fs";
3
+ import { compile, type CompileInput } from "../core/summarize";
4
+ import { loadSettings, getModelThreshold, resolveReserveTokens, type PiVccSettings } from "../core/settings";
5
+ import { isProactiveTriggerActive } from "./proactive-threshold";
6
+ import { triggerInvisibleContinue } from "../core/invisible-continue";
7
+ import type { PiVccCompactionDetails } from "../details";
8
+
9
+ export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
10
+
11
+ export interface CompactionStats {
12
+ summarized: number;
13
+ kept: number;
14
+ keptTokensEst: number;
15
+ }
16
+
17
+ let lastStats: CompactionStats | null = null;
18
+ let lastCompactWasPiVcc = false;
19
+ let lastCompactHandledByVcc = false;
20
+ export const getLastCompactionStats = () => lastStats;
21
+
22
+ const formatTokens = (n: number): string => {
23
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
24
+ return String(n);
25
+ };
26
+
27
+ /**
28
+ * Compute the entry-ID range for summarized messages.
29
+ *
30
+ * Uses entry IDs instead of numeric indices so that vcc_recall can correctly
31
+ * resolve the range against the full session file (not just the active branch,
32
+ * where numeric indices would be branch-relative and wrong).
33
+ *
34
+ * Returns [firstSummarizedEntryId, lastSummarizedEntryId] or undefined.
35
+ */
36
+ const computeMessageRange = (
37
+ branchEntries: any[],
38
+ firstKeptEntryId: string,
39
+ ): [string, string] | undefined => {
40
+ if (!firstKeptEntryId) return undefined;
41
+
42
+ // If compact-all sentinel, find the last message entry
43
+ if (firstKeptEntryId === "") {
44
+ let lastId: string | undefined;
45
+ for (const e of branchEntries) {
46
+ if (e.type === "message" && e.message && e.id) {
47
+ lastId = e.id;
48
+ }
49
+ }
50
+ return lastId ? [branchEntries.find((e: any) => e.type === "message" && e.message)?.id ?? "", lastId] as [string, string] : undefined;
51
+ }
52
+
53
+ // Find the first message entry (start of summarized range)
54
+ const firstMsgId = branchEntries.find(
55
+ (e: any) => e.type === "message" && e.message && e.id,
56
+ )?.id;
57
+ if (!firstMsgId) return undefined;
58
+
59
+ // If first kept entry IS the first message, nothing was summarized
60
+ if (firstMsgId === firstKeptEntryId) return undefined;
61
+
62
+ return [firstMsgId, firstKeptEntryId];
63
+ };
64
+
65
+ const dbg = (settings: PiVccSettings, data: Record<string, unknown>) => {
66
+ if (!settings.debug) return;
67
+ try { writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
68
+ };
69
+
70
+ const previewContent = (content: unknown): string => {
71
+ if (typeof content === "string") return content.slice(0, 300);
72
+ if (Array.isArray(content)) {
73
+ return content
74
+ .map((c: any) => {
75
+ if (c?.type === "text") return c.text ?? "";
76
+ if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
77
+ if (c?.type === "thinking") return `[thinking]`;
78
+ if (c?.type === "image") return `[image:${c.mimeType}]`;
79
+ return `[${c?.type ?? "unknown"}]`;
80
+ })
81
+ .join("\n")
82
+ .slice(0, 300);
83
+ }
84
+ return "";
85
+ };
86
+
87
+ interface EntryWithMessage {
88
+ entry: { id: string; type: string };
89
+ message: { role: string; content: unknown };
90
+ }
91
+
92
+ export type OwnCutCancelReason =
93
+ | "no_live_messages"
94
+ | "too_few_live_messages";
95
+
96
+ export type OwnCutResult =
97
+ | { ok: true; messages: any[]; firstKeptEntryId: string; compactAll: boolean }
98
+ | { ok: false; reason: OwnCutCancelReason };
99
+
100
+ /**
101
+ * Find a completed tool-call cycle boundary in the first half of the
102
+ * live messages. Used when there's only a single user message and we
103
+ * can't cut at a task boundary.
104
+ *
105
+ * Scans for completed assistant→toolResult cycles and returns the index
106
+ * of the last toolResult in the cycle nearest the midpoint.
107
+ */
108
+ const findMidCycleBoundary = (liveMessages: EntryWithMessage[]): number => {
109
+ const cycles: number[] = []; // end indices (toolResult) of completed cycles
110
+ let currentAssistantIdx = -1;
111
+ const pendingCalls = new Set<string>();
112
+
113
+ for (let i = 0; i < liveMessages.length; i++) {
114
+ const msg = liveMessages[i].message;
115
+ if (msg.role === "user") continue;
116
+ if (msg.role === "assistant") {
117
+ currentAssistantIdx = i;
118
+ pendingCalls.clear();
119
+ const content = msg.content;
120
+ if (Array.isArray(content)) {
121
+ for (const part of content) {
122
+ if (part.type === "toolCall" && part.id) pendingCalls.add(part.id);
123
+ }
124
+ }
125
+ continue;
126
+ }
127
+ if (msg.role === "toolResult") {
128
+ const callId = (msg as any).toolCallId as string | undefined;
129
+ if (callId) pendingCalls.delete(callId);
130
+ if (pendingCalls.size === 0 && currentAssistantIdx >= 0) {
131
+ cycles.push(i);
132
+ currentAssistantIdx = -1;
133
+ }
134
+ }
135
+ }
136
+
137
+ if (cycles.length === 0) return -1;
138
+
139
+ // Pick the cycle nearest the midpoint of the first half
140
+ const targetIdx = Math.floor(liveMessages.length / 2);
141
+ let best = cycles[0];
142
+ let bestDist = Math.abs(cycles[0] - targetIdx);
143
+ for (let i = 1; i < cycles.length; i++) {
144
+ const dist = Math.abs(cycles[i] - targetIdx);
145
+ if (dist < bestDist) {
146
+ best = cycles[i];
147
+ bestDist = dist;
148
+ }
149
+ }
150
+ return best;
151
+ };
152
+
153
+ export function buildOwnCut(branchEntries: any[]): OwnCutResult {
154
+ // Find the last compaction entry and its firstKeptEntryId
155
+ let lastCompactionIdx = -1;
156
+ let lastKeptId: string | undefined;
157
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
158
+ if (branchEntries[i].type === "compaction") {
159
+ lastCompactionIdx = i;
160
+ lastKeptId = branchEntries[i].firstKeptEntryId;
161
+ break;
162
+ }
163
+ }
164
+
165
+ // Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
166
+ // compact-all) OR set to an id that no longer exists in the branch. In both cases,
167
+ // start collecting from right after the last compaction entry.
168
+ const hasPriorCompaction = lastCompactionIdx >= 0;
169
+ const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
170
+ const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
171
+
172
+ // Collect live messages
173
+ const liveMessages: EntryWithMessage[] = [];
174
+ if (orphanRecovery) {
175
+ for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
176
+ const e = branchEntries[i];
177
+ if (e.type === "compaction") continue;
178
+ if (e.type === "message" && e.message) {
179
+ liveMessages.push({ entry: e, message: e.message });
180
+ }
181
+ }
182
+ } else {
183
+ let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
184
+ for (const e of branchEntries) {
185
+ if (!foundKept && e.id === lastKeptId) foundKept = true;
186
+ if (!foundKept) continue;
187
+ if (e.type === "compaction") continue;
188
+ if (e.type === "message" && e.message) {
189
+ liveMessages.push({ entry: e, message: e.message });
190
+ }
191
+ }
192
+ }
193
+
194
+ if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
195
+ if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
196
+
197
+ // Task-boundary-aware cut: find the last user message whose response cycle
198
+ // is complete (no unmatched tool calls). If the turn is mid-flight, push the
199
+ // cut back to the previous user message to keep the entire in-progress turn
200
+ // in the tail.
201
+ let cutIdx = liveMessages.length - 1;
202
+ while (cutIdx > 0 && liveMessages[cutIdx].message.role !== "user") {
203
+ cutIdx--;
204
+ }
205
+
206
+ // Check if the turn following the last user message is "in progress"
207
+ // (has an unmatched toolCall — assistant started but didn't finish)
208
+ if (cutIdx > 0) {
209
+ const toolCallIds = new Set<string>();
210
+ const toolResultIds = new Set<string>();
211
+ for (let i = cutIdx + 1; i < liveMessages.length; i++) {
212
+ const msg = liveMessages[i].message;
213
+ if (msg.role === "user") break; // next turn starts
214
+ // toolResult messages carry toolCallId at the message level, not in content parts
215
+ if (msg.role === "toolResult" && (msg as any).toolCallId) {
216
+ toolResultIds.add((msg as any).toolCallId);
217
+ continue;
218
+ }
219
+ const content = msg.content;
220
+ if (typeof content === "string" || !Array.isArray(content)) continue;
221
+ for (const part of content) {
222
+ if (part.type === "toolCall" && part.id) toolCallIds.add(part.id);
223
+ if (part.type === "toolResult" && part.toolCallId) toolResultIds.add(part.toolCallId);
224
+ }
225
+ }
226
+ const hasUnmatchedToolCall = [...toolCallIds].some(id => !toolResultIds.has(id));
227
+ if (hasUnmatchedToolCall) {
228
+ // Push cut back to the previous user message
229
+ for (let i = cutIdx - 1; i > 0; i--) {
230
+ if (liveMessages[i].message.role === "user") {
231
+ cutIdx = i;
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ if (cutIdx <= 0) {
239
+ // Single user prompt (or no user at all) with a long agentic chain.
240
+ // Instead of compact-all (which destroys the tail), find a completed
241
+ // tool-call cycle boundary in the first half and cut there. This
242
+ // preserves the later part of the session while summarizing the earlier
243
+ // tool-call cycles.
244
+ const cycleEndIdx = findMidCycleBoundary(liveMessages);
245
+ if (cycleEndIdx > 0 && cycleEndIdx < liveMessages.length - 1) {
246
+ return {
247
+ ok: true,
248
+ messages: liveMessages.slice(0, cycleEndIdx + 1).map((e) => e.message),
249
+ firstKeptEntryId: liveMessages[cycleEndIdx + 1].entry.id,
250
+ compactAll: false,
251
+ };
252
+ }
253
+ // No completed cycle boundary found — fall back to compact-all as last resort.
254
+ // firstKeptEntryId="" is a sentinel: pi-core's buildSessionContext won't match it
255
+ // (so 0 kept from pre-compaction), and next buildOwnCut triggers orphan recovery.
256
+ return {
257
+ ok: true,
258
+ messages: liveMessages.map((e) => e.message),
259
+ firstKeptEntryId: "",
260
+ compactAll: true,
261
+ };
262
+ }
263
+
264
+ return {
265
+ ok: true,
266
+ messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
267
+ firstKeptEntryId: liveMessages[cutIdx].entry.id,
268
+ compactAll: false,
269
+ };
270
+ }
271
+
272
+ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
273
+ no_live_messages: "pi-vcc: Nothing to compact (no live messages)",
274
+ too_few_live_messages: "pi-vcc: Too few messages to compact",
275
+ };
276
+
277
+ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
278
+ pi.on("session_before_compact", (event, ctx) => {
279
+ const { preparation, branchEntries, customInstructions } = event;
280
+ const settings = loadSettings();
281
+
282
+ // Per-model threshold check — applies regardless of which compactor handles
283
+ // the actual summary. When pi-core's global threshold triggers compaction
284
+ // but the current model's per-model threshold hasn't been crossed yet,
285
+ // cancel to avoid compacting prematurely.
286
+ //
287
+ // This is NOT applied to explicit /pi-vcc commands — if the user asked
288
+ // for a compaction, honor it.
289
+ const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
290
+ if (!isPiVcc && !isProactiveTriggerActive()) {
291
+ const threshold = getModelThreshold(settings, ctx.model);
292
+ if (threshold) {
293
+ const contextWindow = ctx.model?.contextWindow ?? 0;
294
+ const reserve = resolveReserveTokens(threshold, contextWindow);
295
+ if (reserve != null && contextWindow > 0) {
296
+ const effectiveThreshold = contextWindow - reserve;
297
+ if (preparation.tokensBefore <= effectiveThreshold) {
298
+ try {
299
+ const pct = Math.round((preparation.tokensBefore / contextWindow) * 100);
300
+ ctx?.ui?.notify?.(
301
+ `pi-vcc: Skipped compaction — ${pct}% of context window used (threshold: ${formatTokens(effectiveThreshold)} tok)`,
302
+ "info",
303
+ );
304
+ } catch {}
305
+ return { cancel: true };
306
+ }
307
+ }
308
+ }
309
+ }
310
+
311
+ // Always handle explicit /pi-vcc marker.
312
+ // Otherwise, only handle when user opted in via settings.
313
+ if (!isPiVcc && !settings.overrideDefaultCompaction) return;
314
+
315
+ const ownCut = buildOwnCut(branchEntries as any[]);
316
+ if (!ownCut.ok) {
317
+ const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction");
318
+ const lastCompIdx = lastComp ? (branchEntries as any[]).indexOf(lastComp) : -1;
319
+
320
+ // Recompute liveMessages view (same logic as buildOwnCut) for diagnostic
321
+ const lastKeptId: string | undefined = (lastComp as any)?.firstKeptEntryId;
322
+ const hasPriorCompaction = lastCompIdx >= 0;
323
+ const hasValidKeptId = !!lastKeptId && (branchEntries as any[]).some((e: any) => e.id === lastKeptId);
324
+ const diagOrphan = hasPriorCompaction && !hasValidKeptId;
325
+ const liveRoles: string[] = [];
326
+ if (diagOrphan) {
327
+ for (let i = lastCompIdx + 1; i < branchEntries.length; i++) {
328
+ const e = (branchEntries as any[])[i];
329
+ if (e.type === "compaction") continue;
330
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
331
+ }
332
+ } else {
333
+ let foundKept = !lastKeptId;
334
+ for (const e of branchEntries as any[]) {
335
+ if (!foundKept && e.id === lastKeptId) foundKept = true;
336
+ if (!foundKept) continue;
337
+ if (e.type === "compaction") continue;
338
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
339
+ }
340
+ }
341
+ const userIndices = liveRoles.reduce<number[]>((acc, r, i) => (r === "user" ? (acc.push(i), acc) : acc), []);
342
+
343
+ dbg(settings, {
344
+ cancelled: true,
345
+ reason: ownCut.reason,
346
+ isPiVcc,
347
+ counts: {
348
+ total: branchEntries.length,
349
+ messages: (branchEntries as any[]).filter((e: any) => e.type === "message").length,
350
+ compactions: (branchEntries as any[]).filter((e: any) => e.type === "compaction").length,
351
+ entriesAfterLastCompaction: lastCompIdx >= 0 ? branchEntries.length - lastCompIdx - 1 : null,
352
+ },
353
+ liveMessages: {
354
+ count: liveRoles.length,
355
+ userCount: userIndices.length,
356
+ firstUserIdx: userIndices[0] ?? null,
357
+ lastUserIdx: userIndices[userIndices.length - 1] ?? null,
358
+ roleSequence: liveRoles.length <= 30
359
+ ? liveRoles
360
+ : [...liveRoles.slice(0, 10), "...", ...liveRoles.slice(-10)],
361
+ },
362
+ lastCompaction: lastComp ? {
363
+ hasFirstKeptEntryId: !!(lastComp as any).firstKeptEntryId,
364
+ foundInBranch: (lastComp as any).firstKeptEntryId
365
+ ? (branchEntries as any[]).some((e: any) => e.id === (lastComp as any).firstKeptEntryId)
366
+ : null,
367
+ } : null,
368
+ tail: (branchEntries as any[]).slice(-5).map((e: any) => ({
369
+ type: e.type,
370
+ role: e.type === "message" ? e.message?.role : undefined,
371
+ hasContent: e.type === "message" ? e.message?.content != null : undefined,
372
+ })),
373
+ });
374
+
375
+ try {
376
+ ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning");
377
+ } catch {}
378
+ return { cancel: true };
379
+ }
380
+
381
+ const agentMessages = ownCut.messages;
382
+ const firstKeptEntryId = ownCut.firstKeptEntryId;
383
+ const messages = agentMessages;
384
+
385
+ // Count kept messages and estimate tokens
386
+ const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId);
387
+ const keptEntries = keptIdx >= 0
388
+ ? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message")
389
+ : [];
390
+ const keptChars = keptEntries.reduce((sum: number, e: any) => {
391
+ const c = e.message?.content;
392
+ if (typeof c === "string") return sum + c.length;
393
+ if (Array.isArray(c)) return sum + c.reduce((s: number, p: any) => {
394
+ if (p.text) return s + p.text.length;
395
+ if (p.type === "toolCall") return s + (p.name?.length ?? 0) + (typeof p.input === "string" ? p.input.length : JSON.stringify(p.input ?? "").length);
396
+ if (p.type === "toolResult") return s + (typeof p.content === "string" ? p.content.length : JSON.stringify(p.content ?? "").length);
397
+ return s;
398
+ }, 0);
399
+ return sum;
400
+ }, 0);
401
+ lastStats = {
402
+ summarized: agentMessages.length,
403
+ kept: keptEntries.length,
404
+ keptTokensEst: Math.round(keptChars / 4),
405
+ };
406
+
407
+ const config = settings;
408
+
409
+ // Compute entry-ID range for compaction-scoped recall
410
+ const messageRange = computeMessageRange(
411
+ branchEntries as any[],
412
+ firstKeptEntryId,
413
+ );
414
+
415
+ const compileInput: CompileInput = {
416
+ messages,
417
+ previousSummary: preparation.previousSummary,
418
+ fileOps: {
419
+ readFiles: [...preparation.fileOps.read],
420
+ modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited],
421
+ },
422
+ };
423
+
424
+ const summary = compile(compileInput);
425
+
426
+ const branchIds = branchEntries.map((e: any) => e.id);
427
+ const cutIdx = branchIds.indexOf(firstKeptEntryId);
428
+ const cutWindow = cutIdx >= 0
429
+ ? branchEntries.slice(Math.max(0, cutIdx - 3), Math.min(branchEntries.length, cutIdx + 3)).map((e: any) => ({
430
+ id: e.id,
431
+ type: e.type,
432
+ role: e.type === "message" ? e.message?.role : undefined,
433
+ preview: e.type === "message" ? previewContent(e.message?.content) : undefined,
434
+ }))
435
+ : [];
436
+
437
+ dbg(config, {
438
+ usedOwnCut: true,
439
+ messagesToSummarize: agentMessages.length,
440
+ messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
441
+ messagesPreviewTail: agentMessages.slice(-3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
442
+ convertedMessages: messages.length,
443
+ firstKeptEntryId,
444
+ messageRange,
445
+ cutWindow,
446
+ tokensBefore: preparation.tokensBefore,
447
+ summaryLength: summary.length,
448
+ summaryPreview: summary.slice(0, 500),
449
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
450
+ });
451
+
452
+ const sections = [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]);
453
+ const details: PiVccCompactionDetails = {
454
+ compactor: "pi-vcc",
455
+ version: 1,
456
+ sections,
457
+ sourceMessageCount: agentMessages.length,
458
+ previousSummaryUsed: Boolean(preparation.previousSummary),
459
+ messageRange,
460
+ compressionRatio: preparation.tokensBefore > 0
461
+ ? Math.round(preparation.tokensBefore / Math.max(1, agentMessages.length))
462
+ : undefined,
463
+ timestamp: new Date().toISOString(),
464
+ tokensBefore: preparation.tokensBefore || undefined,
465
+ keptCount: lastStats?.kept || undefined,
466
+ keptTokensEst: lastStats?.keptTokensEst || undefined,
467
+ };
468
+
469
+ lastCompactWasPiVcc = isPiVcc;
470
+ lastCompactHandledByVcc = true;
471
+
472
+ // Signal to neuralwatt-mcr that pi-vcc is handling compaction
473
+ // so it doesn't cancel the event. Without this flag, neuralwatt-mcr
474
+ // returns { cancel: true } for MCR models and pi-vcc's summary is
475
+ // discarded by the runner's short-circuit.
476
+ (event as any)._piVccOverriding = true;
477
+
478
+ return {
479
+ compaction: {
480
+ summary,
481
+ details,
482
+ tokensBefore: preparation.tokensBefore,
483
+ firstKeptEntryId,
484
+ },
485
+ };
486
+ });
487
+
488
+ // After compaction completes, check if the agent loop stalled and needs
489
+ // an invisible continue to resume. This handles threshold compaction
490
+ // where willRetry=false — pi-core doesn't auto-retry, and the agent loop
491
+ // exits because hasQueuedMessages() returns false. If the last message in
492
+ // the rebuilt context is an assistant mid-task (tool_use, length, or error
493
+ // that isn't a clean end_turn), the agent was interrupted and should
494
+ // continue.
495
+ pi.on("session_compact", (event, ctx) => {
496
+ // Only act when pi-vcc drove the compaction
497
+ if (!lastCompactHandledByVcc) return;
498
+ lastCompactHandledByVcc = false;
499
+
500
+ // Fire success toast for /compact path only (delayed to let UI settle).
501
+ // /pi-vcc path uses its own onComplete callback in the command handler.
502
+ if (!lastCompactWasPiVcc) {
503
+ const stats = lastStats;
504
+ if (stats) {
505
+ setTimeout(() => {
506
+ try {
507
+ ctx?.ui?.notify?.(
508
+ `pi-vcc: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
509
+ "info",
510
+ );
511
+ } catch {}
512
+ }, 500);
513
+ }
514
+ }
515
+
516
+ // Determine if the agent needs to continue after compaction.
517
+ // After rebuildSessionContext, the agent's state.messages are updated.
518
+ // Check the last message: if it's an assistant message that isn't a
519
+ // clean stop, the agent was mid-task and needs to resume.
520
+ //
521
+ // We do NOT continue when:
522
+ // - Last message is user/toolResult (agent can continue naturally)
523
+ // - Last message is assistant with stopReason=stop (task finished)
524
+ // - Last message is assistant with stopReason=aborted (user cancelled)
525
+ // - Last message is assistant with stopReason=error (pi-retry handles
526
+ // retry via its agent_end handler — avoid duplicate triggerInvisibleContinue)
527
+ //
528
+ // We DO continue when:
529
+ // - Last message is assistant with stopReason=toolUse (mid-tool cycle)
530
+ // - Last message is assistant with stopReason=length (hit max tokens)
531
+ // - Compact-all (firstKeptEntryId="") — context is just the summary,
532
+ // the agent needs to re-enter the loop to continue the task
533
+ try {
534
+ const entries = ctx.sessionManager.getEntries();
535
+ // Walk backwards to find the last message entry
536
+ let lastMsg: { role: string; stopReason?: string; content?: unknown } | undefined;
537
+ for (let i = entries.length - 1; i >= 0; i--) {
538
+ const e = (entries as any[])[i];
539
+ if (e.type === "message" && e.message) {
540
+ lastMsg = e.message;
541
+ break;
542
+ }
543
+ }
544
+ if (!lastMsg || lastMsg.role !== "assistant") return;
545
+
546
+ // Agent completed its turn cleanly — no continuation needed
547
+ if (lastMsg.stopReason === "stop") return;
548
+
549
+ // Agent was aborted by user — don't auto-continue
550
+ if (lastMsg.stopReason === "aborted") return;
551
+
552
+ // Agent hit an error — pi-retry handles this via its agent_end handler.
553
+ // If we also fire triggerInvisibleContinue, both extensions race
554
+ // to call prompt([]), causing "Agent is already processing" (wasteful)
555
+ // or a duplicate continuation. Let pi-retry own error retries.
556
+ if (lastMsg.stopReason === "error") return;
557
+
558
+ // Agent was mid-task (toolUse or length) — needs to continue.
559
+ triggerInvisibleContinue();
560
+ } catch {
561
+ // Non-critical — if context inspection fails, don't block compaction
562
+ }
563
+ });
564
+ };
@@ -0,0 +1,131 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { loadSettings, getModelThreshold, resolveReserveTokens } from "../core/settings";
3
+
4
+ const formatTokens = (n: number): string => {
5
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
6
+ return String(n);
7
+ };
8
+
9
+ // Cooldown after compaction to prevent double-trigger.
10
+ // Set immediately when we call ctx.compact() AND on session_compact,
11
+ // cleared after 3 seconds.
12
+ let lastCompactTime = 0;
13
+ const COOLDOWN_MS = 3000;
14
+
15
+ // Flag: when true, session_before_compact should NOT cancel even if
16
+ // tokensBefore is below the per-model threshold. This is set when
17
+ // our proactive trigger calls ctx.compact() and cleared when
18
+ // session_compact fires. It prevents the threshold guard from
19
+ // cancelling a compaction that we ourselves initiated.
20
+ let proactiveTriggerActive = false;
21
+
22
+ const setCooldown = () => { lastCompactTime = Date.now(); };
23
+ const isCoolingDown = () => Date.now() - lastCompactTime < COOLDOWN_MS;
24
+
25
+ /** Check if a proactive trigger is currently in flight. */
26
+ export const isProactiveTriggerActive = () => proactiveTriggerActive;
27
+
28
+ /** Reset all proactive state (for testing / session start). */
29
+ export const resetProactiveState = () => {
30
+ lastCompactTime = 0;
31
+ proactiveTriggerActive = false;
32
+ };
33
+
34
+ /**
35
+ * Check if per-model threshold has been crossed and trigger compaction
36
+ * if so. Safe to call from multiple event handlers — cooldown prevents
37
+ * double-triggering.
38
+ */
39
+ const checkAndTrigger = (ctx: { model?: any; getContextUsage?: () => any; compact?: () => void; ui?: any }, source: string) => {
40
+ const settings = loadSettings();
41
+ const threshold = getModelThreshold(settings, ctx.model);
42
+
43
+ // No threshold → nothing to do (pi-core's global threshold owns it)
44
+ if (!threshold) return;
45
+
46
+ const contextWindow = ctx.model?.contextWindow ?? 0;
47
+ const reserve = resolveReserveTokens(threshold, contextWindow);
48
+ if (reserve == null || contextWindow <= 0) return;
49
+
50
+ const usage = ctx.getContextUsage?.();
51
+ if (!usage || usage.tokens === null) return;
52
+
53
+ // This threshold's compaction trigger point
54
+ const effectiveThreshold = contextWindow - reserve;
55
+
56
+ // Only trigger if context EXCEEDS the threshold.
57
+ if (usage.tokens <= effectiveThreshold) return;
58
+
59
+ // Cooldown guard — prevent double-trigger within 3s of last compaction.
60
+ if (isCoolingDown()) return;
61
+
62
+ try {
63
+ const pct = Math.round((usage.tokens / contextWindow) * 100);
64
+ ctx?.ui?.notify?.(
65
+ `pi-vcc: [${source}] Context at ${pct}% exceeds threshold (${formatTokens(effectiveThreshold)} tok). Compacting...`,
66
+ "info",
67
+ );
68
+ } catch {}
69
+
70
+ // Set cooldown IMMEDIATELY (before ctx.compact() runs) to prevent
71
+ // pi-core's own _checkCompaction from also triggering compaction
72
+ // on the same turn.
73
+ setCooldown();
74
+
75
+ // Mark that this compaction was triggered by us, so session_before_compact
76
+ // doesn't cancel it if tokensBefore differs from getContextUsage().
77
+ proactiveTriggerActive = true;
78
+
79
+ ctx.compact?.();
80
+ };
81
+
82
+ /**
83
+ * Registers proactive per-model compaction thresholds.
84
+ *
85
+ * Three triggers:
86
+ *
87
+ * 1. `agent_end` — after each agent run completes, check if context
88
+ * exceeds the current model's per-model threshold. If the per-model
89
+ * threshold is *lower* than pi-core's global threshold (meaning the
90
+ * model wants to compact *earlier*), pi-core won't trigger compaction
91
+ * at this point. We step in and trigger it proactively.
92
+ *
93
+ * 2. `model_select` — when switching to a model with a lower effective
94
+ * threshold, the current context may already exceed the new model's
95
+ * capacity. Trigger compaction immediately.
96
+ *
97
+ * 3. `session_compact` — cooldown tracking + clear proactiveTriggerActive.
98
+ * After any compaction completes, we set a cooldown to prevent
99
+ * double-triggering and clear the self-initiated flag.
100
+ *
101
+ * `session_before_compact` reads `isProactiveTriggerActive()` to decide
102
+ * whether to cancel. When our proactive trigger fires, ctx.compact() is
103
+ * queued but hasn't run yet. By the time session_before_compact actually
104
+ * fires, tokensBefore may differ from the getContextUsage() snapshot
105
+ * that triggered the compact. Without the flag, the threshold guard would
106
+ * cancel the compaction we ourselves requested — producing confusing
107
+ * "Compacting..." then "Skipped compaction" notifications.
108
+ */
109
+ export const registerProactiveThresholdHook = (pi: ExtensionAPI) => {
110
+ // Proactive compaction after each agent run
111
+ pi.on("agent_end", (_event, ctx) => {
112
+ checkAndTrigger(ctx, "auto");
113
+ });
114
+
115
+ // Proactive compaction on model switch
116
+ pi.on("model_select", (_event, ctx) => {
117
+ checkAndTrigger(ctx, "model-switch");
118
+ });
119
+
120
+ // Track compaction completion: set cooldown and clear self-initiated flag
121
+ pi.on("session_compact", () => {
122
+ setCooldown();
123
+ proactiveTriggerActive = false;
124
+ });
125
+
126
+ // Reset state on session start so state doesn't leak between sessions
127
+ pi.on("session_start", () => {
128
+ lastCompactTime = 0;
129
+ proactiveTriggerActive = false;
130
+ });
131
+ };