pi-blackhole 0.3.2 → 0.3.4

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.
@@ -15,7 +15,18 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
15
15
  const CONFIG_DIR = "pi-blackhole";
16
16
  const CONFIG_FILE = "pi-blackhole-config.json";
17
17
 
18
- function configPath(): string {
18
+ /** Test-only: override for config directory. Set via __setTestConfigDir(). */
19
+ let __testConfigDir: string | undefined;
20
+
21
+ /** Test-only: set to override config directory. Use in beforeEach/afterEach. */
22
+ export function __setTestConfigDir(dir: string | undefined): void {
23
+ __testConfigDir = dir;
24
+ }
25
+
26
+ export function configPath(): string {
27
+ if (__testConfigDir) {
28
+ return join(__testConfigDir, CONFIG_DIR, CONFIG_FILE);
29
+ }
19
30
  return join(getAgentDir(), CONFIG_DIR, CONFIG_FILE);
20
31
  }
21
32
 
@@ -28,14 +39,35 @@ export interface OmModelConfig {
28
39
  /** Cooldown duration in hours after a retryable error (429/5xx/timeout).
29
40
  * Defaults to 1 hour when omitted. */
30
41
  cooldownHours?: number;
42
+ /** Context window override for this model. Inherits from Pi's model registry when unset. */
43
+ contextWindow?: number;
31
44
  }
32
45
 
33
46
  export interface UnifiedConfig {
34
- /** When true, pi-vcc handles all compactions (not just /pi-vcc). */
35
- overrideDefaultCompaction: boolean;
47
+ /** @deprecated Use compactionEngine instead. */
48
+ overrideDefaultCompaction?: boolean;
36
49
  /** Write debug snapshots to /tmp/pi-blackhole-debug.json. */
37
50
  debug: boolean;
38
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
+ /** How much recent transcript to keep visible after compaction.
66
+ * "pi-default" — use Pi's firstKeptEntryId (respects Pi's keepRecentTokens)
67
+ * "minimal" — keep only last user message (current agressive pi-vcc behavior)
68
+ * ONLY applies when compactionEngine: "blackhole" */
69
+ tailBehavior: "pi-default" | "minimal";
70
+
39
71
  /** Token threshold for observer runs. */
40
72
  observeAfterTokens: number;
41
73
  /** Token threshold for reflector and dropper. */
@@ -68,6 +100,7 @@ export interface UnifiedConfig {
68
100
  /** Shared turn cap for background memory agents. */
69
101
  agentMaxTurns: number;
70
102
 
103
+
71
104
  /** Base model override for all memory workers. */
72
105
  model?: OmModelConfig;
73
106
  /** Model override for observer (most frequent worker). */
@@ -84,12 +117,10 @@ export interface UnifiedConfig {
84
117
  /** Fallback models for dropper, tried in order after primary model fails. */
85
118
  dropperFallbackModels?: OmModelConfig[];
86
119
 
87
- /** When true, observations/reflections are saved to pending.json
88
- * instead of appended to the conversation. Auto-compaction is
89
- * disabled. User triggers /blackhole to flush and compact. */
90
- noAutoCompact: boolean;
91
- /** Disables background workers and auto-compaction entirely. */
92
- passive: boolean;
120
+ /** @deprecated Use compaction instead. */
121
+ noAutoCompact?: boolean;
122
+ /** @deprecated Use compaction + memory instead. */
123
+ passive?: boolean;
93
124
  /** Enables observational memory (workers + content injection). Set to false for pi-vcc only. */
94
125
  memory: boolean;
95
126
  /** Writes debug JSONL to agent directory. */
@@ -99,9 +130,13 @@ export interface UnifiedConfig {
99
130
  // ── Defaults ─────────────────────────────────────────────────────────────────
100
131
 
101
132
  export const DEFAULTS: UnifiedConfig = {
102
- overrideDefaultCompaction: false,
103
133
  debug: false,
104
134
 
135
+ // New config surface
136
+ compaction: "auto",
137
+ compactionEngine: "blackhole",
138
+ tailBehavior: "minimal",
139
+
105
140
  observeAfterTokens: 15_000,
106
141
  reflectAfterTokens: 25_000,
107
142
  compactAfterTokens: 81_000,
@@ -113,8 +148,6 @@ export const DEFAULTS: UnifiedConfig = {
113
148
  observerPreambleMaxTokens: 0,
114
149
  agentMaxTurns: 16,
115
150
 
116
- noAutoCompact: false,
117
- passive: false,
118
151
  memory: true,
119
152
  debugLog: false,
120
153
  };
@@ -123,6 +156,21 @@ export const DEFAULTS: UnifiedConfig = {
123
156
 
124
157
  const THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
125
158
 
159
+ // String enums for new config surface
160
+ const COMPACTION_VALUES = ["auto", "manual", "off"] as const;
161
+ const COMPACTION_ENGINE_VALUES = ["blackhole", "pi-default"] as const;
162
+ const TAIL_BEHAVIOR_VALUES = ["pi-default", "minimal"] as const;
163
+
164
+ function isCompaction(v: unknown): v is "auto" | "manual" | "off" {
165
+ return typeof v === "string" && (COMPACTION_VALUES as readonly string[]).includes(v);
166
+ }
167
+ function isCompactionEngine(v: unknown): v is "blackhole" | "pi-default" {
168
+ return typeof v === "string" && (COMPACTION_ENGINE_VALUES as readonly string[]).includes(v);
169
+ }
170
+ function isTailBehavior(v: unknown): v is "pi-default" | "minimal" {
171
+ return typeof v === "string" && (TAIL_BEHAVIOR_VALUES as readonly string[]).includes(v);
172
+ }
173
+
126
174
  function isRecord(v: unknown): v is Record<string, unknown> {
127
175
  return typeof v === "object" && v !== null;
128
176
  }
@@ -139,6 +187,11 @@ function positiveInt(v: unknown): number | undefined {
139
187
  return Number.isInteger(v) && typeof v === "number" && v > 0 ? v : undefined;
140
188
  }
141
189
 
190
+ /** Like positiveInt but allows 0. Used for cooldownHours where 0 means "disabled". */
191
+ function nonNegativeInt(v: unknown): number | undefined {
192
+ return Number.isInteger(v) && typeof v === "number" && v >= 0 ? v : undefined;
193
+ }
194
+
142
195
  function parseModel(v: unknown): OmModelConfig | undefined {
143
196
  if (!isRecord(v)) return undefined;
144
197
  const provider = nonEmptyString(v.provider);
@@ -146,8 +199,10 @@ function parseModel(v: unknown): OmModelConfig | undefined {
146
199
  if (!provider || !id) return undefined;
147
200
  const model: OmModelConfig = { provider, id };
148
201
  if (isThinkingLevel(v.thinking)) model.thinking = v.thinking;
149
- const cooldown = positiveInt(v.cooldownHours);
202
+ const cooldown = nonNegativeInt(v.cooldownHours);
150
203
  if (cooldown !== undefined) model.cooldownHours = cooldown;
204
+ const ctxWindow = positiveInt(v.contextWindow);
205
+ if (ctxWindow !== undefined) model.contextWindow = ctxWindow;
151
206
  return model;
152
207
  }
153
208
 
@@ -160,6 +215,11 @@ function parseModelArray(v: unknown): OmModelConfig[] | undefined {
160
215
  function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
161
216
  const c: Partial<UnifiedConfig> = {};
162
217
 
218
+ // String enums — compaction surface
219
+ if (isCompaction(raw.compaction)) c.compaction = raw.compaction;
220
+ if (isCompactionEngine(raw.compactionEngine)) c.compactionEngine = raw.compactionEngine;
221
+ if (isTailBehavior(raw.tailBehavior)) c.tailBehavior = raw.tailBehavior;
222
+
163
223
  // Booleans — pi-vcc
164
224
  if (typeof raw.overrideDefaultCompaction === "boolean") c.overrideDefaultCompaction = raw.overrideDefaultCompaction;
165
225
  if (typeof raw.debug === "boolean") c.debug = raw.debug;
@@ -198,6 +258,45 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
198
258
  return c;
199
259
  }
200
260
 
261
+ // ── Migration ────────────────────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Migrate legacy config knobs to new unified surface.
265
+ * Runs once at load time; old keys are removed from the parsed object.
266
+ * Does NOT mutate the on-disk config file.
267
+ */
268
+ function migrateOldKnobs(parsed: Record<string, unknown>): void {
269
+ // Only run if new keys are absent AND old keys are present
270
+ if (parsed.compaction !== undefined || parsed.compactionEngine !== undefined) {
271
+ return; // new keys already set — no migration
272
+ }
273
+
274
+ // passive → compaction: "off" + memory: false
275
+ if (parsed.passive === true) {
276
+ parsed.compaction = "off";
277
+ parsed.memory = false;
278
+ }
279
+ // noAutoCompact → compaction: "manual"
280
+ else if (parsed.noAutoCompact === true) {
281
+ parsed.compaction = "manual";
282
+ }
283
+ // overrideDefaultCompaction → compactionEngine + tailBehavior
284
+ if (parsed.overrideDefaultCompaction === true) {
285
+ parsed.compactionEngine = "blackhole";
286
+ // Preserve aggressive cut for existing users
287
+ if (parsed.tailBehavior === undefined) {
288
+ parsed.tailBehavior = "minimal";
289
+ }
290
+ } else if (parsed.overrideDefaultCompaction === false) {
291
+ parsed.compactionEngine = "pi-default";
292
+ }
293
+
294
+ // Remove old keys so migration runs only once
295
+ delete parsed.passive;
296
+ delete parsed.noAutoCompact;
297
+ delete parsed.overrideDefaultCompaction;
298
+ }
299
+
201
300
  // ── Load and save ────────────────────────────────────────────────────────────
202
301
 
203
302
  function readJson(path: string): Record<string, unknown> | null {
@@ -241,12 +340,34 @@ export function loadUnifiedConfig(cwd: string): UnifiedConfig {
241
340
 
242
341
  const parsed = parseConfig(raw);
243
342
 
244
- // Env override
343
+ // ── Migration: old → new knobs ──
344
+ migrateOldKnobs(parsed);
345
+
346
+ // Env override — legacy passive env vars
245
347
  const envPassive = process.env.PI_BLACKHOLE_PASSIVE ?? process.env.PI_VCC_OM_PASSIVE ?? process.env.PI_OBSERVATIONAL_MEMORY_PASSIVE;
246
348
  if (envPassive !== undefined) {
247
349
  const v = envPassive.trim().toLowerCase();
248
- if (["1", "true", "yes", "on"].includes(v)) parsed.passive = true;
249
- else if (["0", "false", "no", "off"].includes(v)) parsed.passive = false;
350
+ if (["1", "true", "yes", "on"].includes(v)) {
351
+ parsed.compaction = "off";
352
+ parsed.memory = false;
353
+ } else if (["0", "false", "no", "off"].includes(v)) {
354
+ // Falsy env override: undo passive migration when config relied on legacy key
355
+ if (raw?.passive === true) {
356
+ delete parsed.compaction;
357
+ delete parsed.memory;
358
+ }
359
+ }
360
+ }
361
+
362
+ // Env override — new compaction surface
363
+ const envCompaction = process.env.PI_BLACKHOLE_COMPACTION;
364
+ if (envCompaction !== undefined && isCompaction(envCompaction.trim().toLowerCase())) {
365
+ parsed.compaction = envCompaction.trim().toLowerCase() as "auto" | "manual" | "off";
366
+ }
367
+
368
+ const envCompactionEngine = process.env.PI_BLACKHOLE_COMPACTION_ENGINE;
369
+ if (envCompactionEngine !== undefined && isCompactionEngine(envCompactionEngine.trim().toLowerCase())) {
370
+ parsed.compactionEngine = envCompactionEngine.trim().toLowerCase() as "blackhole" | "pi-default";
250
371
  }
251
372
 
252
373
  // Merge defaults then override
@@ -254,23 +375,26 @@ export function loadUnifiedConfig(cwd: string): UnifiedConfig {
254
375
 
255
376
  // ── Validate all numeric fields ──
256
377
  // Prevents NaN/undefined from leaking into runtime math.
257
- const NUMERIC_KEYS: ReadonlyArray<keyof UnifiedConfig> = [
378
+ // Required numeric keys must be >= 1 (or >= 0 for observerPreambleMaxTokens)
379
+ const REQUIRED_NUMERIC_KEYS: ReadonlyArray<keyof UnifiedConfig> = [
258
380
  "observeAfterTokens", "reflectAfterTokens", "compactAfterTokens",
259
381
  "observationsPoolMaxTokens", "observationsPoolTargetTokens",
260
382
  "reflectorInputMaxTokens", "dropperInputMaxTokens",
261
383
  "observerChunkMaxTokens", "observerPreambleMaxTokens",
262
384
  "agentMaxTurns",
263
385
  ];
264
- for (const k of NUMERIC_KEYS) {
386
+ for (const k of REQUIRED_NUMERIC_KEYS) {
265
387
  const v = (merged as Record<string, unknown>)[k];
266
388
  // observerPreambleMaxTokens=0 means "auto-compute from observerChunkMaxTokens (30%)"
267
- // so 0 is valid for that field. All other numeric fields must be strictly positive.
268
- const minVal = k === "observerPreambleMaxTokens" ? 0 : 1;
389
+ // so 0 is valid for those fields. All other numeric fields must be strictly positive.
390
+ const minVal = (k === "observerPreambleMaxTokens") ? 0 : 1;
269
391
  if (typeof v !== "number" || !Number.isFinite(v) || v < minVal) {
270
392
  (merged as Record<string, unknown>)[k] = DEFAULTS[k];
271
393
  }
272
394
  }
273
395
 
396
+
397
+
274
398
  // Derive observationsPoolTargetTokens if still unset or invalid (must be < max)
275
399
  if (
276
400
  merged.observationsPoolTargetTokens === undefined ||
@@ -320,3 +444,43 @@ export function scaffoldConfig(): void {
320
444
  console.error("blackhole: config scaffold failed", e);
321
445
  }
322
446
  }
447
+
448
+ // ── Toggle helpers ───────────────────────────────────────────────────────────
449
+
450
+ /** Cycle compaction: auto → manual → off → auto */
451
+ export function toggleCompaction(current: "auto" | "manual" | "off"): "auto" | "manual" | "off" {
452
+ const cycle: Array<"auto" | "manual" | "off"> = ["auto", "manual", "off"];
453
+ const idx = cycle.indexOf(current);
454
+ return cycle[(idx + 1) % cycle.length];
455
+ }
456
+
457
+ /** Toggle compactionEngine: blackhole ↔ pi-default */
458
+ export function toggleCompactionEngine(current: "blackhole" | "pi-default"): "blackhole" | "pi-default" {
459
+ return current === "blackhole" ? "pi-default" : "blackhole";
460
+ }
461
+
462
+ /** Toggle tailBehavior: pi-default ↔ minimal */
463
+ export function toggleTailBehavior(current: "pi-default" | "minimal"): "pi-default" | "minimal" {
464
+ return current === "pi-default" ? "minimal" : "pi-default";
465
+ }
466
+
467
+ // ── Migration detection ───────────────────────────────────────────────────────
468
+
469
+ /**
470
+ * Check if the on-disk config file still uses legacy keys (needs migration).
471
+ * Returns true when the file exists, has no new keys, but has old keys.
472
+ * Used to prompt users to save their config with the new keys.
473
+ */
474
+ export function configFileNeedsMigration(): boolean {
475
+ try {
476
+ const path = configPath();
477
+ if (!existsSync(path)) return false;
478
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
479
+ if (raw.compaction !== undefined || raw.compactionEngine !== undefined || raw.tailBehavior !== undefined) {
480
+ return false; // already has new keys
481
+ }
482
+ return raw.passive !== undefined || raw.noAutoCompact !== undefined || raw.overrideDefaultCompaction !== undefined;
483
+ } catch {
484
+ return false;
485
+ }
486
+ }
@@ -15,9 +15,34 @@ import type { PiVccCompactionDetails } from "../details";
15
15
  import { buildCompactionProjection, renderSummary } from "../om/ledger/index.js";
16
16
  import type { Runtime } from "../om/runtime.js";
17
17
  import { debugLog } from "../om/debug-log.js";
18
+ import { configFileNeedsMigration } from "../core/unified-config.js";
18
19
 
19
20
  export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
20
21
 
22
+ // ── Migration reminder ────────────────────────────────────────────────────────
23
+
24
+ /** Per-session notification count for migration reminder (max 2). */
25
+ const migrationNotifyCount = new Map<string, number>();
26
+
27
+ /**
28
+ * Show migration reminder notification if user's on-disk config still has legacy keys.
29
+ * At most 2 notifications per session. Call after compaction completes.
30
+ */
31
+ export function notifyMigrationReminder(
32
+ sessionId: string,
33
+ notify: (msg: string, level: string) => void,
34
+ ): void {
35
+ const count = migrationNotifyCount.get(sessionId) ?? 0;
36
+ if (count >= 2) return;
37
+ if (!configFileNeedsMigration()) return;
38
+ migrationNotifyCount.set(sessionId, count + 1);
39
+ notify(
40
+ "blackhole: Use `/blackhole configure` to save your updated configuration.",
41
+ "info",
42
+ );
43
+ }
44
+
45
+
21
46
  const formatTokens = (n: number): string => {
22
47
  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
23
48
  return String(n);
@@ -58,7 +83,13 @@ export type OwnCutResult =
58
83
  | { ok: true; messages: any[]; firstKeptEntryId: string; compactAll: boolean }
59
84
  | { ok: false; reason: OwnCutCancelReason };
60
85
 
61
- export function buildOwnCut(branchEntries: any[]): OwnCutResult {
86
+ export function buildOwnCut(
87
+ branchEntries: any[],
88
+ /** Pi's firstKeptEntryId from preparation (undefined = don't use Pi's cut). */
89
+ piFirstKeptEntryId?: string,
90
+ /** "pi-default" = use Pi's cut, "minimal" = keep only last user message (current). */
91
+ tailBehavior?: "pi-default" | "minimal",
92
+ ): OwnCutResult {
62
93
  // Find the last compaction entry and its firstKeptEntryId
63
94
  let lastCompactionIdx = -1;
64
95
  let lastKeptId: string | undefined;
@@ -99,6 +130,74 @@ export function buildOwnCut(branchEntries: any[]): OwnCutResult {
99
130
  }
100
131
  }
101
132
 
133
+ // ── Pi's cut path: use Pi's firstKeptEntryId instead of last-user cut ──
134
+ if (tailBehavior === "pi-default" && piFirstKeptEntryId) {
135
+ const cutInBranch = branchEntries.findIndex((e: any) => e.id === piFirstKeptEntryId);
136
+ if (cutInBranch >= 0) {
137
+ const liveCutIdx = liveMessages.findIndex((lm) => lm.entry.id === piFirstKeptEntryId);
138
+ if (liveCutIdx > 0) {
139
+ return {
140
+ ok: true,
141
+ messages: liveMessages.slice(0, liveCutIdx).map((e) => e.message),
142
+ firstKeptEntryId: piFirstKeptEntryId,
143
+ compactAll: false,
144
+ };
145
+ }
146
+ if (liveCutIdx === 0) {
147
+ // Pi's cut is at first live message.
148
+ // Pi wants to keep everything, so only compact-all is acceptable (summarizes
149
+ // everything for a fresh page). If minimal path would aggressively cut
150
+ // (multiple user messages), cancel to respect Pi's guidance.
151
+ let lastUserIdx = liveMessages.length - 1;
152
+ while (lastUserIdx > 0 && liveMessages[lastUserIdx].message.role !== "user") {
153
+ lastUserIdx--;
154
+ }
155
+ if (lastUserIdx > 0) {
156
+ // Multiple user messages — minimal would aggressively cut, violating Pi
157
+ return { ok: false, reason: "too_few_live_messages" };
158
+ }
159
+ // Single user message — fall through to minimal path (will compact-all)
160
+ }
161
+ // liveCutIdx === -1: piFirstKeptEntryId not found in liveMessages
162
+ // (e.g., refers to a non-message entry like type:"custom" OM metadata or
163
+ // type:"compaction"). Resolve to the next message entry after pi's cut point.
164
+ if (liveCutIdx < 0) {
165
+ const nextMsgEntry = branchEntries.find(
166
+ (e: any, i: number) => i > cutInBranch && e.type === "message" && e.message,
167
+ );
168
+ if (nextMsgEntry) {
169
+ const resolvedId: string = nextMsgEntry.id;
170
+ const resolvedLiveIdx = liveMessages.findIndex(
171
+ (lm) => lm.entry.id === resolvedId,
172
+ );
173
+ if (resolvedLiveIdx > 0) {
174
+ return {
175
+ ok: true,
176
+ messages: liveMessages.slice(0, resolvedLiveIdx).map((e) => e.message),
177
+ firstKeptEntryId: resolvedId,
178
+ compactAll: false,
179
+ };
180
+ }
181
+ if (resolvedLiveIdx === 0) {
182
+ let lastUserIdx = liveMessages.length - 1;
183
+ while (lastUserIdx > 0 && liveMessages[lastUserIdx].message.role !== "user") {
184
+ lastUserIdx--;
185
+ }
186
+ if (lastUserIdx > 0) {
187
+ return { ok: false, reason: "too_few_live_messages" };
188
+ }
189
+ // Single user message — fall through to minimal (will compact-all)
190
+ }
191
+ // resolvedLiveIdx === -1: resolved message not in liveMessages
192
+ // (shouldn't happen since liveMessages starts from prior firstKeptEntryId
193
+ // which should be before or at pi's cut), but fall through if it does.
194
+ }
195
+ // No message found after pi's cut point in branch — fall through
196
+ }
197
+ }
198
+ // piFirstKeptEntryId not found in branch → fall through to minimal / orphan recovery
199
+ }
200
+
102
201
  if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
103
202
  if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
104
203
 
@@ -135,7 +234,7 @@ export function buildOwnCut(branchEntries: any[]): OwnCutResult {
135
234
 
136
235
  const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
137
236
  no_live_messages: "blackhole: Nothing to compact (no live messages)",
138
- too_few_live_messages: "blackhole: Too few messages to compact",
237
+ too_few_live_messages: "blackhole: Too few live messages — Pi's default logic preserves visible context. Set tailBehavior to \"minimal\" in config to force compaction with fewer messages.",
139
238
  };
140
239
 
141
240
  export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime) => {
@@ -156,18 +255,56 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
156
255
  // Always handle explicit /blackhole marker.
157
256
  // Otherwise, only handle when user opted in via settings.
158
257
  const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
159
- if (!isPiVcc && !omRuntime.config.overrideDefaultCompaction) {
160
- trace("before_compact.return_early", { reason: "overrideDefaultCompaction=false and not /blackhole" });
258
+
259
+ // NEW: Unified compaction guards
260
+ // compaction "off": blackhole skips auto-triggered, but /blackhole still uses blackhole pipeline
261
+ if (omRuntime.config.compaction === "off" && !isPiVcc) {
262
+ trace("before_compact.return_early", { reason: "compaction_off" });
161
263
  return;
162
264
  }
163
265
 
164
- // When noAutoCompact is active, only /blackhole can trigger compaction
165
- if (omRuntime.config.noAutoCompact && !isPiVcc) {
166
- trace("before_compact.cancel", { reason: "noAutoCompact and not /blackhole" });
167
- return { cancel: true };
266
+ // compactionEngine "pi-default" means let Pi handle auto-triggered compactions
267
+ if (omRuntime.config.compactionEngine === "pi-default" && !isPiVcc) {
268
+ trace("before_compact.return_early", { reason: "compactionEngine_pi_default" });
269
+ return;
168
270
  }
169
271
 
170
- const ownCut = buildOwnCut(branchEntries as any[]);
272
+ // compaction "manual": /compact falls through to Pi, /blackhole still works
273
+ if (omRuntime.config.compaction === "manual" && !isPiVcc) {
274
+ trace("before_compact.return_early", { reason: "compaction_manual" });
275
+ return;
276
+ }
277
+
278
+ // LEGACY: old config key guards — only apply when new keys are absent (unmigrated config)
279
+ if (omRuntime.config.compaction === undefined && omRuntime.config.compactionEngine === undefined) {
280
+ if (!isPiVcc && !omRuntime.config.overrideDefaultCompaction) {
281
+ trace("before_compact.return_early", { reason: "overrideDefaultCompaction=false and not /blackhole" });
282
+ return;
283
+ }
284
+
285
+ if (omRuntime.config.noAutoCompact && !isPiVcc) {
286
+ trace("before_compact.cancel", { reason: "noAutoCompact and not /blackhole" });
287
+ return { cancel: true };
288
+ }
289
+ }
290
+
291
+ // Determine effective tail behavior for buildOwnCut
292
+ const effectiveTailBehavior = isPiVcc
293
+ ? (omRuntime.config.tailBehavior ?? "minimal") // /blackhole: minimal by default
294
+ : (omRuntime.config.tailBehavior ?? "minimal");
295
+
296
+ trace("before_compact.tail_behavior", {
297
+ effectiveTailBehavior,
298
+ configTailBehavior: omRuntime.config.tailBehavior,
299
+ isPiVcc,
300
+ piFirstKeptEntryId: preparation.firstKeptEntryId,
301
+ });
302
+
303
+ const ownCut = buildOwnCut(
304
+ branchEntries as any[],
305
+ preparation.firstKeptEntryId,
306
+ effectiveTailBehavior,
307
+ );
171
308
  if (!ownCut.ok) {
172
309
  const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction");
173
310
  const lastCompIdx = lastComp ? (branchEntries as any[]).indexOf(lastComp) : -1;
@@ -344,12 +481,14 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
344
481
  if (omRuntime.compactWasPiVcc) return; // /blackhole handles its own toast via onComplete
345
482
  const stats = omRuntime.compactionStats;
346
483
  if (!stats) return;
484
+ const sessionId = ctx.sessionManager.getSessionId();
347
485
  setTimeout(() => {
348
486
  try {
349
487
  ctx?.ui?.notify?.(
350
488
  `blackhole: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
351
489
  "info",
352
490
  );
491
+ notifyMigrationReminder(sessionId, (msg, level) => ctx?.ui?.notify?.(msg, level as any));
353
492
  } catch {}
354
493
  }, 500);
355
494
  });
@@ -29,24 +29,38 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
29
29
  compactAfterTokens: runtime.config.compactAfterTokens,
30
30
  });
31
31
 
32
- if (runtime.config.passive === true) {
33
- dbg("compaction_trigger.skip", { reason: "passive" });
32
+ // NEW: Unified compaction guards
33
+ if (runtime.config.compaction === "off") {
34
+ dbg("compaction_trigger.skip", { reason: "compaction_off" });
34
35
  return;
35
36
  }
36
- if (runtime.config.memory === false) {
37
- dbg("compaction_trigger.skip", { reason: "memory" });
37
+ if (runtime.config.compaction === "manual") {
38
+ dbg("compaction_trigger.skip", { reason: "compaction_manual" });
38
39
  return;
39
40
  }
40
- if (runtime.config.noAutoCompact === true) {
41
- dbg("compaction_trigger.skip", { reason: "noAutoCompact" });
41
+ if (runtime.config.compactionEngine === "pi-default") {
42
+ dbg("compaction_trigger.skip", { reason: "compactionEngine_pi_default" });
42
43
  return;
43
44
  }
44
- // Don't force Pi to compact unless the user explicitly opted into blackhole's pipeline.
45
- // When overrideDefaultCompaction is false (default), blackhole stays out of the way
46
- // and lets Pi handle its own compaction naturally.
47
- if (runtime.config.overrideDefaultCompaction === false) {
48
- dbg("compaction_trigger.skip", { reason: "overrideDefaultCompaction_false" });
49
- return;
45
+ // NOTE: memory no longer gates compaction memory:false + compaction:auto = compact without OM
46
+
47
+ // LEGACY: old config key guards only apply when new keys are absent (unmigrated config)
48
+ if (runtime.config.compaction === undefined && runtime.config.compactionEngine === undefined) {
49
+ if (runtime.config.passive === true) {
50
+ dbg("compaction_trigger.skip", { reason: "passive" });
51
+ return;
52
+ }
53
+ if (runtime.config.noAutoCompact === true) {
54
+ dbg("compaction_trigger.skip", { reason: "noAutoCompact" });
55
+ return;
56
+ }
57
+ // Don't force Pi to compact unless the user explicitly opted into blackhole's pipeline.
58
+ // When overrideDefaultCompaction is false (default), blackhole stays out of the way
59
+ // and lets Pi handle its own compaction naturally.
60
+ if (runtime.config.overrideDefaultCompaction === false) {
61
+ dbg("compaction_trigger.skip", { reason: "overrideDefaultCompaction_false" });
62
+ return;
63
+ }
50
64
  }
51
65
  if (runtime.compactInFlight) {
52
66
  dbg("compaction_trigger.skip", { reason: "compactInFlight" });