metrascope 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Keshav
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # metrascope
2
+
3
+ See where your coding-agent tokens go. One local command, no upload.
4
+
5
+ A unified, local dashboard for **multiple coding agents** — pick an agent in the
6
+ header and see its own usage. Auto-detects whatever you have installed.
7
+
8
+ | Agent | Reads | Tokens | Reasoning | Cache | Est. cost | Rate limit |
9
+ |---|---|---|---|---|---|---|
10
+ | **Codex** | `~/.codex` sessions | ✓ | ✓ | ✓ | — | ✓ |
11
+ | **Claude Code** | `~/.claude/projects` | ✓ | — | ✓ | ✓ | — |
12
+ | **Qwen Code** | `~/.qwen/projects/**/chats` | ✓ | ✓ | ✓ | — | — |
13
+ | **OpenCode** | `~/.local/share/opencode/opencode.db` (SQLite) | ✓ | ✓ | ✓ | ✓ | — |
14
+ | **Gemini CLI** | `~/.gemini` | detected (no per-turn token data) | — | — | — | — |
15
+
16
+ ## Run
17
+
18
+ ```bash
19
+ npx metrascope
20
+ ```
21
+
22
+ That's it — it opens a dashboard in your browser. Or from a clone:
23
+
24
+ ```bash
25
+ npm install
26
+ npm start
27
+ ```
28
+
29
+ > The OpenCode adapter reads a SQLite store via Node's built-in `node:sqlite`,
30
+ > which needs **Node 22.5+**. Everything else works on Node 18+. OpenCode is
31
+ > loaded lazily, so older Node still runs fine for the other agents.
32
+
33
+ ## Options
34
+
35
+ ```bash
36
+ metrascope --port 8080
37
+ metrascope --no-open
38
+ metrascope --codex-home ~/.codex
39
+ ```
40
+
41
+ Per-agent homes can be overridden via env: `CODEX_HOME`, `CLAUDE_HOME`,
42
+ `QWEN_HOME`, `GEMINI_HOME`.
43
+
44
+ ## Architecture
45
+
46
+ Each agent is an **adapter** in `src/adapters/` exporting
47
+ `{ id, label, mark, accent, capabilities, home, detect, parse }`. Every adapter
48
+ normalizes its raw logs into one shared schema and hands them to
49
+ `aggregate.buildResult()`, which produces the daily/model/project/tool/weekday
50
+ breakdowns and insights. The dashboard reads `capabilities` to show only the
51
+ panels an agent supports (rate-limit for Codex, est. cost for Claude, reasoning
52
+ for Codex/Qwen, …).
53
+
54
+ To add an agent, drop a new adapter module in `src/adapters/`, register it in
55
+ `src/adapters/index.js`, and the UI picks it up automatically.
56
+
57
+ - `GET /api/sources` — all known agents + whether their data is present
58
+ - `GET /api/data?source=<id>` — normalized dashboard data for one agent
59
+ - `GET /api/refresh?source=<id>` — re-parse one agent
60
+
61
+ ## Dashboard
62
+
63
+ - **Agent switcher** — segmented control of detected agents; per-agent accent/branding.
64
+ - **KPI strip** — total / cached tokens, sessions, and an adaptive third stat
65
+ (reasoning, est. cost, or output depending on the agent).
66
+ - **Rate-limit panel** (Codex) — live 5-hour and weekly window usage with reset countdowns.
67
+ - **Overview** — daily stacked-token chart, model-share donut, top projects, weekday, tools (all hover-interactive).
68
+ - **Sessions** — sortable, searchable, model-filterable table; click a row for a drilldown drawer.
69
+ - **Drilldown drawer** — every prompt in a session, its turn-by-turn token chart, and tool usage.
70
+ - **Prompts** — most expensive prompts across all sessions.
71
+ - **Insights** — actionable findings (context pressure, reasoning share, tool-heavy sessions, marathon threads, est. spend, rate-limit pressure), each with a concrete "try this".
72
+ - **Share card** — render a 1200×630 PNG of your stats locally (nothing is uploaded).
73
+ - Light / dark themes (varna design tokens).
74
+
75
+ ## Notes
76
+
77
+ Token events expose usage (and, for Codex, rate-limit pressure), not a reliable
78
+ per-token invoice. Costs shown for Claude Code are **API-rate estimates**, not
79
+ your subscription bill. Reasoning tokens are reported as a subset of output
80
+ tokens (`total = input + output`).
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "metrascope",
3
+ "version": "0.1.0",
4
+ "description": "See where your coding-agent tokens go. One local command — Codex, Claude Code, Qwen Code, OpenCode & more. Zero upload.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "metrascope": "src/index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/index.js",
11
+ "check": "node --check src/index.js && node --check src/server.js && node -e \"require('./src/adapters')\""
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "codex",
23
+ "claude-code",
24
+ "qwen-code",
25
+ "opencode",
26
+ "tokens",
27
+ "usage",
28
+ "analytics",
29
+ "dashboard",
30
+ "ai-agent",
31
+ "llm",
32
+ "cost"
33
+ ],
34
+ "author": "Keshav <crownbalaji27@gmail.com>",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/Buckibarnes17/codex-spend.git"
39
+ },
40
+ "homepage": "https://github.com/Buckibarnes17/codex-spend#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/Buckibarnes17/codex-spend/issues"
43
+ },
44
+ "dependencies": {
45
+ "express": "^4.21.0",
46
+ "open": "^10.1.0"
47
+ }
48
+ }
@@ -0,0 +1,331 @@
1
+ // Shared aggregation: turns normalized per-session data from any adapter into
2
+ // the single dashboard schema (daily/model/project/tool breakdowns + insights).
3
+ const { sum, fmt } = require('./shared');
4
+
5
+ function addMetric(target, source) {
6
+ target.inputTokens += source.inputTokens || 0;
7
+ target.cachedInputTokens += source.cachedInputTokens || 0;
8
+ target.outputTokens += source.outputTokens || 0;
9
+ target.reasoningOutputTokens += source.reasoningOutputTokens || 0;
10
+ target.totalTokens += source.totalTokens || 0;
11
+ }
12
+
13
+ function groupTokenTotals(sessions, key) {
14
+ const out = {};
15
+ for (const session of sessions) {
16
+ const value = session[key] || 'unknown';
17
+ out[value] = (out[value] || 0) + session.totalTokens;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ // Group a flat list of turns into prompt buckets (consecutive turns under the
23
+ // same user prompt), attaching per-prompt tool counts. Used by every adapter.
24
+ function buildPromptBreakdown(turns, toolEvents, fallbackTitle) {
25
+ const promptKey = (prompt, fallback) => {
26
+ const value = String(prompt || fallback || '(continuation)').trim();
27
+ return value || '(continuation)';
28
+ };
29
+ const groups = [];
30
+ let current = null;
31
+ const flush = () => {
32
+ if (!current) return;
33
+ current.totalTokens = current.turns.reduce((t, turn) => t + (turn.totalTokens || 0), 0);
34
+ current.model = Object.entries(current.modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
35
+ current.tools = Object.entries(current.toolCounts).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count);
36
+ delete current.modelCounts;
37
+ delete current.toolCounts;
38
+ groups.push(current);
39
+ current = null;
40
+ };
41
+ for (const turn of turns) {
42
+ const key = promptKey(turn.prompt, fallbackTitle);
43
+ if (!current || current.key !== key) {
44
+ flush();
45
+ current = {
46
+ key, prompt: key.slice(0, 700), firstTimestamp: turn.timestamp, lastTimestamp: turn.timestamp,
47
+ turnIds: [], turnCount: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0,
48
+ reasoningOutputTokens: 0, totalTokens: 0, maxTurnTokens: 0, model: 'unknown', modelCounts: {}, toolCounts: {}, turns: [],
49
+ };
50
+ }
51
+ current.lastTimestamp = turn.timestamp || current.lastTimestamp;
52
+ current.turnIds.push(turn.turnId);
53
+ current.turnCount += 1;
54
+ current.inputTokens += turn.inputTokens || 0;
55
+ current.cachedInputTokens += turn.cachedInputTokens || 0;
56
+ current.outputTokens += turn.outputTokens || 0;
57
+ current.reasoningOutputTokens += turn.reasoningOutputTokens || 0;
58
+ current.maxTurnTokens = Math.max(current.maxTurnTokens, turn.totalTokens || 0);
59
+ if (turn.model) current.modelCounts[turn.model] = (current.modelCounts[turn.model] || 0) + 1;
60
+ current.turns.push({
61
+ turnId: turn.turnId, timestamp: turn.timestamp, model: turn.model,
62
+ inputTokens: turn.inputTokens, cachedInputTokens: turn.cachedInputTokens, outputTokens: turn.outputTokens,
63
+ reasoningOutputTokens: turn.reasoningOutputTokens, totalTokens: turn.totalTokens, contextWindow: turn.contextWindow,
64
+ });
65
+ }
66
+ flush();
67
+ for (const toolEvent of toolEvents || []) {
68
+ const key = promptKey(toolEvent.prompt, fallbackTitle);
69
+ const group = groups.find((item) => item.key === key);
70
+ if (!group) continue;
71
+ const existing = group.tools.find((tool) => tool.tool === toolEvent.name);
72
+ if (existing) existing.count += 1;
73
+ else group.tools.push({ tool: toolEvent.name, count: 1 });
74
+ group.tools.sort((a, b) => b.count - a.count);
75
+ }
76
+ return groups.map(({ key, ...group }, index) => ({ rank: index + 1, ...group }));
77
+ }
78
+
79
+ function generateInsights(sessions, totals, largestTurns, topPrompts, weekdayUsage, caps) {
80
+ const insights = [];
81
+ if (sessions.length === 0) return insights;
82
+ const agent = caps.label || 'the agent';
83
+
84
+ const cachedPct = totals.totalTokens > 0 ? totals.cachedInputTokens / totals.totalTokens : 0;
85
+ if (caps.cache && cachedPct > 0.35) {
86
+ insights.push({
87
+ type: 'info',
88
+ title: `${Math.round(cachedPct * 100)}% of tokens came from cached input`,
89
+ detail: `${agent} is reusing substantial context. That is usually good for continuity, but long threads can still grow expensive in raw token volume.`,
90
+ action: 'Cached context is cheaper than fresh reads, so this is mostly healthy. The lever that still matters is thread length — a fresh thread resets the context carried forward.',
91
+ });
92
+ }
93
+
94
+ if (caps.cost && totals.totalCost > 0) {
95
+ insights.push({
96
+ type: 'info',
97
+ title: `Estimated API-equivalent spend: $${totals.totalCost.toFixed(2)}`,
98
+ detail: `Across ${fmt(totals.totalTokens)} tokens. This is an API-rate estimate, not your subscription bill — useful for comparing where tokens (and cost) concentrate.`,
99
+ action: 'Sort the sessions and prompts tables by tokens to find the few threads driving most of the estimated cost.',
100
+ });
101
+ }
102
+
103
+ const reasoningPct = totals.outputTokens > 0 ? totals.reasoningOutputTokens / totals.outputTokens : 0;
104
+ if (caps.reasoning && totals.reasoningOutputTokens > 0 && reasoningPct > 0.4) {
105
+ insights.push({
106
+ type: 'neutral',
107
+ title: `${Math.round(reasoningPct * 100)}% of output tokens were reasoning, not final answers`,
108
+ detail: `${agent} spent ${fmt(totals.reasoningOutputTokens)} tokens thinking versus ${fmt(totals.outputTokens - totals.reasoningOutputTokens)} tokens writing visible replies.`,
109
+ action: 'Higher reasoning effort helps on hard problems but costs tokens on simple ones. Lower the reasoning effort for routine edits and questions.',
110
+ });
111
+ }
112
+
113
+ const contextPressured = sessions.filter((s) => s.contextWindow && s.peakInputTokens && s.peakInputTokens / s.contextWindow >= 0.8);
114
+ if (contextPressured.length >= 3) {
115
+ insights.push({
116
+ type: 'warning',
117
+ title: `${contextPressured.length} sessions filled 80%+ of the context window`,
118
+ detail: 'When the context window gets close to full, the agent spends more tokens carrying history forward, and older details can be summarized away.',
119
+ action: 'Start a fresh session for a new task instead of continuing a near-full thread. Paste a short summary into the first message to preserve what matters.',
120
+ });
121
+ }
122
+
123
+ const outputPct = totals.totalTokens > 0 ? totals.outputTokens / totals.totalTokens : 0;
124
+ if (outputPct < 0.05 && totals.totalTokens > 0) {
125
+ insights.push({
126
+ type: 'neutral',
127
+ title: `${(outputPct * 100).toFixed(1)}% of tokens were visible output`,
128
+ detail: 'Most usage is the agent reading context, tool results, instructions, and prior conversation rather than writing final answers.',
129
+ action: 'Because reading dominates, keeping threads short and pointing the agent at specific files matters far more than asking for shorter answers.',
130
+ });
131
+ }
132
+
133
+ const longSessions = sessions.filter((session) => session.turnCount >= 50);
134
+ if (longSessions.length > 0) {
135
+ const longTokens = longSessions.reduce((s, ses) => s + ses.totalTokens, 0);
136
+ insights.push({
137
+ type: 'warning',
138
+ title: `${longSessions.length} long session${longSessions.length === 1 ? '' : 's'} crossed 50 turns`,
139
+ detail: `These threads used ${fmt(longTokens)} tokens combined. Long sessions are often where context grows the most.`,
140
+ action: 'When a session drifts into a new task, start a fresh thread. A handoff note (or CONTEXT.md) carries the important bits without the full history.',
141
+ });
142
+ }
143
+
144
+ const topProject = Object.entries(groupTokenTotals(sessions, 'project')).sort((a, b) => b[1] - a[1])[0];
145
+ if (topProject && topProject[1] / Math.max(totals.totalTokens, 1) >= 0.6) {
146
+ insights.push({
147
+ type: 'info',
148
+ title: `${topProject[0]} dominates usage`,
149
+ detail: `${topProject[0]} accounts for ${Math.round((topProject[1] / totals.totalTokens) * 100)}% of all parsed tokens.`,
150
+ action: 'Not a problem by itself, but if this project runs long marathon threads, splitting them into focused sessions would shrink its footprint.',
151
+ });
152
+ }
153
+
154
+ if (largestTurns[0] && largestTurns[0].totalTokens > 100000) {
155
+ insights.push({
156
+ type: 'warning',
157
+ title: 'Your largest turn crossed 100K tokens',
158
+ detail: `One turn alone used ${fmt(largestTurns[0].totalTokens)} tokens — likely a large context window, many tool outputs, or a long accumulated thread.`,
159
+ action: 'Single huge turns usually mean the agent re-read a very full context. Breaking the work into smaller asks keeps each turn cheaper.',
160
+ });
161
+ }
162
+
163
+ const shortExpensive = topPrompts.filter((prompt) => prompt.prompt.length < 40 && prompt.totalTokens > 100000);
164
+ if (shortExpensive.length > 0) {
165
+ insights.push({
166
+ type: 'warning',
167
+ title: `${shortExpensive.length} short prompt${shortExpensive.length === 1 ? '' : 's'} used 100K+ tokens`,
168
+ detail: 'Short follow-ups can still be expensive because the agent may be re-reading the full thread, tool output, and workspace context.',
169
+ action: 'Be specific even on follow-ups. "Yes, update auth.js and run the tests" gives a target so the agent spends fewer turns figuring out what you meant.',
170
+ });
171
+ }
172
+
173
+ const toolHeavy = sessions.filter((s) => s.promptCount > 0 && s.toolCount > s.promptCount * 4);
174
+ if (caps.tools && toolHeavy.length >= 3) {
175
+ const toolTokens = toolHeavy.reduce((s, ses) => s + ses.totalTokens, 0);
176
+ insights.push({
177
+ type: 'info',
178
+ title: `${toolHeavy.length} sessions ran 4x+ more tool calls than prompts`,
179
+ detail: `These tool-heavy sessions used ${fmt(toolTokens)} tokens. Every tool call (reading files, running commands) is a round trip that re-reads the thread.`,
180
+ action: 'Point the agent at exact files and lines when you can. "Fix the bug in src/auth.js:42" triggers fewer searches than "fix the login bug".',
181
+ });
182
+ }
183
+
184
+ const multiTurnPrompts = topPrompts.filter((prompt) => prompt.turnCount >= 5);
185
+ if (multiTurnPrompts.length > 0) {
186
+ insights.push({
187
+ type: 'info',
188
+ title: `${multiTurnPrompts.length} costly prompt${multiTurnPrompts.length === 1 ? '' : 's'} triggered multiple turns`,
189
+ detail: 'These prompts likely caused tool-heavy work. Drill into a session to see how much each prompt and continuation consumed.',
190
+ action: 'Open one of these in the session view to see exactly which continuation turns spent the tokens.',
191
+ });
192
+ }
193
+
194
+ if (weekdayUsage.length >= 3) {
195
+ const ranked = [...weekdayUsage].filter((d) => d.sessions > 0).sort((a, b) => b.avgTokens - a.avgTokens);
196
+ if (ranked.length >= 2) {
197
+ const busiest = ranked[0];
198
+ const quietest = ranked[ranked.length - 1];
199
+ insights.push({
200
+ type: 'neutral',
201
+ title: `You use ${agent} most on ${busiest.name}s`,
202
+ detail: `${busiest.name} sessions average ${fmt(busiest.avgTokens)} tokens each, versus ${fmt(quietest.avgTokens)} on ${quietest.name}s.`,
203
+ action: null,
204
+ });
205
+ }
206
+ }
207
+
208
+ const latestRate = sessions.find((session) => session.rateLimit)?.rateLimit;
209
+ if (caps.rateLimit && latestRate?.primaryUsedPercent != null && latestRate.primaryUsedPercent >= 80) {
210
+ insights.push({
211
+ type: 'warning',
212
+ title: `Primary limit is ${latestRate.primaryUsedPercent}% used`,
213
+ detail: 'The latest local rate-limit event is near exhaustion for the current 5-hour window.',
214
+ action: 'You are close to the cap. Heavy, long-context sessions burn the window fastest — defer big runs until it resets, shown in the limits panel.',
215
+ });
216
+ }
217
+
218
+ return insights;
219
+ }
220
+
221
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
222
+
223
+ // sessions: normalized session objects. source/capabilities: adapter metadata.
224
+ function buildResult(sessions, source, capabilities, warnings = []) {
225
+ const caps = { label: source.label, ...capabilities };
226
+ sessions.sort((a, b) => (b.updatedTimestamp || '').localeCompare(a.updatedTimestamp || ''));
227
+
228
+ const dailyMap = {}, weekdayMap = {}, modelMap = {}, projectMap = {}, toolMap = {};
229
+ const largestTurns = [], topPrompts = [];
230
+ const totals = {
231
+ totalSessions: sessions.length, totalTurns: 0, totalPrompts: 0, totalToolCalls: 0,
232
+ inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0,
233
+ totalCost: 0, avgTokensPerTurn: 0, avgTokensPerSession: 0, dateRange: null, latestRateLimit: null,
234
+ };
235
+
236
+ for (const session of sessions) {
237
+ totals.totalTurns += session.turnCount;
238
+ totals.totalPrompts += session.promptCount;
239
+ totals.totalToolCalls += session.toolCount;
240
+ totals.totalCost += session.cost || 0;
241
+ addMetric(totals, session);
242
+
243
+ if (!dailyMap[session.date]) dailyMap[session.date] = { date: session.date, sessions: 0, turns: 0, toolCalls: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 };
244
+ dailyMap[session.date].sessions += 1;
245
+ dailyMap[session.date].turns += session.turnCount;
246
+ dailyMap[session.date].toolCalls += session.toolCount;
247
+ addMetric(dailyMap[session.date], session);
248
+
249
+ if (session.timestamp) {
250
+ const wd = new Date(session.timestamp).getDay();
251
+ if (!weekdayMap[wd]) weekdayMap[wd] = { weekday: wd, sessions: 0, totalTokens: 0 };
252
+ weekdayMap[wd].sessions += 1;
253
+ weekdayMap[wd].totalTokens += session.totalTokens;
254
+ }
255
+
256
+ const model = session.model || 'unknown';
257
+ if (!modelMap[model]) modelMap[model] = { model, sessions: 0, turns: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 };
258
+ modelMap[model].sessions += 1;
259
+ modelMap[model].turns += session.turnCount;
260
+ addMetric(modelMap[model], session);
261
+
262
+ const project = session.project || 'unknown';
263
+ if (!projectMap[project]) projectMap[project] = { project, sessions: 0, turns: 0, toolCalls: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 };
264
+ projectMap[project].sessions += 1;
265
+ projectMap[project].turns += session.turnCount;
266
+ projectMap[project].toolCalls += session.toolCount;
267
+ addMetric(projectMap[project], session);
268
+
269
+ for (const [tool, count] of Object.entries(session.toolCounts || {})) {
270
+ if (!toolMap[tool]) toolMap[tool] = { tool, count: 0 };
271
+ toolMap[tool].count += count;
272
+ }
273
+
274
+ for (const turn of session.turns) {
275
+ largestTurns.push({
276
+ sessionId: session.sessionId, title: session.title, project: session.project, timestamp: turn.timestamp, model: turn.model,
277
+ prompt: turn.prompt ? String(turn.prompt).slice(0, 260) : session.title,
278
+ inputTokens: turn.inputTokens, cachedInputTokens: turn.cachedInputTokens, outputTokens: turn.outputTokens,
279
+ reasoningOutputTokens: turn.reasoningOutputTokens, totalTokens: turn.totalTokens,
280
+ });
281
+ }
282
+ for (const prompt of session.promptBreakdown) {
283
+ topPrompts.push({
284
+ sessionId: session.sessionId, title: session.title, project: session.project, date: session.date,
285
+ timestamp: prompt.firstTimestamp, model: prompt.model, prompt: prompt.prompt, turnCount: prompt.turnCount,
286
+ inputTokens: prompt.inputTokens, cachedInputTokens: prompt.cachedInputTokens, outputTokens: prompt.outputTokens,
287
+ reasoningOutputTokens: prompt.reasoningOutputTokens, totalTokens: prompt.totalTokens, maxTurnTokens: prompt.maxTurnTokens, tools: prompt.tools,
288
+ });
289
+ }
290
+ if (session.rateLimit) totals.latestRateLimit = session.rateLimit;
291
+ }
292
+
293
+ const dailyUsage = Object.values(dailyMap).filter((d) => d.date !== 'unknown').sort((a, b) => a.date.localeCompare(b.date));
294
+ const weekdayUsage = Object.values(weekdayMap)
295
+ .map((w) => ({ ...w, name: WEEKDAYS[w.weekday], avgTokens: w.sessions ? Math.round(w.totalTokens / w.sessions) : 0 }))
296
+ .sort((a, b) => a.weekday - b.weekday);
297
+ totals.avgTokensPerTurn = totals.totalTurns > 0 ? Math.round(totals.totalTokens / totals.totalTurns) : 0;
298
+ totals.avgTokensPerSession = totals.totalSessions > 0 ? Math.round(totals.totalTokens / totals.totalSessions) : 0;
299
+ totals.dateRange = dailyUsage.length ? { from: dailyUsage[0].date, to: dailyUsage[dailyUsage.length - 1].date } : null;
300
+
301
+ largestTurns.sort((a, b) => b.totalTokens - a.totalTokens);
302
+ topPrompts.sort((a, b) => b.totalTokens - a.totalTokens);
303
+
304
+ return {
305
+ source, capabilities,
306
+ sessions, dailyUsage, weekdayUsage,
307
+ modelBreakdown: Object.values(modelMap).sort((a, b) => b.totalTokens - a.totalTokens),
308
+ projectBreakdown: Object.values(projectMap).sort((a, b) => b.totalTokens - a.totalTokens),
309
+ toolBreakdown: Object.values(toolMap).sort((a, b) => b.count - a.count),
310
+ largestTurns: largestTurns.slice(0, 30),
311
+ topPrompts: topPrompts.slice(0, 50),
312
+ insights: generateInsights(sessions, totals, largestTurns, topPrompts, weekdayUsage, caps),
313
+ totals, warnings,
314
+ };
315
+ }
316
+
317
+ function emptyResult(source, capabilities, warnings) {
318
+ return {
319
+ source, capabilities,
320
+ sessions: [], dailyUsage: [], weekdayUsage: [], modelBreakdown: [], projectBreakdown: [], toolBreakdown: [],
321
+ largestTurns: [], topPrompts: [], insights: [],
322
+ totals: {
323
+ totalSessions: 0, totalTurns: 0, totalPrompts: 0, totalToolCalls: 0,
324
+ inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0,
325
+ totalCost: 0, avgTokensPerTurn: 0, avgTokensPerSession: 0, dateRange: null, latestRateLimit: null,
326
+ },
327
+ warnings,
328
+ };
329
+ }
330
+
331
+ module.exports = { buildResult, emptyResult, buildPromptBreakdown };
@@ -0,0 +1,183 @@
1
+ // Claude Code adapter — reads ~/.claude/projects/**/*.jsonl (assistant usage blocks).
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { expandHome, parseJSONLFile } = require('./shared');
6
+ const { buildResult, emptyResult, buildPromptBreakdown } = require('./aggregate');
7
+
8
+ const id = 'claude';
9
+ const label = 'Claude Code';
10
+ const mark = 'CC';
11
+ const accent = '#cc785c';
12
+ const capabilities = { cost: true, reasoning: false, rateLimit: false, cache: true, tools: true, contextWindow: false };
13
+
14
+ // Anthropic API per-token pricing (estimate; subscription billing differs).
15
+ const MODEL_PRICING = {
16
+ 'opus-4.5': { input: 5 / 1e6, output: 25 / 1e6, cacheWrite: 6.25 / 1e6, cacheRead: 0.50 / 1e6 },
17
+ 'opus-4.6': { input: 5 / 1e6, output: 25 / 1e6, cacheWrite: 6.25 / 1e6, cacheRead: 0.50 / 1e6 },
18
+ 'opus-4.0': { input: 15 / 1e6, output: 75 / 1e6, cacheWrite: 18.75 / 1e6, cacheRead: 1.50 / 1e6 },
19
+ 'opus-4.1': { input: 15 / 1e6, output: 75 / 1e6, cacheWrite: 18.75 / 1e6, cacheRead: 1.50 / 1e6 },
20
+ sonnet: { input: 3 / 1e6, output: 15 / 1e6, cacheWrite: 3.75 / 1e6, cacheRead: 0.30 / 1e6 },
21
+ 'haiku-4.5': { input: 1 / 1e6, output: 5 / 1e6, cacheWrite: 1.25 / 1e6, cacheRead: 0.10 / 1e6 },
22
+ 'haiku-3.5': { input: 0.80 / 1e6, output: 4 / 1e6, cacheWrite: 1.00 / 1e6, cacheRead: 0.08 / 1e6 },
23
+ };
24
+ const DEFAULT_PRICING = MODEL_PRICING.sonnet;
25
+ function getPricing(model) {
26
+ if (!model) return DEFAULT_PRICING;
27
+ const m = model.toLowerCase();
28
+ if (m.includes('opus')) {
29
+ if (m.includes('4-6') || m.includes('4.6')) return MODEL_PRICING['opus-4.6'];
30
+ if (m.includes('4-5') || m.includes('4.5')) return MODEL_PRICING['opus-4.5'];
31
+ if (m.includes('4-1') || m.includes('4.1')) return MODEL_PRICING['opus-4.1'];
32
+ return MODEL_PRICING['opus-4.0'];
33
+ }
34
+ if (m.includes('sonnet')) return MODEL_PRICING.sonnet;
35
+ if (m.includes('haiku')) return m.includes('4-5') || m.includes('4.5') ? MODEL_PRICING['haiku-4.5'] : MODEL_PRICING['haiku-3.5'];
36
+ return DEFAULT_PRICING;
37
+ }
38
+
39
+ function home(options = {}) {
40
+ return expandHome(options.home || process.env.CLAUDE_HOME) || path.join(os.homedir(), '.claude');
41
+ }
42
+ function detect(options = {}) {
43
+ return fs.existsSync(path.join(home(options), 'projects'));
44
+ }
45
+
46
+ // Pair each user prompt with the assistant usage turns that follow it.
47
+ function extractTurns(entries) {
48
+ const turns = [];
49
+ let pendingPrompt = null;
50
+ for (const entry of entries) {
51
+ if (entry.type === 'user' && entry.message?.role === 'user') {
52
+ if (entry.isMeta) continue;
53
+ const content = entry.message.content;
54
+ if (typeof content === 'string' && (content.startsWith('<local-command') || content.startsWith('<command-name'))) continue;
55
+ const text = typeof content === 'string'
56
+ ? content
57
+ : (Array.isArray(content) ? content.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim() : '');
58
+ pendingPrompt = text || null;
59
+ }
60
+ if (entry.type === 'assistant' && entry.message?.usage) {
61
+ const u = entry.message.usage;
62
+ const model = entry.message.model || 'unknown';
63
+ if (model === '<synthetic>') continue;
64
+ const pricing = getPricing(model);
65
+ const inputTokens = u.input_tokens || 0;
66
+ const cacheCreate = u.cache_creation_input_tokens || 0;
67
+ const cacheRead = u.cache_read_input_tokens || 0;
68
+ const outputTokens = u.output_tokens || 0;
69
+ // Treat all input flavors (fresh + cache write + cache read) as input volume,
70
+ // and cacheRead as the "cached" slice, to match the dashboard's token model.
71
+ const totalInput = inputTokens + cacheCreate + cacheRead;
72
+ const cost = inputTokens * pricing.input + cacheCreate * pricing.cacheWrite + cacheRead * pricing.cacheRead + outputTokens * pricing.output;
73
+ const tools = [];
74
+ if (Array.isArray(entry.message.content)) {
75
+ for (const b of entry.message.content) if (b.type === 'tool_use' && b.name) tools.push(b.name);
76
+ }
77
+ turns.push({
78
+ turnId: entry.uuid || `turn-${turns.length + 1}`,
79
+ timestamp: entry.timestamp,
80
+ prompt: pendingPrompt,
81
+ model,
82
+ inputTokens: totalInput,
83
+ cachedInputTokens: cacheRead,
84
+ outputTokens,
85
+ reasoningOutputTokens: 0,
86
+ totalTokens: totalInput + outputTokens,
87
+ contextWindow: null,
88
+ cost,
89
+ tools,
90
+ });
91
+ }
92
+ }
93
+ return turns;
94
+ }
95
+
96
+ function modelLabel(model) {
97
+ if (!model) return 'unknown';
98
+ const m = model.toLowerCase();
99
+ if (m.includes('opus')) return 'claude-opus';
100
+ if (m.includes('sonnet')) return 'claude-sonnet';
101
+ if (m.includes('haiku')) return 'claude-haiku';
102
+ return model;
103
+ }
104
+
105
+ async function parse(options = {}) {
106
+ const h = home(options);
107
+ const source = { id, label, mark, accent, home: h };
108
+ const projectsDir = path.join(h, 'projects');
109
+ if (!fs.existsSync(projectsDir)) return emptyResult(source, capabilities, [{ type: 'missing-dir', message: `Claude Code data not found at ${projectsDir}` }]);
110
+
111
+ // history.jsonl gives a friendlier first-prompt per session.
112
+ const historyPath = path.join(h, 'history.jsonl');
113
+ const historyEntries = fs.existsSync(historyPath) ? await parseJSONLFile(historyPath) : [];
114
+ const sessionFirstPrompt = {};
115
+ for (const e of historyEntries) {
116
+ if (e.sessionId && e.display && !sessionFirstPrompt[e.sessionId]) {
117
+ const d = e.display.trim();
118
+ if (d.startsWith('/') && d.length < 30) continue;
119
+ sessionFirstPrompt[e.sessionId] = d;
120
+ }
121
+ }
122
+
123
+ const warnings = [];
124
+ const sessions = [];
125
+ let projectDirs = [];
126
+ try { projectDirs = fs.readdirSync(projectsDir).filter((d) => { try { return fs.statSync(path.join(projectsDir, d)).isDirectory(); } catch { return false; } }); } catch { /* */ }
127
+
128
+ for (const projectDir of projectDirs) {
129
+ const dir = path.join(projectsDir, projectDir);
130
+ let files;
131
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
132
+ for (const file of files) {
133
+ const filePath = path.join(dir, file);
134
+ const sessionId = path.basename(file, '.jsonl');
135
+ let entries;
136
+ try { entries = await parseJSONLFile(filePath); } catch { continue; }
137
+ if (!entries.length) continue;
138
+ const rawTurns = extractTurns(entries);
139
+ if (!rawTurns.length) continue;
140
+
141
+ // Normalize project label from the encoded dir name (cwd path with '-' separators).
142
+ const cwd = entries.find((e) => e.cwd)?.cwd || projectDir.replace(/^-/, '/').replace(/-/g, '/');
143
+ const project = cwd.split('/').filter(Boolean).slice(-2).join('/') || projectDir;
144
+
145
+ const toolCounts = {};
146
+ const toolEvents = [];
147
+ for (const t of rawTurns) for (const name of t.tools || []) {
148
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
149
+ toolEvents.push({ name, prompt: t.prompt, timestamp: t.timestamp });
150
+ }
151
+
152
+ const firstTs = entries.find((e) => e.timestamp)?.timestamp || null;
153
+ const lastTs = entries.slice().reverse().find((e) => e.timestamp)?.timestamp || firstTs;
154
+ const date = firstTs ? firstTs.split('T')[0] : 'unknown';
155
+ const modelCounts = {};
156
+ for (const t of rawTurns) modelCounts[t.model] = (modelCounts[t.model] || 0) + 1;
157
+ const primaryModel = modelLabel(Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0]);
158
+ const title = sessionFirstPrompt[sessionId] || rawTurns.find((t) => t.prompt)?.prompt || '(no prompt)';
159
+ const promptBreakdown = buildPromptBreakdown(rawTurns, toolEvents, title);
160
+
161
+ sessions.push({
162
+ sessionId, filePath, archived: false,
163
+ title: String(title).slice(0, 240), project, cwd, date,
164
+ timestamp: firstTs, updatedTimestamp: lastTs, model: primaryModel,
165
+ turnCount: rawTurns.length, agentMessages: rawTurns.length,
166
+ toolCount: Object.values(toolCounts).reduce((s, n) => s + n, 0), toolCounts,
167
+ promptCount: promptBreakdown.length, promptBreakdown,
168
+ turns: rawTurns.map((t) => ({ ...t, model: modelLabel(t.model) })),
169
+ inputTokens: rawTurns.reduce((s, t) => s + t.inputTokens, 0),
170
+ cachedInputTokens: rawTurns.reduce((s, t) => s + t.cachedInputTokens, 0),
171
+ outputTokens: rawTurns.reduce((s, t) => s + t.outputTokens, 0),
172
+ reasoningOutputTokens: 0,
173
+ totalTokens: rawTurns.reduce((s, t) => s + t.totalTokens, 0),
174
+ cost: rawTurns.reduce((s, t) => s + (t.cost || 0), 0),
175
+ contextWindow: null, peakInputTokens: 0, peakTurnTokens: 0, rateLimit: null,
176
+ });
177
+ }
178
+ }
179
+ if (!sessions.length) return emptyResult(source, capabilities, [{ type: 'no-sessions', message: 'No Claude Code sessions with usage found.' }]);
180
+ return buildResult(sessions, source, capabilities, warnings);
181
+ }
182
+
183
+ module.exports = { id, label, mark, accent, capabilities, home, detect, parse };