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.
@@ -18,41 +18,84 @@ const firstLineOf = (text: string): string => {
18
18
  const cleanMessage = (msg: string): string =>
19
19
  msg.replace(/\\"/g, '"').replace(/\\'/g, "'").trim();
20
20
 
21
+ /** Extract commit hash from git output text (tool_result or bash output). */
22
+ const extractHashFromOutput = (text: string): string | undefined => {
23
+ const bracket = text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
24
+ if (bracket) return bracket[1];
25
+ const range = text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
26
+ if (range) return range[2];
27
+ const plain = text.match(HASH_RE);
28
+ if (plain) return plain[1];
29
+ return undefined;
30
+ };
31
+
32
+ /** Try to extract a commit message from a git commit command string. */
33
+ const tryExtractMessage = (cmd: string): string | undefined => {
34
+ if (!/\bgit\s+commit\b/.test(cmd)) return undefined;
35
+ const m = cmd.match(COMMIT_MSG_RE);
36
+ if (!m) return undefined;
37
+ const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
38
+ return message || undefined;
39
+ };
40
+
21
41
  /**
22
- * Extract git commits from bash tool calls (`git commit -m "..."`) and pair
23
- * with hash from the immediately following tool_result.
42
+ * Extract git commits from bash tool calls, bash execution messages,
43
+ * and user messages that wrap bash execution output (post-convertToLlm).
44
+ *
45
+ * Handles three block kinds:
46
+ * - tool_call (name: "bash") — agent tool call to the bash tool
47
+ * - bash — pi's internal bashExecution message
48
+ * - user — convertToLlm wraps bashExecution as "Ran `cmd`\n```\noutput\n```"
24
49
  */
25
50
  export const extractCommits = (blocks: NormalizedBlock[]): CommitInfo[] => {
26
51
  const commits: CommitInfo[] = [];
52
+ const addCommit = (hash: string | undefined, message: string) => {
53
+ const key = `${hash ?? ""}::${message}`;
54
+ if (!commits.some((c) => `${c.hash ?? ""}::${c.message}` === key)) {
55
+ commits.push({ hash, message });
56
+ }
57
+ };
27
58
 
28
59
  for (let i = 0; i < blocks.length; i++) {
29
60
  const b = blocks[i];
30
- if (b.kind !== "tool_call" || b.name !== "bash") continue;
31
- const cmd = typeof b.args.command === "string" ? b.args.command : "";
32
- if (!/\bgit\s+commit\b/.test(cmd)) continue;
33
- const m = cmd.match(COMMIT_MSG_RE);
34
- if (!m) continue;
35
- const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
36
- if (!message) continue;
37
61
 
38
- let hash: string | undefined;
39
- // Look at next tool_result for hash
40
- for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
41
- const r = blocks[j];
42
- if (r.kind !== "tool_result") continue;
43
- // Common git commit output: `[branch <hash>] message` or `<branch> <hash>..<hash>`
44
- const bracket = r.text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
45
- if (bracket) { hash = bracket[1]; break; }
46
- const range = r.text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
47
- if (range) { hash = range[2]; break; }
48
- const plain = r.text.match(HASH_RE);
49
- if (plain) { hash = plain[1]; break; }
62
+ // ── Case 1: tool_call (agent calls bash tool) ──
63
+ if (b.kind === "tool_call" && b.name === "bash") {
64
+ const cmd = (b.args && typeof b.args.command === "string") ? b.args.command : "";
65
+ const message = tryExtractMessage(cmd);
66
+ if (!message) continue;
67
+
68
+ let hash: string | undefined;
69
+ for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
70
+ const r = blocks[j];
71
+ if (r.kind !== "tool_result") continue;
72
+ hash = extractHashFromOutput(r.text);
73
+ if (hash) break;
74
+ }
75
+ addCommit(hash, message);
76
+ continue;
50
77
  }
51
78
 
52
- // Dedup by message+hash
53
- const key = `${hash ?? ""}::${message}`;
54
- if (!commits.some((c) => `${c.hash ?? ""}::${c.message}` === key)) {
55
- commits.push({ hash, message });
79
+ // ── Case 2: bash execution message ──
80
+ if (b.kind === "bash") {
81
+ const message = tryExtractMessage(b.command);
82
+ if (!message) continue;
83
+ const hash = extractHashFromOutput(b.output);
84
+ addCommit(hash, message);
85
+ continue;
86
+ }
87
+
88
+ // ── Case 3: user message wrapping a bash execution (post-convertToLlm) ──
89
+ if (b.kind === "user") {
90
+ // Detect "Ran `git commit -m "..."`" pattern in user text
91
+ const ranCmd = b.text.match(/Ran\s+`((?:[^`\\]|\\.)*)`/);
92
+ if (!ranCmd) continue;
93
+ const message = tryExtractMessage(ranCmd[1]);
94
+ if (!message) continue;
95
+ // Extract output from the code block following the command
96
+ const codeBlock = b.text.match(/```\n([\s\S]*?)```/);
97
+ const hash = codeBlock ? extractHashFromOutput(codeBlock[1]) : undefined;
98
+ addCommit(hash, message);
56
99
  }
57
100
  }
58
101
 
@@ -40,6 +40,11 @@ const isSubstantiveGoal = (text: string): boolean => {
40
40
  return true;
41
41
  };
42
42
 
43
+ const FIRST_MSG_CLIP = 80;
44
+
45
+ const indexSuffix = (sourceIndex?: number): string =>
46
+ sourceIndex != null ? ` (#${sourceIndex})` : "";
47
+
43
48
  // Test scope-change / task intent only on the leading portion of a user block
44
49
  // so that pasted outputs below the actual instruction do not trigger matches.
45
50
  const LEADING_CHARS = 200;
@@ -47,6 +52,7 @@ const LEADING_CHARS = 200;
47
52
  export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
48
53
  const goals: string[] = [];
49
54
  let latestScopeChange: string[] | null = null;
55
+ let latestScopeIndex: number | undefined;
50
56
 
51
57
  for (const b of blocks) {
52
58
  if (b.kind !== "user") continue;
@@ -58,21 +64,26 @@ export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
58
64
  if (lines.length === 0) continue;
59
65
 
60
66
  if (goals.length === 0) {
61
- goals.push(...lines.slice(0, 6));
67
+ goals.push(...lines.slice(0, 6).map((l) => clip(l, FIRST_MSG_CLIP) + indexSuffix(b.sourceIndex)));
62
68
  continue;
63
69
  }
64
70
 
65
71
  const leading = b.text.slice(0, LEADING_CHARS);
66
72
  if (SCOPE_CHANGE_RE.test(leading)) {
67
73
  latestScopeChange = lines.slice(0, 3).map((l) => clip(l, MAX_GOAL_CHARS));
74
+ latestScopeIndex = b.sourceIndex;
68
75
  } else if (TASK_RE.test(leading) && lines[0].length > 15) {
69
76
  latestScopeChange = lines.slice(0, 2).map((l) => clip(l, MAX_GOAL_CHARS));
77
+ latestScopeIndex = b.sourceIndex;
70
78
  }
71
79
  }
72
80
 
73
81
  // Only emit the [Scope change] marker when we actually captured bullets.
74
82
  if (latestScopeChange && latestScopeChange.length > 0) {
75
- goals.push("[Scope change]", ...latestScopeChange);
83
+ goals.push("[Scope change]");
84
+ for (const line of latestScopeChange) {
85
+ goals.push(line + indexSuffix(latestScopeIndex));
86
+ }
76
87
  }
77
88
 
78
89
  return goals.slice(0, 8);
@@ -240,7 +240,7 @@ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
240
240
  export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime) => {
241
241
  pi.on("session_before_compact", (event, ctx) => {
242
242
  const { preparation, branchEntries, customInstructions } = event;
243
- omRuntime.ensureConfig(ctx.cwd ?? process.cwd());
243
+ omRuntime.ensureConfig(ctx.cwd ?? process.cwd(), (msg) => ctx.ui?.notify?.(msg, "warning"));
244
244
  const trace = (ev: string, d?: Record<string, unknown>) => debugLog(ev, d, omRuntime.config.debugLog === true);
245
245
 
246
246
  trace("before_compact.enter", {
@@ -460,7 +460,10 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
460
460
  const projection = buildCompactionProjection(
461
461
  branchEntries as any[],
462
462
  firstKeptEntryId,
463
- { observationsPoolMaxTokens: omRuntime.config.observationsPoolMaxTokens },
463
+ {
464
+ observationsPoolMaxTokens: omRuntime.config.observationsPoolMaxTokens,
465
+ fullFoldAlways: omRuntime.config.fullFoldAlways,
466
+ },
464
467
  );
465
468
  omContent = renderSummary(projection.reflections, projection.observations);
466
469
  omDetails = projection.details;
@@ -8,7 +8,7 @@
8
8
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
9
9
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
10
  import { createBridgeStreamFn } from "../../provider-stream.js";
11
- import { streamSimple } from "@earendil-works/pi-ai";
11
+ import { streamSimple } from "@earendil-works/pi-ai/compat";
12
12
  import { Type } from "typebox";
13
13
  import type { Static } from "typebox";
14
14
  import { debugLog } from "../../debug-log.js";
@@ -9,7 +9,7 @@
9
9
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
10
10
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
11
11
  import { createBridgeStreamFn } from "../../provider-stream.js";
12
- import { streamSimple } from "@earendil-works/pi-ai";
12
+ import { streamSimple } from "@earendil-works/pi-ai/compat";
13
13
  import { Type } from "typebox";
14
14
  import type { Static } from "typebox";
15
15
  import { hashId } from "../../ids.js";
@@ -8,7 +8,7 @@
8
8
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
9
9
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
10
  import { createBridgeStreamFn } from "../../provider-stream.js";
11
- import { streamSimple } from "@earendil-works/pi-ai";
11
+ import { streamSimple } from "@earendil-works/pi-ai/compat";
12
12
  import { Type } from "typebox";
13
13
  import type { Static } from "typebox";
14
14
  import { hashId } from "../../ids.js";
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Cleanup utility — scans pending JSON files and cross-references against
3
+ * session JSONL files to find orphaned entries safe to delete.
4
+ *
5
+ * Per-session pending files under ~/.pi/agent/pi-blackhole/ accumulate when:
6
+ * - compaction is set to "manual" (noAutoCompact legacy) — OM outputs are
7
+ * buffered rather than appended to the session
8
+ * - sessions are forked, abandoned, or deleted — the pending files remain
9
+ * - stale backup files (-pending.stale.json) persist after write-safe renames
10
+ *
11
+ * Safety invariant: a pending file is ONLY orphaned if its sessionId does NOT
12
+ * appear in ANY session JSONL file across all known session directories.
13
+ */
14
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
15
+ import { join, resolve, sep } from "node:path";
16
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
+
18
+ // ── Types ───────────────────────────────────────────────────────────────────
19
+
20
+ export interface PendingFile {
21
+ sessionId: string;
22
+ filename: string;
23
+ path: string;
24
+ /** True for -pending.stale.json (backup of previous write). */
25
+ isStale: boolean;
26
+ sizeBytes: number;
27
+ /** Last modified timestamp (epoch ms). */
28
+ mtimeMs: number;
29
+ }
30
+
31
+ export interface CleanupReport {
32
+ /** All pending files found (both pending and stale). */
33
+ all: PendingFile[];
34
+ /** Files whose sessionId does not appear in any session JSONL. */
35
+ orphaned: PendingFile[];
36
+ /** Files whose sessionId was matched to an active session. */
37
+ active: PendingFile[];
38
+ }
39
+
40
+ // ── Constants ───────────────────────────────────────────────────────────────
41
+
42
+ const PENDING_DIR = "pi-blackhole";
43
+ const PENDING_SUFFIX = "-pending.json";
44
+ const STALE_SUFFIX = "-pending.stale.json";
45
+
46
+ // ── Helper: extract session ID from filename ────────────────────────────────
47
+
48
+ function extractSessionId(filename: string): string | null {
49
+ if (filename.endsWith(STALE_SUFFIX)) {
50
+ return filename.slice(0, -STALE_SUFFIX.length) || null;
51
+ }
52
+ if (filename.endsWith(PENDING_SUFFIX)) {
53
+ return filename.slice(0, -PENDING_SUFFIX.length) || null;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // ── Scan pending files ──────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Scan the pi-blackhole directory for all *-pending.json and *-pending.stale.json
62
+ * files. Returns file metadata sorted by mtime (newest first).
63
+ */
64
+ function scanPendingFiles(agentDir: string = getAgentDir()): PendingFile[] {
65
+ const dir = join(agentDir, PENDING_DIR);
66
+ if (!existsSync(dir)) return [];
67
+
68
+ const results: PendingFile[] = [];
69
+ let entries: string[];
70
+ try {
71
+ entries = readdirSync(dir);
72
+ } catch {
73
+ return [];
74
+ }
75
+
76
+ for (const filename of entries) {
77
+ const sessionId = extractSessionId(filename);
78
+ if (!sessionId) continue;
79
+
80
+ const filePath = join(dir, filename);
81
+ let stat;
82
+ try {
83
+ stat = statSync(filePath);
84
+ } catch {
85
+ continue; // file disappeared between readdir and stat
86
+ }
87
+
88
+ results.push({
89
+ sessionId,
90
+ filename,
91
+ path: filePath,
92
+ isStale: filename.endsWith(STALE_SUFFIX),
93
+ sizeBytes: stat.size,
94
+ mtimeMs: stat.mtimeMs,
95
+ });
96
+ }
97
+
98
+ // Sort: newest first
99
+ results.sort((a, b) => b.mtimeMs - a.mtimeMs);
100
+ return results;
101
+ }
102
+
103
+ // ── Scan session IDs from JSONL files ───────────────────────────────────────
104
+
105
+ /**
106
+ * Recursively scan a directory for .jsonl files and extract session IDs from
107
+ * their header line (first line: {"type":"session","id":"<uuid>",...}).
108
+ */
109
+ function scanSessionDir(dir: string): Set<string> {
110
+ const ids = new Set<string>();
111
+ if (!existsSync(dir)) return ids;
112
+
113
+ const stack: string[] = [dir];
114
+ while (stack.length > 0) {
115
+ const current = stack.pop()!;
116
+ let names: string[];
117
+ try {
118
+ names = readdirSync(current);
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ for (const name of names) {
124
+ const fullPath = join(current, name);
125
+ let st;
126
+ try {
127
+ st = statSync(fullPath);
128
+ } catch {
129
+ continue;
130
+ }
131
+
132
+ if (st.isDirectory()) {
133
+ stack.push(fullPath);
134
+ } else if (st.isFile() && name.endsWith(".jsonl")) {
135
+ try {
136
+ const fd = readFileSync(fullPath, "utf-8");
137
+ const newlineIdx = fd.indexOf("\n");
138
+ const firstLine = newlineIdx >= 0 ? fd.slice(0, newlineIdx) : fd;
139
+ const header = JSON.parse(firstLine) as Record<string, unknown>;
140
+ if (header.type === "session" && typeof header.id === "string" && header.id.length > 0) {
141
+ ids.add(header.id);
142
+ }
143
+ } catch {
144
+ // Corrupt or unreadable file — skip
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ return ids;
151
+ }
152
+
153
+ /**
154
+ * Read sessionDir override from settings.json, if any.
155
+ * Returns the resolved absolute path, or undefined if not set or unreadable.
156
+ */
157
+ function readSettingsSessionDir(): string | undefined {
158
+ try {
159
+ const settingsPath = join(getAgentDir(), "settings.json");
160
+ if (!existsSync(settingsPath)) return undefined;
161
+ const raw = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
162
+ const dir = raw.sessionDir;
163
+ if (typeof dir === "string" && dir.trim().length > 0) {
164
+ // Expand ~ if present
165
+ const expanded = dir.startsWith("~") ? join(process.env.HOME ?? "/home", dir.slice(2)) : dir;
166
+ return resolve(expanded);
167
+ }
168
+ } catch {
169
+ // Unreadable settings — use default
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ /**
175
+ * Find all known session directories.
176
+ * Always includes the default sessions dir; also includes custom sessionDir
177
+ * from settings.json if present and different from the default.
178
+ */
179
+ /** Get the default sessions directory. */
180
+ function getDefaultSessionsDir(): string {
181
+ return join(getAgentDir(), "sessions");
182
+ }
183
+
184
+ function findSessionDirs(): string[] {
185
+ const dirs: string[] = [];
186
+ const default_ = getDefaultSessionsDir();
187
+ dirs.push(default_);
188
+
189
+ const custom = readSettingsSessionDir();
190
+ if (custom && custom !== default_ && existsSync(custom)) {
191
+ dirs.push(custom);
192
+ }
193
+
194
+ return dirs;
195
+ }
196
+
197
+ /**
198
+ * Collect all session IDs from all known session directories.
199
+ * Returns a Set of session UUIDs found in JSONL headers.
200
+ */
201
+ function collectAllSessionIds(sessionDirs?: string[]): Set<string> {
202
+ const dirs = sessionDirs ?? findSessionDirs();
203
+ const allIds = new Set<string>();
204
+ for (const dir of dirs) {
205
+ const ids = scanSessionDir(dir);
206
+ for (const id of ids) allIds.add(id);
207
+ }
208
+ return allIds;
209
+ }
210
+
211
+ // ── Cross-reference: find orphaned files ────────────────────────────────────
212
+
213
+ /**
214
+ * Determine which pending files are orphaned (no matching session JSONL).
215
+ *
216
+ * A pending file is orphaned when no session JSONL in any known session
217
+ * directory declares the same session ID in its header.
218
+ *
219
+ * Returns the full report with all files classified.
220
+ */
221
+ function crossReference(
222
+ pending: PendingFile[],
223
+ sessionIds: Set<string>,
224
+ ): CleanupReport {
225
+ const orphaned: PendingFile[] = [];
226
+ const active: PendingFile[] = [];
227
+
228
+ for (const pf of pending) {
229
+ if (sessionIds.has(pf.sessionId)) {
230
+ active.push(pf);
231
+ } else {
232
+ orphaned.push(pf);
233
+ }
234
+ }
235
+
236
+ return { all: pending, orphaned, active };
237
+ }
238
+
239
+ /**
240
+ * Full pipeline: scan pending files, collect session IDs, cross-reference.
241
+ * Returns the cleanup report.
242
+ */
243
+ export function analyzeOrphaned(agentDir?: string, sessionDirs?: string[]): CleanupReport {
244
+ const pending = scanPendingFiles(agentDir);
245
+ if (pending.length === 0) {
246
+ return { all: [], orphaned: [], active: [] };
247
+ }
248
+ const sessionIds = collectAllSessionIds(sessionDirs);
249
+ return crossReference(pending, sessionIds);
250
+ }
251
+
252
+ // ── Deletion ────────────────────────────────────────────────────────────────
253
+
254
+ /** Session IDs are UUIDs or similar alphanumeric+hyphen strings. */
255
+ const SAFE_SESSION_ID_RE = /^[a-zA-Z0-9][-a-zA-Z0-9]*$/;
256
+
257
+ /**
258
+ * Validate that resolving and joining with `sessionId` cannot escape the
259
+ * pi-blackhole directory. Must be called before any unlink.
260
+ */
261
+ function validateDeletionPaths(
262
+ sessionId: string,
263
+ pendingDir: string,
264
+ ): { ok: true; pendingPath: string; stalePath: string } | { ok: false } {
265
+ // sessionId must be non-empty and contain only safe filesystem characters
266
+ if (!sessionId || typeof sessionId !== "string") return { ok: false };
267
+ if (!SAFE_SESSION_ID_RE.test(sessionId)) return { ok: false };
268
+
269
+ const resolvedDir = resolve(pendingDir);
270
+ const pendingPath = join(resolvedDir, `${sessionId}${PENDING_SUFFIX}`);
271
+ const stalePath = join(resolvedDir, `${sessionId}${STALE_SUFFIX}`);
272
+
273
+ // Resolve to absolute and verify containment within pi-blackhole/
274
+ const resolvedPending = resolve(pendingPath);
275
+ const resolvedStale = resolve(stalePath);
276
+
277
+ if (!resolvedPending.startsWith(resolvedDir + sep)) return { ok: false };
278
+ if (!resolvedStale.startsWith(resolvedDir + sep)) return { ok: false };
279
+
280
+ return { ok: true, pendingPath: resolvedPending, stalePath: resolvedStale };
281
+ }
282
+
283
+ /**
284
+ * Delete all pending files (pending + stale) for a given sessionId.
285
+ *
286
+ * Safety: validates that both resolved paths live under the pi-blackhole/
287
+ * directory before unlinking. Returns false if validation fails.
288
+ *
289
+ * Returns true if at least one file was deleted, false if no files existed
290
+ * or if the paths failed containment validation.
291
+ */
292
+ export function deletePendingFiles(sessionId: string, agentDir?: string): boolean {
293
+ const dir = join(agentDir ?? getAgentDir(), PENDING_DIR);
294
+ const valid = validateDeletionPaths(sessionId, dir);
295
+ if (!valid.ok) return false;
296
+
297
+ const { pendingPath, stalePath } = valid;
298
+
299
+ let deleted = false;
300
+
301
+ try {
302
+ if (existsSync(pendingPath)) {
303
+ unlinkSync(pendingPath);
304
+ deleted = true;
305
+ }
306
+ } catch {
307
+ // Best-effort — file may be locked or already gone
308
+ }
309
+
310
+ try {
311
+ if (existsSync(stalePath)) {
312
+ unlinkSync(stalePath);
313
+ deleted = true;
314
+ }
315
+ } catch {
316
+ // Best-effort
317
+ }
318
+
319
+ return deleted;
320
+ }
321
+
322
+ /**
323
+ * Delete multiple pending files by sessionId.
324
+ * Returns the count of successfully deleted file sets.
325
+ */
326
+ export function deleteOrphanedBatch(
327
+ orphaned: PendingFile[],
328
+ agentDir?: string,
329
+ ): number {
330
+ let count = 0;
331
+ for (const pf of orphaned) {
332
+ if (deletePendingFiles(pf.sessionId, agentDir)) {
333
+ count++;
334
+ }
335
+ }
336
+ return count;
337
+ }
338
+
339
+ // ── Formatting helpers ──────────────────────────────────────────────────────
340
+
341
+ /** Human-readable file size. */
342
+ function formatSize(bytes: number): string {
343
+ if (bytes < 1024) return `${bytes} B`;
344
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
345
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
346
+ }
347
+
348
+ /** Human-readable age from epoch ms. */
349
+ function formatAge(mtimeMs: number, nowMs: number = Date.now()): string {
350
+ const diffMs = nowMs - mtimeMs;
351
+ const seconds = Math.floor(diffMs / 1000);
352
+ if (seconds < 60) return `${seconds}s ago`;
353
+ const minutes = Math.floor(seconds / 60);
354
+ if (minutes < 60) return `${minutes}m ago`;
355
+ const hours = Math.floor(minutes / 60);
356
+ if (hours < 24) return `${hours}h ago`;
357
+ const days = Math.floor(hours / 24);
358
+ if (days < 365) return `${days}d ago`;
359
+ const years = Math.floor(days / 365);
360
+ return `${years}y ago`;
361
+ }
362
+
363
+ /** Summary line for a pending file. */
364
+ export function describeFile(pf: PendingFile, nowMs?: number): string {
365
+ const label = pf.isStale ? "stale" : "pending";
366
+ return `${pf.sessionId.slice(0, 8)}… ${label} ${formatSize(pf.sizeBytes)} ${formatAge(pf.mtimeMs, nowMs)}`;
367
+ }
@@ -28,6 +28,9 @@ function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" |
28
28
 
29
29
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
30
30
  pi.on("agent_start", () => {
31
+ // Reset the info gate — allow one info notification during the new turn.
32
+ runtime.resetInfoGate();
33
+
31
34
  // A new turn is starting — abort any pending auto-compaction wait.
32
35
  // The new turn's own agent_end will re-evaluate the threshold and
33
36
  // schedule a fresh wait if compaction is still needed.
@@ -49,7 +52,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
49
52
  }
50
53
 
51
54
  function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
52
- runtime.ensureConfig(ctx.cwd);
55
+ runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
56
+ // Reset the info gate — allow one notification during agent_end.
57
+ runtime.resetInfoGate();
53
58
 
54
59
  // Pass the config flag explicitly — this handler runs outside ALS context
55
60
  // (agent_end events don't flow through consolidation's withDebugLogContext),
@@ -136,12 +141,8 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
136
141
 
137
142
  dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
138
143
 
139
- notifySafely(
140
- hasUI,
141
- ui,
142
- `Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
143
- "info",
144
- );
144
+ runtime.tryEmitInfo(hasUI, ui,
145
+ `Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`);
145
146
 
146
147
  runtime.compactInFlight = true;
147
148
  const controller = new AbortController();
@@ -189,12 +190,8 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
189
190
  runtime.compactInFlight = false;
190
191
  runtime.autoCompactionController = null;
191
192
  dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
192
- notifySafely(
193
- hasUI,
194
- ui,
195
- "Observational memory: compaction cancelled — session changed before compaction",
196
- "info",
197
- );
193
+ runtime.tryEmitInfo(hasUI, ui,
194
+ "Observational memory: compaction cancelled — session changed before compaction");
198
195
  return;
199
196
  }
200
197
 
@@ -229,12 +226,8 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
229
226
  runtime.compactInFlight = false;
230
227
  runtime.autoCompactionController = null;
231
228
  dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
232
- notifySafely(
233
- hasUI,
234
- ui,
235
- "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
236
- "info",
237
- );
229
+ runtime.tryEmitInfo(hasUI, ui,
230
+ "Observational memory: compaction skipped — another compaction already ran before deferred compaction");
238
231
  return;
239
232
  }
240
233
 
@@ -246,7 +239,7 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
246
239
  onComplete: (result: any) => {
247
240
  runtime.compactInFlight = false;
248
241
  dbg("compaction_trigger.onComplete", { result: !!result });
249
- notifySafely(hasUI, ui, "Observational memory: compaction complete", "info");
242
+ runtime.tryEmitInfo(hasUI, ui, "Observational memory: compaction complete");
250
243
  },
251
244
  onError: (error: { message: string }) => {
252
245
  runtime.compactInFlight = false;