agentlytics 0.2.7 → 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 +95 -170
- package/editors/opencode.js +55 -28
- package/package.json +1 -1
- package/ui/src/pages/Dashboard.jsx +3 -2
- package/ui/src/pages/DeepAnalysis.jsx +10 -7
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
|
|
@@ -985,7 +987,7 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
985
987
|
`).all(...params);
|
|
986
988
|
const modelFreq = {};
|
|
987
989
|
for (const r of modelRows) {
|
|
988
|
-
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 { }
|
|
989
991
|
}
|
|
990
992
|
const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
991
993
|
|
|
@@ -996,7 +998,7 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
996
998
|
const toolFreq = {};
|
|
997
999
|
let totalToolCalls = 0;
|
|
998
1000
|
for (const r of toolRows) {
|
|
999
|
-
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 { }
|
|
1000
1002
|
}
|
|
1001
1003
|
const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
1002
1004
|
|
|
@@ -1038,20 +1040,24 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
1038
1040
|
// ============================================================
|
|
1039
1041
|
|
|
1040
1042
|
function estimateCosts(whereClause = '', params = []) {
|
|
1041
|
-
// Per-model token usage from messages table
|
|
1043
|
+
// Per-model token usage from messages table (including cache tokens)
|
|
1042
1044
|
const modelTokens = db.prepare(`
|
|
1043
|
-
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
|
|
1044
1048
|
FROM messages m JOIN chats c ON m.chat_id = c.id
|
|
1045
|
-
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}
|
|
1046
1050
|
GROUP BY m.model
|
|
1047
1051
|
`).all(...params);
|
|
1048
1052
|
|
|
1049
1053
|
// Orphaned tokens: messages with token data but NULL model.
|
|
1050
1054
|
// Attribute these to the session's dominant model from chat_stats.
|
|
1051
1055
|
const orphanRows = db.prepare(`
|
|
1052
|
-
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
|
|
1053
1059
|
FROM messages m JOIN chats c ON m.chat_id = c.id
|
|
1054
|
-
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}
|
|
1055
1061
|
GROUP BY m.chat_id
|
|
1056
1062
|
`).all(...params);
|
|
1057
1063
|
|
|
@@ -1065,30 +1071,11 @@ function estimateCosts(whereClause = '', params = []) {
|
|
|
1065
1071
|
const freq = {};
|
|
1066
1072
|
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1067
1073
|
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1068
|
-
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
1074
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
1069
1075
|
orphanByModel[dominant].input += r.input || 0;
|
|
1070
1076
|
orphanByModel[dominant].output += r.output || 0;
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
// Cache tokens per session with dominant model
|
|
1074
|
-
const cacheRows = db.prepare(`
|
|
1075
|
-
SELECT cs.total_cache_read, cs.total_cache_write, cs.models
|
|
1076
|
-
FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
1077
|
-
WHERE (cs.total_cache_read > 0 OR cs.total_cache_write > 0)${whereClause}
|
|
1078
|
-
`).all(...params);
|
|
1079
|
-
|
|
1080
|
-
// Aggregate cache tokens by dominant model
|
|
1081
|
-
const cacheByModel = {};
|
|
1082
|
-
for (const r of cacheRows) {
|
|
1083
|
-
let models;
|
|
1084
|
-
try { models = JSON.parse(r.models || '[]'); } catch { continue; }
|
|
1085
|
-
if (models.length === 0) continue;
|
|
1086
|
-
const freq = {};
|
|
1087
|
-
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1088
|
-
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1089
|
-
if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
|
|
1090
|
-
cacheByModel[dominant].cacheRead += r.total_cache_read;
|
|
1091
|
-
cacheByModel[dominant].cacheWrite += r.total_cache_write;
|
|
1077
|
+
orphanByModel[dominant].cacheRead += r.cacheRead || 0;
|
|
1078
|
+
orphanByModel[dominant].cacheWrite += r.cacheWrite || 0;
|
|
1092
1079
|
}
|
|
1093
1080
|
|
|
1094
1081
|
// Char-based estimation: sessions with models + chars but zero tokens.
|
|
@@ -1108,7 +1095,7 @@ function estimateCosts(whereClause = '', params = []) {
|
|
|
1108
1095
|
const freq = {};
|
|
1109
1096
|
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1110
1097
|
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1111
|
-
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
1098
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
1112
1099
|
orphanByModel[dominant].input += Math.round((r.userChars || 0) / CHARS_PER_TOKEN);
|
|
1113
1100
|
orphanByModel[dominant].output += Math.round((r.asstChars || 0) / CHARS_PER_TOKEN);
|
|
1114
1101
|
}
|
|
@@ -1148,64 +1135,41 @@ function estimateCosts(whereClause = '', params = []) {
|
|
|
1148
1135
|
? Object.entries(sf).sort((a, b) => b[1] - a[1])[0]?.[0]
|
|
1149
1136
|
: globalDominant;
|
|
1150
1137
|
if (!dominant) continue;
|
|
1151
|
-
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
1138
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
1152
1139
|
orphanByModel[dominant].input += r.input || 0;
|
|
1153
1140
|
orphanByModel[dominant].output += r.output || 0;
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
cacheByModel[dominant].cacheRead += r.cacheRead || 0;
|
|
1157
|
-
cacheByModel[dominant].cacheWrite += r.cacheWrite || 0;
|
|
1141
|
+
orphanByModel[dominant].cacheRead += r.cacheRead || 0;
|
|
1142
|
+
orphanByModel[dominant].cacheWrite += r.cacheWrite || 0;
|
|
1158
1143
|
}
|
|
1159
1144
|
}
|
|
1160
1145
|
|
|
1161
1146
|
// Merge modelTokens + orphanByModel into a unified map, normalizing keys
|
|
1162
1147
|
const tokenMap = {};
|
|
1163
|
-
const addTokens = (rawModel, input, output) => {
|
|
1148
|
+
const addTokens = (rawModel, input, output, cacheRead, cacheWrite) => {
|
|
1164
1149
|
const key = normalizeModelName(rawModel) || rawModel;
|
|
1165
|
-
if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0 };
|
|
1150
|
+
if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
1166
1151
|
tokenMap[key].input += input || 0;
|
|
1167
1152
|
tokenMap[key].output += output || 0;
|
|
1153
|
+
tokenMap[key].cacheRead += cacheRead || 0;
|
|
1154
|
+
tokenMap[key].cacheWrite += cacheWrite || 0;
|
|
1168
1155
|
};
|
|
1169
|
-
for (const row of modelTokens) addTokens(row.model, row.input, row.output);
|
|
1170
|
-
for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output);
|
|
1171
|
-
|
|
1172
|
-
// Normalize cacheByModel keys
|
|
1173
|
-
const normCache = {};
|
|
1174
|
-
for (const [model, cache] of Object.entries(cacheByModel)) {
|
|
1175
|
-
const key = normalizeModelName(model) || model;
|
|
1176
|
-
if (!normCache[key]) normCache[key] = { cacheRead: 0, cacheWrite: 0 };
|
|
1177
|
-
normCache[key].cacheRead += cache.cacheRead;
|
|
1178
|
-
normCache[key].cacheWrite += cache.cacheWrite;
|
|
1179
|
-
}
|
|
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);
|
|
1180
1158
|
|
|
1181
1159
|
let totalCost = 0;
|
|
1182
|
-
let knownCost = 0;
|
|
1183
1160
|
let unknownModels = [];
|
|
1184
1161
|
const byModel = [];
|
|
1185
1162
|
|
|
1186
1163
|
for (const [model, tok] of Object.entries(tokenMap)) {
|
|
1187
|
-
const
|
|
1188
|
-
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);
|
|
1189
1165
|
if (cost !== null) {
|
|
1190
|
-
knownCost += cost;
|
|
1191
1166
|
totalCost += cost;
|
|
1192
|
-
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 });
|
|
1193
1168
|
} else {
|
|
1194
1169
|
unknownModels.push(model);
|
|
1195
1170
|
}
|
|
1196
1171
|
}
|
|
1197
1172
|
|
|
1198
|
-
// Handle cache tokens for models that had cache but no message-level tokens
|
|
1199
|
-
for (const [model, cache] of Object.entries(normCache)) {
|
|
1200
|
-
if (!tokenMap[model]) {
|
|
1201
|
-
const cost = calculateCost(model, 0, 0, cache.cacheRead, cache.cacheWrite);
|
|
1202
|
-
if (cost !== null) {
|
|
1203
|
-
totalCost += cost;
|
|
1204
|
-
byModel.push({ model, inputTokens: 0, outputTokens: 0, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
1173
|
byModel.sort((a, b) => b.cost - a.cost);
|
|
1210
1174
|
unknownModels = [...new Set(unknownModels)];
|
|
1211
1175
|
|
|
@@ -1233,116 +1197,77 @@ function getCostAnalytics(opts = {}) {
|
|
|
1233
1197
|
if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
|
|
1234
1198
|
if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
|
|
1235
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); }
|
|
1236
1201
|
const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
|
|
1237
1202
|
|
|
1238
1203
|
// Overall cost breakdown by model
|
|
1239
1204
|
const overall = getCostBreakdown(opts);
|
|
1240
1205
|
|
|
1241
|
-
//
|
|
1242
|
-
const
|
|
1243
|
-
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}
|
|
1244
1213
|
`).all(...params);
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
if (ec.totalCost > 0) {
|
|
1250
|
-
byEditor.push({ editor: source, cost: ec.totalCost, models: ec.byModel.length });
|
|
1251
|
-
}
|
|
1214
|
+
|
|
1215
|
+
const chatCostCache = new Map();
|
|
1216
|
+
for (const r of sessionRows) {
|
|
1217
|
+
chatCostCache.set(r.id, getCostBreakdown({ ...opts, chatId: r.id }));
|
|
1252
1218
|
}
|
|
1253
|
-
byEditor.sort((a, b) => b.cost - a.cost);
|
|
1254
1219
|
|
|
1255
|
-
//
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
const pc = getCostBreakdown({ ...opts, folder });
|
|
1264
|
-
if (pc.totalCost > 0) {
|
|
1265
|
-
byProject.push({ folder, name: folder.split('/').pop(), cost: pc.totalCost });
|
|
1266
|
-
}
|
|
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);
|
|
1267
1228
|
}
|
|
1268
|
-
|
|
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);
|
|
1269
1232
|
|
|
1270
|
-
//
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
|
1283
1248
|
const monthCosts = {};
|
|
1284
|
-
for (const r of
|
|
1249
|
+
for (const r of sessionRows) {
|
|
1285
1250
|
if (!r.month) continue;
|
|
1286
|
-
|
|
1287
|
-
try {
|
|
1288
|
-
const models = JSON.parse(r._models || '[]');
|
|
1289
|
-
if (models.length > 0) {
|
|
1290
|
-
const freq = {};
|
|
1291
|
-
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1292
|
-
topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1293
|
-
}
|
|
1294
|
-
} catch {}
|
|
1295
|
-
if (!topModel) continue;
|
|
1296
|
-
let inTok = r.inTok || 0, outTok = r.outTok || 0;
|
|
1297
|
-
if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
|
|
1298
|
-
inTok = Math.round((r.uChars || 0) / 4);
|
|
1299
|
-
outTok = Math.round((r.aChars || 0) / 4);
|
|
1300
|
-
}
|
|
1301
|
-
const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
|
|
1251
|
+
const sc = chatCostCache.get(r.id);
|
|
1302
1252
|
if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
|
|
1303
|
-
monthCosts[r.month].cost +=
|
|
1253
|
+
monthCosts[r.month].cost += sc.totalCost;
|
|
1304
1254
|
monthCosts[r.month].sessions++;
|
|
1305
1255
|
}
|
|
1306
1256
|
const monthly = Object.entries(monthCosts).sort((a, b) => a[0].localeCompare(b[0]))
|
|
1307
1257
|
.map(([month, d]) => ({ month, cost: Math.round(d.cost * 100) / 100, sessions: d.sessions }));
|
|
1308
1258
|
|
|
1309
|
-
// Top expensive sessions
|
|
1310
|
-
const sessionRows = db.prepare(`
|
|
1311
|
-
SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
|
|
1312
|
-
cs.models AS _models,
|
|
1313
|
-
cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
|
|
1314
|
-
cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
|
|
1315
|
-
cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars,
|
|
1316
|
-
cs.total_messages AS msgs
|
|
1317
|
-
FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
|
|
1318
|
-
WHERE 1=1${whereAnd}
|
|
1319
|
-
`).all(...params);
|
|
1259
|
+
// Top expensive sessions from cached per-chat costs
|
|
1320
1260
|
const sessionCosts = [];
|
|
1321
1261
|
for (const r of sessionRows) {
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
}
|
|
1331
|
-
if (!topModel) continue;
|
|
1332
|
-
let inTok = r.inTok || 0, outTok = r.outTok || 0;
|
|
1333
|
-
if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
|
|
1334
|
-
inTok = Math.round((r.uChars || 0) / 4);
|
|
1335
|
-
outTok = Math.round((r.aChars || 0) / 4);
|
|
1336
|
-
}
|
|
1337
|
-
const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
|
|
1338
|
-
if (cost > 0) {
|
|
1339
|
-
sessionCosts.push({
|
|
1340
|
-
id: r.id, source: r.source, name: r.name, folder: r.folder,
|
|
1341
|
-
model: normalizeModelName(topModel) || topModel,
|
|
1342
|
-
cost, messages: r.msgs || 0,
|
|
1343
|
-
lastUpdatedAt: r.last_updated_at || r.created_at,
|
|
1344
|
-
});
|
|
1345
|
-
}
|
|
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
|
+
});
|
|
1346
1271
|
}
|
|
1347
1272
|
sessionCosts.sort((a, b) => b.cost - a.cost);
|
|
1348
1273
|
|
|
@@ -1361,7 +1286,7 @@ function getCostAnalytics(opts = {}) {
|
|
|
1361
1286
|
byModel: overall.byModel,
|
|
1362
1287
|
unknownModels: overall.unknownModels,
|
|
1363
1288
|
byEditor,
|
|
1364
|
-
byProject
|
|
1289
|
+
byProject,
|
|
1365
1290
|
monthly,
|
|
1366
1291
|
topSessions: sessionCosts.slice(0, 50),
|
|
1367
1292
|
summary: {
|
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/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 */}
|