agentlytics 0.1.5 → 0.1.7

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
  // ============================================================
@@ -266,9 +293,23 @@ function scanAll(onProgress, opts = {}) {
266
293
  // Query helpers (used by server.js)
267
294
  // ============================================================
268
295
 
296
+ // Returns { sql, params } for excluding hidden folders
297
+ function hiddenFolderFilter(opts, colName = 'folder') {
298
+ if (!opts.hiddenFolders || opts.hiddenFolders.length === 0) return { sql: '', params: [] };
299
+ const placeholders = opts.hiddenFolders.map(() => '?').join(',');
300
+ return { sql: ` AND (${colName} IS NULL OR ${colName} NOT IN (${placeholders}))`, params: [...opts.hiddenFolders] };
301
+ }
302
+
269
303
  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';
304
+ let sql = `SELECT c.*,
305
+ cs.models AS _models,
306
+ cs.total_input_tokens AS _inTok, cs.total_output_tokens AS _outTok,
307
+ cs.total_cache_read AS _cacheR, cs.total_cache_write AS _cacheW,
308
+ cs.total_user_chars AS _uChars, cs.total_assistant_chars AS _aChars
309
+ FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id WHERE 1=1`;
271
310
  const params = [];
311
+ const hf = hiddenFolderFilter(opts, 'c.folder');
312
+ if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
272
313
  if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
273
314
  if (opts.folder) { sql += ' AND c.folder LIKE ?'; params.push(`%${opts.folder}%`); }
274
315
  if (opts.named !== false) { sql += ' AND (c.name IS NOT NULL OR c.bubble_count > 0)'; }
@@ -288,7 +329,14 @@ function getCachedChats(opts = {}) {
288
329
  r.top_model = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
289
330
  }
290
331
  } catch {}
291
- delete r._models;
332
+ // Per-session cost estimate
333
+ let inTok = r._inTok || 0, outTok = r._outTok || 0;
334
+ if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
335
+ inTok = Math.round((r._uChars || 0) / 4);
336
+ outTok = Math.round((r._aChars || 0) / 4);
337
+ }
338
+ r.cost = r.top_model ? (calculateCost(r.top_model, inTok, outTok, r._cacheR || 0, r._cacheW || 0) || 0) : 0;
339
+ delete r._models; delete r._inTok; delete r._outTok; delete r._cacheR; delete r._cacheW; delete r._uChars; delete r._aChars;
292
340
  }
293
341
  return rows;
294
342
  }
@@ -296,6 +344,8 @@ function getCachedChats(opts = {}) {
296
344
  function countCachedChats(opts = {}) {
297
345
  let sql = 'SELECT COUNT(*) as cnt FROM chats WHERE 1=1';
298
346
  const params = [];
347
+ const hf = hiddenFolderFilter(opts);
348
+ if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
299
349
  if (opts.editor) { sql += ' AND source LIKE ?'; params.push(`%${opts.editor}%`); }
300
350
  if (opts.folder) { sql += ' AND folder LIKE ?'; params.push(`%${opts.folder}%`); }
301
351
  if (opts.named !== false) { sql += ' AND (name IS NOT NULL OR bubble_count > 0)'; }
@@ -308,6 +358,8 @@ function getCachedOverview(opts = {}) {
308
358
  // Build conditions dynamically to support editor + date range filters
309
359
  const conditions = [];
310
360
  const params = [];
361
+ const hf = hiddenFolderFilter(opts);
362
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
311
363
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
312
364
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
313
365
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
@@ -372,6 +424,8 @@ function getCachedOverview(opts = {}) {
372
424
  function getCachedDailyActivity(opts = {}) {
373
425
  const conditions = [];
374
426
  const params = [];
427
+ const hf = hiddenFolderFilter(opts);
428
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
375
429
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
376
430
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
377
431
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
@@ -402,6 +456,8 @@ function getCachedDailyActivity(opts = {}) {
402
456
  function getCachedDeepAnalytics(opts = {}) {
403
457
  let sql = 'SELECT cs.* FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id WHERE 1=1';
404
458
  const params = [];
459
+ const hf = hiddenFolderFilter(opts, 'c.folder');
460
+ if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
405
461
  if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
406
462
  if (opts.folder) { sql += ' AND c.folder = ?'; params.push(opts.folder); }
407
463
  if (opts.dateFrom) { sql += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
@@ -432,15 +488,23 @@ function getCachedDeepAnalytics(opts = {}) {
432
488
  } catch {}
433
489
  try {
434
490
  const models = JSON.parse(r.models);
435
- for (const m of models) { modelFreq[m] = (modelFreq[m] || 0) + 1; }
491
+ for (const m of models) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; }
436
492
  } catch {}
437
493
  }
438
494
 
495
+ // Estimate tokens from chars when no token data available
496
+ let tokensEstimated = false;
497
+ if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
498
+ totalInputTokens = Math.round(totalUserChars / 4);
499
+ totalOutputTokens = Math.round(totalAssistantChars / 4);
500
+ tokensEstimated = true;
501
+ }
502
+
439
503
  return {
440
504
  analyzedChats: rows.length,
441
505
  totalMessages, totalToolCalls,
442
506
  totalUserChars, totalAssistantChars,
443
- totalInputTokens, totalOutputTokens,
507
+ totalInputTokens, totalOutputTokens, tokensEstimated,
444
508
  totalCacheRead, totalCacheWrite,
445
509
  topTools: Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, count]) => ({ name, count })),
446
510
  topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([name, count]) => ({ name, count })),
@@ -495,6 +559,10 @@ function getCachedProjects(opts = {}) {
495
559
  // Build date filter
496
560
  let dateFilter = '';
497
561
  const dateParams = [];
562
+ if (!opts.includeHidden) {
563
+ const hf = hiddenFolderFilter(opts);
564
+ if (hf.sql) { dateFilter += hf.sql; dateParams.push(...hf.params); }
565
+ }
498
566
  if (opts.dateFrom) { dateFilter += ' AND COALESCE(last_updated_at, created_at) >= ?'; dateParams.push(opts.dateFrom); }
499
567
  if (opts.dateTo) { dateFilter += ' AND COALESCE(last_updated_at, created_at) <= ?'; dateParams.push(opts.dateTo); }
500
568
 
@@ -543,10 +611,18 @@ function getCachedProjects(opts = {}) {
543
611
  totalAssistantChars += s.total_assistant_chars;
544
612
  totalCacheRead += s.total_cache_read;
545
613
  totalCacheWrite += s.total_cache_write;
546
- try { for (const m of JSON.parse(s.models)) { modelFreq[m] = (modelFreq[m] || 0) + 1; } } catch {}
614
+ try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
547
615
  try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch {}
548
616
  }
549
617
 
618
+ // Estimate tokens from chars when no token data available
619
+ let tokensEstimated = false;
620
+ if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
621
+ totalInputTokens = Math.round(totalUserChars / 4);
622
+ totalOutputTokens = Math.round(totalAssistantChars / 4);
623
+ tokensEstimated = true;
624
+ }
625
+
550
626
  result.push({
551
627
  folder: proj.folder,
552
628
  name: proj.folder.split('/').pop(),
@@ -556,7 +632,7 @@ function getCachedProjects(opts = {}) {
556
632
  lastSeen: proj.lastSeen,
557
633
  totalMessages,
558
634
  totalInputTokens,
559
- totalOutputTokens,
635
+ totalOutputTokens, tokensEstimated,
560
636
  totalUserChars,
561
637
  totalAssistantChars,
562
638
  totalToolCalls,
@@ -690,6 +766,8 @@ function getCachedDashboardStats(opts = {}) {
690
766
  // Build conditions dynamically to support editor + date range filters
691
767
  const conditions = [];
692
768
  const params = [];
769
+ const hf = hiddenFolderFilter(opts);
770
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
693
771
  if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
694
772
  if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
695
773
  if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
@@ -818,7 +896,7 @@ function getCachedDashboardStats(opts = {}) {
818
896
  `).all(...params);
819
897
  const modelFreq = {};
820
898
  for (const r of modelRows) {
821
- try { for (const m of JSON.parse(r.models)) modelFreq[m] = (modelFreq[m] || 0) + 1; } catch {}
899
+ try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
822
900
  }
823
901
  const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
824
902
 
@@ -833,18 +911,29 @@ function getCachedDashboardStats(opts = {}) {
833
911
  }
834
912
  const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
835
913
 
914
+ // If no token data but chars exist, estimate tokens (~4 chars/token)
915
+ let inputTokens = tokenRow.input;
916
+ let outputTokens = tokenRow.output;
917
+ let tokensEstimated = false;
918
+ if (inputTokens === 0 && outputTokens === 0 && (tokenRow.userChars > 0 || tokenRow.assistantChars > 0)) {
919
+ inputTokens = Math.round(tokenRow.userChars / 4);
920
+ outputTokens = Math.round(tokenRow.assistantChars / 4);
921
+ tokensEstimated = true;
922
+ }
923
+
836
924
  return {
837
925
  hourly,
838
926
  weekdays,
839
927
  depthBuckets,
840
928
  tokens: {
841
- input: tokenRow.input,
842
- output: tokenRow.output,
929
+ input: inputTokens,
930
+ output: outputTokens,
843
931
  cacheRead: tokenRow.cacheRead,
844
932
  cacheWrite: tokenRow.cacheWrite,
845
933
  userChars: tokenRow.userChars,
846
934
  assistantChars: tokenRow.assistantChars,
847
935
  sessions: tokenRow.sessions,
936
+ estimated: tokensEstimated,
848
937
  },
849
938
  streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
850
939
  monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
@@ -855,6 +944,346 @@ function getCachedDashboardStats(opts = {}) {
855
944
  };
856
945
  }
857
946
 
947
+ // ============================================================
948
+ // Cost estimation
949
+ // ============================================================
950
+
951
+ function estimateCosts(whereClause = '', params = []) {
952
+ // Per-model token usage from messages table
953
+ const modelTokens = db.prepare(`
954
+ SELECT m.model, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
955
+ FROM messages m JOIN chats c ON m.chat_id = c.id
956
+ WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
957
+ GROUP BY m.model
958
+ `).all(...params);
959
+
960
+ // Orphaned tokens: messages with token data but NULL model.
961
+ // Attribute these to the session's dominant model from chat_stats.
962
+ const orphanRows = db.prepare(`
963
+ SELECT m.chat_id, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
964
+ FROM messages m JOIN chats c ON m.chat_id = c.id
965
+ WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
966
+ GROUP BY m.chat_id
967
+ `).all(...params);
968
+
969
+ const orphanByModel = {};
970
+ for (const r of orphanRows) {
971
+ const stat = db.prepare('SELECT models FROM chat_stats WHERE chat_id = ?').get(r.chat_id);
972
+ if (!stat) continue;
973
+ let models;
974
+ try { models = JSON.parse(stat.models || '[]'); } catch { continue; }
975
+ if (models.length === 0) continue;
976
+ const freq = {};
977
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
978
+ const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
979
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
980
+ orphanByModel[dominant].input += r.input || 0;
981
+ orphanByModel[dominant].output += r.output || 0;
982
+ }
983
+
984
+ // Cache tokens per session with dominant model
985
+ const cacheRows = db.prepare(`
986
+ SELECT cs.total_cache_read, cs.total_cache_write, cs.models
987
+ FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
988
+ WHERE (cs.total_cache_read > 0 OR cs.total_cache_write > 0)${whereClause}
989
+ `).all(...params);
990
+
991
+ // Aggregate cache tokens by dominant model
992
+ const cacheByModel = {};
993
+ for (const r of cacheRows) {
994
+ let models;
995
+ try { models = JSON.parse(r.models || '[]'); } catch { continue; }
996
+ if (models.length === 0) continue;
997
+ const freq = {};
998
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
999
+ const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1000
+ if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
1001
+ cacheByModel[dominant].cacheRead += r.total_cache_read;
1002
+ cacheByModel[dominant].cacheWrite += r.total_cache_write;
1003
+ }
1004
+
1005
+ // Char-based estimation: sessions with models + chars but zero tokens.
1006
+ // Estimate ~4 chars per token (user chars → input, assistant chars → output).
1007
+ const CHARS_PER_TOKEN = 4;
1008
+ const charRows = db.prepare(`
1009
+ SELECT cs.models, cs.total_user_chars as userChars, cs.total_assistant_chars as asstChars
1010
+ FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
1011
+ WHERE cs.models != '[]' AND cs.total_input_tokens = 0 AND cs.total_output_tokens = 0
1012
+ AND (cs.total_user_chars > 0 OR cs.total_assistant_chars > 0)${whereClause}
1013
+ `).all(...params);
1014
+
1015
+ for (const r of charRows) {
1016
+ let models;
1017
+ try { models = JSON.parse(r.models || '[]'); } catch { continue; }
1018
+ if (models.length === 0) continue;
1019
+ const freq = {};
1020
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
1021
+ const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1022
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1023
+ orphanByModel[dominant].input += Math.round((r.userChars || 0) / CHARS_PER_TOKEN);
1024
+ orphanByModel[dominant].output += Math.round((r.asstChars || 0) / CHARS_PER_TOKEN);
1025
+ }
1026
+
1027
+ // Sessions with token totals but empty models (e.g. Cursor composer chats).
1028
+ // Attribute to the dominant model from same editor source.
1029
+ const unmodeledRows = db.prepare(`
1030
+ SELECT c.source, cs.total_input_tokens as input, cs.total_output_tokens as output,
1031
+ cs.total_cache_read as cacheRead, cs.total_cache_write as cacheWrite
1032
+ FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
1033
+ WHERE cs.models = '[]' AND (cs.total_input_tokens > 0 OR cs.total_output_tokens > 0)${whereClause}
1034
+ `).all(...params);
1035
+
1036
+ if (unmodeledRows.length > 0) {
1037
+ // Find dominant model per source from sessions that DO have models
1038
+ const sourceModelFreq = {};
1039
+ const allSessions = db.prepare(`
1040
+ SELECT c.source, cs.models FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
1041
+ WHERE cs.models != '[]'${whereClause}
1042
+ `).all(...params);
1043
+ for (const s of allSessions) {
1044
+ let models;
1045
+ try { models = JSON.parse(s.models || '[]'); } catch { continue; }
1046
+ if (!sourceModelFreq[s.source]) sourceModelFreq[s.source] = {};
1047
+ for (const m of models) sourceModelFreq[s.source][m] = (sourceModelFreq[s.source][m] || 0) + 1;
1048
+ }
1049
+ // Global fallback: dominant model across all sources
1050
+ const globalFreq = {};
1051
+ for (const sf of Object.values(sourceModelFreq)) {
1052
+ for (const [m, c] of Object.entries(sf)) globalFreq[m] = (globalFreq[m] || 0) + c;
1053
+ }
1054
+ const globalDominant = Object.entries(globalFreq).sort((a, b) => b[1] - a[1])[0]?.[0];
1055
+
1056
+ for (const r of unmodeledRows) {
1057
+ const sf = sourceModelFreq[r.source];
1058
+ const dominant = sf
1059
+ ? Object.entries(sf).sort((a, b) => b[1] - a[1])[0]?.[0]
1060
+ : globalDominant;
1061
+ if (!dominant) continue;
1062
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1063
+ orphanByModel[dominant].input += r.input || 0;
1064
+ orphanByModel[dominant].output += r.output || 0;
1065
+ // Also merge cache data
1066
+ if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
1067
+ cacheByModel[dominant].cacheRead += r.cacheRead || 0;
1068
+ cacheByModel[dominant].cacheWrite += r.cacheWrite || 0;
1069
+ }
1070
+ }
1071
+
1072
+ // Merge modelTokens + orphanByModel into a unified map, normalizing keys
1073
+ const tokenMap = {};
1074
+ const addTokens = (rawModel, input, output) => {
1075
+ const key = normalizeModelName(rawModel) || rawModel;
1076
+ if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0 };
1077
+ tokenMap[key].input += input || 0;
1078
+ tokenMap[key].output += output || 0;
1079
+ };
1080
+ for (const row of modelTokens) addTokens(row.model, row.input, row.output);
1081
+ for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output);
1082
+
1083
+ // Normalize cacheByModel keys
1084
+ const normCache = {};
1085
+ for (const [model, cache] of Object.entries(cacheByModel)) {
1086
+ const key = normalizeModelName(model) || model;
1087
+ if (!normCache[key]) normCache[key] = { cacheRead: 0, cacheWrite: 0 };
1088
+ normCache[key].cacheRead += cache.cacheRead;
1089
+ normCache[key].cacheWrite += cache.cacheWrite;
1090
+ }
1091
+
1092
+ let totalCost = 0;
1093
+ let knownCost = 0;
1094
+ let unknownModels = [];
1095
+ const byModel = [];
1096
+
1097
+ for (const [model, tok] of Object.entries(tokenMap)) {
1098
+ const cache = normCache[model] || { cacheRead: 0, cacheWrite: 0 };
1099
+ const cost = calculateCost(model, tok.input, tok.output, cache.cacheRead, cache.cacheWrite);
1100
+ if (cost !== null) {
1101
+ knownCost += cost;
1102
+ totalCost += cost;
1103
+ byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
1104
+ } else {
1105
+ unknownModels.push(model);
1106
+ }
1107
+ }
1108
+
1109
+ // Handle cache tokens for models that had cache but no message-level tokens
1110
+ for (const [model, cache] of Object.entries(normCache)) {
1111
+ if (!tokenMap[model]) {
1112
+ const cost = calculateCost(model, 0, 0, cache.cacheRead, cache.cacheWrite);
1113
+ if (cost !== null) {
1114
+ totalCost += cost;
1115
+ byModel.push({ model, inputTokens: 0, outputTokens: 0, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ byModel.sort((a, b) => b.cost - a.cost);
1121
+ unknownModels = [...new Set(unknownModels)];
1122
+
1123
+ return { totalCost, byModel, unknownModels };
1124
+ }
1125
+
1126
+ function getCostBreakdown(opts = {}) {
1127
+ let whereClause = '';
1128
+ const params = [];
1129
+ const hf = hiddenFolderFilter(opts, 'c.folder');
1130
+ if (hf.sql) { whereClause += hf.sql; params.push(...hf.params); }
1131
+ if (opts.editor) { whereClause += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
1132
+ if (opts.folder) { whereClause += ' AND c.folder = ?'; params.push(opts.folder); }
1133
+ if (opts.dateFrom) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
1134
+ if (opts.dateTo) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) <= ?'; params.push(opts.dateTo); }
1135
+ if (opts.chatId) { whereClause += ' AND c.id = ?'; params.push(opts.chatId); }
1136
+ return estimateCosts(whereClause, params);
1137
+ }
1138
+
1139
+ function getCostAnalytics(opts = {}) {
1140
+ const conditions = [];
1141
+ const params = [];
1142
+ const hf = hiddenFolderFilter(opts, 'c.folder');
1143
+ if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
1144
+ if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
1145
+ if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
1146
+ if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
1147
+ const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
1148
+
1149
+ // Overall cost breakdown by model
1150
+ const overall = getCostBreakdown(opts);
1151
+
1152
+ // Cost by editor: get costs per source
1153
+ const editorRows = db.prepare(`
1154
+ SELECT DISTINCT c.source FROM chats c WHERE c.source IS NOT NULL${whereAnd}
1155
+ `).all(...params);
1156
+ const byEditor = [];
1157
+ for (const { source } of editorRows) {
1158
+ const editorOpts = { ...opts, editor: source };
1159
+ const ec = getCostBreakdown(editorOpts);
1160
+ if (ec.totalCost > 0) {
1161
+ byEditor.push({ editor: source, cost: ec.totalCost, models: ec.byModel.length });
1162
+ }
1163
+ }
1164
+ byEditor.sort((a, b) => b.cost - a.cost);
1165
+
1166
+ // Cost by project (top 20)
1167
+ const projectRows = db.prepare(`
1168
+ SELECT c.folder, COUNT(*) as sessions FROM chats c
1169
+ WHERE c.folder IS NOT NULL${whereAnd}
1170
+ GROUP BY c.folder ORDER BY sessions DESC LIMIT 30
1171
+ `).all(...params);
1172
+ const byProject = [];
1173
+ for (const { folder } of projectRows) {
1174
+ const pc = getCostBreakdown({ ...opts, folder });
1175
+ if (pc.totalCost > 0) {
1176
+ byProject.push({ folder, name: folder.split('/').pop(), cost: pc.totalCost });
1177
+ }
1178
+ }
1179
+ byProject.sort((a, b) => b.cost - a.cost);
1180
+
1181
+ // Monthly trend
1182
+ const monthRows = db.prepare(`
1183
+ SELECT
1184
+ substr(date(COALESCE(c.last_updated_at, c.created_at)/1000, 'unixepoch'), 1, 7) as month,
1185
+ c.id, c.source,
1186
+ cs.models AS _models,
1187
+ cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
1188
+ cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
1189
+ cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars
1190
+ FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1191
+ WHERE (c.last_updated_at IS NOT NULL OR c.created_at IS NOT NULL)${whereAnd}
1192
+ ORDER BY month
1193
+ `).all(...params);
1194
+ const monthCosts = {};
1195
+ for (const r of monthRows) {
1196
+ if (!r.month) continue;
1197
+ let topModel = null;
1198
+ try {
1199
+ const models = JSON.parse(r._models || '[]');
1200
+ if (models.length > 0) {
1201
+ const freq = {};
1202
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
1203
+ topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1204
+ }
1205
+ } catch {}
1206
+ if (!topModel) continue;
1207
+ let inTok = r.inTok || 0, outTok = r.outTok || 0;
1208
+ if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
1209
+ inTok = Math.round((r.uChars || 0) / 4);
1210
+ outTok = Math.round((r.aChars || 0) / 4);
1211
+ }
1212
+ const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
1213
+ if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
1214
+ monthCosts[r.month].cost += cost;
1215
+ monthCosts[r.month].sessions++;
1216
+ }
1217
+ const monthly = Object.entries(monthCosts).sort((a, b) => a[0].localeCompare(b[0]))
1218
+ .map(([month, d]) => ({ month, cost: Math.round(d.cost * 100) / 100, sessions: d.sessions }));
1219
+
1220
+ // Top expensive sessions
1221
+ const sessionRows = db.prepare(`
1222
+ SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
1223
+ cs.models AS _models,
1224
+ cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
1225
+ cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
1226
+ cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars,
1227
+ cs.total_messages AS msgs
1228
+ FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1229
+ WHERE 1=1${whereAnd}
1230
+ `).all(...params);
1231
+ const sessionCosts = [];
1232
+ for (const r of sessionRows) {
1233
+ let topModel = null;
1234
+ try {
1235
+ const models = JSON.parse(r._models || '[]');
1236
+ if (models.length > 0) {
1237
+ const freq = {};
1238
+ for (const m of models) freq[m] = (freq[m] || 0) + 1;
1239
+ topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1240
+ }
1241
+ } catch {}
1242
+ if (!topModel) continue;
1243
+ let inTok = r.inTok || 0, outTok = r.outTok || 0;
1244
+ if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
1245
+ inTok = Math.round((r.uChars || 0) / 4);
1246
+ outTok = Math.round((r.aChars || 0) / 4);
1247
+ }
1248
+ const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
1249
+ if (cost > 0) {
1250
+ sessionCosts.push({
1251
+ id: r.id, source: r.source, name: r.name, folder: r.folder,
1252
+ model: normalizeModelName(topModel) || topModel,
1253
+ cost, messages: r.msgs || 0,
1254
+ lastUpdatedAt: r.last_updated_at || r.created_at,
1255
+ });
1256
+ }
1257
+ }
1258
+ sessionCosts.sort((a, b) => b.cost - a.cost);
1259
+
1260
+ // Summary stats
1261
+ const totalSessions = sessionCosts.length;
1262
+ const avgPerSession = totalSessions > 0 ? overall.totalCost / totalSessions : 0;
1263
+ const totalDays = monthly.length > 0 ? (() => {
1264
+ const first = new Date(monthly[0].month + '-01');
1265
+ const last = new Date(monthly[monthly.length - 1].month + '-01');
1266
+ return Math.max(1, Math.ceil((last - first) / 86400000) + 30);
1267
+ })() : 1;
1268
+ const avgPerDay = overall.totalCost / totalDays;
1269
+
1270
+ return {
1271
+ totalCost: overall.totalCost,
1272
+ byModel: overall.byModel,
1273
+ unknownModels: overall.unknownModels,
1274
+ byEditor,
1275
+ byProject: byProject.slice(0, 20),
1276
+ monthly,
1277
+ topSessions: sessionCosts.slice(0, 50),
1278
+ summary: {
1279
+ totalSessions,
1280
+ avgPerSession: Math.round(avgPerSession * 100) / 100,
1281
+ avgPerDay: Math.round(avgPerDay * 100) / 100,
1282
+ totalDays,
1283
+ },
1284
+ };
1285
+ }
1286
+
858
1287
  function getDb() { return db; }
859
1288
 
860
1289
  module.exports = {
@@ -871,5 +1300,7 @@ module.exports = {
871
1300
  resetAndRescan,
872
1301
  resetAndRescanAsync,
873
1302
  getCachedDashboardStats,
1303
+ getCostBreakdown,
1304
+ getCostAnalytics,
874
1305
  getDb,
875
1306
  };
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 };