burner-cc 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,35 +1,85 @@
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 that span several directories, and intent tagging that doesn't guess from tool counts.
6
6
 
7
- ## Usage
7
+ Zero dependencies. Reads `~/.claude/projects/` — nothing leaves your machine except the public price list fetch.
8
8
 
9
- ```
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
9
+ ```bash
10
+ npx burner-cc # or: node bin/burner.js
15
11
  ```
16
12
 
17
- Or install it globally: `npm install -g burner-cc`, then run `burner-cc` directly.
13
+ Opens a local app at `http://127.0.0.1:4777` — the report, per-session receipts, and **Settings** (projects, categories, pricing, tagging).
18
14
 
19
- ### Local development
15
+ ## Commands
20
16
 
17
+ ```bash
18
+ burner # web app: report + settings
19
+ burner report # static ~/.burner/report.html (+ receipts)
20
+ burner list # terminal table
21
+ burner receipt [id|title] # one receipt (default: latest)
22
+
23
+ burner install # install /burner-tag globally, apply tagging level
24
+ burner tag <category> [--task] [--reason TEXT] [--session ID]
25
+ burner pricing refresh # fetch fresh prices now
26
+ burner model train [--swe-chat] [--data FILE.jsonl]
27
+
28
+ --project NAME --from YYYY-MM-DD --to YYYY-MM-DD --json --offline
21
29
  ```
22
- node bin/burner-cc.js <same args as above>
23
- ```
24
30
 
25
- ## How it works
31
+ ## Categories
32
+
33
+ Intent, not mechanics — reading code to fix a bug is a bug fix:
34
+
35
+ `coding` · `debugging` · `planning` · `research` · `writing` · `analysis` · `review` · `learning` · `other`
36
+
37
+ Edit them on Settings → Categories. A session's category comes from, in order:
38
+
39
+ 1. **A tag** — from `/burner-tag`, or the receipt's category picker
40
+ 2. **Its project's default category** (or the project's only allowed category)
41
+ 3. **The learned model**, once trained on your tags and only when confident
42
+ 4. Otherwise **untagged**. There are no folder-name or tool-ratio rules.
43
+
44
+ ## Projects
45
+
46
+ A project groups one or more directories; any session started under one of them belongs to it (longest match wins). Each project can restrict which categories apply and set a default. Configure on Settings → Projects — it's stored in `~/.burner/config.yaml`, which you never have to open.
47
+
48
+ ## Tagging levels
49
+
50
+ Like Claude Code's effort slider (Settings → Tagging):
51
+
52
+ | Level | What happens |
53
+ |---|---|
54
+ | Off | No skill, nothing injected |
55
+ | Manual | `/burner-tag` is available in every project |
56
+ | Session end | A block in `~/.claude/CLAUDE.md` asks Claude to tag once when the work is done (~100 output tokens) |
57
+ | Every task | Claude tags each finished task, so one session can split across categories by cost |
58
+
59
+ ### Learned model
60
+
61
+ `burner model train` fits a small naive-Bayes classifier on prompt words + tool steps from sessions *you* tagged. `--swe-chat` adds intent-labeled prompts from real Claude Code sessions from [SALT-NLP/SWE-chat](https://huggingface.co/datasets/SALT-NLP/SWE-chat) (gated: accept its terms and set `HF_TOKEN`). `--data` takes any `{"text", "category"}` JSONL.
62
+
63
+ ## Tokens
64
+
65
+ Claude Code writes one API response as several lines (one per streamed block) with growing `output_tokens`. Burner keeps the final line per request ID, drops requests a continued session re-logs from its parent, and bills subagent transcripts to their parent session. Totals include all four billed buckets: fresh input, cache write (5m/1h), cache read, output. On real sessions this lands within a few percent of Claude Code's own `/cost`; the gap is background calls (titles, compaction) Claude Code doesn't write to the transcript.
66
+
67
+ ## Pricing
68
+
69
+ - **Claude models** — public list prices from [LiteLLM's price table](https://github.com/BerriAI/litellm), refreshed daily (bundled snapshot when offline). Bedrock/Vertex/dated ids are normalized.
70
+ - **Everything else** (gateway, on-prem, local models behind the Claude Code harness) — **$0 until you set a price**. Settings → Pricing shows each model, offers the public price when one exists, and saves overrides to `~/.burner/pricing.yaml`.
71
+
72
+ ## Files
26
73
 
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.
74
+ | Path | |
75
+ |---|---|
76
+ | `~/.burner/config.yaml` | projects, categories, tagging level, theme |
77
+ | `~/.burner/pricing.yaml` | your price overrides (USD / 1M tokens) |
78
+ | `~/.burner/tags.json` | session tags |
79
+ | `~/.burner/model/intent-nb.json` | learned model |
80
+ | `~/.burner/cache/litellm-pricing.json` | price cache |
81
+ | `~/.claude/skills/burner-tag/` | the global skill (`burner install`) |
32
82
 
33
- ## What this is not
83
+ Old `~/.ti-burner` overrides and custom prices are imported on first run. `BURNER_HOME` and `CLAUDE_CONFIG_DIR` relocate both sides.
34
84
 
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.
85
+ *not official 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.0",
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,