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.
package/src/om/runtime.ts CHANGED
@@ -45,6 +45,13 @@ export class Runtime {
45
45
  consolidationInFlight = false;
46
46
  consolidationPromise: Promise<void> | null = null;
47
47
  consolidationPhase: ConsolidationPhase | undefined;
48
+ /**
49
+ * Models that failed in the current consolidation stage (in-memory only).
50
+ * Used when cooldownHours is 0 — avoids disk writes while still letting
51
+ * the retry loop advance past the failed model within this stage.
52
+ * Cleared between stages at the pipeline level.
53
+ */
54
+ failedInCycle: Set<string> = new Set();
48
55
  compactInFlight = false;
49
56
  compactHookInFlight = false;
50
57
  resolveFailureNotified = false;
@@ -101,10 +108,23 @@ export class Runtime {
101
108
 
102
109
  // Try configured candidates
103
110
  for (const candidate of candidates) {
111
+ const key = modelKey(candidate);
112
+
113
+ // In-memory skip: model failed earlier in this stage with cooldownHours 0
114
+ if (this.failedInCycle.has(key)) {
115
+ if (ctx.hasUI && ctx.ui) {
116
+ ctx.ui.notify(
117
+ `Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`,
118
+ "info",
119
+ );
120
+ }
121
+ continue;
122
+ }
123
+
104
124
  if (isCooldownActive(candidate)) {
105
125
  if (ctx.hasUI && ctx.ui) {
106
126
  ctx.ui.notify(
107
- `Observational memory: ${stageName} skipping ${modelKey(candidate)} (cooldown active)`,
127
+ `Observational memory: ${stageName} skipping ${key} (cooldown active)`,
108
128
  "info",
109
129
  );
110
130
  }
@@ -179,9 +199,19 @@ export class Runtime {
179
199
  /**
180
200
  * Record a retryable error for a model. The model must be one of the candidates
181
201
  * (not the session model). If it's the session model we don't cool it down.
202
+ *
203
+ * When cooldownHours is explicitly 0, the model is tracked in-memory for the
204
+ * current consolidation stage (no disk writes). Otherwise a persisted cooldown
205
+ * is recorded.
182
206
  */
183
207
  recordRetryableError(modelConfig: ConfiguredModel | undefined, error: unknown, stage: ConsolidationPhase): void {
184
208
  if (!modelConfig) return;
209
+ if (modelConfig.cooldownHours === 0) {
210
+ // In-memory only: skip this model for the rest of this stage.
211
+ // No disk writes, no persistent cooldown.
212
+ this.failedInCycle.add(modelKey(modelConfig));
213
+ return;
214
+ }
185
215
  const reason = error instanceof Error ? error.message : String(error || "unknown error");
186
216
  recordCooldown(modelConfig, reason, stage);
187
217
  }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Status Overlay — shows current compaction config, OM state, and pipeline status.
3
+ *
4
+ * Used by `/blackhole-memory` to display runtime state.
5
+ * Opens as a floating overlay via ctx.ui.custom({ overlay: true }).
6
+ * Press Enter/Space on "Open configure overlay" to open config overlay.
7
+ * Esc to close.
8
+ */
9
+
10
+ import { visibleWidth } from "./key-matcher.js";
11
+ import { matchesKey } from "@earendil-works/pi-tui";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Theme shape (duck-typed from what pi provides)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ type ThemeShim = {
18
+ fg: (style: string, text: string) => string;
19
+ };
20
+
21
+ const EMPTY_THEME: ThemeShim = {
22
+ fg: (_s: string, t: string) => t,
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Status display
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface StatusInfo {
30
+ compaction: "auto" | "manual" | "off";
31
+ compactionEngine: "blackhole" | "pi-default";
32
+ tailBehavior: "pi-default" | "minimal";
33
+ memory: boolean;
34
+ compactAfterTokens: number;
35
+ consolidationInFlight: boolean;
36
+ compactInFlight: boolean;
37
+ lastObserverError?: string;
38
+ lastReflectorError?: string;
39
+ lastDropperError?: string;
40
+ }
41
+
42
+ export interface StatusResult {
43
+ action: "configure" | "close";
44
+ }
45
+
46
+ /**
47
+ * Create a StatusOverlay instance to pass to ctx.ui.custom().
48
+ *
49
+ * Usage:
50
+ * ```ts
51
+ * const result = await ctx.ui.custom<StatusResult | undefined>(
52
+ * (tui, theme, _kb, done) => createStatusOverlay(info, theme, tui, done),
53
+ * { overlay: true },
54
+ * );
55
+ * if (result?.action === "configure") {
56
+ * // open configure overlay
57
+ * }
58
+ * ```
59
+ */
60
+ export function createStatusOverlay(
61
+ info: StatusInfo,
62
+ theme: unknown,
63
+ tui: { requestRender: () => void },
64
+ done: (result: StatusResult | undefined) => void,
65
+ ): { render(width: number): string[]; handleInput(data: string): void; invalidate(): void; dispose(): void } {
66
+ const th: ThemeShim = (theme && typeof (theme as Record<string, unknown>).fg === "function")
67
+ ? theme as ThemeShim
68
+ : EMPTY_THEME;
69
+
70
+ const fg = (style: string, text: string) => th.fg(style, text);
71
+ const dim = (s: string) => fg("dim", s);
72
+ const accent = (s: string) => fg("accent", s);
73
+ const success = (s: string) => fg("success", s);
74
+ const warning = (s: string) => fg("warning", s);
75
+ const error = (s: string) => fg("error", s);
76
+ const muted = (s: string) => fg("muted", s);
77
+
78
+ const configItems: { label: string; value: string }[] = [
79
+ { label: "Compaction", value: info.compaction },
80
+ { label: "Engine", value: info.compactionEngine },
81
+ { label: "Tail behavior", value: info.tailBehavior },
82
+ { label: "Memory", value: info.memory ? "Enabled" : "Disabled" },
83
+ { label: "Threshold", value: `${info.compactAfterTokens.toLocaleString()} tok` },
84
+ ];
85
+
86
+ const pipelineItems: { label: string; value: string }[] = [
87
+ { label: "Compaction in flight", value: info.compactInFlight ? "yes" : "no" },
88
+ { label: "Consolidation in flight", value: info.consolidationInFlight ? "yes" : "no" },
89
+ ];
90
+
91
+ const errorItems: { label: string; value: string }[] = [];
92
+ if (info.lastObserverError) errorItems.push({ label: "Observer", value: info.lastObserverError });
93
+ if (info.lastReflectorError) errorItems.push({ label: "Reflector", value: info.lastReflectorError });
94
+ if (info.lastDropperError) errorItems.push({ label: "Dropper", value: info.lastDropperError });
95
+
96
+ const actionItems = [
97
+ { label: "Open configure overlay", value: "configure" as const },
98
+ { label: "Close", value: "close" as const },
99
+ ];
100
+
101
+ // Contiguous selectable item model: no section headers in nav indices
102
+ const selectableConfigCount = configItems.length;
103
+ const selectablePipelineCount = pipelineItems.length;
104
+ const selectableErrorCount = errorItems.length;
105
+ const selectableActionCount = actionItems.length;
106
+ const totalSelectable = selectableConfigCount + selectablePipelineCount + selectableErrorCount + selectableActionCount;
107
+
108
+ // Render helpers: map a contiguous nav index to the display positions
109
+ // (section headers are inserted at render time, not in the nav model)
110
+ const navIsAction = (idx: number) => idx >= selectableConfigCount + selectablePipelineCount + selectableErrorCount;
111
+
112
+ let selectedIndex = 0;
113
+ let cachedLines: string[] | undefined;
114
+
115
+ function invalidate(): void {
116
+ cachedLines = undefined;
117
+ try { tui.requestRender(); } catch { /* no-op */ }
118
+ }
119
+
120
+ function handleInput(data: string): void {
121
+ if (matchesKey(data, "escape")) {
122
+ done({ action: "close" });
123
+ return;
124
+ }
125
+
126
+ if (matchesKey(data, "enter") || matchesKey(data, "space")) {
127
+ if (navIsAction(selectedIndex)) {
128
+ const actionIdx = selectedIndex - selectableConfigCount - selectablePipelineCount - selectableErrorCount;
129
+ const action = actionItems[actionIdx]!;
130
+ done({ action: action.value });
131
+ return;
132
+ }
133
+ return;
134
+ }
135
+
136
+ if (matchesKey(data, "up")) {
137
+ selectedIndex = Math.max(0, selectedIndex - 1);
138
+ invalidate();
139
+ return;
140
+ }
141
+ if (matchesKey(data, "down")) {
142
+ selectedIndex = Math.min(totalSelectable - 1, selectedIndex + 1);
143
+ invalidate();
144
+ return;
145
+ }
146
+ }
147
+
148
+ function render(width: number): string[] {
149
+ if (cachedLines) return cachedLines;
150
+
151
+ const w = Math.max(2, Math.min(width - 2, 74));
152
+ const innerW = w - 4;
153
+
154
+ const pad = (s: string, len: number) => {
155
+ const vis = visibleWidth(s);
156
+ return s + " ".repeat(Math.max(0, len - vis));
157
+ };
158
+
159
+ const line = (content: string) => `│ ${pad(content, innerW)} │`;
160
+ const border = (s: string) => fg("border", s);
161
+
162
+ const lines: string[] = [];
163
+
164
+ // Top border + header
165
+ lines.push(fg("border", `╭${"─".repeat(w - 2)}╮`));
166
+ lines.push(fg("border", `│ ${accent("Blackhole Status")}${" ".repeat(Math.max(0, innerW + 1 - 16))}│`));
167
+ lines.push(fg("border", `├${"─".repeat(w - 2)}┤`));
168
+
169
+ // Config section
170
+ lines.push(border(line(` ${dim("─── Compaction Config ───")}`)));
171
+ for (let i = 0; i < configItems.length; i++) {
172
+ const item = configItems[i]!;
173
+ const isSelected = selectedIndex === i;
174
+ const prefix = isSelected ? accent(" ›") : " ";
175
+ const label = isSelected ? accent(`${item.label}:`) : `${item.label}:`;
176
+ let value: string;
177
+ switch (item.label) {
178
+ case "Compaction":
179
+ value = item.value === "auto" ? accent(item.value) : item.value === "off" ? muted(item.value) : dim(item.value);
180
+ break;
181
+ case "Engine":
182
+ value = item.value === "blackhole" ? success(item.value) : dim(item.value);
183
+ break;
184
+ case "Memory":
185
+ value = item.value === "Enabled" ? success("Enabled") : muted("Disabled");
186
+ break;
187
+ default:
188
+ value = dim(item.value);
189
+ }
190
+ lines.push(border(line(`${prefix} ${pad(label, 16)} ${value}`)));
191
+ }
192
+
193
+ // Pipeline section
194
+ if (pipelineItems.length > 0) {
195
+ lines.push(border(line(` ${dim("─── Pipeline ───")}`)));
196
+ for (let i = 0; i < pipelineItems.length; i++) {
197
+ const item = pipelineItems[i]!;
198
+ const isSelected = selectedIndex === selectableConfigCount + i;
199
+ const prefix = isSelected ? accent(" ›") : " ";
200
+ const label = isSelected ? accent(`${item.label}:`) : `${item.label}:`;
201
+ const value = item.value === "yes" ? warning("yes") : dim("no");
202
+ lines.push(border(line(`${prefix} ${pad(label, 16)} ${value}`)));
203
+ }
204
+ }
205
+
206
+ // Errors section
207
+ if (errorItems.length > 0) {
208
+ lines.push(border(line(` ${dim("─── Last Errors ───")}`)));
209
+ for (let i = 0; i < errorItems.length; i++) {
210
+ const item = errorItems[i]!;
211
+ const isSelected = selectedIndex === selectableConfigCount + selectablePipelineCount + i;
212
+ const prefix = isSelected ? accent(" ›") : " ";
213
+ const label = isSelected ? accent(`${item.label}:`) : `${item.label}:`;
214
+ const truncated = item.value.length > 40
215
+ ? item.value.slice(0, 37) + "..."
216
+ : item.value;
217
+ lines.push(border(line(`${prefix} ${pad(label, 16)} ${error(truncated)}`)));
218
+ }
219
+ }
220
+
221
+ // Separator
222
+ lines.push(border(line(` ${dim("─── Actions ───")}`)));
223
+
224
+ // Action items
225
+ for (let i = 0; i < actionItems.length; i++) {
226
+ const item = actionItems[i]!;
227
+ const isSelected = selectedIndex === selectableConfigCount + selectablePipelineCount + selectableErrorCount + i;
228
+ const prefix = isSelected ? accent(" ▶") : " ";
229
+ const text = isSelected ? accent(item.label) : dim(item.label);
230
+ lines.push(border(line(`${prefix} ${text}`)));
231
+ }
232
+
233
+ // Bottom hints
234
+ lines.push(border(line("")));
235
+ lines.push(border(dim(" ↑↓ navigate Enter/Space select Esc close ")));
236
+ lines.push(fg("border", `╰${"─".repeat(w - 2)}╯`));
237
+
238
+ cachedLines = lines;
239
+ return lines;
240
+ }
241
+
242
+ return { render, handleInput, invalidate, dispose: () => {} };
243
+ }