agentlytics 0.1.3 → 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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/cache.js +420 -10
  3. package/editors/cursor.js +28 -6
  4. package/editors/vscode.js +6 -0
  5. package/index.js +78 -11
  6. package/package.json +2 -1
  7. package/server.js +27 -0
  8. package/ui/package-lock.json +60 -375
  9. package/ui/package.json +1 -1
  10. package/ui/src/App.jsx +22 -17
  11. package/ui/src/components/ActivityHeatmap.jsx +3 -3
  12. package/ui/src/components/AnimatedLogo.jsx +96 -0
  13. package/ui/src/components/ChatSidebar.jsx +7 -7
  14. package/ui/src/components/DateRangePicker.jsx +5 -5
  15. package/ui/src/components/EditorBreakdown.jsx +2 -2
  16. package/ui/src/components/EditorDot.jsx +1 -1
  17. package/ui/src/components/KpiCard.jsx +2 -2
  18. package/ui/src/components/LiveFeed.jsx +8 -8
  19. package/ui/src/components/LoginScreen.jsx +8 -6
  20. package/ui/src/components/MessageRenderer.jsx +5 -5
  21. package/ui/src/components/ModelBreakdown.jsx +3 -3
  22. package/ui/src/components/SectionTitle.jsx +1 -1
  23. package/ui/src/index.css +1 -1
  24. package/ui/src/lib/api.js +20 -0
  25. package/ui/src/lib/constants.js +8 -0
  26. package/ui/src/pages/ChatDetail.jsx +5 -2
  27. package/ui/src/pages/Compare.jsx +18 -18
  28. package/ui/src/pages/CostAnalysis.jsx +356 -0
  29. package/ui/src/pages/Dashboard.jsx +39 -21
  30. package/ui/src/pages/DeepAnalysis.jsx +38 -31
  31. package/ui/src/pages/ProjectDetail.jsx +23 -15
  32. package/ui/src/pages/Projects.jsx +14 -8
  33. package/ui/src/pages/RelayDashboard.jsx +29 -29
  34. package/ui/src/pages/RelaySessionDetail.jsx +1 -1
  35. package/ui/src/pages/RelayUserDetail.jsx +18 -18
  36. package/ui/src/pages/Sessions.jsx +24 -20
  37. package/ui/src/pages/SqlViewer.jsx +14 -14
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
  // ============================================================
@@ -193,7 +220,7 @@ function analyzeAndStore(chat) {
193
220
  function scanAll(onProgress, opts = {}) {
194
221
  const force = opts.force || false;
195
222
  if (force || opts.resetCaches) resetCaches();
196
- const chats = getAllChats();
223
+ const chats = opts.chats || getAllChats();
197
224
  const total = chats.length;
198
225
  let scanned = 0;
199
226
  let analyzed = 0;
@@ -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,
@@ -602,6 +657,9 @@ function safeParseJson(s) {
602
657
  function resetAndRescan(onProgress) {
603
658
  if (db) db.close();
604
659
  if (fs.existsSync(CACHE_DB)) fs.unlinkSync(CACHE_DB);
660
+ for (const suffix of ['-wal', '-shm']) {
661
+ if (fs.existsSync(CACHE_DB + suffix)) fs.unlinkSync(CACHE_DB + suffix);
662
+ }
605
663
  initDb();
606
664
  return scanAll(onProgress);
607
665
  }
@@ -676,6 +734,9 @@ async function scanAllAsync(onProgress) {
676
734
  async function resetAndRescanAsync(onProgress) {
677
735
  if (db) db.close();
678
736
  if (fs.existsSync(CACHE_DB)) fs.unlinkSync(CACHE_DB);
737
+ for (const suffix of ['-wal', '-shm']) {
738
+ if (fs.existsSync(CACHE_DB + suffix)) fs.unlinkSync(CACHE_DB + suffix);
739
+ }
679
740
  initDb();
680
741
  return scanAllAsync(onProgress);
681
742
  }
@@ -812,7 +873,7 @@ function getCachedDashboardStats(opts = {}) {
812
873
  `).all(...params);
813
874
  const modelFreq = {};
814
875
  for (const r of modelRows) {
815
- 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 {}
816
877
  }
817
878
  const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
818
879
 
@@ -827,18 +888,29 @@ function getCachedDashboardStats(opts = {}) {
827
888
  }
828
889
  const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
829
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
+
830
901
  return {
831
902
  hourly,
832
903
  weekdays,
833
904
  depthBuckets,
834
905
  tokens: {
835
- input: tokenRow.input,
836
- output: tokenRow.output,
906
+ input: inputTokens,
907
+ output: outputTokens,
837
908
  cacheRead: tokenRow.cacheRead,
838
909
  cacheWrite: tokenRow.cacheWrite,
839
910
  userChars: tokenRow.userChars,
840
911
  assistantChars: tokenRow.assistantChars,
841
912
  sessions: tokenRow.sessions,
913
+ estimated: tokensEstimated,
842
914
  },
843
915
  streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
844
916
  monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
@@ -849,6 +921,342 @@ function getCachedDashboardStats(opts = {}) {
849
921
  };
850
922
  }
851
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
+
852
1260
  function getDb() { return db; }
853
1261
 
854
1262
  module.exports = {
@@ -865,5 +1273,7 @@ module.exports = {
865
1273
  resetAndRescan,
866
1274
  resetAndRescanAsync,
867
1275
  getCachedDashboardStats,
1276
+ getCostBreakdown,
1277
+ getCostAnalytics,
868
1278
  getDb,
869
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
  });