agentlytics 0.2.6 → 0.2.8

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/cache.js CHANGED
@@ -7,7 +7,7 @@ const { calculateCost, getModelPricing, normalizeModelName } = require('./pricin
7
7
 
8
8
  const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
9
9
  const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
10
- const SCHEMA_VERSION = 5; // bump this when schema changes to auto-revalidate
10
+ const SCHEMA_VERSION = 6; // bump this when schema changes to auto-revalidate
11
11
 
12
12
  /**
13
13
  * Normalize a folder path for consistent storage/lookup.
@@ -128,6 +128,8 @@ function initDb() {
128
128
  model TEXT,
129
129
  input_tokens INTEGER,
130
130
  output_tokens INTEGER,
131
+ cache_read INTEGER,
132
+ cache_write INTEGER,
131
133
  FOREIGN KEY (chat_id) REFERENCES chats(id)
132
134
  );
133
135
 
@@ -163,7 +165,7 @@ function initDb() {
163
165
  try {
164
166
  const row = db.prepare("SELECT value FROM meta WHERE key = 'folder_norm_v'").get();
165
167
  if (row) normV = parseInt(row.value) || 0;
166
- } catch {}
168
+ } catch { }
167
169
  if (normV < 2) {
168
170
  const chatRows = db.prepare('SELECT id, folder FROM chats WHERE folder IS NOT NULL').all();
169
171
  const updChat = db.prepare('UPDATE chats SET folder = ? WHERE id = ?');
@@ -197,8 +199,8 @@ const insertStat = () => db.prepare(`
197
199
  `);
198
200
 
199
201
  const insertMsg = () => db.prepare(`
200
- INSERT INTO messages (chat_id, seq, role, content, model, input_tokens, output_tokens)
201
- VALUES (?, ?, ?, ?, ?, ?, ?)
202
+ INSERT INTO messages (chat_id, seq, role, content, model, input_tokens, output_tokens, cache_read, cache_write)
203
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
202
204
  `);
203
205
  const updateChatBubbleCount = () => db.prepare(`
204
206
  UPDATE chats SET bubble_count = ? WHERE id = ?
@@ -244,7 +246,7 @@ function analyzeAndStore(chat) {
244
246
  stats.toolCalls.push(tc.name);
245
247
  try {
246
248
  insTc.run(chat.composerId, tc.name, JSON.stringify(tc.args || {}), chat.source, chat.folder || null, chatTs);
247
- } catch {}
249
+ } catch { }
248
250
  }
249
251
  } else {
250
252
  const toolMatches = text.match(/\[tool-call: ([^\]]+)\]/g);
@@ -269,7 +271,7 @@ function analyzeAndStore(chat) {
269
271
 
270
272
  // Store message (truncate very long content for storage)
271
273
  const storedContent = text.length > 50000 ? text.substring(0, 50000) : text;
272
- ins.run(chat.composerId, seq++, msg.role, storedContent, msg._model || null, msg._inputTokens || null, msg._outputTokens || null);
274
+ ins.run(chat.composerId, seq++, msg.role, storedContent, msg._model || null, msg._inputTokens || null, msg._outputTokens || null, msg._cacheRead || null, msg._cacheWrite || null);
273
275
  }
274
276
 
275
277
  updBubbleCount.run(messages.length, chat.composerId);
@@ -398,7 +400,7 @@ function getCachedChats(opts = {}) {
398
400
  for (const m of models) freq[m] = (freq[m] || 0) + 1;
399
401
  r.top_model = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
400
402
  }
401
- } catch {}
403
+ } catch { }
402
404
  // Per-session cost estimate
403
405
  let inTok = r._inTok || 0, outTok = r._outTok || 0;
404
406
  if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
@@ -558,11 +560,11 @@ function getCachedDeepAnalytics(opts = {}) {
558
560
  try {
559
561
  const tools = JSON.parse(r.tool_calls);
560
562
  for (const t of tools) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; }
561
- } catch {}
563
+ } catch { }
562
564
  try {
563
565
  const models = JSON.parse(r.models);
564
566
  for (const m of models) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; }
565
- } catch {}
567
+ } catch { }
566
568
  }
567
569
 
568
570
  // Estimate tokens from chars when no token data available
@@ -589,7 +591,7 @@ function getCachedChat(id) {
589
591
  if (!chat) return null;
590
592
 
591
593
  const stats = db.prepare('SELECT * FROM chat_stats WHERE chat_id = ?').get(chat.id);
592
- let messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
594
+ let messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens, cache_read, cache_write FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
593
595
 
594
596
  // If no cached messages, try fetching live from the editor
595
597
  if (messages.length === 0 && !chat.encrypted) {
@@ -604,10 +606,10 @@ function getCachedChat(id) {
604
606
  const liveMessages = getMessages(reconstructed);
605
607
  if (liveMessages && liveMessages.length > 0) {
606
608
  // Store for next time
607
- try { analyzeAndStore(reconstructed); } catch {}
608
- messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
609
+ try { analyzeAndStore(reconstructed); } catch { }
610
+ messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens, cache_read, cache_write FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
609
611
  }
610
- } catch {}
612
+ } catch { }
611
613
  }
612
614
 
613
615
  let parsedStats = null;
@@ -703,8 +705,8 @@ function getCachedProjects(opts = {}) {
703
705
  totalAssistantChars += s.total_assistant_chars;
704
706
  totalCacheRead += s.total_cache_read;
705
707
  totalCacheWrite += s.total_cache_write;
706
- try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
707
- try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch {}
708
+ try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch { }
709
+ try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch { }
708
710
  }
709
711
 
710
712
  // Estimate tokens from chars when no token data available
@@ -778,9 +780,10 @@ async function scanAllAsync(onProgress, opts = {}) {
778
780
  let analyzed = 0;
779
781
  let skipped = 0;
780
782
 
783
+ // Build existing cache map with both timestamp and bubble count
781
784
  const existing = {};
782
- for (const row of db.prepare('SELECT id, last_updated_at FROM chats').all()) {
783
- existing[row.id] = row.last_updated_at;
785
+ for (const row of db.prepare('SELECT id, last_updated_at, bubble_count FROM chats').all()) {
786
+ existing[row.id] = { ts: row.last_updated_at, bc: row.bubble_count };
784
787
  }
785
788
 
786
789
  // Normalize folder paths
@@ -803,10 +806,12 @@ async function scanAllAsync(onProgress, opts = {}) {
803
806
 
804
807
  for (const chat of chats) {
805
808
  scanned++;
806
- const cachedTs = existing[chat.composerId];
807
809
  const chatTs = chat.lastUpdatedAt || chat.createdAt || 0;
810
+ const chatBc = chat.bubbleCount || 0;
808
811
 
809
- if (cachedTs && cachedTs >= chatTs) {
812
+ // Skip if already cached, not updated, and bubble count hasn't grown
813
+ const cached = existing[chat.composerId];
814
+ if (cached && cached.ts && cached.ts >= chatTs && cached.bc >= chatBc) {
810
815
  const hasStat = db.prepare('SELECT 1 FROM chat_stats WHERE chat_id = ?').get(chat.composerId);
811
816
  if (hasStat) {
812
817
  skipped++;
@@ -982,7 +987,7 @@ function getCachedDashboardStats(opts = {}) {
982
987
  `).all(...params);
983
988
  const modelFreq = {};
984
989
  for (const r of modelRows) {
985
- try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
990
+ try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch { }
986
991
  }
987
992
  const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
988
993
 
@@ -993,7 +998,7 @@ function getCachedDashboardStats(opts = {}) {
993
998
  const toolFreq = {};
994
999
  let totalToolCalls = 0;
995
1000
  for (const r of toolRows) {
996
- try { for (const t of JSON.parse(r.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch {}
1001
+ try { for (const t of JSON.parse(r.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch { }
997
1002
  }
998
1003
  const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
999
1004
 
@@ -1035,20 +1040,24 @@ function getCachedDashboardStats(opts = {}) {
1035
1040
  // ============================================================
1036
1041
 
1037
1042
  function estimateCosts(whereClause = '', params = []) {
1038
- // Per-model token usage from messages table
1043
+ // Per-model token usage from messages table (including cache tokens)
1039
1044
  const modelTokens = db.prepare(`
1040
- SELECT m.model, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
1045
+ SELECT m.model,
1046
+ SUM(m.input_tokens) as input, SUM(m.output_tokens) as output,
1047
+ SUM(m.cache_read) as cacheRead, SUM(m.cache_write) as cacheWrite
1041
1048
  FROM messages m JOIN chats c ON m.chat_id = c.id
1042
- WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
1049
+ WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read > 0 OR m.cache_write > 0)${whereClause}
1043
1050
  GROUP BY m.model
1044
1051
  `).all(...params);
1045
1052
 
1046
1053
  // Orphaned tokens: messages with token data but NULL model.
1047
1054
  // Attribute these to the session's dominant model from chat_stats.
1048
1055
  const orphanRows = db.prepare(`
1049
- SELECT m.chat_id, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
1056
+ SELECT m.chat_id,
1057
+ SUM(m.input_tokens) as input, SUM(m.output_tokens) as output,
1058
+ SUM(m.cache_read) as cacheRead, SUM(m.cache_write) as cacheWrite
1050
1059
  FROM messages m JOIN chats c ON m.chat_id = c.id
1051
- WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
1060
+ WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read > 0 OR m.cache_write > 0)${whereClause}
1052
1061
  GROUP BY m.chat_id
1053
1062
  `).all(...params);
1054
1063
 
@@ -1062,30 +1071,11 @@ function estimateCosts(whereClause = '', params = []) {
1062
1071
  const freq = {};
1063
1072
  for (const m of models) freq[m] = (freq[m] || 0) + 1;
1064
1073
  const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1065
- if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1074
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1066
1075
  orphanByModel[dominant].input += r.input || 0;
1067
1076
  orphanByModel[dominant].output += r.output || 0;
1068
- }
1069
-
1070
- // Cache tokens per session with dominant model
1071
- const cacheRows = db.prepare(`
1072
- SELECT cs.total_cache_read, cs.total_cache_write, cs.models
1073
- FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
1074
- WHERE (cs.total_cache_read > 0 OR cs.total_cache_write > 0)${whereClause}
1075
- `).all(...params);
1076
-
1077
- // Aggregate cache tokens by dominant model
1078
- const cacheByModel = {};
1079
- for (const r of cacheRows) {
1080
- let models;
1081
- try { models = JSON.parse(r.models || '[]'); } catch { continue; }
1082
- if (models.length === 0) continue;
1083
- const freq = {};
1084
- for (const m of models) freq[m] = (freq[m] || 0) + 1;
1085
- const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1086
- if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
1087
- cacheByModel[dominant].cacheRead += r.total_cache_read;
1088
- cacheByModel[dominant].cacheWrite += r.total_cache_write;
1077
+ orphanByModel[dominant].cacheRead += r.cacheRead || 0;
1078
+ orphanByModel[dominant].cacheWrite += r.cacheWrite || 0;
1089
1079
  }
1090
1080
 
1091
1081
  // Char-based estimation: sessions with models + chars but zero tokens.
@@ -1105,7 +1095,7 @@ function estimateCosts(whereClause = '', params = []) {
1105
1095
  const freq = {};
1106
1096
  for (const m of models) freq[m] = (freq[m] || 0) + 1;
1107
1097
  const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1108
- if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1098
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1109
1099
  orphanByModel[dominant].input += Math.round((r.userChars || 0) / CHARS_PER_TOKEN);
1110
1100
  orphanByModel[dominant].output += Math.round((r.asstChars || 0) / CHARS_PER_TOKEN);
1111
1101
  }
@@ -1145,64 +1135,41 @@ function estimateCosts(whereClause = '', params = []) {
1145
1135
  ? Object.entries(sf).sort((a, b) => b[1] - a[1])[0]?.[0]
1146
1136
  : globalDominant;
1147
1137
  if (!dominant) continue;
1148
- if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
1138
+ if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1149
1139
  orphanByModel[dominant].input += r.input || 0;
1150
1140
  orphanByModel[dominant].output += r.output || 0;
1151
- // Also merge cache data
1152
- if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
1153
- cacheByModel[dominant].cacheRead += r.cacheRead || 0;
1154
- cacheByModel[dominant].cacheWrite += r.cacheWrite || 0;
1141
+ orphanByModel[dominant].cacheRead += r.cacheRead || 0;
1142
+ orphanByModel[dominant].cacheWrite += r.cacheWrite || 0;
1155
1143
  }
1156
1144
  }
1157
1145
 
1158
1146
  // Merge modelTokens + orphanByModel into a unified map, normalizing keys
1159
1147
  const tokenMap = {};
1160
- const addTokens = (rawModel, input, output) => {
1148
+ const addTokens = (rawModel, input, output, cacheRead, cacheWrite) => {
1161
1149
  const key = normalizeModelName(rawModel) || rawModel;
1162
- if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0 };
1150
+ if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1163
1151
  tokenMap[key].input += input || 0;
1164
1152
  tokenMap[key].output += output || 0;
1153
+ tokenMap[key].cacheRead += cacheRead || 0;
1154
+ tokenMap[key].cacheWrite += cacheWrite || 0;
1165
1155
  };
1166
- for (const row of modelTokens) addTokens(row.model, row.input, row.output);
1167
- for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output);
1168
-
1169
- // Normalize cacheByModel keys
1170
- const normCache = {};
1171
- for (const [model, cache] of Object.entries(cacheByModel)) {
1172
- const key = normalizeModelName(model) || model;
1173
- if (!normCache[key]) normCache[key] = { cacheRead: 0, cacheWrite: 0 };
1174
- normCache[key].cacheRead += cache.cacheRead;
1175
- normCache[key].cacheWrite += cache.cacheWrite;
1176
- }
1156
+ for (const row of modelTokens) addTokens(row.model, row.input, row.output, row.cacheRead, row.cacheWrite);
1157
+ for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
1177
1158
 
1178
1159
  let totalCost = 0;
1179
- let knownCost = 0;
1180
1160
  let unknownModels = [];
1181
1161
  const byModel = [];
1182
1162
 
1183
1163
  for (const [model, tok] of Object.entries(tokenMap)) {
1184
- const cache = normCache[model] || { cacheRead: 0, cacheWrite: 0 };
1185
- const cost = calculateCost(model, tok.input, tok.output, cache.cacheRead, cache.cacheWrite);
1164
+ const cost = calculateCost(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
1186
1165
  if (cost !== null) {
1187
- knownCost += cost;
1188
1166
  totalCost += cost;
1189
- byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
1167
+ byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: tok.cacheRead, cacheWrite: tok.cacheWrite, cost });
1190
1168
  } else {
1191
1169
  unknownModels.push(model);
1192
1170
  }
1193
1171
  }
1194
1172
 
1195
- // Handle cache tokens for models that had cache but no message-level tokens
1196
- for (const [model, cache] of Object.entries(normCache)) {
1197
- if (!tokenMap[model]) {
1198
- const cost = calculateCost(model, 0, 0, cache.cacheRead, cache.cacheWrite);
1199
- if (cost !== null) {
1200
- totalCost += cost;
1201
- byModel.push({ model, inputTokens: 0, outputTokens: 0, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
1202
- }
1203
- }
1204
- }
1205
-
1206
1173
  byModel.sort((a, b) => b.cost - a.cost);
1207
1174
  unknownModels = [...new Set(unknownModels)];
1208
1175
 
@@ -1230,116 +1197,77 @@ function getCostAnalytics(opts = {}) {
1230
1197
  if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
1231
1198
  if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
1232
1199
  if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
1200
+ if (opts.folder) { conditions.push('c.folder = ?'); params.push(opts.folder); }
1233
1201
  const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
1234
1202
 
1235
1203
  // Overall cost breakdown by model
1236
1204
  const overall = getCostBreakdown(opts);
1237
1205
 
1238
- // Cost by editor: get costs per source
1239
- const editorRows = db.prepare(`
1240
- SELECT DISTINCT c.source FROM chats c WHERE c.source IS NOT NULL${whereAnd}
1206
+ // Per-chat cost map (single pass reused for all breakdowns)
1207
+ const sessionRows = db.prepare(`
1208
+ SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
1209
+ cs.total_messages AS msgs,
1210
+ substr(date(COALESCE(c.last_updated_at, c.created_at)/1000, 'unixepoch'), 1, 7) as month
1211
+ FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1212
+ WHERE 1=1${whereAnd}
1241
1213
  `).all(...params);
1242
- const byEditor = [];
1243
- for (const { source } of editorRows) {
1244
- const editorOpts = { ...opts, editor: source };
1245
- const ec = getCostBreakdown(editorOpts);
1246
- if (ec.totalCost > 0) {
1247
- byEditor.push({ editor: source, cost: ec.totalCost, models: ec.byModel.length });
1248
- }
1214
+
1215
+ const chatCostCache = new Map();
1216
+ for (const r of sessionRows) {
1217
+ chatCostCache.set(r.id, getCostBreakdown({ ...opts, chatId: r.id }));
1249
1218
  }
1250
- byEditor.sort((a, b) => b.cost - a.cost);
1251
1219
 
1252
- // Cost by project (top 20)
1253
- const projectRows = db.prepare(`
1254
- SELECT c.folder, COUNT(*) as sessions FROM chats c
1255
- WHERE c.folder IS NOT NULL${whereAnd}
1256
- GROUP BY c.folder ORDER BY sessions DESC LIMIT 30
1257
- `).all(...params);
1258
- const byProject = [];
1259
- for (const { folder } of projectRows) {
1260
- const pc = getCostBreakdown({ ...opts, folder });
1261
- if (pc.totalCost > 0) {
1262
- byProject.push({ folder, name: folder.split('/').pop(), cost: pc.totalCost });
1263
- }
1220
+ // Derive byEditor from cached per-chat costs
1221
+ const editorAgg = {};
1222
+ for (const r of sessionRows) {
1223
+ const sc = chatCostCache.get(r.id);
1224
+ if (!sc || sc.totalCost <= 0) continue;
1225
+ if (!editorAgg[r.source]) editorAgg[r.source] = { cost: 0, models: new Set() };
1226
+ editorAgg[r.source].cost += sc.totalCost;
1227
+ for (const m of sc.byModel) editorAgg[r.source].models.add(m.model);
1264
1228
  }
1265
- byProject.sort((a, b) => b.cost - a.cost);
1229
+ const byEditor = Object.entries(editorAgg)
1230
+ .map(([editor, d]) => ({ editor, cost: d.cost, models: d.models.size }))
1231
+ .sort((a, b) => b.cost - a.cost);
1266
1232
 
1267
- // Monthly trend
1268
- const monthRows = db.prepare(`
1269
- SELECT
1270
- substr(date(COALESCE(c.last_updated_at, c.created_at)/1000, 'unixepoch'), 1, 7) as month,
1271
- c.id, c.source,
1272
- cs.models AS _models,
1273
- cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
1274
- cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
1275
- cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars
1276
- FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1277
- WHERE (c.last_updated_at IS NOT NULL OR c.created_at IS NOT NULL)${whereAnd}
1278
- ORDER BY month
1279
- `).all(...params);
1233
+ // Derive byProject from cached per-chat costs
1234
+ const projectAgg = {};
1235
+ for (const r of sessionRows) {
1236
+ if (!r.folder) continue;
1237
+ const sc = chatCostCache.get(r.id);
1238
+ if (!sc || sc.totalCost <= 0) continue;
1239
+ if (!projectAgg[r.folder]) projectAgg[r.folder] = 0;
1240
+ projectAgg[r.folder] += sc.totalCost;
1241
+ }
1242
+ const byProject = Object.entries(projectAgg)
1243
+ .map(([folder, cost]) => ({ folder, name: folder.split('/').pop(), cost }))
1244
+ .sort((a, b) => b.cost - a.cost)
1245
+ .slice(0, 20);
1246
+
1247
+ // Monthly trend from cached per-chat costs
1280
1248
  const monthCosts = {};
1281
- for (const r of monthRows) {
1249
+ for (const r of sessionRows) {
1282
1250
  if (!r.month) continue;
1283
- let topModel = null;
1284
- try {
1285
- const models = JSON.parse(r._models || '[]');
1286
- if (models.length > 0) {
1287
- const freq = {};
1288
- for (const m of models) freq[m] = (freq[m] || 0) + 1;
1289
- topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1290
- }
1291
- } catch {}
1292
- if (!topModel) continue;
1293
- let inTok = r.inTok || 0, outTok = r.outTok || 0;
1294
- if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
1295
- inTok = Math.round((r.uChars || 0) / 4);
1296
- outTok = Math.round((r.aChars || 0) / 4);
1297
- }
1298
- const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
1251
+ const sc = chatCostCache.get(r.id);
1299
1252
  if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
1300
- monthCosts[r.month].cost += cost;
1253
+ monthCosts[r.month].cost += sc.totalCost;
1301
1254
  monthCosts[r.month].sessions++;
1302
1255
  }
1303
1256
  const monthly = Object.entries(monthCosts).sort((a, b) => a[0].localeCompare(b[0]))
1304
1257
  .map(([month, d]) => ({ month, cost: Math.round(d.cost * 100) / 100, sessions: d.sessions }));
1305
1258
 
1306
- // Top expensive sessions
1307
- const sessionRows = db.prepare(`
1308
- SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
1309
- cs.models AS _models,
1310
- cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
1311
- cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
1312
- cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars,
1313
- cs.total_messages AS msgs
1314
- FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
1315
- WHERE 1=1${whereAnd}
1316
- `).all(...params);
1259
+ // Top expensive sessions from cached per-chat costs
1317
1260
  const sessionCosts = [];
1318
1261
  for (const r of sessionRows) {
1319
- let topModel = null;
1320
- try {
1321
- const models = JSON.parse(r._models || '[]');
1322
- if (models.length > 0) {
1323
- const freq = {};
1324
- for (const m of models) freq[m] = (freq[m] || 0) + 1;
1325
- topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
1326
- }
1327
- } catch {}
1328
- if (!topModel) continue;
1329
- let inTok = r.inTok || 0, outTok = r.outTok || 0;
1330
- if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
1331
- inTok = Math.round((r.uChars || 0) / 4);
1332
- outTok = Math.round((r.aChars || 0) / 4);
1333
- }
1334
- const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
1335
- if (cost > 0) {
1336
- sessionCosts.push({
1337
- id: r.id, source: r.source, name: r.name, folder: r.folder,
1338
- model: normalizeModelName(topModel) || topModel,
1339
- cost, messages: r.msgs || 0,
1340
- lastUpdatedAt: r.last_updated_at || r.created_at,
1341
- });
1342
- }
1262
+ const sc = chatCostCache.get(r.id);
1263
+ if (!sc || sc.totalCost <= 0) continue;
1264
+ const topModel = sc.byModel.length > 0 ? sc.byModel[0].model : null;
1265
+ sessionCosts.push({
1266
+ id: r.id, source: r.source, name: r.name, folder: r.folder,
1267
+ model: topModel,
1268
+ cost: sc.totalCost, messages: r.msgs || 0,
1269
+ lastUpdatedAt: r.last_updated_at || r.created_at,
1270
+ });
1343
1271
  }
1344
1272
  sessionCosts.sort((a, b) => b.cost - a.cost);
1345
1273
 
@@ -1358,7 +1286,7 @@ function getCostAnalytics(opts = {}) {
1358
1286
  byModel: overall.byModel,
1359
1287
  unknownModels: overall.unknownModels,
1360
1288
  byEditor,
1361
- byProject: byProject.slice(0, 20),
1289
+ byProject,
1362
1290
  monthly,
1363
1291
  topSessions: sessionCosts.slice(0, 50),
1364
1292
  summary: {
@@ -855,6 +855,8 @@ function getMessages(chat) {
855
855
  // ============================================================
856
856
 
857
857
  function getUsage() {
858
+ const { isSubscriptionAccessAllowed } = require('./base');
859
+ if (!isSubscriptionAccessAllowed()) return null;
858
860
  const resp = callRpc('GetUserStatus', {});
859
861
  if (!resp || !resp.userStatus) return null;
860
862
 
package/editors/base.js CHANGED
@@ -1,8 +1,19 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
+ const fs = require('fs');
3
4
 
4
5
  const HOME = os.homedir();
5
6
 
7
+ // --- Permission check ---
8
+
9
+ function isSubscriptionAccessAllowed() {
10
+ try {
11
+ const configPath = path.join(HOME, '.agentlytics', 'config.json');
12
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
13
+ return config.allowSubscriptionAccess === true;
14
+ } catch { return false; }
15
+ }
16
+
6
17
  // --- Platform utilities ---
7
18
 
8
19
  /**
@@ -303,6 +314,7 @@ function queryMcpServerToolsStdio(server, timeout) {
303
314
 
304
315
  module.exports = {
305
316
  getAppDataPath,
317
+ isSubscriptionAccessAllowed,
306
318
  scanArtifacts,
307
319
  parseMcpConfigFile,
308
320
  queryMcpServerTools,
package/editors/claude.js CHANGED
@@ -204,18 +204,11 @@ function extractAssistantContent(content) {
204
204
  // Usage / quota data from Anthropic OAuth API
205
205
  // ============================================================
206
206
 
207
- function isKeychainAccessAllowed() {
208
- try {
209
- const configPath = path.join(os.homedir(), '.agentlytics', 'config.json');
210
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
211
- return config.allowKeychainAccess === true;
212
- } catch { return false; }
213
- }
214
-
215
207
  function getClaudeCredentials() {
216
208
  // macOS: Keychain; Linux: secret-tool; Windows: not yet supported
217
- // Requires explicit user permission (allowKeychainAccess in config)
218
- if (!isKeychainAccessAllowed()) return null;
209
+ // Requires explicit user permission (allowSubscriptionAccess in config)
210
+ const { isSubscriptionAccessAllowed } = require('./base');
211
+ if (!isSubscriptionAccessAllowed()) return null;
219
212
  try {
220
213
  const { execSync } = require('child_process');
221
214
  let raw;
package/editors/codex.js CHANGED
@@ -477,6 +477,8 @@ function decodeJwtPayload(token) {
477
477
  }
478
478
 
479
479
  async function getUsage() {
480
+ const { isSubscriptionAccessAllowed } = require('./base');
481
+ if (!isSubscriptionAccessAllowed()) return null;
480
482
  const auth = getCodexAuth();
481
483
  if (!auth || !auth.tokens) return null;
482
484
 
@@ -211,6 +211,8 @@ function fetchCopilotStatus(token) {
211
211
  }
212
212
 
213
213
  async function getUsage() {
214
+ const { isSubscriptionAccessAllowed } = require('./base');
215
+ if (!isSubscriptionAccessAllowed()) return null;
214
216
  const creds = getCopilotToken();
215
217
  if (!creds) return null;
216
218
 
package/editors/cursor.js CHANGED
@@ -374,6 +374,8 @@ function cursorApiFetch(endpoint, token) {
374
374
  }
375
375
 
376
376
  async function getUsage() {
377
+ const { isSubscriptionAccessAllowed } = require('./base');
378
+ if (!isSubscriptionAccessAllowed()) return null;
377
379
  const token = getCursorAccessToken();
378
380
  if (!token) return null;
379
381
 
@@ -36,6 +36,38 @@ function queryDb(sql) {
36
36
  }
37
37
  }
38
38
 
39
+ function extractModelInfo(data) {
40
+ let modelValue = null;
41
+ let providerValue = null;
42
+
43
+ if (typeof data?.modelID === 'string') {
44
+ modelValue = data.modelID;
45
+ providerValue = typeof data.providerID === 'string' ? data.providerID : null;
46
+ } else if (data?.model && typeof data.model === 'object') {
47
+ modelValue = typeof data.model.modelID === 'string' ? data.model.modelID : null;
48
+ providerValue = typeof data.providerID === 'string'
49
+ ? data.providerID
50
+ : (typeof data.model.providerID === 'string' ? data.model.providerID : null);
51
+ } else if (typeof data?.model === 'string') {
52
+ modelValue = data.model;
53
+ providerValue = typeof data.providerID === 'string' ? data.providerID : null;
54
+ }
55
+
56
+ return { modelValue, providerValue };
57
+ }
58
+
59
+ function extractTokenInfo(data) {
60
+ const tokens = data?.tokens && typeof data.tokens === 'object' ? data.tokens : null;
61
+ const cache = tokens?.cache && typeof tokens.cache === 'object' ? tokens.cache : null;
62
+
63
+ return {
64
+ inputTokens: tokens?.input,
65
+ outputTokens: tokens?.output,
66
+ cacheRead: cache?.read,
67
+ cacheWrite: cache?.write,
68
+ };
69
+ }
70
+
39
71
  function getSqliteSessions() {
40
72
  return queryDb(
41
73
  `SELECT s.id, s.title, s.directory, s.time_created, s.time_updated,
@@ -94,19 +126,18 @@ function getSqliteMessages(sessionId) {
94
126
  const content = contentParts.join('\n');
95
127
  if (!content) continue;
96
128
 
97
- let modelValue = null;
98
- if (typeof msgData.modelID === 'string') {
99
- modelValue = msgData.modelID;
100
- } else if (msgData.model && typeof msgData.model === 'object' && msgData.model.modelID) {
101
- modelValue = msgData.model.modelID;
102
- } else if (typeof msgData.model === 'string') {
103
- modelValue = msgData.model;
104
- }
129
+ const { modelValue, providerValue } = extractModelInfo(msgData);
130
+ const { inputTokens, outputTokens, cacheRead, cacheWrite } = extractTokenInfo(msgData);
105
131
 
106
132
  result.push({
107
133
  role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role,
108
134
  content,
109
135
  _model: modelValue,
136
+ _provider: providerValue,
137
+ _inputTokens: inputTokens,
138
+ _outputTokens: outputTokens,
139
+ _cacheRead: cacheRead,
140
+ _cacheWrite: cacheWrite,
110
141
  });
111
142
  }
112
143
 
@@ -165,12 +196,19 @@ function getMessagesForSession(sessionId) {
165
196
  let files;
166
197
  try { files = fs.readdirSync(sessionMsgDir).filter(f => f.startsWith('msg_') && f.endsWith('.json')); } catch { return []; }
167
198
 
168
- const messages = [];
199
+ const rawMsgs = [];
169
200
  for (const file of files) {
170
201
  const msgPath = path.join(sessionMsgDir, file);
171
202
  const msg = readJson(msgPath);
172
203
  if (!msg || !msg.id) continue;
204
+ rawMsgs.push(msg);
205
+ }
173
206
 
207
+ // Sort by creation time before building output
208
+ rawMsgs.sort((a, b) => (a.time?.created || 0) - (b.time?.created || 0));
209
+
210
+ const messages = [];
211
+ for (const msg of rawMsgs) {
174
212
  // Get parts for this message
175
213
  const msgPartDir = path.join(PART_DIR, msg.id);
176
214
  const parts = [];
@@ -213,35 +251,24 @@ function getMessagesForSession(sessionId) {
213
251
 
214
252
  const content = contentParts.join('\n');
215
253
  if (content) {
216
- // Extract model value - handle both string and object formats
217
- let modelValue = null;
218
- if (typeof msg.modelID === 'string') {
219
- modelValue = msg.modelID;
220
- } else if (msg.model && typeof msg.model === 'object' && msg.model.modelID) {
221
- modelValue = msg.model.modelID;
222
- } else if (typeof msg.model === 'string') {
223
- modelValue = msg.model;
224
- }
254
+ const { modelValue, providerValue } = extractModelInfo(msg);
255
+ const { inputTokens, outputTokens, cacheRead, cacheWrite } = extractTokenInfo(msg);
225
256
 
226
257
  messages.push({
227
258
  role: msg.role || 'assistant',
228
259
  content,
229
260
  _model: modelValue,
230
- _inputTokens: msg.tokens?.input,
231
- _outputTokens: msg.tokens?.output,
232
- _cacheRead: msg.tokens?.cache?.read,
233
- _cacheWrite: msg.tokens?.cache?.write,
261
+ _provider: providerValue,
262
+ _inputTokens: inputTokens,
263
+ _outputTokens: outputTokens,
264
+ _cacheRead: cacheRead,
265
+ _cacheWrite: cacheWrite,
234
266
  _finish: msg.finish,
235
267
  });
236
268
  }
237
269
  }
238
270
 
239
- // Sort by creation time
240
- return messages.sort((a, b) => {
241
- const aTime = a.time?.created || 0;
242
- const bTime = b.time?.created || 0;
243
- return aTime - bTime;
244
- });
271
+ return messages;
245
272
  }
246
273
 
247
274
  // ============================================================
package/editors/vscode.js CHANGED
@@ -355,6 +355,8 @@ function fetchCopilotStatus(token) {
355
355
  }
356
356
 
357
357
  async function getUsage() {
358
+ const { isSubscriptionAccessAllowed } = require('./base');
359
+ if (!isSubscriptionAccessAllowed()) return null;
358
360
  const creds = getCopilotToken();
359
361
  if (!creds) return null;
360
362
 
@@ -476,6 +476,9 @@ function getWindsurfApiKey(appName) {
476
476
  }
477
477
 
478
478
  function getUsage() {
479
+ const { isSubscriptionAccessAllowed } = require('./base');
480
+ if (!isSubscriptionAccessAllowed()) return [];
481
+
479
482
  const results = [];
480
483
 
481
484
  for (const variant of VARIANTS) {
package/index.js CHANGED
@@ -253,37 +253,48 @@ const BOT_STYLES = [
253
253
  ];
254
254
 
255
255
  (async () => {
256
- // ── Ask for keychain access permission (first run only) ──
256
+ // ── Ask for subscription access permission (first run only) ──
257
257
  const CONFIG_DIR = path.join(os.homedir(), '.agentlytics');
258
258
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
259
259
  let agentConfig = {};
260
260
  try { agentConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
261
261
 
262
- if (agentConfig.allowKeychainAccess === undefined) {
263
- // Only relevant on macOS / Linux where keychain/secret-tool is used
264
- if (process.platform === 'darwin' || process.platform === 'linux') {
265
- const storeName = process.platform === 'darwin' ? 'Keychain' : 'secret store';
266
- console.log(chalk.yellow(` ⚠ Some subscription details (e.g. Claude Code) require ${storeName} access.`));
267
- console.log(chalk.dim(` This reads stored credentials to show plan/usage info.`));
268
- console.log('');
269
- const readline = require('readline');
270
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
271
- const answer = await new Promise(r => {
272
- rl.question(chalk.bold(` Allow ${storeName} access for subscription details? (y/N) `), (a) => {
273
- rl.close();
274
- r(a.trim().toLowerCase());
275
- });
262
+ if (agentConfig.allowSubscriptionAccess === undefined) {
263
+ console.log(chalk.yellow(' ⚠ Subscription & usage details require access to local auth tokens.'));
264
+ console.log('');
265
+ console.log(chalk.dim(' To show your plan and usage info, Agentlytics needs to read'));
266
+ console.log(chalk.dim(' locally stored tokens from the following sources:'));
267
+ console.log('');
268
+ console.log(chalk.dim(' • Claude Code – macOS Keychain / Linux secret-tool'));
269
+ console.log(chalk.dim(' Cursor – local SQLite (state.vscdb)'));
270
+ console.log(chalk.dim(' • Copilot – ~/.config/github-copilot/apps.json'));
271
+ console.log(chalk.dim(' VS Code – ~/.config/github-copilot/apps.json'));
272
+ console.log(chalk.dim(' • Codex – local auth.json (JWT decode only)'));
273
+ console.log(chalk.dim(' • Windsurf – local SQLite (state.vscdb)'));
274
+ console.log('');
275
+ console.log(chalk.dim(' These tokens are used to query each editor\'s own API for'));
276
+ console.log(chalk.dim(' your plan name and usage limits.'));
277
+ console.log('');
278
+ console.log(chalk.bold.white(' → Tokens are kept in-memory only and never sent to any'));
279
+ console.log(chalk.bold.white(' third-party service. They are discarded after the request.'));
280
+ console.log('');
281
+ const readline = require('readline');
282
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
283
+ const answer = await new Promise(r => {
284
+ rl.question(chalk.bold(' Allow local token inspection for subscription details? (y/N) '), (a) => {
285
+ rl.close();
286
+ r(a.trim().toLowerCase());
276
287
  });
277
- agentConfig.allowKeychainAccess = answer === 'y' || answer === 'yes';
278
- if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
279
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(agentConfig, null, 2));
280
- if (agentConfig.allowKeychainAccess) {
281
- console.log(chalk.green(` ✓ ${storeName} access enabled`));
282
- } else {
283
- console.log(chalk.dim(` – ${storeName} access skipped (subscription details won't be collected)`));
284
- }
285
- console.log('');
288
+ });
289
+ agentConfig.allowSubscriptionAccess = answer === 'y' || answer === 'yes';
290
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
291
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(agentConfig, null, 2));
292
+ if (agentConfig.allowSubscriptionAccess) {
293
+ console.log(chalk.green(' ✓ Subscription access enabled'));
294
+ } else {
295
+ console.log(chalk.dim(' – Subscription access skipped (plan/usage details won\'t be collected)'));
286
296
  }
297
+ console.log('');
287
298
  }
288
299
 
289
300
  let tick = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlytics",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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": {
@@ -164,8 +164,9 @@ export default function Dashboard({ overview }) {
164
164
  } : null
165
165
 
166
166
  const tk = stats?.tokens
167
- const cacheHitRate = tk && tk.input > 0 ? ((tk.cacheRead / tk.input) * 100).toFixed(1) : 0
168
- const outputInputRatio = tk && tk.input > 0 ? (tk.output / tk.input).toFixed(2) : 0
167
+ const totalInputAll = tk ? tk.input + tk.cacheRead + (tk.cacheWrite || 0) : 0
168
+ const cacheHitRate = totalInputAll > 0 ? ((tk.cacheRead / totalInputAll) * 100).toFixed(1) : 0
169
+ const outputInputRatio = totalInputAll > 0 ? (tk.output / totalInputAll).toFixed(3) : 0
169
170
  const avgMsgsPerSession = tk && tk.sessions > 0 ? (depthData ? (Object.values(stats.depthBuckets).reduce((s, v, i) => {
170
171
  const labels = Object.keys(stats.depthBuckets)
171
172
  const midpoints = [1, 3.5, 8, 15.5, 35.5, 75.5, 150]
@@ -227,12 +227,13 @@ export default function DeepAnalysis({ overview }) {
227
227
  // Computed insights
228
228
  const insights = useMemo(() => {
229
229
  if (!data) return null
230
- const totalTok = data.totalInputTokens + data.totalOutputTokens
230
+ const totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheRead + data.totalCacheWrite
231
231
  const msgsPerSession = data.analyzedChats > 0 ? (data.totalMessages / data.analyzedChats).toFixed(1) : 0
232
232
  const toolsPerSession = data.analyzedChats > 0 ? (data.totalToolCalls / data.analyzedChats).toFixed(1) : 0
233
233
  const tokPerMsg = data.totalMessages > 0 ? Math.round(totalTok / data.totalMessages) : 0
234
- const cacheHitRate = data.totalInputTokens > 0 ? ((data.totalCacheRead / data.totalInputTokens) * 100).toFixed(1) : 0
235
- const outputRatio = data.totalInputTokens > 0 ? (data.totalOutputTokens / data.totalInputTokens).toFixed(2) : 0
234
+ const totalInputAll = data.totalInputTokens + data.totalCacheRead + data.totalCacheWrite
235
+ const cacheHitRate = totalInputAll > 0 ? ((data.totalCacheRead / totalInputAll) * 100).toFixed(1) : 0
236
+ const outputRatio = totalInputAll > 0 ? (data.totalOutputTokens / totalInputAll).toFixed(3) : 0
236
237
  const aiVsHuman = data.totalUserChars > 0 ? (data.totalAssistantChars / data.totalUserChars).toFixed(1) : 0
237
238
  return { totalTok, msgsPerSession, toolsPerSession, tokPerMsg, cacheHitRate, outputRatio, aiVsHuman }
238
239
  }, [data])
@@ -300,15 +301,17 @@ export default function DeepAnalysis({ overview }) {
300
301
  <div>
301
302
  <div className="flex items-center justify-between text-[11px] mb-1">
302
303
  <span style={{ color: 'var(--c-text2)' }}>input tokens</span>
303
- <span className="font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(data.totalInputTokens)}</span>
304
+ <span className="font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(data.totalInputTokens + data.totalCacheRead + data.totalCacheWrite)}</span>
304
305
  </div>
305
306
  <ProportionBar segments={[
306
- { label: 'Fresh input', value: data.totalInputTokens - data.totalCacheRead, color: '#6366f1' },
307
+ { label: 'Fresh input', value: data.totalInputTokens, color: '#6366f1' },
308
+ { label: 'Cache write', value: data.totalCacheWrite, color: '#fbbf24' },
307
309
  { label: 'Cache read', value: data.totalCacheRead, color: '#34d399' },
308
310
  ]} />
309
311
  <div className="flex items-center gap-3 mt-1 text-[10px]">
310
- <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#6366f1' }} /> fresh {formatNumber(data.totalInputTokens - data.totalCacheRead)}</span>
311
- <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#34d399' }} /> cached {formatNumber(data.totalCacheRead)}</span>
312
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#6366f1' }} /> fresh {formatNumber(data.totalInputTokens)}</span>
313
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#fbbf24' }} /> cache write {formatNumber(data.totalCacheWrite)}</span>
314
+ <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#34d399' }} /> cache read {formatNumber(data.totalCacheRead)}</span>
312
315
  </div>
313
316
  </div>
314
317
  {/* Output tokens */}
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect } from 'react'
2
- import { Settings as SettingsIcon, EyeOff, Eye, FolderOpen, Search } from 'lucide-react'
2
+ import { Settings as SettingsIcon, EyeOff, Eye, FolderOpen, Search, ShieldCheck, ShieldOff, AlertTriangle, X } from 'lucide-react'
3
3
  import { fetchConfig, updateConfig, fetchAllProjects } from '../lib/api'
4
4
  import { editorLabel, formatNumber, formatDate } from '../lib/constants'
5
5
  import EditorIcon from '../components/EditorIcon'
@@ -13,6 +13,7 @@ export default function Settings() {
13
13
  const [loading, setLoading] = useState(true)
14
14
  const [saving, setSaving] = useState(false)
15
15
  const [search, setSearch] = useState('')
16
+ const [showConfirm, setShowConfirm] = useState(false)
16
17
 
17
18
  useEffect(() => {
18
19
  Promise.all([fetchConfig(), fetchAllProjects()]).then(([cfg, projs]) => {
@@ -47,10 +48,45 @@ export default function Settings() {
47
48
 
48
49
  const sorted = [...filtered].sort((a, b) => a.name.localeCompare(b.name))
49
50
 
51
+ const subscriptionAccess = !!config.allowSubscriptionAccess
52
+
53
+ const toggleSubscriptionAccess = async () => {
54
+ setSaving(true)
55
+ const newConfig = await updateConfig({ allowSubscriptionAccess: !subscriptionAccess })
56
+ setConfig(newConfig)
57
+ setSaving(false)
58
+ setShowConfirm(false)
59
+ }
60
+
50
61
  return (
51
62
  <div className="fade-in space-y-3">
52
63
  <PageHeader icon={SettingsIcon} title="Settings" />
53
64
 
65
+ <div className="card overflow-hidden">
66
+ <div className="px-3 py-2 flex items-center justify-between" style={{ borderBottom: '1px solid var(--c-border)' }}>
67
+ <SectionTitle>
68
+ {subscriptionAccess ? <ShieldCheck size={11} className="inline mr-1" /> : <ShieldOff size={11} className="inline mr-1" />}
69
+ subscription access
70
+ </SectionTitle>
71
+ <button
72
+ onClick={() => setShowConfirm(true)}
73
+ disabled={saving}
74
+ className="text-[11px] px-2 py-0.5 rounded transition"
75
+ style={{
76
+ background: subscriptionAccess ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
77
+ color: subscriptionAccess ? '#22c55e' : '#ef4444',
78
+ border: `1px solid ${subscriptionAccess ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)'}`,
79
+ }}
80
+ >
81
+ {subscriptionAccess ? 'Enabled' : 'Disabled'}
82
+ </button>
83
+ </div>
84
+ <div className="text-[11px] px-3 py-2" style={{ color: 'var(--c-text3)' }}>
85
+ When enabled, Agentlytics reads locally stored auth tokens (Keychain, SQLite, config files) to show your plan and usage info for each editor.
86
+ Tokens are kept in-memory only and <span style={{ color: 'var(--c-text2)' }}>never sent to any third-party service</span>.
87
+ </div>
88
+ </div>
89
+
54
90
  <div className="card overflow-hidden">
55
91
  <div className="px-3 py-2 flex items-center justify-between" style={{ borderBottom: '1px solid var(--c-border)' }}>
56
92
  <SectionTitle>
@@ -88,6 +124,70 @@ export default function Settings() {
88
124
  <div className="text-center py-6 text-[12px]" style={{ color: 'var(--c-text3)' }}>no projects match filter</div>
89
125
  )}
90
126
  </div>
127
+ {showConfirm && (
128
+ <ConfirmModal
129
+ enabling={!subscriptionAccess}
130
+ saving={saving}
131
+ onConfirm={toggleSubscriptionAccess}
132
+ onCancel={() => setShowConfirm(false)}
133
+ />
134
+ )}
135
+ </div>
136
+ )
137
+ }
138
+
139
+ function ConfirmModal({ enabling, saving, onConfirm, onCancel }) {
140
+ return (
141
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }} onClick={onCancel}>
142
+ <div className="card w-[420px] mx-4" onClick={e => e.stopPropagation()} style={{ background: 'var(--c-bg2)', border: '1px solid var(--c-border)' }}>
143
+ <div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
144
+ <div className="flex items-center gap-2 text-[13px] font-medium" style={{ color: 'var(--c-white)' }}>
145
+ <AlertTriangle size={14} style={{ color: enabling ? '#fbbf24' : '#ef4444' }} />
146
+ {enabling ? 'Enable' : 'Disable'} Subscription Access
147
+ </div>
148
+ <button onClick={onCancel} className="p-1 rounded hover:bg-[var(--c-bg3)]" style={{ color: 'var(--c-text3)' }}>
149
+ <X size={14} />
150
+ </button>
151
+ </div>
152
+ <div className="px-4 py-3 text-[11px] space-y-2" style={{ color: 'var(--c-text2)' }}>
153
+ {enabling ? (
154
+ <>
155
+ <p>This will allow Agentlytics to read locally stored auth tokens from:</p>
156
+ <ul className="space-y-1 pl-3" style={{ color: 'var(--c-text3)' }}>
157
+ <li>Claude Code &ndash; macOS Keychain / Linux secret-tool</li>
158
+ <li>Cursor &ndash; local SQLite (state.vscdb)</li>
159
+ <li>Copilot / VS Code &ndash; ~/.config/github-copilot/apps.json</li>
160
+ <li>Codex &ndash; local auth.json (JWT decode only)</li>
161
+ <li>Windsurf &ndash; local SQLite (state.vscdb)</li>
162
+ </ul>
163
+ <p style={{ color: 'var(--c-text2)' }}>Tokens are kept <strong>in-memory only</strong> and never sent to any third-party service.</p>
164
+ </>
165
+ ) : (
166
+ <p>This will stop Agentlytics from reading any local auth tokens. Subscription and plan details will no longer be collected.</p>
167
+ )}
168
+ </div>
169
+ <div className="flex justify-end gap-2 px-4 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
170
+ <button
171
+ onClick={onCancel}
172
+ className="text-[11px] px-3 py-1 rounded transition"
173
+ style={{ color: 'var(--c-text3)', border: '1px solid var(--c-border)' }}
174
+ >
175
+ Cancel
176
+ </button>
177
+ <button
178
+ onClick={onConfirm}
179
+ disabled={saving}
180
+ className="text-[11px] px-3 py-1 rounded transition font-medium"
181
+ style={{
182
+ background: enabling ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
183
+ color: enabling ? '#22c55e' : '#ef4444',
184
+ border: `1px solid ${enabling ? 'rgba(34,197,94,0.2)' : 'rgba(239,68,68,0.2)'}`,
185
+ }}
186
+ >
187
+ {saving ? 'Saving...' : enabling ? 'Yes, enable' : 'Yes, disable'}
188
+ </button>
189
+ </div>
190
+ </div>
91
191
  </div>
92
192
  )
93
193
  }