agentlytics 0.0.7 → 0.0.10

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 CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>Unified analytics for your AI coding agents</strong><br>
9
- <sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Gemini CLI · Copilot CLI · Cursor Agent</sub>
9
+ <sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Codex · Gemini CLI · Copilot CLI · Cursor Agent · Command Code</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
13
  <a href="https://www.npmjs.com/package/agentlytics"><img src="https://img.shields.io/npm/v/agentlytics?color=6366f1&label=npm" alt="npm"></a>
14
- <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-12-818cf8" alt="editors"></a>
14
+ <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-14-818cf8" alt="editors"></a>
15
15
  <a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
16
16
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A518-brightgreen" alt="node"></a>
17
17
  </p>
@@ -32,6 +32,8 @@ npx agentlytics
32
32
 
33
33
  Opens at **http://localhost:4637**. Requires Node.js ≥ 18, macOS.
34
34
 
35
+ For local development, run `npm run dev` from the repo root. That starts both the backend on `http://localhost:4637` and the Vite frontend on `http://localhost:5173`.
36
+
35
37
  ## Features
36
38
 
37
39
  - **Dashboard** — KPIs, activity heatmap, editor breakdown, coding streaks, token economy, peak hours, top models & tools
@@ -54,12 +56,16 @@ Opens at **http://localhost:4637**. Requires Node.js ≥ 18, macOS.
54
56
  | **VS Code Insiders** | `vscode-insiders` | ✅ | ✅ | ✅ | ✅ |
55
57
  | **Zed** | `zed` | ✅ | ✅ | ✅ | ❌ |
56
58
  | **OpenCode** | `opencode` | ✅ | ✅ | ✅ | ✅ |
59
+ | **Codex** | `codex` | ✅ | ✅ | ✅ | ✅ |
57
60
  | **Gemini CLI** | `gemini-cli` | ✅ | ✅ | ✅ | ✅ |
58
61
  | **Copilot CLI** | `copilot-cli` | ✅ | ✅ | ✅ | ✅ |
59
62
  | **Cursor Agent** | `cursor-agent` | ✅ | ❌ | ❌ | ❌ |
63
+ | **Command Code** | `commandcode` | ✅ | ✅ | ❌ | ❌ |
60
64
 
61
65
  > Windsurf, Windsurf Next, and Antigravity must be running during scan.
62
66
 
67
+ Codex sessions are read from `${CODEX_HOME:-~/.codex}/sessions/**/*.jsonl`. Reasoning summaries may appear in transcripts when Codex records them in clear text, but encrypted reasoning content is not readable. Codex Desktop and CLI sessions are aggregated into one `codex` editor in analytics.
68
+
63
69
  ## How It Works
64
70
 
65
71
  ```
package/cache.js CHANGED
@@ -106,6 +106,9 @@ const insertMsg = () => db.prepare(`
106
106
  INSERT INTO messages (chat_id, seq, role, content, model, input_tokens, output_tokens)
107
107
  VALUES (?, ?, ?, ?, ?, ?, ?)
108
108
  `);
109
+ const updateChatBubbleCount = () => db.prepare(`
110
+ UPDATE chats SET bubble_count = ? WHERE id = ?
111
+ `);
109
112
 
110
113
  function analyzeAndStore(chat) {
111
114
  if (chat.encrypted) return;
@@ -127,6 +130,7 @@ function analyzeAndStore(chat) {
127
130
  delTc.run(chat.composerId);
128
131
 
129
132
  const ins = insertMsg();
133
+ const updBubbleCount = updateChatBubbleCount();
130
134
  const insTc = db.prepare('INSERT INTO tool_calls (chat_id, tool_name, args_json, source, folder, timestamp) VALUES (?, ?, ?, ?, ?, ?)');
131
135
  const chatTs = chat.lastUpdatedAt || chat.createdAt || null;
132
136
 
@@ -174,6 +178,8 @@ function analyzeAndStore(chat) {
174
178
  ins.run(chat.composerId, seq++, msg.role, storedContent, msg._model || null, msg._inputTokens || null, msg._outputTokens || null);
175
179
  }
176
180
 
181
+ updBubbleCount.run(messages.length, chat.composerId);
182
+
177
183
  const insStat = insertStat();
178
184
  insStat.run(
179
185
  chat.composerId, stats.total, stats.user, stats.assistant, stats.tool, stats.system,
@@ -205,7 +211,7 @@ function scanAll(onProgress) {
205
211
  chat.composerId, chat.source, chat.name || null, chat.mode || null,
206
212
  chat.folder || null, chat.createdAt || null, chat.lastUpdatedAt || null,
207
213
  chat.encrypted ? 1 : 0, chat.bubbleCount || 0,
208
- JSON.stringify({ _type: chat._type, _dbPath: chat._dbPath, _filePath: chat._filePath, _port: chat._port, _csrf: chat._csrf, _https: chat._https, _rootBlobId: chat._rootBlobId, _dataType: chat._dataType })
214
+ JSON.stringify({ _type: chat._type, _dbPath: chat._dbPath, _filePath: chat._filePath, _port: chat._port, _csrf: chat._csrf, _https: chat._https, _rootBlobId: chat._rootBlobId, _dataType: chat._dataType, _rawSource: chat._rawSource, _originator: chat._originator, _cliVersion: chat._cliVersion, _modelProvider: chat._modelProvider })
209
215
  );
210
216
  }
211
217
  });
@@ -256,15 +262,30 @@ function scanAll(onProgress) {
256
262
  // ============================================================
257
263
 
258
264
  function getCachedChats(opts = {}) {
259
- let sql = 'SELECT * FROM chats WHERE 1=1';
265
+ let sql = 'SELECT c.*, cs.models AS _models FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id WHERE 1=1';
260
266
  const params = [];
261
- if (opts.editor) { sql += ' AND source LIKE ?'; params.push(`%${opts.editor}%`); }
262
- if (opts.folder) { sql += ' AND folder LIKE ?'; params.push(`%${opts.folder}%`); }
263
- if (opts.named !== false) { sql += ' AND (name IS NOT NULL OR bubble_count > 0)'; }
264
- sql += ' ORDER BY last_updated_at DESC';
267
+ if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
268
+ if (opts.folder) { sql += ' AND c.folder LIKE ?'; params.push(`%${opts.folder}%`); }
269
+ if (opts.named !== false) { sql += ' AND (c.name IS NOT NULL OR c.bubble_count > 0)'; }
270
+ if (opts.dateFrom) { sql += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
271
+ if (opts.dateTo) { sql += ' AND COALESCE(c.last_updated_at, c.created_at) <= ?'; params.push(opts.dateTo); }
272
+ sql += ' ORDER BY c.last_updated_at DESC';
265
273
  if (opts.limit) { sql += ' LIMIT ?'; params.push(opts.limit); }
266
274
  if (opts.offset) { sql += ' OFFSET ?'; params.push(opts.offset); }
267
- return db.prepare(sql).all(params);
275
+ const rows = db.prepare(sql).all(params);
276
+ for (const r of rows) {
277
+ r.top_model = null;
278
+ try {
279
+ const models = JSON.parse(r._models || '[]');
280
+ if (models.length > 0) {
281
+ const freq = {};
282
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
283
+ r.top_model = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
284
+ }
285
+ } catch {}
286
+ delete r._models;
287
+ }
288
+ return rows;
268
289
  }
269
290
 
270
291
  function countCachedChats(opts = {}) {
@@ -273,14 +294,20 @@ function countCachedChats(opts = {}) {
273
294
  if (opts.editor) { sql += ' AND source LIKE ?'; params.push(`%${opts.editor}%`); }
274
295
  if (opts.folder) { sql += ' AND folder LIKE ?'; params.push(`%${opts.folder}%`); }
275
296
  if (opts.named !== false) { sql += ' AND (name IS NOT NULL OR bubble_count > 0)'; }
297
+ if (opts.dateFrom) { sql += ' AND COALESCE(last_updated_at, created_at) >= ?'; params.push(opts.dateFrom); }
298
+ if (opts.dateTo) { sql += ' AND COALESCE(last_updated_at, created_at) <= ?'; params.push(opts.dateTo); }
276
299
  return db.prepare(sql).get(params).cnt;
277
300
  }
278
301
 
279
302
  function getCachedOverview(opts = {}) {
280
- const editorFilter = opts.editor || null;
281
- const where = editorFilter ? ' WHERE source = ?' : '';
282
- const whereAnd = editorFilter ? ' AND source = ?' : '';
283
- const params = editorFilter ? [editorFilter] : [];
303
+ // Build conditions dynamically to support editor + date range filters
304
+ const conditions = [];
305
+ const params = [];
306
+ if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
307
+ if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
308
+ if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
309
+ const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
310
+ const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
284
311
 
285
312
  const totalChats = db.prepare(`SELECT COUNT(*) as cnt FROM chats${where}`).get(...params).cnt;
286
313
  // Editors list is always unfiltered so the breakdown remains visible
@@ -338,9 +365,12 @@ function getCachedOverview(opts = {}) {
338
365
  }
339
366
 
340
367
  function getCachedDailyActivity(opts = {}) {
341
- const editorFilter = opts.editor || null;
342
- const whereAnd = editorFilter ? ' AND source = ?' : '';
343
- const params = editorFilter ? [editorFilter] : [];
368
+ const conditions = [];
369
+ const params = [];
370
+ if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
371
+ if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
372
+ if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
373
+ const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
344
374
  const rows = db.prepare(`
345
375
  SELECT
346
376
  date(COALESCE(last_updated_at, created_at)/1000, 'unixepoch', 'localtime') as day,
@@ -369,6 +399,8 @@ function getCachedDeepAnalytics(opts = {}) {
369
399
  const params = [];
370
400
  if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
371
401
  if (opts.folder) { sql += ' AND c.folder = ?'; params.push(opts.folder); }
402
+ if (opts.dateFrom) { sql += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
403
+ if (opts.dateTo) { sql += ' AND COALESCE(c.last_updated_at, c.created_at) <= ?'; params.push(opts.dateTo); }
372
404
  sql += ' ORDER BY cs.analyzed_at DESC';
373
405
  if (opts.limit) { sql += ' LIMIT ?'; params.push(opts.limit); }
374
406
 
@@ -454,16 +486,22 @@ function getCachedChat(id) {
454
486
  };
455
487
  }
456
488
 
457
- function getCachedProjects() {
489
+ function getCachedProjects(opts = {}) {
490
+ // Build date filter
491
+ let dateFilter = '';
492
+ const dateParams = [];
493
+ if (opts.dateFrom) { dateFilter += ' AND COALESCE(last_updated_at, created_at) >= ?'; dateParams.push(opts.dateFrom); }
494
+ if (opts.dateTo) { dateFilter += ' AND COALESCE(last_updated_at, created_at) <= ?'; dateParams.push(opts.dateTo); }
495
+
458
496
  // All unique projects with their stats
459
497
  const projects = db.prepare(`
460
498
  SELECT folder, source, COUNT(*) as count,
461
499
  MIN(COALESCE(last_updated_at, created_at)) as first_seen,
462
500
  MAX(COALESCE(last_updated_at, created_at)) as last_seen
463
- FROM chats WHERE folder IS NOT NULL
501
+ FROM chats WHERE folder IS NOT NULL${dateFilter}
464
502
  GROUP BY folder, source
465
503
  ORDER BY folder, count DESC
466
- `).all();
504
+ `).all(...dateParams);
467
505
 
468
506
  // Group by folder
469
507
  const map = {};
@@ -478,12 +516,13 @@ function getCachedProjects() {
478
516
  // For each project, get models and tools from chat_stats
479
517
  const result = [];
480
518
  for (const [folder, proj] of Object.entries(map)) {
519
+ const statsDateFilter = dateFilter.replace(/COALESCE\(last_updated_at/g, 'COALESCE(c.last_updated_at').replace(/created_at\)/g, 'c.created_at)');
481
520
  const stats = db.prepare(`
482
521
  SELECT cs.models, cs.tool_calls, cs.total_messages, cs.total_input_tokens, cs.total_output_tokens,
483
522
  cs.total_user_chars, cs.total_assistant_chars, cs.total_cache_read, cs.total_cache_write
484
523
  FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
485
- WHERE c.folder = ?
486
- `).all(folder);
524
+ WHERE c.folder = ?${statsDateFilter}
525
+ `).all(folder, ...dateParams);
487
526
 
488
527
  const modelFreq = {};
489
528
  const toolFreq = {};
@@ -585,7 +624,7 @@ async function scanAllAsync(onProgress) {
585
624
  chat.composerId, chat.source, chat.name || null, chat.mode || null,
586
625
  chat.folder || null, chat.createdAt || null, chat.lastUpdatedAt || null,
587
626
  chat.encrypted ? 1 : 0, chat.bubbleCount || 0,
588
- JSON.stringify({ _type: chat._type, _dbPath: chat._dbPath, _filePath: chat._filePath, _port: chat._port, _csrf: chat._csrf, _https: chat._https, _rootBlobId: chat._rootBlobId, _dataType: chat._dataType })
627
+ JSON.stringify({ _type: chat._type, _dbPath: chat._dbPath, _filePath: chat._filePath, _port: chat._port, _csrf: chat._csrf, _https: chat._https, _rootBlobId: chat._rootBlobId, _dataType: chat._dataType, _rawSource: chat._rawSource, _originator: chat._originator, _cliVersion: chat._cliVersion, _modelProvider: chat._modelProvider })
589
628
  );
590
629
  }
591
630
  });
@@ -637,10 +676,14 @@ async function resetAndRescanAsync(onProgress) {
637
676
  }
638
677
 
639
678
  function getCachedDashboardStats(opts = {}) {
640
- const editorFilter = opts.editor || null;
641
- const where = editorFilter ? ' WHERE source = ?' : '';
642
- const whereAnd = editorFilter ? ' AND source = ?' : '';
643
- const params = editorFilter ? [editorFilter] : [];
679
+ // Build conditions dynamically to support editor + date range filters
680
+ const conditions = [];
681
+ const params = [];
682
+ if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
683
+ if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
684
+ if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
685
+ const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
686
+ const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
644
687
 
645
688
  // ── Hourly distribution (aggregate across all days) ──
646
689
  const hourlyRows = db.prepare(`
@@ -0,0 +1,453 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+
5
+ const name = 'codex';
6
+ const DEFAULT_CODEX_HOME = path.join(os.homedir(), '.codex');
7
+ const SESSION_SUBDIR = 'sessions';
8
+ const MAX_TOOL_RESULT_PREVIEW = 500;
9
+
10
+ function getChats() {
11
+ const sessionsDir = getSessionsDir();
12
+ if (!fs.existsSync(sessionsDir)) return [];
13
+
14
+ const chats = [];
15
+ for (const filePath of walkJsonlFiles(sessionsDir)) {
16
+ const chat = readChatMetadata(filePath);
17
+ if (chat) chats.push(chat);
18
+ }
19
+ return chats;
20
+ }
21
+
22
+ function getMessages(chat) {
23
+ const filePath = chat && chat._filePath;
24
+ if (!filePath || !fs.existsSync(filePath)) return [];
25
+ return parseSessionMessages(filePath);
26
+ }
27
+
28
+ function getSessionsDir() {
29
+ const codexHome = process.env.CODEX_HOME && process.env.CODEX_HOME.trim()
30
+ ? path.resolve(process.env.CODEX_HOME.trim())
31
+ : DEFAULT_CODEX_HOME;
32
+ return path.join(codexHome, SESSION_SUBDIR);
33
+ }
34
+
35
+ function walkJsonlFiles(dir) {
36
+ const results = [];
37
+ const stack = [dir];
38
+
39
+ while (stack.length > 0) {
40
+ const current = stack.pop();
41
+ let entries;
42
+ try {
43
+ entries = fs.readdirSync(current, { withFileTypes: true });
44
+ } catch {
45
+ continue;
46
+ }
47
+
48
+ for (const entry of entries) {
49
+ const fullPath = path.join(current, entry.name);
50
+ if (entry.isDirectory()) {
51
+ stack.push(fullPath);
52
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
53
+ results.push(fullPath);
54
+ }
55
+ }
56
+ }
57
+
58
+ return results.sort();
59
+ }
60
+
61
+ function readChatMetadata(filePath) {
62
+ const lines = readLines(filePath);
63
+ if (lines.length === 0) return null;
64
+
65
+ const first = safeParseJson(lines[0]);
66
+ if (!first || first.type !== 'session_meta' || !first.payload) return null;
67
+
68
+ let title = null;
69
+ for (let i = 1; i < lines.length; i++) {
70
+ const entry = safeParseJson(lines[i]);
71
+ if (!entry || entry.type !== 'response_item' || !entry.payload) continue;
72
+ const payload = entry.payload;
73
+ if (payload.type !== 'message' || payload.role !== 'user') continue;
74
+ const text = extractUserText(payload.content);
75
+ if (!text || isBootstrapMessage(text)) continue;
76
+ title = cleanPrompt(text);
77
+ break;
78
+ }
79
+
80
+ let stat;
81
+ try {
82
+ stat = fs.statSync(filePath);
83
+ } catch {
84
+ stat = null;
85
+ }
86
+
87
+ const payload = first.payload;
88
+ return {
89
+ source: 'codex',
90
+ composerId: payload.id || path.basename(filePath, '.jsonl'),
91
+ name: title,
92
+ createdAt: toTimestamp(payload.timestamp) || (stat ? stat.birthtimeMs : null),
93
+ lastUpdatedAt: stat ? stat.mtimeMs : null,
94
+ mode: 'codex',
95
+ folder: payload.cwd || null,
96
+ encrypted: false,
97
+ bubbleCount: 0,
98
+ _filePath: filePath,
99
+ _rawSource: payload.source || null,
100
+ _originator: payload.originator || null,
101
+ _cliVersion: payload.cli_version || null,
102
+ _modelProvider: payload.model_provider || null,
103
+ };
104
+ }
105
+
106
+ function parseSessionMessages(filePath) {
107
+ const lines = readLines(filePath);
108
+ const messages = [];
109
+
110
+ let currentModel = null;
111
+ let previousTotals = null;
112
+ let currentTurn = createTurnState();
113
+ let turnHasStarted = false;
114
+ const toolNamesByCallId = new Map();
115
+
116
+ function flushTurn() {
117
+ const hasAssistantContent = currentTurn.parts.length > 0;
118
+ const hasTokens = currentTurn.inputTokens > 0 || currentTurn.outputTokens > 0 || currentTurn.cacheRead > 0;
119
+ const hasTools = currentTurn.toolCalls.length > 0;
120
+ if (!hasAssistantContent && !hasTokens && !hasTools) {
121
+ currentTurn = createTurnState();
122
+ toolNamesByCallId.clear();
123
+ return;
124
+ }
125
+
126
+ messages.push(composeAssistantMessage(currentTurn));
127
+ currentTurn = createTurnState();
128
+ toolNamesByCallId.clear();
129
+ }
130
+
131
+ for (const line of lines) {
132
+ const entry = safeParseJson(line);
133
+ if (!entry) continue;
134
+
135
+ if (entry.type === 'turn_context') {
136
+ if (turnHasStarted) flushTurn();
137
+ turnHasStarted = true;
138
+ const model = extractModel(entry.payload);
139
+ if (model) {
140
+ currentModel = model;
141
+ currentTurn.model = model;
142
+ }
143
+ continue;
144
+ }
145
+
146
+ if (entry.type === 'response_item' && entry.payload) {
147
+ const payload = entry.payload;
148
+
149
+ if (payload.type === 'message') {
150
+ if (payload.role === 'user') {
151
+ const text = extractUserText(payload.content);
152
+ if (!text || isBootstrapMessage(text)) continue;
153
+ if (turnHasStarted) flushTurn();
154
+ messages.push({ role: 'user', content: text });
155
+ } else if (payload.role === 'assistant') {
156
+ const text = extractAssistantText(payload.content);
157
+ if (text) currentTurn.parts.push(text);
158
+ if (!currentTurn.model && currentModel) currentTurn.model = currentModel;
159
+ } else if (payload.role === 'system') {
160
+ const text = extractAssistantText(payload.content);
161
+ if (text) messages.push({ role: 'system', content: text });
162
+ }
163
+ continue;
164
+ }
165
+
166
+ if (payload.type === 'reasoning') {
167
+ const summary = extractReasoningSummary(payload);
168
+ if (summary) currentTurn.parts.push(summary);
169
+ continue;
170
+ }
171
+
172
+ if (isToolCallPayload(payload.type)) {
173
+ const toolCall = normalizeToolCall(payload);
174
+ currentTurn.parts.push(toolCall.line);
175
+ currentTurn.toolCalls.push({ name: toolCall.name, args: toolCall.args });
176
+ if (payload.call_id) toolNamesByCallId.set(payload.call_id, toolCall.name);
177
+ continue;
178
+ }
179
+
180
+ if (isToolOutputPayload(payload.type)) {
181
+ const lineText = normalizeToolResult(payload, toolNamesByCallId.get(payload.call_id));
182
+ if (lineText) currentTurn.parts.push(lineText);
183
+ continue;
184
+ }
185
+
186
+ continue;
187
+ }
188
+
189
+ if (entry.type === 'event_msg' && entry.payload && entry.payload.type === 'token_count') {
190
+ const tokenInfo = entry.payload.info || {};
191
+ const lastUsage = normalizeRawUsage(tokenInfo.last_token_usage);
192
+ const totalUsage = normalizeRawUsage(tokenInfo.total_token_usage);
193
+
194
+ let rawUsage = lastUsage;
195
+ if (!rawUsage && totalUsage) rawUsage = subtractRawUsage(totalUsage, previousTotals);
196
+ if (totalUsage) previousTotals = totalUsage;
197
+ if (!rawUsage) continue;
198
+
199
+ const delta = convertToDelta(rawUsage);
200
+ if (delta.inputTokens === 0 && delta.outputTokens === 0 && delta.cacheRead === 0) continue;
201
+
202
+ currentTurn.inputTokens += delta.inputTokens;
203
+ currentTurn.outputTokens += delta.outputTokens;
204
+ currentTurn.cacheRead += delta.cacheRead;
205
+
206
+ const model = extractModel(tokenInfo) || extractModel(entry.payload);
207
+ if (model) {
208
+ currentModel = model;
209
+ currentTurn.model = model;
210
+ } else if (!currentTurn.model && currentModel) {
211
+ currentTurn.model = currentModel;
212
+ }
213
+ }
214
+ }
215
+
216
+ if (turnHasStarted) flushTurn();
217
+
218
+ return messages;
219
+ }
220
+
221
+ function createTurnState() {
222
+ return {
223
+ parts: [],
224
+ toolCalls: [],
225
+ inputTokens: 0,
226
+ outputTokens: 0,
227
+ cacheRead: 0,
228
+ model: null,
229
+ };
230
+ }
231
+
232
+ function composeAssistantMessage(turn) {
233
+ const content = turn.parts.join('\n') || '[assistant activity]';
234
+ return {
235
+ role: 'assistant',
236
+ content,
237
+ _model: turn.model || undefined,
238
+ _inputTokens: turn.inputTokens || undefined,
239
+ _outputTokens: turn.outputTokens || undefined,
240
+ _cacheRead: turn.cacheRead || undefined,
241
+ _toolCalls: turn.toolCalls.length > 0 ? turn.toolCalls : undefined,
242
+ };
243
+ }
244
+
245
+ function extractUserText(content) {
246
+ if (!Array.isArray(content)) return '';
247
+ return content
248
+ .filter((item) => item && item.type === 'input_text' && typeof item.text === 'string')
249
+ .map((item) => item.text.trim())
250
+ .filter(Boolean)
251
+ .join('\n')
252
+ .trim();
253
+ }
254
+
255
+ function extractAssistantText(content) {
256
+ if (!Array.isArray(content)) return '';
257
+ return content
258
+ .filter((item) => item && item.type === 'output_text' && typeof item.text === 'string')
259
+ .map((item) => item.text.trim())
260
+ .filter(Boolean)
261
+ .join('\n')
262
+ .trim();
263
+ }
264
+
265
+ function extractReasoningSummary(payload) {
266
+ if (!Array.isArray(payload.summary)) return '';
267
+ return payload.summary
268
+ .filter((item) => item && typeof item.text === 'string' && item.text.trim())
269
+ .map((item) => `[thinking] ${item.text.trim()}`)
270
+ .join('\n')
271
+ .trim();
272
+ }
273
+
274
+ function isBootstrapMessage(text) {
275
+ const trimmed = text.trim();
276
+ return trimmed.startsWith('<user_instructions>') || trimmed.startsWith('<environment_context>');
277
+ }
278
+
279
+ function cleanPrompt(text) {
280
+ return text.replace(/\s+/g, ' ').trim().substring(0, 120) || null;
281
+ }
282
+
283
+ function isToolCallPayload(type) {
284
+ return type === 'function_call' || type === 'custom_tool_call' || type === 'web_search_call';
285
+ }
286
+
287
+ function isToolOutputPayload(type) {
288
+ return type === 'function_call_output' || type === 'custom_tool_call_output';
289
+ }
290
+
291
+ function normalizeToolCall(payload) {
292
+ const name = payload.name || (payload.type === 'web_search_call' ? 'web_search' : 'tool');
293
+ const args = parseToolArgs(payload);
294
+ const argKeys = Object.keys(args).join(', ');
295
+ return {
296
+ name,
297
+ args,
298
+ line: `[tool-call: ${name}(${argKeys})]`,
299
+ };
300
+ }
301
+
302
+ function normalizeToolResult(payload, fallbackName) {
303
+ const name = payload.name || fallbackName || 'tool';
304
+ const preview = previewToolOutput(payload.output);
305
+ return preview ? `[tool-result: ${name}] ${preview}` : `[tool-result: ${name}]`;
306
+ }
307
+
308
+ function parseToolArgs(payload) {
309
+ if (payload.type === 'function_call') {
310
+ return parseJsonRecord(payload.arguments);
311
+ }
312
+ if (payload.type === 'custom_tool_call') {
313
+ return { input: truncateSingleLine(String(payload.input || ''), 300) };
314
+ }
315
+ if (payload.type === 'web_search_call') {
316
+ return parseJsonRecord(payload.arguments || payload.input || payload.query);
317
+ }
318
+ return {};
319
+ }
320
+
321
+ function previewToolOutput(output) {
322
+ if (output == null) return '';
323
+ let value = output;
324
+ if (typeof value === 'string') {
325
+ const parsed = safeParseJson(value);
326
+ if (parsed && typeof parsed === 'object' && typeof parsed.output === 'string') {
327
+ value = parsed.output;
328
+ } else {
329
+ value = value;
330
+ }
331
+ } else if (typeof value === 'object') {
332
+ value = JSON.stringify(value);
333
+ } else {
334
+ value = String(value);
335
+ }
336
+
337
+ const trimmed = String(value).trim();
338
+ if (!trimmed) return '';
339
+ return truncateSingleLine(trimmed, MAX_TOOL_RESULT_PREVIEW);
340
+ }
341
+
342
+ function truncateSingleLine(text, maxLen) {
343
+ const oneLine = String(text).replace(/\s+/g, ' ').trim();
344
+ return oneLine.length > maxLen ? oneLine.substring(0, maxLen) + '…' : oneLine;
345
+ }
346
+
347
+ function parseJsonRecord(value) {
348
+ if (value == null) return {};
349
+ if (typeof value === 'object' && !Array.isArray(value)) return value;
350
+ if (typeof value !== 'string') return {};
351
+ const parsed = safeParseJson(value);
352
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
353
+ }
354
+
355
+ function normalizeRawUsage(value) {
356
+ if (!value || typeof value !== 'object') return null;
357
+ const input = ensureNumber(value.input_tokens);
358
+ const cached = ensureNumber(value.cached_input_tokens != null ? value.cached_input_tokens : value.cache_read_input_tokens);
359
+ const output = ensureNumber(value.output_tokens);
360
+ const total = ensureNumber(value.total_tokens);
361
+ return {
362
+ input_tokens: input,
363
+ cached_input_tokens: cached,
364
+ output_tokens: output,
365
+ total_tokens: total > 0 ? total : input + output,
366
+ };
367
+ }
368
+
369
+ function subtractRawUsage(current, previous) {
370
+ return {
371
+ input_tokens: Math.max(current.input_tokens - (previous ? previous.input_tokens : 0), 0),
372
+ cached_input_tokens: Math.max(current.cached_input_tokens - (previous ? previous.cached_input_tokens : 0), 0),
373
+ output_tokens: Math.max(current.output_tokens - (previous ? previous.output_tokens : 0), 0),
374
+ total_tokens: Math.max(current.total_tokens - (previous ? previous.total_tokens : 0), 0),
375
+ };
376
+ }
377
+
378
+ function convertToDelta(raw) {
379
+ return {
380
+ inputTokens: raw.input_tokens,
381
+ cacheRead: Math.min(raw.cached_input_tokens, raw.input_tokens),
382
+ outputTokens: raw.output_tokens,
383
+ totalTokens: raw.total_tokens > 0 ? raw.total_tokens : raw.input_tokens + raw.output_tokens,
384
+ };
385
+ }
386
+
387
+ function extractModel(value) {
388
+ if (!value || typeof value !== 'object') return null;
389
+
390
+ const direct = asNonEmptyString(value.model)
391
+ || asNonEmptyString(value.model_name);
392
+ if (direct) return direct;
393
+
394
+ if (value.info && typeof value.info === 'object') {
395
+ const infoModel = extractModel(value.info);
396
+ if (infoModel) return infoModel;
397
+ }
398
+
399
+ if (value.metadata && typeof value.metadata === 'object') {
400
+ const metadataModel = extractModel(value.metadata);
401
+ if (metadataModel) return metadataModel;
402
+ }
403
+
404
+ return null;
405
+ }
406
+
407
+ function asNonEmptyString(value) {
408
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
409
+ }
410
+
411
+ function ensureNumber(value) {
412
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
413
+ }
414
+
415
+ function toTimestamp(value) {
416
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
417
+ if (typeof value !== 'string' || !value.trim()) return null;
418
+ const parsed = Date.parse(value);
419
+ return Number.isFinite(parsed) ? parsed : null;
420
+ }
421
+
422
+ function readLines(filePath) {
423
+ try {
424
+ return fs.readFileSync(filePath, 'utf-8').split(/\r?\n/).filter(Boolean);
425
+ } catch {
426
+ return [];
427
+ }
428
+ }
429
+
430
+ function safeParseJson(value) {
431
+ try {
432
+ return JSON.parse(value);
433
+ } catch {
434
+ return null;
435
+ }
436
+ }
437
+
438
+ module.exports = {
439
+ name,
440
+ getChats,
441
+ getMessages,
442
+ _test: {
443
+ getSessionsDir,
444
+ readChatMetadata,
445
+ parseSessionMessages,
446
+ normalizeRawUsage,
447
+ subtractRawUsage,
448
+ convertToDelta,
449
+ extractModel,
450
+ isBootstrapMessage,
451
+ previewToolOutput,
452
+ },
453
+ };