pi-blackhole 0.3.5 → 0.3.7
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 +0 -3
- package/package.json +1 -1
- package/src/commands/memory.ts +1 -1
- package/src/commands/vcc-recall.ts +16 -3
- package/src/core/recall-scope.ts +3 -3
- package/src/core/search-entries.ts +3 -6
- package/src/om/compaction-trigger.ts +54 -11
- package/src/tools/recall.ts +4 -7
package/README.md
CHANGED
|
@@ -244,7 +244,6 @@ The agent gets a unified `recall` tool that handles three types of input:
|
|
|
244
244
|
| `#N:path` | Drill-down into file content from a tool call (e.g. `#42:auth.ts` shows first 30 lines; `#42:auth.ts:30` shows next 30; `#42:auth.ts:full` shows everything) |
|
|
245
245
|
| Free text | BM25-ranked OR search across transcript and/or file content. Rare terms weighted higher. |
|
|
246
246
|
| `mode:file` | Search only write/edit file content |
|
|
247
|
-
| `mode:transcript` | Search only conversation text |
|
|
248
247
|
| `mode:touched` | Aggregate all files written/edited, grouped by path with entry indices |
|
|
249
248
|
| Regex | Pattern search (e.g. `fork.*pi-vcc`, `hook|inject`) |
|
|
250
249
|
| `scope:all` | Search across all session lineages, not just the active one |
|
|
@@ -453,7 +452,6 @@ The agent gets one unified tool that searches session history, expands entries,
|
|
|
453
452
|
| `#N:path` | Drill-down into file content from a tool call (e.g. `#42:auth.ts` shows first 30 lines; `#42:auth.ts:30` shows next 30; `#42:auth.ts:full` shows everything) |
|
|
454
453
|
| Free text | BM25-ranked OR search across transcript + file indicators. Rare terms weighted higher. |
|
|
455
454
|
| `mode:file` | Search only write/edit file content |
|
|
456
|
-
| `mode:transcript` | Search only conversation text |
|
|
457
455
|
| `mode:touched` | Aggregate all files written/edited across the session, grouped by path with entry indices |
|
|
458
456
|
| Regex | Pattern search (e.g. `fork.*pi-vcc`, `hook\|inject`) |
|
|
459
457
|
| `scope:all` | Search across all session lineages (default: active lineage only) |
|
|
@@ -470,7 +468,6 @@ Results are shown as a collapsible message and auto-fed to the agent as context.
|
|
|
470
468
|
/blackhole-recall hook|inject # regex
|
|
471
469
|
/blackhole-recall fail.*build scope:all # regex across all lineages
|
|
472
470
|
/blackhole-recall mode:file # search only write/edit file content
|
|
473
|
-
/blackhole-recall mode:transcript # search only conversation
|
|
474
471
|
/blackhole-recall mode:touched # aggregate view of all files touched
|
|
475
472
|
/blackhole-recall # recent 25 entries
|
|
476
473
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-blackhole",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"packageManager": "pnpm@11.2.2",
|
|
5
5
|
"description": "Unified compaction + observational memory extension for Pi — compresses conversation context while preserving durable observations and reflections",
|
|
6
6
|
"license": "MIT",
|
package/src/commands/memory.ts
CHANGED
|
@@ -36,7 +36,7 @@ function firstArg(args: unknown): string | undefined {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function pct(current: number, total: number): number {
|
|
39
|
-
return total > 0 ? Math.
|
|
39
|
+
return total > 0 ? Math.round((current / total) * 100) : 0;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function tokenSum(items: { tokenCount: number }[]): number {
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { loadAllMessages } from "../core/load-messages.js";
|
|
9
|
-
import { searchEntries } from "../core/search-entries.js";
|
|
10
|
-
import { formatRecallOutput } from "../core/format-recall.js";
|
|
9
|
+
import { searchEntries, getTouchedFiles } from "../core/search-entries.js";
|
|
10
|
+
import { formatRecallOutput, formatTouchedOutput } from "../core/format-recall.js";
|
|
11
11
|
import { getActiveLineageEntryIds } from "../core/lineage.js";
|
|
12
12
|
import { parseRecallScope } from "../core/recall-scope.js";
|
|
13
13
|
import {
|
|
@@ -41,7 +41,7 @@ async function augmentWithObservations(
|
|
|
41
41
|
export const registerVccRecallCommand = (pi: ExtensionAPI) => {
|
|
42
42
|
pi.registerCommand("blackhole-recall", {
|
|
43
43
|
description:
|
|
44
|
-
"Search session history. Defaults to active lineage. Usage: /blackhole-recall <query> [page:N] [scope:all] [mode:file|
|
|
44
|
+
"Search session history. Defaults to active lineage. Usage: /blackhole-recall <query> [page:N] [scope:all] [mode:file|touched]",
|
|
45
45
|
handler: async (args: string, ctx) => {
|
|
46
46
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
47
47
|
if (!sessionFile) {
|
|
@@ -57,6 +57,19 @@ export const registerVccRecallCommand = (pi: ExtensionAPI) => {
|
|
|
57
57
|
: undefined;
|
|
58
58
|
const mode = parsed.mode;
|
|
59
59
|
|
|
60
|
+
if (mode === "touched") {
|
|
61
|
+
const pageMatch = raw.match(/\bpage:(\d+)\b/i);
|
|
62
|
+
const page = pageMatch ? Math.max(1, parseInt(pageMatch[1], 10)) : 1;
|
|
63
|
+
const { rendered, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
64
|
+
const touched = getTouchedFiles(rawMessages, rendered);
|
|
65
|
+
const text = formatTouchedOutput(touched, page);
|
|
66
|
+
pi.sendMessage(
|
|
67
|
+
{ customType: "blackhole-recall", content: text, display: true },
|
|
68
|
+
{ triggerTurn: true },
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
if (!parsed.text) {
|
|
61
74
|
// No query: show recent entries
|
|
62
75
|
const { rendered } = loadAllMessages(sessionFile, false, lineageEntryIds);
|
package/src/core/recall-scope.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export type RecallScope = "lineage" | "all";
|
|
2
|
-
export type RecallMode = "hybrid" | "file" | "
|
|
2
|
+
export type RecallMode = "hybrid" | "file" | "touched";
|
|
3
3
|
|
|
4
4
|
const SCOPE_RE = /\bscope:(lineage|all)\b/i;
|
|
5
|
-
const MODE_RE = /\bmode:(hybrid|file|
|
|
5
|
+
const MODE_RE = /\bmode:(hybrid|file|touched)\b/i;
|
|
6
6
|
|
|
7
|
-
const VALID_MODES = new Set(["hybrid", "file", "
|
|
7
|
+
const VALID_MODES = new Set(["hybrid", "file", "touched"]);
|
|
8
8
|
|
|
9
9
|
export const normalizeRecallScope = (scope?: unknown): RecallScope =>
|
|
10
10
|
typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
|
|
@@ -196,10 +196,7 @@ const fullText = (msg: Message, mode?: RecallMode): string => {
|
|
|
196
196
|
if (mode === "file") {
|
|
197
197
|
return toolCallArgsText(msg.content);
|
|
198
198
|
}
|
|
199
|
-
|
|
200
|
-
return textOf(msg.content);
|
|
201
|
-
}
|
|
202
|
-
// hybrid (default): both
|
|
199
|
+
// hybrid (default): both transcript text + tool call args
|
|
203
200
|
const text = textOf(msg.content);
|
|
204
201
|
const toolArgs = toolCallArgsText(msg.content);
|
|
205
202
|
return toolArgs ? `${text}\n${toolArgs}` : text;
|
|
@@ -330,7 +327,7 @@ export const searchEntries = (
|
|
|
330
327
|
const hay = `${e.role} ${text} ${filePart}`;
|
|
331
328
|
if (regex.test(hay)) {
|
|
332
329
|
const snip = lineSnippet(text, regex);
|
|
333
|
-
const fileMatches =
|
|
330
|
+
const fileMatches = computeFileMatches(msg, rawQuery);
|
|
334
331
|
const extra = fileMatches.length > 0 ? { fileMatches } : {};
|
|
335
332
|
hits.push({ ...e, snippet: snip, matchCount: 1, ...extra });
|
|
336
333
|
}
|
|
@@ -366,7 +363,7 @@ export const searchEntries = (
|
|
|
366
363
|
const score = bm25Score(hay, terms, ctx);
|
|
367
364
|
const text = fullTextCache[i];
|
|
368
365
|
const snip = lineSnippet(text, snipRe);
|
|
369
|
-
const fileMatches =
|
|
366
|
+
const fileMatches = computeFileMatches(messages[i], rawQuery);
|
|
370
367
|
const extra = fileMatches.length > 0 ? { fileMatches } : {};
|
|
371
368
|
scored.push({
|
|
372
369
|
hit: { ...e, snippet: snip, matchCount: mc, ...extra },
|
|
@@ -4,9 +4,41 @@ import type { Runtime } from "./runtime.js";
|
|
|
4
4
|
import { debugLog } from "./debug-log.js";
|
|
5
5
|
import { RETRYABLE_ERROR_RE } from "./retryable-error.js";
|
|
6
6
|
|
|
7
|
+
function getErrorMessage(error: unknown): string {
|
|
8
|
+
if (error instanceof Error) return error.message;
|
|
9
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
10
|
+
return String((error as { message: unknown }).message);
|
|
11
|
+
}
|
|
12
|
+
return String(error);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isStaleExtensionContextError(error: unknown): boolean {
|
|
16
|
+
const message = getErrorMessage(error);
|
|
17
|
+
return message.includes("extension ctx is stale") || message.includes("ctx is stale");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" | "warning" | "error"): void {
|
|
21
|
+
if (!hasUI) return;
|
|
22
|
+
try {
|
|
23
|
+
ui?.notify(message, level);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (!isStaleExtensionContextError(error)) throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
7
29
|
export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
8
30
|
pi.on("agent_end", (event: any, ctx: any) => {
|
|
9
|
-
|
|
31
|
+
try {
|
|
32
|
+
handleAgentEnd(event, ctx, runtime);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (isStaleExtensionContextError(error)) return;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
41
|
+
runtime.ensureConfig(ctx.cwd);
|
|
10
42
|
|
|
11
43
|
// Pass the config flag explicitly — this handler runs outside ALS context
|
|
12
44
|
// (agent_end events don't flow through consolidation's withDebugLogContext),
|
|
@@ -93,7 +125,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
93
125
|
|
|
94
126
|
dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
|
|
95
127
|
|
|
96
|
-
|
|
128
|
+
notifySafely(
|
|
129
|
+
hasUI,
|
|
130
|
+
ui,
|
|
97
131
|
`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
|
|
98
132
|
"info",
|
|
99
133
|
);
|
|
@@ -110,7 +144,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
110
144
|
if (currentSessionId !== sessionId) {
|
|
111
145
|
runtime.compactInFlight = false;
|
|
112
146
|
dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
|
|
113
|
-
|
|
147
|
+
notifySafely(
|
|
148
|
+
hasUI,
|
|
149
|
+
ui,
|
|
114
150
|
"Observational memory: compaction cancelled — session changed before compaction",
|
|
115
151
|
"info",
|
|
116
152
|
);
|
|
@@ -122,7 +158,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
122
158
|
if (!isIdle) {
|
|
123
159
|
runtime.compactInFlight = false;
|
|
124
160
|
dbg("compaction_trigger.microtask.bail", { reason: "not_idle" });
|
|
125
|
-
|
|
161
|
+
notifySafely(
|
|
162
|
+
hasUI,
|
|
163
|
+
ui,
|
|
126
164
|
"Observational memory: compaction deferred — agent became busy before compaction",
|
|
127
165
|
"info",
|
|
128
166
|
);
|
|
@@ -134,7 +172,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
134
172
|
if (currentTokens < runtime.config.compactAfterTokens) {
|
|
135
173
|
runtime.compactInFlight = false;
|
|
136
174
|
dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
|
|
137
|
-
|
|
175
|
+
notifySafely(
|
|
176
|
+
hasUI,
|
|
177
|
+
ui,
|
|
138
178
|
"Observational memory: compaction skipped — another compaction already ran before deferred compaction",
|
|
139
179
|
"info",
|
|
140
180
|
);
|
|
@@ -146,7 +186,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
146
186
|
onComplete: (result: any) => {
|
|
147
187
|
runtime.compactInFlight = false;
|
|
148
188
|
dbg("compaction_trigger.onComplete", { result: !!result });
|
|
149
|
-
|
|
189
|
+
notifySafely(hasUI, ui, "Observational memory: compaction complete", "info");
|
|
150
190
|
},
|
|
151
191
|
onError: (error: { message: string }) => {
|
|
152
192
|
runtime.compactInFlight = false;
|
|
@@ -155,15 +195,18 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
155
195
|
// We already notified the user with the real reason before returning { cancel: true }.
|
|
156
196
|
return;
|
|
157
197
|
}
|
|
158
|
-
|
|
198
|
+
notifySafely(hasUI, ui, `Observational memory: ${error.message}`, "error");
|
|
159
199
|
},
|
|
160
200
|
});
|
|
161
201
|
} catch (error) {
|
|
162
202
|
runtime.compactInFlight = false;
|
|
163
|
-
const msg =
|
|
203
|
+
const msg = getErrorMessage(error);
|
|
204
|
+
if (isStaleExtensionContextError(error)) {
|
|
205
|
+
dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx", message: msg });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
164
208
|
dbg("compaction_trigger.microtask.error", { message: msg });
|
|
165
|
-
|
|
209
|
+
notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
|
|
166
210
|
}
|
|
167
|
-
|
|
168
|
-
});
|
|
211
|
+
}, 0);
|
|
169
212
|
}
|
package/src/tools/recall.ts
CHANGED
|
@@ -238,13 +238,10 @@ export function registerRecallTool(pi: ExtensionAPI): void {
|
|
|
238
238
|
"Search session history and earlier lines omitted, file write/edit content by text/regex. " +
|
|
239
239
|
"Expand entries (#N), drill-down file content (#N:path) with paging, or aggregate touched files (mode:touched).",
|
|
240
240
|
promptSnippet:
|
|
241
|
-
"recall: Search session history + file write/edit content by text/regex. #N expand, #N:path drill-down with offset
|
|
241
|
+
"recall: Search session history + file write/edit content by text/regex. #N expand, #N:path drill-down with optional :offset:limit or :full, mode:file/touched.",
|
|
242
242
|
promptGuidelines: [
|
|
243
|
-
"Use recall —
|
|
244
|
-
"Use recall —
|
|
245
|
-
"Use recall — drill-down supports paging: #42:auth.ts shows first 30 lines, #42:auth.ts:30 shows next 30, #42:auth.ts:full shows everything. Note: edit diffs are not indexed for text search — drill-down reads them from raw JSONL.",
|
|
246
|
-
"Use recall — when a drill-down path matches multiple files, options are listed. Narrow with a more specific path substring.",
|
|
247
|
-
"Use recall — hex observation/reflection ids (12-char hex) link memory evidence to session entries with cross-references for navigation.",
|
|
243
|
+
"Use recall — literal text/regex search across session history and file write/edit content. #N expands an entry; #N:path with optional :offset:limit or :full drills down into file content; 12-char hex ids recover observation/reflection sources. mode:file for file-content-only, mode:touched for aggregated files-by-path. scope:'all' to search the full session. If no results, try fewer terms or a regex pattern.",
|
|
244
|
+
"Use recall — when a drill-down path matches multiple files, options are listed. Narrow with a more specific path substring. Only full-file writes are indexed for text search (edit diffs are not).",
|
|
248
245
|
],
|
|
249
246
|
parameters: Type.Object({
|
|
250
247
|
query: Type.Optional(
|
|
@@ -260,7 +257,7 @@ export function registerRecallTool(pi: ExtensionAPI): void {
|
|
|
260
257
|
StringEnum(["lineage", "all"] as const, { description: "Search scope. lineage = active lineage (default), all = entire session." }),
|
|
261
258
|
),
|
|
262
259
|
mode: Type.Optional(
|
|
263
|
-
StringEnum(["hybrid", "file", "
|
|
260
|
+
StringEnum(["hybrid", "file", "touched"] as const, { description: "What content to search. hybrid (default) = all session content. file = file content only. touched = files-by-path summary with entry indices." }),
|
|
264
261
|
),
|
|
265
262
|
}),
|
|
266
263
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|