pi-blackhole 0.3.9 → 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.
@@ -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
@@ -87,14 +87,47 @@ export class Runtime {
87
87
  cursors: PipelineCursors = {};
88
88
  /** Session ID for which cursors have been loaded/validated. Undefined until first load. */
89
89
  cursorsLoadedSessionId: string | undefined = undefined;
90
+ /** Info-notification gate: only the first info-level notification per turn/phase is emitted. */
91
+ hasEmittedInfoThisTurn = false;
90
92
 
91
- 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 {
92
116
  if (this.configLoaded) return;
93
- this.config = loadConfig(cwd);
117
+ this.config = loadConfig(cwd, warn);
94
118
  this.configLoaded = true;
95
119
  expireCooldowns();
96
120
  }
97
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
+
98
131
  /**
99
132
  * Build the ordered model candidate list for a stage:
100
133
  * 1. Primary stage model (observerModel, reflectorModel, dropperModel)
@@ -139,24 +172,16 @@ export class Runtime {
139
172
 
140
173
  // In-memory skip: model failed earlier in this stage with cooldownHours 0
141
174
  if (this.failedInCycle.has(key)) {
142
- if (ctx.hasUI && ctx.ui) {
143
- ctx.ui.notify(
144
- `Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`,
145
- "info",
146
- );
147
- }
175
+ this.tryEmitInfo(ctx.hasUI, ctx.ui,
176
+ `Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`);
148
177
  continue;
149
178
  }
150
179
 
151
180
  if (isCooldownActive(candidate)) {
152
- if (ctx.hasUI && ctx.ui) {
153
- const entry = getCooldownEntry(candidate);
154
- const reason = entry ? `: ${entry.reason}` : "";
155
- ctx.ui.notify(
156
- `Observational memory: ${stageName} skipping ${key} (cooldown${reason})`,
157
- "info",
158
- );
159
- }
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)`);
160
185
  continue;
161
186
  }
162
187
 
@@ -172,7 +197,8 @@ export class Runtime {
172
197
  }
173
198
 
174
199
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configured);
175
- if (!auth.ok || !auth.apiKey) {
200
+ const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(configured) ?? true;
201
+ if (!auth.ok || !hasAuth) {
176
202
  if (ctx.hasUI && ctx.ui) {
177
203
  ctx.ui.notify(
178
204
  `Observational memory: ${stageName} no auth for ${candidate.provider}`,
@@ -185,7 +211,7 @@ export class Runtime {
185
211
  return {
186
212
  ok: true,
187
213
  model: configured,
188
- apiKey: auth.apiKey as string,
214
+ apiKey: (auth.apiKey as string) ?? "",
189
215
  headers: auth.headers as Record<string, string> | undefined,
190
216
  cooldownApplied: false,
191
217
  };
@@ -199,29 +225,25 @@ export class Runtime {
199
225
  }
200
226
 
201
227
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
202
- if (!auth.ok || !auth.apiKey) {
228
+ const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(sessionModel) ?? true;
229
+ if (!auth.ok || !hasAuth) {
203
230
  const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
204
- return { ok: false, reason: `no API key for session model provider "${provider}"` };
231
+ return { ok: false, reason: `no auth for session model provider "${provider}"` };
205
232
  }
206
233
 
207
234
  return {
208
235
  ok: true,
209
236
  model: sessionModel,
210
- apiKey: auth.apiKey as string,
237
+ apiKey: (auth.apiKey as string) ?? "",
211
238
  headers: auth.headers as Record<string, string> | undefined,
212
239
  cooldownApplied: false,
213
240
  };
214
241
  }
215
242
 
216
243
  // All configured candidates exhausted and session fallback disabled —
217
- // skip the stage entirely. Info-level to match cooldown-disabled pattern.
218
- // Set resolveFailureNotified so the consolidation layer doesn't duplicate.
219
- if (ctx.hasUI && ctx.ui) {
220
- ctx.ui.notify(
221
- `Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`,
222
- "info",
223
- );
224
- }
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)`);
225
247
  this.resolveFailureNotified = true;
226
248
 
227
249
  return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, sessionFallback disabled)` };
@@ -256,8 +278,13 @@ export class Runtime {
256
278
  this.failedInCycle.add(modelKey(modelConfig));
257
279
  return;
258
280
  }
259
- const reason = error instanceof Error ? error.message : String(error || "unknown error");
260
- 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);
261
288
  }
262
289
 
263
290
  /**