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 +79 -19
- package/bin/burner.js +2 -0
- package/package.json +11 -8
- package/src/analyze.js +241 -0
- package/src/build-receipt.js +118 -11
- package/src/classifier.js +140 -0
- package/src/cli.js +181 -84
- package/src/config.js +222 -0
- package/src/discover.js +10 -7
- package/src/install.js +215 -0
- package/src/model/prior.json +1 -0
- package/src/model/prior.meta.json +320 -0
- package/src/parse.js +234 -42
- package/src/pricing-fallback.json +199 -6
- package/src/pricing.js +176 -98
- package/src/receipt.js +351 -0
- package/src/report.js +873 -0
- package/src/server.js +261 -0
- package/src/settings-page.js +752 -0
- package/src/tag-end.js +94 -0
- package/src/tagging.js +122 -0
- package/src/tags.js +79 -0
- package/src/themes.js +122 -0
- package/src/yaml.js +163 -0
- package/web/receipt.css +0 -298
- package/web/receipt.html +0 -32
- package/web/receipt.js +0 -315
- /package/{web → assets}/logo-mark.png +0 -0
- /package/{web → assets}/logo-with-text.png +0 -0
- /package/{web/vendor → vendor}/html2canvas.min.js +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
npx burner-cc
|
|
15
|
+
|
|
16
|
+
Or without installing:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx burner-cc
|
|
15
20
|
```
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
**Node 18+** required.
|
|
18
23
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
95
|
+
*Not official Anthropic billing.*
|
package/bin/burner.js
ADDED
package/package.json
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "burner-cc",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|
package/src/build-receipt.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import { parseTranscript, timeRange,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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) =>
|
|
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,
|