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 +21 -0
- package/README.md +80 -0
- package/package.json +48 -0
- package/src/adapters/aggregate.js +331 -0
- package/src/adapters/claude.js +183 -0
- package/src/adapters/codex.js +173 -0
- package/src/adapters/gemini.js +30 -0
- package/src/adapters/index.js +29 -0
- package/src/adapters/opencode.js +155 -0
- package/src/adapters/qwen.js +138 -0
- package/src/adapters/shared.js +87 -0
- package/src/index.js +77 -0
- package/src/public/index.html +398 -0
- package/src/server.js +57 -0
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 };
|