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.
- package/README.md +13 -13
- package/example-config.json +19 -27
- package/package.json +1 -1
- package/src/commands/pi-vcc.ts +3 -9
- 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/render-entries.ts +10 -2
- package/src/core/search-entries.ts +9 -1
- 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 +33 -26
- 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 +1 -8
- package/src/om/configure-overlay.ts +20 -14
- package/src/om/consolidation.ts +57 -18
- package/src/om/cooldown.ts +24 -11
- package/src/om/debug-log.ts +79 -11
- package/src/om/key-matcher.ts +6 -66
- 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 +69 -18
- package/src/om/status-overlay.ts +6 -5
- package/src/sections.ts +0 -3
- package/src/tools/recall.ts +7 -2
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,
|
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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");
|
package/src/om/cooldown.ts
CHANGED
|
@@ -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
|
|
96
|
+
if (!entry) return undefined;
|
|
84
97
|
|
|
85
98
|
const until = new Date(entry.until);
|
|
86
|
-
if (isNaN(until.getTime())) return
|
|
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
|
|
105
|
+
return undefined;
|
|
93
106
|
}
|
|
94
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
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,72 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal utilities shared by blackhole overlay components.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* to keep overlay components free of that dependency.
|
|
4
|
+
* Re-exports visibleWidth from pi-tui.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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";
|
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
|
+
}
|