@monotykamary/pi-vcc 0.7.0 → 0.7.2
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/package.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const CODEX_OUTPUT_LIMIT_COMPACT_INSTRUCTION = "__pi_vcc_codex_output_limit__";
|
|
2
|
+
|
|
3
|
+
/** Identify the Codex provider error used when a response reaches its output cap. */
|
|
4
|
+
export const isCodexOutputLimitError = (message: unknown): boolean => {
|
|
5
|
+
if (!message || typeof message !== "object") return false;
|
|
6
|
+
|
|
7
|
+
const candidate = message as {
|
|
8
|
+
role?: unknown;
|
|
9
|
+
provider?: unknown;
|
|
10
|
+
api?: unknown;
|
|
11
|
+
stopReason?: unknown;
|
|
12
|
+
errorMessage?: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const isCodexMessage =
|
|
16
|
+
candidate.provider === "openai-codex" ||
|
|
17
|
+
candidate.api === "openai-codex-responses";
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
candidate.role === "assistant" &&
|
|
21
|
+
isCodexMessage &&
|
|
22
|
+
candidate.stopReason === "error" &&
|
|
23
|
+
typeof candidate.errorMessage === "string" &&
|
|
24
|
+
/maximum output token limit/i.test(candidate.errorMessage)
|
|
25
|
+
);
|
|
26
|
+
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { writeFileSync } from "fs";
|
|
3
3
|
import { compile, type CompileInput } from "../core/summarize";
|
|
4
|
+
import {
|
|
5
|
+
CODEX_OUTPUT_LIMIT_COMPACT_INSTRUCTION,
|
|
6
|
+
isCodexOutputLimitError,
|
|
7
|
+
} from "../core/codex-output-limit";
|
|
4
8
|
import { loadSettings, type PiVccSettings } from "../core/settings";
|
|
5
9
|
import { triggerInvisibleContinue } from "../core/invisible-continue";
|
|
6
10
|
import { countPiVccCompactionsFromSession, ordinalSuffix } from "../core/compaction-count";
|
|
@@ -16,6 +20,7 @@ export interface CompactionStats {
|
|
|
16
20
|
|
|
17
21
|
let lastStats: CompactionStats | null = null;
|
|
18
22
|
let lastCompactWasPiVcc = false;
|
|
23
|
+
let lastCompactWasCodexOutputLimit = false;
|
|
19
24
|
let lastCompactHandledByVcc = false;
|
|
20
25
|
export const getLastCompactionStats = () => lastStats;
|
|
21
26
|
|
|
@@ -150,7 +155,88 @@ const findMidCycleBoundary = (liveMessages: EntryWithMessage[]): number => {
|
|
|
150
155
|
return best;
|
|
151
156
|
};
|
|
152
157
|
|
|
153
|
-
|
|
158
|
+
/** Rough token estimate (chars/4) for a live message, consistent with the
|
|
159
|
+
* kept-tokens estimate used elsewhere in this module. */
|
|
160
|
+
function estimateMessageTokens(message: { content: unknown }): number {
|
|
161
|
+
const c = message.content;
|
|
162
|
+
let chars = 0;
|
|
163
|
+
if (typeof c === "string") {
|
|
164
|
+
chars = c.length;
|
|
165
|
+
} else if (Array.isArray(c)) {
|
|
166
|
+
for (const part of c as any[]) {
|
|
167
|
+
if (part.text) chars += part.text.length;
|
|
168
|
+
else if (part.type === "toolCall") {
|
|
169
|
+
const args = part.arguments ?? part.input;
|
|
170
|
+
chars += (part.name?.length ?? 0) + (typeof args === "string" ? args.length : JSON.stringify(args ?? "").length);
|
|
171
|
+
} else if (part.type === "toolResult") {
|
|
172
|
+
chars += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content ?? "").length;
|
|
173
|
+
} else if (part.type === "thinking") {
|
|
174
|
+
chars += (part.thinking?.length ?? 0);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Math.ceil(chars / 4);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Find a completed tool-cycle boundary within `suffix` such that the kept
|
|
182
|
+
* tail (suffix[boundary+1 .. end]) fits within `budgetTokens`, keeping as
|
|
183
|
+
* much recent context as possible. Returns the index of the first message to
|
|
184
|
+
* KEEP, or -1 when the suffix can't be split to fit (single oversized cycle,
|
|
185
|
+
* or no completed cycles). */
|
|
186
|
+
const findSuffixSplitPoint = (
|
|
187
|
+
suffix: EntryWithMessage[],
|
|
188
|
+
budgetTokens: number,
|
|
189
|
+
): number => {
|
|
190
|
+
if (suffix.length <= 2) return -1;
|
|
191
|
+
|
|
192
|
+
// Completed-cycle end-indices (toolResult closing a cycle). Same detection
|
|
193
|
+
// as findMidCycleBoundary.
|
|
194
|
+
const cycleEnds: number[] = [];
|
|
195
|
+
let currentAssistantIdx = -1;
|
|
196
|
+
const pendingCalls = new Set<string>();
|
|
197
|
+
for (let i = 0; i < suffix.length; i++) {
|
|
198
|
+
const msg = suffix[i].message;
|
|
199
|
+
if (msg.role === "user") { currentAssistantIdx = -1; pendingCalls.clear(); continue; }
|
|
200
|
+
if (msg.role === "assistant") {
|
|
201
|
+
currentAssistantIdx = i;
|
|
202
|
+
pendingCalls.clear();
|
|
203
|
+
const content = msg.content;
|
|
204
|
+
if (Array.isArray(content)) {
|
|
205
|
+
for (const part of content) {
|
|
206
|
+
if (part.type === "toolCall" && part.id) pendingCalls.add(part.id);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (msg.role === "toolResult") {
|
|
212
|
+
const callId = (msg as any).toolCallId as string | undefined;
|
|
213
|
+
if (callId) pendingCalls.delete(callId);
|
|
214
|
+
if (pendingCalls.size === 0 && currentAssistantIdx >= 0) {
|
|
215
|
+
cycleEnds.push(i);
|
|
216
|
+
currentAssistantIdx = -1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (cycleEnds.length === 0) return -1;
|
|
221
|
+
|
|
222
|
+
// tailTokens[i] = tokens of suffix[i .. end].
|
|
223
|
+
const tailTokens: number[] = new Array(suffix.length + 1).fill(0);
|
|
224
|
+
for (let i = suffix.length - 1; i >= 0; i--) {
|
|
225
|
+
tailTokens[i] = tailTokens[i + 1] + estimateMessageTokens(suffix[i].message);
|
|
226
|
+
}
|
|
227
|
+
// Earliest cycle boundary whose kept tail fits — keeps the most recent
|
|
228
|
+
// context while staying under budget. boundary+1 must be < length so the
|
|
229
|
+
// kept tail is non-empty.
|
|
230
|
+
for (const boundary of cycleEnds) {
|
|
231
|
+
if (boundary + 1 < suffix.length && tailTokens[boundary + 1] <= budgetTokens) {
|
|
232
|
+
return boundary + 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return -1;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export function buildOwnCut(branchEntries: any[], options?: { maxKeptTokens?: number }): OwnCutResult {
|
|
239
|
+
const maxKeptTokens = options?.maxKeptTokens ?? 0;
|
|
154
240
|
// Find the last compaction entry and its firstKeptEntryId
|
|
155
241
|
let lastCompactionIdx = -1;
|
|
156
242
|
let lastKeptId: string | undefined;
|
|
@@ -235,6 +321,38 @@ export function buildOwnCut(branchEntries: any[]): OwnCutResult {
|
|
|
235
321
|
}
|
|
236
322
|
}
|
|
237
323
|
|
|
324
|
+
// Oversized-turn guard (opt-in via options.maxKeptTokens > 0). When the
|
|
325
|
+
// kept suffix — the most recent turn from the last user message — exceeds
|
|
326
|
+
// the budget, keeping it whole would re-overflow on the compaction retry.
|
|
327
|
+
// Split the turn at a completed tool-cycle boundary so the oversized early
|
|
328
|
+
// part (typically a giant tool result) is summarized and only recent cycles
|
|
329
|
+
// that fit are kept. If a single cycle is itself oversized (or there are no
|
|
330
|
+
// completed cycles to split at), fall back to compact-all — safe because
|
|
331
|
+
// pi-vcc compiles summaries statically (no LLM call that could overflow).
|
|
332
|
+
if (cutIdx > 0 && maxKeptTokens > 0) {
|
|
333
|
+
const suffix = liveMessages.slice(cutIdx);
|
|
334
|
+
let suffixTokens = 0;
|
|
335
|
+
for (const e of suffix) suffixTokens += estimateMessageTokens(e.message);
|
|
336
|
+
if (suffixTokens > maxKeptTokens) {
|
|
337
|
+
const splitIdx = findSuffixSplitPoint(suffix, maxKeptTokens);
|
|
338
|
+
if (splitIdx >= 0) {
|
|
339
|
+
const globalIdx = cutIdx + splitIdx;
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
messages: liveMessages.slice(0, globalIdx).map((e) => e.message),
|
|
343
|
+
firstKeptEntryId: liveMessages[globalIdx].entry.id,
|
|
344
|
+
compactAll: false,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
messages: liveMessages.map((e) => e.message),
|
|
350
|
+
firstKeptEntryId: "",
|
|
351
|
+
compactAll: true,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
238
356
|
if (cutIdx <= 0) {
|
|
239
357
|
// Single user prompt (or no user at all) with a long agentic chain.
|
|
240
358
|
// Instead of compact-all (which destroys the tail), find a completed
|
|
@@ -274,6 +392,20 @@ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
|
|
|
274
392
|
too_few_live_messages: "pi-vcc: Too few messages to compact",
|
|
275
393
|
};
|
|
276
394
|
|
|
395
|
+
export const shouldResumeAfterCompaction = (
|
|
396
|
+
lastMsg: unknown,
|
|
397
|
+
allowCodexOutputLimit = false,
|
|
398
|
+
): boolean => {
|
|
399
|
+
if (!lastMsg || typeof lastMsg !== "object") return false;
|
|
400
|
+
const message = lastMsg as { role?: unknown; stopReason?: unknown };
|
|
401
|
+
if (message.role !== "assistant") return false;
|
|
402
|
+
if (message.stopReason === "stop" || message.stopReason === "aborted") return false;
|
|
403
|
+
if (message.stopReason === "error") {
|
|
404
|
+
return allowCodexOutputLimit && isCodexOutputLimitError(lastMsg);
|
|
405
|
+
}
|
|
406
|
+
return true;
|
|
407
|
+
};
|
|
408
|
+
|
|
277
409
|
export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
278
410
|
pi.on("session_before_compact", (event, ctx) => {
|
|
279
411
|
const { preparation, branchEntries, customInstructions } = event;
|
|
@@ -296,12 +428,28 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
296
428
|
// per-model threshold's perspective, but this is preferable to blocking
|
|
297
429
|
// an explicit user action (/compact).
|
|
298
430
|
const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
|
|
431
|
+
const isCodexOutputLimitCompaction =
|
|
432
|
+
customInstructions === CODEX_OUTPUT_LIMIT_COMPACT_INSTRUCTION;
|
|
433
|
+
const isPiVccHandled = isPiVcc || isCodexOutputLimitCompaction;
|
|
299
434
|
|
|
300
|
-
// Always handle explicit /pi-vcc
|
|
435
|
+
// Always handle explicit /pi-vcc and Codex output-limit markers.
|
|
301
436
|
// Otherwise, only handle when user opted in via settings.
|
|
302
|
-
if (!
|
|
303
|
-
|
|
304
|
-
|
|
437
|
+
if (!isPiVccHandled && !settings.overrideDefaultCompaction) return;
|
|
438
|
+
|
|
439
|
+
// Budget the kept tail so that summary + kept + system/tools + the model's
|
|
440
|
+
// OUTPUT budget (maxTokens) all fit in the window. Without reserving
|
|
441
|
+
// maxTokens, the compaction-retry request can be rejected upfront
|
|
442
|
+
// (input + maxTokens > contextWindow) and re-overflow. Falls back to
|
|
443
|
+
// pi-core's keepRecentTokens when the window is unknown.
|
|
444
|
+
const contextWindow = (ctx as any)?.model?.contextWindow ?? 0;
|
|
445
|
+
const maxTokens = (ctx as any)?.model?.maxTokens ?? 0;
|
|
446
|
+
const keepRecentTokens = (preparation as any)?.settings?.keepRecentTokens ?? 20000;
|
|
447
|
+
const overhead = contextWindow > 0 ? Math.min(32768, Math.floor(contextWindow * 0.2)) : 32768;
|
|
448
|
+
const outputReserve = maxTokens > 0 ? maxTokens : Math.floor(contextWindow * 0.5);
|
|
449
|
+
const maxKeptTokens = contextWindow > 0
|
|
450
|
+
? Math.max(2048, contextWindow - outputReserve - overhead)
|
|
451
|
+
: keepRecentTokens;
|
|
452
|
+
const ownCut = buildOwnCut(branchEntries as any[], { maxKeptTokens });
|
|
305
453
|
if (!ownCut.ok) {
|
|
306
454
|
const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction");
|
|
307
455
|
const lastCompIdx = lastComp ? (branchEntries as any[]).indexOf(lastComp) : -1;
|
|
@@ -456,6 +604,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
456
604
|
};
|
|
457
605
|
|
|
458
606
|
lastCompactWasPiVcc = isPiVcc;
|
|
607
|
+
lastCompactWasCodexOutputLimit = isCodexOutputLimitCompaction;
|
|
459
608
|
lastCompactHandledByVcc = true;
|
|
460
609
|
|
|
461
610
|
// Signal to neuralwatt-mcr that pi-vcc is handling compaction
|
|
@@ -484,7 +633,9 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
484
633
|
pi.on("session_compact", (event, ctx) => {
|
|
485
634
|
// Only act when pi-vcc drove the compaction
|
|
486
635
|
if (!lastCompactHandledByVcc) return;
|
|
636
|
+
const wasCodexOutputLimitCompaction = lastCompactWasCodexOutputLimit;
|
|
487
637
|
lastCompactHandledByVcc = false;
|
|
638
|
+
lastCompactWasCodexOutputLimit = false;
|
|
488
639
|
|
|
489
640
|
// Fire success toast for /compact path only (delayed to let UI settle).
|
|
490
641
|
// /pi-vcc path uses its own onComplete callback in the command handler.
|
|
@@ -515,12 +666,14 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
515
666
|
// - Last message is user/toolResult (agent can continue naturally)
|
|
516
667
|
// - Last message is assistant with stopReason=stop (task finished)
|
|
517
668
|
// - Last message is assistant with stopReason=aborted (user cancelled)
|
|
518
|
-
// - Last message is assistant with stopReason=error
|
|
519
|
-
//
|
|
669
|
+
// - Last message is assistant with stopReason=error, unless this was
|
|
670
|
+
// the pi-vcc Codex maximum-output recovery compaction
|
|
520
671
|
//
|
|
521
672
|
// We DO continue when:
|
|
522
673
|
// - Last message is assistant with stopReason=toolUse (mid-tool cycle)
|
|
523
674
|
// - Last message is assistant with stopReason=length (hit max tokens)
|
|
675
|
+
// - Last message is the known Codex maximum-output error from the
|
|
676
|
+
// pi-vcc recovery compaction
|
|
524
677
|
// - Compact-all (firstKeptEntryId="") — context is just the summary,
|
|
525
678
|
// the agent needs to re-enter the loop to continue the task
|
|
526
679
|
try {
|
|
@@ -534,21 +687,10 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
534
687
|
break;
|
|
535
688
|
}
|
|
536
689
|
}
|
|
537
|
-
if (!lastMsg
|
|
538
|
-
|
|
539
|
-
// Agent completed its turn cleanly — no continuation needed
|
|
540
|
-
if (lastMsg.stopReason === "stop") return;
|
|
541
|
-
|
|
542
|
-
// Agent was aborted by user — don't auto-continue
|
|
543
|
-
if (lastMsg.stopReason === "aborted") return;
|
|
544
|
-
|
|
545
|
-
// Agent hit an error — pi-retry handles this via its agent_end handler.
|
|
546
|
-
// If we also fire triggerInvisibleContinue, both extensions race
|
|
547
|
-
// to call prompt([]), causing "Agent is already processing" (wasteful)
|
|
548
|
-
// or a duplicate continuation. Let pi-retry own error retries.
|
|
549
|
-
if (lastMsg.stopReason === "error") return;
|
|
690
|
+
if (!shouldResumeAfterCompaction(lastMsg, wasCodexOutputLimitCompaction)) return;
|
|
550
691
|
|
|
551
|
-
// Agent was mid-task (toolUse or length)
|
|
692
|
+
// Agent was mid-task (toolUse or length). The Codex recovery marker
|
|
693
|
+
// additionally permits its maximum-output error to continue.
|
|
552
694
|
triggerInvisibleContinue();
|
|
553
695
|
} catch {
|
|
554
696
|
// Non-critical — if context inspection fails, don't block compaction
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
CODEX_OUTPUT_LIMIT_COMPACT_INSTRUCTION,
|
|
4
|
+
isCodexOutputLimitError,
|
|
5
|
+
} from "../core/codex-output-limit";
|
|
2
6
|
import { loadSettings, getModelThreshold, resolveTriggerTokens } from "../core/settings";
|
|
3
7
|
|
|
8
|
+
type ProactiveContext = {
|
|
9
|
+
model?: any;
|
|
10
|
+
getContextUsage?: () => any;
|
|
11
|
+
compact?: (options?: any) => void;
|
|
12
|
+
ui?: any;
|
|
13
|
+
};
|
|
14
|
+
|
|
4
15
|
const formatTokens = (n: number): string => {
|
|
5
16
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
6
17
|
return String(n);
|
|
@@ -36,7 +47,7 @@ export const resetProactiveState = () => {
|
|
|
36
47
|
* if so. Safe to call from multiple event handlers — cooldown prevents
|
|
37
48
|
* double-triggering.
|
|
38
49
|
*/
|
|
39
|
-
const checkAndTrigger = (ctx:
|
|
50
|
+
const checkAndTrigger = (ctx: ProactiveContext, source: string) => {
|
|
40
51
|
const settings = loadSettings();
|
|
41
52
|
const threshold = getModelThreshold(settings, ctx.model);
|
|
42
53
|
|
|
@@ -78,6 +89,32 @@ const checkAndTrigger = (ctx: { model?: any; getContextUsage?: () => any; compac
|
|
|
78
89
|
ctx.compact?.();
|
|
79
90
|
};
|
|
80
91
|
|
|
92
|
+
/** Force compaction for Codex responses that report an output limit as an error. */
|
|
93
|
+
const triggerCodexOutputLimitCompaction = (ctx: ProactiveContext) => {
|
|
94
|
+
if (isCoolingDown()) return;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
ctx?.ui?.notify?.(
|
|
98
|
+
"pi-vcc: Codex reached its maximum output token limit. Compacting...",
|
|
99
|
+
"info",
|
|
100
|
+
);
|
|
101
|
+
} catch {}
|
|
102
|
+
|
|
103
|
+
setCooldown();
|
|
104
|
+
proactiveTriggerActive = true;
|
|
105
|
+
ctx.compact?.({ customInstructions: CODEX_OUTPUT_LIMIT_COMPACT_INSTRUCTION });
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const lastAssistantMessage = (event: unknown): unknown => {
|
|
109
|
+
const messages = (event as any)?.messages;
|
|
110
|
+
if (!Array.isArray(messages)) return undefined;
|
|
111
|
+
|
|
112
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
113
|
+
if (messages[i]?.role === "assistant") return messages[i];
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
81
118
|
/**
|
|
82
119
|
* Registers proactive configured compaction thresholds.
|
|
83
120
|
*
|
|
@@ -105,8 +142,14 @@ const checkAndTrigger = (ctx: { model?: any; getContextUsage?: () => any; compac
|
|
|
105
142
|
* "Compacting..." then "Skipped compaction" notifications.
|
|
106
143
|
*/
|
|
107
144
|
export const registerProactiveThresholdHook = (pi: ExtensionAPI) => {
|
|
108
|
-
//
|
|
109
|
-
|
|
145
|
+
// Codex reports some output-limit responses as errors instead of the
|
|
146
|
+
// standard "length" stop reason. The error has no usable context usage,
|
|
147
|
+
// so pi-core cannot discover the need to compact from its normal checks.
|
|
148
|
+
pi.on("agent_end", (event, ctx) => {
|
|
149
|
+
if (isCodexOutputLimitError(lastAssistantMessage(event))) {
|
|
150
|
+
triggerCodexOutputLimitCompaction(ctx);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
110
153
|
checkAndTrigger(ctx, "auto");
|
|
111
154
|
});
|
|
112
155
|
|