claude-spend 1.0.1 → 1.0.3
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 +19 -0
- package/package.json +1 -1
- package/src/index.js +1 -1
- package/src/parser.js +212 -15
- package/src/public/index.html +316 -44
- package/src/server.js +3 -4
package/README.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
See where your Claude Code tokens go. One command, zero setup.
|
|
4
4
|
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
I've been using Claude Code every day for 3 months. I hit the usage limit almost daily, but had zero visibility into which prompts were eating my tokens. So I built claude-spend. One command, zero setup.
|
|
8
|
+
|
|
9
|
+
## How does it look
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
<img width="1910" height="966" alt="Screenshot 2026-02-18 092727" src="https://github.com/user-attachments/assets/11cc7149-d4dd-4e44-a3a0-0b48e935b7bc" />
|
|
13
|
+
|
|
14
|
+
<img width="1906" height="966" alt="Screenshot 2026-02-18 093529" src="https://github.com/user-attachments/assets/537c3611-5794-41d2-864e-e368e6949812" />
|
|
15
|
+
|
|
16
|
+
<img width="1908" height="969" alt="Screenshot 2026-02-18 093647" src="https://github.com/user-attachments/assets/aaaa8ce5-2025-407d-8596-ea1965748691" />
|
|
17
|
+
|
|
18
|
+
<img width="1908" height="969" alt="Screenshot 2026-02-18 093647" src="https://github.com/user-attachments/assets/a9fde5e2-6e52-4bae-9b96-03655109aef6" />
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
5
22
|
## Install
|
|
6
23
|
|
|
7
24
|
```
|
|
@@ -10,12 +27,14 @@ npx claude-spend
|
|
|
10
27
|
|
|
11
28
|
That's it. Opens a dashboard in your browser.
|
|
12
29
|
|
|
30
|
+
|
|
13
31
|
## What it does
|
|
14
32
|
|
|
15
33
|
- Reads your local Claude Code session files (nothing leaves your machine)
|
|
16
34
|
- Shows token usage per conversation, per day, and per model
|
|
17
35
|
- Surfaces insights like which prompts cost the most and usage patterns
|
|
18
36
|
|
|
37
|
+
|
|
19
38
|
## Options
|
|
20
39
|
|
|
21
40
|
```
|
package/package.json
CHANGED
package/src/index.js
CHANGED
package/src/parser.js
CHANGED
|
@@ -3,6 +3,43 @@ const path = require('path');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const readline = require('readline');
|
|
5
5
|
|
|
6
|
+
// Anthropic API pricing per token (from platform.claude.com/docs/en/about-claude/pricing)
|
|
7
|
+
// Note: These are API-equivalent estimates. Claude Code subscription pricing differs.
|
|
8
|
+
// Cache write = 1.25x base input (5-min TTL). Cache read = 0.1x base input.
|
|
9
|
+
const MODEL_PRICING = {
|
|
10
|
+
// Opus 4.5, 4.6: $5/MTok in, $25/MTok out
|
|
11
|
+
'opus-4.5': { input: 5 / 1e6, output: 25 / 1e6, cacheWrite: 6.25 / 1e6, cacheRead: 0.50 / 1e6 },
|
|
12
|
+
'opus-4.6': { input: 5 / 1e6, output: 25 / 1e6, cacheWrite: 6.25 / 1e6, cacheRead: 0.50 / 1e6 },
|
|
13
|
+
// Opus 4.0, 4.1: $15/MTok in, $75/MTok out
|
|
14
|
+
'opus-4.0': { input: 15 / 1e6, output: 75 / 1e6, cacheWrite: 18.75 / 1e6, cacheRead: 1.50 / 1e6 },
|
|
15
|
+
'opus-4.1': { input: 15 / 1e6, output: 75 / 1e6, cacheWrite: 18.75 / 1e6, cacheRead: 1.50 / 1e6 },
|
|
16
|
+
// Sonnet 3.7, 4, 4.5, 4.6: $3/MTok in, $15/MTok out
|
|
17
|
+
sonnet: { input: 3 / 1e6, output: 15 / 1e6, cacheWrite: 3.75 / 1e6, cacheRead: 0.30 / 1e6 },
|
|
18
|
+
// Haiku 4.5: $1/MTok in, $5/MTok out
|
|
19
|
+
'haiku-4.5': { input: 1 / 1e6, output: 5 / 1e6, cacheWrite: 1.25 / 1e6, cacheRead: 0.10 / 1e6 },
|
|
20
|
+
// Haiku 3.5: $0.80/MTok in, $4/MTok out
|
|
21
|
+
'haiku-3.5': { input: 0.80 / 1e6, output: 4 / 1e6, cacheWrite: 1.00 / 1e6, cacheRead: 0.08 / 1e6 },
|
|
22
|
+
};
|
|
23
|
+
const DEFAULT_PRICING = MODEL_PRICING.sonnet;
|
|
24
|
+
|
|
25
|
+
function getPricing(model) {
|
|
26
|
+
if (!model) return DEFAULT_PRICING;
|
|
27
|
+
const m = model.toLowerCase();
|
|
28
|
+
if (m.includes('opus')) {
|
|
29
|
+
// Opus 4.5/4.6 are cheaper than Opus 4.0/4.1
|
|
30
|
+
if (m.includes('4-6') || m.includes('4.6')) return MODEL_PRICING['opus-4.6'];
|
|
31
|
+
if (m.includes('4-5') || m.includes('4.5')) return MODEL_PRICING['opus-4.5'];
|
|
32
|
+
if (m.includes('4-1') || m.includes('4.1')) return MODEL_PRICING['opus-4.1'];
|
|
33
|
+
return MODEL_PRICING['opus-4.0']; // Opus 4.0 and Opus 3
|
|
34
|
+
}
|
|
35
|
+
if (m.includes('sonnet')) return MODEL_PRICING.sonnet;
|
|
36
|
+
if (m.includes('haiku')) {
|
|
37
|
+
if (m.includes('4-5') || m.includes('4.5')) return MODEL_PRICING['haiku-4.5'];
|
|
38
|
+
return MODEL_PRICING['haiku-3.5'];
|
|
39
|
+
}
|
|
40
|
+
return DEFAULT_PRICING;
|
|
41
|
+
}
|
|
42
|
+
|
|
6
43
|
function getClaudeDir() {
|
|
7
44
|
return path.join(os.homedir(), '.claude');
|
|
8
45
|
}
|
|
@@ -36,8 +73,11 @@ function extractSessionData(entries) {
|
|
|
36
73
|
content.startsWith('<command-name')
|
|
37
74
|
)) continue;
|
|
38
75
|
|
|
76
|
+
const textContent = typeof content === 'string'
|
|
77
|
+
? content
|
|
78
|
+
: content.filter(b => b.type === 'text').map(b => b.text).join('\n').trim();
|
|
39
79
|
pendingUserMessage = {
|
|
40
|
-
text:
|
|
80
|
+
text: textContent || null,
|
|
41
81
|
timestamp: entry.timestamp,
|
|
42
82
|
};
|
|
43
83
|
}
|
|
@@ -47,10 +87,23 @@ function extractSessionData(entries) {
|
|
|
47
87
|
const model = entry.message.model || 'unknown';
|
|
48
88
|
if (model === '<synthetic>') continue;
|
|
49
89
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
90
|
+
const pricing = getPricing(model);
|
|
91
|
+
const inputTokens = usage.input_tokens || 0;
|
|
92
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
93
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
53
94
|
const outputTokens = usage.output_tokens || 0;
|
|
95
|
+
const totalTokens = inputTokens + cacheCreationTokens + cacheReadTokens + outputTokens;
|
|
96
|
+
const cost = (inputTokens * pricing.input)
|
|
97
|
+
+ (cacheCreationTokens * pricing.cacheWrite)
|
|
98
|
+
+ (cacheReadTokens * pricing.cacheRead)
|
|
99
|
+
+ (outputTokens * pricing.output);
|
|
100
|
+
|
|
101
|
+
const tools = [];
|
|
102
|
+
if (Array.isArray(entry.message.content)) {
|
|
103
|
+
for (const block of entry.message.content) {
|
|
104
|
+
if (block.type === 'tool_use' && block.name) tools.push(block.name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
54
107
|
|
|
55
108
|
queries.push({
|
|
56
109
|
userPrompt: pendingUserMessage?.text || null,
|
|
@@ -58,8 +111,12 @@ function extractSessionData(entries) {
|
|
|
58
111
|
assistantTimestamp: entry.timestamp,
|
|
59
112
|
model,
|
|
60
113
|
inputTokens,
|
|
114
|
+
cacheCreationTokens,
|
|
115
|
+
cacheReadTokens,
|
|
61
116
|
outputTokens,
|
|
62
|
-
totalTokens
|
|
117
|
+
totalTokens,
|
|
118
|
+
cost,
|
|
119
|
+
tools,
|
|
63
120
|
});
|
|
64
121
|
}
|
|
65
122
|
}
|
|
@@ -90,7 +147,11 @@ async function parseAllSessions() {
|
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
const projectDirs = fs.readdirSync(projectsDir).filter(d => {
|
|
93
|
-
|
|
150
|
+
try {
|
|
151
|
+
return fs.statSync(path.join(projectsDir, d)).isDirectory();
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
94
155
|
});
|
|
95
156
|
|
|
96
157
|
const sessions = [];
|
|
@@ -100,7 +161,12 @@ async function parseAllSessions() {
|
|
|
100
161
|
|
|
101
162
|
for (const projectDir of projectDirs) {
|
|
102
163
|
const dir = path.join(projectsDir, projectDir);
|
|
103
|
-
|
|
164
|
+
let files;
|
|
165
|
+
try {
|
|
166
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
167
|
+
} catch {
|
|
168
|
+
continue; // Skip directories we can't read
|
|
169
|
+
}
|
|
104
170
|
|
|
105
171
|
for (const file of files) {
|
|
106
172
|
const filePath = path.join(dir, file);
|
|
@@ -117,12 +183,15 @@ async function parseAllSessions() {
|
|
|
117
183
|
const queries = extractSessionData(entries);
|
|
118
184
|
if (queries.length === 0) continue;
|
|
119
185
|
|
|
120
|
-
let inputTokens = 0, outputTokens = 0;
|
|
186
|
+
let inputTokens = 0, outputTokens = 0, cacheCreationTokens = 0, cacheReadTokens = 0, cost = 0;
|
|
121
187
|
for (const q of queries) {
|
|
122
188
|
inputTokens += q.inputTokens;
|
|
123
189
|
outputTokens += q.outputTokens;
|
|
190
|
+
cacheCreationTokens += q.cacheCreationTokens;
|
|
191
|
+
cacheReadTokens += q.cacheReadTokens;
|
|
192
|
+
cost += q.cost;
|
|
124
193
|
}
|
|
125
|
-
const totalTokens = inputTokens + outputTokens;
|
|
194
|
+
const totalTokens = inputTokens + cacheCreationTokens + cacheReadTokens + outputTokens;
|
|
126
195
|
|
|
127
196
|
const firstTimestamp = entries.find(e => e.timestamp)?.timestamp;
|
|
128
197
|
const date = firstTimestamp ? firstTimestamp.split('T')[0] : 'unknown';
|
|
@@ -141,14 +210,17 @@ async function parseAllSessions() {
|
|
|
141
210
|
// Collect per-prompt data for "most expensive prompts"
|
|
142
211
|
// Group consecutive queries under the same user prompt
|
|
143
212
|
let currentPrompt = null;
|
|
144
|
-
let promptInput = 0, promptOutput = 0;
|
|
213
|
+
let promptInput = 0, promptOutput = 0, promptCacheCreation = 0, promptCacheRead = 0, promptCost = 0;
|
|
145
214
|
const flushPrompt = () => {
|
|
146
|
-
if (currentPrompt && (promptInput + promptOutput) > 0) {
|
|
215
|
+
if (currentPrompt && (promptInput + promptOutput + promptCacheCreation + promptCacheRead) > 0) {
|
|
147
216
|
allPrompts.push({
|
|
148
217
|
prompt: currentPrompt.substring(0, 300),
|
|
149
218
|
inputTokens: promptInput,
|
|
150
219
|
outputTokens: promptOutput,
|
|
151
|
-
|
|
220
|
+
cacheCreationTokens: promptCacheCreation,
|
|
221
|
+
cacheReadTokens: promptCacheRead,
|
|
222
|
+
totalTokens: promptInput + promptOutput + promptCacheCreation + promptCacheRead,
|
|
223
|
+
cost: promptCost,
|
|
152
224
|
date,
|
|
153
225
|
sessionId,
|
|
154
226
|
model: primaryModel,
|
|
@@ -161,9 +233,15 @@ async function parseAllSessions() {
|
|
|
161
233
|
currentPrompt = q.userPrompt;
|
|
162
234
|
promptInput = 0;
|
|
163
235
|
promptOutput = 0;
|
|
236
|
+
promptCacheCreation = 0;
|
|
237
|
+
promptCacheRead = 0;
|
|
238
|
+
promptCost = 0;
|
|
164
239
|
}
|
|
165
240
|
promptInput += q.inputTokens;
|
|
166
241
|
promptOutput += q.outputTokens;
|
|
242
|
+
promptCacheCreation += q.cacheCreationTokens;
|
|
243
|
+
promptCacheRead += q.cacheReadTokens;
|
|
244
|
+
promptCost += q.cost;
|
|
167
245
|
}
|
|
168
246
|
flushPrompt();
|
|
169
247
|
|
|
@@ -178,17 +256,23 @@ async function parseAllSessions() {
|
|
|
178
256
|
queries,
|
|
179
257
|
inputTokens,
|
|
180
258
|
outputTokens,
|
|
259
|
+
cacheCreationTokens,
|
|
260
|
+
cacheReadTokens,
|
|
181
261
|
totalTokens,
|
|
262
|
+
cost,
|
|
182
263
|
});
|
|
183
264
|
|
|
184
265
|
// Daily
|
|
185
266
|
if (date !== 'unknown') {
|
|
186
267
|
if (!dailyMap[date]) {
|
|
187
|
-
dailyMap[date] = { date, inputTokens: 0, outputTokens: 0, totalTokens: 0, sessions: 0, queries: 0 };
|
|
268
|
+
dailyMap[date] = { date, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, cost: 0, sessions: 0, queries: 0 };
|
|
188
269
|
}
|
|
189
270
|
dailyMap[date].inputTokens += inputTokens;
|
|
190
271
|
dailyMap[date].outputTokens += outputTokens;
|
|
272
|
+
dailyMap[date].cacheCreationTokens += cacheCreationTokens;
|
|
273
|
+
dailyMap[date].cacheReadTokens += cacheReadTokens;
|
|
191
274
|
dailyMap[date].totalTokens += totalTokens;
|
|
275
|
+
dailyMap[date].cost += cost;
|
|
192
276
|
dailyMap[date].sessions += 1;
|
|
193
277
|
dailyMap[date].queries += queries.length;
|
|
194
278
|
}
|
|
@@ -197,11 +281,14 @@ async function parseAllSessions() {
|
|
|
197
281
|
for (const q of queries) {
|
|
198
282
|
if (q.model === '<synthetic>' || q.model === 'unknown') continue;
|
|
199
283
|
if (!modelMap[q.model]) {
|
|
200
|
-
modelMap[q.model] = { model: q.model, inputTokens: 0, outputTokens: 0, totalTokens: 0, queryCount: 0 };
|
|
284
|
+
modelMap[q.model] = { model: q.model, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, cost: 0, queryCount: 0 };
|
|
201
285
|
}
|
|
202
286
|
modelMap[q.model].inputTokens += q.inputTokens;
|
|
203
287
|
modelMap[q.model].outputTokens += q.outputTokens;
|
|
288
|
+
modelMap[q.model].cacheCreationTokens += q.cacheCreationTokens;
|
|
289
|
+
modelMap[q.model].cacheReadTokens += q.cacheReadTokens;
|
|
204
290
|
modelMap[q.model].totalTokens += q.totalTokens;
|
|
291
|
+
modelMap[q.model].cost += q.cost;
|
|
205
292
|
modelMap[q.model].queryCount += 1;
|
|
206
293
|
}
|
|
207
294
|
}
|
|
@@ -209,18 +296,113 @@ async function parseAllSessions() {
|
|
|
209
296
|
|
|
210
297
|
sessions.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
211
298
|
|
|
299
|
+
// Build per-project aggregation
|
|
300
|
+
const projectMap = {};
|
|
301
|
+
for (const session of sessions) {
|
|
302
|
+
const proj = session.project;
|
|
303
|
+
if (!projectMap[proj]) {
|
|
304
|
+
projectMap[proj] = {
|
|
305
|
+
project: proj,
|
|
306
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
|
|
307
|
+
sessionCount: 0, queryCount: 0,
|
|
308
|
+
modelMap: {},
|
|
309
|
+
allPrompts: [],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const p = projectMap[proj];
|
|
313
|
+
p.inputTokens += session.inputTokens;
|
|
314
|
+
p.outputTokens += session.outputTokens;
|
|
315
|
+
p.totalTokens += session.totalTokens;
|
|
316
|
+
p.sessionCount += 1;
|
|
317
|
+
p.queryCount += session.queryCount;
|
|
318
|
+
|
|
319
|
+
for (const q of session.queries) {
|
|
320
|
+
if (q.model === '<synthetic>' || q.model === 'unknown') continue;
|
|
321
|
+
if (!p.modelMap[q.model]) {
|
|
322
|
+
p.modelMap[q.model] = { model: q.model, inputTokens: 0, outputTokens: 0, totalTokens: 0, queryCount: 0 };
|
|
323
|
+
}
|
|
324
|
+
const m = p.modelMap[q.model];
|
|
325
|
+
m.inputTokens += q.inputTokens;
|
|
326
|
+
m.outputTokens += q.outputTokens;
|
|
327
|
+
m.totalTokens += q.totalTokens;
|
|
328
|
+
m.queryCount += 1;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Per-project prompt grouping with tool tracking
|
|
332
|
+
let curPrompt = null, curInput = 0, curOutput = 0, curConts = 0;
|
|
333
|
+
let curModels = {}, curTools = {};
|
|
334
|
+
const flushProjectPrompt = () => {
|
|
335
|
+
if (curPrompt && (curInput + curOutput) > 0) {
|
|
336
|
+
const topModel = Object.entries(curModels).sort((a, b) => b[1] - a[1])[0]?.[0] || session.model;
|
|
337
|
+
p.allPrompts.push({
|
|
338
|
+
prompt: curPrompt.substring(0, 300),
|
|
339
|
+
inputTokens: curInput,
|
|
340
|
+
outputTokens: curOutput,
|
|
341
|
+
totalTokens: curInput + curOutput,
|
|
342
|
+
continuations: curConts,
|
|
343
|
+
model: topModel,
|
|
344
|
+
toolCounts: { ...curTools },
|
|
345
|
+
date: session.date,
|
|
346
|
+
sessionId: session.sessionId,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
for (const q of session.queries) {
|
|
351
|
+
if (q.userPrompt && q.userPrompt !== curPrompt) {
|
|
352
|
+
flushProjectPrompt();
|
|
353
|
+
curPrompt = q.userPrompt;
|
|
354
|
+
curInput = 0; curOutput = 0; curConts = 0;
|
|
355
|
+
curModels = {}; curTools = {};
|
|
356
|
+
} else if (!q.userPrompt) {
|
|
357
|
+
curConts++;
|
|
358
|
+
}
|
|
359
|
+
curInput += q.inputTokens;
|
|
360
|
+
curOutput += q.outputTokens;
|
|
361
|
+
if (q.model && q.model !== '<synthetic>') curModels[q.model] = (curModels[q.model] || 0) + 1;
|
|
362
|
+
for (const t of q.tools || []) curTools[t] = (curTools[t] || 0) + 1;
|
|
363
|
+
}
|
|
364
|
+
flushProjectPrompt();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const projectBreakdown = Object.values(projectMap).map(p => ({
|
|
368
|
+
project: p.project,
|
|
369
|
+
inputTokens: p.inputTokens,
|
|
370
|
+
outputTokens: p.outputTokens,
|
|
371
|
+
totalTokens: p.totalTokens,
|
|
372
|
+
sessionCount: p.sessionCount,
|
|
373
|
+
queryCount: p.queryCount,
|
|
374
|
+
modelBreakdown: Object.values(p.modelMap).sort((a, b) => b.totalTokens - a.totalTokens),
|
|
375
|
+
topPrompts: (p.allPrompts || []).sort((a, b) => b.totalTokens - a.totalTokens).slice(0, 10),
|
|
376
|
+
})).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
377
|
+
|
|
212
378
|
const dailyUsage = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
|
|
213
379
|
|
|
214
380
|
// Top 20 most expensive individual prompts
|
|
215
381
|
allPrompts.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
216
382
|
const topPrompts = allPrompts.slice(0, 20);
|
|
217
383
|
|
|
384
|
+
const totalCacheCreationTokens = sessions.reduce((sum, s) => sum + s.cacheCreationTokens, 0);
|
|
385
|
+
const totalCacheReadTokens = sessions.reduce((sum, s) => sum + s.cacheReadTokens, 0);
|
|
386
|
+
const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
|
|
387
|
+
const totalAllInput = sessions.reduce((sum, s) => sum + s.inputTokens + s.cacheCreationTokens + s.cacheReadTokens, 0);
|
|
388
|
+
|
|
389
|
+
// What caching saved: cache reads at full input price minus what they actually cost
|
|
390
|
+
const avgInputPrice = DEFAULT_PRICING.input;
|
|
391
|
+
const avgCacheReadPrice = DEFAULT_PRICING.cacheRead;
|
|
392
|
+
const totalSaved = totalCacheReadTokens * (avgInputPrice - avgCacheReadPrice);
|
|
393
|
+
const cacheHitRate = totalAllInput > 0 ? totalCacheReadTokens / totalAllInput : 0;
|
|
394
|
+
|
|
218
395
|
const grandTotals = {
|
|
219
396
|
totalSessions: sessions.length,
|
|
220
397
|
totalQueries: sessions.reduce((sum, s) => sum + s.queryCount, 0),
|
|
221
398
|
totalTokens: sessions.reduce((sum, s) => sum + s.totalTokens, 0),
|
|
222
399
|
totalInputTokens: sessions.reduce((sum, s) => sum + s.inputTokens, 0),
|
|
223
400
|
totalOutputTokens: sessions.reduce((sum, s) => sum + s.outputTokens, 0),
|
|
401
|
+
totalCacheCreationTokens,
|
|
402
|
+
totalCacheReadTokens,
|
|
403
|
+
totalCost,
|
|
404
|
+
totalSaved,
|
|
405
|
+
cacheHitRate,
|
|
224
406
|
avgTokensPerQuery: 0,
|
|
225
407
|
avgTokensPerSession: 0,
|
|
226
408
|
dateRange: dailyUsage.length > 0
|
|
@@ -241,6 +423,7 @@ async function parseAllSessions() {
|
|
|
241
423
|
sessions,
|
|
242
424
|
dailyUsage,
|
|
243
425
|
modelBreakdown: Object.values(modelMap),
|
|
426
|
+
projectBreakdown,
|
|
244
427
|
topPrompts,
|
|
245
428
|
totals: grandTotals,
|
|
246
429
|
insights,
|
|
@@ -259,7 +442,7 @@ function generateInsights(sessions, allPrompts, totals) {
|
|
|
259
442
|
id: 'vague-prompts',
|
|
260
443
|
type: 'warning',
|
|
261
444
|
title: 'Short, vague messages are costing you the most',
|
|
262
|
-
description: `${shortExpensive.length} times you sent a short message like ${examples.map(e => '"' + e + '"').join(', ')} -- and each time, Claude
|
|
445
|
+
description: `${shortExpensive.length} times you sent a short message like ${examples.map(e => '"' + e + '"').join(', ')} -- and each time, Claude burned through over 100K tokens before it figured out what you wanted. That adds up to ${fmt(totalWasted)} tokens total. Most of that isn't Claude writing back to you -- it's Claude re-reading your entire conversation, searching files, and making multiple attempts because the instruction was too vague.`,
|
|
263
446
|
action: 'Try being specific. Instead of "Yes", say "Yes, update the login page and run the tests." It gives Claude a clear target, so it finishes faster and uses fewer tokens.',
|
|
264
447
|
});
|
|
265
448
|
}
|
|
@@ -446,6 +629,20 @@ function generateInsights(sessions, allPrompts, totals) {
|
|
|
446
629
|
}
|
|
447
630
|
}
|
|
448
631
|
|
|
632
|
+
// 11. Cache efficiency
|
|
633
|
+
if (totals.totalCacheReadTokens > 0) {
|
|
634
|
+
const saved = totals.totalSaved;
|
|
635
|
+
const hitRate = (totals.cacheHitRate * 100).toFixed(1);
|
|
636
|
+
const withoutCaching = totals.totalCost + saved;
|
|
637
|
+
insights.push({
|
|
638
|
+
id: 'cache-savings',
|
|
639
|
+
type: 'info',
|
|
640
|
+
title: `Caching saved you an estimated $${saved.toFixed(2)}`,
|
|
641
|
+
description: `Your cache hit rate is ${hitRate}% -- meaning ${hitRate}% of all input tokens were served from cache at 10x lower cost. Without caching, your estimated API-equivalent bill would be $${withoutCaching.toFixed(2)} instead of $${totals.totalCost.toFixed(2)}. Cache reads happen when Claude re-reads parts of the conversation that haven't changed since the last turn.`,
|
|
642
|
+
action: 'Caching works best in longer conversations where context stays stable. Shorter sessions mean less cache reuse but also less context growth. The sweet spot is medium-length focused sessions on a single task.',
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
449
646
|
return insights;
|
|
450
647
|
}
|
|
451
648
|
|
package/src/public/index.html
CHANGED
|
@@ -229,12 +229,6 @@
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
/* Token explainer */
|
|
232
|
-
.token-explainer {
|
|
233
|
-
font-size: 13px; color: var(--text-tertiary); font-weight: 500;
|
|
234
|
-
text-align: center; padding: 10px 20px; margin: -16px 0 28px;
|
|
235
|
-
line-height: 1.5;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
232
|
/* ---- SECTION HEADINGS ---- */
|
|
239
233
|
.section-header {
|
|
240
234
|
display: flex; align-items: center; gap: 10px;
|
|
@@ -350,8 +344,9 @@
|
|
|
350
344
|
display: flex; gap: 1px; height: 4px; margin-top: 8px;
|
|
351
345
|
border-radius: 4px; overflow: hidden;
|
|
352
346
|
}
|
|
353
|
-
.token-bar-in { background: var(--indigo); height: 100%;
|
|
354
|
-
.token-bar-
|
|
347
|
+
.token-bar-in { background: var(--indigo); height: 100%; }
|
|
348
|
+
.token-bar-cache { background: var(--amber); height: 100%; }
|
|
349
|
+
.token-bar-out { background: var(--teal); height: 100%; }
|
|
355
350
|
.prompt-tokens { text-align: right; white-space: nowrap; padding-top: 2px; }
|
|
356
351
|
.prompt-tokens .value {
|
|
357
352
|
font-size: 16px; font-weight: 700; font-family: var(--mono);
|
|
@@ -359,6 +354,50 @@
|
|
|
359
354
|
}
|
|
360
355
|
.prompt-tokens .sub { font-size: 11px; color: var(--text-tertiary); font-weight: 500; margin-top: 2px; }
|
|
361
356
|
|
|
357
|
+
/* ---- PROJECTS ---- */
|
|
358
|
+
.projects-section { margin-bottom: 32px; }
|
|
359
|
+
|
|
360
|
+
/* Project accordion */
|
|
361
|
+
.proj-chevron {
|
|
362
|
+
width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
|
|
363
|
+
color: var(--text-tertiary); transition: transform 0.25s ease; flex-shrink: 0;
|
|
364
|
+
}
|
|
365
|
+
.proj-row { cursor: pointer; }
|
|
366
|
+
.proj-row:hover td { background: #FAFBFF; }
|
|
367
|
+
.proj-row.expanded .proj-chevron { transform: rotate(90deg); }
|
|
368
|
+
.proj-drawer td { padding: 0; border-bottom: 1px solid var(--border); }
|
|
369
|
+
.proj-drawer-inner { max-height: 0; overflow: hidden; transition: max-height 0.35s ease; background: #FAFBFC; }
|
|
370
|
+
.proj-drawer.open .proj-drawer-inner { max-height: 600px; }
|
|
371
|
+
.proj-drawer-content { padding: 12px 16px 16px; }
|
|
372
|
+
.drawer-prompt-list { display: flex; flex-direction: column; gap: 8px; }
|
|
373
|
+
.drawer-prompt-row {
|
|
374
|
+
display: grid; grid-template-columns: 24px 1fr auto;
|
|
375
|
+
gap: 10px; padding: 10px 14px; align-items: start;
|
|
376
|
+
background: white; border: 1px solid var(--border); border-radius: 10px;
|
|
377
|
+
cursor: pointer; transition: box-shadow 0.15s;
|
|
378
|
+
}
|
|
379
|
+
.drawer-prompt-row:hover { box-shadow: var(--shadow-md); }
|
|
380
|
+
.drawer-rank {
|
|
381
|
+
width: 20px; height: 20px; border-radius: 6px;
|
|
382
|
+
background: var(--bg); display: flex; align-items: center; justify-content: center;
|
|
383
|
+
font-size: 11px; font-weight: 700; color: var(--text-secondary); flex-shrink: 0;
|
|
384
|
+
}
|
|
385
|
+
.drawer-prompt-text {
|
|
386
|
+
font-size: 13px; font-weight: 500; line-height: 1.5;
|
|
387
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
388
|
+
word-break: break-word;
|
|
389
|
+
}
|
|
390
|
+
.drawer-prompt-meta { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; margin-top: 5px; }
|
|
391
|
+
.tool-chip {
|
|
392
|
+
background: var(--bg); border: 1px solid var(--border-strong);
|
|
393
|
+
padding: 1px 7px; border-radius: 6px; font-size: 10px; font-weight: 600;
|
|
394
|
+
color: var(--text-secondary); white-space: nowrap;
|
|
395
|
+
}
|
|
396
|
+
.drawer-tokens { text-align: right; white-space: nowrap; }
|
|
397
|
+
.drawer-tokens .value { font-family: var(--mono); font-size: 14px; font-weight: 700; }
|
|
398
|
+
.drawer-tokens .sub { font-size: 11px; color: var(--text-tertiary); font-weight: 500; margin-top: 2px; }
|
|
399
|
+
.drawer-empty { text-align: center; padding: 20px; color: var(--text-tertiary); font-size: 13px; font-weight: 500; }
|
|
400
|
+
|
|
362
401
|
/* ---- SESSIONS ---- */
|
|
363
402
|
.sessions-section { margin-bottom: 40px; }
|
|
364
403
|
.sessions-toolbar {
|
|
@@ -409,15 +448,43 @@
|
|
|
409
448
|
display: inline-flex; align-items: center; gap: 5px;
|
|
410
449
|
padding: 3px 10px; border-radius: 20px;
|
|
411
450
|
font-size: 12px; font-weight: 600;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
.model-
|
|
415
|
-
|
|
416
|
-
.model-
|
|
417
|
-
.model-
|
|
418
|
-
.model-
|
|
451
|
+
transition: box-shadow 0.15s;
|
|
452
|
+
}
|
|
453
|
+
.model-badge:focus-visible { outline: 2px solid var(--indigo); outline-offset: 2px; }
|
|
454
|
+
/* Contrast-corrected badge colors (WCAG AA compliant) */
|
|
455
|
+
.model-opus { background: #EEF2FF; color: #3730A3; }
|
|
456
|
+
.model-sonnet { background: #ECFDF5; color: #047857; }
|
|
457
|
+
.model-haiku { background: #FFF7ED; color: #C2410C; }
|
|
458
|
+
.model-unknown { background: #F1F5F9; color: #475569; }
|
|
459
|
+
.model-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
460
|
+
.model-opus .model-dot { background: #4F46E5; }
|
|
419
461
|
.model-sonnet .model-dot { background: #10B981; }
|
|
420
462
|
.model-haiku .model-dot { background: #F97316; }
|
|
463
|
+
/* Sub-line pills in project rows — compact variant */
|
|
464
|
+
.model-pills {
|
|
465
|
+
list-style: none; padding: 0; margin: 5px 0 0;
|
|
466
|
+
display: flex; flex-wrap: wrap; gap: 4px;
|
|
467
|
+
}
|
|
468
|
+
.model-pills li { display: flex; }
|
|
469
|
+
.model-pills .model-badge {
|
|
470
|
+
padding: 2px 8px; font-size: 11px; border-radius: 10px;
|
|
471
|
+
}
|
|
472
|
+
/* Screen-reader-only label (WCAG 2.1 compliant) */
|
|
473
|
+
.sr-only {
|
|
474
|
+
position: absolute; width: 1px; height: 1px; padding: 0;
|
|
475
|
+
margin: -1px; overflow: hidden; clip: rect(0,0,0,0);
|
|
476
|
+
white-space: nowrap; border-width: 0;
|
|
477
|
+
}
|
|
478
|
+
/* Inline ↓in ↑out token pair inside a model badge */
|
|
479
|
+
.token-detail {
|
|
480
|
+
display: inline-flex; align-items: center; gap: 2px;
|
|
481
|
+
font-size: 10px; font-weight: 600; opacity: 0.9;
|
|
482
|
+
}
|
|
483
|
+
/* Project name styled as path */
|
|
484
|
+
.proj-name {
|
|
485
|
+
font-weight: 600; font-size: 13px;
|
|
486
|
+
font-family: var(--mono); letter-spacing: -0.2px; color: var(--text);
|
|
487
|
+
}
|
|
421
488
|
.date-cell { white-space: nowrap; color: var(--text-secondary); font-weight: 500; }
|
|
422
489
|
.project-tag {
|
|
423
490
|
font-size: 11px; color: var(--text-tertiary); display: block;
|
|
@@ -537,9 +604,6 @@
|
|
|
537
604
|
|
|
538
605
|
<!-- Stats -->
|
|
539
606
|
<div class="stats-row" id="statsRow"></div>
|
|
540
|
-
<div id="tokenExplainer" class="token-explainer animate delay-2">
|
|
541
|
-
Tokens are how AI measures text -- roughly 1 token = 1 word. Hover over any label with a <span style="display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;background:rgba(0,0,0,0.08);font-size:10px;font-weight:700;vertical-align:middle;">?</span> for an explanation.
|
|
542
|
-
</div>
|
|
543
607
|
|
|
544
608
|
<!-- Insights -->
|
|
545
609
|
<div id="insightsSection" class="insights-section" style="display:none">
|
|
@@ -555,11 +619,12 @@
|
|
|
555
619
|
<!-- Charts -->
|
|
556
620
|
<div class="charts-grid animate delay-3">
|
|
557
621
|
<div class="chart-card">
|
|
558
|
-
<h3 class="has-tooltip has-tooltip-below" style="display:inline-block">Tokens per Day<div class="tooltip">How many tokens you used each day.
|
|
622
|
+
<h3 class="has-tooltip has-tooltip-below" style="display:inline-block">Tokens per Day<div class="tooltip">How many tokens you used each day. Indigo = fresh input + cache writes (full price), amber = cache reads (10x cheaper), teal = Claude's output.</div></h3>
|
|
559
623
|
<canvas id="dailyChart"></canvas>
|
|
560
624
|
<div class="legend">
|
|
561
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--indigo)"></div>
|
|
562
|
-
<div class="legend-item"><div class="legend-dot" style="background:var(--
|
|
625
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--indigo)"></div> Fresh input (full price)</div>
|
|
626
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--amber)"></div> Cache reads (10x cheaper)</div>
|
|
627
|
+
<div class="legend-item"><div class="legend-dot" style="background:var(--teal)"></div> Claude's output</div>
|
|
563
628
|
</div>
|
|
564
629
|
</div>
|
|
565
630
|
<div class="chart-card">
|
|
@@ -569,6 +634,30 @@
|
|
|
569
634
|
</div>
|
|
570
635
|
</div>
|
|
571
636
|
|
|
637
|
+
<!-- Projects -->
|
|
638
|
+
<div class="projects-section animate delay-4">
|
|
639
|
+
<div class="section-header">
|
|
640
|
+
<div class="section-icon" style="background:linear-gradient(135deg,#EDE9FE,#DDD6FE)">
|
|
641
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="#7C3AED" stroke-width="2.5" stroke-linecap="round"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M12 3v18"/><path d="M3 7l9 4 9-4"/></svg>
|
|
642
|
+
</div>
|
|
643
|
+
<div class="section-title">Projects</div>
|
|
644
|
+
<span id="projectsCount" style="font-size:13px;color:var(--text-tertiary);font-weight:600;margin-left:auto"></span>
|
|
645
|
+
</div>
|
|
646
|
+
<div class="sessions-card">
|
|
647
|
+
<table class="sessions-table">
|
|
648
|
+
<thead>
|
|
649
|
+
<tr>
|
|
650
|
+
<th>Project</th>
|
|
651
|
+
<th style="text-align:right">Total Tokens</th>
|
|
652
|
+
<th style="text-align:right">Sessions</th>
|
|
653
|
+
<th style="text-align:right">Queries</th>
|
|
654
|
+
</tr>
|
|
655
|
+
</thead>
|
|
656
|
+
<tbody id="projectsBody"></tbody>
|
|
657
|
+
</table>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
|
|
572
661
|
<!-- Most Expensive Prompts -->
|
|
573
662
|
<div class="top-prompts animate delay-4">
|
|
574
663
|
<div class="section-header">
|
|
@@ -623,6 +712,7 @@
|
|
|
623
712
|
</div>
|
|
624
713
|
|
|
625
714
|
<div class="footer">
|
|
715
|
+
<div style="margin-bottom: 8px; color: var(--text-secondary); font-size: 13px;">This tool runs 100% locally -- no analytics, no tracking. If you find it useful (or not), I'd love to hear from you at <a href="mailto:writetoaniketparihar@gmail.com">writetoaniketparihar@gmail.com</a></div>
|
|
626
716
|
Made with <a href="https://claude.ai/code" target="_blank">Claude Code</a> and ❤ by <a href="https://www.linkedin.com/in/aniketparihar/" target="_blank">Aniket</a>
|
|
627
717
|
</div>
|
|
628
718
|
</div>
|
|
@@ -647,13 +737,42 @@ function modelClass(m) {
|
|
|
647
737
|
return 'model-unknown';
|
|
648
738
|
}
|
|
649
739
|
function modelShort(m) {
|
|
740
|
+
// New format: claude-{family}-{major}-{minor}[-date]
|
|
741
|
+
// e.g. claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
|
742
|
+
let match = m.match(/^claude-(opus|sonnet|haiku)-(\d+)-(\d+)/i);
|
|
743
|
+
if (match) {
|
|
744
|
+
const name = match[1].charAt(0).toUpperCase() + match[1].slice(1);
|
|
745
|
+
return name + ' ' + match[2] + '.' + match[3];
|
|
746
|
+
}
|
|
747
|
+
// Old format with sub-version: claude-3-5-{family} or claude-3-7-{family}
|
|
748
|
+
match = m.match(/^claude-(\d+)-(\d+)-(opus|sonnet|haiku)/i);
|
|
749
|
+
if (match) {
|
|
750
|
+
const name = match[3].charAt(0).toUpperCase() + match[3].slice(1);
|
|
751
|
+
return name + ' ' + match[1] + '.' + match[2];
|
|
752
|
+
}
|
|
753
|
+
// Old format: claude-3-{family}
|
|
754
|
+
match = m.match(/^claude-(\d+)-(opus|sonnet|haiku)/i);
|
|
755
|
+
if (match) {
|
|
756
|
+
const name = match[2].charAt(0).toUpperCase() + match[2].slice(1);
|
|
757
|
+
return name + ' ' + match[1];
|
|
758
|
+
}
|
|
650
759
|
if (m.includes('opus')) return 'Opus';
|
|
651
760
|
if (m.includes('sonnet')) return 'Sonnet';
|
|
652
761
|
if (m.includes('haiku')) return 'Haiku';
|
|
653
762
|
return m;
|
|
654
763
|
}
|
|
655
764
|
function projectShort(p) {
|
|
656
|
-
|
|
765
|
+
// Strip Windows drive prefix (e.g. D-- or C--)
|
|
766
|
+
let s = p.replace(/^[A-Za-z]--/, '');
|
|
767
|
+
// Iteratively strip known parent directory segments
|
|
768
|
+
const known = /^(?:Users|home|user)-[^-]+-|^(?:GitHub|GitLab|git|Projects|projects|workspace|Workspace|Desktop|Documents|source|src|dev|Dev|code|Code|repos|Repos)-/;
|
|
769
|
+
let prev;
|
|
770
|
+
do { prev = s; s = s.replace(known, ''); } while (s !== prev && s.length > 0);
|
|
771
|
+
return s || p;
|
|
772
|
+
}
|
|
773
|
+
function projectFull(p) {
|
|
774
|
+
// Show the raw encoded name with Windows drive restored for readability
|
|
775
|
+
return p.replace(/^([A-Za-z])--/, '$1:\\');
|
|
657
776
|
}
|
|
658
777
|
function escapeHtml(s) {
|
|
659
778
|
const d = document.createElement('div'); d.textContent = s; return d.innerHTML;
|
|
@@ -666,14 +785,44 @@ function formatDate(d) {
|
|
|
666
785
|
}
|
|
667
786
|
|
|
668
787
|
async function fetchData() {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
788
|
+
try {
|
|
789
|
+
const res = await fetch('/api/data');
|
|
790
|
+
const json = await res.json();
|
|
791
|
+
if (!res.ok || json.error) {
|
|
792
|
+
showError(json.error || `Server returned ${res.status}`);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (!json.totals || !json.sessions) {
|
|
796
|
+
showError('No session data found. Make sure you have used Claude Code at least once.');
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
DATA = json;
|
|
800
|
+
render();
|
|
801
|
+
} catch (err) {
|
|
802
|
+
showError('Failed to load data: ' + err.message);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
function showError(msg) {
|
|
806
|
+
document.getElementById('loading').style.display = 'none';
|
|
807
|
+
document.getElementById('app').style.display = 'none';
|
|
808
|
+
let el = document.getElementById('error-display');
|
|
809
|
+
if (!el) {
|
|
810
|
+
el = document.createElement('div');
|
|
811
|
+
el.id = 'error-display';
|
|
812
|
+
el.style.cssText = 'max-width:600px;margin:80px auto;padding:32px;text-align:center;font-family:inherit;';
|
|
813
|
+
document.body.appendChild(el);
|
|
814
|
+
}
|
|
815
|
+
el.innerHTML = '<div style="font-size:48px;margin-bottom:16px">⚠️</div>' +
|
|
816
|
+
'<h2 style="margin:0 0 12px;color:#1a1a2e">Something went wrong</h2>' +
|
|
817
|
+
'<p style="color:#64748b;margin:0 0 20px;line-height:1.6">' + msg.replace(/</g, '<') + '</p>' +
|
|
818
|
+
'<button onclick="location.reload()" style="padding:8px 20px;border-radius:8px;border:1px solid #e2e8f0;background:#fff;cursor:pointer;font-size:14px">Retry</button>';
|
|
672
819
|
}
|
|
673
820
|
async function refreshData() {
|
|
674
821
|
document.getElementById('loading').style.display = 'flex';
|
|
675
822
|
document.getElementById('app').style.display = 'none';
|
|
676
|
-
|
|
823
|
+
const errEl = document.getElementById('error-display');
|
|
824
|
+
if (errEl) errEl.remove();
|
|
825
|
+
try { await fetch('/api/refresh'); } catch {}
|
|
677
826
|
await fetchData();
|
|
678
827
|
}
|
|
679
828
|
|
|
@@ -684,6 +833,7 @@ function render() {
|
|
|
684
833
|
renderInsights();
|
|
685
834
|
renderDailyChart();
|
|
686
835
|
renderModelChart();
|
|
836
|
+
renderProjectBreakdown();
|
|
687
837
|
renderTopPrompts();
|
|
688
838
|
renderSessions();
|
|
689
839
|
}
|
|
@@ -694,15 +844,18 @@ function renderStats() {
|
|
|
694
844
|
const range = t.dateRange ? `${formatDate(t.dateRange.from)} - ${formatDate(t.dateRange.to)}` : '';
|
|
695
845
|
document.getElementById('dateRange').textContent = range;
|
|
696
846
|
|
|
847
|
+
const cacheTotal = (t.totalCacheCreationTokens || 0) + (t.totalCacheReadTokens || 0);
|
|
848
|
+
const avgTokensPerMsg = t.totalQueries > 0 ? Math.round(t.totalTokens / t.totalQueries) : 0;
|
|
849
|
+
|
|
697
850
|
const cards = [
|
|
698
|
-
{ label: 'Total Usage', value: fmt(t.totalTokens), sub: `${fmt(t.totalInputTokens)}
|
|
699
|
-
tip: 'The total number of tokens used across all your conversations.
|
|
851
|
+
{ label: 'Total Usage', value: fmt(t.totalTokens), sub: `${fmt(t.totalInputTokens)} input + ${fmt(cacheTotal)} cached + ${fmt(t.totalOutputTokens)} output`,
|
|
852
|
+
tip: 'The total number of tokens used across all your conversations. Input = fresh context sent at full price. Cached = context reused from previous turns at 10x lower cost. Output = what Claude wrote back.' },
|
|
700
853
|
{ label: 'Conversations', value: fmtFull(t.totalSessions), sub: `Each one used ~${fmt(t.avgTokensPerSession)} tokens on average`,
|
|
701
854
|
tip: 'Each time you start Claude Code and begin chatting, that counts as one conversation. A new conversation starts fresh with no prior context.' },
|
|
702
|
-
{ label: 'Messages Sent', value: fmtFull(t.totalQueries), sub: `Each message
|
|
855
|
+
{ label: 'Messages Sent', value: fmtFull(t.totalQueries), sub: `Each message used ~${fmt(avgTokensPerMsg)} tokens on average`,
|
|
703
856
|
tip: 'Every time you hit Enter and send something to Claude, that is one message. This includes follow-up tool calls Claude makes automatically behind the scenes.' },
|
|
704
|
-
{ label: '
|
|
705
|
-
tip: 'The tokens
|
|
857
|
+
{ label: 'Cache Hit Rate', value: `${((t.cacheHitRate || 0) * 100).toFixed(0)}%`, sub: `${fmt(t.totalCacheReadTokens || 0)} tokens served from cache`,
|
|
858
|
+
tip: 'The percentage of input tokens that were served from cache instead of being processed fresh. Higher is better -- cached tokens are 10x cheaper and help you stay under rate limits.' },
|
|
706
859
|
];
|
|
707
860
|
|
|
708
861
|
document.getElementById('statsRow').innerHTML = cards.map((c, i) => `
|
|
@@ -740,7 +893,7 @@ function renderInsights() {
|
|
|
740
893
|
}).join('');
|
|
741
894
|
}
|
|
742
895
|
|
|
743
|
-
// Daily chart
|
|
896
|
+
// Daily chart -- 3 color stacked bars
|
|
744
897
|
function renderDailyChart() {
|
|
745
898
|
const canvas = document.getElementById('dailyChart');
|
|
746
899
|
const ctx = canvas.getContext('2d');
|
|
@@ -773,9 +926,11 @@ function renderDailyChart() {
|
|
|
773
926
|
ctx.beginPath(); ctx.moveTo(startX, y); ctx.lineTo(w, y); ctx.stroke();
|
|
774
927
|
}
|
|
775
928
|
|
|
776
|
-
//
|
|
929
|
+
// Gradients
|
|
777
930
|
const inGrad = ctx.createLinearGradient(0, chartH + 8, 0, 0);
|
|
778
931
|
inGrad.addColorStop(0, '#818CF8'); inGrad.addColorStop(1, '#6366F1');
|
|
932
|
+
const cacheGrad = ctx.createLinearGradient(0, chartH + 8, 0, 0);
|
|
933
|
+
cacheGrad.addColorStop(0, '#FBBF24'); cacheGrad.addColorStop(1, '#F59E0B');
|
|
779
934
|
const outGrad = ctx.createLinearGradient(0, chartH + 8, 0, 0);
|
|
780
935
|
outGrad.addColorStop(0, '#2DD4BF'); outGrad.addColorStop(1, '#14B8A6');
|
|
781
936
|
|
|
@@ -784,16 +939,25 @@ function renderDailyChart() {
|
|
|
784
939
|
const baseY = chartH + 8;
|
|
785
940
|
const r = Math.min(4, barW / 2);
|
|
786
941
|
|
|
942
|
+
// Bottom: output (teal)
|
|
787
943
|
const outH = (d.outputTokens / maxTotal) * chartH;
|
|
788
944
|
if (outH > 0) {
|
|
789
945
|
ctx.fillStyle = outGrad;
|
|
790
946
|
ctx.beginPath(); roundedRect(ctx, x, baseY - outH, barW, outH, r); ctx.fill();
|
|
791
947
|
}
|
|
792
948
|
|
|
793
|
-
|
|
794
|
-
|
|
949
|
+
// Middle: cache reads (amber)
|
|
950
|
+
const cacheH = ((d.cacheReadTokens || 0) / maxTotal) * chartH;
|
|
951
|
+
if (cacheH > 0) {
|
|
952
|
+
ctx.fillStyle = cacheGrad;
|
|
953
|
+
ctx.beginPath(); roundedRect(ctx, x, baseY - outH - cacheH, barW, cacheH, 0); ctx.fill();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Top: input + cache creation (indigo)
|
|
957
|
+
const freshH = ((d.inputTokens + (d.cacheCreationTokens || 0)) / maxTotal) * chartH;
|
|
958
|
+
if (freshH > 0) {
|
|
795
959
|
ctx.fillStyle = inGrad;
|
|
796
|
-
ctx.beginPath(); roundedRect(ctx, x, baseY - outH -
|
|
960
|
+
ctx.beginPath(); roundedRect(ctx, x, baseY - outH - cacheH - freshH, barW, freshH, r); ctx.fill();
|
|
797
961
|
}
|
|
798
962
|
});
|
|
799
963
|
|
|
@@ -883,6 +1047,95 @@ function renderModelChart() {
|
|
|
883
1047
|
}).join('');
|
|
884
1048
|
}
|
|
885
1049
|
|
|
1050
|
+
// Project accordion
|
|
1051
|
+
function toggleProjectDrawer(i) {
|
|
1052
|
+
const row = document.getElementById('proj-row-' + i);
|
|
1053
|
+
const drawer = document.getElementById('proj-drawer-' + i);
|
|
1054
|
+
const isOpen = drawer.classList.contains('open');
|
|
1055
|
+
row.classList.toggle('expanded', !isOpen);
|
|
1056
|
+
drawer.classList.toggle('open', !isOpen);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function buildDrawerContent(p) {
|
|
1060
|
+
if (!p.topPrompts || p.topPrompts.length === 0) {
|
|
1061
|
+
return '<div class="drawer-empty">No prompt data available</div>';
|
|
1062
|
+
}
|
|
1063
|
+
const items = p.topPrompts.map((pr, i) => {
|
|
1064
|
+
const toolEntries = Object.entries(pr.toolCounts || {}).sort((a, b) => b[1] - a[1]);
|
|
1065
|
+
const chips = toolEntries.map(([name, count]) =>
|
|
1066
|
+
'<span class="tool-chip">' + count + '\u00d7\u00a0' + escapeHtml(name) + '</span>'
|
|
1067
|
+
).join('') + (pr.continuations > 0 ? '<span class="tool-chip">+' + pr.continuations + ' turns</span>' : '');
|
|
1068
|
+
const badge = '<span class="model-badge ' + modelClass(pr.model) + '"><span class="model-dot"></span>' + modelShort(pr.model) + '</span>';
|
|
1069
|
+
const tokVal = fmt(pr.totalTokens);
|
|
1070
|
+
const tokSub = fmt(pr.inputTokens) + ' / ' + fmt(pr.outputTokens);
|
|
1071
|
+
const promptText = escapeHtml(pr.prompt);
|
|
1072
|
+
const sid = pr.sessionId;
|
|
1073
|
+
return [
|
|
1074
|
+
'<div class="drawer-prompt-row" onclick="openDrilldown(\'' + sid + '\')">',
|
|
1075
|
+
'<div class="drawer-rank">' + (i + 1) + '</div>',
|
|
1076
|
+
'<div>',
|
|
1077
|
+
'<div class="drawer-prompt-text">' + promptText + '</div>',
|
|
1078
|
+
'<div class="drawer-prompt-meta">' + badge + chips + '</div>',
|
|
1079
|
+
'</div>',
|
|
1080
|
+
'<div class="drawer-tokens"><div class="value">' + tokVal + '</div><div class="sub">' + tokSub + '</div></div>',
|
|
1081
|
+
'</div>',
|
|
1082
|
+
].join('');
|
|
1083
|
+
});
|
|
1084
|
+
return '<div class="drawer-prompt-list">' + items.join('') + '</div>';
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Project breakdown
|
|
1088
|
+
function renderProjectBreakdown() {
|
|
1089
|
+
const projects = DATA.projectBreakdown;
|
|
1090
|
+
if (!projects || !projects.length) return;
|
|
1091
|
+
|
|
1092
|
+
const countEl = document.getElementById('projectsCount');
|
|
1093
|
+
countEl.textContent = projects.length + ' project' + (projects.length === 1 ? '' : 's');
|
|
1094
|
+
|
|
1095
|
+
const maxTokens = projects[0].totalTokens;
|
|
1096
|
+
const chevron = '<svg class="proj-chevron" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6l4 4-4 4"/></svg>';
|
|
1097
|
+
const rows = [];
|
|
1098
|
+
projects.forEach((p, i) => {
|
|
1099
|
+
const barPct = (p.totalTokens / maxTokens * 100).toFixed(1);
|
|
1100
|
+
const ariaLabel = p.modelBreakdown.map(m => modelShort(m.model) + ': ' + fmt(m.inputTokens) + ' input, ' + fmt(m.outputTokens) + ' output').join(', ');
|
|
1101
|
+
const pillItems = p.modelBreakdown.map(m =>
|
|
1102
|
+
'<li><span class="model-badge ' + modelClass(m.model) +
|
|
1103
|
+
'" aria-label="' + modelShort(m.model) + ': ' + fmt(m.inputTokens) + ' input, ' + fmt(m.outputTokens) + ' output">' +
|
|
1104
|
+
'<span class="model-dot" aria-hidden="true"></span>' +
|
|
1105
|
+
modelShort(m.model) +
|
|
1106
|
+
' <span class="token-detail"><span aria-hidden="true">\u2193</span><span class="sr-only">in </span>' + fmt(m.inputTokens) + '</span>' +
|
|
1107
|
+
' <span class="token-detail"><span aria-hidden="true">\u2191</span><span class="sr-only">out </span>' + fmt(m.outputTokens) + '</span>' +
|
|
1108
|
+
'</span></li>'
|
|
1109
|
+
).join('');
|
|
1110
|
+
const summaryRow = [
|
|
1111
|
+
'<tr class="proj-row" id="proj-row-' + i + '" onclick="toggleProjectDrawer(' + i + ')">',
|
|
1112
|
+
'<td style="padding:10px 16px">',
|
|
1113
|
+
'<div style="display:flex;align-items:center;gap:6px">',
|
|
1114
|
+
chevron,
|
|
1115
|
+
'<div>',
|
|
1116
|
+
'<div class="proj-name" title="' + projectFull(p.project) + '">' + escapeHtml(projectShort(p.project)) + '</div>',
|
|
1117
|
+
'<ul class="model-pills" aria-label="Models used: ' + ariaLabel + '">' + pillItems + '</ul>',
|
|
1118
|
+
'</div></div></td>',
|
|
1119
|
+
'<td class="token-num" style="font-weight:700;vertical-align:top;padding-top:12px">',
|
|
1120
|
+
'<div style="display:flex;align-items:center;gap:8px;justify-content:flex-end">',
|
|
1121
|
+
'<div style="flex:1;max-width:60px;height:3px;background:var(--bg);border-radius:4px;overflow:hidden">',
|
|
1122
|
+
'<div style="width:' + barPct + '%;height:100%;background:var(--indigo);border-radius:4px"></div>',
|
|
1123
|
+
'</div><div><div>' + fmt(p.totalTokens) + '</div><div style="font-size:11px;font-weight:500;color:var(--text-tertiary);margin-top:1px">' + fmt(p.inputTokens) + ' in\u00a0\u00b7\u00a0' + fmt(p.outputTokens) + ' out</div></div></div></td>',
|
|
1124
|
+
'<td class="token-num" style="vertical-align:top;padding-top:12px">' + p.sessionCount + '</td>',
|
|
1125
|
+
'<td class="token-num" style="vertical-align:top;padding-top:12px">' + p.queryCount + '</td>',
|
|
1126
|
+
'</tr>',
|
|
1127
|
+
].join('');
|
|
1128
|
+
const drawerRow = [
|
|
1129
|
+
'<tr class="proj-drawer" id="proj-drawer-' + i + '">',
|
|
1130
|
+
'<td colspan="4"><div class="proj-drawer-inner"><div class="proj-drawer-content">',
|
|
1131
|
+
buildDrawerContent(p),
|
|
1132
|
+
'</div></div></td></tr>',
|
|
1133
|
+
].join('');
|
|
1134
|
+
rows.push(summaryRow, drawerRow);
|
|
1135
|
+
});
|
|
1136
|
+
document.getElementById('projectsBody').innerHTML = rows.join('');
|
|
1137
|
+
}
|
|
1138
|
+
|
|
886
1139
|
// Top prompts
|
|
887
1140
|
function renderTopPrompts() {
|
|
888
1141
|
const prompts = DATA.topPrompts;
|
|
@@ -890,20 +1143,26 @@ function renderTopPrompts() {
|
|
|
890
1143
|
const maxTokens = prompts[0].totalTokens;
|
|
891
1144
|
|
|
892
1145
|
document.getElementById('topPromptsList').innerHTML = prompts.map((p, i) => {
|
|
893
|
-
const
|
|
1146
|
+
const freshIn = p.inputTokens + (p.cacheCreationTokens || 0);
|
|
1147
|
+
const cached = p.cacheReadTokens || 0;
|
|
1148
|
+
const total = freshIn + cached + p.outputTokens;
|
|
1149
|
+
const freshPct = total > 0 ? (freshIn / total) * 100 : 0;
|
|
1150
|
+
const cachePct = total > 0 ? (cached / total) * 100 : 0;
|
|
1151
|
+
const outPct = total > 0 ? (p.outputTokens / total) * 100 : 0;
|
|
894
1152
|
return `<div class="prompt-row" onclick="openDrilldown('${p.sessionId}')">
|
|
895
1153
|
<div class="prompt-rank">${i + 1}</div>
|
|
896
1154
|
<div>
|
|
897
1155
|
<div class="prompt-text">${escapeHtml(p.prompt)}</div>
|
|
898
1156
|
<div class="prompt-meta">${formatDate(p.date)} · ${modelShort(p.model)}</div>
|
|
899
1157
|
<div class="token-bar-wrap" style="width:${Math.max(10, (p.totalTokens / maxTokens) * 100)}%">
|
|
900
|
-
<div class="token-bar-in" style="width:${
|
|
901
|
-
<div class="token-bar-
|
|
1158
|
+
<div class="token-bar-in" style="width:${freshPct}%"></div>
|
|
1159
|
+
<div class="token-bar-cache" style="width:${cachePct}%"></div>
|
|
1160
|
+
<div class="token-bar-out" style="width:${outPct}%"></div>
|
|
902
1161
|
</div>
|
|
903
1162
|
</div>
|
|
904
1163
|
<div class="prompt-tokens">
|
|
905
|
-
<div
|
|
906
|
-
<div class="sub">${fmt(p.inputTokens)}
|
|
1164
|
+
<div style="font-family:var(--mono);font-size:14px;font-weight:700;color:var(--indigo)">${fmt(p.totalTokens)}</div>
|
|
1165
|
+
<div class="sub">${fmt(p.inputTokens)} in / ${fmt(cached)} cached / ${fmt(p.outputTokens)} out</div>
|
|
907
1166
|
</div>
|
|
908
1167
|
</div>`;
|
|
909
1168
|
}).join('');
|
|
@@ -975,18 +1234,30 @@ function openDrilldown(sessionId) {
|
|
|
975
1234
|
|
|
976
1235
|
document.getElementById('drilldownTitle').textContent = session.firstPrompt.substring(0, 140);
|
|
977
1236
|
document.getElementById('drilldownMeta').textContent =
|
|
978
|
-
`${formatDate(session.date)} \u00B7 ${modelShort(session.model)} \u00B7 ${session.queryCount} messages \u00B7 ${fmt(session.totalTokens)} tokens
|
|
1237
|
+
`${formatDate(session.date)} \u00B7 ${modelShort(session.model)} \u00B7 ${session.queryCount} messages \u00B7 ${fmt(session.totalTokens)} tokens`;
|
|
979
1238
|
|
|
980
1239
|
const grouped = [];
|
|
981
1240
|
let current = null;
|
|
982
1241
|
for (const q of session.queries) {
|
|
983
1242
|
if (q.userPrompt) {
|
|
984
1243
|
if (current) grouped.push(current);
|
|
985
|
-
current = {
|
|
1244
|
+
current = {
|
|
1245
|
+
prompt: q.userPrompt,
|
|
1246
|
+
inputTokens: q.inputTokens,
|
|
1247
|
+
outputTokens: q.outputTokens,
|
|
1248
|
+
cacheCreationTokens: q.cacheCreationTokens || 0,
|
|
1249
|
+
cacheReadTokens: q.cacheReadTokens || 0,
|
|
1250
|
+
totalTokens: q.totalTokens,
|
|
1251
|
+
cost: q.cost || 0,
|
|
1252
|
+
continuations: 0,
|
|
1253
|
+
};
|
|
986
1254
|
} else if (current) {
|
|
987
1255
|
current.inputTokens += q.inputTokens;
|
|
988
1256
|
current.outputTokens += q.outputTokens;
|
|
1257
|
+
current.cacheCreationTokens += (q.cacheCreationTokens || 0);
|
|
1258
|
+
current.cacheReadTokens += (q.cacheReadTokens || 0);
|
|
989
1259
|
current.totalTokens += q.totalTokens;
|
|
1260
|
+
current.cost += (q.cost || 0);
|
|
990
1261
|
current.continuations++;
|
|
991
1262
|
}
|
|
992
1263
|
}
|
|
@@ -994,12 +1265,13 @@ function openDrilldown(sessionId) {
|
|
|
994
1265
|
|
|
995
1266
|
document.getElementById('queryList').innerHTML = grouped.map((q, i) => {
|
|
996
1267
|
const cont = q.continuations > 0 ? ` + ${q.continuations} tool uses` : '';
|
|
1268
|
+
const cached = q.cacheReadTokens || 0;
|
|
997
1269
|
return `<div class="query-item">
|
|
998
1270
|
<div class="query-num">${i + 1}</div>
|
|
999
1271
|
<div class="query-prompt">${escapeHtml(q.prompt.substring(0, 500))}</div>
|
|
1000
1272
|
<div class="query-tokens-col">
|
|
1001
1273
|
<div class="total">${fmt(q.totalTokens)}</div>
|
|
1002
|
-
<div class="detail">${fmt(q.inputTokens)}
|
|
1274
|
+
<div class="detail">${fmt(q.inputTokens)} in / ${fmt(cached)} cached / ${fmt(q.outputTokens)} out${cont}</div>
|
|
1003
1275
|
</div>
|
|
1004
1276
|
</div>`;
|
|
1005
1277
|
}).join('');
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { parseAllSessions } = require('./parser');
|
|
4
|
-
|
|
5
3
|
function createServer() {
|
|
6
4
|
const app = express();
|
|
7
5
|
|
|
@@ -11,7 +9,7 @@ function createServer() {
|
|
|
11
9
|
app.get('/api/data', async (req, res) => {
|
|
12
10
|
try {
|
|
13
11
|
if (!cachedData) {
|
|
14
|
-
cachedData = await parseAllSessions();
|
|
12
|
+
cachedData = await require('./parser').parseAllSessions();
|
|
15
13
|
}
|
|
16
14
|
res.json(cachedData);
|
|
17
15
|
} catch (err) {
|
|
@@ -21,7 +19,8 @@ function createServer() {
|
|
|
21
19
|
|
|
22
20
|
app.get('/api/refresh', async (req, res) => {
|
|
23
21
|
try {
|
|
24
|
-
|
|
22
|
+
delete require.cache[require.resolve('./parser')];
|
|
23
|
+
cachedData = await require('./parser').parseAllSessions();
|
|
25
24
|
res.json({ ok: true, sessions: cachedData.sessions.length });
|
|
26
25
|
} catch (err) {
|
|
27
26
|
res.status(500).json({ error: err.message });
|