pi-blackhole 0.3.2 → 0.3.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.
- package/README.md +63 -62
- package/package.json +1 -1
- package/src/commands/memory.ts +5 -5
- package/src/commands/pi-vcc.ts +44 -7
- package/src/core/settings.ts +17 -2
- package/src/core/unified-config.ts +178 -19
- package/src/hooks/before-compact.ts +148 -9
- package/src/om/compaction-trigger.ts +26 -12
- package/src/om/configure-overlay.ts +381 -0
- package/src/om/consolidation.ts +42 -1
- package/src/om/key-matcher.ts +72 -0
- package/src/om/model-budget.ts +22 -0
- package/src/om/status-overlay.ts +242 -0
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
35
|
-
overrideDefaultCompaction
|
|
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
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
}
|
|
@@ -148,6 +196,8 @@ function parseModel(v: unknown): OmModelConfig | undefined {
|
|
|
148
196
|
if (isThinkingLevel(v.thinking)) model.thinking = v.thinking;
|
|
149
197
|
const cooldown = positiveInt(v.cooldownHours);
|
|
150
198
|
if (cooldown !== undefined) model.cooldownHours = cooldown;
|
|
199
|
+
const ctxWindow = positiveInt(v.contextWindow);
|
|
200
|
+
if (ctxWindow !== undefined) model.contextWindow = ctxWindow;
|
|
151
201
|
return model;
|
|
152
202
|
}
|
|
153
203
|
|
|
@@ -160,6 +210,11 @@ function parseModelArray(v: unknown): OmModelConfig[] | undefined {
|
|
|
160
210
|
function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
|
|
161
211
|
const c: Partial<UnifiedConfig> = {};
|
|
162
212
|
|
|
213
|
+
// String enums — compaction surface
|
|
214
|
+
if (isCompaction(raw.compaction)) c.compaction = raw.compaction;
|
|
215
|
+
if (isCompactionEngine(raw.compactionEngine)) c.compactionEngine = raw.compactionEngine;
|
|
216
|
+
if (isTailBehavior(raw.tailBehavior)) c.tailBehavior = raw.tailBehavior;
|
|
217
|
+
|
|
163
218
|
// Booleans — pi-vcc
|
|
164
219
|
if (typeof raw.overrideDefaultCompaction === "boolean") c.overrideDefaultCompaction = raw.overrideDefaultCompaction;
|
|
165
220
|
if (typeof raw.debug === "boolean") c.debug = raw.debug;
|
|
@@ -198,6 +253,45 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
|
|
|
198
253
|
return c;
|
|
199
254
|
}
|
|
200
255
|
|
|
256
|
+
// ── Migration ────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Migrate legacy config knobs to new unified surface.
|
|
260
|
+
* Runs once at load time; old keys are removed from the parsed object.
|
|
261
|
+
* Does NOT mutate the on-disk config file.
|
|
262
|
+
*/
|
|
263
|
+
function migrateOldKnobs(parsed: Record<string, unknown>): void {
|
|
264
|
+
// Only run if new keys are absent AND old keys are present
|
|
265
|
+
if (parsed.compaction !== undefined || parsed.compactionEngine !== undefined) {
|
|
266
|
+
return; // new keys already set — no migration
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// passive → compaction: "off" + memory: false
|
|
270
|
+
if (parsed.passive === true) {
|
|
271
|
+
parsed.compaction = "off";
|
|
272
|
+
parsed.memory = false;
|
|
273
|
+
}
|
|
274
|
+
// noAutoCompact → compaction: "manual"
|
|
275
|
+
else if (parsed.noAutoCompact === true) {
|
|
276
|
+
parsed.compaction = "manual";
|
|
277
|
+
}
|
|
278
|
+
// overrideDefaultCompaction → compactionEngine + tailBehavior
|
|
279
|
+
if (parsed.overrideDefaultCompaction === true) {
|
|
280
|
+
parsed.compactionEngine = "blackhole";
|
|
281
|
+
// Preserve aggressive cut for existing users
|
|
282
|
+
if (parsed.tailBehavior === undefined) {
|
|
283
|
+
parsed.tailBehavior = "minimal";
|
|
284
|
+
}
|
|
285
|
+
} else if (parsed.overrideDefaultCompaction === false) {
|
|
286
|
+
parsed.compactionEngine = "pi-default";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Remove old keys so migration runs only once
|
|
290
|
+
delete parsed.passive;
|
|
291
|
+
delete parsed.noAutoCompact;
|
|
292
|
+
delete parsed.overrideDefaultCompaction;
|
|
293
|
+
}
|
|
294
|
+
|
|
201
295
|
// ── Load and save ────────────────────────────────────────────────────────────
|
|
202
296
|
|
|
203
297
|
function readJson(path: string): Record<string, unknown> | null {
|
|
@@ -241,12 +335,34 @@ export function loadUnifiedConfig(cwd: string): UnifiedConfig {
|
|
|
241
335
|
|
|
242
336
|
const parsed = parseConfig(raw);
|
|
243
337
|
|
|
244
|
-
//
|
|
338
|
+
// ── Migration: old → new knobs ──
|
|
339
|
+
migrateOldKnobs(parsed);
|
|
340
|
+
|
|
341
|
+
// Env override — legacy passive env vars
|
|
245
342
|
const envPassive = process.env.PI_BLACKHOLE_PASSIVE ?? process.env.PI_VCC_OM_PASSIVE ?? process.env.PI_OBSERVATIONAL_MEMORY_PASSIVE;
|
|
246
343
|
if (envPassive !== undefined) {
|
|
247
344
|
const v = envPassive.trim().toLowerCase();
|
|
248
|
-
if (["1", "true", "yes", "on"].includes(v))
|
|
249
|
-
|
|
345
|
+
if (["1", "true", "yes", "on"].includes(v)) {
|
|
346
|
+
parsed.compaction = "off";
|
|
347
|
+
parsed.memory = false;
|
|
348
|
+
} else if (["0", "false", "no", "off"].includes(v)) {
|
|
349
|
+
// Falsy env override: undo passive migration when config relied on legacy key
|
|
350
|
+
if (raw?.passive === true) {
|
|
351
|
+
delete parsed.compaction;
|
|
352
|
+
delete parsed.memory;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Env override — new compaction surface
|
|
358
|
+
const envCompaction = process.env.PI_BLACKHOLE_COMPACTION;
|
|
359
|
+
if (envCompaction !== undefined && isCompaction(envCompaction.trim().toLowerCase())) {
|
|
360
|
+
parsed.compaction = envCompaction.trim().toLowerCase() as "auto" | "manual" | "off";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const envCompactionEngine = process.env.PI_BLACKHOLE_COMPACTION_ENGINE;
|
|
364
|
+
if (envCompactionEngine !== undefined && isCompactionEngine(envCompactionEngine.trim().toLowerCase())) {
|
|
365
|
+
parsed.compactionEngine = envCompactionEngine.trim().toLowerCase() as "blackhole" | "pi-default";
|
|
250
366
|
}
|
|
251
367
|
|
|
252
368
|
// Merge defaults then override
|
|
@@ -254,23 +370,26 @@ export function loadUnifiedConfig(cwd: string): UnifiedConfig {
|
|
|
254
370
|
|
|
255
371
|
// ── Validate all numeric fields ──
|
|
256
372
|
// Prevents NaN/undefined from leaking into runtime math.
|
|
257
|
-
|
|
373
|
+
// Required numeric keys — must be >= 1 (or >= 0 for observerPreambleMaxTokens)
|
|
374
|
+
const REQUIRED_NUMERIC_KEYS: ReadonlyArray<keyof UnifiedConfig> = [
|
|
258
375
|
"observeAfterTokens", "reflectAfterTokens", "compactAfterTokens",
|
|
259
376
|
"observationsPoolMaxTokens", "observationsPoolTargetTokens",
|
|
260
377
|
"reflectorInputMaxTokens", "dropperInputMaxTokens",
|
|
261
378
|
"observerChunkMaxTokens", "observerPreambleMaxTokens",
|
|
262
379
|
"agentMaxTurns",
|
|
263
380
|
];
|
|
264
|
-
for (const k of
|
|
381
|
+
for (const k of REQUIRED_NUMERIC_KEYS) {
|
|
265
382
|
const v = (merged as Record<string, unknown>)[k];
|
|
266
383
|
// observerPreambleMaxTokens=0 means "auto-compute from observerChunkMaxTokens (30%)"
|
|
267
|
-
// so 0 is valid for
|
|
268
|
-
const minVal = k === "observerPreambleMaxTokens" ? 0 : 1;
|
|
384
|
+
// so 0 is valid for those fields. All other numeric fields must be strictly positive.
|
|
385
|
+
const minVal = (k === "observerPreambleMaxTokens") ? 0 : 1;
|
|
269
386
|
if (typeof v !== "number" || !Number.isFinite(v) || v < minVal) {
|
|
270
387
|
(merged as Record<string, unknown>)[k] = DEFAULTS[k];
|
|
271
388
|
}
|
|
272
389
|
}
|
|
273
390
|
|
|
391
|
+
|
|
392
|
+
|
|
274
393
|
// Derive observationsPoolTargetTokens if still unset or invalid (must be < max)
|
|
275
394
|
if (
|
|
276
395
|
merged.observationsPoolTargetTokens === undefined ||
|
|
@@ -320,3 +439,43 @@ export function scaffoldConfig(): void {
|
|
|
320
439
|
console.error("blackhole: config scaffold failed", e);
|
|
321
440
|
}
|
|
322
441
|
}
|
|
442
|
+
|
|
443
|
+
// ── Toggle helpers ───────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
/** Cycle compaction: auto → manual → off → auto */
|
|
446
|
+
export function toggleCompaction(current: "auto" | "manual" | "off"): "auto" | "manual" | "off" {
|
|
447
|
+
const cycle: Array<"auto" | "manual" | "off"> = ["auto", "manual", "off"];
|
|
448
|
+
const idx = cycle.indexOf(current);
|
|
449
|
+
return cycle[(idx + 1) % cycle.length];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Toggle compactionEngine: blackhole ↔ pi-default */
|
|
453
|
+
export function toggleCompactionEngine(current: "blackhole" | "pi-default"): "blackhole" | "pi-default" {
|
|
454
|
+
return current === "blackhole" ? "pi-default" : "blackhole";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Toggle tailBehavior: pi-default ↔ minimal */
|
|
458
|
+
export function toggleTailBehavior(current: "pi-default" | "minimal"): "pi-default" | "minimal" {
|
|
459
|
+
return current === "pi-default" ? "minimal" : "pi-default";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Migration detection ───────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Check if the on-disk config file still uses legacy keys (needs migration).
|
|
466
|
+
* Returns true when the file exists, has no new keys, but has old keys.
|
|
467
|
+
* Used to prompt users to save their config with the new keys.
|
|
468
|
+
*/
|
|
469
|
+
export function configFileNeedsMigration(): boolean {
|
|
470
|
+
try {
|
|
471
|
+
const path = configPath();
|
|
472
|
+
if (!existsSync(path)) return false;
|
|
473
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
|
474
|
+
if (raw.compaction !== undefined || raw.compactionEngine !== undefined || raw.tailBehavior !== undefined) {
|
|
475
|
+
return false; // already has new keys
|
|
476
|
+
}
|
|
477
|
+
return raw.passive !== undefined || raw.noAutoCompact !== undefined || raw.overrideDefaultCompaction !== undefined;
|
|
478
|
+
} catch {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
//
|
|
165
|
-
if (omRuntime.config.
|
|
166
|
-
trace("before_compact.
|
|
167
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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.
|
|
37
|
-
dbg("compaction_trigger.skip", { reason: "
|
|
37
|
+
if (runtime.config.compaction === "manual") {
|
|
38
|
+
dbg("compaction_trigger.skip", { reason: "compaction_manual" });
|
|
38
39
|
return;
|
|
39
40
|
}
|
|
40
|
-
if (runtime.config.
|
|
41
|
-
dbg("compaction_trigger.skip", { reason: "
|
|
41
|
+
if (runtime.config.compactionEngine === "pi-default") {
|
|
42
|
+
dbg("compaction_trigger.skip", { reason: "compactionEngine_pi_default" });
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
if (runtime.config.
|
|
48
|
-
|
|
49
|
-
|
|
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" });
|