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.
- package/README.md +13 -16
- package/example-config.json +19 -27
- package/package.json +1 -1
- package/src/commands/memory.ts +1 -1
- package/src/commands/pi-vcc.ts +3 -9
- package/src/commands/vcc-recall.ts +16 -3
- package/src/core/brief.ts +18 -13
- package/src/core/build-sections.ts +1 -2
- package/src/core/content.ts +8 -5
- package/src/core/drill-down.ts +2 -9
- package/src/core/filter-noise.ts +7 -10
- package/src/core/format.ts +25 -25
- package/src/core/load-messages.ts +71 -2
- package/src/core/normalize.ts +10 -4
- package/src/core/recall-scope.ts +3 -3
- package/src/core/render-entries.ts +10 -2
- package/src/core/search-entries.ts +12 -7
- package/src/core/settings.ts +3 -34
- package/src/core/summarize.ts +33 -13
- package/src/core/tool-args.ts +4 -1
- package/src/core/unified-config.ts +27 -25
- package/src/extract/commits.ts +3 -2
- package/src/extract/files.ts +6 -4
- package/src/hooks/before-compact.ts +7 -3
- package/src/om/agents/dropper/agent.ts +2 -7
- package/src/om/agents/observer/agent.ts +11 -13
- package/src/om/agents/reflector/agent.ts +2 -7
- package/src/om/compaction-trigger.ts +54 -18
- package/src/om/configure-overlay.ts +5 -1
- package/src/om/consolidation.ts +35 -13
- package/src/om/cooldown.ts +15 -12
- package/src/om/debug-log.ts +79 -11
- package/src/om/key-matcher.ts +6 -32
- package/src/om/ledger/fold.ts +8 -0
- package/src/om/ledger/render-summary.ts +16 -15
- package/src/om/ledger/types.ts +1 -1
- package/src/om/pending.ts +38 -5
- package/src/om/provider-stream.ts +17 -0
- package/src/om/retryable-error.ts +26 -0
- package/src/om/reverse-recall.ts +9 -1
- package/src/om/runtime.ts +39 -18
- package/src/sections.ts +0 -3
- 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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
```
|
package/example-config.json
CHANGED
|
@@ -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
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
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
|
-
"
|
|
102
|
-
"
|
|
103
|
+
"sessionFallback": true,
|
|
104
|
+
"debug": false,
|
|
103
105
|
"debugLog": false,
|
|
104
106
|
|
|
105
107
|
"_notes": [
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
"
|
|
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
|
-
"
|
|
119
|
-
"
|
|
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
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
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.
|
|
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 {
|
package/src/commands/pi-vcc.ts
CHANGED
|
@@ -24,14 +24,8 @@ const formatTokens = (n: number): string => {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
27
|
-
const
|
|
28
|
-
|
|
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) =>
|
|
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|
|
|
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
|
-
|
|
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:
|
|
102
|
-
let cmd = raw.split("\n").map(l => l.trim()).filter(Boolean)
|
|
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
|
|
106
|
-
for (let i = 0; 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
|
-
|
|
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: "
|
|
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
|
|
328
|
-
prev.
|
|
329
|
-
const
|
|
330
|
-
sec.
|
|
331
|
-
if (!(
|
|
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,
|
|
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
|
};
|
package/src/core/content.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
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
|
|
package/src/core/drill-down.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/src/core/filter-noise.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
19
|
-
const
|
|
20
|
-
return
|
|
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
|
-
|
|
34
|
-
const cleaned = cleanUserText(b.text);
|
|
31
|
+
const cleaned = cleanOrNull(b.text);
|
|
35
32
|
if (!cleaned) continue;
|
|
36
|
-
out.push({
|
|
33
|
+
out.push({ ...b, text: cleaned }); // preserve sourceIndex and any future fields
|
|
37
34
|
continue;
|
|
38
35
|
}
|
|
39
36
|
out.push(b);
|
package/src/core/format.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
"
|
|
51
|
-
"
|
|
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
|
|
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
|
-
|
|
112
|
+
const result: LoadedMessages = { rendered, rawMessages, entryIds };
|
|
113
|
+
setCache(sessionFile, full, allowedEntryIds, result);
|
|
114
|
+
return result;
|
|
46
115
|
};
|
package/src/core/normalize.ts
CHANGED
|
@@ -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
|
|
29
|
-
|
|
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") {
|
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";
|