pi-blackhole 0.3.3 → 0.3.5

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.
Files changed (41) hide show
  1. package/README.md +13 -13
  2. package/example-config.json +19 -27
  3. package/package.json +1 -1
  4. package/src/commands/pi-vcc.ts +3 -9
  5. package/src/core/brief.ts +18 -13
  6. package/src/core/build-sections.ts +1 -2
  7. package/src/core/content.ts +8 -5
  8. package/src/core/drill-down.ts +2 -9
  9. package/src/core/filter-noise.ts +7 -10
  10. package/src/core/format.ts +25 -25
  11. package/src/core/load-messages.ts +71 -2
  12. package/src/core/normalize.ts +10 -4
  13. package/src/core/render-entries.ts +10 -2
  14. package/src/core/search-entries.ts +9 -1
  15. package/src/core/settings.ts +3 -34
  16. package/src/core/summarize.ts +33 -13
  17. package/src/core/tool-args.ts +4 -1
  18. package/src/core/unified-config.ts +33 -26
  19. package/src/extract/commits.ts +3 -2
  20. package/src/extract/files.ts +6 -4
  21. package/src/hooks/before-compact.ts +7 -3
  22. package/src/om/agents/dropper/agent.ts +2 -7
  23. package/src/om/agents/observer/agent.ts +11 -13
  24. package/src/om/agents/reflector/agent.ts +2 -7
  25. package/src/om/compaction-trigger.ts +1 -8
  26. package/src/om/configure-overlay.ts +20 -14
  27. package/src/om/consolidation.ts +57 -18
  28. package/src/om/cooldown.ts +24 -11
  29. package/src/om/debug-log.ts +79 -11
  30. package/src/om/key-matcher.ts +6 -66
  31. package/src/om/ledger/fold.ts +8 -0
  32. package/src/om/ledger/render-summary.ts +16 -15
  33. package/src/om/ledger/types.ts +1 -1
  34. package/src/om/pending.ts +38 -5
  35. package/src/om/provider-stream.ts +17 -0
  36. package/src/om/retryable-error.ts +26 -0
  37. package/src/om/reverse-recall.ts +9 -1
  38. package/src/om/runtime.ts +69 -18
  39. package/src/om/status-overlay.ts +6 -5
  40. package/src/sections.ts +0 -3
  41. package/src/tools/recall.ts +7 -2
@@ -15,7 +15,7 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
15
15
  import type { ConfiguredModel } from "./config.js";
16
16
  import { debugLog, withDebugLogContext } from "./debug-log.js";
17
17
  import { type ResolveResult, type Runtime } from "./runtime.js";
18
- import { isRetryableError } from "./cooldown.js";
18
+ import { isRetryableError } from "./retryable-error.js";
19
19
  import { effectiveContextWindow } from "./model-budget.js";
20
20
  import { serializeSourceAddressedBranchEntries } from "./serialize.js";
21
21
 
@@ -27,6 +27,7 @@ import {
27
27
  savePendingReflection,
28
28
  savePendingDropped,
29
29
  isObservationChunkPending,
30
+ PendingOMState,
30
31
  } from "./pending.js";
31
32
  import {
32
33
  OM_OBSERVATIONS_DROPPED,
@@ -59,9 +60,9 @@ import {
59
60
  type Reflection,
60
61
  } from "./ledger/index.js";
61
62
 
62
- type ResolvedModel = Extract<ResolveResult, { ok: true }>;
63
+ export type ResolvedModel = Extract<ResolveResult, { ok: true }>;
63
64
 
64
- type ConsolidationCtx = {
65
+ export type ConsolidationCtx = {
65
66
  cwd: string;
66
67
  hasUI: boolean;
67
68
  ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
@@ -98,6 +99,8 @@ function capSourceEntriesToTokens(entries: Entry[], maxTokens: number): Entry[]
98
99
  for (let i = entries.length - 1; i >= 0; i--) {
99
100
  const entry = entries[i];
100
101
  let chars = 0;
102
+ // Tokenize all entry types, not just "message": custom_message and
103
+ // branch_summary entries also consume observer context window.
101
104
  if (entry.type === "message" && entry.message) {
102
105
  const msg = entry.message as any;
103
106
  if (typeof msg.content === "string") chars = msg.content.length;
@@ -106,9 +109,25 @@ function capSourceEntriesToTokens(entries: Entry[], maxTokens: number): Entry[]
106
109
  if (block.text) chars += block.text.length;
107
110
  }
108
111
  }
112
+ } else if (entry.type === "custom" && (entry.customType === OM_OBSERVATIONS_RECORDED || entry.customType === OM_REFLECTIONS_RECORDED || entry.customType === OM_OBSERVATIONS_DROPPED)) {
113
+ // Custom entries carry structured data — estimate from JSON serialization
114
+ chars = String(JSON.stringify(entry.data ?? {})).length;
115
+ } else if (entry.summary) {
116
+ chars = String(entry.summary).length;
109
117
  }
110
118
  const estTokens = Math.ceil(chars / 4);
111
119
  if (totalTokens + estTokens > maxTokens && kept.length > 0) break;
120
+ // Remove the `kept.length > 0` guard? No — keep the guard but allow
121
+ // the first entry to be dropped only if it exceeds maxTokens alone.
122
+ // (The guard against empty kept list prevents dropping the first entry
123
+ // when later entries are small; but a single oversized entry should
124
+ // still be included to avoid losing the newest data entirely.)
125
+ if (totalTokens + estTokens > maxTokens && kept.length === 0) {
126
+ // First (newest) entry exceeds maxTokens alone — include it anyway
127
+ // to avoid data loss, but don't add more.
128
+ kept.unshift(entry);
129
+ break;
130
+ }
112
131
  kept.unshift(entry);
113
132
  totalTokens += estTokens;
114
133
  }
@@ -140,7 +159,7 @@ function mergeReflections(existing: Reflection[], additional: Reflection[]): Ref
140
159
  * should still be served as "new" on subsequent runs.
141
160
  */
142
161
  function pendingObservationsCreatedAfter(
143
- pending: any,
162
+ pending: PendingOMState,
144
163
  entries: Entry[],
145
164
  afterCoversUpToId: string | undefined,
146
165
  ): Observation[] {
@@ -182,15 +201,16 @@ function stageThinkingLevel(runtime: Runtime, stage: "observer" | "reflector" |
182
201
  return stageModel?.thinking ?? runtime.config.model?.thinking ?? "low";
183
202
  }
184
203
 
185
- function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
204
+ export function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
186
205
  return async (stage) => {
206
+ const stageFallbacks = stageFallbackModels(runtime, stage);
187
207
  const resolved = await runtime.resolveModel({
188
208
  model: ctx.model,
189
209
  modelRegistry: ctx.modelRegistry,
190
210
  hasUI: ctx.hasUI,
191
211
  ui: ctx.ui,
192
212
  stageModel: stageModelConfig(runtime, stage),
193
- stageFallbacks: stageFallbackModels(runtime, stage),
213
+ stageFallbacks,
194
214
  });
195
215
  if (resolved.ok) {
196
216
  runtime.resolveFailureNotified = false;
@@ -198,7 +218,17 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
198
218
  }
199
219
  debugLog(`${stage}.model_unavailable`, { reason: resolved.reason });
200
220
  if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
201
- ctx.ui.notify(`Observational memory: ${stage} skipped — ${resolved.reason}`, "warning");
221
+ if (runtime.failedInCycle.size > 0 && resolved.reason.includes("all candidates exhausted")) {
222
+ const fallbackMsg = stageFallbacks.length === 0
223
+ ? "no fallbacks configured"
224
+ : "no available fallbacks";
225
+ ctx.ui.notify(
226
+ `Observational memory: ${stage} skipped — model unavailable (cooldown set to 0, ${fallbackMsg}, will retry next run)`,
227
+ "info",
228
+ );
229
+ } else {
230
+ ctx.ui.notify(`Observational memory: ${stage} skipped — ${resolved.reason}`, "warning");
231
+ }
202
232
  runtime.resolveFailureNotified = true;
203
233
  }
204
234
  return undefined;
@@ -254,6 +284,8 @@ export async function runConsolidationPipeline(
254
284
  const resolveModel = makeModelResolver(runtime, ctx);
255
285
 
256
286
  runtime.consolidationPhase = "observer";
287
+ runtime.failedInCycle.clear();
288
+ runtime.resolveFailureNotified = false;
257
289
  try {
258
290
  const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel);
259
291
  if (observerOutcome === "abort") return;
@@ -263,6 +295,8 @@ export async function runConsolidationPipeline(
263
295
  }
264
296
 
265
297
  runtime.consolidationPhase = "reflector";
298
+ runtime.failedInCycle.clear();
299
+ runtime.resolveFailureNotified = false;
266
300
  let reflectorResult: ReflectorStageResult;
267
301
  try {
268
302
  reflectorResult = await runReflectorStage(pi, runtime, ctx, resolveModel);
@@ -273,6 +307,8 @@ export async function runConsolidationPipeline(
273
307
  }
274
308
 
275
309
  runtime.consolidationPhase = "dropper";
310
+ runtime.failedInCycle.clear();
311
+ runtime.resolveFailureNotified = false;
276
312
  try {
277
313
  await runDropperStage(pi, runtime, ctx, resolveModel, reflectorResult.sameRunReflections, reflectorResult.effectiveReflectionCoverageId);
278
314
  } catch (error) {
@@ -298,8 +334,6 @@ async function runObserverStage(
298
334
  // of processing everything including the compaction summary.
299
335
  const effectiveStart = lastCoverageIdx >= 0 ? lastCoverageIdx : findLastCompactionIndex(entries);
300
336
  let chunkEntries = sourceEntriesAfter(entries, effectiveStart);
301
- const coversUpToId = chunkEntries.at(-1)?.id;
302
- if (!coversUpToId) return "continue";
303
337
 
304
338
  // Cap observer input to observerChunkMaxTokens (newest-to-oldest)
305
339
  const maxChunkTokens = runtime.config.observerChunkMaxTokens;
@@ -307,6 +341,10 @@ async function runObserverStage(
307
341
  chunkEntries = capSourceEntriesToTokens(chunkEntries, maxChunkTokens);
308
342
  }
309
343
 
344
+ // coversUpToId must point to the LAST entry AFTER capping, not before
345
+ const coversUpToId = chunkEntries.at(-1)?.id;
346
+ if (!coversUpToId) return "continue";
347
+
310
348
  const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
311
349
  if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
312
350
  const chunkTokens = Math.ceil(chunk.length / 4);
@@ -373,8 +411,9 @@ async function runObserverStage(
373
411
  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") });
374
412
 
375
413
  // Check if estimated input fits in model's context window
414
+ // Use actual chunk tokens (already computed) instead of the configured cap
376
415
  const effectiveObsCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
377
- const observerEstimatedInput = runtime.config.observerChunkMaxTokens + AGENT_LOOP_RESERVE;
416
+ const observerEstimatedInput = chunkTokens + AGENT_LOOP_RESERVE;
378
417
  if (observerEstimatedInput > effectiveObsCtx) {
379
418
  debugLog("observer.context_window_exceeded", { estimatedInput: observerEstimatedInput, effectiveCtx: effectiveObsCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
380
419
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveObsCtx} too small for estimated input ${observerEstimatedInput}`), "observer");
@@ -457,7 +496,7 @@ async function runReflectorStage(
457
496
  ): Promise<ReflectorStageResult> {
458
497
  const sessionId = ctx.sessionManager.getSessionId();
459
498
  const entries = ctx.sessionManager.getBranch() as Entry[];
460
- let reflectionTokens: number;
499
+ let reflectionTokens = 0;
461
500
  let observationCoverageId: string | undefined;
462
501
  if (runtime.config.noAutoCompact) {
463
502
  const pending = readPendingState(sessionId);
@@ -506,8 +545,7 @@ async function runReflectorStage(
506
545
  // Adjust accumulated for pending coverage in noAutoCompact mode
507
546
  let effectiveReflectionTokens = reflectionTokens;
508
547
  if (runtime.config.noAutoCompact) {
509
- const pending = readPendingState(sessionId);
510
- if (pending.reflection?.coversUpToId) {
548
+ if (pending?.reflection?.coversUpToId) {
511
549
  const idx = entryIndexForId(entries, pending.reflection.coversUpToId);
512
550
  if (idx >= 0) effectiveReflectionTokens = rawTokensAfterIndex(entries, idx);
513
551
  }
@@ -518,8 +556,9 @@ async function runReflectorStage(
518
556
  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") });
519
557
 
520
558
  // Check if estimated input fits in model's context window
559
+ // Use actual computed input size (new items + summary budget) instead of cap
521
560
  const effectiveRefCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
522
- const reflectorEstimatedInput = runtime.config.reflectorInputMaxTokens + AGENT_LOOP_RESERVE;
561
+ const reflectorEstimatedInput = reflectorInputTokens + AGENT_LOOP_RESERVE;
523
562
  if (reflectorEstimatedInput > effectiveRefCtx) {
524
563
  debugLog("reflector.context_window_exceeded", { estimatedInput: reflectorEstimatedInput, effectiveCtx: effectiveRefCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
525
564
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveRefCtx} too small for estimated input ${reflectorEstimatedInput}`), "reflector");
@@ -597,7 +636,7 @@ async function runDropperStage(
597
636
  ): Promise<StageOutcome> {
598
637
  const sessionId = ctx.sessionManager.getSessionId();
599
638
  const entries = ctx.sessionManager.getBranch() as Entry[];
600
- let dropTokens: number;
639
+ let dropTokens = 0;
601
640
  let observationCoverageId: string | undefined;
602
641
  if (runtime.config.noAutoCompact) {
603
642
  const pending = readPendingState(sessionId);
@@ -644,8 +683,7 @@ async function runDropperStage(
644
683
  // Adjust accumulated for pending coverage in noAutoCompact mode
645
684
  let effectiveDropTokens = dropTokens;
646
685
  if (runtime.config.noAutoCompact) {
647
- const pending = readPendingState(sessionId);
648
- if (pending.dropped?.coversUpToId) {
686
+ if (pending?.dropped?.coversUpToId) {
649
687
  const idx = entryIndexForId(entries, pending.dropped.coversUpToId);
650
688
  if (idx >= 0) effectiveDropTokens = rawTokensAfterIndex(entries, idx);
651
689
  }
@@ -675,8 +713,9 @@ async function runDropperStage(
675
713
  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") });
676
714
 
677
715
  // Check if estimated input fits in model's context window
716
+ // Use actual computed input size (new observations + summary budget) instead of cap
678
717
  const effectiveDropCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
679
- const dropperEstimatedInput = runtime.config.dropperInputMaxTokens + AGENT_LOOP_RESERVE;
718
+ const dropperEstimatedInput = dropperInputTokens + AGENT_LOOP_RESERVE;
680
719
  if (dropperEstimatedInput > effectiveDropCtx) {
681
720
  debugLog("dropper.context_window_exceeded", { estimatedInput: dropperEstimatedInput, effectiveCtx: effectiveDropCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
682
721
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveDropCtx} too small for estimated input ${dropperEstimatedInput}`), "dropper");
@@ -75,33 +75,51 @@ function writeCooldownMap(map: CooldownMap): void {
75
75
  /**
76
76
  * Check whether a model is currently cooled down.
77
77
  * Expired entries are cleaned up lazily.
78
+ *
79
+ * When cooldownHours is explicitly 0, cooldown is disabled — always returns false.
78
80
  */
79
81
  export function isCooldownActive(model: OmModelConfig, now: Date = new Date()): boolean {
82
+ return getCooldownEntry(model, now) !== undefined;
83
+ }
84
+
85
+ /**
86
+ * Returns the active cooldown entry for a model, or undefined if not cooled down.
87
+ * Expired entries are cleaned up lazily.
88
+ */
89
+ export function getCooldownEntry(model: OmModelConfig, now: Date = new Date()): CooldownEntry | undefined {
90
+ // cooldownHours === 0 means cooldown disabled
91
+ if (model.cooldownHours === 0) return undefined;
92
+
80
93
  const map = readCooldownMap();
81
94
  const key = modelKey(model);
82
95
  const entry = map[key];
83
- if (!entry) return false;
96
+ if (!entry) return undefined;
84
97
 
85
98
  const until = new Date(entry.until);
86
- if (isNaN(until.getTime())) return false;
99
+ if (isNaN(until.getTime())) return undefined;
87
100
 
88
101
  if (now >= until) {
89
102
  // Expired — clean up
90
103
  delete map[key];
91
104
  writeCooldownMap(map);
92
- return false;
105
+ return undefined;
93
106
  }
94
- return true;
107
+ return entry;
95
108
  }
96
109
 
97
110
  /**
98
111
  * Record a cooldown for a model after a retryable error.
99
112
  *
113
+ * When cooldownHours is explicitly 0, cooldown is disabled — no-op.
114
+ *
100
115
  * @param model The model that failed.
101
116
  * @param reason Human-readable error reason (e.g. "429 Too Many Requests").
102
117
  * @param stage Which pipeline stage failed ("observer" | "reflector" | "dropper").
103
118
  */
104
119
  export function recordCooldown(model: OmModelConfig, reason: string, stage: string): void {
120
+ // cooldownHours === 0 means cooldown disabled
121
+ if (model.cooldownHours === 0) return;
122
+
105
123
  const hours = model.cooldownHours ?? 1;
106
124
  const until = new Date(Date.now() + hours * 3_600_000).toISOString();
107
125
  const map = readCooldownMap();
@@ -127,11 +145,6 @@ export function expireCooldowns(): void {
127
145
  if (changed) writeCooldownMap(map);
128
146
  }
129
147
 
130
- /** Regex matching retryable API error messages. */
131
- const RETRYABLE_ERROR_RE = /(?:\b|^)(?:overloaded|provider|rate\s*limit|too\s+many\s+requests|429|500|502|503|504|timeout|timed?\s*out|network\s*error|connection\s*error|service\s*unavailable|server\s*error|internal\s*error|fetch\s*failed|upstream|websocket\s*closed|retry)(?:\b|$)/i;
148
+ import { isRetryableError } from "./retryable-error.js";
132
149
 
133
- /** Check whether an error string or Error indicates a retryable error. */
134
- export function isRetryableError(error: unknown): boolean {
135
- const message = error instanceof Error ? error.message : String(error || "");
136
- return RETRYABLE_ERROR_RE.test(message);
137
- }
150
+ export { isRetryableError };
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Debug logging — writes JSONL to ~/.pi/agent/pi-blackhole/debug.ndjson.
3
3
  *
4
+ * Uses a memory buffer flushed asynchronously on a timer to avoid blocking
5
+ * the event loop with synchronous disk I/O on every event.
6
+ *
4
7
  * Upstream: https://github.com/elpapi42/pi-observational-memory (src/debug-log.ts)
5
- * Modified: path changed from observational-memory/ to pi-blackhole/.
8
+ * Modified: path changed from observational-memory/ to pi-blackhole/; async buffered.
6
9
  */
7
10
  import { AsyncLocalStorage } from "node:async_hooks";
8
11
  import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, appendFileSync } from "node:fs";
12
+ import { appendFile } from "node:fs/promises";
9
13
  import { dirname, join } from "node:path";
10
14
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
15
 
@@ -25,25 +29,89 @@ export function withDebugLogContext<T>(context: DebugLogContext, fn: () => T): T
25
29
  return storage.run({ ...parent, ...context }, fn);
26
30
  }
27
31
 
32
+ // ── Async buffer ────────────────────────────────────────────────────────────
33
+
34
+ const BUFFER_FLUSH_MS = 1_000;
35
+ const FLUSH_IDLE_MS = 10_000;
36
+ let buffer: string[] = [];
37
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
38
+ let flushing = false;
39
+ let lastWriteMs = 0;
40
+
41
+ function ensureFlushTimer(): void {
42
+ if (flushTimer) return;
43
+ flushTimer = setInterval(() => {
44
+ // Stop the timer if buffer has been empty for a while
45
+ if (buffer.length === 0 && lastWriteMs > 0 && Date.now() - lastWriteMs > FLUSH_IDLE_MS) {
46
+ clearInterval(flushTimer!);
47
+ flushTimer = null;
48
+ return;
49
+ }
50
+ flushBuffer().catch(() => {});
51
+ }, BUFFER_FLUSH_MS);
52
+ // Don't prevent process exit
53
+ if (flushTimer && typeof flushTimer === "object" && "unref" in flushTimer) {
54
+ flushTimer.unref();
55
+ }
56
+ }
57
+
58
+ async function flushBuffer(): Promise<void> {
59
+ if (flushing) return;
60
+ if (buffer.length === 0) return;
61
+ flushing = true;
62
+ // Drain the buffer atomically so flushDebugLog doesn't split entries
63
+ const batch = buffer;
64
+ buffer = [];
65
+ try {
66
+ const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ rotateIfNeeded(path);
69
+ await appendFile(path, batch.join(""), "utf-8");
70
+ } catch (error) {
71
+ console.error("blackhole: debug log write failed", error);
72
+ } finally {
73
+ flushing = false;
74
+ }
75
+ }
76
+
77
+ // Flush remaining buffer on exit — synchronous to work with process.exit() too
78
+ process.on("exit", () => {
79
+ flushDebugLog();
80
+ });
81
+
28
82
  export function debugLog(event: string, data: Record<string, unknown> = {}, forceEnabled?: boolean): void {
29
83
  const context = storage.getStore();
30
84
  const enabled = forceEnabled ?? context?.enabled ?? false;
31
85
  if (enabled !== true) return;
32
86
 
87
+ const payload = {
88
+ ts: new Date().toISOString(),
89
+ event,
90
+ cwd: context?.cwd,
91
+ runId: context?.runId,
92
+ data,
93
+ };
94
+ buffer.push(JSON.stringify(payload) + "\n");
95
+ lastWriteMs = Date.now();
96
+ ensureFlushTimer();
97
+ }
98
+
99
+ /**
100
+ * Synchronously flush the buffer to disk. Used by tests to verify written content.
101
+ * Skips if an async flush is in progress to avoid splitting the buffer.
102
+ * In production, the background timer handles flushing automatically.
103
+ */
104
+ export function flushDebugLog(): void {
105
+ if (flushing || buffer.length === 0) return;
106
+ const batch = buffer;
107
+ buffer = [];
33
108
  try {
34
109
  const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
35
110
  mkdirSync(dirname(path), { recursive: true });
36
111
  rotateIfNeeded(path);
37
- const payload = {
38
- ts: new Date().toISOString(),
39
- event,
40
- cwd: context?.cwd,
41
- runId: context?.runId,
42
- data,
43
- };
44
- appendFileSync(path, `${JSON.stringify(payload)}\n`, "utf-8");
45
- } catch {
46
- // Debug logging must never affect memory behavior.
112
+ appendFileSync(path, batch.join(""), "utf-8");
113
+ } catch (error) {
114
+ console.error("blackhole: debug log flush failed", error);
47
115
  }
48
116
  }
49
117
 
@@ -1,72 +1,12 @@
1
1
  /**
2
2
  * Terminal utilities shared by blackhole overlay components.
3
3
  *
4
- * Key matcher standalone replacement for pi-tui's `matchesKey`
5
- * to keep overlay components free of that dependency.
4
+ * Re-exports visibleWidth from pi-tui.
6
5
  *
7
- * visibleWidth CJK-aware visible width for terminal columns,
8
- * extracted from pi-tui to avoid import resolution issues.
9
- *
10
- * Ported from voice-type extension's key-matcher.ts.
6
+ * Previously had a local CJK-width implementation extracted from pi-tui
7
+ * to avoid import resolution issues in the overlay TUI context.
8
+ * If this re-export causes runtime resolution failures, restore the
9
+ * local copy (see git history for the original implementation).
11
10
  */
12
11
 
13
- // ---------------------------------------------------------------------------
14
- // Key matching
15
- // ---------------------------------------------------------------------------
16
-
17
- export function matchKey(data: string, key: string): boolean {
18
- // Escape
19
- if (key === "escape") return data === "\x1b";
20
- // Enter
21
- if (key === "enter") return data === "\r" || data === "\n";
22
- // Tab
23
- if (key === "tab") return data === "\t";
24
- // Space
25
- if (key === "space") return data === " ";
26
- // Backspace
27
- if (key === "backspace") return data === "\x7f" || data === "\b";
28
- // Arrows
29
- if (key === "up") return data === "\x1b[A" || data === "\x1bOA";
30
- if (key === "down") return data === "\x1b[B" || data === "\x1bOB";
31
- if (key === "left") return data === "\x1b[D" || data === "\x1bOD";
32
- if (key === "right") return data === "\x1b[C" || data === "\x1bOC";
33
- // Ctrl+letter (must be exactly 6 chars: "ctrl+" + one lowercase letter)
34
- if (key.startsWith("ctrl+") && key.length === 6) {
35
- const letter = key[5];
36
- if (letter && letter >= "a" && letter <= "z") {
37
- const code = letter.charCodeAt(0) - 96; // ctrl+a = 1, ctrl+z = 26
38
- return data.length === 1 && data.charCodeAt(0) === code;
39
- }
40
- }
41
- return false;
42
- }
43
-
44
- // ---------------------------------------------------------------------------
45
- // Visible width (CJK-aware)
46
- // ---------------------------------------------------------------------------
47
-
48
- function stripAnsi(s: string): string {
49
- return s.replace(/\x1b\[[0-9;]*m/g, "");
50
- }
51
-
52
- /**
53
- * Calculate the visible width of a string in terminal columns.
54
- * Strips ANSI codes and counts CJK characters as width 2.
55
- */
56
- export function visibleWidth(s: string): number {
57
- const stripped = stripAnsi(s);
58
- let w = 0;
59
- for (const ch of stripped) {
60
- const code = ch.codePointAt(0)!;
61
- if (code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
62
- (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) ||
63
- (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
64
- (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) ||
65
- (code >= 0x1b000 && code <= 0x1b0ff) || (code >= 0x20000 && code <= 0x2fa1f))) {
66
- w += 2;
67
- } else {
68
- w += 1;
69
- }
70
- }
71
- return w;
72
- }
12
+ export { visibleWidth } from "@earendil-works/pi-tui";
@@ -15,6 +15,7 @@ import {
15
15
  type Observation,
16
16
  type Reflection,
17
17
  } from "./types.js";
18
+ import { debugLog } from "../debug-log.js";
18
19
 
19
20
  export type FoldLedgerOptions = {
20
21
  /** Fold entries from branch root through this entry id, inclusive. Omit to fold through branch tip. */
@@ -88,6 +89,13 @@ export function foldLedger(entries: Entry[], options: FoldLedgerOptions = {}): F
88
89
  for (const observationId of entry.data.observationIds) {
89
90
  droppedObservationIds.add(observationId);
90
91
  }
92
+ continue;
93
+ }
94
+
95
+ // Log unknown custom entry types — they are silently skipped, but
96
+ // should be visible when debugging extension compatibility issues.
97
+ if (entry.type === "custom" && entry.customType) {
98
+ debugLog("fold.unknown_custom_type", { customType: entry.customType, entryId: entry.id });
91
99
  }
92
100
  }
93
101
 
@@ -6,20 +6,16 @@
6
6
  */
7
7
  import type { Observation, Reflection } from "./types.js";
8
8
 
9
- const RECALL_NOTE =
10
- "Use `recall` to search for prior work, decisions, and context from before this summary. " +
11
- "Do not redo work already completed.";
9
+ const OM_INSTRUCTIONS_FULL = `Bracketed ids in reflections and observations connect to their source session entries. These are condensed memories from earlier in this session.
10
+ When entries conflict, the most recent observation reflects the latest known state.
11
+ Use \`recall\` with an id to retrieve original context, or \`#N:path\` drill-down to explore file content from referenced entries.
12
+ When exact source context is needed for precision or traceability, use the \`recall\` tool with the relevant observation or reflection id. This is especially useful when a reflection materially affects a decision or is too compressed to continue confidently.`;
12
13
 
13
- const CONTEXT_USAGE_INSTRUCTIONS = `These are condensed memories from earlier in this session.
14
+ const OM_INSTRUCTIONS_BASIC = `Use \`recall\` with an id to retrieve original context, or \`#N:path\` drill-down to explore file content from referenced entries.
15
+ When entries conflict, the most recent entry reflects the latest known state.`;
14
16
 
15
- - Reflections: stable, long-lived facts about the user, project, decisions, and constraints. New reflection lines may include ids in brackets.
16
- - Observations: timestamped events from the conversation history, in chronological order. Observation lines include ids in brackets.
17
-
18
- Treat these as past records. When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
19
-
20
- When exact source context is needed for precision or traceability, use the \`recall\` tool with the relevant observation or reflection id. This is especially useful when a reflection materially affects a decision or is too compressed to continue confidently. Do not use \`recall\` as broad search or inject raw source unless it is needed.`;
21
-
22
- export const OM_FOOTER = `----\n${RECALL_NOTE}\n\n${CONTEXT_USAGE_INSTRUCTIONS}\n----`;
17
+ export const OM_FOOTER_FULL = `----\n${OM_INSTRUCTIONS_FULL}\n----`;
18
+ export const OM_FOOTER_BASIC = `----\n${OM_INSTRUCTIONS_BASIC}\n----`;
23
19
 
24
20
  export function observationToSummaryLine(observation: Observation): string {
25
21
  return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] ${observation.content}`;
@@ -81,7 +77,7 @@ export function reflectionToSummaryLine(reflection: Reflection): string {
81
77
  }
82
78
 
83
79
  export function renderSummary(reflections: Reflection[], observations: Observation[]): string {
84
- if (reflections.length === 0 && observations.length === 0) return "";
80
+ const hasContent = reflections.length > 0 || observations.length > 0;
85
81
 
86
82
  const parts: string[] = [];
87
83
  if (reflections.length > 0) {
@@ -90,6 +86,11 @@ export function renderSummary(reflections: Reflection[], observations: Observati
90
86
  if (observations.length > 0) {
91
87
  parts.push(`## Observations\n${observations.map(observationToSummaryLine).join("\n")}`);
92
88
  }
93
- parts.push(OM_FOOTER);
94
- return parts.join("\n\n");
89
+
90
+ const footer = hasContent ? OM_FOOTER_FULL : OM_FOOTER_BASIC;
91
+ if (parts.length > 0) {
92
+ parts.push(footer);
93
+ return parts.join("\n\n");
94
+ }
95
+ return footer;
95
96
  }
@@ -12,7 +12,7 @@ export const OM_FOLDED = "om.folded";
12
12
  export const RELEVANCE_VALUES = ["low", "medium", "high", "critical"] as const;
13
13
  export type Relevance = (typeof RELEVANCE_VALUES)[number];
14
14
 
15
- export const MEMORY_ID_PATTERN = /^[a-f0-9]{12}$/;
15
+ export const MEMORY_ID_PATTERN = /^[a-f0-9]{12}$/i;
16
16
 
17
17
  export type Entry = {
18
18
  type: string;
package/src/om/pending.ts CHANGED
@@ -106,7 +106,7 @@ function readSessionState(sessionId: string): PendingOMState {
106
106
 
107
107
  try {
108
108
  const raw = JSON.parse(readFileSync(path, "utf-8"));
109
- if (isPendingOMState(raw)) return raw;
109
+ if (isPendingOMState(raw)) return sanitizePendingState(raw);
110
110
  return defaultState();
111
111
  } catch {
112
112
  return defaultState();
@@ -150,16 +150,49 @@ function writeSessionState(sessionId: string, state: PendingOMState): void {
150
150
  }
151
151
  }
152
152
 
153
- /**
154
- * Validate that an unknown value is a valid PendingOMState.
155
- */
153
+ /** Validate that unknown value is shape-compatible with PendingOMState. */
156
154
  function isPendingOMState(value: unknown): value is PendingOMState {
157
155
  if (!value || typeof value !== "object") return false;
158
156
  const v = value as Record<string, unknown>;
159
157
  const hasObs = !!(v.observation && typeof v.observation === "object" && typeof (v.observation as any).coversUpToId === "string");
160
158
  const hasRef = !!(v.reflection && typeof v.reflection === "object" && typeof (v.reflection as any).coversUpToId === "string");
161
159
  const hasDrop = !!(v.dropped && typeof v.dropped === "object" && typeof (v.dropped as any).coversUpToId === "string");
162
- return hasObs || hasRef || hasDrop;
160
+ // Also accept states with only batch arrays (no singular fields)
161
+ const hasBatches = Array.isArray(v.observationBatches) || Array.isArray(v.reflectionBatches) || Array.isArray(v.droppedBatches);
162
+ return hasObs || hasRef || hasDrop || hasBatches;
163
+ }
164
+
165
+ /**
166
+ * Sanitize pending state: filter out corrupted batch entries (missing required fields).
167
+ * Returns a clean copy; does not mutate the input.
168
+ */
169
+ function sanitizePendingState(raw: PendingOMState): PendingOMState {
170
+ const sanitized: PendingOMState = {
171
+ ...raw,
172
+ observation: raw.observation ?? undefined,
173
+ reflection: raw.reflection ?? undefined,
174
+ dropped: raw.dropped ?? undefined,
175
+ };
176
+ // Filter batch arrays to only include valid entries with the required shape
177
+ if (Array.isArray(raw.observationBatches)) {
178
+ sanitized.observationBatches = raw.observationBatches.filter(
179
+ (b): b is PendingObservation =>
180
+ !!b && typeof b === "object" && typeof (b as any).coversUpToId === "string" && (b as any).data !== undefined,
181
+ );
182
+ }
183
+ if (Array.isArray(raw.reflectionBatches)) {
184
+ sanitized.reflectionBatches = raw.reflectionBatches.filter(
185
+ (b): b is PendingReflection =>
186
+ !!b && typeof b === "object" && typeof (b as any).coversUpToId === "string" && (b as any).data !== undefined,
187
+ );
188
+ }
189
+ if (Array.isArray(raw.droppedBatches)) {
190
+ sanitized.droppedBatches = raw.droppedBatches.filter(
191
+ (b): b is PendingDropped =>
192
+ !!b && typeof b === "object" && typeof (b as any).coversUpToId === "string" && (b as any).data !== undefined,
193
+ );
194
+ }
195
+ return sanitized;
163
196
  }
164
197
 
165
198
  // ── API ─────────────────────────────────────────────────────────────────────
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Bridge streamSimple to support custom providers via global Symbol.for().
3
+ *
4
+ * When a custom provider is registered (via index.ts at startup), its stream
5
+ * function is stored under a shared global symbol. This module provides the
6
+ * bridge logic so all OM agents (observer, reflector, dropper) use the same
7
+ * custom-provider resolution instead of each duplicating the 15-line function.
8
+ */
9
+ export function createBridgeStreamFn(streamSimple: any) {
10
+ const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
11
+ return (model: any, ctx: any, opts: any) => {
12
+ const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
13
+ if (!providerStreams) return streamSimple(model, ctx, opts);
14
+ const customFn = model?.api ? providerStreams.get(model.api) : undefined;
15
+ return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
16
+ };
17
+ }