agentlytics 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cache.js +420 -10
- package/editors/cursor.js +28 -6
- package/editors/vscode.js +6 -0
- package/index.js +78 -11
- package/package.json +2 -1
- package/server.js +27 -0
- package/ui/package-lock.json +60 -375
- package/ui/package.json +1 -1
- package/ui/src/App.jsx +22 -17
- package/ui/src/components/ActivityHeatmap.jsx +3 -3
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +7 -7
- package/ui/src/components/DateRangePicker.jsx +5 -5
- package/ui/src/components/EditorBreakdown.jsx +2 -2
- package/ui/src/components/EditorDot.jsx +1 -1
- package/ui/src/components/KpiCard.jsx +2 -2
- package/ui/src/components/LiveFeed.jsx +8 -8
- package/ui/src/components/LoginScreen.jsx +8 -6
- package/ui/src/components/MessageRenderer.jsx +5 -5
- package/ui/src/components/ModelBreakdown.jsx +3 -3
- package/ui/src/components/SectionTitle.jsx +1 -1
- package/ui/src/index.css +1 -1
- package/ui/src/lib/api.js +20 -0
- package/ui/src/lib/constants.js +8 -0
- package/ui/src/pages/ChatDetail.jsx +5 -2
- package/ui/src/pages/Compare.jsx +18 -18
- package/ui/src/pages/CostAnalysis.jsx +356 -0
- package/ui/src/pages/Dashboard.jsx +39 -21
- package/ui/src/pages/DeepAnalysis.jsx +38 -31
- package/ui/src/pages/ProjectDetail.jsx +23 -15
- package/ui/src/pages/Projects.jsx +14 -8
- package/ui/src/pages/RelayDashboard.jsx +29 -29
- package/ui/src/pages/RelaySessionDetail.jsx +1 -1
- package/ui/src/pages/RelayUserDetail.jsx +18 -18
- package/ui/src/pages/Sessions.jsx +24 -20
- package/ui/src/pages/SqlViewer.jsx +14 -14
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ All endpoints accept optional `editor` filter. See **[API.md](API.md)** for full
|
|
|
179
179
|
- [ ] **LLM-powered insights** — Use an LLM to analyze session patterns, generate summaries, detect coding habits, and surface actionable recommendations
|
|
180
180
|
- [ ] **Linux & Windows support** — Adapt editor paths for non-macOS platforms
|
|
181
181
|
- [ ] **Export & reports** — PDF/CSV export of analytics and session data
|
|
182
|
-
- [
|
|
182
|
+
- [x] **Cost tracking** — Estimate API costs per editor/model based on token usage
|
|
183
183
|
|
|
184
184
|
## Contributions Needed
|
|
185
185
|
|
package/cache.js
CHANGED
|
@@ -3,9 +3,11 @@ const path = require('path');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const { getAllChats, getMessages, findChat: findChatRaw, resetCaches } = require('./editors');
|
|
6
|
+
const { calculateCost, getModelPricing, normalizeModelName } = require('./pricing');
|
|
6
7
|
|
|
7
8
|
const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
|
|
8
9
|
const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
|
|
10
|
+
const SCHEMA_VERSION = 4; // bump this when schema changes to auto-revalidate
|
|
9
11
|
|
|
10
12
|
let db = null;
|
|
11
13
|
|
|
@@ -15,6 +17,28 @@ let db = null;
|
|
|
15
17
|
|
|
16
18
|
function initDb() {
|
|
17
19
|
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
20
|
+
|
|
21
|
+
// Check schema version; wipe DB on mismatch
|
|
22
|
+
if (fs.existsSync(CACHE_DB)) {
|
|
23
|
+
try {
|
|
24
|
+
const tmp = new Database(CACHE_DB, { readonly: true });
|
|
25
|
+
const row = tmp.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
26
|
+
tmp.close();
|
|
27
|
+
if (!row || parseInt(row.value) !== SCHEMA_VERSION) {
|
|
28
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
29
|
+
const f = CACHE_DB + suffix;
|
|
30
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Corrupt or unreadable DB — wipe it
|
|
35
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
36
|
+
const f = CACHE_DB + suffix;
|
|
37
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
db = new Database(CACHE_DB);
|
|
19
43
|
db.pragma('journal_mode = WAL');
|
|
20
44
|
db.pragma('synchronous = NORMAL');
|
|
@@ -86,6 +110,9 @@ function initDb() {
|
|
|
86
110
|
CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON tool_calls(tool_name);
|
|
87
111
|
CREATE INDEX IF NOT EXISTS idx_tool_calls_chat ON tool_calls(chat_id);
|
|
88
112
|
`);
|
|
113
|
+
|
|
114
|
+
// Store schema version so future runs can detect mismatches
|
|
115
|
+
db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION.toString());
|
|
89
116
|
}
|
|
90
117
|
|
|
91
118
|
// ============================================================
|
|
@@ -193,7 +220,7 @@ function analyzeAndStore(chat) {
|
|
|
193
220
|
function scanAll(onProgress, opts = {}) {
|
|
194
221
|
const force = opts.force || false;
|
|
195
222
|
if (force || opts.resetCaches) resetCaches();
|
|
196
|
-
const chats = getAllChats();
|
|
223
|
+
const chats = opts.chats || getAllChats();
|
|
197
224
|
const total = chats.length;
|
|
198
225
|
let scanned = 0;
|
|
199
226
|
let analyzed = 0;
|
|
@@ -267,7 +294,12 @@ function scanAll(onProgress, opts = {}) {
|
|
|
267
294
|
// ============================================================
|
|
268
295
|
|
|
269
296
|
function getCachedChats(opts = {}) {
|
|
270
|
-
let sql =
|
|
297
|
+
let sql = `SELECT c.*,
|
|
298
|
+
cs.models AS _models,
|
|
299
|
+
cs.total_input_tokens AS _inTok, cs.total_output_tokens AS _outTok,
|
|
300
|
+
cs.total_cache_read AS _cacheR, cs.total_cache_write AS _cacheW,
|
|
301
|
+
cs.total_user_chars AS _uChars, cs.total_assistant_chars AS _aChars
|
|
302
|
+
FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id WHERE 1=1`;
|
|
271
303
|
const params = [];
|
|
272
304
|
if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
273
305
|
if (opts.folder) { sql += ' AND c.folder LIKE ?'; params.push(`%${opts.folder}%`); }
|
|
@@ -288,7 +320,14 @@ function getCachedChats(opts = {}) {
|
|
|
288
320
|
r.top_model = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
289
321
|
}
|
|
290
322
|
} catch {}
|
|
291
|
-
|
|
323
|
+
// Per-session cost estimate
|
|
324
|
+
let inTok = r._inTok || 0, outTok = r._outTok || 0;
|
|
325
|
+
if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
|
|
326
|
+
inTok = Math.round((r._uChars || 0) / 4);
|
|
327
|
+
outTok = Math.round((r._aChars || 0) / 4);
|
|
328
|
+
}
|
|
329
|
+
r.cost = r.top_model ? (calculateCost(r.top_model, inTok, outTok, r._cacheR || 0, r._cacheW || 0) || 0) : 0;
|
|
330
|
+
delete r._models; delete r._inTok; delete r._outTok; delete r._cacheR; delete r._cacheW; delete r._uChars; delete r._aChars;
|
|
292
331
|
}
|
|
293
332
|
return rows;
|
|
294
333
|
}
|
|
@@ -432,15 +471,23 @@ function getCachedDeepAnalytics(opts = {}) {
|
|
|
432
471
|
} catch {}
|
|
433
472
|
try {
|
|
434
473
|
const models = JSON.parse(r.models);
|
|
435
|
-
for (const m of models) { modelFreq[
|
|
474
|
+
for (const m of models) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; }
|
|
436
475
|
} catch {}
|
|
437
476
|
}
|
|
438
477
|
|
|
478
|
+
// Estimate tokens from chars when no token data available
|
|
479
|
+
let tokensEstimated = false;
|
|
480
|
+
if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
|
|
481
|
+
totalInputTokens = Math.round(totalUserChars / 4);
|
|
482
|
+
totalOutputTokens = Math.round(totalAssistantChars / 4);
|
|
483
|
+
tokensEstimated = true;
|
|
484
|
+
}
|
|
485
|
+
|
|
439
486
|
return {
|
|
440
487
|
analyzedChats: rows.length,
|
|
441
488
|
totalMessages, totalToolCalls,
|
|
442
489
|
totalUserChars, totalAssistantChars,
|
|
443
|
-
totalInputTokens, totalOutputTokens,
|
|
490
|
+
totalInputTokens, totalOutputTokens, tokensEstimated,
|
|
444
491
|
totalCacheRead, totalCacheWrite,
|
|
445
492
|
topTools: Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, count]) => ({ name, count })),
|
|
446
493
|
topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([name, count]) => ({ name, count })),
|
|
@@ -543,10 +590,18 @@ function getCachedProjects(opts = {}) {
|
|
|
543
590
|
totalAssistantChars += s.total_assistant_chars;
|
|
544
591
|
totalCacheRead += s.total_cache_read;
|
|
545
592
|
totalCacheWrite += s.total_cache_write;
|
|
546
|
-
try { for (const m of JSON.parse(s.models)) { modelFreq[
|
|
593
|
+
try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
|
|
547
594
|
try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch {}
|
|
548
595
|
}
|
|
549
596
|
|
|
597
|
+
// Estimate tokens from chars when no token data available
|
|
598
|
+
let tokensEstimated = false;
|
|
599
|
+
if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
|
|
600
|
+
totalInputTokens = Math.round(totalUserChars / 4);
|
|
601
|
+
totalOutputTokens = Math.round(totalAssistantChars / 4);
|
|
602
|
+
tokensEstimated = true;
|
|
603
|
+
}
|
|
604
|
+
|
|
550
605
|
result.push({
|
|
551
606
|
folder: proj.folder,
|
|
552
607
|
name: proj.folder.split('/').pop(),
|
|
@@ -556,7 +611,7 @@ function getCachedProjects(opts = {}) {
|
|
|
556
611
|
lastSeen: proj.lastSeen,
|
|
557
612
|
totalMessages,
|
|
558
613
|
totalInputTokens,
|
|
559
|
-
totalOutputTokens,
|
|
614
|
+
totalOutputTokens, tokensEstimated,
|
|
560
615
|
totalUserChars,
|
|
561
616
|
totalAssistantChars,
|
|
562
617
|
totalToolCalls,
|
|
@@ -602,6 +657,9 @@ function safeParseJson(s) {
|
|
|
602
657
|
function resetAndRescan(onProgress) {
|
|
603
658
|
if (db) db.close();
|
|
604
659
|
if (fs.existsSync(CACHE_DB)) fs.unlinkSync(CACHE_DB);
|
|
660
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
661
|
+
if (fs.existsSync(CACHE_DB + suffix)) fs.unlinkSync(CACHE_DB + suffix);
|
|
662
|
+
}
|
|
605
663
|
initDb();
|
|
606
664
|
return scanAll(onProgress);
|
|
607
665
|
}
|
|
@@ -676,6 +734,9 @@ async function scanAllAsync(onProgress) {
|
|
|
676
734
|
async function resetAndRescanAsync(onProgress) {
|
|
677
735
|
if (db) db.close();
|
|
678
736
|
if (fs.existsSync(CACHE_DB)) fs.unlinkSync(CACHE_DB);
|
|
737
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
738
|
+
if (fs.existsSync(CACHE_DB + suffix)) fs.unlinkSync(CACHE_DB + suffix);
|
|
739
|
+
}
|
|
679
740
|
initDb();
|
|
680
741
|
return scanAllAsync(onProgress);
|
|
681
742
|
}
|
|
@@ -812,7 +873,7 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
812
873
|
`).all(...params);
|
|
813
874
|
const modelFreq = {};
|
|
814
875
|
for (const r of modelRows) {
|
|
815
|
-
try { for (const m of JSON.parse(r.models)) modelFreq[
|
|
876
|
+
try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
|
|
816
877
|
}
|
|
817
878
|
const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
818
879
|
|
|
@@ -827,18 +888,29 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
827
888
|
}
|
|
828
889
|
const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
829
890
|
|
|
891
|
+
// If no token data but chars exist, estimate tokens (~4 chars/token)
|
|
892
|
+
let inputTokens = tokenRow.input;
|
|
893
|
+
let outputTokens = tokenRow.output;
|
|
894
|
+
let tokensEstimated = false;
|
|
895
|
+
if (inputTokens === 0 && outputTokens === 0 && (tokenRow.userChars > 0 || tokenRow.assistantChars > 0)) {
|
|
896
|
+
inputTokens = Math.round(tokenRow.userChars / 4);
|
|
897
|
+
outputTokens = Math.round(tokenRow.assistantChars / 4);
|
|
898
|
+
tokensEstimated = true;
|
|
899
|
+
}
|
|
900
|
+
|
|
830
901
|
return {
|
|
831
902
|
hourly,
|
|
832
903
|
weekdays,
|
|
833
904
|
depthBuckets,
|
|
834
905
|
tokens: {
|
|
835
|
-
input:
|
|
836
|
-
output:
|
|
906
|
+
input: inputTokens,
|
|
907
|
+
output: outputTokens,
|
|
837
908
|
cacheRead: tokenRow.cacheRead,
|
|
838
909
|
cacheWrite: tokenRow.cacheWrite,
|
|
839
910
|
userChars: tokenRow.userChars,
|
|
840
911
|
assistantChars: tokenRow.assistantChars,
|
|
841
912
|
sessions: tokenRow.sessions,
|
|
913
|
+
estimated: tokensEstimated,
|
|
842
914
|
},
|
|
843
915
|
streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
|
|
844
916
|
monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
|
|
@@ -849,6 +921,342 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
849
921
|
};
|
|
850
922
|
}
|
|
851
923
|
|
|
924
|
+
// ============================================================
|
|
925
|
+
// Cost estimation
|
|
926
|
+
// ============================================================
|
|
927
|
+
|
|
928
|
+
function estimateCosts(whereClause = '', params = []) {
|
|
929
|
+
// Per-model token usage from messages table
|
|
930
|
+
const modelTokens = db.prepare(`
|
|
931
|
+
SELECT m.model, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
|
|
932
|
+
FROM messages m JOIN chats c ON m.chat_id = c.id
|
|
933
|
+
WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
|
|
934
|
+
GROUP BY m.model
|
|
935
|
+
`).all(...params);
|
|
936
|
+
|
|
937
|
+
// Orphaned tokens: messages with token data but NULL model.
|
|
938
|
+
// Attribute these to the session's dominant model from chat_stats.
|
|
939
|
+
const orphanRows = db.prepare(`
|
|
940
|
+
SELECT m.chat_id, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
|
|
941
|
+
FROM messages m JOIN chats c ON m.chat_id = c.id
|
|
942
|
+
WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
|
|
943
|
+
GROUP BY m.chat_id
|
|
944
|
+
`).all(...params);
|
|
945
|
+
|
|
946
|
+
const orphanByModel = {};
|
|
947
|
+
for (const r of orphanRows) {
|
|
948
|
+
const stat = db.prepare('SELECT models FROM chat_stats WHERE chat_id = ?').get(r.chat_id);
|
|
949
|
+
if (!stat) continue;
|
|
950
|
+
let models;
|
|
951
|
+
try { models = JSON.parse(stat.models || '[]'); } catch { continue; }
|
|
952
|
+
if (models.length === 0) continue;
|
|
953
|
+
const freq = {};
|
|
954
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
955
|
+
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
956
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
957
|
+
orphanByModel[dominant].input += r.input || 0;
|
|
958
|
+
orphanByModel[dominant].output += r.output || 0;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Cache tokens per session with dominant model
|
|
962
|
+
const cacheRows = db.prepare(`
|
|
963
|
+
SELECT cs.total_cache_read, cs.total_cache_write, cs.models
|
|
964
|
+
FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
965
|
+
WHERE (cs.total_cache_read > 0 OR cs.total_cache_write > 0)${whereClause}
|
|
966
|
+
`).all(...params);
|
|
967
|
+
|
|
968
|
+
// Aggregate cache tokens by dominant model
|
|
969
|
+
const cacheByModel = {};
|
|
970
|
+
for (const r of cacheRows) {
|
|
971
|
+
let models;
|
|
972
|
+
try { models = JSON.parse(r.models || '[]'); } catch { continue; }
|
|
973
|
+
if (models.length === 0) continue;
|
|
974
|
+
const freq = {};
|
|
975
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
976
|
+
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
977
|
+
if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
|
|
978
|
+
cacheByModel[dominant].cacheRead += r.total_cache_read;
|
|
979
|
+
cacheByModel[dominant].cacheWrite += r.total_cache_write;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Char-based estimation: sessions with models + chars but zero tokens.
|
|
983
|
+
// Estimate ~4 chars per token (user chars → input, assistant chars → output).
|
|
984
|
+
const CHARS_PER_TOKEN = 4;
|
|
985
|
+
const charRows = db.prepare(`
|
|
986
|
+
SELECT cs.models, cs.total_user_chars as userChars, cs.total_assistant_chars as asstChars
|
|
987
|
+
FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
988
|
+
WHERE cs.models != '[]' AND cs.total_input_tokens = 0 AND cs.total_output_tokens = 0
|
|
989
|
+
AND (cs.total_user_chars > 0 OR cs.total_assistant_chars > 0)${whereClause}
|
|
990
|
+
`).all(...params);
|
|
991
|
+
|
|
992
|
+
for (const r of charRows) {
|
|
993
|
+
let models;
|
|
994
|
+
try { models = JSON.parse(r.models || '[]'); } catch { continue; }
|
|
995
|
+
if (models.length === 0) continue;
|
|
996
|
+
const freq = {};
|
|
997
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
998
|
+
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
999
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
1000
|
+
orphanByModel[dominant].input += Math.round((r.userChars || 0) / CHARS_PER_TOKEN);
|
|
1001
|
+
orphanByModel[dominant].output += Math.round((r.asstChars || 0) / CHARS_PER_TOKEN);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Sessions with token totals but empty models (e.g. Cursor composer chats).
|
|
1005
|
+
// Attribute to the dominant model from same editor source.
|
|
1006
|
+
const unmodeledRows = db.prepare(`
|
|
1007
|
+
SELECT c.source, cs.total_input_tokens as input, cs.total_output_tokens as output,
|
|
1008
|
+
cs.total_cache_read as cacheRead, cs.total_cache_write as cacheWrite
|
|
1009
|
+
FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
1010
|
+
WHERE cs.models = '[]' AND (cs.total_input_tokens > 0 OR cs.total_output_tokens > 0)${whereClause}
|
|
1011
|
+
`).all(...params);
|
|
1012
|
+
|
|
1013
|
+
if (unmodeledRows.length > 0) {
|
|
1014
|
+
// Find dominant model per source from sessions that DO have models
|
|
1015
|
+
const sourceModelFreq = {};
|
|
1016
|
+
const allSessions = db.prepare(`
|
|
1017
|
+
SELECT c.source, cs.models FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
1018
|
+
WHERE cs.models != '[]'${whereClause}
|
|
1019
|
+
`).all(...params);
|
|
1020
|
+
for (const s of allSessions) {
|
|
1021
|
+
let models;
|
|
1022
|
+
try { models = JSON.parse(s.models || '[]'); } catch { continue; }
|
|
1023
|
+
if (!sourceModelFreq[s.source]) sourceModelFreq[s.source] = {};
|
|
1024
|
+
for (const m of models) sourceModelFreq[s.source][m] = (sourceModelFreq[s.source][m] || 0) + 1;
|
|
1025
|
+
}
|
|
1026
|
+
// Global fallback: dominant model across all sources
|
|
1027
|
+
const globalFreq = {};
|
|
1028
|
+
for (const sf of Object.values(sourceModelFreq)) {
|
|
1029
|
+
for (const [m, c] of Object.entries(sf)) globalFreq[m] = (globalFreq[m] || 0) + c;
|
|
1030
|
+
}
|
|
1031
|
+
const globalDominant = Object.entries(globalFreq).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
1032
|
+
|
|
1033
|
+
for (const r of unmodeledRows) {
|
|
1034
|
+
const sf = sourceModelFreq[r.source];
|
|
1035
|
+
const dominant = sf
|
|
1036
|
+
? Object.entries(sf).sort((a, b) => b[1] - a[1])[0]?.[0]
|
|
1037
|
+
: globalDominant;
|
|
1038
|
+
if (!dominant) continue;
|
|
1039
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
1040
|
+
orphanByModel[dominant].input += r.input || 0;
|
|
1041
|
+
orphanByModel[dominant].output += r.output || 0;
|
|
1042
|
+
// Also merge cache data
|
|
1043
|
+
if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
|
|
1044
|
+
cacheByModel[dominant].cacheRead += r.cacheRead || 0;
|
|
1045
|
+
cacheByModel[dominant].cacheWrite += r.cacheWrite || 0;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Merge modelTokens + orphanByModel into a unified map, normalizing keys
|
|
1050
|
+
const tokenMap = {};
|
|
1051
|
+
const addTokens = (rawModel, input, output) => {
|
|
1052
|
+
const key = normalizeModelName(rawModel) || rawModel;
|
|
1053
|
+
if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0 };
|
|
1054
|
+
tokenMap[key].input += input || 0;
|
|
1055
|
+
tokenMap[key].output += output || 0;
|
|
1056
|
+
};
|
|
1057
|
+
for (const row of modelTokens) addTokens(row.model, row.input, row.output);
|
|
1058
|
+
for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output);
|
|
1059
|
+
|
|
1060
|
+
// Normalize cacheByModel keys
|
|
1061
|
+
const normCache = {};
|
|
1062
|
+
for (const [model, cache] of Object.entries(cacheByModel)) {
|
|
1063
|
+
const key = normalizeModelName(model) || model;
|
|
1064
|
+
if (!normCache[key]) normCache[key] = { cacheRead: 0, cacheWrite: 0 };
|
|
1065
|
+
normCache[key].cacheRead += cache.cacheRead;
|
|
1066
|
+
normCache[key].cacheWrite += cache.cacheWrite;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
let totalCost = 0;
|
|
1070
|
+
let knownCost = 0;
|
|
1071
|
+
let unknownModels = [];
|
|
1072
|
+
const byModel = [];
|
|
1073
|
+
|
|
1074
|
+
for (const [model, tok] of Object.entries(tokenMap)) {
|
|
1075
|
+
const cache = normCache[model] || { cacheRead: 0, cacheWrite: 0 };
|
|
1076
|
+
const cost = calculateCost(model, tok.input, tok.output, cache.cacheRead, cache.cacheWrite);
|
|
1077
|
+
if (cost !== null) {
|
|
1078
|
+
knownCost += cost;
|
|
1079
|
+
totalCost += cost;
|
|
1080
|
+
byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
|
|
1081
|
+
} else {
|
|
1082
|
+
unknownModels.push(model);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Handle cache tokens for models that had cache but no message-level tokens
|
|
1087
|
+
for (const [model, cache] of Object.entries(normCache)) {
|
|
1088
|
+
if (!tokenMap[model]) {
|
|
1089
|
+
const cost = calculateCost(model, 0, 0, cache.cacheRead, cache.cacheWrite);
|
|
1090
|
+
if (cost !== null) {
|
|
1091
|
+
totalCost += cost;
|
|
1092
|
+
byModel.push({ model, inputTokens: 0, outputTokens: 0, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
byModel.sort((a, b) => b.cost - a.cost);
|
|
1098
|
+
unknownModels = [...new Set(unknownModels)];
|
|
1099
|
+
|
|
1100
|
+
return { totalCost, byModel, unknownModels };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function getCostBreakdown(opts = {}) {
|
|
1104
|
+
let whereClause = '';
|
|
1105
|
+
const params = [];
|
|
1106
|
+
if (opts.editor) { whereClause += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
1107
|
+
if (opts.folder) { whereClause += ' AND c.folder = ?'; params.push(opts.folder); }
|
|
1108
|
+
if (opts.dateFrom) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
|
|
1109
|
+
if (opts.dateTo) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) <= ?'; params.push(opts.dateTo); }
|
|
1110
|
+
if (opts.chatId) { whereClause += ' AND c.id = ?'; params.push(opts.chatId); }
|
|
1111
|
+
return estimateCosts(whereClause, params);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function getCostAnalytics(opts = {}) {
|
|
1115
|
+
const conditions = [];
|
|
1116
|
+
const params = [];
|
|
1117
|
+
if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
|
|
1118
|
+
if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
|
|
1119
|
+
if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
|
|
1120
|
+
const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
|
|
1121
|
+
|
|
1122
|
+
// Overall cost breakdown by model
|
|
1123
|
+
const overall = getCostBreakdown(opts);
|
|
1124
|
+
|
|
1125
|
+
// Cost by editor: get costs per source
|
|
1126
|
+
const editorRows = db.prepare(`
|
|
1127
|
+
SELECT DISTINCT c.source FROM chats c WHERE c.source IS NOT NULL${whereAnd}
|
|
1128
|
+
`).all(...params);
|
|
1129
|
+
const byEditor = [];
|
|
1130
|
+
for (const { source } of editorRows) {
|
|
1131
|
+
const editorOpts = { ...opts, editor: source };
|
|
1132
|
+
const ec = getCostBreakdown(editorOpts);
|
|
1133
|
+
if (ec.totalCost > 0) {
|
|
1134
|
+
byEditor.push({ editor: source, cost: ec.totalCost, models: ec.byModel.length });
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
byEditor.sort((a, b) => b.cost - a.cost);
|
|
1138
|
+
|
|
1139
|
+
// Cost by project (top 20)
|
|
1140
|
+
const projectRows = db.prepare(`
|
|
1141
|
+
SELECT c.folder, COUNT(*) as sessions FROM chats c
|
|
1142
|
+
WHERE c.folder IS NOT NULL${whereAnd}
|
|
1143
|
+
GROUP BY c.folder ORDER BY sessions DESC LIMIT 30
|
|
1144
|
+
`).all(...params);
|
|
1145
|
+
const byProject = [];
|
|
1146
|
+
for (const { folder } of projectRows) {
|
|
1147
|
+
const pc = getCostBreakdown({ ...opts, folder });
|
|
1148
|
+
if (pc.totalCost > 0) {
|
|
1149
|
+
byProject.push({ folder, name: folder.split('/').pop(), cost: pc.totalCost });
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
byProject.sort((a, b) => b.cost - a.cost);
|
|
1153
|
+
|
|
1154
|
+
// Monthly trend
|
|
1155
|
+
const monthRows = db.prepare(`
|
|
1156
|
+
SELECT
|
|
1157
|
+
substr(date(COALESCE(c.last_updated_at, c.created_at)/1000, 'unixepoch'), 1, 7) as month,
|
|
1158
|
+
c.id, c.source,
|
|
1159
|
+
cs.models AS _models,
|
|
1160
|
+
cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
|
|
1161
|
+
cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
|
|
1162
|
+
cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars
|
|
1163
|
+
FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
|
|
1164
|
+
WHERE (c.last_updated_at IS NOT NULL OR c.created_at IS NOT NULL)${whereAnd}
|
|
1165
|
+
ORDER BY month
|
|
1166
|
+
`).all(...params);
|
|
1167
|
+
const monthCosts = {};
|
|
1168
|
+
for (const r of monthRows) {
|
|
1169
|
+
if (!r.month) continue;
|
|
1170
|
+
let topModel = null;
|
|
1171
|
+
try {
|
|
1172
|
+
const models = JSON.parse(r._models || '[]');
|
|
1173
|
+
if (models.length > 0) {
|
|
1174
|
+
const freq = {};
|
|
1175
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1176
|
+
topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1177
|
+
}
|
|
1178
|
+
} catch {}
|
|
1179
|
+
if (!topModel) continue;
|
|
1180
|
+
let inTok = r.inTok || 0, outTok = r.outTok || 0;
|
|
1181
|
+
if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
|
|
1182
|
+
inTok = Math.round((r.uChars || 0) / 4);
|
|
1183
|
+
outTok = Math.round((r.aChars || 0) / 4);
|
|
1184
|
+
}
|
|
1185
|
+
const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
|
|
1186
|
+
if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
|
|
1187
|
+
monthCosts[r.month].cost += cost;
|
|
1188
|
+
monthCosts[r.month].sessions++;
|
|
1189
|
+
}
|
|
1190
|
+
const monthly = Object.entries(monthCosts).sort((a, b) => a[0].localeCompare(b[0]))
|
|
1191
|
+
.map(([month, d]) => ({ month, cost: Math.round(d.cost * 100) / 100, sessions: d.sessions }));
|
|
1192
|
+
|
|
1193
|
+
// Top expensive sessions
|
|
1194
|
+
const sessionRows = db.prepare(`
|
|
1195
|
+
SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
|
|
1196
|
+
cs.models AS _models,
|
|
1197
|
+
cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
|
|
1198
|
+
cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
|
|
1199
|
+
cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars,
|
|
1200
|
+
cs.total_messages AS msgs
|
|
1201
|
+
FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
|
|
1202
|
+
WHERE 1=1${whereAnd}
|
|
1203
|
+
`).all(...params);
|
|
1204
|
+
const sessionCosts = [];
|
|
1205
|
+
for (const r of sessionRows) {
|
|
1206
|
+
let topModel = null;
|
|
1207
|
+
try {
|
|
1208
|
+
const models = JSON.parse(r._models || '[]');
|
|
1209
|
+
if (models.length > 0) {
|
|
1210
|
+
const freq = {};
|
|
1211
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1212
|
+
topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1213
|
+
}
|
|
1214
|
+
} catch {}
|
|
1215
|
+
if (!topModel) continue;
|
|
1216
|
+
let inTok = r.inTok || 0, outTok = r.outTok || 0;
|
|
1217
|
+
if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
|
|
1218
|
+
inTok = Math.round((r.uChars || 0) / 4);
|
|
1219
|
+
outTok = Math.round((r.aChars || 0) / 4);
|
|
1220
|
+
}
|
|
1221
|
+
const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
|
|
1222
|
+
if (cost > 0) {
|
|
1223
|
+
sessionCosts.push({
|
|
1224
|
+
id: r.id, source: r.source, name: r.name, folder: r.folder,
|
|
1225
|
+
model: normalizeModelName(topModel) || topModel,
|
|
1226
|
+
cost, messages: r.msgs || 0,
|
|
1227
|
+
lastUpdatedAt: r.last_updated_at || r.created_at,
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
sessionCosts.sort((a, b) => b.cost - a.cost);
|
|
1232
|
+
|
|
1233
|
+
// Summary stats
|
|
1234
|
+
const totalSessions = sessionCosts.length;
|
|
1235
|
+
const avgPerSession = totalSessions > 0 ? overall.totalCost / totalSessions : 0;
|
|
1236
|
+
const totalDays = monthly.length > 0 ? (() => {
|
|
1237
|
+
const first = new Date(monthly[0].month + '-01');
|
|
1238
|
+
const last = new Date(monthly[monthly.length - 1].month + '-01');
|
|
1239
|
+
return Math.max(1, Math.ceil((last - first) / 86400000) + 30);
|
|
1240
|
+
})() : 1;
|
|
1241
|
+
const avgPerDay = overall.totalCost / totalDays;
|
|
1242
|
+
|
|
1243
|
+
return {
|
|
1244
|
+
totalCost: overall.totalCost,
|
|
1245
|
+
byModel: overall.byModel,
|
|
1246
|
+
unknownModels: overall.unknownModels,
|
|
1247
|
+
byEditor,
|
|
1248
|
+
byProject: byProject.slice(0, 20),
|
|
1249
|
+
monthly,
|
|
1250
|
+
topSessions: sessionCosts.slice(0, 50),
|
|
1251
|
+
summary: {
|
|
1252
|
+
totalSessions,
|
|
1253
|
+
avgPerSession: Math.round(avgPerSession * 100) / 100,
|
|
1254
|
+
avgPerDay: Math.round(avgPerDay * 100) / 100,
|
|
1255
|
+
totalDays,
|
|
1256
|
+
},
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
852
1260
|
function getDb() { return db; }
|
|
853
1261
|
|
|
854
1262
|
module.exports = {
|
|
@@ -865,5 +1273,7 @@ module.exports = {
|
|
|
865
1273
|
resetAndRescan,
|
|
866
1274
|
resetAndRescanAsync,
|
|
867
1275
|
getCachedDashboardStats,
|
|
1276
|
+
getCostBreakdown,
|
|
1277
|
+
getCostAnalytics,
|
|
868
1278
|
getDb,
|
|
869
1279
|
};
|
package/editors/cursor.js
CHANGED
|
@@ -91,11 +91,6 @@ function normalizeStoreMessage(json) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
if (json.model) msg._model = json.model;
|
|
94
|
-
// Extract provider as model hint if no explicit model
|
|
95
|
-
if (!msg._model && json.providerOptions && typeof json.providerOptions === 'object') {
|
|
96
|
-
const providers = Object.keys(json.providerOptions).filter(k => k !== 'type');
|
|
97
|
-
if (providers.length > 0) msg._model = providers[0];
|
|
98
|
-
}
|
|
99
94
|
return msg;
|
|
100
95
|
}
|
|
101
96
|
|
|
@@ -159,6 +154,15 @@ function getComposerHeaders(stateDbPath) {
|
|
|
159
154
|
} catch { return []; }
|
|
160
155
|
}
|
|
161
156
|
|
|
157
|
+
function getModelPreference(globalDb) {
|
|
158
|
+
try {
|
|
159
|
+
const row = globalDb.prepare("SELECT value FROM ItemTable WHERE key = 'cursor/lastSingleModelPreference'").get();
|
|
160
|
+
if (!row) return null;
|
|
161
|
+
const pref = JSON.parse(row.value);
|
|
162
|
+
return pref.composer || pref.agent || null;
|
|
163
|
+
} catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
162
166
|
function getComposerBubbles(globalDb, composerId) {
|
|
163
167
|
const prefix = `bubbleId:${composerId}:`;
|
|
164
168
|
const rows = globalDb.prepare(
|
|
@@ -260,9 +264,11 @@ function getChats() {
|
|
|
260
264
|
composerId: chatId,
|
|
261
265
|
name: meta.name || null,
|
|
262
266
|
createdAt: meta.createdAt || null,
|
|
267
|
+
mode: meta.mode || null,
|
|
263
268
|
folder: null,
|
|
264
269
|
_dbPath: dbPath,
|
|
265
270
|
_rootBlobId: meta.latestRootBlobId,
|
|
271
|
+
_lastUsedModel: meta.lastUsedModel || null,
|
|
266
272
|
_type: 'agent-store',
|
|
267
273
|
});
|
|
268
274
|
}
|
|
@@ -273,6 +279,8 @@ function getChats() {
|
|
|
273
279
|
let globalDb = null;
|
|
274
280
|
try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { /* no global db */ }
|
|
275
281
|
|
|
282
|
+
const modelPref = globalDb ? getModelPreference(globalDb) : null;
|
|
283
|
+
|
|
276
284
|
for (const { hash, folder, stateDb } of getWorkspaceMap()) {
|
|
277
285
|
const headers = getComposerHeaders(stateDb);
|
|
278
286
|
for (const h of headers) {
|
|
@@ -295,6 +303,7 @@ function getChats() {
|
|
|
295
303
|
folder,
|
|
296
304
|
bubbleCount,
|
|
297
305
|
_type: 'workspace',
|
|
306
|
+
_modelPref: modelPref,
|
|
298
307
|
});
|
|
299
308
|
}
|
|
300
309
|
}
|
|
@@ -308,6 +317,12 @@ function getMessages(chat) {
|
|
|
308
317
|
const db = new Database(chat._dbPath, { readonly: true });
|
|
309
318
|
const msgs = collectStoreMessages(db, chat._rootBlobId);
|
|
310
319
|
db.close();
|
|
320
|
+
// Use lastUsedModel as fallback for assistant messages without model info
|
|
321
|
+
if (chat._lastUsedModel) {
|
|
322
|
+
for (const m of msgs) {
|
|
323
|
+
if (m.role === 'assistant' && !m._model) m._model = chat._lastUsedModel;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
311
326
|
return msgs;
|
|
312
327
|
}
|
|
313
328
|
|
|
@@ -315,7 +330,14 @@ function getMessages(chat) {
|
|
|
315
330
|
try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { return []; }
|
|
316
331
|
const bubbles = getComposerBubbles(globalDb, chat.composerId);
|
|
317
332
|
globalDb.close();
|
|
318
|
-
|
|
333
|
+
const msgs = bubblesToMessages(bubbles);
|
|
334
|
+
// Use model preference as fallback for messages without model info
|
|
335
|
+
if (chat._modelPref) {
|
|
336
|
+
for (const m of msgs) {
|
|
337
|
+
if (m.role === 'assistant' && !m._model) m._model = chat._modelPref;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return msgs;
|
|
319
341
|
}
|
|
320
342
|
|
|
321
343
|
module.exports = { name, getChats, getMessages };
|
package/editors/vscode.js
CHANGED
|
@@ -118,6 +118,7 @@ function parseSessionFile(filePath) {
|
|
|
118
118
|
title: state.customTitle || null,
|
|
119
119
|
requests: state.requests || [],
|
|
120
120
|
format: 'jsonl',
|
|
121
|
+
selectedModel: state.inputState?.selectedModel?.metadata?.id || null,
|
|
121
122
|
};
|
|
122
123
|
} else if (ext === '.json') {
|
|
123
124
|
let data;
|
|
@@ -128,6 +129,7 @@ function parseSessionFile(filePath) {
|
|
|
128
129
|
title: data.customTitle || null,
|
|
129
130
|
requests: data.requests || [],
|
|
130
131
|
format: 'json',
|
|
132
|
+
selectedModel: data.inputState?.selectedModel?.metadata?.id || null,
|
|
131
133
|
};
|
|
132
134
|
}
|
|
133
135
|
return null;
|
|
@@ -204,6 +206,7 @@ function collectSessions(dir, folder, source, chats) {
|
|
|
204
206
|
encrypted: false,
|
|
205
207
|
bubbleCount: meta.requestCount || 0,
|
|
206
208
|
_filePath: filePath,
|
|
209
|
+
_modelPref: meta.selectedModel || null,
|
|
207
210
|
});
|
|
208
211
|
} catch { /* skip */ }
|
|
209
212
|
}
|
|
@@ -221,6 +224,7 @@ function peekMeta(filePath) {
|
|
|
221
224
|
createdAt: data.creationDate || data.lastMessageDate,
|
|
222
225
|
requestCount: data.requests?.length || 0,
|
|
223
226
|
firstUserText: firstText.substring(0, 120) || null,
|
|
227
|
+
selectedModel: data.inputState?.selectedModel?.metadata?.id || null,
|
|
224
228
|
};
|
|
225
229
|
} catch { return {}; }
|
|
226
230
|
}
|
|
@@ -251,6 +255,7 @@ function peekMeta(filePath) {
|
|
|
251
255
|
createdAt: state.creationDate,
|
|
252
256
|
requestCount: null,
|
|
253
257
|
firstUserText: null,
|
|
258
|
+
selectedModel: state.inputState?.selectedModel?.metadata?.id || null,
|
|
254
259
|
};
|
|
255
260
|
} catch { return {}; }
|
|
256
261
|
}
|
|
@@ -301,6 +306,7 @@ function getMessages(chat) {
|
|
|
301
306
|
const meta = req.result?.metadata;
|
|
302
307
|
messages.push({
|
|
303
308
|
role: 'assistant', content: responseText,
|
|
309
|
+
_model: parsed.selectedModel || null,
|
|
304
310
|
_inputTokens: meta?.promptTokens, _outputTokens: meta?.outputTokens,
|
|
305
311
|
_toolCalls,
|
|
306
312
|
});
|