@monotykamary/pi-vcc 0.7.4 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -5,17 +5,14 @@ import { registerProactiveThresholdHook } from "./src/hooks/proactive-threshold"
5
5
  import { registerPiVccCommand } from "./src/commands/pi-vcc";
6
6
  import { registerVccRecallCommand } from "./src/commands/vcc-recall";
7
7
  import { registerRecallTool } from "./src/tools/recall";
8
- import { resetInvisibleContinue } from "./src/core/invisible-continue";
8
+ import { registerInvisibleContinue } from "./src/core/invisible-continue";
9
9
 
10
10
  export default (pi: ExtensionAPI) => {
11
11
  scaffoldSettings();
12
+ registerInvisibleContinue(pi);
12
13
  registerBeforeCompactHook(pi);
13
14
  registerProactiveThresholdHook(pi);
14
15
  registerPiVccCommand(pi);
15
16
  registerVccRecallCommand(pi);
16
17
  registerRecallTool(pi);
17
-
18
- pi.on("session_start", () => {
19
- resetInvisibleContinue();
20
- });
21
18
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-vcc",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Algorithmic conversation compactor for pi - transcript-preserving structured summaries, no LLM calls",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -1,176 +1,32 @@
1
- import { Agent } from "@earendil-works/pi-agent-core";
2
-
3
- /**
4
- * Invisible continue — resume the agent loop after compaction without
5
- * injecting any message into context.
6
- *
7
- * Same mechanism as pi-retry: capture the live Agent instance via
8
- * subscribe() monkey-patch, then call agent.prompt([]) to start a
9
- * fresh agent loop that picks up where the compacted transcript left off.
10
- * The LLM sees the exact same message list — no new user prompt.
11
- *
12
- * Used after threshold compaction when the agent would otherwise stall
13
- * (willRetry=false, no queued messages).
14
- */
15
-
16
- let _agent: Agent | null = null;
17
- let _continueInProgress = false;
18
-
19
- // Timestamp of the last completed triggerInvisibleContinue().
20
- // Used by the continue() monkey-patch to detect when triggerInvisibleContinue
21
- // just ran (so it shouldn't fall back to prompt([]) again — the agent
22
- // already continued). This prevents double continuation when both
23
- // pi-retry and pi-vcc are installed.
24
- let _lastInvisibleContinueTime = 0;
25
-
26
- // Monkey-patch Agent.prototype.subscribe to capture the live instance.
27
- // Chain the previous patch (if pi-retry already patched it) so both
28
- // extensions can coexist.
29
- const _prevSubscribe = Agent.prototype.subscribe;
30
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
- Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
32
- _agent = this;
33
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- return (_prevSubscribe as any).apply(this, args);
35
- };
36
-
37
- // Monkey-patch continue() so that after compaction, when the rebuilt
38
- // context ends with an assistant message, the agent loop can still
39
- // continue. Without this patch, the session's continue() call throws
40
- // "Cannot continue from message role: assistant" and the agent loop
41
- // exits — leaving mid-task work unfinished.
42
- //
43
- // Note (pi 0.79+): Agent.continue() now drains queued steering/follow-up
44
- // messages before throwing, so this throw path only fires when there are
45
- // genuinely no queued messages — the prompt([]) fallback is still correct.
46
- //
47
- // The fix: when continue() would throw because the last message is an
48
- // assistant, fall back to prompt([]) instead. prompt([]) doesn't check
49
- // the last message role — it starts a fresh agent loop with the current
50
- // context. This is equivalent to the invisible-continue mechanism
51
- // (same call the extension fires from session_compact), but triggered
52
- // from the session's own continue() path so the while loop stays alive.
53
- //
54
- // Chains the previous patch (pi-retry, pi-invisible-continue) so all
55
- // mutexes are respected.
56
- const _prevContinue = Agent.prototype.continue;
57
- Agent.prototype.continue = function (this: Agent): Promise<void> {
58
- const self = this;
59
- return (async () => {
60
- while (_continueInProgress) {
61
- await new Promise(r => setTimeout(r, 10));
62
- }
63
- try {
64
- return await (_prevContinue as any).call(self);
65
- } catch (e: unknown) {
66
- const msg = e instanceof Error ? e.message : String(e);
67
-
68
- // After compaction, the rebuilt context often ends with an assistant
69
- // message. The original continue() throws "Cannot continue from
70
- // message role: assistant". Instead of swallowing the error and
71
- // letting the agent loop die (which leaves mid-task work unfinished),
72
- // fall back to prompt([]). This restarts the agent loop with the
73
- // compacted context — the LLM picks up where it left off.
74
- //
75
- // prompt([]) is safe regardless of last message role. The agent
76
- // processes the current context and continues.
77
- //
78
- // We only do this when the agent is NOT currently processing
79
- // (no other triggerInvisibleContinue in flight) to avoid races.
80
- if (msg.includes("Cannot continue from message role") ||
81
- msg.includes("Cannot continue from an assistant message")) {
82
- // Check stopReason of the last message — only continue if
83
- // the agent was mid-task (not a clean stop or user abort).
84
- const lastMsg = self.state.messages[self.state.messages.length - 1];
85
- if (lastMsg?.role === "assistant" &&
86
- lastMsg.stopReason !== "stop" &&
87
- lastMsg.stopReason !== "aborted") {
88
- // Agent was mid-task — fall through to prompt([])
89
- // (after the error-handling block)
90
- } else {
91
- // Agent finished cleanly or was aborted — don't continue.
92
- // The session loop should exit (task complete).
93
- return;
94
- }
95
- // Fall back to prompt([]) to actually continue the agent.
96
- // The while loop in _runAgentPrompt stays alive because prompt([])
97
- // runs the agent (which emits events, updates _lastAssistantMessage,
98
- // etc.).
99
- //
100
- // Guard: if triggerInvisibleContinue() just completed (within the
101
- // last 500ms), the agent already continued — skip the fallback to
102
- // avoid double continuation. This happens when both pi-retry and
103
- // pi-vcc are installed: pi-retry's triggerInvisibleContinue runs
104
- // first, then the session's continue() wrapper unblocks and would
105
- // fall back to prompt([]) again.
106
- if (!_continueInProgress && Date.now() - _lastInvisibleContinueTime > 500) {
107
- _continueInProgress = true;
108
- try {
109
- await self.prompt([]);
110
- } catch {
111
- // Agent already processing or other transient error
112
- } finally {
113
- _continueInProgress = false;
114
- }
115
- }
116
- return;
117
- }
118
-
119
- if (msg.includes("Agent is already processing")) {
120
- // Another extension is driving the agent — wait it out.
121
- return;
122
- }
123
- throw e;
124
- }
125
- })();
126
- };
127
-
128
- function sleep(ms: number): Promise<void> {
129
- return new Promise(resolve => setTimeout(resolve, ms));
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const VCC_RESUME_CUSTOM_TYPE = "pi-vcc:resume-after-compaction";
4
+
5
+ export function registerInvisibleContinue(pi: ExtensionAPI): void {
6
+ pi.on("context", (event) => {
7
+ const messages = event.messages.filter((message) => {
8
+ if (message.role !== "custom") return true;
9
+ return message.customType !== VCC_RESUME_CUSTOM_TYPE;
10
+ });
11
+ if (messages.length !== event.messages.length) return { messages };
12
+ });
130
13
  }
131
14
 
132
15
  /**
133
- * Fire an invisible continue after the agent becomes idle.
134
- *
135
- * MUST NOT be awaited — it schedules work that runs after the current
136
- * session loop iteration finishes. Awaiting would deadlock inside the
137
- * session's _handlePostAgentRun → _runAutoCompaction call stack.
16
+ * Queue a hidden follow-up through AgentSession. This keeps Pi's session-level
17
+ * busy state, retry, compaction, and native steer/follow-up queues coherent.
138
18
  */
139
- export function triggerInvisibleContinue(): void {
140
- if (!_agent) return;
141
- if (_continueInProgress) return;
142
- _continueInProgress = true;
143
-
144
- const run = async () => {
145
- try {
146
- // Wait for the current run to finish (activeRun resolves in
147
- // finishRun() after agent_end listeners return).
148
- await _agent!.waitForIdle();
149
-
150
- // Small delay to ensure the session's while-loop has exited
151
- // after _handlePostAgentRun returns false.
152
- await sleep(50);
153
-
154
- try {
155
- // Await so _continueInProgress stays true for the full cycle.
156
- await _agent!.prompt([]);
157
- } catch {
158
- // Ignore — if prompt throws, something else is driving.
159
- }
160
- } finally {
161
- _continueInProgress = false;
162
- // Record completion time so the continue() monkey-patch can
163
- // detect that an invisible continue just ran and avoid firing
164
- // a duplicate prompt([]) (RC7: double continuation guard).
165
- _lastInvisibleContinueTime = Date.now();
166
- }
167
- };
168
-
169
- void run();
170
- }
171
-
172
- /** Reset state on new session. */
173
- export function resetInvisibleContinue(): void {
174
- _continueInProgress = false;
175
- _lastInvisibleContinueTime = 0;
19
+ export function triggerInvisibleContinue(pi: ExtensionAPI): void {
20
+ pi.sendMessage(
21
+ {
22
+ customType: VCC_RESUME_CUSTOM_TYPE,
23
+ content: [],
24
+ display: false,
25
+ details: undefined,
26
+ },
27
+ {
28
+ triggerTurn: true,
29
+ deliverAs: "followUp",
30
+ },
31
+ );
176
32
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from "../core/codex-output-limit";
12
12
  import { loadSettings, type PiVccSettings } from "../core/settings";
13
13
  import { triggerInvisibleContinue } from "../core/invisible-continue";
14
+ import { isProactiveTriggerActive } from "./proactive-threshold";
14
15
  import { countPiVccCompactionsFromSession, ordinalSuffix } from "../core/compaction-count";
15
16
  import type { PiVccCompactionDetails } from "../details";
16
17
 
@@ -25,6 +26,7 @@ export interface CompactionStats {
25
26
  let lastStats: CompactionStats | null = null;
26
27
  let lastCompactWasPiVcc = false;
27
28
  let lastCompactWasCodexRecovery = false;
29
+ let lastCompactWasProactive = false;
28
30
  let lastCompactHandledByVcc = false;
29
31
  export const getLastCompactionStats = () => lastStats;
30
32
 
@@ -98,6 +100,19 @@ interface EntryWithMessage {
98
100
  message: { role: string; content: unknown };
99
101
  }
100
102
 
103
+ const isHiddenEmptyCustomMessage = (message: unknown): boolean => {
104
+ if (!message || typeof message !== "object") return false;
105
+ const candidate = message as {
106
+ role?: unknown;
107
+ content?: unknown;
108
+ display?: unknown;
109
+ };
110
+ if (candidate.role !== "custom" || candidate.display !== false) return false;
111
+ return candidate.content === "" || (
112
+ Array.isArray(candidate.content) && candidate.content.length === 0
113
+ );
114
+ };
115
+
101
116
  export type OwnCutCancelReason =
102
117
  | "no_live_messages"
103
118
  | "too_few_live_messages";
@@ -265,7 +280,11 @@ export function buildOwnCut(branchEntries: any[], options?: { maxKeptTokens?: nu
265
280
  for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
266
281
  const e = branchEntries[i];
267
282
  if (e.type === "compaction") continue;
268
- if (e.type === "message" && e.message) {
283
+ if (
284
+ e.type === "message" &&
285
+ e.message &&
286
+ !isHiddenEmptyCustomMessage(e.message)
287
+ ) {
269
288
  liveMessages.push({ entry: e, message: e.message });
270
289
  }
271
290
  }
@@ -275,7 +294,11 @@ export function buildOwnCut(branchEntries: any[], options?: { maxKeptTokens?: nu
275
294
  if (!foundKept && e.id === lastKeptId) foundKept = true;
276
295
  if (!foundKept) continue;
277
296
  if (e.type === "compaction") continue;
278
- if (e.type === "message" && e.message) {
297
+ if (
298
+ e.type === "message" &&
299
+ e.message &&
300
+ !isHiddenEmptyCustomMessage(e.message)
301
+ ) {
279
302
  liveMessages.push({ entry: e, message: e.message });
280
303
  }
281
304
  }
@@ -413,6 +436,27 @@ export const shouldResumeAfterCompaction = (
413
436
  return true;
414
437
  };
415
438
 
439
+ export type CompactionCompletionMetadata = {
440
+ reason?: "manual" | "threshold" | "overflow";
441
+ willRetry?: boolean;
442
+ };
443
+
444
+ export function shouldTriggerResumeForCompaction(
445
+ event: CompactionCompletionMetadata,
446
+ sessionIsIdle: boolean,
447
+ wasProactiveCompaction: boolean,
448
+ wasCodexRecoveryCompaction: boolean,
449
+ ): boolean {
450
+ if (event.willRetry) return true;
451
+ if (wasCodexRecoveryCompaction || wasProactiveCompaction) return true;
452
+ if (event.reason === "manual" || event.reason === "overflow") return false;
453
+ if (event.reason === "threshold") return !sessionIsIdle;
454
+
455
+ // Pi before 0.79.10 did not expose compaction reason metadata. Be
456
+ // conservative there: an idle session may be manual or pre-prompt.
457
+ return !sessionIsIdle;
458
+ }
459
+
416
460
  export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
417
461
  pi.on("session_before_compact", (event, ctx) => {
418
462
  const { preparation, branchEntries, customInstructions } = event;
@@ -626,6 +670,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
626
670
  lastCompactWasPiVcc = isPiVcc;
627
671
  lastCompactWasCodexRecovery =
628
672
  isCodexOutputLimitCompaction || isCodexContextOverflowCompaction;
673
+ lastCompactWasProactive = isProactiveTriggerActive();
629
674
  lastCompactHandledByVcc = true;
630
675
 
631
676
  // Signal to neuralwatt-mcr that pi-vcc is handling compaction
@@ -655,8 +700,10 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
655
700
  // Only act when pi-vcc drove the compaction
656
701
  if (!lastCompactHandledByVcc) return;
657
702
  const wasCodexRecoveryCompaction = lastCompactWasCodexRecovery;
703
+ const wasProactiveCompaction = lastCompactWasProactive;
658
704
  lastCompactHandledByVcc = false;
659
705
  lastCompactWasCodexRecovery = false;
706
+ lastCompactWasProactive = false;
660
707
 
661
708
  // Fire success toast for /compact path only (delayed to let UI settle).
662
709
  // /pi-vcc path uses its own onComplete callback in the command handler.
@@ -708,11 +755,29 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
708
755
  break;
709
756
  }
710
757
  }
711
- if (!shouldResumeAfterCompaction(lastMsg, wasCodexRecoveryCompaction)) return;
712
-
713
- // Agent was mid-task (toolUse or length). The Codex recovery marker
714
- // additionally permits its known provider errors to continue.
715
- triggerInvisibleContinue();
758
+ // A core-owned retry also gets a marker. Agent.continue() drains the
759
+ // queued marker before checking the assistant-tail role, so no Agent
760
+ // prototype fallback is needed.
761
+ const completion = event as CompactionCompletionMetadata;
762
+ const legacyActiveCompaction =
763
+ completion.reason === undefined &&
764
+ completion.willRetry === undefined &&
765
+ !ctx.isIdle();
766
+ if (
767
+ !completion.willRetry &&
768
+ !legacyActiveCompaction &&
769
+ !shouldResumeAfterCompaction(lastMsg, wasCodexRecoveryCompaction)
770
+ ) return;
771
+ if (!shouldTriggerResumeForCompaction(
772
+ completion,
773
+ ctx.isIdle(),
774
+ wasProactiveCompaction,
775
+ wasCodexRecoveryCompaction,
776
+ )) return;
777
+
778
+ // Queue through Pi's native follow-up path so a concurrent user prompt
779
+ // wins cleanly instead of racing a low-level Agent.prompt([]) call.
780
+ triggerInvisibleContinue(pi);
716
781
  } catch {
717
782
  // Non-critical — if context inspection fails, don't block compaction
718
783
  }