pi-blackhole 0.3.8 → 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
+ }