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 +71 -21
- 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/src/receipt.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// Single-session receipt — thermal-receipt view of one session.
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { ATTRIBUTION_NOTE } from './build-receipt.js';
|
|
7
|
+
import { themeCss, categoryOverrideCss } from './themes.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const ASSETS_DIR = path.join(__dirname, '..', 'assets');
|
|
11
|
+
const VENDOR_DIR = path.join(__dirname, '..', 'vendor');
|
|
12
|
+
|
|
13
|
+
function readVendor(name) {
|
|
14
|
+
try { return readFileSync(path.join(VENDOR_DIR, name), 'utf-8'); } catch {}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fmtTok(n) {
|
|
19
|
+
if (!n) return '0 tok';
|
|
20
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M tok';
|
|
21
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K tok';
|
|
22
|
+
return n + ' tok';
|
|
23
|
+
}
|
|
24
|
+
function fmtUsd(n) {
|
|
25
|
+
if (n == null) return '—';
|
|
26
|
+
return '$' + n.toFixed(3);
|
|
27
|
+
}
|
|
28
|
+
function shortDate(iso) {
|
|
29
|
+
if (!iso) return 'unknown';
|
|
30
|
+
return new Date(iso).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' }).toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
function readLogoBase64(name) {
|
|
33
|
+
try { return readFileSync(path.join(ASSETS_DIR, name)).toString('base64'); } catch {}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function hashSeed(str) {
|
|
37
|
+
let h = 0;
|
|
38
|
+
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
|
39
|
+
return h;
|
|
40
|
+
}
|
|
41
|
+
function barcode(totalCost, seed) {
|
|
42
|
+
const cents = Math.max(0, Math.round(totalCost * 100));
|
|
43
|
+
const bits = cents.toString(2).padStart(16, '0').slice(-16);
|
|
44
|
+
const bars = [];
|
|
45
|
+
for (const bit of bits) {
|
|
46
|
+
const w = bit === '1' ? 4 : 1;
|
|
47
|
+
bars.push(`<span style="width:${w}px;height:32px"></span>`);
|
|
48
|
+
bars.push(`<span style="width:1px;height:32px;background:transparent"></span>`);
|
|
49
|
+
}
|
|
50
|
+
let x = seed || 1;
|
|
51
|
+
for (let i = 0; i < 10; i++) {
|
|
52
|
+
x = (x * 9301 + 49297) % 233280;
|
|
53
|
+
bars.push(`<span style="width:${1 + (x % 2)}px;height:${18 + (x % 14)}px"></span>`);
|
|
54
|
+
}
|
|
55
|
+
return { html: bars.join(''), title: `encodes total cost: ${cents}¢ as binary (${bits})` };
|
|
56
|
+
}
|
|
57
|
+
const TAGLINES = [
|
|
58
|
+
'context in, cash out',
|
|
59
|
+
'tokens are not free, apparently',
|
|
60
|
+
'thoughts, itemized',
|
|
61
|
+
'you paid for every "let me check"',
|
|
62
|
+
'burned so you don\'t have to guess',
|
|
63
|
+
'classified and costed',
|
|
64
|
+
];
|
|
65
|
+
const SOURCE_LABEL = { skill: 'TAGGED BY CLAUDE', manual: 'TAGGED BY YOU', 'end-model': 'SESSION-END MODEL', learned: 'LEARNED GUESS', project: 'PROJECT DEFAULT', none: 'NOT TAGGED' };
|
|
66
|
+
|
|
67
|
+
function money(n, unpriced) {
|
|
68
|
+
if (unpriced) return '—';
|
|
69
|
+
if (n == null) return '—';
|
|
70
|
+
return '$' + n.toFixed(3);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function attrRow(label, tok, amt, level = 0) {
|
|
74
|
+
const pad = level ? ' indent' : '';
|
|
75
|
+
return `<div class="line"><span class="label${pad}">${label}</span><span class="tok">${tok}</span><span class="amt">${amt}</span></div>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function contextCells(row) {
|
|
79
|
+
if (row.contextMissing === row.calls) return { tok: '—', amt: '', title: 'no follow-up request in transcript' };
|
|
80
|
+
return { tok: fmtTok(row.contextTokens), amt: money(row.contextCost, row.unpriced), title: '' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bucketLines(row, label, unit, level, esc) {
|
|
84
|
+
const context = contextCells(row);
|
|
85
|
+
let html = attrRow(esc(label), `${row.calls} ${unit}`, '', level);
|
|
86
|
+
html += attrRow('call output', fmtTok(row.outputTokens), money(row.outputCost, row.unpriced), level + 1);
|
|
87
|
+
html += attrRow('context added', context.tok, context.amt, level + 1);
|
|
88
|
+
for (const via of row.via ?? []) html += attrRow(`via ${esc(via.skill)}`, `${via.calls} ${unit}`, '', level + 1);
|
|
89
|
+
return html;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function attributedSection(session, esc) {
|
|
93
|
+
const attr = session.attribution;
|
|
94
|
+
const mcp = attr?.mcp ?? [];
|
|
95
|
+
const skills = attr?.skills ?? [];
|
|
96
|
+
const webEntries = Object.entries(session.activity?.web ?? {});
|
|
97
|
+
const agentEntries = Object.entries(session.agents ?? {});
|
|
98
|
+
const runs = session.subagentRuns ?? [];
|
|
99
|
+
if (!mcp.length && !skills.length && !webEntries.length && !agentEntries.length && !runs.length) return '';
|
|
100
|
+
|
|
101
|
+
const servers = mcp.map((server) => {
|
|
102
|
+
const tools = server.tools.map((tool) => bucketLines(tool, tool.name, tool.calls === 1 ? 'call' : 'calls', 1, esc)).join('');
|
|
103
|
+
const totalCost = server.outputCost == null && server.contextCost == null ? null : (server.outputCost ?? 0) + (server.contextCost ?? 0);
|
|
104
|
+
const total = server.tools.length > 1 ? attrRow('total', '', money(totalCost, server.unpriced), 1) : '';
|
|
105
|
+
return `<div class="model-heading">MCP: ${esc(server.displayName)}</div>${tools}${total}`;
|
|
106
|
+
}).join('');
|
|
107
|
+
|
|
108
|
+
const skillLines = skills.map((skill) => bucketLines(skill, skill.name, skill.calls === 1 ? 'turn' : 'turns', 1, esc)).join('');
|
|
109
|
+
const skillBlock = skills.length ? `<div class="model-heading">SKILLS</div>${skillLines}` : '';
|
|
110
|
+
const webBlock = webEntries.length
|
|
111
|
+
? `<div class="model-heading">WEB</div>${webEntries.map(([tool, count]) => attrRow(esc(tool), `${count}×`, '', 1)).join('')}`
|
|
112
|
+
: '';
|
|
113
|
+
const agentBlock = agentEntries.length
|
|
114
|
+
? `<div class="model-heading">SUBAGENT TASKS (parent transcript)</div>${agentEntries.map(([name, n]) => attrRow(esc(name), `${n}×`, '', 1)).join('')}`
|
|
115
|
+
: '';
|
|
116
|
+
const runBlock = runs.length
|
|
117
|
+
? `<div class="model-heading">SUBAGENT RUNS (${runs.length})</div>${runs.map((r) => attrRow(esc(r.type), r.description ? esc(r.description).slice(0, 48) : '—', '', 1)).join('')}`
|
|
118
|
+
: '';
|
|
119
|
+
const note = mcp.length || skills.length ? `<div class="reason">${esc(attr?.note ?? ATTRIBUTION_NOTE)}</div>` : '';
|
|
120
|
+
|
|
121
|
+
return `
|
|
122
|
+
<hr class="rule" />
|
|
123
|
+
<div class="model-heading">MCP · SKILLS · SUBAGENTS</div>
|
|
124
|
+
${servers}${skillBlock}${webBlock}${agentBlock}${runBlock}${note}
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function subagentModelSections(session, esc, modelBlock) {
|
|
129
|
+
const groups = session.subagentGroups ?? [];
|
|
130
|
+
if (!groups.length) return '';
|
|
131
|
+
return groups.map((g) => `
|
|
132
|
+
<div class="model-heading">SUBAGENT: ${esc(g.agentType)} (${g.runCount} run${g.runCount > 1 ? 's' : ''})</div>
|
|
133
|
+
${g.models.map(modelBlock).join('')}
|
|
134
|
+
`).join('');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderReceipt(session, categories, { served = false, theme = null, projects = [] } = {}) {
|
|
138
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
139
|
+
const catById = Object.fromEntries(categories.map((c) => [c.id, c]));
|
|
140
|
+
const cat = catById[session.category] ?? { id: session.category, label: session.category, color: '#888' };
|
|
141
|
+
const catVar = (id) => `var(--cat-${esc(id)})`;
|
|
142
|
+
const catOverrideCss = categoryOverrideCss(categories);
|
|
143
|
+
|
|
144
|
+
const logoUri = (name) => {
|
|
145
|
+
if (served) return `/assets/${name}`;
|
|
146
|
+
const b64 = readLogoBase64(name);
|
|
147
|
+
return b64 ? `data:image/png;base64,${b64}` : '';
|
|
148
|
+
};
|
|
149
|
+
const logoMarkUri = logoUri('logo-mark.png');
|
|
150
|
+
const logoTextUri = logoUri('logo-with-text.png');
|
|
151
|
+
|
|
152
|
+
const seed = hashSeed(session.sessionId);
|
|
153
|
+
const tagline = TAGLINES[seed % TAGLINES.length];
|
|
154
|
+
const authCode = session.sessionId.slice(0, 4).toUpperCase() + '-' + (seed % 9999);
|
|
155
|
+
const bc = barcode(session.totalCost.total, seed);
|
|
156
|
+
const totalTok = session.totalTokens.total;
|
|
157
|
+
|
|
158
|
+
function modelBlock(m) {
|
|
159
|
+
const t = m.tokens, c = m.cost;
|
|
160
|
+
const badge = m.unpriced ? ' <span class="unpriced-flag" title="no price set — counted as $0">[$0 · unpriced]</span>' : '';
|
|
161
|
+
const rows = [`<div class="model-heading">${esc(m.rawModel)}${badge}</div>`];
|
|
162
|
+
const line = (lbl, tok, amt) =>
|
|
163
|
+
`<div class="line"><span class="label indent">${lbl}</span><span class="tok">${fmtTok(tok)}</span><span class="amt">${fmtUsd(amt)}</span></div>`;
|
|
164
|
+
if (t.input) rows.push(line('FRESH INPUT (uncached)', t.input, c?.input));
|
|
165
|
+
if (t.cacheWrite5m || t.cacheWrite1h) rows.push(line('CACHE WRITE (new context)', t.cacheWrite5m + t.cacheWrite1h, c?.cacheWrite));
|
|
166
|
+
if (t.cacheRead) rows.push(line('CACHE READ (reused)', t.cacheRead, c?.cacheRead));
|
|
167
|
+
if (t.output) rows.push(line('OUTPUT TOKENS', t.output, c?.output));
|
|
168
|
+
return rows.join('');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const detailSection = attributedSection(session, esc);
|
|
172
|
+
const ccLine = session.ccReportedCost != null
|
|
173
|
+
? `<div class="line"><span class="label indent">Claude Code reported</span><span class="tok"></span><span class="amt">${fmtUsd(session.ccReportedCost)}</span></div>`
|
|
174
|
+
: '';
|
|
175
|
+
|
|
176
|
+
const splitSection = session.categorySplit?.length > 1 ? session.categorySplit.map((x) =>
|
|
177
|
+
`<div class="line"><span class="label indent"><span class="dot" style="background:${catVar(x.category)}"></span>${esc(catById[x.category]?.label ?? x.category)}</span><span class="tok">${fmtTok(x.tokens)}</span><span class="amt">${fmtUsd(x.cost)}</span></div>`).join('') : '';
|
|
178
|
+
|
|
179
|
+
const sourceLabel = SOURCE_LABEL[session.categorySource] ?? 'AUTO';
|
|
180
|
+
const picker = served
|
|
181
|
+
? `<select id="catPick" title="change category">${categories.map((c) => `<option value="${esc(c.id)}"${c.id === session.category ? ' selected' : ''}>${esc(c.label)}</option>`).join('')}</select>`
|
|
182
|
+
: `<span class="cat-badge" style="background:${catVar(cat.id)}">${esc(cat.label)}</span>`;
|
|
183
|
+
|
|
184
|
+
const copyLines = [
|
|
185
|
+
'CLAUDE CODE — AGENTIC SESSION RECEIPT',
|
|
186
|
+
`SESSION: ${session.title} DATE: ${shortDate(session.date)} PROJECT: ${session.project}`,
|
|
187
|
+
`INTENT: ${cat.label} (${sourceLabel})`,
|
|
188
|
+
'-'.repeat(40),
|
|
189
|
+
...session.models.map((m) => `${m.rawModel}: ${fmtTok(m.tokens.input + m.tokens.output + m.tokens.cacheWrite5m + m.tokens.cacheWrite1h + m.tokens.cacheRead)} ${m.unpriced ? '$0 (unpriced)' : fmtUsd(m.cost.total)}`),
|
|
190
|
+
'-'.repeat(40),
|
|
191
|
+
`TOTAL: ${fmtUsd(session.totalCost.total)}`,
|
|
192
|
+
'~ BURNER ~',
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
return `<!doctype html>
|
|
196
|
+
<html lang="en" data-theme="paper">
|
|
197
|
+
<head>
|
|
198
|
+
<meta charset="utf-8" />
|
|
199
|
+
<title>Burner · ${esc(session.title)}</title>
|
|
200
|
+
<script>(function(){var t=${served ? JSON.stringify(theme) : 'null'};try{t=t||localStorage.getItem('burner-theme');}catch(e){}if(['paper','thermal','matrix','dark'].indexOf(t)<0)t='paper';document.documentElement.setAttribute('data-theme',t);})();</script>
|
|
201
|
+
<link rel="icon" type="image/png" href="${logoMarkUri}" />
|
|
202
|
+
<style>
|
|
203
|
+
${themeCss()}
|
|
204
|
+
${catOverrideCss}
|
|
205
|
+
*{box-sizing:border-box;}
|
|
206
|
+
body{margin:0;font-family:'IBM Plex Mono','Courier New',monospace;background:var(--page-bg);color:var(--ink);padding:28px 16px 60px;transition:background .15s,color .15s;}
|
|
207
|
+
.page-brand{position:fixed;top:20px;left:24px;height:56px;width:auto;z-index:6;}
|
|
208
|
+
.toolbar{max-width:520px;margin:0 auto 16px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;padding-left:90px;}
|
|
209
|
+
.toolbar-group{display:flex;gap:8px;flex-wrap:wrap;}
|
|
210
|
+
button{font-family:inherit;font-size:12px;letter-spacing:.05em;border:1px solid var(--rule);background:transparent;color:var(--ink);padding:6px 12px;cursor:pointer;border-radius:3px;text-transform:uppercase;}
|
|
211
|
+
button.active,button:hover{background:var(--ink);color:var(--page-bg);}
|
|
212
|
+
select{font-family:inherit;font-size:11px;background:var(--bg);border:1px solid var(--rule);color:var(--ink);padding:3px 6px;border-radius:3px;}
|
|
213
|
+
.receipt{width:100%;max-width:460px;background:var(--bg);color:var(--ink);padding:28px 26px 30px;box-shadow:0 6px 18px var(--shadow-lg);margin:0 auto;}
|
|
214
|
+
.receipt h1{text-align:center;font-size:22px;letter-spacing:.15em;margin:0 0 4px;}
|
|
215
|
+
.subtitle{text-align:center;font-size:12px;letter-spacing:.1em;font-weight:bold;margin:0 0 4px;}
|
|
216
|
+
.tagline{text-align:center;font-size:11px;color:var(--dim);margin:0 0 14px;font-style:italic;}
|
|
217
|
+
.rule{border:none;border-top:1px dashed var(--rule);margin:12px 0;}
|
|
218
|
+
.meta-row{display:flex;justify-content:space-between;font-size:11px;line-height:1.6;gap:10px;}
|
|
219
|
+
.line{display:flex;justify-content:space-between;align-items:baseline;font-size:12.5px;line-height:1.7;gap:8px;}
|
|
220
|
+
.line .label{flex:1;}.line .label.indent{padding-left:14px;color:var(--dim);font-size:11px;}
|
|
221
|
+
.line .tok{color:var(--dim);font-size:11px;min-width:72px;text-align:right;}
|
|
222
|
+
.line .amt{font-weight:bold;min-width:60px;text-align:right;}
|
|
223
|
+
.line.bold .label,.line.bold .amt{font-weight:bold;}
|
|
224
|
+
.line.total .amt{font-size:15px;}
|
|
225
|
+
.model-heading{font-weight:bold;font-size:12px;letter-spacing:.05em;margin-top:10px;}
|
|
226
|
+
a.back-link{color:var(--ink);text-decoration:none;font-size:11px;letter-spacing:.05em;border:1px solid var(--rule);padding:6px 12px;border-radius:3px;text-transform:uppercase;display:inline-block;}
|
|
227
|
+
a.back-link:hover{background:var(--ink);color:var(--page-bg);}
|
|
228
|
+
.cat-badge{display:inline-block;padding:2px 8px;border-radius:3px;font-size:10px;font-weight:bold;color:var(--page-bg);letter-spacing:.05em;}
|
|
229
|
+
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px;}
|
|
230
|
+
.footer{text-align:center;font-size:11px;color:var(--dim);margin-top:4px;}
|
|
231
|
+
.receipt-logo{display:block;height:64px;width:auto;margin:14px auto 4px;}
|
|
232
|
+
.footer .brand{margin-top:2px;font-size:10px;letter-spacing:.15em;color:var(--dim);}
|
|
233
|
+
.unpriced-flag{color:var(--warn);font-weight:bold;font-size:10px;}
|
|
234
|
+
.reason{font-size:10px;color:var(--dim);font-style:italic;padding-left:14px;}
|
|
235
|
+
.barcode{display:flex;justify-content:center;align-items:flex-end;gap:2px;margin:18px 0 4px;cursor:help;}
|
|
236
|
+
.barcode span{background:var(--ink);}
|
|
237
|
+
.barcode-label{text-align:center;font-size:10px;color:var(--dim);letter-spacing:.1em;}
|
|
238
|
+
.disclaimer{max-width:460px;margin:14px auto 0;font-size:10px;color:var(--dim);text-align:center;}
|
|
239
|
+
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--page-bg);padding:8px 16px;border-radius:4px;font-size:12px;letter-spacing:.05em;opacity:0;transition:opacity .2s;pointer-events:none;}
|
|
240
|
+
.toast.show{opacity:1;}
|
|
241
|
+
</style>
|
|
242
|
+
</head>
|
|
243
|
+
<body>
|
|
244
|
+
${logoTextUri ? `<img class="page-brand" src="${logoTextUri}" alt="Burner" />` : ''}
|
|
245
|
+
<div class="toolbar">
|
|
246
|
+
<div class="toolbar-group">
|
|
247
|
+
${served ? '<a class="back-link" href="/">← Report</a>' : ''}
|
|
248
|
+
<button data-theme="paper">Paper</button>
|
|
249
|
+
<button data-theme="thermal">Thermal</button>
|
|
250
|
+
<button data-theme="matrix">Matrix</button>
|
|
251
|
+
<button data-theme="dark">Dark</button>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="toolbar-group">
|
|
254
|
+
<button id="copyBtn">Copy</button>
|
|
255
|
+
<button id="downloadBtn">PNG</button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div class="receipt" id="receipt">
|
|
260
|
+
<h1>CLAUDE CODE</h1>
|
|
261
|
+
<div class="subtitle">AGENTIC SESSION RECEIPT</div>
|
|
262
|
+
<div class="tagline">-- ${tagline} --</div>
|
|
263
|
+
<hr class="rule" />
|
|
264
|
+
<div class="meta-row">
|
|
265
|
+
<div class="left">
|
|
266
|
+
SESSION: ${esc(session.title)}<br/>
|
|
267
|
+
DATE: ${shortDate(session.date)}<br/>
|
|
268
|
+
PROJECT: ${served && projects.length ? `<select id="projPick" title="move this session to a project"><option value="">${esc(session.projectSource === 'session' ? '(directory default)' : session.project)}</option>${projects.map((n) => `<option value="${esc(n)}"${session.projectSource === 'session' && session.project === n ? ' selected' : ''}>${esc(n)}</option>`).join('')}</select>` : `<span title="${esc(session.cwd)}">${esc(session.project)}</span>`}<br/>
|
|
269
|
+
BRANCH: ${esc(session.gitBranch ?? '—')}
|
|
270
|
+
</div>
|
|
271
|
+
<div class="right" style="text-align:right">
|
|
272
|
+
MAIN: ${session.turnCount} req · SUB: ${session.subagentTurnCount ?? 0} req (${session.subagentCount ?? 0} runs)<br/>
|
|
273
|
+
DURATION: ${session.durationMin != null ? session.durationMin + 'm' : '—'}<br/>
|
|
274
|
+
CACHE HIT: ${session.cacheHitRate}% · PROMPTS: ${session.promptCount ?? '—'}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
<hr class="rule" />
|
|
278
|
+
<div class="line"><span class="label">INTENT</span><span class="amt" style="display:flex;align-items:center;gap:8px">${picker}<span style="font-size:10px;color:var(--dim);font-weight:normal">${sourceLabel}</span></span></div>
|
|
279
|
+
${session.tagReason ? `<div class="reason">"${esc(session.tagReason)}"</div>` : ''}
|
|
280
|
+
${splitSection}
|
|
281
|
+
<hr class="rule" />
|
|
282
|
+
${session.models.map(modelBlock).join('')}
|
|
283
|
+
${subagentModelSections(session, esc, modelBlock)}
|
|
284
|
+
${detailSection}
|
|
285
|
+
${ccLine}
|
|
286
|
+
<hr class="rule" />
|
|
287
|
+
<div class="line bold"><span class="label">SUBTOTAL</span><span class="tok">${fmtTok(totalTok)}</span><span class="amt"></span></div>
|
|
288
|
+
<div class="line total bold"><span class="label">TOTAL</span><span class="tok"></span><span class="amt">${fmtUsd(session.totalCost.total)}</span></div>
|
|
289
|
+
<hr class="rule" />
|
|
290
|
+
<div class="footer">
|
|
291
|
+
PAID WITH: TOKEN BUDGET<br/>
|
|
292
|
+
AUTH CODE: ${authCode}
|
|
293
|
+
</div>
|
|
294
|
+
<hr class="rule" />
|
|
295
|
+
<div class="footer">
|
|
296
|
+
* THANK YOU FOR YOUR CONTEXT *<br/>
|
|
297
|
+
ITEMS NON-REFUNDABLE ONCE SENT
|
|
298
|
+
</div>
|
|
299
|
+
<div class="barcode" title="${bc.title}">${bc.html}</div>
|
|
300
|
+
<div class="barcode-label">CC-SESSION-${session.sessionId.slice(0, 8).toUpperCase()}</div>
|
|
301
|
+
${logoMarkUri ? `<img class="receipt-logo" src="${logoMarkUri}" alt="Burner" />` : ''}
|
|
302
|
+
<div class="footer brand">~ B U R N E R ~</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div class="disclaimer">not official billing${session.hasUnpriced ? ' · unpriced models counted as $0' : ''}</div>
|
|
306
|
+
<div class="toast" id="toast"></div>
|
|
307
|
+
|
|
308
|
+
<script>${readVendor('html2canvas.min.js') ?? ''}</script>
|
|
309
|
+
<script>
|
|
310
|
+
const SESSION_ID = ${JSON.stringify(session.sessionId)};
|
|
311
|
+
const COPY_TEXT = ${JSON.stringify(copyLines.join('\n')).replace(/</g, '\\u003c')};
|
|
312
|
+
function setTheme(t,persist=true){
|
|
313
|
+
document.documentElement.setAttribute('data-theme',t);
|
|
314
|
+
try{localStorage.setItem('burner-theme',t);}catch(e){}
|
|
315
|
+
if(persist&&${served})fetch('/api/theme',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({theme:t})}).catch(()=>{});
|
|
316
|
+
document.querySelectorAll('button[data-theme]').forEach(b=>b.classList.toggle('active',b.dataset.theme===t));
|
|
317
|
+
}
|
|
318
|
+
document.querySelectorAll('button[data-theme]').forEach(b=>b.addEventListener('click',()=>setTheme(b.dataset.theme)));
|
|
319
|
+
setTheme(document.documentElement.getAttribute('data-theme'),false);
|
|
320
|
+
|
|
321
|
+
function showToast(m){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1600);}
|
|
322
|
+
|
|
323
|
+
const pp=document.getElementById('projPick');
|
|
324
|
+
if(pp) pp.addEventListener('change',async()=>{
|
|
325
|
+
const res=await fetch('/api/session-project',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:SESSION_ID,project:pp.value||null})});
|
|
326
|
+
if(res.ok){showToast('project updated');setTimeout(()=>location.reload(),500);} else showToast((await res.json()).error||'failed');
|
|
327
|
+
});
|
|
328
|
+
const pick=document.getElementById('catPick');
|
|
329
|
+
if(pick) pick.addEventListener('change',async()=>{
|
|
330
|
+
const res=await fetch('/api/tag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:SESSION_ID,category:pick.value})});
|
|
331
|
+
if(res.ok){showToast('tagged');setTimeout(()=>location.reload(),500);} else showToast((await res.json()).error||'failed');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
document.getElementById('copyBtn').addEventListener('click',async()=>{
|
|
335
|
+
await navigator.clipboard.writeText(COPY_TEXT);
|
|
336
|
+
showToast('copied');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
document.getElementById('downloadBtn').addEventListener('click',()=>{
|
|
340
|
+
const node=document.getElementById('receipt');
|
|
341
|
+
html2canvas(node,{backgroundColor:null,scale:2}).then(canvas=>{
|
|
342
|
+
const a=document.createElement('a');
|
|
343
|
+
a.download='burner-'+SESSION_ID.slice(0,8)+'.png';
|
|
344
|
+
a.href=canvas.toDataURL('image/png');
|
|
345
|
+
a.click();showToast('downloaded');
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
</script>
|
|
349
|
+
</body>
|
|
350
|
+
</html>`;
|
|
351
|
+
}
|