pi-blackhole 0.3.9 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/example-config.json +2 -0
- package/index.ts +2 -2
- package/package.json +10 -10
- package/src/commands/cleanup.ts +319 -0
- package/src/commands/memory.ts +2 -2
- package/src/commands/pi-vcc.ts +37 -14
- package/src/core/format.ts +1 -1
- package/src/core/summarize.ts +30 -23
- package/src/core/unified-config.ts +53 -11
- package/src/extract/commits.ts +68 -25
- package/src/extract/goals.ts +13 -2
- package/src/hooks/before-compact.ts +50 -9
- package/src/om/agents/dropper/agent.ts +1 -1
- package/src/om/agents/observer/agent.ts +1 -1
- package/src/om/agents/reflector/agent.ts +1 -1
- package/src/om/cleanup.ts +367 -0
- package/src/om/compaction-trigger.ts +147 -51
- package/src/om/configure-overlay.ts +41 -5
- package/src/om/consolidation.ts +103 -27
- package/src/om/ledger/projection.ts +6 -1
- package/src/om/retryable-error.ts +14 -0
- package/src/om/runtime.ts +78 -34
package/README.md
CHANGED
|
@@ -240,6 +240,10 @@ The agent gets a unified `recall` tool that handles three types of input:
|
|
|
240
240
|
|
|
241
241
|
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).
|
|
242
242
|
|
|
243
|
+
**Invalid JSON protection:** If the config file has a syntax error (trailing comma, partial write, sync conflict), the overlay (`/blackhole configure`) shows a red error banner and blocks save to prevent wiping your model configs. A yellow warning is also shown in the TUI. Fix the JSON directly in the file, then reopen the overlay.
|
|
244
|
+
|
|
245
|
+
**Overlay reload:** Changes saved via `/blackhole configure` take effect immediately — no session restart needed. The runtime reloads config from disk after every successful overlay save.
|
|
246
|
+
|
|
243
247
|
Quick start — just set custom models (if you want):
|
|
244
248
|
|
|
245
249
|
```json
|
|
@@ -343,7 +347,7 @@ These are the built-in defaults. If you reset your config, these are what you ge
|
|
|
343
347
|
|
|
344
348
|
### Tip: comments in config
|
|
345
349
|
|
|
346
|
-
The config preserves unknown keys, so you can add `_comment` or `_notes` fields to document your choices inline. They're ignored by the parser.
|
|
350
|
+
The config preserves unknown keys when loaded, so you can add `_comment` or `_notes` fields to document your choices inline. They're ignored by the parser.
|
|
347
351
|
|
|
348
352
|
```json
|
|
349
353
|
{
|
|
@@ -352,6 +356,8 @@ The config preserves unknown keys, so you can add `_comment` or `_notes` fields
|
|
|
352
356
|
}
|
|
353
357
|
```
|
|
354
358
|
|
|
359
|
+
**Note:** The `/blackhole configure` overlay only manages the keys it knows about. All other keys (including `observerModel`, `reflectorModel`, `dropperModel`, and their fallback arrays) are preserved on save when the file has valid JSON. If the file has invalid JSON, save is blocked entirely — the overlay will not overwrite the file.
|
|
360
|
+
|
|
355
361
|
---
|
|
356
362
|
|
|
357
363
|
## Model fallback chains
|
package/example-config.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"compaction": "auto",
|
|
8
8
|
"compactionEngine": "blackhole",
|
|
9
9
|
"tailBehavior": "minimal",
|
|
10
|
+
"midRunCompaction": "resume",
|
|
10
11
|
|
|
11
12
|
"model": {
|
|
12
13
|
"_comment": "Base model (last fallback before session model).",
|
|
@@ -110,6 +111,7 @@
|
|
|
110
111
|
" compaction (auto|manual|off) — how compaction triggers",
|
|
111
112
|
" compactionEngine (blackhole|pi-default) — who handles it",
|
|
112
113
|
" tailBehavior (pi-default|minimal) — how much stays visible",
|
|
114
|
+
" midRunCompaction (resume|pause|off) — threshold check during tool loops",
|
|
113
115
|
" sessionFallback (true|false) — if false, skip session model as last-resort fallback",
|
|
114
116
|
" dropperPressureThreshold (0-1, default 0.70) — pressure relief valve: fraction of reflectorInputMaxTokens where dropper fires even without new data",
|
|
115
117
|
"",
|
package/index.ts
CHANGED
|
@@ -34,12 +34,12 @@ export default (pi: ExtensionAPI) => {
|
|
|
34
34
|
const providerStreams: Map<string, Function> = (globalThis as any)[PROVIDER_STREAMS_KEY] ??= new Map();
|
|
35
35
|
|
|
36
36
|
const origRegisterProvider = pi.registerProvider.bind(pi);
|
|
37
|
-
pi.registerProvider = (name: string, config: any) => {
|
|
37
|
+
pi.registerProvider = ((name: string, config: any) => {
|
|
38
38
|
if (config && config.streamSimple && config.api) {
|
|
39
39
|
providerStreams.set(config.api, config.streamSimple);
|
|
40
40
|
}
|
|
41
41
|
origRegisterProvider(name, config);
|
|
42
|
-
};
|
|
42
|
+
}) as typeof pi.registerProvider;
|
|
43
43
|
|
|
44
44
|
// Fallback: on agent_start, capture providers that registered before our wrapper
|
|
45
45
|
// (handles the case where pi-blackhole loads after another provider extension).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-blackhole",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@earendil-works/pi-agent-core": ">=0.
|
|
44
|
-
"@earendil-works/pi-ai": ">=0.
|
|
45
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
46
|
-
"@earendil-works/pi-tui": ">=0.
|
|
43
|
+
"@earendil-works/pi-agent-core": ">=0.81.1 <1.0.0",
|
|
44
|
+
"@earendil-works/pi-ai": ">=0.81.1 <1.0.0",
|
|
45
|
+
"@earendil-works/pi-coding-agent": ">=0.81.1 <1.0.0",
|
|
46
|
+
"@earendil-works/pi-tui": ">=0.81.1 <1.0.0"
|
|
47
47
|
},
|
|
48
48
|
"pi": {
|
|
49
49
|
"extensions": [
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
]
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-agent-core": "0.
|
|
55
|
-
"@earendil-works/pi-ai": "0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
57
|
-
"@earendil-works/pi-tui": "0.
|
|
54
|
+
"@earendil-works/pi-agent-core": "0.81.1",
|
|
55
|
+
"@earendil-works/pi-ai": "0.81.1",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "0.81.1",
|
|
57
|
+
"@earendil-works/pi-tui": "0.81.1",
|
|
58
58
|
"@typescript-eslint/eslint-plugin": "8.60.0",
|
|
59
59
|
"@typescript-eslint/parser": "8.60.0",
|
|
60
60
|
"eslint": "9.39.4",
|
|
@@ -62,6 +62,6 @@
|
|
|
62
62
|
"lint-staged": "15.5.2",
|
|
63
63
|
"typebox": "1.1.38",
|
|
64
64
|
"typescript": "5.9.3",
|
|
65
|
-
"vitest": "4.1.
|
|
65
|
+
"vitest": "4.1.9"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup handler — TUI picker for orphaned pending files.
|
|
3
|
+
*
|
|
4
|
+
* Called from /blackhole cleanup subcommand.
|
|
5
|
+
* Scans pi-blackhole pending files, cross-references against session JSONL
|
|
6
|
+
* files, and lets the user delete orphaned ones interactively.
|
|
7
|
+
*
|
|
8
|
+
* TUI picker (overlay) provides:
|
|
9
|
+
* ↑↓ navigate
|
|
10
|
+
* Enter delete selected file
|
|
11
|
+
* D delete all orphaned (with inline confirmation)
|
|
12
|
+
* Esc cancel / close
|
|
13
|
+
*
|
|
14
|
+
* Non-TUI fallback (RPC / JSON / print modes): lists orphaned files as text
|
|
15
|
+
* notification. Use TUI mode to actually delete.
|
|
16
|
+
*/
|
|
17
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
|
|
19
|
+
import { visibleWidth } from "../om/key-matcher.js";
|
|
20
|
+
import {
|
|
21
|
+
analyzeOrphaned,
|
|
22
|
+
deletePendingFiles,
|
|
23
|
+
deleteOrphanedBatch,
|
|
24
|
+
describeFile,
|
|
25
|
+
type PendingFile,
|
|
26
|
+
} from "../om/cleanup.js";
|
|
27
|
+
|
|
28
|
+
// ── TUI Picker Component ────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const LIST_ROWS = 10;
|
|
31
|
+
|
|
32
|
+
/** Clamp a list index to [0, length-1]. */
|
|
33
|
+
function clampIndex(idx: number, length: number): number {
|
|
34
|
+
if (length === 0) return 0;
|
|
35
|
+
return Math.max(0, Math.min(idx, length - 1));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build the framed top border with optional title and right-aligned info. */
|
|
39
|
+
function buildTopBorder(
|
|
40
|
+
width: number,
|
|
41
|
+
border: (s: string) => string,
|
|
42
|
+
dim: (s: string) => string,
|
|
43
|
+
title?: string,
|
|
44
|
+
right?: string,
|
|
45
|
+
): string {
|
|
46
|
+
const innerW = Math.max(1, width - 2);
|
|
47
|
+
if (!title && !right) {
|
|
48
|
+
return border(`┏${"━".repeat(innerW)}┓`);
|
|
49
|
+
}
|
|
50
|
+
const rightText = right ? ` ${right} ` : "";
|
|
51
|
+
const titleBudget = Math.max(1, innerW - visibleWidth(rightText) - 1);
|
|
52
|
+
const titleFitted = title
|
|
53
|
+
? ` ${title.slice(0, Math.max(1, titleBudget - 2))}${title.length > titleBudget - 2 ? "…" : ""} `
|
|
54
|
+
: "";
|
|
55
|
+
const fill = Math.max(1, innerW - visibleWidth(titleFitted) - visibleWidth(rightText));
|
|
56
|
+
return border(`┏${titleFitted}${"━".repeat(fill)}${right ? dim(rightText) : ""}┓`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Component ───────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create the orphaned-files picker component for ctx.ui.custom().
|
|
63
|
+
*
|
|
64
|
+
* The component mutates the `orphaned` array in-place when single items are
|
|
65
|
+
* deleted (immediate feedback). The caller creates a copy if needed.
|
|
66
|
+
*/
|
|
67
|
+
function createCleanupPicker(
|
|
68
|
+
orphaned: PendingFile[],
|
|
69
|
+
theme: { fg: (s: string, t: string) => string; bg: (s: string, t: string) => string },
|
|
70
|
+
done: (result: "deleteAll" | "cancel") => void,
|
|
71
|
+
): { render(width: number): string[]; handleInput(data: string): void; invalidate(): void } {
|
|
72
|
+
let selectedIndex = 0;
|
|
73
|
+
let scrollOffset = 0;
|
|
74
|
+
let confirmDeleteAll = false;
|
|
75
|
+
|
|
76
|
+
function clampScroll(): void {
|
|
77
|
+
const count = orphaned.length;
|
|
78
|
+
selectedIndex = clampIndex(selectedIndex, count);
|
|
79
|
+
const maxOffset = Math.max(0, count - LIST_ROWS);
|
|
80
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset, maxOffset));
|
|
81
|
+
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
|
82
|
+
if (selectedIndex >= scrollOffset + LIST_ROWS && count > 0) {
|
|
83
|
+
scrollOffset = selectedIndex - LIST_ROWS + 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const bdr = (t: string) => theme.fg("border", t);
|
|
88
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
89
|
+
const accent = (t: string) => theme.fg("accent", t);
|
|
90
|
+
const err = (t: string) => theme.fg("error", t);
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
|
|
93
|
+
function itemLine(pf: PendingFile, isSel: boolean, cw: number): string {
|
|
94
|
+
const desc = describeFile(pf, now);
|
|
95
|
+
const prefix = isSel ? accent(" ▶") : " ";
|
|
96
|
+
const main = isSel ? accent(desc) : desc;
|
|
97
|
+
const line = `${prefix} ${main}`;
|
|
98
|
+
return ` ${line}${" ".repeat(Math.max(0, cw - visibleWidth(line)))} `;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
invalidate(): void {
|
|
103
|
+
// No cache — render is always fresh
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
render(width: number): string[] {
|
|
107
|
+
const w = Math.max(40, width);
|
|
108
|
+
const innerW = Math.max(1, w - 2);
|
|
109
|
+
const PX = 1;
|
|
110
|
+
const cw = Math.max(1, innerW - PX * 2);
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
|
|
113
|
+
// ── Confirm-delete-all ──
|
|
114
|
+
if (confirmDeleteAll) {
|
|
115
|
+
const title = `Delete ${orphaned.length} orphaned file${orphaned.length === 1 ? "" : "s"}?`;
|
|
116
|
+
lines.push(buildTopBorder(w, bdr, dim, "Cleanup Pending Files"));
|
|
117
|
+
lines.push(bdr(`┃${" ".repeat(innerW)}┃`));
|
|
118
|
+
lines.push(bdr(`┃ ${err(title)}${" ".repeat(Math.max(0, cw - visibleWidth(title)))} ┃`));
|
|
119
|
+
lines.push(bdr(`┃${" ".repeat(innerW)}┃`));
|
|
120
|
+
const hint = "Enter confirm · Esc cancel";
|
|
121
|
+
lines.push(bdr(`┃ ${dim(hint)}${" ".repeat(Math.max(0, cw - visibleWidth(hint)))} ┃`));
|
|
122
|
+
lines.push(bdr(`┗${"━".repeat(innerW)}┛`));
|
|
123
|
+
return lines;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── List ──
|
|
127
|
+
clampScroll();
|
|
128
|
+
|
|
129
|
+
const sizeLabel = `${orphaned.length} file${orphaned.length === 1 ? "" : "s"}`;
|
|
130
|
+
lines.push(buildTopBorder(w, bdr, dim, "Orphaned Pending Files", sizeLabel));
|
|
131
|
+
lines.push(bdr(`┃${" ".repeat(innerW)}┃`));
|
|
132
|
+
|
|
133
|
+
if (orphaned.length === 0) {
|
|
134
|
+
lines.push(bdr(`┃ ${dim("No orphaned pending files found")}${" ".repeat(Math.max(0, cw - 29))} ┃`));
|
|
135
|
+
} else {
|
|
136
|
+
const visible = orphaned.slice(scrollOffset, scrollOffset + LIST_ROWS);
|
|
137
|
+
for (let vi = 0; vi < visible.length; vi++) {
|
|
138
|
+
const idx = scrollOffset + vi;
|
|
139
|
+
const pf = visible[vi];
|
|
140
|
+
if (!pf) continue;
|
|
141
|
+
const isSel = idx === selectedIndex;
|
|
142
|
+
const row = itemLine(pf, isSel, cw);
|
|
143
|
+
lines.push(isSel
|
|
144
|
+
? theme.bg("selectedBg", bdr(`┃${row}┃`))
|
|
145
|
+
: bdr(`┃${row}┃`));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Filler rows
|
|
150
|
+
for (let i = Math.min(orphaned.length, LIST_ROWS); i < LIST_ROWS; i++) {
|
|
151
|
+
lines.push(bdr(`┃${" ".repeat(innerW)}┃`));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lines.push(bdr(`┃${" ".repeat(innerW)}┃`));
|
|
155
|
+
|
|
156
|
+
if (orphaned.length > 0) {
|
|
157
|
+
const totalBytes = orphaned.reduce((s, pf) => s + pf.sizeBytes, 0);
|
|
158
|
+
const kb = totalBytes > 0 ? (totalBytes / 1024).toFixed(1) : "0.0";
|
|
159
|
+
const totalStr = `Total: ${kb} KB`;
|
|
160
|
+
const hint = "↑↓ navigate Enter delete D delete all Esc cancel";
|
|
161
|
+
lines.push(bdr(`┃ ${dim(totalStr)}${" ".repeat(Math.max(0, cw - visibleWidth(totalStr)))} ┃`));
|
|
162
|
+
lines.push(bdr(`┃ ${dim(hint)}${" ".repeat(Math.max(0, cw - visibleWidth(hint)))} ┃`));
|
|
163
|
+
} else {
|
|
164
|
+
const hint = "Esc close";
|
|
165
|
+
lines.push(bdr(`┃ ${dim(hint)}${" ".repeat(Math.max(0, cw - visibleWidth(hint)))} ┃`));
|
|
166
|
+
}
|
|
167
|
+
lines.push(bdr(`┗${"━".repeat(innerW)}┛`));
|
|
168
|
+
return lines;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
handleInput(data: string): void {
|
|
172
|
+
const key = decodeKittyPrintable(data) ?? data;
|
|
173
|
+
|
|
174
|
+
if (confirmDeleteAll) {
|
|
175
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
176
|
+
done("deleteAll");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (matchesKey(data, "escape")) {
|
|
180
|
+
confirmDeleteAll = false;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (orphaned.length === 0) {
|
|
187
|
+
if (matchesKey(data, "escape")) done("cancel");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (matchesKey(data, "up")) {
|
|
192
|
+
selectedIndex = clampIndex(selectedIndex - 1, orphaned.length);
|
|
193
|
+
clampScroll();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (matchesKey(data, "down")) {
|
|
197
|
+
selectedIndex = clampIndex(selectedIndex + 1, orphaned.length);
|
|
198
|
+
clampScroll();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (matchesKey(data, "pageUp") || key === "-") {
|
|
202
|
+
selectedIndex = clampIndex(selectedIndex - LIST_ROWS, orphaned.length);
|
|
203
|
+
clampScroll();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (matchesKey(data, "pageDown") || key === "=") {
|
|
207
|
+
selectedIndex = clampIndex(selectedIndex + LIST_ROWS, orphaned.length);
|
|
208
|
+
clampScroll();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (matchesKey(data, "home")) {
|
|
212
|
+
selectedIndex = 0;
|
|
213
|
+
scrollOffset = 0;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (matchesKey(data, "end")) {
|
|
217
|
+
selectedIndex = Math.max(0, orphaned.length - 1);
|
|
218
|
+
clampScroll();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
222
|
+
const pf = orphaned[selectedIndex];
|
|
223
|
+
if (pf) {
|
|
224
|
+
deletePendingFiles(pf.sessionId);
|
|
225
|
+
orphaned.splice(selectedIndex, 1);
|
|
226
|
+
if (orphaned.length === 0) {
|
|
227
|
+
done("cancel");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
selectedIndex = clampIndex(selectedIndex, orphaned.length);
|
|
231
|
+
clampScroll();
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (key === "d" || key === "D") {
|
|
236
|
+
confirmDeleteAll = true;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (matchesKey(data, "escape")) {
|
|
240
|
+
done("cancel");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Public handler ──────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Handle the /blackhole cleanup subcommand.
|
|
251
|
+
*
|
|
252
|
+
* Runs the full analysis pipeline (scan pending → scan sessions → cross-ref),
|
|
253
|
+
* then opens the TUI picker if there are orphaned files.
|
|
254
|
+
*
|
|
255
|
+
* In non-TUI modes (RPC/JSON/print): lists orphaned files as a notification
|
|
256
|
+
* without deleting anything.
|
|
257
|
+
*/
|
|
258
|
+
export async function handleCleanup(ctx: ExtensionContext): Promise<void> {
|
|
259
|
+
const { orphaned } = analyzeOrphaned();
|
|
260
|
+
|
|
261
|
+
if (orphaned.length === 0) {
|
|
262
|
+
ctx.ui.notify("pi-blackhole: No orphaned pending files found.", "info");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const isRpc = ctx.mode === "rpc" || ctx.mode === "json" || ctx.mode === "print";
|
|
267
|
+
if (isRpc) {
|
|
268
|
+
// Non-TUI: list only
|
|
269
|
+
const totalSize = orphaned.reduce((s, pf) => s + pf.sizeBytes, 0);
|
|
270
|
+
const lines = [
|
|
271
|
+
`Orphaned pending files: ${orphaned.length} (${(totalSize / 1024).toFixed(1)} KB)`,
|
|
272
|
+
"",
|
|
273
|
+
...orphaned.map((pf) => ` ${describeFile(pf)}`),
|
|
274
|
+
"",
|
|
275
|
+
"Use /blackhole cleanup in TUI mode to delete these files.",
|
|
276
|
+
];
|
|
277
|
+
ctx.ui.notify(lines.join("\n"), "warning");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Work on a copy — the picker mutates the array for inline deletes.
|
|
282
|
+
const items = [...orphaned];
|
|
283
|
+
const result = await ctx.ui.custom<"deleteAll" | "cancel" | null>(
|
|
284
|
+
(_tui, theme, _kb, done) => {
|
|
285
|
+
return createCleanupPicker(items, theme as any, done);
|
|
286
|
+
},
|
|
287
|
+
{ overlay: true },
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (result === "deleteAll") {
|
|
291
|
+
const deleted = deleteOrphanedBatch(items);
|
|
292
|
+
const intended = items.length;
|
|
293
|
+
if (deleted === intended) {
|
|
294
|
+
ctx.ui.notify(
|
|
295
|
+
`pi-blackhole: Deleted ${intended} orphaned pending file${intended === 1 ? "" : "s"}.`,
|
|
296
|
+
"info",
|
|
297
|
+
);
|
|
298
|
+
} else {
|
|
299
|
+
ctx.ui.notify(
|
|
300
|
+
`pi-blackhole: Deleted ${deleted}/${intended} orphaned pending file${intended === 1 ? "" : "s"} (${intended - deleted} failed).`,
|
|
301
|
+
"warning",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
} else if (items.length > 0 && items.length < orphaned.length) {
|
|
305
|
+
// Some were deleted inline, some remain
|
|
306
|
+
const remainingSize = items.reduce((s, pf) => s + pf.sizeBytes, 0);
|
|
307
|
+
ctx.ui.notify(
|
|
308
|
+
`pi-blackhole: ${orphaned.length - items.length} deleted, ${items.length} remain (${(remainingSize / 1024).toFixed(1)} KB).`,
|
|
309
|
+
"info",
|
|
310
|
+
);
|
|
311
|
+
} else if (items.length === 0 && orphaned.length > 0) {
|
|
312
|
+
// All were deleted inline
|
|
313
|
+
ctx.ui.notify(
|
|
314
|
+
`pi-blackhole: All ${orphaned.length} orphaned pending file${orphaned.length === 1 ? "" : "s"} removed.`,
|
|
315
|
+
"info",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
// If nothing was deleted (user just pressed Esc), stay silent
|
|
319
|
+
}
|
package/src/commands/memory.ts
CHANGED
|
@@ -72,9 +72,9 @@ function renderContentOnlyProjection(projection: Projection, emptyScope: "visibl
|
|
|
72
72
|
|
|
73
73
|
export function registerMemoryCommand(pi: ExtensionAPI, runtime: Runtime): void {
|
|
74
74
|
pi.registerCommand("blackhole-memory", {
|
|
75
|
-
description: "Show memory pipeline status & token counters. /blackhole-memory
|
|
75
|
+
description: "Show memory pipeline status & token counters. /blackhole-memory [view] visible observations & reflections, [full] complete recorded memory (copies to clipboard).",
|
|
76
76
|
handler: async (args, ctx) => {
|
|
77
|
-
runtime.ensureConfig(ctx.cwd);
|
|
77
|
+
runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
|
|
78
78
|
const entries = ctx.sessionManager.getBranch() as Entry[];
|
|
79
79
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
80
80
|
const mode = firstArg(args);
|
package/src/commands/pi-vcc.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import type { Runtime } from "../om/runtime.js";
|
|
11
|
-
import { PI_VCC_COMPACT_INSTRUCTION, notifyMigrationReminder } from "../hooks/before-compact";
|
|
11
|
+
import { PI_VCC_COMPACT_INSTRUCTION, notifyMigrationReminder, formatCompactionStats } from "../hooks/before-compact";
|
|
12
12
|
import { saveUnifiedConfig, configPath } from "../core/unified-config.js";
|
|
13
13
|
import { readPendingState, clearPendingState, hasPendingData } from "../om/pending.js";
|
|
14
14
|
import { createConfigureOverlay } from "../om/configure-overlay.js";
|
|
@@ -17,11 +17,7 @@ import {
|
|
|
17
17
|
OM_OBSERVATIONS_RECORDED,
|
|
18
18
|
OM_REFLECTIONS_RECORDED,
|
|
19
19
|
} from "../om/ledger/index.js";
|
|
20
|
-
|
|
21
|
-
const formatTokens = (n: number): string => {
|
|
22
|
-
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
23
|
-
return String(n);
|
|
24
|
-
};
|
|
20
|
+
import { handleCleanup } from "./cleanup.js";
|
|
25
21
|
|
|
26
22
|
export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
27
23
|
const prefixMatch = (value: string, prefix: string): boolean => {
|
|
@@ -31,11 +27,12 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
31
27
|
pi.registerCommand("blackhole", {
|
|
32
28
|
description:
|
|
33
29
|
"Compact conversation — structured summary (with observational memory when enabled). " +
|
|
34
|
-
"Subcommands:
|
|
35
|
-
"
|
|
30
|
+
"Subcommands: [configure] settings overlay, [cleanup] remove orphaned files, " +
|
|
31
|
+
"[om-off] / [om-on] disable / re-enable memory.",
|
|
36
32
|
getArgumentCompletions: (prefix: string) => {
|
|
37
33
|
const subcommands = [
|
|
38
34
|
{ value: "configure", label: "Open configuration overlay to edit settings [configure]" },
|
|
35
|
+
{ value: "cleanup", label: "Find and remove orphaned pending files [cleanup]" },
|
|
39
36
|
{ value: "om-off", label: "Disable observational memory [om-off]" },
|
|
40
37
|
{ value: "om-on", label: "Enable observational memory [om-on]" },
|
|
41
38
|
];
|
|
@@ -49,12 +46,15 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
49
46
|
const trimmed = (typeof args === "string" ? args : "").trim();
|
|
50
47
|
if (trimmed === "configure") {
|
|
51
48
|
// Open the config overlay
|
|
52
|
-
const result = await ctx.ui.custom<{ saved: boolean; path: string } | undefined>(
|
|
49
|
+
const result = await ctx.ui.custom<{ saved: boolean; path: string; error?: string } | undefined>(
|
|
53
50
|
(tui, theme, _kb, done) => createConfigureOverlay(configPath(), theme, tui, done),
|
|
54
51
|
{ overlay: true },
|
|
55
52
|
);
|
|
56
53
|
if (result) {
|
|
57
|
-
if (result.
|
|
54
|
+
if (result.error) {
|
|
55
|
+
ctx.ui.notify(result.error, "warning");
|
|
56
|
+
} else if (result.saved) {
|
|
57
|
+
runtime.reloadConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
|
|
58
58
|
ctx.ui.notify("Configuration saved.", "info");
|
|
59
59
|
} else {
|
|
60
60
|
ctx.ui.notify("Failed to save configuration — the config file may be read-only (e.g., managed by Nix).", "warning");
|
|
@@ -62,6 +62,10 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
62
62
|
}
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
|
+
if (trimmed === "cleanup") {
|
|
66
|
+
await handleCleanup(ctx);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
65
69
|
if (trimmed === "om-off") {
|
|
66
70
|
const saved = saveUnifiedConfig({ memory: false });
|
|
67
71
|
runtime.config.memory = false;
|
|
@@ -91,6 +95,21 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
91
95
|
return;
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
// Warn if input starts with a known subcommand but isn't an exact match.
|
|
99
|
+
// Prevents "/blackhole configure foo" from silently becoming a follow-up.
|
|
100
|
+
const SUBCOMMAND_NAMES = ["configure", "cleanup", "om-off", "om-on"];
|
|
101
|
+
const nearMiss = SUBCOMMAND_NAMES.find(
|
|
102
|
+
name => trimmed.toLowerCase().startsWith(name.toLowerCase()) && trimmed.length > name.length
|
|
103
|
+
);
|
|
104
|
+
if (nearMiss) {
|
|
105
|
+
ctx.ui.notify(`/blackhole ${nearMiss} accepts no arguments. Did you mean \"/blackhole ${nearMiss}\"?`, "warning");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Extract follow-up prompt: everything after the subcommand check
|
|
110
|
+
// that isn't a known subcommand is treated as follow-up text.
|
|
111
|
+
const followUpPrompt = trimmed ? trimmed : null;
|
|
112
|
+
|
|
94
113
|
// If compaction is manual (or legacy noAutoCompact): flush pending OM entries
|
|
95
114
|
// into the branch before compacting so the summary includes accumulated memory.
|
|
96
115
|
if (runtime.config.compaction === "manual" && hasPendingData(sessionId)) {
|
|
@@ -128,14 +147,18 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
128
147
|
onComplete: () => {
|
|
129
148
|
const stats = runtime.compactionStats;
|
|
130
149
|
if (stats) {
|
|
131
|
-
ctx.ui.notify(
|
|
132
|
-
`blackhole: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
|
|
133
|
-
"info",
|
|
134
|
-
);
|
|
150
|
+
ctx.ui.notify(formatCompactionStats(stats), "info");
|
|
135
151
|
} else {
|
|
136
152
|
ctx.ui.notify("Compacted with blackhole", "info");
|
|
137
153
|
}
|
|
138
154
|
notifyMigrationReminder(sessionId, (msg, level) => ctx.ui.notify(msg, level as any));
|
|
155
|
+
|
|
156
|
+
// Fire follow-up prompt after compaction completes
|
|
157
|
+
if (followUpPrompt) {
|
|
158
|
+
try {
|
|
159
|
+
void Promise.resolve(pi.sendUserMessage(followUpPrompt)).catch(() => {});
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
139
162
|
},
|
|
140
163
|
onError: (err) => {
|
|
141
164
|
if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
|
package/src/core/format.ts
CHANGED
|
@@ -31,7 +31,6 @@ export const wrapLongLines = (text: string, maxChars = TUI_SAFE_LINE_CHARS): str
|
|
|
31
31
|
export const capBrief = (text: string): string => {
|
|
32
32
|
const lines = text.split("\n");
|
|
33
33
|
if (lines.length <= BRIEF_MAX_LINES) return text;
|
|
34
|
-
const omitted = lines.length - BRIEF_MAX_LINES;
|
|
35
34
|
const kept = lines.slice(-BRIEF_MAX_LINES);
|
|
36
35
|
// Find first section header to avoid cutting mid-section
|
|
37
36
|
let firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
|
|
@@ -42,6 +41,7 @@ export const capBrief = (text: string): string => {
|
|
|
42
41
|
if (anyAnchor > 0) firstHeader = anyAnchor;
|
|
43
42
|
}
|
|
44
43
|
const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
|
|
44
|
+
const omitted = lines.length - clean.length;
|
|
45
45
|
return `...(${omitted} earlier lines omitted)\n\n${clean.join("\n")}`;
|
|
46
46
|
};
|
|
47
47
|
|
package/src/core/summarize.ts
CHANGED
|
@@ -71,7 +71,11 @@ const mergeHeaderSection = (header: string, prev: string, fresh: string): string
|
|
|
71
71
|
const freshLines = fresh.split("\n").filter(isClean);
|
|
72
72
|
const combined = [...new Set([...prevLines, ...freshLines])];
|
|
73
73
|
const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : 15;
|
|
74
|
-
|
|
74
|
+
// Session Goal: keep first items so the original first message persists
|
|
75
|
+
// Other sections: keep last items (fresh overrides stale)
|
|
76
|
+
const capped = combined.length > CAP
|
|
77
|
+
? (header === "Session Goal" ? combined.slice(0, CAP) : combined.slice(-CAP))
|
|
78
|
+
: combined;
|
|
75
79
|
if (capped.length === 0) return "";
|
|
76
80
|
return `[${header}]\n${capped.join("\n")}`;
|
|
77
81
|
};
|
|
@@ -154,38 +158,41 @@ export const compile = (input: CompileInput): string => {
|
|
|
154
158
|
const blocks = filterNoise(normalize(input.messages));
|
|
155
159
|
const data = buildSections({ blocks });
|
|
156
160
|
const fresh = formatSummary(data);
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
+
|
|
162
|
+
// Strip OM content first (## Reflections / ## Observations + preamble),
|
|
163
|
+
// then strip ALL recall notes from the previous summary using paragraph-level
|
|
164
|
+
// matching. Order matters: OM sections appear before the recall note in the
|
|
165
|
+
// stored summary, so we must remove them first to avoid leaving the recall
|
|
166
|
+
// stripper with fragments.
|
|
161
167
|
let prev = input.previousSummary
|
|
162
|
-
?
|
|
168
|
+
? stripOMContent(input.previousSummary)
|
|
163
169
|
: undefined;
|
|
164
|
-
prev = prev ?
|
|
170
|
+
prev = prev ? stripRecallNotes(prev) : undefined;
|
|
165
171
|
const merged = prev ? mergePrevious(prev, fresh) : fresh;
|
|
166
172
|
if (!merged) return "";
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// Remove trailing RECALL_NOTE (and any separators surrounding it) if present.
|
|
172
|
-
// Handles both current format (---\n\nNOTE) and bare trailing NOTE.
|
|
173
|
-
const idx = text.lastIndexOf(RECALL_NOTE);
|
|
174
|
-
if (idx < 0) return text;
|
|
175
|
-
return text.slice(0, idx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
|
|
173
|
+
// Defensive: remove any recall notes that survived the above (e.g. nested
|
|
174
|
+
// inside the brief transcript after a prior merge).
|
|
175
|
+
const cleaned = stripRecallNotes(merged);
|
|
176
|
+
return wrapLongLines(cleaned + SEPARATOR + RECALL_NOTE);
|
|
176
177
|
};
|
|
177
178
|
|
|
178
179
|
/**
|
|
179
|
-
* Strip
|
|
180
|
+
* Strip ALL recall-note paragraphs from text using paragraph-level matching.
|
|
180
181
|
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* summary to prevent compounding across compactions. The fresh OM projection is
|
|
184
|
-
* re-rendered each time.
|
|
182
|
+
* The recall note is identified by the sentence:
|
|
183
|
+
* "The conversation before this point has been compacted"
|
|
185
184
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
185
|
+
* After wrapLongLines runs, the recall note may be split across multiple lines,
|
|
186
|
+
* so exact string matching against RECALL_NOTE fails. Instead, split the text
|
|
187
|
+
* into paragraphs (double-newline boundaries) and drop any paragraph that
|
|
188
|
+
* contains the identifying sentence.
|
|
188
189
|
*/
|
|
190
|
+
const stripRecallNotes = (text: string): string => {
|
|
191
|
+
const paragraphs = text.split(/\n\n+/);
|
|
192
|
+
const kept = paragraphs.filter((p) => !p.includes("The conversation before this point has been compacted"));
|
|
193
|
+
return kept.join("\n\n");
|
|
194
|
+
};
|
|
195
|
+
|
|
189
196
|
const stripOMContent = (text: string): string => {
|
|
190
197
|
// Remove everything from "## Reflections" or "## Observations" onward,
|
|
191
198
|
// plus the instructions preamble that precedes them.
|