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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }