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.
@@ -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
- notifySafely(
129
- hasUI,
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
- runtime.compactInFlight = true;
136
- dbg("compaction_trigger.scheduled", { compactInFlight: runtime.compactInFlight });
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
- const currentSessionId = ctx.sessionManager.getSessionId();
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
- notifySafely(
148
- hasUI,
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
- const isIdle = ctx.isIdle();
198
+ isIdle = ctx.isIdle();
157
199
  dbg("compaction_trigger.microtask.idle_check", { isIdle });
158
200
  if (!isIdle) {
159
- runtime.compactInFlight = false;
160
- dbg("compaction_trigger.microtask.bail", { reason: "not_idle" });
161
- notifySafely(
162
- hasUI,
163
- ui,
164
- "Observational memory: compaction deferred agent became busy before compaction",
165
- "info",
166
- );
167
- return;
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
- notifySafely(hasUI, ui, `Observational memory: ${error.message}`, "error");
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
- }, 0);
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
- updated[f.def.key] = (val && !isNaN(num)) ? num : raw[f.def.key];
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 = " Ctrl+S save \u2191\u2193 navigate Enter toggle Esc cancel ";
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
 
@@ -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.notify(
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
- if (ctx.hasUI) ctx.ui?.notify(
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
- if (ctx.hasUI && ctx.ui) ctx.ui.notify(`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)`, "info");
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
- if (ctx.hasUI) ctx.ui?.notify(`Observational memory: ${result.observations.length} observation${result.observations.length === 1 ? "" : "s"} recorded`, "info");
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 (ctx.hasUI) ctx.ui?.notify(`Observational memory: no observations — ${reasonLabel}`, reasonLevel);
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
- if (ctx.hasUI) ctx.ui?.notify(`Observational memory: reflector running (~${effectiveReflectionTokens.toLocaleString()} tokens accumulated, ~${reflectorInputTokens.toLocaleString()}-token input)`, "info");
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
- if (ctx.hasUI && ctx.ui) ctx.ui.notify(`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)`, "info");
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
- if (ctx.hasUI) ctx.ui?.notify(`Observational memory: dropper running (~${effectiveDropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`, "info");
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
- if (ctx.hasUI && ctx.ui) ctx.ui.notify(`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)`, "info");
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 ? entryBoundary(fullFoldBoundaryId) : noneBoundary();
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
- ensureConfig(cwd: string): void {
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
- if (ctx.hasUI && ctx.ui) {
139
- ctx.ui.notify(
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
- if (ctx.hasUI && ctx.ui) {
149
- const entry = getCooldownEntry(candidate);
150
- const reason = entry ? `: ${entry.reason}` : "";
151
- ctx.ui.notify(
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
- if (!auth.ok || !auth.apiKey) {
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
- if (!auth.ok || !auth.apiKey) {
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 API key for session model provider "${provider}"` };
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. Info-level to match cooldown-disabled pattern.
214
- // Set resolveFailureNotified so the consolidation layer doesn't duplicate.
215
- if (ctx.hasUI && ctx.ui) {
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 reason = error instanceof Error ? error.message : String(error || "unknown error");
256
- recordCooldown(modelConfig, reason, stage);
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
  /**