pi-blackhole 0.3.9 → 0.4.0
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/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 +13 -4
- package/src/core/summarize.ts +30 -23
- package/src/core/unified-config.ts +39 -11
- package/src/extract/commits.ts +68 -25
- package/src/extract/goals.ts +13 -2
- package/src/hooks/before-compact.ts +5 -2
- 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 +13 -20
- package/src/om/configure-overlay.ts +39 -5
- package/src/om/consolidation.ts +23 -16
- package/src/om/ledger/projection.ts +6 -1
- package/src/om/runtime.ts +58 -31
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/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.0",
|
|
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
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
OM_OBSERVATIONS_RECORDED,
|
|
18
18
|
OM_REFLECTIONS_RECORDED,
|
|
19
19
|
} from "../om/ledger/index.js";
|
|
20
|
+
import { handleCleanup } from "./cleanup.js";
|
|
20
21
|
|
|
21
22
|
const formatTokens = (n: number): string => {
|
|
22
23
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
@@ -31,11 +32,12 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
31
32
|
pi.registerCommand("blackhole", {
|
|
32
33
|
description:
|
|
33
34
|
"Compact conversation — structured summary (with observational memory when enabled). " +
|
|
34
|
-
"Subcommands:
|
|
35
|
-
"
|
|
35
|
+
"Subcommands: [configure] settings overlay, [cleanup] remove orphaned files, " +
|
|
36
|
+
"[om-off] / [om-on] disable / re-enable memory.",
|
|
36
37
|
getArgumentCompletions: (prefix: string) => {
|
|
37
38
|
const subcommands = [
|
|
38
39
|
{ value: "configure", label: "Open configuration overlay to edit settings [configure]" },
|
|
40
|
+
{ value: "cleanup", label: "Find and remove orphaned pending files [cleanup]" },
|
|
39
41
|
{ value: "om-off", label: "Disable observational memory [om-off]" },
|
|
40
42
|
{ value: "om-on", label: "Enable observational memory [om-on]" },
|
|
41
43
|
];
|
|
@@ -49,12 +51,15 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
49
51
|
const trimmed = (typeof args === "string" ? args : "").trim();
|
|
50
52
|
if (trimmed === "configure") {
|
|
51
53
|
// Open the config overlay
|
|
52
|
-
const result = await ctx.ui.custom<{ saved: boolean; path: string } | undefined>(
|
|
54
|
+
const result = await ctx.ui.custom<{ saved: boolean; path: string; error?: string } | undefined>(
|
|
53
55
|
(tui, theme, _kb, done) => createConfigureOverlay(configPath(), theme, tui, done),
|
|
54
56
|
{ overlay: true },
|
|
55
57
|
);
|
|
56
58
|
if (result) {
|
|
57
|
-
if (result.
|
|
59
|
+
if (result.error) {
|
|
60
|
+
ctx.ui.notify(result.error, "warning");
|
|
61
|
+
} else if (result.saved) {
|
|
62
|
+
runtime.reloadConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
|
|
58
63
|
ctx.ui.notify("Configuration saved.", "info");
|
|
59
64
|
} else {
|
|
60
65
|
ctx.ui.notify("Failed to save configuration — the config file may be read-only (e.g., managed by Nix).", "warning");
|
|
@@ -62,6 +67,10 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
62
67
|
}
|
|
63
68
|
return;
|
|
64
69
|
}
|
|
70
|
+
if (trimmed === "cleanup") {
|
|
71
|
+
await handleCleanup(ctx);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
65
74
|
if (trimmed === "om-off") {
|
|
66
75
|
const saved = saveUnifiedConfig({ memory: false });
|
|
67
76
|
runtime.config.memory = false;
|
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.
|
|
@@ -76,6 +76,9 @@ export interface UnifiedConfig {
|
|
|
76
76
|
compactAfterTokens: number;
|
|
77
77
|
/** Observation pool token pressure for full fold. */
|
|
78
78
|
observationsPoolMaxTokens: number;
|
|
79
|
+
/** Treat every compaction as a full-fold boundary so early reflections/drops
|
|
80
|
+
* survive the first compaction in a fresh session. Default true. */
|
|
81
|
+
fullFoldAlways: boolean;
|
|
79
82
|
/** Target token budget for the observation pool (dropper aims here).
|
|
80
83
|
* Optional; defaults to half of observationsPoolMaxTokens when unset.
|
|
81
84
|
* Must be less than observationsPoolMaxTokens.
|
|
@@ -151,6 +154,7 @@ export const DEFAULTS: UnifiedConfig = {
|
|
|
151
154
|
reflectAfterTokens: 25_000,
|
|
152
155
|
compactAfterTokens: 81_000,
|
|
153
156
|
observationsPoolMaxTokens: 20_000,
|
|
157
|
+
fullFoldAlways: true,
|
|
154
158
|
observationsPoolTargetTokens: 10_000,
|
|
155
159
|
reflectorInputMaxTokens: 80_000,
|
|
156
160
|
dropperInputMaxTokens: 80_000,
|
|
@@ -240,6 +244,7 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
|
|
|
240
244
|
if (typeof raw.noAutoCompact === "boolean") c.noAutoCompact = raw.noAutoCompact;
|
|
241
245
|
if (typeof raw.passive === "boolean") c.passive = raw.passive;
|
|
242
246
|
if (typeof raw.memory === "boolean") c.memory = raw.memory;
|
|
247
|
+
if (typeof raw.fullFoldAlways === "boolean") c.fullFoldAlways = raw.fullFoldAlways;
|
|
243
248
|
if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
|
|
244
249
|
|
|
245
250
|
// Numeric fields — use nonNegativeInt for observerPreambleMaxTokens (0 = auto)
|
|
@@ -319,35 +324,54 @@ function migrateOldKnobs(parsed: Record<string, unknown>): void {
|
|
|
319
324
|
|
|
320
325
|
// ── Load and save ────────────────────────────────────────────────────────────
|
|
321
326
|
|
|
322
|
-
function readJson(path: string): Record<string, unknown> | null {
|
|
323
|
-
if (!existsSync(path)) return null;
|
|
327
|
+
function readJson(path: string): { data: Record<string, unknown> | null; error: string | null } {
|
|
328
|
+
if (!existsSync(path)) return { data: null, error: null };
|
|
324
329
|
try {
|
|
325
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
326
|
-
} catch {
|
|
327
|
-
|
|
330
|
+
return { data: JSON.parse(readFileSync(path, "utf-8")), error: null };
|
|
331
|
+
} catch (e) {
|
|
332
|
+
const msg = `blackhole: config file at ${path} has invalid JSON: ${(e as Error).message}. Using defaults.`;
|
|
333
|
+
console.warn(msg);
|
|
334
|
+
return { data: null, error: msg };
|
|
328
335
|
}
|
|
329
336
|
}
|
|
330
337
|
|
|
338
|
+
/** Optional warning callback invoked when the primary config file has invalid JSON.
|
|
339
|
+
* Receives the warning message string. Used by callers with UI access to surface
|
|
340
|
+
* the error via ctx.ui.notify(message, "warning").
|
|
341
|
+
*/
|
|
342
|
+
type WarnFn = (message: string) => void;
|
|
343
|
+
|
|
331
344
|
/**
|
|
332
345
|
* Load unified configuration from ~/.pi/agent/pi-blackhole/pi-blackhole-config.json.
|
|
333
346
|
* Falls back to legacy sources if the unified file doesn't exist.
|
|
334
347
|
*/
|
|
335
|
-
export function loadUnifiedConfig(cwd: string): UnifiedConfig {
|
|
348
|
+
export function loadUnifiedConfig(cwd: string, onWarn?: WarnFn): UnifiedConfig {
|
|
336
349
|
const path = configPath();
|
|
337
|
-
let raw
|
|
350
|
+
let raw: Record<string, unknown> | null;
|
|
351
|
+
let primaryError: string | null = null;
|
|
352
|
+
const result = readJson(path);
|
|
353
|
+
raw = result.data;
|
|
354
|
+
primaryError = result.error;
|
|
355
|
+
if (primaryError && onWarn) onWarn(primaryError);
|
|
338
356
|
|
|
339
357
|
// Fallback to legacy sources if unified file doesn't exist
|
|
340
358
|
if (!raw) {
|
|
341
359
|
// Try legacy pi-vcc config
|
|
342
360
|
const piVccPath = join(getAgentDir(), "pi-vcc-config.json");
|
|
343
|
-
const
|
|
361
|
+
const piVccResult = readJson(piVccPath);
|
|
362
|
+
const piVccRaw = piVccResult.data;
|
|
363
|
+
if (piVccResult.error && onWarn) onWarn(piVccResult.error);
|
|
344
364
|
|
|
345
365
|
// Try legacy om config from settings.json
|
|
346
366
|
const settingsPath = join(getAgentDir(), "settings.json");
|
|
347
|
-
const
|
|
367
|
+
const settingsResult = readJson(settingsPath);
|
|
368
|
+
const settingsRaw = settingsResult.data;
|
|
369
|
+
if (settingsResult.error && onWarn) onWarn(settingsResult.error);
|
|
348
370
|
const omRaw = settingsRaw?.["pi-blackhole"] ?? settingsRaw?.["observational-memory"];
|
|
349
371
|
const projectSettingsPath = join(cwd, ".pi", "settings.json");
|
|
350
|
-
const
|
|
372
|
+
const projectResult = readJson(projectSettingsPath);
|
|
373
|
+
const projectRaw = projectResult.data;
|
|
374
|
+
if (projectResult.error && onWarn) onWarn(projectResult.error);
|
|
351
375
|
const projectOmRaw = projectRaw?.["pi-blackhole"] ?? projectRaw?.["observational-memory"];
|
|
352
376
|
|
|
353
377
|
// Merge legacy sources
|
|
@@ -449,7 +473,11 @@ export function saveUnifiedConfig(settings: Partial<UnifiedConfig>): boolean {
|
|
|
449
473
|
const path = configPath();
|
|
450
474
|
const dir = dirname(path);
|
|
451
475
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
452
|
-
const
|
|
476
|
+
const existingResult = readJson(path);
|
|
477
|
+
const existing = existingResult.data ?? {};
|
|
478
|
+
if (existingResult.error) {
|
|
479
|
+
console.warn("blackhole: overwriting corrupt config file at " + path);
|
|
480
|
+
}
|
|
453
481
|
const next = { ...existing, ...settings };
|
|
454
482
|
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
455
483
|
return true;
|