burner-cc 0.1.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,35 +1,95 @@
1
- <img src="assets/logo-mark.png" alt="Burner" width="90" />
1
+ <img src="assets/logo-mark.png" alt="Burner" width="90" />
2
2
 
3
3
  # burner
4
4
 
5
- Itemized dollar-and-token cost receipts for Claude Code sessions.
5
+ What your Claude Code sessions cost, and what they were for — itemized receipts, projects, intent tagging, and a local analytics app.
6
6
 
7
- ## Usage
7
+ Zero runtime dependencies. Reads `~/.claude/projects/` on your machine. Only network use by default is refreshing public model prices (optional).
8
8
 
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g burner-cc
13
+ # commands: burner (alias entry: burner-cc)
9
14
  ```
10
- npx burner-cc # latest session -> opens a receipt
11
- npx burner-cc <session-id> # specific session (accepts a title, slug, or id prefix)
12
- npx burner-cc --all # every session found, sliding through one receipt each
13
- npx burner-cc list # quick terminal table, no browser
14
- npx burner-cc --json # machine-readable, no browser
15
+
16
+ Or without installing:
17
+
18
+ ```bash
19
+ npx burner-cc
15
20
  ```
16
21
 
17
- Or install it globally: `npm install -g burner-cc`, then run `burner-cc` directly.
22
+ **Node 18+** required.
18
23
 
19
- ### Local development
24
+ ## Quick start
20
25
 
26
+ ```bash
27
+ burner # opens http://127.0.0.1:4777 (report + Settings)
28
+ burner serve # same; --port N, --no-open
29
+ burner install # global /burner-tag skill + tagging level from ~/.burner/config.yaml
21
30
  ```
22
- node bin/burner-cc.js <same args as above>
31
+
32
+ Settings covers **projects**, **categories**, **pricing** (pin/refresh per model), and **tagging** (slider, session-end model, train your intent model).
33
+
34
+ ## Commands
35
+
36
+ ```bash
37
+ burner list [--json] [--project NAME] [--from YYYY-MM-DD] [--to YYYY-MM-DD]
38
+ burner receipt [session-id|title] # one receipt HTML under ~/.burner/
39
+ burner report [--no-open] # static report.html + receipt-*.html
40
+
41
+ burner tag <category> [--session ID] [--reason TEXT] [--task]
42
+ burner tag --clear [--session ID]
43
+ burner install | uninstall | status
44
+
45
+ burner pricing refresh
46
+ burner model train [--data FILE.jsonl] [--swe-chat] [--prior]
23
47
  ```
24
48
 
25
- ## How it works
49
+ `burner tag` is what `/burner-tag` runs. After `npm i -g burner-cc`, hooks and the skill use the `burner` command on your PATH.
50
+
51
+ ## Categories
52
+
53
+ Intent, not tool counts — e.g. reading files to fix a bug is **debugging**, not research.
54
+
55
+ Default ids: `coding` · `debugging` · `planning` · `research` · `writing` · `analysis` · `review` · `learning` · `other` (editable in Settings).
56
+
57
+ Resolution order:
58
+
59
+ 1. Tag (`/burner-tag`, receipt picker, session-end model, or CLI)
60
+ 2. Project default category
61
+ 3. **Your trained model** (optional; only if enabled and confidence ≥ 60%)
62
+ 4. Otherwise **untagged** — no folder-name or tool-ratio guessing
63
+
64
+ ## Tagging levels (Settings)
65
+
66
+ | Level | Behavior |
67
+ |--------|----------|
68
+ | Off | Removes global skill / hooks |
69
+ | Manual | `/burner-tag` only; skill stays installed |
70
+ | Session end | SessionEnd hook + optional Anthropic model id, or CLAUDE.md asks Claude to tag via `/burner-tag` |
71
+ | Every task | CLAUDE.md asks Claude to tag each task (`--task`) |
72
+
73
+ Moving the slider (or saving session-end model) updates `~/.claude` automatically. Use **Train from my tags** in Settings for a local naive-Bayes model (`~/.burner/model/intent-nb.json`). Training is pure Node — no GPU or Python. CLI `burner model train` does the same; `--prior` / `--swe-chat` are optional extras.
74
+
75
+ ## Tokens & pricing
76
+
77
+ - One API request → several transcript lines; burner bills the **last** chunk per `requestId`, dedupes continued sessions, rolls subagents into the parent.
78
+ - Totals: fresh input, cache write (5m/1h), cache read, output.
79
+ - **Claude** models: public LiteLLM price list (daily refresh, offline snapshot fallback).
80
+ - **Other** models (gateway, on-prem, local): **$0** until you set a price in Settings.
81
+
82
+ ## Files
26
83
 
27
- - **Discovery** (`src/discover.js`) walks `~/.claude/projects/**/*.jsonl`, matching each top-level session transcript with any `subagents/agent-*.jsonl` files spawned inside it (those are billed separately by the API and get folded into the parent session's receipt).
28
- - **Parsing** (`src/parse.js`) reads each assistant turn's `usage` block (input/output/cache-write/cache-read tokens) straight from the transcript.
29
- - **Pricing** (`src/pricing.js`) is fetched automatically — no file to hand-edit. It pulls Anthropic's per-model rates from [LiteLLM's community-maintained pricing JSON](https://github.com/BerriAI/litellm) (sourced from `platform.claude.com/docs/.../pricing`), caches it locally for 6 hours, and falls back to a stale cache or a small bundled snapshot (`src/pricing-fallback.json`) if offline.
30
- - **Receipt** (`src/build-receipt.js`) groups tokens by model and computes cost per token-type line.
31
- - **Rendering** (`src/render.js` + `web/`) injects the receipt JSON into `web/receipt.html` and opens it in the default browser. Theme buttons (paper / thermal / matrix / dark) switch CSS variables client-side.
84
+ | Path | Purpose |
85
+ |------|---------|
86
+ | `~/.burner/config.yaml` | projects, categories, tagging, UI theme/range |
87
+ | `~/.burner/pricing.yaml` | your USD/1M overrides |
88
+ | `~/.burner/tags.json` | session tags |
89
+ | `~/.burner/assignments.json` | per-session project overrides |
90
+ | `~/.burner/model/intent-nb.json` | your trained intent model |
91
+ | `~/.claude/skills/burner-tag/` | global skill (`burner install`) |
32
92
 
33
- ## What this is not
93
+ `BURNER_HOME` and `CLAUDE_CONFIG_DIR` override `~/.burner` and `~/.claude`. Legacy `~/.ti-burner` data is migrated on first run.
34
94
 
35
- Not official billing. Composition breakdown by *source* (system prompt vs. skills vs. MCP tool schemas) isn't available from local transcripts alone — that needs request interception, which this tool doesn't do. The receipt instead itemizes by token *type*: fresh input, cache write, cache read, output.
95
+ *Not official Anthropic billing.*
package/bin/burner.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/cli.js';
package/package.json CHANGED
@@ -1,16 +1,23 @@
1
1
  {
2
2
  "name": "burner-cc",
3
- "version": "0.1.2",
4
- "description": "Itemized dollar-and-token cost receipts for Claude Code sessions.",
3
+ "version": "0.3.1",
4
+ "description": "What your Claude Code sessions cost, and what they were for — itemized receipts, projects, intent tagging.",
5
5
  "type": "module",
6
6
  "bin": {
7
+ "burner": "bin/burner.js",
7
8
  "burner-cc": "bin/burner-cc.js"
8
9
  },
9
10
  "files": [
10
11
  "bin",
11
12
  "src",
12
- "web"
13
+ "assets",
14
+ "vendor"
13
15
  ],
16
+ "scripts": {
17
+ "test": "node --test",
18
+ "train-prior": "node scripts/train-prior.mjs",
19
+ "train-prior:offline": "node scripts/train-prior.mjs --offline"
20
+ },
14
21
  "keywords": [
15
22
  "claude-code",
16
23
  "claude",
@@ -19,16 +26,12 @@
19
26
  "tokens",
20
27
  "cli",
21
28
  "usage",
22
- "billing"
29
+ "analytics"
23
30
  ],
24
31
  "repository": {
25
32
  "type": "git",
26
33
  "url": "git+https://github.com/shoryasethia/burner.git"
27
34
  },
28
- "homepage": "https://github.com/shoryasethia/burner#readme",
29
- "bugs": {
30
- "url": "https://github.com/shoryasethia/burner/issues"
31
- },
32
35
  "engines": {
33
36
  "node": ">=18"
34
37
  },
package/src/analyze.js ADDED
@@ -0,0 +1,241 @@
1
+ // Reads every session, prices it, resolves its project and category, and
2
+ // returns the data behind the report, receipts and settings pages.
3
+
4
+ import { discoverSessions } from './discover.js';
5
+ import { parseTranscript, timeRange, mergeParsed } from './parse.js';
6
+ import { priceAttribution, summarizeSubagentsByType, activityFrom } from './build-receipt.js';
7
+ import { loadPricingTable, lookupPricing, costFor, pricingInfo } from './pricing.js';
8
+ import { loadConfig, resolveProject, UNTAGGED } from './config.js';
9
+ import { loadTags, segmentTurns, loadAssignments, resolveSessionProject } from './tags.js';
10
+ import { resolveCategory, buildDigest, loadLearned } from './classifier.js';
11
+
12
+ const zeroTokens = () => ({ input: 0, output: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0 });
13
+ const zeroCost = () => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0 });
14
+
15
+ function addTokens(into, t) {
16
+ into.input += t.input;
17
+ into.output += t.output;
18
+ into.cacheWrite5m += t.cacheWrite5m;
19
+ into.cacheWrite1h += t.cacheWrite1h;
20
+ into.cacheRead += t.cacheRead;
21
+ }
22
+
23
+ function addCost(into, c) {
24
+ if (!c) return;
25
+ for (const k of Object.keys(into)) into[k] += c[k];
26
+ }
27
+
28
+ export function tokenTotal(t) {
29
+ return t.input + t.output + (t.cacheWrite ?? t.cacheWrite5m + t.cacheWrite1h) + t.cacheRead;
30
+ }
31
+
32
+ function priceTurns(turns, table) {
33
+ const byModel = new Map();
34
+ for (const t of turns) {
35
+ if (!byModel.has(t.model)) byModel.set(t.model, { rawModel: t.model, tokens: zeroTokens(), turnCount: 0, unreported: 0 });
36
+ const b = byModel.get(t.model);
37
+ b.turnCount += 1;
38
+ if (t.unreported) b.unreported += 1;
39
+ addTokens(b.tokens, t);
40
+ }
41
+ const models = [];
42
+ const tokens = zeroTokens();
43
+ const cost = zeroCost();
44
+ for (const b of byModel.values()) {
45
+ const priced = lookupPricing(b.rawModel, table);
46
+ const c = costFor(b.tokens, priced.rates);
47
+ models.push({ rawModel: b.rawModel, fidelity: priced.fidelity, turnCount: b.turnCount, unreported: b.unreported, tokens: b.tokens, cost: c, unpriced: priced.fidelity === 'unpriced' });
48
+ addTokens(tokens, b.tokens);
49
+ addCost(cost, c);
50
+ }
51
+ models.sort((a, b) => b.cost.total - a.cost.total || tokenTotal(b.tokens) - tokenTotal(a.tokens));
52
+ return { models, tokens, cost };
53
+ }
54
+
55
+ // Spend per calendar day (UTC, like the session date): a session that ran for
56
+ // weeks should not show up as one lump on the day it started.
57
+ function dailySpend(turns, table) {
58
+ const byDay = new Map();
59
+ for (const t of turns) {
60
+ if (!t.timestamp) continue;
61
+ const day = new Date(t.timestamp).toISOString().slice(0, 10);
62
+ if (!byDay.has(day)) byDay.set(day, []);
63
+ byDay.get(day).push(t);
64
+ }
65
+ return [...byDay.entries()].sort((x, y) => (x[0] < y[0] ? -1 : 1)).map(([date, ts]) => {
66
+ const p = priceTurns(ts, table);
67
+ return { date, turns: ts.length, cost: p.cost.total, tokens: tokenTotal(p.tokens) };
68
+ });
69
+ }
70
+
71
+ function agentInvocations(parsedList) {
72
+ const counts = {};
73
+ for (const p of parsedList) {
74
+ for (const step of p.digest?.tools ?? []) {
75
+ if (step.startsWith('Task(') || step.startsWith('Agent(')) counts[step] = (counts[step] ?? 0) + 1;
76
+ }
77
+ }
78
+ return counts;
79
+ }
80
+
81
+ function ccReportedCost(reported) {
82
+ if (!reported?.modelUsage) return null;
83
+ let total = 0;
84
+ for (const u of Object.values(reported.modelUsage)) {
85
+ const n = u?.costUSD ?? u?.totalCost ?? u?.cost;
86
+ if (typeof n === 'number') total += n;
87
+ }
88
+ return total > 0 ? total : null;
89
+ }
90
+
91
+ function within(s, opts) {
92
+ if (opts.from && s.mtime < new Date(opts.from).getTime()) return false;
93
+ if (opts.to && s.mtime > new Date(opts.to + 'T23:59:59').getTime()) return false;
94
+ return true;
95
+ }
96
+
97
+ export async function analyzeSessions(opts = {}) {
98
+ const cfg = opts.config ?? loadConfig();
99
+ const table = opts.table ?? (await loadPricingTable({ offline: opts.offline }));
100
+ const tags = loadTags();
101
+ const assignments = loadAssignments();
102
+ const learned = loadLearned();
103
+ const discovered = discoverSessions(opts.claudeHome);
104
+
105
+ // Oldest first: a continued session re-logs requests from its parent;
106
+ // they're billed to the session that made them.
107
+ const seenRequests = new Set();
108
+ const results = [];
109
+ const digests = new Map();
110
+ const modelIndex = new Map();
111
+ const cwdIndex = new Map();
112
+ const projectTotals = new Map();
113
+
114
+ for (const session of [...discovered].reverse()) {
115
+ const main = parseTranscript(session.mainFile);
116
+ const subs = session.subagentFiles.map(({ file, meta }) => ({ file, meta, parsed: parseTranscript(file) }));
117
+ const fresh = (turns) => turns.filter((t) => {
118
+ if (seenRequests.has(t.requestId)) return false;
119
+ seenRequests.add(t.requestId);
120
+ return true;
121
+ });
122
+ const mainTurns = fresh(main.turns);
123
+ const subTurns = subs.flatMap((s) => fresh(s.parsed.turns));
124
+ const turns = [...mainTurns, ...subTurns];
125
+ if (!turns.length && !main.digest.prompts.length) continue;
126
+
127
+ const cwd = main.digest.cwd;
128
+ const project = resolveSessionProject(session.sessionId, cwd, cfg, assignments);
129
+ const dirProject = resolveProject(cwd, cfg);
130
+ const digest = buildDigest(main, { title: session.title, projectName: project.name });
131
+ const { models, tokens, cost } = priceTurns(turns, table);
132
+ const tag = tags[session.sessionId];
133
+
134
+ let { category, source, confidence } = resolveCategory({ tag, project, cfg, digest, learned });
135
+ let split = null;
136
+ const groups = segmentTurns(turns, tag?.segments);
137
+ if (groups && groups.length > 1) {
138
+ const byCat = new Map();
139
+ for (const g of groups) {
140
+ const p = priceTurns(g.turns, table);
141
+ const row = byCat.get(g.category) ?? { category: g.category, tokens: 0, cost: 0, turns: 0 };
142
+ row.tokens += tokenTotal(p.tokens);
143
+ row.cost += p.cost.total;
144
+ row.turns += g.turns.length;
145
+ byCat.set(g.category, row);
146
+ }
147
+ split = [...byCat.values()].sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
148
+ if (split[0].cost > 0 || split[0].tokens > 0) category = split[0].category;
149
+ }
150
+
151
+ const range = timeRange(turns);
152
+ const totalTokens = { input: tokens.input, output: tokens.output, cacheWrite: tokens.cacheWrite5m + tokens.cacheWrite1h, cacheRead: tokens.cacheRead };
153
+ totalTokens.total = tokenTotal(totalTokens);
154
+ const promptSide = totalTokens.input + totalTokens.cacheWrite + totalTokens.cacheRead;
155
+
156
+ const row = {
157
+ sessionId: session.sessionId,
158
+ title: session.customTitle ?? main.digest.aiTitle ?? session.slug ?? session.sessionId.slice(0, 8),
159
+ project: project.name,
160
+ projectImplicit: project.implicit,
161
+ projectSource: project.bySession ? 'session' : project.implicit ? 'none' : 'directory',
162
+ cwd,
163
+ gitBranch: main.digest.gitBranch,
164
+ date: new Date(range.startedAt ?? session.mtime).toISOString().slice(0, 10),
165
+ mtime: session.mtime,
166
+ startedAt: range.startedAt,
167
+ endedAt: range.endedAt,
168
+ durationMin: range.startedAt && range.endedAt ? Math.round((new Date(range.endedAt) - new Date(range.startedAt)) / 60000) : null,
169
+ turnCount: mainTurns.length,
170
+ subagentTurnCount: subTurns.length,
171
+ subagentCount: session.subagentFiles.length,
172
+ promptCount: main.digest.prompts.length,
173
+ models,
174
+ daily: dailySpend(turns, table),
175
+ totalTokens,
176
+ totalCost: cost,
177
+ hasUnpriced: models.some((m) => m.unpriced),
178
+ cacheHitRate: promptSide ? Math.round((totalTokens.cacheRead / promptSide) * 100) : 0,
179
+ category,
180
+ categorySource: source,
181
+ confidence: confidence ?? null,
182
+ categorySplit: split,
183
+ tagReason: tag?.reason ?? null,
184
+ activity: activityFrom(mergeParsed([main, ...subs.map((s) => s.parsed)])),
185
+ attribution: priceAttribution(mergeParsed([main, ...subs.map((s) => s.parsed)]), table),
186
+ subagentGroups: summarizeSubagentsByType(session.subagentFiles, table),
187
+ subagentRuns: subs.map(({ meta }) => ({
188
+ type: meta?.agentType ?? 'unknown',
189
+ description: meta?.description ?? null,
190
+ spawnDepth: meta?.spawnDepth ?? null,
191
+ })),
192
+ agents: agentInvocations([main, ...subs.map((s) => s.parsed)]),
193
+ ccReportedCost: ccReportedCost(main.reported),
194
+ };
195
+ if (opts.project && !row.project.toLowerCase().includes(opts.project.toLowerCase())) continue;
196
+ if (!within(session, opts)) continue;
197
+ results.push(row);
198
+ digests.set(session.sessionId, digest);
199
+
200
+ for (const m of models) {
201
+ const e = modelIndex.get(m.rawModel) ?? { rawModel: m.rawModel, sessions: 0, unreported: 0, tokens: zeroTokens(), cost: 0 };
202
+ e.sessions += 1;
203
+ e.unreported += m.unreported ?? 0;
204
+ addTokens(e.tokens, m.tokens);
205
+ e.cost += m.cost.total;
206
+ modelIndex.set(m.rawModel, e);
207
+ }
208
+ if (cwd) {
209
+ const e = cwdIndex.get(cwd) ?? { cwd, project: dirProject.name, implicit: dirProject.implicit, sessions: 0, moved: 0, cost: 0, lastUsed: 0 };
210
+ if (project.bySession) e.moved += 1;
211
+ else { e.sessions += 1; e.cost += cost.total; }
212
+ e.lastUsed = Math.max(e.lastUsed, session.mtime);
213
+ cwdIndex.set(cwd, e);
214
+ }
215
+ const pt = projectTotals.get(project.name) ?? { name: project.name, sessions: 0, cost: 0, implicit: project.implicit };
216
+ pt.sessions += 1;
217
+ pt.cost += cost.total;
218
+ projectTotals.set(project.name, pt);
219
+ }
220
+
221
+ results.sort((a, b) => b.mtime - a.mtime);
222
+
223
+ const models = [...modelIndex.values()].map((e) => {
224
+ const p = lookupPricing(e.rawModel, table);
225
+ return { ...e, fidelity: p.fidelity, rates: p.rates, suggested: p.suggested };
226
+ }).sort((a, b) => b.sessions - a.sessions);
227
+
228
+ return {
229
+ sessions: results,
230
+ models,
231
+ directories: [...cwdIndex.values()].filter((d) => d.sessions || d.moved).sort((a, b) => b.lastUsed - a.lastUsed),
232
+ projectTotals: [...projectTotals.values()],
233
+ categories: [...cfg.categories, UNTAGGED],
234
+ config: cfg,
235
+ pricing: pricingInfo(table),
236
+ pricingSource: table.source,
237
+ generatedAt: new Date().toISOString(),
238
+ unpricedModels: models.filter((m) => m.fidelity === 'unpriced').map((m) => m.rawModel),
239
+ digests,
240
+ };
241
+ }
@@ -1,17 +1,20 @@
1
- import { parseTranscript, timeRange, mergeActivity } from './parse.js';
1
+ import { parseTranscript, timeRange, mergeParsed } from './parse.js';
2
2
  import { lookupPricing, costFor, loadPricingTable } from './pricing.js';
3
3
 
4
+ // Shown on the receipt and in --json. The dollars below are a slice of the
5
+ // session total, not a second charge.
6
+ export const ATTRIBUTION_NOTE =
7
+ 'Call output is that API request\'s output tokens. Context added is the next request\'s fresh input and cache write — an upper bound, since it also includes the call message. These amounts are already inside the session total. MCP tool schemas cached in the prompt are not in the transcript.';
8
+
4
9
  function summarizeTurns(turns, table) {
5
- // Group by the *priced* model identity (post lookup), not the raw string,
6
- // so aliases like "sonnet" fold into the concrete model they resolved to.
7
10
  const byModel = new Map();
8
11
 
9
12
  for (const t of turns) {
10
- const priced = lookupPricing(t.model, table);
11
- const key = priced.displayName;
13
+ const key = t.model;
12
14
  if (!byModel.has(key)) {
15
+ const priced = lookupPricing(t.model, table);
13
16
  byModel.set(key, {
14
- displayName: priced.displayName,
17
+ rawModel: t.model,
15
18
  fidelity: priced.fidelity,
16
19
  rates: priced.rates,
17
20
  tokens: { input: 0, output: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0 },
@@ -31,7 +34,7 @@ function summarizeTurns(turns, table) {
31
34
  for (const bucket of byModel.values()) {
32
35
  const cost = costFor(bucket.tokens, bucket.rates);
33
36
  models.push({
34
- displayName: bucket.displayName,
37
+ rawModel: bucket.rawModel,
35
38
  fidelity: bucket.fidelity,
36
39
  turnCount: bucket.turnCount,
37
40
  tokens: bucket.tokens,
@@ -67,7 +70,7 @@ function sumTokens(models) {
67
70
  // Groups subagent runs by their agentType (from the sidecar .meta.json),
68
71
  // each with its own real, measured cost — instead of lumping every
69
72
  // subagent run into one "SUBAGENTS" blob.
70
- function summarizeSubagentsByType(subagentFiles, table) {
73
+ export function summarizeSubagentsByType(subagentFiles, table) {
71
74
  const byType = new Map();
72
75
 
73
76
  for (const { file, meta } of subagentFiles) {
@@ -86,22 +89,125 @@ function summarizeSubagentsByType(subagentFiles, table) {
86
89
  }));
87
90
  }
88
91
 
92
+ export function activityFrom(parsed) {
93
+ const mcp = {};
94
+ for (const server of Object.values(parsed.mcp)) {
95
+ const calls = Object.values(server.tools).reduce((sum, bucket) => sum + bucket.calls, 0);
96
+ if (calls) mcp[server.displayName] = (mcp[server.displayName] ?? 0) + calls;
97
+ }
98
+ const skills = {};
99
+ for (const [name, bucket] of Object.entries(parsed.skills)) skills[name] = bucket.calls;
100
+ return { mcp, skills, web: parsed.web };
101
+ }
102
+
103
+ function priceBucket(bucket, table) {
104
+ let outputCost = 0;
105
+ let contextCost = 0;
106
+ let pricedOutput = 0;
107
+ let pricedContext = 0;
108
+ let unpriced = false;
109
+
110
+ for (const [model, tokens] of Object.entries(bucket.outputByModel)) {
111
+ const { rates } = lookupPricing(model, table);
112
+ if (!rates) {
113
+ unpriced = true;
114
+ continue;
115
+ }
116
+ outputCost += (tokens / 1_000_000) * rates.output;
117
+ pricedOutput += tokens;
118
+ }
119
+
120
+ for (const [model, usage] of Object.entries(bucket.contextByModel)) {
121
+ const { rates } = lookupPricing(model, table);
122
+ if (!rates) {
123
+ unpriced = true;
124
+ continue;
125
+ }
126
+ const cost = costFor({ ...usage, output: 0, cacheRead: 0 }, rates);
127
+ contextCost += cost.input + cost.cacheWrite;
128
+ pricedContext += usage.input + usage.cacheWrite5m + usage.cacheWrite1h;
129
+ }
130
+
131
+ return {
132
+ outputCost: pricedOutput === 0 && bucket.outputTokens > 0 ? null : outputCost,
133
+ contextCost: pricedContext === 0 && bucket.contextTokens > 0 ? null : contextCost,
134
+ unpriced,
135
+ };
136
+ }
137
+
138
+ function finishBucket(bucket, table) {
139
+ const priced = priceBucket(bucket, table);
140
+ const via = Object.entries(bucket.via)
141
+ .map(([skill, calls]) => ({ skill, calls }))
142
+ .sort((a, b) => b.calls - a.calls);
143
+ return {
144
+ calls: bucket.calls,
145
+ outputTokens: bucket.outputTokens,
146
+ contextTokens: bucket.contextTokens,
147
+ contextMissing: bucket.contextMissing,
148
+ outputCost: priced.outputCost,
149
+ contextCost: priced.contextCost,
150
+ unpriced: priced.unpriced,
151
+ ...(via.length ? { via } : {}),
152
+ };
153
+ }
154
+
155
+ function sumField(rows, field) {
156
+ if (rows.every((row) => row[field] == null)) return null;
157
+ return rows.reduce((sum, row) => sum + (row[field] ?? 0), 0);
158
+ }
159
+
160
+ function byWeight(a, b) {
161
+ const cost = (row) => (row.contextCost ?? 0) + (row.outputCost ?? 0);
162
+ return cost(b) - cost(a) || (b.contextTokens + b.outputTokens) - (a.contextTokens + a.outputTokens);
163
+ }
164
+
165
+ export function priceAttribution(parsed, table) {
166
+ const mcp = Object.entries(parsed.mcp).map(([slug, server]) => {
167
+ const tools = Object.entries(server.tools)
168
+ .map(([name, bucket]) => ({ name, ...finishBucket(bucket, table) }))
169
+ .sort(byWeight);
170
+ return {
171
+ server: slug,
172
+ displayName: server.displayName,
173
+ calls: tools.reduce((sum, tool) => sum + tool.calls, 0),
174
+ outputTokens: tools.reduce((sum, tool) => sum + tool.outputTokens, 0),
175
+ contextTokens: tools.reduce((sum, tool) => sum + tool.contextTokens, 0),
176
+ contextMissing: tools.reduce((sum, tool) => sum + tool.contextMissing, 0),
177
+ outputCost: sumField(tools, 'outputCost'),
178
+ contextCost: sumField(tools, 'contextCost'),
179
+ unpriced: tools.some((tool) => tool.unpriced),
180
+ tools,
181
+ };
182
+ }).sort(byWeight);
183
+
184
+ const skills = Object.entries(parsed.skills)
185
+ .map(([name, bucket]) => ({ name, ...finishBucket(bucket, table) }))
186
+ .sort(byWeight);
187
+
188
+ return { note: ATTRIBUTION_NOTE, mcp, skills };
189
+ }
190
+
89
191
  // Builds one receipt object for a discovered session (main transcript +
90
192
  // any subagent transcripts spawned within it). `table` is the pricing
91
193
  // table already resolved once via loadPricingTable() by the caller.
194
+ // Attribution dollars are not added into totals — they are a slice of them.
92
195
  export function buildReceipt(session, table) {
93
- const { turns: mainTurns, activity: mainActivity } = parseTranscript(session.mainFile);
196
+ const main = parseTranscript(session.mainFile);
94
197
  const subagentParsed = session.subagentFiles.map(({ file }) => parseTranscript(file));
198
+ const mainTurns = main.turns;
95
199
  const subagentTurns = subagentParsed.flatMap((p) => p.turns);
200
+ const merged = mergeParsed([main, ...subagentParsed]);
96
201
 
97
202
  const mainModels = summarizeTurns(mainTurns, table);
98
203
  const subagentGroups = summarizeSubagentsByType(session.subagentFiles, table);
99
204
  const allSubagentModels = subagentGroups.flatMap((g) => g.models);
100
205
 
101
- const activity = mergeActivity([mainActivity, ...subagentParsed.map((p) => p.activity)]);
206
+ const activity = activityFrom(merged);
207
+ const attribution = priceAttribution(merged, table);
102
208
 
103
209
  const { startedAt, endedAt } = timeRange([...mainTurns, ...subagentTurns]);
104
- const hasUnpriced = [...mainModels, ...allSubagentModels].some((m) => !m.cost);
210
+ const hasUnpriced = [...mainModels, ...allSubagentModels].some((m) => m.fidelity === 'unpriced');
105
211
 
106
212
  const totals = {
107
213
  tokens: sumTokens([...mainModels, ...allSubagentModels]),
@@ -119,6 +225,7 @@ export function buildReceipt(session, table) {
119
225
  models: mainModels,
120
226
  subagentGroups,
121
227
  activity,
228
+ attribution,
122
229
  totals,
123
230
  hasUnpriced,
124
231
  pricingSource: table.source,