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.
- package/example-config.json +2 -0
- package/package.json +1 -1
- package/src/commands/pi-vcc.ts +24 -10
- package/src/core/format.ts +1 -1
- package/src/core/unified-config.ts +14 -0
- package/src/hooks/before-compact.ts +45 -7
- package/src/om/compaction-trigger.ts +134 -31
- package/src/om/configure-overlay.ts +2 -0
- package/src/om/consolidation.ts +80 -11
- package/src/om/retryable-error.ts +14 -0
- package/src/om/runtime.ts +20 -3
package/example-config.json
CHANGED
|
@@ -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.
|
|
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",
|
package/src/commands/pi-vcc.ts
CHANGED
|
@@ -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") {
|
package/src/core/format.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
package/src/om/consolidation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
872
|
-
|
|
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:
|
|
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)
|
|
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)
|
|
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
|
}
|