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,43 @@
1
+ /**
2
+ * Renderer for `type: "action"` rows. Non-storing — Enter triggers the
3
+ * caller-supplied `onActivate(ctx)` and never changes a value.
4
+ */
5
+
6
+ import { matchesKey } from "@earendil-works/pi-tui";
7
+ import type { ActionField, FieldRenderer } from "../types";
8
+
9
+ export const actionRenderer: FieldRenderer<ActionField, void> = {
10
+ type: "action",
11
+ renderValue(row, { selected, ctx }) {
12
+ const text = row.field.display ?? "(run)";
13
+ if (row.field.disabled) {
14
+ return ctx.theme.fg("muted", text);
15
+ }
16
+ return ctx.theme.fg(selected ? "accent" : "muted", text);
17
+ },
18
+ hints(row) {
19
+ if (row.field.disabled) return [];
20
+ return [{ key: "enter", label: "run" }];
21
+ },
22
+ handleKey(row, data, { ctx }) {
23
+ if (row.field.disabled) return {};
24
+ if (
25
+ matchesKey(data, "enter") ||
26
+ matchesKey(data, "return") ||
27
+ data === " "
28
+ ) {
29
+ // Fire and forget — actions are deliberately fire-and-go so the
30
+ // modal stays responsive even when `onActivate` is async.
31
+ try {
32
+ const ret = row.field.onActivate(ctx.ctx);
33
+ if (ret && typeof (ret as Promise<void>).then === "function") {
34
+ (ret as Promise<void>).catch(() => {});
35
+ }
36
+ } catch {
37
+ // Swallow — actions must never break the modal loop.
38
+ }
39
+ return { consumed: true };
40
+ }
41
+ return {};
42
+ },
43
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Renderer for `type: "boolean"` rows. Enter / Space toggles between
3
+ * `on` and `off`; the value is committed immediately.
4
+ */
5
+
6
+ import { matchesKey } from "@earendil-works/pi-tui";
7
+ import type { BooleanField, FieldRenderer } from "../types";
8
+
9
+ export const booleanRenderer: FieldRenderer<BooleanField, boolean> = {
10
+ type: "boolean",
11
+ renderValue(row, { selected, ctx }) {
12
+ const text = row.value ? "on" : "off";
13
+ if (row.field.disabled) {
14
+ return ctx.theme.fg("muted", text);
15
+ }
16
+ return ctx.theme.fg(
17
+ selected ? "accent" : row.value ? "success" : "muted",
18
+ text,
19
+ );
20
+ },
21
+ hints(row) {
22
+ if (row.field.disabled) return [];
23
+ return [
24
+ { key: "enter/space", label: row.value ? "turn off" : "turn on" },
25
+ { key: "←/→", label: "toggle" },
26
+ ];
27
+ },
28
+ handleKey(row, data) {
29
+ if (row.field.disabled) return {};
30
+ if (
31
+ matchesKey(data, "enter") ||
32
+ matchesKey(data, "return") ||
33
+ data === " "
34
+ ) {
35
+ return { consumed: true, commit: !row.value };
36
+ }
37
+ if (matchesKey(data, "left")) {
38
+ if (row.value === false) return { consumed: true };
39
+ return { consumed: true, commit: false };
40
+ }
41
+ if (matchesKey(data, "right")) {
42
+ if (row.value === true) return { consumed: true };
43
+ return { consumed: true, commit: true };
44
+ }
45
+ return {};
46
+ },
47
+ };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Renderer for `type: "custom"` rows. The caller supplies:
3
+ *
4
+ * - `render(args)` — returns the right-hand value cell as a string.
5
+ * - `handleInput?(data, args)` — optional inline-edit handler; return
6
+ * true to consume the key.
7
+ * - `openSubmenu?(args)` — optional submenu mounted on Enter; the
8
+ * factory receives a `done(value?)` callback the modal supplies.
9
+ *
10
+ * Anything more exotic than the built-in `model` widget belongs here.
11
+ */
12
+
13
+ import { matchesKey } from "@earendil-works/pi-tui";
14
+ import type { CustomField, FieldRenderer, SubmenuFactory } from "../types";
15
+
16
+ export const customRenderer: FieldRenderer<CustomField, unknown> = {
17
+ type: "custom",
18
+ renderValue(row, args) {
19
+ const text = row.field.render({
20
+ value: row.value,
21
+ width: args.width,
22
+ selected: args.selected && !row.field.disabled,
23
+ theme: args.ctx.theme,
24
+ });
25
+ if (row.field.disabled) {
26
+ return args.ctx.theme.fg("muted", text);
27
+ }
28
+ return text;
29
+ },
30
+ hints(row) {
31
+ if (row.field.disabled) return [];
32
+ // Caller-supplied override wins outright — useful when
33
+ // `handleInput` consumes a specific key (e.g. space) but Enter
34
+ // is a no-op, in which case the default "enter edit" heuristic
35
+ // would be misleading.
36
+ if (row.field.hints) return row.field.hints;
37
+ if (row.field.openSubmenu) return [{ key: "enter", label: "open" }];
38
+ if (row.field.handleInput) return [{ key: "enter", label: "edit" }];
39
+ return [];
40
+ },
41
+ handleKey(row, data, args) {
42
+ if (row.field.disabled) return {};
43
+ // Caller-supplied inline-edit handler runs first so it can intercept
44
+ // arrow keys etc. before we treat Enter as "open submenu".
45
+ if (row.field.handleInput) {
46
+ const consumed = row.field.handleInput(data, {
47
+ value: row.value,
48
+ width: 0,
49
+ selected: true,
50
+ theme: args.ctx.theme,
51
+ });
52
+ if (consumed) return { consumed: true };
53
+ }
54
+ if (
55
+ matchesKey(data, "enter") ||
56
+ matchesKey(data, "return") ||
57
+ data === " "
58
+ ) {
59
+ if (row.field.openSubmenu) {
60
+ const factory: SubmenuFactory<unknown> = (done) =>
61
+ row.field.openSubmenu!({
62
+ value: row.value,
63
+ theme: args.ctx.theme,
64
+ tui: args.ctx.tui,
65
+ done,
66
+ });
67
+ return { consumed: true, submenu: factory };
68
+ }
69
+ }
70
+ return {};
71
+ },
72
+ };
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Renderer for `type: "enum"` rows.
3
+ *
4
+ * Short option lists (≤ `cycleThreshold`, default 4) cycle in place via
5
+ * Enter / Space; longer lists open a `SelectList` submenu so users
6
+ * don't have to mash Enter to scroll through 20+ entries.
7
+ */
8
+
9
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ matchesKey,
12
+ SelectList,
13
+ truncateToWidth,
14
+ type Component,
15
+ type SelectItem,
16
+ } from "@earendil-works/pi-tui";
17
+ import { formatHintLine } from "../frame";
18
+ import type {
19
+ EnumField,
20
+ FieldRenderContext,
21
+ FieldRenderer,
22
+ SubmenuFactory,
23
+ } from "../types";
24
+
25
+ const DEFAULT_CYCLE_THRESHOLD = 4;
26
+ const MAX_VISIBLE_ROWS = 12;
27
+
28
+ function labelFor(field: EnumField, value: string): string {
29
+ return field.optionLabels?.[value] ?? value;
30
+ }
31
+
32
+ function nextCycleValue(field: EnumField, current: string): string {
33
+ if (field.options.length === 0) return current;
34
+ const idx = field.options.indexOf(current as never);
35
+ const nextIdx = (idx + 1 + field.options.length) % field.options.length;
36
+ return field.options[nextIdx]!;
37
+ }
38
+
39
+ function prevCycleValue(field: EnumField, current: string): string {
40
+ if (field.options.length === 0) return current;
41
+ const idx = field.options.indexOf(current as never);
42
+ const prevIdx = (idx - 1 + field.options.length) % field.options.length;
43
+ return field.options[prevIdx]!;
44
+ }
45
+
46
+ /** Build a submenu factory the modal can mount with its own `done`. */
47
+ function makeEnumSubmenu(
48
+ field: EnumField,
49
+ current: string,
50
+ ctx: FieldRenderContext,
51
+ ): SubmenuFactory<string> {
52
+ if (field.search) {
53
+ return makeSearchableEnumSubmenu(field, current, ctx);
54
+ }
55
+
56
+ return (done) => {
57
+ const items: SelectItem[] = field.options.map((value, idx) => ({
58
+ value,
59
+ label: `${idx + 1}. ${labelFor(field, value)}`,
60
+ }));
61
+ const list = new SelectList(
62
+ items,
63
+ Math.min(items.length, MAX_VISIBLE_ROWS),
64
+ getSelectListTheme(),
65
+ );
66
+ const idx = field.options.indexOf(current);
67
+ list.setSelectedIndex(idx >= 0 ? idx : 0);
68
+ list.onSelect = (item) => done(item.value);
69
+ list.onCancel = () => done();
70
+
71
+ const component: Component = {
72
+ render(width: number): string[] {
73
+ const lines = [...list.render(width)];
74
+ lines.push("");
75
+ const hints = [
76
+ { key: "↑↓", label: "select" },
77
+ ...(items.length > 1
78
+ ? [{ key: `1-${Math.min(9, items.length)}`, label: "choose" }]
79
+ : []),
80
+ { key: "enter", label: "save" },
81
+ { key: "esc", label: "cancel" },
82
+ ];
83
+ const hintText = ` ${formatHintLine(hints, ctx.theme)}`;
84
+ lines.push(truncateToWidth(hintText, width, "…", true));
85
+ return lines;
86
+ },
87
+ invalidate(): void {
88
+ list.invalidate();
89
+ },
90
+ handleInput(data: string): void {
91
+ const num = parseInt(data, 10);
92
+ if (
93
+ data.length === 1 &&
94
+ !isNaN(num) &&
95
+ num >= 1 &&
96
+ num <= Math.min(9, items.length)
97
+ ) {
98
+ const item = items[num - 1];
99
+ if (item) {
100
+ done(item.value);
101
+ return;
102
+ }
103
+ }
104
+ list.handleInput(data);
105
+ ctx.tui.requestRender();
106
+ },
107
+ };
108
+ return component;
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Build a searchable enum submenu with a filter bar at the top.
114
+ * Used when `field.search === true`.
115
+ */
116
+ function makeSearchableEnumSubmenu(
117
+ field: EnumField,
118
+ _current: string,
119
+ ctx: FieldRenderContext,
120
+ ): SubmenuFactory<string> {
121
+ return (done) => {
122
+ const allItems: SelectItem[] = field.options.map((value) => ({
123
+ value,
124
+ label: labelFor(field, value),
125
+ }));
126
+
127
+ let search = "";
128
+ let selected = 0;
129
+
130
+ function filteredItems(): SelectItem[] {
131
+ if (!search.trim()) return allItems;
132
+ const q = search.toLowerCase();
133
+ return allItems.filter(
134
+ (item) =>
135
+ item.label.toLowerCase().includes(q) ||
136
+ item.value.toLowerCase().includes(q),
137
+ );
138
+ }
139
+
140
+ const component: Component = {
141
+ render(width: number): string[] {
142
+ const lines: string[] = [];
143
+ const items = filteredItems();
144
+ const searchPrompt = `Search: ${search}${ctx.theme.inverse(" ")}`;
145
+ lines.push(
146
+ ctx.theme.bg(
147
+ "toolPendingBg",
148
+ truncateToWidth(searchPrompt, width, "…", true),
149
+ ),
150
+ );
151
+ lines.push("");
152
+
153
+ if (items.length === 0) {
154
+ lines.push(ctx.theme.fg("muted", " No matching options."));
155
+ } else {
156
+ const maxVisible = MAX_VISIBLE_ROWS;
157
+ const scroll = Math.max(
158
+ 0,
159
+ Math.min(
160
+ selected - Math.floor(maxVisible / 2),
161
+ Math.max(0, items.length - maxVisible),
162
+ ),
163
+ );
164
+ const slice = items.slice(scroll, scroll + maxVisible);
165
+ for (let i = 0; i < slice.length; i++) {
166
+ const isSelected = scroll + i === selected;
167
+ const prefix = isSelected ? ctx.theme.fg("accent", "▌ ") : " ";
168
+ const display = isSelected
169
+ ? ctx.theme.fg("accent", slice[i]!.label)
170
+ : ctx.theme.fg("muted", slice[i]!.label);
171
+ const line = `${prefix}${display}`;
172
+ lines.push(
173
+ isSelected
174
+ ? ctx.theme.bg(
175
+ "selectedBg",
176
+ truncateToWidth(line, width, "…", true),
177
+ )
178
+ : truncateToWidth(line, width, "…", true),
179
+ );
180
+ }
181
+ }
182
+
183
+ lines.push("");
184
+ const hints = [
185
+ { key: "↑↓", label: "select" },
186
+ { key: "enter", label: "save" },
187
+ { key: "esc", label: "cancel" },
188
+ ];
189
+ if (search) hints.unshift({ key: "type", label: "to filter" });
190
+ lines.push(` ${formatHintLine(hints, ctx.theme)}`);
191
+ return lines;
192
+ },
193
+ invalidate(): void {
194
+ // no external state to invalidate
195
+ },
196
+ handleInput(data: string): void {
197
+ if (matchesKey(data, "up")) {
198
+ selected = Math.max(0, selected - 1);
199
+ ctx.tui.requestRender();
200
+ return;
201
+ }
202
+ if (matchesKey(data, "down")) {
203
+ const items = filteredItems();
204
+ selected = Math.min(selected + 1, Math.max(0, items.length - 1));
205
+ ctx.tui.requestRender();
206
+ return;
207
+ }
208
+ if (matchesKey(data, "enter") || matchesKey(data, "return")) {
209
+ const items = filteredItems();
210
+ if (items.length > 0) {
211
+ done(items[Math.min(selected, items.length - 1)]!.value);
212
+ }
213
+ return;
214
+ }
215
+ if (matchesKey(data, "escape")) {
216
+ done();
217
+ return;
218
+ }
219
+ if (matchesKey(data, "backspace") || matchesKey(data, "ctrl+h")) {
220
+ search = search.slice(0, -1);
221
+ selected = 0;
222
+ ctx.tui.requestRender();
223
+ return;
224
+ }
225
+ if (data.length === 1 && data >= " " && data !== "\x7f") {
226
+ search += data;
227
+ selected = 0;
228
+ ctx.tui.requestRender();
229
+ }
230
+ },
231
+ };
232
+ return component;
233
+ };
234
+ }
235
+
236
+ export const enumRenderer: FieldRenderer<EnumField, string> = {
237
+ type: "enum",
238
+ renderValue(row, { selected, ctx }) {
239
+ const text = labelFor(row.field, row.value);
240
+ if (row.field.disabled) {
241
+ return ctx.theme.fg("muted", text);
242
+ }
243
+ return ctx.theme.fg(selected ? "accent" : "muted", text);
244
+ },
245
+ hints(row) {
246
+ if (row.field.disabled) return [];
247
+ // Searchable enums always open a list submenu
248
+ if (row.field.search) {
249
+ return [{ key: "enter", label: "open list" }];
250
+ }
251
+ const threshold = row.field.cycleThreshold ?? DEFAULT_CYCLE_THRESHOLD;
252
+ if (row.field.options.length > threshold) {
253
+ return [{ key: "enter", label: "open list" }];
254
+ }
255
+ return [
256
+ { key: "enter/space", label: "cycle" },
257
+ { key: "←/→", label: "prev/next" },
258
+ ];
259
+ },
260
+ handleKey(row, data, { ctx }) {
261
+ if (row.field.disabled) return {};
262
+ // Searchable enums always open a submenu on Enter/Space
263
+ if (row.field.search) {
264
+ if (
265
+ matchesKey(data, "enter") ||
266
+ matchesKey(data, "return") ||
267
+ data === " "
268
+ ) {
269
+ return {
270
+ consumed: true,
271
+ submenu: makeEnumSubmenu(row.field, row.value, ctx),
272
+ };
273
+ }
274
+ // No cycling for searchable enums — all navigation is in the submenu
275
+ return {};
276
+ }
277
+
278
+ const threshold = row.field.cycleThreshold ?? DEFAULT_CYCLE_THRESHOLD;
279
+ const isLongList = row.field.options.length > threshold;
280
+
281
+ if (
282
+ matchesKey(data, "enter") ||
283
+ matchesKey(data, "return") ||
284
+ data === " "
285
+ ) {
286
+ if (isLongList) {
287
+ return {
288
+ consumed: true,
289
+ submenu: makeEnumSubmenu(row.field, row.value, ctx),
290
+ };
291
+ }
292
+ return { consumed: true, commit: nextCycleValue(row.field, row.value) };
293
+ }
294
+
295
+ if (!isLongList) {
296
+ if (matchesKey(data, "left")) {
297
+ const prev = prevCycleValue(row.field, row.value);
298
+ if (prev === row.value) return { consumed: true };
299
+ return { consumed: true, commit: prev };
300
+ }
301
+ if (matchesKey(data, "right")) {
302
+ const next = nextCycleValue(row.field, row.value);
303
+ if (next === row.value) return { consumed: true };
304
+ return { consumed: true, commit: next };
305
+ }
306
+ }
307
+
308
+ return {};
309
+ },
310
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Built-in field renderers. The modal's `RENDERERS` lookup is
3
+ * constructed from these so user-supplied `type: "custom"` fields can
4
+ * coexist with built-ins without any registration ceremony.
5
+ */
6
+
7
+ import type { Field, FieldRenderer } from "../types";
8
+ import { actionRenderer } from "./action";
9
+ import { booleanRenderer } from "./boolean";
10
+ import { customRenderer } from "./custom";
11
+ import { enumRenderer } from "./enum";
12
+ import { modelRenderer } from "./model";
13
+ import {
14
+ numberRenderer,
15
+ pathRenderer,
16
+ secretRenderer,
17
+ stringRenderer,
18
+ } from "./string";
19
+ import { textRenderer } from "./text";
20
+
21
+ export {
22
+ actionRenderer,
23
+ booleanRenderer,
24
+ customRenderer,
25
+ enumRenderer,
26
+ modelRenderer,
27
+ numberRenderer,
28
+ pathRenderer,
29
+ secretRenderer,
30
+ stringRenderer,
31
+ textRenderer,
32
+ };
33
+
34
+ /** Map of field discriminator → renderer. */
35
+ export const RENDERERS: Record<Field["type"], FieldRenderer<any, any>> = {
36
+ boolean: booleanRenderer,
37
+ enum: enumRenderer,
38
+ string: stringRenderer,
39
+ number: numberRenderer,
40
+ secret: secretRenderer,
41
+ path: pathRenderer,
42
+ text: textRenderer,
43
+ action: actionRenderer,
44
+ model: modelRenderer,
45
+ custom: customRenderer,
46
+ };