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 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.5",
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",
@@ -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.min(100, Math.round((current / total) * 100)) : 0;
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|transcript]",
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);
@@ -1,10 +1,10 @@
1
1
  export type RecallScope = "lineage" | "all";
2
- export type RecallMode = "hybrid" | "file" | "transcript" | "touched";
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|transcript|touched)\b/i;
5
+ const MODE_RE = /\bmode:(hybrid|file|touched)\b/i;
6
6
 
7
- const VALID_MODES = new Set(["hybrid", "file", "transcript", "touched"]);
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
- if (mode === "transcript") {
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 = mode === "transcript" ? [] : computeFileMatches(msg, rawQuery);
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 = mode === "transcript" ? [] : computeFileMatches(messages[i], rawQuery);
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
- runtime.ensureConfig(ctx.cwd);
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
- if (hasUI) ui?.notify(
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
- if (hasUI) ui?.notify(
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
- if (hasUI) ui?.notify(
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
- if (hasUI) ui?.notify(
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
- if (hasUI) ui?.notify("Observational memory: compaction complete", "info");
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
- if (hasUI) ui?.notify(`Observational memory: ${error.message}`, "error");
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 = error instanceof Error ? error.message : String(error);
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
- if (hasUI) ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
209
+ notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
166
210
  }
167
- }, 0);
168
- });
211
+ }, 0);
169
212
  }
@@ -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/limit paging, mode:file/transcript/touched.",
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 — search is literal text/regex matching, NOT semantic. If no results, try different terms or a regex pattern. Set scope:'all' to search the full session.",
244
- "Use recall — mode:file to search only write/edit file content, mode:transcript for conversation-only, mode:touched for aggregate view of all files written/edited across the session.",
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", "transcript", "touched"] as const, { description: "What content to search. hybrid (default) = transcript + file indicators. file = write/edit file content only. transcript = conversation only. touched = aggregate files grouped by path (not per-entry search)." }),
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) {