pi-blackhole 0.2.3 → 0.3.1
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 +244 -113
- package/example-config.json +6 -2
- package/index.ts +45 -0
- package/package.json +12 -5
- package/src/commands/pi-vcc.ts +18 -8
- package/src/commands/vcc-recall.ts +3 -2
- package/src/core/build-sections.ts +1 -1
- package/src/core/content.ts +70 -0
- package/src/core/drill-down.ts +273 -0
- package/src/core/format-recall.ts +107 -3
- package/src/core/load-messages.ts +3 -2
- package/src/core/recall-scope.ts +15 -4
- package/src/core/search-entries.ts +155 -10
- package/src/core/unified-config.ts +29 -17
- package/src/hooks/before-compact.ts +27 -1
- package/src/om/agents/dropper/agent.ts +16 -2
- package/src/om/agents/observer/agent.ts +22 -2
- package/src/om/agents/reflector/agent.ts +51 -2
- package/src/om/compaction-trigger.ts +59 -9
- package/src/om/consolidation.ts +0 -1
- package/src/om/cooldown.ts +11 -4
- package/src/om/debug-log.ts +5 -4
- package/src/om/pending.ts +8 -4
- package/src/tools/recall.ts +120 -44
- package/vitest.config.ts +1 -23
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
256
|
-
|
|
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
|
-
*
|
|
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
|
}
|
|
@@ -14,6 +14,7 @@ import { compile } from "../core/summarize";
|
|
|
14
14
|
import type { PiVccCompactionDetails } from "../details";
|
|
15
15
|
import { buildCompactionProjection, renderSummary } from "../om/ledger/index.js";
|
|
16
16
|
import type { Runtime } from "../om/runtime.js";
|
|
17
|
+
import { debugLog } from "../om/debug-log.js";
|
|
17
18
|
|
|
18
19
|
export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
|
|
19
20
|
|
|
@@ -141,14 +142,28 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
|
|
|
141
142
|
pi.on("session_before_compact", (event, ctx) => {
|
|
142
143
|
const { preparation, branchEntries, customInstructions } = event;
|
|
143
144
|
omRuntime.ensureConfig(ctx.cwd ?? process.cwd());
|
|
145
|
+
const trace = (ev: string, d?: Record<string, unknown>) => debugLog(ev, d, omRuntime.config.debugLog === true);
|
|
146
|
+
|
|
147
|
+
trace("before_compact.enter", {
|
|
148
|
+
customInstructions,
|
|
149
|
+
isPiVcc: customInstructions === PI_VCC_COMPACT_INSTRUCTION,
|
|
150
|
+
overrideDefaultCompaction: omRuntime.config.overrideDefaultCompaction,
|
|
151
|
+
noAutoCompact: omRuntime.config.noAutoCompact,
|
|
152
|
+
branchLength: branchEntries.length,
|
|
153
|
+
hasPreviousSummary: !!preparation.previousSummary,
|
|
154
|
+
});
|
|
144
155
|
|
|
145
156
|
// Always handle explicit /blackhole marker.
|
|
146
157
|
// Otherwise, only handle when user opted in via settings.
|
|
147
158
|
const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
|
|
148
|
-
if (!isPiVcc && !omRuntime.config.overrideDefaultCompaction)
|
|
159
|
+
if (!isPiVcc && !omRuntime.config.overrideDefaultCompaction) {
|
|
160
|
+
trace("before_compact.return_early", { reason: "overrideDefaultCompaction=false and not /blackhole" });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
149
163
|
|
|
150
164
|
// When noAutoCompact is active, only /blackhole can trigger compaction
|
|
151
165
|
if (omRuntime.config.noAutoCompact && !isPiVcc) {
|
|
166
|
+
trace("before_compact.cancel", { reason: "noAutoCompact and not /blackhole" });
|
|
152
167
|
return { cancel: true };
|
|
153
168
|
}
|
|
154
169
|
|
|
@@ -212,12 +227,20 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
|
|
|
212
227
|
})),
|
|
213
228
|
});
|
|
214
229
|
|
|
230
|
+
trace("before_compact.cancel", { reason: ownCut.reason, isPiVcc });
|
|
215
231
|
try {
|
|
216
232
|
ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning");
|
|
217
233
|
} catch {}
|
|
218
234
|
return { cancel: true };
|
|
219
235
|
}
|
|
220
236
|
|
|
237
|
+
trace("before_compact.proceeding", {
|
|
238
|
+
messageCount: ownCut.messages.length,
|
|
239
|
+
firstKeptEntryId: ownCut.firstKeptEntryId,
|
|
240
|
+
compactAll: ownCut.compactAll,
|
|
241
|
+
isPiVcc,
|
|
242
|
+
});
|
|
243
|
+
|
|
221
244
|
const agentMessages = ownCut.messages;
|
|
222
245
|
const firstKeptEntryId = ownCut.firstKeptEntryId;
|
|
223
246
|
const messages = convertToLlm(agentMessages);
|
|
@@ -278,6 +301,8 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
|
|
|
278
301
|
sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
|
|
279
302
|
});
|
|
280
303
|
|
|
304
|
+
trace("before_compact.summary_generated", { summaryLength: summary.length, messageCount: agentMessages.length });
|
|
305
|
+
|
|
281
306
|
const details: PiVccCompactionDetails = {
|
|
282
307
|
compactor: "blackhole",
|
|
283
308
|
version: 1,
|
|
@@ -291,6 +316,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
|
|
|
291
316
|
// ── Inject observational-memory content ───────────────────────────
|
|
292
317
|
let omContent = "";
|
|
293
318
|
let omDetails: Record<string, unknown> | undefined;
|
|
319
|
+
trace("before_compact.om_injection", { memoryEnabled: omRuntime.config.memory !== false });
|
|
294
320
|
if (omRuntime.config.memory !== false) {
|
|
295
321
|
const projection = buildCompactionProjection(
|
|
296
322
|
branchEntries as any[],
|
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { rawTokensSinceLastCompaction, type Entry } from "./ledger/index.js";
|
|
3
3
|
import type { Runtime } from "./runtime.js";
|
|
4
|
+
import { debugLog } from "./debug-log.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Regex matching Pi's internal retryable error detection.
|
|
@@ -13,10 +14,36 @@ const RETRYABLE_ERROR_RE =
|
|
|
13
14
|
export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
14
15
|
pi.on("agent_end", (event: any, ctx: any) => {
|
|
15
16
|
runtime.ensureConfig(ctx.cwd);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
|
|
18
|
+
// Pass the config flag explicitly — this handler runs outside ALS context
|
|
19
|
+
// (agent_end events don't flow through consolidation's withDebugLogContext),
|
|
20
|
+
// and the setTimeout callback would lose ALS context anyway.
|
|
21
|
+
const dbg = (ev: string, d?: Record<string, unknown>) => debugLog(ev, d, runtime.config.debugLog === true);
|
|
22
|
+
|
|
23
|
+
dbg("compaction_trigger.agent_end", {
|
|
24
|
+
passive: runtime.config.passive,
|
|
25
|
+
memory: runtime.config.memory,
|
|
26
|
+
noAutoCompact: runtime.config.noAutoCompact,
|
|
27
|
+
compactInFlight: runtime.compactInFlight,
|
|
28
|
+
compactAfterTokens: runtime.config.compactAfterTokens,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (runtime.config.passive === true) {
|
|
32
|
+
dbg("compaction_trigger.skip", { reason: "passive" });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (runtime.config.memory === false) {
|
|
36
|
+
dbg("compaction_trigger.skip", { reason: "memory" });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (runtime.config.noAutoCompact === true) {
|
|
40
|
+
dbg("compaction_trigger.skip", { reason: "noAutoCompact" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (runtime.compactInFlight) {
|
|
44
|
+
dbg("compaction_trigger.skip", { reason: "compactInFlight" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
20
47
|
|
|
21
48
|
// Don't trigger compaction if Pi will auto-retry — the agent hasn't truly finished.
|
|
22
49
|
// Pi emits agent_end before its own retry check, so we must detect this ourselves.
|
|
@@ -34,8 +61,14 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
34
61
|
}
|
|
35
62
|
|
|
36
63
|
const entries = ctx.sessionManager.getBranch() as Entry[];
|
|
64
|
+
dbg("compaction_trigger.branch_check", { branchLength: entries.length, hasLastEntry: entries.length > 0, lastEntryType: entries.length > 0 ? entries[entries.length - 1].type : "none" });
|
|
65
|
+
|
|
37
66
|
const tokens = rawTokensSinceLastCompaction(entries);
|
|
38
|
-
|
|
67
|
+
dbg("compaction_trigger.tokens", { tokens, compactAfterTokens: runtime.config.compactAfterTokens, branchLength: entries.length });
|
|
68
|
+
if (tokens < runtime.config.compactAfterTokens) {
|
|
69
|
+
dbg("compaction_trigger.skip", { reason: "below_threshold", tokens, threshold: runtime.config.compactAfterTokens });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
39
72
|
|
|
40
73
|
// Capture ctx properties synchronously — the deferred callback below
|
|
41
74
|
// may outlive the extension ctx (stale after session replacement/reload).
|
|
@@ -43,18 +76,25 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
43
76
|
const ui = ctx.ui;
|
|
44
77
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
45
78
|
|
|
79
|
+
dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
|
|
80
|
+
|
|
46
81
|
if (hasUI) ui?.notify(
|
|
47
82
|
`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
|
|
48
83
|
"info",
|
|
49
84
|
);
|
|
50
85
|
|
|
51
86
|
runtime.compactInFlight = true;
|
|
52
|
-
|
|
87
|
+
dbg("compaction_trigger.scheduled", { compactInFlight: runtime.compactInFlight });
|
|
88
|
+
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
dbg("compaction_trigger.microtask.enter", {});
|
|
53
91
|
try {
|
|
54
92
|
// Validate session identity — bail if the session was replaced/reloaded.
|
|
55
93
|
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
94
|
+
dbg("compaction_trigger.microtask.session_check", { currentSessionId, expectedSessionId: sessionId, match: currentSessionId === sessionId });
|
|
56
95
|
if (currentSessionId !== sessionId) {
|
|
57
96
|
runtime.compactInFlight = false;
|
|
97
|
+
dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
|
|
58
98
|
if (hasUI) ui?.notify(
|
|
59
99
|
"Observational memory: compaction cancelled — session changed before compaction",
|
|
60
100
|
"info",
|
|
@@ -62,8 +102,11 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
62
102
|
return;
|
|
63
103
|
}
|
|
64
104
|
|
|
65
|
-
|
|
105
|
+
const isIdle = ctx.isIdle();
|
|
106
|
+
dbg("compaction_trigger.microtask.idle_check", { isIdle });
|
|
107
|
+
if (!isIdle) {
|
|
66
108
|
runtime.compactInFlight = false;
|
|
109
|
+
dbg("compaction_trigger.microtask.bail", { reason: "not_idle" });
|
|
67
110
|
if (hasUI) ui?.notify(
|
|
68
111
|
"Observational memory: compaction deferred — agent became busy before compaction",
|
|
69
112
|
"info",
|
|
@@ -72,21 +115,27 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
72
115
|
}
|
|
73
116
|
const currentEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
74
117
|
const currentTokens = rawTokensSinceLastCompaction(currentEntries);
|
|
118
|
+
dbg("compaction_trigger.microtask.recheck_tokens", { currentTokens, threshold: runtime.config.compactAfterTokens, ok: currentTokens >= runtime.config.compactAfterTokens });
|
|
75
119
|
if (currentTokens < runtime.config.compactAfterTokens) {
|
|
76
120
|
runtime.compactInFlight = false;
|
|
121
|
+
dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
|
|
77
122
|
if (hasUI) ui?.notify(
|
|
78
123
|
"Observational memory: compaction skipped — another compaction already ran before deferred compaction",
|
|
79
124
|
"info",
|
|
80
125
|
);
|
|
81
126
|
return;
|
|
82
127
|
}
|
|
128
|
+
|
|
129
|
+
dbg("compaction_trigger.microtask.calling_compact", {});
|
|
83
130
|
ctx.compact({
|
|
84
|
-
onComplete: () => {
|
|
131
|
+
onComplete: (result: any) => {
|
|
85
132
|
runtime.compactInFlight = false;
|
|
133
|
+
dbg("compaction_trigger.onComplete", { result: !!result });
|
|
86
134
|
if (hasUI) ui?.notify("Observational memory: compaction complete", "info");
|
|
87
135
|
},
|
|
88
136
|
onError: (error: { message: string }) => {
|
|
89
137
|
runtime.compactInFlight = false;
|
|
138
|
+
dbg("compaction_trigger.onError", { message: error?.message ?? String(error) });
|
|
90
139
|
if (error.message === "Compaction cancelled") {
|
|
91
140
|
// We already notified the user with the real reason before returning { cancel: true }.
|
|
92
141
|
return;
|
|
@@ -97,8 +146,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
97
146
|
} catch (error) {
|
|
98
147
|
runtime.compactInFlight = false;
|
|
99
148
|
const msg = error instanceof Error ? error.message : String(error);
|
|
149
|
+
dbg("compaction_trigger.microtask.error", { message: msg });
|
|
100
150
|
if (hasUI) ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
|
|
101
151
|
}
|
|
102
|
-
});
|
|
152
|
+
}, 0);
|
|
103
153
|
});
|
|
104
154
|
}
|