agentlytics 0.2.7 → 0.2.9

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
@@ -14,6 +14,7 @@
14
14
  <a href="#supported-editors"><img src="https://img.shields.io/badge/editors-16-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%A520.19%20%7C%20%E2%89%A522.12-brightgreen" alt="node"></a>
17
+ <a href="https://deno.land"><img src="https://img.shields.io/badge/deno-%E2%89%A52.0-000?logo=deno" alt="deno"></a>
17
18
  </p>
18
19
 
19
20
  <p align="center">
@@ -39,10 +40,61 @@ You switch between Cursor, Windsurf, Claude Code, VS Code Copilot, and more —
39
40
 
40
41
  ```bash
41
42
  npx agentlytics
43
+ # or
44
+ pnpm dlx agentlytics
45
+ # or
46
+ yarn dlx agentlytics
47
+ # or
48
+ bunx agentlytics
42
49
  ```
43
50
 
44
51
  Opens at **http://localhost:4637**. Requires Node.js ≥ 20.19 or ≥ 22.12, macOS. No data ever leaves your machine.
45
52
 
53
+ ### Deno (Sandboxed)
54
+
55
+ Run a lightweight, zero-dependency analytics scan with Deno's permission sandbox — directly from a URL, no install needed:
56
+
57
+ ```bash
58
+ deno run --allow-read --allow-env https://raw.githubusercontent.com/f/agentlytics/master/mod.ts
59
+ ```
60
+
61
+ Only `--allow-read` and `--allow-env` are required. No network access, no file writes, no code execution — just reads your local editor data and prints a summary.
62
+
63
+ ```
64
+ (● ●) [● ●] Agentlytics — Deno Sandboxed Edition
65
+ {● ●} <● ●> Lightweight CLI analytics for AI coding agents
66
+
67
+ ✓ Claude Code 8 sessions
68
+ ✓ VS Code 23 sessions
69
+ ✓ VS Code Insiders 66 sessions
70
+ ● Cursor detected
71
+ ✓ Codex CLI 3 sessions
72
+ ...
73
+
74
+ Summary
75
+ Sessions 109
76
+ Messages 459
77
+ Projects 18
78
+ Editors 7 of 15 checked
79
+ Date range 2025-04-02 → 2026-03-09
80
+ ```
81
+
82
+ Add `--json` for machine-readable output:
83
+
84
+ ```bash
85
+ deno run --allow-read --allow-env mod.ts --json
86
+ ```
87
+
88
+ If you've cloned the repo, you can also use Deno tasks for the full dashboard:
89
+
90
+ ```bash
91
+ deno task start # Full dashboard (all permissions)
92
+ deno task scan # Lightweight CLI scan
93
+ deno task scan:json # JSON output
94
+ ```
95
+
96
+ ### Node.js
97
+
46
98
  ```
47
99
  $ npx agentlytics
48
100
 
@@ -68,6 +120,7 @@ To only build the cache without starting the server:
68
120
 
69
121
  ```bash
70
122
  npx agentlytics --collect
123
+ # or: pnpm dlx agentlytics --collect
71
124
  ```
72
125
 
73
126
  ## Features
@@ -112,6 +165,7 @@ Relay enables multi-user context sharing across a team. One person starts a rela
112
165
 
113
166
  ```bash
114
167
  npx agentlytics --relay
168
+ # or: pnpm dlx agentlytics --relay
115
169
  ```
116
170
 
117
171
  Optionally protect with a password:
@@ -138,6 +192,7 @@ This starts a relay server on port `4638` and prints the join command and MCP en
138
192
  ```bash
139
193
  cd /path/to/your-project
140
194
  npx agentlytics --join <host:port>
195
+ # or: pnpm dlx agentlytics --join <host:port>
141
196
  ```
142
197
 
143
198
  If the relay is password-protected:
@@ -186,7 +241,11 @@ Editor files/APIs → editors/*.js → cache.js (SQLite) → server.js (REST)
186
241
  Relay: join clients → POST /relay/sync → relay.db (SQLite) → MCP server → AI clients
187
242
  ```
188
243
 
189
- All data is normalized into a local SQLite cache at `~/.agentlytics/cache.db`. The Express server exposes read-only REST endpoints consumed by the React frontend. Relay data is stored separately in `~/.agentlytics/relay.db`.
244
+ ```
245
+ Deno: Editor files → mod.ts (zero deps) → stdout (CLI/JSON)
246
+ ```
247
+
248
+ All data is normalized into a local SQLite cache at `~/.agentlytics/cache.db`. The Express server exposes read-only REST endpoints consumed by the React frontend. Relay data is stored separately in `~/.agentlytics/relay.db`. The Deno sandboxed edition (`mod.ts`) bypasses SQLite entirely and reads editor files directly for a lightweight, permission-minimal CLI report.
190
249
 
191
250
  ## API
192
251
 
package/cache.js CHANGED
@@ -7,7 +7,7 @@ const { calculateCost, getModelPricing, normalizeModelName } = require('./pricin
7
7
 
8
8
  const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
9
9
  const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
10
- const SCHEMA_VERSION = 5; // bump this when schema changes to auto-revalidate
10
+ const SCHEMA_VERSION = 6; // bump this when schema changes to auto-revalidate
11
11
 
12
12
  /**
13
13
  * Normalize a folder path for consistent storage/lookup.
@@ -128,6 +128,8 @@ function initDb() {
128
128
  model TEXT,
129
129
  input_tokens INTEGER,
130
130
  output_tokens INTEGER,
131
+ cache_read INTEGER,
132
+ cache_write INTEGER,
131
133
  FOREIGN KEY (chat_id) REFERENCES chats(id)
132
134
  );
133
135
 
@@ -163,7 +165,7 @@ function initDb() {
163
165
  try {
164
166
  const row = db.prepare("SELECT value FROM meta WHERE key = 'folder_norm_v'").get();
165
167
  if (row) normV = parseInt(row.value) || 0;
166
- } catch {}
168
+ } catch { }
167
169
  if (normV < 2) {
168
170
  const chatRows = db.prepare('SELECT id, folder FROM chats WHERE folder IS NOT NULL').all();
169
171
  const updChat = db.prepare('UPDATE chats SET folder = ? WHERE id = ?');
@@ -197,8 +199,8 @@ const insertStat = () => db.prepare(`
197
199
  `);
198
200
 
199
201
  const insertMsg = () => db.prepare(`
200
- INSERT INTO messages (chat_id, seq, role, content, model, input_tokens, output_tokens)
201
- VALUES (?, ?, ?, ?, ?, ?, ?)
202
+ INSERT INTO messages (chat_id, seq, role, content, model, input_tokens, output_tokens, cache_read, cache_write)
203
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
202
204
  `);
203
205
  const updateChatBubbleCount = () => db.prepare(`
204
206
  UPDATE chats SET bubble_count = ? WHERE id = ?
@@ -244,7 +246,7 @@ function analyzeAndStore(chat) {
244
246
  stats.toolCalls.push(tc.name);
245
247
  try {
246
248
  insTc.run(chat.composerId, tc.name, JSON.stringify(tc.args || {}), chat.source, chat.folder || null, chatTs);
247
- } catch {}
249
+ } catch { }
248
250
  }
249
251
  } else {
250
252
  const toolMatches = text.match(/\[tool-call: ([^\]]+)\]/g);
@@ -269,7 +271,7 @@ function analyzeAndStore(chat) {
269
271
 
270
272
  // Store message (truncate very long content for storage)
271
273
  const storedContent = text.length > 50000 ? text.substring(0, 50000) : text;
272
- ins.run(chat.composerId, seq++, msg.role, storedContent, msg._model || null, msg._inputTokens || null, msg._outputTokens || null);
274
+ ins.run(chat.composerId, seq++, msg.role, storedContent, msg._model || null, msg._inputTokens || null, msg._outputTokens || null, msg._cacheRead || null, msg._cacheWrite || null);
273
275
  }
274
276
 
275
277
  updBubbleCount.run(messages.length, chat.composerId);
@@ -398,7 +400,7 @@ function getCachedChats(opts = {}) {
398
400
  for (const m of models) freq[m] = (freq[m] || 0) + 1;
399
401
  r.top_model = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
400
402
  }
401
- } catch {}
403
+ } catch { }
402
404
  // Per-session cost estimate
403
405
  let inTok = r._inTok || 0, outTok = r._outTok || 0;
404
406
  if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
@@ -558,11 +560,11 @@ function getCachedDeepAnalytics(opts = {}) {
558
560
  try {
559
561
  const tools = JSON.parse(r.tool_calls);
560
562
  for (const t of tools) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; }
561
- } catch {}
563
+ } catch { }
562
564
  try {
563
565
  const models = JSON.parse(r.models);
564
566
  for (const m of models) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; }
565
- } catch {}
567
+ } catch { }
566
568
  }
567
569
 
568
570
  // Estimate tokens from chars when no token data available
@@ -589,7 +591,7 @@ function getCachedChat(id) {
589
591
  if (!chat) return null;
590
592
 
591
593
  const stats = db.prepare('SELECT * FROM chat_stats WHERE chat_id = ?').get(chat.id);
592
- let messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
594
+ let messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens, cache_read, cache_write FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
593
595
 
594
596
  // If no cached messages, try fetching live from the editor
595
597
  if (messages.length === 0 && !chat.encrypted) {
@@ -604,10 +606,10 @@ function getCachedChat(id) {
604
606
  const liveMessages = getMessages(reconstructed);
605
607
  if (liveMessages && liveMessages.length > 0) {
606
608
  // Store for next time
607
- try { analyzeAndStore(reconstructed); } catch {}
608
- messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
609
+ try { analyzeAndStore(reconstructed); } catch { }
610
+ messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens, cache_read, cache_write FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
609
611
  }
610
- } catch {}
612
+ } catch { }
611
613
  }
612
614
 
613
615
  let parsedStats = null;
@@ -703,8 +705,8 @@ function getCachedProjects(opts = {}) {
703
705
  totalAssistantChars += s.total_assistant_chars;
704
706
  totalCacheRead += s.total_cache_read;
705
707
  totalCacheWrite += s.total_cache_write;
706
- try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
707
- try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch {}
708
+ try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch { }
709
+ try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch { }
708
710
  }
709
711
 
710
712
  // Estimate tokens from chars when no token data available
@@ -985,7 +987,7 @@ function getCachedDashboardStats(opts = {}) {
985
987
  `).all(...params);
986
988
  const modelFreq = {};
987
989
  for (const r of modelRows) {
988
- try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
990
+ try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch { }
989
991
  }
990
992
  const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
991
993
 
@@ -996,7 +998,7 @@ function getCachedDashboardStats(opts = {}) {
996
998
  const toolFreq = {};
997
999
  let totalToolCalls = 0;
998
1000
  for (const r of toolRows) {
999
- try { for (const t of JSON.parse(r.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch {}
1001
+ try { for (const t of JSON.parse(r.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch { }
1000
1002
  }
1001
1003
  const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
1002
1004
 
@@ -1038,20 +1040,24 @@ function getCachedDashboardStats(opts = {}) {
1038
1040
  // ============================================================
1039
1041
 
1040
1042
  function estimateCosts(whereClause = '', params = []) {
1041
- // Per-model token usage from messages table
1043
+ // Per-model token usage from messages table (including cache tokens)
1042
1044
  const modelTokens = db.prepare(`
1043
- SELECT m.model, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
1045
+ SELECT m.model,
1046
+ SUM(m.input_tokens) as input, SUM(m.output_tokens) as output,
1047
+ SUM(m.cache_read) as cacheRead, SUM(m.cache_write) as cacheWrite
1044
1048
  FROM messages m JOIN chats c ON m.chat_id = c.id
1045
- WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
1049
+ WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read > 0 OR m.cache_write > 0)${whereClause}
1046
1050
  GROUP BY m.model
1047
1051
  `).all(...params);
1048
1052
 
1049
1053
  // Orphaned tokens: messages with token data but NULL model.
1050
1054
  // Attribute these to the session's dominant model from chat_stats.
1051
1055
  const orphanRows = db.prepare(`
1052
- SELECT m.chat_id, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
1056
+ SELECT m.chat_id,
1057
+ SUM(m.input_tokens) as input, SUM(m.output_tokens) as output,
1058
+ SUM(m.cache_read) as cacheRead, SUM(m.cache_write) as cacheWrite
1053
1059
  FROM messages m JOIN chats c ON m.chat_id = c.id
1054
- WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
1060
+ WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read > 0 OR m.cache_write > 0)${whereClause}
1055
1061
  GROUP BY m.chat_id
1056
1062
  `).all(...params);
1057
1063
 
@@ -1065,30 +1071,11 @@ function estimateCosts(whereClause = '', params = []) {
1065
1071
  const freq = {};
1066
1072
  for (const m of models) freq[m] = (freq[m] || 0) + 1;
1067
1073
  const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1068
- if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1074
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1069
1075
  orphanByModel[dominant].input += r.input || 0;
1070
1076
  orphanByModel[dominant].output += r.output || 0;
1071
- }
1072
-
1073
- // Cache tokens per session with dominant model
1074
- const cacheRows = db.prepare(`
1075
- SELECT cs.total_cache_read, cs.total_cache_write, cs.models
1076
- FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
1077
- WHERE (cs.total_cache_read > 0 OR cs.total_cache_write > 0)${whereClause}
1078
- `).all(...params);
1079
-
1080
- // Aggregate cache tokens by dominant model
1081
- const cacheByModel = {};
1082
- for (const r of cacheRows) {
1083
- let models;
1084
- try { models = JSON.parse(r.models || '[]'); } catch { continue; }
1085
- if (models.length === 0) continue;
1086
- const freq = {};
1087
- for (const m of models) freq[m] = (freq[m] || 0) + 1;
1088
- const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1089
- if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
1090
- cacheByModel[dominant].cacheRead += r.total_cache_read;
1091
- cacheByModel[dominant].cacheWrite += r.total_cache_write;
1077
+ orphanByModel[dominant].cacheRead += r.cacheRead || 0;
1078
+ orphanByModel[dominant].cacheWrite += r.cacheWrite || 0;
1092
1079
  }
1093
1080
 
1094
1081
  // Char-based estimation: sessions with models + chars but zero tokens.
@@ -1108,7 +1095,7 @@ function estimateCosts(whereClause = '', params = []) {
1108
1095
  const freq = {};
1109
1096
  for (const m of models) freq[m] = (freq[m] || 0) + 1;
1110
1097
  const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1111
- if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1098
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1112
1099
  orphanByModel[dominant].input += Math.round((r.userChars || 0) / CHARS_PER_TOKEN);
1113
1100
  orphanByModel[dominant].output += Math.round((r.asstChars || 0) / CHARS_PER_TOKEN);
1114
1101
  }
@@ -1148,64 +1135,41 @@ function estimateCosts(whereClause = '', params = []) {
1148
1135
  ? Object.entries(sf).sort((a, b) => b[1] - a[1])[0]?.[0]
1149
1136
  : globalDominant;
1150
1137
  if (!dominant) continue;
1151
- if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1138
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1152
1139
  orphanByModel[dominant].input += r.input || 0;
1153
1140
  orphanByModel[dominant].output += r.output || 0;
1154
- // Also merge cache data
1155
- if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
1156
- cacheByModel[dominant].cacheRead += r.cacheRead || 0;
1157
- cacheByModel[dominant].cacheWrite += r.cacheWrite || 0;
1141
+ orphanByModel[dominant].cacheRead += r.cacheRead || 0;
1142
+ orphanByModel[dominant].cacheWrite += r.cacheWrite || 0;
1158
1143
  }
1159
1144
  }
1160
1145
 
1161
1146
  // Merge modelTokens + orphanByModel into a unified map, normalizing keys
1162
1147
  const tokenMap = {};
1163
- const addTokens = (rawModel, input, output) => {
1148
+ const addTokens = (rawModel, input, output, cacheRead, cacheWrite) => {
1164
1149
  const key = normalizeModelName(rawModel) || rawModel;
1165
- if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0 };
1150
+ if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1166
1151
  tokenMap[key].input += input || 0;
1167
1152
  tokenMap[key].output += output || 0;
1153
+ tokenMap[key].cacheRead += cacheRead || 0;
1154
+ tokenMap[key].cacheWrite += cacheWrite || 0;
1168
1155
  };
1169
- for (const row of modelTokens) addTokens(row.model, row.input, row.output);
1170
- for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output);
1171
-
1172
- // Normalize cacheByModel keys
1173
- const normCache = {};
1174
- for (const [model, cache] of Object.entries(cacheByModel)) {
1175
- const key = normalizeModelName(model) || model;
1176
- if (!normCache[key]) normCache[key] = { cacheRead: 0, cacheWrite: 0 };
1177
- normCache[key].cacheRead += cache.cacheRead;
1178
- normCache[key].cacheWrite += cache.cacheWrite;
1179
- }
1156
+ for (const row of modelTokens) addTokens(row.model, row.input, row.output, row.cacheRead, row.cacheWrite);
1157
+ for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
1180
1158
 
1181
1159
  let totalCost = 0;
1182
- let knownCost = 0;
1183
1160
  let unknownModels = [];
1184
1161
  const byModel = [];
1185
1162
 
1186
1163
  for (const [model, tok] of Object.entries(tokenMap)) {
1187
- const cache = normCache[model] || { cacheRead: 0, cacheWrite: 0 };
1188
- const cost = calculateCost(model, tok.input, tok.output, cache.cacheRead, cache.cacheWrite);
1164
+ const cost = calculateCost(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
1189
1165
  if (cost !== null) {
1190
- knownCost += cost;
1191
1166
  totalCost += cost;
1192
- byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
1167
+ byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: tok.cacheRead, cacheWrite: tok.cacheWrite, cost });
1193
1168
  } else {
1194
1169
  unknownModels.push(model);
1195
1170
  }
1196
1171
  }
1197
1172
 
1198
- // Handle cache tokens for models that had cache but no message-level tokens
1199
- for (const [model, cache] of Object.entries(normCache)) {
1200
- if (!tokenMap[model]) {
1201
- const cost = calculateCost(model, 0, 0, cache.cacheRead, cache.cacheWrite);
1202
- if (cost !== null) {
1203
- totalCost += cost;
1204
- byModel.push({ model, inputTokens: 0, outputTokens: 0, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
1205
- }
1206
- }
1207
- }
1208
-
1209
1173
  byModel.sort((a, b) => b.cost - a.cost);
1210
1174
  unknownModels = [...new Set(unknownModels)];
1211
1175
 
@@ -1233,116 +1197,77 @@ function getCostAnalytics(opts = {}) {
1233
1197
  if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
1234
1198
  if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
1235
1199
  if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
1200
+ if (opts.folder) { conditions.push('c.folder = ?'); params.push(opts.folder); }
1236
1201
  const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
1237
1202
 
1238
1203
  // Overall cost breakdown by model
1239
1204
  const overall = getCostBreakdown(opts);
1240
1205
 
1241
- // Cost by editor: get costs per source
1242
- const editorRows = db.prepare(`
1243
- SELECT DISTINCT c.source FROM chats c WHERE c.source IS NOT NULL${whereAnd}
1206
+ // Per-chat cost map (single pass reused for all breakdowns)
1207
+ const sessionRows = db.prepare(`
1208
+ SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
1209
+ cs.total_messages AS msgs,
1210
+ substr(date(COALESCE(c.last_updated_at, c.created_at)/1000, 'unixepoch'), 1, 7) as month
1211
+ FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1212
+ WHERE 1=1${whereAnd}
1244
1213
  `).all(...params);
1245
- const byEditor = [];
1246
- for (const { source } of editorRows) {
1247
- const editorOpts = { ...opts, editor: source };
1248
- const ec = getCostBreakdown(editorOpts);
1249
- if (ec.totalCost > 0) {
1250
- byEditor.push({ editor: source, cost: ec.totalCost, models: ec.byModel.length });
1251
- }
1214
+
1215
+ const chatCostCache = new Map();
1216
+ for (const r of sessionRows) {
1217
+ chatCostCache.set(r.id, getCostBreakdown({ ...opts, chatId: r.id }));
1252
1218
  }
1253
- byEditor.sort((a, b) => b.cost - a.cost);
1254
1219
 
1255
- // Cost by project (top 20)
1256
- const projectRows = db.prepare(`
1257
- SELECT c.folder, COUNT(*) as sessions FROM chats c
1258
- WHERE c.folder IS NOT NULL${whereAnd}
1259
- GROUP BY c.folder ORDER BY sessions DESC LIMIT 30
1260
- `).all(...params);
1261
- const byProject = [];
1262
- for (const { folder } of projectRows) {
1263
- const pc = getCostBreakdown({ ...opts, folder });
1264
- if (pc.totalCost > 0) {
1265
- byProject.push({ folder, name: folder.split('/').pop(), cost: pc.totalCost });
1266
- }
1220
+ // Derive byEditor from cached per-chat costs
1221
+ const editorAgg = {};
1222
+ for (const r of sessionRows) {
1223
+ const sc = chatCostCache.get(r.id);
1224
+ if (!sc || sc.totalCost <= 0) continue;
1225
+ if (!editorAgg[r.source]) editorAgg[r.source] = { cost: 0, models: new Set() };
1226
+ editorAgg[r.source].cost += sc.totalCost;
1227
+ for (const m of sc.byModel) editorAgg[r.source].models.add(m.model);
1267
1228
  }
1268
- byProject.sort((a, b) => b.cost - a.cost);
1229
+ const byEditor = Object.entries(editorAgg)
1230
+ .map(([editor, d]) => ({ editor, cost: d.cost, models: d.models.size }))
1231
+ .sort((a, b) => b.cost - a.cost);
1269
1232
 
1270
- // Monthly trend
1271
- const monthRows = db.prepare(`
1272
- SELECT
1273
- substr(date(COALESCE(c.last_updated_at, c.created_at)/1000, 'unixepoch'), 1, 7) as month,
1274
- c.id, c.source,
1275
- cs.models AS _models,
1276
- cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
1277
- cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
1278
- cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars
1279
- FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1280
- WHERE (c.last_updated_at IS NOT NULL OR c.created_at IS NOT NULL)${whereAnd}
1281
- ORDER BY month
1282
- `).all(...params);
1233
+ // Derive byProject from cached per-chat costs
1234
+ const projectAgg = {};
1235
+ for (const r of sessionRows) {
1236
+ if (!r.folder) continue;
1237
+ const sc = chatCostCache.get(r.id);
1238
+ if (!sc || sc.totalCost <= 0) continue;
1239
+ if (!projectAgg[r.folder]) projectAgg[r.folder] = 0;
1240
+ projectAgg[r.folder] += sc.totalCost;
1241
+ }
1242
+ const byProject = Object.entries(projectAgg)
1243
+ .map(([folder, cost]) => ({ folder, name: folder.split('/').pop(), cost }))
1244
+ .sort((a, b) => b.cost - a.cost)
1245
+ .slice(0, 20);
1246
+
1247
+ // Monthly trend from cached per-chat costs
1283
1248
  const monthCosts = {};
1284
- for (const r of monthRows) {
1249
+ for (const r of sessionRows) {
1285
1250
  if (!r.month) continue;
1286
- let topModel = null;
1287
- try {
1288
- const models = JSON.parse(r._models || '[]');
1289
- if (models.length > 0) {
1290
- const freq = {};
1291
- for (const m of models) freq[m] = (freq[m] || 0) + 1;
1292
- topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1293
- }
1294
- } catch {}
1295
- if (!topModel) continue;
1296
- let inTok = r.inTok || 0, outTok = r.outTok || 0;
1297
- if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
1298
- inTok = Math.round((r.uChars || 0) / 4);
1299
- outTok = Math.round((r.aChars || 0) / 4);
1300
- }
1301
- const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
1251
+ const sc = chatCostCache.get(r.id);
1302
1252
  if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
1303
- monthCosts[r.month].cost += cost;
1253
+ monthCosts[r.month].cost += sc.totalCost;
1304
1254
  monthCosts[r.month].sessions++;
1305
1255
  }
1306
1256
  const monthly = Object.entries(monthCosts).sort((a, b) => a[0].localeCompare(b[0]))
1307
1257
  .map(([month, d]) => ({ month, cost: Math.round(d.cost * 100) / 100, sessions: d.sessions }));
1308
1258
 
1309
- // Top expensive sessions
1310
- const sessionRows = db.prepare(`
1311
- SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
1312
- cs.models AS _models,
1313
- cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
1314
- cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
1315
- cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars,
1316
- cs.total_messages AS msgs
1317
- FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1318
- WHERE 1=1${whereAnd}
1319
- `).all(...params);
1259
+ // Top expensive sessions from cached per-chat costs
1320
1260
  const sessionCosts = [];
1321
1261
  for (const r of sessionRows) {
1322
- let topModel = null;
1323
- try {
1324
- const models = JSON.parse(r._models || '[]');
1325
- if (models.length > 0) {
1326
- const freq = {};
1327
- for (const m of models) freq[m] = (freq[m] || 0) + 1;
1328
- topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1329
- }
1330
- } catch {}
1331
- if (!topModel) continue;
1332
- let inTok = r.inTok || 0, outTok = r.outTok || 0;
1333
- if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
1334
- inTok = Math.round((r.uChars || 0) / 4);
1335
- outTok = Math.round((r.aChars || 0) / 4);
1336
- }
1337
- const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
1338
- if (cost > 0) {
1339
- sessionCosts.push({
1340
- id: r.id, source: r.source, name: r.name, folder: r.folder,
1341
- model: normalizeModelName(topModel) || topModel,
1342
- cost, messages: r.msgs || 0,
1343
- lastUpdatedAt: r.last_updated_at || r.created_at,
1344
- });
1345
- }
1262
+ const sc = chatCostCache.get(r.id);
1263
+ if (!sc || sc.totalCost <= 0) continue;
1264
+ const topModel = sc.byModel.length > 0 ? sc.byModel[0].model : null;
1265
+ sessionCosts.push({
1266
+ id: r.id, source: r.source, name: r.name, folder: r.folder,
1267
+ model: topModel,
1268
+ cost: sc.totalCost, messages: r.msgs || 0,
1269
+ lastUpdatedAt: r.last_updated_at || r.created_at,
1270
+ });
1346
1271
  }
1347
1272
  sessionCosts.sort((a, b) => b.cost - a.cost);
1348
1273
 
@@ -1361,7 +1286,7 @@ function getCostAnalytics(opts = {}) {
1361
1286
  byModel: overall.byModel,
1362
1287
  unknownModels: overall.unknownModels,
1363
1288
  byEditor,
1364
- byProject: byProject.slice(0, 20),
1289
+ byProject,
1365
1290
  monthly,
1366
1291
  topSessions: sessionCosts.slice(0, 50),
1367
1292
  summary: {
package/deno.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "tasks": {
3
+ "start": "deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-run index.js",
4
+ "collect": "deno run --allow-read --allow-write --allow-env --allow-ffi index.js --collect",
5
+ "scan": "deno run --allow-read --allow-env mod.ts",
6
+ "scan:json": "deno run --allow-read --allow-env mod.ts --json"
7
+ },
8
+ "nodeModulesDir": "auto"
9
+ }