@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-vcc",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Algorithmic conversation compactor for pi - transcript-preserving structured summaries, no LLM calls",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -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
- export function buildOwnCut(branchEntries: any[]): OwnCutResult {
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 marker.
435
+ // Always handle explicit /pi-vcc and Codex output-limit markers.
301
436
  // Otherwise, only handle when user opted in via settings.
302
- if (!isPiVcc && !settings.overrideDefaultCompaction) return;
303
-
304
- const ownCut = buildOwnCut(branchEntries as any[]);
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 (pi-retry handles
519
- // retry via its agent_end handler — avoid duplicate triggerInvisibleContinue)
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 || lastMsg.role !== "assistant") return;
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) — needs to continue.
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: { model?: any; getContextUsage?: () => any; compact?: () => void; ui?: any }, source: string) => {
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
- // Proactive compaction after each agent run
109
- pi.on("agent_end", (_event, ctx) => {
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