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.
- package/README.md +251 -0
- package/lib/actions/delete.d.ts +4 -0
- package/lib/actions/delete.js +57 -0
- package/lib/actions/fork.d.ts +2 -0
- package/lib/actions/fork.js +30 -0
- package/lib/actions/merge.d.ts +2 -0
- package/lib/actions/merge.js +106 -0
- package/lib/actions/open.d.ts +2 -0
- package/lib/actions/open.js +10 -0
- package/lib/actions/preview.d.ts +2 -0
- package/lib/actions/preview.js +56 -0
- package/lib/actions/prune.d.ts +2 -0
- package/lib/actions/prune.js +103 -0
- package/lib/actions/rename.d.ts +2 -0
- package/lib/actions/rename.js +87 -0
- package/lib/actions/trim.d.ts +2 -0
- package/lib/actions/trim.js +26 -0
- package/lib/bin/index.d.ts +2 -0
- package/lib/bin/index.js +217 -0
- package/lib/helpers/ansi.d.ts +9 -0
- package/lib/helpers/ansi.js +9 -0
- package/lib/helpers/format-date.d.ts +1 -0
- package/lib/helpers/format-date.js +8 -0
- package/lib/helpers/format-stats.d.ts +3 -0
- package/lib/helpers/format-stats.js +81 -0
- package/lib/helpers/get-columns.d.ts +1 -0
- package/lib/helpers/get-columns.js +8 -0
- package/lib/helpers/rename-cache.d.ts +3 -0
- package/lib/helpers/rename-cache.js +18 -0
- package/lib/helpers/styled-label.d.ts +2 -0
- package/lib/helpers/styled-label.js +2 -0
- package/lib/helpers/truncate.d.ts +1 -0
- package/lib/helpers/truncate.js +1 -0
- package/lib/sessions/analyze-prune.d.ts +12 -0
- package/lib/sessions/analyze-prune.js +77 -0
- package/lib/sessions/count-subagents.d.ts +2 -0
- package/lib/sessions/count-subagents.js +13 -0
- package/lib/sessions/extract-first-user-message.d.ts +1 -0
- package/lib/sessions/extract-first-user-message.js +42 -0
- package/lib/sessions/extract-messages.d.ts +5 -0
- package/lib/sessions/extract-messages.js +43 -0
- package/lib/sessions/get-entries.d.ts +1 -0
- package/lib/sessions/get-entries.js +25 -0
- package/lib/sessions/list-sessions.d.ts +2 -0
- package/lib/sessions/list-sessions.js +29 -0
- package/lib/sessions/parse-line.d.ts +2 -0
- package/lib/sessions/parse-line.js +8 -0
- package/lib/sessions/rewrite-jsonl.d.ts +1 -0
- package/lib/sessions/rewrite-jsonl.js +34 -0
- package/lib/sessions/stats.d.ts +3 -0
- package/lib/sessions/stats.js +146 -0
- package/lib/types.d.ts +100 -0
- package/lib/types.js +1 -0
- package/lib/ui/custom-select.d.ts +2 -0
- package/lib/ui/custom-select.js +113 -0
- package/lib/ui/select.d.ts +10 -0
- package/lib/ui/select.js +459 -0
- 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
|
+
[](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,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,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,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,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,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,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
|
+
};
|