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 +1 -1
- package/src/core/unified-config.ts +6 -1
- package/src/om/configure-overlay.ts +15 -13
- package/src/om/consolidation.ts +22 -5
- package/src/om/cooldown.ts +10 -0
- package/src/om/key-matcher.ts +0 -34
- package/src/om/runtime.ts +31 -1
- package/src/om/status-overlay.ts +6 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-blackhole",
|
|
3
|
-
"version": "0.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 =
|
|
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 {
|
|
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 (
|
|
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 (
|
|
201
|
+
if (matchesKey(data, "enter") || matchesKey(data, "tab")) {
|
|
201
202
|
cur.editing = false;
|
|
202
203
|
invalidate();
|
|
203
204
|
return;
|
|
204
205
|
}
|
|
205
|
-
if (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
224
|
-
|
|
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 (
|
|
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 (
|
|
243
|
+
if (matchesKey(data, "escape")) {
|
|
242
244
|
done(undefined);
|
|
243
245
|
return;
|
|
244
246
|
}
|
|
245
247
|
|
|
246
248
|
// Enter/space → edit/toggle
|
|
247
|
-
if (
|
|
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 (
|
|
273
|
+
if (matchesKey(data, "up")) {
|
|
272
274
|
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
273
275
|
invalidate();
|
|
274
276
|
return;
|
|
275
277
|
}
|
|
276
|
-
if (
|
|
278
|
+
if (matchesKey(data, "down")) {
|
|
277
279
|
selectedIndex = Math.min(fields.length - 1, selectedIndex + 1);
|
|
278
280
|
invalidate();
|
|
279
281
|
return;
|
package/src/om/consolidation.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|
package/src/om/cooldown.ts
CHANGED
|
@@ -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();
|
package/src/om/key-matcher.ts
CHANGED
|
@@ -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 ${
|
|
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
|
}
|
package/src/om/status-overlay.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Esc to close.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
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 (
|
|
121
|
+
if (matchesKey(data, "escape")) {
|
|
121
122
|
done({ action: "close" });
|
|
122
123
|
return;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
if (
|
|
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 (
|
|
136
|
+
if (matchesKey(data, "up")) {
|
|
136
137
|
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
137
138
|
invalidate();
|
|
138
139
|
return;
|
|
139
140
|
}
|
|
140
|
-
if (
|
|
141
|
+
if (matchesKey(data, "down")) {
|
|
141
142
|
selectedIndex = Math.min(totalSelectable - 1, selectedIndex + 1);
|
|
142
143
|
invalidate();
|
|
143
144
|
return;
|