pi-blackhole 0.3.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-blackhole",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "packageManager": "pnpm@11.2.2",
5
5
  "description": "Unified compaction + observational memory extension for Pi — compresses conversation context while preserving durable observations and reflections",
6
6
  "license": "MIT",
@@ -187,6 +187,11 @@ function positiveInt(v: unknown): number | undefined {
187
187
  return Number.isInteger(v) && typeof v === "number" && v > 0 ? v : undefined;
188
188
  }
189
189
 
190
+ /** Like positiveInt but allows 0. Used for cooldownHours where 0 means "disabled". */
191
+ function nonNegativeInt(v: unknown): number | undefined {
192
+ return Number.isInteger(v) && typeof v === "number" && v >= 0 ? v : undefined;
193
+ }
194
+
190
195
  function parseModel(v: unknown): OmModelConfig | undefined {
191
196
  if (!isRecord(v)) return undefined;
192
197
  const provider = nonEmptyString(v.provider);
@@ -194,7 +199,7 @@ function parseModel(v: unknown): OmModelConfig | undefined {
194
199
  if (!provider || !id) return undefined;
195
200
  const model: OmModelConfig = { provider, id };
196
201
  if (isThinkingLevel(v.thinking)) model.thinking = v.thinking;
197
- const cooldown = positiveInt(v.cooldownHours);
202
+ const cooldown = nonNegativeInt(v.cooldownHours);
198
203
  if (cooldown !== undefined) model.cooldownHours = cooldown;
199
204
  const ctxWindow = positiveInt(v.contextWindow);
200
205
  if (ctxWindow !== undefined) model.contextWindow = ctxWindow;
@@ -6,7 +6,8 @@
6
6
  * Navigation: ↑↓ Edit: Enter Save: Ctrl+S Cancel: Esc
7
7
  */
8
8
 
9
- import { matchKey, visibleWidth } from "./key-matcher.js";
9
+ import { visibleWidth } from "./key-matcher.js";
10
+ import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
10
11
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
12
  import { dirname } from "node:path";
12
13
 
@@ -191,18 +192,18 @@ export function createConfigureOverlay(
191
192
 
192
193
  // While editing a number field
193
194
  if (cur.editing) {
194
- if (matchKey(data, "escape")) {
195
+ if (matchesKey(data, "escape")) {
195
196
  cur.value = formatValue(cur.def, raw[cur.def.key]);
196
197
  cur.editing = false;
197
198
  invalidate();
198
199
  return;
199
200
  }
200
- if (matchKey(data, "enter") || matchKey(data, "tab")) {
201
+ if (matchesKey(data, "enter") || matchesKey(data, "tab")) {
201
202
  cur.editing = false;
202
203
  invalidate();
203
204
  return;
204
205
  }
205
- if (matchKey(data, "backspace")) {
206
+ if (matchesKey(data, "backspace")) {
206
207
  if (cur.cursor > 0) {
207
208
  cur.value = cur.value.slice(0, cur.cursor - 1) + cur.value.slice(cur.cursor);
208
209
  cur.cursor--;
@@ -210,18 +211,19 @@ export function createConfigureOverlay(
210
211
  }
211
212
  return;
212
213
  }
213
- if (matchKey(data, "left")) {
214
+ if (matchesKey(data, "left")) {
214
215
  cur.cursor = Math.max(0, cur.cursor - 1);
215
216
  invalidate();
216
217
  return;
217
218
  }
218
- if (matchKey(data, "right")) {
219
+ if (matchesKey(data, "right")) {
219
220
  cur.cursor = Math.min(cur.value.length, cur.cursor + 1);
220
221
  invalidate();
221
222
  return;
222
223
  }
223
- if (data.length === 1 && data >= "0" && data <= "9") {
224
- cur.value = cur.value.slice(0, cur.cursor) + data + cur.value.slice(cur.cursor);
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);
225
227
  cur.cursor++;
226
228
  invalidate();
227
229
  }
@@ -231,20 +233,20 @@ export function createConfigureOverlay(
231
233
  // Not editing — global navigation
232
234
 
233
235
  // Ctrl+S → save and close
234
- if (matchKey(data, "ctrl+s")) {
236
+ if (matchesKey(data, "ctrl+s")) {
235
237
  const saved = save();
236
238
  done({ saved, path: configPath });
237
239
  return;
238
240
  }
239
241
 
240
242
  // Esc → close without saving
241
- if (matchKey(data, "escape")) {
243
+ if (matchesKey(data, "escape")) {
242
244
  done(undefined);
243
245
  return;
244
246
  }
245
247
 
246
248
  // Enter/space → edit/toggle
247
- if (matchKey(data, "enter") || matchKey(data, "space")) {
249
+ if (matchesKey(data, "enter") || matchesKey(data, "space")) {
248
250
  switch (cur.def.type) {
249
251
  case "boolean":
250
252
  cur.value = cur.value === "on" ? "off" : "on";
@@ -268,12 +270,12 @@ export function createConfigureOverlay(
268
270
  }
269
271
 
270
272
  // ↑↓ navigation
271
- if (matchKey(data, "up")) {
273
+ if (matchesKey(data, "up")) {
272
274
  selectedIndex = Math.max(0, selectedIndex - 1);
273
275
  invalidate();
274
276
  return;
275
277
  }
276
- if (matchKey(data, "down")) {
278
+ if (matchesKey(data, "down")) {
277
279
  selectedIndex = Math.min(fields.length - 1, selectedIndex + 1);
278
280
  invalidate();
279
281
  return;
@@ -59,9 +59,9 @@ import {
59
59
  type Reflection,
60
60
  } from "./ledger/index.js";
61
61
 
62
- type ResolvedModel = Extract<ResolveResult, { ok: true }>;
62
+ export type ResolvedModel = Extract<ResolveResult, { ok: true }>;
63
63
 
64
- type ConsolidationCtx = {
64
+ export type ConsolidationCtx = {
65
65
  cwd: string;
66
66
  hasUI: boolean;
67
67
  ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
@@ -182,15 +182,16 @@ function stageThinkingLevel(runtime: Runtime, stage: "observer" | "reflector" |
182
182
  return stageModel?.thinking ?? runtime.config.model?.thinking ?? "low";
183
183
  }
184
184
 
185
- 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> {
186
186
  return async (stage) => {
187
+ const stageFallbacks = stageFallbackModels(runtime, stage);
187
188
  const resolved = await runtime.resolveModel({
188
189
  model: ctx.model,
189
190
  modelRegistry: ctx.modelRegistry,
190
191
  hasUI: ctx.hasUI,
191
192
  ui: ctx.ui,
192
193
  stageModel: stageModelConfig(runtime, stage),
193
- stageFallbacks: stageFallbackModels(runtime, stage),
194
+ stageFallbacks,
194
195
  });
195
196
  if (resolved.ok) {
196
197
  runtime.resolveFailureNotified = false;
@@ -198,7 +199,17 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
198
199
  }
199
200
  debugLog(`${stage}.model_unavailable`, { reason: resolved.reason });
200
201
  if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
201
- 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
+ }
202
213
  runtime.resolveFailureNotified = true;
203
214
  }
204
215
  return undefined;
@@ -254,6 +265,8 @@ export async function runConsolidationPipeline(
254
265
  const resolveModel = makeModelResolver(runtime, ctx);
255
266
 
256
267
  runtime.consolidationPhase = "observer";
268
+ runtime.failedInCycle.clear();
269
+ runtime.resolveFailureNotified = false;
257
270
  try {
258
271
  const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel);
259
272
  if (observerOutcome === "abort") return;
@@ -263,6 +276,8 @@ export async function runConsolidationPipeline(
263
276
  }
264
277
 
265
278
  runtime.consolidationPhase = "reflector";
279
+ runtime.failedInCycle.clear();
280
+ runtime.resolveFailureNotified = false;
266
281
  let reflectorResult: ReflectorStageResult;
267
282
  try {
268
283
  reflectorResult = await runReflectorStage(pi, runtime, ctx, resolveModel);
@@ -273,6 +288,8 @@ export async function runConsolidationPipeline(
273
288
  }
274
289
 
275
290
  runtime.consolidationPhase = "dropper";
291
+ runtime.failedInCycle.clear();
292
+ runtime.resolveFailureNotified = false;
276
293
  try {
277
294
  await runDropperStage(pi, runtime, ctx, resolveModel, reflectorResult.sameRunReflections, reflectorResult.effectiveReflectionCoverageId);
278
295
  } catch (error) {
@@ -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();
@@ -1,46 +1,12 @@
1
1
  /**
2
2
  * Terminal utilities shared by blackhole overlay components.
3
3
  *
4
- * Key matcher — standalone replacement for pi-tui's `matchesKey`
5
- * to keep overlay components free of that dependency.
6
- *
7
4
  * visibleWidth — CJK-aware visible width for terminal columns,
8
5
  * extracted from pi-tui to avoid import resolution issues.
9
6
  *
10
7
  * Ported from voice-type extension's key-matcher.ts.
11
8
  */
12
9
 
13
- // ---------------------------------------------------------------------------
14
- // Key matching
15
- // ---------------------------------------------------------------------------
16
-
17
- export function matchKey(data: string, key: string): boolean {
18
- // Escape
19
- if (key === "escape") return data === "\x1b";
20
- // Enter
21
- if (key === "enter") return data === "\r" || data === "\n";
22
- // Tab
23
- if (key === "tab") return data === "\t";
24
- // Space
25
- if (key === "space") return data === " ";
26
- // Backspace
27
- if (key === "backspace") return data === "\x7f" || data === "\b";
28
- // Arrows
29
- if (key === "up") return data === "\x1b[A" || data === "\x1bOA";
30
- if (key === "down") return data === "\x1b[B" || data === "\x1bOB";
31
- if (key === "left") return data === "\x1b[D" || data === "\x1bOD";
32
- if (key === "right") return data === "\x1b[C" || data === "\x1bOC";
33
- // Ctrl+letter (must be exactly 6 chars: "ctrl+" + one lowercase letter)
34
- if (key.startsWith("ctrl+") && key.length === 6) {
35
- const letter = key[5];
36
- if (letter && letter >= "a" && letter <= "z") {
37
- const code = letter.charCodeAt(0) - 96; // ctrl+a = 1, ctrl+z = 26
38
- return data.length === 1 && data.charCodeAt(0) === code;
39
- }
40
- }
41
- return false;
42
- }
43
-
44
10
  // ---------------------------------------------------------------------------
45
11
  // Visible width (CJK-aware)
46
12
  // ---------------------------------------------------------------------------
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
  }
@@ -7,7 +7,8 @@
7
7
  * Esc to close.
8
8
  */
9
9
 
10
- import { matchKey, visibleWidth } from "./key-matcher.js";
10
+ import { visibleWidth } from "./key-matcher.js";
11
+ import { matchesKey } from "@earendil-works/pi-tui";
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Theme shape (duck-typed from what pi provides)
@@ -117,12 +118,12 @@ export function createStatusOverlay(
117
118
  }
118
119
 
119
120
  function handleInput(data: string): void {
120
- if (matchKey(data, "escape")) {
121
+ if (matchesKey(data, "escape")) {
121
122
  done({ action: "close" });
122
123
  return;
123
124
  }
124
125
 
125
- if (matchKey(data, "enter") || matchKey(data, "space")) {
126
+ if (matchesKey(data, "enter") || matchesKey(data, "space")) {
126
127
  if (navIsAction(selectedIndex)) {
127
128
  const actionIdx = selectedIndex - selectableConfigCount - selectablePipelineCount - selectableErrorCount;
128
129
  const action = actionItems[actionIdx]!;
@@ -132,12 +133,12 @@ export function createStatusOverlay(
132
133
  return;
133
134
  }
134
135
 
135
- if (matchKey(data, "up")) {
136
+ if (matchesKey(data, "up")) {
136
137
  selectedIndex = Math.max(0, selectedIndex - 1);
137
138
  invalidate();
138
139
  return;
139
140
  }
140
- if (matchKey(data, "down")) {
141
+ if (matchesKey(data, "down")) {
141
142
  selectedIndex = Math.min(totalSelectable - 1, selectedIndex + 1);
142
143
  invalidate();
143
144
  return;