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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spend",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "See where your Claude Code tokens go. One command, zero setup.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  const { createServer } = require('./server');
4
4
 
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: typeof content === 'string' ? content : JSON.stringify(content),
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 inputTokens = (usage.input_tokens || 0)
51
- + (usage.cache_creation_input_tokens || 0)
52
- + (usage.cache_read_input_tokens || 0);
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: inputTokens + outputTokens,
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
- return fs.statSync(path.join(projectsDir, d)).isDirectory();
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
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
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
- totalTokens: promptInput + promptOutput,
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 used over 100K tokens to respond. That adds up to ${fmt(totalWasted)} tokens total. When you say just "Yes" or "Do it", Claude doesn't know exactly what you want, so it tries harder -- reading more files, running more tools, making more attempts. Each of those steps re-sends the entire conversation, which multiplies the cost.`,
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
 
@@ -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%; border-radius: 4px 0 0 4px; }
354
- .token-bar-out { background: var(--teal); height: 100%; border-radius: 0 4px 4px 0; }
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
- .model-opus { background: #EEF2FF; color: #4F46E5; }
414
- .model-sonnet { background: #ECFDF5; color: #059669; }
415
- .model-haiku { background: #FFF7ED; color: #EA580C; }
416
- .model-unknown { background: #F1F5F9; color: #64748B; }
417
- .model-dot { width: 6px; height: 6px; border-radius: 50%; }
418
- .model-opus .model-dot { background: #6366F1; }
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. Taller bars mean heavier usage days. The indigo portion is what Claude read, the teal is what Claude wrote.</div></h3>
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> Read by Claude (your messages + context)</div>
562
- <div class="legend-item"><div class="legend-dot" style="background:var(--teal)"></div> Written by Claude (responses)</div>
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 &#10084; 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
- return p.replace(/^C--Users-[^-]+-?/, '').replace(/^Projects-?/, '').replace(/-/g, '/') || '~';
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
- const res = await fetch('/api/data');
670
- DATA = await res.json();
671
- render();
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">&#9888;&#65039;</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, '&lt;') + '</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
- await fetch('/api/refresh');
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)} read by Claude + ${fmt(t.totalOutputTokens)} written back`,
699
- tip: 'The total number of tokens used across all your conversations. This includes everything Claude reads (your messages, conversation history, files) plus everything Claude writes back.' },
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 cost ~${fmt(t.avgTokensPerQuery)} tokens on average`,
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: 'Claude Wrote', value: fmt(t.totalOutputTokens), sub: `Only ${((t.totalOutputTokens / Math.max(t.totalTokens, 1)) * 100).toFixed(1)}% of total -- most usage is from re-reading context`,
705
- tip: 'The tokens Claude spent writing responses, code, and explanations. This is usually a tiny fraction of total usage because most tokens go toward re-reading your conversation history.' },
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
- // Bars with gradient
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
- const inH = (d.inputTokens / maxTotal) * chartH;
794
- if (inH > 0) {
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 - inH, barW, inH, r); ctx.fill();
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 inPct = (p.inputTokens / p.totalTokens) * 100;
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)} &middot; ${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:${inPct}%"></div>
901
- <div class="token-bar-out" style="width:${100 - inPct}%"></div>
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 class="value">${fmt(p.totalTokens)}</div>
906
- <div class="sub">${fmt(p.inputTokens)} read &middot; ${fmt(p.outputTokens)} written</div>
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 used`;
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 = { prompt: q.userPrompt, inputTokens: q.inputTokens, outputTokens: q.outputTokens, totalTokens: q.totalTokens, continuations: 0 };
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)} read / ${fmt(q.outputTokens)} written${cont}</div>
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
- cachedData = await parseAllSessions();
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 });