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/README.md +63 -62
- package/package.json +1 -1
- package/src/commands/memory.ts +5 -5
- package/src/commands/pi-vcc.ts +44 -7
- package/src/core/settings.ts +17 -2
- package/src/core/unified-config.ts +184 -20
- package/src/hooks/before-compact.ts +148 -9
- package/src/om/compaction-trigger.ts +26 -12
- package/src/om/configure-overlay.ts +383 -0
- package/src/om/consolidation.ts +64 -6
- package/src/om/cooldown.ts +10 -0
- package/src/om/key-matcher.ts +38 -0
- package/src/om/model-budget.ts +22 -0
- package/src/om/runtime.ts +31 -1
- package/src/om/status-overlay.ts +243 -0
|
@@ -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
|
+
}
|
package/src/om/consolidation.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
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();
|
|
@@ -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
|
+
}
|
package/src/om/model-budget.ts
CHANGED
|
@@ -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
|
+
}
|