pi-blackhole 0.4.2 → 0.4.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.
Files changed (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -411
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. package/vitest.config.ts +15 -15
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Renderer for `type: "model"` — the two-axis "model + reasoning
3
+ * effort" widget that voice and web both reinvented before this
4
+ * package existed.
5
+ *
6
+ * UX:
7
+ * - Row collapsed: `<model> · <effort>` (or just `<model>` when
8
+ * `hideEffort` is set).
9
+ * - Enter opens a submenu with:
10
+ * · a typeable filter row at the top — printable keystrokes
11
+ * narrow the model list by name (case-insensitive substring);
12
+ * · `↑/↓` over a `SelectList` of models;
13
+ * · `←/→` over the supported effort ladder for the highlighted
14
+ * model (skipped entirely when `hideEffort` is set).
15
+ * Enter saves both axes atomically; Esc abandons.
16
+ * - Discovery: by default, models come from
17
+ * `pi.modelRegistry.getAvailable()` (filtered to entries the user
18
+ * has authed, matching voice's old behaviour). The caller can
19
+ * narrow with `filter` or replace the list entirely with `models`.
20
+ *
21
+ * The submenu's footer reuses `formatHintLine` so the hot-keys are
22
+ * highlighted with the same accent colour as the main modal's footer
23
+ * — no second-class colour scheme inside submenus.
24
+ */
25
+
26
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
27
+ import type {
28
+ Api,
29
+ Model,
30
+ ModelThinkingLevel,
31
+ ThinkingLevelMap,
32
+ } from "@earendil-works/pi-ai";
33
+ import {
34
+ matchesKey,
35
+ SelectList,
36
+ SelectItem,
37
+ truncateToWidth,
38
+ } from "@earendil-works/pi-tui";
39
+ import { formatHintLine, type KeyHint } from "../frame";
40
+ import { handleInlineEditInput, type InlineEditState } from "../inline-edit";
41
+ import type {
42
+ FieldRenderContext,
43
+ FieldRenderer,
44
+ ModelField,
45
+ ModelOption,
46
+ ModelValue,
47
+ SubmenuFactory,
48
+ } from "../types";
49
+
50
+ const ALL_THINKING_LEVELS: ModelThinkingLevel[] = [
51
+ "off",
52
+ "minimal",
53
+ "low",
54
+ "medium",
55
+ "high",
56
+ "xhigh",
57
+ "max",
58
+ ];
59
+
60
+ /** Short display labels for pi's internal thinking levels — used as a
61
+ * fallback when the model's `thinkingLevelMap` doesn't override one.
62
+ * Mirrors `THINK_LABELS` in `packages/statusline/index.ts` so the
63
+ * picker and statusline never disagree on what `medium` looks like. */
64
+ const LEVEL_FALLBACK_LABELS: Record<ModelThinkingLevel, string> = {
65
+ off: "off",
66
+ minimal: "min",
67
+ low: "low",
68
+ medium: "med",
69
+ high: "high",
70
+ xhigh: "xhigh",
71
+ max: "max",
72
+ };
73
+
74
+ const DEFAULT_SESSION_LABEL = "(session model)";
75
+ const MAX_VISIBLE_MODELS = 12;
76
+
77
+ /**
78
+ * Resolve the human-readable label for a thinking level.
79
+ *
80
+ * Models may override per-level display strings via
81
+ * `thinkingLevelMap` (e.g. Anthropic models map `xhigh` to a token
82
+ * budget like `"60000"`); we honour those overrides exactly the
83
+ * same way `packages/statusline/index.ts` does. Missing keys and
84
+ * non-string values fall back to the short label. Pure presentation
85
+ * — callers still pass / store the canonical level name (`"xhigh"`).
86
+ */
87
+ function effortDisplayLabel(
88
+ level: ModelThinkingLevel,
89
+ thinkingLevelMap: ThinkingLevelMap | undefined,
90
+ ): string {
91
+ const mapped = thinkingLevelMap?.[level];
92
+ if (typeof mapped === "string" && mapped.length > 0) return mapped;
93
+ return LEVEL_FALLBACK_LABELS[level] ?? level;
94
+ }
95
+
96
+ function listModelOptions(
97
+ field: ModelField,
98
+ ctx: FieldRenderContext,
99
+ ): ModelOption[] {
100
+ if (field.models) return field.models;
101
+ const sessionLabel = field.sessionLabel ?? DEFAULT_SESSION_LABEL;
102
+ const available = ctx.ctx.modelRegistry.getAvailable();
103
+ const filtered = field.filter ? available.filter(field.filter) : available;
104
+ const live: ModelOption[] = filtered.map((m) => ({
105
+ value: `${m.provider}/${m.id}`,
106
+ label: `${m.name} [${m.provider}]`,
107
+ model: m,
108
+ }));
109
+ return field.hideSession
110
+ ? live
111
+ : [{ value: "", label: sessionLabel }, ...live];
112
+ }
113
+
114
+ /**
115
+ * Find the live `Model<Api>` for a stored value.
116
+ *
117
+ * Looks at the caller-provided `field.models` first (so `web` and
118
+ * other extensions that pre-build their option list still get rich
119
+ * label resolution), then falls back to the host model registry.
120
+ * Returns undefined for the `(session model)` sentinel and for any
121
+ * value that no longer matches a registered model.
122
+ */
123
+ function resolveModelForValue(
124
+ field: ModelField,
125
+ value: ModelValue,
126
+ ctx: FieldRenderContext,
127
+ ): Model<Api> | undefined {
128
+ if (!value.id) return undefined;
129
+ if (field.models) {
130
+ const opt = field.models.find((o) => o.value === value.id);
131
+ if (opt?.model) return opt.model;
132
+ // Pre-built option without a backing `Model<Api>` — we can't
133
+ // resolve thinkingLevelMap from here. Fall through to the registry.
134
+ }
135
+ const slash = value.id.indexOf("/");
136
+ if (slash <= 0) return undefined;
137
+ const provider = value.id.slice(0, slash);
138
+ const id = value.id.slice(slash + 1);
139
+ return ctx.ctx.modelRegistry.find(provider, id);
140
+ }
141
+
142
+ function supportedEfforts(model: Model<Api> | undefined): ModelThinkingLevel[] {
143
+ if (!model || !model.thinkingLevelMap) return ALL_THINKING_LEVELS;
144
+ const map = model.thinkingLevelMap;
145
+ return ALL_THINKING_LEVELS.filter(
146
+ (lvl) => !(lvl in map && map[lvl] === null),
147
+ );
148
+ }
149
+
150
+ function clampEffort(
151
+ desired: ModelThinkingLevel | undefined,
152
+ supported: ModelThinkingLevel[],
153
+ ): ModelThinkingLevel {
154
+ if (desired && supported.includes(desired)) return desired;
155
+ return supported.includes("medium") ? "medium" : (supported[0] ?? "off");
156
+ }
157
+
158
+ function modelDisplay(field: ModelField, value: ModelValue): string {
159
+ if (value.id === "") return field.sessionLabel ?? DEFAULT_SESSION_LABEL;
160
+ return value.id;
161
+ }
162
+
163
+ function rowLabel(
164
+ field: ModelField,
165
+ value: ModelValue,
166
+ ctx: FieldRenderContext,
167
+ ): string {
168
+ const left = modelDisplay(field, value);
169
+ if (field.hideEffort) return left;
170
+ if (!value.thinking) return left;
171
+ // Honour the model's `thinkingLevelMap` overrides so the row
172
+ // displays the same label statusline shows (e.g. token-budget
173
+ // numbers for Anthropic) instead of pi's internal level name.
174
+ const model = resolveModelForValue(field, value, ctx);
175
+ const label = effortDisplayLabel(value.thinking, model?.thinkingLevelMap);
176
+ return `${left} · ${label}`;
177
+ }
178
+
179
+ /**
180
+ * Build the submenu component. The submenu owns three pieces of state:
181
+ *
182
+ * 1. `filter` — the typeable name-filter buffer.
183
+ * 2. `effortIndex` / `supported` — current effort axis (only when
184
+ * `hideEffort` is unset).
185
+ * 3. The currently-mounted SelectList. We rebuild it whenever the
186
+ * filter changes so SelectList's internal selectedIndex stays
187
+ * coherent with the visible items.
188
+ */
189
+ function makeSubmenu(
190
+ field: ModelField,
191
+ current: ModelValue,
192
+ ctx: FieldRenderContext,
193
+ ): SubmenuFactory<ModelValue> {
194
+ return (done) => {
195
+ const allOptions = listModelOptions(field, ctx);
196
+ const showEffort = !field.hideEffort;
197
+
198
+ const filter: InlineEditState = { buffer: "", cursor: 0 };
199
+
200
+ let list!: SelectList;
201
+ let visibleOptions: ModelOption[] = [];
202
+
203
+ let effortIndex = 0;
204
+ let supported: ModelThinkingLevel[] = [];
205
+
206
+ const refreshEffort = (preferred: ModelThinkingLevel | undefined): void => {
207
+ if (!showEffort) return;
208
+ const item = list.getSelectedItem();
209
+ const opt = visibleOptions.find((m) => m.value === item?.value);
210
+ supported = supportedEfforts(opt?.model);
211
+ if (supported.length === 0) supported = ["off"];
212
+ const clamped = clampEffort(preferred, supported);
213
+ effortIndex = Math.max(0, supported.indexOf(clamped));
214
+ };
215
+
216
+ /** Build / rebuild the SelectList from the current filter. Tries
217
+ * to keep the highlight on `preserveValue` when possible so
218
+ * cursor focus doesn't jump around on every keystroke. */
219
+ const buildList = (preserveValue: string | undefined): void => {
220
+ const query = filter.buffer.trim().toLowerCase();
221
+ visibleOptions = query
222
+ ? allOptions.filter(
223
+ (o) =>
224
+ o.label.toLowerCase().includes(query) ||
225
+ o.value.toLowerCase().includes(query),
226
+ )
227
+ : allOptions;
228
+
229
+ const items: SelectItem[] = visibleOptions.map((m) => ({
230
+ value: m.value,
231
+ label: m.label,
232
+ description: m.value || undefined,
233
+ }));
234
+ list = new SelectList(
235
+ items,
236
+ Math.min(Math.max(items.length, 1), MAX_VISIBLE_MODELS),
237
+ getSelectListTheme(),
238
+ );
239
+ const idx =
240
+ preserveValue !== undefined
241
+ ? items.findIndex((i) => i.value === preserveValue)
242
+ : -1;
243
+ list.setSelectedIndex(idx >= 0 ? idx : 0);
244
+
245
+ list.onSelect = (item) => {
246
+ if (showEffort) {
247
+ const effort = supported[effortIndex] ?? "off";
248
+ done({ id: item.value, thinking: effort });
249
+ } else {
250
+ done({ id: item.value });
251
+ }
252
+ };
253
+ list.onCancel = () => done();
254
+ list.onSelectionChange = () => {
255
+ if (showEffort) {
256
+ const previous = supported[effortIndex];
257
+ refreshEffort(previous);
258
+ }
259
+ ctx.tui.requestRender();
260
+ };
261
+ };
262
+
263
+ buildList(current.id);
264
+ refreshEffort(current.thinking);
265
+
266
+ const renderEffortRow = (width: number): string => {
267
+ const currentLevel = supported[effortIndex] ?? "off";
268
+ // Resolve the override label from the highlighted model's
269
+ // thinkingLevelMap so the user sees what statusline shows
270
+ // (token budget for Anthropic, etc.) rather than pi's
271
+ // internal level name.
272
+ const item = list.getSelectedItem();
273
+ const opt = visibleOptions.find((m) => m.value === item?.value);
274
+ const displayLabel = effortDisplayLabel(
275
+ currentLevel,
276
+ opt?.model?.thinkingLevelMap,
277
+ );
278
+ const left =
279
+ effortIndex > 0
280
+ ? ctx.theme.fg("accent", "‹")
281
+ : ctx.theme.fg("dim", "‹");
282
+ const right =
283
+ effortIndex < supported.length - 1
284
+ ? ctx.theme.fg("accent", "›")
285
+ : ctx.theme.fg("dim", "›");
286
+ const label = ctx.theme.fg("muted", " effort: ");
287
+ // Show the canonical level name in dim parentheses next to the
288
+ // override so power-users still know which pi level they're
289
+ // selecting (e.g. `60000 (xhigh)`).
290
+ const valueText =
291
+ displayLabel === currentLevel
292
+ ? displayLabel
293
+ : `${displayLabel} ${ctx.theme.fg("dim", `(${currentLevel})`)}`;
294
+ const value = ctx.theme.fg("accent", ctx.theme.bold(valueText));
295
+ const counter = ctx.theme.fg(
296
+ "dim",
297
+ ` (${effortIndex + 1}/${supported.length})`,
298
+ );
299
+ return truncateToWidth(
300
+ `${label}${left} ${value} ${right}${counter}`,
301
+ width,
302
+ "…",
303
+ true,
304
+ );
305
+ };
306
+
307
+ const renderFilterRow = (width: number): string => {
308
+ const cursorBlock = ctx.theme.inverse(" ");
309
+ const buf = filter.buffer;
310
+ const before = buf.slice(0, filter.cursor);
311
+ const after = buf.slice(filter.cursor);
312
+ const placeholder = !buf ? ctx.theme.fg("dim", " type to filter…") : "";
313
+ const hasMatches = visibleOptions.length > 0;
314
+ const colorKey = hasMatches ? "accent" : "warning";
315
+ const text = buf
316
+ ? ` ${ctx.theme.fg("muted", "filter:")} ${ctx.theme.fg(colorKey, before)}${cursorBlock}${ctx.theme.fg(colorKey, after)}`
317
+ : ` ${ctx.theme.fg("muted", "filter:")} ${cursorBlock}${placeholder}`;
318
+ return truncateToWidth(text, width, "…", true);
319
+ };
320
+
321
+ const renderHints = (width: number): string => {
322
+ const hints: KeyHint[] = [{ key: "↑↓", label: "model" }];
323
+ if (showEffort) hints.push({ key: "←→", label: "effort" });
324
+ hints.push(
325
+ { key: "type", label: "filter" },
326
+ { key: "enter", label: "save" },
327
+ );
328
+ if (filter.buffer !== "") {
329
+ hints.push({ key: "esc", label: "clear filter" });
330
+ } else {
331
+ hints.push({ key: "esc", label: "cancel" });
332
+ }
333
+ return truncateToWidth(
334
+ ` ${formatHintLine(hints, ctx.theme)}`,
335
+ width,
336
+ "…",
337
+ true,
338
+ );
339
+ };
340
+
341
+ return {
342
+ render(width: number): string[] {
343
+ const lines: string[] = [];
344
+ lines.push(renderFilterRow(width));
345
+ lines.push("");
346
+ if (visibleOptions.length === 0) {
347
+ lines.push(
348
+ ctx.theme.fg("muted", " No matching models. (press esc to clear)"),
349
+ );
350
+ } else {
351
+ for (const line of list.render(width)) lines.push(line);
352
+ }
353
+ lines.push("");
354
+ if (showEffort) {
355
+ lines.push(renderEffortRow(width));
356
+ lines.push("");
357
+ }
358
+ lines.push(renderHints(width));
359
+ return lines;
360
+ },
361
+ invalidate(): void {
362
+ list.invalidate();
363
+ },
364
+ handleInput(data: string): void {
365
+ // Effort axis (only when not hidden) — must run before the
366
+ // filter handler so ←/→ aren't typed into the search buffer.
367
+ if (showEffort) {
368
+ if (matchesKey(data, "left")) {
369
+ if (effortIndex > 0) {
370
+ effortIndex -= 1;
371
+ ctx.tui.requestRender();
372
+ }
373
+ return;
374
+ }
375
+ if (matchesKey(data, "right")) {
376
+ if (effortIndex < supported.length - 1) {
377
+ effortIndex += 1;
378
+ ctx.tui.requestRender();
379
+ }
380
+ return;
381
+ }
382
+ }
383
+ // Intercept escape key to clear filter first
384
+ if (matchesKey(data, "escape") && filter.buffer !== "") {
385
+ const previousValue = list.getSelectedItem()?.value;
386
+ filter.buffer = "";
387
+ filter.cursor = 0;
388
+ buildList(previousValue);
389
+ refreshEffort(supported[effortIndex]);
390
+ ctx.tui.requestRender();
391
+ return;
392
+ }
393
+ // Up / Down / Enter / Esc go to the SelectList first so the
394
+ // user can still drive the list while typing.
395
+ if (
396
+ matchesKey(data, "up") ||
397
+ matchesKey(data, "down") ||
398
+ matchesKey(data, "enter") ||
399
+ matchesKey(data, "return") ||
400
+ matchesKey(data, "escape") ||
401
+ matchesKey(data, "ctrl+c")
402
+ ) {
403
+ list.handleInput(data);
404
+ ctx.tui.requestRender();
405
+ return;
406
+ }
407
+ // Everything else (printable chars, backspace, ctrl+u, …) is
408
+ // routed to the inline-edit state machine.
409
+ const previousValue = list.getSelectedItem()?.value;
410
+ const consumed = handleInlineEditInput(filter, data);
411
+ if (consumed) {
412
+ buildList(previousValue);
413
+ refreshEffort(supported[effortIndex]);
414
+ ctx.tui.requestRender();
415
+ }
416
+ },
417
+ };
418
+ };
419
+ }
420
+
421
+ export const modelRenderer: FieldRenderer<ModelField, ModelValue> = {
422
+ type: "model",
423
+ renderValue(row, { selected, ctx }) {
424
+ const text = rowLabel(row.field, row.value, ctx);
425
+ if (row.field.disabled) {
426
+ return ctx.theme.fg("muted", text);
427
+ }
428
+ return ctx.theme.fg(selected ? "accent" : "muted", text);
429
+ },
430
+ hints(row) {
431
+ if (row.field.disabled) return [];
432
+ // The main-window hint advertises only what the row itself does
433
+ // there: opening the submenu. `↑↓` and `←→` only fire **inside**
434
+ // the submenu — emitting them at row level would mislead the
435
+ // user about what the keys do. The submenu has its own footer.
436
+ return [{ key: "enter", label: "open" }];
437
+ },
438
+ handleKey(row, data, { ctx }) {
439
+ if (row.field.disabled) return {};
440
+ if (
441
+ matchesKey(data, "enter") ||
442
+ matchesKey(data, "return") ||
443
+ data === " "
444
+ ) {
445
+ return {
446
+ consumed: true,
447
+ submenu: makeSubmenu(row.field, row.value, ctx),
448
+ };
449
+ }
450
+ return {};
451
+ },
452
+ };