1450f718ba9b90c04f8e452604c229eec1583fbf
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/dot_pi/agent/extensions/pi-tree-pruning/index.ts b/dot_pi/agent/extensions/pi-tree-pruning/index.ts
2index 4a95e8325e9b1634fb628d27e4e8b7734d5d1f85..4e22f76ea2d2db556de5e55cb89b2f61fc885ca5 100644
3--- a/dot_pi/agent/extensions/pi-tree-pruning/index.ts
4+++ b/dot_pi/agent/extensions/pi-tree-pruning/index.ts
5@@ -2,20 +2,25 @@ import { basename } from "node:path";
6 import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7 import {
8 captureSessionFile,
9+ countPruneEntries,
10 createBackupPath,
11 listSessionBackups,
12 pruneSessionFile,
13 restoreSessionBackup,
14 } from "./session-file.ts";
15-import { selectPruneCandidate } from "./prune-selector.ts";
16-import { findInactiveBranchCandidates } from "./tree-model.ts";
17-import { buildPruneTreeItems, formatEntryCount } from "./tree-view.ts";
18+import { selectPruneCandidates } from "./prune-selector.ts";
19+import { findInactiveBranchCandidates, normalizePruneCandidates } from "./tree-model.ts";
20+import {
21+ buildPruneTreeItems,
22+ formatEntryCount,
23+ formatPruneTreeItem,
24+} from "./tree-view.ts";
25
26 const COMMAND_NAME = "tree-prune";
27
28 export default function registerTreePruning(pi: ExtensionAPI): void {
29 pi.registerCommand(COMMAND_NAME, {
30- description: "Delete an inactive session-tree branch with a backup; use /tree-prune undo to restore",
31+ description: "Delete inactive session-tree branches with a backup; use /tree-prune undo to restore",
32 handler: async (args, ctx) => {
33 await runTreePruneCommand(args, ctx);
34 },
35@@ -71,22 +76,29 @@ async function runPrune(ctx: ExtensionCommandContext): Promise<void> {
36 }
37
38 const treeItems = buildPruneTreeItems(candidates, (entryId) => ctx.sessionManager.getLabel(entryId));
39- const candidate = await selectPruneCandidate(ctx, treeItems);
40- if (!candidate) return;
41-
42+ const selectedCandidates = await selectPruneCandidates(ctx, treeItems);
43+ if (!selectedCandidates || selectedCandidates.length === 0) return;
44+
45+ const candidatesToPrune = normalizePruneCandidates(selectedCandidates);
46+ const removedEntryCount = countPruneEntries(candidatesToPrune);
47+ const branchesToPrune = candidatesToPrune.map((candidate) => {
48+ const item = treeItems.find((treeItem) => treeItem.candidate.rootId === candidate.rootId);
49+ return `• ${item ? formatPruneTreeItem(item) : candidate.preview}`;
50+ });
51 const backupPath = createBackupPath(sessionFile);
52 const confirmed = await ctx.ui.confirm(
53- "Delete inactive branch?",
54+ "Delete inactive branches?",
55 [
56- `Branch: ${candidate.preview}`,
57- `Entries to delete: ${candidate.entryCount}`,
58+ "Branches to delete:",
59+ ...branchesToPrune,
60+ `Entries to delete: ${formatEntryCount(removedEntryCount)}`,
61 `Backup: ${backupPath}`,
62- "The branch will be removed from this session file.",
63+ "The branches will be removed from this session file.",
64 ].join("\n"),
65 );
66 if (!confirmed) return;
67
68- const result = await pruneSessionFile(snapshot, candidate, backupPath);
69+ const result = await pruneSessionFile(snapshot, candidatesToPrune, backupPath);
70 const switchResult = await ctx.switchSession(sessionFile, {
71 withSession: async (replacement) => {
72 replacement.ui.notify(
73diff --git a/dot_pi/agent/extensions/pi-tree-pruning/package-lock.json b/dot_pi/agent/extensions/pi-tree-pruning/package-lock.json
74new file mode 100644
75index 0000000000000000000000000000000000000000..5602dffa2670211769a814791d54ddca57f2499d
76--- /dev/null
77+++ b/dot_pi/agent/extensions/pi-tree-pruning/package-lock.json
78@@ -0,0 +1,1927 @@
79+{
80+ "name": "pi-tree-pruning",
81+ "version": "0.1.0",
82+ "lockfileVersion": 3,
83+ "requires": true,
84+ "packages": {
85+ "": {
86+ "name": "pi-tree-pruning",
87+ "version": "0.1.0",
88+ "devDependencies": {
89+ "@earendil-works/pi-coding-agent": "0.84.2",
90+ "@earendil-works/pi-tui": "0.84.2",
91+ "@types/node": "^24.5.2",
92+ "typescript": "^5.9.2"
93+ },
94+ "peerDependencies": {
95+ "@earendil-works/pi-coding-agent": "^0.84.2",
96+ "@earendil-works/pi-tui": "^0.84.2"
97+ }
98+ },
99+ "node_modules/@anthropic-ai/sdk": {
100+ "version": "0.91.1",
101+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
102+ "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
103+ "dev": true,
104+ "license": "MIT",
105+ "dependencies": {
106+ "json-schema-to-ts": "^3.1.1"
107+ },
108+ "bin": {
109+ "anthropic-ai-sdk": "bin/cli"
110+ },
111+ "peerDependencies": {
112+ "zod": "^3.25.0 || ^4.0.0"
113+ },
114+ "peerDependenciesMeta": {
115+ "zod": {
116+ "optional": true
117+ }
118+ }
119+ },
120+ "node_modules/@aws-crypto/sha256-browser": {
121+ "version": "5.2.0",
122+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
123+ "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
124+ "dev": true,
125+ "license": "Apache-2.0",
126+ "dependencies": {
127+ "@aws-crypto/sha256-js": "^5.2.0",
128+ "@aws-crypto/supports-web-crypto": "^5.2.0",
129+ "@aws-crypto/util": "^5.2.0",
130+ "@aws-sdk/types": "^3.222.0",
131+ "@aws-sdk/util-locate-window": "^3.0.0",
132+ "@smithy/util-utf8": "^2.0.0",
133+ "tslib": "^2.6.2"
134+ }
135+ },
136+ "node_modules/@aws-crypto/sha256-js": {
137+ "version": "5.2.0",
138+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
139+ "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
140+ "dev": true,
141+ "license": "Apache-2.0",
142+ "dependencies": {
143+ "@aws-crypto/util": "^5.2.0",
144+ "@aws-sdk/types": "^3.222.0",
145+ "tslib": "^2.6.2"
146+ },
147+ "engines": {
148+ "node": ">=16.0.0"
149+ }
150+ },
151+ "node_modules/@aws-crypto/supports-web-crypto": {
152+ "version": "5.2.0",
153+ "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
154+ "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
155+ "dev": true,
156+ "license": "Apache-2.0",
157+ "dependencies": {
158+ "tslib": "^2.6.2"
159+ }
160+ },
161+ "node_modules/@aws-crypto/util": {
162+ "version": "5.2.0",
163+ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
164+ "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
165+ "dev": true,
166+ "license": "Apache-2.0",
167+ "dependencies": {
168+ "@aws-sdk/types": "^3.222.0",
169+ "@smithy/util-utf8": "^2.0.0",
170+ "tslib": "^2.6.2"
171+ }
172+ },
173+ "node_modules/@aws-sdk/client-bedrock-runtime": {
174+ "version": "3.1048.0",
175+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz",
176+ "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==",
177+ "dev": true,
178diff --git a/dot_pi/agent/extensions/pi-tree-pruning/package.json b/dot_pi/agent/extensions/pi-tree-pruning/package.json
179index bbcf59847315555fa7bb05f4482ed9c159ff9815..3763c9b0ac5eb8a67516a13b313a1cd7b20fb86c 100644
180--- a/dot_pi/agent/extensions/pi-tree-pruning/package.json
181+++ b/dot_pi/agent/extensions/pi-tree-pruning/package.json
182@@ -4,9 +4,8 @@
183 "version": "0.1.0",
184 "type": "module",
185 "scripts": {
186- "test": "node --test test/*.test.ts",
187 "typecheck": "tsc --noEmit",
188- "check": "npm run typecheck && npm test"
189+ "check": "npm run typecheck"
190 },
191 "pi": {
192 "extensions": [
193@@ -14,10 +13,12 @@
194 ]
195 },
196 "peerDependencies": {
197- "@earendil-works/pi-coding-agent": "*",
198- "@earendil-works/pi-tui": "*"
199+ "@earendil-works/pi-coding-agent": "^0.84.2",
200+ "@earendil-works/pi-tui": "^0.84.2"
201 },
202 "devDependencies": {
203+ "@earendil-works/pi-coding-agent": "0.84.2",
204+ "@earendil-works/pi-tui": "0.84.2",
205 "@types/node": "^24.5.2",
206 "typescript": "^5.9.2"
207 }
208diff --git a/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts b/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
209index abdacaf07d2e58f793c1d5973796c403177357d4..e07ad6dfc25731207d4cc4b0c20be3639f9976fc 100644
210--- a/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
211+++ b/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
212@@ -1,28 +1,30 @@
213 import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
214-import { type BranchCandidate } from "./tree-model.ts";
215+import {
216+ pruneCandidatesOverlap,
217+ type BranchCandidate,
218+} from "./tree-model.ts";
219 import { formatPruneTreeItem, type PruneTreeItem } from "./tree-view.ts";
220
221-export async function selectPruneCandidate(
222+export async function selectPruneCandidates(
223 ctx: ExtensionCommandContext,
224 items: readonly PruneTreeItem[],
225-): Promise<BranchCandidate | undefined> {
226+): Promise<BranchCandidate[] | undefined> {
227 if (ctx.mode === "tui") {
228 return selectWithTreeUi(ctx, items);
229 }
230
231- const choices = makeUniqueChoices(items);
232- const selected = await ctx.ui.select("Prune inactive branch:", choices.map((choice) => choice.label));
233- return choices.find((choice) => choice.label === selected)?.item.candidate;
234+ return selectWithFallback(ctx, items);
235 }
236
237 async function selectWithTreeUi(
238 ctx: ExtensionCommandContext,
239 items: readonly PruneTreeItem[],
240-): Promise<BranchCandidate | undefined> {
241+): Promise<BranchCandidate[] | undefined> {
242 const { Key, matchesKey, truncateToWidth } = await import("@earendil-works/pi-tui");
243- const selectedRootId = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
244+ const selectedRootIds = await ctx.ui.custom<readonly string[] | undefined>((tui, theme, _keybindings, done) => {
245 let selectedIndex = 0;
246 let scrollOffset = 0;
247+ let selectedRootIds = new Set<string>();
248
249 const visibleItemCount = (): number => Math.max(1, (tui.terminal.rows ?? 24) - 5);
250 const keepSelectionVisible = (): void => {
251@@ -52,16 +54,26 @@ async function selectWithTreeUi(
252 moveSelection(1);
253 return;
254 }
255- if (matchesKey(data, Key.enter)) {
256- done(items[selectedIndex]?.candidate.rootId);
257+ if (matchesKey(data, Key.space)) {
258+ const candidate = items[selectedIndex]?.candidate;
259+ if (!candidate) return;
260+ selectedRootIds = togglePruneCandidateSelection(selectedRootIds, candidate, items);
261+ tui.requestRender();
262+ return;
263+ }
264+ if (matchesKey(data, Key.enter) && selectedRootIds.size > 0) {
265+ done([...selectedRootIds]);
266 }
267 },
268 render(width: number): string[] {
269 const visibleCount = visibleItemCount();
270 const visibleItems = items.slice(scrollOffset, scrollOffset + visibleCount);
271 const lines = [
272- theme.fg("accent", theme.bold("Prune inactive branch:")),
273- theme.fg("dim", "↑↓ move • enter select • esc cancel"),
274+ theme.fg("accent", theme.bold("Prune inactive branches:")),
275+ theme.fg(
276+ "dim",
277+ `↑↓ move • space toggle • enter confirm (${formatSelectedBranchCount(selectedRootIds.size)}) • esc cancel`,
278+ ),
279 ];
280
281 if (scrollOffset > 0) lines.push(theme.fg("dim", `… ${scrollOffset} earlier branches`));
282@@ -71,7 +83,8 @@ async function selectWithTreeUi(
283 if (!item) continue;
284
285 const itemIndex = scrollOffset + index;
286- const row = `${itemIndex === selectedIndex ? "→" : " "} ${item.treePrefix}${formatPruneTreeItem(item)}`;
287+ const marker = selectedRootIds.has(item.candidate.rootId) ? "[x]" : "[ ]";
288+ const row = `${itemIndex === selectedIndex ? "→" : " "} ${marker} ${item.treePrefix}${formatPruneTreeItem(item)}`;
289 const rendered = itemIndex === selectedIndex ? theme.fg("accent", theme.bold(row)) : row;
290 lines.push(rendered);
291 }
292@@ -85,7 +98,77 @@ async function selectWithTreeUi(
293 };
294 });
295
296- return items.find((item) => item.candidate.rootId === selectedRootId)?.candidate;
297+ return selectedRootIds ? selectedCandidates(items, new Set(selectedRootIds)) : undefined;
298+}
299+
300+async function selectWithFallback(
301+ ctx: ExtensionCommandContext,
302+ items: readonly PruneTreeItem[],
303+): Promise<BranchCandidate[] | undefined> {
304+ const choices = makeUniqueChoices(items);
305+ let selectedRootIds = new Set<string>();
306+
307+ while (true) {
308+ const selectableChoices = choices.filter(
309+ (choice) =>
310+ !selectedRootIds.has(choice.item.candidate.rootId) &&
311+ ![...selectedRootIds].some((rootId) => {
312diff --git a/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts b/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
313index 6017175b3d145c0ced784043f1884abe4466dc4b..99b6ec58ce17a0303d5fb3bb00c935544419c01d 100644
314--- a/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
315+++ b/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
316@@ -47,18 +47,9 @@ export async function assertSessionFileUnchanged(snapshot: SessionFileSnapshot):
317
318 export function buildPrunedSessionContent(
319 snapshot: SessionFileSnapshot,
320- candidate: BranchCandidate,
321+ candidates: readonly BranchCandidate[],
322 ): string {
323- const expectedEntries = new Map<string, TreeEntry>();
324- for (const entry of candidate.entries) {
325- if (expectedEntries.has(entry.id)) {
326- throw new Error(`Branch has duplicate entry ID ${entry.id}`);
327- }
328- expectedEntries.set(entry.id, entry);
329- }
330-
331- if (expectedEntries.size === 0) throw new Error("Branch has no entries");
332-
333+ const expectedEntries = collectPruneEntries(candidates);
334 const rawLines = splitRawLines(snapshot.content);
335 const linesByEntryId = new Map<string, number[]>();
336
337@@ -119,17 +110,22 @@ export async function createSessionBackup(
338 return path;
339 }
340
341+export function countPruneEntries(candidates: readonly BranchCandidate[]): number {
342+ return collectPruneEntries(candidates).size;
343+}
344+
345 export async function pruneSessionFile(
346 snapshot: SessionFileSnapshot,
347- candidate: BranchCandidate,
348+ candidates: readonly BranchCandidate[],
349 backupPath = createBackupPath(snapshot.path),
350 ): Promise<{ backupPath: string; removedEntryCount: number }> {
351- const prunedContent = buildPrunedSessionContent(snapshot, candidate);
352+ const removedEntryCount = countPruneEntries(candidates);
353+ const prunedContent = buildPrunedSessionContent(snapshot, candidates);
354 await assertSessionFileUnchanged(snapshot);
355 const createdBackupPath = await createSessionBackup(snapshot, backupPath);
356 await replaceSessionFile(snapshot, prunedContent);
357
358- return { backupPath: createdBackupPath, removedEntryCount: candidate.entryCount };
359+ return { backupPath: createdBackupPath, removedEntryCount };
360 }
361
362 export async function restoreSessionBackup(
363@@ -259,10 +255,31 @@ function parseRecord(line: string): Record<string, unknown> | undefined {
364 }
365 }
366
367+function collectPruneEntries(candidates: readonly BranchCandidate[]): Map<string, TreeEntry> {
368+ const entriesById = new Map<string, TreeEntry>();
369+
370+ for (const candidate of candidates) {
371+ for (const entry of candidate.entries) {
372+ const existingEntry = entriesById.get(entry.id);
373+ if (existingEntry && !sameTreeEntry(existingEntry, entry)) {
374+ throw new Error(`Selected branches disagree about entry ${entry.id}`);
375+ }
376+ entriesById.set(entry.id, entry);
377+ }
378+ }
379+
380+ if (entriesById.size === 0) throw new Error("Select at least one branch");
381+ return entriesById;
382+}
383+
384 function matchesEntry(record: Record<string, unknown>, entry: TreeEntry): boolean {
385 return record.id === entry.id && record.parentId === entry.parentId && record.type === entry.type;
386 }
387
388+function sameTreeEntry(left: TreeEntry, right: TreeEntry): boolean {
389+ return left.id === right.id && left.parentId === right.parentId && left.type === right.type;
390+}
391+
392 function temporaryPathFor(path: string): string {
393 return join(dirname(path), `.${basename(path)}.tree-prune-tmp-${process.pid}-${randomUUID()}`);
394 }
395diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts
396deleted file mode 100644
397index 60c6f31aafed54975c0e0acbc474b869f7d96988..0000000000000000000000000000000000000000
398--- a/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts
399+++ /dev/null
400@@ -1,75 +0,0 @@
401-import assert from "node:assert/strict";
402-import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
403-import { tmpdir } from "node:os";
404-import { join } from "node:path";
405-import test from "node:test";
406-import type {
407- ExtensionAPI,
408- ExtensionCommandContext,
409- RegisteredCommand,
410-} from "@earendil-works/pi-coding-agent";
411-import registerTreePruning from "../index.ts";
412-
413-const originalContent = [
414- '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
415- '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
416- '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
417- '{"type":"message","id":"inactive","parentId":"root","timestamp":"now","message":{"role":"user","content":"inactive"}}\n',
418-].join("");
419-
420-const entries = [
421- { id: "root", parentId: null, type: "message", message: { role: "user", content: "root" } },
422- { id: "active", parentId: "root", type: "message", message: { role: "user", content: "active" } },
423- { id: "inactive", parentId: "root", type: "message", message: { role: "user", content: "inactive" } },
424-];
425-
426-test("prunes a selected branch, creates a backup, and reloads the session", async () => {
427- const directory = await mkdtemp(join(tmpdir(), "pi-tree-pruning-command-"));
428- const sessionPath = join(directory, "session.jsonl");
429- await writeFile(sessionPath, originalContent, "utf8");
430-
431- let handler: ((args: string, ctx: ExtensionCommandContext) => Promise<void>) | undefined;
432- registerTreePruning({
433- registerCommand(
434- _name: string,
435- command: Omit<RegisteredCommand, "name" | "sourceInfo">,
436- ) {
437- handler = command.handler;
438- },
439- } as unknown as ExtensionAPI);
440-
441- const notifications: string[] = [];
442- let reloadedSession: string | undefined;
443- const context = {
444- hasUI: true,
445- waitForIdle: async () => {},
446- sessionManager: {
447- getSessionFile: () => sessionPath,
448- getLeafId: () => "active",
449- getEntries: () => entries,
450- getLabel: () => undefined,
451- },
452- ui: {
453- select: async (_title: string, options: string[]) => options[0],
454- confirm: async () => true,
455- notify: (message: string) => notifications.push(message),
456- },
457- switchSession: async (path: string, options?: { withSession?: (ctx: { ui: { notify: (message: string) => void } }) => Promise<void> }) => {
458- reloadedSession = path;
459- await options?.withSession?.({ ui: { notify: (message) => notifications.push(message) } });
460- return { cancelled: false };
461- },
462- } as unknown as ExtensionCommandContext;
463-
464- assert.ok(handler);
465- await handler("", context);
466-
467- assert.equal(reloadedSession, sessionPath);
468- assert.doesNotMatch(await readFile(sessionPath, "utf8"), /"inactive"/);
469- assert.match(notifications.join("\n"), /Tree Prune removed 1 entry/);
470-
471- const files = await readdir(directory);
472- const backupName = files.find((file) => file.startsWith("session.jsonl.tree-prune-backup-"));
473- assert.ok(backupName);
474- assert.equal(await readFile(join(directory, backupName), "utf8"), originalContent);
475-});
476diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts
477deleted file mode 100644
478index 3cf76bfcdc7e98b8c525f78c17fa90d41d4a5b0b..0000000000000000000000000000000000000000
479--- a/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts
480+++ /dev/null
481@@ -1,110 +0,0 @@
482-import assert from "node:assert/strict";
483-import { mkdtemp, readFile, writeFile } from "node:fs/promises";
484-import { tmpdir } from "node:os";
485-import { join } from "node:path";
486-import test from "node:test";
487-import {
488- assertSessionFileUnchanged,
489- buildPrunedSessionContent,
490- captureSessionFile,
491- createBackupPath,
492- createSessionBackup,
493- pruneSessionFile,
494- restoreSessionBackup,
495-} from "../session-file.ts";
496-import { findInactiveBranchCandidates, type TreeEntry } from "../tree-model.ts";
497-
498-const sessionContent = [
499- '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
500- '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
501- 'this is not json\n',
502- '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
503- '{"type":"message","id":"inactive","parentId":"root","timestamp":"now","message":{"role":"user","content":"inactive"}}\n',
504- '{"type":"custom","id":"metadata","parentId":"inactive","timestamp":"now","customType":"example"}\n',
505-].join("");
506-
507-const prunedContent = [
508- '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
509- '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
510- 'this is not json\n',
511- '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
512-].join("");
513-
514-function treeEntries(): TreeEntry[] {
515- return [
516- { id: "root", parentId: null, type: "message", message: { role: "user", content: "root" } },
517- { id: "active", parentId: "root", type: "message", message: { role: "user", content: "active" } },
518- { id: "inactive", parentId: "root", type: "message", message: { role: "user", content: "inactive" } },
519- { id: "metadata", parentId: "inactive", type: "custom" },
520- ];
521-}
522-
523-async function createFixture(): Promise<string> {
524- const directory = await mkdtemp(join(tmpdir(), "pi-tree-pruning-"));
525- const sessionPath = join(directory, "session.jsonl");
526- await writeFile(sessionPath, sessionContent, "utf8");
527- return sessionPath;
528-}
529-
530-function inactiveCandidate() {
531- const candidate = findInactiveBranchCandidates(treeEntries(), "active")[0];
532- assert.ok(candidate);
533- return candidate;
534-}
535-
536-test("removes only the selected JSONL lines and retains unrelated raw lines", async () => {
537- const sessionPath = await createFixture();
538- const snapshot = await captureSessionFile(sessionPath);
539-
540- assert.equal(buildPrunedSessionContent(snapshot, inactiveCandidate()), prunedContent);
541-});
542-
543-test("creates a same-directory backup without changing the session", async () => {
544- const sessionPath = await createFixture();
545- const snapshot = await captureSessionFile(sessionPath);
546- const backupPath = createBackupPath(sessionPath);
547-
548- const createdBackupPath = await createSessionBackup(snapshot, backupPath);
549-
550- assert.equal(createdBackupPath, backupPath);
551- assert.match(backupPath, /session\.jsonl\.tree-prune-backup-/);
552- assert.equal(await readFile(backupPath, "utf8"), sessionContent);
553- assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
554-});
555-
556-test("detects a changed session file before mutation", async () => {
557- const sessionPath = await createFixture();
558- const snapshot = await captureSessionFile(sessionPath);
559- await writeFile(sessionPath, `${sessionContent}changed\n`, "utf8");
560-
561- await assert.rejects(assertSessionFileUnchanged(snapshot), /changed while the dialog was open/);
562-});
563-
564-test("prunes with a backup and can restore that backup with a backup of the pruned session", async () => {
565- const sessionPath = await createFixture();
566- const originalSnapshot = await captureSessionFile(sessionPath);
567- const originalBackupPath = createBackupPath(sessionPath);
568-
569- const pruneResult = await pruneSessionFile(
570- originalSnapshot,
571- inactiveCandidate(),
572- originalBackupPath,
573- );
574-
575- assert.equal(pruneResult.removedEntryCount, 2);
576- assert.equal(await readFile(sessionPath, "utf8"), prunedContent);
577- assert.equal(await readFile(originalBackupPath, "utf8"), sessionContent);
578-
579- const prunedSnapshot = await captureSessionFile(sessionPath);
580- const originalBackupSnapshot = await captureSessionFile(originalBackupPath);
581diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts
582deleted file mode 100644
583index f81e4524a301d64febd9c3da6181fd27c787fb92..0000000000000000000000000000000000000000
584--- a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts
585+++ /dev/null
586@@ -1,83 +0,0 @@
587-import assert from "node:assert/strict";
588-import test from "node:test";
589-import { findInactiveBranchCandidates, type TreeEntry } from "../tree-model.ts";
590-
591-function entry(id: string, parentId: string | null, type = "message"): TreeEntry {
592- if (type !== "message") return { id, parentId, type };
593- return { id, parentId, type, message: { role: "user", content: `message ${id}` } };
594-}
595-
596-test("lists complete inactive subtrees and protects the active subtree", () => {
597- const entries = [
598- entry("root", null),
599- entry("active", "root"),
600- entry("active-leaf", "active"),
601- entry("inactive", "root"),
602- entry("inactive-child", "inactive"),
603- ];
604-
605- const candidates = findInactiveBranchCandidates(entries, "active-leaf");
606-
607- assert.equal(candidates.length, 1);
608- assert.equal(candidates[0]?.rootId, "inactive");
609- assert.deepEqual(candidates[0]?.entryIds, ["inactive", "inactive-child"]);
610- assert.equal(candidates[0]?.entryCount, 2);
611- assert.match(candidates[0]?.preview ?? "", /^user: message inactive$/);
612-});
613-
614-test("keeps metadata in a removable subtree and supports multiple roots", () => {
615- const entries = [
616- entry("root-a", null),
617- entry("active", "root-a"),
618- entry("inactive", "root-a"),
619- entry("label", "inactive", "label"),
620- entry("root-b", null),
621- entry("branch-b1", "root-b"),
622- entry("branch-b2", "root-b"),
623- ];
624-
625- const candidates = findInactiveBranchCandidates(entries, "active");
626-
627- assert.deepEqual(
628- candidates.map((candidate) => [candidate.rootId, candidate.entryIds]),
629- [
630- ["inactive", ["inactive", "label"]],
631- ["branch-b1", ["branch-b1"]],
632- ["branch-b2", ["branch-b2"]],
633- ],
634- );
635-});
636-
637-test("rejects ambiguous children, cycles, duplicates, and a missing active leaf", () => {
638- const ambiguousChild = { id: "broken", parentId: "root", type: 42 };
639- const entries = [
640- entry("root", null),
641- entry("active", "root"),
642- entry("inactive", "root"),
643- ambiguousChild,
644- entry("duplicate", "inactive"),
645- entry("duplicate", "inactive"),
646- entry("cycle-a", "cycle-b"),
647- entry("cycle-b", "cycle-a"),
648- ] as unknown as TreeEntry[];
649-
650- assert.deepEqual(findInactiveBranchCandidates(entries, "active"), []);
651- assert.deepEqual(findInactiveBranchCandidates(entries, "missing"), []);
652-});
653-
654-test("does not offer a nested candidate that reaches a cycle", () => {
655- const entries = [
656- entry("root", null),
657- entry("active", "root"),
658- entry("inactive", "root"),
659- entry("fork", "inactive"),
660- entry("safe", "fork"),
661- entry("cycle-a", "fork"),
662- entry("cycle-b", "cycle-a"),
663- entry("cycle-a", "cycle-b"),
664- ] as unknown as TreeEntry[];
665-
666- const candidates = findInactiveBranchCandidates(entries, "active");
667-
668- assert.deepEqual(candidates, []);
669-});
670diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts
671deleted file mode 100644
672index 67fe657929ac3f9c83c9529e3e878bfae6a4af8e..0000000000000000000000000000000000000000
673--- a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts
674+++ /dev/null
675@@ -1,42 +0,0 @@
676-import assert from "node:assert/strict";
677-import test from "node:test";
678-import type { BranchCandidate } from "../tree-model.ts";
679-import {
680- buildPruneTreeItems,
681- formatEntryCount,
682- formatPruneTreeItem,
683-} from "../tree-view.ts";
684-
685-function candidate(
686- rootId: string,
687- forkPointId: string,
688- entryIds: string[],
689- preview: string,
690-): BranchCandidate {
691- return {
692- rootId,
693- forkPointId,
694- entries: entryIds.map((id) => ({ id, parentId: null, type: "message" })),
695- entryIds,
696- entryCount: entryIds.length,
697- preview,
698- };
699-}
700-
701-test("renders nested branches with labels and correct entry counts", () => {
702- const main = candidate("main", "active-fork", ["main", "nested-fork", "quick"], "user: main work");
703- const quick = candidate("quick", "nested-fork", ["quick", "quick-reply"], "user: check implementation");
704-
705- const items = buildPruneTreeItems([main, quick], (entryId) =>
706- entryId === "quick" ? "quick check" : undefined,
707- );
708-
709- assert.deepEqual(
710- items.map((item) => item.treePrefix),
711- ["", " └─ "],
712- );
713- assert.equal(formatPruneTreeItem(items[0]!), "user: main work · 3 entries");
714- assert.equal(formatPruneTreeItem(items[1]!), "[quick check] user: check implementation · 2 entries");
715- assert.equal(formatEntryCount(1), "1 entry");
716- assert.equal(formatEntryCount(2), "2 entries");
717-});
718diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts b/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
719index a73d083dd3080d2846508ab78445db2f75ab6c0b..0259ff06f65fdbe261c8f0112122d3696a4c6fd0 100644
720--- a/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
721+++ b/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
722@@ -56,6 +56,32 @@ export function findInactiveBranchCandidates(
723 return candidates;
724 }
725
726+export function normalizePruneCandidates(
727+ candidates: readonly BranchCandidate[],
728+): BranchCandidate[] {
729+ const candidatesByRootId = new Map<string, BranchCandidate>();
730+ for (const candidate of candidates) {
731+ if (!candidatesByRootId.has(candidate.rootId)) {
732+ candidatesByRootId.set(candidate.rootId, candidate);
733+ }
734+ }
735+
736+ const uniqueCandidates = [...candidatesByRootId.values()];
737+ return uniqueCandidates.filter(
738+ (candidate) =>
739+ !uniqueCandidates.some(
740+ (possibleParent) =>
741+ possibleParent.rootId !== candidate.rootId &&
742+ possibleParent.entryIds.includes(candidate.rootId),
743+ ),
744+ );
745+}
746+
747+export function pruneCandidatesOverlap(left: BranchCandidate, right: BranchCandidate): boolean {
748+ if (left.rootId === right.rootId) return true;
749+ return left.entryIds.some((entryId) => right.entryIds.includes(entryId));
750+}
751+
752 function buildTreeIndex(entries: readonly TreeEntry[]): TreeIndex {
753 const validEntries = entries.filter(isTreeEntry);
754 const occurrences = new Map<string, number>();
755diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json b/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
756index 86e5d35b67366531cab4135639e3eef377e6945a..ea2b147fee7ae840125b3ade94280e9d9a8ecbcf 100644
757--- a/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
758+++ b/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
759@@ -14,5 +14,5 @@
760 "skipLibCheck": true,
761 "types": ["node"]
762 },
763- "include": ["./*.ts", "./test/**/*.ts"]
764+ "include": ["./*.ts"]
765 }