pi-blackhole 0.3.9 → 0.4.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/README.md +7 -1
- package/index.ts +2 -2
- package/package.json +10 -10
- package/src/commands/cleanup.ts +319 -0
- package/src/commands/memory.ts +2 -2
- package/src/commands/pi-vcc.ts +13 -4
- package/src/core/summarize.ts +30 -23
- package/src/core/unified-config.ts +39 -11
- package/src/extract/commits.ts +68 -25
- package/src/extract/goals.ts +13 -2
- package/src/hooks/before-compact.ts +5 -2
- package/src/om/agents/dropper/agent.ts +1 -1
- package/src/om/agents/observer/agent.ts +1 -1
- package/src/om/agents/reflector/agent.ts +1 -1
- package/src/om/cleanup.ts +367 -0
- package/src/om/compaction-trigger.ts +13 -20
- package/src/om/configure-overlay.ts +39 -5
- package/src/om/consolidation.ts +23 -16
- package/src/om/ledger/projection.ts +6 -1
- package/src/om/runtime.ts +58 -31
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { visibleWidth } from "./key-matcher.js";
|
|
10
10
|
import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
|
|
11
11
|
import { DEFAULTS } from "../core/unified-config.js";
|
|
12
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -68,8 +68,12 @@ const FIELDS: FieldDef[] = [
|
|
|
68
68
|
helpText: "Max source entry tokens sent to observer per chunk" },
|
|
69
69
|
{ key: "observerPreambleMaxTokens", label: "Observer preamble max", type: "number", section: "Observational Memory",
|
|
70
70
|
helpText: "Preamble budget in manual compaction mode (0=auto-compute 30% of chunk)" },
|
|
71
|
+
{ key: "dropperPressureThreshold", label: "Dropper pressure threshold", type: "number", section: "Observational Memory",
|
|
72
|
+
helpText: "Fraction of reflectorInputMaxTokens that triggers pressure-driven dropper (0-1, default 0.70)" },
|
|
71
73
|
{ key: "agentMaxTurns", label: "Max turns per agent", type: "number", section: "Observational Memory",
|
|
72
74
|
helpText: "Shared turn cap for background memory agents" },
|
|
75
|
+
{ key: "fullFoldAlways", label: "Preserve OM on first compaction", type: "boolean", section: "Observational Memory",
|
|
76
|
+
helpText: "When true, early reflections/drops survive the first compaction in a fresh session" },
|
|
73
77
|
|
|
74
78
|
// ── Debug ──
|
|
75
79
|
{ key: "debug", label: "Debug snapshots", type: "boolean", section: "Debug",
|
|
@@ -111,6 +115,7 @@ const EMPTY_THEME: ThemeShim = {
|
|
|
111
115
|
export interface OverlayResult {
|
|
112
116
|
saved: boolean;
|
|
113
117
|
path: string;
|
|
118
|
+
error?: string;
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
/**
|
|
@@ -136,9 +141,13 @@ export function createConfigureOverlay(
|
|
|
136
141
|
|
|
137
142
|
// Parse config file
|
|
138
143
|
let raw: Record<string, unknown>;
|
|
144
|
+
let fileError: string | undefined;
|
|
139
145
|
try {
|
|
140
146
|
raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
141
|
-
} catch {
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (existsSync(configPath)) {
|
|
149
|
+
fileError = `Config file has invalid JSON: ${(e as Error).message}. Edit the file directly to fix it.`;
|
|
150
|
+
}
|
|
142
151
|
raw = {};
|
|
143
152
|
}
|
|
144
153
|
|
|
@@ -170,7 +179,16 @@ export function createConfigureOverlay(
|
|
|
170
179
|
break;
|
|
171
180
|
case "number": {
|
|
172
181
|
const num = Number(val);
|
|
173
|
-
|
|
182
|
+
if (val && !isNaN(num)) {
|
|
183
|
+
if (f.def.key === "dropperPressureThreshold") {
|
|
184
|
+
// Clamp to (0, 1] — runtime validates on reload
|
|
185
|
+
updated[f.def.key] = Math.min(1, Math.max(0.01, num));
|
|
186
|
+
} else {
|
|
187
|
+
updated[f.def.key] = num;
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
updated[f.def.key] = raw[f.def.key];
|
|
191
|
+
}
|
|
174
192
|
break;
|
|
175
193
|
}
|
|
176
194
|
case "enum":
|
|
@@ -238,8 +256,12 @@ export function createConfigureOverlay(
|
|
|
238
256
|
|
|
239
257
|
// Ctrl+S → save and close
|
|
240
258
|
if (matchesKey(data, "ctrl+s")) {
|
|
259
|
+
if (fileError) {
|
|
260
|
+
done({ saved: false, path: configPath, error: fileError });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
241
263
|
const saved = save();
|
|
242
|
-
done({ saved, path: configPath });
|
|
264
|
+
done({ saved, path: configPath, error: saved ? undefined : "Failed to write config file" });
|
|
243
265
|
return;
|
|
244
266
|
}
|
|
245
267
|
|
|
@@ -317,6 +339,16 @@ export function createConfigureOverlay(
|
|
|
317
339
|
lines.push(fg("border", `│ ${fg("accent", "Blackhole Configuration")}${" ".repeat(Math.max(0, innerW + 1 - 24))}│`));
|
|
318
340
|
lines.push(fg("border", `├${"─".repeat(w - 2)}┤`));
|
|
319
341
|
|
|
342
|
+
// Error banner when config file has invalid JSON
|
|
343
|
+
if (fileError) {
|
|
344
|
+
lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
|
|
345
|
+
const errorPrefix = fg("error", " ERROR:");
|
|
346
|
+
const errorText = fg("error", fileError);
|
|
347
|
+
const errorLine = `│ ${errorPrefix} ${errorText}${" ".repeat(Math.max(0, innerW - visibleWidth(errorPrefix) - 1 - visibleWidth(errorText)))}│`;
|
|
348
|
+
lines.push(errorLine);
|
|
349
|
+
lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
|
|
350
|
+
}
|
|
351
|
+
|
|
320
352
|
let currentSection = "";
|
|
321
353
|
|
|
322
354
|
for (let i = 0; i < fields.length; i++) {
|
|
@@ -375,7 +407,9 @@ export function createConfigureOverlay(
|
|
|
375
407
|
|
|
376
408
|
// Bottom hints
|
|
377
409
|
lines.push(fg("border", `│${" ".repeat(w - 2)}│`));
|
|
378
|
-
const hintText =
|
|
410
|
+
const hintText = fileError
|
|
411
|
+
? " Ctrl+S blocked ↑↓ navigate Esc cancel (fix JSON first) "
|
|
412
|
+
: " Ctrl+S save \u2191\u2193 navigate Enter toggle Esc cancel ";
|
|
379
413
|
lines.push(fg("border", `│${fg("accent", hintText)}${" ".repeat(Math.max(1, innerW + 2 - visibleWidth(hintText)))}│`));
|
|
380
414
|
lines.push(fg("border", `╰${"─".repeat(w - 2)}╯`));
|
|
381
415
|
|
package/src/om/consolidation.ts
CHANGED
|
@@ -360,10 +360,8 @@ export function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (sta
|
|
|
360
360
|
const fallbackMsg = stageFallbacks.length === 0
|
|
361
361
|
? "no fallbacks configured"
|
|
362
362
|
: "no available fallbacks";
|
|
363
|
-
ctx.ui
|
|
364
|
-
`Observational memory: ${stage} skipped — model unavailable (cooldown set to 0, ${fallbackMsg}, will retry next run)
|
|
365
|
-
"info",
|
|
366
|
-
);
|
|
363
|
+
runtime.tryEmitInfo(true, ctx.ui,
|
|
364
|
+
`Observational memory: ${stage} skipped — model unavailable (cooldown set to 0, ${fallbackMsg}, will retry next run)`);
|
|
367
365
|
} else {
|
|
368
366
|
ctx.ui.notify(`Observational memory: ${stage} skipped — ${resolved.reason}`, "warning");
|
|
369
367
|
}
|
|
@@ -421,7 +419,7 @@ function validateCursors(entries: Entry[], runtime: Runtime): void {
|
|
|
421
419
|
}
|
|
422
420
|
|
|
423
421
|
function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
|
|
424
|
-
runtime.ensureConfig(ctx.cwd);
|
|
422
|
+
runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
|
|
425
423
|
if (runtime.config.memory === false) return;
|
|
426
424
|
|
|
427
425
|
// LEGACY: passive check — only applies when new keys are absent (unmigrated config)
|
|
@@ -617,10 +615,8 @@ async function runObserverStage(
|
|
|
617
615
|
if (idx >= 0) effectiveTokens = rawTokensAfterIndex(entries, idx);
|
|
618
616
|
}
|
|
619
617
|
}
|
|
620
|
-
|
|
621
|
-
`Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${effectiveTokens.toLocaleString()} accumulated)
|
|
622
|
-
"info",
|
|
623
|
-
);
|
|
618
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
619
|
+
`Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${effectiveTokens.toLocaleString()} accumulated)`);
|
|
624
620
|
debugLog("observer.start", { tokens, coversUpToId, sourceEntryIds, sourceEntryCount: sourceEntryIds.length, priorReflections: priorReflections.length, priorObservations: priorObservations.length });
|
|
625
621
|
|
|
626
622
|
// Resolve thinking level for the specific model (fallbacks may have their own thinking config)
|
|
@@ -633,7 +629,8 @@ async function runObserverStage(
|
|
|
633
629
|
if (observerEstimatedInput > effectiveObsCtx) {
|
|
634
630
|
debugLog("observer.context_window_exceeded", { estimatedInput: observerEstimatedInput, effectiveCtx: effectiveObsCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
635
631
|
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveObsCtx} too small for estimated input ${observerEstimatedInput}`), "observer");
|
|
636
|
-
|
|
632
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
633
|
+
`Observational memory: observer skipping ${(resolved.model as any).provider}/${(resolved.model as any).id} (context window ${effectiveObsCtx.toLocaleString()} too small for ~${observerEstimatedInput.toLocaleString()}-token input)`);
|
|
637
634
|
continue;
|
|
638
635
|
}
|
|
639
636
|
|
|
@@ -662,7 +659,8 @@ async function runObserverStage(
|
|
|
662
659
|
debugLog("observer.appended", { count: result.observations.length, coversUpToId });
|
|
663
660
|
}
|
|
664
661
|
runtime.advanceCursor("observer", coversUpToId, "recorded");
|
|
665
|
-
|
|
662
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
663
|
+
`Observational memory: ${result.observations.length} observation${result.observations.length === 1 ? "" : "s"} recorded`);
|
|
666
664
|
return "continue";
|
|
667
665
|
}
|
|
668
666
|
|
|
@@ -686,7 +684,12 @@ async function runObserverStage(
|
|
|
686
684
|
: "warning";
|
|
687
685
|
debugLog("observer.empty", { coversUpToId, reason: reason?.kind });
|
|
688
686
|
runtime.advanceCursor("observer", coversUpToId, "empty");
|
|
689
|
-
if (
|
|
687
|
+
if (reasonLevel === "warning") {
|
|
688
|
+
if (ctx.hasUI) ctx.ui?.notify(`Observational memory: no observations — ${reasonLabel}`, "warning");
|
|
689
|
+
} else {
|
|
690
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
691
|
+
`Observational memory: no observations — ${reasonLabel}`);
|
|
692
|
+
}
|
|
690
693
|
return "continue";
|
|
691
694
|
} catch (error) {
|
|
692
695
|
// Always try next fallback — don't abort pipeline for a single model failure.
|
|
@@ -769,7 +772,8 @@ async function runReflectorStage(
|
|
|
769
772
|
}
|
|
770
773
|
}
|
|
771
774
|
debugLog("reflector.start", { tokens: effectiveReflectionTokens, inputTokens: reflectorInputTokens, newObsCount: newObservations.length, newRefCount: newReflections.length });
|
|
772
|
-
|
|
775
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
776
|
+
`Observational memory: reflector running (~${effectiveReflectionTokens.toLocaleString()} tokens accumulated, ~${reflectorInputTokens.toLocaleString()}-token input)`);
|
|
773
777
|
|
|
774
778
|
// Resolve thinking level for the specific model (fallbacks may have their own thinking config)
|
|
775
779
|
const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "reflector"), stageFallbacks: stageFallbackModels(runtime, "reflector") });
|
|
@@ -781,7 +785,8 @@ async function runReflectorStage(
|
|
|
781
785
|
if (reflectorEstimatedInput > effectiveRefCtx) {
|
|
782
786
|
debugLog("reflector.context_window_exceeded", { estimatedInput: reflectorEstimatedInput, effectiveCtx: effectiveRefCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
783
787
|
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveRefCtx} too small for estimated input ${reflectorEstimatedInput}`), "reflector");
|
|
784
|
-
|
|
788
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
789
|
+
`Observational memory: reflector skipping ${(resolved.model as any).provider}/${(resolved.model as any).id} (context window ${effectiveRefCtx.toLocaleString()} too small for ~${reflectorEstimatedInput.toLocaleString()}-token input)`);
|
|
785
790
|
continue;
|
|
786
791
|
}
|
|
787
792
|
|
|
@@ -917,7 +922,8 @@ async function runDropperStage(
|
|
|
917
922
|
if (idx >= 0) effectiveDropTokens = rawTokensAfterIndex(entries, idx);
|
|
918
923
|
}
|
|
919
924
|
}
|
|
920
|
-
|
|
925
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
926
|
+
`Observational memory: dropper running (~${effectiveDropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`);
|
|
921
927
|
|
|
922
928
|
try {
|
|
923
929
|
// Existing active observations summary for context (capped).
|
|
@@ -948,7 +954,8 @@ async function runDropperStage(
|
|
|
948
954
|
if (dropperEstimatedInput > effectiveDropCtx) {
|
|
949
955
|
debugLog("dropper.context_window_exceeded", { estimatedInput: dropperEstimatedInput, effectiveCtx: effectiveDropCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
|
|
950
956
|
runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveDropCtx} too small for estimated input ${dropperEstimatedInput}`), "dropper");
|
|
951
|
-
|
|
957
|
+
runtime.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
958
|
+
`Observational memory: dropper skipping ${(resolved.model as any).provider}/${(resolved.model as any).id} (context window ${effectiveDropCtx.toLocaleString()} too small for ~${dropperEstimatedInput.toLocaleString()}-token input)`);
|
|
952
959
|
continue;
|
|
953
960
|
}
|
|
954
961
|
|
|
@@ -30,6 +30,7 @@ export type ProjectionDiff = {
|
|
|
30
30
|
|
|
31
31
|
export type CompactionProjectionConfig = {
|
|
32
32
|
observationsPoolMaxTokens: number;
|
|
33
|
+
fullFoldAlways?: boolean;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
export type CompactionProjection = Projection & {
|
|
@@ -197,7 +198,11 @@ export function buildCompactionProjection(
|
|
|
197
198
|
config: CompactionProjectionConfig,
|
|
198
199
|
): CompactionProjection {
|
|
199
200
|
const fullFoldBoundaryId = latestFullFoldBoundaryId(entries);
|
|
200
|
-
const maintenanceBoundary = fullFoldBoundaryId
|
|
201
|
+
const maintenanceBoundary = fullFoldBoundaryId
|
|
202
|
+
? entryBoundary(fullFoldBoundaryId)
|
|
203
|
+
: config.fullFoldAlways
|
|
204
|
+
? entryBoundary(firstKeptEntryId)
|
|
205
|
+
: noneBoundary();
|
|
201
206
|
const normalProjection = foldProjection(entries, {
|
|
202
207
|
observationsBoundary: entryBoundary(firstKeptEntryId),
|
|
203
208
|
reflectionsBoundary: maintenanceBoundary,
|
package/src/om/runtime.ts
CHANGED
|
@@ -87,14 +87,47 @@ export class Runtime {
|
|
|
87
87
|
cursors: PipelineCursors = {};
|
|
88
88
|
/** Session ID for which cursors have been loaded/validated. Undefined until first load. */
|
|
89
89
|
cursorsLoadedSessionId: string | undefined = undefined;
|
|
90
|
+
/** Info-notification gate: only the first info-level notification per turn/phase is emitted. */
|
|
91
|
+
hasEmittedInfoThisTurn = false;
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Emit an info-level notification if none has been emitted this turn/phase yet.
|
|
95
|
+
* Returns true if emitted, false if suppressed (already emitted earlier).
|
|
96
|
+
*/
|
|
97
|
+
tryEmitInfo(hasUI: boolean, ui: { notify: Notify } | undefined, message: string): boolean {
|
|
98
|
+
if (!hasUI || !ui || typeof ui.notify !== "function") return false;
|
|
99
|
+
if (this.hasEmittedInfoThisTurn) return false;
|
|
100
|
+
this.hasEmittedInfoThisTurn = true;
|
|
101
|
+
try {
|
|
102
|
+
ui.notify(message, "info");
|
|
103
|
+
} catch {
|
|
104
|
+
// Stale extension context — harmless.
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Reset the info gate — call at agent_start and agent_end to allow one
|
|
110
|
+
* notification per phase. */
|
|
111
|
+
resetInfoGate(): void {
|
|
112
|
+
this.hasEmittedInfoThisTurn = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ensureConfig(cwd: string, warn?: (message: string) => void): void {
|
|
92
116
|
if (this.configLoaded) return;
|
|
93
|
-
this.config = loadConfig(cwd);
|
|
117
|
+
this.config = loadConfig(cwd, warn);
|
|
94
118
|
this.configLoaded = true;
|
|
95
119
|
expireCooldowns();
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Force reload config from disk, discarding cached values.
|
|
124
|
+
* Call this after external config changes (e.g., overlay save, manual edit).
|
|
125
|
+
*/
|
|
126
|
+
reloadConfig(cwd: string, warn?: (message: string) => void): void {
|
|
127
|
+
this.configLoaded = false;
|
|
128
|
+
this.ensureConfig(cwd, warn);
|
|
129
|
+
}
|
|
130
|
+
|
|
98
131
|
/**
|
|
99
132
|
* Build the ordered model candidate list for a stage:
|
|
100
133
|
* 1. Primary stage model (observerModel, reflectorModel, dropperModel)
|
|
@@ -139,24 +172,16 @@ export class Runtime {
|
|
|
139
172
|
|
|
140
173
|
// In-memory skip: model failed earlier in this stage with cooldownHours 0
|
|
141
174
|
if (this.failedInCycle.has(key)) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
`Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`,
|
|
145
|
-
"info",
|
|
146
|
-
);
|
|
147
|
-
}
|
|
175
|
+
this.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
176
|
+
`Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`);
|
|
148
177
|
continue;
|
|
149
178
|
}
|
|
150
179
|
|
|
151
180
|
if (isCooldownActive(candidate)) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
`Observational memory: ${stageName} skipping ${key} (cooldown${reason})`,
|
|
157
|
-
"info",
|
|
158
|
-
);
|
|
159
|
-
}
|
|
181
|
+
const entry = getCooldownEntry(candidate);
|
|
182
|
+
const reason = entry ? `: ${entry.reason}` : "";
|
|
183
|
+
this.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
184
|
+
`Observational memory: ${stageName} skipping ${key} (cooldown${reason} — details in cooldown log)`);
|
|
160
185
|
continue;
|
|
161
186
|
}
|
|
162
187
|
|
|
@@ -172,7 +197,8 @@ export class Runtime {
|
|
|
172
197
|
}
|
|
173
198
|
|
|
174
199
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configured);
|
|
175
|
-
|
|
200
|
+
const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(configured) ?? true;
|
|
201
|
+
if (!auth.ok || !hasAuth) {
|
|
176
202
|
if (ctx.hasUI && ctx.ui) {
|
|
177
203
|
ctx.ui.notify(
|
|
178
204
|
`Observational memory: ${stageName} no auth for ${candidate.provider}`,
|
|
@@ -185,7 +211,7 @@ export class Runtime {
|
|
|
185
211
|
return {
|
|
186
212
|
ok: true,
|
|
187
213
|
model: configured,
|
|
188
|
-
apiKey: auth.apiKey as string,
|
|
214
|
+
apiKey: (auth.apiKey as string) ?? "",
|
|
189
215
|
headers: auth.headers as Record<string, string> | undefined,
|
|
190
216
|
cooldownApplied: false,
|
|
191
217
|
};
|
|
@@ -199,29 +225,25 @@ export class Runtime {
|
|
|
199
225
|
}
|
|
200
226
|
|
|
201
227
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
|
|
202
|
-
|
|
228
|
+
const hasAuth = ctx.modelRegistry.hasConfiguredAuth?.(sessionModel) ?? true;
|
|
229
|
+
if (!auth.ok || !hasAuth) {
|
|
203
230
|
const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
|
|
204
|
-
return { ok: false, reason: `no
|
|
231
|
+
return { ok: false, reason: `no auth for session model provider "${provider}"` };
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
return {
|
|
208
235
|
ok: true,
|
|
209
236
|
model: sessionModel,
|
|
210
|
-
apiKey: auth.apiKey as string,
|
|
237
|
+
apiKey: (auth.apiKey as string) ?? "",
|
|
211
238
|
headers: auth.headers as Record<string, string> | undefined,
|
|
212
239
|
cooldownApplied: false,
|
|
213
240
|
};
|
|
214
241
|
}
|
|
215
242
|
|
|
216
243
|
// All configured candidates exhausted and session fallback disabled —
|
|
217
|
-
// skip the stage entirely.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
ctx.ui.notify(
|
|
221
|
-
`Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`,
|
|
222
|
-
"info",
|
|
223
|
-
);
|
|
224
|
-
}
|
|
244
|
+
// skip the stage entirely.
|
|
245
|
+
this.tryEmitInfo(ctx.hasUI, ctx.ui,
|
|
246
|
+
`Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`);
|
|
225
247
|
this.resolveFailureNotified = true;
|
|
226
248
|
|
|
227
249
|
return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, sessionFallback disabled)` };
|
|
@@ -256,8 +278,13 @@ export class Runtime {
|
|
|
256
278
|
this.failedInCycle.add(modelKey(modelConfig));
|
|
257
279
|
return;
|
|
258
280
|
}
|
|
259
|
-
const
|
|
260
|
-
|
|
281
|
+
const rawReason = error instanceof Error ? error.message : String(error || "unknown error");
|
|
282
|
+
// Strip trailing JSON body from API error messages for display cleanliness.
|
|
283
|
+
// To avoid stripping non-JSON braces like "{host}", only strip if the text
|
|
284
|
+
// after the brace pair consists solely of whitespace (i.e. JSON is at end).
|
|
285
|
+
// The full rawReason is NOT stored in the cooldown log — only this brief form.
|
|
286
|
+
const brief = rawReason.replace(/\s*\{[\s\S]*?\}\s*$/, "").trim();
|
|
287
|
+
recordCooldown(modelConfig, brief, stage);
|
|
261
288
|
}
|
|
262
289
|
|
|
263
290
|
/**
|