pi-blackhole 0.2.3 → 0.3.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.
@@ -1,4 +1,86 @@
1
- import type { SearchHit } from "./search-entries";
1
+ import type { SearchHit, FileMatch, TouchedFile } from "./search-entries";
2
+
3
+ // ── Path shortening ───────────────────────────────────────────────────────
4
+
5
+ const CWD = process.cwd();
6
+
7
+ /**
8
+ * Shorten an absolute file path for display:
9
+ * - If within cwd, return `./relative/path`
10
+ * - Otherwise, show last 3 path components with `.../` prefix
11
+ * - Short paths (≤3 components) returned as-is
12
+ */
13
+ export function shortPath(fullPath: string): string {
14
+ // Normalize backslashes to forward slashes for cross-platform path handling
15
+ const normalized = fullPath.replace(/\\/g, "/");
16
+ const cwdNormalized = CWD.replace(/\\/g, "/");
17
+ if (normalized.startsWith(cwdNormalized + "/")) {
18
+ return "." + normalized.slice(cwdNormalized.length);
19
+ }
20
+ const parts = normalized.split("/");
21
+ if (parts.length > 3) {
22
+ return ".../" + parts.slice(-3).join("/");
23
+ }
24
+ return normalized;
25
+ }
26
+
27
+ // ── File indicator formatting ─────────────────────────────────────────────
28
+
29
+ /** Render one file indicator line with shortened path. */
30
+ function formatFileMatch(fm: FileMatch, index: number, isQuery: boolean): string {
31
+ const label = isQuery
32
+ ? (fm.lineCount === 1 ? "match" : "matches")
33
+ : (fm.lineCount === 1 ? "line" : "lines");
34
+ const displayPath = shortPath(fm.path);
35
+ let line = ` [${fm.toolName}] ${displayPath} — ${fm.lineCount} ${label} use #${index}:${fm.path}`;
36
+ if (fm.snippet) {
37
+ line += `\n | ${fm.snippet}`;
38
+ }
39
+ return line;
40
+ }
41
+
42
+ // ── Touched file output ───────────────────────────────────────────────────
43
+
44
+ const TOUCHED_PAGE_SIZE = 5;
45
+
46
+ /**
47
+ * Format aggregated "files touched" output.
48
+ */
49
+ export function formatTouchedOutput(
50
+ touched: TouchedFile[],
51
+ page?: number,
52
+ pageSize?: number,
53
+ ): string {
54
+ if (touched.length === 0) {
55
+ return "No file operations found in session history.";
56
+ }
57
+
58
+ const ps = pageSize ?? TOUCHED_PAGE_SIZE;
59
+ const totalPages = Math.ceil(touched.length / ps);
60
+ const currentPage = Math.max(1, page ?? 1);
61
+ const start = (currentPage - 1) * ps;
62
+ const pageFiles = touched.slice(start, start + ps);
63
+
64
+ const header = totalPages > 1
65
+ ? `Page ${currentPage}/${totalPages} (${touched.length} total files)`
66
+ : `${touched.length} files touched`;
67
+
68
+ const lines = pageFiles.map((tf) => {
69
+ const displayPath = shortPath(tf.path);
70
+ const indices = tf.entries
71
+ .map((e) => `#${e.index} (${e.toolName})`)
72
+ .join(", ");
73
+ return ` ${displayPath} ${indices}`;
74
+ });
75
+
76
+ let result = `${header}:\n\n${lines.join("\n")}`;
77
+
78
+ if (currentPage < totalPages) {
79
+ result += `\n\n--- Use page:${currentPage + 1} for more results ---`;
80
+ }
81
+
82
+ return result;
83
+ }
2
84
 
3
85
  export const formatRecallOutput = (
4
86
  entries: SearchHit[],
@@ -18,9 +100,31 @@ export const formatRecallOutput = (
18
100
  : `Session history (${entries.length} entries):`;
19
101
 
20
102
  const lines = entries.map((e) => {
21
- const fileSuffix = e.files?.length ? ` files:[${e.files.join(", ")}]` : "";
22
103
  const body = query && e.snippet ? e.snippet : e.summary;
23
- return `#${e.index} [${e.role}]${fileSuffix} ${body}`;
104
+ let line = `#${e.index} [${e.role}]`;
105
+
106
+ // Entry text on next line if it's long or has fileMatches
107
+ if (e.fileMatches?.length) {
108
+ line += `\n ${body}`;
109
+ // Only show top 3 file matches
110
+ const isQuery = Boolean(query);
111
+ const topFileMatches = e.fileMatches.slice(0, 3);
112
+ for (const fm of topFileMatches) {
113
+ line += `\n${formatFileMatch(fm, e.index, isQuery)}`;
114
+ }
115
+ if (e.fileMatches.length > 3) {
116
+ line += `\n ...(${e.fileMatches.length - 3} more file matches)`;
117
+ }
118
+ } else if (e.files?.length) {
119
+ // Fallback: entries without fileMatches (e.g. expand-only path) still have
120
+ // the legacy .files array from renderMessage — show it in old format.
121
+ const fileSuffix = ` files:[${e.files.join(", ")}]`;
122
+ line += `${fileSuffix} ${body}`;
123
+ } else {
124
+ line += ` ${body}`;
125
+ }
126
+
127
+ return line;
24
128
  });
25
129
 
26
130
  return `${header}\n\n${lines.join("\n\n")}`;
@@ -34,9 +34,10 @@ export const loadAllMessages = (
34
34
 
35
35
  const allowed = !allowedEntryIds || allowedEntryIds.has(e.id);
36
36
  if (allowed) {
37
- rendered.push(renderMessage(e.message, messageIndex, String(e.id), full));
37
+ const entryId = e.id != null ? String(e.id) : "";
38
+ rendered.push(renderMessage(e.message, messageIndex, entryId, full));
38
39
  rawMessages.push(e.message);
39
- entryIds.push(String(e.id));
40
+ entryIds.push(entryId);
40
41
  }
41
42
  messageIndex++;
42
43
  }
@@ -1,14 +1,25 @@
1
1
  export type RecallScope = "lineage" | "all";
2
+ export type RecallMode = "hybrid" | "file" | "transcript" | "touched";
2
3
 
3
4
  const SCOPE_RE = /\bscope:(lineage|all)\b/i;
5
+ const MODE_RE = /\bmode:(hybrid|file|transcript|touched)\b/i;
6
+
7
+ const VALID_MODES = new Set(["hybrid", "file", "transcript", "touched"]);
4
8
 
5
9
  export const normalizeRecallScope = (scope?: unknown): RecallScope =>
6
10
  typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
7
11
 
8
- export const parseRecallScope = (text: string): { scope: RecallScope; text: string } => {
9
- const match = text.match(SCOPE_RE);
12
+ export const normalizeRecallMode = (mode?: unknown): RecallMode =>
13
+ typeof mode === "string" && VALID_MODES.has(mode.toLowerCase())
14
+ ? (mode.toLowerCase() as RecallMode)
15
+ : "hybrid";
16
+
17
+ export const parseRecallScope = (text: string): { scope: RecallScope; mode: RecallMode; text: string } => {
18
+ const scopeMatch = text.match(SCOPE_RE);
19
+ const modeMatch = text.match(MODE_RE);
10
20
  return {
11
- scope: normalizeRecallScope(match?.[1]),
12
- text: text.replace(SCOPE_RE, "").replace(/\s+/g, " ").trim(),
21
+ scope: normalizeRecallScope(scopeMatch?.[1]),
22
+ mode: normalizeRecallMode(modeMatch?.[1]),
23
+ text: text.replace(SCOPE_RE, "").replace(MODE_RE, "").replace(/\s+/g, " ").trim(),
13
24
  };
14
25
  };
@@ -6,13 +6,39 @@
6
6
  */
7
7
  import type { Message } from "@earendil-works/pi-ai";
8
8
  import type { RenderedEntry } from "./render-entries";
9
- import { textOf } from "./content";
9
+ import { textOf, toolCallArgsText, isContentBearing } from "./content";
10
+ import type { RecallMode } from "./recall-scope";
11
+
12
+ export interface FileMatch {
13
+ /** Name of the tool (write, edit, hex_edit) */
14
+ toolName: string;
15
+ /** File path from the tool call arguments */
16
+ path: string;
17
+ /** Number of lines in the content that matched the query */
18
+ lineCount: number;
19
+ /** First matching line snippet (only populated for top matches) */
20
+ snippet?: string;
21
+ }
22
+
23
+ /** A file touched in one entry — used by mode:touched aggregation. */
24
+ export interface FileTouch {
25
+ index: number;
26
+ toolName: string;
27
+ }
28
+
29
+ /** Aggregated view of a file touched across multiple entries. */
30
+ export interface TouchedFile {
31
+ path: string;
32
+ entries: FileTouch[];
33
+ }
10
34
 
11
35
  export interface SearchHit extends RenderedEntry {
12
36
  /** Context snippet around the first matched term (only when query provided) */
13
37
  snippet?: string;
14
38
  /** Number of query terms matched (for ranking) */
15
39
  matchCount?: number;
40
+ /** Per-file match indicators from content-bearing tool calls */
41
+ fileMatches?: FileMatch[];
16
42
  }
17
43
 
18
44
  const escapeRegex = (s: string): string =>
@@ -153,18 +179,131 @@ const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | un
153
179
  return parts.join("\n");
154
180
  };
155
181
 
156
- /** Build full searchable text for a message. */
157
- const fullText = (msg: Message): string => {
182
+ /** Build full searchable text for a message, optionally filtered by mode. */
183
+ const fullText = (msg: Message, mode?: RecallMode): string => {
158
184
  if ((msg as any).role === "bashExecution") {
185
+ if (mode === "file") return ""; // bash is not file content
159
186
  return `${(msg as any).command ?? ""} ${(msg as any).output ?? ""}`;
160
187
  }
161
- return textOf(msg.content);
188
+ if (mode === "file") {
189
+ return toolCallArgsText(msg.content);
190
+ }
191
+ if (mode === "transcript") {
192
+ return textOf(msg.content);
193
+ }
194
+ // hybrid (default): both
195
+ const text = textOf(msg.content);
196
+ const toolArgs = toolCallArgsText(msg.content);
197
+ return toolArgs ? `${text}\n${toolArgs}` : text;
162
198
  };
163
199
 
200
+ /**
201
+ * Extract searchable text from tool call arguments (content, edits, oldText, newText).
202
+ */
203
+ function extractToolCallText(args: Record<string, unknown>): string {
204
+ let text = "";
205
+ if (typeof args.content === "string") text += args.content + "\n";
206
+ if (Array.isArray(args.edits)) {
207
+ for (const edit of args.edits) {
208
+ if (edit && typeof edit === "object") {
209
+ if (typeof edit.oldText === "string") text += edit.oldText + "\n";
210
+ if (typeof edit.newText === "string") text += edit.newText + "\n";
211
+ }
212
+ }
213
+ }
214
+ if (typeof args.oldText === "string" && !Array.isArray(args.edits)) text += args.oldText + "\n";
215
+ if (typeof args.newText === "string" && !Array.isArray(args.edits)) text += args.newText + "\n";
216
+ return text;
217
+ }
218
+
219
+ /**
220
+ * Compute file indicators from a message (no query — counts total lines per file).
221
+ */
222
+ export function getFileIndicators(msg: Message): FileMatch[] {
223
+ if (!msg?.content || typeof msg.content === "string") return [];
224
+ const fileMatches: FileMatch[] = [];
225
+ for (const part of msg.content) {
226
+ if (!part || typeof part !== "object" || part.type !== "toolCall") continue;
227
+ const args = part.arguments as Record<string, unknown>;
228
+ if (!isContentBearing(args)) continue;
229
+
230
+ const path = ["path", "filePath", "file_path", "file"]
231
+ .map((k) => args[k])
232
+ .find((v): v is string => typeof v === "string")!;
233
+
234
+ const totalText = extractToolCallText(args);
235
+ const nonEmpty = totalText.split("\n").filter((l) => l.trim().length > 0);
236
+ fileMatches.push({
237
+ toolName: part.name || "",
238
+ path,
239
+ lineCount: nonEmpty.length,
240
+ });
241
+ }
242
+ return fileMatches;
243
+ }
244
+
245
+ function computeFileMatches(msg: Message | undefined, query: string): FileMatch[] {
246
+ if (!msg?.content || typeof msg.content === "string") return [];
247
+ const rawQuery = query.trim();
248
+ const hasQuery = rawQuery.length > 0;
249
+ if (!hasQuery) return getFileIndicators(msg as Message);
250
+ const regex = looksLikeRegex(rawQuery) ? safeRegex(rawQuery) : snippetRegex(rawQuery.split(/\s+/));
251
+ const fileMatches: FileMatch[] = [];
252
+
253
+ for (const part of msg.content) {
254
+ if (!part || typeof part !== "object" || part.type !== "toolCall") continue;
255
+ const args = part.arguments as Record<string, unknown>;
256
+ if (!isContentBearing(args)) continue;
257
+
258
+ // Path is guaranteed by isContentBearing check
259
+ const path = ["path", "filePath", "file_path", "file"]
260
+ .map((k) => args[k])
261
+ .find((v): v is string => typeof v === "string")!;
262
+
263
+ const searchText = extractToolCallText(args);
264
+ if (!searchText) continue;
265
+
266
+ const lines = searchText.split("\n");
267
+ const matchingLines = lines.filter((line) => regex.test(line));
268
+ if (matchingLines.length > 0) {
269
+ fileMatches.push({
270
+ toolName: part.name || "",
271
+ path,
272
+ lineCount: matchingLines.length,
273
+ snippet: matchingLines[0],
274
+ });
275
+ }
276
+ }
277
+
278
+ return fileMatches;
279
+ }
280
+
281
+ /** Aggregate file operations across all entries for mode:touched. */
282
+ export function getTouchedFiles(
283
+ messages: Message[],
284
+ rendered: RenderedEntry[],
285
+ ): TouchedFile[] {
286
+ const map = new Map<string, TouchedFile>();
287
+ for (let i = 0; i < messages.length; i++) {
288
+ const msg = messages[i];
289
+ const indicators = getFileIndicators(msg);
290
+ for (const fm of indicators) {
291
+ const index = rendered[i]?.index ?? i;
292
+ if (!map.has(fm.path)) {
293
+ map.set(fm.path, { path: fm.path, entries: [] });
294
+ }
295
+ map.get(fm.path)!.entries.push({ index, toolName: fm.toolName });
296
+ }
297
+ }
298
+ return Array.from(map.values());
299
+ }
300
+
164
301
  export const searchEntries = (
165
302
  entries: RenderedEntry[],
166
303
  messages: Message[],
167
304
  query?: string,
305
+ _page?: number,
306
+ mode?: RecallMode,
168
307
  ): SearchHit[] => {
169
308
  if (!query?.trim()) return entries;
170
309
 
@@ -178,12 +317,14 @@ export const searchEntries = (
178
317
  for (let i = 0; i < entries.length; i++) {
179
318
  const e = entries[i];
180
319
  const msg = messages[i];
181
- const text = msg ? fullText(msg) : e.summary;
320
+ const text = msg ? fullText(msg, mode) : e.summary;
182
321
  const filePart = e.files?.join(" ") ?? "";
183
322
  const hay = `${e.role} ${text} ${filePart}`;
184
323
  if (regex.test(hay)) {
185
324
  const snip = lineSnippet(text, regex);
186
- hits.push({ ...e, snippet: snip, matchCount: 1 });
325
+ const fileMatches = mode === "transcript" ? [] : computeFileMatches(msg, rawQuery);
326
+ const extra = fileMatches.length > 0 ? { fileMatches } : {};
327
+ hits.push({ ...e, snippet: snip, matchCount: 1, ...extra });
187
328
  }
188
329
  }
189
330
  return hits;
@@ -194,12 +335,14 @@ export const searchEntries = (
194
335
  const terms = filterStopwords(rawTerms);
195
336
  const snipRe = snippetRegex(terms);
196
337
 
197
- // Build all docs for BM25 context
338
+ // Build all docs for BM25 context (cache fullText to avoid recomputing)
198
339
  const docs: string[] = [];
340
+ const fullTextCache: string[] = [];
199
341
  for (let i = 0; i < entries.length; i++) {
200
342
  const e = entries[i];
201
343
  const msg = messages[i];
202
- const text = msg ? fullText(msg) : e.summary;
344
+ const text = msg ? fullText(msg, mode) : e.summary;
345
+ fullTextCache.push(text);
203
346
  const filePart = e.files?.join(" ") ?? "";
204
347
  docs.push(`${e.role} ${text} ${filePart}`);
205
348
  }
@@ -213,10 +356,12 @@ export const searchEntries = (
213
356
  const mc = countMatches(hay, terms);
214
357
  if (mc === 0) continue;
215
358
  const score = bm25Score(hay, terms, ctx);
216
- const text = messages[i] ? fullText(messages[i]) : e.summary;
359
+ const text = fullTextCache[i];
217
360
  const snip = lineSnippet(text, snipRe);
361
+ const fileMatches = mode === "transcript" ? [] : computeFileMatches(messages[i], rawQuery);
362
+ const extra = fileMatches.length > 0 ? { fileMatches } : {};
218
363
  scored.push({
219
- hit: { ...e, snippet: snip, matchCount: mc },
364
+ hit: { ...e, snippet: snip, matchCount: mc, ...extra },
220
365
  score,
221
366
  });
222
367
  }
@@ -252,8 +252,30 @@ export function loadUnifiedConfig(cwd: string): UnifiedConfig {
252
252
  // Merge defaults then override
253
253
  const merged = { ...DEFAULTS, ...parsed };
254
254
 
255
- // Derive observationsPoolTargetTokens if unset or invalid (must be < max)
256
- if (merged.observationsPoolTargetTokens >= merged.observationsPoolMaxTokens) {
255
+ // ── Validate all numeric fields ──
256
+ // Prevents NaN/undefined from leaking into runtime math.
257
+ const NUMERIC_KEYS: ReadonlyArray<keyof UnifiedConfig> = [
258
+ "observeAfterTokens", "reflectAfterTokens", "compactAfterTokens",
259
+ "observationsPoolMaxTokens", "observationsPoolTargetTokens",
260
+ "reflectorInputMaxTokens", "dropperInputMaxTokens",
261
+ "observerChunkMaxTokens", "observerPreambleMaxTokens",
262
+ "agentMaxTurns",
263
+ ];
264
+ for (const k of NUMERIC_KEYS) {
265
+ const v = (merged as Record<string, unknown>)[k];
266
+ // observerPreambleMaxTokens=0 means "auto-compute from observerChunkMaxTokens (30%)"
267
+ // so 0 is valid for that field. All other numeric fields must be strictly positive.
268
+ const minVal = k === "observerPreambleMaxTokens" ? 0 : 1;
269
+ if (typeof v !== "number" || !Number.isFinite(v) || v < minVal) {
270
+ (merged as Record<string, unknown>)[k] = DEFAULTS[k];
271
+ }
272
+ }
273
+
274
+ // Derive observationsPoolTargetTokens if still unset or invalid (must be < max)
275
+ if (
276
+ merged.observationsPoolTargetTokens === undefined ||
277
+ merged.observationsPoolTargetTokens >= merged.observationsPoolMaxTokens
278
+ ) {
257
279
  merged.observationsPoolTargetTokens = Math.floor(merged.observationsPoolMaxTokens / 2);
258
280
  }
259
281
 
@@ -279,7 +301,11 @@ export function saveUnifiedConfig(settings: Partial<UnifiedConfig>): boolean {
279
301
 
280
302
  /**
281
303
  * Ensure ~/.pi/agent/pi-blackhole/pi-blackhole-config.json exists with defaults.
282
- * If the file exists but is missing keys, fill them in.
304
+ *
305
+ * Only creates the file if it doesn't exist. Missing keys are filled at read
306
+ * time by loadUnifiedConfig() via { ...DEFAULTS, ...parsed } merge, so there
307
+ * is no need to keep the on-disk file "complete". This avoids a crash on
308
+ * read-only filesystems where the config is managed externally (e.g., Nix).
283
309
  */
284
310
  export function scaffoldConfig(): void {
285
311
  try {
@@ -289,21 +315,7 @@ export function scaffoldConfig(): void {
289
315
 
290
316
  if (!existsSync(path)) {
291
317
  writeFileSync(path, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
292
- return;
293
- }
294
-
295
- const parsed = readJson(path);
296
- if (!parsed || typeof parsed !== "object") return;
297
-
298
- let changed = false;
299
- const next: Record<string, unknown> = { ...parsed };
300
- for (const [key, value] of Object.entries(DEFAULTS)) {
301
- if (!(key in next)) {
302
- next[key] = value;
303
- changed = true;
304
- }
305
318
  }
306
- if (changed) writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
307
319
  } catch (e) {
308
320
  console.error("blackhole: config scaffold failed", e);
309
321
  }
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
9
9
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
- import { Type } from "@earendil-works/pi-ai";
10
+ import { streamSimple } from "@earendil-works/pi-ai";
11
+ import { Type } from "typebox";
11
12
  import type { Static } from "typebox";
12
13
  import { debugLog } from "../../debug-log.js";
13
14
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
@@ -33,6 +34,10 @@ interface RunDropperArgs {
33
34
  budgetTokens: number;
34
35
  signal?: AbortSignal;
35
36
  agentLoop?: typeof agentLoop;
37
+ /** Optional custom stream function bypassing agentLoop's default streamSimple.
38
+ * Used by the Symbol.for bridge to access native pi-ai provider registrations
39
+ * from jiti-loaded consolidation agents. */
40
+ streamFn?: (model: any, context: any, options: any) => any;
36
41
  maxTurns?: number;
37
42
  thinkingLevel?: ModelThinkingLevel;
38
43
  }
@@ -281,7 +286,16 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
281
286
  };
282
287
 
283
288
  const loop = args.agentLoop ?? agentLoop;
284
- const stream = loop(prompts, context, config, signal);
289
+ // ── Bridge stream function ──
290
+ const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
291
+ const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
292
+ const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
293
+ if (!providerStreams) return streamSimple(model, ctx, opts);
294
+ const customFn = model?.api ? providerStreams.get(model.api) : undefined;
295
+ return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
296
+ };
297
+ const streamFn = args.streamFn ?? bridgeStreamFn;
298
+ const stream = loop(prompts, context, config, signal, streamFn);
285
299
  let agentError: string | undefined;
286
300
  for await (const event of stream) {
287
301
  // Tool execution collects candidate ids.
@@ -8,7 +8,8 @@
8
8
  */
9
9
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
10
10
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
11
- import { Type } from "@earendil-works/pi-ai";
11
+ import { streamSimple } from "@earendil-works/pi-ai";
12
+ import { Type } from "typebox";
12
13
  import type { Static } from "typebox";
13
14
  import { hashId } from "../../ids.js";
14
15
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
@@ -27,6 +28,10 @@ interface RunObserverArgs {
27
28
  allowedSourceEntryIds: string[];
28
29
  signal?: AbortSignal;
29
30
  agentLoop?: typeof agentLoop;
31
+ /** Optional custom stream function bypassing agentLoop's default streamSimple.
32
+ * Used by the Symbol.for bridge to access native pi-ai provider registrations
33
+ * from jiti-loaded consolidation agents. */
34
+ streamFn?: (model: any, context: any, options: any) => any;
30
35
  maxTurns?: number;
31
36
  thinkingLevel?: ModelThinkingLevel;
32
37
  }
@@ -217,7 +222,22 @@ ${conversation}`;
217
222
  };
218
223
 
219
224
  const loop = args.agentLoop ?? agentLoop;
220
- const stream = loop(prompts, context, config, signal);
225
+ // ── Bridge stream function ──
226
+ // Consolidation agents run via jiti (moduleCache: false) which creates a separate
227
+ // pi-ai instance whose apiProviderRegistry lacks custom providers registered by
228
+ // other extensions (e.g., claude-bridge). The bridge looks up streamSimple functions
229
+ // from a Symbol.for() global that index.ts populates during provider registration.
230
+ // If no custom provider is found, it falls back to the jiti-loaded streamSimple
231
+ // which handles all built-in providers correctly.
232
+ const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
233
+ const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
234
+ const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
235
+ if (!providerStreams) return streamSimple(model, ctx, opts);
236
+ const customFn = model?.api ? providerStreams.get(model.api) : undefined;
237
+ return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
238
+ };
239
+ const streamFn = args.streamFn ?? bridgeStreamFn;
240
+ const stream = loop(prompts, context, config, signal, streamFn);
221
241
  let agentError: string | undefined;
222
242
  for await (const event of stream) {
223
243
  // Drain events; the tool's execute already collects records.
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
9
9
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
- import { Type } from "@earendil-works/pi-ai";
10
+ import { streamSimple } from "@earendil-works/pi-ai";
11
+ import { Type } from "typebox";
11
12
  import type { Static } from "typebox";
12
13
  import { hashId } from "../../ids.js";
13
14
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
@@ -15,6 +16,7 @@ import { truncateRecordContent } from "../../serialize.js";
15
16
  import { REFLECTOR_SYSTEM } from "./prompts.js";
16
17
  import { estimateStringTokens } from "../../tokens.js";
17
18
  import { observationToSummaryLine, reflectionToSummaryLine, type Observation, type Reflection } from "../../ledger/index.js";
19
+ import type { ReflectionCoverageTier } from "../dropper/coverage.js";
18
20
 
19
21
  interface RunReflectorArgs {
20
22
  model: Model<any>;
@@ -28,6 +30,10 @@ interface RunReflectorArgs {
28
30
  existingObservationsSummary?: string;
29
31
  signal?: AbortSignal;
30
32
  agentLoop?: typeof agentLoop;
33
+ /** Optional custom stream function bypassing agentLoop's default streamSimple.
34
+ * Used by the Symbol.for bridge to access native pi-ai provider registrations
35
+ * from jiti-loaded consolidation agents. */
36
+ streamFn?: (model: any, context: any, options: any) => any;
31
37
  maxTurns?: number;
32
38
  thinkingLevel?: ModelThinkingLevel;
33
39
  }
@@ -143,7 +149,16 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
143
149
  };
144
150
 
145
151
  const loop = args.agentLoop ?? agentLoop;
146
- const stream = loop(prompts, context, config, signal);
152
+ // ── Bridge stream function ──
153
+ const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
154
+ const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
155
+ const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
156
+ if (!providerStreams) return streamSimple(model, ctx, opts);
157
+ const customFn = model?.api ? providerStreams.get(model.api) : undefined;
158
+ return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
159
+ };
160
+ const streamFn = args.streamFn ?? bridgeStreamFn;
161
+ const stream = loop(prompts, context, config, signal, streamFn);
147
162
  let agentError: string | undefined;
148
163
  for await (const event of stream) {
149
164
  // Tool execution collects records.
@@ -159,3 +174,37 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
159
174
  if (agentError && accumulated.size === 0) throw new Error(`Reflector API error: ${agentError}`);
160
175
  return accumulated.size > 0 ? Array.from(accumulated.values()) : undefined;
161
176
  }
177
+
178
+ export function observationToReflectorLine(
179
+ observation: Observation,
180
+ coverage: ReflectionCoverageTier,
181
+ ): string {
182
+ return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] [coverage: ${coverage}] ${observation.content}`;
183
+ }
184
+
185
+ export function summarizeSupportIdCounts(reflections: readonly Reflection[]): {
186
+ reflectionCount: number;
187
+ totalSupportIds: number;
188
+ minSupportIds: number;
189
+ maxSupportIds: number;
190
+ averageSupportIds: number;
191
+ histogram: Record<string, number>;
192
+ } {
193
+ if (reflections.length === 0) {
194
+ return { reflectionCount: 0, totalSupportIds: 0, minSupportIds: 0, maxSupportIds: 0, averageSupportIds: 0, histogram: {} };
195
+ }
196
+ const supportIdCounts = reflections.map((r) => r.supportingObservationIds.length);
197
+ const total = supportIdCounts.reduce((sum, c) => sum + c, 0);
198
+ const histogram: Record<string, number> = {};
199
+ for (const count of supportIdCounts) {
200
+ histogram[String(count)] = (histogram[String(count)] || 0) + 1;
201
+ }
202
+ return {
203
+ reflectionCount: reflections.length,
204
+ totalSupportIds: total,
205
+ minSupportIds: Math.min(...supportIdCounts),
206
+ maxSupportIds: Math.max(...supportIdCounts),
207
+ averageSupportIds: total / reflections.length,
208
+ histogram,
209
+ };
210
+ }
@@ -49,7 +49,6 @@ import {
49
49
  rawTokensSinceReflectionCoverage,
50
50
  reflectionToSummaryLine,
51
51
  reflectionsCreatedAfterIndex,
52
- scoreObservation,
53
52
  selectPriorObservations,
54
53
  type Entry,
55
54
  type Observation,
@@ -57,10 +57,17 @@ function readCooldownMap(): CooldownMap {
57
57
  }
58
58
 
59
59
  function writeCooldownMap(map: CooldownMap): void {
60
- const path = cooldownPath();
61
- const dir = dirname(path);
62
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
63
- writeFileSync(path, `${JSON.stringify(map, null, 2)}\n`);
60
+ try {
61
+ const path = cooldownPath();
62
+ const dir = dirname(path);
63
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
64
+ writeFileSync(path, `${JSON.stringify(map, null, 2)}\n`);
65
+ } catch {
66
+ // Best-effort: cooldowns are advisory. Losing them means a rate-limited
67
+ // model might be retried before its cooldown window expires — slightly
68
+ // more API traffic, no data loss. This also prevents a process crash
69
+ // on read-only filesystems.
70
+ }
64
71
  }
65
72
 
66
73
  // ── API ─────────────────────────────────────────────────────────────────────