agentlytics 0.1.5 → 0.1.6

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
@@ -179,7 +179,7 @@ All endpoints accept optional `editor` filter. See **[API.md](API.md)** for full
179
179
  - [ ] **LLM-powered insights** — Use an LLM to analyze session patterns, generate summaries, detect coding habits, and surface actionable recommendations
180
180
  - [ ] **Linux & Windows support** — Adapt editor paths for non-macOS platforms
181
181
  - [ ] **Export & reports** — PDF/CSV export of analytics and session data
182
- - [ ] **Cost tracking** — Estimate API costs per editor/model based on token usage
182
+ - [x] **Cost tracking** — Estimate API costs per editor/model based on token usage
183
183
 
184
184
  ## Contributions Needed
185
185
 
package/cache.js CHANGED
@@ -3,9 +3,11 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const fs = require('fs');
5
5
  const { getAllChats, getMessages, findChat: findChatRaw, resetCaches } = require('./editors');
6
+ const { calculateCost, getModelPricing, normalizeModelName } = require('./pricing');
6
7
 
7
8
  const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
8
9
  const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
10
+ const SCHEMA_VERSION = 4; // bump this when schema changes to auto-revalidate
9
11
 
10
12
  let db = null;
11
13
 
@@ -15,6 +17,28 @@ let db = null;
15
17
 
16
18
  function initDb() {
17
19
  if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
20
+
21
+ // Check schema version; wipe DB on mismatch
22
+ if (fs.existsSync(CACHE_DB)) {
23
+ try {
24
+ const tmp = new Database(CACHE_DB, { readonly: true });
25
+ const row = tmp.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
26
+ tmp.close();
27
+ if (!row || parseInt(row.value) !== SCHEMA_VERSION) {
28
+ for (const suffix of ['', '-wal', '-shm']) {
29
+ const f = CACHE_DB + suffix;
30
+ if (fs.existsSync(f)) fs.unlinkSync(f);
31
+ }
32
+ }
33
+ } catch {
34
+ // Corrupt or unreadable DB — wipe it
35
+ for (const suffix of ['', '-wal', '-shm']) {
36
+ const f = CACHE_DB + suffix;
37
+ if (fs.existsSync(f)) fs.unlinkSync(f);
38
+ }
39
+ }
40
+ }
41
+
18
42
  db = new Database(CACHE_DB);
19
43
  db.pragma('journal_mode = WAL');
20
44
  db.pragma('synchronous = NORMAL');
@@ -86,6 +110,9 @@ function initDb() {
86
110
  CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON tool_calls(tool_name);
87
111
  CREATE INDEX IF NOT EXISTS idx_tool_calls_chat ON tool_calls(chat_id);
88
112
  `);
113
+
114
+ // Store schema version so future runs can detect mismatches
115
+ db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION.toString());
89
116
  }
90
117
 
91
118
  // ============================================================
@@ -267,7 +294,12 @@ function scanAll(onProgress, opts = {}) {
267
294
  // ============================================================
268
295
 
269
296
  function getCachedChats(opts = {}) {
270
- 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';
297
+ let sql = `SELECT c.*,
298
+ cs.models AS _models,
299
+ cs.total_input_tokens AS _inTok, cs.total_output_tokens AS _outTok,
300
+ cs.total_cache_read AS _cacheR, cs.total_cache_write AS _cacheW,
301
+ cs.total_user_chars AS _uChars, cs.total_assistant_chars AS _aChars
302
+ FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id WHERE 1=1`;
271
303
  const params = [];
272
304
  if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
273
305
  if (opts.folder) { sql += ' AND c.folder LIKE ?'; params.push(`%${opts.folder}%`); }
@@ -288,7 +320,14 @@ function getCachedChats(opts = {}) {
288
320
  r.top_model = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
289
321
  }
290
322
  } catch {}
291
- delete r._models;
323
+ // Per-session cost estimate
324
+ let inTok = r._inTok || 0, outTok = r._outTok || 0;
325
+ if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
326
+ inTok = Math.round((r._uChars || 0) / 4);
327
+ outTok = Math.round((r._aChars || 0) / 4);
328
+ }
329
+ r.cost = r.top_model ? (calculateCost(r.top_model, inTok, outTok, r._cacheR || 0, r._cacheW || 0) || 0) : 0;
330
+ delete r._models; delete r._inTok; delete r._outTok; delete r._cacheR; delete r._cacheW; delete r._uChars; delete r._aChars;
292
331
  }
293
332
  return rows;
294
333
  }
@@ -432,15 +471,23 @@ function getCachedDeepAnalytics(opts = {}) {
432
471
  } catch {}
433
472
  try {
434
473
  const models = JSON.parse(r.models);
435
- for (const m of models) { modelFreq[m] = (modelFreq[m] || 0) + 1; }
474
+ for (const m of models) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; }
436
475
  } catch {}
437
476
  }
438
477
 
478
+ // Estimate tokens from chars when no token data available
479
+ let tokensEstimated = false;
480
+ if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
481
+ totalInputTokens = Math.round(totalUserChars / 4);
482
+ totalOutputTokens = Math.round(totalAssistantChars / 4);
483
+ tokensEstimated = true;
484
+ }
485
+
439
486
  return {
440
487
  analyzedChats: rows.length,
441
488
  totalMessages, totalToolCalls,
442
489
  totalUserChars, totalAssistantChars,
443
- totalInputTokens, totalOutputTokens,
490
+ totalInputTokens, totalOutputTokens, tokensEstimated,
444
491
  totalCacheRead, totalCacheWrite,
445
492
  topTools: Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, count]) => ({ name, count })),
446
493
  topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([name, count]) => ({ name, count })),
@@ -543,10 +590,18 @@ function getCachedProjects(opts = {}) {
543
590
  totalAssistantChars += s.total_assistant_chars;
544
591
  totalCacheRead += s.total_cache_read;
545
592
  totalCacheWrite += s.total_cache_write;
546
- try { for (const m of JSON.parse(s.models)) { modelFreq[m] = (modelFreq[m] || 0) + 1; } } catch {}
593
+ try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
547
594
  try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch {}
548
595
  }
549
596
 
597
+ // Estimate tokens from chars when no token data available
598
+ let tokensEstimated = false;
599
+ if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
600
+ totalInputTokens = Math.round(totalUserChars / 4);
601
+ totalOutputTokens = Math.round(totalAssistantChars / 4);
602
+ tokensEstimated = true;
603
+ }
604
+
550
605
  result.push({
551
606
  folder: proj.folder,
552
607
  name: proj.folder.split('/').pop(),
@@ -556,7 +611,7 @@ function getCachedProjects(opts = {}) {
556
611
  lastSeen: proj.lastSeen,
557
612
  totalMessages,
558
613
  totalInputTokens,
559
- totalOutputTokens,
614
+ totalOutputTokens, tokensEstimated,
560
615
  totalUserChars,
561
616
  totalAssistantChars,
562
617
  totalToolCalls,
@@ -818,7 +873,7 @@ function getCachedDashboardStats(opts = {}) {
818
873
  `).all(...params);
819
874
  const modelFreq = {};
820
875
  for (const r of modelRows) {
821
- try { for (const m of JSON.parse(r.models)) modelFreq[m] = (modelFreq[m] || 0) + 1; } catch {}
876
+ try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
822
877
  }
823
878
  const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
824
879
 
@@ -833,18 +888,29 @@ function getCachedDashboardStats(opts = {}) {
833
888
  }
834
889
  const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
835
890
 
891
+ // If no token data but chars exist, estimate tokens (~4 chars/token)
892
+ let inputTokens = tokenRow.input;
893
+ let outputTokens = tokenRow.output;
894
+ let tokensEstimated = false;
895
+ if (inputTokens === 0 && outputTokens === 0 && (tokenRow.userChars > 0 || tokenRow.assistantChars > 0)) {
896
+ inputTokens = Math.round(tokenRow.userChars / 4);
897
+ outputTokens = Math.round(tokenRow.assistantChars / 4);
898
+ tokensEstimated = true;
899
+ }
900
+
836
901
  return {
837
902
  hourly,
838
903
  weekdays,
839
904
  depthBuckets,
840
905
  tokens: {
841
- input: tokenRow.input,
842
- output: tokenRow.output,
906
+ input: inputTokens,
907
+ output: outputTokens,
843
908
  cacheRead: tokenRow.cacheRead,
844
909
  cacheWrite: tokenRow.cacheWrite,
845
910
  userChars: tokenRow.userChars,
846
911
  assistantChars: tokenRow.assistantChars,
847
912
  sessions: tokenRow.sessions,
913
+ estimated: tokensEstimated,
848
914
  },
849
915
  streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
850
916
  monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
@@ -855,6 +921,342 @@ function getCachedDashboardStats(opts = {}) {
855
921
  };
856
922
  }
857
923
 
924
+ // ============================================================
925
+ // Cost estimation
926
+ // ============================================================
927
+
928
+ function estimateCosts(whereClause = '', params = []) {
929
+ // Per-model token usage from messages table
930
+ const modelTokens = db.prepare(`
931
+ SELECT m.model, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
932
+ FROM messages m JOIN chats c ON m.chat_id = c.id
933
+ WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
934
+ GROUP BY m.model
935
+ `).all(...params);
936
+
937
+ // Orphaned tokens: messages with token data but NULL model.
938
+ // Attribute these to the session's dominant model from chat_stats.
939
+ const orphanRows = db.prepare(`
940
+ SELECT m.chat_id, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
941
+ FROM messages m JOIN chats c ON m.chat_id = c.id
942
+ WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
943
+ GROUP BY m.chat_id
944
+ `).all(...params);
945
+
946
+ const orphanByModel = {};
947
+ for (const r of orphanRows) {
948
+ const stat = db.prepare('SELECT models FROM chat_stats WHERE chat_id = ?').get(r.chat_id);
949
+ if (!stat) continue;
950
+ let models;
951
+ try { models = JSON.parse(stat.models || '[]'); } catch { continue; }
952
+ if (models.length === 0) continue;
953
+ const freq = {};
954
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
955
+ const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
956
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
957
+ orphanByModel[dominant].input += r.input || 0;
958
+ orphanByModel[dominant].output += r.output || 0;
959
+ }
960
+
961
+ // Cache tokens per session with dominant model
962
+ const cacheRows = db.prepare(`
963
+ SELECT cs.total_cache_read, cs.total_cache_write, cs.models
964
+ FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
965
+ WHERE (cs.total_cache_read > 0 OR cs.total_cache_write > 0)${whereClause}
966
+ `).all(...params);
967
+
968
+ // Aggregate cache tokens by dominant model
969
+ const cacheByModel = {};
970
+ for (const r of cacheRows) {
971
+ let models;
972
+ try { models = JSON.parse(r.models || '[]'); } catch { continue; }
973
+ if (models.length === 0) continue;
974
+ const freq = {};
975
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
976
+ const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
977
+ if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
978
+ cacheByModel[dominant].cacheRead += r.total_cache_read;
979
+ cacheByModel[dominant].cacheWrite += r.total_cache_write;
980
+ }
981
+
982
+ // Char-based estimation: sessions with models + chars but zero tokens.
983
+ // Estimate ~4 chars per token (user chars → input, assistant chars → output).
984
+ const CHARS_PER_TOKEN = 4;
985
+ const charRows = db.prepare(`
986
+ SELECT cs.models, cs.total_user_chars as userChars, cs.total_assistant_chars as asstChars
987
+ FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
988
+ WHERE cs.models != '[]' AND cs.total_input_tokens = 0 AND cs.total_output_tokens = 0
989
+ AND (cs.total_user_chars > 0 OR cs.total_assistant_chars > 0)${whereClause}
990
+ `).all(...params);
991
+
992
+ for (const r of charRows) {
993
+ let models;
994
+ try { models = JSON.parse(r.models || '[]'); } catch { continue; }
995
+ if (models.length === 0) continue;
996
+ const freq = {};
997
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
998
+ const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
999
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1000
+ orphanByModel[dominant].input += Math.round((r.userChars || 0) / CHARS_PER_TOKEN);
1001
+ orphanByModel[dominant].output += Math.round((r.asstChars || 0) / CHARS_PER_TOKEN);
1002
+ }
1003
+
1004
+ // Sessions with token totals but empty models (e.g. Cursor composer chats).
1005
+ // Attribute to the dominant model from same editor source.
1006
+ const unmodeledRows = db.prepare(`
1007
+ SELECT c.source, cs.total_input_tokens as input, cs.total_output_tokens as output,
1008
+ cs.total_cache_read as cacheRead, cs.total_cache_write as cacheWrite
1009
+ FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
1010
+ WHERE cs.models = '[]' AND (cs.total_input_tokens > 0 OR cs.total_output_tokens > 0)${whereClause}
1011
+ `).all(...params);
1012
+
1013
+ if (unmodeledRows.length > 0) {
1014
+ // Find dominant model per source from sessions that DO have models
1015
+ const sourceModelFreq = {};
1016
+ const allSessions = db.prepare(`
1017
+ SELECT c.source, cs.models FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
1018
+ WHERE cs.models != '[]'${whereClause}
1019
+ `).all(...params);
1020
+ for (const s of allSessions) {
1021
+ let models;
1022
+ try { models = JSON.parse(s.models || '[]'); } catch { continue; }
1023
+ if (!sourceModelFreq[s.source]) sourceModelFreq[s.source] = {};
1024
+ for (const m of models) sourceModelFreq[s.source][m] = (sourceModelFreq[s.source][m] || 0) + 1;
1025
+ }
1026
+ // Global fallback: dominant model across all sources
1027
+ const globalFreq = {};
1028
+ for (const sf of Object.values(sourceModelFreq)) {
1029
+ for (const [m, c] of Object.entries(sf)) globalFreq[m] = (globalFreq[m] || 0) + c;
1030
+ }
1031
+ const globalDominant = Object.entries(globalFreq).sort((a, b) => b[1] - a[1])[0]?.[0];
1032
+
1033
+ for (const r of unmodeledRows) {
1034
+ const sf = sourceModelFreq[r.source];
1035
+ const dominant = sf
1036
+ ? Object.entries(sf).sort((a, b) => b[1] - a[1])[0]?.[0]
1037
+ : globalDominant;
1038
+ if (!dominant) continue;
1039
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1040
+ orphanByModel[dominant].input += r.input || 0;
1041
+ orphanByModel[dominant].output += r.output || 0;
1042
+ // Also merge cache data
1043
+ if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
1044
+ cacheByModel[dominant].cacheRead += r.cacheRead || 0;
1045
+ cacheByModel[dominant].cacheWrite += r.cacheWrite || 0;
1046
+ }
1047
+ }
1048
+
1049
+ // Merge modelTokens + orphanByModel into a unified map, normalizing keys
1050
+ const tokenMap = {};
1051
+ const addTokens = (rawModel, input, output) => {
1052
+ const key = normalizeModelName(rawModel) || rawModel;
1053
+ if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0 };
1054
+ tokenMap[key].input += input || 0;
1055
+ tokenMap[key].output += output || 0;
1056
+ };
1057
+ for (const row of modelTokens) addTokens(row.model, row.input, row.output);
1058
+ for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output);
1059
+
1060
+ // Normalize cacheByModel keys
1061
+ const normCache = {};
1062
+ for (const [model, cache] of Object.entries(cacheByModel)) {
1063
+ const key = normalizeModelName(model) || model;
1064
+ if (!normCache[key]) normCache[key] = { cacheRead: 0, cacheWrite: 0 };
1065
+ normCache[key].cacheRead += cache.cacheRead;
1066
+ normCache[key].cacheWrite += cache.cacheWrite;
1067
+ }
1068
+
1069
+ let totalCost = 0;
1070
+ let knownCost = 0;
1071
+ let unknownModels = [];
1072
+ const byModel = [];
1073
+
1074
+ for (const [model, tok] of Object.entries(tokenMap)) {
1075
+ const cache = normCache[model] || { cacheRead: 0, cacheWrite: 0 };
1076
+ const cost = calculateCost(model, tok.input, tok.output, cache.cacheRead, cache.cacheWrite);
1077
+ if (cost !== null) {
1078
+ knownCost += cost;
1079
+ totalCost += cost;
1080
+ byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
1081
+ } else {
1082
+ unknownModels.push(model);
1083
+ }
1084
+ }
1085
+
1086
+ // Handle cache tokens for models that had cache but no message-level tokens
1087
+ for (const [model, cache] of Object.entries(normCache)) {
1088
+ if (!tokenMap[model]) {
1089
+ const cost = calculateCost(model, 0, 0, cache.cacheRead, cache.cacheWrite);
1090
+ if (cost !== null) {
1091
+ totalCost += cost;
1092
+ byModel.push({ model, inputTokens: 0, outputTokens: 0, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ byModel.sort((a, b) => b.cost - a.cost);
1098
+ unknownModels = [...new Set(unknownModels)];
1099
+
1100
+ return { totalCost, byModel, unknownModels };
1101
+ }
1102
+
1103
+ function getCostBreakdown(opts = {}) {
1104
+ let whereClause = '';
1105
+ const params = [];
1106
+ if (opts.editor) { whereClause += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
1107
+ if (opts.folder) { whereClause += ' AND c.folder = ?'; params.push(opts.folder); }
1108
+ if (opts.dateFrom) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
1109
+ if (opts.dateTo) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) <= ?'; params.push(opts.dateTo); }
1110
+ if (opts.chatId) { whereClause += ' AND c.id = ?'; params.push(opts.chatId); }
1111
+ return estimateCosts(whereClause, params);
1112
+ }
1113
+
1114
+ function getCostAnalytics(opts = {}) {
1115
+ const conditions = [];
1116
+ const params = [];
1117
+ if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
1118
+ if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
1119
+ if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
1120
+ const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
1121
+
1122
+ // Overall cost breakdown by model
1123
+ const overall = getCostBreakdown(opts);
1124
+
1125
+ // Cost by editor: get costs per source
1126
+ const editorRows = db.prepare(`
1127
+ SELECT DISTINCT c.source FROM chats c WHERE c.source IS NOT NULL${whereAnd}
1128
+ `).all(...params);
1129
+ const byEditor = [];
1130
+ for (const { source } of editorRows) {
1131
+ const editorOpts = { ...opts, editor: source };
1132
+ const ec = getCostBreakdown(editorOpts);
1133
+ if (ec.totalCost > 0) {
1134
+ byEditor.push({ editor: source, cost: ec.totalCost, models: ec.byModel.length });
1135
+ }
1136
+ }
1137
+ byEditor.sort((a, b) => b.cost - a.cost);
1138
+
1139
+ // Cost by project (top 20)
1140
+ const projectRows = db.prepare(`
1141
+ SELECT c.folder, COUNT(*) as sessions FROM chats c
1142
+ WHERE c.folder IS NOT NULL${whereAnd}
1143
+ GROUP BY c.folder ORDER BY sessions DESC LIMIT 30
1144
+ `).all(...params);
1145
+ const byProject = [];
1146
+ for (const { folder } of projectRows) {
1147
+ const pc = getCostBreakdown({ ...opts, folder });
1148
+ if (pc.totalCost > 0) {
1149
+ byProject.push({ folder, name: folder.split('/').pop(), cost: pc.totalCost });
1150
+ }
1151
+ }
1152
+ byProject.sort((a, b) => b.cost - a.cost);
1153
+
1154
+ // Monthly trend
1155
+ const monthRows = db.prepare(`
1156
+ SELECT
1157
+ substr(date(COALESCE(c.last_updated_at, c.created_at)/1000, 'unixepoch'), 1, 7) as month,
1158
+ c.id, c.source,
1159
+ cs.models AS _models,
1160
+ cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
1161
+ cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
1162
+ cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars
1163
+ FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1164
+ WHERE (c.last_updated_at IS NOT NULL OR c.created_at IS NOT NULL)${whereAnd}
1165
+ ORDER BY month
1166
+ `).all(...params);
1167
+ const monthCosts = {};
1168
+ for (const r of monthRows) {
1169
+ if (!r.month) continue;
1170
+ let topModel = null;
1171
+ try {
1172
+ const models = JSON.parse(r._models || '[]');
1173
+ if (models.length > 0) {
1174
+ const freq = {};
1175
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
1176
+ topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1177
+ }
1178
+ } catch {}
1179
+ if (!topModel) continue;
1180
+ let inTok = r.inTok || 0, outTok = r.outTok || 0;
1181
+ if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
1182
+ inTok = Math.round((r.uChars || 0) / 4);
1183
+ outTok = Math.round((r.aChars || 0) / 4);
1184
+ }
1185
+ const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
1186
+ if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
1187
+ monthCosts[r.month].cost += cost;
1188
+ monthCosts[r.month].sessions++;
1189
+ }
1190
+ const monthly = Object.entries(monthCosts).sort((a, b) => a[0].localeCompare(b[0]))
1191
+ .map(([month, d]) => ({ month, cost: Math.round(d.cost * 100) / 100, sessions: d.sessions }));
1192
+
1193
+ // Top expensive sessions
1194
+ const sessionRows = db.prepare(`
1195
+ SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
1196
+ cs.models AS _models,
1197
+ cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
1198
+ cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
1199
+ cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars,
1200
+ cs.total_messages AS msgs
1201
+ FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1202
+ WHERE 1=1${whereAnd}
1203
+ `).all(...params);
1204
+ const sessionCosts = [];
1205
+ for (const r of sessionRows) {
1206
+ let topModel = null;
1207
+ try {
1208
+ const models = JSON.parse(r._models || '[]');
1209
+ if (models.length > 0) {
1210
+ const freq = {};
1211
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
1212
+ topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1213
+ }
1214
+ } catch {}
1215
+ if (!topModel) continue;
1216
+ let inTok = r.inTok || 0, outTok = r.outTok || 0;
1217
+ if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
1218
+ inTok = Math.round((r.uChars || 0) / 4);
1219
+ outTok = Math.round((r.aChars || 0) / 4);
1220
+ }
1221
+ const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
1222
+ if (cost > 0) {
1223
+ sessionCosts.push({
1224
+ id: r.id, source: r.source, name: r.name, folder: r.folder,
1225
+ model: normalizeModelName(topModel) || topModel,
1226
+ cost, messages: r.msgs || 0,
1227
+ lastUpdatedAt: r.last_updated_at || r.created_at,
1228
+ });
1229
+ }
1230
+ }
1231
+ sessionCosts.sort((a, b) => b.cost - a.cost);
1232
+
1233
+ // Summary stats
1234
+ const totalSessions = sessionCosts.length;
1235
+ const avgPerSession = totalSessions > 0 ? overall.totalCost / totalSessions : 0;
1236
+ const totalDays = monthly.length > 0 ? (() => {
1237
+ const first = new Date(monthly[0].month + '-01');
1238
+ const last = new Date(monthly[monthly.length - 1].month + '-01');
1239
+ return Math.max(1, Math.ceil((last - first) / 86400000) + 30);
1240
+ })() : 1;
1241
+ const avgPerDay = overall.totalCost / totalDays;
1242
+
1243
+ return {
1244
+ totalCost: overall.totalCost,
1245
+ byModel: overall.byModel,
1246
+ unknownModels: overall.unknownModels,
1247
+ byEditor,
1248
+ byProject: byProject.slice(0, 20),
1249
+ monthly,
1250
+ topSessions: sessionCosts.slice(0, 50),
1251
+ summary: {
1252
+ totalSessions,
1253
+ avgPerSession: Math.round(avgPerSession * 100) / 100,
1254
+ avgPerDay: Math.round(avgPerDay * 100) / 100,
1255
+ totalDays,
1256
+ },
1257
+ };
1258
+ }
1259
+
858
1260
  function getDb() { return db; }
859
1261
 
860
1262
  module.exports = {
@@ -871,5 +1273,7 @@ module.exports = {
871
1273
  resetAndRescan,
872
1274
  resetAndRescanAsync,
873
1275
  getCachedDashboardStats,
1276
+ getCostBreakdown,
1277
+ getCostAnalytics,
874
1278
  getDb,
875
1279
  };
package/editors/cursor.js CHANGED
@@ -91,11 +91,6 @@ function normalizeStoreMessage(json) {
91
91
  }
92
92
  }
93
93
  if (json.model) msg._model = json.model;
94
- // Extract provider as model hint if no explicit model
95
- if (!msg._model && json.providerOptions && typeof json.providerOptions === 'object') {
96
- const providers = Object.keys(json.providerOptions).filter(k => k !== 'type');
97
- if (providers.length > 0) msg._model = providers[0];
98
- }
99
94
  return msg;
100
95
  }
101
96
 
@@ -159,6 +154,15 @@ function getComposerHeaders(stateDbPath) {
159
154
  } catch { return []; }
160
155
  }
161
156
 
157
+ function getModelPreference(globalDb) {
158
+ try {
159
+ const row = globalDb.prepare("SELECT value FROM ItemTable WHERE key = 'cursor/lastSingleModelPreference'").get();
160
+ if (!row) return null;
161
+ const pref = JSON.parse(row.value);
162
+ return pref.composer || pref.agent || null;
163
+ } catch { return null; }
164
+ }
165
+
162
166
  function getComposerBubbles(globalDb, composerId) {
163
167
  const prefix = `bubbleId:${composerId}:`;
164
168
  const rows = globalDb.prepare(
@@ -260,9 +264,11 @@ function getChats() {
260
264
  composerId: chatId,
261
265
  name: meta.name || null,
262
266
  createdAt: meta.createdAt || null,
267
+ mode: meta.mode || null,
263
268
  folder: null,
264
269
  _dbPath: dbPath,
265
270
  _rootBlobId: meta.latestRootBlobId,
271
+ _lastUsedModel: meta.lastUsedModel || null,
266
272
  _type: 'agent-store',
267
273
  });
268
274
  }
@@ -273,6 +279,8 @@ function getChats() {
273
279
  let globalDb = null;
274
280
  try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { /* no global db */ }
275
281
 
282
+ const modelPref = globalDb ? getModelPreference(globalDb) : null;
283
+
276
284
  for (const { hash, folder, stateDb } of getWorkspaceMap()) {
277
285
  const headers = getComposerHeaders(stateDb);
278
286
  for (const h of headers) {
@@ -295,6 +303,7 @@ function getChats() {
295
303
  folder,
296
304
  bubbleCount,
297
305
  _type: 'workspace',
306
+ _modelPref: modelPref,
298
307
  });
299
308
  }
300
309
  }
@@ -308,6 +317,12 @@ function getMessages(chat) {
308
317
  const db = new Database(chat._dbPath, { readonly: true });
309
318
  const msgs = collectStoreMessages(db, chat._rootBlobId);
310
319
  db.close();
320
+ // Use lastUsedModel as fallback for assistant messages without model info
321
+ if (chat._lastUsedModel) {
322
+ for (const m of msgs) {
323
+ if (m.role === 'assistant' && !m._model) m._model = chat._lastUsedModel;
324
+ }
325
+ }
311
326
  return msgs;
312
327
  }
313
328
 
@@ -315,7 +330,14 @@ function getMessages(chat) {
315
330
  try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { return []; }
316
331
  const bubbles = getComposerBubbles(globalDb, chat.composerId);
317
332
  globalDb.close();
318
- return bubblesToMessages(bubbles);
333
+ const msgs = bubblesToMessages(bubbles);
334
+ // Use model preference as fallback for messages without model info
335
+ if (chat._modelPref) {
336
+ for (const m of msgs) {
337
+ if (m.role === 'assistant' && !m._model) m._model = chat._modelPref;
338
+ }
339
+ }
340
+ return msgs;
319
341
  }
320
342
 
321
343
  module.exports = { name, getChats, getMessages };
package/editors/vscode.js CHANGED
@@ -118,6 +118,7 @@ function parseSessionFile(filePath) {
118
118
  title: state.customTitle || null,
119
119
  requests: state.requests || [],
120
120
  format: 'jsonl',
121
+ selectedModel: state.inputState?.selectedModel?.metadata?.id || null,
121
122
  };
122
123
  } else if (ext === '.json') {
123
124
  let data;
@@ -128,6 +129,7 @@ function parseSessionFile(filePath) {
128
129
  title: data.customTitle || null,
129
130
  requests: data.requests || [],
130
131
  format: 'json',
132
+ selectedModel: data.inputState?.selectedModel?.metadata?.id || null,
131
133
  };
132
134
  }
133
135
  return null;
@@ -204,6 +206,7 @@ function collectSessions(dir, folder, source, chats) {
204
206
  encrypted: false,
205
207
  bubbleCount: meta.requestCount || 0,
206
208
  _filePath: filePath,
209
+ _modelPref: meta.selectedModel || null,
207
210
  });
208
211
  } catch { /* skip */ }
209
212
  }
@@ -221,6 +224,7 @@ function peekMeta(filePath) {
221
224
  createdAt: data.creationDate || data.lastMessageDate,
222
225
  requestCount: data.requests?.length || 0,
223
226
  firstUserText: firstText.substring(0, 120) || null,
227
+ selectedModel: data.inputState?.selectedModel?.metadata?.id || null,
224
228
  };
225
229
  } catch { return {}; }
226
230
  }
@@ -251,6 +255,7 @@ function peekMeta(filePath) {
251
255
  createdAt: state.creationDate,
252
256
  requestCount: null,
253
257
  firstUserText: null,
258
+ selectedModel: state.inputState?.selectedModel?.metadata?.id || null,
254
259
  };
255
260
  } catch { return {}; }
256
261
  }
@@ -301,6 +306,7 @@ function getMessages(chat) {
301
306
  const meta = req.result?.metadata;
302
307
  messages.push({
303
308
  role: 'assistant', content: responseText,
309
+ _model: parsed.selectedModel || null,
304
310
  _inputTokens: meta?.promptTokens, _outputTokens: meta?.outputTokens,
305
311
  _toolCalls,
306
312
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
5
5
  "main": "index.js",
6
6
  "bin": {