@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.
- package/README.md +688 -0
- package/__tests__/helpers.ts +135 -0
- package/__tests__/root-causes.test.ts +858 -0
- package/demo.gif +0 -0
- package/index.ts +21 -0
- package/knip.json +4 -0
- package/npm-shrinkwrap.json +3001 -0
- package/package.json +36 -0
- package/scripts/audit-sessions.ts +88 -0
- package/scripts/benchmark-real-sessions.ts +25 -0
- package/scripts/compare-before-after.ts +36 -0
- package/scripts/dump-branch-output.ts +20 -0
- package/src/commands/pi-vcc.ts +36 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +497 -0
- package/src/core/build-sections.ts +431 -0
- package/src/core/content.ts +71 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +177 -0
- package/src/core/format.ts +98 -0
- package/src/core/invisible-continue.ts +172 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +40 -0
- package/src/core/normalize.ts +73 -0
- package/src/core/recall-scope.ts +49 -0
- package/src/core/render-entries.ts +58 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +375 -0
- package/src/core/settings.ts +190 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +179 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +19 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/shared-symbols.ts +357 -0
- package/src/hooks/before-compact.ts +564 -0
- package/src/hooks/proactive-threshold.ts +131 -0
- package/src/sections.ts +23 -0
- package/src/tools/recall.ts +204 -0
- package/src/types.ts +20 -0
- package/tests/australian-government.test.ts +479 -0
- package/tests/before-compact-hook.test.ts +185 -0
- package/tests/before-compact.test.ts +227 -0
- package/tests/brief.test.ts +215 -0
- package/tests/build-sections.test.ts +59 -0
- package/tests/compile.test.ts +89 -0
- package/tests/content.test.ts +66 -0
- package/tests/deep-extraction.test.ts +313 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +87 -0
- package/tests/format.test.ts +73 -0
- package/tests/government-domain.test.ts +542 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +49 -0
- package/tests/model-threshold-hook.test.ts +377 -0
- package/tests/model-threshold.test.ts +223 -0
- package/tests/normalize.test.ts +104 -0
- package/tests/proactive-threshold.test.ts +490 -0
- package/tests/real-sessions.test.ts +38 -0
- package/tests/recall-expand.test.ts +15 -0
- package/tests/recall-scope.test.ts +32 -0
- package/tests/recall-tool-scope.test.ts +67 -0
- package/tests/render-entries.test.ts +92 -0
- package/tests/report.test.ts +58 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +233 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tests/threshold-integration.test.ts +359 -0
- 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
|
+
};
|