pi-blackhole 0.2.0 → 0.2.1

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
@@ -170,7 +170,7 @@ Set `memory: false` or run `/blackhole om-off` for pure pi-vcc compaction — no
170
170
 
171
171
  ## Configuration
172
172
 
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.
173
+ 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
174
 
175
175
  Quick start with custom models:
176
176
 
@@ -354,7 +354,7 @@ pnpm add pi-blackhole
354
354
 
355
355
  ```bash
356
356
  pi uninstall git:github.com/k0valik/pi-blackhole
357
- rm -rf ~/.pi/agent/pi-blackhole ~/.pi/agent/pi-blackhole-config.json
357
+ rm -rf ~/.pi/agent/pi-blackhole
358
358
  ```
359
359
 
360
360
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-blackhole",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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 ──",
@@ -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 {
@@ -71,7 +71,7 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
71
71
  ctx.compact({
72
72
  customInstructions: PI_VCC_COMPACT_INSTRUCTION,
73
73
  onComplete: () => {
74
- const stats = getLastCompactionStats();
74
+ const stats = runtime.compactionStats;
75
75
  if (stats) {
76
76
  ctx.ui.notify(
77
77
  `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
+
@@ -279,7 +279,7 @@ export function scaffoldConfig(): void {
279
279
  }
280
280
  }
281
281
  if (changed) writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
282
- } catch {
283
- // best-effort
282
+ } catch (e) {
283
+ console.error("blackhole: config scaffold failed", e);
284
284
  }
285
285
  }
@@ -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
  }
@@ -18,6 +18,7 @@ import { type ResolveResult, type Runtime } from "./runtime.js";
18
18
  import { isRetryableError } from "./cooldown.js";
19
19
  import { serializeSourceAddressedBranchEntries } from "./serialize.js";
20
20
  import {
21
+ readPendingState,
21
22
  savePendingObservation,
22
23
  savePendingReflection,
23
24
  savePendingDropped,
@@ -33,6 +34,7 @@ import {
33
34
  buildObservationsRecordedData,
34
35
  buildReflectionsRecordedData,
35
36
  earlierCoverageMarkerId,
37
+ entryIndexForId,
36
38
  foldLedger,
37
39
  findLastCompactionIndex,
38
40
  fullProjection,
@@ -41,6 +43,7 @@ import {
41
43
  latestCoverageMarkerId,
42
44
  observationsCreatedAfterIndex,
43
45
  observationToSummaryLine,
46
+ rawTokensAfterIndex,
44
47
  rawTokensSinceDropCoverage,
45
48
  rawTokensSinceObservationCoverage,
46
49
  rawTokensSinceReflectionCoverage,
@@ -139,8 +142,8 @@ function stageFallbackModels(runtime: Runtime, stage: "observer" | "reflector" |
139
142
  return runtime.config.dropperFallbackModels ?? [];
140
143
  }
141
144
 
142
- function stageThinkingLevel(runtime: Runtime, stage: "observer" | "reflector" | "dropper"): ModelThinkingLevel {
143
- const stageModel = stageModelConfig(runtime, stage);
145
+ function stageThinkingLevel(runtime: Runtime, stage: "observer" | "reflector" | "dropper", modelConfig?: ConfiguredModel): ModelThinkingLevel {
146
+ const stageModel = modelConfig ?? stageModelConfig(runtime, stage);
144
147
  return stageModel?.thinking ?? runtime.config.model?.thinking ?? "low";
145
148
  }
146
149
 
@@ -284,12 +287,23 @@ async function runObserverStage(
284
287
  const resolved = await resolveModel("observer");
285
288
  if (!resolved) return "abort";
286
289
 
290
+ // Adjust accumulated for pending coverage in noAutoCompact mode
291
+ let effectiveTokens = tokens;
292
+ if (runtime.config.noAutoCompact) {
293
+ const pending = readPendingState(sessionId);
294
+ if (pending.observation?.coversUpToId) {
295
+ const idx = entryIndexForId(entries, pending.observation.coversUpToId);
296
+ if (idx >= 0) effectiveTokens = rawTokensAfterIndex(entries, idx);
297
+ }
298
+ }
287
299
  if (ctx.hasUI) ctx.ui?.notify(
288
- `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${tokens.toLocaleString()} accumulated)`,
300
+ `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${effectiveTokens.toLocaleString()} accumulated)`,
289
301
  "info",
290
302
  );
291
303
  debugLog("observer.start", { tokens, coversUpToId, sourceEntryIds, sourceEntryCount: sourceEntryIds.length, priorReflections: priorReflections.length, priorObservations: priorObservations.length });
292
304
 
305
+ // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
306
+ const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "observer"), stageFallbacks: stageFallbackModels(runtime, "observer") });
293
307
  try {
294
308
  const result = await runObserver({
295
309
  model: resolved.model as any,
@@ -300,7 +314,7 @@ async function runObserverStage(
300
314
  chunk,
301
315
  allowedSourceEntryIds: sourceEntryIds,
302
316
  maxTurns: runtime.config.agentMaxTurns,
303
- thinkingLevel: stageThinkingLevel(runtime, "observer"),
317
+ thinkingLevel: stageThinkingLevel(runtime, "observer", stageModelForThinking),
304
318
  });
305
319
 
306
320
  if (result.observations && result.observations.length > 0) {
@@ -386,8 +400,19 @@ async function runReflectorStage(
386
400
  );
387
401
  const summaryBudget = Math.floor(runtime.config.reflectorInputMaxTokens * 0.15) * 2;
388
402
  const reflectorInputTokens = Math.min(newItemsTokens + summaryBudget, runtime.config.reflectorInputMaxTokens);
389
- if (ctx.hasUI) ctx.ui?.notify(`Observational memory: reflector running (~${reflectionTokens.toLocaleString()} tokens accumulated, ~${reflectorInputTokens.toLocaleString()}-token input)`, "info");
403
+ // Adjust accumulated for pending coverage in noAutoCompact mode
404
+ let effectiveReflectionTokens = reflectionTokens;
405
+ if (runtime.config.noAutoCompact) {
406
+ const pending = readPendingState(sessionId);
407
+ if (pending.reflection?.coversUpToId) {
408
+ const idx = entryIndexForId(entries, pending.reflection.coversUpToId);
409
+ if (idx >= 0) effectiveReflectionTokens = rawTokensAfterIndex(entries, idx);
410
+ }
411
+ }
412
+ if (ctx.hasUI) ctx.ui?.notify(`Observational memory: reflector running (~${effectiveReflectionTokens.toLocaleString()} tokens accumulated, ~${reflectorInputTokens.toLocaleString()}-token input)`, "info");
390
413
 
414
+ // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
415
+ const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "reflector"), stageFallbacks: stageFallbackModels(runtime, "reflector") });
391
416
  try {
392
417
  // Existing memory summaries for context (capped)
393
418
  const existingReflectionsSummary = buildExistingReflectionsSummary(
@@ -408,7 +433,7 @@ async function runReflectorStage(
408
433
  existingReflectionsSummary: existingReflectionsSummary || undefined,
409
434
  existingObservationsSummary: existingObservationsSummary || undefined,
410
435
  maxTurns: runtime.config.agentMaxTurns,
411
- thinkingLevel: stageThinkingLevel(runtime, "reflector"),
436
+ thinkingLevel: stageThinkingLevel(runtime, "reflector", stageModelForThinking),
412
437
  });
413
438
 
414
439
  if (!reflections || reflections.length === 0) return { outcome: "continue", sameRunReflections: [] };
@@ -468,7 +493,16 @@ async function runDropperStage(
468
493
  );
469
494
  const dropperSummaryBudget = Math.floor(runtime.config.dropperInputMaxTokens * 0.2);
470
495
  const dropperInputTokens = Math.min(dropperNewObsTokens + dropperSummaryBudget, runtime.config.dropperInputMaxTokens);
471
- if (ctx.hasUI) ctx.ui?.notify(`Observational memory: dropper running (~${dropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`, "info");
496
+ // Adjust accumulated for pending coverage in noAutoCompact mode
497
+ let effectiveDropTokens = dropTokens;
498
+ if (runtime.config.noAutoCompact) {
499
+ const pending = readPendingState(sessionId);
500
+ if (pending.dropped?.coversUpToId) {
501
+ const idx = entryIndexForId(entries, pending.dropped.coversUpToId);
502
+ if (idx >= 0) effectiveDropTokens = rawTokensAfterIndex(entries, idx);
503
+ }
504
+ }
505
+ if (ctx.hasUI) ctx.ui?.notify(`Observational memory: dropper running (~${effectiveDropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`, "info");
472
506
 
473
507
  try {
474
508
  // Existing active observations summary for context (capped)
@@ -478,6 +512,8 @@ async function runDropperStage(
478
512
  );
479
513
  const reflectionsForDropper = mergeReflections(folded.reflections, sameRunReflections);
480
514
 
515
+ // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
516
+ const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "dropper"), stageFallbacks: stageFallbackModels(runtime, "dropper") });
481
517
  const droppedIds = await runDropper({
482
518
  model: resolved.model as any,
483
519
  apiKey: resolved.apiKey,
@@ -487,7 +523,7 @@ async function runDropperStage(
487
523
  existingObservationsSummary: existingObservationsSummary || undefined,
488
524
  budgetTokens: runtime.config.observationsPoolMaxTokens,
489
525
  maxTurns: runtime.config.agentMaxTurns,
490
- thinkingLevel: stageThinkingLevel(runtime, "dropper"),
526
+ thinkingLevel: stageThinkingLevel(runtime, "dropper", stageModelForThinking),
491
527
  });
492
528
  const latestReflectionCoverageId = latestCoverageMarkerId(entries, OM_REFLECTIONS_RECORDED);
493
529
  const effectiveReflectionCoverageId = sameRunReflectionCoverageId ?? latestReflectionCoverageId;
@@ -157,7 +157,10 @@ export function fullProjection(entries: Entry[], upToEntryId?: string): Projecti
157
157
  export function visibleProjection(entries: Entry[], upToEntryId?: string): Projection {
158
158
  if (!upToEntryId) {
159
159
  const details = latestV3CompactionDetails(entries);
160
- return details ? projectionFromMemoryDetails(details) : { observations: [], reflections: [] };
160
+ if (details) return projectionFromMemoryDetails(details);
161
+ // No compaction has run yet — show everything so the user sees
162
+ // recorded data until first /blackhole creates a proper snapshot.
163
+ return fullProjection(entries);
161
164
  }
162
165
 
163
166
  return buildCompactionProjection(entries, upToEntryId, { observationsPoolMaxTokens: Number.POSITIVE_INFINITY });
@@ -188,7 +188,7 @@ export function recallMemorySources(entries: Entry[], memoryId: string): RecallR
188
188
  }
189
189
 
190
190
  const recalledByKey = new Map<string, RecalledObservation>();
191
- const missingSupportingObservationIds: string[] = [];
191
+ const rawMissingSupportingObservationIds: string[] = [];
192
192
 
193
193
  function addObservation(indexed: IndexedObservation): void {
194
194
  const key = `${indexed.entryId}:${indexed.recordIndex}`;
@@ -204,7 +204,7 @@ export function recallMemorySources(entries: Entry[], memoryId: string): RecallR
204
204
  for (const observationId of uniqueStrings(reflection.supportingObservationIds)) {
205
205
  const indexed = observationsById.get(observationId);
206
206
  if (!indexed) {
207
- missingSupportingObservationIds.push(observationId);
207
+ rawMissingSupportingObservationIds.push(observationId);
208
208
  continue;
209
209
  }
210
210
  addObservation(indexed);
@@ -220,7 +220,7 @@ export function recallMemorySources(entries: Entry[], memoryId: string): RecallR
220
220
  const sourceEntries = uniqueById(recalledObservations.flatMap((match) => match.sourceEntries));
221
221
  const missingSourceEntryIds = uniqueStrings(recalledObservations.flatMap((match) => match.missingSourceEntryIds));
222
222
  const nonSourceEntryIds = uniqueStrings(recalledObservations.flatMap((match) => match.nonSourceEntryIds));
223
- const uniqueMissingSupportingObservationIds = uniqueStrings(missingSupportingObservationIds);
223
+ const uniqueMissingSupportingObservationIds = uniqueStrings(rawMissingSupportingObservationIds);
224
224
  const matchCount = directObservationMatches.length + reflectionMatches.length;
225
225
 
226
226
  return {
@@ -58,11 +58,10 @@ export function findReflectionsForEntryIds(
58
58
  targetEntryIds: string[],
59
59
  ): RelatedReflection[] {
60
60
  if (targetEntryIds.length === 0) return [];
61
- const { reflections } = indexLedger(entries);
61
+ const { reflections, observations } = indexLedger(entries);
62
62
  // Reflections don't directly reference entry IDs — they reference observation IDs.
63
63
  // So we only match indirectly: first find observations for these entry IDs,
64
64
  // then find reflections that support those observations.
65
- const { observations } = indexLedger(entries);
66
65
  const targetSet = new Set(targetEntryIds);
67
66
  const matchingObsIds = new Set<string>();
68
67
  for (const indexed of observations) {
package/src/om/runtime.ts CHANGED
@@ -53,6 +53,10 @@ export class Runtime {
53
53
  lastDropperError: string | undefined;
54
54
  /** Epoch ms of the last failed consolidation run (any stage). */
55
55
  lastConsolidationErrorAt: number | undefined;
56
+ /** Stats from the most recent compaction run (session-scoped via handler closure). */
57
+ compactionStats: { summarized: number; kept: number; keptTokensEst: number } | null = null;
58
+ /** Whether the most recent compaction was triggered by /blackhole (vs auto-compact). */
59
+ compactWasPiVcc = false;
56
60
 
57
61
  ensureConfig(cwd: string): void {
58
62
  if (this.configLoaded) return;
@@ -1,237 +0,0 @@
1
- import type { Message } from "@earendil-works/pi-ai";
2
- import { buildSections } from "./build-sections";
3
- import { clip } from "./content";
4
- import { normalize } from "./normalize";
5
- import { renderMessage } from "./render-entries";
6
- import { searchEntries } from "./search-entries";
7
- import { type CompileInput, compile } from "./summarize";
8
-
9
- const SECTION_HEADERS = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context"];
10
-
11
- interface RoleCounts {
12
- user: number;
13
- assistant: number;
14
- toolResult: number;
15
- }
16
-
17
- interface BlockCounts {
18
- user: number;
19
- assistant: number;
20
- toolCalls: number;
21
- toolResults: number;
22
- thinking: number;
23
- }
24
-
25
- export interface RecallProbe {
26
- label: string;
27
- sourceText: string;
28
- query: string;
29
- summaryMentioned: boolean;
30
- recallHits: number;
31
- }
32
-
33
- export interface CompactReport {
34
- summary: string;
35
- before: {
36
- messageCount: number;
37
- roleCounts: RoleCounts;
38
- blockCounts: BlockCounts;
39
- inputChars: number;
40
- estimatedTokens: number;
41
- topFiles: string[];
42
- preview: string;
43
- };
44
- after: {
45
- summaryLength: number;
46
- estimatedTokens: number;
47
- sectionCount: number;
48
- summaryPreview: string;
49
- goalsCount: number;
50
- blockersCount: number;
51
- briefTranscriptLines: number;
52
- };
53
- compression: {
54
- charsBefore: number;
55
- charsAfter: number;
56
- ratio: number;
57
- messagesBefore: number;
58
- };
59
- recall: {
60
- probes: RecallProbe[];
61
- };
62
- }
63
-
64
- const estimateTokensFromChars = (chars: number): number =>
65
- Math.ceil(chars / 4);
66
-
67
- const countRoles = (messages: Message[]): RoleCounts => {
68
- const counts: RoleCounts = { user: 0, assistant: 0, toolResult: 0 };
69
- for (const msg of messages) {
70
- if (msg.role === "user") counts.user += 1;
71
- else if (msg.role === "assistant") counts.assistant += 1;
72
- else if (msg.role === "toolResult") counts.toolResult += 1;
73
- }
74
- return counts;
75
- };
76
-
77
- const countBlocks = (messages: Message[]): BlockCounts => {
78
- const counts: BlockCounts = {
79
- user: 0,
80
- assistant: 0,
81
- toolCalls: 0,
82
- toolResults: 0,
83
- thinking: 0,
84
- };
85
-
86
- for (const block of normalize(messages)) {
87
- if (block.kind === "user") counts.user += 1;
88
- else if (block.kind === "assistant") counts.assistant += 1;
89
- else if (block.kind === "tool_call") counts.toolCalls += 1;
90
- else if (block.kind === "tool_result") counts.toolResults += 1;
91
- else if (block.kind === "thinking") counts.thinking += 1;
92
- }
93
-
94
- return counts;
95
- };
96
-
97
- const inputCharsOf = (messages: Message[]): number =>
98
- messages
99
- .map((msg, index) => renderMessage(msg, index, "", true).summary.length)
100
- .reduce((sum, len) => sum + len, 0);
101
-
102
- const topFilesOf = (messages: Message[]): string[] => {
103
- const files = new Set<string>();
104
- for (const block of normalize(messages)) {
105
- if (block.kind === "tool_call") {
106
- for (const key of ["path", "file_path", "filePath", "file"]) {
107
- const val = block.args[key];
108
- if (typeof val === "string") { files.add(val); break; }
109
- }
110
- }
111
- }
112
- return [...files].slice(0, 10);
113
- };
114
-
115
- const previewOf = (messages: Message[], edgeCount = 3): string => {
116
- const rendered = messages.map((msg, index) => renderMessage(msg, index, ""));
117
- if (rendered.length === 0) return "(empty)";
118
- if (rendered.length <= edgeCount * 2) {
119
- return rendered
120
- .map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`)
121
- .join("\n");
122
- }
123
-
124
- const first = rendered.slice(0, edgeCount);
125
- const last = rendered.slice(-edgeCount);
126
- return [
127
- ...first.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
128
- "...",
129
- ...last.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
130
- ].join("\n");
131
- };
132
-
133
- const sectionCountOf = (summary: string): number =>
134
- SECTION_HEADERS.filter((header) => summary.includes(`[${header}]`)).length;
135
-
136
- const briefLineCountOf = (summary: string): number => {
137
- const sep = "\n\n---\n\n";
138
- const idx = summary.indexOf(sep);
139
- if (idx < 0) return 0;
140
- return summary.slice(idx + sep.length).split("\n").length;
141
- };
142
-
143
- const queryTermsOf = (text: string): string[] =>
144
- (text.match(/[\p{L}\p{N}_./-]{3,}/gu) ?? [])
145
- .map((part) => part.trim())
146
- .filter(Boolean);
147
-
148
- const queryOf = (text: string): string => {
149
- const terms = queryTermsOf(text);
150
- return terms.slice(0, 6).join(" ");
151
- };
152
-
153
- const matchesQuery = (text: string, query: string): boolean => {
154
- const hay = text.toLowerCase();
155
- return query
156
- .toLowerCase()
157
- .split(/\s+/)
158
- .filter(Boolean)
159
- .every((term) => hay.includes(term));
160
- };
161
-
162
- const probesOf = (messages: Message[], summary: string): RecallProbe[] => {
163
- const blocks = normalize(messages);
164
- const data = buildSections({ blocks });
165
-
166
- // Find first file from tool calls
167
- let firstFile = "";
168
- for (const b of blocks) {
169
- if (b.kind === "tool_call") {
170
- for (const key of ["path", "file_path", "filePath", "file"]) {
171
- if (typeof b.args[key] === "string") { firstFile = b.args[key] as string; break; }
172
- }
173
- if (firstFile) break;
174
- }
175
- }
176
-
177
- const rawProbes = [
178
- { label: "goal", text: data.sessionGoal[0] ?? "" },
179
- { label: "file", text: firstFile },
180
- { label: "problem", text: data.outstandingContext[0] ?? "" },
181
- ];
182
-
183
- const rendered = messages.map((msg, index) => renderMessage(msg, index, ""));
184
-
185
- return rawProbes
186
- .map(({ label, text }) => {
187
- const sourceText = text.trim();
188
- const query = queryOf(sourceText);
189
- if (!query) return null;
190
- return {
191
- label,
192
- sourceText,
193
- query,
194
- summaryMentioned: matchesQuery(summary, query),
195
- recallHits: searchEntries(rendered, messages, query).length,
196
- };
197
- })
198
- .filter((probe): probe is RecallProbe => probe !== null);
199
- };
200
-
201
- export const buildCompactReport = (input: CompileInput): CompactReport => {
202
- const summary = compile(input);
203
- const data = buildSections({ blocks: normalize(input.messages) });
204
- const inputChars = inputCharsOf(input.messages);
205
- const topFiles = topFilesOf(input.messages);
206
-
207
- return {
208
- summary,
209
- before: {
210
- messageCount: input.messages.length,
211
- roleCounts: countRoles(input.messages),
212
- blockCounts: countBlocks(input.messages),
213
- inputChars,
214
- estimatedTokens: estimateTokensFromChars(inputChars),
215
- topFiles,
216
- preview: previewOf(input.messages),
217
- },
218
- after: {
219
- summaryLength: summary.length,
220
- estimatedTokens: estimateTokensFromChars(summary.length),
221
- sectionCount: sectionCountOf(summary),
222
- summaryPreview: summary,
223
- goalsCount: data.sessionGoal.length,
224
- blockersCount: data.outstandingContext.length,
225
- briefTranscriptLines: briefLineCountOf(summary),
226
- },
227
- compression: {
228
- charsBefore: inputChars,
229
- charsAfter: summary.length,
230
- ratio: summary.length === 0 ? 0 : Number((inputChars / summary.length).toFixed(2)),
231
- messagesBefore: input.messages.length,
232
- },
233
- recall: {
234
- probes: probesOf(input.messages, summary),
235
- },
236
- };
237
- };
@@ -1,63 +0,0 @@
1
- /**
2
- * Observational memory compaction hook — injects OM content into pi-vcc summaries.
3
- *
4
- * Upstream: https://github.com/elpapi42/pi-observational-memory (src/hooks/compaction-hook.ts)
5
- * NOTE: This hook is intentionally NOT registered in index.ts.
6
- * The joining point is in src/hooks/before-compact.ts which handles both
7
- * pi-vcc compilation AND OM projection in a single pass.
8
- * Kept as reference for the original standalone behavior.
9
- */
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
-
12
- import type { Runtime } from "./runtime.js";
13
- import { buildCompactionProjection, renderSummary, type Entry } from "./ledger/index.js";
14
-
15
- const DEFAULT_OBSERVATIONS_POOL_MAX_TOKENS = 20_000;
16
-
17
- function observationsPoolMaxTokens(runtime: Runtime): number {
18
- const value = (runtime.config as { observationsPoolMaxTokens?: unknown }).observationsPoolMaxTokens;
19
- return typeof value === "number" && Number.isFinite(value) && value > 0
20
- ? value
21
- : DEFAULT_OBSERVATIONS_POOL_MAX_TOKENS;
22
- }
23
-
24
- export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
25
- pi.on("session_before_compact", async (event: any, ctx: any) => {
26
- // Dead code — this hook is intentionally not registered in index.ts.
27
- // Kept as reference for the original observational-memory behavior.
28
- if ((runtime.config as any).disableCompactionHook) return;
29
- if (runtime.compactHookInFlight) {
30
- if (ctx.hasUI) {
31
- ctx.ui.notify(
32
- "Observational memory: another compaction is already in progress; cancelling duplicate",
33
- "warning",
34
- );
35
- }
36
- return { cancel: true };
37
- }
38
-
39
- runtime.compactHookInFlight = true;
40
- try {
41
- runtime.ensureConfig(ctx.cwd);
42
- const { preparation, branchEntries } = event;
43
- const { firstKeptEntryId, tokensBefore } = preparation;
44
- const projection = buildCompactionProjection(
45
- branchEntries as Entry[],
46
- firstKeptEntryId,
47
- { observationsPoolMaxTokens: observationsPoolMaxTokens(runtime) },
48
- );
49
- const summary = renderSummary(projection.reflections, projection.observations);
50
-
51
- return {
52
- compaction: {
53
- summary,
54
- firstKeptEntryId,
55
- tokensBefore,
56
- details: projection.details,
57
- },
58
- };
59
- } finally {
60
- runtime.compactHookInFlight = false;
61
- }
62
- });
63
- }