pi-blackhole 0.3.1 → 0.3.3
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 +66 -43
- 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 +178 -19
- package/src/hooks/before-compact.ts +148 -9
- package/src/om/compaction-trigger.ts +28 -6
- package/src/om/configure-overlay.ts +381 -0
- package/src/om/consolidation.ts +42 -1
- package/src/om/key-matcher.ts +72 -0
- package/src/om/model-budget.ts +22 -0
- package/src/om/status-overlay.ts +242 -0
|
@@ -0,0 +1,381 @@
|
|
|
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 { matchKey, visibleWidth } from "./key-matcher.js";
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Field types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
interface FieldDef {
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
type: "number" | "boolean" | "enum";
|
|
21
|
+
section: string;
|
|
22
|
+
enumValues?: string[];
|
|
23
|
+
helpText?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FieldState {
|
|
27
|
+
def: FieldDef;
|
|
28
|
+
value: string;
|
|
29
|
+
editing: boolean;
|
|
30
|
+
cursor: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Field definitions
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const FIELDS: FieldDef[] = [
|
|
38
|
+
// ── Compaction ──
|
|
39
|
+
{ key: "compaction", label: "Compaction mode", type: "enum", section: "Compaction", enumValues: ["auto", "manual", "off"],
|
|
40
|
+
helpText: "auto=trigger on threshold, manual=only /blackhole, off=auto:Pi handles, /blackhole:blackhole pipeline" },
|
|
41
|
+
{ key: "compactionEngine", label: "Compaction engine", type: "enum", section: "Compaction", enumValues: ["blackhole", "pi-default"],
|
|
42
|
+
helpText: "blackhole=structured summary+OM, pi-default=built-in Pi summarization" },
|
|
43
|
+
{ key: "tailBehavior", label: "Visible tail", type: "enum", section: "Compaction", enumValues: ["minimal", "pi-default"],
|
|
44
|
+
helpText: "minimal=keep last user message only (default), pi-default=keep Pi's preserved visible context" },
|
|
45
|
+
{ key: "compactAfterTokens", label: "Auto-compact threshold", type: "number", section: "Compaction",
|
|
46
|
+
helpText: "Token count that triggers auto-compaction when reached" },
|
|
47
|
+
|
|
48
|
+
// ── Observational Memory ──
|
|
49
|
+
{ key: "memory", label: "Observational memory", type: "boolean", section: "Observational Memory",
|
|
50
|
+
helpText: "Enable OM workers (observer, reflector, dropper) and content injection" },
|
|
51
|
+
{ key: "observeAfterTokens", label: "Observer threshold", type: "number", section: "Observational Memory",
|
|
52
|
+
helpText: "Tokens accumulated since last observer run before triggering next observe" },
|
|
53
|
+
{ key: "reflectAfterTokens", label: "Reflect + dropper threshold", type: "number", section: "Observational Memory",
|
|
54
|
+
helpText: "Tokens accumulated since last reflect before triggering reflector and dropper" },
|
|
55
|
+
{ key: "observationsPoolMaxTokens", label: "Observation pool max", type: "number", section: "Observational Memory",
|
|
56
|
+
helpText: "Max tokens in observation pool before dropper prunes (fold pressure)" },
|
|
57
|
+
{ key: "observationsPoolTargetTokens", label: "Observation pool target", type: "number", section: "Observational Memory",
|
|
58
|
+
helpText: "Target tokens after dropper prunes (defaults to half of pool max)" },
|
|
59
|
+
{ key: "reflectorInputMaxTokens", label: "Reflector input max", type: "number", section: "Observational Memory",
|
|
60
|
+
helpText: "Max prompt tokens for reflector model input (rolling window cap)" },
|
|
61
|
+
{ key: "dropperInputMaxTokens", label: "Dropper input max", type: "number", section: "Observational Memory",
|
|
62
|
+
helpText: "Max prompt tokens for dropper model input (rolling window cap)" },
|
|
63
|
+
{ key: "observerChunkMaxTokens", label: "Observer chunk max", type: "number", section: "Observational Memory",
|
|
64
|
+
helpText: "Max source entry tokens sent to observer per chunk" },
|
|
65
|
+
{ key: "observerPreambleMaxTokens", label: "Observer preamble max", type: "number", section: "Observational Memory",
|
|
66
|
+
helpText: "Preamble budget in manual compaction mode (0=auto-compute 30% of chunk)" },
|
|
67
|
+
{ key: "agentMaxTurns", label: "Max turns per agent", type: "number", section: "Observational Memory",
|
|
68
|
+
helpText: "Shared turn cap for background memory agents" },
|
|
69
|
+
|
|
70
|
+
// ── Debug ──
|
|
71
|
+
{ key: "debug", label: "Debug snapshots", type: "boolean", section: "Debug",
|
|
72
|
+
helpText: "Write detailed debug snapshots to /tmp/pi-blackhole-debug.json" },
|
|
73
|
+
{ key: "debugLog", label: "Debug JSONL logging", type: "boolean", section: "Debug",
|
|
74
|
+
helpText: "Write structured JSONL debug logs to agent directory" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Helpers
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function formatValue(def: FieldDef, rawValue: unknown): string {
|
|
82
|
+
if (rawValue === undefined || rawValue === null) return "";
|
|
83
|
+
switch (def.type) {
|
|
84
|
+
case "boolean":
|
|
85
|
+
return rawValue ? "on" : "off";
|
|
86
|
+
default:
|
|
87
|
+
return String(rawValue);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Theme helper — wraps a minimal theme shape
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
type ThemeShim = {
|
|
96
|
+
fg: (style: string, text: string) => string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const EMPTY_THEME: ThemeShim = {
|
|
100
|
+
fg: (_s: string, t: string) => t,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Overlay Component factory
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export interface OverlayResult {
|
|
108
|
+
saved: boolean;
|
|
109
|
+
path: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a ConfigureOverlay instance to pass to ctx.ui.custom().
|
|
114
|
+
*
|
|
115
|
+
* Usage:
|
|
116
|
+
* ```ts
|
|
117
|
+
* const result = await ctx.ui.custom<OverlayResult | undefined>(
|
|
118
|
+
* (tui, theme, _kb, done) => createConfigureOverlay(configPath, theme, tui, done),
|
|
119
|
+
* { overlay: true },
|
|
120
|
+
* );
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function createConfigureOverlay(
|
|
124
|
+
configPath: string,
|
|
125
|
+
theme: unknown,
|
|
126
|
+
tui: { requestRender: () => void },
|
|
127
|
+
done: (result: OverlayResult | undefined) => void,
|
|
128
|
+
): { render(width: number): string[]; handleInput(data: string): void; invalidate(): void; dispose(): void } {
|
|
129
|
+
const th: ThemeShim = (theme && typeof (theme as Record<string, unknown>).fg === "function")
|
|
130
|
+
? theme as ThemeShim
|
|
131
|
+
: EMPTY_THEME;
|
|
132
|
+
|
|
133
|
+
// Parse config file
|
|
134
|
+
let raw: Record<string, unknown>;
|
|
135
|
+
try {
|
|
136
|
+
raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
137
|
+
} catch {
|
|
138
|
+
raw = {};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fields: FieldState[] = FIELDS.map((def) => ({
|
|
142
|
+
def,
|
|
143
|
+
value: formatValue(def, raw[def.key]),
|
|
144
|
+
editing: false,
|
|
145
|
+
cursor: 0,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
let selectedIndex = 0;
|
|
149
|
+
let cachedLines: string[] | undefined;
|
|
150
|
+
|
|
151
|
+
function invalidate(): void {
|
|
152
|
+
cachedLines = undefined;
|
|
153
|
+
try { tui.requestRender(); } catch { /* no-op */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function save(): boolean {
|
|
157
|
+
try {
|
|
158
|
+
const updated = { ...raw };
|
|
159
|
+
for (const f of fields) {
|
|
160
|
+
const val = f.value;
|
|
161
|
+
if (val === "" && !(f.def.key in raw)) continue;
|
|
162
|
+
switch (f.def.type) {
|
|
163
|
+
case "boolean":
|
|
164
|
+
updated[f.def.key] = val === "on";
|
|
165
|
+
break;
|
|
166
|
+
case "number": {
|
|
167
|
+
const num = Number(val);
|
|
168
|
+
updated[f.def.key] = (val && !isNaN(num)) ? num : raw[f.def.key];
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "enum":
|
|
172
|
+
updated[f.def.key] = val;
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
updated[f.def.key] = val;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
180
|
+
writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n");
|
|
181
|
+
raw = updated;
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleInput(data: string): void {
|
|
189
|
+
const cur = fields[selectedIndex];
|
|
190
|
+
if (!cur) return;
|
|
191
|
+
|
|
192
|
+
// While editing a number field
|
|
193
|
+
if (cur.editing) {
|
|
194
|
+
if (matchKey(data, "escape")) {
|
|
195
|
+
cur.value = formatValue(cur.def, raw[cur.def.key]);
|
|
196
|
+
cur.editing = false;
|
|
197
|
+
invalidate();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (matchKey(data, "enter") || matchKey(data, "tab")) {
|
|
201
|
+
cur.editing = false;
|
|
202
|
+
invalidate();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (matchKey(data, "backspace")) {
|
|
206
|
+
if (cur.cursor > 0) {
|
|
207
|
+
cur.value = cur.value.slice(0, cur.cursor - 1) + cur.value.slice(cur.cursor);
|
|
208
|
+
cur.cursor--;
|
|
209
|
+
invalidate();
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (matchKey(data, "left")) {
|
|
214
|
+
cur.cursor = Math.max(0, cur.cursor - 1);
|
|
215
|
+
invalidate();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (matchKey(data, "right")) {
|
|
219
|
+
cur.cursor = Math.min(cur.value.length, cur.cursor + 1);
|
|
220
|
+
invalidate();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (data.length === 1 && data >= "0" && data <= "9") {
|
|
224
|
+
cur.value = cur.value.slice(0, cur.cursor) + data + cur.value.slice(cur.cursor);
|
|
225
|
+
cur.cursor++;
|
|
226
|
+
invalidate();
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Not editing — global navigation
|
|
232
|
+
|
|
233
|
+
// Ctrl+S → save and close
|
|
234
|
+
if (matchKey(data, "ctrl+s")) {
|
|
235
|
+
const saved = save();
|
|
236
|
+
done({ saved, path: configPath });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Esc → close without saving
|
|
241
|
+
if (matchKey(data, "escape")) {
|
|
242
|
+
done(undefined);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Enter/space → edit/toggle
|
|
247
|
+
if (matchKey(data, "enter") || matchKey(data, "space")) {
|
|
248
|
+
switch (cur.def.type) {
|
|
249
|
+
case "boolean":
|
|
250
|
+
cur.value = cur.value === "on" ? "off" : "on";
|
|
251
|
+
invalidate();
|
|
252
|
+
break;
|
|
253
|
+
case "enum": {
|
|
254
|
+
const vals = cur.def.enumValues;
|
|
255
|
+
if (!vals || vals.length === 0) return;
|
|
256
|
+
const idx = vals.indexOf(cur.value);
|
|
257
|
+
cur.value = vals[(idx + 1) % vals.length];
|
|
258
|
+
invalidate();
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
case "number":
|
|
262
|
+
cur.editing = true;
|
|
263
|
+
cur.cursor = cur.value.length;
|
|
264
|
+
invalidate();
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ↑↓ navigation
|
|
271
|
+
if (matchKey(data, "up")) {
|
|
272
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
273
|
+
invalidate();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (matchKey(data, "down")) {
|
|
277
|
+
selectedIndex = Math.min(fields.length - 1, selectedIndex + 1);
|
|
278
|
+
invalidate();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Compute minimum width to fit all content, plus borders. */
|
|
284
|
+
function contentWidth(): number {
|
|
285
|
+
const longestHelp = FIELDS.reduce((max, f) => {
|
|
286
|
+
if (!f.helpText) return max;
|
|
287
|
+
return Math.max(max, visibleWidth(f.helpText));
|
|
288
|
+
}, 0);
|
|
289
|
+
// Help line: │ ${help} ${│ → 4 chars overhead + at least 1 space
|
|
290
|
+
const helpOverhead = 4;
|
|
291
|
+
const longestLabel = FIELDS.reduce((max, f) => {
|
|
292
|
+
return Math.max(max, visibleWidth(f.label) + 3); // prefix + space + label
|
|
293
|
+
}, 0);
|
|
294
|
+
// Section header: ── N ── → up to ~30, but help text dominates
|
|
295
|
+
// Hint line: ~54 visible
|
|
296
|
+
return Math.max(54 + 4, longestHelp + helpOverhead, longestLabel + 20);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function render(width: number): string[] {
|
|
300
|
+
if (cachedLines) return cachedLines;
|
|
301
|
+
|
|
302
|
+
const minW = contentWidth();
|
|
303
|
+
const w = Math.max(2, Math.min(width - 2, Math.max(minW, 50)));
|
|
304
|
+
const innerW = w - 4;
|
|
305
|
+
const fg = (style: string, text: string) => th.fg(style, text);
|
|
306
|
+
|
|
307
|
+
const lines: string[] = [];
|
|
308
|
+
|
|
309
|
+
// Top border + header
|
|
310
|
+
lines.push(fg("border", `╭${"─".repeat(w - 2)}╮`));
|
|
311
|
+
lines.push(fg("border", `│ ${fg("accent", "Blackhole Configuration")}${" ".repeat(Math.max(0, innerW + 1 - 24))}│`));
|
|
312
|
+
lines.push(fg("border", `├${"─".repeat(w - 2)}┤`));
|
|
313
|
+
|
|
314
|
+
let currentSection = "";
|
|
315
|
+
|
|
316
|
+
for (let i = 0; i < fields.length; i++) {
|
|
317
|
+
const f = fields[i];
|
|
318
|
+
const isSelected = i === selectedIndex;
|
|
319
|
+
const isEditing = isSelected && f.editing;
|
|
320
|
+
|
|
321
|
+
// Section header
|
|
322
|
+
if (f.def.section !== currentSection) {
|
|
323
|
+
currentSection = f.def.section;
|
|
324
|
+
if (i > 0) {
|
|
325
|
+
lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
|
|
326
|
+
}
|
|
327
|
+
lines.push(fg("border", `│ ${fg("dim", `── ${currentSection} ──`)}${" ".repeat(Math.max(0, innerW + 1 - currentSection.length - 6))}│`));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Field label
|
|
331
|
+
const prefix = isSelected ? fg("accent", isEditing ? ">>" : " >") : " ";
|
|
332
|
+
const label = isSelected ? fg("accent", `${f.def.label}:`) : fg("text", `${f.def.label}:`);
|
|
333
|
+
const labelStr = `${prefix} ${label}`;
|
|
334
|
+
const labelVis = visibleWidth(labelStr);
|
|
335
|
+
|
|
336
|
+
// Value
|
|
337
|
+
let valueStr: string;
|
|
338
|
+
if (isEditing) {
|
|
339
|
+
const before = f.value.slice(0, f.cursor);
|
|
340
|
+
const cursorChar = f.cursor < f.value.length ? f.value[f.cursor] : " ";
|
|
341
|
+
const after = f.value.slice(f.cursor + 1);
|
|
342
|
+
valueStr = `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;
|
|
343
|
+
} else {
|
|
344
|
+
switch (f.def.type) {
|
|
345
|
+
case "boolean":
|
|
346
|
+
valueStr = f.value === "on" ? fg("success", "on") : fg("muted", "off");
|
|
347
|
+
break;
|
|
348
|
+
default:
|
|
349
|
+
valueStr = f.value ? fg("text", f.value) : fg("dim", "(empty)");
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const valSpace = Math.max(10, innerW - labelVis - 1);
|
|
355
|
+
const truncated = visibleWidth(valueStr) > valSpace
|
|
356
|
+
? valueStr.slice(0, Math.max(0, valSpace - 3)) + "..."
|
|
357
|
+
: valueStr;
|
|
358
|
+
const remaining = innerW + 1 - labelVis - visibleWidth(truncated);
|
|
359
|
+
|
|
360
|
+
lines.push(fg("border", `│ ${labelStr}${truncated}${" ".repeat(Math.max(1, remaining))}│`));
|
|
361
|
+
|
|
362
|
+
// Help text for selected item
|
|
363
|
+
if (isSelected && f.def.helpText && !isEditing) {
|
|
364
|
+
const help = fg("dim", f.def.helpText);
|
|
365
|
+
const helpRemaining = innerW - visibleWidth(help);
|
|
366
|
+
lines.push(fg("border", `│ ${help}${" ".repeat(Math.max(1, helpRemaining))}│`));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Bottom hints
|
|
371
|
+
lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
|
|
372
|
+
const hintText = " Ctrl+S save \u2191\u2193 navigate Enter toggle Esc cancel ";
|
|
373
|
+
lines.push(fg("border", `│${fg("accent", hintText)}${" ".repeat(Math.max(1, innerW + 2 - visibleWidth(hintText)))}│`));
|
|
374
|
+
lines.push(fg("border", `╰${"─".repeat(w - 2)}╯`));
|
|
375
|
+
|
|
376
|
+
cachedLines = lines;
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { render, handleInput, invalidate, dispose: () => {} };
|
|
381
|
+
}
|
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,
|
|
@@ -213,8 +217,12 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
|
|
|
213
217
|
|
|
214
218
|
function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
|
|
215
219
|
runtime.ensureConfig(ctx.cwd);
|
|
216
|
-
if (runtime.config.passive === true) return;
|
|
217
220
|
if (runtime.config.memory === false) return;
|
|
221
|
+
|
|
222
|
+
// LEGACY: passive check — only applies when new keys are absent (unmigrated config)
|
|
223
|
+
if (runtime.config.compaction === undefined && runtime.config.compactionEngine === undefined) {
|
|
224
|
+
if (runtime.config.passive === true) return;
|
|
225
|
+
}
|
|
218
226
|
if (runtime.consolidationInFlight) return;
|
|
219
227
|
if (runtime.isConsolidationRetryGated()) return;
|
|
220
228
|
|
|
@@ -363,6 +371,17 @@ async function runObserverStage(
|
|
|
363
371
|
|
|
364
372
|
// Resolve thinking level for the specific model (fallbacks may have their own thinking config)
|
|
365
373
|
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") });
|
|
374
|
+
|
|
375
|
+
// Check if estimated input fits in model's context window
|
|
376
|
+
const effectiveObsCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
|
|
377
|
+
const observerEstimatedInput = runtime.config.observerChunkMaxTokens + AGENT_LOOP_RESERVE;
|
|
378
|
+
if (observerEstimatedInput > effectiveObsCtx) {
|
|
379
|
+
debugLog("observer.context_window_exceeded", { estimatedInput: observerEstimatedInput, effectiveCtx: effectiveObsCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
380
|
+
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveObsCtx} too small for estimated input ${observerEstimatedInput}`), "observer");
|
|
381
|
+
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");
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
366
385
|
try {
|
|
367
386
|
const result = await runObserver({
|
|
368
387
|
model: resolved.model as any,
|
|
@@ -497,6 +516,17 @@ async function runReflectorStage(
|
|
|
497
516
|
|
|
498
517
|
// Resolve thinking level for the specific model (fallbacks may have their own thinking config)
|
|
499
518
|
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") });
|
|
519
|
+
|
|
520
|
+
// Check if estimated input fits in model's context window
|
|
521
|
+
const effectiveRefCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
|
|
522
|
+
const reflectorEstimatedInput = runtime.config.reflectorInputMaxTokens + AGENT_LOOP_RESERVE;
|
|
523
|
+
if (reflectorEstimatedInput > effectiveRefCtx) {
|
|
524
|
+
debugLog("reflector.context_window_exceeded", { estimatedInput: reflectorEstimatedInput, effectiveCtx: effectiveRefCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
525
|
+
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveRefCtx} too small for estimated input ${reflectorEstimatedInput}`), "reflector");
|
|
526
|
+
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");
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
500
530
|
try {
|
|
501
531
|
// Existing memory summaries for context (capped).
|
|
502
532
|
// In noAutoCompact, merge accumulated pending batches with
|
|
@@ -643,6 +673,17 @@ async function runDropperStage(
|
|
|
643
673
|
|
|
644
674
|
// Resolve thinking level for the specific model (fallbacks may have their own thinking config)
|
|
645
675
|
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") });
|
|
676
|
+
|
|
677
|
+
// Check if estimated input fits in model's context window
|
|
678
|
+
const effectiveDropCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
|
|
679
|
+
const dropperEstimatedInput = runtime.config.dropperInputMaxTokens + AGENT_LOOP_RESERVE;
|
|
680
|
+
if (dropperEstimatedInput > effectiveDropCtx) {
|
|
681
|
+
debugLog("dropper.context_window_exceeded", { estimatedInput: dropperEstimatedInput, effectiveCtx: effectiveDropCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
682
|
+
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveDropCtx} too small for estimated input ${dropperEstimatedInput}`), "dropper");
|
|
683
|
+
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");
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
|
|
646
687
|
const droppedIds = await runDropper({
|
|
647
688
|
model: resolved.model as any,
|
|
648
689
|
apiKey: resolved.apiKey,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal utilities shared by blackhole overlay components.
|
|
3
|
+
*
|
|
4
|
+
* Key matcher — standalone replacement for pi-tui's `matchesKey`
|
|
5
|
+
* to keep overlay components free of that dependency.
|
|
6
|
+
*
|
|
7
|
+
* visibleWidth — CJK-aware visible width for terminal columns,
|
|
8
|
+
* extracted from pi-tui to avoid import resolution issues.
|
|
9
|
+
*
|
|
10
|
+
* Ported from voice-type extension's key-matcher.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Visible width (CJK-aware)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function stripAnsi(s: string): string {
|
|
49
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate the visible width of a string in terminal columns.
|
|
54
|
+
* Strips ANSI codes and counts CJK characters as width 2.
|
|
55
|
+
*/
|
|
56
|
+
export function visibleWidth(s: string): number {
|
|
57
|
+
const stripped = stripAnsi(s);
|
|
58
|
+
let w = 0;
|
|
59
|
+
for (const ch of stripped) {
|
|
60
|
+
const code = ch.codePointAt(0)!;
|
|
61
|
+
if (code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
|
|
62
|
+
(code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) ||
|
|
63
|
+
(code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
|
|
64
|
+
(code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) ||
|
|
65
|
+
(code >= 0x1b000 && code <= 0x1b0ff) || (code >= 0x20000 && code <= 0x2fa1f))) {
|
|
66
|
+
w += 2;
|
|
67
|
+
} else {
|
|
68
|
+
w += 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return w;
|
|
72
|
+
}
|
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
|
+
}
|