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
@@ -7,9 +7,29 @@
7
7
  */
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+ import { applyEnvOverrides, DECLARATIVE_ENV_OVERRIDES } from "./config-env.js";
11
+ import { getAgentDir as originalGetAgentDir } from "@earendil-works/pi-coding-agent";
11
12
  import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
12
13
 
14
+ // ── getAgentDir with PI_CODING_AGENT_DIR override ───────────────────────────
15
+
16
+ let __lastAgentDirEnv: string | undefined;
17
+ let __cachedAgentDir: string | null = null;
18
+
19
+ /**
20
+ * Get the canonical pi-agent data directory.
21
+ * Respects the `PI_CODING_AGENT_DIR` environment variable when set.
22
+ * Memoized — only recomputes when the env var changes.
23
+ */
24
+ export function getAgentDir(): string {
25
+ const current = process.env.PI_CODING_AGENT_DIR?.trim();
26
+ if (current === __lastAgentDirEnv && __cachedAgentDir !== null)
27
+ return __cachedAgentDir;
28
+ __lastAgentDirEnv = current;
29
+ __cachedAgentDir = current || originalGetAgentDir();
30
+ return __cachedAgentDir;
31
+ }
32
+
13
33
  // ── Config path ──────────────────────────────────────────────────────────────
14
34
 
15
35
  const CONFIG_DIR = "pi-blackhole";
@@ -20,166 +40,176 @@ let __testConfigDir: string | undefined;
20
40
 
21
41
  /** Test-only: set to override config directory. Use in beforeEach/afterEach. */
22
42
  export function __setTestConfigDir(dir: string | undefined): void {
23
- __testConfigDir = dir;
43
+ __testConfigDir = dir;
24
44
  }
25
45
 
26
46
  export function configPath(): string {
27
- if (__testConfigDir) {
28
- return join(__testConfigDir, CONFIG_DIR, CONFIG_FILE);
29
- }
30
- return join(getAgentDir(), CONFIG_DIR, CONFIG_FILE);
47
+ if (__testConfigDir) {
48
+ return join(__testConfigDir, CONFIG_DIR, CONFIG_FILE);
49
+ }
50
+ return join(getAgentDir(), CONFIG_DIR, CONFIG_FILE);
31
51
  }
32
52
 
33
53
  // ── Types ────────────────────────────────────────────────────────────────────
34
54
 
35
55
  export interface OmModelConfig {
36
- provider: string;
37
- id: string;
38
- thinking?: ModelThinkingLevel;
39
- /** Cooldown duration in hours after a retryable error (429/5xx/timeout).
40
- * Defaults to 1 hour when omitted. */
41
- cooldownHours?: number;
42
- /** Context window override for this model. Inherits from Pi's model registry when unset. */
43
- contextWindow?: number;
56
+ provider: string;
57
+ id: string;
58
+ thinking?: ModelThinkingLevel;
59
+ /** Cooldown duration in hours after a retryable error (429/5xx/timeout).
60
+ * Defaults to 1 hour when omitted. */
61
+ cooldownHours?: number;
62
+ /** Context window override for this model. Inherits from Pi's model registry when unset. */
63
+ contextWindow?: number;
44
64
  }
45
65
 
46
66
  export interface UnifiedConfig {
47
- /** @deprecated Use compactionEngine instead. */
48
- overrideDefaultCompaction?: boolean;
49
- /** Write debug snapshots to /tmp/pi-blackhole-debug.json. */
50
- debug: boolean;
51
-
52
- // ── New config surface — compaction, engine, tail behavior ──
53
-
54
- /** Unified compaction control: "auto" | "manual" | "off".
55
- * "auto" — auto-trigger on compactAfterTokens threshold
56
- * "manual" — only via /blackhole command
57
- * "off" — never compact (disables auto + blocks /blackhole) */
58
- compaction: "auto" | "manual" | "off";
59
-
60
- /** Which engine handles compaction.
61
- * "blackhole" — blackhole's compile() + OM injection
62
- * "pi-default" — Pi's built-in summarization */
63
- compactionEngine: "blackhole" | "pi-default";
64
-
65
- /** Mid-run auto-compaction (turn_end trigger, fires while the agent is still
66
- * executing tool loops — agent_end alone never fires during long runs).
67
- * "resume" — compact at threshold and inject a resume message so the agent
68
- * continues the task (default)
69
- * "pause" — compact at threshold but stop; user continues manually
70
- * "off" — only evaluate the threshold when the agent finishes a run */
71
- midRunCompaction: "resume" | "pause" | "off";
72
-
73
- /** How much recent transcript to keep visible after compaction.
74
- * "pi-default" — use Pi's firstKeptEntryId (respects Pi's keepRecentTokens)
75
- * "minimal" — keep only last user message (current agressive pi-vcc behavior)
76
- * ONLY applies when compactionEngine: "blackhole" */
77
- tailBehavior: "pi-default" | "minimal";
78
-
79
- /** Token threshold for observer runs. */
80
- observeAfterTokens: number;
81
- /** Token threshold for reflector and dropper. */
82
- reflectAfterTokens: number;
83
- /** Token threshold for proactive auto-compaction. */
84
- compactAfterTokens: number;
85
- /** Observation pool token pressure for full fold. */
86
- observationsPoolMaxTokens: number;
87
- /** Treat every compaction as a full-fold boundary so early reflections/drops
88
- * survive the first compaction in a fresh session. Default true. */
89
- fullFoldAlways: boolean;
90
- /** Target token budget for the observation pool (dropper aims here).
91
- * Optional; defaults to half of observationsPoolMaxTokens when unset.
92
- * Must be less than observationsPoolMaxTokens.
93
- *
94
- * NOTE: Ported from upstream as forward-compat (no-op in our pool algorithm).
95
- * Upstream renamed budgetTokens→targetTokens (52b5844) and uses this
96
- * for their tokensOverTarget / avgTokensPerObservation drop calculation.
97
- * We keep our ratio-based urgency algorithm; this knob exists so future
98
- * lockstep iterations don't diverge on the config shape. */
99
- observationsPoolTargetTokens: number;
100
- /** Max prompt tokens for reflector model input (rolling window cap). */
101
- reflectorInputMaxTokens: number;
102
- /** Max prompt tokens for dropper model input (rolling window cap). */
103
- dropperInputMaxTokens: number;
104
- /** Pressure threshold for dropper. When active observation pool tokens exceed
105
- * this fraction of reflectorInputMaxTokens, the dropper runs even without new
106
- * observations/reflections (to keep the pool pruned).
107
- * Default 0.70 (70%). Must be in range (0, 1]. */
108
- dropperPressureThreshold: number;
109
- /** Max source entries tokens sent to observer per chunk. */
110
- observerChunkMaxTokens: number;
111
- /** Max preamble tokens (CURRENT REFLECTIONS / OBSERVATIONS) in the observer prompt.
112
- * Default 0 means auto-compute from observerChunkMaxTokens (30%). Only applied in
113
- * noAutoCompact mode where accumulated batch history can grow unbounded.
114
- * Set to an explicit value to override the auto-computed budget. */
115
- observerPreambleMaxTokens: number;
116
- /** Shared turn cap for background memory agents. */
117
- agentMaxTurns: number;
118
-
119
-
120
- /** Base model override for all memory workers. */
121
- model?: OmModelConfig;
122
- /** Model override for observer (most frequent worker). */
123
- observerModel?: OmModelConfig;
124
- /** Model override for reflector (synthesizes durable facts). */
125
- reflectorModel?: OmModelConfig;
126
- /** Model override for dropper (prunes observations). */
127
- dropperModel?: OmModelConfig;
128
-
129
- /** Fallback models for observer, tried in order after primary model fails. */
130
- observerFallbackModels?: OmModelConfig[];
131
- /** Fallback models for reflector, tried in order after primary model fails. */
132
- reflectorFallbackModels?: OmModelConfig[];
133
- /** Fallback models for dropper, tried in order after primary model fails. */
134
- dropperFallbackModels?: OmModelConfig[];
135
-
136
- /** When false, skip session model fallback when all OM model candidates are exhausted.
137
- * Default true for backward compatibility. */
138
- sessionFallback?: boolean;
139
-
140
- /** @deprecated Use compaction instead. */
141
- noAutoCompact?: boolean;
142
- /** @deprecated Use compaction + memory instead. */
143
- passive?: boolean;
144
- /** Enables observational memory (workers + content injection). Set to false for pi-vcc only. */
145
- memory: boolean;
146
- /** Writes debug JSONL to agent directory. */
147
- debugLog: boolean;
67
+ /** @deprecated Use compactionEngine instead. */
68
+ overrideDefaultCompaction?: boolean;
69
+ /** Write debug snapshots to /tmp/pi-blackhole-debug.json. */
70
+ debug: boolean;
71
+
72
+ // ── New config surface — compaction, engine, tail behavior ──
73
+
74
+ /** Unified compaction control: "auto" | "manual" | "off".
75
+ * "auto" — auto-trigger on compactAfterTokens threshold
76
+ * "manual" — only via /blackhole command
77
+ * "off" — never compact (disables auto + blocks /blackhole) */
78
+ compaction: "auto" | "manual" | "off";
79
+
80
+ /** Which engine handles compaction.
81
+ * "blackhole" — blackhole's compile() + OM injection
82
+ * "pi-default" — Pi's built-in summarization */
83
+ compactionEngine: "blackhole" | "pi-default";
84
+
85
+ /** Mid-run auto-compaction (turn_end trigger, fires while the agent is still
86
+ * executing tool loops — agent_end alone never fires during long runs).
87
+ * "resume" — compact at threshold and inject a resume message so the agent
88
+ * continues the task (default)
89
+ * "pause" — compact at threshold but stop; user continues manually
90
+ * "off" — only evaluate the threshold when the agent finishes a run */
91
+ midRunCompaction: "resume" | "pause" | "off";
92
+
93
+ /** How much recent transcript to keep visible after compaction.
94
+ * "pi-default" — use Pi's firstKeptEntryId (respects Pi's keepRecentTokens)
95
+ * "minimal" — keep only last user message (current agressive pi-vcc behavior)
96
+ * ONLY applies when compactionEngine: "blackhole" */
97
+ tailBehavior: "pi-default" | "minimal";
98
+
99
+ /** Token threshold for observer runs. */
100
+ observeAfterTokens: number;
101
+ /** Token threshold for reflector and dropper. */
102
+ reflectAfterTokens: number;
103
+ /** Token threshold for proactive auto-compaction. */
104
+ compactAfterTokens: number;
105
+ /** Observation pool token pressure for full fold. */
106
+ observationsPoolMaxTokens: number;
107
+ /** Treat every compaction as a full-fold boundary so early reflections/drops
108
+ * survive the first compaction in a fresh session. Default true. */
109
+ fullFoldAlways: boolean;
110
+ /** Target token budget for the observation pool (dropper aims here).
111
+ * Optional; defaults to half of observationsPoolMaxTokens when unset.
112
+ * Must be less than observationsPoolMaxTokens.
113
+ *
114
+ * NOTE: Ported from upstream as forward-compat (no-op in our pool algorithm).
115
+ * Upstream renamed budgetTokens→targetTokens (52b5844) and uses this
116
+ * for their tokensOverTarget / avgTokensPerObservation drop calculation.
117
+ * We keep our ratio-based urgency algorithm; this knob exists so future
118
+ * lockstep iterations don't diverge on the config shape. */
119
+ observationsPoolTargetTokens: number;
120
+ /** Max prompt tokens for reflector model input (rolling window cap). */
121
+ reflectorInputMaxTokens: number;
122
+ /** Max prompt tokens for dropper model input (rolling window cap). */
123
+ dropperInputMaxTokens: number;
124
+ /** Pressure threshold for dropper. When active observation pool tokens exceed
125
+ * this fraction of reflectorInputMaxTokens, the dropper runs even without new
126
+ * observations/reflections (to keep the pool pruned).
127
+ * Default 0.70 (70%). Must be in range (0, 1]. */
128
+ dropperPressureThreshold: number;
129
+ /** Minimum observation-pool fullness (fraction of observationsPoolMaxTokens)
130
+ * before the dropper may run. Prevents churn on a nearly empty pool.
131
+ * Default 0.10 (10%). Must be in range (0, 1]. */
132
+ dropperPoolFullnessThreshold: number;
133
+ /** Max source entries tokens sent to observer per chunk. */
134
+ observerChunkMaxTokens: number;
135
+ /** Max preamble tokens (CURRENT REFLECTIONS / OBSERVATIONS) in the observer prompt.
136
+ * Default 0 means auto-compute from observerChunkMaxTokens (30%). Only applied in
137
+ * noAutoCompact mode where accumulated batch history can grow unbounded.
138
+ * Set to an explicit value to override the auto-computed budget. */
139
+ observerPreambleMaxTokens: number;
140
+ /** Shared turn cap for background memory agents. */
141
+ agentMaxTurns: number;
142
+
143
+ /** Base model override for all memory workers. */
144
+ model?: OmModelConfig;
145
+ /** Model override for observer (most frequent worker). */
146
+ observerModel?: OmModelConfig;
147
+ /** Model override for reflector (synthesizes durable facts). */
148
+ reflectorModel?: OmModelConfig;
149
+ /** Model override for dropper (prunes observations). */
150
+ dropperModel?: OmModelConfig;
151
+
152
+ /** Fallback models for observer, tried in order after primary model fails. */
153
+ observerFallbackModels?: OmModelConfig[];
154
+ /** Fallback models for reflector, tried in order after primary model fails. */
155
+ reflectorFallbackModels?: OmModelConfig[];
156
+ /** Fallback models for dropper, tried in order after primary model fails. */
157
+ dropperFallbackModels?: OmModelConfig[];
158
+
159
+ /** When false, skip session model fallback when all OM model candidates are exhausted.
160
+ * Default true for backward compatibility. */
161
+ sessionFallback?: boolean;
162
+
163
+ /** @deprecated Use compaction instead. */
164
+ noAutoCompact?: boolean;
165
+ /** @deprecated Use compaction + memory instead. */
166
+ passive?: boolean;
167
+ /** Enables observational memory (workers + content injection). Set to false for pi-vcc only. */
168
+ memory: boolean;
169
+ /** Writes debug JSONL to agent directory. */
170
+ debugLog: boolean;
148
171
  }
149
172
 
150
173
  // ── Defaults ─────────────────────────────────────────────────────────────────
151
174
 
152
175
  export const DEFAULTS: UnifiedConfig = {
153
- debug: false,
154
- sessionFallback: true,
155
-
156
- // New config surface
157
- compaction: "auto",
158
- compactionEngine: "blackhole",
159
- tailBehavior: "minimal",
160
- midRunCompaction: "off",
161
-
162
-
163
- observeAfterTokens: 15_000,
164
- reflectAfterTokens: 25_000,
165
- compactAfterTokens: 81_000,
166
- observationsPoolMaxTokens: 20_000,
167
- fullFoldAlways: true,
168
- observationsPoolTargetTokens: 10_000,
169
- reflectorInputMaxTokens: 80_000,
170
- dropperInputMaxTokens: 80_000,
171
- dropperPressureThreshold: 0.70,
172
- observerChunkMaxTokens: 40_000,
173
- observerPreambleMaxTokens: 0,
174
- agentMaxTurns: 16,
175
-
176
- memory: true,
177
- debugLog: false,
176
+ debug: false,
177
+ sessionFallback: true,
178
+
179
+ // New config surface
180
+ compaction: "auto",
181
+ compactionEngine: "blackhole",
182
+ tailBehavior: "minimal",
183
+ midRunCompaction: "off",
184
+
185
+ observeAfterTokens: 15_000,
186
+ reflectAfterTokens: 25_000,
187
+ compactAfterTokens: 81_000,
188
+ observationsPoolMaxTokens: 20_000,
189
+ fullFoldAlways: true,
190
+ observationsPoolTargetTokens: 10_000,
191
+ reflectorInputMaxTokens: 80_000,
192
+ dropperInputMaxTokens: 80_000,
193
+ dropperPressureThreshold: 0.7,
194
+ dropperPoolFullnessThreshold: 0.1,
195
+ observerChunkMaxTokens: 40_000,
196
+ observerPreambleMaxTokens: 0,
197
+ agentMaxTurns: 16,
198
+
199
+ memory: true,
200
+ debugLog: false,
178
201
  };
179
202
 
180
203
  // ── Parsing helpers ──────────────────────────────────────────────────────────
181
204
 
182
- const THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
205
+ const THINKING_LEVELS: readonly string[] = [
206
+ "off",
207
+ "minimal",
208
+ "low",
209
+ "medium",
210
+ "high",
211
+ "xhigh",
212
+ ];
183
213
 
184
214
  // String enums for new config surface
185
215
  const COMPACTION_VALUES = ["auto", "manual", "off"] as const;
@@ -188,113 +218,159 @@ const TAIL_BEHAVIOR_VALUES = ["pi-default", "minimal"] as const;
188
218
  const MID_RUN_COMPACTION_VALUES = ["resume", "pause", "off"] as const;
189
219
 
190
220
  function isCompaction(v: unknown): v is "auto" | "manual" | "off" {
191
- return typeof v === "string" && (COMPACTION_VALUES as readonly string[]).includes(v);
221
+ return (
222
+ typeof v === "string" &&
223
+ (COMPACTION_VALUES as readonly string[]).includes(v)
224
+ );
192
225
  }
193
226
  function isCompactionEngine(v: unknown): v is "blackhole" | "pi-default" {
194
- return typeof v === "string" && (COMPACTION_ENGINE_VALUES as readonly string[]).includes(v);
227
+ return (
228
+ typeof v === "string" &&
229
+ (COMPACTION_ENGINE_VALUES as readonly string[]).includes(v)
230
+ );
195
231
  }
196
232
  function isTailBehavior(v: unknown): v is "pi-default" | "minimal" {
197
- return typeof v === "string" && (TAIL_BEHAVIOR_VALUES as readonly string[]).includes(v);
233
+ return (
234
+ typeof v === "string" &&
235
+ (TAIL_BEHAVIOR_VALUES as readonly string[]).includes(v)
236
+ );
198
237
  }
199
238
  function isMidRunCompaction(v: unknown): v is "resume" | "pause" | "off" {
200
- return typeof v === "string" && (MID_RUN_COMPACTION_VALUES as readonly string[]).includes(v);
239
+ return (
240
+ typeof v === "string" &&
241
+ (MID_RUN_COMPACTION_VALUES as readonly string[]).includes(v)
242
+ );
201
243
  }
202
244
 
203
245
  function isRecord(v: unknown): v is Record<string, unknown> {
204
- return typeof v === "object" && v !== null;
246
+ return typeof v === "object" && v !== null;
205
247
  }
206
248
 
207
249
  function nonEmptyString(v: unknown): string | undefined {
208
- return typeof v === "string" && v.length > 0 ? v : undefined;
250
+ return typeof v === "string" && v.length > 0 ? v : undefined;
209
251
  }
210
252
 
211
253
  function isThinkingLevel(v: unknown): v is ModelThinkingLevel {
212
- return typeof v === "string" && THINKING_LEVELS.includes(v);
254
+ return typeof v === "string" && THINKING_LEVELS.includes(v);
213
255
  }
214
256
 
215
257
  function positiveInt(v: unknown): number | undefined {
216
- return Number.isInteger(v) && typeof v === "number" && v > 0 ? v : undefined;
258
+ return Number.isInteger(v) && typeof v === "number" && v > 0 ? v : undefined;
217
259
  }
218
260
 
219
261
  /** Like positiveInt but allows 0. Used for cooldownHours where 0 means "disabled". */
220
262
  function nonNegativeInt(v: unknown): number | undefined {
221
- return Number.isInteger(v) && typeof v === "number" && v >= 0 ? v : undefined;
263
+ return Number.isInteger(v) && typeof v === "number" && v >= 0 ? v : undefined;
222
264
  }
223
265
 
224
266
  function parseModel(v: unknown): OmModelConfig | undefined {
225
- if (!isRecord(v)) return undefined;
226
- const provider = nonEmptyString(v.provider);
227
- const id = nonEmptyString(v.id);
228
- if (!provider || !id) return undefined;
229
- const model: OmModelConfig = { provider, id };
230
- if (isThinkingLevel(v.thinking)) model.thinking = v.thinking;
231
- const cooldown = nonNegativeInt(v.cooldownHours);
232
- if (cooldown !== undefined) model.cooldownHours = cooldown;
233
- const ctxWindow = positiveInt(v.contextWindow);
234
- if (ctxWindow !== undefined) model.contextWindow = ctxWindow;
235
- return model;
267
+ if (!isRecord(v)) return undefined;
268
+ const provider = nonEmptyString(v.provider);
269
+ const id = nonEmptyString(v.id);
270
+ if (!provider || !id) return undefined;
271
+ const model: OmModelConfig = { provider, id };
272
+ if (isThinkingLevel(v.thinking)) model.thinking = v.thinking;
273
+ const cooldown = nonNegativeInt(v.cooldownHours);
274
+ if (cooldown !== undefined) model.cooldownHours = cooldown;
275
+ const ctxWindow = positiveInt(v.contextWindow);
276
+ if (ctxWindow !== undefined) model.contextWindow = ctxWindow;
277
+ return model;
236
278
  }
237
279
 
238
280
  function parseModelArray(v: unknown): OmModelConfig[] | undefined {
239
- if (!Array.isArray(v)) return undefined;
240
- const parsed = v.map(parseModel).filter((m): m is OmModelConfig => m !== undefined);
241
- return parsed.length > 0 ? parsed : undefined;
281
+ if (!Array.isArray(v)) return undefined;
282
+ const parsed = v
283
+ .map(parseModel)
284
+ .filter((m): m is OmModelConfig => m !== undefined);
285
+ return parsed.length > 0 ? parsed : undefined;
242
286
  }
243
287
 
244
288
  function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
245
- const c: Partial<UnifiedConfig> = {};
246
-
247
- // String enums — compaction surface
248
- if (isCompaction(raw.compaction)) c.compaction = raw.compaction;
249
- if (isCompactionEngine(raw.compactionEngine)) c.compactionEngine = raw.compactionEngine;
250
- if (isTailBehavior(raw.tailBehavior)) c.tailBehavior = raw.tailBehavior;
251
- if (isMidRunCompaction(raw.midRunCompaction)) c.midRunCompaction = raw.midRunCompaction;
252
-
253
- // Booleans — pi-vcc
254
- if (typeof raw.overrideDefaultCompaction === "boolean") c.overrideDefaultCompaction = raw.overrideDefaultCompaction;
255
- if (typeof raw.debug === "boolean") c.debug = raw.debug;
256
-
257
- // Booleans — om
258
- if (typeof raw.sessionFallback === "boolean") c.sessionFallback = raw.sessionFallback;
259
- if (typeof raw.noAutoCompact === "boolean") c.noAutoCompact = raw.noAutoCompact;
260
- if (typeof raw.passive === "boolean") c.passive = raw.passive;
261
- if (typeof raw.memory === "boolean") c.memory = raw.memory;
262
- if (typeof raw.fullFoldAlways === "boolean") c.fullFoldAlways = raw.fullFoldAlways;
263
- if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
264
-
265
- // Numeric fields use nonNegativeInt for observerPreambleMaxTokens (0 = auto)
266
- const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "observationsPoolTargetTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "observerPreambleMaxTokens", "agentMaxTurns"] as const;
267
-
268
- // dropperPressureThreshold: fractional, must be in (0, 1]
269
- if (typeof raw.dropperPressureThreshold === "number" && Number.isFinite(raw.dropperPressureThreshold) && raw.dropperPressureThreshold > 0 && raw.dropperPressureThreshold <= 1) {
270
- c.dropperPressureThreshold = raw.dropperPressureThreshold;
271
- }
272
- for (const k of numKeys) {
273
- // observerPreambleMaxTokens accepts 0 (auto-compute); everything else must be > 0
274
- const validator = k === "observerPreambleMaxTokens" ? nonNegativeInt : positiveInt;
275
- const v = validator(raw[k]);
276
- if (v !== undefined) (c as Record<string, unknown>)[k] = v;
277
- }
278
-
279
- // Models
280
- const model = parseModel(raw.model);
281
- if (model) c.model = model;
282
- const obsModel = parseModel(raw.observerModel);
283
- if (obsModel) c.observerModel = obsModel;
284
- const refModel = parseModel(raw.reflectorModel);
285
- if (refModel) c.reflectorModel = refModel;
286
- const dropModel = parseModel(raw.dropperModel);
287
- if (dropModel) c.dropperModel = dropModel;
288
-
289
- // Fallback model arrays
290
- const obsFallback = parseModelArray(raw.observerFallbackModels);
291
- if (obsFallback) c.observerFallbackModels = obsFallback;
292
- const refFallback = parseModelArray(raw.reflectorFallbackModels);
293
- if (refFallback) c.reflectorFallbackModels = refFallback;
294
- const dropFallback = parseModelArray(raw.dropperFallbackModels);
295
- if (dropFallback) c.dropperFallbackModels = dropFallback;
296
-
297
- return c;
289
+ const c: Partial<UnifiedConfig> = {};
290
+
291
+ // String enums — compaction surface
292
+ if (isCompaction(raw.compaction)) c.compaction = raw.compaction;
293
+ if (isCompactionEngine(raw.compactionEngine))
294
+ c.compactionEngine = raw.compactionEngine;
295
+ if (isTailBehavior(raw.tailBehavior)) c.tailBehavior = raw.tailBehavior;
296
+ if (isMidRunCompaction(raw.midRunCompaction))
297
+ c.midRunCompaction = raw.midRunCompaction;
298
+
299
+ // Booleans pi-vcc
300
+ if (typeof raw.overrideDefaultCompaction === "boolean")
301
+ c.overrideDefaultCompaction = raw.overrideDefaultCompaction;
302
+ if (typeof raw.debug === "boolean") c.debug = raw.debug;
303
+
304
+ // Booleans om
305
+ if (typeof raw.sessionFallback === "boolean")
306
+ c.sessionFallback = raw.sessionFallback;
307
+ if (typeof raw.noAutoCompact === "boolean")
308
+ c.noAutoCompact = raw.noAutoCompact;
309
+ if (typeof raw.passive === "boolean") c.passive = raw.passive;
310
+ if (typeof raw.memory === "boolean") c.memory = raw.memory;
311
+ if (typeof raw.fullFoldAlways === "boolean")
312
+ c.fullFoldAlways = raw.fullFoldAlways;
313
+ if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
314
+
315
+ // Numeric fields — use nonNegativeInt for observerPreambleMaxTokens (0 = auto)
316
+ const numKeys = [
317
+ "observeAfterTokens",
318
+ "reflectAfterTokens",
319
+ "compactAfterTokens",
320
+ "observationsPoolMaxTokens",
321
+ "observationsPoolTargetTokens",
322
+ "reflectorInputMaxTokens",
323
+ "dropperInputMaxTokens",
324
+ "observerChunkMaxTokens",
325
+ "observerPreambleMaxTokens",
326
+ "agentMaxTurns",
327
+ ] as const;
328
+
329
+ // dropperPressureThreshold: fractional, must be in (0, 1]
330
+ if (
331
+ typeof raw.dropperPressureThreshold === "number" &&
332
+ Number.isFinite(raw.dropperPressureThreshold) &&
333
+ raw.dropperPressureThreshold > 0 &&
334
+ raw.dropperPressureThreshold <= 1
335
+ ) {
336
+ c.dropperPressureThreshold = raw.dropperPressureThreshold;
337
+ }
338
+ // dropperPoolFullnessThreshold: fractional, must be in (0, 1]
339
+ if (
340
+ typeof raw.dropperPoolFullnessThreshold === "number" &&
341
+ Number.isFinite(raw.dropperPoolFullnessThreshold) &&
342
+ raw.dropperPoolFullnessThreshold > 0 &&
343
+ raw.dropperPoolFullnessThreshold <= 1
344
+ ) {
345
+ c.dropperPoolFullnessThreshold = raw.dropperPoolFullnessThreshold;
346
+ }
347
+ for (const k of numKeys) {
348
+ // observerPreambleMaxTokens accepts 0 (auto-compute); everything else must be > 0
349
+ const validator =
350
+ k === "observerPreambleMaxTokens" ? nonNegativeInt : positiveInt;
351
+ const v = validator(raw[k]);
352
+ if (v !== undefined) (c as Record<string, unknown>)[k] = v;
353
+ }
354
+
355
+ // Models
356
+ const model = parseModel(raw.model);
357
+ if (model) c.model = model;
358
+ const obsModel = parseModel(raw.observerModel);
359
+ if (obsModel) c.observerModel = obsModel;
360
+ const refModel = parseModel(raw.reflectorModel);
361
+ if (refModel) c.reflectorModel = refModel;
362
+ const dropModel = parseModel(raw.dropperModel);
363
+ if (dropModel) c.dropperModel = dropModel;
364
+
365
+ // Fallback model arrays
366
+ const obsFallback = parseModelArray(raw.observerFallbackModels);
367
+ if (obsFallback) c.observerFallbackModels = obsFallback;
368
+ const refFallback = parseModelArray(raw.reflectorFallbackModels);
369
+ if (refFallback) c.reflectorFallbackModels = refFallback;
370
+ const dropFallback = parseModelArray(raw.dropperFallbackModels);
371
+ if (dropFallback) c.dropperFallbackModels = dropFallback;
372
+
373
+ return c;
298
374
  }
299
375
 
300
376
  // ── Migration ────────────────────────────────────────────────────────────────
@@ -306,48 +382,54 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
306
382
  * Idempotent — safe to call repeatedly.
307
383
  */
308
384
  function migrateOldKnobs(parsed: Record<string, unknown>): void {
309
- // Only run if new keys are absent AND old keys are present
310
- if (parsed.compaction !== undefined || parsed.compactionEngine !== undefined) {
311
- return; // new keys already set — no migration
312
- }
313
-
314
- // passive compaction: "off" + memory: false
315
- if (parsed.passive === true) {
316
- parsed.compaction = "off";
317
- parsed.memory = false;
318
- }
319
- // noAutoCompact → compaction: "manual"
320
- else if (parsed.noAutoCompact === true) {
321
- parsed.compaction = "manual";
322
- }
323
- // overrideDefaultCompaction compactionEngine + tailBehavior
324
- if (parsed.overrideDefaultCompaction === true) {
325
- parsed.compactionEngine = "blackhole";
326
- // Preserve aggressive cut for existing users
327
- if (parsed.tailBehavior === undefined) {
328
- parsed.tailBehavior = "minimal";
329
- }
330
- } else if (parsed.overrideDefaultCompaction === false) {
331
- parsed.compactionEngine = "pi-default";
332
- }
333
-
334
- // Remove old keys so migration runs only once
335
- delete parsed.passive;
336
- delete parsed.noAutoCompact;
337
- delete parsed.overrideDefaultCompaction;
385
+ // Only run if new keys are absent AND old keys are present
386
+ if (
387
+ parsed.compaction !== undefined ||
388
+ parsed.compactionEngine !== undefined
389
+ ) {
390
+ return; // new keys already set no migration
391
+ }
392
+
393
+ // passive → compaction: "off" + memory: false
394
+ if (parsed.passive === true) {
395
+ parsed.compaction = "off";
396
+ parsed.memory = false;
397
+ }
398
+ // noAutoCompact → compaction: "manual"
399
+ else if (parsed.noAutoCompact === true) {
400
+ parsed.compaction = "manual";
401
+ }
402
+ // overrideDefaultCompaction compactionEngine + tailBehavior
403
+ if (parsed.overrideDefaultCompaction === true) {
404
+ parsed.compactionEngine = "blackhole";
405
+ // Preserve aggressive cut for existing users
406
+ if (parsed.tailBehavior === undefined) {
407
+ parsed.tailBehavior = "minimal";
408
+ }
409
+ } else if (parsed.overrideDefaultCompaction === false) {
410
+ parsed.compactionEngine = "pi-default";
411
+ }
412
+
413
+ // Remove old keys so migration runs only once
414
+ delete parsed.passive;
415
+ delete parsed.noAutoCompact;
416
+ delete parsed.overrideDefaultCompaction;
338
417
  }
339
418
 
340
419
  // ── Load and save ────────────────────────────────────────────────────────────
341
420
 
342
- function readJson(path: string): { data: Record<string, unknown> | null; error: string | null } {
343
- if (!existsSync(path)) return { data: null, error: null };
344
- try {
345
- return { data: JSON.parse(readFileSync(path, "utf-8")), error: null };
346
- } catch (e) {
347
- const msg = `blackhole: config file at ${path} has invalid JSON: ${(e as Error).message}. Using defaults.`;
348
- console.warn(msg);
349
- return { data: null, error: msg };
350
- }
421
+ function readJson(path: string): {
422
+ data: Record<string, unknown> | null;
423
+ error: string | null;
424
+ } {
425
+ if (!existsSync(path)) return { data: null, error: null };
426
+ try {
427
+ return { data: JSON.parse(readFileSync(path, "utf-8")), error: null };
428
+ } catch (e) {
429
+ const msg = `blackhole: config file at ${path} has invalid JSON: ${(e as Error).message}. Using defaults.`;
430
+ console.warn(msg);
431
+ return { data: null, error: msg };
432
+ }
351
433
  }
352
434
 
353
435
  /** Optional warning callback invoked when the primary config file has invalid JSON.
@@ -361,144 +443,171 @@ type WarnFn = (message: string) => void;
361
443
  * Falls back to legacy sources if the unified file doesn't exist.
362
444
  */
363
445
  export function loadUnifiedConfig(cwd: string, onWarn?: WarnFn): UnifiedConfig {
364
- const path = configPath();
365
- let raw: Record<string, unknown> | null;
366
- let primaryError: string | null = null;
367
- const result = readJson(path);
368
- raw = result.data;
369
- primaryError = result.error;
370
- if (primaryError && onWarn) onWarn(primaryError);
371
-
372
- // Fallback to legacy sources if unified file doesn't exist
373
- if (!raw) {
374
- // Try legacy pi-vcc config
375
- const piVccPath = join(getAgentDir(), "pi-vcc-config.json");
376
- const piVccResult = readJson(piVccPath);
377
- const piVccRaw = piVccResult.data;
378
- if (piVccResult.error && onWarn) onWarn(piVccResult.error);
379
-
380
- // Try legacy om config from settings.json
381
- const settingsPath = join(getAgentDir(), "settings.json");
382
- const settingsResult = readJson(settingsPath);
383
- const settingsRaw = settingsResult.data;
384
- if (settingsResult.error && onWarn) onWarn(settingsResult.error);
385
- const omRaw = settingsRaw?.["pi-blackhole"] ?? settingsRaw?.["observational-memory"];
386
- const projectSettingsPath = join(cwd, ".pi", "settings.json");
387
- const projectResult = readJson(projectSettingsPath);
388
- const projectRaw = projectResult.data;
389
- if (projectResult.error && onWarn) onWarn(projectResult.error);
390
- const projectOmRaw = projectRaw?.["pi-blackhole"] ?? projectRaw?.["observational-memory"];
391
-
392
- // Merge legacy sources
393
- const merged: Record<string, unknown> = {};
394
- if (piVccRaw && isRecord(piVccRaw)) Object.assign(merged, piVccRaw);
395
- if (omRaw && isRecord(omRaw)) Object.assign(merged, omRaw);
396
- if (projectOmRaw && isRecord(projectOmRaw)) Object.assign(merged, projectOmRaw);
397
- raw = merged;
398
- }
399
-
400
- const parsed = parseConfig(raw);
401
-
402
- // ── Migration: old → new knobs ──
403
- migrateOldKnobs(parsed);
404
-
405
- // Env override — legacy passive env vars
406
- const envPassive = process.env.PI_BLACKHOLE_PASSIVE ?? process.env.PI_VCC_OM_PASSIVE ?? process.env.PI_OBSERVATIONAL_MEMORY_PASSIVE;
407
- if (envPassive !== undefined) {
408
- const v = envPassive.trim().toLowerCase();
409
- if (["1", "true", "yes", "on"].includes(v)) {
410
- parsed.compaction = "off";
411
- parsed.memory = false;
412
- } else if (["0", "false", "no", "off"].includes(v)) {
413
- // Falsy env override: undo passive migration when config relied on legacy key
414
- if (raw?.passive === true) {
415
- delete parsed.compaction;
416
- delete parsed.memory;
417
- }
418
- }
419
- }
420
-
421
- // Env override — new compaction surface
422
- const envCompaction = process.env.PI_BLACKHOLE_COMPACTION;
423
- if (envCompaction !== undefined) {
424
- const trimmed = envCompaction.trim().toLowerCase();
425
- if (isCompaction(trimmed)) {
426
- parsed.compaction = trimmed as "auto" | "manual" | "off";
427
- } else {
428
- console.warn(`blackhole: invalid PI_BLACKHOLE_COMPACTION value "${envCompaction}"; ignoring`);
429
- }
430
- }
431
-
432
- const envCompactionEngine = process.env.PI_BLACKHOLE_COMPACTION_ENGINE;
433
- if (envCompactionEngine !== undefined) {
434
- const trimmed = envCompactionEngine.trim().toLowerCase();
435
- if (isCompactionEngine(trimmed)) {
436
- parsed.compactionEngine = trimmed as "blackhole" | "pi-default";
437
- } else {
438
- console.warn(`blackhole: invalid PI_BLACKHOLE_COMPACTION_ENGINE value "${envCompactionEngine}"; ignoring`);
439
- }
440
- }
441
-
442
- // Merge defaults then override
443
- const merged = { ...DEFAULTS, ...parsed };
444
-
445
- // ── Validate all numeric fields ──
446
- // Prevents NaN/undefined from leaking into runtime math.
447
- // Required numeric keys must be >= 1 (or >= 0 for observerPreambleMaxTokens)
448
- const REQUIRED_NUMERIC_KEYS: ReadonlyArray<keyof UnifiedConfig> = [
449
- "observeAfterTokens", "reflectAfterTokens", "compactAfterTokens",
450
- "observationsPoolMaxTokens", "observationsPoolTargetTokens",
451
- "reflectorInputMaxTokens", "dropperInputMaxTokens",
452
- "observerChunkMaxTokens", "observerPreambleMaxTokens",
453
- "agentMaxTurns",
454
- ];
455
- for (const k of REQUIRED_NUMERIC_KEYS) {
456
- const v = (merged as Record<string, unknown>)[k];
457
- // observerPreambleMaxTokens=0 means "auto-compute from observerChunkMaxTokens (30%)"
458
- // so 0 is valid for those fields. All other numeric fields must be strictly positive.
459
- const minVal = (k === "observerPreambleMaxTokens") ? 0 : 1;
460
- if (typeof v !== "number" || !Number.isFinite(v) || v < minVal) {
461
- (merged as Record<string, unknown>)[k] = DEFAULTS[k];
462
- }
463
- }
464
-
465
-
466
-
467
- // Validate dropperPressureThreshold must be in (0, 1]
468
- if (typeof merged.dropperPressureThreshold !== "number" || !Number.isFinite(merged.dropperPressureThreshold) || merged.dropperPressureThreshold <= 0 || merged.dropperPressureThreshold > 1) {
469
- merged.dropperPressureThreshold = DEFAULTS.dropperPressureThreshold;
470
- }
471
-
472
- // Derive observationsPoolTargetTokens if still unset or invalid (must be < max)
473
- if (
474
- merged.observationsPoolTargetTokens === undefined ||
475
- merged.observationsPoolTargetTokens >= merged.observationsPoolMaxTokens
476
- ) {
477
- merged.observationsPoolTargetTokens = Math.floor(merged.observationsPoolMaxTokens / 2);
478
- }
479
-
480
- return merged;
446
+ const path = configPath();
447
+ let raw: Record<string, unknown> | null;
448
+ let primaryError: string | null = null;
449
+ const result = readJson(path);
450
+ raw = result.data;
451
+ primaryError = result.error;
452
+ if (primaryError && onWarn) onWarn(primaryError);
453
+
454
+ // Fallback to legacy sources if unified file doesn't exist
455
+ if (!raw) {
456
+ // Try legacy pi-vcc config
457
+ const piVccPath = join(getAgentDir(), "pi-vcc-config.json");
458
+ const piVccResult = readJson(piVccPath);
459
+ const piVccRaw = piVccResult.data;
460
+ if (piVccResult.error && onWarn) onWarn(piVccResult.error);
461
+
462
+ // Try legacy om config from settings.json
463
+ const settingsPath = join(getAgentDir(), "settings.json");
464
+ const settingsResult = readJson(settingsPath);
465
+ const settingsRaw = settingsResult.data;
466
+ if (settingsResult.error && onWarn) onWarn(settingsResult.error);
467
+ const omRaw =
468
+ settingsRaw?.["pi-blackhole"] ?? settingsRaw?.["observational-memory"];
469
+ const projectSettingsPath = join(cwd, ".pi", "settings.json");
470
+ const projectResult = readJson(projectSettingsPath);
471
+ const projectRaw = projectResult.data;
472
+ if (projectResult.error && onWarn) onWarn(projectResult.error);
473
+ const projectOmRaw =
474
+ projectRaw?.["pi-blackhole"] ?? projectRaw?.["observational-memory"];
475
+
476
+ // Merge legacy sources
477
+ const merged: Record<string, unknown> = {};
478
+ if (piVccRaw && isRecord(piVccRaw)) Object.assign(merged, piVccRaw);
479
+ if (omRaw && isRecord(omRaw)) Object.assign(merged, omRaw);
480
+ if (projectOmRaw && isRecord(projectOmRaw))
481
+ Object.assign(merged, projectOmRaw);
482
+ raw = merged;
483
+ }
484
+
485
+ // Project-local override: <cwd>/.pi/pi-blackhole-config.json
486
+ const projectConfigPath = join(cwd, ".pi", CONFIG_FILE);
487
+ const projectResult = readJson(projectConfigPath);
488
+ const projectRaw = projectResult.data;
489
+ if (projectResult.error && onWarn) onWarn(projectResult.error);
490
+ if (projectRaw && isRecord(projectRaw)) {
491
+ raw = { ...raw, ...projectRaw };
492
+ }
493
+
494
+ const parsed = parseConfig(raw);
495
+
496
+ // ── Migration: old → new knobs ──
497
+ migrateOldKnobs(parsed);
498
+
499
+ // Env override — legacy passive env vars
500
+ const envPassive =
501
+ process.env.PI_BLACKHOLE_PASSIVE ??
502
+ process.env.PI_VCC_OM_PASSIVE ??
503
+ process.env.PI_OBSERVATIONAL_MEMORY_PASSIVE;
504
+ if (envPassive !== undefined) {
505
+ const v = envPassive.trim().toLowerCase();
506
+ if (["1", "true", "yes", "on"].includes(v)) {
507
+ parsed.compaction = "off";
508
+ parsed.memory = false;
509
+ } else if (["0", "false", "no", "off"].includes(v)) {
510
+ // Falsy env override: undo passive migration when config relied on legacy key
511
+ if (raw?.passive === true) {
512
+ delete parsed.compaction;
513
+ delete parsed.memory;
514
+ }
515
+ }
516
+ }
517
+
518
+ // Merge defaults then override
519
+ const merged = { ...DEFAULTS, ...parsed };
520
+
521
+ // ── Env override — compaction ──
522
+ const envCompaction = process.env.PI_BLACKHOLE_COMPACTION;
523
+ if (envCompaction !== undefined) {
524
+ const trimmed = envCompaction.trim().toLowerCase();
525
+ if (isCompaction(trimmed)) {
526
+ merged.compaction = trimmed as "auto" | "manual" | "off";
527
+ } else {
528
+ console.warn(
529
+ `blackhole: invalid PI_BLACKHOLE_COMPACTION value "${envCompaction}"; ignoring`,
530
+ );
531
+ }
532
+ }
533
+
534
+ // ── Env override — compaction engine ──
535
+ const envCompactionEngine = process.env.PI_BLACKHOLE_COMPACTION_ENGINE;
536
+ if (envCompactionEngine !== undefined) {
537
+ const trimmed = envCompactionEngine.trim().toLowerCase();
538
+ if (isCompactionEngine(trimmed)) {
539
+ merged.compactionEngine = trimmed as "blackhole" | "pi-default";
540
+ } else {
541
+ console.warn(
542
+ `blackhole: invalid PI_BLACKHOLE_COMPACTION_ENGINE value "${envCompactionEngine}"; ignoring`,
543
+ );
544
+ }
545
+ }
546
+
547
+ // ── Declarative PI_BLACKHOLE_* overrides ──
548
+ // Same env map as the ConfigManager modal path, so env overrides apply
549
+ // to the RUNTIME config, not just the modal. Overrides any file value.
550
+ const withEnv = applyEnvOverrides(
551
+ merged,
552
+ DECLARATIVE_ENV_OVERRIDES,
553
+ DEFAULTS as unknown as Record<string, unknown>,
554
+ );
555
+
556
+ return withEnv;
481
557
  }
482
558
 
483
559
  /**
484
560
  * Write settings back to disk. Preserves unknown keys.
485
561
  */
486
562
  export function saveUnifiedConfig(settings: Partial<UnifiedConfig>): boolean {
487
- try {
488
- const path = configPath();
489
- const dir = dirname(path);
490
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
491
- const existingResult = readJson(path);
492
- const existing = existingResult.data ?? {};
493
- if (existingResult.error) {
494
- console.warn("blackhole: overwriting corrupt config file at " + path);
495
- }
496
- const next = { ...existing, ...settings };
497
- writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
498
- return true;
499
- } catch {
500
- return false;
501
- }
563
+ try {
564
+ const path = configPath();
565
+ const dir = dirname(path);
566
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
567
+ const existingResult = readJson(path);
568
+ const existing = existingResult.data ?? {};
569
+ if (existingResult.error) {
570
+ console.warn("blackhole: overwriting corrupt config file at " + path);
571
+ }
572
+ const next = { ...existing, ...settings };
573
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
574
+ return true;
575
+ } catch {
576
+ return false;
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Write settings back to disk for a specific scope.
582
+ *
583
+ * - global: writes to `<agentDir>/pi-blackhole/pi-blackhole-config.json`
584
+ * - project: writes to `<cwd>/.pi/pi-blackhole-config.json`
585
+ *
586
+ * Preserves unknown keys in the target file.
587
+ */
588
+ export function saveUnifiedConfigScoped(
589
+ settings: Partial<UnifiedConfig>,
590
+ scope: "global" | "project",
591
+ cwd: string,
592
+ ): boolean {
593
+ try {
594
+ const dir =
595
+ scope === "project"
596
+ ? join(cwd, ".pi")
597
+ : join(getAgentDir(), "pi-blackhole");
598
+ const path = join(dir, CONFIG_FILE);
599
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
600
+ const existingResult = readJson(path);
601
+ const existing = existingResult.data ?? {};
602
+ if (existingResult.error) {
603
+ console.warn("blackhole: overwriting corrupt config file at " + path);
604
+ }
605
+ const next = { ...existing, ...settings };
606
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
607
+ return true;
608
+ } catch {
609
+ return false;
610
+ }
502
611
  }
503
612
 
504
613
  /**
@@ -510,21 +619,19 @@ export function saveUnifiedConfig(settings: Partial<UnifiedConfig>): boolean {
510
619
  * read-only filesystems where the config is managed externally (e.g., Nix).
511
620
  */
512
621
  export function scaffoldConfig(): void {
513
- try {
514
- const path = configPath();
515
- const dir = dirname(path);
516
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
517
-
518
- if (!existsSync(path)) {
519
- writeFileSync(path, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
520
- }
521
- } catch (e) {
522
- console.error("blackhole: config scaffold failed", e);
523
- }
622
+ try {
623
+ const path = configPath();
624
+ const dir = dirname(path);
625
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
626
+
627
+ if (!existsSync(path)) {
628
+ writeFileSync(path, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
629
+ }
630
+ } catch (e) {
631
+ console.error("blackhole: config scaffold failed", e);
632
+ }
524
633
  }
525
634
 
526
-
527
-
528
635
  // ── Migration detection ───────────────────────────────────────────────────────
529
636
 
530
637
  /**
@@ -533,15 +640,46 @@ export function scaffoldConfig(): void {
533
640
  * Used to prompt users to save their config with the new keys.
534
641
  */
535
642
  export function configFileNeedsMigration(): boolean {
536
- try {
537
- const path = configPath();
538
- if (!existsSync(path)) return false;
539
- const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
540
- if (raw.compaction !== undefined || raw.compactionEngine !== undefined || raw.tailBehavior !== undefined) {
541
- return false; // already has new keys
542
- }
543
- return raw.passive !== undefined || raw.noAutoCompact !== undefined || raw.overrideDefaultCompaction !== undefined;
544
- } catch {
545
- return false;
546
- }
643
+ try {
644
+ const path = configPath();
645
+ if (!existsSync(path)) return false;
646
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<
647
+ string,
648
+ unknown
649
+ >;
650
+ if (
651
+ raw.compaction !== undefined ||
652
+ raw.compactionEngine !== undefined ||
653
+ raw.tailBehavior !== undefined
654
+ ) {
655
+ return false; // already has new keys
656
+ }
657
+ return (
658
+ raw.passive !== undefined ||
659
+ raw.noAutoCompact !== undefined ||
660
+ raw.overrideDefaultCompaction !== undefined
661
+ );
662
+ } catch {
663
+ return false;
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Check whether the OM pipeline is in manual mode.
669
+ *
670
+ * In manual mode, observations/reflections/dropped are saved to the
671
+ * per-session pending file instead of being appended to the branch.
672
+ * On `/blackhole`, pending entries are flushed to the branch and
673
+ * the pending file is cleared.
674
+ *
675
+ * Handles both the new `compaction: "manual"` key and the legacy
676
+ * `noAutoCompact: true` key for backward compatibility.
677
+ *
678
+ * @param config — The current runtime config (may include legacy noAutoCompact)
679
+ */
680
+ export function isManualMode(config: {
681
+ compaction?: string;
682
+ noAutoCompact?: boolean;
683
+ }): boolean {
684
+ return config.compaction === "manual" || config.noAutoCompact === true;
547
685
  }