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.
- package/README.md +13 -16
- package/example-config.json +19 -27
- package/package.json +1 -1
- package/src/commands/memory.ts +1 -1
- package/src/commands/pi-vcc.ts +3 -9
- package/src/commands/vcc-recall.ts +16 -3
- package/src/core/brief.ts +18 -13
- package/src/core/build-sections.ts +1 -2
- package/src/core/content.ts +8 -5
- package/src/core/drill-down.ts +2 -9
- package/src/core/filter-noise.ts +7 -10
- package/src/core/format.ts +25 -25
- package/src/core/load-messages.ts +71 -2
- package/src/core/normalize.ts +10 -4
- package/src/core/recall-scope.ts +3 -3
- package/src/core/render-entries.ts +10 -2
- package/src/core/search-entries.ts +12 -7
- package/src/core/settings.ts +3 -34
- package/src/core/summarize.ts +33 -13
- package/src/core/tool-args.ts +4 -1
- package/src/core/unified-config.ts +27 -25
- package/src/extract/commits.ts +3 -2
- package/src/extract/files.ts +6 -4
- package/src/hooks/before-compact.ts +7 -3
- package/src/om/agents/dropper/agent.ts +2 -7
- package/src/om/agents/observer/agent.ts +11 -13
- package/src/om/agents/reflector/agent.ts +2 -7
- package/src/om/compaction-trigger.ts +54 -18
- package/src/om/configure-overlay.ts +5 -1
- package/src/om/consolidation.ts +35 -13
- package/src/om/cooldown.ts +15 -12
- package/src/om/debug-log.ts +79 -11
- package/src/om/key-matcher.ts +6 -32
- package/src/om/ledger/fold.ts +8 -0
- package/src/om/ledger/render-summary.ts +16 -15
- package/src/om/ledger/types.ts +1 -1
- package/src/om/pending.ts +38 -5
- package/src/om/provider-stream.ts +17 -0
- package/src/om/retryable-error.ts +26 -0
- package/src/om/reverse-recall.ts +9 -1
- package/src/om/runtime.ts +39 -18
- package/src/sections.ts +0 -3
- package/src/tools/recall.ts +11 -9
package/src/om/consolidation.ts
CHANGED
|
@@ -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 "./
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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");
|
package/src/om/cooldown.ts
CHANGED
|
@@ -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
|
|
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
|
|
96
|
+
if (!entry) return undefined;
|
|
89
97
|
|
|
90
98
|
const until = new Date(entry.until);
|
|
91
|
-
if (isNaN(until.getTime())) return
|
|
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
|
|
105
|
+
return undefined;
|
|
98
106
|
}
|
|
99
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/om/debug-log.ts
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
package/src/om/key-matcher.ts
CHANGED
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal utilities shared by blackhole overlay components.
|
|
3
3
|
*
|
|
4
|
-
* visibleWidth
|
|
5
|
-
* extracted from pi-tui to avoid import resolution issues.
|
|
4
|
+
* Re-exports visibleWidth from pi-tui.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
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";
|
package/src/om/ledger/fold.ts
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
}
|
package/src/om/ledger/types.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/om/reverse-recall.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function findReflectionsForEntryIds(
|
|
|
82
82
|
|
|
83
83
|
export function formatRelatedObservations(
|
|
84
84
|
observations: RelatedObservation[],
|
|
85
|
-
|
|
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
|
|