pi-blackhole 0.3.4 → 0.3.7

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 (43) hide show
  1. package/README.md +13 -16
  2. package/example-config.json +19 -27
  3. package/package.json +1 -1
  4. package/src/commands/memory.ts +1 -1
  5. package/src/commands/pi-vcc.ts +3 -9
  6. package/src/commands/vcc-recall.ts +16 -3
  7. package/src/core/brief.ts +18 -13
  8. package/src/core/build-sections.ts +1 -2
  9. package/src/core/content.ts +8 -5
  10. package/src/core/drill-down.ts +2 -9
  11. package/src/core/filter-noise.ts +7 -10
  12. package/src/core/format.ts +25 -25
  13. package/src/core/load-messages.ts +71 -2
  14. package/src/core/normalize.ts +10 -4
  15. package/src/core/recall-scope.ts +3 -3
  16. package/src/core/render-entries.ts +10 -2
  17. package/src/core/search-entries.ts +12 -7
  18. package/src/core/settings.ts +3 -34
  19. package/src/core/summarize.ts +33 -13
  20. package/src/core/tool-args.ts +4 -1
  21. package/src/core/unified-config.ts +27 -25
  22. package/src/extract/commits.ts +3 -2
  23. package/src/extract/files.ts +6 -4
  24. package/src/hooks/before-compact.ts +7 -3
  25. package/src/om/agents/dropper/agent.ts +2 -7
  26. package/src/om/agents/observer/agent.ts +11 -13
  27. package/src/om/agents/reflector/agent.ts +2 -7
  28. package/src/om/compaction-trigger.ts +54 -18
  29. package/src/om/configure-overlay.ts +5 -1
  30. package/src/om/consolidation.ts +35 -13
  31. package/src/om/cooldown.ts +15 -12
  32. package/src/om/debug-log.ts +79 -11
  33. package/src/om/key-matcher.ts +6 -32
  34. package/src/om/ledger/fold.ts +8 -0
  35. package/src/om/ledger/render-summary.ts +16 -15
  36. package/src/om/ledger/types.ts +1 -1
  37. package/src/om/pending.ts +38 -5
  38. package/src/om/provider-stream.ts +17 -0
  39. package/src/om/retryable-error.ts +26 -0
  40. package/src/om/reverse-recall.ts +9 -1
  41. package/src/om/runtime.ts +39 -18
  42. package/src/sections.ts +0 -3
  43. package/src/tools/recall.ts +11 -9
@@ -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,
@@ -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[] {
@@ -315,8 +334,6 @@ async function runObserverStage(
315
334
  // of processing everything including the compaction summary.
316
335
  const effectiveStart = lastCoverageIdx >= 0 ? lastCoverageIdx : findLastCompactionIndex(entries);
317
336
  let chunkEntries = sourceEntriesAfter(entries, effectiveStart);
318
- const coversUpToId = chunkEntries.at(-1)?.id;
319
- if (!coversUpToId) return "continue";
320
337
 
321
338
  // Cap observer input to observerChunkMaxTokens (newest-to-oldest)
322
339
  const maxChunkTokens = runtime.config.observerChunkMaxTokens;
@@ -324,6 +341,10 @@ async function runObserverStage(
324
341
  chunkEntries = capSourceEntriesToTokens(chunkEntries, maxChunkTokens);
325
342
  }
326
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
+
327
348
  const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
328
349
  if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
329
350
  const chunkTokens = Math.ceil(chunk.length / 4);
@@ -390,8 +411,9 @@ async function runObserverStage(
390
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") });
391
412
 
392
413
  // Check if estimated input fits in model's context window
414
+ // Use actual chunk tokens (already computed) instead of the configured cap
393
415
  const effectiveObsCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
394
- const observerEstimatedInput = runtime.config.observerChunkMaxTokens + AGENT_LOOP_RESERVE;
416
+ const observerEstimatedInput = chunkTokens + AGENT_LOOP_RESERVE;
395
417
  if (observerEstimatedInput > effectiveObsCtx) {
396
418
  debugLog("observer.context_window_exceeded", { estimatedInput: observerEstimatedInput, effectiveCtx: effectiveObsCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
397
419
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveObsCtx} too small for estimated input ${observerEstimatedInput}`), "observer");
@@ -474,7 +496,7 @@ async function runReflectorStage(
474
496
  ): Promise<ReflectorStageResult> {
475
497
  const sessionId = ctx.sessionManager.getSessionId();
476
498
  const entries = ctx.sessionManager.getBranch() as Entry[];
477
- let reflectionTokens: number;
499
+ let reflectionTokens = 0;
478
500
  let observationCoverageId: string | undefined;
479
501
  if (runtime.config.noAutoCompact) {
480
502
  const pending = readPendingState(sessionId);
@@ -523,8 +545,7 @@ async function runReflectorStage(
523
545
  // Adjust accumulated for pending coverage in noAutoCompact mode
524
546
  let effectiveReflectionTokens = reflectionTokens;
525
547
  if (runtime.config.noAutoCompact) {
526
- const pending = readPendingState(sessionId);
527
- if (pending.reflection?.coversUpToId) {
548
+ if (pending?.reflection?.coversUpToId) {
528
549
  const idx = entryIndexForId(entries, pending.reflection.coversUpToId);
529
550
  if (idx >= 0) effectiveReflectionTokens = rawTokensAfterIndex(entries, idx);
530
551
  }
@@ -535,8 +556,9 @@ async function runReflectorStage(
535
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") });
536
557
 
537
558
  // Check if estimated input fits in model's context window
559
+ // Use actual computed input size (new items + summary budget) instead of cap
538
560
  const effectiveRefCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
539
- const reflectorEstimatedInput = runtime.config.reflectorInputMaxTokens + AGENT_LOOP_RESERVE;
561
+ const reflectorEstimatedInput = reflectorInputTokens + AGENT_LOOP_RESERVE;
540
562
  if (reflectorEstimatedInput > effectiveRefCtx) {
541
563
  debugLog("reflector.context_window_exceeded", { estimatedInput: reflectorEstimatedInput, effectiveCtx: effectiveRefCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
542
564
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveRefCtx} too small for estimated input ${reflectorEstimatedInput}`), "reflector");
@@ -614,7 +636,7 @@ async function runDropperStage(
614
636
  ): Promise<StageOutcome> {
615
637
  const sessionId = ctx.sessionManager.getSessionId();
616
638
  const entries = ctx.sessionManager.getBranch() as Entry[];
617
- let dropTokens: number;
639
+ let dropTokens = 0;
618
640
  let observationCoverageId: string | undefined;
619
641
  if (runtime.config.noAutoCompact) {
620
642
  const pending = readPendingState(sessionId);
@@ -661,8 +683,7 @@ async function runDropperStage(
661
683
  // Adjust accumulated for pending coverage in noAutoCompact mode
662
684
  let effectiveDropTokens = dropTokens;
663
685
  if (runtime.config.noAutoCompact) {
664
- const pending = readPendingState(sessionId);
665
- if (pending.dropped?.coversUpToId) {
686
+ if (pending?.dropped?.coversUpToId) {
666
687
  const idx = entryIndexForId(entries, pending.dropped.coversUpToId);
667
688
  if (idx >= 0) effectiveDropTokens = rawTokensAfterIndex(entries, idx);
668
689
  }
@@ -692,8 +713,9 @@ async function runDropperStage(
692
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") });
693
714
 
694
715
  // Check if estimated input fits in model's context window
716
+ // Use actual computed input size (new observations + summary budget) instead of cap
695
717
  const effectiveDropCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
696
- const dropperEstimatedInput = runtime.config.dropperInputMaxTokens + AGENT_LOOP_RESERVE;
718
+ const dropperEstimatedInput = dropperInputTokens + AGENT_LOOP_RESERVE;
697
719
  if (dropperEstimatedInput > effectiveDropCtx) {
698
720
  debugLog("dropper.context_window_exceeded", { estimatedInput: dropperEstimatedInput, effectiveCtx: effectiveDropCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
699
721
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveDropCtx} too small for estimated input ${dropperEstimatedInput}`), "dropper");
@@ -79,24 +79,32 @@ function writeCooldownMap(map: CooldownMap): void {
79
79
  * When cooldownHours is explicitly 0, cooldown is disabled — always returns false.
80
80
  */
81
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 {
82
90
  // cooldownHours === 0 means cooldown disabled
83
- if (model.cooldownHours === 0) return false;
91
+ if (model.cooldownHours === 0) return undefined;
84
92
 
85
93
  const map = readCooldownMap();
86
94
  const key = modelKey(model);
87
95
  const entry = map[key];
88
- if (!entry) return false;
96
+ if (!entry) return undefined;
89
97
 
90
98
  const until = new Date(entry.until);
91
- if (isNaN(until.getTime())) return false;
99
+ if (isNaN(until.getTime())) return undefined;
92
100
 
93
101
  if (now >= until) {
94
102
  // Expired — clean up
95
103
  delete map[key];
96
104
  writeCooldownMap(map);
97
- return false;
105
+ return undefined;
98
106
  }
99
- return true;
107
+ return entry;
100
108
  }
101
109
 
102
110
  /**
@@ -137,11 +145,6 @@ export function expireCooldowns(): void {
137
145
  if (changed) writeCooldownMap(map);
138
146
  }
139
147
 
140
- /** Regex matching retryable API error messages. */
141
- 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";
142
149
 
143
- /** Check whether an error string or Error indicates a retryable error. */
144
- export function isRetryableError(error: unknown): boolean {
145
- const message = error instanceof Error ? error.message : String(error || "");
146
- return RETRYABLE_ERROR_RE.test(message);
147
- }
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,38 +1,12 @@
1
1
  /**
2
2
  * Terminal utilities shared by blackhole overlay components.
3
3
  *
4
- * visibleWidth CJK-aware visible width for terminal columns,
5
- * extracted from pi-tui to avoid import resolution issues.
4
+ * Re-exports visibleWidth from pi-tui.
6
5
  *
7
- * 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).
8
10
  */
9
11
 
10
- // ---------------------------------------------------------------------------
11
- // Visible width (CJK-aware)
12
- // ---------------------------------------------------------------------------
13
-
14
- function stripAnsi(s: string): string {
15
- return s.replace(/\x1b\[[0-9;]*m/g, "");
16
- }
17
-
18
- /**
19
- * Calculate the visible width of a string in terminal columns.
20
- * Strips ANSI codes and counts CJK characters as width 2.
21
- */
22
- export function visibleWidth(s: string): number {
23
- const stripped = stripAnsi(s);
24
- let w = 0;
25
- for (const ch of stripped) {
26
- const code = ch.codePointAt(0)!;
27
- if (code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
28
- (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) ||
29
- (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
30
- (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) ||
31
- (code >= 0x1b000 && code <= 0x1b0ff) || (code >= 0x20000 && code <= 0x2fa1f))) {
32
- w += 2;
33
- } else {
34
- w += 1;
35
- }
36
- }
37
- return w;
38
- }
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
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared retryable-error regex and detection function.
3
+ *
4
+ * Extracted from compaction-trigger.ts and cooldown.ts which had diverging
5
+ * copies of the same logic. This is the single source of truth.
6
+ *
7
+ * Now also re-exports Pi's context-overflow detection from @earendil-works/pi-ai,
8
+ * avoiding the need to duplicate 20+ provider-specific overflow patterns.
9
+ */
10
+
11
+ /** Regex matching retryable API error messages. */
12
+ export const RETRYABLE_ERROR_RE =
13
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
14
+
15
+ /** Check whether an error string or Error indicates a retryable error. */
16
+ export function isRetryableError(error: unknown): boolean {
17
+ const message = error instanceof Error ? error.message : String(error || "");
18
+ return RETRYABLE_ERROR_RE.test(message);
19
+ }
20
+
21
+ /**
22
+ * Re-export Pi's context-overflow detection so blackhole callers use one
23
+ * authoritative source instead of maintaining their own provider-specific patterns.
24
+ * @see @earendil-works/pi-ai/dist/utils/overflow.d.ts
25
+ */
26
+ export { isContextOverflow } from "@earendil-works/pi-ai";
@@ -82,7 +82,7 @@ export function findReflectionsForEntryIds(
82
82
 
83
83
  export function formatRelatedObservations(
84
84
  observations: RelatedObservation[],
85
- _reflections: RelatedReflection[],
85
+ reflections: RelatedReflection[],
86
86
  ): string {
87
87
  const parts: string[] = [];
88
88
 
@@ -97,6 +97,14 @@ export function formatRelatedObservations(
97
97
  }
98
98
  }
99
99
 
100
+ if (reflections.length > 0) {
101
+ if (parts.length > 0) parts.push("");
102
+ parts.push("Related reflections:");
103
+ for (const ref of reflections) {
104
+ parts.push(` [${ref.memoryId}] ${ref.content}`);
105
+ }
106
+ }
107
+
100
108
  return parts.join("\n");
101
109
  }
102
110