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 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
@@ -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.9",
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.75.5 <1.0.0",
44
- "@earendil-works/pi-ai": ">=0.75.4 <1.0.0",
45
- "@earendil-works/pi-coding-agent": ">=0.75.4 <1.0.0",
46
- "@earendil-works/pi-tui": ">=0.75.5 <1.0.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.75.5",
55
- "@earendil-works/pi-ai": "0.75.4",
56
- "@earendil-works/pi-coding-agent": "0.75.4",
57
- "@earendil-works/pi-tui": "0.75.5",
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.7"
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
+ }
@@ -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 for overview, /blackhole-memory view for visible observations & reflections, /blackhole-memory full for complete recorded memory (copies to clipboard).",
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);
@@ -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: /blackhole configure (open settings overlay), " +
35
- "/blackhole om-off (disable memory), /blackhole om-on (re-enable memory).",
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.saved) {
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") {
@@ -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
 
@@ -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
- const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
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
- // Strip any legacy RECALL_NOTE baked into prev summary (pre-fix format)
158
- // so merge doesn't re-stack it inside the brief.
159
- // Also strip OM content (## Reflections / ## Observations) from previous
160
- // compactions these are re-rendered fresh by the before-compact hook.
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
- ? stripRecallNote(input.previousSummary)
168
+ ? stripOMContent(input.previousSummary)
163
169
  : undefined;
164
- prev = prev ? stripOMContent(prev) : undefined;
170
+ prev = prev ? stripRecallNotes(prev) : undefined;
165
171
  const merged = prev ? mergePrevious(prev, fresh) : fresh;
166
172
  if (!merged) return "";
167
- return wrapLongLines(merged + SEPARATOR + RECALL_NOTE);
168
- };
169
-
170
- const stripRecallNote = (text: string): string => {
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 OM content and recall-guidance footers from a previous compaction summary.
180
+ * Strip ALL recall-note paragraphs from text using paragraph-level matching.
180
181
  *
181
- * OM content (## Reflections / ## Observations + instructions) is appended after
182
- * compile() by the before-compact hook, so it must be stripped from the previous
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
- * Also strips the basic recall-guidance footer that is always appended when OM
187
- * is off or has no entries.
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.