pi-blackhole 0.3.4 → 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.
Files changed (43) hide show
  1. package/README.md +13 -16
  2. package/example-config.json +19 -27
  3. package/package.json +1 -1
  4. package/src/commands/memory.ts +1 -1
  5. package/src/commands/pi-vcc.ts +3 -9
  6. package/src/commands/vcc-recall.ts +16 -3
  7. package/src/core/brief.ts +18 -13
  8. package/src/core/build-sections.ts +1 -2
  9. package/src/core/content.ts +8 -5
  10. package/src/core/drill-down.ts +2 -9
  11. package/src/core/filter-noise.ts +7 -10
  12. package/src/core/format.ts +25 -25
  13. package/src/core/load-messages.ts +71 -2
  14. package/src/core/normalize.ts +10 -4
  15. package/src/core/recall-scope.ts +3 -3
  16. package/src/core/render-entries.ts +10 -2
  17. package/src/core/search-entries.ts +12 -7
  18. package/src/core/settings.ts +3 -34
  19. package/src/core/summarize.ts +33 -13
  20. package/src/core/tool-args.ts +4 -1
  21. package/src/core/unified-config.ts +27 -25
  22. package/src/extract/commits.ts +3 -2
  23. package/src/extract/files.ts +6 -4
  24. package/src/hooks/before-compact.ts +7 -3
  25. package/src/om/agents/dropper/agent.ts +2 -7
  26. package/src/om/agents/observer/agent.ts +11 -13
  27. package/src/om/agents/reflector/agent.ts +2 -7
  28. package/src/om/compaction-trigger.ts +54 -18
  29. package/src/om/configure-overlay.ts +5 -1
  30. package/src/om/consolidation.ts +35 -13
  31. package/src/om/cooldown.ts +15 -12
  32. package/src/om/debug-log.ts +79 -11
  33. package/src/om/key-matcher.ts +6 -32
  34. package/src/om/ledger/fold.ts +8 -0
  35. package/src/om/ledger/render-summary.ts +16 -15
  36. package/src/om/ledger/types.ts +1 -1
  37. package/src/om/pending.ts +38 -5
  38. package/src/om/provider-stream.ts +17 -0
  39. package/src/om/retryable-error.ts +26 -0
  40. package/src/om/reverse-recall.ts +9 -1
  41. package/src/om/runtime.ts +39 -18
  42. package/src/sections.ts +0 -3
  43. package/src/tools/recall.ts +11 -9
package/README.md CHANGED
@@ -160,6 +160,11 @@ Root cause is a missing token refresh...
160
160
 
161
161
  ---
162
162
 
163
+ ---
164
+ The conversation before this point has been compacted into the summary above.
165
+ Details not captured here — exact code, error messages, file paths — are only recoverable via `recall`.
166
+ Use `recall` to search the session history. Do not redo work already completed.
167
+
163
168
  ## Reflections
164
169
  [c3d4e5f6a1b2] User is building Acme Dashboard on Next.js 15 with Supabase auth.
165
170
 
@@ -168,20 +173,14 @@ Root cause is a missing token refresh...
168
173
  [b2c3d4e5f6a1] 2026-05-23 [medium] GraphQL migration completed; user confirmed working.
169
174
 
170
175
  ----
171
- Use `recall` to search for prior work, decisions, and context from before this summary. Do not redo work already completed.
172
-
173
- These are condensed memories from earlier in this session.
174
-
175
- - Reflections: stable, long-lived facts about the user, project, decisions, and constraints.
176
- - Observations: timestamped events from the conversation history, in chronological order.
177
-
178
- Treat these as past records. When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
179
-
180
- When exact source context is needed for precision or traceability, use the `recall` tool with the relevant observation or reflection id.
176
+ Bracketed ids in reflections and observations connect to their source session entries. These are condensed memories from earlier in this session.
177
+ When entries conflict, the most recent observation reflects the latest known state.
178
+ Use `recall` with an id to retrieve original context, or `#N:path` drill-down to explore file content from referenced entries.
179
+ When exact source context is needed for precision or traceability, use the `recall` tool with the relevant observation or reflection id. This is especially useful when a reflection materially affects a decision or is too compressed to continue confidently.
181
180
  ----
182
181
  ```
183
182
 
184
- > **Note:** The OM injection format uses `## Reflections` and `## Observations` Markdown headers with a preamble and footer. Each observation and reflection has a 12-char hex identifier you can use with the `recall` tool to recover source evidence, as well as the agent can search based on them and get relevant context back.
183
+ > **Note:** The OM injection format uses `## Reflections` and `## Observations` Markdown headers followed by a brief footer. Each observation and reflection has a 12-char hex identifier you can use with the `recall` tool to recover source evidence, as well as the agent can search based on them and get relevant context back. When no observations or reflections exist, only a short recall-guidance footer is appended.
185
184
 
186
185
  ---
187
186
 
@@ -245,7 +244,6 @@ The agent gets a unified `recall` tool that handles three types of input:
245
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) |
246
245
  | Free text | BM25-ranked OR search across transcript and/or file content. Rare terms weighted higher. |
247
246
  | `mode:file` | Search only write/edit file content |
248
- | `mode:transcript` | Search only conversation text |
249
247
  | `mode:touched` | Aggregate all files written/edited, grouped by path with entry indices |
250
248
  | Regex | Pattern search (e.g. `fork.*pi-vcc`, `hook|inject`) |
251
249
  | `scope:all` | Search across all session lineages, not just the active one |
@@ -254,7 +252,7 @@ The agent gets a unified `recall` tool that handles three types of input:
254
252
 
255
253
  ## Configuration
256
254
 
257
- All settings in a single JSON file: **`~/.pi/agent/pi-blackhole/pi-blackhole-config.json`** — auto-created with defaults on first startup. See [`CONFIG.md`](CONFIG.md) for the full reference with detailed explanations for every knob. An annotated example config with the new config surface is at [`example-config-v2.json`](example-config-v2.json). The legacy example is at [`example-config.json`](example-config.json).
255
+ All settings in a single JSON file: **`~/.pi/agent/pi-blackhole/pi-blackhole-config.json`** — auto-created with defaults on first startup. See [`CONFIG.md`](CONFIG.md) for the full reference with detailed explanations for every knob. An annotated example config is at [`example-config.json`](example-config.json).
258
256
 
259
257
  Quick start — just set custom models (if you want):
260
258
 
@@ -274,12 +272,13 @@ Everything else has sensible defaults.
274
272
  |---|---|---|
275
273
  | `compaction` | `"auto"` | When compaction triggers: `"auto"` (blackhole auto-fires), `"manual"` (only `/blackhole`), `"off"` (Pi handles auto + `/compact`, `/blackhole` still works) |
276
274
  | `compactionEngine` | `"blackhole"` | Which engine handles auto-compaction: `"blackhole"` or `"pi-default"`. Only meaningful when `compaction: "auto"` — for `"manual"`/`"off"` the hook lets Pi handle everything except `/blackhole` |
277
- | `tailBehavior` | `"minimal"` | How much of the transcript stays visible after compaction: `"minimal"` (last user message only, default) or `"pi-default"` (gentle, ~20k tokens) |
275
+ | `tailBehavior` | `"minimal"` | How much stays visible after compaction: `"minimal"` (last user message only, default) or `"pi-default"` (gentle, ~20k tokens). Both `/blackhole` and auto-triggered default to `"minimal"`; set explicitly to opt into gentler cut |
278
276
  | `memory` | `true` | `false` = OM workers off + no memory injection (compaction still runs) |
279
277
  | `model` | — | Base fallback model for all workers (last resort before session model) |
280
278
  | `observerModel` / `observerFallbackModels` | — / `[]` | Primary + fallback models for observer (extracts facts) |
281
279
  | `reflectorModel` / `reflectorFallbackModels` | — / `[]` | Primary + fallback models for reflector (synthesizes reflections) |
282
280
  | `dropperModel` / `dropperFallbackModels` | — / `[]` | Primary + fallback models for dropper (prunes observations) |
281
+ | `sessionFallback` | `true` | When false, skip session model fallback when all OM model candidates are exhausted. Default true for backward compatibility. |
283
282
  | *(per model)* `thinking` | `"low"` | Thinking/reasoning level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
284
283
  | *(per model)* `cooldownHours` | `1` | How long to skip this model after a retryable error |
285
284
  | *(per model)* `contextWindow` | *(inherited from Pi)* | Override context window for this model. If unset, inherits from Pi's model registry. When set, the OM pipeline checks if the estimated input fits before calling the model — if not, the next fallback is tried. |
@@ -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
  ```
@@ -1,10 +1,12 @@
1
1
  {
2
- "_comment": "blackhole unified configuration — ~/.pi/agent/pi-blackhole/pi-blackhole-config.json",
2
+ "_comment": "blackhole unified configuration v2 — ~/.pi/agent/pi-blackhole/pi-blackhole-config.json",
3
3
  "_all_settings_in_one_file": true,
4
+ "_new_keys": "compaction, compactionEngine, tailBehavior, sessionFallback — new config surface",
5
+ "_renamed": "was example-config-v2.json; old example-config.json → example-config-old.json",
4
6
 
5
- "overrideDefaultCompaction": false,
6
- "noAutoCompact": false,
7
- "debug": false,
7
+ "compaction": "auto",
8
+ "compactionEngine": "blackhole",
9
+ "tailBehavior": "minimal",
8
10
 
9
11
  "model": {
10
12
  "_comment": "Base model (last fallback before session model).",
@@ -98,33 +100,23 @@
98
100
  "agentMaxTurns": 16,
99
101
 
100
102
  "memory": true,
101
- "passive": false,
102
- "noAutoCompact": false,
103
+ "sessionFallback": true,
104
+ "debug": false,
103
105
  "debugLog": false,
104
106
 
105
107
  "_notes": [
106
- "Model fallback chains per worker:",
107
- " Observer: qwen3-next-80b gemma4:31b-cloud google/gemma-4-31b-it:free → base model → session",
108
- " Reflector: gpt-oss-120b (cerebras) → glm-4.7 (z.ai) openai/gpt-oss-120b:free base model → session",
109
- " Dropper: gpt-oss-120b (cerebras) → glm-4.7 (z.ai) openai/gpt-oss-120b:free base model → session",
110
- "",
111
- "On retryable error (429, 5xx, timeout), the model is cooled down for cooldownHours.",
112
- "Cooled-down models are skipped on subsequent consolidation attempts until cooldown expires.",
113
- "After all candidates exhausted, the stage aborts. 30s retry gate prevents spam.",
114
- "Cooldowns persist in ~/.pi/agent/pi-blackhole/pi-blackhole-cooldown.json (survives pi restarts).",
115
- "Valid thinking values: off, minimal, low, medium, high, xhigh.",
116
- "Env override for passive mode: PI_BLACKHOLE_PASSIVE=true.",
108
+ "v2 config surface:",
109
+ " compaction (auto|manual|off) how compaction triggers",
110
+ " compactionEngine (blackhole|pi-default) who handles it",
111
+ " tailBehavior (pi-default|minimal) how much stays visible",
112
+ " sessionFallback (true|false) — if false, skip session model as last-resort fallback",
117
113
  "",
118
- "noAutoCompact mode accumulates observationBatches/reflectionBatches in",
119
- "<sessionId>-pending.json (per-session) across pipeline runs.",
120
- "On /blackhole flush, all accumulated batches are written to the branch and the file is cleared.",
121
- "The observer preamble (CURRENT OBSERVATIONS)",
122
- "is capped to observerPreambleMaxTokens (default 0 = auto 30% of observerChunkMaxTokens).",
123
- "This is a soft safety valve — it only trims when accumulated observations exceed",
124
- "the budget. High-relevance observations are always kept. Reflections are never trimmed.",
114
+ "Backward compat: old keys (overrideDefaultCompaction, noAutoCompact, passive)",
115
+ "are migrated to new keys at load time.",
125
116
  "",
126
- "agentMaxTurns controls how many agent-loop turns each worker gets per chunk.",
127
- "Lower-context models can reduce this (e.g., 8) so the observer doesn't waste",
128
- "turns trying to extract more than it can reliably handle per run."
117
+ "Env overrides:",
118
+ " PI_BLACKHOLE_PASSIVE=1 → compaction:off + memory:false",
119
+ " PI_BLACKHOLE_COMPACTION → override compaction setting",
120
+ " PI_BLACKHOLE_COMPACTION_ENGINE → override compactionEngine setting"
129
121
  ]
130
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-blackhole",
3
- "version": "0.3.4",
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 {
@@ -24,14 +24,8 @@ const formatTokens = (n: number): string => {
24
24
  };
25
25
 
26
26
  export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
27
- const fuzzyMatch = (value: string, prefix: string): boolean => {
28
- const q = prefix.toLowerCase();
29
- let qi = 0;
30
- for (const ch of value) {
31
- if (qi < q.length && ch === q[qi]) qi++;
32
- if (qi === q.length) return true;
33
- }
34
- return false;
27
+ const prefixMatch = (value: string, prefix: string): boolean => {
28
+ return value.toLowerCase().startsWith(prefix.toLowerCase());
35
29
  };
36
30
 
37
31
  pi.registerCommand("blackhole", {
@@ -46,7 +40,7 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
46
40
  { value: "om-on", label: "Enable observational memory [om-on]" },
47
41
  ];
48
42
  if (!prefix) return subcommands;
49
- return subcommands.filter((s) => fuzzyMatch(s.value, prefix));
43
+ return subcommands.filter((s) => prefixMatch(s.value, prefix));
50
44
  },
51
45
  handler: async (args, ctx) => {
52
46
  const sessionId = ctx.sessionManager.getSessionId();
@@ -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);
package/src/core/brief.ts CHANGED
@@ -94,22 +94,27 @@ const truncateTokens = (text: string, limit: number): string => {
94
94
  // ── bash command compression ──
95
95
 
96
96
  const BASH_CAP = 120;
97
- const PIPE_TAIL_RE = /\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|awk|uniq|python3|node|bun)(?:\s[^|]*)?$/;
97
+ // Only strip pure-formatting pipe tails (head/tail for truncation, sort/wc/column for display).
98
+ // awk/python3/node/bun are data-processing commands — their output carries semantic meaning.
99
+ const PIPE_TAIL_RE = /\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|uniq)(?:\s[^|]*)?$/;
98
100
 
99
101
  /** Semantic compression: strip cd prefix, pipe tail formatting, cap length */
100
102
  const compressBash = (raw: string): string => {
101
- // Flatten multi-line: take first meaningful line
102
- let cmd = raw.split("\n").map(l => l.trim()).filter(Boolean)[0] ?? raw;
103
+ // Flatten multi-line: join lines with semicolons
104
+ let cmd = raw.split("\n").map(l => l.trim()).filter(Boolean).join("; ");
103
105
  // Strip cd <path> && prefix
104
106
  cmd = cmd.replace(/^cd\s+\S+\s*&&\s*/, "");
105
- // Strip pipe tail formatting commands (up to 3 times)
106
- for (let i = 0; i < 3; i++) {
107
+ // Strip pipe tail formatting commands (up to 10 iterations, stop when stable)
108
+ for (let i = 0; i < 10; i++) {
107
109
  const stripped = cmd.replace(PIPE_TAIL_RE, "");
108
110
  if (stripped === cmd) break;
109
111
  cmd = stripped;
110
112
  }
111
113
  if (cmd.length > BASH_CAP) {
112
- return cmd.slice(0, BASH_CAP - 3) + "...";
114
+ // Cut at word boundary to avoid mid-word truncation
115
+ const cut = cmd.lastIndexOf(" ", BASH_CAP - 2);
116
+ const end = cut > BASH_CAP * 0.6 ? cut : BASH_CAP - 3;
117
+ return cmd.slice(0, end).trimEnd() + "...";
113
118
  }
114
119
  return cmd;
115
120
  };
@@ -118,7 +123,7 @@ const compressBash = (raw: string): string => {
118
123
 
119
124
  const TOOL_SUMMARY_FIELDS: Record<string, string> = {
120
125
  Read: "file_path", Edit: "file_path", Write: "file_path",
121
- read: "file_path", edit: "file_path", write: "file_path",
126
+ read: "path", edit: "path", write: "path",
122
127
  Glob: "pattern", Grep: "pattern",
123
128
  };
124
129
 
@@ -318,17 +323,17 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
318
323
  */
319
324
  export const stringifyBrief = (sections: BriefLine[]): string => {
320
325
 
321
- // Emit sections -- suppress blank lines between consecutive tool summaries
326
+ // Emit sections -- suppress blank lines between consecutive tool/error summaries
322
327
  const out: string[] = [];
323
328
  for (let i = 0; i < sections.length; i++) {
324
329
  const sec = sections[i];
325
330
  if (i > 0) {
326
331
  const prev = sections[i - 1];
327
- const prevIsTools = prev.header === "[assistant]" &&
328
- prev.lines.every((l) => l.startsWith("* "));
329
- const curIsTools = sec.header === "[assistant]" &&
330
- sec.lines.every((l) => l.startsWith("* "));
331
- if (!(prevIsTools && curIsTools)) {
332
+ const prevIsToolLike = (prev.header === "[assistant]" && prev.lines.every((l) => l.startsWith("* "))) ||
333
+ prev.header.startsWith("[tool_error]");
334
+ const curIsToolLike = (sec.header === "[assistant]" && sec.lines.every((l) => l.startsWith("* "))) ||
335
+ sec.header.startsWith("[tool_error]");
336
+ if (!(prevIsToolLike && curIsToolLike)) {
332
337
  out.push("");
333
338
  }
334
339
  }
@@ -11,7 +11,7 @@ import { extractGoals } from "../extract/goals";
11
11
  import { extractFiles } from "../extract/files";
12
12
  import { extractPreferences, dedupPreferencesAgainstGoals } from "../extract/preferences";
13
13
  import { extractCommits, formatCommits } from "../extract/commits";
14
- import { buildBriefSections, sectionsToTranscript, stringifyBrief } from "./brief";
14
+ import { buildBriefSections, stringifyBrief } from "./brief";
15
15
 
16
16
  export interface BuildSectionsInput {
17
17
  blocks: NormalizedBlock[];
@@ -80,6 +80,5 @@ export const buildSections = (input: BuildSectionsInput): SectionData => {
80
80
  commits: formatCommits(extractCommits(blocks)),
81
81
  userPreferences,
82
82
  briefTranscript: stringifyBrief(briefSections),
83
- transcriptEntries: sectionsToTranscript(briefSections),
84
83
  };
85
84
  };
@@ -1,4 +1,5 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
+ import { PATH_KEYS } from "./tool-args.js";
2
3
 
3
4
  export const clip = (text: string, max = 200): string => {
4
5
  if (text.length <= max) return text;
@@ -57,14 +58,16 @@ export const textOf = (content: Message["content"]): string =>
57
58
  */
58
59
  export const isContentBearing = (args: Record<string, unknown>): boolean => {
59
60
  if (!args || typeof args !== "object") return false;
60
- // Must have a path in one of the known keys
61
- const hasPath = ["path", "filePath", "file_path", "file"].some((k) => typeof args[k] === "string");
61
+ // Must have a path in one of the known keys (from tool-args.ts)
62
+ const hasPath = PATH_KEYS.some((k) => typeof args[k] === "string");
62
63
  if (!hasPath) return false;
63
64
  // Must have at least one content-bearing field
64
65
  if (typeof args.content === "string" && args.content.length > 0) return true;
65
- if (Array.isArray(args.edits) && args.edits.length > 0) return true;
66
- if (typeof args.oldText === "string" && args.oldText.length > 0 && !Array.isArray(args.edits)) return true;
67
- if (typeof args.newText === "string" && args.newText.length > 0 && !Array.isArray(args.edits)) return true;
66
+ // edits must be a non-empty array of objects (each with oldText/newText)
67
+ if (Array.isArray(args.edits) && args.edits.length > 0 && args.edits.every((e) => typeof e === "object" && e !== null)) return true;
68
+ // oldText/newText without edits are content-bearing
69
+ if (typeof args.oldText === "string" && args.oldText.length > 0 && args.edits === undefined) return true;
70
+ if (typeof args.newText === "string" && args.newText.length > 0 && args.edits === undefined) return true;
68
71
  return false;
69
72
  };
70
73
 
@@ -13,6 +13,7 @@
13
13
  * requires the entire query to be the drill-down pattern.
14
14
  */
15
15
  import { isContentBearing } from "./content.js";
16
+ import { extractPath } from "./tool-args.js";
16
17
  import { loadAllMessages } from "./load-messages.js";
17
18
 
18
19
  // ── Types ─────────────────────────────────────────────────────────────────
@@ -28,14 +29,6 @@ interface ContentBearingCall {
28
29
 
29
30
  // ── Helpers ───────────────────────────────────────────────────────────────
30
31
 
31
- /** Extract the file path from tool call args, checking all known keys. */
32
- function extractPathFromArgs(args: Record<string, unknown>): string | null {
33
- for (const key of ["path", "filePath", "file_path", "file"]) {
34
- if (typeof args[key] === "string") return args[key] as string;
35
- }
36
- return null;
37
- }
38
-
39
32
  /**
40
33
  * Find content-bearing tool calls that have a `path` argument and at least
41
34
  * one content field (content, edits, oldText, newText).
@@ -48,7 +41,7 @@ function findContentBearingCalls(content: unknown[]): ContentBearingCall[] {
48
41
  if (!part || (part as any).type !== "toolCall") continue;
49
42
  const args = (part as any).arguments ?? {};
50
43
  if (!isContentBearing(args)) continue;
51
- const path = extractPathFromArgs(args);
44
+ const path = extractPath(args);
52
45
  if (!path) continue;
53
46
  const entry: ContentBearingCall = { name: (part as any).name ?? "", path };
54
47
  if (typeof args.content === "string") entry.content = args.content;
@@ -13,16 +13,14 @@ const NOISE_STRINGS = [
13
13
 
14
14
  const XML_WRAPPER_RE = /<(system-reminder|ide_opened_file|command-message|context-window-usage)[^>]*>[\s\S]*?<\/\1>/g;
15
15
 
16
- const isNoiseUserBlock = (text: string): boolean => {
16
+ /** Return cleaned user text, or null if the block is noise. Cleans once. */
17
+ const cleanOrNull = (text: string): string | null => {
17
18
  const trimmed = text.trim();
18
- if (NOISE_STRINGS.some((s) => trimmed.includes(s))) return true;
19
- const stripped = trimmed.replace(XML_WRAPPER_RE, "").trim();
20
- return stripped.length === 0;
19
+ if (NOISE_STRINGS.some((s) => trimmed.includes(s))) return null;
20
+ const cleaned = trimmed.replace(XML_WRAPPER_RE, "").trim();
21
+ return cleaned.length > 0 ? cleaned : null;
21
22
  };
22
23
 
23
- const cleanUserText = (text: string): string =>
24
- text.replace(XML_WRAPPER_RE, "").trim();
25
-
26
24
  export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => {
27
25
  const out: NormalizedBlock[] = [];
28
26
  for (const b of blocks) {
@@ -30,10 +28,9 @@ export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => {
30
28
  if (b.kind === "tool_call" && NOISE_TOOLS.has(b.name)) continue;
31
29
  if (b.kind === "tool_result" && NOISE_TOOLS.has(b.name)) continue;
32
30
  if (b.kind === "user") {
33
- if (isNoiseUserBlock(b.text)) continue;
34
- const cleaned = cleanUserText(b.text);
31
+ const cleaned = cleanOrNull(b.text);
35
32
  if (!cleaned) continue;
36
- out.push({ kind: "user", text: cleaned });
33
+ out.push({ ...b, text: cleaned }); // preserve sourceIndex and any future fields
37
34
  continue;
38
35
  }
39
36
  out.push(b);
@@ -1,4 +1,5 @@
1
1
  import type { SectionData } from "../sections";
2
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
3
 
3
4
  const section = (title: string, items: string[]): string => {
4
5
  if (items.length === 0) return "";
@@ -9,31 +10,23 @@ const section = (title: string, items: string[]): string => {
9
10
  const BRIEF_MAX_LINES = 120;
10
11
  const TUI_SAFE_LINE_CHARS = 120;
11
12
 
12
- const wrapLine = (line: string, maxChars: number): string[] => {
13
- if (line.length <= maxChars) return [line];
14
-
13
+ /**
14
+ * Wrap a single line of text, preserving list-item continuation indent.
15
+ * Detects leading bullets (-, *, N.) and indents continuation lines
16
+ * to match, so wrapped list items remain visually grouped.
17
+ */
18
+ function wrapLineWithContinuation(line: string, maxChars: number): string[] {
15
19
  const indent = line.match(/^\s*(?:[-*]\s+|\d+\.\s+)?/)?.[0] ?? "";
16
20
  const continuationIndent = indent ? " ".repeat(Math.min(indent.length, 8)) : "";
17
- const wrapped: string[] = [];
18
- let remaining = line;
19
- let prefix = "";
20
-
21
- while (prefix.length + remaining.length > maxChars) {
22
- const available = Math.max(20, maxChars - prefix.length);
23
- let splitAt = remaining.lastIndexOf(" ", available);
24
- if (splitAt < Math.floor(available * 0.5)) splitAt = available;
25
-
26
- wrapped.push(prefix + remaining.slice(0, splitAt).trimEnd());
27
- remaining = remaining.slice(splitAt).trimStart();
28
- prefix = continuationIndent;
29
- }
30
-
31
- if (remaining) wrapped.push(prefix + remaining);
32
- return wrapped;
33
- };
21
+ // Wrap at reduced width so prepending continuationIndent doesn't exceed maxChars
22
+ const safeMaxChars = continuationIndent ? maxChars - continuationIndent.length : maxChars;
23
+ const wrapped = wrapTextWithAnsi(line, safeMaxChars);
24
+ if (wrapped.length <= 1 || !continuationIndent) return wrapped;
25
+ return [wrapped[0], ...wrapped.slice(1).map((l) => continuationIndent + l)];
26
+ }
34
27
 
35
28
  export const wrapLongLines = (text: string, maxChars = TUI_SAFE_LINE_CHARS): string =>
36
- text.split("\n").flatMap((line) => wrapLine(line, maxChars)).join("\n");
29
+ text.split("\n").flatMap((line) => wrapLineWithContinuation(line, maxChars)).join("\n");
37
30
 
38
31
  export const capBrief = (text: string): string => {
39
32
  const lines = text.split("\n");
@@ -41,14 +34,21 @@ export const capBrief = (text: string): string => {
41
34
  const omitted = lines.length - BRIEF_MAX_LINES;
42
35
  const kept = lines.slice(-BRIEF_MAX_LINES);
43
36
  // Find first section header to avoid cutting mid-section
44
- const firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
37
+ let firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
38
+ if (firstHeader < 0) {
39
+ // No header in the kept window — scan for any bracket-delimited anchor
40
+ // (e.g., tool name, inline reference) to avoid starting mid-paragraph.
41
+ const anyAnchor = kept.findIndex((l) => /^\[[^\]]+\]/.test(l));
42
+ if (anyAnchor > 0) firstHeader = anyAnchor;
43
+ }
45
44
  const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
46
45
  return `...(${omitted} earlier lines omitted)\n\n${clean.join("\n")}`;
47
46
  };
48
47
 
49
48
  export const RECALL_NOTE =
50
- "Use `recall` to search for prior work, decisions, and context from before this summary. " +
51
- "Do not redo work already completed.";
49
+ "The conversation before this point has been compacted into the summary above. " +
50
+ "Details not captured here exact code, error messages, file paths — are only recoverable via `recall`. " +
51
+ "Use `recall` to search the session history. Do not redo work already completed.";
52
52
 
53
53
  export const formatSummary = (data: SectionData): string => {
54
54
  const headerParts = [
@@ -69,7 +69,7 @@ export const formatSummary = (data: SectionData): string => {
69
69
 
70
70
  if (parts.length === 0) return "";
71
71
 
72
- // NOTE: RECALL_NOTE is intentionally NOT appended here.
72
+ // NOTE: RECALL_NOTE is appended by compile(), not here.
73
73
  // It is appended once by `compile()` at the very end, after merge-with-previous,
74
74
  // to avoid the note compounding inside the brief transcript across compactions.
75
75
  return wrapLongLines(parts.join("\n\n---\n\n"));
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "fs";
1
+ import { readFileSync, statSync } from "fs";
2
2
  import type { Message } from "@earendil-works/pi-ai";
3
3
  import { renderMessage, type RenderedEntry } from "./render-entries";
4
4
 
@@ -8,11 +8,78 @@ export interface LoadedMessages {
8
8
  entryIds: string[];
9
9
  }
10
10
 
11
+ // ── Session-file cache (LRU, invalidated by mtime) ─────────────────────────
12
+
13
+ interface CacheEntry {
14
+ result: LoadedMessages;
15
+ mtimeMs: number;
16
+ timestamp: number;
17
+ }
18
+
19
+ const MAX_CACHE_SIZE = 3;
20
+ const CACHE_TTL_MS = 2_000;
21
+ const cache = new Map<string, CacheEntry>();
22
+
23
+ function cacheKey(sessionFile: string, full: boolean, allowedEntryIds: Set<string> | undefined): string {
24
+ let hash = `${sessionFile}::${full}`;
25
+ if (allowedEntryIds && allowedEntryIds.size > 0) {
26
+ // Include the full set as sorted JSON for collision-free caching
27
+ hash += `::${JSON.stringify([...allowedEntryIds].sort())}`;
28
+ }
29
+ return hash;
30
+ }
31
+
32
+ function getCached(sessionFile: string, full: boolean, allowedEntryIds?: Set<string>): LoadedMessages | undefined {
33
+ const key = cacheKey(sessionFile, full, allowedEntryIds);
34
+ const entry = cache.get(key);
35
+ if (!entry) return undefined;
36
+
37
+ // Check TTL (mtime-based invalidation)
38
+ if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
39
+ cache.delete(key);
40
+ return undefined;
41
+ }
42
+
43
+ // Verify file mtime hasn't changed
44
+ try {
45
+ const mtimeMs = statSync(sessionFile).mtimeMs;
46
+ if (mtimeMs !== entry.mtimeMs) {
47
+ cache.delete(key);
48
+ return undefined;
49
+ }
50
+ } catch {
51
+ cache.delete(key);
52
+ return undefined;
53
+ }
54
+
55
+ return entry.result;
56
+ }
57
+
58
+ function setCache(sessionFile: string, full: boolean, allowedEntryIds: Set<string> | undefined, result: LoadedMessages): void {
59
+ // Evict oldest if at capacity
60
+ if (cache.size >= MAX_CACHE_SIZE) {
61
+ const oldest = cache.entries().next();
62
+ if (!oldest.done) cache.delete(oldest.value[0]);
63
+ }
64
+
65
+ try {
66
+ const mtimeMs = statSync(sessionFile).mtimeMs;
67
+ const key = cacheKey(sessionFile, full, allowedEntryIds);
68
+ cache.set(key, { result, mtimeMs, timestamp: Date.now() });
69
+ } catch {
70
+ // Can't stat the file — don't cache
71
+ }
72
+ }
73
+
11
74
  export const loadAllMessages = (
12
75
  sessionFile: string,
13
76
  full: boolean,
14
77
  allowedEntryIds?: Set<string>,
15
78
  ): LoadedMessages => {
79
+ // Check cache first (same sessionFile + full flag + lineage size — approximate)
80
+ const cached = getCached(sessionFile, full, allowedEntryIds);
81
+ if (cached) return cached;
82
+
16
83
  const content = readFileSync(sessionFile, "utf-8");
17
84
  const entries: any[] = [];
18
85
  let parseErrors = 0;
@@ -42,5 +109,7 @@ export const loadAllMessages = (
42
109
  messageIndex++;
43
110
  }
44
111
 
45
- return { rendered, rawMessages, entryIds };
112
+ const result: LoadedMessages = { rendered, rawMessages, entryIds };
113
+ setCache(sessionFile, full, allowedEntryIds, result);
114
+ return result;
46
115
  };
@@ -9,6 +9,14 @@ import type { NormalizedBlock } from "../types";
9
9
  import { textOf } from "./content";
10
10
  import { sanitize } from "./sanitize";
11
11
 
12
+ // Mirrors @earendil-works/pi-coding-agent's BashExecutionMessage (not re-exported from index)
13
+ interface LocalBashMessage {
14
+ role: "bashExecution";
15
+ command: string;
16
+ output: string;
17
+ exitCode: number | undefined;
18
+ }
19
+
12
20
  const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
13
21
  if (msg.role === "user") {
14
22
  const blocks: NormalizedBlock[] = [];
@@ -25,10 +33,8 @@ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
25
33
  }
26
34
 
27
35
  if ((msg as any).role === "bashExecution") {
28
- const cmd = (msg as any).command ?? "";
29
- const out = (msg as any).output ?? "";
30
- const exit = (msg as any).exitCode;
31
- return [{ kind: "bash", command: cmd, output: out, exitCode: exit, sourceIndex: msgIndex }];
36
+ const bashMsg = msg as unknown as LocalBashMessage;
37
+ return [{ kind: "bash", command: bashMsg.command ?? "", output: bashMsg.output ?? "", exitCode: bashMsg.exitCode, sourceIndex: msgIndex }];
32
38
  }
33
39
 
34
40
  if (msg.role === "toolResult") {
@@ -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";