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,440 @@
1
+ /**
2
+ * ConfigManager — declarative config management for pi extensions.
3
+ *
4
+ * Packages configure a single `ConfigManager<T>` with their config shape,
5
+ * defaults, field definitions, optional validation, and optional env-var
6
+ * overrides. The manager handles loading, saving, and modal wiring.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { existsSync } from "node:fs";
11
+ import {
12
+ applyEnvOverrides as applyEnvOverridesGeneric,
13
+ type EnvParser,
14
+ } from "../core/config-env.js";
15
+ export type { EnvParser } from "../core/config-env.js";
16
+ import {
17
+ loadConfig,
18
+ writeConfig,
19
+ readConfig,
20
+ deleteConfig,
21
+ getExtensionsDir,
22
+ deepEqual,
23
+ checkConfigFile,
24
+ } from "./config.ts";
25
+ import { openSettingsModal } from "./settings/index.ts";
26
+ import { validateFieldValue } from "./settings/validate-field.ts";
27
+ import type { Field } from "./settings/types.ts";
28
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
29
+
30
+ // ── Types ──────────────────────────────────────────────────────────────
31
+
32
+ export interface ConfigManagerOptions<T extends object = object> {
33
+ /** Extension identifier — e.g. "bash-timeout" */
34
+ id: string;
35
+ /** Human-readable label shown in the UI */
36
+ label: string;
37
+ /** Config file name — e.g. "bash-timeout-config.json" */
38
+ filename: string;
39
+ /** Default config values (the complete object) */
40
+ defaults: T;
41
+ /** Build Field[] from current config values */
42
+ fields: (config: T) => Field[];
43
+ /**
44
+ * Optional validator. Receives raw merged data and returns a fully
45
+ * coerced config. If omitted, the manager does a simple spread merge.
46
+ */
47
+ validate?: (raw: Record<string, unknown>) => T;
48
+ /**
49
+ * Optional env-var overrides. Maps config keys to env var names.
50
+ * Boolean types use `readBooleanEnv`, numeric types use `readPositiveIntEnv`.
51
+ * For custom parsing, use a parser object with a `parse` function.
52
+ */
53
+ env?: Partial<Record<keyof T, string | EnvParser>>;
54
+ }
55
+
56
+ export interface ConfigLoadWarning {
57
+ /** Scope where the warning originated */
58
+ scope: "global" | "project";
59
+ /** Human-readable warning message */
60
+ message: string;
61
+ /** Config key involved (if applicable) */
62
+ key?: string;
63
+ }
64
+
65
+ export interface ConfigLoadResult<T> {
66
+ /** Fully resolved config value */
67
+ config: T;
68
+ /** Non-fatal warnings discovered during loading (bad values, unknown keys, etc.) */
69
+ warnings: ConfigLoadWarning[];
70
+ }
71
+
72
+ // ── ConfigManager ───────────────────────────────────────────────────────
73
+
74
+ export class ConfigManager<T extends object> {
75
+ private opts: ConfigManagerOptions<T>;
76
+
77
+ constructor(opts: ConfigManagerOptions<T>) {
78
+ this.opts = opts;
79
+ }
80
+
81
+ /**
82
+ * Load config with layered resolution:
83
+ * defaults ← global file ← project file ← env overrides
84
+ *
85
+ * @param cwd Working directory for project-local override
86
+ * @param configDir Global config directory (defaults to getExtensionsDir())
87
+ */
88
+ load(cwd?: string, configDir?: string): T {
89
+ return this.loadWithWarnings(cwd, configDir).config;
90
+ }
91
+
92
+ /**
93
+ * Load config with warnings. Same as `load()` but also returns per-field
94
+ * validation warnings and unknown-key warnings discovered during loading.
95
+ *
96
+ * Extensions that open a modal can surface these warnings in the UI;
97
+ * extensions that load config programmatically can log or inspect them.
98
+ */
99
+ loadWithWarnings(cwd?: string, configDir?: string): ConfigLoadResult<T> {
100
+ const warnings: ConfigLoadWarning[] = [];
101
+
102
+ const loaded = loadConfig(this.opts.filename, this.opts.defaults, {
103
+ cwd,
104
+ configDir,
105
+ merge: "deep",
106
+ });
107
+
108
+ // Run validate if provided
109
+ const config = this.opts.validate
110
+ ? this.opts.validate(loaded as Record<string, unknown>)
111
+ : (loaded as T);
112
+
113
+ // Validate each field value and collect warnings
114
+ try {
115
+ const fields = this.opts.fields(config);
116
+ const scope = "global";
117
+ for (const field of fields) {
118
+ if (field.type === "action" || field.type === "custom") continue;
119
+ const warning = validateFieldValue(field, field.value);
120
+ if (warning) {
121
+ warnings.push({
122
+ scope: scope as "global" | "project",
123
+ key: String(field.key),
124
+ message: `"${field.label}": ${warning}`,
125
+ });
126
+ }
127
+ }
128
+ } catch {
129
+ // Fields function may fail for partial configs; skip validation
130
+ }
131
+
132
+ // Apply env overrides
133
+ const final = this.applyEnvOverrides(config);
134
+
135
+ return { config: final, warnings };
136
+ }
137
+
138
+ private applyEnvOverrides(config: T): T {
139
+ const env = this.opts.env;
140
+ if (!env) return config;
141
+ return applyEnvOverridesGeneric(
142
+ config,
143
+ env as Record<string, EnvParser | string>,
144
+ this.opts.defaults as Record<string, unknown>,
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Save config scoped to global or project directory.
150
+ *
151
+ * Writes only the fields that differ from the existing file content
152
+ * (diff-based save against the file). This means:
153
+ *
154
+ * - **First save** (no file exists): writes ALL fields — the file is
155
+ * fully populated with every value the user confirmed.
156
+ * - **Subsequent saves**: only the fields that actually changed in the
157
+ * current session are written. Everything else stays untouched.
158
+ * - **Unknown keys** (hand-edited extras outside the schema) are
159
+ * automatically preserved by the read-patch-write cycle.
160
+ * - **No automatic removal**: only explicit reset/delete removes keys
161
+ * from the file.
162
+ *
163
+ * Reads the existing file, patches it with the deltas, and writes the
164
+ * merged result. This prevents accidental overwrites of fields the user
165
+ * never touched while keeping the file consistent with the UI.
166
+ *
167
+ * @param config Config to persist
168
+ * @param scope Target scope
169
+ * @param cwd Working directory (required for "project" scope)
170
+ * @param configDir Override the global config dir (for tests)
171
+ */
172
+ save(
173
+ config: T,
174
+ scope: "global" | "project",
175
+ cwd?: string,
176
+ configDir?: string,
177
+ ): void {
178
+ if (scope === "project" && !cwd) {
179
+ throw new Error("cwd is required for project-scoped config save");
180
+ }
181
+
182
+ const dir =
183
+ scope === "project" && cwd
184
+ ? join(cwd, ".pi")
185
+ : (configDir ?? getExtensionsDir());
186
+ const knownKeys = new Set(Object.keys(this.opts.defaults));
187
+
188
+ // Read the existing file. If none exists, start from an empty object.
189
+ const existing =
190
+ readConfig<Record<string, unknown>>(this.opts.filename, dir) ?? {};
191
+
192
+ // Build field map for validation
193
+ const fieldMap = new Map<string, Field>();
194
+ try {
195
+ const fields = this.opts.fields(config);
196
+ for (const f of fields) fieldMap.set(String(f.key), f);
197
+ } catch {
198
+ // Fields function may fail for partial configs; skip validation
199
+ }
200
+
201
+ // Compute diff: compare each known key's modal value against the
202
+ // existing file value. Only fields that actually changed are written.
203
+ const diff: Record<string, unknown> = {};
204
+ let hasDiff = false;
205
+
206
+ for (const key of Object.keys(this.opts.defaults) as (keyof T)[]) {
207
+ const modalVal = config[key];
208
+ const fileVal = existing[String(key)];
209
+
210
+ if (deepEqual(modalVal, fileVal)) continue;
211
+
212
+ // Validate before persisting — skip invalid values to repair
213
+ // config files (invalid values fall back to defaults next load).
214
+ const field = fieldMap.get(String(key));
215
+ if (field && !(field.type === "action" || field.type === "custom")) {
216
+ const warning = validateFieldValue(field, modalVal);
217
+ if (warning) continue;
218
+ }
219
+
220
+ diff[String(key)] = modalVal;
221
+ hasDiff = true;
222
+ }
223
+
224
+ // Preserve unknown keys from the existing file.
225
+ for (const [key, val] of Object.entries(existing)) {
226
+ if (!knownKeys.has(key)) {
227
+ diff[key] = val;
228
+ hasDiff = true;
229
+ }
230
+ }
231
+
232
+ if (!hasDiff) return;
233
+
234
+ // Merge: start from the existing file, overlay the diff. This keeps
235
+ // every untouched key exactly where it was while updating only the
236
+ // fields the user actually changed in this session.
237
+ const merged = { ...existing, ...diff };
238
+ const wrote = writeConfig(this.opts.filename, merged as Partial<T>, dir);
239
+ if (!wrote) {
240
+ throw new Error(
241
+ `Failed to save ${this.opts.filename} — the config file may be read-only (e.g., managed by Nix). ` +
242
+ `Runtime state was updated for this session only.`,
243
+ );
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Open the settings modal with scope tabs, auto-generated onSave, etc.
249
+ *
250
+ * Before opening the modal, checks global and project-local config files
251
+ * for malformed JSON. If either is invalid, a warning notification is
252
+ * shown so the user knows their config couldn't be fully loaded.
253
+ *
254
+ * @param ctx Extension context
255
+ * @param cwd Working directory for project-local override
256
+ * @param onSave Called with the validated config after the user saves
257
+ * @param configDir Override the global config dir (for tests)
258
+ */
259
+ /**
260
+ * Open the settings modal with scope tabs, auto-generated onSave, scope
261
+ * actions (reset/delete), etc.
262
+ *
263
+ * Before opening the modal, checks global and project-local config files
264
+ * for malformed JSON. If either is invalid, a warning notification is
265
+ * shown so the user knows their config couldn't be fully loaded.
266
+ *
267
+ * @param ctx Extension context
268
+ * @param cwd Working directory for project-local override
269
+ * @param onSave Called with the validated config after the user saves
270
+ * @param configDir Override the global config dir (for tests)
271
+ * @param onChange Optional per-field change handler passed through to
272
+ * the settings modal. Called synchronously when any
273
+ * field value changes (including in buffered mode).
274
+ */
275
+ async openSettings(
276
+ ctx: ExtensionContext,
277
+ cwd: string,
278
+ onSave: (updated: T) => void,
279
+ configDir?: string,
280
+ onChange?: (key: string, value: unknown) => void,
281
+ ): Promise<void> {
282
+ // Check for malformed config files before opening the modal
283
+ this.warnOnMalformedConfig(ctx, cwd, configDir);
284
+
285
+ const config = this.load(cwd, configDir);
286
+ const fields = this.opts.fields(config);
287
+
288
+ // Auto-populate field-level defaults from the config defaults so
289
+ // field-level reset (ctrl+r/ctrl+d) works without every extension
290
+ // manually setting `default` on each field definition.
291
+ const configDefaults = this.opts.defaults as Record<string, unknown>;
292
+ for (const field of fields) {
293
+ if (field.type === "action" || field.type === "custom") continue;
294
+ if (field.default === undefined) {
295
+ const key = String(field.key);
296
+ if (key in configDefaults) {
297
+ (field as any).default = configDefaults[key];
298
+ }
299
+ }
300
+ }
301
+
302
+ // Validate loaded field values and warn about inconsistencies.
303
+ // Per-field warnings are also shown inline in the modal UI.
304
+ const loadWarnings: string[] = [];
305
+ for (const field of fields) {
306
+ if (field.type === "action" || field.type === "custom") continue;
307
+ const warning = validateFieldValue(field, field.value);
308
+ if (warning) loadWarnings.push(`"${field.label}": ${warning}`);
309
+ }
310
+ if (loadWarnings.length > 0) {
311
+ const msg =
312
+ loadWarnings.length === 1
313
+ ? `Config warning: ${loadWarnings[0]}`
314
+ : `Config has ${loadWarnings.length} warnings. First: ${loadWarnings[0]}`;
315
+ ctx.ui.notify(msg, "warning");
316
+ }
317
+
318
+ await openSettingsModal(ctx, {
319
+ title: this.opts.label,
320
+ configFilename: this.opts.filename,
321
+ mode: "buffered",
322
+ // MUST match the dir used by load()/save() — without this the modal
323
+ // initializes its rows from the wrong file (extensions dir fallback),
324
+ // so an untouched field shows DEFAULTS and a save writes those
325
+ // defaults over the real config.
326
+ globalConfigDir: configDir,
327
+ defaults: this.opts.defaults as unknown as Record<string, unknown>,
328
+ inferDefaultScope: () =>
329
+ existsSync(join(cwd, ".pi", this.opts.filename)) ? "project" : "global",
330
+ fields,
331
+ onChange,
332
+ onSave: async (
333
+ values: Record<string, unknown>,
334
+ scope: "global" | "project",
335
+ ) => {
336
+ const merged = { ...config, ...values };
337
+ const updated = this.opts.validate
338
+ ? this.opts.validate(merged as Record<string, unknown>)
339
+ : (merged as T);
340
+ this.save(updated, scope, cwd, configDir);
341
+ onSave(updated);
342
+ },
343
+ onResetScope: async (scope) => {
344
+ this.resetScope(scope, cwd, configDir);
345
+ },
346
+ onDeleteScope: async (scope) => {
347
+ this.deleteScope(scope, cwd, configDir);
348
+ },
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Reset a scope's configuration to defaults. Known config keys
354
+ * (those defined in the schema) are removed from the file; unknown
355
+ * keys (from future versions or user additions) are preserved so
356
+ * forward-compatibility is maintained. Next load will use defaults
357
+ * for all known fields.
358
+ *
359
+ * After this call, the file contains only unknown keys. If no
360
+ * unknown keys exist, the file is deleted entirely.
361
+ */
362
+ resetScope(
363
+ scope: "global" | "project",
364
+ cwd?: string,
365
+ configDir?: string,
366
+ ): void {
367
+ if (scope === "project" && !cwd) {
368
+ throw new Error("cwd is required for project-scoped config reset");
369
+ }
370
+ const dir =
371
+ scope === "project" && cwd
372
+ ? join(cwd, ".pi")
373
+ : (configDir ?? getExtensionsDir());
374
+ const knownKeys = new Set(Object.keys(this.opts.defaults));
375
+ const existing = readConfig<Record<string, unknown>>(
376
+ this.opts.filename,
377
+ dir,
378
+ );
379
+ const unknownKeys: Record<string, unknown> = {};
380
+ if (existing && typeof existing === "object") {
381
+ for (const [key, val] of Object.entries(existing)) {
382
+ if (!knownKeys.has(key)) unknownKeys[key] = val;
383
+ }
384
+ }
385
+ if (Object.keys(unknownKeys).length > 0) {
386
+ writeConfig(this.opts.filename, unknownKeys as T, dir);
387
+ } else {
388
+ deleteConfig(this.opts.filename, dir);
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Delete the entire config file for a scope. Unlike `resetScope`,
394
+ * this removes unknown keys as well — the file is completely gone.
395
+ * Next load will use nothing but defaults.
396
+ */
397
+ deleteScope(
398
+ scope: "global" | "project",
399
+ cwd?: string,
400
+ configDir?: string,
401
+ ): void {
402
+ if (scope === "project" && !cwd) {
403
+ throw new Error("cwd is required for project-scoped config delete");
404
+ }
405
+ const dir =
406
+ scope === "project" && cwd
407
+ ? join(cwd, ".pi")
408
+ : (configDir ?? getExtensionsDir());
409
+ deleteConfig(this.opts.filename, dir);
410
+ }
411
+
412
+ /**
413
+ * Check global and project-local config files for malformed JSON.
414
+ * Warns via ctx.ui.notify if any are found.
415
+ */
416
+ private warnOnMalformedConfig(
417
+ ctx: ExtensionContext,
418
+ cwd: string,
419
+ configDir?: string,
420
+ ): void {
421
+ const filename = this.opts.filename;
422
+
423
+ const globalStatus = checkConfigFile(filename, configDir);
424
+ if (globalStatus.exists && !globalStatus.valid) {
425
+ ctx.ui.notify(
426
+ `Config file "${filename}" is ${globalStatus.error}. Using defaults.`,
427
+ "warning",
428
+ );
429
+ }
430
+
431
+ const projectDir = join(cwd, ".pi");
432
+ const projectStatus = checkConfigFile(filename, projectDir);
433
+ if (projectStatus.exists && !projectStatus.valid) {
434
+ ctx.ui.notify(
435
+ `Project config file ".pi/${filename}" is ${projectStatus.error}. Using defaults.`,
436
+ "warning",
437
+ );
438
+ }
439
+ }
440
+ }