pi-blackhole 0.3.3 → 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 (41) 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 +33 -26
  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 +20 -14
  27. package/src/om/consolidation.ts +57 -18
  28. package/src/om/cooldown.ts +24 -11
  29. package/src/om/debug-log.ts +79 -11
  30. package/src/om/key-matcher.ts +6 -66
  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 +69 -18
  39. package/src/om/status-overlay.ts +6 -5
  40. package/src/sections.ts +0 -3
  41. package/src/tools/recall.ts +7 -2
@@ -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 }
@@ -45,6 +45,13 @@ export class Runtime {
45
45
  consolidationInFlight = false;
46
46
  consolidationPromise: Promise<void> | null = null;
47
47
  consolidationPhase: ConsolidationPhase | undefined;
48
+ /**
49
+ * Models that failed in the current consolidation stage (in-memory only).
50
+ * Used when cooldownHours is 0 — avoids disk writes while still letting
51
+ * the retry loop advance past the failed model within this stage.
52
+ * Cleared between stages at the pipeline level.
53
+ */
54
+ failedInCycle: Set<string> = new Set();
48
55
  compactInFlight = false;
49
56
  compactHookInFlight = false;
50
57
  resolveFailureNotified = false;
@@ -87,13 +94,17 @@ export class Runtime {
87
94
  * Tries the candidate list in order:
88
95
  * 1. Primary stage model → 2. Stage fallbacks → 3. Base config.model → 4. Session model.
89
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
+ *
90
101
  * Skips models that are currently in a cooldown window.
91
102
  * On retryable error (after the agent runs), the model that failed is cooled down
92
103
  * and the next candidate is tried. The caller must call `recordRetryableError`
93
104
  * after the API attempt to mark the failed model.
94
105
  *
95
106
  * Returns `ok: true` with the resolved model, or `ok: false` with a reason
96
- * if all candidates (including session model) are exhausted or unavailable.
107
+ * if all candidates (including session model, if enabled) are exhausted or unavailable.
97
108
  */
98
109
  async resolveModel(ctx: ResolveCtx): Promise<ResolveResult> {
99
110
  const candidates = this.buildCandidateList(ctx.stageModel, ctx.stageFallbacks);
@@ -101,10 +112,25 @@ export class Runtime {
101
112
 
102
113
  // Try configured candidates
103
114
  for (const candidate of candidates) {
115
+ const key = modelKey(candidate);
116
+
117
+ // In-memory skip: model failed earlier in this stage with cooldownHours 0
118
+ if (this.failedInCycle.has(key)) {
119
+ if (ctx.hasUI && ctx.ui) {
120
+ ctx.ui.notify(
121
+ `Observational memory: ${stageName} skipping ${key} (failed this cycle, cooldown disabled)`,
122
+ "info",
123
+ );
124
+ }
125
+ continue;
126
+ }
127
+
104
128
  if (isCooldownActive(candidate)) {
105
129
  if (ctx.hasUI && ctx.ui) {
130
+ const entry = getCooldownEntry(candidate);
131
+ const reason = entry ? `: ${entry.reason}` : "";
106
132
  ctx.ui.notify(
107
- `Observational memory: ${stageName} skipping ${modelKey(candidate)} (cooldown active)`,
133
+ `Observational memory: ${stageName} skipping ${key} (cooldown${reason})`,
108
134
  "info",
109
135
  );
110
136
  }
@@ -142,25 +168,40 @@ export class Runtime {
142
168
  };
143
169
  }
144
170
 
145
- // Fall back to session model
146
- const sessionModel = ctx.model;
147
- if (!sessionModel) {
148
- 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
+ };
149
191
  }
150
192
 
151
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(sessionModel);
152
- if (!auth.ok || !auth.apiKey) {
153
- const provider = (sessionModel as { provider?: string }).provider ?? "unknown";
154
- 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
+ );
155
201
  }
202
+ this.resolveFailureNotified = true;
156
203
 
157
- return {
158
- ok: true,
159
- model: sessionModel,
160
- apiKey: auth.apiKey as string,
161
- headers: auth.headers as Record<string, string> | undefined,
162
- cooldownApplied: false,
163
- };
204
+ return { ok: false, reason: `no model available for ${stageName} (all candidates exhausted, sessionFallback disabled)` };
164
205
  }
165
206
 
166
207
  /**
@@ -179,9 +220,19 @@ export class Runtime {
179
220
  /**
180
221
  * Record a retryable error for a model. The model must be one of the candidates
181
222
  * (not the session model). If it's the session model we don't cool it down.
223
+ *
224
+ * When cooldownHours is explicitly 0, the model is tracked in-memory for the
225
+ * current consolidation stage (no disk writes). Otherwise a persisted cooldown
226
+ * is recorded.
182
227
  */
183
228
  recordRetryableError(modelConfig: ConfiguredModel | undefined, error: unknown, stage: ConsolidationPhase): void {
184
229
  if (!modelConfig) return;
230
+ if (modelConfig.cooldownHours === 0) {
231
+ // In-memory only: skip this model for the rest of this stage.
232
+ // No disk writes, no persistent cooldown.
233
+ this.failedInCycle.add(modelKey(modelConfig));
234
+ return;
235
+ }
185
236
  const reason = error instanceof Error ? error.message : String(error || "unknown error");
186
237
  recordCooldown(modelConfig, reason, stage);
187
238
  }
@@ -7,7 +7,8 @@
7
7
  * Esc to close.
8
8
  */
9
9
 
10
- import { matchKey, visibleWidth } from "./key-matcher.js";
10
+ import { visibleWidth } from "./key-matcher.js";
11
+ import { matchesKey } from "@earendil-works/pi-tui";
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Theme shape (duck-typed from what pi provides)
@@ -117,12 +118,12 @@ export function createStatusOverlay(
117
118
  }
118
119
 
119
120
  function handleInput(data: string): void {
120
- if (matchKey(data, "escape")) {
121
+ if (matchesKey(data, "escape")) {
121
122
  done({ action: "close" });
122
123
  return;
123
124
  }
124
125
 
125
- if (matchKey(data, "enter") || matchKey(data, "space")) {
126
+ if (matchesKey(data, "enter") || matchesKey(data, "space")) {
126
127
  if (navIsAction(selectedIndex)) {
127
128
  const actionIdx = selectedIndex - selectableConfigCount - selectablePipelineCount - selectableErrorCount;
128
129
  const action = actionItems[actionIdx]!;
@@ -132,12 +133,12 @@ export function createStatusOverlay(
132
133
  return;
133
134
  }
134
135
 
135
- if (matchKey(data, "up")) {
136
+ if (matchesKey(data, "up")) {
136
137
  selectedIndex = Math.max(0, selectedIndex - 1);
137
138
  invalidate();
138
139
  return;
139
140
  }
140
- if (matchKey(data, "down")) {
141
+ if (matchesKey(data, "down")) {
141
142
  selectedIndex = Math.min(totalSelectable - 1, selectedIndex + 1);
142
143
  invalidate();
143
144
  return;
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
  : "";