pi-blackhole 0.3.2 → 0.3.4

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.
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Configure Overlay — editable settings overlay for pi-blackhole-config.json.
3
+ *
4
+ * Used by `/blackhole configure` to edit compaction, memory, and debug settings.
5
+ * Opens as a floating overlay via ctx.ui.custom({ overlay: true }).
6
+ * Navigation: ↑↓ Edit: Enter Save: Ctrl+S Cancel: Esc
7
+ */
8
+
9
+ import { visibleWidth } from "./key-matcher.js";
10
+ import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
11
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Field types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface FieldDef {
19
+ key: string;
20
+ label: string;
21
+ type: "number" | "boolean" | "enum";
22
+ section: string;
23
+ enumValues?: string[];
24
+ helpText?: string;
25
+ }
26
+
27
+ interface FieldState {
28
+ def: FieldDef;
29
+ value: string;
30
+ editing: boolean;
31
+ cursor: number;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Field definitions
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const FIELDS: FieldDef[] = [
39
+ // ── Compaction ──
40
+ { key: "compaction", label: "Compaction mode", type: "enum", section: "Compaction", enumValues: ["auto", "manual", "off"],
41
+ helpText: "auto=trigger on threshold, manual=only /blackhole, off=auto:Pi handles, /blackhole:blackhole pipeline" },
42
+ { key: "compactionEngine", label: "Compaction engine", type: "enum", section: "Compaction", enumValues: ["blackhole", "pi-default"],
43
+ helpText: "blackhole=structured summary+OM, pi-default=built-in Pi summarization" },
44
+ { key: "tailBehavior", label: "Visible tail", type: "enum", section: "Compaction", enumValues: ["minimal", "pi-default"],
45
+ helpText: "minimal=keep last user message only (default), pi-default=keep Pi's preserved visible context" },
46
+ { key: "compactAfterTokens", label: "Auto-compact threshold", type: "number", section: "Compaction",
47
+ helpText: "Token count that triggers auto-compaction when reached" },
48
+
49
+ // ── Observational Memory ──
50
+ { key: "memory", label: "Observational memory", type: "boolean", section: "Observational Memory",
51
+ helpText: "Enable OM workers (observer, reflector, dropper) and content injection" },
52
+ { key: "observeAfterTokens", label: "Observer threshold", type: "number", section: "Observational Memory",
53
+ helpText: "Tokens accumulated since last observer run before triggering next observe" },
54
+ { key: "reflectAfterTokens", label: "Reflect + dropper threshold", type: "number", section: "Observational Memory",
55
+ helpText: "Tokens accumulated since last reflect before triggering reflector and dropper" },
56
+ { key: "observationsPoolMaxTokens", label: "Observation pool max", type: "number", section: "Observational Memory",
57
+ helpText: "Max tokens in observation pool before dropper prunes (fold pressure)" },
58
+ { key: "observationsPoolTargetTokens", label: "Observation pool target", type: "number", section: "Observational Memory",
59
+ helpText: "Target tokens after dropper prunes (defaults to half of pool max)" },
60
+ { key: "reflectorInputMaxTokens", label: "Reflector input max", type: "number", section: "Observational Memory",
61
+ helpText: "Max prompt tokens for reflector model input (rolling window cap)" },
62
+ { key: "dropperInputMaxTokens", label: "Dropper input max", type: "number", section: "Observational Memory",
63
+ helpText: "Max prompt tokens for dropper model input (rolling window cap)" },
64
+ { key: "observerChunkMaxTokens", label: "Observer chunk max", type: "number", section: "Observational Memory",
65
+ helpText: "Max source entry tokens sent to observer per chunk" },
66
+ { key: "observerPreambleMaxTokens", label: "Observer preamble max", type: "number", section: "Observational Memory",
67
+ helpText: "Preamble budget in manual compaction mode (0=auto-compute 30% of chunk)" },
68
+ { key: "agentMaxTurns", label: "Max turns per agent", type: "number", section: "Observational Memory",
69
+ helpText: "Shared turn cap for background memory agents" },
70
+
71
+ // ── Debug ──
72
+ { key: "debug", label: "Debug snapshots", type: "boolean", section: "Debug",
73
+ helpText: "Write detailed debug snapshots to /tmp/pi-blackhole-debug.json" },
74
+ { key: "debugLog", label: "Debug JSONL logging", type: "boolean", section: "Debug",
75
+ helpText: "Write structured JSONL debug logs to agent directory" },
76
+ ];
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function formatValue(def: FieldDef, rawValue: unknown): string {
83
+ if (rawValue === undefined || rawValue === null) return "";
84
+ switch (def.type) {
85
+ case "boolean":
86
+ return rawValue ? "on" : "off";
87
+ default:
88
+ return String(rawValue);
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Theme helper — wraps a minimal theme shape
94
+ // ---------------------------------------------------------------------------
95
+
96
+ type ThemeShim = {
97
+ fg: (style: string, text: string) => string;
98
+ };
99
+
100
+ const EMPTY_THEME: ThemeShim = {
101
+ fg: (_s: string, t: string) => t,
102
+ };
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Overlay Component factory
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export interface OverlayResult {
109
+ saved: boolean;
110
+ path: string;
111
+ }
112
+
113
+ /**
114
+ * Create a ConfigureOverlay instance to pass to ctx.ui.custom().
115
+ *
116
+ * Usage:
117
+ * ```ts
118
+ * const result = await ctx.ui.custom<OverlayResult | undefined>(
119
+ * (tui, theme, _kb, done) => createConfigureOverlay(configPath, theme, tui, done),
120
+ * { overlay: true },
121
+ * );
122
+ * ```
123
+ */
124
+ export function createConfigureOverlay(
125
+ configPath: string,
126
+ theme: unknown,
127
+ tui: { requestRender: () => void },
128
+ done: (result: OverlayResult | undefined) => void,
129
+ ): { render(width: number): string[]; handleInput(data: string): void; invalidate(): void; dispose(): void } {
130
+ const th: ThemeShim = (theme && typeof (theme as Record<string, unknown>).fg === "function")
131
+ ? theme as ThemeShim
132
+ : EMPTY_THEME;
133
+
134
+ // Parse config file
135
+ let raw: Record<string, unknown>;
136
+ try {
137
+ raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
138
+ } catch {
139
+ raw = {};
140
+ }
141
+
142
+ const fields: FieldState[] = FIELDS.map((def) => ({
143
+ def,
144
+ value: formatValue(def, raw[def.key]),
145
+ editing: false,
146
+ cursor: 0,
147
+ }));
148
+
149
+ let selectedIndex = 0;
150
+ let cachedLines: string[] | undefined;
151
+
152
+ function invalidate(): void {
153
+ cachedLines = undefined;
154
+ try { tui.requestRender(); } catch { /* no-op */ }
155
+ }
156
+
157
+ function save(): boolean {
158
+ try {
159
+ const updated = { ...raw };
160
+ for (const f of fields) {
161
+ const val = f.value;
162
+ if (val === "" && !(f.def.key in raw)) continue;
163
+ switch (f.def.type) {
164
+ case "boolean":
165
+ updated[f.def.key] = val === "on";
166
+ break;
167
+ case "number": {
168
+ const num = Number(val);
169
+ updated[f.def.key] = (val && !isNaN(num)) ? num : raw[f.def.key];
170
+ break;
171
+ }
172
+ case "enum":
173
+ updated[f.def.key] = val;
174
+ break;
175
+ default:
176
+ updated[f.def.key] = val;
177
+ break;
178
+ }
179
+ }
180
+ mkdirSync(dirname(configPath), { recursive: true });
181
+ writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n");
182
+ raw = updated;
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ function handleInput(data: string): void {
190
+ const cur = fields[selectedIndex];
191
+ if (!cur) return;
192
+
193
+ // While editing a number field
194
+ if (cur.editing) {
195
+ if (matchesKey(data, "escape")) {
196
+ cur.value = formatValue(cur.def, raw[cur.def.key]);
197
+ cur.editing = false;
198
+ invalidate();
199
+ return;
200
+ }
201
+ if (matchesKey(data, "enter") || matchesKey(data, "tab")) {
202
+ cur.editing = false;
203
+ invalidate();
204
+ return;
205
+ }
206
+ if (matchesKey(data, "backspace")) {
207
+ if (cur.cursor > 0) {
208
+ cur.value = cur.value.slice(0, cur.cursor - 1) + cur.value.slice(cur.cursor);
209
+ cur.cursor--;
210
+ invalidate();
211
+ }
212
+ return;
213
+ }
214
+ if (matchesKey(data, "left")) {
215
+ cur.cursor = Math.max(0, cur.cursor - 1);
216
+ invalidate();
217
+ return;
218
+ }
219
+ if (matchesKey(data, "right")) {
220
+ cur.cursor = Math.min(cur.value.length, cur.cursor + 1);
221
+ invalidate();
222
+ return;
223
+ }
224
+ const digit = decodeKittyPrintable(data) ?? data;
225
+ if (digit.length === 1 && digit >= "0" && digit <= "9") {
226
+ cur.value = cur.value.slice(0, cur.cursor) + digit + cur.value.slice(cur.cursor);
227
+ cur.cursor++;
228
+ invalidate();
229
+ }
230
+ return;
231
+ }
232
+
233
+ // Not editing — global navigation
234
+
235
+ // Ctrl+S → save and close
236
+ if (matchesKey(data, "ctrl+s")) {
237
+ const saved = save();
238
+ done({ saved, path: configPath });
239
+ return;
240
+ }
241
+
242
+ // Esc → close without saving
243
+ if (matchesKey(data, "escape")) {
244
+ done(undefined);
245
+ return;
246
+ }
247
+
248
+ // Enter/space → edit/toggle
249
+ if (matchesKey(data, "enter") || matchesKey(data, "space")) {
250
+ switch (cur.def.type) {
251
+ case "boolean":
252
+ cur.value = cur.value === "on" ? "off" : "on";
253
+ invalidate();
254
+ break;
255
+ case "enum": {
256
+ const vals = cur.def.enumValues;
257
+ if (!vals || vals.length === 0) return;
258
+ const idx = vals.indexOf(cur.value);
259
+ cur.value = vals[(idx + 1) % vals.length];
260
+ invalidate();
261
+ break;
262
+ }
263
+ case "number":
264
+ cur.editing = true;
265
+ cur.cursor = cur.value.length;
266
+ invalidate();
267
+ break;
268
+ }
269
+ return;
270
+ }
271
+
272
+ // ↑↓ navigation
273
+ if (matchesKey(data, "up")) {
274
+ selectedIndex = Math.max(0, selectedIndex - 1);
275
+ invalidate();
276
+ return;
277
+ }
278
+ if (matchesKey(data, "down")) {
279
+ selectedIndex = Math.min(fields.length - 1, selectedIndex + 1);
280
+ invalidate();
281
+ return;
282
+ }
283
+ }
284
+
285
+ /** Compute minimum width to fit all content, plus borders. */
286
+ function contentWidth(): number {
287
+ const longestHelp = FIELDS.reduce((max, f) => {
288
+ if (!f.helpText) return max;
289
+ return Math.max(max, visibleWidth(f.helpText));
290
+ }, 0);
291
+ // Help line: │ ${help} ${│ → 4 chars overhead + at least 1 space
292
+ const helpOverhead = 4;
293
+ const longestLabel = FIELDS.reduce((max, f) => {
294
+ return Math.max(max, visibleWidth(f.label) + 3); // prefix + space + label
295
+ }, 0);
296
+ // Section header: ── N ── → up to ~30, but help text dominates
297
+ // Hint line: ~54 visible
298
+ return Math.max(54 + 4, longestHelp + helpOverhead, longestLabel + 20);
299
+ }
300
+
301
+ function render(width: number): string[] {
302
+ if (cachedLines) return cachedLines;
303
+
304
+ const minW = contentWidth();
305
+ const w = Math.max(2, Math.min(width - 2, Math.max(minW, 50)));
306
+ const innerW = w - 4;
307
+ const fg = (style: string, text: string) => th.fg(style, text);
308
+
309
+ const lines: string[] = [];
310
+
311
+ // Top border + header
312
+ lines.push(fg("border", `╭${"─".repeat(w - 2)}╮`));
313
+ lines.push(fg("border", `│ ${fg("accent", "Blackhole Configuration")}${" ".repeat(Math.max(0, innerW + 1 - 24))}│`));
314
+ lines.push(fg("border", `├${"─".repeat(w - 2)}┤`));
315
+
316
+ let currentSection = "";
317
+
318
+ for (let i = 0; i < fields.length; i++) {
319
+ const f = fields[i];
320
+ const isSelected = i === selectedIndex;
321
+ const isEditing = isSelected && f.editing;
322
+
323
+ // Section header
324
+ if (f.def.section !== currentSection) {
325
+ currentSection = f.def.section;
326
+ if (i > 0) {
327
+ lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
328
+ }
329
+ lines.push(fg("border", `│ ${fg("dim", `── ${currentSection} ──`)}${" ".repeat(Math.max(0, innerW + 1 - currentSection.length - 6))}│`));
330
+ }
331
+
332
+ // Field label
333
+ const prefix = isSelected ? fg("accent", isEditing ? ">>" : " >") : " ";
334
+ const label = isSelected ? fg("accent", `${f.def.label}:`) : fg("text", `${f.def.label}:`);
335
+ const labelStr = `${prefix} ${label}`;
336
+ const labelVis = visibleWidth(labelStr);
337
+
338
+ // Value
339
+ let valueStr: string;
340
+ if (isEditing) {
341
+ const before = f.value.slice(0, f.cursor);
342
+ const cursorChar = f.cursor < f.value.length ? f.value[f.cursor] : " ";
343
+ const after = f.value.slice(f.cursor + 1);
344
+ valueStr = `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;
345
+ } else {
346
+ switch (f.def.type) {
347
+ case "boolean":
348
+ valueStr = f.value === "on" ? fg("success", "on") : fg("muted", "off");
349
+ break;
350
+ default:
351
+ valueStr = f.value ? fg("text", f.value) : fg("dim", "(empty)");
352
+ break;
353
+ }
354
+ }
355
+
356
+ const valSpace = Math.max(10, innerW - labelVis - 1);
357
+ const truncated = visibleWidth(valueStr) > valSpace
358
+ ? valueStr.slice(0, Math.max(0, valSpace - 3)) + "..."
359
+ : valueStr;
360
+ const remaining = innerW + 1 - labelVis - visibleWidth(truncated);
361
+
362
+ lines.push(fg("border", `│ ${labelStr}${truncated}${" ".repeat(Math.max(1, remaining))}│`));
363
+
364
+ // Help text for selected item
365
+ if (isSelected && f.def.helpText && !isEditing) {
366
+ const help = fg("dim", f.def.helpText);
367
+ const helpRemaining = innerW - visibleWidth(help);
368
+ lines.push(fg("border", `│ ${help}${" ".repeat(Math.max(1, helpRemaining))}│`));
369
+ }
370
+ }
371
+
372
+ // Bottom hints
373
+ lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
374
+ const hintText = " Ctrl+S save \u2191\u2193 navigate Enter toggle Esc cancel ";
375
+ lines.push(fg("border", `│${fg("accent", hintText)}${" ".repeat(Math.max(1, innerW + 2 - visibleWidth(hintText)))}│`));
376
+ lines.push(fg("border", `╰${"─".repeat(w - 2)}╯`));
377
+
378
+ cachedLines = lines;
379
+ return lines;
380
+ }
381
+
382
+ return { render, handleInput, invalidate, dispose: () => {} };
383
+ }
@@ -16,7 +16,11 @@ 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
18
  import { isRetryableError } from "./cooldown.js";
19
+ import { effectiveContextWindow } from "./model-budget.js";
19
20
  import { serializeSourceAddressedBranchEntries } from "./serialize.js";
21
+
22
+ /** Fixed overhead for system prompt, tool definitions, and turn scaffold in context window pre-check. */
23
+ const AGENT_LOOP_RESERVE = 8_000;
20
24
  import {
21
25
  readPendingState,
22
26
  savePendingObservation,
@@ -55,9 +59,9 @@ import {
55
59
  type Reflection,
56
60
  } from "./ledger/index.js";
57
61
 
58
- type ResolvedModel = Extract<ResolveResult, { ok: true }>;
62
+ export type ResolvedModel = Extract<ResolveResult, { ok: true }>;
59
63
 
60
- type ConsolidationCtx = {
64
+ export type ConsolidationCtx = {
61
65
  cwd: string;
62
66
  hasUI: boolean;
63
67
  ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
@@ -178,15 +182,16 @@ function stageThinkingLevel(runtime: Runtime, stage: "observer" | "reflector" |
178
182
  return stageModel?.thinking ?? runtime.config.model?.thinking ?? "low";
179
183
  }
180
184
 
181
- function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
185
+ export function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
182
186
  return async (stage) => {
187
+ const stageFallbacks = stageFallbackModels(runtime, stage);
183
188
  const resolved = await runtime.resolveModel({
184
189
  model: ctx.model,
185
190
  modelRegistry: ctx.modelRegistry,
186
191
  hasUI: ctx.hasUI,
187
192
  ui: ctx.ui,
188
193
  stageModel: stageModelConfig(runtime, stage),
189
- stageFallbacks: stageFallbackModels(runtime, stage),
194
+ stageFallbacks,
190
195
  });
191
196
  if (resolved.ok) {
192
197
  runtime.resolveFailureNotified = false;
@@ -194,7 +199,17 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
194
199
  }
195
200
  debugLog(`${stage}.model_unavailable`, { reason: resolved.reason });
196
201
  if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
197
- ctx.ui.notify(`Observational memory: ${stage} skipped — ${resolved.reason}`, "warning");
202
+ if (runtime.failedInCycle.size > 0 && resolved.reason.includes("all candidates exhausted")) {
203
+ const fallbackMsg = stageFallbacks.length === 0
204
+ ? "no fallbacks configured"
205
+ : "no available fallbacks";
206
+ ctx.ui.notify(
207
+ `Observational memory: ${stage} skipped — model unavailable (cooldown set to 0, ${fallbackMsg}, will retry next run)`,
208
+ "info",
209
+ );
210
+ } else {
211
+ ctx.ui.notify(`Observational memory: ${stage} skipped — ${resolved.reason}`, "warning");
212
+ }
198
213
  runtime.resolveFailureNotified = true;
199
214
  }
200
215
  return undefined;
@@ -213,8 +228,12 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
213
228
 
214
229
  function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
215
230
  runtime.ensureConfig(ctx.cwd);
216
- if (runtime.config.passive === true) return;
217
231
  if (runtime.config.memory === false) return;
232
+
233
+ // LEGACY: passive check — only applies when new keys are absent (unmigrated config)
234
+ if (runtime.config.compaction === undefined && runtime.config.compactionEngine === undefined) {
235
+ if (runtime.config.passive === true) return;
236
+ }
218
237
  if (runtime.consolidationInFlight) return;
219
238
  if (runtime.isConsolidationRetryGated()) return;
220
239
 
@@ -246,6 +265,8 @@ export async function runConsolidationPipeline(
246
265
  const resolveModel = makeModelResolver(runtime, ctx);
247
266
 
248
267
  runtime.consolidationPhase = "observer";
268
+ runtime.failedInCycle.clear();
269
+ runtime.resolveFailureNotified = false;
249
270
  try {
250
271
  const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel);
251
272
  if (observerOutcome === "abort") return;
@@ -255,6 +276,8 @@ export async function runConsolidationPipeline(
255
276
  }
256
277
 
257
278
  runtime.consolidationPhase = "reflector";
279
+ runtime.failedInCycle.clear();
280
+ runtime.resolveFailureNotified = false;
258
281
  let reflectorResult: ReflectorStageResult;
259
282
  try {
260
283
  reflectorResult = await runReflectorStage(pi, runtime, ctx, resolveModel);
@@ -265,6 +288,8 @@ export async function runConsolidationPipeline(
265
288
  }
266
289
 
267
290
  runtime.consolidationPhase = "dropper";
291
+ runtime.failedInCycle.clear();
292
+ runtime.resolveFailureNotified = false;
268
293
  try {
269
294
  await runDropperStage(pi, runtime, ctx, resolveModel, reflectorResult.sameRunReflections, reflectorResult.effectiveReflectionCoverageId);
270
295
  } catch (error) {
@@ -363,6 +388,17 @@ async function runObserverStage(
363
388
 
364
389
  // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
365
390
  const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "observer"), stageFallbacks: stageFallbackModels(runtime, "observer") });
391
+
392
+ // Check if estimated input fits in model's context window
393
+ const effectiveObsCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
394
+ const observerEstimatedInput = runtime.config.observerChunkMaxTokens + AGENT_LOOP_RESERVE;
395
+ if (observerEstimatedInput > effectiveObsCtx) {
396
+ debugLog("observer.context_window_exceeded", { estimatedInput: observerEstimatedInput, effectiveCtx: effectiveObsCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
397
+ runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveObsCtx} too small for estimated input ${observerEstimatedInput}`), "observer");
398
+ 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");
399
+ continue;
400
+ }
401
+
366
402
  try {
367
403
  const result = await runObserver({
368
404
  model: resolved.model as any,
@@ -497,6 +533,17 @@ async function runReflectorStage(
497
533
 
498
534
  // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
499
535
  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") });
536
+
537
+ // Check if estimated input fits in model's context window
538
+ const effectiveRefCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
539
+ const reflectorEstimatedInput = runtime.config.reflectorInputMaxTokens + AGENT_LOOP_RESERVE;
540
+ if (reflectorEstimatedInput > effectiveRefCtx) {
541
+ debugLog("reflector.context_window_exceeded", { estimatedInput: reflectorEstimatedInput, effectiveCtx: effectiveRefCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
542
+ runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveRefCtx} too small for estimated input ${reflectorEstimatedInput}`), "reflector");
543
+ 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");
544
+ continue;
545
+ }
546
+
500
547
  try {
501
548
  // Existing memory summaries for context (capped).
502
549
  // In noAutoCompact, merge accumulated pending batches with
@@ -643,6 +690,17 @@ async function runDropperStage(
643
690
 
644
691
  // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
645
692
  const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "dropper"), stageFallbacks: stageFallbackModels(runtime, "dropper") });
693
+
694
+ // Check if estimated input fits in model's context window
695
+ const effectiveDropCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
696
+ const dropperEstimatedInput = runtime.config.dropperInputMaxTokens + AGENT_LOOP_RESERVE;
697
+ if (dropperEstimatedInput > effectiveDropCtx) {
698
+ debugLog("dropper.context_window_exceeded", { estimatedInput: dropperEstimatedInput, effectiveCtx: effectiveDropCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
699
+ runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveDropCtx} too small for estimated input ${dropperEstimatedInput}`), "dropper");
700
+ 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");
701
+ continue;
702
+ }
703
+
646
704
  const droppedIds = await runDropper({
647
705
  model: resolved.model as any,
648
706
  apiKey: resolved.apiKey,
@@ -75,8 +75,13 @@ function writeCooldownMap(map: CooldownMap): void {
75
75
  /**
76
76
  * Check whether a model is currently cooled down.
77
77
  * Expired entries are cleaned up lazily.
78
+ *
79
+ * When cooldownHours is explicitly 0, cooldown is disabled — always returns false.
78
80
  */
79
81
  export function isCooldownActive(model: OmModelConfig, now: Date = new Date()): boolean {
82
+ // cooldownHours === 0 means cooldown disabled
83
+ if (model.cooldownHours === 0) return false;
84
+
80
85
  const map = readCooldownMap();
81
86
  const key = modelKey(model);
82
87
  const entry = map[key];
@@ -97,11 +102,16 @@ export function isCooldownActive(model: OmModelConfig, now: Date = new Date()):
97
102
  /**
98
103
  * Record a cooldown for a model after a retryable error.
99
104
  *
105
+ * When cooldownHours is explicitly 0, cooldown is disabled — no-op.
106
+ *
100
107
  * @param model The model that failed.
101
108
  * @param reason Human-readable error reason (e.g. "429 Too Many Requests").
102
109
  * @param stage Which pipeline stage failed ("observer" | "reflector" | "dropper").
103
110
  */
104
111
  export function recordCooldown(model: OmModelConfig, reason: string, stage: string): void {
112
+ // cooldownHours === 0 means cooldown disabled
113
+ if (model.cooldownHours === 0) return;
114
+
105
115
  const hours = model.cooldownHours ?? 1;
106
116
  const until = new Date(Date.now() + hours * 3_600_000).toISOString();
107
117
  const map = readCooldownMap();
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Terminal utilities shared by blackhole overlay components.
3
+ *
4
+ * visibleWidth — CJK-aware visible width for terminal columns,
5
+ * extracted from pi-tui to avoid import resolution issues.
6
+ *
7
+ * Ported from voice-type extension's key-matcher.ts.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Visible width (CJK-aware)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function stripAnsi(s: string): string {
15
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
16
+ }
17
+
18
+ /**
19
+ * Calculate the visible width of a string in terminal columns.
20
+ * Strips ANSI codes and counts CJK characters as width 2.
21
+ */
22
+ export function visibleWidth(s: string): number {
23
+ const stripped = stripAnsi(s);
24
+ let w = 0;
25
+ for (const ch of stripped) {
26
+ const code = ch.codePointAt(0)!;
27
+ if (code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
28
+ (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) ||
29
+ (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
30
+ (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) ||
31
+ (code >= 0x1b000 && code <= 0x1b0ff) || (code >= 0x20000 && code <= 0x2fa1f))) {
32
+ w += 2;
33
+ } else {
34
+ w += 1;
35
+ }
36
+ }
37
+ return w;
38
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Model } from "@earendil-works/pi-ai";
2
+ import type { OmModelConfig } from "../core/unified-config.js";
2
3
 
3
4
  export const AGENT_LOOP_MAX_TOKENS = 32_000;
4
5
 
@@ -7,3 +8,24 @@ export function boundedMaxTokens(model: Model<any>, requested: number = AGENT_LO
7
8
  ? Math.min(model.maxTokens, requested)
8
9
  : requested;
9
10
  }
11
+
12
+ /**
13
+ * Get the effective context window for a resolved model.
14
+ *
15
+ * Resolution order:
16
+ * 1. Config override on the model config (OmModelConfig.contextWindow)
17
+ * 2. Pi's model registry value (model.contextWindow)
18
+ * 3. Fallback default (128000)
19
+ */
20
+ export function effectiveContextWindow(
21
+ resolvedModel: Model<any>,
22
+ modelConfig?: OmModelConfig,
23
+ ): number {
24
+ if (modelConfig?.contextWindow !== undefined && modelConfig.contextWindow > 0) {
25
+ return modelConfig.contextWindow;
26
+ }
27
+ if (resolvedModel && typeof resolvedModel.contextWindow === "number" && resolvedModel.contextWindow > 0) {
28
+ return resolvedModel.contextWindow;
29
+ }
30
+ return 128_000;
31
+ }