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 +102 -174
- package/editors/antigravity.js +2 -0
- package/editors/base.js +12 -0
- package/editors/claude.js +3 -10
- package/editors/codex.js +2 -0
- package/editors/copilot.js +2 -0
- package/editors/cursor.js +2 -0
- package/editors/opencode.js +55 -28
- package/editors/vscode.js +2 -0
- package/editors/windsurf.js +3 -0
- package/index.js +35 -24
- package/package.json +1 -1
- package/ui/src/pages/Dashboard.jsx +3 -2
- package/ui/src/pages/DeepAnalysis.jsx +10 -7
- package/ui/src/pages/Settings.jsx +101 -1
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 =
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
1152
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
1239
|
-
const
|
|
1240
|
-
SELECT
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
//
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
|
1249
|
+
for (const r of sessionRows) {
|
|
1282
1250
|
if (!r.month) continue;
|
|
1283
|
-
|
|
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 +=
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
}
|
|
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
|
|
1289
|
+
byProject,
|
|
1362
1290
|
monthly,
|
|
1363
1291
|
topSessions: sessionCosts.slice(0, 50),
|
|
1364
1292
|
summary: {
|
package/editors/antigravity.js
CHANGED
|
@@ -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 (
|
|
218
|
-
|
|
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
|
|
package/editors/copilot.js
CHANGED
|
@@ -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
|
|
package/editors/opencode.js
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
package/editors/windsurf.js
CHANGED
|
@@ -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
|
|
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.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
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
|
|
168
|
-
const
|
|
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
|
|
235
|
-
const
|
|
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
|
|
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
|
|
311
|
-
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#
|
|
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 – macOS Keychain / Linux secret-tool</li>
|
|
158
|
+
<li>Cursor – local SQLite (state.vscdb)</li>
|
|
159
|
+
<li>Copilot / VS Code – ~/.config/github-copilot/apps.json</li>
|
|
160
|
+
<li>Codex – local auth.json (JWT decode only)</li>
|
|
161
|
+
<li>Windsurf – 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
|
}
|