pi-blackhole 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +13 -13
  2. package/example-config.json +19 -27
  3. package/package.json +1 -1
  4. package/src/commands/pi-vcc.ts +3 -9
  5. package/src/core/brief.ts +18 -13
  6. package/src/core/build-sections.ts +1 -2
  7. package/src/core/content.ts +8 -5
  8. package/src/core/drill-down.ts +2 -9
  9. package/src/core/filter-noise.ts +7 -10
  10. package/src/core/format.ts +25 -25
  11. package/src/core/load-messages.ts +71 -2
  12. package/src/core/normalize.ts +10 -4
  13. package/src/core/render-entries.ts +10 -2
  14. package/src/core/search-entries.ts +9 -1
  15. package/src/core/settings.ts +3 -34
  16. package/src/core/summarize.ts +33 -13
  17. package/src/core/tool-args.ts +4 -1
  18. package/src/core/unified-config.ts +27 -25
  19. package/src/extract/commits.ts +3 -2
  20. package/src/extract/files.ts +6 -4
  21. package/src/hooks/before-compact.ts +7 -3
  22. package/src/om/agents/dropper/agent.ts +2 -7
  23. package/src/om/agents/observer/agent.ts +11 -13
  24. package/src/om/agents/reflector/agent.ts +2 -7
  25. package/src/om/compaction-trigger.ts +1 -8
  26. package/src/om/configure-overlay.ts +5 -1
  27. package/src/om/consolidation.ts +35 -13
  28. package/src/om/cooldown.ts +15 -12
  29. package/src/om/debug-log.ts +79 -11
  30. package/src/om/key-matcher.ts +6 -32
  31. package/src/om/ledger/fold.ts +8 -0
  32. package/src/om/ledger/render-summary.ts +16 -15
  33. package/src/om/ledger/types.ts +1 -1
  34. package/src/om/pending.ts +38 -5
  35. package/src/om/provider-stream.ts +17 -0
  36. package/src/om/retryable-error.ts +26 -0
  37. package/src/om/reverse-recall.ts +9 -1
  38. package/src/om/runtime.ts +39 -18
  39. package/src/sections.ts +0 -3
  40. package/src/tools/recall.ts +7 -2
@@ -79,24 +79,32 @@ function writeCooldownMap(map: CooldownMap): void {
79
79
  * When cooldownHours is explicitly 0, cooldown is disabled — always returns false.
80
80
  */
81
81
  export function isCooldownActive(model: OmModelConfig, now: Date = new Date()): boolean {
82
+ return getCooldownEntry(model, now) !== undefined;
83
+ }
84
+
85
+ /**
86
+ * Returns the active cooldown entry for a model, or undefined if not cooled down.
87
+ * Expired entries are cleaned up lazily.
88
+ */
89
+ export function getCooldownEntry(model: OmModelConfig, now: Date = new Date()): CooldownEntry | undefined {
82
90
  // cooldownHours === 0 means cooldown disabled
83
- if (model.cooldownHours === 0) return false;
91
+ if (model.cooldownHours === 0) return undefined;
84
92
 
85
93
  const map = readCooldownMap();
86
94
  const key = modelKey(model);
87
95
  const entry = map[key];
88
- if (!entry) return false;
96
+ if (!entry) return undefined;
89
97
 
90
98
  const until = new Date(entry.until);
91
- if (isNaN(until.getTime())) return false;
99
+ if (isNaN(until.getTime())) return undefined;
92
100
 
93
101
  if (now >= until) {
94
102
  // Expired — clean up
95
103
  delete map[key];
96
104
  writeCooldownMap(map);
97
- return false;
105
+ return undefined;
98
106
  }
99
- return true;
107
+ return entry;
100
108
  }
101
109
 
102
110
  /**
@@ -137,11 +145,6 @@ export function expireCooldowns(): void {
137
145
  if (changed) writeCooldownMap(map);
138
146
  }
139
147
 
140
- /** Regex matching retryable API error messages. */
141
- const RETRYABLE_ERROR_RE = /(?:\b|^)(?:overloaded|provider|rate\s*limit|too\s+many\s+requests|429|500|502|503|504|timeout|timed?\s*out|network\s*error|connection\s*error|service\s*unavailable|server\s*error|internal\s*error|fetch\s*failed|upstream|websocket\s*closed|retry)(?:\b|$)/i;
148
+ import { isRetryableError } from "./retryable-error.js";
142
149
 
143
- /** Check whether an error string or Error indicates a retryable error. */
144
- export function isRetryableError(error: unknown): boolean {
145
- const message = error instanceof Error ? error.message : String(error || "");
146
- return RETRYABLE_ERROR_RE.test(message);
147
- }
150
+ export { isRetryableError };
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * Debug logging — writes JSONL to ~/.pi/agent/pi-blackhole/debug.ndjson.
3
3
  *
4
+ * Uses a memory buffer flushed asynchronously on a timer to avoid blocking
5
+ * the event loop with synchronous disk I/O on every event.
6
+ *
4
7
  * Upstream: https://github.com/elpapi42/pi-observational-memory (src/debug-log.ts)
5
- * Modified: path changed from observational-memory/ to pi-blackhole/.
8
+ * Modified: path changed from observational-memory/ to pi-blackhole/; async buffered.
6
9
  */
7
10
  import { AsyncLocalStorage } from "node:async_hooks";
8
11
  import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, appendFileSync } from "node:fs";
12
+ import { appendFile } from "node:fs/promises";
9
13
  import { dirname, join } from "node:path";
10
14
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
15
 
@@ -25,25 +29,89 @@ export function withDebugLogContext<T>(context: DebugLogContext, fn: () => T): T
25
29
  return storage.run({ ...parent, ...context }, fn);
26
30
  }
27
31
 
32
+ // ── Async buffer ────────────────────────────────────────────────────────────
33
+
34
+ const BUFFER_FLUSH_MS = 1_000;
35
+ const FLUSH_IDLE_MS = 10_000;
36
+ let buffer: string[] = [];
37
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
38
+ let flushing = false;
39
+ let lastWriteMs = 0;
40
+
41
+ function ensureFlushTimer(): void {
42
+ if (flushTimer) return;
43
+ flushTimer = setInterval(() => {
44
+ // Stop the timer if buffer has been empty for a while
45
+ if (buffer.length === 0 && lastWriteMs > 0 && Date.now() - lastWriteMs > FLUSH_IDLE_MS) {
46
+ clearInterval(flushTimer!);
47
+ flushTimer = null;
48
+ return;
49
+ }
50
+ flushBuffer().catch(() => {});
51
+ }, BUFFER_FLUSH_MS);
52
+ // Don't prevent process exit
53
+ if (flushTimer && typeof flushTimer === "object" && "unref" in flushTimer) {
54
+ flushTimer.unref();
55
+ }
56
+ }
57
+
58
+ async function flushBuffer(): Promise<void> {
59
+ if (flushing) return;
60
+ if (buffer.length === 0) return;
61
+ flushing = true;
62
+ // Drain the buffer atomically so flushDebugLog doesn't split entries
63
+ const batch = buffer;
64
+ buffer = [];
65
+ try {
66
+ const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ rotateIfNeeded(path);
69
+ await appendFile(path, batch.join(""), "utf-8");
70
+ } catch (error) {
71
+ console.error("blackhole: debug log write failed", error);
72
+ } finally {
73
+ flushing = false;
74
+ }
75
+ }
76
+
77
+ // Flush remaining buffer on exit — synchronous to work with process.exit() too
78
+ process.on("exit", () => {
79
+ flushDebugLog();
80
+ });
81
+
28
82
  export function debugLog(event: string, data: Record<string, unknown> = {}, forceEnabled?: boolean): void {
29
83
  const context = storage.getStore();
30
84
  const enabled = forceEnabled ?? context?.enabled ?? false;
31
85
  if (enabled !== true) return;
32
86
 
87
+ const payload = {
88
+ ts: new Date().toISOString(),
89
+ event,
90
+ cwd: context?.cwd,
91
+ runId: context?.runId,
92
+ data,
93
+ };
94
+ buffer.push(JSON.stringify(payload) + "\n");
95
+ lastWriteMs = Date.now();
96
+ ensureFlushTimer();
97
+ }
98
+
99
+ /**
100
+ * Synchronously flush the buffer to disk. Used by tests to verify written content.
101
+ * Skips if an async flush is in progress to avoid splitting the buffer.
102
+ * In production, the background timer handles flushing automatically.
103
+ */
104
+ export function flushDebugLog(): void {
105
+ if (flushing || buffer.length === 0) return;
106
+ const batch = buffer;
107
+ buffer = [];
33
108
  try {
34
109
  const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
35
110
  mkdirSync(dirname(path), { recursive: true });
36
111
  rotateIfNeeded(path);
37
- const payload = {
38
- ts: new Date().toISOString(),
39
- event,
40
- cwd: context?.cwd,
41
- runId: context?.runId,
42
- data,
43
- };
44
- appendFileSync(path, `${JSON.stringify(payload)}\n`, "utf-8");
45
- } catch {
46
- // Debug logging must never affect memory behavior.
112
+ appendFileSync(path, batch.join(""), "utf-8");
113
+ } catch (error) {
114
+ console.error("blackhole: debug log flush failed", error);
47
115
  }
48
116
  }
49
117
 
@@ -1,38 +1,12 @@
1
1
  /**
2
2
  * Terminal utilities shared by blackhole overlay components.
3
3
  *
4
- * visibleWidth CJK-aware visible width for terminal columns,
5
- * extracted from pi-tui to avoid import resolution issues.
4
+ * Re-exports visibleWidth from pi-tui.
6
5
  *
7
- * Ported from voice-type extension's key-matcher.ts.
6
+ * Previously had a local CJK-width implementation extracted from pi-tui
7
+ * to avoid import resolution issues in the overlay TUI context.
8
+ * If this re-export causes runtime resolution failures, restore the
9
+ * local copy (see git history for the original implementation).
8
10
  */
9
11
 
10
- // ---------------------------------------------------------------------------
11
- // Visible width (CJK-aware)
12
- // ---------------------------------------------------------------------------
13
-
14
- function stripAnsi(s: string): string {
15
- return s.replace(/\x1b\[[0-9;]*m/g, "");
16
- }
17
-
18
- /**
19
- * Calculate the visible width of a string in terminal columns.
20
- * Strips ANSI codes and counts CJK characters as width 2.
21
- */
22
- export function visibleWidth(s: string): number {
23
- const stripped = stripAnsi(s);
24
- let w = 0;
25
- for (const ch of stripped) {
26
- const code = ch.codePointAt(0)!;
27
- if (code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
28
- (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) ||
29
- (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
30
- (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) ||
31
- (code >= 0x1b000 && code <= 0x1b0ff) || (code >= 0x20000 && code <= 0x2fa1f))) {
32
- w += 2;
33
- } else {
34
- w += 1;
35
- }
36
- }
37
- return w;
38
- }
12
+ export { visibleWidth } from "@earendil-works/pi-tui";
@@ -15,6 +15,7 @@ import {
15
15
  type Observation,
16
16
  type Reflection,
17
17
  } from "./types.js";
18
+ import { debugLog } from "../debug-log.js";
18
19
 
19
20
  export type FoldLedgerOptions = {
20
21
  /** Fold entries from branch root through this entry id, inclusive. Omit to fold through branch tip. */
@@ -88,6 +89,13 @@ export function foldLedger(entries: Entry[], options: FoldLedgerOptions = {}): F
88
89
  for (const observationId of entry.data.observationIds) {
89
90
  droppedObservationIds.add(observationId);
90
91
  }
92
+ continue;
93
+ }
94
+
95
+ // Log unknown custom entry types — they are silently skipped, but
96
+ // should be visible when debugging extension compatibility issues.
97
+ if (entry.type === "custom" && entry.customType) {
98
+ debugLog("fold.unknown_custom_type", { customType: entry.customType, entryId: entry.id });
91
99
  }
92
100
  }
93
101
 
@@ -6,20 +6,16 @@
6
6
  */
7
7
  import type { Observation, Reflection } from "./types.js";
8
8
 
9
- const RECALL_NOTE =
10
- "Use `recall` to search for prior work, decisions, and context from before this summary. " +
11
- "Do not redo work already completed.";
9
+ const OM_INSTRUCTIONS_FULL = `Bracketed ids in reflections and observations connect to their source session entries. These are condensed memories from earlier in this session.
10
+ When entries conflict, the most recent observation reflects the latest known state.
11
+ Use \`recall\` with an id to retrieve original context, or \`#N:path\` drill-down to explore file content from referenced entries.
12
+ When exact source context is needed for precision or traceability, use the \`recall\` tool with the relevant observation or reflection id. This is especially useful when a reflection materially affects a decision or is too compressed to continue confidently.`;
12
13
 
13
- const CONTEXT_USAGE_INSTRUCTIONS = `These are condensed memories from earlier in this session.
14
+ const OM_INSTRUCTIONS_BASIC = `Use \`recall\` with an id to retrieve original context, or \`#N:path\` drill-down to explore file content from referenced entries.
15
+ When entries conflict, the most recent entry reflects the latest known state.`;
14
16
 
15
- - Reflections: stable, long-lived facts about the user, project, decisions, and constraints. New reflection lines may include ids in brackets.
16
- - Observations: timestamped events from the conversation history, in chronological order. Observation lines include ids in brackets.
17
-
18
- Treat these as past records. When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
19
-
20
- When exact source context is needed for precision or traceability, use the \`recall\` tool with the relevant observation or reflection id. This is especially useful when a reflection materially affects a decision or is too compressed to continue confidently. Do not use \`recall\` as broad search or inject raw source unless it is needed.`;
21
-
22
- export const OM_FOOTER = `----\n${RECALL_NOTE}\n\n${CONTEXT_USAGE_INSTRUCTIONS}\n----`;
17
+ export const OM_FOOTER_FULL = `----\n${OM_INSTRUCTIONS_FULL}\n----`;
18
+ export const OM_FOOTER_BASIC = `----\n${OM_INSTRUCTIONS_BASIC}\n----`;
23
19
 
24
20
  export function observationToSummaryLine(observation: Observation): string {
25
21
  return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] ${observation.content}`;
@@ -81,7 +77,7 @@ export function reflectionToSummaryLine(reflection: Reflection): string {
81
77
  }
82
78
 
83
79
  export function renderSummary(reflections: Reflection[], observations: Observation[]): string {
84
- if (reflections.length === 0 && observations.length === 0) return "";
80
+ const hasContent = reflections.length > 0 || observations.length > 0;
85
81
 
86
82
  const parts: string[] = [];
87
83
  if (reflections.length > 0) {
@@ -90,6 +86,11 @@ export function renderSummary(reflections: Reflection[], observations: Observati
90
86
  if (observations.length > 0) {
91
87
  parts.push(`## Observations\n${observations.map(observationToSummaryLine).join("\n")}`);
92
88
  }
93
- parts.push(OM_FOOTER);
94
- return parts.join("\n\n");
89
+
90
+ const footer = hasContent ? OM_FOOTER_FULL : OM_FOOTER_BASIC;
91
+ if (parts.length > 0) {
92
+ parts.push(footer);
93
+ return parts.join("\n\n");
94
+ }
95
+ return footer;
95
96
  }
@@ -12,7 +12,7 @@ export const OM_FOLDED = "om.folded";
12
12
  export const RELEVANCE_VALUES = ["low", "medium", "high", "critical"] as const;
13
13
  export type Relevance = (typeof RELEVANCE_VALUES)[number];
14
14
 
15
- export const MEMORY_ID_PATTERN = /^[a-f0-9]{12}$/;
15
+ export const MEMORY_ID_PATTERN = /^[a-f0-9]{12}$/i;
16
16
 
17
17
  export type Entry = {
18
18
  type: string;
package/src/om/pending.ts CHANGED
@@ -106,7 +106,7 @@ function readSessionState(sessionId: string): PendingOMState {
106
106
 
107
107
  try {
108
108
  const raw = JSON.parse(readFileSync(path, "utf-8"));
109
- if (isPendingOMState(raw)) return raw;
109
+ if (isPendingOMState(raw)) return sanitizePendingState(raw);
110
110
  return defaultState();
111
111
  } catch {
112
112
  return defaultState();
@@ -150,16 +150,49 @@ function writeSessionState(sessionId: string, state: PendingOMState): void {
150
150
  }
151
151
  }
152
152
 
153
- /**
154
- * Validate that an unknown value is a valid PendingOMState.
155
- */
153
+ /** Validate that unknown value is shape-compatible with PendingOMState. */
156
154
  function isPendingOMState(value: unknown): value is PendingOMState {
157
155
  if (!value || typeof value !== "object") return false;
158
156
  const v = value as Record<string, unknown>;
159
157
  const hasObs = !!(v.observation && typeof v.observation === "object" && typeof (v.observation as any).coversUpToId === "string");
160
158
  const hasRef = !!(v.reflection && typeof v.reflection === "object" && typeof (v.reflection as any).coversUpToId === "string");
161
159
  const hasDrop = !!(v.dropped && typeof v.dropped === "object" && typeof (v.dropped as any).coversUpToId === "string");
162
- return hasObs || hasRef || hasDrop;
160
+ // Also accept states with only batch arrays (no singular fields)
161
+ const hasBatches = Array.isArray(v.observationBatches) || Array.isArray(v.reflectionBatches) || Array.isArray(v.droppedBatches);
162
+ return hasObs || hasRef || hasDrop || hasBatches;
163
+ }
164
+
165
+ /**
166
+ * Sanitize pending state: filter out corrupted batch entries (missing required fields).
167
+ * Returns a clean copy; does not mutate the input.
168
+ */
169
+ function sanitizePendingState(raw: PendingOMState): PendingOMState {
170
+ const sanitized: PendingOMState = {
171
+ ...raw,
172
+ observation: raw.observation ?? undefined,
173
+ reflection: raw.reflection ?? undefined,
174
+ dropped: raw.dropped ?? undefined,
175
+ };
176
+ // Filter batch arrays to only include valid entries with the required shape
177
+ if (Array.isArray(raw.observationBatches)) {
178
+ sanitized.observationBatches = raw.observationBatches.filter(
179
+ (b): b is PendingObservation =>
180
+ !!b && typeof b === "object" && typeof (b as any).coversUpToId === "string" && (b as any).data !== undefined,
181
+ );
182
+ }
183
+ if (Array.isArray(raw.reflectionBatches)) {
184
+ sanitized.reflectionBatches = raw.reflectionBatches.filter(
185
+ (b): b is PendingReflection =>
186
+ !!b && typeof b === "object" && typeof (b as any).coversUpToId === "string" && (b as any).data !== undefined,
187
+ );
188
+ }
189
+ if (Array.isArray(raw.droppedBatches)) {
190
+ sanitized.droppedBatches = raw.droppedBatches.filter(
191
+ (b): b is PendingDropped =>
192
+ !!b && typeof b === "object" && typeof (b as any).coversUpToId === "string" && (b as any).data !== undefined,
193
+ );
194
+ }
195
+ return sanitized;
163
196
  }
164
197
 
165
198
  // ── API ─────────────────────────────────────────────────────────────────────
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Bridge streamSimple to support custom providers via global Symbol.for().
3
+ *
4
+ * When a custom provider is registered (via index.ts at startup), its stream
5
+ * function is stored under a shared global symbol. This module provides the
6
+ * bridge logic so all OM agents (observer, reflector, dropper) use the same
7
+ * custom-provider resolution instead of each duplicating the 15-line function.
8
+ */
9
+ export function createBridgeStreamFn(streamSimple: any) {
10
+ const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
11
+ return (model: any, ctx: any, opts: any) => {
12
+ const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
13
+ if (!providerStreams) return streamSimple(model, ctx, opts);
14
+ const customFn = model?.api ? providerStreams.get(model.api) : undefined;
15
+ return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
16
+ };
17
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared retryable-error regex and detection function.
3
+ *
4
+ * Extracted from compaction-trigger.ts and cooldown.ts which had diverging
5
+ * copies of the same logic. This is the single source of truth.
6
+ *
7
+ * Now also re-exports Pi's context-overflow detection from @earendil-works/pi-ai,
8
+ * avoiding the need to duplicate 20+ provider-specific overflow patterns.
9
+ */
10
+
11
+ /** Regex matching retryable API error messages. */
12
+ export const RETRYABLE_ERROR_RE =
13
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
14
+
15
+ /** Check whether an error string or Error indicates a retryable error. */
16
+ export function isRetryableError(error: unknown): boolean {
17
+ const message = error instanceof Error ? error.message : String(error || "");
18
+ return RETRYABLE_ERROR_RE.test(message);
19
+ }
20
+
21
+ /**
22
+ * Re-export Pi's context-overflow detection so blackhole callers use one
23
+ * authoritative source instead of maintaining their own provider-specific patterns.
24
+ * @see @earendil-works/pi-ai/dist/utils/overflow.d.ts
25
+ */
26
+ export { isContextOverflow } from "@earendil-works/pi-ai";
@@ -82,7 +82,7 @@ export function findReflectionsForEntryIds(
82
82
 
83
83
  export function formatRelatedObservations(
84
84
  observations: RelatedObservation[],
85
- _reflections: RelatedReflection[],
85
+ reflections: RelatedReflection[],
86
86
  ): string {
87
87
  const parts: string[] = [];
88
88
 
@@ -97,6 +97,14 @@ export function formatRelatedObservations(
97
97
  }
98
98
  }
99
99
 
100
+ if (reflections.length > 0) {
101
+ if (parts.length > 0) parts.push("");
102
+ parts.push("Related reflections:");
103
+ for (const ref of reflections) {
104
+ parts.push(` [${ref.memoryId}] ${ref.content}`);
105
+ }
106
+ }
107
+
100
108
  return parts.join("\n");
101
109
  }
102
110
 
package/src/om/runtime.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * - markConsolidationError sets 30s retry gate for failed runs.
11
11
  */
12
12
  import { type Config, type ConfiguredModel, DEFAULTS, loadConfig } from "./config.js";
13
- import { isCooldownActive, recordCooldown, expireCooldowns, modelKey } from "./cooldown.js";
13
+ import { isCooldownActive, getCooldownEntry, recordCooldown, expireCooldowns, modelKey } from "./cooldown.js";
14
14
 
15
15
  export type ResolveResult =
16
16
  | { ok: true; model: any; apiKey: string; headers?: Record<string, string>; cooldownApplied?: boolean }
@@ -94,13 +94,17 @@ export class Runtime {
94
94
  * Tries the candidate list in order:
95
95
  * 1. Primary stage model → 2. Stage fallbacks → 3. Base config.model → 4. Session model.
96
96
  *
97
+ * Session model fallback can be disabled via config.sessionFallback: false.
98
+ * When disabled, returns { ok: false } instead of using the session model,
99
+ * allowing the stage to be skipped entirely when all configured OM models fail.
100
+ *
97
101
  * Skips models that are currently in a cooldown window.
98
102
  * On retryable error (after the agent runs), the model that failed is cooled down
99
103
  * and the next candidate is tried. The caller must call `recordRetryableError`
100
104
  * after the API attempt to mark the failed model.
101
105
  *
102
106
  * Returns `ok: true` with the resolved model, or `ok: false` with a reason
103
- * if all candidates (including session model) are exhausted or unavailable.
107
+ * if all candidates (including session model, if enabled) are exhausted or unavailable.
104
108
  */
105
109
  async resolveModel(ctx: ResolveCtx): Promise<ResolveResult> {
106
110
  const candidates = this.buildCandidateList(ctx.stageModel, ctx.stageFallbacks);
@@ -123,8 +127,10 @@ export class Runtime {
123
127
 
124
128
  if (isCooldownActive(candidate)) {
125
129
  if (ctx.hasUI && ctx.ui) {
130
+ const entry = getCooldownEntry(candidate);
131
+ const reason = entry ? `: ${entry.reason}` : "";
126
132
  ctx.ui.notify(
127
- `Observational memory: ${stageName} skipping ${key} (cooldown active)`,
133
+ `Observational memory: ${stageName} skipping ${key} (cooldown${reason})`,
128
134
  "info",
129
135
  );
130
136
  }
@@ -162,25 +168,40 @@ export class Runtime {
162
168
  };
163
169
  }
164
170
 
165
- // Fall back to session model
166
- const sessionModel = ctx.model;
167
- if (!sessionModel) {
168
- return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, no session model)` };
171
+ // Fall back to session model (if enabled)
172
+ if (this.config.sessionFallback !== false) {
173
+ const sessionModel = ctx.model;
174
+ if (!sessionModel) {
175
+ return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, no session model)` };
176
+ }
177
+
178
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
179
+ if (!auth.ok || !auth.apiKey) {
180
+ const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
181
+ return { ok: false, reason: `no API key for session model provider "${provider}"` };
182
+ }
183
+
184
+ return {
185
+ ok: true,
186
+ model: sessionModel,
187
+ apiKey: auth.apiKey as string,
188
+ headers: auth.headers as Record<string, string> | undefined,
189
+ cooldownApplied: false,
190
+ };
169
191
  }
170
192
 
171
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
172
- if (!auth.ok || !auth.apiKey) {
173
- const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
174
- return { ok: false, reason: `no API key for session model provider "${provider}"` };
193
+ // All configured candidates exhausted and session fallback disabled —
194
+ // skip the stage entirely. Info-level to match cooldown-disabled pattern.
195
+ // Set resolveFailureNotified so the consolidation layer doesn't duplicate.
196
+ if (ctx.hasUI && ctx.ui) {
197
+ ctx.ui.notify(
198
+ `Observational memory: ${stageName} skipped — all candidates failed (sessionFallback disabled, won't use main model)`,
199
+ "info",
200
+ );
175
201
  }
202
+ this.resolveFailureNotified = true;
176
203
 
177
- return {
178
- ok: true,
179
- model: sessionModel,
180
- apiKey: auth.apiKey as string,
181
- headers: auth.headers as Record<string, string> | undefined,
182
- cooldownApplied: false,
183
- };
204
+ return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, sessionFallback disabled)` };
184
205
  }
185
206
 
186
207
  /**
package/src/sections.ts CHANGED
@@ -4,7 +4,6 @@
4
4
  * Upstream: https://github.com/sting8k/pi-vcc (src/sections.ts)
5
5
  * Unmodified.
6
6
  */
7
- import type { TranscriptEntry } from "./core/brief";
8
7
 
9
8
  export interface SectionData {
10
9
  sessionGoal: string[];
@@ -13,6 +12,4 @@ export interface SectionData {
13
12
  commits: string[];
14
13
  userPreferences: string[];
15
14
  briefTranscript: string;
16
- /** Structured transcript entries (verbose object format) */
17
- transcriptEntries: TranscriptEntry[];
18
15
  }
@@ -138,7 +138,11 @@ async function vccRecall(params: { query?: string; expand?: number[]; page?: num
138
138
 
139
139
  // Merge expanded entries into full result set BEFORE pagination
140
140
  // so pagination counts and positioning stay consistent
141
+ let appendedExpandCount = 0;
141
142
  if (expandedFullEntries) {
143
+ // Track expand-only entries that don't match the query so the header can distinguish them
144
+ const existingIndices = new Set(allResults.map((r) => r.index));
145
+ appendedExpandCount = expandedFullEntries.filter((fe) => !existingIndices.has(fe.index)).length;
142
146
  allResults = mergeExpandedIntoSearchResults(allResults, expandedFullEntries);
143
147
  }
144
148
 
@@ -148,9 +152,10 @@ async function vccRecall(params: { query?: string; expand?: number[]; page?: num
148
152
  const pageResults: SearchHit[] = allResults.slice(start, start + PAGE_SIZE);
149
153
  const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
150
154
  const scopeSuffix = scope === "all" ? " (scope: all)" : "";
155
+ const matchCount = allResults.length - appendedExpandCount;
151
156
  const header = totalPages > 1
152
- ? `Page ${page}/${totalPages} (${allResults.length} total matches${scopeSuffix})`
153
- : `${allResults.length} matches${scopeSuffix}`;
157
+ ? `Page ${page}/${totalPages} (${matchCount} matches${appendedExpandCount > 0 ? ` + ${appendedExpandCount} expanded` : ""}${scopeSuffix})`
158
+ : `${matchCount} matches${appendedExpandCount > 0 ? ` (+ ${appendedExpandCount} expanded)` : ""}${scopeSuffix}`;
154
159
  const footer = page < totalPages
155
160
  ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---`
156
161
  : "";