pi-blackhole 0.2.0
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/LICENSE +21 -0
- package/README.md +373 -0
- package/example-config.json +115 -0
- package/index.ts +39 -0
- package/package.json +55 -0
- package/src/commands/memory.ts +191 -0
- package/src/commands/pi-vcc.ts +94 -0
- package/src/commands/vcc-recall.ts +112 -0
- package/src/core/brief.ts +390 -0
- package/src/core/build-sections.ts +85 -0
- package/src/core/content.ts +60 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +27 -0
- package/src/core/format.ts +76 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +41 -0
- package/src/core/normalize.ts +79 -0
- package/src/core/recall-scope.ts +14 -0
- package/src/core/render-entries.ts +56 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +227 -0
- package/src/core/settings.ts +34 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +213 -0
- package/src/core/tool-args.ts +14 -0
- package/src/core/unified-config.ts +285 -0
- package/src/details.ts +13 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/files.ts +80 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/hooks/before-compact.ts +345 -0
- package/src/om/agents/dropper/agent.ts +204 -0
- package/src/om/agents/dropper/prompts.ts +48 -0
- package/src/om/agents/observer/agent.ts +256 -0
- package/src/om/agents/observer/prompts.ts +119 -0
- package/src/om/agents/reflector/agent.ts +161 -0
- package/src/om/agents/reflector/prompts.ts +77 -0
- package/src/om/clipboard.ts +63 -0
- package/src/om/compaction-hook.ts +63 -0
- package/src/om/compaction-trigger.ts +92 -0
- package/src/om/config.ts +22 -0
- package/src/om/consolidation.ts +514 -0
- package/src/om/cooldown.ts +130 -0
- package/src/om/debug-log.ts +55 -0
- package/src/om/ids.ts +5 -0
- package/src/om/ledger/fold.ts +106 -0
- package/src/om/ledger/index.ts +6 -0
- package/src/om/ledger/progress.ts +225 -0
- package/src/om/ledger/projection.ts +237 -0
- package/src/om/ledger/recall.ts +243 -0
- package/src/om/ledger/render-summary.ts +44 -0
- package/src/om/ledger/types.ts +206 -0
- package/src/om/model-budget.ts +9 -0
- package/src/om/pending.ts +225 -0
- package/src/om/reverse-recall.ts +130 -0
- package/src/om/runtime.ts +241 -0
- package/src/om/serialize.ts +224 -0
- package/src/om/tokens.ts +33 -0
- package/src/sections.ts +18 -0
- package/src/tools/recall.ts +212 -0
- package/src/types.ts +19 -0
- package/vitest.config.ts +41 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending OM state persistence.
|
|
3
|
+
*
|
|
4
|
+
* When `noAutoCompact` is enabled, observations, reflections, and dropper
|
|
5
|
+
* results are saved to disk instead of being appended to the conversation.
|
|
6
|
+
* Each new pipeline run replaces the previous result (latest subsumes earlier
|
|
7
|
+
* since every run processes all entries since the last actual branch append).
|
|
8
|
+
*
|
|
9
|
+
* On manual `/pi-vcc` trigger, pending entries are flushed to the branch
|
|
10
|
+
* and the file is cleared.
|
|
11
|
+
*
|
|
12
|
+
* Per-session files: each session gets its own <sessionId>-pending.json
|
|
13
|
+
* under ~/.pi/agent/pi-blackhole/. This eliminates race conditions from
|
|
14
|
+
* concurrent pi sessions writing to a shared file.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface PendingObservation {
|
|
23
|
+
coversUpToId: string;
|
|
24
|
+
data: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PendingReflection {
|
|
28
|
+
coversUpToId: string;
|
|
29
|
+
data: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PendingDropped {
|
|
33
|
+
coversUpToId: string;
|
|
34
|
+
data: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PendingOMState {
|
|
38
|
+
/** Latest observation run (replaced each time, not accumulated). */
|
|
39
|
+
observation?: PendingObservation;
|
|
40
|
+
/** Latest reflection run (replaced each time, not accumulated). */
|
|
41
|
+
reflection?: PendingReflection;
|
|
42
|
+
/** Latest dropper run (replaced each time, not accumulated). */
|
|
43
|
+
dropped?: PendingDropped;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Persistence ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const PENDING_DIR = "pi-blackhole";
|
|
49
|
+
const PENDING_SUFFIX = "-pending.json";
|
|
50
|
+
const STALE_SUFFIX = "-pending.stale.json";
|
|
51
|
+
|
|
52
|
+
/** Build the path for a given session's pending file. */
|
|
53
|
+
function pendingPath(sessionId: string): string {
|
|
54
|
+
return join(getAgentDir(), PENDING_DIR, `${sessionId}${PENDING_SUFFIX}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build the path for a given session's stale pending file (backup of previous write). */
|
|
58
|
+
function stalePath(sessionId: string): string {
|
|
59
|
+
return join(getAgentDir(), PENDING_DIR, `${sessionId}${STALE_SUFFIX}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Ensure the pending directory exists. */
|
|
63
|
+
function ensureDir(): void {
|
|
64
|
+
const dir = join(getAgentDir(), PENDING_DIR);
|
|
65
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultState(): PendingOMState {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isEmptyState(s: PendingOMState): boolean {
|
|
73
|
+
return !s.observation && !s.reflection && !s.dropped;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Per-session file read/write ─────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read pending state for a specific session from its dedicated file.
|
|
80
|
+
* Returns default (empty) state if file doesn't exist or is corrupt.
|
|
81
|
+
*/
|
|
82
|
+
function readSessionState(sessionId: string): PendingOMState {
|
|
83
|
+
const path = pendingPath(sessionId);
|
|
84
|
+
if (!existsSync(path)) return defaultState();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
88
|
+
if (isPendingOMState(raw)) return raw;
|
|
89
|
+
return defaultState();
|
|
90
|
+
} catch {
|
|
91
|
+
return defaultState();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Write pending state for a specific session to its dedicated file.
|
|
97
|
+
* Deletes the file if state is empty.
|
|
98
|
+
* Preserves the previous state as <sessionId>-pending.stale.json as backup.
|
|
99
|
+
*/
|
|
100
|
+
function writeSessionState(sessionId: string, state: PendingOMState): void {
|
|
101
|
+
const path = pendingPath(sessionId);
|
|
102
|
+
if (isEmptyState(state)) {
|
|
103
|
+
// Clear: remove both main and stale
|
|
104
|
+
try {
|
|
105
|
+
if (existsSync(path)) unlinkSync(path);
|
|
106
|
+
} catch { /* best-effort */ }
|
|
107
|
+
try {
|
|
108
|
+
const stale = stalePath(sessionId);
|
|
109
|
+
if (existsSync(stale)) unlinkSync(stale);
|
|
110
|
+
} catch { /* best-effort */ }
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ensureDir();
|
|
115
|
+
|
|
116
|
+
// Before writing new state, rename current file to stale as backup
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(path)) {
|
|
119
|
+
renameSync(path, stalePath(sessionId));
|
|
120
|
+
}
|
|
121
|
+
} catch { /* best-effort — stale backup is optional */ }
|
|
122
|
+
|
|
123
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate that an unknown value is a valid PendingOMState.
|
|
128
|
+
*/
|
|
129
|
+
function isPendingOMState(value: unknown): value is PendingOMState {
|
|
130
|
+
if (!value || typeof value !== "object") return false;
|
|
131
|
+
const v = value as Record<string, unknown>;
|
|
132
|
+
const hasObs = !!(v.observation && typeof v.observation === "object" && typeof (v.observation as any).coversUpToId === "string");
|
|
133
|
+
const hasRef = !!(v.reflection && typeof v.reflection === "object" && typeof (v.reflection as any).coversUpToId === "string");
|
|
134
|
+
const hasDrop = !!(v.dropped && typeof v.dropped === "object" && typeof (v.dropped as any).coversUpToId === "string");
|
|
135
|
+
return hasObs || hasRef || hasDrop;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── API ─────────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Save (replace) the latest observation result for a session.
|
|
142
|
+
* Each new run covers all entries since last branch append, so the latest
|
|
143
|
+
* result fully subsumes any previous one.
|
|
144
|
+
*/
|
|
145
|
+
export function savePendingObservation(sessionId: string, entry: PendingObservation): void {
|
|
146
|
+
const state = readSessionState(sessionId);
|
|
147
|
+
state.observation = entry;
|
|
148
|
+
writeSessionState(sessionId, state);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Save (replace) the latest reflection result for a session.
|
|
153
|
+
*/
|
|
154
|
+
export function savePendingReflection(sessionId: string, entry: PendingReflection): void {
|
|
155
|
+
const state = readSessionState(sessionId);
|
|
156
|
+
state.reflection = entry;
|
|
157
|
+
writeSessionState(sessionId, state);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Save (replace) the latest dropper result for a session.
|
|
162
|
+
*/
|
|
163
|
+
export function savePendingDropped(sessionId: string, entry: PendingDropped): void {
|
|
164
|
+
const state = readSessionState(sessionId);
|
|
165
|
+
state.dropped = entry;
|
|
166
|
+
writeSessionState(sessionId, state);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check whether a coversUpToId matches the already-pending observation
|
|
171
|
+
* for the given session. Returns true if the chunk was already processed.
|
|
172
|
+
*/
|
|
173
|
+
export function isObservationChunkPending(sessionId: string, coversUpToId: string): boolean {
|
|
174
|
+
const s = readSessionState(sessionId);
|
|
175
|
+
return s.observation?.coversUpToId === coversUpToId;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check whether a coversUpToId matches the already-pending reflection
|
|
180
|
+
* for the given session.
|
|
181
|
+
*/
|
|
182
|
+
export function isReflectionChunkPending(sessionId: string, coversUpToId: string): boolean {
|
|
183
|
+
const s = readSessionState(sessionId);
|
|
184
|
+
return s.reflection?.coversUpToId === coversUpToId;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read the pending OM state for a specific session. */
|
|
188
|
+
export function readPendingState(sessionId: string): PendingOMState {
|
|
189
|
+
return readSessionState(sessionId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Clear the pending OM state for a specific session after flushing to branch. */
|
|
193
|
+
export function clearPendingState(sessionId: string): void {
|
|
194
|
+
writeSessionState(sessionId, defaultState());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Check whether there is any pending OM state for a specific session. */
|
|
198
|
+
export function hasPendingData(sessionId: string): boolean {
|
|
199
|
+
return !isEmptyState(readSessionState(sessionId));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* List all session IDs that have pending data by scanning the pending directory
|
|
204
|
+
* for *-pending.json files.
|
|
205
|
+
*/
|
|
206
|
+
export function listPendingSessions(): string[] {
|
|
207
|
+
const dir = join(getAgentDir(), PENDING_DIR);
|
|
208
|
+
if (!existsSync(dir)) return [];
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const files = readdirSync(dir);
|
|
212
|
+
const sessions: string[] = [];
|
|
213
|
+
for (const file of files) {
|
|
214
|
+
if (!file.endsWith(PENDING_SUFFIX)) continue;
|
|
215
|
+
const sessionId = file.slice(0, -PENDING_SUFFIX.length);
|
|
216
|
+
if (!sessionId) continue;
|
|
217
|
+
// Verify the file actually has non-empty data
|
|
218
|
+
const state = readSessionState(sessionId);
|
|
219
|
+
if (!isEmptyState(state)) sessions.push(sessionId);
|
|
220
|
+
}
|
|
221
|
+
return sessions;
|
|
222
|
+
} catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse recall — given session entry IDs, find related OM observations/reflections.
|
|
3
|
+
*
|
|
4
|
+
* This is the vcc→OM direction: when expanding session entries, look up
|
|
5
|
+
* observations whose sourceEntryIds contain those entry IDs.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
indexLedger,
|
|
9
|
+
type Entry,
|
|
10
|
+
} from "./ledger/recall.js";
|
|
11
|
+
import { type RenderedEntry } from "../core/render-entries.js";
|
|
12
|
+
|
|
13
|
+
// ── Types ─────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface RelatedObservation {
|
|
16
|
+
memoryId: string;
|
|
17
|
+
content: string;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
relevance: string;
|
|
20
|
+
status: "active" | "dropped";
|
|
21
|
+
matchedEntryIds: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RelatedReflection {
|
|
25
|
+
memoryId: string;
|
|
26
|
+
content: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Lookup ────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export function findObservationsForEntryIds(
|
|
32
|
+
entries: Entry[],
|
|
33
|
+
targetEntryIds: string[],
|
|
34
|
+
): RelatedObservation[] {
|
|
35
|
+
if (targetEntryIds.length === 0) return [];
|
|
36
|
+
const { observations, droppedIds } = indexLedger(entries);
|
|
37
|
+
const targetSet = new Set(targetEntryIds);
|
|
38
|
+
|
|
39
|
+
const result: RelatedObservation[] = [];
|
|
40
|
+
for (const indexed of observations) {
|
|
41
|
+
const matched = indexed.observation.sourceEntryIds.filter((id) => targetSet.has(id));
|
|
42
|
+
if (matched.length > 0) {
|
|
43
|
+
result.push({
|
|
44
|
+
memoryId: indexed.observation.id,
|
|
45
|
+
content: indexed.observation.content,
|
|
46
|
+
timestamp: indexed.observation.timestamp,
|
|
47
|
+
relevance: indexed.observation.relevance,
|
|
48
|
+
status: droppedIds.has(indexed.observation.id) ? "dropped" : "active",
|
|
49
|
+
matchedEntryIds: matched,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function findReflectionsForEntryIds(
|
|
57
|
+
entries: Entry[],
|
|
58
|
+
targetEntryIds: string[],
|
|
59
|
+
): RelatedReflection[] {
|
|
60
|
+
if (targetEntryIds.length === 0) return [];
|
|
61
|
+
const { reflections } = indexLedger(entries);
|
|
62
|
+
// Reflections don't directly reference entry IDs — they reference observation IDs.
|
|
63
|
+
// So we only match indirectly: first find observations for these entry IDs,
|
|
64
|
+
// then find reflections that support those observations.
|
|
65
|
+
const { observations } = indexLedger(entries);
|
|
66
|
+
const targetSet = new Set(targetEntryIds);
|
|
67
|
+
const matchingObsIds = new Set<string>();
|
|
68
|
+
for (const indexed of observations) {
|
|
69
|
+
if (indexed.observation.sourceEntryIds.some((id) => targetSet.has(id))) {
|
|
70
|
+
matchingObsIds.add(indexed.observation.id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (matchingObsIds.size === 0) return [];
|
|
74
|
+
return reflections
|
|
75
|
+
.filter((r) => r.reflection.supportingObservationIds.some((id) => matchingObsIds.has(id)))
|
|
76
|
+
.map((r) => ({
|
|
77
|
+
memoryId: r.reflection.id,
|
|
78
|
+
content: r.reflection.content,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Formatters ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export function formatRelatedObservations(
|
|
85
|
+
observations: RelatedObservation[],
|
|
86
|
+
_reflections: RelatedReflection[],
|
|
87
|
+
): string {
|
|
88
|
+
const parts: string[] = [];
|
|
89
|
+
|
|
90
|
+
if (observations.length > 0) {
|
|
91
|
+
parts.push("Related observations:");
|
|
92
|
+
for (const obs of observations) {
|
|
93
|
+
const dropped = obs.status === "dropped" ? " [dropped]" : "";
|
|
94
|
+
const entryRefs = obs.matchedEntryIds.length > 0
|
|
95
|
+
? ` (${obs.matchedEntryIds.join(", ")})`
|
|
96
|
+
: "";
|
|
97
|
+
parts.push(` [${obs.memoryId}]${dropped} ${obs.timestamp} [${obs.relevance}] ${obs.content}${entryRefs}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return parts.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a session-entry-id → message-index map from RenderedEntry[].
|
|
106
|
+
* Used to annotate source entries with their #N indices.
|
|
107
|
+
*/
|
|
108
|
+
export function buildIndexMap(rendered: RenderedEntry[]): Map<string, number> {
|
|
109
|
+
const map = new Map<string, number>();
|
|
110
|
+
for (const entry of rendered) {
|
|
111
|
+
if (entry.id && !map.has(entry.id)) {
|
|
112
|
+
map.set(entry.id, entry.index);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return map;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatEntryIndexAnnotation(
|
|
119
|
+
sourceEntryIds: string[],
|
|
120
|
+
idToIndex: Map<string, number>,
|
|
121
|
+
): string {
|
|
122
|
+
const indices: number[] = [];
|
|
123
|
+
for (const id of sourceEntryIds) {
|
|
124
|
+
const idx = idToIndex.get(id);
|
|
125
|
+
if (idx !== undefined) indices.push(idx);
|
|
126
|
+
}
|
|
127
|
+
if (indices.length === 0) return "";
|
|
128
|
+
indices.sort((a, b) => a - b);
|
|
129
|
+
return `(at index #${indices.join(", #")})`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observational memory runtime — model resolution, consolidation lifecycle,
|
|
3
|
+
* cooldown integration, error tracking.
|
|
4
|
+
*
|
|
5
|
+
* Upstream: https://github.com/elpapi42/pi-observational-memory (src/runtime.ts)
|
|
6
|
+
* Modified by pi-vcc-om:
|
|
7
|
+
* - resolveModel iterates fallback chain (stage → fallbacks → base → session).
|
|
8
|
+
* - Skips cooled-down models (cooldown.ts).
|
|
9
|
+
* - recordRetryableError persists cooldown on API errors.
|
|
10
|
+
* - markConsolidationError sets 30s retry gate for failed runs.
|
|
11
|
+
*/
|
|
12
|
+
import { type Config, type ConfiguredModel, DEFAULTS, loadConfig } from "./config.js";
|
|
13
|
+
import { isCooldownActive, recordCooldown, expireCooldowns, modelKey } from "./cooldown.js";
|
|
14
|
+
|
|
15
|
+
export type ResolveResult =
|
|
16
|
+
| { ok: true; model: any; apiKey: string; headers?: Record<string, string>; cooldownApplied?: boolean }
|
|
17
|
+
| { ok: false; reason: string };
|
|
18
|
+
|
|
19
|
+
type NotifyLevel = "warning" | "info" | "error";
|
|
20
|
+
type Notify = (message: string, type?: NotifyLevel) => void;
|
|
21
|
+
export type ConsolidationPhase = "observer" | "reflector" | "dropper";
|
|
22
|
+
|
|
23
|
+
export interface ResolveCtx {
|
|
24
|
+
model: unknown;
|
|
25
|
+
modelRegistry: any;
|
|
26
|
+
hasUI: boolean;
|
|
27
|
+
ui?: { notify: Notify };
|
|
28
|
+
/** Primary stage model (from config). */
|
|
29
|
+
stageModel?: ConfiguredModel;
|
|
30
|
+
/** Fallback models for this stage (from config). */
|
|
31
|
+
stageFallbacks?: ConfiguredModel[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LaunchCtx {
|
|
35
|
+
hasUI: boolean;
|
|
36
|
+
ui?: { notify: Notify };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Default cooldown interval between failed consolidation runs (ms). */
|
|
40
|
+
const CONSOLIDATION_RETRY_COOLDOWN_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
export class Runtime {
|
|
43
|
+
config: Config = { ...DEFAULTS };
|
|
44
|
+
configLoaded = false;
|
|
45
|
+
consolidationInFlight = false;
|
|
46
|
+
consolidationPromise: Promise<void> | null = null;
|
|
47
|
+
consolidationPhase: ConsolidationPhase | undefined;
|
|
48
|
+
compactInFlight = false;
|
|
49
|
+
compactHookInFlight = false;
|
|
50
|
+
resolveFailureNotified = false;
|
|
51
|
+
lastObserverError: string | undefined;
|
|
52
|
+
lastReflectorError: string | undefined;
|
|
53
|
+
lastDropperError: string | undefined;
|
|
54
|
+
/** Epoch ms of the last failed consolidation run (any stage). */
|
|
55
|
+
lastConsolidationErrorAt: number | undefined;
|
|
56
|
+
|
|
57
|
+
ensureConfig(cwd: string): void {
|
|
58
|
+
if (this.configLoaded) return;
|
|
59
|
+
this.config = loadConfig(cwd);
|
|
60
|
+
this.configLoaded = true;
|
|
61
|
+
expireCooldowns();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the ordered model candidate list for a stage:
|
|
66
|
+
* 1. Primary stage model (observerModel, reflectorModel, dropperModel)
|
|
67
|
+
* 2. Stage fallbacks (observerFallbackModels, etc.)
|
|
68
|
+
* 3. Base config.model
|
|
69
|
+
*
|
|
70
|
+
* Session model (ctx.model) is only used as the last resort inside resolveModel.
|
|
71
|
+
*/
|
|
72
|
+
private buildCandidateList(stageModel?: ConfiguredModel, stageFallbacks?: ConfiguredModel[]): ConfiguredModel[] {
|
|
73
|
+
const candidates: ConfiguredModel[] = [];
|
|
74
|
+
if (stageModel) candidates.push(stageModel);
|
|
75
|
+
if (stageFallbacks) candidates.push(...stageFallbacks);
|
|
76
|
+
if (this.config.model) candidates.push(this.config.model);
|
|
77
|
+
return candidates;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a model for a consolidation stage.
|
|
82
|
+
*
|
|
83
|
+
* Tries the candidate list in order:
|
|
84
|
+
* 1. Primary stage model → 2. Stage fallbacks → 3. Base config.model → 4. Session model.
|
|
85
|
+
*
|
|
86
|
+
* Skips models that are currently in a cooldown window.
|
|
87
|
+
* On retryable error (after the agent runs), the model that failed is cooled down
|
|
88
|
+
* and the next candidate is tried. The caller must call `recordRetryableError`
|
|
89
|
+
* after the API attempt to mark the failed model.
|
|
90
|
+
*
|
|
91
|
+
* Returns `ok: true` with the resolved model, or `ok: false` with a reason
|
|
92
|
+
* if all candidates (including session model) are exhausted or unavailable.
|
|
93
|
+
*/
|
|
94
|
+
async resolveModel(ctx: ResolveCtx): Promise<ResolveResult> {
|
|
95
|
+
const candidates = this.buildCandidateList(ctx.stageModel, ctx.stageFallbacks);
|
|
96
|
+
const stageName = this.consolidationPhase ?? "unknown";
|
|
97
|
+
|
|
98
|
+
// Try configured candidates
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
if (isCooldownActive(candidate)) {
|
|
101
|
+
if (ctx.hasUI && ctx.ui) {
|
|
102
|
+
ctx.ui.notify(
|
|
103
|
+
`Observational memory: ${stageName} skipping ${modelKey(candidate)} (cooldown active)`,
|
|
104
|
+
"info",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const configured = ctx.modelRegistry.find(candidate.provider, candidate.id);
|
|
111
|
+
if (!configured) {
|
|
112
|
+
if (ctx.hasUI && ctx.ui) {
|
|
113
|
+
ctx.ui.notify(
|
|
114
|
+
`Observational memory: ${stageName} model ${candidate.provider}/${candidate.id} not found`,
|
|
115
|
+
"warning",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configured);
|
|
122
|
+
if (!auth.ok || !auth.apiKey) {
|
|
123
|
+
if (ctx.hasUI && ctx.ui) {
|
|
124
|
+
ctx.ui.notify(
|
|
125
|
+
`Observational memory: ${stageName} no auth for ${candidate.provider}`,
|
|
126
|
+
"warning",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
model: configured,
|
|
135
|
+
apiKey: auth.apiKey as string,
|
|
136
|
+
headers: auth.headers as Record<string, string> | undefined,
|
|
137
|
+
cooldownApplied: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Fall back to session model
|
|
142
|
+
const sessionModel = ctx.model;
|
|
143
|
+
if (!sessionModel) {
|
|
144
|
+
return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, no session model)` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
|
|
148
|
+
if (!auth.ok || !auth.apiKey) {
|
|
149
|
+
const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
|
|
150
|
+
return { ok: false, reason: `no API key for session model provider "${provider}"` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
model: sessionModel,
|
|
156
|
+
apiKey: auth.apiKey as string,
|
|
157
|
+
headers: auth.headers as Record<string, string> | undefined,
|
|
158
|
+
cooldownApplied: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get the model config for the currently resolved model (used for cooldown recording).
|
|
164
|
+
* Returns the candidate config if the model was from the candidate list,
|
|
165
|
+
* or undefined if it's the session model.
|
|
166
|
+
*/
|
|
167
|
+
findCandidateConfig(resolvedModel: unknown, ctx: ResolveCtx): ConfiguredModel | undefined {
|
|
168
|
+
const candidates = this.buildCandidateList(ctx.stageModel, ctx.stageFallbacks);
|
|
169
|
+
const model = resolvedModel as { provider?: string; id?: string };
|
|
170
|
+
if (!model.provider || !model.id) return undefined;
|
|
171
|
+
return candidates.find((c) => c.provider === model.provider && c.id === model.id)
|
|
172
|
+
?? (this.config.model?.provider === model.provider && this.config.model?.id === model.id ? this.config.model : undefined);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Record a retryable error for a model. The model must be one of the candidates
|
|
177
|
+
* (not the session model). If it's the session model we don't cool it down.
|
|
178
|
+
*/
|
|
179
|
+
recordRetryableError(modelConfig: ConfiguredModel | undefined, error: unknown, stage: ConsolidationPhase): void {
|
|
180
|
+
if (!modelConfig) return;
|
|
181
|
+
const reason = error instanceof Error ? error.message : String(error || "unknown error");
|
|
182
|
+
recordCooldown(modelConfig, reason, stage);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Record that a consolidation stage error occurred.
|
|
187
|
+
* Sets the retry-gate timestamp so the next trigger is delayed.
|
|
188
|
+
*/
|
|
189
|
+
markConsolidationError(): void {
|
|
190
|
+
this.lastConsolidationErrorAt = Date.now();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Check if the consolidation retry gate is active (too soon after last error). */
|
|
194
|
+
isConsolidationRetryGated(): boolean {
|
|
195
|
+
if (!this.lastConsolidationErrorAt) return false;
|
|
196
|
+
return Date.now() - this.lastConsolidationErrorAt < CONSOLIDATION_RETRY_COOLDOWN_MS;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
launchConsolidationTask(ctx: LaunchCtx, work: () => Promise<void>): Promise<void> {
|
|
200
|
+
this.consolidationInFlight = true;
|
|
201
|
+
this.consolidationPhase = undefined;
|
|
202
|
+
const promise = this.launchTrackedTask(ctx, "consolidation", work, () => {
|
|
203
|
+
this.consolidationInFlight = false;
|
|
204
|
+
this.consolidationPhase = undefined;
|
|
205
|
+
if (this.consolidationPromise === promise) this.consolidationPromise = null;
|
|
206
|
+
});
|
|
207
|
+
this.consolidationPromise = promise;
|
|
208
|
+
return promise;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
recordConsolidationStageError(ctx: LaunchCtx, phase: ConsolidationPhase, error: unknown): string {
|
|
212
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
213
|
+
if (phase === "observer") this.lastObserverError = message;
|
|
214
|
+
if (phase === "reflector") this.lastReflectorError = message;
|
|
215
|
+
if (phase === "dropper") this.lastDropperError = message;
|
|
216
|
+
if (ctx.hasUI && ctx.ui) ctx.ui.notify(`Observational memory: ${phase} failed: ${message}`, "warning");
|
|
217
|
+
this.markConsolidationError();
|
|
218
|
+
return message;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private launchTrackedTask(
|
|
222
|
+
ctx: LaunchCtx,
|
|
223
|
+
label: string,
|
|
224
|
+
work: () => Promise<void>,
|
|
225
|
+
onFinally: (error: string | undefined) => void,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
const hasUI = ctx.hasUI;
|
|
228
|
+
const ui = ctx.ui;
|
|
229
|
+
return (async () => {
|
|
230
|
+
let errorMessage: string | undefined;
|
|
231
|
+
try {
|
|
232
|
+
await work();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
errorMessage = error instanceof Error ? error.message : String(error);
|
|
235
|
+
if (hasUI && ui) ui.notify(`Observational memory: ${label} failed: ${errorMessage}`, "warning");
|
|
236
|
+
} finally {
|
|
237
|
+
onFinally(errorMessage);
|
|
238
|
+
}
|
|
239
|
+
})();
|
|
240
|
+
}
|
|
241
|
+
}
|