pi-blackhole 0.3.8 → 0.4.0
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/README.md +7 -1
- package/index.ts +2 -2
- package/package.json +10 -10
- package/src/commands/cleanup.ts +319 -0
- package/src/commands/memory.ts +2 -2
- package/src/commands/pi-vcc.ts +13 -4
- package/src/core/summarize.ts +30 -23
- package/src/core/unified-config.ts +39 -11
- package/src/extract/commits.ts +68 -25
- package/src/extract/goals.ts +13 -2
- package/src/hooks/before-compact.ts +5 -2
- package/src/om/agents/dropper/agent.ts +1 -1
- package/src/om/agents/observer/agent.ts +1 -1
- package/src/om/agents/reflector/agent.ts +1 -1
- package/src/om/cleanup.ts +367 -0
- package/src/om/compaction-trigger.ts +123 -69
- package/src/om/configure-overlay.ts +39 -5
- package/src/om/consolidation.ts +23 -16
- package/src/om/ledger/projection.ts +6 -1
- package/src/om/runtime.ts +62 -31
|
@@ -27,6 +27,20 @@ function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" |
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
30
|
+
pi.on("agent_start", () => {
|
|
31
|
+
// Reset the info gate — allow one info notification during the new turn.
|
|
32
|
+
runtime.resetInfoGate();
|
|
33
|
+
|
|
34
|
+
// A new turn is starting — abort any pending auto-compaction wait.
|
|
35
|
+
// The new turn's own agent_end will re-evaluate the threshold and
|
|
36
|
+
// schedule a fresh wait if compaction is still needed.
|
|
37
|
+
if (runtime.autoCompactionController) {
|
|
38
|
+
runtime.autoCompactionController.abort();
|
|
39
|
+
runtime.autoCompactionController = null;
|
|
40
|
+
runtime.compactInFlight = false;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
30
44
|
pi.on("agent_end", (event: any, ctx: any) => {
|
|
31
45
|
try {
|
|
32
46
|
handleAgentEnd(event, ctx, runtime);
|
|
@@ -38,7 +52,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
41
|
-
runtime.ensureConfig(ctx.cwd);
|
|
55
|
+
runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
|
|
56
|
+
// Reset the info gate — allow one notification during agent_end.
|
|
57
|
+
runtime.resetInfoGate();
|
|
42
58
|
|
|
43
59
|
// Pass the config flag explicitly — this handler runs outside ALS context
|
|
44
60
|
// (agent_end events don't flow through consolidation's withDebugLogContext),
|
|
@@ -125,88 +141,126 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
|
125
141
|
|
|
126
142
|
dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
|
|
127
143
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
ui,
|
|
131
|
-
`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
|
|
132
|
-
"info",
|
|
133
|
-
);
|
|
144
|
+
runtime.tryEmitInfo(hasUI, ui,
|
|
145
|
+
`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`);
|
|
134
146
|
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
runtime.compactInFlight = true;
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
runtime.autoCompactionController = controller;
|
|
150
|
+
const signal = controller.signal;
|
|
151
|
+
dbg("compaction_trigger.scheduled", { compactInFlight: runtime.compactInFlight });
|
|
152
|
+
|
|
153
|
+
// Issue #31: keep waiting for the agent to become idle instead of bailing
|
|
154
|
+
// after the first non-idle check. The agent may need a few hundred ms to
|
|
155
|
+
// finish async work from other extension handlers (e.g. pi-rewind's
|
|
156
|
+
// checkpoint I/O) before it is truly idle. The only legitimate cancellation
|
|
157
|
+
// is the agent_start handler above aborting the controller.
|
|
158
|
+
void (async () => {
|
|
159
|
+
try {
|
|
160
|
+
// Yield to the event loop first — matches the historical
|
|
161
|
+
// setTimeout(0) deferral that lets other agent_end listeners run.
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
163
|
+
|
|
164
|
+
// Poll isIdle() every 200ms until it returns true. No max-retries
|
|
165
|
+
// cap: the user can be reading the response for arbitrarily long.
|
|
166
|
+
// ctx.compact() itself aborts any in-flight agent operation, so we
|
|
167
|
+
// must wait until the agent is truly idle.
|
|
168
|
+
let isIdle = false;
|
|
169
|
+
while (!isIdle) {
|
|
170
|
+
if (signal.aborted) {
|
|
171
|
+
dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
137
174
|
|
|
138
|
-
setTimeout(() => {
|
|
139
|
-
dbg("compaction_trigger.microtask.enter", {});
|
|
140
|
-
try {
|
|
141
175
|
// Validate session identity — bail if the session was replaced/reloaded.
|
|
142
|
-
|
|
176
|
+
let currentSessionId: string;
|
|
177
|
+
try {
|
|
178
|
+
currentSessionId = ctx.sessionManager.getSessionId();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (isStaleExtensionContextError(error)) {
|
|
181
|
+
runtime.compactInFlight = false;
|
|
182
|
+
runtime.autoCompactionController = null;
|
|
183
|
+
dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx" });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
143
188
|
dbg("compaction_trigger.microtask.session_check", { currentSessionId, expectedSessionId: sessionId, match: currentSessionId === sessionId });
|
|
144
189
|
if (currentSessionId !== sessionId) {
|
|
145
190
|
runtime.compactInFlight = false;
|
|
191
|
+
runtime.autoCompactionController = null;
|
|
146
192
|
dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
ui,
|
|
150
|
-
"Observational memory: compaction cancelled — session changed before compaction",
|
|
151
|
-
"info",
|
|
152
|
-
);
|
|
193
|
+
runtime.tryEmitInfo(hasUI, ui,
|
|
194
|
+
"Observational memory: compaction cancelled — session changed before compaction");
|
|
153
195
|
return;
|
|
154
196
|
}
|
|
155
197
|
|
|
156
|
-
|
|
198
|
+
isIdle = ctx.isIdle();
|
|
157
199
|
dbg("compaction_trigger.microtask.idle_check", { isIdle });
|
|
158
200
|
if (!isIdle) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
const currentEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
170
|
-
const currentTokens = rawTokensSinceLastCompaction(currentEntries);
|
|
171
|
-
dbg("compaction_trigger.microtask.recheck_tokens", { currentTokens, threshold: runtime.config.compactAfterTokens, ok: currentTokens >= runtime.config.compactAfterTokens });
|
|
172
|
-
if (currentTokens < runtime.config.compactAfterTokens) {
|
|
173
|
-
runtime.compactInFlight = false;
|
|
174
|
-
dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
|
|
175
|
-
notifySafely(
|
|
176
|
-
hasUI,
|
|
177
|
-
ui,
|
|
178
|
-
"Observational memory: compaction skipped — another compaction already ran before deferred compaction",
|
|
179
|
-
"info",
|
|
180
|
-
);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
dbg("compaction_trigger.microtask.calling_compact", {});
|
|
185
|
-
ctx.compact({
|
|
186
|
-
onComplete: (result: any) => {
|
|
187
|
-
runtime.compactInFlight = false;
|
|
188
|
-
dbg("compaction_trigger.onComplete", { result: !!result });
|
|
189
|
-
notifySafely(hasUI, ui, "Observational memory: compaction complete", "info");
|
|
190
|
-
},
|
|
191
|
-
onError: (error: { message: string }) => {
|
|
192
|
-
runtime.compactInFlight = false;
|
|
193
|
-
dbg("compaction_trigger.onError", { message: error?.message ?? String(error) });
|
|
194
|
-
if (error.message === "Compaction cancelled") {
|
|
195
|
-
// We already notified the user with the real reason before returning { cancel: true }.
|
|
201
|
+
// Sleep in 50ms slices so agent_start aborts are noticed quickly.
|
|
202
|
+
// A single 200ms await would let the loop run for 200ms after
|
|
203
|
+
// the user typed — too long, since we want compaction to wait
|
|
204
|
+
// only for the agent to settle, not for a full tick.
|
|
205
|
+
const sliceMs = 50;
|
|
206
|
+
const end = Date.now() + 200;
|
|
207
|
+
while (Date.now() < end) {
|
|
208
|
+
if (signal.aborted) {
|
|
209
|
+
dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
|
|
196
210
|
return;
|
|
197
211
|
}
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
} catch (error) {
|
|
202
|
-
runtime.compactInFlight = false;
|
|
203
|
-
const msg = getErrorMessage(error);
|
|
204
|
-
if (isStaleExtensionContextError(error)) {
|
|
205
|
-
dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx", message: msg });
|
|
206
|
-
return;
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, sliceMs));
|
|
213
|
+
}
|
|
207
214
|
}
|
|
208
|
-
dbg("compaction_trigger.microtask.error", { message: msg });
|
|
209
|
-
notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
|
|
210
215
|
}
|
|
211
|
-
|
|
216
|
+
|
|
217
|
+
if (signal.aborted) {
|
|
218
|
+
dbg("compaction_trigger.microtask.bail", { reason: "aborted_agent_start" });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const currentEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
223
|
+
const currentTokens = rawTokensSinceLastCompaction(currentEntries);
|
|
224
|
+
dbg("compaction_trigger.microtask.recheck_tokens", { currentTokens, threshold: runtime.config.compactAfterTokens, ok: currentTokens >= runtime.config.compactAfterTokens });
|
|
225
|
+
if (currentTokens < runtime.config.compactAfterTokens) {
|
|
226
|
+
runtime.compactInFlight = false;
|
|
227
|
+
runtime.autoCompactionController = null;
|
|
228
|
+
dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
|
|
229
|
+
runtime.tryEmitInfo(hasUI, ui,
|
|
230
|
+
"Observational memory: compaction skipped — another compaction already ran before deferred compaction");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
dbg("compaction_trigger.microtask.calling_compact", {});
|
|
235
|
+
// Compaction is now actually starting — clear the controller so
|
|
236
|
+
// agent_start doesn't abort an in-progress compact.
|
|
237
|
+
runtime.autoCompactionController = null;
|
|
238
|
+
ctx.compact({
|
|
239
|
+
onComplete: (result: any) => {
|
|
240
|
+
runtime.compactInFlight = false;
|
|
241
|
+
dbg("compaction_trigger.onComplete", { result: !!result });
|
|
242
|
+
runtime.tryEmitInfo(hasUI, ui, "Observational memory: compaction complete");
|
|
243
|
+
},
|
|
244
|
+
onError: (error: { message: string }) => {
|
|
245
|
+
runtime.compactInFlight = false;
|
|
246
|
+
dbg("compaction_trigger.onError", { message: error?.message ?? String(error) });
|
|
247
|
+
if (error.message === "Compaction cancelled") {
|
|
248
|
+
// We already notified the user with the real reason before returning { cancel: true }.
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
notifySafely(hasUI, ui, `Observational memory: ${error.message}`, "error");
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
runtime.compactInFlight = false;
|
|
256
|
+
runtime.autoCompactionController = null;
|
|
257
|
+
const msg = getErrorMessage(error);
|
|
258
|
+
if (isStaleExtensionContextError(error)) {
|
|
259
|
+
dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx", message: msg });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
dbg("compaction_trigger.microtask.error", { message: msg });
|
|
263
|
+
notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
212
266
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { visibleWidth } from "./key-matcher.js";
|
|
10
10
|
import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
|
|
11
11
|
import { DEFAULTS } from "../core/unified-config.js";
|
|
12
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -68,8 +68,12 @@ const FIELDS: FieldDef[] = [
|
|
|
68
68
|
helpText: "Max source entry tokens sent to observer per chunk" },
|
|
69
69
|
{ key: "observerPreambleMaxTokens", label: "Observer preamble max", type: "number", section: "Observational Memory",
|
|
70
70
|
helpText: "Preamble budget in manual compaction mode (0=auto-compute 30% of chunk)" },
|
|
71
|
+
{ key: "dropperPressureThreshold", label: "Dropper pressure threshold", type: "number", section: "Observational Memory",
|
|
72
|
+
helpText: "Fraction of reflectorInputMaxTokens that triggers pressure-driven dropper (0-1, default 0.70)" },
|
|
71
73
|
{ key: "agentMaxTurns", label: "Max turns per agent", type: "number", section: "Observational Memory",
|
|
72
74
|
helpText: "Shared turn cap for background memory agents" },
|
|
75
|
+
{ key: "fullFoldAlways", label: "Preserve OM on first compaction", type: "boolean", section: "Observational Memory",
|
|
76
|
+
helpText: "When true, early reflections/drops survive the first compaction in a fresh session" },
|
|
73
77
|
|
|
74
78
|
// ── Debug ──
|
|
75
79
|
{ key: "debug", label: "Debug snapshots", type: "boolean", section: "Debug",
|
|
@@ -111,6 +115,7 @@ const EMPTY_THEME: ThemeShim = {
|
|
|
111
115
|
export interface OverlayResult {
|
|
112
116
|
saved: boolean;
|
|
113
117
|
path: string;
|
|
118
|
+
error?: string;
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
/**
|
|
@@ -136,9 +141,13 @@ export function createConfigureOverlay(
|
|
|
136
141
|
|
|
137
142
|
// Parse config file
|
|
138
143
|
let raw: Record<string, unknown>;
|
|
144
|
+
let fileError: string | undefined;
|
|
139
145
|
try {
|
|
140
146
|
raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
141
|
-
} catch {
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (existsSync(configPath)) {
|
|
149
|
+
fileError = `Config file has invalid JSON: ${(e as Error).message}. Edit the file directly to fix it.`;
|
|
150
|
+
}
|
|
142
151
|
raw = {};
|
|
143
152
|
}
|
|
144
153
|
|
|
@@ -170,7 +179,16 @@ export function createConfigureOverlay(
|
|
|
170
179
|
break;
|
|
171
180
|
case "number": {
|
|
172
181
|
const num = Number(val);
|
|
173
|
-
|
|
182
|
+
if (val && !isNaN(num)) {
|
|
183
|
+
if (f.def.key === "dropperPressureThreshold") {
|
|
184
|
+
// Clamp to (0, 1] — runtime validates on reload
|
|
185
|
+
updated[f.def.key] = Math.min(1, Math.max(0.01, num));
|
|
186
|
+
} else {
|
|
187
|
+
updated[f.def.key] = num;
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
updated[f.def.key] = raw[f.def.key];
|
|
191
|
+
}
|
|
174
192
|
break;
|
|
175
193
|
}
|
|
176
194
|
case "enum":
|
|
@@ -238,8 +256,12 @@ export function createConfigureOverlay(
|
|
|
238
256
|
|
|
239
257
|
// Ctrl+S → save and close
|
|
240
258
|
if (matchesKey(data, "ctrl+s")) {
|
|
259
|
+
if (fileError) {
|
|
260
|
+
done({ saved: false, path: configPath, error: fileError });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
241
263
|
const saved = save();
|
|
242
|
-
done({ saved, path: configPath });
|
|
264
|
+
done({ saved, path: configPath, error: saved ? undefined : "Failed to write config file" });
|
|
243
265
|
return;
|
|
244
266
|
}
|
|
245
267
|
|
|
@@ -317,6 +339,16 @@ export function createConfigureOverlay(
|
|
|
317
339
|
lines.push(fg("border", `│ ${fg("accent", "Blackhole Configuration")}${" ".repeat(Math.max(0, innerW + 1 - 24))}│`));
|
|
318
340
|
lines.push(fg("border", `├${"─".repeat(w - 2)}┤`));
|
|
319
341
|
|
|
342
|
+
// Error banner when config file has invalid JSON
|
|
343
|
+
if (fileError) {
|
|
344
|
+
lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
|
|
345
|
+
const errorPrefix = fg("error", " ERROR:");
|
|
346
|
+
const errorText = fg("error", fileError);
|
|
347
|
+
const errorLine = `│ ${errorPrefix} ${errorText}${" ".repeat(Math.max(0, innerW - visibleWidth(errorPrefix) - 1 - visibleWidth(errorText)))}│`;
|
|
348
|
+
lines.push(errorLine);
|
|
349
|
+
lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
|
|
350
|
+
}
|
|
351
|
+
|
|
320
352
|
let currentSection = "";
|
|
321
353
|
|
|
322
354
|
for (let i = 0; i < fields.length; i++) {
|
|
@@ -375,7 +407,9 @@ export function createConfigureOverlay(
|
|
|
375
407
|
|
|
376
408
|
// Bottom hints
|
|
377
409
|
lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
|
|
378
|
-
const hintText =
|
|
410
|
+
const hintText = fileError
|
|
411
|
+
? " Ctrl+S blocked ↑↓ navigate Esc cancel (fix JSON first) "
|
|
412
|
+
: " Ctrl+S save \u2191\u2193 navigate Enter toggle Esc cancel ";
|
|
379
413
|
lines.push(fg("border", `│${fg("accent", hintText)}${" ".repeat(Math.max(1, innerW + 2 - visibleWidth(hintText)))}│`));
|
|
380
414
|
lines.push(fg("border", `╰${"─".repeat(w - 2)}╯`));
|
|
381
415
|
|
package/src/om/consolidation.ts
CHANGED
|
@@ -360,10 +360,8 @@ export function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (sta
|
|
|
360
360
|
const fallbackMsg = stageFallbacks.length === 0
|
|
361
361
|
? "no fallbacks configured"
|
|
362
362
|
: "no available fallbacks";
|
|
363
|
-
ctx.ui
|
|
364
|
-
`Observational memory: ${stage} skipped — model unavailable (cooldown set to 0, ${fallbackMsg}, will retry next run)
|
|
365
|
-
"info",
|
|
366
|
-
);
|
|
363
|
+
runtime.tryEmitInfo(true, ctx.ui,
|
|
364
|
+
`Observational memory: ${stage} skipped — model unavailable (cooldown set to 0, ${fallbackMsg}, will retry next run)`);
|
|
367
365
|
} else {
|
|
368
366
|
ctx.ui.notify(`Observational memory: ${stage} skipped — ${resolved.reason}`, "warning");
|
|
369
367
|
}
|
|
@@ -421,7 +419,7 @@ function validateCursors(entries: Entry[], runtime: Runtime): void {
|
|
|
421
419
|
}
|
|
422
420
|
|
|
423
421
|
function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
|
|
424
|
-
runtime.ensureConfig(ctx.cwd);
|
|
422
|
+
runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
|
|
425
423
|
if (runtime.config.memory === false) return;
|
|
426
424
|
|
|
427
425
|
// LEGACY: passive check — only applies when new keys are absent (unmigrated config)
|
|
@@ -617,10 +615,8 @@ async function runObserverStage(
|
|
|
617
615
|
if (idx >= 0) effectiveTokens = rawTokensAfterIndex(entries, idx);
|
|
618
616
|
}
|
|
619
617
|
}
|
|
620
|
-
|
|
621
|
-
`Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${effectiveTokens.toLocaleString()} accumulated)
|
|
622
|
-
"info",
|
|
623
|
-
);
|
|
618
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
619
|
+
`Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${effectiveTokens.toLocaleString()} accumulated)`);
|
|
624
620
|
debugLog("observer.start", { tokens, coversUpToId, sourceEntryIds, sourceEntryCount: sourceEntryIds.length, priorReflections: priorReflections.length, priorObservations: priorObservations.length });
|
|
625
621
|
|
|
626
622
|
// Resolve thinking level for the specific model (fallbacks may have their own thinking config)
|
|
@@ -633,7 +629,8 @@ async function runObserverStage(
|
|
|
633
629
|
if (observerEstimatedInput > effectiveObsCtx) {
|
|
634
630
|
debugLog("observer.context_window_exceeded", { estimatedInput: observerEstimatedInput, effectiveCtx: effectiveObsCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
635
631
|
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveObsCtx} too small for estimated input ${observerEstimatedInput}`), "observer");
|
|
636
|
-
|
|
632
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
633
|
+
`Observational memory: observer skipping ${(resolved.model as any).provider}/${(resolved.model as any).id} (context window ${effectiveObsCtx.toLocaleString()} too small for ~${observerEstimatedInput.toLocaleString()}-token input)`);
|
|
637
634
|
continue;
|
|
638
635
|
}
|
|
639
636
|
|
|
@@ -662,7 +659,8 @@ async function runObserverStage(
|
|
|
662
659
|
debugLog("observer.appended", { count: result.observations.length, coversUpToId });
|
|
663
660
|
}
|
|
664
661
|
runtime.advanceCursor("observer", coversUpToId, "recorded");
|
|
665
|
-
|
|
662
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
663
|
+
`Observational memory: ${result.observations.length} observation${result.observations.length === 1 ? "" : "s"} recorded`);
|
|
666
664
|
return "continue";
|
|
667
665
|
}
|
|
668
666
|
|
|
@@ -686,7 +684,12 @@ async function runObserverStage(
|
|
|
686
684
|
: "warning";
|
|
687
685
|
debugLog("observer.empty", { coversUpToId, reason: reason?.kind });
|
|
688
686
|
runtime.advanceCursor("observer", coversUpToId, "empty");
|
|
689
|
-
if (
|
|
687
|
+
if (reasonLevel === "warning") {
|
|
688
|
+
if (ctx.hasUI) ctx.ui?.notify(`Observational memory: no observations — ${reasonLabel}`, "warning");
|
|
689
|
+
} else {
|
|
690
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
691
|
+
`Observational memory: no observations — ${reasonLabel}`);
|
|
692
|
+
}
|
|
690
693
|
return "continue";
|
|
691
694
|
} catch (error) {
|
|
692
695
|
// Always try next fallback — don't abort pipeline for a single model failure.
|
|
@@ -769,7 +772,8 @@ async function runReflectorStage(
|
|
|
769
772
|
}
|
|
770
773
|
}
|
|
771
774
|
debugLog("reflector.start", { tokens: effectiveReflectionTokens, inputTokens: reflectorInputTokens, newObsCount: newObservations.length, newRefCount: newReflections.length });
|
|
772
|
-
|
|
775
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
776
|
+
`Observational memory: reflector running (~${effectiveReflectionTokens.toLocaleString()} tokens accumulated, ~${reflectorInputTokens.toLocaleString()}-token input)`);
|
|
773
777
|
|
|
774
778
|
// Resolve thinking level for the specific model (fallbacks may have their own thinking config)
|
|
775
779
|
const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "reflector"), stageFallbacks: stageFallbackModels(runtime, "reflector") });
|
|
@@ -781,7 +785,8 @@ async function runReflectorStage(
|
|
|
781
785
|
if (reflectorEstimatedInput > effectiveRefCtx) {
|
|
782
786
|
debugLog("reflector.context_window_exceeded", { estimatedInput: reflectorEstimatedInput, effectiveCtx: effectiveRefCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
783
787
|
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveRefCtx} too small for estimated input ${reflectorEstimatedInput}`), "reflector");
|
|
784
|
-
|
|
788
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
789
|
+
`Observational memory: reflector skipping ${(resolved.model as any).provider}/${(resolved.model as any).id} (context window ${effectiveRefCtx.toLocaleString()} too small for ~${reflectorEstimatedInput.toLocaleString()}-token input)`);
|
|
785
790
|
continue;
|
|
786
791
|
}
|
|
787
792
|
|
|
@@ -917,7 +922,8 @@ async function runDropperStage(
|
|
|
917
922
|
if (idx >= 0) effectiveDropTokens = rawTokensAfterIndex(entries, idx);
|
|
918
923
|
}
|
|
919
924
|
}
|
|
920
|
-
|
|
925
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
926
|
+
`Observational memory: dropper running (~${effectiveDropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`);
|
|
921
927
|
|
|
922
928
|
try {
|
|
923
929
|
// Existing active observations summary for context (capped).
|
|
@@ -948,7 +954,8 @@ async function runDropperStage(
|
|
|
948
954
|
if (dropperEstimatedInput > effectiveDropCtx) {
|
|
949
955
|
debugLog("dropper.context_window_exceeded", { estimatedInput: dropperEstimatedInput, effectiveCtx: effectiveDropCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
950
956
|
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveDropCtx} too small for estimated input ${dropperEstimatedInput}`), "dropper");
|
|
951
|
-
|
|
957
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
958
|
+
`Observational memory: dropper skipping ${(resolved.model as any).provider}/${(resolved.model as any).id} (context window ${effectiveDropCtx.toLocaleString()} too small for ~${dropperEstimatedInput.toLocaleString()}-token input)`);
|
|
952
959
|
continue;
|
|
953
960
|
}
|
|
954
961
|
|
|
@@ -30,6 +30,7 @@ export type ProjectionDiff = {
|
|
|
30
30
|
|
|
31
31
|
export type CompactionProjectionConfig = {
|
|
32
32
|
observationsPoolMaxTokens: number;
|
|
33
|
+
fullFoldAlways?: boolean;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
export type CompactionProjection = Projection & {
|
|
@@ -197,7 +198,11 @@ export function buildCompactionProjection(
|
|
|
197
198
|
config: CompactionProjectionConfig,
|
|
198
199
|
): CompactionProjection {
|
|
199
200
|
const fullFoldBoundaryId = latestFullFoldBoundaryId(entries);
|
|
200
|
-
const maintenanceBoundary = fullFoldBoundaryId
|
|
201
|
+
const maintenanceBoundary = fullFoldBoundaryId
|
|
202
|
+
? entryBoundary(fullFoldBoundaryId)
|
|
203
|
+
: config.fullFoldAlways
|
|
204
|
+
? entryBoundary(firstKeptEntryId)
|
|
205
|
+
: noneBoundary();
|
|
201
206
|
const normalProjection = foldProjection(entries, {
|
|
202
207
|
observationsBoundary: entryBoundary(firstKeptEntryId),
|
|
203
208
|
reflectionsBoundary: maintenanceBoundary,
|
package/src/om/runtime.ts
CHANGED
|
@@ -69,6 +69,10 @@ export class Runtime {
|
|
|
69
69
|
failedInCycle: Set<string> = new Set();
|
|
70
70
|
compactInFlight = false;
|
|
71
71
|
compactHookInFlight = false;
|
|
72
|
+
/** AbortController for the pending auto-compaction wait loop, or null if none.
|
|
73
|
+
* Set when handleAgentEnd schedules a wait; cleared on abort, success, or terminal bail.
|
|
74
|
+
* agent_start handlers read this to abort the pending wait when a new turn starts. */
|
|
75
|
+
autoCompactionController: AbortController | null = null;
|
|
72
76
|
resolveFailureNotified = false;
|
|
73
77
|
lastObserverError: string | undefined;
|
|
74
78
|
lastReflectorError: string | undefined;
|
|
@@ -83,14 +87,47 @@ export class Runtime {
|
|
|
83
87
|
cursors: PipelineCursors = {};
|
|
84
88
|
/** Session ID for which cursors have been loaded/validated. Undefined until first load. */
|
|
85
89
|
cursorsLoadedSessionId: string | undefined = undefined;
|
|
90
|
+
/** Info-notification gate: only the first info-level notification per turn/phase is emitted. */
|
|
91
|
+
hasEmittedInfoThisTurn = false;
|
|
86
92
|
|
|
87
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Emit an info-level notification if none has been emitted this turn/phase yet.
|
|
95
|
+
* Returns true if emitted, false if suppressed (already emitted earlier).
|
|
96
|
+
*/
|
|
97
|
+
tryEmitInfo(hasUI: boolean, ui: { notify: Notify } | undefined, message: string): boolean {
|
|
98
|
+
if (!hasUI || !ui || typeof ui.notify !== "function") return false;
|
|
99
|
+
if (this.hasEmittedInfoThisTurn) return false;
|
|
100
|
+
this.hasEmittedInfoThisTurn = true;
|
|
101
|
+
try {
|
|
102
|
+
ui.notify(message, "info");
|
|
103
|
+
} catch {
|
|
104
|
+
// Stale extension context — harmless.
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Reset the info gate — call at agent_start and agent_end to allow one
|
|
110
|
+
* notification per phase. */
|
|
111
|
+
resetInfoGate(): void {
|
|
112
|
+
this.hasEmittedInfoThisTurn = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ensureConfig(cwd: string, warn?: (message: string) => void): void {
|
|
88
116
|
if (this.configLoaded) return;
|
|
89
|
-
this.config = loadConfig(cwd);
|
|
117
|
+
this.config = loadConfig(cwd, warn);
|
|
90
118
|
this.configLoaded = true;
|
|
91
119
|
expireCooldowns();
|
|
92
120
|
}
|
|
93
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Force reload config from disk, discarding cached values.
|
|
124
|
+
* Call this after external config changes (e.g., overlay save, manual edit).
|
|
125
|
+
*/
|
|
126
|
+
reloadConfig(cwd: string, warn?: (message: string) => void): void {
|
|
127
|
+
this.configLoaded = false;
|
|
128
|
+
this.ensureConfig(cwd, warn);
|
|
129
|
+
}
|
|
130
|
+
|
|
94
131
|
/**
|
|
95
132
|
* Build the ordered model candidate list for a stage:
|
|
96
133
|
* 1. Primary stage model (observerModel, reflectorModel, dropperModel)
|
|
@@ -135,24 +172,16 @@ export class Runtime {
|
|
|
135
172
|
|
|
136
173
|
// In-memory skip: model failed earlier in this stage with cooldownHours 0
|
|
137
174
|
if (this.failedInCycle.has(key)) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
`Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`,
|
|
141
|
-
"info",
|
|
142
|
-
);
|
|
143
|
-
}
|
|
175
|
+
this.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
176
|
+
`Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`);
|
|
144
177
|
continue;
|
|
145
178
|
}
|
|
146
179
|
|
|
147
180
|
if (isCooldownActive(candidate)) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
`Observational memory: ${stageName} skipping ${key} (cooldown${reason})`,
|
|
153
|
-
"info",
|
|
154
|
-
);
|
|
155
|
-
}
|
|
181
|
+
const entry = getCooldownEntry(candidate);
|
|
182
|
+
const reason = entry ? `: ${entry.reason}` : "";
|
|
183
|
+
this.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
184
|
+
`Observational memory: ${stageName} skipping ${key} (cooldown${reason} — details in cooldown log)`);
|
|
156
185
|
continue;
|
|
157
186
|
}
|
|
158
187
|
|
|
@@ -168,7 +197,8 @@ export class Runtime {
|
|
|
168
197
|
}
|
|
169
198
|
|
|
170
199
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configured);
|
|
171
|
-
|
|
200
|
+
const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(configured) ?? true;
|
|
201
|
+
if (!auth.ok || !hasAuth) {
|
|
172
202
|
if (ctx.hasUI && ctx.ui) {
|
|
173
203
|
ctx.ui.notify(
|
|
174
204
|
`Observational memory: ${stageName} no auth for ${candidate.provider}`,
|
|
@@ -181,7 +211,7 @@ export class Runtime {
|
|
|
181
211
|
return {
|
|
182
212
|
ok: true,
|
|
183
213
|
model: configured,
|
|
184
|
-
apiKey: auth.apiKey as string,
|
|
214
|
+
apiKey: (auth.apiKey as string) ?? "",
|
|
185
215
|
headers: auth.headers as Record<string, string> | undefined,
|
|
186
216
|
cooldownApplied: false,
|
|
187
217
|
};
|
|
@@ -195,29 +225,25 @@ export class Runtime {
|
|
|
195
225
|
}
|
|
196
226
|
|
|
197
227
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
|
|
198
|
-
|
|
228
|
+
const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(sessionModel) ?? true;
|
|
229
|
+
if (!auth.ok || !hasAuth) {
|
|
199
230
|
const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
|
|
200
|
-
return { ok: false, reason: `no
|
|
231
|
+
return { ok: false, reason: `no auth for session model provider "${provider}"` };
|
|
201
232
|
}
|
|
202
233
|
|
|
203
234
|
return {
|
|
204
235
|
ok: true,
|
|
205
236
|
model: sessionModel,
|
|
206
|
-
apiKey: auth.apiKey as string,
|
|
237
|
+
apiKey: (auth.apiKey as string) ?? "",
|
|
207
238
|
headers: auth.headers as Record<string, string> | undefined,
|
|
208
239
|
cooldownApplied: false,
|
|
209
240
|
};
|
|
210
241
|
}
|
|
211
242
|
|
|
212
243
|
// All configured candidates exhausted and session fallback disabled —
|
|
213
|
-
// skip the stage entirely.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
ctx.ui.notify(
|
|
217
|
-
`Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`,
|
|
218
|
-
"info",
|
|
219
|
-
);
|
|
220
|
-
}
|
|
244
|
+
// skip the stage entirely.
|
|
245
|
+
this.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
246
|
+
`Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`);
|
|
221
247
|
this.resolveFailureNotified = true;
|
|
222
248
|
|
|
223
249
|
return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, sessionFallback disabled)` };
|
|
@@ -252,8 +278,13 @@ export class Runtime {
|
|
|
252
278
|
this.failedInCycle.add(modelKey(modelConfig));
|
|
253
279
|
return;
|
|
254
280
|
}
|
|
255
|
-
const
|
|
256
|
-
|
|
281
|
+
const rawReason = error instanceof Error ? error.message : String(error || "unknown error");
|
|
282
|
+
// Strip trailing JSON body from API error messages for display cleanliness.
|
|
283
|
+
// To avoid stripping non-JSON braces like "{host}", only strip if the text
|
|
284
|
+
// after the brace pair consists solely of whitespace (i.e. JSON is at end).
|
|
285
|
+
// The full rawReason is NOT stored in the cooldown log — only this brief form.
|
|
286
|
+
const brief = rawReason.replace(/\s*\{[\s\S]*?\}\s*$/, "").trim();
|
|
287
|
+
recordCooldown(modelConfig, brief, stage);
|
|
257
288
|
}
|
|
258
289
|
|
|
259
290
|
/**
|