agentlytics 0.2.7 → 0.2.9
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 +60 -1
- package/cache.js +95 -170
- package/deno.json +9 -0
- package/editors/opencode.js +55 -28
- package/index.js +46 -8
- package/mod.ts +1020 -0
- package/package.json +7 -2
- package/relay-server.js +1 -1
- package/ui/package.json +1 -1
- package/ui/src/App.jsx +13 -8
- package/ui/src/hooks/useLive.jsx +15 -0
- package/ui/src/pages/Dashboard.jsx +3 -2
- package/ui/src/pages/DeepAnalysis.jsx +10 -7
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<a href="#supported-editors"><img src="https://img.shields.io/badge/editors-16-818cf8" alt="editors"></a>
|
|
15
15
|
<a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
|
|
16
16
|
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A520.19%20%7C%20%E2%89%A522.12-brightgreen" alt="node"></a>
|
|
17
|
+
<a href="https://deno.land"><img src="https://img.shields.io/badge/deno-%E2%89%A52.0-000?logo=deno" alt="deno"></a>
|
|
17
18
|
</p>
|
|
18
19
|
|
|
19
20
|
<p align="center">
|
|
@@ -39,10 +40,61 @@ You switch between Cursor, Windsurf, Claude Code, VS Code Copilot, and more —
|
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
42
|
npx agentlytics
|
|
43
|
+
# or
|
|
44
|
+
pnpm dlx agentlytics
|
|
45
|
+
# or
|
|
46
|
+
yarn dlx agentlytics
|
|
47
|
+
# or
|
|
48
|
+
bunx agentlytics
|
|
42
49
|
```
|
|
43
50
|
|
|
44
51
|
Opens at **http://localhost:4637**. Requires Node.js ≥ 20.19 or ≥ 22.12, macOS. No data ever leaves your machine.
|
|
45
52
|
|
|
53
|
+
### Deno (Sandboxed)
|
|
54
|
+
|
|
55
|
+
Run a lightweight, zero-dependency analytics scan with Deno's permission sandbox — directly from a URL, no install needed:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
deno run --allow-read --allow-env https://raw.githubusercontent.com/f/agentlytics/master/mod.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Only `--allow-read` and `--allow-env` are required. No network access, no file writes, no code execution — just reads your local editor data and prints a summary.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
(● ●) [● ●] Agentlytics — Deno Sandboxed Edition
|
|
65
|
+
{● ●} <● ●> Lightweight CLI analytics for AI coding agents
|
|
66
|
+
|
|
67
|
+
✓ Claude Code 8 sessions
|
|
68
|
+
✓ VS Code 23 sessions
|
|
69
|
+
✓ VS Code Insiders 66 sessions
|
|
70
|
+
● Cursor detected
|
|
71
|
+
✓ Codex CLI 3 sessions
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
Summary
|
|
75
|
+
Sessions 109
|
|
76
|
+
Messages 459
|
|
77
|
+
Projects 18
|
|
78
|
+
Editors 7 of 15 checked
|
|
79
|
+
Date range 2025-04-02 → 2026-03-09
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Add `--json` for machine-readable output:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
deno run --allow-read --allow-env mod.ts --json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
If you've cloned the repo, you can also use Deno tasks for the full dashboard:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
deno task start # Full dashboard (all permissions)
|
|
92
|
+
deno task scan # Lightweight CLI scan
|
|
93
|
+
deno task scan:json # JSON output
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Node.js
|
|
97
|
+
|
|
46
98
|
```
|
|
47
99
|
$ npx agentlytics
|
|
48
100
|
|
|
@@ -68,6 +120,7 @@ To only build the cache without starting the server:
|
|
|
68
120
|
|
|
69
121
|
```bash
|
|
70
122
|
npx agentlytics --collect
|
|
123
|
+
# or: pnpm dlx agentlytics --collect
|
|
71
124
|
```
|
|
72
125
|
|
|
73
126
|
## Features
|
|
@@ -112,6 +165,7 @@ Relay enables multi-user context sharing across a team. One person starts a rela
|
|
|
112
165
|
|
|
113
166
|
```bash
|
|
114
167
|
npx agentlytics --relay
|
|
168
|
+
# or: pnpm dlx agentlytics --relay
|
|
115
169
|
```
|
|
116
170
|
|
|
117
171
|
Optionally protect with a password:
|
|
@@ -138,6 +192,7 @@ This starts a relay server on port `4638` and prints the join command and MCP en
|
|
|
138
192
|
```bash
|
|
139
193
|
cd /path/to/your-project
|
|
140
194
|
npx agentlytics --join <host:port>
|
|
195
|
+
# or: pnpm dlx agentlytics --join <host:port>
|
|
141
196
|
```
|
|
142
197
|
|
|
143
198
|
If the relay is password-protected:
|
|
@@ -186,7 +241,11 @@ Editor files/APIs → editors/*.js → cache.js (SQLite) → server.js (REST)
|
|
|
186
241
|
Relay: join clients → POST /relay/sync → relay.db (SQLite) → MCP server → AI clients
|
|
187
242
|
```
|
|
188
243
|
|
|
189
|
-
|
|
244
|
+
```
|
|
245
|
+
Deno: Editor files → mod.ts (zero deps) → stdout (CLI/JSON)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
All data is normalized into a local SQLite cache at `~/.agentlytics/cache.db`. The Express server exposes read-only REST endpoints consumed by the React frontend. Relay data is stored separately in `~/.agentlytics/relay.db`. The Deno sandboxed edition (`mod.ts`) bypasses SQLite entirely and reads editor files directly for a lightweight, permission-minimal CLI report.
|
|
190
249
|
|
|
191
250
|
## API
|
|
192
251
|
|
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/deno.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tasks": {
|
|
3
|
+
"start": "deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-run index.js",
|
|
4
|
+
"collect": "deno run --allow-read --allow-write --allow-env --allow-ffi index.js --collect",
|
|
5
|
+
"scan": "deno run --allow-read --allow-env mod.ts",
|
|
6
|
+
"scan:json": "deno run --allow-read --allow-env mod.ts --json"
|
|
7
|
+
},
|
|
8
|
+
"nodeModulesDir": "auto"
|
|
9
|
+
}
|