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.
- 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 +184 -20
- package/src/hooks/before-compact.ts +148 -9
- package/src/om/compaction-trigger.ts +26 -12
- package/src/om/configure-overlay.ts +383 -0
- package/src/om/consolidation.ts +64 -6
- package/src/om/cooldown.ts +10 -0
- package/src/om/key-matcher.ts +38 -0
- package/src/om/model-budget.ts +22 -0
- package/src/om/runtime.ts +31 -1
- package/src/om/status-overlay.ts +243 -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
|
}
|
|
@@ -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 =
|
|
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
|
-
//
|
|
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))
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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" });
|