pi-blackhole 0.4.0 → 0.4.1

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.
@@ -7,6 +7,7 @@
7
7
  "compaction": "auto",
8
8
  "compactionEngine": "blackhole",
9
9
  "tailBehavior": "minimal",
10
+ "midRunCompaction": "resume",
10
11
 
11
12
  "model": {
12
13
  "_comment": "Base model (last fallback before session model).",
@@ -110,6 +111,7 @@
110
111
  " compaction (auto|manual|off) — how compaction triggers",
111
112
  " compactionEngine (blackhole|pi-default) — who handles it",
112
113
  " tailBehavior (pi-default|minimal) — how much stays visible",
114
+ " midRunCompaction (resume|pause|off) — threshold check during tool loops",
113
115
  " sessionFallback (true|false) — if false, skip session model as last-resort fallback",
114
116
  " dropperPressureThreshold (0-1, default 0.70) — pressure relief valve: fraction of reflectorInputMaxTokens where dropper fires even without new data",
115
117
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-blackhole",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "packageManager": "pnpm@11.2.2",
5
5
  "description": "Unified compaction + observational memory extension for Pi — compresses conversation context while preserving durable observations and reflections",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import type { Runtime } from "../om/runtime.js";
11
- import { PI_VCC_COMPACT_INSTRUCTION, notifyMigrationReminder } from "../hooks/before-compact";
11
+ import { PI_VCC_COMPACT_INSTRUCTION, notifyMigrationReminder, formatCompactionStats } from "../hooks/before-compact";
12
12
  import { saveUnifiedConfig, configPath } from "../core/unified-config.js";
13
13
  import { readPendingState, clearPendingState, hasPendingData } from "../om/pending.js";
14
14
  import { createConfigureOverlay } from "../om/configure-overlay.js";
@@ -19,11 +19,6 @@ import {
19
19
  } from "../om/ledger/index.js";
20
20
  import { handleCleanup } from "./cleanup.js";
21
21
 
22
- const formatTokens = (n: number): string => {
23
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
24
- return String(n);
25
- };
26
-
27
22
  export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
28
23
  const prefixMatch = (value: string, prefix: string): boolean => {
29
24
  return value.toLowerCase().startsWith(prefix.toLowerCase());
@@ -100,6 +95,21 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
100
95
  return;
101
96
  }
102
97
 
98
+ // Warn if input starts with a known subcommand but isn't an exact match.
99
+ // Prevents "/blackhole configure foo" from silently becoming a follow-up.
100
+ const SUBCOMMAND_NAMES = ["configure", "cleanup", "om-off", "om-on"];
101
+ const nearMiss = SUBCOMMAND_NAMES.find(
102
+ name => trimmed.toLowerCase().startsWith(name.toLowerCase()) && trimmed.length > name.length
103
+ );
104
+ if (nearMiss) {
105
+ ctx.ui.notify(`/blackhole ${nearMiss} accepts no arguments. Did you mean \"/blackhole ${nearMiss}\"?`, "warning");
106
+ return;
107
+ }
108
+
109
+ // Extract follow-up prompt: everything after the subcommand check
110
+ // that isn't a known subcommand is treated as follow-up text.
111
+ const followUpPrompt = trimmed ? trimmed : null;
112
+
103
113
  // If compaction is manual (or legacy noAutoCompact): flush pending OM entries
104
114
  // into the branch before compacting so the summary includes accumulated memory.
105
115
  if (runtime.config.compaction === "manual" && hasPendingData(sessionId)) {
@@ -137,14 +147,18 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
137
147
  onComplete: () => {
138
148
  const stats = runtime.compactionStats;
139
149
  if (stats) {
140
- ctx.ui.notify(
141
- `blackhole: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
142
- "info",
143
- );
150
+ ctx.ui.notify(formatCompactionStats(stats), "info");
144
151
  } else {
145
152
  ctx.ui.notify("Compacted with blackhole", "info");
146
153
  }
147
154
  notifyMigrationReminder(sessionId, (msg, level) => ctx.ui.notify(msg, level as any));
155
+
156
+ // Fire follow-up prompt after compaction completes
157
+ if (followUpPrompt) {
158
+ try {
159
+ void Promise.resolve(pi.sendUserMessage(followUpPrompt)).catch(() => {});
160
+ } catch {}
161
+ }
148
162
  },
149
163
  onError: (err) => {
150
164
  if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
@@ -31,7 +31,6 @@ export const wrapLongLines = (text: string, maxChars = TUI_SAFE_LINE_CHARS): str
31
31
  export const capBrief = (text: string): string => {
32
32
  const lines = text.split("\n");
33
33
  if (lines.length <= BRIEF_MAX_LINES) return text;
34
- const omitted = lines.length - BRIEF_MAX_LINES;
35
34
  const kept = lines.slice(-BRIEF_MAX_LINES);
36
35
  // Find first section header to avoid cutting mid-section
37
36
  let firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
@@ -42,6 +41,7 @@ export const capBrief = (text: string): string => {
42
41
  if (anyAnchor > 0) firstHeader = anyAnchor;
43
42
  }
44
43
  const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
44
+ const omitted = lines.length - clean.length;
45
45
  return `...(${omitted} earlier lines omitted)\n\n${clean.join("\n")}`;
46
46
  };
47
47
 
@@ -62,6 +62,14 @@ export interface UnifiedConfig {
62
62
  * "pi-default" — Pi's built-in summarization */
63
63
  compactionEngine: "blackhole" | "pi-default";
64
64
 
65
+ /** Mid-run auto-compaction (turn_end trigger, fires while the agent is still
66
+ * executing tool loops — agent_end alone never fires during long runs).
67
+ * "resume" — compact at threshold and inject a resume message so the agent
68
+ * continues the task (default)
69
+ * "pause" — compact at threshold but stop; user continues manually
70
+ * "off" — only evaluate the threshold when the agent finishes a run */
71
+ midRunCompaction: "resume" | "pause" | "off";
72
+
65
73
  /** How much recent transcript to keep visible after compaction.
66
74
  * "pi-default" — use Pi's firstKeptEntryId (respects Pi's keepRecentTokens)
67
75
  * "minimal" — keep only last user message (current agressive pi-vcc behavior)
@@ -149,6 +157,7 @@ export const DEFAULTS: UnifiedConfig = {
149
157
  compaction: "auto",
150
158
  compactionEngine: "blackhole",
151
159
  tailBehavior: "minimal",
160
+ midRunCompaction: "resume",
152
161
 
153
162
  observeAfterTokens: 15_000,
154
163
  reflectAfterTokens: 25_000,
@@ -175,6 +184,7 @@ const THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "
175
184
  const COMPACTION_VALUES = ["auto", "manual", "off"] as const;
176
185
  const COMPACTION_ENGINE_VALUES = ["blackhole", "pi-default"] as const;
177
186
  const TAIL_BEHAVIOR_VALUES = ["pi-default", "minimal"] as const;
187
+ const MID_RUN_COMPACTION_VALUES = ["resume", "pause", "off"] as const;
178
188
 
179
189
  function isCompaction(v: unknown): v is "auto" | "manual" | "off" {
180
190
  return typeof v === "string" && (COMPACTION_VALUES as readonly string[]).includes(v);
@@ -185,6 +195,9 @@ function isCompactionEngine(v: unknown): v is "blackhole" | "pi-default" {
185
195
  function isTailBehavior(v: unknown): v is "pi-default" | "minimal" {
186
196
  return typeof v === "string" && (TAIL_BEHAVIOR_VALUES as readonly string[]).includes(v);
187
197
  }
198
+ function isMidRunCompaction(v: unknown): v is "resume" | "pause" | "off" {
199
+ return typeof v === "string" && (MID_RUN_COMPACTION_VALUES as readonly string[]).includes(v);
200
+ }
188
201
 
189
202
  function isRecord(v: unknown): v is Record<string, unknown> {
190
203
  return typeof v === "object" && v !== null;
@@ -234,6 +247,7 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
234
247
  if (isCompaction(raw.compaction)) c.compaction = raw.compaction;
235
248
  if (isCompactionEngine(raw.compactionEngine)) c.compactionEngine = raw.compactionEngine;
236
249
  if (isTailBehavior(raw.tailBehavior)) c.tailBehavior = raw.tailBehavior;
250
+ if (isMidRunCompaction(raw.midRunCompaction)) c.midRunCompaction = raw.midRunCompaction;
237
251
 
238
252
  // Booleans — pi-vcc
239
253
  if (typeof raw.overrideDefaultCompaction === "boolean") c.overrideDefaultCompaction = raw.overrideDefaultCompaction;
@@ -48,6 +48,37 @@ const formatTokens = (n: number): string => {
48
48
  return String(n);
49
49
  };
50
50
 
51
+ export interface CompactionStats {
52
+ summarized: number;
53
+ kept: number;
54
+ keptTokensEst: number;
55
+ compactAll: boolean;
56
+ totalUserTurns: number;
57
+ keptUserTurns: number;
58
+ requestedKeepUserTurns: number;
59
+ keepUserTurnsExplicit: boolean;
60
+ keepFallbackToCompactAll: boolean;
61
+ smartKeepAdjusted: boolean;
62
+ smartFromKeep: number;
63
+ }
64
+
65
+ /**
66
+ * Format compaction stats for user-visible notification.
67
+ * Example output:
68
+ * blackhole: 6 source entries processed; tail kept 1/4 user turns (~0.5k tok).
69
+ */
70
+ export const formatCompactionStats = (stats: CompactionStats): string => {
71
+ const parts: string[] = [`${stats.summarized} source entries processed`];
72
+ parts.push(`tail kept ${stats.keptUserTurns}/${stats.totalUserTurns} user turns`);
73
+ if (stats.smartKeepAdjusted) {
74
+ parts.push(`smart keep:${stats.smartFromKeep}→${stats.keptUserTurns}`);
75
+ }
76
+ if (stats.keepFallbackToCompactAll) {
77
+ parts.push(`compact-all`);
78
+ }
79
+ return `blackhole: ${parts.join("; ")} (~${formatTokens(stats.keptTokensEst)} tok).`;
80
+ };
81
+
51
82
  const dbg = (debug: boolean, data: Record<string, unknown>) => {
52
83
  if (!debug) return;
53
84
  try { writeFileSync("/tmp/pi-blackhole-debug.json", JSON.stringify(data, null, 2)); } catch {}
@@ -291,9 +322,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
291
322
  // Determine effective tail behavior for buildOwnCut
292
323
  // Both /blackhole and auto-triggered default to "minimal" (aggressive cut);
293
324
  // users can opt into "pi-default" (gentler) by setting tailBehavior in config.
294
- const effectiveTailBehavior = isPiVcc
295
- ? (omRuntime.config.tailBehavior ?? "minimal")
296
- : (omRuntime.config.tailBehavior ?? "minimal");
325
+ const effectiveTailBehavior = omRuntime.config.tailBehavior ?? "minimal";
297
326
 
298
327
  trace("before_compact.tail_behavior", {
299
328
  effectiveTailBehavior,
@@ -400,10 +429,22 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
400
429
  }, 0);
401
430
  return sum;
402
431
  }, 0);
432
+ const totalUserTurns = (branchEntries as any[]).filter((e: any) => e.type === "message" && e.message?.role === "user").length;
433
+ const keptUserTurns = ownCut.compactAll
434
+ ? 0
435
+ : (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message" && e.message?.role === "user").length;
403
436
  omRuntime.compactionStats = {
404
437
  summarized: agentMessages.length,
405
438
  kept: keptEntries.length,
406
439
  keptTokensEst: Math.round(keptChars / 4),
440
+ compactAll: ownCut.compactAll,
441
+ totalUserTurns,
442
+ keptUserTurns,
443
+ requestedKeepUserTurns: 1,
444
+ keepUserTurnsExplicit: false,
445
+ keepFallbackToCompactAll: ownCut.compactAll,
446
+ smartKeepAdjusted: false,
447
+ smartFromKeep: 1,
407
448
  };
408
449
 
409
450
  const summary = compile({
@@ -491,10 +532,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
491
532
  const sessionId = ctx.sessionManager.getSessionId();
492
533
  setTimeout(() => {
493
534
  try {
494
- ctx?.ui?.notify?.(
495
- `blackhole: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
496
- "info",
497
- );
535
+ ctx?.ui?.notify?.(formatCompactionStats(stats), "info");
498
536
  notifyMigrationReminder(sessionId, (msg, level) => ctx?.ui?.notify?.(msg, level as any));
499
537
  } catch {}
500
538
  }, 500);
@@ -26,6 +26,36 @@ function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" |
26
26
  }
27
27
  }
28
28
 
29
+ /**
30
+ * Message injected after a mid-run compaction so the agent resumes the task
31
+ * instead of stopping (ctx.compact() aborts the in-flight run and Pi does not
32
+ * auto-continue).
33
+ */
34
+ export const MID_RUN_RESUME_CUSTOM_TYPE = "blackhole-resume";
35
+ export const MID_RUN_RESUME_MESSAGE =
36
+ "Context was auto-compacted mid-task to stay under the token threshold. "
37
+ + "The summary above preserves prior progress. Continue the task from where you left off.";
38
+
39
+ /**
40
+ * Shared config gating for auto-compaction (agent_end and turn_end paths).
41
+ * Returns null when compaction may proceed, or a skip reason string.
42
+ */
43
+ function autoCompactionSkipReason(runtime: Runtime): string | null {
44
+ if (runtime.config.compaction === "off") return "compaction_off";
45
+ if (runtime.config.compaction === "manual") return "compaction_manual";
46
+ if (runtime.config.compactionEngine === "pi-default") return "compactionEngine_pi_default";
47
+ // NOTE: memory does not gate compaction — memory:false + compaction:auto = compact without OM
48
+
49
+ // LEGACY: old config key guards — only apply when new keys are absent (unmigrated config)
50
+ if (runtime.config.compaction === undefined && runtime.config.compactionEngine === undefined) {
51
+ if (runtime.config.passive === true) return "passive";
52
+ if (runtime.config.noAutoCompact === true) return "noAutoCompact";
53
+ // Don't force Pi to compact unless the user explicitly opted into blackhole's pipeline.
54
+ if (runtime.config.overrideDefaultCompaction === false) return "overrideDefaultCompaction_false";
55
+ }
56
+ return null;
57
+ }
58
+
29
59
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
30
60
  pi.on("agent_start", () => {
31
61
  // Reset the info gate — allow one info notification during the new turn.
@@ -49,6 +79,106 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
49
79
  throw error;
50
80
  }
51
81
  });
82
+
83
+ // Mid-run trigger: turn_end fires after every assistant-message + tool-execution
84
+ // cycle while the agent is still working. agent_end only fires when the run
85
+ // exits, so during long tool loops the threshold would otherwise never be
86
+ // evaluated (the configured compactAfterTokens could be exceeded many times
87
+ // over before compaction had a chance to run).
88
+ pi.on("turn_end", (_event: any, ctx: any) => {
89
+ try {
90
+ handleTurnEnd(ctx, runtime, pi);
91
+ } catch (error) {
92
+ if (isStaleExtensionContextError(error)) return;
93
+ throw error;
94
+ }
95
+ });
96
+ }
97
+
98
+ function handleTurnEnd(ctx: any, runtime: Runtime, pi: ExtensionAPI): void {
99
+ runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
100
+ const dbg = (ev: string, d?: Record<string, unknown>) => debugLog(ev, d, runtime.config.debugLog === true);
101
+
102
+ const mode = runtime.config.midRunCompaction ?? "resume";
103
+ if (mode === "off") {
104
+ dbg("compaction_trigger.turn_end.skip", { reason: "midRunCompaction_off" });
105
+ return;
106
+ }
107
+ const skipReason = autoCompactionSkipReason(runtime);
108
+ if (skipReason) {
109
+ dbg("compaction_trigger.turn_end.skip", { reason: skipReason });
110
+ return;
111
+ }
112
+ if (runtime.compactInFlight) {
113
+ dbg("compaction_trigger.turn_end.skip", { reason: "compactInFlight" });
114
+ return;
115
+ }
116
+
117
+ const entries = ctx.sessionManager.getBranch() as Entry[];
118
+ const tokens = rawTokensSinceLastCompaction(entries);
119
+ if (tokens < runtime.config.compactAfterTokens) {
120
+ // Pressure relieved (a compaction ran) — lift any failure suspension.
121
+ runtime.midRunCompactionSuspended = false;
122
+ return;
123
+ }
124
+ if (runtime.midRunCompactionSuspended) {
125
+ // A previous mid-run attempt failed/cancelled at this pressure level.
126
+ // Re-triggering every turn would thrash (each attempt aborts the run).
127
+ // Stay suspended until a compaction lowers pressure below the threshold.
128
+ dbg("compaction_trigger.turn_end.skip", { reason: "suspended_after_failure", tokens });
129
+ return;
130
+ }
131
+
132
+ const hasUI = ctx.hasUI;
133
+ const ui = ctx.ui;
134
+ dbg("compaction_trigger.turn_end.threshold_reached", { tokens, threshold: runtime.config.compactAfterTokens, mode });
135
+ runtime.tryEmitInfo(hasUI, ui,
136
+ `Observational memory: compaction threshold reached mid-run (~${tokens.toLocaleString()} tokens); compacting${mode === "resume" ? " and resuming" : ""}`);
137
+
138
+ // ctx.compact() aborts the in-flight agent run before compacting — that is
139
+ // the intended trade: turn_end is a clean boundary (tool results are already
140
+ // persisted; at most one just-started LLM call is wasted).
141
+ runtime.compactInFlight = true;
142
+ ctx.compact({
143
+ onComplete: (_result: any) => {
144
+ runtime.compactInFlight = false;
145
+ dbg("compaction_trigger.turn_end.onComplete", { mode });
146
+ if (mode === "resume") {
147
+ try {
148
+ pi.sendMessage(
149
+ { customType: MID_RUN_RESUME_CUSTOM_TYPE, content: MID_RUN_RESUME_MESSAGE, display: true },
150
+ { triggerTurn: true },
151
+ );
152
+ } catch (error) {
153
+ if (!isStaleExtensionContextError(error)) throw error;
154
+ }
155
+ }
156
+ runtime.tryEmitInfo(hasUI, ui, "Observational memory: mid-run compaction complete");
157
+ },
158
+ onError: (error: { message: string }) => {
159
+ runtime.compactInFlight = false;
160
+ // Don't retry at this pressure level — the next attempt would abort the
161
+ // run again just to fail the same way. Cleared when pressure drops.
162
+ runtime.midRunCompactionSuspended = true;
163
+ dbg("compaction_trigger.turn_end.onError", { message: error?.message ?? String(error) });
164
+ if (error.message !== "Compaction cancelled") {
165
+ notifySafely(hasUI, ui, `Observational memory: mid-run compaction failed: ${error.message}`, "error");
166
+ }
167
+ // ctx.compact() already aborted the run. In resume mode the agent must
168
+ // continue regardless of the failed compaction — otherwise it stalls
169
+ // mid-task with no one at the wheel.
170
+ if (mode === "resume") {
171
+ try {
172
+ pi.sendMessage(
173
+ { customType: MID_RUN_RESUME_CUSTOM_TYPE, content: MID_RUN_RESUME_MESSAGE, display: true },
174
+ { triggerTurn: true },
175
+ );
176
+ } catch (sendError) {
177
+ if (!isStaleExtensionContextError(sendError)) throw sendError;
178
+ }
179
+ }
180
+ },
181
+ });
52
182
  }
53
183
 
54
184
  function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
@@ -70,39 +200,12 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
70
200
  compactAfterTokens: runtime.config.compactAfterTokens,
71
201
  });
72
202
 
73
- // NEW: Unified compaction guards
74
- if (runtime.config.compaction === "off") {
75
- dbg("compaction_trigger.skip", { reason: "compaction_off" });
203
+ // Unified + legacy compaction guards (shared with the turn_end path)
204
+ const skipReason = autoCompactionSkipReason(runtime);
205
+ if (skipReason) {
206
+ dbg("compaction_trigger.skip", { reason: skipReason });
76
207
  return;
77
208
  }
78
- if (runtime.config.compaction === "manual") {
79
- dbg("compaction_trigger.skip", { reason: "compaction_manual" });
80
- return;
81
- }
82
- if (runtime.config.compactionEngine === "pi-default") {
83
- dbg("compaction_trigger.skip", { reason: "compactionEngine_pi_default" });
84
- return;
85
- }
86
- // NOTE: memory no longer gates compaction — memory:false + compaction:auto = compact without OM
87
-
88
- // LEGACY: old config key guards — only apply when new keys are absent (unmigrated config)
89
- if (runtime.config.compaction === undefined && runtime.config.compactionEngine === undefined) {
90
- if (runtime.config.passive === true) {
91
- dbg("compaction_trigger.skip", { reason: "passive" });
92
- return;
93
- }
94
- if (runtime.config.noAutoCompact === true) {
95
- dbg("compaction_trigger.skip", { reason: "noAutoCompact" });
96
- return;
97
- }
98
- // Don't force Pi to compact unless the user explicitly opted into blackhole's pipeline.
99
- // When overrideDefaultCompaction is false (default), blackhole stays out of the way
100
- // and lets Pi handle its own compaction naturally.
101
- if (runtime.config.overrideDefaultCompaction === false) {
102
- dbg("compaction_trigger.skip", { reason: "overrideDefaultCompaction_false" });
103
- return;
104
- }
105
- }
106
209
  if (runtime.compactInFlight) {
107
210
  dbg("compaction_trigger.skip", { reason: "compactInFlight" });
108
211
  return;
@@ -44,6 +44,8 @@ const FIELDS: FieldDef[] = [
44
44
  helpText: "blackhole=structured summary+OM, pi-default=built-in Pi summarization" },
45
45
  { key: "tailBehavior", label: "Visible tail", type: "enum", section: "Compaction", enumValues: ["minimal", "pi-default"],
46
46
  helpText: "minimal=keep last user message only (default), pi-default=keep Pi's preserved visible context" },
47
+ { key: "midRunCompaction", label: "Mid-run compaction", type: "enum", section: "Compaction", enumValues: ["resume", "pause", "off"],
48
+ helpText: "resume=compact at threshold during tool loops and continue (default), pause=compact and stop, off=only check when run ends" },
47
49
  { key: "compactAfterTokens", label: "Auto-compact threshold", type: "number", section: "Compaction",
48
50
  helpText: "Token count that triggers auto-compaction when reached" },
49
51
 
@@ -15,7 +15,7 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
15
15
  import type { ConfiguredModel } from "./config.js";
16
16
  import { debugLog, withDebugLogContext } from "./debug-log.js";
17
17
  import { type ResolveResult, type Runtime } from "./runtime.js";
18
- import { isRetryableError } from "./retryable-error.js";
18
+ import { isRetryableError, isStaleExtensionContextError } from "./retryable-error.js";
19
19
  import { effectiveContextWindow } from "./model-budget.js";
20
20
  import { serializeSourceAddressedBranchEntries } from "./serialize.js";
21
21
 
@@ -430,12 +430,24 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
430
430
  if (runtime.isConsolidationRetryGated()) return;
431
431
 
432
432
  // Load and validate cursors from pending file (once per session; re-load on fork)
433
- const sessionId = ctx.sessionManager.getSessionId();
433
+ let sessionId: string;
434
+ try {
435
+ sessionId = ctx.sessionManager.getSessionId();
436
+ } catch (error) {
437
+ if (isStaleExtensionContextError(error)) return;
438
+ throw error;
439
+ }
434
440
  if (runtime.cursorsLoadedSessionId !== sessionId) {
435
441
  if (typeof runtime.loadCursorsFromPending === "function") {
436
442
  runtime.loadCursorsFromPending(sessionId);
437
443
  }
438
- const entries = ctx.sessionManager.getBranch() as Entry[];
444
+ let entries: Entry[];
445
+ try {
446
+ entries = ctx.sessionManager.getBranch() as Entry[];
447
+ } catch (error) {
448
+ if (isStaleExtensionContextError(error)) return;
449
+ throw error;
450
+ }
439
451
  validateCursors(entries, runtime);
440
452
  runtime.cursorsLoadedSessionId = sessionId;
441
453
  const c = runtime.cursors ?? {};
@@ -446,7 +458,13 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
446
458
  });
447
459
  }
448
460
 
449
- const entries = ctx.sessionManager.getBranch() as Entry[];
461
+ let entries: Entry[];
462
+ try {
463
+ entries = ctx.sessionManager.getBranch() as Entry[];
464
+ } catch (error) {
465
+ if (isStaleExtensionContextError(error)) return;
466
+ throw error;
467
+ }
450
468
  // In manual mode, the branch has no OM markers — pending state provides
451
469
  // pool fullness and new‑data visibility for reflector/dropper checks.
452
470
  const pending = runtime.config.noAutoCompact ? readPendingState(sessionId) : undefined;
@@ -509,7 +527,16 @@ export async function runConsolidationPipeline(
509
527
  }
510
528
 
511
529
  // Flush cursors to pending file after all stages complete (non‑blocking)
512
- const sessionId = ctx.sessionManager.getSessionId();
530
+ let sessionId: string;
531
+ try {
532
+ sessionId = ctx.sessionManager.getSessionId();
533
+ } catch (error) {
534
+ if (isStaleExtensionContextError(error)) {
535
+ debugLog("pipeline.stale_ctx", { error: String(error) });
536
+ return;
537
+ }
538
+ throw error;
539
+ }
513
540
  runtime.scheduleCursorFlush(sessionId);
514
541
  const c = runtime.cursors ?? {};
515
542
  debugLog("cursor.saved", {
@@ -527,8 +554,18 @@ async function runObserverStage(
527
554
  ctx: ConsolidationCtx,
528
555
  resolveModel: (stage: "observer") => Promise<ResolvedModel | undefined>,
529
556
  ): Promise<StageOutcome> {
530
- const entries = ctx.sessionManager.getBranch() as Entry[];
531
- const sessionId = ctx.sessionManager.getSessionId();
557
+ let entries: Entry[];
558
+ let sessionId: string;
559
+ try {
560
+ entries = ctx.sessionManager.getBranch() as Entry[];
561
+ sessionId = ctx.sessionManager.getSessionId();
562
+ } catch (error) {
563
+ if (isStaleExtensionContextError(error)) {
564
+ debugLog("observer.stale_ctx", { error: String(error) });
565
+ return "abort";
566
+ }
567
+ throw error;
568
+ }
532
569
 
533
570
  // Determine start index: cursor takes priority, fall back to coverage markers
534
571
  const observerCursor = runtime.getCursor("observer");
@@ -692,6 +729,10 @@ async function runObserverStage(
692
729
  }
693
730
  return "continue";
694
731
  } catch (error) {
732
+ if (isStaleExtensionContextError(error)) {
733
+ debugLog("observer.stale_ctx", { error: String(error) });
734
+ return "abort";
735
+ }
695
736
  // Always try next fallback — don't abort pipeline for a single model failure.
696
737
  // Record cooldown so resolveModel skips this model in the next iteration.
697
738
  const candidateConfig = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "observer"), stageFallbacks: stageFallbackModels(runtime, "observer") });
@@ -715,8 +756,18 @@ async function runReflectorStage(
715
756
  ctx: ConsolidationCtx,
716
757
  resolveModel: (stage: "reflector") => Promise<ResolvedModel | undefined>,
717
758
  ): Promise<ReflectorStageResult> {
718
- const sessionId = ctx.sessionManager.getSessionId();
719
- const entries = ctx.sessionManager.getBranch() as Entry[];
759
+ let entries: Entry[];
760
+ let sessionId: string;
761
+ try {
762
+ entries = ctx.sessionManager.getBranch() as Entry[];
763
+ sessionId = ctx.sessionManager.getSessionId();
764
+ } catch (error) {
765
+ if (isStaleExtensionContextError(error)) {
766
+ debugLog("reflector.stale_ctx", { error: String(error) });
767
+ return { outcome: "abort", sameRunReflections: [] };
768
+ }
769
+ throw error;
770
+ }
720
771
  let reflectionTokens = 0;
721
772
  let observationCoverageId: string | undefined;
722
773
  if (runtime.config.noAutoCompact) {
@@ -847,6 +898,10 @@ async function runReflectorStage(
847
898
  effectiveReflectionCoverageId: data.coversUpToId,
848
899
  };
849
900
  } catch (error) {
901
+ if (isStaleExtensionContextError(error)) {
902
+ debugLog("reflector.stale_ctx", { error: String(error) });
903
+ return { outcome: "abort", sameRunReflections: [] };
904
+ }
850
905
  const candidateConfig = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "reflector"), stageFallbacks: stageFallbackModels(runtime, "reflector") });
851
906
  runtime.recordRetryableError(candidateConfig, error, "reflector");
852
907
  debugLog("reflector.error", { error: String(error), retryable: isRetryableError(error) });
@@ -868,8 +923,18 @@ async function runDropperStage(
868
923
  sameRunReflections: Reflection[],
869
924
  sameRunReflectionCoverageId: string | undefined,
870
925
  ): Promise<StageOutcome> {
871
- const sessionId = ctx.sessionManager.getSessionId();
872
- const entries = ctx.sessionManager.getBranch() as Entry[];
926
+ let entries: Entry[];
927
+ let sessionId: string;
928
+ try {
929
+ entries = ctx.sessionManager.getBranch() as Entry[];
930
+ sessionId = ctx.sessionManager.getSessionId();
931
+ } catch (error) {
932
+ if (isStaleExtensionContextError(error)) {
933
+ debugLog("dropper.stale_ctx", { error: String(error) });
934
+ return "abort";
935
+ }
936
+ throw error;
937
+ }
873
938
  let dropTokens = 0;
874
939
  let observationCoverageId: string | undefined;
875
940
  if (runtime.config.noAutoCompact) {
@@ -989,6 +1054,10 @@ async function runDropperStage(
989
1054
  }
990
1055
  return "continue";
991
1056
  } catch (error) {
1057
+ if (isStaleExtensionContextError(error)) {
1058
+ debugLog("dropper.stale_ctx", { error: String(error) });
1059
+ return "abort";
1060
+ }
992
1061
  const candidateConfig = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "dropper"), stageFallbacks: stageFallbackModels(runtime, "dropper") });
993
1062
  runtime.recordRetryableError(candidateConfig, error, "dropper");
994
1063
  debugLog("dropper.error", { error: String(error), retryable: isRetryableError(error) });
@@ -24,3 +24,17 @@ export function isRetryableError(error: unknown): boolean {
24
24
  * @see @earendil-works/pi-ai/dist/utils/overflow.d.ts
25
25
  */
26
26
  export { isContextOverflow } from "@earendil-works/pi-ai";
27
+
28
+ /** Detect Pi's "extension ctx is stale" error from session replacement/reload.
29
+ * These are not model errors and must not be recorded as cooldowns. */
30
+ export function isStaleExtensionContextError(error: unknown): boolean {
31
+ let message: string;
32
+ if (error instanceof Error) {
33
+ message = error.message;
34
+ } else if (error && typeof error === "object" && "message" in error) {
35
+ message = String((error as { message: unknown }).message);
36
+ } else {
37
+ message = String(error || "");
38
+ }
39
+ return message.includes("extension ctx is stale") || message.includes("ctx is stale");
40
+ }
package/src/om/runtime.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * - markConsolidationError sets 30s retry gate for failed runs.
11
11
  */
12
12
  import { type Config, type ConfiguredModel, DEFAULTS, loadConfig } from "./config.js";
13
+ import type { CompactionStats } from "../hooks/before-compact.js";
13
14
  import { isCooldownActive, getCooldownEntry, recordCooldown, expireCooldowns, modelKey } from "./cooldown.js";
14
15
  import { readPendingCursors, writePendingCursors } from "./pending.js";
15
16
  import type { PendingOMState } from "./pending.js";
@@ -73,6 +74,10 @@ export class Runtime {
73
74
  * Set when handleAgentEnd schedules a wait; cleared on abort, success, or terminal bail.
74
75
  * agent_start handlers read this to abort the pending wait when a new turn starts. */
75
76
  autoCompactionController: AbortController | null = null;
77
+ /** Mid-run (turn_end) compaction is suspended after a failed/cancelled attempt
78
+ * at the current pressure level. Cleared when accumulated tokens drop below
79
+ * the threshold again (i.e. a compaction ran). Prevents abort/cancel thrash. */
80
+ midRunCompactionSuspended = false;
76
81
  resolveFailureNotified = false;
77
82
  lastObserverError: string | undefined;
78
83
  lastReflectorError: string | undefined;
@@ -80,7 +85,7 @@ export class Runtime {
80
85
  /** Epoch ms of the last failed consolidation run (any stage). */
81
86
  lastConsolidationErrorAt: number | undefined;
82
87
  /** Stats from the most recent compaction run (session-scoped via handler closure). */
83
- compactionStats: { summarized: number; kept: number; keptTokensEst: number } | null = null;
88
+ compactionStats: CompactionStats | null = null;
84
89
  /** Whether the most recent compaction was triggered by /blackhole (vs auto-compact). */
85
90
  compactWasPiVcc = false;
86
91
  /** In‑memory pipeline cursors — authoritative copy for gating decisions. */
@@ -369,7 +374,13 @@ export class Runtime {
369
374
  if (phase === "observer") this.lastObserverError = message;
370
375
  if (phase === "reflector") this.lastReflectorError = message;
371
376
  if (phase === "dropper") this.lastDropperError = message;
372
- if (ctx.hasUI && ctx.ui) ctx.ui.notify(`Observational memory: ${phase} failed: ${message}`, "warning");
377
+ if (ctx.hasUI && ctx.ui) {
378
+ try {
379
+ ctx.ui.notify(`Observational memory: ${phase} failed: ${message}`, "warning");
380
+ } catch {
381
+ // Stale extension context — harmless.
382
+ }
383
+ }
373
384
  this.markConsolidationError();
374
385
  return message;
375
386
  }
@@ -388,7 +399,13 @@ export class Runtime {
388
399
  await work();
389
400
  } catch (error) {
390
401
  errorMessage = error instanceof Error ? error.message : String(error);
391
- if (hasUI && ui) ui.notify(`Observational memory: ${label} failed: ${errorMessage}`, "warning");
402
+ if (hasUI && ui) {
403
+ try {
404
+ ui.notify(`Observational memory: ${label} failed: ${errorMessage}`, "warning");
405
+ } catch {
406
+ // Stale extension context — harmless.
407
+ }
408
+ }
392
409
  } finally {
393
410
  onFinally(errorMessage);
394
411
  }