sessioner 0.1.0 → 0.1.1
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 +5 -10
- package/lib/actions/delete.js +0 -12
- package/lib/actions/merge.js +2 -2
- package/lib/actions/preview.js +1 -1
- package/lib/actions/prune.js +15 -1
- package/lib/actions/rename.js +7 -85
- package/lib/bin/index.js +1 -0
- package/lib/sessions/analyze-prune.js +7 -1
- package/lib/sessions/extract-first-user-message.js +15 -6
- package/lib/types.d.ts +2 -8
- package/lib/ui/select.js +19 -7
- package/package.json +4 -2
- package/lib/helpers/rename-cache.d.ts +0 -3
- package/lib/helpers/rename-cache.js +0 -18
package/README.md
CHANGED
|
@@ -32,8 +32,6 @@ Claude Code supports native forking from specific points, but loses context like
|
|
|
32
32
|
npm i sessioner
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
> Requires **Node.js >= 22**.
|
|
36
|
-
|
|
37
35
|
---
|
|
38
36
|
|
|
39
37
|
## 🚀 Quick Start
|
|
@@ -114,9 +112,10 @@ sessioner
|
|
|
114
112
|
- Analyzes the session and reports what can be removed:
|
|
115
113
|
- **Tool blocks**: `tool_use` and `tool_result` entries
|
|
116
114
|
- **Empty messages**: no text content after tools are stripped
|
|
117
|
-
- **Short messages**: text under 50 characters
|
|
118
115
|
- **System/IDE tags**: `<system-reminder>`, `<ide_selection>`, `<ide_opened_file>`
|
|
119
|
-
-
|
|
116
|
+
- **Old custom titles**: duplicate `custom-title` entries (keeps the most recent)
|
|
117
|
+
- **Short messages**: text under 50 characters (unselected by default)
|
|
118
|
+
- Checkbox selection for what to remove (all pre-selected by default, except short messages)
|
|
120
119
|
- Repairs the parent-child UUID chain after pruning
|
|
121
120
|
- Shows "Nothing to prune" when the session is already clean
|
|
122
121
|
|
|
@@ -135,11 +134,7 @@ sessioner
|
|
|
135
134
|
### Rename
|
|
136
135
|
|
|
137
136
|
- Text input for the new title (<kbd>Esc</kbd> to cancel)
|
|
138
|
-
-
|
|
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`)
|
|
137
|
+
- Writes a `custom-title` entry to the session file (same format used by the Claude Code extension)
|
|
143
138
|
|
|
144
139
|
---
|
|
145
140
|
|
|
@@ -148,7 +143,7 @@ sessioner
|
|
|
148
143
|
- Shows exactly what will be deleted:
|
|
149
144
|
- Session `.jsonl` file
|
|
150
145
|
- Subagents directory (with file count)
|
|
151
|
-
- Removes the entry from the sessions index
|
|
146
|
+
- Removes the entry from the sessions index
|
|
152
147
|
- Confirmation prompt before applying
|
|
153
148
|
|
|
154
149
|
---
|
package/lib/actions/delete.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { readFile, rm, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
|
-
import { readCache, writeCache } from '../helpers/rename-cache.js';
|
|
4
3
|
import { countSubagents } from '../sessions/count-subagents.js';
|
|
5
4
|
export const targets = async (session) => {
|
|
6
5
|
const name = basename(session.file);
|
|
@@ -23,16 +22,6 @@ const removeFromIndex = async (session) => {
|
|
|
23
22
|
}
|
|
24
23
|
catch { }
|
|
25
24
|
};
|
|
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
25
|
export const cleanEmpty = async (files) => {
|
|
37
26
|
for (const file of files) {
|
|
38
27
|
const id = basename(file, '.jsonl');
|
|
@@ -53,5 +42,4 @@ export const remove = async (session) => {
|
|
|
53
42
|
}
|
|
54
43
|
catch { }
|
|
55
44
|
await removeFromIndex(session);
|
|
56
|
-
await removeFromCache(session);
|
|
57
45
|
};
|
package/lib/actions/merge.js
CHANGED
|
@@ -9,7 +9,7 @@ const buildUuidMap = (lines) => {
|
|
|
9
9
|
continue;
|
|
10
10
|
try {
|
|
11
11
|
const parsed = JSON.parse(line);
|
|
12
|
-
if (parsed.type === 'session_title')
|
|
12
|
+
if (parsed.type === 'session_title' || parsed.type === 'custom-title')
|
|
13
13
|
continue;
|
|
14
14
|
if (parsed.uuid)
|
|
15
15
|
uuidMap.set(parsed.uuid, randomUUID());
|
|
@@ -26,7 +26,7 @@ const remapSession = (lines, uuidMap, sessionId, previousLastUuid) => {
|
|
|
26
26
|
continue;
|
|
27
27
|
try {
|
|
28
28
|
const parsed = JSON.parse(line);
|
|
29
|
-
if (parsed.type === 'session_title')
|
|
29
|
+
if (parsed.type === 'session_title' || parsed.type === 'custom-title')
|
|
30
30
|
continue;
|
|
31
31
|
if (parsed.uuid) {
|
|
32
32
|
const newUuid = uuidMap.get(parsed.uuid);
|
package/lib/actions/preview.js
CHANGED
|
@@ -29,7 +29,7 @@ export const preview = (session, limit = PREVIEW_LIMIT) => new Promise((resolve)
|
|
|
29
29
|
const entry = parseLine(line);
|
|
30
30
|
if (!entry)
|
|
31
31
|
return;
|
|
32
|
-
if (entry.type === 'session_title')
|
|
32
|
+
if (entry.type === 'session_title' || entry.type === 'custom-title')
|
|
33
33
|
return;
|
|
34
34
|
const texts = entry.message?.content ?? [];
|
|
35
35
|
for (const block of texts) {
|
package/lib/actions/prune.js
CHANGED
|
@@ -75,11 +75,25 @@ export const prune = async (session, options) => {
|
|
|
75
75
|
const lines = raw.split('\n');
|
|
76
76
|
const kept = [];
|
|
77
77
|
const survivingUuids = new Set();
|
|
78
|
-
|
|
78
|
+
let lastTitleIndex = -1;
|
|
79
|
+
if (options.customTitles) {
|
|
80
|
+
for (let index = 0; index < lines.length; index++) {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(lines[index] ?? '');
|
|
83
|
+
if (parsed.type === 'custom-title')
|
|
84
|
+
lastTitleIndex = index;
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (let index = 0; index < lines.length; index++) {
|
|
90
|
+
const line = lines[index] ?? '';
|
|
79
91
|
if (!line.trim())
|
|
80
92
|
continue;
|
|
81
93
|
try {
|
|
82
94
|
const parsed = JSON.parse(line);
|
|
95
|
+
if (parsed.type === 'custom-title' && index !== lastTitleIndex)
|
|
96
|
+
continue;
|
|
83
97
|
if (hasContent(parsed)) {
|
|
84
98
|
const content = filterContent(parsed.message.content, options);
|
|
85
99
|
if (shouldSkipMessage(parsed.type, content, options))
|
package/lib/actions/rename.js
CHANGED
|
@@ -1,87 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
import { readCache, writeCache } from '../helpers/rename-cache.js';
|
|
4
|
-
const updateIndex = async (session, title) => {
|
|
5
|
-
const path = join(dirname(session.file), 'sessions-index.json');
|
|
6
|
-
try {
|
|
7
|
-
const raw = await readFile(path, 'utf-8');
|
|
8
|
-
const index = JSON.parse(raw);
|
|
9
|
-
const entry = index.entries.find((item) => item.sessionId === session.id);
|
|
10
|
-
if (!entry)
|
|
11
|
-
return;
|
|
12
|
-
entry.firstPrompt = title;
|
|
13
|
-
await writeFile(path, JSON.stringify(index, null, 2));
|
|
14
|
-
}
|
|
15
|
-
catch { }
|
|
16
|
-
};
|
|
17
|
-
const stripExistingTitle = (content) => {
|
|
18
|
-
const newline = content.indexOf('\n');
|
|
19
|
-
if (newline === -1)
|
|
20
|
-
return content;
|
|
21
|
-
try {
|
|
22
|
-
const parsed = JSON.parse(content.slice(0, newline));
|
|
23
|
-
if (parsed.type === 'session_title')
|
|
24
|
-
return content.slice(newline + 1);
|
|
25
|
-
}
|
|
26
|
-
catch { }
|
|
27
|
-
return content;
|
|
28
|
-
};
|
|
29
|
-
const isUserLine = (value) => typeof value === 'object' &&
|
|
30
|
-
value !== null &&
|
|
31
|
-
value.type === 'user' &&
|
|
32
|
-
Array.isArray(value.message?.content);
|
|
33
|
-
const isTextBlock = (block) => block.type === 'text' &&
|
|
34
|
-
typeof block.text === 'string' &&
|
|
35
|
-
!block.text.startsWith('<');
|
|
36
|
-
const updateUserMessage = async (filePath, title) => {
|
|
37
|
-
const cache = await readCache();
|
|
38
|
-
const previous = cache[filePath];
|
|
39
|
-
const raw = await readFile(filePath, 'utf-8');
|
|
40
|
-
const lines = raw.split('\n');
|
|
41
|
-
for (let index = 0; index < lines.length; index++) {
|
|
42
|
-
const current = lines[index];
|
|
43
|
-
if (!current?.trim())
|
|
44
|
-
continue;
|
|
45
|
-
let parsed;
|
|
46
|
-
try {
|
|
47
|
-
parsed = JSON.parse(current);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (!isUserLine(parsed))
|
|
53
|
-
continue;
|
|
54
|
-
const content = parsed.message.content;
|
|
55
|
-
for (const block of content) {
|
|
56
|
-
delete block._renamed;
|
|
57
|
-
}
|
|
58
|
-
let replaced = false;
|
|
59
|
-
if (previous) {
|
|
60
|
-
const existing = content.findIndex((block) => block.type === 'text' && block.text === previous);
|
|
61
|
-
const match = content[existing];
|
|
62
|
-
if (existing !== -1 && match) {
|
|
63
|
-
match.text = title;
|
|
64
|
-
replaced = true;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
if (!replaced) {
|
|
68
|
-
const target = content.findIndex(isTextBlock);
|
|
69
|
-
if (target === -1)
|
|
70
|
-
break;
|
|
71
|
-
content.splice(target, 0, { type: 'text', text: title });
|
|
72
|
-
}
|
|
73
|
-
lines[index] = JSON.stringify(parsed);
|
|
74
|
-
await writeFile(filePath, lines.join('\n'));
|
|
75
|
-
cache[filePath] = title;
|
|
76
|
-
await writeCache(cache);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
};
|
|
1
|
+
import { appendFile } from 'node:fs/promises';
|
|
80
2
|
export const rename = async (session, title) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
await
|
|
3
|
+
const entry = JSON.stringify({
|
|
4
|
+
type: 'custom-title',
|
|
5
|
+
sessionId: session.id,
|
|
6
|
+
customTitle: title,
|
|
7
|
+
});
|
|
8
|
+
await appendFile(session.file, `\n${entry}`);
|
|
87
9
|
};
|
package/lib/bin/index.js
CHANGED
|
@@ -171,6 +171,7 @@ menu: while (true) {
|
|
|
171
171
|
shortMessages: selected.has('shortMessages'),
|
|
172
172
|
emptyMessages: selected.has('emptyMessages'),
|
|
173
173
|
systemTags: selected.has('systemTags'),
|
|
174
|
+
customTitles: selected.has('customTitles'),
|
|
174
175
|
});
|
|
175
176
|
previewLines = await preview(session);
|
|
176
177
|
subagentCount = await countSubagents(session);
|
|
@@ -52,11 +52,16 @@ export const analyze = async (session) => {
|
|
|
52
52
|
let shortMessages = 0;
|
|
53
53
|
let emptyMessages = 0;
|
|
54
54
|
let systemTags = 0;
|
|
55
|
+
let titleLines = 0;
|
|
55
56
|
for (const line of lines) {
|
|
56
57
|
if (!line.trim())
|
|
57
58
|
continue;
|
|
58
59
|
try {
|
|
59
60
|
const parsed = JSON.parse(line);
|
|
61
|
+
if (parsed.type === 'custom-title') {
|
|
62
|
+
titleLines++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
60
65
|
if (!hasContent(parsed))
|
|
61
66
|
continue;
|
|
62
67
|
const content = parsed.message.content;
|
|
@@ -73,5 +78,6 @@ export const analyze = async (session) => {
|
|
|
73
78
|
}
|
|
74
79
|
catch { }
|
|
75
80
|
}
|
|
76
|
-
|
|
81
|
+
const customTitles = Math.max(0, titleLines - 1);
|
|
82
|
+
return { toolBlocks, shortMessages, emptyMessages, systemTags, customTitles };
|
|
77
83
|
};
|
|
@@ -2,9 +2,12 @@ import { createReadStream } from 'node:fs';
|
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { parseLine } from './parse-line.js';
|
|
4
4
|
const MIN_TEXT_LENGTH = 5;
|
|
5
|
+
const normalizeText = (value) => value.replace(/\s+/g, ' ').trim();
|
|
5
6
|
export const extractFirstUserMessage = (filePath) => new Promise((resolve) => {
|
|
6
7
|
const stream = createReadStream(filePath, 'utf-8');
|
|
7
8
|
const reader = createInterface({ input: stream, crlfDelay: Infinity });
|
|
9
|
+
let customTitle = '';
|
|
10
|
+
let fallback = '';
|
|
8
11
|
const close = (value) => {
|
|
9
12
|
resolve(value);
|
|
10
13
|
reader.close();
|
|
@@ -16,27 +19,33 @@ export const extractFirstUserMessage = (filePath) => new Promise((resolve) => {
|
|
|
16
19
|
const entry = parseLine(line);
|
|
17
20
|
if (!entry)
|
|
18
21
|
return;
|
|
22
|
+
if (entry.type === 'custom-title' && 'customTitle' in entry) {
|
|
23
|
+
customTitle = normalizeText(String(entry.customTitle));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
19
26
|
if (entry.type === 'session_title' && 'title' in entry) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
if (!fallback) {
|
|
28
|
+
fallback = normalizeText(String(entry.title));
|
|
29
|
+
}
|
|
23
30
|
return;
|
|
24
31
|
}
|
|
32
|
+
if (fallback)
|
|
33
|
+
return;
|
|
25
34
|
if (entry.type !== 'user')
|
|
26
35
|
return;
|
|
27
36
|
const texts = entry.message?.content ?? [];
|
|
28
37
|
for (const block of texts) {
|
|
29
38
|
if (block.type !== 'text' || !block.text)
|
|
30
39
|
continue;
|
|
31
|
-
const text = block.text
|
|
40
|
+
const text = normalizeText(block.text);
|
|
32
41
|
if (text.startsWith('<'))
|
|
33
42
|
continue;
|
|
34
43
|
if (text.length <= MIN_TEXT_LENGTH)
|
|
35
44
|
continue;
|
|
36
|
-
|
|
45
|
+
fallback = text;
|
|
37
46
|
return;
|
|
38
47
|
}
|
|
39
48
|
});
|
|
40
|
-
reader.on('close', () => resolve(
|
|
49
|
+
reader.on('close', () => resolve(customTitle || fallback));
|
|
41
50
|
stream.on('error', () => close(''));
|
|
42
51
|
});
|
package/lib/types.d.ts
CHANGED
|
@@ -51,19 +51,11 @@ export type SessionIndex = {
|
|
|
51
51
|
entries: IndexEntry[];
|
|
52
52
|
[key: string]: unknown;
|
|
53
53
|
};
|
|
54
|
-
export type RenameCache = Record<string, string>;
|
|
55
54
|
export type ContentBlock = {
|
|
56
55
|
type: string;
|
|
57
56
|
text?: string;
|
|
58
57
|
[key: string]: unknown;
|
|
59
58
|
};
|
|
60
|
-
export type UserLine = {
|
|
61
|
-
type: 'user';
|
|
62
|
-
message: {
|
|
63
|
-
content: ContentBlock[];
|
|
64
|
-
};
|
|
65
|
-
[key: string]: unknown;
|
|
66
|
-
};
|
|
67
59
|
export type ModelName = 'opus' | 'sonnet' | 'haiku' | 'unknown';
|
|
68
60
|
export type TokenUsage = {
|
|
69
61
|
input: number;
|
|
@@ -91,10 +83,12 @@ export type PruneStats = {
|
|
|
91
83
|
shortMessages: number;
|
|
92
84
|
emptyMessages: number;
|
|
93
85
|
systemTags: number;
|
|
86
|
+
customTitles: number;
|
|
94
87
|
};
|
|
95
88
|
export type PruneOptions = {
|
|
96
89
|
toolBlocks: boolean;
|
|
97
90
|
shortMessages: boolean;
|
|
98
91
|
emptyMessages: boolean;
|
|
99
92
|
systemTags: boolean;
|
|
93
|
+
customTitles: boolean;
|
|
100
94
|
};
|
package/lib/ui/select.js
CHANGED
|
@@ -299,21 +299,31 @@ export const selectPruneOptions = async (stats, fields) => {
|
|
|
299
299
|
entries.push({
|
|
300
300
|
key: 'toolBlocks',
|
|
301
301
|
label: `${stats.toolBlocks} tool blocks`,
|
|
302
|
+
defaultSelected: true,
|
|
302
303
|
});
|
|
303
304
|
if (stats.emptyMessages > 0)
|
|
304
305
|
entries.push({
|
|
305
306
|
key: 'emptyMessages',
|
|
306
307
|
label: `${stats.emptyMessages} empty messages`,
|
|
307
|
-
|
|
308
|
-
if (stats.shortMessages > 0)
|
|
309
|
-
entries.push({
|
|
310
|
-
key: 'shortMessages',
|
|
311
|
-
label: `${stats.shortMessages} short messages (<50 chars)`,
|
|
308
|
+
defaultSelected: true,
|
|
312
309
|
});
|
|
313
310
|
if (stats.systemTags > 0)
|
|
314
311
|
entries.push({
|
|
315
312
|
key: 'systemTags',
|
|
316
313
|
label: `${stats.systemTags} system/IDE tags`,
|
|
314
|
+
defaultSelected: true,
|
|
315
|
+
});
|
|
316
|
+
if (stats.customTitles > 0)
|
|
317
|
+
entries.push({
|
|
318
|
+
key: 'customTitles',
|
|
319
|
+
label: `${stats.customTitles} old custom titles`,
|
|
320
|
+
defaultSelected: true,
|
|
321
|
+
});
|
|
322
|
+
if (stats.shortMessages > 0)
|
|
323
|
+
entries.push({
|
|
324
|
+
key: 'shortMessages',
|
|
325
|
+
label: `${stats.shortMessages} short messages (<50 chars)`,
|
|
326
|
+
defaultSelected: false,
|
|
317
327
|
});
|
|
318
328
|
if (entries.length === 0) {
|
|
319
329
|
showHeader(fields);
|
|
@@ -332,8 +342,10 @@ export const selectPruneOptions = async (stats, fields) => {
|
|
|
332
342
|
}
|
|
333
343
|
const selected = new Set();
|
|
334
344
|
let cursor;
|
|
335
|
-
for (let index = 0; index < entries.length; index++)
|
|
336
|
-
|
|
345
|
+
for (let index = 0; index < entries.length; index++) {
|
|
346
|
+
if (entries[index]?.defaultSelected)
|
|
347
|
+
selected.add(index);
|
|
348
|
+
}
|
|
337
349
|
toggle: while (true) {
|
|
338
350
|
showHeader(fields);
|
|
339
351
|
const options = entries.map((entry, index) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sessioner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "✨ An interactive CLI to navigate and manage Claude Code sessions from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"start": "tsx src/bin/index.ts",
|
|
28
28
|
"lint": "prettier --check .",
|
|
29
29
|
"lint:fix": "prettier --write .",
|
|
30
|
-
"typecheck": "tsc --noEmit"
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"patch": "npm version patch --no-git-tag-version",
|
|
32
|
+
"prepublish": "npm run build"
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"@clack/core": "^1.0.1",
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
const cacheDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.cache');
|
|
5
|
-
const cacheFile = join(cacheDir, 'renames.json');
|
|
6
|
-
export const readCache = async () => {
|
|
7
|
-
try {
|
|
8
|
-
const raw = await readFile(cacheFile, 'utf-8');
|
|
9
|
-
return JSON.parse(raw);
|
|
10
|
-
}
|
|
11
|
-
catch {
|
|
12
|
-
return {};
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
export const writeCache = async (cache) => {
|
|
16
|
-
await mkdir(cacheDir, { recursive: true });
|
|
17
|
-
await writeFile(cacheFile, JSON.stringify(cache, null, 2));
|
|
18
|
-
};
|