pi-blackhole 0.3.4 → 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 +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 +1 -8
- 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 +7 -2
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
|
|
package/src/om/runtime.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - markConsolidationError sets 30s retry gate for failed runs.
|
|
11
11
|
*/
|
|
12
12
|
import { type Config, type ConfiguredModel, DEFAULTS, loadConfig } from "./config.js";
|
|
13
|
-
import { isCooldownActive, recordCooldown, expireCooldowns, modelKey } from "./cooldown.js";
|
|
13
|
+
import { isCooldownActive, getCooldownEntry, recordCooldown, expireCooldowns, modelKey } from "./cooldown.js";
|
|
14
14
|
|
|
15
15
|
export type ResolveResult =
|
|
16
16
|
| { ok: true; model: any; apiKey: string; headers?: Record<string, string>; cooldownApplied?: boolean }
|
|
@@ -94,13 +94,17 @@ export class Runtime {
|
|
|
94
94
|
* Tries the candidate list in order:
|
|
95
95
|
* 1. Primary stage model → 2. Stage fallbacks → 3. Base config.model → 4. Session model.
|
|
96
96
|
*
|
|
97
|
+
* Session model fallback can be disabled via config.sessionFallback: false.
|
|
98
|
+
* When disabled, returns { ok: false } instead of using the session model,
|
|
99
|
+
* allowing the stage to be skipped entirely when all configured OM models fail.
|
|
100
|
+
*
|
|
97
101
|
* Skips models that are currently in a cooldown window.
|
|
98
102
|
* On retryable error (after the agent runs), the model that failed is cooled down
|
|
99
103
|
* and the next candidate is tried. The caller must call `recordRetryableError`
|
|
100
104
|
* after the API attempt to mark the failed model.
|
|
101
105
|
*
|
|
102
106
|
* Returns `ok: true` with the resolved model, or `ok: false` with a reason
|
|
103
|
-
* if all candidates (including session model) are exhausted or unavailable.
|
|
107
|
+
* if all candidates (including session model, if enabled) are exhausted or unavailable.
|
|
104
108
|
*/
|
|
105
109
|
async resolveModel(ctx: ResolveCtx): Promise<ResolveResult> {
|
|
106
110
|
const candidates = this.buildCandidateList(ctx.stageModel, ctx.stageFallbacks);
|
|
@@ -123,8 +127,10 @@ export class Runtime {
|
|
|
123
127
|
|
|
124
128
|
if (isCooldownActive(candidate)) {
|
|
125
129
|
if (ctx.hasUI && ctx.ui) {
|
|
130
|
+
const entry = getCooldownEntry(candidate);
|
|
131
|
+
const reason = entry ? `: ${entry.reason}` : "";
|
|
126
132
|
ctx.ui.notify(
|
|
127
|
-
`Observational memory: ${stageName} skipping ${key} (cooldown
|
|
133
|
+
`Observational memory: ${stageName} skipping ${key} (cooldown${reason})`,
|
|
128
134
|
"info",
|
|
129
135
|
);
|
|
130
136
|
}
|
|
@@ -162,25 +168,40 @@ export class Runtime {
|
|
|
162
168
|
};
|
|
163
169
|
}
|
|
164
170
|
|
|
165
|
-
// Fall back to session model
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
// Fall back to session model (if enabled)
|
|
172
|
+
if (this.config.sessionFallback !== false) {
|
|
173
|
+
const sessionModel = ctx.model;
|
|
174
|
+
if (!sessionModel) {
|
|
175
|
+
return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, no session model)` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
|
|
179
|
+
if (!auth.ok || !auth.apiKey) {
|
|
180
|
+
const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
|
|
181
|
+
return { ok: false, reason: `no API key for session model provider "${provider}"` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
model: sessionModel,
|
|
187
|
+
apiKey: auth.apiKey as string,
|
|
188
|
+
headers: auth.headers as Record<string, string> | undefined,
|
|
189
|
+
cooldownApplied: false,
|
|
190
|
+
};
|
|
169
191
|
}
|
|
170
192
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
193
|
+
// All configured candidates exhausted and session fallback disabled —
|
|
194
|
+
// skip the stage entirely. Info-level to match cooldown-disabled pattern.
|
|
195
|
+
// Set resolveFailureNotified so the consolidation layer doesn't duplicate.
|
|
196
|
+
if (ctx.hasUI && ctx.ui) {
|
|
197
|
+
ctx.ui.notify(
|
|
198
|
+
`Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`,
|
|
199
|
+
"info",
|
|
200
|
+
);
|
|
175
201
|
}
|
|
202
|
+
this.resolveFailureNotified = true;
|
|
176
203
|
|
|
177
|
-
return {
|
|
178
|
-
ok: true,
|
|
179
|
-
model: sessionModel,
|
|
180
|
-
apiKey: auth.apiKey as string,
|
|
181
|
-
headers: auth.headers as Record<string, string> | undefined,
|
|
182
|
-
cooldownApplied: false,
|
|
183
|
-
};
|
|
204
|
+
return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, sessionFallback disabled)` };
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
/**
|
package/src/sections.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Upstream: https://github.com/sting8k/pi-vcc (src/sections.ts)
|
|
5
5
|
* Unmodified.
|
|
6
6
|
*/
|
|
7
|
-
import type { TranscriptEntry } from "./core/brief";
|
|
8
7
|
|
|
9
8
|
export interface SectionData {
|
|
10
9
|
sessionGoal: string[];
|
|
@@ -13,6 +12,4 @@ export interface SectionData {
|
|
|
13
12
|
commits: string[];
|
|
14
13
|
userPreferences: string[];
|
|
15
14
|
briefTranscript: string;
|
|
16
|
-
/** Structured transcript entries (verbose object format) */
|
|
17
|
-
transcriptEntries: TranscriptEntry[];
|
|
18
15
|
}
|
package/src/tools/recall.ts
CHANGED
|
@@ -138,7 +138,11 @@ async function vccRecall(params: { query?: string; expand?: number[]; page?: num
|
|
|
138
138
|
|
|
139
139
|
// Merge expanded entries into full result set BEFORE pagination
|
|
140
140
|
// so pagination counts and positioning stay consistent
|
|
141
|
+
let appendedExpandCount = 0;
|
|
141
142
|
if (expandedFullEntries) {
|
|
143
|
+
// Track expand-only entries that don't match the query so the header can distinguish them
|
|
144
|
+
const existingIndices = new Set(allResults.map((r) => r.index));
|
|
145
|
+
appendedExpandCount = expandedFullEntries.filter((fe) => !existingIndices.has(fe.index)).length;
|
|
142
146
|
allResults = mergeExpandedIntoSearchResults(allResults, expandedFullEntries);
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -148,9 +152,10 @@ async function vccRecall(params: { query?: string; expand?: number[]; page?: num
|
|
|
148
152
|
const pageResults: SearchHit[] = allResults.slice(start, start + PAGE_SIZE);
|
|
149
153
|
const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
|
|
150
154
|
const scopeSuffix = scope === "all" ? " (scope: all)" : "";
|
|
155
|
+
const matchCount = allResults.length - appendedExpandCount;
|
|
151
156
|
const header = totalPages > 1
|
|
152
|
-
? `Page ${page}/${totalPages} (${
|
|
153
|
-
: `${
|
|
157
|
+
? `Page ${page}/${totalPages} (${matchCount} matches${appendedExpandCount > 0 ? ` + ${appendedExpandCount} expanded` : ""}${scopeSuffix})`
|
|
158
|
+
: `${matchCount} matches${appendedExpandCount > 0 ? ` (+ ${appendedExpandCount} expanded)` : ""}${scopeSuffix}`;
|
|
154
159
|
const footer = page < totalPages
|
|
155
160
|
? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---`
|
|
156
161
|
: "";
|