pi-blackhole 0.4.2 → 0.4.3

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 (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -411
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. package/vitest.config.ts +15 -15
package/src/om/runtime.ts CHANGED
@@ -9,406 +9,505 @@
9
9
  * - recordRetryableError persists cooldown on API errors.
10
10
  * - markConsolidationError sets 30s retry gate for failed runs.
11
11
  */
12
- import { type Config, type ConfiguredModel, DEFAULTS, loadConfig } from "./config.js";
12
+ import {
13
+ type Config,
14
+ type ConfiguredModel,
15
+ DEFAULTS,
16
+ loadConfig,
17
+ } from "./config.js";
13
18
  import type { CompactionStats } from "../hooks/before-compact.js";
14
- import { isCooldownActive, getCooldownEntry, recordCooldown, expireCooldowns, modelKey } from "./cooldown.js";
19
+ import {
20
+ isCooldownActive,
21
+ getCooldownEntry,
22
+ recordCooldown,
23
+ expireCooldowns,
24
+ modelKey,
25
+ } from "./cooldown.js";
15
26
  import { readPendingCursors, writePendingCursors } from "./pending.js";
16
27
  import type { PendingOMState } from "./pending.js";
17
28
 
18
29
  export type ResolveResult =
19
- | { ok: true; model: any; apiKey: string; headers?: Record<string, string>; cooldownApplied?: boolean }
20
- | { ok: false; reason: string };
30
+ | {
31
+ ok: true;
32
+ model: any;
33
+ apiKey: string;
34
+ headers?: Record<string, string>;
35
+ cooldownApplied?: boolean;
36
+ }
37
+ | { ok: false; reason: string };
21
38
 
22
39
  type NotifyLevel = "warning" | "info" | "error";
23
40
  type Notify = (message: string, type?: NotifyLevel) => void;
24
41
  export type ConsolidationPhase = "observer" | "reflector" | "dropper";
25
42
 
26
- export type CursorState = "initial" | "recorded" | "empty" | "error" | "skipped" | "not_due";
43
+ export type CursorState =
44
+ "initial" | "recorded" | "empty" | "error" | "skipped" | "not_due";
27
45
 
28
46
  export interface PipelineCursor {
29
- entryId: string;
30
- state: CursorState;
47
+ entryId: string;
48
+ state: CursorState;
31
49
  }
32
50
 
33
51
  export interface PipelineCursors {
34
- observer?: PipelineCursor;
35
- reflector?: PipelineCursor;
36
- dropper?: PipelineCursor;
52
+ observer?: PipelineCursor;
53
+ reflector?: PipelineCursor;
54
+ dropper?: PipelineCursor;
37
55
  }
38
56
 
39
57
  export interface ResolveCtx {
40
- model: unknown;
41
- modelRegistry: any;
42
- hasUI: boolean;
43
- ui?: { notify: Notify };
44
- /** Primary stage model (from config). */
45
- stageModel?: ConfiguredModel;
46
- /** Fallback models for this stage (from config). */
47
- stageFallbacks?: ConfiguredModel[];
58
+ model: unknown;
59
+ modelRegistry: any;
60
+ hasUI: boolean;
61
+ ui?: { notify: Notify };
62
+ /** Primary stage model (from config). */
63
+ stageModel?: ConfiguredModel;
64
+ /** Fallback models for this stage (from config). */
65
+ stageFallbacks?: ConfiguredModel[];
48
66
  }
49
67
 
50
68
  export interface LaunchCtx {
51
- hasUI: boolean;
52
- ui?: { notify: Notify };
69
+ hasUI: boolean;
70
+ ui?: { notify: Notify };
53
71
  }
54
72
 
55
73
  /** Default cooldown interval between failed consolidation runs (ms). */
56
74
  const CONSOLIDATION_RETRY_COOLDOWN_MS = 30_000;
57
75
 
58
76
  export class Runtime {
59
- config: Config = { ...DEFAULTS };
60
- configLoaded = false;
61
- consolidationInFlight = false;
62
- consolidationPromise: Promise<void> | null = null;
63
- consolidationPhase: ConsolidationPhase | undefined;
64
- /**
65
- * Models that failed in the current consolidation stage (in-memory only).
66
- * Used when cooldownHours is 0 — avoids disk writes while still letting
67
- * the retry loop advance past the failed model within this stage.
68
- * Cleared between stages at the pipeline level.
69
- */
70
- failedInCycle: Set<string> = new Set();
71
- compactInFlight = false;
72
- compactHookInFlight = false;
73
- /** AbortController for the pending auto-compaction wait loop, or null if none.
74
- * Set when handleAgentEnd schedules a wait; cleared on abort, success, or terminal bail.
75
- * agent_start handlers read this to abort the pending wait when a new turn starts. */
76
- autoCompactionController: AbortController | null = null;
77
- /** Mid-run (turn_end) compaction is suspended after a failed/cancelled attempt
78
- * at the current pressure level. Cleared when accumulated tokens drop below
79
- * the threshold again (i.e. a compaction ran). Prevents abort/cancel thrash. */
80
- midRunCompactionSuspended = false;
81
- resolveFailureNotified = false;
82
- lastObserverError: string | undefined;
83
- lastReflectorError: string | undefined;
84
- lastDropperError: string | undefined;
85
- /** Epoch ms of the last failed consolidation run (any stage). */
86
- lastConsolidationErrorAt: number | undefined;
87
- /** Stats from the most recent compaction run (session-scoped via handler closure). */
88
- compactionStats: CompactionStats | null = null;
89
- /** Whether the most recent compaction was triggered by /blackhole (vs auto-compact). */
90
- compactWasPiVcc = false;
91
- /** In‑memory pipeline cursors — authoritative copy for gating decisions. */
92
- cursors: PipelineCursors = {};
93
- /** Session ID for which cursors have been loaded/validated. Undefined until first load. */
94
- cursorsLoadedSessionId: string | undefined = undefined;
95
- /** Info-notification gate: only the first info-level notification per turn/phase is emitted. */
96
- hasEmittedInfoThisTurn = false;
97
-
98
- /**
99
- * Emit an info-level notification if none has been emitted this turn/phase yet.
100
- * Returns true if emitted, false if suppressed (already emitted earlier).
101
- */
102
- tryEmitInfo(hasUI: boolean, ui: { notify: Notify } | undefined, message: string): boolean {
103
- if (!hasUI || !ui || typeof ui.notify !== "function") return false;
104
- if (this.hasEmittedInfoThisTurn) return false;
105
- this.hasEmittedInfoThisTurn = true;
106
- try {
107
- ui.notify(message, "info");
108
- } catch {
109
- // Stale extension context — harmless.
110
- }
111
- return true;
112
- }
113
-
114
- /** Reset the info gate — call at agent_start and agent_end to allow one
115
- * notification per phase. */
116
- resetInfoGate(): void {
117
- this.hasEmittedInfoThisTurn = false;
118
- }
119
-
120
- ensureConfig(cwd: string, warn?: (message: string) => void): void {
121
- if (this.configLoaded) return;
122
- this.config = loadConfig(cwd, warn);
123
- this.configLoaded = true;
124
- expireCooldowns();
125
- }
126
-
127
- /**
128
- * Force reload config from disk, discarding cached values.
129
- * Call this after external config changes (e.g., overlay save, manual edit).
130
- */
131
- reloadConfig(cwd: string, warn?: (message: string) => void): void {
132
- this.configLoaded = false;
133
- this.ensureConfig(cwd, warn);
134
- }
135
-
136
- /**
137
- * Build the ordered model candidate list for a stage:
138
- * 1. Primary stage model (observerModel, reflectorModel, dropperModel)
139
- * 2. Stage fallbacks (observerFallbackModels, etc.)
140
- * 3. Base config.model
141
- *
142
- * Session model (ctx.model) is only used as the last resort inside resolveModel.
143
- */
144
- private buildCandidateList(stageModel?: ConfiguredModel, stageFallbacks?: ConfiguredModel[]): ConfiguredModel[] {
145
- const candidates: ConfiguredModel[] = [];
146
- if (stageModel) candidates.push(stageModel);
147
- if (stageFallbacks) candidates.push(...stageFallbacks);
148
- if (this.config.model) candidates.push(this.config.model);
149
- return candidates;
150
- }
151
-
152
- /**
153
- * Resolve a model for a consolidation stage.
154
- *
155
- * Tries the candidate list in order:
156
- * 1. Primary stage model → 2. Stage fallbacks → 3. Base config.model → 4. Session model.
157
- *
158
- * Session model fallback can be disabled via config.sessionFallback: false.
159
- * When disabled, returns { ok: false } instead of using the session model,
160
- * allowing the stage to be skipped entirely when all configured OM models fail.
161
- *
162
- * Skips models that are currently in a cooldown window.
163
- * On retryable error (after the agent runs), the model that failed is cooled down
164
- * and the next candidate is tried. The caller must call `recordRetryableError`
165
- * after the API attempt to mark the failed model.
166
- *
167
- * Returns `ok: true` with the resolved model, or `ok: false` with a reason
168
- * if all candidates (including session model, if enabled) are exhausted or unavailable.
169
- */
170
- async resolveModel(ctx: ResolveCtx): Promise<ResolveResult> {
171
- const candidates = this.buildCandidateList(ctx.stageModel, ctx.stageFallbacks);
172
- const stageName = this.consolidationPhase ?? "unknown";
173
-
174
- // Try configured candidates
175
- for (const candidate of candidates) {
176
- const key = modelKey(candidate);
177
-
178
- // In-memory skip: model failed earlier in this stage with cooldownHours 0
179
- if (this.failedInCycle.has(key)) {
180
- this.tryEmitInfo(ctx.hasUI, ctx.ui,
181
- `Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`);
182
- continue;
183
- }
184
-
185
- if (isCooldownActive(candidate)) {
186
- const entry = getCooldownEntry(candidate);
187
- const reason = entry ? `: ${entry.reason}` : "";
188
- this.tryEmitInfo(ctx.hasUI, ctx.ui,
189
- `Observational memory: ${stageName} skipping ${key} (cooldown${reason} — details in cooldown log)`);
190
- continue;
191
- }
192
-
193
- const configured = ctx.modelRegistry.find(candidate.provider, candidate.id);
194
- if (!configured) {
195
- if (ctx.hasUI && ctx.ui) {
196
- ctx.ui.notify(
197
- `Observational memory: ${stageName} model ${candidate.provider}/${candidate.id} not found`,
198
- "warning",
199
- );
200
- }
201
- continue;
202
- }
203
-
204
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configured);
205
- const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(configured) ?? true;
206
- if (!auth.ok || !hasAuth) {
207
- if (ctx.hasUI && ctx.ui) {
208
- ctx.ui.notify(
209
- `Observational memory: ${stageName} no auth for ${candidate.provider}`,
210
- "warning",
211
- );
212
- }
213
- continue;
214
- }
215
-
216
- return {
217
- ok: true,
218
- model: configured,
219
- apiKey: (auth.apiKey as string) ?? "",
220
- headers: auth.headers as Record<string, string> | undefined,
221
- cooldownApplied: false,
222
- };
223
- }
224
-
225
- // Fall back to session model (if enabled)
226
- if (this.config.sessionFallback !== false) {
227
- const sessionModel = ctx.model;
228
- if (!sessionModel) {
229
- return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, no session model)` };
230
- }
231
-
232
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
233
- const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(sessionModel) ?? true;
234
- if (!auth.ok || !hasAuth) {
235
- const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
236
- return { ok: false, reason: `no auth for session model provider "${provider}"` };
237
- }
238
-
239
- return {
240
- ok: true,
241
- model: sessionModel,
242
- apiKey: (auth.apiKey as string) ?? "",
243
- headers: auth.headers as Record<string, string> | undefined,
244
- cooldownApplied: false,
245
- };
246
- }
247
-
248
- // All configured candidates exhausted and session fallback disabled —
249
- // skip the stage entirely.
250
- this.tryEmitInfo(ctx.hasUI, ctx.ui,
251
- `Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`);
252
- this.resolveFailureNotified = true;
253
-
254
- return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, sessionFallback disabled)` };
255
- }
256
-
257
- /**
258
- * Get the model config for the currently resolved model (used for cooldown recording).
259
- * Returns the candidate config if the model was from the candidate list,
260
- * or undefined if it's the session model.
261
- */
262
- findCandidateConfig(resolvedModel: unknown, ctx: ResolveCtx): ConfiguredModel | undefined {
263
- const candidates = this.buildCandidateList(ctx.stageModel, ctx.stageFallbacks);
264
- const model = resolvedModel as { provider?: string; id?: string };
265
- if (!model.provider || !model.id) return undefined;
266
- return candidates.find((c) => c.provider === model.provider && c.id === model.id)
267
- ?? (this.config.model?.provider === model.provider && this.config.model?.id === model.id ? this.config.model : undefined);
268
- }
269
-
270
- /**
271
- * Record a retryable error for a model. The model must be one of the candidates
272
- * (not the session model). If it's the session model we don't cool it down.
273
- *
274
- * When cooldownHours is explicitly 0, the model is tracked in-memory for the
275
- * current consolidation stage (no disk writes). Otherwise a persisted cooldown
276
- * is recorded.
277
- */
278
- recordRetryableError(modelConfig: ConfiguredModel | undefined, error: unknown, stage: ConsolidationPhase): void {
279
- if (!modelConfig) return;
280
- if (modelConfig.cooldownHours === 0) {
281
- // In-memory only: skip this model for the rest of this stage.
282
- // No disk writes, no persistent cooldown.
283
- this.failedInCycle.add(modelKey(modelConfig));
284
- return;
285
- }
286
- const rawReason = error instanceof Error ? error.message : String(error || "unknown error");
287
- // Strip trailing JSON body from API error messages for display cleanliness.
288
- // To avoid stripping non-JSON braces like "{host}", only strip if the text
289
- // after the brace pair consists solely of whitespace (i.e. JSON is at end).
290
- // The full rawReason is NOT stored in the cooldown log — only this brief form.
291
- const brief = rawReason.replace(/\s*\{[\s\S]*?\}\s*$/, "").trim();
292
- recordCooldown(modelConfig, brief, stage);
293
- }
294
-
295
- /**
296
- * Record that a consolidation stage error occurred.
297
- * Sets the retry-gate timestamp so the next trigger is delayed.
298
- */
299
- markConsolidationError(): void {
300
- this.lastConsolidationErrorAt = Date.now();
301
- }
302
-
303
- /** Check if the consolidation retry gate is active (too soon after last error). */
304
- isConsolidationRetryGated(): boolean {
305
- if (!this.lastConsolidationErrorAt) return false;
306
- return Date.now() - this.lastConsolidationErrorAt < CONSOLIDATION_RETRY_COOLDOWN_MS;
307
- }
308
-
309
- /** Get the current cursor for a pipeline stage. */
310
- getCursor(stage: ConsolidationPhase): PipelineCursor | undefined {
311
- return this.cursors[stage];
312
- }
313
-
314
- /** Advance a stage's cursor to a new entry ID with the given state. */
315
- advanceCursor(stage: ConsolidationPhase, entryId: string, state: CursorState): void {
316
- this.cursors[stage] = { entryId, state };
317
- }
318
-
319
- /** Load cursors from the per‑session pending file into the in‑memory map. */
320
- loadCursorsFromPending(sessionId: string): void {
321
- try {
322
- const stored = readPendingCursors(sessionId);
323
- if (!stored) return;
324
- if (stored.observer?.entryId && stored.observer?.state) {
325
- this.cursors.observer = { entryId: stored.observer.entryId, state: stored.observer.state as CursorState };
326
- }
327
- if (stored.reflector?.entryId && stored.reflector?.state) {
328
- this.cursors.reflector = { entryId: stored.reflector.entryId, state: stored.reflector.state as CursorState };
329
- }
330
- if (stored.dropper?.entryId && stored.dropper?.state) {
331
- this.cursors.dropper = { entryId: stored.dropper.entryId, state: stored.dropper.state as CursorState };
332
- }
333
- } catch {
334
- // Best‑effort: missing or corrupt files are harmless.
335
- }
336
- }
337
-
338
- /** Save in‑memory cursors to the per‑session pending file (synchronous, for tests). */
339
- saveCursorsToPending(sessionId: string): void {
340
- try {
341
- writePendingCursors(sessionId, this.cursors as PendingOMState["cursors"]);
342
- } catch {
343
- // Best‑effort: graceful degradation on read‑only filesystems.
344
- }
345
- }
346
-
347
- /** Schedule an async flush of cursors to the pending file.
348
- * Uses a micro‑task to avoid blocking the pipeline. */
349
- scheduleCursorFlush(sessionId: string): void {
350
- const cursors = { ...this.cursors };
351
- queueMicrotask(() => {
352
- try {
353
- writePendingCursors(sessionId, cursors as PendingOMState["cursors"]);
354
- } catch {
355
- // Best‑effort: graceful degradation.
356
- }
357
- });
358
- }
359
-
360
- launchConsolidationTask(ctx: LaunchCtx, work: () => Promise<void>): Promise<void> {
361
- this.consolidationInFlight = true;
362
- this.consolidationPhase = undefined;
363
- const promise = this.launchTrackedTask(ctx, "consolidation", work, () => {
364
- this.consolidationInFlight = false;
365
- this.consolidationPhase = undefined;
366
- if (this.consolidationPromise === promise) this.consolidationPromise = null;
367
- });
368
- this.consolidationPromise = promise;
369
- return promise;
370
- }
371
-
372
- recordConsolidationStageError(ctx: LaunchCtx, phase: ConsolidationPhase, error: unknown): string {
373
- const message = error instanceof Error ? error.message : String(error);
374
- if (phase === "observer") this.lastObserverError = message;
375
- if (phase === "reflector") this.lastReflectorError = message;
376
- if (phase === "dropper") this.lastDropperError = message;
377
- if (ctx.hasUI && ctx.ui) {
378
- try {
379
- ctx.ui.notify(`Observational memory: ${phase} failed: ${message}`, "warning");
380
- } catch {
381
- // Stale extension context — harmless.
382
- }
383
- }
384
- this.markConsolidationError();
385
- return message;
386
- }
387
-
388
- private launchTrackedTask(
389
- ctx: LaunchCtx,
390
- label: string,
391
- work: () => Promise<void>,
392
- onFinally: (error: string | undefined) => void,
393
- ): Promise<void> {
394
- const hasUI = ctx.hasUI;
395
- const ui = ctx.ui;
396
- return (async () => {
397
- let errorMessage: string | undefined;
398
- try {
399
- await work();
400
- } catch (error) {
401
- errorMessage = error instanceof Error ? error.message : String(error);
402
- if (hasUI && ui) {
403
- try {
404
- ui.notify(`Observational memory: ${label} failed: ${errorMessage}`, "warning");
405
- } catch {
406
- // Stale extension context — harmless.
407
- }
408
- }
409
- } finally {
410
- onFinally(errorMessage);
411
- }
412
- })();
413
- }
77
+ config: Config = { ...DEFAULTS };
78
+ configLoaded = false;
79
+ consolidationInFlight = false;
80
+ consolidationPromise: Promise<void> | null = null;
81
+ consolidationPhase: ConsolidationPhase | undefined;
82
+ /**
83
+ * Models that failed in the current consolidation stage (in-memory only).
84
+ * Used when cooldownHours is 0 — avoids disk writes while still letting
85
+ * the retry loop advance past the failed model within this stage.
86
+ * Cleared between stages at the pipeline level.
87
+ */
88
+ failedInCycle: Set<string> = new Set();
89
+ compactInFlight = false;
90
+ compactHookInFlight = false;
91
+ /** AbortController for the pending auto-compaction wait loop, or null if none.
92
+ * Set when handleAgentEnd schedules a wait; cleared on abort, success, or terminal bail.
93
+ * agent_start handlers read this to abort the pending wait when a new turn starts. */
94
+ autoCompactionController: AbortController | null = null;
95
+ /** Mid-run (turn_end) compaction is suspended after a failed/cancelled attempt
96
+ * at the current pressure level. Cleared when accumulated tokens drop below
97
+ * the threshold again (i.e. a compaction ran). Prevents abort/cancel thrash. */
98
+ midRunCompactionSuspended = false;
99
+ resolveFailureNotified = false;
100
+ lastObserverError: string | undefined;
101
+ lastReflectorError: string | undefined;
102
+ lastDropperError: string | undefined;
103
+ /** Epoch ms of the last failed consolidation run (any stage). */
104
+ lastConsolidationErrorAt: number | undefined;
105
+ /** Stats from the most recent compaction run (session-scoped via handler closure). */
106
+ compactionStats: CompactionStats | null = null;
107
+ /** Whether the most recent compaction was triggered by /blackhole (vs auto-compact). */
108
+ compactWasPiVcc = false;
109
+ /** In‑memory pipeline cursors — authoritative copy for gating decisions. */
110
+ cursors: PipelineCursors = {};
111
+ /** Session ID for which cursors have been loaded/validated. Undefined until first load. */
112
+ cursorsLoadedSessionId: string | undefined = undefined;
113
+ /** Info-notification gate: only the first info-level notification per turn/phase is emitted. */
114
+ hasEmittedInfoThisTurn = false;
115
+
116
+ /**
117
+ * Emit an info-level notification if none has been emitted this turn/phase yet.
118
+ * Returns true if emitted, false if suppressed (already emitted earlier).
119
+ */
120
+ tryEmitInfo(
121
+ hasUI: boolean,
122
+ ui: { notify: Notify } | undefined,
123
+ message: string,
124
+ ): boolean {
125
+ if (!hasUI || !ui || typeof ui.notify !== "function") return false;
126
+ if (this.hasEmittedInfoThisTurn) return false;
127
+ this.hasEmittedInfoThisTurn = true;
128
+ try {
129
+ ui.notify(message, "info");
130
+ } catch {
131
+ // Stale extension context — harmless.
132
+ }
133
+ return true;
134
+ }
135
+
136
+ /** Reset the info gate — call at agent_start and agent_end to allow one
137
+ * notification per phase. */
138
+ resetInfoGate(): void {
139
+ this.hasEmittedInfoThisTurn = false;
140
+ }
141
+
142
+ ensureConfig(cwd: string, warn?: (message: string) => void): void {
143
+ if (this.configLoaded) return;
144
+ this.config = loadConfig(cwd, warn);
145
+ this.configLoaded = true;
146
+ expireCooldowns();
147
+ }
148
+
149
+ /**
150
+ * Force reload config from disk, discarding cached values.
151
+ * Call this after external config changes (e.g., overlay save, manual edit).
152
+ */
153
+ reloadConfig(cwd: string, warn?: (message: string) => void): void {
154
+ this.configLoaded = false;
155
+ this.ensureConfig(cwd, warn);
156
+ }
157
+
158
+ /**
159
+ * Build the ordered model candidate list for a stage:
160
+ * 1. Primary stage model (observerModel, reflectorModel, dropperModel)
161
+ * 2. Stage fallbacks (observerFallbackModels, etc.)
162
+ * 3. Base config.model
163
+ *
164
+ * Session model (ctx.model) is only used as the last resort inside resolveModel.
165
+ */
166
+ private buildCandidateList(
167
+ stageModel?: ConfiguredModel,
168
+ stageFallbacks?: ConfiguredModel[],
169
+ ): ConfiguredModel[] {
170
+ const candidates: ConfiguredModel[] = [];
171
+ if (stageModel) candidates.push(stageModel);
172
+ if (stageFallbacks) candidates.push(...stageFallbacks);
173
+ if (this.config.model) candidates.push(this.config.model);
174
+ return candidates;
175
+ }
176
+
177
+ /**
178
+ * Resolve a model for a consolidation stage.
179
+ *
180
+ * Tries the candidate list in order:
181
+ * 1. Primary stage model 2. Stage fallbacks → 3. Base config.model 4. Session model.
182
+ *
183
+ * Session model fallback can be disabled via config.sessionFallback: false.
184
+ * When disabled, returns { ok: false } instead of using the session model,
185
+ * allowing the stage to be skipped entirely when all configured OM models fail.
186
+ *
187
+ * Skips models that are currently in a cooldown window.
188
+ * On retryable error (after the agent runs), the model that failed is cooled down
189
+ * and the next candidate is tried. The caller must call `recordRetryableError`
190
+ * after the API attempt to mark the failed model.
191
+ *
192
+ * Returns `ok: true` with the resolved model, or `ok: false` with a reason
193
+ * if all candidates (including session model, if enabled) are exhausted or unavailable.
194
+ */
195
+ async resolveModel(ctx: ResolveCtx): Promise<ResolveResult> {
196
+ const candidates = this.buildCandidateList(
197
+ ctx.stageModel,
198
+ ctx.stageFallbacks,
199
+ );
200
+ const stageName = this.consolidationPhase ?? "unknown";
201
+
202
+ // Try configured candidates
203
+ for (const candidate of candidates) {
204
+ const key = modelKey(candidate);
205
+
206
+ // In-memory skip: model failed earlier in this stage with cooldownHours 0
207
+ if (this.failedInCycle.has(key)) {
208
+ this.tryEmitInfo(
209
+ ctx.hasUI,
210
+ ctx.ui,
211
+ `Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`,
212
+ );
213
+ continue;
214
+ }
215
+
216
+ if (isCooldownActive(candidate)) {
217
+ const entry = getCooldownEntry(candidate);
218
+ const reason = entry ? `: ${entry.reason}` : "";
219
+ this.tryEmitInfo(
220
+ ctx.hasUI,
221
+ ctx.ui,
222
+ `Observational memory: ${stageName} skipping ${key} (cooldown${reason} — details in cooldown log)`,
223
+ );
224
+ continue;
225
+ }
226
+
227
+ const configured = ctx.modelRegistry.find(
228
+ candidate.provider,
229
+ candidate.id,
230
+ );
231
+ if (!configured) {
232
+ if (ctx.hasUI && ctx.ui) {
233
+ ctx.ui.notify(
234
+ `Observational memory: ${stageName} model ${candidate.provider}/${candidate.id} not found`,
235
+ "warning",
236
+ );
237
+ }
238
+ continue;
239
+ }
240
+
241
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configured);
242
+ const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(configured) ?? true;
243
+ if (!auth.ok || !hasAuth) {
244
+ if (ctx.hasUI && ctx.ui) {
245
+ ctx.ui.notify(
246
+ `Observational memory: ${stageName} no auth for ${candidate.provider}`,
247
+ "warning",
248
+ );
249
+ }
250
+ continue;
251
+ }
252
+
253
+ return {
254
+ ok: true,
255
+ model: configured,
256
+ apiKey: (auth.apiKey as string) ?? "",
257
+ headers: auth.headers as Record<string, string> | undefined,
258
+ cooldownApplied: false,
259
+ };
260
+ }
261
+
262
+ // Fall back to session model (if enabled)
263
+ if (this.config.sessionFallback !== false) {
264
+ const sessionModel = ctx.model;
265
+ if (!sessionModel) {
266
+ return {
267
+ ok: false,
268
+ reason: `no model available for ${stageName} (all candidates exhausted, no session model)`,
269
+ };
270
+ }
271
+
272
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
273
+ const hasAuth =
274
+ ctx.modelRegistry.hasConfiguredAuth?.(sessionModel) ?? true;
275
+ if (!auth.ok || !hasAuth) {
276
+ const provider =
277
+ (sessionModel as { provider?: string }).provider ?? "unknown";
278
+ return {
279
+ ok: false,
280
+ reason: `no auth for session model provider "${provider}"`,
281
+ };
282
+ }
283
+
284
+ return {
285
+ ok: true,
286
+ model: sessionModel,
287
+ apiKey: (auth.apiKey as string) ?? "",
288
+ headers: auth.headers as Record<string, string> | undefined,
289
+ cooldownApplied: false,
290
+ };
291
+ }
292
+
293
+ // All configured candidates exhausted and session fallback disabled
294
+ // skip the stage entirely.
295
+ this.tryEmitInfo(
296
+ ctx.hasUI,
297
+ ctx.ui,
298
+ `Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`,
299
+ );
300
+ this.resolveFailureNotified = true;
301
+
302
+ return {
303
+ ok: false,
304
+ reason: `no model available for ${stageName} (all candidates exhausted, sessionFallback disabled)`,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Get the model config for the currently resolved model (used for cooldown recording).
310
+ * Returns the candidate config if the model was from the candidate list,
311
+ * or undefined if it's the session model.
312
+ */
313
+ findCandidateConfig(
314
+ resolvedModel: unknown,
315
+ ctx: ResolveCtx,
316
+ ): ConfiguredModel | undefined {
317
+ const candidates = this.buildCandidateList(
318
+ ctx.stageModel,
319
+ ctx.stageFallbacks,
320
+ );
321
+ const model = resolvedModel as { provider?: string; id?: string };
322
+ if (!model.provider || !model.id) return undefined;
323
+ return (
324
+ candidates.find(
325
+ (c) => c.provider === model.provider && c.id === model.id,
326
+ ) ??
327
+ (this.config.model?.provider === model.provider &&
328
+ this.config.model?.id === model.id
329
+ ? this.config.model
330
+ : undefined)
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Record a retryable error for a model. The model must be one of the candidates
336
+ * (not the session model). If it's the session model we don't cool it down.
337
+ *
338
+ * When cooldownHours is explicitly 0, the model is tracked in-memory for the
339
+ * current consolidation stage (no disk writes). Otherwise a persisted cooldown
340
+ * is recorded.
341
+ */
342
+ recordRetryableError(
343
+ modelConfig: ConfiguredModel | undefined,
344
+ error: unknown,
345
+ stage: ConsolidationPhase,
346
+ ): void {
347
+ if (!modelConfig) return;
348
+ if (modelConfig.cooldownHours === 0) {
349
+ // In-memory only: skip this model for the rest of this stage.
350
+ // No disk writes, no persistent cooldown.
351
+ this.failedInCycle.add(modelKey(modelConfig));
352
+ return;
353
+ }
354
+ const rawReason =
355
+ error instanceof Error ? error.message : String(error || "unknown error");
356
+ // Strip trailing JSON body from API error messages for display cleanliness.
357
+ // To avoid stripping non-JSON braces like "{host}", only strip if the text
358
+ // after the brace pair consists solely of whitespace (i.e. JSON is at end).
359
+ // The full rawReason is NOT stored in the cooldown log — only this brief form.
360
+ const brief = rawReason.replace(/\s*\{[\s\S]*?\}\s*$/, "").trim();
361
+ recordCooldown(modelConfig, brief, stage);
362
+ }
363
+
364
+ /**
365
+ * Record that a consolidation stage error occurred.
366
+ * Sets the retry-gate timestamp so the next trigger is delayed.
367
+ */
368
+ markConsolidationError(): void {
369
+ this.lastConsolidationErrorAt = Date.now();
370
+ }
371
+
372
+ /** Check if the consolidation retry gate is active (too soon after last error). */
373
+ isConsolidationRetryGated(): boolean {
374
+ if (!this.lastConsolidationErrorAt) return false;
375
+ return (
376
+ Date.now() - this.lastConsolidationErrorAt <
377
+ CONSOLIDATION_RETRY_COOLDOWN_MS
378
+ );
379
+ }
380
+
381
+ /** Get the current cursor for a pipeline stage. */
382
+ getCursor(stage: ConsolidationPhase): PipelineCursor | undefined {
383
+ return this.cursors[stage];
384
+ }
385
+
386
+ /** Advance a stage's cursor to a new entry ID with the given state. */
387
+ advanceCursor(
388
+ stage: ConsolidationPhase,
389
+ entryId: string,
390
+ state: CursorState,
391
+ ): void {
392
+ this.cursors[stage] = { entryId, state };
393
+ }
394
+
395
+ /** Load cursors from the per‑session pending file into the in‑memory map. */
396
+ loadCursorsFromPending(sessionId: string): void {
397
+ try {
398
+ const stored = readPendingCursors(sessionId);
399
+ if (!stored) return;
400
+ if (stored.observer?.entryId && stored.observer?.state) {
401
+ this.cursors.observer = {
402
+ entryId: stored.observer.entryId,
403
+ state: stored.observer.state as CursorState,
404
+ };
405
+ }
406
+ if (stored.reflector?.entryId && stored.reflector?.state) {
407
+ this.cursors.reflector = {
408
+ entryId: stored.reflector.entryId,
409
+ state: stored.reflector.state as CursorState,
410
+ };
411
+ }
412
+ if (stored.dropper?.entryId && stored.dropper?.state) {
413
+ this.cursors.dropper = {
414
+ entryId: stored.dropper.entryId,
415
+ state: stored.dropper.state as CursorState,
416
+ };
417
+ }
418
+ } catch {
419
+ // Best‑effort: missing or corrupt files are harmless.
420
+ }
421
+ }
422
+
423
+ /** Save in‑memory cursors to the per‑session pending file (synchronous, for tests). */
424
+ saveCursorsToPending(sessionId: string): void {
425
+ try {
426
+ writePendingCursors(sessionId, this.cursors as PendingOMState["cursors"]);
427
+ } catch {
428
+ // Best‑effort: graceful degradation on read‑only filesystems.
429
+ }
430
+ }
431
+
432
+ /** Schedule an async flush of cursors to the pending file.
433
+ * Uses a micro‑task to avoid blocking the pipeline. */
434
+ scheduleCursorFlush(sessionId: string): void {
435
+ const cursors = { ...this.cursors };
436
+ queueMicrotask(() => {
437
+ try {
438
+ writePendingCursors(sessionId, cursors as PendingOMState["cursors"]);
439
+ } catch {
440
+ // Best‑effort: graceful degradation.
441
+ }
442
+ });
443
+ }
444
+
445
+ launchConsolidationTask(
446
+ ctx: LaunchCtx,
447
+ work: () => Promise<void>,
448
+ ): Promise<void> {
449
+ this.consolidationInFlight = true;
450
+ this.consolidationPhase = undefined;
451
+ const promise = this.launchTrackedTask(ctx, "consolidation", work, () => {
452
+ this.consolidationInFlight = false;
453
+ this.consolidationPhase = undefined;
454
+ if (this.consolidationPromise === promise)
455
+ this.consolidationPromise = null;
456
+ });
457
+ this.consolidationPromise = promise;
458
+ return promise;
459
+ }
460
+
461
+ recordConsolidationStageError(
462
+ ctx: LaunchCtx,
463
+ phase: ConsolidationPhase,
464
+ error: unknown,
465
+ ): string {
466
+ const message = error instanceof Error ? error.message : String(error);
467
+ if (phase === "observer") this.lastObserverError = message;
468
+ if (phase === "reflector") this.lastReflectorError = message;
469
+ if (phase === "dropper") this.lastDropperError = message;
470
+ if (ctx.hasUI && ctx.ui) {
471
+ try {
472
+ ctx.ui.notify(
473
+ `Observational memory: ${phase} failed: ${message}`,
474
+ "warning",
475
+ );
476
+ } catch {
477
+ // Stale extension context — harmless.
478
+ }
479
+ }
480
+ this.markConsolidationError();
481
+ return message;
482
+ }
483
+
484
+ private launchTrackedTask(
485
+ ctx: LaunchCtx,
486
+ label: string,
487
+ work: () => Promise<void>,
488
+ onFinally: (error: string | undefined) => void,
489
+ ): Promise<void> {
490
+ const hasUI = ctx.hasUI;
491
+ const ui = ctx.ui;
492
+ return (async () => {
493
+ let errorMessage: string | undefined;
494
+ try {
495
+ await work();
496
+ } catch (error) {
497
+ errorMessage = error instanceof Error ? error.message : String(error);
498
+ if (hasUI && ui) {
499
+ try {
500
+ ui.notify(
501
+ `Observational memory: ${label} failed: ${errorMessage}`,
502
+ "warning",
503
+ );
504
+ } catch {
505
+ // Stale extension context — harmless.
506
+ }
507
+ }
508
+ } finally {
509
+ onFinally(errorMessage);
510
+ }
511
+ })();
512
+ }
414
513
  }