claude-spend 1.0.0

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 ADDED
@@ -0,0 +1,32 @@
1
+ # claude-spend
2
+
3
+ See where your Claude Code tokens go. One command, zero setup.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npx claude-spend
9
+ ```
10
+
11
+ That's it. Opens a dashboard in your browser.
12
+
13
+ ## What it does
14
+
15
+ - Reads your local Claude Code session files (nothing leaves your machine)
16
+ - Shows token usage per conversation, per day, and per model
17
+ - Surfaces insights like which prompts cost the most and usage patterns
18
+
19
+ ## Options
20
+
21
+ ```
22
+ claude-spend --port 8080 # custom port (default: 3456)
23
+ claude-spend --no-open # don't auto-open browser
24
+ ```
25
+
26
+ ## Privacy
27
+
28
+ All data stays local. claude-spend reads files from `~/.claude/` on your machine and serves a dashboard on localhost. No data is sent anywhere.
29
+
30
+ ## License
31
+
32
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "claude-spend",
3
+ "version": "1.0.0",
4
+ "description": "See where your Claude Code tokens go. One command, zero setup.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "claude-spend": "src/index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/index.js"
11
+ },
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "tokens",
16
+ "usage",
17
+ "analytics",
18
+ "dashboard",
19
+ "spend",
20
+ "cost"
21
+ ],
22
+ "author": "Aniket Parihar",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/writetoaniketparihar-collab/claude-spend.git"
27
+ },
28
+ "dependencies": {
29
+ "express": "^4.21.0",
30
+ "open": "^10.1.0"
31
+ }
32
+ }
package/src/index.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { createServer } = require('./server');
4
+
5
+ const args = process.argv.slice(2);
6
+
7
+ if (args.includes('--help') || args.includes('-h')) {
8
+ console.log(`
9
+ claude-spend - See where your Claude Code tokens go
10
+
11
+ Usage:
12
+ claude-spend [options]
13
+
14
+ Options:
15
+ --port <port> Port to run dashboard on (default: 3456)
16
+ --no-open Don't auto-open browser
17
+ --help, -h Show this help message
18
+
19
+ Examples:
20
+ npx claude-spend Open dashboard in browser
21
+ claude-spend --port 8080 Use custom port
22
+ `);
23
+ process.exit(0);
24
+ }
25
+
26
+ const portIndex = args.indexOf('--port');
27
+ const port = portIndex !== -1 ? parseInt(args[portIndex + 1], 10) : 3456;
28
+ const noOpen = args.includes('--no-open');
29
+
30
+ if (isNaN(port)) {
31
+ console.error('Error: --port must be a number');
32
+ process.exit(1);
33
+ }
34
+
35
+ const app = createServer();
36
+
37
+ const server = app.listen(port, async () => {
38
+ const url = `http://localhost:${port}`;
39
+ console.log(`\n claude-spend dashboard running at ${url}\n`);
40
+
41
+ if (!noOpen) {
42
+ try {
43
+ const open = (await import('open')).default;
44
+ await open(url);
45
+ } catch {
46
+ console.log(' Could not auto-open browser. Open the URL manually.');
47
+ }
48
+ }
49
+ });
50
+
51
+ server.on('error', (err) => {
52
+ if (err.code === 'EADDRINUSE') {
53
+ console.error(`Port ${port} is already in use. Try --port <other-port>`);
54
+ process.exit(1);
55
+ }
56
+ throw err;
57
+ });
58
+
59
+ // Graceful shutdown
60
+ process.on('SIGINT', () => {
61
+ console.log('\n Shutting down...');
62
+ server.close();
63
+ process.exit(0);
64
+ });
package/src/parser.js ADDED
@@ -0,0 +1,459 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const readline = require('readline');
5
+
6
+ function getClaudeDir() {
7
+ return path.join(os.homedir(), '.claude');
8
+ }
9
+
10
+ async function parseJSONLFile(filePath) {
11
+ const lines = [];
12
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
13
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
14
+
15
+ for await (const line of rl) {
16
+ if (!line.trim()) continue;
17
+ try {
18
+ lines.push(JSON.parse(line));
19
+ } catch {
20
+ // Skip malformed lines
21
+ }
22
+ }
23
+ return lines;
24
+ }
25
+
26
+ function extractSessionData(entries) {
27
+ const queries = [];
28
+ let pendingUserMessage = null;
29
+
30
+ for (const entry of entries) {
31
+ if (entry.type === 'user' && entry.message?.role === 'user') {
32
+ const content = entry.message.content;
33
+ if (entry.isMeta) continue;
34
+ if (typeof content === 'string' && (
35
+ content.startsWith('<local-command') ||
36
+ content.startsWith('<command-name')
37
+ )) continue;
38
+
39
+ pendingUserMessage = {
40
+ text: typeof content === 'string' ? content : JSON.stringify(content),
41
+ timestamp: entry.timestamp,
42
+ };
43
+ }
44
+
45
+ if (entry.type === 'assistant' && entry.message?.usage) {
46
+ const usage = entry.message.usage;
47
+ const model = entry.message.model || 'unknown';
48
+ if (model === '<synthetic>') continue;
49
+
50
+ const inputTokens = (usage.input_tokens || 0)
51
+ + (usage.cache_creation_input_tokens || 0)
52
+ + (usage.cache_read_input_tokens || 0);
53
+ const outputTokens = usage.output_tokens || 0;
54
+
55
+ queries.push({
56
+ userPrompt: pendingUserMessage?.text || null,
57
+ userTimestamp: pendingUserMessage?.timestamp || null,
58
+ assistantTimestamp: entry.timestamp,
59
+ model,
60
+ inputTokens,
61
+ outputTokens,
62
+ totalTokens: inputTokens + outputTokens,
63
+ });
64
+ }
65
+ }
66
+
67
+ return queries;
68
+ }
69
+
70
+ async function parseAllSessions() {
71
+ const claudeDir = getClaudeDir();
72
+ const projectsDir = path.join(claudeDir, 'projects');
73
+
74
+ if (!fs.existsSync(projectsDir)) {
75
+ return { sessions: [], dailyUsage: [], modelBreakdown: [], topPrompts: [], totals: {} };
76
+ }
77
+
78
+ // Read history.jsonl for prompt display text
79
+ const historyPath = path.join(claudeDir, 'history.jsonl');
80
+ const historyEntries = fs.existsSync(historyPath) ? await parseJSONLFile(historyPath) : [];
81
+
82
+ // Build a map: sessionId -> first meaningful prompt
83
+ const sessionFirstPrompt = {};
84
+ for (const entry of historyEntries) {
85
+ if (entry.sessionId && entry.display && !sessionFirstPrompt[entry.sessionId]) {
86
+ const display = entry.display.trim();
87
+ if (display.startsWith('/') && display.length < 30) continue;
88
+ sessionFirstPrompt[entry.sessionId] = display;
89
+ }
90
+ }
91
+
92
+ const projectDirs = fs.readdirSync(projectsDir).filter(d => {
93
+ return fs.statSync(path.join(projectsDir, d)).isDirectory();
94
+ });
95
+
96
+ const sessions = [];
97
+ const dailyMap = {};
98
+ const modelMap = {};
99
+ const allPrompts = []; // for "most expensive prompts" across all sessions
100
+
101
+ for (const projectDir of projectDirs) {
102
+ const dir = path.join(projectsDir, projectDir);
103
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
104
+
105
+ for (const file of files) {
106
+ const filePath = path.join(dir, file);
107
+ const sessionId = path.basename(file, '.jsonl');
108
+
109
+ let entries;
110
+ try {
111
+ entries = await parseJSONLFile(filePath);
112
+ } catch {
113
+ continue;
114
+ }
115
+ if (entries.length === 0) continue;
116
+
117
+ const queries = extractSessionData(entries);
118
+ if (queries.length === 0) continue;
119
+
120
+ let inputTokens = 0, outputTokens = 0;
121
+ for (const q of queries) {
122
+ inputTokens += q.inputTokens;
123
+ outputTokens += q.outputTokens;
124
+ }
125
+ const totalTokens = inputTokens + outputTokens;
126
+
127
+ const firstTimestamp = entries.find(e => e.timestamp)?.timestamp;
128
+ const date = firstTimestamp ? firstTimestamp.split('T')[0] : 'unknown';
129
+
130
+ // Primary model
131
+ const modelCounts = {};
132
+ for (const q of queries) {
133
+ modelCounts[q.model] = (modelCounts[q.model] || 0) + 1;
134
+ }
135
+ const primaryModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
136
+
137
+ const firstPrompt = sessionFirstPrompt[sessionId]
138
+ || queries.find(q => q.userPrompt)?.userPrompt
139
+ || '(no prompt)';
140
+
141
+ // Collect per-prompt data for "most expensive prompts"
142
+ // Group consecutive queries under the same user prompt
143
+ let currentPrompt = null;
144
+ let promptInput = 0, promptOutput = 0;
145
+ const flushPrompt = () => {
146
+ if (currentPrompt && (promptInput + promptOutput) > 0) {
147
+ allPrompts.push({
148
+ prompt: currentPrompt.substring(0, 300),
149
+ inputTokens: promptInput,
150
+ outputTokens: promptOutput,
151
+ totalTokens: promptInput + promptOutput,
152
+ date,
153
+ sessionId,
154
+ model: primaryModel,
155
+ });
156
+ }
157
+ };
158
+ for (const q of queries) {
159
+ if (q.userPrompt && q.userPrompt !== currentPrompt) {
160
+ flushPrompt();
161
+ currentPrompt = q.userPrompt;
162
+ promptInput = 0;
163
+ promptOutput = 0;
164
+ }
165
+ promptInput += q.inputTokens;
166
+ promptOutput += q.outputTokens;
167
+ }
168
+ flushPrompt();
169
+
170
+ sessions.push({
171
+ sessionId,
172
+ project: projectDir,
173
+ date,
174
+ timestamp: firstTimestamp,
175
+ firstPrompt: firstPrompt.substring(0, 200),
176
+ model: primaryModel,
177
+ queryCount: queries.length,
178
+ queries,
179
+ inputTokens,
180
+ outputTokens,
181
+ totalTokens,
182
+ });
183
+
184
+ // Daily
185
+ if (date !== 'unknown') {
186
+ if (!dailyMap[date]) {
187
+ dailyMap[date] = { date, inputTokens: 0, outputTokens: 0, totalTokens: 0, sessions: 0, queries: 0 };
188
+ }
189
+ dailyMap[date].inputTokens += inputTokens;
190
+ dailyMap[date].outputTokens += outputTokens;
191
+ dailyMap[date].totalTokens += totalTokens;
192
+ dailyMap[date].sessions += 1;
193
+ dailyMap[date].queries += queries.length;
194
+ }
195
+
196
+ // Model
197
+ for (const q of queries) {
198
+ if (q.model === '<synthetic>' || q.model === 'unknown') continue;
199
+ if (!modelMap[q.model]) {
200
+ modelMap[q.model] = { model: q.model, inputTokens: 0, outputTokens: 0, totalTokens: 0, queryCount: 0 };
201
+ }
202
+ modelMap[q.model].inputTokens += q.inputTokens;
203
+ modelMap[q.model].outputTokens += q.outputTokens;
204
+ modelMap[q.model].totalTokens += q.totalTokens;
205
+ modelMap[q.model].queryCount += 1;
206
+ }
207
+ }
208
+ }
209
+
210
+ sessions.sort((a, b) => b.totalTokens - a.totalTokens);
211
+
212
+ const dailyUsage = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
213
+
214
+ // Top 20 most expensive individual prompts
215
+ allPrompts.sort((a, b) => b.totalTokens - a.totalTokens);
216
+ const topPrompts = allPrompts.slice(0, 20);
217
+
218
+ const grandTotals = {
219
+ totalSessions: sessions.length,
220
+ totalQueries: sessions.reduce((sum, s) => sum + s.queryCount, 0),
221
+ totalTokens: sessions.reduce((sum, s) => sum + s.totalTokens, 0),
222
+ totalInputTokens: sessions.reduce((sum, s) => sum + s.inputTokens, 0),
223
+ totalOutputTokens: sessions.reduce((sum, s) => sum + s.outputTokens, 0),
224
+ avgTokensPerQuery: 0,
225
+ avgTokensPerSession: 0,
226
+ dateRange: dailyUsage.length > 0
227
+ ? { from: dailyUsage[0].date, to: dailyUsage[dailyUsage.length - 1].date }
228
+ : null,
229
+ };
230
+ if (grandTotals.totalQueries > 0) {
231
+ grandTotals.avgTokensPerQuery = Math.round(grandTotals.totalTokens / grandTotals.totalQueries);
232
+ }
233
+ if (grandTotals.totalSessions > 0) {
234
+ grandTotals.avgTokensPerSession = Math.round(grandTotals.totalTokens / grandTotals.totalSessions);
235
+ }
236
+
237
+ // Generate insights
238
+ const insights = generateInsights(sessions, allPrompts, grandTotals);
239
+
240
+ return {
241
+ sessions,
242
+ dailyUsage,
243
+ modelBreakdown: Object.values(modelMap),
244
+ topPrompts,
245
+ totals: grandTotals,
246
+ insights,
247
+ };
248
+ }
249
+
250
+ function generateInsights(sessions, allPrompts, totals) {
251
+ const insights = [];
252
+
253
+ // 1. Short, vague messages that cost a lot
254
+ const shortExpensive = allPrompts.filter(p => p.prompt.trim().length < 30 && p.totalTokens > 100_000);
255
+ if (shortExpensive.length > 0) {
256
+ const totalWasted = shortExpensive.reduce((s, p) => s + p.totalTokens, 0);
257
+ const examples = [...new Set(shortExpensive.map(p => p.prompt.trim()))].slice(0, 4);
258
+ insights.push({
259
+ id: 'vague-prompts',
260
+ type: 'warning',
261
+ 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.`,
263
+ 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
+ });
265
+ }
266
+
267
+ // 2. Long conversations getting more expensive over time
268
+ const longSessions = sessions.filter(s => s.queries.length > 50);
269
+ if (longSessions.length > 0) {
270
+ const growthData = longSessions.map(s => {
271
+ const first5 = s.queries.slice(0, 5).reduce((sum, q) => sum + q.totalTokens, 0) / Math.min(5, s.queries.length);
272
+ const last5 = s.queries.slice(-5).reduce((sum, q) => sum + q.totalTokens, 0) / Math.min(5, s.queries.length);
273
+ return { session: s, first5, last5, ratio: last5 / Math.max(first5, 1) };
274
+ }).filter(g => g.ratio > 2);
275
+
276
+ if (growthData.length > 0) {
277
+ const avgGrowth = (growthData.reduce((s, g) => s + g.ratio, 0) / growthData.length).toFixed(1);
278
+ const worstSession = growthData.sort((a, b) => b.ratio - a.ratio)[0];
279
+ insights.push({
280
+ id: 'context-growth',
281
+ type: 'warning',
282
+ title: 'The longer you chat, the more each message costs',
283
+ description: `In ${growthData.length} of your conversations, the messages near the end cost ${avgGrowth}x more than the ones at the start. Why? Every time you send a message, Claude re-reads the entire conversation from the beginning. So message #5 is cheap, but message #80 is expensive because Claude is re-reading 79 previous messages plus all the code it wrote. Your longest conversation ("${worstSession.session.firstPrompt.substring(0, 50)}...") grew ${worstSession.ratio.toFixed(1)}x more expensive by the end.`,
284
+ action: 'Start a fresh conversation when you move to a new task. If you need context from before, paste a short summary in your first message. This gives Claude a clean slate instead of re-reading hundreds of old messages.',
285
+ });
286
+ }
287
+ }
288
+
289
+ // 3. Marathon conversations
290
+ const turnCounts = sessions.map(s => s.queryCount);
291
+ const medianTurns = turnCounts.sort((a, b) => a - b)[Math.floor(turnCounts.length / 2)] || 0;
292
+ const longCount = sessions.filter(s => s.queryCount > 200).length;
293
+ if (longCount >= 3) {
294
+ const longTokens = sessions.filter(s => s.queryCount > 200).reduce((s, ses) => s + ses.totalTokens, 0);
295
+ const longPct = ((longTokens / Math.max(totals.totalTokens, 1)) * 100).toFixed(0);
296
+ insights.push({
297
+ id: 'marathon-sessions',
298
+ type: 'info',
299
+ title: `Just ${longCount} long conversations used ${longPct}% of all your tokens`,
300
+ description: `You have ${longCount} conversations with over 200 messages each. These alone consumed ${fmt(longTokens)} tokens -- that's ${longPct}% of everything. Meanwhile, your typical conversation is about ${medianTurns} messages. Long conversations aren't always bad, but they're disproportionately expensive because of how context builds up.`,
301
+ action: 'Try keeping one conversation per task. When a conversation starts drifting into different topics, that is a good time to start a new one.',
302
+ });
303
+ }
304
+
305
+ // 4. Most tokens are re-reading, not writing
306
+ if (totals.totalTokens > 0) {
307
+ const outputPct = (totals.totalOutputTokens / totals.totalTokens) * 100;
308
+ if (outputPct < 2) {
309
+ insights.push({
310
+ id: 'input-heavy',
311
+ type: 'info',
312
+ title: `${outputPct.toFixed(1)}% of your tokens are Claude actually writing`,
313
+ description: `Here's something surprising: out of ${fmt(totals.totalTokens)} total tokens, only ${fmt(totals.totalOutputTokens)} are from Claude writing responses. The other ${(100 - outputPct).toFixed(1)}% is Claude re-reading your conversation history, files, and context before each response. This means the biggest factor in token usage isn't how much Claude writes -- it's how long your conversations are.`,
314
+ action: 'Keeping conversations shorter has more impact than asking for shorter answers. A 20-message conversation costs far less than a 200-message one, even if the total output is similar.',
315
+ });
316
+ }
317
+ }
318
+
319
+ // 5. Day-of-week pattern
320
+ if (sessions.length >= 10) {
321
+ const dayOfWeekMap = {};
322
+ for (const s of sessions) {
323
+ if (!s.timestamp) continue;
324
+ const d = new Date(s.timestamp);
325
+ const day = d.getDay();
326
+ if (!dayOfWeekMap[day]) dayOfWeekMap[day] = { tokens: 0, sessions: 0 };
327
+ dayOfWeekMap[day].tokens += s.totalTokens;
328
+ dayOfWeekMap[day].sessions += 1;
329
+ }
330
+ const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
331
+ const days = Object.entries(dayOfWeekMap).map(([d, v]) => ({ day: dayNames[d], ...v, avg: v.tokens / v.sessions }));
332
+ if (days.length >= 3) {
333
+ days.sort((a, b) => b.avg - a.avg);
334
+ const busiest = days[0];
335
+ const quietest = days[days.length - 1];
336
+ insights.push({
337
+ id: 'day-pattern',
338
+ type: 'neutral',
339
+ title: `You use Claude the most on ${busiest.day}s`,
340
+ description: `Your ${busiest.day} conversations average ${fmt(Math.round(busiest.avg))} tokens each, compared to ${fmt(Math.round(quietest.avg))} on ${quietest.day}s. This could mean you tackle bigger tasks on ${busiest.day}s, or your conversations tend to run longer.`,
341
+ action: null,
342
+ });
343
+ }
344
+ }
345
+
346
+ // 6. Model mismatch -- Opus used for simple conversations
347
+ const opusSessions = sessions.filter(s => s.model.includes('opus'));
348
+ if (opusSessions.length > 0) {
349
+ const simpleOpus = opusSessions.filter(s => s.queryCount < 10 && s.totalTokens < 200_000);
350
+ if (simpleOpus.length >= 3) {
351
+ const wastedTokens = simpleOpus.reduce((s, ses) => s + ses.totalTokens, 0);
352
+ const examples = simpleOpus.slice(0, 3).map(s => '"' + s.firstPrompt.substring(0, 40) + '"').join(', ');
353
+ insights.push({
354
+ id: 'model-mismatch',
355
+ type: 'warning',
356
+ title: `${simpleOpus.length} simple conversations used Opus unnecessarily`,
357
+ description: `These conversations had fewer than 10 messages and used ${fmt(wastedTokens)} tokens on Opus: ${examples}. Opus is the most capable model but also the most expensive. For quick questions and small tasks, Sonnet or Haiku would give similar results at a fraction of the cost.`,
358
+ action: 'Use /model to switch to Sonnet or Haiku for simple tasks. Save Opus for complex multi-file changes, architecture decisions, or tricky debugging.',
359
+ });
360
+ }
361
+ }
362
+
363
+ // 7. Tool-heavy conversations
364
+ if (sessions.length >= 5) {
365
+ const toolHeavy = sessions.filter(s => {
366
+ const userMessages = s.queries.filter(q => q.userPrompt).length;
367
+ const toolCalls = s.queryCount - userMessages;
368
+ return userMessages > 0 && toolCalls > userMessages * 3;
369
+ });
370
+ if (toolHeavy.length >= 3) {
371
+ const totalToolTokens = toolHeavy.reduce((s, ses) => s + ses.totalTokens, 0);
372
+ const avgRatio = toolHeavy.reduce((s, ses) => {
373
+ const userMsgs = ses.queries.filter(q => q.userPrompt).length;
374
+ return s + (ses.queryCount - userMsgs) / Math.max(userMsgs, 1);
375
+ }, 0) / toolHeavy.length;
376
+ insights.push({
377
+ id: 'tool-heavy',
378
+ type: 'info',
379
+ title: `${toolHeavy.length} conversations had ${Math.round(avgRatio)}x more tool calls than messages`,
380
+ description: `In these conversations, Claude made ~${Math.round(avgRatio)} tool calls for every message you sent. Each tool call (reading files, running commands, searching code) is a full round trip that re-reads the entire conversation. These ${toolHeavy.length} conversations used ${fmt(totalToolTokens)} tokens total.`,
381
+ action: 'Point Claude to specific files and line numbers when you can. "Fix the bug in src/auth.js line 42" triggers fewer tool calls than "fix the login bug" where Claude has to search for the right file first.',
382
+ });
383
+ }
384
+ }
385
+
386
+ // 8. One project dominates usage
387
+ if (sessions.length >= 5) {
388
+ const projectTokens = {};
389
+ for (const s of sessions) {
390
+ const proj = s.project || 'unknown';
391
+ projectTokens[proj] = (projectTokens[proj] || 0) + s.totalTokens;
392
+ }
393
+ const sorted = Object.entries(projectTokens).sort((a, b) => b[1] - a[1]);
394
+ if (sorted.length >= 2) {
395
+ const [topProject, topTokens] = sorted[0];
396
+ const pct = ((topTokens / Math.max(totals.totalTokens, 1)) * 100).toFixed(0);
397
+ if (pct >= 60) {
398
+ const projName = topProject.replace(/^C--Users-[^-]+-?/, '').replace(/^Projects-?/, '').replace(/-/g, '/') || '~';
399
+ insights.push({
400
+ id: 'project-dominance',
401
+ type: 'info',
402
+ title: `${pct}% of your tokens went to one project: ${projName}`,
403
+ description: `Your "${projName}" project used ${fmt(topTokens)} tokens out of ${fmt(totals.totalTokens)} total. That is ${pct}% of all your usage. The next closest project used ${fmt(sorted[1][1])} tokens.`,
404
+ action: 'Not necessarily a problem, but worth knowing. If this project has long-running conversations, breaking them into smaller sessions could reduce its footprint.',
405
+ });
406
+ }
407
+ }
408
+ }
409
+
410
+ // 9. Conversation efficiency -- short vs long conversations cost per message
411
+ if (sessions.length >= 10) {
412
+ const shortSessions = sessions.filter(s => s.queryCount >= 3 && s.queryCount <= 15);
413
+ const longSessions2 = sessions.filter(s => s.queryCount > 80);
414
+ if (shortSessions.length >= 3 && longSessions2.length >= 2) {
415
+ const shortAvg = Math.round(shortSessions.reduce((s, ses) => s + ses.totalTokens / ses.queryCount, 0) / shortSessions.length);
416
+ const longAvg = Math.round(longSessions2.reduce((s, ses) => s + ses.totalTokens / ses.queryCount, 0) / longSessions2.length);
417
+ const ratio = (longAvg / Math.max(shortAvg, 1)).toFixed(1);
418
+ if (ratio >= 2) {
419
+ insights.push({
420
+ id: 'conversation-efficiency',
421
+ type: 'warning',
422
+ title: `Each message costs ${ratio}x more in long conversations`,
423
+ description: `In your short conversations (under 15 messages), each message costs ~${fmt(shortAvg)} tokens. In your long ones (80+ messages), each message costs ~${fmt(longAvg)} tokens. That is ${ratio}x more per message, because Claude re-reads the entire history every turn.`,
424
+ action: 'This is the single biggest lever for reducing token usage. Start fresh conversations more often. A 5-conversation workflow costs far less than one 500-message marathon.',
425
+ });
426
+ }
427
+ }
428
+ }
429
+
430
+ // 10. Heavy context on first message (large CLAUDE.md or system prompts)
431
+ if (sessions.length >= 5) {
432
+ const heavyStarts = sessions.filter(s => {
433
+ const firstQuery = s.queries[0];
434
+ return firstQuery && firstQuery.inputTokens > 50_000;
435
+ });
436
+ if (heavyStarts.length >= 5) {
437
+ const avgStartTokens = Math.round(heavyStarts.reduce((s, ses) => s + ses.queries[0].inputTokens, 0) / heavyStarts.length);
438
+ const totalOverhead = heavyStarts.reduce((s, ses) => s + ses.queries[0].inputTokens, 0);
439
+ insights.push({
440
+ id: 'heavy-context',
441
+ type: 'info',
442
+ title: `${heavyStarts.length} conversations started with ${fmt(avgStartTokens)}+ tokens of context`,
443
+ description: `Before you even type your first message, Claude reads your CLAUDE.md, project files, and system context. In ${heavyStarts.length} conversations, this starting context averaged ${fmt(avgStartTokens)} tokens. Across all of them, that is ${fmt(totalOverhead)} tokens just on setup -- and this context gets re-read with every message.`,
444
+ action: 'Keep your CLAUDE.md files concise. Remove sections you rarely need. A smaller starting context compounds into savings across every message in the conversation.',
445
+ });
446
+ }
447
+ }
448
+
449
+ return insights;
450
+ }
451
+
452
+ function fmt(n) {
453
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
454
+ if (n >= 10_000) return (n / 1_000).toFixed(0) + 'K';
455
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
456
+ return n.toLocaleString();
457
+ }
458
+
459
+ module.exports = { parseAllSessions };