sessioner 0.1.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.
Files changed (58) hide show
  1. package/README.md +251 -0
  2. package/lib/actions/delete.d.ts +4 -0
  3. package/lib/actions/delete.js +57 -0
  4. package/lib/actions/fork.d.ts +2 -0
  5. package/lib/actions/fork.js +30 -0
  6. package/lib/actions/merge.d.ts +2 -0
  7. package/lib/actions/merge.js +106 -0
  8. package/lib/actions/open.d.ts +2 -0
  9. package/lib/actions/open.js +10 -0
  10. package/lib/actions/preview.d.ts +2 -0
  11. package/lib/actions/preview.js +56 -0
  12. package/lib/actions/prune.d.ts +2 -0
  13. package/lib/actions/prune.js +103 -0
  14. package/lib/actions/rename.d.ts +2 -0
  15. package/lib/actions/rename.js +87 -0
  16. package/lib/actions/trim.d.ts +2 -0
  17. package/lib/actions/trim.js +26 -0
  18. package/lib/bin/index.d.ts +2 -0
  19. package/lib/bin/index.js +217 -0
  20. package/lib/helpers/ansi.d.ts +9 -0
  21. package/lib/helpers/ansi.js +9 -0
  22. package/lib/helpers/format-date.d.ts +1 -0
  23. package/lib/helpers/format-date.js +8 -0
  24. package/lib/helpers/format-stats.d.ts +3 -0
  25. package/lib/helpers/format-stats.js +81 -0
  26. package/lib/helpers/get-columns.d.ts +1 -0
  27. package/lib/helpers/get-columns.js +8 -0
  28. package/lib/helpers/rename-cache.d.ts +3 -0
  29. package/lib/helpers/rename-cache.js +18 -0
  30. package/lib/helpers/styled-label.d.ts +2 -0
  31. package/lib/helpers/styled-label.js +2 -0
  32. package/lib/helpers/truncate.d.ts +1 -0
  33. package/lib/helpers/truncate.js +1 -0
  34. package/lib/sessions/analyze-prune.d.ts +12 -0
  35. package/lib/sessions/analyze-prune.js +77 -0
  36. package/lib/sessions/count-subagents.d.ts +2 -0
  37. package/lib/sessions/count-subagents.js +13 -0
  38. package/lib/sessions/extract-first-user-message.d.ts +1 -0
  39. package/lib/sessions/extract-first-user-message.js +42 -0
  40. package/lib/sessions/extract-messages.d.ts +5 -0
  41. package/lib/sessions/extract-messages.js +43 -0
  42. package/lib/sessions/get-entries.d.ts +1 -0
  43. package/lib/sessions/get-entries.js +25 -0
  44. package/lib/sessions/list-sessions.d.ts +2 -0
  45. package/lib/sessions/list-sessions.js +29 -0
  46. package/lib/sessions/parse-line.d.ts +2 -0
  47. package/lib/sessions/parse-line.js +8 -0
  48. package/lib/sessions/rewrite-jsonl.d.ts +1 -0
  49. package/lib/sessions/rewrite-jsonl.js +34 -0
  50. package/lib/sessions/stats.d.ts +3 -0
  51. package/lib/sessions/stats.js +146 -0
  52. package/lib/types.d.ts +100 -0
  53. package/lib/types.js +1 -0
  54. package/lib/ui/custom-select.d.ts +2 -0
  55. package/lib/ui/custom-select.js +113 -0
  56. package/lib/ui/select.d.ts +10 -0
  57. package/lib/ui/select.js +459 -0
  58. package/package.json +64 -0
package/README.md ADDED
@@ -0,0 +1,251 @@
1
+ <div align="center">
2
+
3
+ # Sessioner
4
+
5
+ ✨ An interactive CLI to navigate and manage [**Claude Code**](https://docs.anthropic.com/en/docs/claude-code) sessions from the terminal.
6
+
7
+ [![NPM Version](https://img.shields.io/npm/v/sessioner.svg?label=&color=70a1ff&logo=npm&logoColor=white)](https://www.npmjs.com/package/sessioner)
8
+
9
+ </div>
10
+
11
+ > 🚧 Work in Progress
12
+
13
+ ---
14
+
15
+ ## 💡 Why
16
+
17
+ Claude Code supports native forking from specific points, but loses context like thoughts, references, choices, and subagents. It also provides no built-in way to browse, merge, prune, or trim the `.jsonl` session files stored in `~/.claude/projects`.
18
+
19
+ **Sessioner** gives you a keyboard-driven interface to manage those sessions directly, including a **full fork** that preserves subagents and all session data:
20
+
21
+ - Browse all sessions in a project, sorted by date
22
+ - Preview conversations before acting
23
+ - Fork, merge, prune, trim, rename, or delete sessions
24
+ - View session and project stats (duration, tokens, costs, tool usage)
25
+ - Batch-clean empty session files
26
+
27
+ ---
28
+
29
+ ## 📦 Install
30
+
31
+ ```bash
32
+ npm i sessioner
33
+ ```
34
+
35
+ > Requires **Node.js >= 22**.
36
+
37
+ ---
38
+
39
+ ## 🚀 Quick Start
40
+
41
+ ```bash
42
+ sessioner
43
+ ```
44
+
45
+ - The CLI detects your current project and opens a main menu.
46
+
47
+ ---
48
+
49
+ ## ⚙️ Options
50
+
51
+ | Flag | Short | Description | Default |
52
+ | ------------------ | ----- | -------------------- | -------------------- |
53
+ | `--project <path>` | `-p` | Project path | Current directory |
54
+ | `--base <path>` | `-b` | Agent base directory | `~/.claude/projects` |
55
+
56
+ ---
57
+
58
+ ## 🔍 How It Works
59
+
60
+ 1. Scans `{base}/{project}` for `.jsonl` session files
61
+ 2. Shows a **main menu** (Sessions, Stats, Clean)
62
+ 3. Shows a **paginated session list** sorted by date (newest first)
63
+ 4. Displays a **conversation preview** for the selected session (up to 10 messages)
64
+ 5. Opens an **action menu** to operate on it
65
+
66
+ ---
67
+
68
+ ## 🛠️ Actions
69
+
70
+ ### Open
71
+
72
+ - Reveals the `.jsonl` file in your native file explorer
73
+ - macOS (Finder), Windows (Explorer), and Linux (xdg-open)
74
+
75
+ ---
76
+
77
+ ### Stats
78
+
79
+ - **Session stats** (from the action menu): statistics for a single session
80
+ - **Project stats** (from the main menu): aggregated statistics across all sessions
81
+ - Both display:
82
+ - Duration (time between first and last message)
83
+ - Message counts (user vs assistant)
84
+ - Token usage (input, output, cache creation, cache read)
85
+ - Cost breakdown by model
86
+ - Tool usage frequency
87
+ - Subagent count
88
+ - Project stats also shows the total session count
89
+
90
+ ---
91
+
92
+ ### Fork
93
+
94
+ - Creates an independent copy of the entire session
95
+ - All UUIDs (messages and subagents) are remapped to new random values
96
+ - Prompts for a custom title after forking
97
+ - Switches context to the new forked session
98
+
99
+ ---
100
+
101
+ ### Merge
102
+
103
+ - Combines multiple sessions into a single new one
104
+ - Multi-select UI with checkboxes for choosing which sessions to include
105
+ - Sessions are concatenated in chronological order
106
+ - All UUIDs are remapped to avoid conflicts
107
+ - Subagent files are copied and remapped
108
+ - Prompts for a custom title after merging
109
+
110
+ ---
111
+
112
+ ### Prune
113
+
114
+ - Analyzes the session and reports what can be removed:
115
+ - **Tool blocks**: `tool_use` and `tool_result` entries
116
+ - **Empty messages**: no text content after tools are stripped
117
+ - **Short messages**: text under 50 characters
118
+ - **System/IDE tags**: `<system-reminder>`, `<ide_selection>`, `<ide_opened_file>`
119
+ - Checkbox selection for what to remove (all pre-selected by default)
120
+ - Repairs the parent-child UUID chain after pruning
121
+ - Shows "Nothing to prune" when the session is already clean
122
+
123
+ ---
124
+
125
+ ### Trim
126
+
127
+ - Lists all messages in reverse order (newest first)
128
+ - Each line shows `[ YOU ]` or `[ LLM ]` followed by the message text
129
+ - Select the last message you want to keep
130
+ - Everything after the selected point is removed
131
+ - Confirmation prompt before applying
132
+
133
+ ---
134
+
135
+ ### Rename
136
+
137
+ - Text input for the new title (<kbd>Esc</kbd> to cancel)
138
+ - Updates three locations:
139
+ - Session file (prepends a `session_title` line)
140
+ - Sessions index (`sessions-index.json`)
141
+ - First user message (injects title into metadata)
142
+ - Tracks renames in a local cache (`.cache/renames.json`)
143
+
144
+ ---
145
+
146
+ ### Delete
147
+
148
+ - Shows exactly what will be deleted:
149
+ - Session `.jsonl` file
150
+ - Subagents directory (with file count)
151
+ - Removes the entry from the sessions index and rename cache
152
+ - Confirmation prompt before applying
153
+
154
+ ---
155
+
156
+ ### Clean (main menu)
157
+
158
+ - Batch-deletes empty session files
159
+ - Only appears when empty files exist
160
+ - Shows the count of empty sessions
161
+ - Confirmation prompt before applying
162
+
163
+ ---
164
+
165
+ ## ⌨️ Navigation
166
+
167
+ ---
168
+
169
+ ### Main Menu
170
+
171
+ - <kbd>Up</kbd> / <kbd>Down</kbd>: move cursor
172
+ - <kbd>Enter</kbd>: confirm
173
+ - <kbd>1</kbd>–<kbd>N</kbd>: select option by number
174
+ - <kbd>0</kbd>: exit
175
+
176
+ ---
177
+
178
+ ### Session List
179
+
180
+ - <kbd>Up</kbd> / <kbd>Down</kbd>: move cursor
181
+ - <kbd>Enter</kbd>: select session
182
+ - `←` Previous: previous page
183
+ - `→` Next: next page
184
+ - `☰` Back to menu: return to main menu
185
+ - `✕` Exit: exit
186
+
187
+ ---
188
+
189
+ ### Action Menu
190
+
191
+ - <kbd>Up</kbd> / <kbd>Down</kbd>: move cursor
192
+ - <kbd>Enter</kbd>: confirm action
193
+ - <kbd>1</kbd>–<kbd>8</kbd>: select action by number
194
+ - <kbd>9</kbd>: back
195
+ - <kbd>0</kbd>: exit
196
+
197
+ ---
198
+
199
+ ### Multi-Select (Merge / Prune)
200
+
201
+ - <kbd>Enter</kbd>: toggle item (`☑` selected, `☐` unselected)
202
+ - `✓` Confirm: confirm selection
203
+ - `✕` Cancel: cancel
204
+
205
+ ---
206
+
207
+ ### Confirmation (Clean / Trim / Delete)
208
+
209
+ - <kbd>Up</kbd> / <kbd>Down</kbd>: move cursor
210
+ - <kbd>Enter</kbd>: confirm selection
211
+ - <kbd>y</kbd>: yes
212
+ - <kbd>n</kbd>: no
213
+ - <kbd>1</kbd>: yes
214
+ - <kbd>2</kbd>: no
215
+
216
+ ---
217
+
218
+ ### Text Input (Rename)
219
+
220
+ - Type the new title
221
+ - <kbd>Enter</kbd>: confirm
222
+ - <kbd>Esc</kbd>: cancel
223
+
224
+ ---
225
+
226
+ ### Global
227
+
228
+ - <kbd>ESC</kbd>: go back one level at a time until exit
229
+ - <kbd>Ctrl+C</kbd>: exit
230
+
231
+ ---
232
+
233
+ ## 💻 Development
234
+
235
+ ```bash
236
+ git clone https://github.com/wellwelwel/sessioner
237
+ cd sessioner
238
+ npm install
239
+ npm start
240
+ ```
241
+
242
+ - `npm start`: run the CLI
243
+ - `npm run typecheck`: type-check without emitting
244
+ - `npm run lint`: check formatting
245
+ - `npm run lint:fix`: fix formatting
246
+
247
+ ---
248
+
249
+ ## ⚖️ License
250
+
251
+ **Sessioner** is under the [**MIT License**](https://github.com/wellwelwel/sessioner/blob/main/LICENSE).
@@ -0,0 +1,4 @@
1
+ import type { Session } from '../types.js';
2
+ export declare const targets: (session: Session) => Promise<string[]>;
3
+ export declare const cleanEmpty: (files: string[]) => Promise<void>;
4
+ export declare const remove: (session: Session) => Promise<void>;
@@ -0,0 +1,57 @@
1
+ import { readFile, rm, unlink, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { readCache, writeCache } from '../helpers/rename-cache.js';
4
+ import { countSubagents } from '../sessions/count-subagents.js';
5
+ export const targets = async (session) => {
6
+ const name = basename(session.file);
7
+ const count = await countSubagents(session);
8
+ const items = [name];
9
+ if (count > 0)
10
+ items.push(`${count} subagent files`);
11
+ return items;
12
+ };
13
+ const removeFromIndex = async (session) => {
14
+ const path = join(dirname(session.file), 'sessions-index.json');
15
+ try {
16
+ const raw = await readFile(path, 'utf-8');
17
+ const index = JSON.parse(raw);
18
+ const before = index.entries.length;
19
+ index.entries = index.entries.filter((entry) => entry.sessionId !== session.id);
20
+ if (index.entries.length < before) {
21
+ await writeFile(path, JSON.stringify(index, null, 2));
22
+ }
23
+ }
24
+ catch { }
25
+ };
26
+ const removeFromCache = async (session) => {
27
+ try {
28
+ const cache = await readCache();
29
+ if (!(session.file in cache))
30
+ return;
31
+ delete cache[session.file];
32
+ await writeCache(cache);
33
+ }
34
+ catch { }
35
+ };
36
+ export const cleanEmpty = async (files) => {
37
+ for (const file of files) {
38
+ const id = basename(file, '.jsonl');
39
+ const subagentDir = join(dirname(file), id);
40
+ try {
41
+ await unlink(file);
42
+ await rm(subagentDir, { recursive: true, force: true });
43
+ }
44
+ catch { }
45
+ }
46
+ };
47
+ export const remove = async (session) => {
48
+ const id = basename(session.file, '.jsonl');
49
+ const subagentDir = join(dirname(session.file), id);
50
+ try {
51
+ await unlink(session.file);
52
+ await rm(subagentDir, { recursive: true, force: true });
53
+ }
54
+ catch { }
55
+ await removeFromIndex(session);
56
+ await removeFromCache(session);
57
+ };
@@ -0,0 +1,2 @@
1
+ import type { Session } from '../types.js';
2
+ export declare const fork: (session: Session, title: string) => Promise<Session>;
@@ -0,0 +1,30 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { cp, rename as fsRename, readdir, readFile, writeFile, } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { rewriteJsonl } from '../sessions/rewrite-jsonl.js';
5
+ export const fork = async (session, title) => {
6
+ const id = randomUUID();
7
+ const dir = dirname(session.file);
8
+ const file = join(dir, `${id}.jsonl`);
9
+ const sourceId = basename(session.file, '.jsonl');
10
+ const raw = await readFile(session.file, 'utf-8');
11
+ const sourceDir = join(dir, sourceId);
12
+ const targetDir = join(dir, id);
13
+ await writeFile(file, rewriteJsonl(raw, id));
14
+ try {
15
+ await cp(sourceDir, targetDir, { recursive: true });
16
+ const subagentsDir = join(targetDir, 'subagents');
17
+ const entries = await readdir(subagentsDir);
18
+ for (const entry of entries) {
19
+ if (!entry.endsWith('.jsonl'))
20
+ continue;
21
+ const path = join(subagentsDir, entry);
22
+ const subagentId = randomUUID();
23
+ const content = await readFile(path, 'utf-8');
24
+ await writeFile(path, rewriteJsonl(content, subagentId));
25
+ await fsRename(path, join(subagentsDir, `${subagentId}.jsonl`));
26
+ }
27
+ }
28
+ catch { }
29
+ return { id, date: '', message: title, file };
30
+ };
@@ -0,0 +1,2 @@
1
+ import type { Session } from '../types.js';
2
+ export declare const merge: (sessions: Session[], title: string) => Promise<Session>;
@@ -0,0 +1,106 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { rewriteJsonl } from '../sessions/rewrite-jsonl.js';
5
+ const buildUuidMap = (lines) => {
6
+ const uuidMap = new Map();
7
+ for (const line of lines) {
8
+ if (!line.trim())
9
+ continue;
10
+ try {
11
+ const parsed = JSON.parse(line);
12
+ if (parsed.type === 'session_title')
13
+ continue;
14
+ if (parsed.uuid)
15
+ uuidMap.set(parsed.uuid, randomUUID());
16
+ }
17
+ catch { }
18
+ }
19
+ return uuidMap;
20
+ };
21
+ const remapSession = (lines, uuidMap, sessionId, previousLastUuid) => {
22
+ const mapped = [];
23
+ let lastUuid;
24
+ for (const line of lines) {
25
+ if (!line.trim())
26
+ continue;
27
+ try {
28
+ const parsed = JSON.parse(line);
29
+ if (parsed.type === 'session_title')
30
+ continue;
31
+ if (parsed.uuid) {
32
+ const newUuid = uuidMap.get(parsed.uuid);
33
+ if (newUuid) {
34
+ parsed.uuid = newUuid;
35
+ lastUuid = newUuid;
36
+ }
37
+ }
38
+ if (parsed.parentUuid) {
39
+ const remapped = uuidMap.get(parsed.parentUuid);
40
+ parsed.parentUuid = remapped ?? previousLastUuid ?? randomUUID();
41
+ }
42
+ else if (previousLastUuid && parsed.uuid) {
43
+ parsed.parentUuid = previousLastUuid;
44
+ }
45
+ if (parsed.sessionId)
46
+ parsed.sessionId = sessionId;
47
+ mapped.push(JSON.stringify(parsed));
48
+ }
49
+ catch {
50
+ mapped.push(line);
51
+ }
52
+ }
53
+ return { mapped, lastUuid };
54
+ };
55
+ const mergeJsonl = (contents, sessionId) => {
56
+ const allLines = [];
57
+ let lastUuid;
58
+ for (const content of contents) {
59
+ const lines = content.split('\n');
60
+ const uuidMap = buildUuidMap(lines);
61
+ const result = remapSession(lines, uuidMap, sessionId, lastUuid);
62
+ allLines.push(...result.mapped);
63
+ if (result.lastUuid)
64
+ lastUuid = result.lastUuid;
65
+ }
66
+ return allLines.join('\n');
67
+ };
68
+ const copySubagents = async (sourceSessions, targetDir) => {
69
+ const subagentsDir = join(targetDir, 'subagents');
70
+ for (const session of sourceSessions) {
71
+ const sourceId = basename(session.file, '.jsonl');
72
+ const sourceSubagents = join(dirname(session.file), sourceId, 'subagents');
73
+ try {
74
+ const entries = await readdir(sourceSubagents);
75
+ for (const entry of entries) {
76
+ if (!entry.endsWith('.jsonl'))
77
+ continue;
78
+ const sourcePath = join(sourceSubagents, entry);
79
+ const subagentId = randomUUID();
80
+ const content = await readFile(sourcePath, 'utf-8');
81
+ await mkdir(subagentsDir, { recursive: true });
82
+ const targetPath = join(subagentsDir, `${subagentId}.jsonl`);
83
+ await writeFile(targetPath, rewriteJsonl(content, subagentId));
84
+ }
85
+ }
86
+ catch { }
87
+ }
88
+ };
89
+ export const merge = async (sessions, title) => {
90
+ const sorted = [...sessions].sort((first, second) => first.date.localeCompare(second.date));
91
+ const contents = [];
92
+ for (const session of sorted) {
93
+ const raw = await readFile(session.file, 'utf-8');
94
+ contents.push(raw);
95
+ }
96
+ const first = sorted[0];
97
+ if (!first)
98
+ return { id: '', date: '', message: title, file: '' };
99
+ const id = randomUUID();
100
+ const dir = dirname(first.file);
101
+ const file = join(dir, `${id}.jsonl`);
102
+ const targetDir = join(dir, id);
103
+ await writeFile(file, mergeJsonl(contents, id));
104
+ await copySubagents(sorted, targetDir);
105
+ return { id, date: '', message: title, file };
106
+ };
@@ -0,0 +1,2 @@
1
+ import type { Session } from '../types.js';
2
+ export declare const open: (session: Session) => void;
@@ -0,0 +1,10 @@
1
+ import { exec } from 'node:child_process';
2
+ const commands = {
3
+ darwin: (file) => `open -R "${file}"`,
4
+ win32: (file) => `explorer /select,"${file}"`,
5
+ };
6
+ const fallback = (file) => `xdg-open "${file}"`;
7
+ export const open = (session) => {
8
+ const build = commands[process.platform] ?? fallback;
9
+ exec(build(session.file));
10
+ };
@@ -0,0 +1,2 @@
1
+ import type { Session } from '../types.js';
2
+ export declare const preview: (session: Session, limit?: number) => Promise<string[]>;
@@ -0,0 +1,56 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { bold } from '../helpers/ansi.js';
4
+ import { getColumns } from '../helpers/get-columns.js';
5
+ import { truncate } from '../helpers/truncate.js';
6
+ import { parseLine } from '../sessions/parse-line.js';
7
+ const ROLE_LENGTH = 7; // "[ YOU ]" / "[ LLM ]"
8
+ const PREVIEW_MARGIN = 10;
9
+ const PREFIX_LENGTH = 2 + ROLE_LENGTH + 1 + PREVIEW_MARGIN;
10
+ const PREVIEW_LIMIT = 10;
11
+ const MIN_TEXT_LENGTH = 5;
12
+ export const preview = (session, limit = PREVIEW_LIMIT) => new Promise((resolve) => {
13
+ const stream = createReadStream(session.file, 'utf-8');
14
+ const reader = createInterface({ input: stream, crlfDelay: Infinity });
15
+ const messages = [];
16
+ const columns = getColumns();
17
+ const close = () => {
18
+ resolve(messages);
19
+ reader.close();
20
+ stream.destroy();
21
+ };
22
+ reader.on('line', (line) => {
23
+ if (!line.trim())
24
+ return;
25
+ if (messages.length >= limit) {
26
+ close();
27
+ return;
28
+ }
29
+ const entry = parseLine(line);
30
+ if (!entry)
31
+ return;
32
+ if (entry.type === 'session_title')
33
+ return;
34
+ const texts = entry.message?.content ?? [];
35
+ for (const block of texts) {
36
+ if (block.type !== 'text' || !block.text)
37
+ continue;
38
+ const text = block.text
39
+ .replace(/\r?\n/g, ' ')
40
+ .trim()
41
+ .replace(/ {2,}/g, ' ');
42
+ if (text.startsWith('<'))
43
+ continue;
44
+ if (text.length <= MIN_TEXT_LENGTH)
45
+ continue;
46
+ const tag = entry.type === 'user' ? '[ YOU ]' : '[ LLM ]';
47
+ const role = bold(tag);
48
+ const available = columns - PREFIX_LENGTH;
49
+ const truncated = available > 0 ? truncate(text, available).trimEnd() : text;
50
+ messages.push(` ${role} ${truncated}`);
51
+ break;
52
+ }
53
+ });
54
+ reader.on('close', () => resolve(messages));
55
+ stream.on('error', () => close());
56
+ });
@@ -0,0 +1,2 @@
1
+ import type { PruneOptions, Session } from '../types.js';
2
+ export declare const prune: (session: Session, options: PruneOptions) => Promise<void>;
@@ -0,0 +1,103 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { hasContent, isToolBlock, MIN_TEXT_LENGTH, SYSTEM_TAGS, textLength, } from '../sessions/analyze-prune.js';
3
+ const stripTagsFromText = (text) => {
4
+ let result = text;
5
+ for (const tag of SYSTEM_TAGS) {
6
+ const open = `<${tag}>`;
7
+ const close = `</${tag}>`;
8
+ scan: while (true) {
9
+ const start = result.indexOf(open);
10
+ if (start === -1)
11
+ break scan;
12
+ const end = result.indexOf(close, start + open.length);
13
+ if (end === -1)
14
+ break scan;
15
+ result = result.slice(0, start) + result.slice(end + close.length);
16
+ }
17
+ }
18
+ return result;
19
+ };
20
+ const stripSystemTags = (blocks) => {
21
+ const result = [];
22
+ for (const block of blocks) {
23
+ if (block.type !== 'text' || !block.text) {
24
+ result.push(block);
25
+ continue;
26
+ }
27
+ const stripped = stripTagsFromText(block.text).trim();
28
+ if (!stripped)
29
+ continue;
30
+ result.push({ ...block, text: stripped });
31
+ }
32
+ return result;
33
+ };
34
+ const filterContent = (content, options) => {
35
+ let filtered = content;
36
+ if (options.toolBlocks) {
37
+ filtered = filtered.filter((block) => !isToolBlock(block));
38
+ }
39
+ if (options.systemTags) {
40
+ filtered = stripSystemTags(filtered);
41
+ }
42
+ return filtered;
43
+ };
44
+ const shouldSkipMessage = (type, content, options) => {
45
+ if (options.emptyMessages && content.length === 0)
46
+ return true;
47
+ if (options.shortMessages &&
48
+ type === 'assistant' &&
49
+ textLength(content) < MIN_TEXT_LENGTH)
50
+ return true;
51
+ return false;
52
+ };
53
+ const repairParentChain = (lines, survivingUuids) => {
54
+ let lastUuid;
55
+ return lines.map((line) => {
56
+ try {
57
+ const parsed = JSON.parse(line);
58
+ if (!parsed.uuid)
59
+ return line;
60
+ if (parsed.parentUuid && !survivingUuids.has(parsed.parentUuid)) {
61
+ parsed.parentUuid = lastUuid ?? parsed.parentUuid;
62
+ lastUuid = parsed.uuid;
63
+ return JSON.stringify(parsed);
64
+ }
65
+ lastUuid = parsed.uuid;
66
+ return line;
67
+ }
68
+ catch {
69
+ return line;
70
+ }
71
+ });
72
+ };
73
+ export const prune = async (session, options) => {
74
+ const raw = await readFile(session.file, 'utf-8');
75
+ const lines = raw.split('\n');
76
+ const kept = [];
77
+ const survivingUuids = new Set();
78
+ for (const line of lines) {
79
+ if (!line.trim())
80
+ continue;
81
+ try {
82
+ const parsed = JSON.parse(line);
83
+ if (hasContent(parsed)) {
84
+ const content = filterContent(parsed.message.content, options);
85
+ if (shouldSkipMessage(parsed.type, content, options))
86
+ continue;
87
+ parsed.message.content = content;
88
+ if (parsed.uuid)
89
+ survivingUuids.add(parsed.uuid);
90
+ kept.push(JSON.stringify(parsed));
91
+ continue;
92
+ }
93
+ if (parsed.uuid)
94
+ survivingUuids.add(parsed.uuid);
95
+ kept.push(line);
96
+ }
97
+ catch {
98
+ kept.push(line);
99
+ }
100
+ }
101
+ const repaired = repairParentChain(kept, survivingUuids);
102
+ await writeFile(session.file, repaired.join('\n') + '\n');
103
+ };
@@ -0,0 +1,2 @@
1
+ import type { Session } from '../types.js';
2
+ export declare const rename: (session: Session, title: string) => Promise<void>;