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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +373 -0
  3. package/example-config.json +115 -0
  4. package/index.ts +39 -0
  5. package/package.json +55 -0
  6. package/src/commands/memory.ts +191 -0
  7. package/src/commands/pi-vcc.ts +94 -0
  8. package/src/commands/vcc-recall.ts +112 -0
  9. package/src/core/brief.ts +390 -0
  10. package/src/core/build-sections.ts +85 -0
  11. package/src/core/content.ts +60 -0
  12. package/src/core/filter-noise.ts +42 -0
  13. package/src/core/format-recall.ts +27 -0
  14. package/src/core/format.ts +76 -0
  15. package/src/core/lineage.ts +26 -0
  16. package/src/core/load-messages.ts +41 -0
  17. package/src/core/normalize.ts +79 -0
  18. package/src/core/recall-scope.ts +14 -0
  19. package/src/core/render-entries.ts +56 -0
  20. package/src/core/report.ts +237 -0
  21. package/src/core/sanitize.ts +5 -0
  22. package/src/core/search-entries.ts +227 -0
  23. package/src/core/settings.ts +34 -0
  24. package/src/core/skill-collapse.ts +35 -0
  25. package/src/core/summarize.ts +213 -0
  26. package/src/core/tool-args.ts +14 -0
  27. package/src/core/unified-config.ts +285 -0
  28. package/src/details.ts +13 -0
  29. package/src/extract/commits.ts +69 -0
  30. package/src/extract/files.ts +80 -0
  31. package/src/extract/goals.ts +79 -0
  32. package/src/extract/preferences.ts +55 -0
  33. package/src/hooks/before-compact.ts +345 -0
  34. package/src/om/agents/dropper/agent.ts +204 -0
  35. package/src/om/agents/dropper/prompts.ts +48 -0
  36. package/src/om/agents/observer/agent.ts +256 -0
  37. package/src/om/agents/observer/prompts.ts +119 -0
  38. package/src/om/agents/reflector/agent.ts +161 -0
  39. package/src/om/agents/reflector/prompts.ts +77 -0
  40. package/src/om/clipboard.ts +63 -0
  41. package/src/om/compaction-hook.ts +63 -0
  42. package/src/om/compaction-trigger.ts +92 -0
  43. package/src/om/config.ts +22 -0
  44. package/src/om/consolidation.ts +514 -0
  45. package/src/om/cooldown.ts +130 -0
  46. package/src/om/debug-log.ts +55 -0
  47. package/src/om/ids.ts +5 -0
  48. package/src/om/ledger/fold.ts +106 -0
  49. package/src/om/ledger/index.ts +6 -0
  50. package/src/om/ledger/progress.ts +225 -0
  51. package/src/om/ledger/projection.ts +237 -0
  52. package/src/om/ledger/recall.ts +243 -0
  53. package/src/om/ledger/render-summary.ts +44 -0
  54. package/src/om/ledger/types.ts +206 -0
  55. package/src/om/model-budget.ts +9 -0
  56. package/src/om/pending.ts +225 -0
  57. package/src/om/reverse-recall.ts +130 -0
  58. package/src/om/runtime.ts +241 -0
  59. package/src/om/serialize.ts +224 -0
  60. package/src/om/tokens.ts +33 -0
  61. package/src/sections.ts +18 -0
  62. package/src/tools/recall.ts +212 -0
  63. package/src/types.ts +19 -0
  64. package/vitest.config.ts +41 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Consolidation pipeline — observer → reflector → dropper with fallback retry.
3
+ *
4
+ * Upstream: https://github.com/elpapi42/pi-observational-memory (src/hooks/consolidation-trigger.ts)
5
+ * Modified by pi-vcc-om:
6
+ * - Each stage retries through fallback models when any error occurs.
7
+ * - All errors record cooldown (so the failed model is skipped next iteration).
8
+ * - 30s retry gate prevents repeated failed runs (isConsolidationRetryGated).
9
+ */
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { runDropper } from "./agents/dropper/agent.js";
12
+ import { runObserver } from "./agents/observer/agent.js";
13
+ import { runReflector } from "./agents/reflector/agent.js";
14
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
15
+ import type { ConfiguredModel } from "./config.js";
16
+ import { debugLog, withDebugLogContext } from "./debug-log.js";
17
+ import { type ResolveResult, type Runtime } from "./runtime.js";
18
+ import { isRetryableError } from "./cooldown.js";
19
+ import { serializeSourceAddressedBranchEntries } from "./serialize.js";
20
+ import {
21
+ savePendingObservation,
22
+ savePendingReflection,
23
+ savePendingDropped,
24
+ isObservationChunkPending,
25
+ } from "./pending.js";
26
+ import {
27
+ OM_OBSERVATIONS_DROPPED,
28
+ OM_OBSERVATIONS_RECORDED,
29
+ OM_REFLECTIONS_RECORDED,
30
+ buildExistingObservationsSummary,
31
+ buildExistingReflectionsSummary,
32
+ buildObservationsDroppedData,
33
+ buildObservationsRecordedData,
34
+ buildReflectionsRecordedData,
35
+ earlierCoverageMarkerId,
36
+ foldLedger,
37
+ findLastCompactionIndex,
38
+ fullProjection,
39
+ isSourceEntry,
40
+ latestCoverageIndex,
41
+ latestCoverageMarkerId,
42
+ observationsCreatedAfterIndex,
43
+ observationToSummaryLine,
44
+ rawTokensSinceDropCoverage,
45
+ rawTokensSinceObservationCoverage,
46
+ rawTokensSinceReflectionCoverage,
47
+ reflectionToSummaryLine,
48
+ reflectionsCreatedAfterIndex,
49
+ type Entry,
50
+ type Reflection,
51
+ } from "./ledger/index.js";
52
+
53
+ type ResolvedModel = Extract<ResolveResult, { ok: true }>;
54
+
55
+ type ConsolidationCtx = {
56
+ cwd: string;
57
+ hasUI: boolean;
58
+ ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
59
+ model: unknown;
60
+ modelRegistry: any;
61
+ sessionManager: { getBranch: () => unknown; getSessionId: () => string };
62
+ };
63
+
64
+ type StageOutcome = "continue" | "abort";
65
+
66
+ type ReflectorStageResult = {
67
+ outcome: StageOutcome;
68
+ sameRunReflections: Reflection[];
69
+ effectiveReflectionCoverageId?: string;
70
+ };
71
+
72
+ // Max attempts per stage (primary + all fallbacks the runtime will try internally).
73
+ // Each call to resolveModel tries all non-cooldown candidates. If the agent throws
74
+ // a retryable error, we record cooldown and call resolveModel again (up to this many times).
75
+ const MAX_STAGE_ATTEMPTS = 10;
76
+
77
+ function sourceEntriesAfter(entries: Entry[], index: number): Entry[] {
78
+ return entries.slice(index + 1).filter(isSourceEntry);
79
+ }
80
+
81
+ /**
82
+ * Cap source entries to maxTokens by keeping newest entries first,
83
+ * walking backwards until the token budget is exceeded.
84
+ * Uses a conservative chars/4 heuristic for token estimation.
85
+ */
86
+ function capSourceEntriesToTokens(entries: Entry[], maxTokens: number): Entry[] {
87
+ let totalTokens = 0;
88
+ const kept: Entry[] = [];
89
+ for (let i = entries.length - 1; i >= 0; i--) {
90
+ const entry = entries[i];
91
+ let chars = 0;
92
+ if (entry.type === "message" && entry.message) {
93
+ const msg = entry.message as any;
94
+ if (typeof msg.content === "string") chars = msg.content.length;
95
+ else if (Array.isArray(msg.content)) {
96
+ for (const block of msg.content) {
97
+ if (block.text) chars += block.text.length;
98
+ }
99
+ }
100
+ }
101
+ const estTokens = Math.ceil(chars / 4);
102
+ if (totalTokens + estTokens > maxTokens && kept.length > 0) break;
103
+ kept.unshift(entry);
104
+ totalTokens += estTokens;
105
+ }
106
+ return kept;
107
+ }
108
+
109
+ function appendEntry(pi: ExtensionAPI, customType: string, data: unknown): void {
110
+ pi.appendEntry(customType, data);
111
+ }
112
+
113
+ function mergeReflections(existing: Reflection[], additional: Reflection[]): Reflection[] {
114
+ const seen = new Set(existing.map((reflection) => reflection.id));
115
+ const merged = [...existing];
116
+ for (const reflection of additional) {
117
+ if (seen.has(reflection.id)) continue;
118
+ seen.add(reflection.id);
119
+ merged.push(reflection);
120
+ }
121
+ return merged;
122
+ }
123
+
124
+ function anyStageDue(entries: Entry[], runtime: Runtime): boolean {
125
+ return rawTokensSinceObservationCoverage(entries) >= runtime.config.observeAfterTokens
126
+ || rawTokensSinceReflectionCoverage(entries) >= runtime.config.reflectAfterTokens
127
+ || rawTokensSinceDropCoverage(entries) >= runtime.config.reflectAfterTokens;
128
+ }
129
+
130
+ function stageModelConfig(runtime: Runtime, stage: "observer" | "reflector" | "dropper"): ConfiguredModel | undefined {
131
+ if (stage === "observer") return runtime.config.observerModel;
132
+ if (stage === "reflector") return runtime.config.reflectorModel;
133
+ return runtime.config.dropperModel;
134
+ }
135
+
136
+ function stageFallbackModels(runtime: Runtime, stage: "observer" | "reflector" | "dropper"): ConfiguredModel[] {
137
+ if (stage === "observer") return runtime.config.observerFallbackModels ?? [];
138
+ if (stage === "reflector") return runtime.config.reflectorFallbackModels ?? [];
139
+ return runtime.config.dropperFallbackModels ?? [];
140
+ }
141
+
142
+ function stageThinkingLevel(runtime: Runtime, stage: "observer" | "reflector" | "dropper"): ModelThinkingLevel {
143
+ const stageModel = stageModelConfig(runtime, stage);
144
+ return stageModel?.thinking ?? runtime.config.model?.thinking ?? "low";
145
+ }
146
+
147
+ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
148
+ return async (stage) => {
149
+ const resolved = await runtime.resolveModel({
150
+ model: ctx.model,
151
+ modelRegistry: ctx.modelRegistry,
152
+ hasUI: ctx.hasUI,
153
+ ui: ctx.ui,
154
+ stageModel: stageModelConfig(runtime, stage),
155
+ stageFallbacks: stageFallbackModels(runtime, stage),
156
+ });
157
+ if (resolved.ok) {
158
+ runtime.resolveFailureNotified = false;
159
+ return resolved;
160
+ }
161
+ debugLog(`${stage}.model_unavailable`, { reason: resolved.reason });
162
+ if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
163
+ ctx.ui.notify(`Observational memory: ${stage} skipped — ${resolved.reason}`, "warning");
164
+ runtime.resolveFailureNotified = true;
165
+ }
166
+ return undefined;
167
+ };
168
+ }
169
+
170
+ // ── Trigger registration ────────────────────────────────────────────────────
171
+
172
+ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime): void {
173
+ const launch = (_event: unknown, ctx: ConsolidationCtx) => {
174
+ maybeLaunchConsolidation(pi, runtime, ctx);
175
+ };
176
+ pi.on("agent_start", launch);
177
+ pi.on("turn_end", launch);
178
+ }
179
+
180
+ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
181
+ runtime.ensureConfig(ctx.cwd);
182
+ if (runtime.config.passive === true) return;
183
+ if (runtime.config.memory === false) return;
184
+ if (runtime.consolidationInFlight) return;
185
+ if (runtime.isConsolidationRetryGated()) return;
186
+
187
+ const entries = ctx.sessionManager.getBranch() as Entry[];
188
+ if (!anyStageDue(entries, runtime)) return;
189
+
190
+ const runId = `consolidation-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
191
+ const consolidationCtx: ConsolidationCtx = {
192
+ cwd: ctx.cwd,
193
+ hasUI: ctx.hasUI,
194
+ ui: ctx.ui,
195
+ model: ctx.model,
196
+ modelRegistry: ctx.modelRegistry,
197
+ sessionManager: ctx.sessionManager,
198
+ };
199
+
200
+ void runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd: ctx.cwd, runId }, async () => {
201
+ await runConsolidationPipeline(pi, runtime, consolidationCtx);
202
+ }));
203
+ }
204
+
205
+ // ── Pipeline ─────────────────────────────────────────────────────────────────
206
+
207
+ export async function runConsolidationPipeline(
208
+ pi: ExtensionAPI,
209
+ runtime: Runtime,
210
+ ctx: ConsolidationCtx,
211
+ ): Promise<void> {
212
+ const resolveModel = makeModelResolver(runtime, ctx);
213
+
214
+ runtime.consolidationPhase = "observer";
215
+ try {
216
+ const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel);
217
+ if (observerOutcome === "abort") return;
218
+ } catch (error) {
219
+ debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
220
+ return;
221
+ }
222
+
223
+ runtime.consolidationPhase = "reflector";
224
+ let reflectorResult: ReflectorStageResult;
225
+ try {
226
+ reflectorResult = await runReflectorStage(pi, runtime, ctx, resolveModel);
227
+ if (reflectorResult.outcome === "abort") return;
228
+ } catch (error) {
229
+ debugLog("reflector.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "reflector", error) });
230
+ return;
231
+ }
232
+
233
+ runtime.consolidationPhase = "dropper";
234
+ try {
235
+ await runDropperStage(pi, runtime, ctx, resolveModel, reflectorResult.sameRunReflections, reflectorResult.effectiveReflectionCoverageId);
236
+ } catch (error) {
237
+ debugLog("dropper.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "dropper", error) });
238
+ }
239
+ }
240
+
241
+ // ── Observer stage (with fallback) ──────────────────────────────────────────
242
+
243
+ async function runObserverStage(
244
+ pi: ExtensionAPI,
245
+ runtime: Runtime,
246
+ ctx: ConsolidationCtx,
247
+ resolveModel: (stage: "observer") => Promise<ResolvedModel | undefined>,
248
+ ): Promise<StageOutcome> {
249
+ const entries = ctx.sessionManager.getBranch() as Entry[];
250
+ const tokens = rawTokensSinceObservationCoverage(entries);
251
+ if (tokens < runtime.config.observeAfterTokens) return "continue";
252
+
253
+ const lastCoverageIdx = latestCoverageIndex(entries, OM_OBSERVATIONS_RECORDED);
254
+ // Mid-session cold start: when no coverage marker exists (e.g., after compaction
255
+ // consumed the markers), fall back to the last compaction as the cutoff instead
256
+ // of processing everything including the compaction summary.
257
+ const effectiveStart = lastCoverageIdx >= 0 ? lastCoverageIdx : findLastCompactionIndex(entries);
258
+ let chunkEntries = sourceEntriesAfter(entries, effectiveStart);
259
+ const coversUpToId = chunkEntries.at(-1)?.id;
260
+ if (!coversUpToId) return "continue";
261
+
262
+ // Cap observer input to observerChunkMaxTokens (newest-to-oldest)
263
+ const maxChunkTokens = runtime.config.observerChunkMaxTokens;
264
+ if (tokens > maxChunkTokens) {
265
+ chunkEntries = capSourceEntriesToTokens(chunkEntries, maxChunkTokens);
266
+ }
267
+
268
+ const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
269
+ if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
270
+ const chunkTokens = Math.ceil(chunk.length / 4);
271
+
272
+ const memory = fullProjection(entries);
273
+ const priorReflections = memory.reflections.map(reflectionToSummaryLine);
274
+ const priorObservations = memory.observations.map(observationToSummaryLine);
275
+
276
+ // If noAutoCompact: skip if this exact chunk was already processed
277
+ const sessionId = ctx.sessionManager.getSessionId();
278
+ if (runtime.config.noAutoCompact && isObservationChunkPending(sessionId, coversUpToId)) {
279
+ debugLog("observer.pending_skip", { coversUpToId, sessionId });
280
+ return "continue";
281
+ }
282
+
283
+ for (let attempt = 0; attempt < MAX_STAGE_ATTEMPTS; attempt++) {
284
+ const resolved = await resolveModel("observer");
285
+ if (!resolved) return "abort";
286
+
287
+ if (ctx.hasUI) ctx.ui?.notify(
288
+ `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${tokens.toLocaleString()} accumulated)`,
289
+ "info",
290
+ );
291
+ debugLog("observer.start", { tokens, coversUpToId, sourceEntryIds, sourceEntryCount: sourceEntryIds.length, priorReflections: priorReflections.length, priorObservations: priorObservations.length });
292
+
293
+ try {
294
+ const result = await runObserver({
295
+ model: resolved.model as any,
296
+ apiKey: resolved.apiKey,
297
+ headers: resolved.headers,
298
+ priorReflections,
299
+ priorObservations,
300
+ chunk,
301
+ allowedSourceEntryIds: sourceEntryIds,
302
+ maxTurns: runtime.config.agentMaxTurns,
303
+ thinkingLevel: stageThinkingLevel(runtime, "observer"),
304
+ });
305
+
306
+ if (result.observations && result.observations.length > 0) {
307
+ const data = buildObservationsRecordedData(result.observations, coversUpToId);
308
+ if (!data) return "continue";
309
+ debugLog("observer.records", { count: result.observations.length, observationTokens: result.observations.reduce((s: number, o: any) => s + o.tokenCount, 0), coversUpToId });
310
+ if (runtime.config.noAutoCompact) {
311
+ savePendingObservation(sessionId, { coversUpToId, data });
312
+ debugLog("observer.pending", { count: result.observations.length, coversUpToId, sessionId });
313
+ } else {
314
+ appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
315
+ debugLog("observer.appended", { count: result.observations.length, coversUpToId });
316
+ }
317
+ if (ctx.hasUI) ctx.ui?.notify(`Observational memory: ${result.observations.length} observation${result.observations.length === 1 ? "" : "s"} recorded`, "info");
318
+ return "continue";
319
+ }
320
+
321
+ // No observations — diagnose the reason for the warning
322
+ const reason = result.emptyReason;
323
+ const reasonLabel = reason
324
+ ? reason.kind === "tool_not_called"
325
+ ? "model did not call the observation tool"
326
+ : reason.kind === "all_rejected"
327
+ ? `${reason.count} observation(s) rejected for invalid sourceEntryIds`
328
+ : reason.kind === "all_duplicates"
329
+ ? `${reason.count} observation(s) were duplicates of already-recorded entries`
330
+ : reason.kind === "empty_array"
331
+ ? "model called the tool but submitted an empty observations array"
332
+ : "nothing new to record"
333
+ : "unknown reason";
334
+ const reasonLevel: "info" | "warning" = reason
335
+ ? reason.kind === "no_new_content" || reason.kind === "all_duplicates"
336
+ ? "info"
337
+ : "warning"
338
+ : "warning";
339
+ debugLog("observer.empty", { coversUpToId, reason: reason?.kind });
340
+ if (ctx.hasUI) ctx.ui?.notify(`Observational memory: no observations — ${reasonLabel}`, reasonLevel);
341
+ return "continue";
342
+ } catch (error) {
343
+ // Always try next fallback — don't abort pipeline for a single model failure.
344
+ // Record cooldown so resolveModel skips this model in the next iteration.
345
+ const candidateConfig = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "observer"), stageFallbacks: stageFallbackModels(runtime, "observer") });
346
+ runtime.recordRetryableError(candidateConfig, error, "observer");
347
+ debugLog("observer.error", { error: String(error), retryable: isRetryableError(error) });
348
+ // Continue loop — resolveModel will skip the cooled-down model
349
+ continue;
350
+ }
351
+ }
352
+
353
+ // All attempts exhausted
354
+ runtime.recordConsolidationStageError(ctx, "observer", new Error("Observer: all model candidates exhausted"));
355
+ return "abort";
356
+ }
357
+
358
+ // ── Reflector stage (with fallback) ─────────────────────────────────────────
359
+
360
+ async function runReflectorStage(
361
+ pi: ExtensionAPI,
362
+ runtime: Runtime,
363
+ ctx: ConsolidationCtx,
364
+ resolveModel: (stage: "reflector") => Promise<ResolvedModel | undefined>,
365
+ ): Promise<ReflectorStageResult> {
366
+ const sessionId = ctx.sessionManager.getSessionId();
367
+ const entries = ctx.sessionManager.getBranch() as Entry[];
368
+ const reflectionTokens = rawTokensSinceReflectionCoverage(entries);
369
+ if (reflectionTokens < runtime.config.reflectAfterTokens) return { outcome: "continue", sameRunReflections: [] };
370
+
371
+ const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
372
+ if (!observationCoverageId) return { outcome: "continue", sameRunReflections: [] };
373
+
374
+ for (let attempt = 0; attempt < MAX_STAGE_ATTEMPTS; attempt++) {
375
+ const resolved = await resolveModel("reflector");
376
+ if (!resolved) return { outcome: "abort", sameRunReflections: [] };
377
+
378
+ // Compute ahead for an accurate notification
379
+ const folded = foldLedger(entries);
380
+ const lastReflectionIdx = latestCoverageIndex(entries, OM_REFLECTIONS_RECORDED);
381
+ const newObservations = observationsCreatedAfterIndex(entries, lastReflectionIdx);
382
+ const newReflections = reflectionsCreatedAfterIndex(entries, lastReflectionIdx);
383
+ const newItemsTokens = Math.ceil(
384
+ (newObservations.reduce((s, o) => s + o.content.length, 0) +
385
+ newReflections.reduce((s, r) => s + r.content.length, 0)) / 4
386
+ );
387
+ const summaryBudget = Math.floor(runtime.config.reflectorInputMaxTokens * 0.15) * 2;
388
+ const reflectorInputTokens = Math.min(newItemsTokens + summaryBudget, runtime.config.reflectorInputMaxTokens);
389
+ if (ctx.hasUI) ctx.ui?.notify(`Observational memory: reflector running (~${reflectionTokens.toLocaleString()} tokens accumulated, ~${reflectorInputTokens.toLocaleString()}-token input)`, "info");
390
+
391
+ try {
392
+ // Existing memory summaries for context (capped)
393
+ const existingReflectionsSummary = buildExistingReflectionsSummary(
394
+ folded.reflections,
395
+ Math.floor(runtime.config.reflectorInputMaxTokens * 0.15),
396
+ );
397
+ const existingObservationsSummary = buildExistingObservationsSummary(
398
+ folded.activeObservations.filter(o => !newObservations.some(no => no.id === o.id)),
399
+ Math.floor(runtime.config.reflectorInputMaxTokens * 0.15),
400
+ );
401
+
402
+ const reflections = await runReflector({
403
+ model: resolved.model as any,
404
+ apiKey: resolved.apiKey,
405
+ headers: resolved.headers,
406
+ reflections: newReflections,
407
+ observations: newObservations,
408
+ existingReflectionsSummary: existingReflectionsSummary || undefined,
409
+ existingObservationsSummary: existingObservationsSummary || undefined,
410
+ maxTurns: runtime.config.agentMaxTurns,
411
+ thinkingLevel: stageThinkingLevel(runtime, "reflector"),
412
+ });
413
+
414
+ if (!reflections || reflections.length === 0) return { outcome: "continue", sameRunReflections: [] };
415
+
416
+ const data = buildReflectionsRecordedData(reflections, observationCoverageId);
417
+ if (!data) return { outcome: "continue", sameRunReflections: [] };
418
+ if (runtime.config.noAutoCompact) {
419
+ savePendingReflection(sessionId, { coversUpToId: data.coversUpToId, data });
420
+ } else {
421
+ appendEntry(pi, OM_REFLECTIONS_RECORDED, data);
422
+ }
423
+ return {
424
+ outcome: "continue",
425
+ sameRunReflections: reflections,
426
+ effectiveReflectionCoverageId: data.coversUpToId,
427
+ };
428
+ } catch (error) {
429
+ const candidateConfig = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "reflector"), stageFallbacks: stageFallbackModels(runtime, "reflector") });
430
+ runtime.recordRetryableError(candidateConfig, error, "reflector");
431
+ debugLog("reflector.error", { error: String(error), retryable: isRetryableError(error) });
432
+ continue;
433
+ }
434
+ }
435
+
436
+ runtime.recordConsolidationStageError(ctx, "reflector", new Error("Reflector: all model candidates exhausted"));
437
+ return { outcome: "abort", sameRunReflections: [] };
438
+ }
439
+
440
+ // ── Dropper stage (with fallback) ───────────────────────────────────────────
441
+
442
+ async function runDropperStage(
443
+ pi: ExtensionAPI,
444
+ runtime: Runtime,
445
+ ctx: ConsolidationCtx,
446
+ resolveModel: (stage: "dropper") => Promise<ResolvedModel | undefined>,
447
+ sameRunReflections: Reflection[],
448
+ sameRunReflectionCoverageId: string | undefined,
449
+ ): Promise<StageOutcome> {
450
+ const sessionId = ctx.sessionManager.getSessionId();
451
+ const entries = ctx.sessionManager.getBranch() as Entry[];
452
+ const dropTokens = rawTokensSinceDropCoverage(entries);
453
+ if (dropTokens < runtime.config.reflectAfterTokens) return "continue";
454
+
455
+ const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
456
+ if (!observationCoverageId) return "continue";
457
+
458
+ for (let attempt = 0; attempt < MAX_STAGE_ATTEMPTS; attempt++) {
459
+ const resolved = await resolveModel("dropper");
460
+ if (!resolved) return "abort";
461
+
462
+ // Compute ahead for an accurate notification
463
+ const folded = foldLedger(entries);
464
+ const lastDropIdx = latestCoverageIndex(entries, OM_OBSERVATIONS_DROPPED);
465
+ const newObservations = observationsCreatedAfterIndex(entries, lastDropIdx);
466
+ const dropperNewObsTokens = Math.ceil(
467
+ newObservations.reduce((s, o) => s + o.content.length, 0) / 4
468
+ );
469
+ const dropperSummaryBudget = Math.floor(runtime.config.dropperInputMaxTokens * 0.2);
470
+ const dropperInputTokens = Math.min(dropperNewObsTokens + dropperSummaryBudget, runtime.config.dropperInputMaxTokens);
471
+ if (ctx.hasUI) ctx.ui?.notify(`Observational memory: dropper running (~${dropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`, "info");
472
+
473
+ try {
474
+ // Existing active observations summary for context (capped)
475
+ const existingObservationsSummary = buildExistingObservationsSummary(
476
+ folded.activeObservations.filter(o => !newObservations.some(no => no.id === o.id)),
477
+ Math.floor(runtime.config.dropperInputMaxTokens * 0.2),
478
+ );
479
+ const reflectionsForDropper = mergeReflections(folded.reflections, sameRunReflections);
480
+
481
+ const droppedIds = await runDropper({
482
+ model: resolved.model as any,
483
+ apiKey: resolved.apiKey,
484
+ headers: resolved.headers,
485
+ reflections: reflectionsForDropper,
486
+ observations: newObservations,
487
+ existingObservationsSummary: existingObservationsSummary || undefined,
488
+ budgetTokens: runtime.config.observationsPoolMaxTokens,
489
+ maxTurns: runtime.config.agentMaxTurns,
490
+ thinkingLevel: stageThinkingLevel(runtime, "dropper"),
491
+ });
492
+ const latestReflectionCoverageId = latestCoverageMarkerId(entries, OM_REFLECTIONS_RECORDED);
493
+ const effectiveReflectionCoverageId = sameRunReflectionCoverageId ?? latestReflectionCoverageId;
494
+ const coversUpToId = earlierCoverageMarkerId(entries, observationCoverageId, effectiveReflectionCoverageId);
495
+ const data = coversUpToId && droppedIds ? buildObservationsDroppedData(droppedIds, coversUpToId) : undefined;
496
+ if (data && coversUpToId) {
497
+ if (runtime.config.noAutoCompact) {
498
+ savePendingDropped(sessionId, { coversUpToId, data });
499
+ } else {
500
+ appendEntry(pi, OM_OBSERVATIONS_DROPPED, data);
501
+ }
502
+ }
503
+ return "continue";
504
+ } catch (error) {
505
+ const candidateConfig = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "dropper"), stageFallbacks: stageFallbackModels(runtime, "dropper") });
506
+ runtime.recordRetryableError(candidateConfig, error, "dropper");
507
+ debugLog("dropper.error", { error: String(error), retryable: isRetryableError(error) });
508
+ continue;
509
+ }
510
+ }
511
+
512
+ runtime.recordConsolidationStageError(ctx, "dropper", new Error("Dropper: all model candidates exhausted"));
513
+ return "abort";
514
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Model cooldown persistence.
3
+ *
4
+ * When a model returns a retryable error (429, 5xx, timeout),
5
+ * we record a cooldown so it won't be retried for the configured duration.
6
+ * Cooldowns are persisted to `~/.pi/agent/pi-blackhole/pi-blackhole-cooldown.json`.
7
+ */
8
+
9
+ /**
10
+ * Cooldown persistence for retryable API errors.
11
+ *
12
+ * Created by pi-vcc-om. Records per-model cooldowns to disk so rate-limited
13
+ * or down models are skipped until their cooldown window expires.
14
+ *
15
+ * Key design:
16
+ * - isCooldownActive reads from disk every call (no in-memory cache needed).
17
+ * - recordCooldown writes to disk synchronously.
18
+ * - Cooldowns survive pi restarts via pi-blackhole/pi-blackhole-cooldown.json.
19
+ */
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
23
+ import type { OmModelConfig } from "../core/unified-config.js";
24
+
25
+ // ── Persistence ─────────────────────────────────────────────────────────────
26
+
27
+ const CONFIG_DIR = "pi-blackhole";
28
+ const COOLDOWN_FILE = "pi-blackhole-cooldown.json";
29
+
30
+ function cooldownPath(): string {
31
+ return join(getAgentDir(), CONFIG_DIR, COOLDOWN_FILE);
32
+ }
33
+
34
+ export interface CooldownEntry {
35
+ until: string; // ISO 8601 timestamp
36
+ reason: string;
37
+ stage: string; // "observer" | "reflector" | "dropper"
38
+ }
39
+
40
+ type CooldownMap = Record<string, CooldownEntry>;
41
+
42
+ /** Provider/id key for cooldown lookup. */
43
+ export function modelKey(model: OmModelConfig): string {
44
+ return `${model.provider}/${model.id}`;
45
+ }
46
+
47
+ // ── Load / save ─────────────────────────────────────────────────────────────
48
+
49
+ function readCooldownMap(): CooldownMap {
50
+ const path = cooldownPath();
51
+ if (!existsSync(path)) return {};
52
+ try {
53
+ return JSON.parse(readFileSync(path, "utf-8"));
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ function writeCooldownMap(map: CooldownMap): void {
60
+ const path = cooldownPath();
61
+ const dir = dirname(path);
62
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
63
+ writeFileSync(path, `${JSON.stringify(map, null, 2)}\n`);
64
+ }
65
+
66
+ // ── API ─────────────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Check whether a model is currently cooled down.
70
+ * Expired entries are cleaned up lazily.
71
+ */
72
+ export function isCooldownActive(model: OmModelConfig, now: Date = new Date()): boolean {
73
+ const map = readCooldownMap();
74
+ const key = modelKey(model);
75
+ const entry = map[key];
76
+ if (!entry) return false;
77
+
78
+ const until = new Date(entry.until);
79
+ if (isNaN(until.getTime())) return false;
80
+
81
+ if (now >= until) {
82
+ // Expired — clean up
83
+ delete map[key];
84
+ writeCooldownMap(map);
85
+ return false;
86
+ }
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Record a cooldown for a model after a retryable error.
92
+ *
93
+ * @param model The model that failed.
94
+ * @param reason Human-readable error reason (e.g. "429 Too Many Requests").
95
+ * @param stage Which pipeline stage failed ("observer" | "reflector" | "dropper").
96
+ */
97
+ export function recordCooldown(model: OmModelConfig, reason: string, stage: string): void {
98
+ const hours = model.cooldownHours ?? 1;
99
+ const until = new Date(Date.now() + hours * 3_600_000).toISOString();
100
+ const map = readCooldownMap();
101
+ map[modelKey(model)] = { until, reason, stage };
102
+ writeCooldownMap(map);
103
+ }
104
+
105
+ /**
106
+ * Expire all cooldowns whose duration has passed.
107
+ * Call on session_start or config reload to clean up.
108
+ */
109
+ export function expireCooldowns(): void {
110
+ const map = readCooldownMap();
111
+ const now = new Date();
112
+ let changed = false;
113
+ for (const [key, entry] of Object.entries(map)) {
114
+ const until = new Date(entry.until);
115
+ if (isNaN(until.getTime()) || now >= until) {
116
+ delete map[key];
117
+ changed = true;
118
+ }
119
+ }
120
+ if (changed) writeCooldownMap(map);
121
+ }
122
+
123
+ /** Regex matching retryable API error messages. */
124
+ 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;
125
+
126
+ /** Check whether an error string or Error indicates a retryable error. */
127
+ export function isRetryableError(error: unknown): boolean {
128
+ const message = error instanceof Error ? error.message : String(error || "");
129
+ return RETRYABLE_ERROR_RE.test(message);
130
+ }