@blockrun/franklin 3.10.0 → 3.10.2
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/dist/commands/start.d.ts +4 -0
- package/dist/commands/start.js +57 -5
- package/dist/index.js +12 -2
- package/dist/panel/html.js +501 -23
- package/dist/panel/server.js +127 -0
- package/dist/session/from-import.d.ts +18 -0
- package/dist/session/from-import.js +553 -0
- package/dist/stats/tracker.d.ts +4 -0
- package/dist/stats/tracker.js +30 -4
- package/dist/ui/app.js +6 -12
- package/package.json +1 -1
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { appendToSession, createSessionId, updateSessionMeta } from './storage.js';
|
|
7
|
+
const MAX_FILES_PER_SOURCE = 500;
|
|
8
|
+
const MAX_MESSAGES_IN_HANDOFF = 24;
|
|
9
|
+
const MAX_TOOL_EVENTS_IN_HANDOFF = 18;
|
|
10
|
+
const MAX_TEXT_CHARS = 3000;
|
|
11
|
+
const MAX_HANDOFF_CHARS = 24000;
|
|
12
|
+
export function parseExternalAgentSource(input) {
|
|
13
|
+
const normalized = input.trim().toLowerCase();
|
|
14
|
+
return normalized === 'claude' || normalized === 'codex' ? normalized : null;
|
|
15
|
+
}
|
|
16
|
+
export async function importExternalSessionAsFranklin(source, externalSessionId, opts) {
|
|
17
|
+
const candidates = discoverExternalSessions(source);
|
|
18
|
+
if (candidates.length === 0) {
|
|
19
|
+
throw new Error(`No ${source} sessions found.`);
|
|
20
|
+
}
|
|
21
|
+
if (!externalSessionId && !process.stdin.isTTY) {
|
|
22
|
+
throw new Error(`--from ${source} requires a session id when stdin is not interactive.`);
|
|
23
|
+
}
|
|
24
|
+
const picked = externalSessionId
|
|
25
|
+
? resolveExternalSession(candidates, externalSessionId)
|
|
26
|
+
: await pickExternalSession(source, candidates, opts.workDir);
|
|
27
|
+
if (!picked) {
|
|
28
|
+
throw new Error(`No ${source} session selected.`);
|
|
29
|
+
}
|
|
30
|
+
const parsed = parseExternalSession(picked);
|
|
31
|
+
const sessionId = createSessionId();
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const handoff = buildHandoffPrompt(parsed);
|
|
34
|
+
const handoffMessage = { role: 'user', content: handoff };
|
|
35
|
+
const ackMessage = {
|
|
36
|
+
role: 'assistant',
|
|
37
|
+
content: 'I have the imported session context and will continue from that state in this new Franklin session.',
|
|
38
|
+
};
|
|
39
|
+
appendToSession(sessionId, handoffMessage);
|
|
40
|
+
appendToSession(sessionId, ackMessage);
|
|
41
|
+
updateSessionMeta(sessionId, {
|
|
42
|
+
model: opts.model,
|
|
43
|
+
workDir: parsed.cwd || opts.workDir,
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
turnCount: 1,
|
|
47
|
+
messageCount: 2,
|
|
48
|
+
});
|
|
49
|
+
return { sessionId, imported: picked };
|
|
50
|
+
}
|
|
51
|
+
function discoverExternalSessions(source) {
|
|
52
|
+
const roots = source === 'codex' ? codexRoots() : claudeRoots();
|
|
53
|
+
const files = roots.flatMap((root) => walkSessionFiles(root, source));
|
|
54
|
+
const candidates = files
|
|
55
|
+
.map((filePath) => sessionCandidateFromFile(source, filePath))
|
|
56
|
+
.filter((candidate) => candidate !== null)
|
|
57
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
58
|
+
const byId = new Map();
|
|
59
|
+
for (const candidate of candidates) {
|
|
60
|
+
const existing = byId.get(candidate.id);
|
|
61
|
+
if (!existing || existing.updatedAt < candidate.updatedAt) {
|
|
62
|
+
byId.set(candidate.id, candidate);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Array.from(byId.values()).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
66
|
+
}
|
|
67
|
+
function codexRoots() {
|
|
68
|
+
const home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
69
|
+
return [path.join(home, 'sessions'), path.join(home, 'archived_sessions')];
|
|
70
|
+
}
|
|
71
|
+
function claudeRoots() {
|
|
72
|
+
const root = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
73
|
+
return [path.join(root, 'projects')];
|
|
74
|
+
}
|
|
75
|
+
function walkSessionFiles(root, source) {
|
|
76
|
+
const out = [];
|
|
77
|
+
const stack = [root];
|
|
78
|
+
while (stack.length > 0 && out.length < MAX_FILES_PER_SOURCE) {
|
|
79
|
+
const dir = stack.pop();
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const full = path.join(dir, entry.name);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
stack.push(full);
|
|
91
|
+
}
|
|
92
|
+
else if (entry.isFile() && isSessionFileName(source, entry.name)) {
|
|
93
|
+
out.push(full);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function isSessionFileName(source, name) {
|
|
100
|
+
if (source === 'codex')
|
|
101
|
+
return name.startsWith('rollout-') && name.endsWith('.jsonl');
|
|
102
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i.test(name);
|
|
103
|
+
}
|
|
104
|
+
function sessionCandidateFromFile(source, filePath) {
|
|
105
|
+
try {
|
|
106
|
+
const stats = fs.statSync(filePath);
|
|
107
|
+
const partial = source === 'codex' ? readCodexMeta(filePath) : readClaudeMeta(filePath);
|
|
108
|
+
const id = partial.id || idFromFileName(source, filePath);
|
|
109
|
+
if (!id)
|
|
110
|
+
return null;
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
source,
|
|
114
|
+
cwd: partial.cwd,
|
|
115
|
+
summary: partial.summary,
|
|
116
|
+
updatedAt: partial.updatedAt || stats.mtimeMs,
|
|
117
|
+
filePath,
|
|
118
|
+
bytes: stats.size,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function idFromFileName(source, filePath) {
|
|
126
|
+
const base = path.basename(filePath, '.jsonl');
|
|
127
|
+
if (source === 'codex')
|
|
128
|
+
return base.replace(/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-/, '');
|
|
129
|
+
return base;
|
|
130
|
+
}
|
|
131
|
+
function readCodexMeta(filePath) {
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const record of readJsonlPrefix(filePath, 180)) {
|
|
134
|
+
const type = stringProp(record, 'type');
|
|
135
|
+
if (type === 'session_meta') {
|
|
136
|
+
const payload = objectProp(record, 'payload');
|
|
137
|
+
out.cwd ||= stringProp(payload, 'cwd');
|
|
138
|
+
out.updatedAt ||= timestampMs(stringProp(payload, 'timestamp')) || timestampMs(stringProp(record, 'timestamp'));
|
|
139
|
+
}
|
|
140
|
+
if (!out.summary) {
|
|
141
|
+
const text = extractCodexMessageText(record);
|
|
142
|
+
if (text && codexRole(record) === 'user')
|
|
143
|
+
out.summary = cleanSummary(text);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
out.id = idFromFileName('codex', filePath);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function readClaudeMeta(filePath) {
|
|
150
|
+
const out = {};
|
|
151
|
+
for (const record of readJsonlPrefix(filePath, 180)) {
|
|
152
|
+
out.id ||= stringProp(record, 'sessionId');
|
|
153
|
+
out.cwd ||= stringProp(record, 'cwd');
|
|
154
|
+
const ts = timestampMs(stringProp(record, 'timestamp'));
|
|
155
|
+
if (ts)
|
|
156
|
+
out.updatedAt = Math.max(out.updatedAt || 0, ts);
|
|
157
|
+
if (!out.summary && stringProp(record, 'type') === 'user') {
|
|
158
|
+
const text = extractClaudeMessageText(record);
|
|
159
|
+
if (text && isHumanText(text))
|
|
160
|
+
out.summary = cleanSummary(text);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
out.id ||= idFromFileName('claude', filePath);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
function resolveExternalSession(candidates, input) {
|
|
167
|
+
const exact = candidates.find((candidate) => candidate.id === input || candidate.filePath === input);
|
|
168
|
+
if (exact)
|
|
169
|
+
return exact;
|
|
170
|
+
const matches = input.length >= 4 ? candidates.filter((candidate) => candidate.id.startsWith(input)) : [];
|
|
171
|
+
if (matches.length === 1)
|
|
172
|
+
return matches[0];
|
|
173
|
+
if (matches.length > 1)
|
|
174
|
+
throw new Error(`Ambiguous ${matches[0].source} session id prefix: ${input}`);
|
|
175
|
+
throw new Error(`No ${candidates[0]?.source ?? 'external'} session found with id: ${input}`);
|
|
176
|
+
}
|
|
177
|
+
async function pickExternalSession(source, candidates, workDir) {
|
|
178
|
+
const shown = prioritizeByCwd(candidates, workDir).slice(0, 20);
|
|
179
|
+
if (process.stdin.isTTY && process.stderr.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
180
|
+
return pickExternalSessionInteractive(source, shown, candidates, workDir);
|
|
181
|
+
}
|
|
182
|
+
console.error('');
|
|
183
|
+
console.error(chalk.bold(` Continue from ${source} session:\n`));
|
|
184
|
+
shown.forEach((session, index) => {
|
|
185
|
+
const here = session.cwd && samePath(session.cwd, workDir) ? chalk.green(' ●') : '';
|
|
186
|
+
console.error(` ${chalk.cyan(String(index + 1).padStart(2))}. ${chalk.dim(formatRelative(session.updatedAt).padEnd(8))} ` +
|
|
187
|
+
`${shortDir(session.cwd || '(unknown dir)').padEnd(42)} ${chalk.dim(session.id.slice(0, 12))}${here}`);
|
|
188
|
+
if (session.summary)
|
|
189
|
+
console.error(chalk.dim(` ${session.summary}`));
|
|
190
|
+
});
|
|
191
|
+
console.error('');
|
|
192
|
+
console.error(chalk.dim(' Enter a number or session id. Press Enter to cancel.'));
|
|
193
|
+
if (shown.some((session) => session.cwd && samePath(session.cwd, workDir))) {
|
|
194
|
+
console.error(chalk.dim(' ● = matches current directory'));
|
|
195
|
+
}
|
|
196
|
+
console.error('');
|
|
197
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: process.stdin.isTTY ?? false });
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
rl.question(chalk.bold(' session> '), (answer) => {
|
|
200
|
+
rl.close();
|
|
201
|
+
const trimmed = answer.trim();
|
|
202
|
+
if (!trimmed)
|
|
203
|
+
return resolve(null);
|
|
204
|
+
const num = Number.parseInt(trimmed, 10);
|
|
205
|
+
if (!Number.isNaN(num) && num >= 1 && num <= shown.length)
|
|
206
|
+
return resolve(shown[num - 1]);
|
|
207
|
+
try {
|
|
208
|
+
resolve(resolveExternalSession(candidates, trimmed));
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.error(chalk.red(` ${err.message}`));
|
|
212
|
+
resolve(null);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async function pickExternalSessionInteractive(source, shown, candidates, workDir) {
|
|
218
|
+
const pageSize = 5;
|
|
219
|
+
let selected = 0;
|
|
220
|
+
let offset = 0;
|
|
221
|
+
const render = () => {
|
|
222
|
+
offset = Math.min(offset, Math.max(0, shown.length - pageSize));
|
|
223
|
+
if (selected < offset)
|
|
224
|
+
offset = selected;
|
|
225
|
+
if (selected >= offset + pageSize)
|
|
226
|
+
offset = selected - pageSize + 1;
|
|
227
|
+
readline.cursorTo(process.stderr, 0, 0);
|
|
228
|
+
readline.clearScreenDown(process.stderr);
|
|
229
|
+
process.stderr.write('\x1b[?25l');
|
|
230
|
+
process.stderr.write(`\n${chalk.bold(` Continue from ${source} session`)}\n\n`);
|
|
231
|
+
process.stderr.write(chalk.dim(' ↑/↓ move · Enter select · type number/id then Enter · q/Esc cancel\n'));
|
|
232
|
+
if (shown.some((session) => session.cwd && samePath(session.cwd, workDir))) {
|
|
233
|
+
process.stderr.write(`${chalk.green(' ● Current Dir')} ${chalk.dim('= matches where you ran Franklin')}\n`);
|
|
234
|
+
}
|
|
235
|
+
process.stderr.write('\n');
|
|
236
|
+
const page = shown.slice(offset, offset + pageSize);
|
|
237
|
+
page.forEach((session, pageIndex) => {
|
|
238
|
+
const index = offset + pageIndex;
|
|
239
|
+
const active = index === selected;
|
|
240
|
+
const pointer = active ? chalk.cyan('›') : ' ';
|
|
241
|
+
const num = String(index + 1).padStart(2);
|
|
242
|
+
const here = !!(session.cwd && samePath(session.cwd, workDir));
|
|
243
|
+
const dir = shortDir(session.cwd || '(unknown dir)').padEnd(42);
|
|
244
|
+
const dirText = here ? chalk.green.bold(dir) : dir;
|
|
245
|
+
const hereText = here ? ` ${chalk.green.bold('● Current Dir')}` : '';
|
|
246
|
+
const line = `${pointer} ${num}. ${formatRelative(session.updatedAt).padEnd(8)} ${dirText} ${session.id.slice(0, 12)}${hereText}`;
|
|
247
|
+
process.stderr.write(active ? `${chalk.inverse(line)}\n` : `${line}\n`);
|
|
248
|
+
if (session.summary) {
|
|
249
|
+
const summary = truncate(session.summary, Math.max(60, (process.stderr.columns ?? 120) - 10));
|
|
250
|
+
process.stderr.write(chalk.dim(` ${summary}\n`));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
if (shown.length > pageSize) {
|
|
254
|
+
process.stderr.write(chalk.dim(`\n Showing ${offset + 1}-${Math.min(offset + pageSize, shown.length)} of ${shown.length}\n`));
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
process.stderr.write('\n');
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
let buffer = '';
|
|
262
|
+
const cleanup = () => {
|
|
263
|
+
process.stdin.off('data', onData);
|
|
264
|
+
process.stdin.setRawMode(false);
|
|
265
|
+
process.stdin.pause();
|
|
266
|
+
process.stderr.write('\x1b[?25h');
|
|
267
|
+
readline.cursorTo(process.stderr, 0, 0);
|
|
268
|
+
readline.clearScreenDown(process.stderr);
|
|
269
|
+
};
|
|
270
|
+
const finish = (value) => {
|
|
271
|
+
cleanup();
|
|
272
|
+
resolve(value);
|
|
273
|
+
};
|
|
274
|
+
const submitBuffer = () => {
|
|
275
|
+
const trimmed = buffer.trim();
|
|
276
|
+
if (!trimmed)
|
|
277
|
+
return finish(shown[selected] ?? null);
|
|
278
|
+
const num = Number.parseInt(trimmed, 10);
|
|
279
|
+
if (!Number.isNaN(num) && num >= 1 && num <= shown.length)
|
|
280
|
+
return finish(shown[num - 1]);
|
|
281
|
+
try {
|
|
282
|
+
return finish(resolveExternalSession(candidates, trimmed));
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
buffer = '';
|
|
286
|
+
render();
|
|
287
|
+
process.stderr.write(chalk.yellow(` ${err.message}\n`));
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const onData = (chunk) => {
|
|
291
|
+
const key = chunk.toString('utf8');
|
|
292
|
+
if (key === '\u0003') {
|
|
293
|
+
cleanup();
|
|
294
|
+
process.kill(process.pid, 'SIGINT');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (key === '\r' || key === '\n')
|
|
298
|
+
return submitBuffer();
|
|
299
|
+
if (key === '\u001b' || key.toLowerCase() === 'q')
|
|
300
|
+
return finish(null);
|
|
301
|
+
if (key === '\u001b[A') {
|
|
302
|
+
selected = Math.max(0, selected - 1);
|
|
303
|
+
render();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (key === '\u001b[B') {
|
|
307
|
+
selected = Math.min(shown.length - 1, selected + 1);
|
|
308
|
+
render();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (key === '\u001b[5~') {
|
|
312
|
+
selected = Math.max(0, selected - pageSize);
|
|
313
|
+
render();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (key === '\u001b[6~') {
|
|
317
|
+
selected = Math.min(shown.length - 1, selected + pageSize);
|
|
318
|
+
render();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (key === '\u007f') {
|
|
322
|
+
buffer = buffer.slice(0, -1);
|
|
323
|
+
render();
|
|
324
|
+
if (buffer)
|
|
325
|
+
process.stderr.write(chalk.dim(` filter/id: ${buffer}\n`));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (/^[\w./:-]$/.test(key)) {
|
|
329
|
+
buffer += key;
|
|
330
|
+
render();
|
|
331
|
+
process.stderr.write(chalk.dim(` filter/id: ${buffer}\n`));
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
process.stdin.setRawMode(true);
|
|
335
|
+
process.stdin.resume();
|
|
336
|
+
process.stdin.on('data', onData);
|
|
337
|
+
render();
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function prioritizeByCwd(candidates, workDir) {
|
|
341
|
+
return [...candidates].sort((a, b) => {
|
|
342
|
+
const ah = a.cwd && samePath(a.cwd, workDir) ? 1 : 0;
|
|
343
|
+
const bh = b.cwd && samePath(b.cwd, workDir) ? 1 : 0;
|
|
344
|
+
return bh - ah || b.updatedAt - a.updatedAt;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function parseExternalSession(candidate) {
|
|
348
|
+
const messages = [];
|
|
349
|
+
const toolEvents = [];
|
|
350
|
+
for (const record of readJsonlPrefix(candidate.filePath, 5000)) {
|
|
351
|
+
const role = candidate.source === 'codex' ? codexRole(record) : claudeRole(record);
|
|
352
|
+
const text = candidate.source === 'codex' ? extractCodexMessageText(record) : extractClaudeMessageText(record);
|
|
353
|
+
if (role && text && isHumanText(text)) {
|
|
354
|
+
messages.push({ role, text: truncate(text, MAX_TEXT_CHARS) });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const tool = candidate.source === 'codex' ? extractCodexToolEvent(record) : extractClaudeToolEvent(record);
|
|
358
|
+
if (tool)
|
|
359
|
+
toolEvents.push(tool);
|
|
360
|
+
}
|
|
361
|
+
return { ...candidate, messages: messages.slice(-MAX_MESSAGES_IN_HANDOFF), toolEvents: toolEvents.slice(-MAX_TOOL_EVENTS_IN_HANDOFF) };
|
|
362
|
+
}
|
|
363
|
+
function buildHandoffPrompt(session) {
|
|
364
|
+
const lines = [
|
|
365
|
+
'You are Franklin continuing work from another AI coding-agent session.',
|
|
366
|
+
'',
|
|
367
|
+
'This is a new Franklin session. Do not assume you can modify or resume the source agent session file. Use this handoff only as context awareness for what happened before.',
|
|
368
|
+
'',
|
|
369
|
+
'## Source Session',
|
|
370
|
+
`- Agent: ${session.source}`,
|
|
371
|
+
`- Session ID: ${session.id}`,
|
|
372
|
+
`- Original path: ${session.filePath}`,
|
|
373
|
+
`- Working directory: ${session.cwd || '(unknown)'}`,
|
|
374
|
+
`- Last active: ${new Date(session.updatedAt).toLocaleString()}`,
|
|
375
|
+
];
|
|
376
|
+
if (session.summary)
|
|
377
|
+
lines.push(`- Summary: ${session.summary}`);
|
|
378
|
+
if (session.toolEvents.length > 0) {
|
|
379
|
+
lines.push('', '## Recent Tool Activity');
|
|
380
|
+
for (const event of session.toolEvents)
|
|
381
|
+
lines.push(`- ${event}`);
|
|
382
|
+
}
|
|
383
|
+
if (session.messages.length > 0) {
|
|
384
|
+
lines.push('', '## Recent Conversation');
|
|
385
|
+
for (const msg of session.messages) {
|
|
386
|
+
lines.push('', `### ${msg.role}`, msg.text);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
lines.push('', '## Continue From Here', 'Ask the user what they want to do next if the next action is unclear. Otherwise continue the unfinished coding task using Franklin tools in the current workspace.');
|
|
390
|
+
return truncate(lines.join('\n'), MAX_HANDOFF_CHARS);
|
|
391
|
+
}
|
|
392
|
+
function readJsonlPrefix(filePath, maxLines) {
|
|
393
|
+
let content = '';
|
|
394
|
+
try {
|
|
395
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
const lines = content.split('\n').filter(Boolean);
|
|
401
|
+
const start = Math.max(0, lines.length - maxLines);
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const line of lines.slice(start)) {
|
|
404
|
+
try {
|
|
405
|
+
out.push(JSON.parse(line));
|
|
406
|
+
}
|
|
407
|
+
catch { /* skip bad lines */ }
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
function codexRole(record) {
|
|
412
|
+
const role = stringProp(record, 'role');
|
|
413
|
+
if (role === 'user' || role === 'assistant' || role === 'system')
|
|
414
|
+
return role;
|
|
415
|
+
const payload = objectProp(record, 'payload');
|
|
416
|
+
const type = stringProp(payload, 'type');
|
|
417
|
+
if (type === 'user_message')
|
|
418
|
+
return 'user';
|
|
419
|
+
if (type === 'agent_message' || type === 'assistant_message')
|
|
420
|
+
return 'assistant';
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
function claudeRole(record) {
|
|
424
|
+
const type = stringProp(record, 'type');
|
|
425
|
+
if (type === 'user' || type === 'assistant' || type === 'system')
|
|
426
|
+
return type;
|
|
427
|
+
const message = objectProp(record, 'message');
|
|
428
|
+
const role = stringProp(message, 'role');
|
|
429
|
+
return role === 'user' || role === 'assistant' || role === 'system' ? role : null;
|
|
430
|
+
}
|
|
431
|
+
function extractCodexMessageText(record) {
|
|
432
|
+
const payload = objectProp(record, 'payload');
|
|
433
|
+
const direct = stringProp(record, 'content') || stringProp(payload, 'message') || stringProp(payload, 'text');
|
|
434
|
+
if (direct)
|
|
435
|
+
return direct;
|
|
436
|
+
return extractTextFromUnknown(objectProp(record, 'message') || objectProp(payload, 'message'));
|
|
437
|
+
}
|
|
438
|
+
function extractClaudeMessageText(record) {
|
|
439
|
+
const message = objectProp(record, 'message');
|
|
440
|
+
return extractTextFromUnknown(rawProp(message, 'content') ?? rawProp(record, 'content'));
|
|
441
|
+
}
|
|
442
|
+
function extractCodexToolEvent(record) {
|
|
443
|
+
const payload = objectProp(record, 'payload');
|
|
444
|
+
const type = stringProp(payload, 'type') || stringProp(record, 'type');
|
|
445
|
+
if (!type || !/(tool|exec|command|patch|call)/i.test(type))
|
|
446
|
+
return null;
|
|
447
|
+
const name = stringProp(payload, 'name') || stringProp(record, 'name') || type;
|
|
448
|
+
const command = stringProp(payload, 'command') || stringProp(payload, 'cmd');
|
|
449
|
+
return truncate(command ? `${name}: ${command}` : name, 300);
|
|
450
|
+
}
|
|
451
|
+
function extractClaudeToolEvent(record) {
|
|
452
|
+
const message = objectProp(record, 'message');
|
|
453
|
+
const content = rawProp(message, 'content') ?? rawProp(record, 'content');
|
|
454
|
+
if (!Array.isArray(content))
|
|
455
|
+
return null;
|
|
456
|
+
const events = [];
|
|
457
|
+
for (const block of content) {
|
|
458
|
+
const type = stringProp(block, 'type');
|
|
459
|
+
if (type !== 'tool_use' && type !== 'tool_result')
|
|
460
|
+
continue;
|
|
461
|
+
const name = stringProp(block, 'name') || type;
|
|
462
|
+
const input = objectProp(block, 'input');
|
|
463
|
+
const command = stringProp(input, 'command') || stringProp(input, 'file_path') || stringProp(input, 'path');
|
|
464
|
+
events.push(truncate(command ? `${name}: ${command}` : name, 300));
|
|
465
|
+
}
|
|
466
|
+
return events.length > 0 ? events.join(' · ') : null;
|
|
467
|
+
}
|
|
468
|
+
function extractTextFromUnknown(value) {
|
|
469
|
+
if (typeof value === 'string')
|
|
470
|
+
return stripMarkup(value).trim();
|
|
471
|
+
if (Array.isArray(value)) {
|
|
472
|
+
return value.map((part) => {
|
|
473
|
+
if (typeof part === 'string')
|
|
474
|
+
return part;
|
|
475
|
+
if (isRecord(part)) {
|
|
476
|
+
if (stringProp(part, 'type') === 'text')
|
|
477
|
+
return stringProp(part, 'text') || '';
|
|
478
|
+
if (stringProp(part, 'type') === 'input_text')
|
|
479
|
+
return stringProp(part, 'text') || '';
|
|
480
|
+
}
|
|
481
|
+
return '';
|
|
482
|
+
}).filter(Boolean).join('\n').trim();
|
|
483
|
+
}
|
|
484
|
+
return '';
|
|
485
|
+
}
|
|
486
|
+
function stripMarkup(text) {
|
|
487
|
+
return text
|
|
488
|
+
.replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/giu, '')
|
|
489
|
+
.replace(/<command-name>[\s\S]*?<\/command-name>/giu, '')
|
|
490
|
+
.replace(/<command-message>[\s\S]*?<\/command-message>/giu, '')
|
|
491
|
+
.replace(/<command-args>[\s\S]*?<\/command-args>/giu, '')
|
|
492
|
+
.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/giu, '')
|
|
493
|
+
.trim();
|
|
494
|
+
}
|
|
495
|
+
function isHumanText(text) {
|
|
496
|
+
const trimmed = text.trim();
|
|
497
|
+
return trimmed.length > 0 && !trimmed.startsWith('<system-reminder>') && !trimmed.startsWith('[Request interrupted');
|
|
498
|
+
}
|
|
499
|
+
function cleanSummary(text) {
|
|
500
|
+
return truncate(text.replace(/\s+/g, ' ').trim(), 100);
|
|
501
|
+
}
|
|
502
|
+
function truncate(text, max) {
|
|
503
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
504
|
+
}
|
|
505
|
+
function timestampMs(value) {
|
|
506
|
+
if (!value)
|
|
507
|
+
return undefined;
|
|
508
|
+
const ms = Date.parse(value);
|
|
509
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
510
|
+
}
|
|
511
|
+
function stringProp(value, key) {
|
|
512
|
+
if (!isRecord(value))
|
|
513
|
+
return undefined;
|
|
514
|
+
const prop = value[key];
|
|
515
|
+
return typeof prop === 'string' ? prop : undefined;
|
|
516
|
+
}
|
|
517
|
+
function objectProp(value, key) {
|
|
518
|
+
if (!isRecord(value))
|
|
519
|
+
return undefined;
|
|
520
|
+
const prop = value[key];
|
|
521
|
+
return isRecord(prop) ? prop : undefined;
|
|
522
|
+
}
|
|
523
|
+
function rawProp(value, key) {
|
|
524
|
+
return isRecord(value) ? value[key] : undefined;
|
|
525
|
+
}
|
|
526
|
+
function isRecord(value) {
|
|
527
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
528
|
+
}
|
|
529
|
+
function samePath(a, b) {
|
|
530
|
+
try {
|
|
531
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
return path.resolve(a) === path.resolve(b);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function shortDir(dir) {
|
|
538
|
+
const home = os.homedir();
|
|
539
|
+
const clean = dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
|
|
540
|
+
return clean.length > 40 ? `…${clean.slice(-39)}` : clean;
|
|
541
|
+
}
|
|
542
|
+
function formatRelative(ts) {
|
|
543
|
+
const diff = Math.max(0, Date.now() - ts);
|
|
544
|
+
const min = Math.floor(diff / 60000);
|
|
545
|
+
if (min < 1)
|
|
546
|
+
return 'now';
|
|
547
|
+
if (min < 60)
|
|
548
|
+
return `${min}m ago`;
|
|
549
|
+
const hr = Math.floor(min / 60);
|
|
550
|
+
if (hr < 24)
|
|
551
|
+
return `${hr}h ago`;
|
|
552
|
+
return `${Math.floor(hr / 24)}d ago`;
|
|
553
|
+
}
|
package/dist/stats/tracker.d.ts
CHANGED
|
@@ -48,6 +48,10 @@ export declare function recordUsage(model: string, inputTokens: number, outputTo
|
|
|
48
48
|
export declare function getStatsSummary(): {
|
|
49
49
|
stats: Stats;
|
|
50
50
|
opusCost: number;
|
|
51
|
+
/** All chat / token-billed model spend (excludes image / video / music). */
|
|
52
|
+
chatOnlyCost: number;
|
|
53
|
+
/** Per-image / per-second / per-track media generation spend. */
|
|
54
|
+
mediaCost: number;
|
|
51
55
|
saved: number;
|
|
52
56
|
savedPct: number;
|
|
53
57
|
avgCostPerRequest: number;
|
package/dist/stats/tracker.js
CHANGED
|
@@ -207,10 +207,36 @@ export function recordUsage(model, inputTokens, outputTokens, costUsd, latencyMs
|
|
|
207
207
|
*/
|
|
208
208
|
export function getStatsSummary() {
|
|
209
209
|
const stats = loadStats();
|
|
210
|
-
//
|
|
211
|
-
|
|
210
|
+
// Hypothetical "if you'd used Opus for everything" baseline. Opus is a
|
|
211
|
+
// chat model — it can't replace ImageGen / VideoGen / Music (per_image,
|
|
212
|
+
// per_second, per_track billing), so for those rows the Opus-equivalent
|
|
213
|
+
// cost IS just the actual cost (no alternative). For chat rows, the
|
|
214
|
+
// baseline is the same tokens repriced at Opus rates.
|
|
215
|
+
//
|
|
216
|
+
// Walk byModel: rows with zero tokens are media (recordUsage stores
|
|
217
|
+
// image/video calls with inputTokens=0 outputTokens=0). Those count
|
|
218
|
+
// towards both sides equally; chat rows count at actual price on the
|
|
219
|
+
// "actual" side and at Opus rates on the "baseline" side. Keeping them
|
|
220
|
+
// on both sides means the displayed totals match the user's real
|
|
221
|
+
// spend rather than an unfamiliar chat-only subset.
|
|
222
|
+
let chatOnlyCost = 0;
|
|
223
|
+
let mediaCost = 0;
|
|
224
|
+
for (const m of Object.values(stats.byModel)) {
|
|
225
|
+
if ((m.inputTokens + m.outputTokens) > 0)
|
|
226
|
+
chatOnlyCost += m.costUsd;
|
|
227
|
+
else
|
|
228
|
+
mediaCost += m.costUsd;
|
|
229
|
+
}
|
|
230
|
+
const opusChatCost = (stats.totalInputTokens / 1_000_000) * OPUS_PRICING.input +
|
|
212
231
|
(stats.totalOutputTokens / 1_000_000) * OPUS_PRICING.output;
|
|
213
|
-
|
|
232
|
+
// Display-side baseline: include media on both sides so "you spent X
|
|
233
|
+
// instead of Y" shows real, comparable totals.
|
|
234
|
+
const opusCost = opusChatCost + mediaCost;
|
|
235
|
+
// Saved is the chat-side delta only — media nets to zero. Clamp to 0
|
|
236
|
+
// so a session where the user paid more than Opus-equivalent for chat
|
|
237
|
+
// (e.g. Sonnet 4.6 with extended thinking enabled) doesn't show a
|
|
238
|
+
// negative "savings" number; we just say zero saved.
|
|
239
|
+
const saved = Math.max(0, opusChatCost - chatOnlyCost);
|
|
214
240
|
const savedPct = opusCost > 0 ? (saved / opusCost) * 100 : 0;
|
|
215
241
|
const avgCostPerRequest = stats.totalRequests > 0 ? stats.totalCostUsd / stats.totalRequests : 0;
|
|
216
242
|
// Calculate period
|
|
@@ -224,5 +250,5 @@ export function getStatsSummary() {
|
|
|
224
250
|
else
|
|
225
251
|
period = `${days} days`;
|
|
226
252
|
}
|
|
227
|
-
return { stats, opusCost, saved, savedPct, avgCostPerRequest, period };
|
|
253
|
+
return { stats, opusCost, chatOnlyCost, mediaCost, saved, savedPct, avgCostPerRequest, period };
|
|
228
254
|
}
|