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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
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
|
+
};
|
|
80
|
+
export const rename = async (session, title) => {
|
|
81
|
+
const raw = await readFile(session.file, 'utf-8');
|
|
82
|
+
const content = stripExistingTitle(raw);
|
|
83
|
+
const line = JSON.stringify({ type: 'session_title', title });
|
|
84
|
+
await writeFile(session.file, `${line}\n${content}`);
|
|
85
|
+
await updateIndex(session, title);
|
|
86
|
+
await updateUserMessage(session.file, title);
|
|
87
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
export const trim = async (session, keepCount) => {
|
|
3
|
+
const raw = await readFile(session.file, 'utf-8');
|
|
4
|
+
const lines = raw.split('\n');
|
|
5
|
+
let seen = 0;
|
|
6
|
+
let cutIndex = lines.length;
|
|
7
|
+
for (let index = 0; index < lines.length; index++) {
|
|
8
|
+
const current = lines[index];
|
|
9
|
+
if (!current?.trim())
|
|
10
|
+
continue;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(current);
|
|
13
|
+
const isMessage = parsed.type === 'user' || parsed.type === 'assistant';
|
|
14
|
+
if (!isMessage)
|
|
15
|
+
continue;
|
|
16
|
+
seen++;
|
|
17
|
+
if (seen > keepCount) {
|
|
18
|
+
cutIndex = index;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
}
|
|
24
|
+
const kept = lines.slice(0, cutIndex).filter((line) => line.trim());
|
|
25
|
+
await writeFile(session.file, kept.join('\n') + '\n');
|
|
26
|
+
};
|
package/lib/bin/index.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { cleanEmpty, remove, targets } from '../actions/delete.js';
|
|
6
|
+
import { fork } from '../actions/fork.js';
|
|
7
|
+
import { merge } from '../actions/merge.js';
|
|
8
|
+
import { open } from '../actions/open.js';
|
|
9
|
+
import { preview } from '../actions/preview.js';
|
|
10
|
+
import { prune } from '../actions/prune.js';
|
|
11
|
+
import { rename } from '../actions/rename.js';
|
|
12
|
+
import { trim } from '../actions/trim.js';
|
|
13
|
+
import { formatProjectStats, formatStats } from '../helpers/format-stats.js';
|
|
14
|
+
import { analyze } from '../sessions/analyze-prune.js';
|
|
15
|
+
import { countSubagents } from '../sessions/count-subagents.js';
|
|
16
|
+
import { extractMessages } from '../sessions/extract-messages.js';
|
|
17
|
+
import { listSessions } from '../sessions/list-sessions.js';
|
|
18
|
+
import { projectStats, stats } from '../sessions/stats.js';
|
|
19
|
+
import { promptConfirm, promptTitle, select, selectAction, selectMain, selectMultiple, selectPruneOptions, selectTrimLine, showProjectStats, } from '../ui/select.js';
|
|
20
|
+
const usage = [
|
|
21
|
+
'Usage: sessioner [options]',
|
|
22
|
+
'',
|
|
23
|
+
'Options:',
|
|
24
|
+
' -p, --project <path> Project path (default: cwd)',
|
|
25
|
+
' -b, --base <path> Agent base directory (default: ~/.claude/projects)',
|
|
26
|
+
].join('\n');
|
|
27
|
+
let values;
|
|
28
|
+
try {
|
|
29
|
+
({ values } = parseArgs({
|
|
30
|
+
args: process.argv.slice(2),
|
|
31
|
+
options: {
|
|
32
|
+
base: { type: 'string', short: 'b' },
|
|
33
|
+
project: { type: 'string', short: 'p' },
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
console.error(usage);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const CTRL_C = 0x03;
|
|
42
|
+
if (process.stdin.isTTY) {
|
|
43
|
+
process.stdin.prependListener('data', (data) => {
|
|
44
|
+
if (data[0] === CTRL_C) {
|
|
45
|
+
process.stdin.setRawMode(false);
|
|
46
|
+
process.stdout.write('\x1b[?25h\n');
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const project = values.project ?? process.cwd();
|
|
52
|
+
const base = values.base ?? join(homedir(), '.claude', 'projects');
|
|
53
|
+
menu: while (true) {
|
|
54
|
+
const { sessions, empty } = await listSessions(project, base);
|
|
55
|
+
const choice = await selectMain(project, base, empty.length);
|
|
56
|
+
if (!choice)
|
|
57
|
+
break;
|
|
58
|
+
if (choice === 'stats') {
|
|
59
|
+
const aggregated = await projectStats(sessions);
|
|
60
|
+
const lines = formatProjectStats(aggregated, sessions.length);
|
|
61
|
+
await showProjectStats(lines, { Project: project, Base: base });
|
|
62
|
+
continue menu;
|
|
63
|
+
}
|
|
64
|
+
if (choice === 'clean') {
|
|
65
|
+
const confirmed = await promptConfirm(`Delete ${empty.length} empty sessions?`);
|
|
66
|
+
if (!confirmed)
|
|
67
|
+
continue menu;
|
|
68
|
+
await cleanEmpty(empty);
|
|
69
|
+
continue menu;
|
|
70
|
+
}
|
|
71
|
+
list: while (true) {
|
|
72
|
+
const { sessions: fresh } = await listSessions(project, base);
|
|
73
|
+
const result = await select(fresh, project, base);
|
|
74
|
+
if (!result)
|
|
75
|
+
continue menu;
|
|
76
|
+
if (result === 'exit')
|
|
77
|
+
break menu;
|
|
78
|
+
let session = result;
|
|
79
|
+
let previewLines = await preview(session);
|
|
80
|
+
let subagentCount = await countSubagents(session);
|
|
81
|
+
action: while (true) {
|
|
82
|
+
const action = await selectAction(session, project, base, previewLines, subagentCount);
|
|
83
|
+
if (!action)
|
|
84
|
+
continue list;
|
|
85
|
+
switch (action) {
|
|
86
|
+
case 'back':
|
|
87
|
+
continue list;
|
|
88
|
+
case 'rename': {
|
|
89
|
+
const title = await promptTitle({
|
|
90
|
+
Project: project,
|
|
91
|
+
Base: base,
|
|
92
|
+
ID: session.id,
|
|
93
|
+
Date: session.date,
|
|
94
|
+
Title: session.message,
|
|
95
|
+
});
|
|
96
|
+
if (!title)
|
|
97
|
+
continue action;
|
|
98
|
+
await rename(session, title);
|
|
99
|
+
session.message = title;
|
|
100
|
+
continue action;
|
|
101
|
+
}
|
|
102
|
+
case 'open':
|
|
103
|
+
open(session);
|
|
104
|
+
continue action;
|
|
105
|
+
case 'stats': {
|
|
106
|
+
const sessionStats = await stats(session);
|
|
107
|
+
previewLines = formatStats(sessionStats);
|
|
108
|
+
continue action;
|
|
109
|
+
}
|
|
110
|
+
case 'fork': {
|
|
111
|
+
const defaultTitle = `Fork: ${session.message}`;
|
|
112
|
+
const forked = await fork(session, defaultTitle);
|
|
113
|
+
await rename(forked, defaultTitle);
|
|
114
|
+
const title = await promptTitle({
|
|
115
|
+
Project: project,
|
|
116
|
+
Base: base,
|
|
117
|
+
ID: forked.id,
|
|
118
|
+
Date: forked.date,
|
|
119
|
+
Title: forked.message,
|
|
120
|
+
});
|
|
121
|
+
if (title) {
|
|
122
|
+
await rename(forked, title);
|
|
123
|
+
forked.message = title;
|
|
124
|
+
}
|
|
125
|
+
session = forked;
|
|
126
|
+
previewLines = await preview(session);
|
|
127
|
+
subagentCount = await countSubagents(session);
|
|
128
|
+
continue action;
|
|
129
|
+
}
|
|
130
|
+
case 'merge': {
|
|
131
|
+
const current = session;
|
|
132
|
+
const others = await selectMultiple(fresh.filter((session) => session.id !== current.id), project, base);
|
|
133
|
+
if (!others || others.length === 0)
|
|
134
|
+
continue action;
|
|
135
|
+
const all = [current, ...others];
|
|
136
|
+
const titles = all.map((session) => session.message);
|
|
137
|
+
const defaultTitle = `Merge: ${titles.join(', ')}`;
|
|
138
|
+
const merged = await merge(all, defaultTitle);
|
|
139
|
+
await rename(merged, defaultTitle);
|
|
140
|
+
const title = await promptTitle({
|
|
141
|
+
Project: project,
|
|
142
|
+
Base: base,
|
|
143
|
+
ID: merged.id,
|
|
144
|
+
Date: merged.date,
|
|
145
|
+
Title: merged.message,
|
|
146
|
+
});
|
|
147
|
+
if (title) {
|
|
148
|
+
await rename(merged, title);
|
|
149
|
+
merged.message = title;
|
|
150
|
+
}
|
|
151
|
+
session = merged;
|
|
152
|
+
previewLines = await preview(session);
|
|
153
|
+
subagentCount = await countSubagents(session);
|
|
154
|
+
continue action;
|
|
155
|
+
}
|
|
156
|
+
case 'prune': {
|
|
157
|
+
const stats = await analyze(session);
|
|
158
|
+
const selected = await selectPruneOptions(stats, {
|
|
159
|
+
Project: project,
|
|
160
|
+
Base: base,
|
|
161
|
+
ID: session.id,
|
|
162
|
+
Date: session.date,
|
|
163
|
+
Title: session.message,
|
|
164
|
+
});
|
|
165
|
+
if (selected === 'exit')
|
|
166
|
+
break menu;
|
|
167
|
+
if (!selected)
|
|
168
|
+
continue action;
|
|
169
|
+
await prune(session, {
|
|
170
|
+
toolBlocks: selected.has('toolBlocks'),
|
|
171
|
+
shortMessages: selected.has('shortMessages'),
|
|
172
|
+
emptyMessages: selected.has('emptyMessages'),
|
|
173
|
+
systemTags: selected.has('systemTags'),
|
|
174
|
+
});
|
|
175
|
+
previewLines = await preview(session);
|
|
176
|
+
subagentCount = await countSubagents(session);
|
|
177
|
+
continue action;
|
|
178
|
+
}
|
|
179
|
+
case 'trim': {
|
|
180
|
+
const { total, lines } = await extractMessages(session);
|
|
181
|
+
if (lines.length <= 1)
|
|
182
|
+
continue action;
|
|
183
|
+
const selected = await selectTrimLine(lines, {
|
|
184
|
+
Project: project,
|
|
185
|
+
Base: base,
|
|
186
|
+
ID: session.id,
|
|
187
|
+
Date: session.date,
|
|
188
|
+
Title: session.message,
|
|
189
|
+
});
|
|
190
|
+
if (selected === null)
|
|
191
|
+
continue action;
|
|
192
|
+
const keepCount = selected + 1;
|
|
193
|
+
const removeCount = total - keepCount;
|
|
194
|
+
if (removeCount <= 0)
|
|
195
|
+
continue action;
|
|
196
|
+
const confirmed = await promptConfirm(`Remove last ${removeCount} of ${total} messages?`);
|
|
197
|
+
if (!confirmed)
|
|
198
|
+
continue action;
|
|
199
|
+
await trim(session, keepCount);
|
|
200
|
+
previewLines = await preview(session);
|
|
201
|
+
subagentCount = await countSubagents(session);
|
|
202
|
+
continue action;
|
|
203
|
+
}
|
|
204
|
+
case 'delete': {
|
|
205
|
+
const items = await targets(session);
|
|
206
|
+
const confirmed = await promptConfirm(`Delete ${items.join(' + ')}?`);
|
|
207
|
+
if (!confirmed)
|
|
208
|
+
continue action;
|
|
209
|
+
await remove(session);
|
|
210
|
+
continue list;
|
|
211
|
+
}
|
|
212
|
+
case 'exit':
|
|
213
|
+
break menu;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const dim: (text: string) => string;
|
|
2
|
+
export declare const bold: (text: string) => string;
|
|
3
|
+
export declare const strikethrough: (text: string) => string;
|
|
4
|
+
export declare const gray: (text: string) => string;
|
|
5
|
+
export declare const cyan: (text: string) => string;
|
|
6
|
+
export declare const green: (text: string) => string;
|
|
7
|
+
export declare const yellow: (text: string) => string;
|
|
8
|
+
export declare const colorize: (text: string, ansiColor: string) => string;
|
|
9
|
+
export declare const DEFAULT_ICON_COLOR = "38;5;214";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const dim = (text) => `\x1b[2m${text}\x1b[22m`;
|
|
2
|
+
export const bold = (text) => `\x1b[1m${text}\x1b[22m`;
|
|
3
|
+
export const strikethrough = (text) => `\x1b[9m${text}\x1b[29m`;
|
|
4
|
+
export const gray = (text) => `\x1b[90m${text}\x1b[39m`;
|
|
5
|
+
export const cyan = (text) => `\x1b[36m${text}\x1b[39m`;
|
|
6
|
+
export const green = (text) => `\x1b[32m${text}\x1b[39m`;
|
|
7
|
+
export const yellow = (text) => `\x1b[33m${text}\x1b[39m`;
|
|
8
|
+
export const colorize = (text, ansiColor) => `\x1b[${ansiColor}m${text}\x1b[39m`;
|
|
9
|
+
export const DEFAULT_ICON_COLOR = '38;5;214';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const formatDate: (date: Date) => string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const formatDate = (date) => {
|
|
2
|
+
const year = date.getFullYear();
|
|
3
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
4
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
5
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
6
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
7
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
8
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { bold, dim, green, yellow } from './ansi.js';
|
|
2
|
+
const COST_DECIMALS = 4;
|
|
3
|
+
const NUMBER_DECIMALS = 1;
|
|
4
|
+
const LABEL_COLUMN_WIDTH = 12;
|
|
5
|
+
const COLUMN_GAP = 2;
|
|
6
|
+
const MS_PER_HOUR = 3_600_000;
|
|
7
|
+
const MS_PER_MINUTE = 60_000;
|
|
8
|
+
const ONE_MILLION = 1_000_000;
|
|
9
|
+
const ONE_THOUSAND = 1_000;
|
|
10
|
+
const formatNumber = (value) => {
|
|
11
|
+
if (value >= ONE_MILLION)
|
|
12
|
+
return `${(value / ONE_MILLION).toFixed(NUMBER_DECIMALS)}M`;
|
|
13
|
+
if (value >= ONE_THOUSAND)
|
|
14
|
+
return `${(value / ONE_THOUSAND).toFixed(NUMBER_DECIMALS)}K`;
|
|
15
|
+
return String(value);
|
|
16
|
+
};
|
|
17
|
+
const formatCost = (value) => `$${value.toFixed(COST_DECIMALS)}`;
|
|
18
|
+
const formatDuration = (duration) => {
|
|
19
|
+
if (!duration.first || !duration.last)
|
|
20
|
+
return [];
|
|
21
|
+
const start = new Date(duration.first);
|
|
22
|
+
const end = new Date(duration.last);
|
|
23
|
+
const elapsed = end.getTime() - start.getTime();
|
|
24
|
+
const hours = Math.floor(elapsed / MS_PER_HOUR);
|
|
25
|
+
const minutes = Math.floor((elapsed % MS_PER_HOUR) / MS_PER_MINUTE);
|
|
26
|
+
const label = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
27
|
+
return [` ${dim('Duration:')} ${label}`];
|
|
28
|
+
};
|
|
29
|
+
const formatMessages = (messages, subagents) => {
|
|
30
|
+
const total = messages.user + messages.assistant;
|
|
31
|
+
const lines = [
|
|
32
|
+
` ${dim('Messages:')} ${total} ${dim(`(${messages.user} user, ${messages.assistant} assistant)`)}`,
|
|
33
|
+
];
|
|
34
|
+
if (subagents > 0)
|
|
35
|
+
lines.push(` ${dim('Subagents:')} ${subagents}`);
|
|
36
|
+
return lines;
|
|
37
|
+
};
|
|
38
|
+
const formatTokens = (tokens) => [
|
|
39
|
+
'',
|
|
40
|
+
` ${bold('Tokens')}`,
|
|
41
|
+
` ${dim('Input:')} ${formatNumber(tokens.input)}`,
|
|
42
|
+
` ${dim('Output:')} ${formatNumber(tokens.output)}`,
|
|
43
|
+
` ${dim('Cache write:')} ${formatNumber(tokens.cacheCreation)}`,
|
|
44
|
+
` ${dim('Cache read:')} ${formatNumber(tokens.cacheRead)}`,
|
|
45
|
+
];
|
|
46
|
+
const formatCostSection = (cost, totalCost) => {
|
|
47
|
+
if (cost.size === 0)
|
|
48
|
+
return [];
|
|
49
|
+
const sorted = [...cost.entries()]
|
|
50
|
+
.filter(([, modelCost]) => modelCost > 0)
|
|
51
|
+
.sort(([, costA], [, costB]) => costB - costA);
|
|
52
|
+
const modelLines = sorted.map(([model, modelCost]) => {
|
|
53
|
+
const padding = ' '.repeat(LABEL_COLUMN_WIDTH - model.length);
|
|
54
|
+
return ` ${dim(`${model}:`)}${padding}${formatCost(modelCost)}`;
|
|
55
|
+
});
|
|
56
|
+
return [
|
|
57
|
+
'',
|
|
58
|
+
` ${bold('Cost')}`,
|
|
59
|
+
...modelLines,
|
|
60
|
+
` ${dim('Total:')} ${green(formatCost(totalCost))}`,
|
|
61
|
+
];
|
|
62
|
+
};
|
|
63
|
+
const formatToolsSection = (tools) => {
|
|
64
|
+
if (tools.size === 0)
|
|
65
|
+
return [];
|
|
66
|
+
const sorted = [...tools.entries()].sort(([, countA], [, countB]) => countB - countA);
|
|
67
|
+
const longest = Math.max(...sorted.map(([name]) => name.length));
|
|
68
|
+
const toolLines = sorted.map(([name, count]) => {
|
|
69
|
+
const padding = ' '.repeat(longest - name.length + COLUMN_GAP);
|
|
70
|
+
return ` ${dim(name)}${padding}${yellow(String(count))}`;
|
|
71
|
+
});
|
|
72
|
+
return ['', ` ${bold('Tools')}`, ...toolLines];
|
|
73
|
+
};
|
|
74
|
+
export const formatStats = (result) => [
|
|
75
|
+
...formatDuration(result.duration),
|
|
76
|
+
...formatMessages(result.messages, result.subagents),
|
|
77
|
+
...formatTokens(result.tokens),
|
|
78
|
+
...formatCostSection(result.cost, result.totalCost),
|
|
79
|
+
...formatToolsSection(result.tools),
|
|
80
|
+
];
|
|
81
|
+
export const formatProjectStats = (result, count) => [` ${dim('Sessions:')} ${count}`, ...formatStats(result)];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const getColumns: () => number;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const DEFAULT_COLUMNS = 80;
|
|
2
|
+
export const getColumns = () => {
|
|
3
|
+
if (process.stdout.isTTY && process.stdout.columns)
|
|
4
|
+
return process.stdout.columns;
|
|
5
|
+
if (process.stderr.isTTY && process.stderr.columns)
|
|
6
|
+
return process.stderr.columns;
|
|
7
|
+
return DEFAULT_COLUMNS;
|
|
8
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const truncate: (text: string, maxLength: number) => string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const truncate = (text, maxLength) => text.length > maxLength ? text.slice(0, maxLength - 1) + '…' : text;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ContentBlock, PruneStats, Session } from '../types.js';
|
|
2
|
+
export declare const MIN_TEXT_LENGTH = 50;
|
|
3
|
+
export declare const SYSTEM_TAGS: readonly ["ide_selection", "ide_opened_file", "system-reminder"];
|
|
4
|
+
export declare const isToolBlock: (block: ContentBlock) => boolean;
|
|
5
|
+
export declare const textLength: (blocks: ContentBlock[]) => number;
|
|
6
|
+
export declare const hasContent: (parsed: {
|
|
7
|
+
type?: string;
|
|
8
|
+
message?: {
|
|
9
|
+
content?: unknown[];
|
|
10
|
+
};
|
|
11
|
+
}) => boolean;
|
|
12
|
+
export declare const analyze: (session: Session) => Promise<PruneStats>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
export const MIN_TEXT_LENGTH = 50;
|
|
3
|
+
export const SYSTEM_TAGS = [
|
|
4
|
+
'ide_selection',
|
|
5
|
+
'ide_opened_file',
|
|
6
|
+
'system-reminder',
|
|
7
|
+
];
|
|
8
|
+
export const isToolBlock = (block) => block.type === 'tool_use' || block.type === 'tool_result';
|
|
9
|
+
export const textLength = (blocks) => {
|
|
10
|
+
let length = 0;
|
|
11
|
+
for (const block of blocks) {
|
|
12
|
+
if (block.type !== 'text' || !block.text)
|
|
13
|
+
continue;
|
|
14
|
+
length += block.text.trim().length;
|
|
15
|
+
}
|
|
16
|
+
return length;
|
|
17
|
+
};
|
|
18
|
+
export const hasContent = (parsed) => (parsed.type === 'assistant' || parsed.type === 'user') &&
|
|
19
|
+
Array.isArray(parsed.message?.content);
|
|
20
|
+
const countTagsInText = (text) => {
|
|
21
|
+
let count = 0;
|
|
22
|
+
for (const tag of SYSTEM_TAGS) {
|
|
23
|
+
const open = `<${tag}>`;
|
|
24
|
+
const close = `</${tag}>`;
|
|
25
|
+
let position = 0;
|
|
26
|
+
scan: while (true) {
|
|
27
|
+
const start = text.indexOf(open, position);
|
|
28
|
+
if (start === -1)
|
|
29
|
+
break scan;
|
|
30
|
+
const end = text.indexOf(close, start + open.length);
|
|
31
|
+
if (end === -1)
|
|
32
|
+
break scan;
|
|
33
|
+
count++;
|
|
34
|
+
position = end + close.length;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return count;
|
|
38
|
+
};
|
|
39
|
+
const countSystemTags = (blocks) => {
|
|
40
|
+
let count = 0;
|
|
41
|
+
for (const block of blocks) {
|
|
42
|
+
if (block.type !== 'text' || !block.text)
|
|
43
|
+
continue;
|
|
44
|
+
count += countTagsInText(block.text);
|
|
45
|
+
}
|
|
46
|
+
return count;
|
|
47
|
+
};
|
|
48
|
+
export const analyze = async (session) => {
|
|
49
|
+
const raw = await readFile(session.file, 'utf-8');
|
|
50
|
+
const lines = raw.split('\n');
|
|
51
|
+
let toolBlocks = 0;
|
|
52
|
+
let shortMessages = 0;
|
|
53
|
+
let emptyMessages = 0;
|
|
54
|
+
let systemTags = 0;
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
if (!line.trim())
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(line);
|
|
60
|
+
if (!hasContent(parsed))
|
|
61
|
+
continue;
|
|
62
|
+
const content = parsed.message.content;
|
|
63
|
+
const tools = content.filter((block) => isToolBlock(block));
|
|
64
|
+
toolBlocks += tools.length;
|
|
65
|
+
systemTags += countSystemTags(content);
|
|
66
|
+
const filtered = content.filter((block) => !isToolBlock(block));
|
|
67
|
+
if (filtered.length === 0) {
|
|
68
|
+
emptyMessages++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (parsed.type === 'assistant' && textLength(filtered) < MIN_TEXT_LENGTH)
|
|
72
|
+
shortMessages++;
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
}
|
|
76
|
+
return { toolBlocks, shortMessages, emptyMessages, systemTags };
|
|
77
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
export const countSubagents = async (session) => {
|
|
4
|
+
const id = basename(session.file, '.jsonl');
|
|
5
|
+
const subagentsDir = join(dirname(session.file), id, 'subagents');
|
|
6
|
+
try {
|
|
7
|
+
const entries = await readdir(subagentsDir);
|
|
8
|
+
return entries.length;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const extractFirstUserMessage: (filePath: string) => Promise<string>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { parseLine } from './parse-line.js';
|
|
4
|
+
const MIN_TEXT_LENGTH = 5;
|
|
5
|
+
export const extractFirstUserMessage = (filePath) => new Promise((resolve) => {
|
|
6
|
+
const stream = createReadStream(filePath, 'utf-8');
|
|
7
|
+
const reader = createInterface({ input: stream, crlfDelay: Infinity });
|
|
8
|
+
const close = (value) => {
|
|
9
|
+
resolve(value);
|
|
10
|
+
reader.close();
|
|
11
|
+
stream.destroy();
|
|
12
|
+
};
|
|
13
|
+
reader.on('line', (line) => {
|
|
14
|
+
if (!line.trim())
|
|
15
|
+
return;
|
|
16
|
+
const entry = parseLine(line);
|
|
17
|
+
if (!entry)
|
|
18
|
+
return;
|
|
19
|
+
if (entry.type === 'session_title' && 'title' in entry) {
|
|
20
|
+
close(String(entry.title)
|
|
21
|
+
.replace(/\s+/g, ' ')
|
|
22
|
+
.trim());
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (entry.type !== 'user')
|
|
26
|
+
return;
|
|
27
|
+
const texts = entry.message?.content ?? [];
|
|
28
|
+
for (const block of texts) {
|
|
29
|
+
if (block.type !== 'text' || !block.text)
|
|
30
|
+
continue;
|
|
31
|
+
const text = block.text.replace(/\s+/g, ' ').trim();
|
|
32
|
+
if (text.startsWith('<'))
|
|
33
|
+
continue;
|
|
34
|
+
if (text.length <= MIN_TEXT_LENGTH)
|
|
35
|
+
continue;
|
|
36
|
+
close(text);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
reader.on('close', () => resolve(''));
|
|
41
|
+
stream.on('error', () => close(''));
|
|
42
|
+
});
|