pi-blackhole 0.2.0 → 0.2.2

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 CHANGED
@@ -131,6 +131,7 @@ The tradeoff is simplicity vs cleanliness:
131
131
  |---|---|---|
132
132
  | Workers run? | Yes | Yes |
133
133
  | Observations go to | Conversation markers (invisible in TUI) | Disk (`pending.json`) |
134
+ | Observations accumulate across runs | Branch markers (replaced each cycle) | Pending batches — `/memory` shows pending counts |
134
135
  | Auto-compact on `agent_end` | Yes | No |
135
136
  | `/blackhole` | Optional — use it whenever you want | Required to flush + compact |
136
137
  | Conversation history | OM marker entries between turns (they exist but do not clutter the display) | Clean — nothing between turns |
@@ -170,7 +171,7 @@ Set `memory: false` or run `/blackhole om-off` for pure pi-vcc compaction — no
170
171
 
171
172
  ## Configuration
172
173
 
173
- All settings in **`~/.pi/agent/pi-blackhole-config.json`** — auto-created with defaults on first startup. See [`CONFIG.md`](CONFIG.md) for the full reference and tuning guidance.
174
+ All settings in **`~/.pi/agent/pi-blackhole/pi-blackhole-config.json`** — auto-created with defaults on first startup. See [`CONFIG.md`](CONFIG.md) for the full reference and tuning guidance. An annotated example config with explanations is at [`example-config.json`](example-config.json).
174
175
 
175
176
  Quick start with custom models:
176
177
 
@@ -198,6 +199,7 @@ Quick start with custom models:
198
199
  | `reflectAfterTokens` | `20000` | Token cadence for reflector and dropper |
199
200
  | `compactAfterTokens` | `81000` | Auto-compaction threshold |
200
201
  | `observerChunkMaxTokens` | `40000` | Max observer input per run (newest-first) |
202
+ | `observerPreambleMaxTokens` | `0` (auto) | Max preamble tokens in observer prompt for `noAutoCompact` mode (auto = 30% of chunk) |
201
203
  | `observationsPoolMaxTokens` | `20000` | Max active observation pool before dropper prunes |
202
204
  | `reflectorInputMaxTokens` | `80000` | Max reflector input budget |
203
205
  | `dropperInputMaxTokens` | `80000` | Max dropper input budget |
@@ -220,6 +222,7 @@ Paste the appropriate block into your config to match your model's context size.
220
222
  "reflectAfterTokens": 10000,
221
223
  "compactAfterTokens": 30000,
222
224
  "observerChunkMaxTokens": 15000,
225
+ "observerPreambleMaxTokens": 0,
223
226
  "observationsPoolMaxTokens": 8000,
224
227
  "reflectorInputMaxTokens": 30000,
225
228
  "dropperInputMaxTokens": 30000
@@ -236,6 +239,7 @@ Our built-in defaults already target this tier. If you reset your config, these
236
239
  "reflectAfterTokens": 20000,
237
240
  "compactAfterTokens": 81000,
238
241
  "observerChunkMaxTokens": 40000,
242
+ "observerPreambleMaxTokens": 0,
239
243
  "observationsPoolMaxTokens": 20000,
240
244
  "reflectorInputMaxTokens": 80000,
241
245
  "dropperInputMaxTokens": 80000
@@ -250,6 +254,7 @@ Our built-in defaults already target this tier. If you reset your config, these
250
254
  "reflectAfterTokens": 40000,
251
255
  "compactAfterTokens": 180000,
252
256
  "observerChunkMaxTokens": 80000,
257
+ "observerPreambleMaxTokens": 0,
253
258
  "observationsPoolMaxTokens": 40000,
254
259
  "reflectorInputMaxTokens": 160000,
255
260
  "dropperInputMaxTokens": 160000
@@ -354,7 +359,7 @@ pnpm add pi-blackhole
354
359
 
355
360
  ```bash
356
361
  pi uninstall git:github.com/k0valik/pi-blackhole
357
- rm -rf ~/.pi/agent/pi-blackhole ~/.pi/agent/pi-blackhole-config.json
362
+ rm -rf ~/.pi/agent/pi-blackhole
358
363
  ```
359
364
 
360
365
  ---
@@ -86,13 +86,14 @@
86
86
  }
87
87
  ],
88
88
 
89
- "observeAfterTokens": 10000,
90
- "reflectAfterTokens": 20000,
89
+ "observeAfterTokens": 15000,
90
+ "reflectAfterTokens": 25000,
91
91
  "compactAfterTokens": 81000,
92
92
  "observationsPoolMaxTokens": 20000,
93
93
  "reflectorInputMaxTokens": 80000,
94
94
  "dropperInputMaxTokens": 80000,
95
95
  "observerChunkMaxTokens": 40000,
96
+ "observerPreambleMaxTokens": 0,
96
97
  "agentMaxTurns": 16,
97
98
 
98
99
  "passive": false,
@@ -110,6 +111,16 @@
110
111
  "After all candidates exhausted, the stage aborts. 30s retry gate prevents spam.",
111
112
  "Cooldowns persist in ~/.pi/agent/pi-blackhole/pi-blackhole-cooldown.json (survives pi restarts).",
112
113
  "Valid thinking values: off, minimal, low, medium, high, xhigh.",
113
- "Env override for passive mode: PI_BLACKHOLE_PASSIVE=true."
114
+ "Env override for passive mode: PI_BLACKHOLE_PASSIVE=true.",
115
+ "",
116
+ "noAutoCompact mode accumulates observationBatches/reflectionBatches in",
117
+ "pending.json across pipeline runs. The observer preamble (CURRENT OBSERVATIONS)",
118
+ "is capped to observerPreambleMaxTokens (default 0 = auto 30% of observerChunkMaxTokens).",
119
+ "This is a soft safety valve — it only trims when accumulated observations exceed",
120
+ "the budget. High-relevance observations are always kept. Reflections are never trimmed.",
121
+ "",
122
+ "agentMaxTurns controls how many agent-loop turns each worker gets per chunk.",
123
+ "Lower-context models can reduce this (e.g., 8) so the observer doesn't waste",
124
+ "turns trying to extract more than it can reliably handle per run."
114
125
  ]
115
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-blackhole",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Unified compaction + observational memory extension for Pi — compresses conversation context while preserving durable observations and reflections",
5
5
  "license": "MIT",
6
6
  "main": "index.ts",
@@ -36,12 +36,12 @@
36
36
  "type": "git",
37
37
  "url": "git+https://github.com/k0valik/pi-blackhole.git"
38
38
  },
39
- "dependencies": {
40
- "@earendil-works/pi-agent-core": "0.75.5",
41
- "@earendil-works/pi-ai": "0.75.4",
42
- "@earendil-works/pi-coding-agent": "0.75.4",
43
- "@earendil-works/pi-tui": "0.75.5",
44
- "typebox": "^1.1.38"
39
+ "dependencies": {},
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-agent-core": ">=0.75.5 <1.0.0",
42
+ "@earendil-works/pi-ai": ">=0.75.4 <1.0.0",
43
+ "@earendil-works/pi-coding-agent": ">=0.75.4 <1.0.0",
44
+ "@earendil-works/pi-tui": ">=0.75.5 <1.0.0"
45
45
  },
46
46
  "pi": {
47
47
  "extensions": [
@@ -49,6 +49,11 @@
49
49
  ]
50
50
  },
51
51
  "devDependencies": {
52
+ "@earendil-works/pi-agent-core": "0.75.5",
53
+ "@earendil-works/pi-ai": "0.75.4",
54
+ "@earendil-works/pi-coding-agent": "0.75.4",
55
+ "@earendil-works/pi-tui": "0.75.5",
56
+ "typebox": "^1.1.38",
52
57
  "typescript": "^5.7.3",
53
58
  "vitest": "^4.1.7"
54
59
  }
@@ -9,9 +9,11 @@ import { copyTextToClipboard } from "../om/clipboard.js";
9
9
  import type { Runtime } from "../om/runtime.js";
10
10
  import {
11
11
  diffProjection,
12
+ entryIndexForId,
12
13
  foldLedger,
13
14
  fullProjection,
14
15
  observationToSummaryLine,
16
+ rawTokensAfterIndex,
15
17
  rawTokensSinceDropCoverage,
16
18
  rawTokensSinceLastCompaction,
17
19
  rawTokensSinceObservationCoverage,
@@ -125,11 +127,29 @@ export function registerMemoryCommand(pi: ExtensionAPI, runtime: Runtime): void
125
127
  `Reflections: ${folded.reflections.length} recorded / ${visible.reflections.length} visible`,
126
128
  [addedSuffix(drift.reflectionsOnlyInFull.length)],
127
129
  );
128
- const obsProgress = rawTokensSinceObservationCoverage(entries);
129
- const reflectionProgress = rawTokensSinceReflectionCoverage(entries);
130
- const dropProgress = rawTokensSinceDropCoverage(entries);
130
+ let obsProgress = rawTokensSinceObservationCoverage(entries);
131
+ let reflectionProgress = rawTokensSinceReflectionCoverage(entries);
132
+ let dropProgress = rawTokensSinceDropCoverage(entries);
131
133
  const compactionProgress = rawTokensSinceLastCompaction(entries);
132
134
 
135
+ // In noAutoCompact mode, pending coversUpToId entries act as virtual coverage markers
136
+ // that aren't reflected in the branch. Adjust accumulated counts accordingly.
137
+ if (runtime.config.noAutoCompact) {
138
+ const pending = readPendingState(sessionId);
139
+ if (pending.observation?.coversUpToId) {
140
+ const idx = entryIndexForId(entries, pending.observation.coversUpToId);
141
+ if (idx >= 0) obsProgress = rawTokensAfterIndex(entries, idx);
142
+ }
143
+ if (pending.reflection?.coversUpToId) {
144
+ const idx = entryIndexForId(entries, pending.reflection.coversUpToId);
145
+ if (idx >= 0) reflectionProgress = rawTokensAfterIndex(entries, idx);
146
+ }
147
+ if (pending.dropped?.coversUpToId) {
148
+ const idx = entryIndexForId(entries, pending.dropped.coversUpToId);
149
+ if (idx >= 0) dropProgress = rawTokensAfterIndex(entries, idx);
150
+ }
151
+ }
152
+
133
153
  const passiveLines = runtime.config.passive === true
134
154
  ? [
135
155
  "── Mode ──",
@@ -144,11 +164,12 @@ export function registerMemoryCommand(pi: ExtensionAPI, runtime: Runtime): void
144
164
  observationLine,
145
165
  reflectionLine,
146
166
  "",
147
- "── Activity ──",
148
- `Observer: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
149
- `Reflector: ~${reflectionProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(reflectionProgress, runtime.config.reflectAfterTokens)}%)`,
150
- `Dropper: ~${dropProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(dropProgress, runtime.config.reflectAfterTokens)}%)`,
151
- `Compaction: ~${compactionProgress.toLocaleString()} / ${runtime.config.compactAfterTokens.toLocaleString()} tokens (${pct(compactionProgress, runtime.config.compactAfterTokens)}%)`,
167
+ "── Pipeline ──",
168
+ "Transcript accumulated since last run. Triggers when exceeding threshold.",
169
+ `Observer: ~${obsProgress.toLocaleString()} tokens (triggers at ${runtime.config.observeAfterTokens.toLocaleString()})`,
170
+ `Reflector: ~${reflectionProgress.toLocaleString()} tokens (triggers at ${runtime.config.reflectAfterTokens.toLocaleString()})`,
171
+ `Dropper: ~${dropProgress.toLocaleString()} tokens (triggers at ${runtime.config.reflectAfterTokens.toLocaleString()})`,
172
+ `Compaction: ~${compactionProgress.toLocaleString()} tokens` + (runtime.config.noAutoCompact ? " [auto-disabled]" : ` (triggers at ${runtime.config.compactAfterTokens.toLocaleString()})`),
152
173
  `Obs pool: ~${visibleObservationTokens.toLocaleString()} / ${runtime.config.observationsPoolMaxTokens.toLocaleString()} tokens (${pct(visibleObservationTokens, runtime.config.observationsPoolMaxTokens)}%)`,
153
174
  `Reflect pool: ~${visibleReflectionTokens.toLocaleString()} tokens`,
154
175
  ];
@@ -164,6 +185,13 @@ export function registerMemoryCommand(pi: ExtensionAPI, runtime: Runtime): void
164
185
  if (hasObs) lines.push("Observation: waiting in pending.json");
165
186
  if (hasRef) lines.push("Reflection: waiting in pending.json");
166
187
  if (hasDrop) lines.push("Dropper: waiting in pending.json");
188
+ const preambleCap = runtime.config.observerPreambleMaxTokens > 0
189
+ ? runtime.config.observerPreambleMaxTokens
190
+ : Math.round(runtime.config.observerChunkMaxTokens * 0.3);
191
+ const pctNote = runtime.config.observerPreambleMaxTokens > 0
192
+ ? ""
193
+ : ` (30% of ${runtime.config.observerChunkMaxTokens.toLocaleString()} chunk)`;
194
+ lines.push(`Preamble cap: ${preambleCap.toLocaleString()} tokens for observations${pctNote}`);
167
195
  lines.push("Run /blackhole to flush and compact.");
168
196
  }
169
197
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import type { Runtime } from "../om/runtime.js";
11
- import { getLastCompactionStats, PI_VCC_COMPACT_INSTRUCTION } from "../hooks/before-compact";
11
+ import { PI_VCC_COMPACT_INSTRUCTION } from "../hooks/before-compact";
12
12
  import { saveUnifiedConfig } from "../core/unified-config.js";
13
13
  import { readPendingState, clearPendingState, hasPendingData } from "../om/pending.js";
14
14
  import {
@@ -55,14 +55,29 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
55
55
  // before compacting so the summary includes accumulated memory.
56
56
  if (runtime.config.noAutoCompact && hasPendingData(sessionId)) {
57
57
  const pending = readPendingState(sessionId);
58
- if (pending.observation) {
59
- pi.appendEntry(OM_OBSERVATIONS_RECORDED, pending.observation.data);
58
+ // Write all accumulated observation batches (or latest single batch
59
+ // as fallback for legacy pending.json without batch arrays).
60
+ const obsBatches = pending.observationBatches?.length
61
+ ? pending.observationBatches
62
+ : (pending.observation ? [pending.observation] : []);
63
+ for (const batch of obsBatches) {
64
+ pi.appendEntry(OM_OBSERVATIONS_RECORDED, batch.data);
60
65
  }
61
- if (pending.reflection) {
62
- pi.appendEntry(OM_REFLECTIONS_RECORDED, pending.reflection.data);
66
+ // Write all accumulated reflection batches (or latest single batch
67
+ // as fallback for legacy pending.json without batch arrays).
68
+ const reflBatches = pending.reflectionBatches?.length
69
+ ? pending.reflectionBatches
70
+ : (pending.reflection ? [pending.reflection] : []);
71
+ for (const batch of reflBatches) {
72
+ pi.appendEntry(OM_REFLECTIONS_RECORDED, batch.data);
63
73
  }
64
- if (pending.dropped) {
65
- pi.appendEntry(OM_OBSERVATIONS_DROPPED, pending.dropped.data);
74
+ // Write all accumulated dropper batches (or latest single batch
75
+ // as fallback for legacy pending.json without batch arrays).
76
+ const dropBatches = pending.droppedBatches?.length
77
+ ? pending.droppedBatches
78
+ : (pending.dropped ? [pending.dropped] : []);
79
+ for (const batch of dropBatches) {
80
+ pi.appendEntry(OM_OBSERVATIONS_DROPPED, batch.data);
66
81
  }
67
82
  clearPendingState(sessionId);
68
83
  ctx.ui.notify("Observational memory: pending entries flushed", "info");
@@ -71,7 +86,7 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
71
86
  ctx.compact({
72
87
  customInstructions: PI_VCC_COMPACT_INSTRUCTION,
73
88
  onComplete: () => {
74
- const stats = getLastCompactionStats();
89
+ const stats = runtime.compactionStats;
75
90
  if (stats) {
76
91
  ctx.ui.notify(
77
92
  `blackhole: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
package/src/core/brief.ts CHANGED
@@ -19,8 +19,38 @@ const isNoiseUser = (text: string): boolean => {
19
19
 
20
20
  // ── truncation ──
21
21
 
22
- // Unicode-aware word segmentation via Intl.Segmenter (built-in, zero dependency)
23
- const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
22
+ // Unicode-aware word segmentation via Intl.Segmenter with lazy init & fallback
23
+ let _segmenter: Intl.Segmenter | null | undefined = undefined;
24
+ const wordSegments = (text: string): Array<{ segment: string; index: number; isWordLike?: boolean }> => {
25
+ // Available: fast path
26
+ if (_segmenter) return Array.from(_segmenter.segment(text));
27
+ // Fallback already established: don't retry the constructor
28
+ if (_segmenter === null) {
29
+ const parts: Array<{ segment: string; index: number; isWordLike?: boolean }> = [];
30
+ let idx = 0;
31
+ for (const part of text.split(/(\s+)/)) {
32
+ if (!part) continue;
33
+ parts.push({ segment: part, index: idx, isWordLike: /\S/.test(part) });
34
+ idx += part.length;
35
+ }
36
+ return parts;
37
+ }
38
+ // _segmenter === undefined: first call — attempt construction
39
+ try {
40
+ _segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
41
+ return Array.from(_segmenter.segment(text));
42
+ } catch {
43
+ _segmenter = null; // permanently fallback
44
+ const parts: Array<{ segment: string; index: number; isWordLike?: boolean }> = [];
45
+ let idx = 0;
46
+ for (const part of text.split(/(\s+)/)) {
47
+ if (!part) continue;
48
+ parts.push({ segment: part, index: idx, isWordLike: /\S/.test(part) });
49
+ idx += part.length;
50
+ }
51
+ return parts;
52
+ }
53
+ };
24
54
 
25
55
  /** Check if segment is a word (Bun's isWordLike is unreliable for alphanumeric tokens) */
26
56
  const isWord = (seg: { segment: string; isWordLike?: boolean }): boolean =>
@@ -47,7 +77,7 @@ const truncateTokens = (text: string, limit: number): string => {
47
77
  const flat = text.replace(/\s+/g, " ").trim();
48
78
  let count = 0;
49
79
  let lastEnd = 0;
50
- for (const seg of segmenter.segment(flat)) {
80
+ for (const seg of wordSegments(flat)) {
51
81
  if (isWord(seg)) {
52
82
  if (!STOP_WORDS.has(seg.segment.toLowerCase())) {
53
83
  count++;
@@ -15,9 +15,13 @@ export const loadAllMessages = (
15
15
  ): LoadedMessages => {
16
16
  const content = readFileSync(sessionFile, "utf-8");
17
17
  const entries: any[] = [];
18
+ let parseErrors = 0;
18
19
  for (const line of content.split("\n")) {
19
20
  if (!line.trim()) continue;
20
- try { entries.push(JSON.parse(line)); } catch {}
21
+ try { entries.push(JSON.parse(line)); } catch { parseErrors++; }
22
+ }
23
+ if (parseErrors > 0) {
24
+ console.warn(`blackhole: ${parseErrors} malformed JSONL line(s) in ${sessionFile}`);
21
25
  }
22
26
  const rendered: RenderedEntry[] = [];
23
27
  const rawMessages: Message[] = [];
@@ -13,11 +13,6 @@ export interface PiVccSettings {
13
13
  debug: boolean;
14
14
  }
15
15
 
16
- export const DEFAULT_SETTINGS: PiVccSettings = {
17
- overrideDefaultCompaction: false,
18
- debug: false,
19
- };
20
-
21
16
  export function loadSettings(): PiVccSettings {
22
17
  const config = loadUnifiedConfig(process.cwd());
23
18
  return {
@@ -30,5 +25,4 @@ export function scaffoldSettings(): void {
30
25
  scaffoldConfig();
31
26
  }
32
27
 
33
- // For compatibility with code that imports saveSettings
34
- export { loadUnifiedConfig as loadConfig } from "./unified-config.js";
28
+
@@ -50,6 +50,11 @@ export interface UnifiedConfig {
50
50
  dropperInputMaxTokens: number;
51
51
  /** Max source entries tokens sent to observer per chunk. */
52
52
  observerChunkMaxTokens: number;
53
+ /** Max preamble tokens (CURRENT REFLECTIONS / OBSERVATIONS) in the observer prompt.
54
+ * Default 0 means auto-compute from observerChunkMaxTokens (30%). Only applied in
55
+ * noAutoCompact mode where accumulated batch history can grow unbounded.
56
+ * Set to an explicit value to override the auto-computed budget. */
57
+ observerPreambleMaxTokens: number;
53
58
  /** Shared turn cap for background memory agents. */
54
59
  agentMaxTurns: number;
55
60
 
@@ -87,13 +92,14 @@ export const DEFAULTS: UnifiedConfig = {
87
92
  overrideDefaultCompaction: false,
88
93
  debug: false,
89
94
 
90
- observeAfterTokens: 10_000,
91
- reflectAfterTokens: 20_000,
95
+ observeAfterTokens: 15_000,
96
+ reflectAfterTokens: 25_000,
92
97
  compactAfterTokens: 81_000,
93
98
  observationsPoolMaxTokens: 20_000,
94
99
  reflectorInputMaxTokens: 80_000,
95
100
  dropperInputMaxTokens: 80_000,
96
101
  observerChunkMaxTokens: 40_000,
102
+ observerPreambleMaxTokens: 0,
97
103
  agentMaxTurns: 16,
98
104
 
99
105
  noAutoCompact: false,
@@ -154,7 +160,7 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
154
160
  if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
155
161
 
156
162
  // Positive integers
157
- const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "agentMaxTurns"] as const;
163
+ const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "observerPreambleMaxTokens", "agentMaxTurns"] as const;
158
164
  for (const k of numKeys) {
159
165
  const v = positiveInt(raw[k]);
160
166
  if (v !== undefined) (c as Record<string, unknown>)[k] = v;
@@ -279,7 +285,7 @@ export function scaffoldConfig(): void {
279
285
  }
280
286
  }
281
287
  if (changed) writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
282
- } catch {
283
- // best-effort
288
+ } catch (e) {
289
+ console.error("blackhole: config scaffold failed", e);
284
290
  }
285
291
  }
@@ -11,30 +11,19 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
12
12
  import { writeFileSync } from "fs";
13
13
  import { compile } from "../core/summarize";
14
- import { loadSettings, type PiVccSettings } from "../core/settings";
15
14
  import type { PiVccCompactionDetails } from "../details";
16
15
  import { buildCompactionProjection, renderSummary } from "../om/ledger/index.js";
17
16
  import type { Runtime } from "../om/runtime.js";
18
17
 
19
18
  export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
20
19
 
21
- export interface CompactionStats {
22
- summarized: number;
23
- kept: number;
24
- keptTokensEst: number;
25
- }
26
-
27
- let lastStats: CompactionStats | null = null;
28
- let lastCompactWasPiVcc = false;
29
- export const getLastCompactionStats = () => lastStats;
30
-
31
20
  const formatTokens = (n: number): string => {
32
21
  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
33
22
  return String(n);
34
23
  };
35
24
 
36
- const dbg = (settings: PiVccSettings, data: Record<string, unknown>) => {
37
- if (!settings.debug) return;
25
+ const dbg = (debug: boolean, data: Record<string, unknown>) => {
26
+ if (!debug) return;
38
27
  try { writeFileSync("/tmp/pi-blackhole-debug.json", JSON.stringify(data, null, 2)); } catch {}
39
28
  };
40
29
 
@@ -151,16 +140,15 @@ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
151
140
  export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime) => {
152
141
  pi.on("session_before_compact", (event, ctx) => {
153
142
  const { preparation, branchEntries, customInstructions } = event;
154
- const settings = loadSettings();
143
+ omRuntime.ensureConfig(ctx.cwd ?? process.cwd());
155
144
 
156
145
  // Always handle explicit /blackhole marker.
157
146
  // Otherwise, only handle when user opted in via settings.
158
147
  const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
159
- if (!isPiVcc && !settings.overrideDefaultCompaction) return;
148
+ if (!isPiVcc && !omRuntime.config.overrideDefaultCompaction) return;
160
149
 
161
150
  // When noAutoCompact is active, only /blackhole can trigger compaction
162
- const unifiedSettings = omRuntime.config;
163
- if (unifiedSettings.noAutoCompact && !isPiVcc) {
151
+ if (omRuntime.config.noAutoCompact && !isPiVcc) {
164
152
  return { cancel: true };
165
153
  }
166
154
 
@@ -192,7 +180,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
192
180
  }
193
181
  const userIndices = liveRoles.reduce<number[]>((acc, r, i) => (r === "user" ? (acc.push(i), acc) : acc), []);
194
182
 
195
- dbg(settings, {
183
+ dbg(omRuntime.config.debug, {
196
184
  cancelled: true,
197
185
  reason: ownCut.reason,
198
186
  isPiVcc,
@@ -250,14 +238,12 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
250
238
  }, 0);
251
239
  return sum;
252
240
  }, 0);
253
- lastStats = {
241
+ omRuntime.compactionStats = {
254
242
  summarized: agentMessages.length,
255
243
  kept: keptEntries.length,
256
244
  keptTokensEst: Math.round(keptChars / 4),
257
245
  };
258
246
 
259
- const config = settings;
260
-
261
247
  const summary = compile({
262
248
  messages,
263
249
  previousSummary: preparation.previousSummary,
@@ -278,7 +264,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
278
264
  }))
279
265
  : [];
280
266
 
281
- dbg(config, {
267
+ dbg(omRuntime.config.debug, {
282
268
  usedOwnCut: true,
283
269
  messagesToSummarize: agentMessages.length,
284
270
  messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
@@ -300,10 +286,9 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
300
286
  previousSummaryUsed: Boolean(preparation.previousSummary),
301
287
  };
302
288
 
303
- lastCompactWasPiVcc = isPiVcc;
289
+ omRuntime.compactWasPiVcc = isPiVcc;
304
290
 
305
291
  // ── Inject observational-memory content ───────────────────────────
306
- omRuntime.ensureConfig(ctx.cwd ?? process.cwd());
307
292
  let omContent = "";
308
293
  let omDetails: Record<string, unknown> | undefined;
309
294
  if (omRuntime.config.memory !== false) {
@@ -330,8 +315,8 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
330
315
  // /blackhole path uses its own onComplete callback in the command handler.
331
316
  pi.on("session_compact", (event, ctx) => {
332
317
  if (!event.fromExtension) return;
333
- if (lastCompactWasPiVcc) return; // /blackhole handles its own toast via onComplete
334
- const stats = lastStats;
318
+ if (omRuntime.compactWasPiVcc) return; // /blackhole handles its own toast via onComplete
319
+ const stats = omRuntime.compactionStats;
335
320
  if (!stats) return;
336
321
  setTimeout(() => {
337
322
  try {
@@ -37,10 +37,11 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
37
37
  const tokens = rawTokensSinceLastCompaction(entries);
38
38
  if (tokens < runtime.config.compactAfterTokens) return;
39
39
 
40
- // Capture ctx properties synchronously — the setTimeout + async work below
40
+ // Capture ctx properties synchronously — the deferred callback below
41
41
  // may outlive the extension ctx (stale after session replacement/reload).
42
42
  const hasUI = ctx.hasUI;
43
43
  const ui = ctx.ui;
44
+ const sessionId = ctx.sessionManager.getSessionId();
44
45
 
45
46
  if (hasUI) ui?.notify(
46
47
  `Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
@@ -48,8 +49,19 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
48
49
  );
49
50
 
50
51
  runtime.compactInFlight = true;
51
- setTimeout(() => {
52
+ queueMicrotask(() => {
52
53
  try {
54
+ // Validate session identity — bail if the session was replaced/reloaded.
55
+ const currentSessionId = ctx.sessionManager.getSessionId();
56
+ if (currentSessionId !== sessionId) {
57
+ runtime.compactInFlight = false;
58
+ if (hasUI) ui?.notify(
59
+ "Observational memory: compaction cancelled — session changed before compaction",
60
+ "info",
61
+ );
62
+ return;
63
+ }
64
+
53
65
  if (!ctx.isIdle()) {
54
66
  runtime.compactInFlight = false;
55
67
  if (hasUI) ui?.notify(
@@ -87,6 +99,6 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
87
99
  const msg = error instanceof Error ? error.message : String(error);
88
100
  if (hasUI) ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
89
101
  }
90
- }, 0);
102
+ });
91
103
  });
92
104
  }