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/src/server.js ADDED
@@ -0,0 +1,261 @@
1
+ // `burner serve` — local-only web app: report, receipts, and the Settings
2
+ // page that edits config.yaml / pricing.yaml so nobody has to touch them.
3
+
4
+ import http from 'node:http';
5
+ import { readFileSync, statSync, rmSync, existsSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { analyzeSessions } from './analyze.js';
9
+ import { renderReport } from './report.js';
10
+ import { renderReceipt } from './receipt.js';
11
+ import { renderSettingsPage } from './settings-page.js';
12
+ import { FILES, loadConfig, saveConfig, loadPricingOverrides, savePricingOverrides, TAGGING_LEVELS, UNTAGGED, THEMES, RANGES } from './config.js';
13
+ import { loadPricingTable, lookupPublic, SOURCE_URL } from './pricing.js';
14
+ import { setTag, clearTag, loadTags, setAssignment } from './tags.js';
15
+ import { applyTagging, installStatus } from './install.js';
16
+ import { loadLearned, priorMeta } from './classifier.js';
17
+ import { trainFromTags } from './tagging.js';
18
+
19
+ const ASSETS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets');
20
+
21
+ export function createApp() {
22
+ let cache = null;
23
+ let cacheAt = 0;
24
+ let cacheKey = '';
25
+ // Tags and project moves are also written by the CLI (/burner-tag) and by
26
+ // new Claude Code activity, so the cache must not outlive them.
27
+ const CACHE_TTL_MS = 15_000;
28
+ const mtime = (f) => { try { return statSync(f).mtimeMs; } catch { return 0; } };
29
+ const stamp = () => `${mtime(FILES.tags)}|${mtime(FILES.assignments)}`;
30
+ const analysis = async ({ refresh = false } = {}) => {
31
+ const stale = !cache || refresh || Date.now() - cacheAt > CACHE_TTL_MS || cacheKey !== stamp();
32
+ if (stale) {
33
+ const key = stamp();
34
+ cache = await analyzeSessions();
35
+ cacheAt = Date.now();
36
+ cacheKey = key;
37
+ }
38
+ return cache;
39
+ };
40
+ const invalidate = () => { cache = null; };
41
+
42
+ async function state() {
43
+ const a = await analysis();
44
+ const tags = loadTags();
45
+ const bySource = {};
46
+ for (const s of a.sessions) bySource[s.categorySource] = (bySource[s.categorySource] ?? 0) + 1;
47
+ const learned = loadLearned();
48
+ return {
49
+ config: a.config,
50
+ levels: TAGGING_LEVELS,
51
+ untagged: UNTAGGED,
52
+ directories: a.directories,
53
+ projectTotals: a.projectTotals,
54
+ models: a.models,
55
+ pricing: a.pricing,
56
+ pricingSourceUrl: SOURCE_URL,
57
+ pricingOverrides: loadPricingOverrides(),
58
+ install: installStatus(loadConfig()),
59
+ learned: learned ? {
60
+ builtin: !!learned.builtin,
61
+ meta: learned.builtin ? priorMeta() : null,
62
+ examples: learned.sources?.local ?? learned.examples,
63
+ totalRows: learned.examples,
64
+ trainedAt: learned.trainedAt,
65
+ sources: learned.sources,
66
+ classes: Object.fromEntries(Object.entries(learned.classes).map(([k, v]) => [k, v.docs])),
67
+ hasUserModel: existsSync(FILES.model),
68
+ } : null,
69
+ stats: { sessions: a.sessions.length, tagged: Object.keys(tags).length, untagged: a.sessions.filter((s) => s.category === UNTAGGED.id).length, bySource },
70
+ };
71
+ }
72
+
73
+ const routes = {
74
+ 'GET /': async () => { const ui = loadConfig().ui; return html(renderReport(await analysis(), { served: true, theme: ui.theme, range: { range: ui.range, from: ui.customFrom, to: ui.customTo } })); },
75
+ 'GET /settings': async () => html(renderSettingsPage(loadConfig().ui.theme)),
76
+ 'GET /api/state': async () => json(await state()),
77
+ 'GET /api/sessions': async () => {
78
+ const a = await analysis();
79
+ return json(a.sessions.map(({ activity, ...s }) => s));
80
+ },
81
+ 'PUT /api/config': async (body) => {
82
+ const cur = loadConfig();
83
+ const next = { ...cur };
84
+ if (body.categories) next.categories = body.categories;
85
+ if (body.projects) next.projects = body.projects;
86
+ if (body.tagging) next.tagging = { ...cur.tagging, ...body.tagging };
87
+ validateConfig(next);
88
+ const saved = saveConfig(next);
89
+ if (body.tagging || body.categories) applyTagging(saved);
90
+ invalidate();
91
+ return json(await state());
92
+ },
93
+ 'PUT /api/pricing': async (body) => {
94
+ savePricingOverrides(body.models ?? {});
95
+ invalidate();
96
+ return json(await state());
97
+ },
98
+ 'POST /api/pricing/lookup': async (body) => {
99
+ const model = String(body.model ?? '').trim();
100
+ if (!model) throw httpError(400, 'model required');
101
+ // refresh:true (per-model Refresh button) always re-fetches the public list first.
102
+ let table = await loadPricingTable({ force: !!body.refresh });
103
+ if (body.refresh && table.source === 'live') cache = await analyzeSessions({ table });
104
+ let r = lookupPublic(model, table);
105
+ // A brand-new model may be newer than the cached list: refetch once.
106
+ if (!r.found && table.source !== 'live') {
107
+ table = await loadPricingTable({ force: true });
108
+ r = lookupPublic(model, table);
109
+ }
110
+ return json({ ...r, listSource: table.source, listError: table.error ?? null });
111
+ },
112
+ 'POST /api/pricing/refresh': async () => {
113
+ const t = await loadPricingTable({ force: true });
114
+ cache = await analyzeSessions({ table: t });
115
+ if (t.source !== 'live') throw httpError(502, `could not fetch fresh pricing: ${t.error ?? t.source}`);
116
+ return json(await state());
117
+ },
118
+ 'POST /api/session-project': async (body) => {
119
+ if (!body.sessionId) throw httpError(400, 'sessionId required');
120
+ if (body.project) {
121
+ const cfg = loadConfig();
122
+ if (!cfg.projects.some((p) => p.name === body.project)) throw httpError(400, `unknown project ${body.project} (save it first)`);
123
+ }
124
+ setAssignment(body.sessionId, body.project || null);
125
+ invalidate();
126
+ return json({ ok: true });
127
+ },
128
+ 'POST /api/tag': async (body) => {
129
+ if (!body.sessionId) throw httpError(400, 'sessionId required');
130
+ if (!body.category) clearTag(body.sessionId);
131
+ else {
132
+ const cfg = loadConfig();
133
+ if (!cfg.categories.some((c) => c.id === body.category)) throw httpError(400, `unknown category ${body.category}`);
134
+ setTag(body.sessionId, { category: body.category, source: 'manual' });
135
+ }
136
+ invalidate();
137
+ return json({ ok: true });
138
+ },
139
+ // The report's time range (7 days, 30 days, ...) is remembered between visits.
140
+ 'PUT /api/range': async (body) => {
141
+ if (!RANGES.includes(String(body.range))) throw httpError(400, 'unknown range');
142
+ const cur = loadConfig();
143
+ saveConfig({ ...cur, ui: { ...cur.ui, range: String(body.range), customFrom: body.from || '', customTo: body.to || '' } });
144
+ return json({ ok: true });
145
+ },
146
+ 'PUT /api/theme': async (body) => {
147
+ if (!THEMES.includes(body.theme)) throw httpError(400, 'unknown theme');
148
+ const cur = loadConfig();
149
+ saveConfig({ ...cur, ui: { ...cur.ui, theme: body.theme } });
150
+ return json({ ok: true });
151
+ },
152
+ 'POST /api/learned/train': async () => {
153
+ const r = await trainFromTags();
154
+ invalidate();
155
+ return json(r);
156
+ },
157
+ 'POST /api/learned/reset': async () => {
158
+ if (existsSync(FILES.model)) rmSync(FILES.model);
159
+ invalidate();
160
+ return json({ ok: true });
161
+ },
162
+ 'POST /api/install/sync': async () => {
163
+ applyTagging(loadConfig());
164
+ invalidate();
165
+ return json(await state());
166
+ },
167
+ };
168
+
169
+ return async function handler(req, res) {
170
+ try {
171
+ const host = (req.headers.host ?? '').replace(/:\d+$/, '');
172
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(host)) throw httpError(403, 'burner only answers on localhost');
173
+ const url = new URL(req.url, 'http://localhost');
174
+ const asset = url.pathname.match(/^\/assets\/(logo-mark|logo-with-text)\.png$/);
175
+ if (req.method === 'GET' && asset) {
176
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=86400' });
177
+ return res.end(readFileSync(path.join(ASSETS_DIR, `${asset[1]}.png`)));
178
+ }
179
+ const receipt = url.pathname.match(/^\/receipt\/([\w-]+)$/);
180
+ if (req.method === 'GET' && receipt) {
181
+ const a = await analysis();
182
+ const s = a.sessions.find((x) => x.sessionId.startsWith(receipt[1]));
183
+ if (!s) throw httpError(404, 'no such session');
184
+ return send(res, html(renderReceipt(s, a.categories, { served: true, theme: loadConfig().ui.theme, projects: loadConfig().projects.map((x) => x.name) })));
185
+ }
186
+ if (req.method === 'GET' && url.searchParams.has('refresh')) invalidate();
187
+ const route = routes[`${req.method} ${url.pathname}`];
188
+ if (!route) throw httpError(404, 'not found');
189
+ let body = {};
190
+ if (req.method !== 'GET') {
191
+ if (!String(req.headers['content-type'] ?? '').includes('application/json')) throw httpError(415, 'send application/json');
192
+ body = await readBody(req);
193
+ }
194
+ send(res, await route(body));
195
+ } catch (err) {
196
+ send(res, json({ error: err.message }, err.status ?? 500));
197
+ }
198
+ };
199
+ }
200
+
201
+ function validateConfig(cfg) {
202
+ const ids = new Set();
203
+ for (const c of cfg.categories) {
204
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(c.id ?? '')) throw httpError(400, `category id "${c.id}" must be lowercase letters, digits or dashes`);
205
+ if (c.id === UNTAGGED.id) throw httpError(400, '"untagged" is reserved');
206
+ if (ids.has(c.id)) throw httpError(400, `duplicate category ${c.id}`);
207
+ ids.add(c.id);
208
+ }
209
+ const names = new Set();
210
+ for (const p of cfg.projects) {
211
+ if (!p.name?.trim()) throw httpError(400, 'every project needs a name');
212
+ if (names.has(p.name)) throw httpError(400, `duplicate project ${p.name}`);
213
+ names.add(p.name);
214
+ for (const c of [...(p.categories ?? []), ...(p.defaultCategory ? [p.defaultCategory] : [])]) {
215
+ if (!ids.has(c)) throw httpError(400, `project ${p.name} uses unknown category ${c}`);
216
+ }
217
+ }
218
+ }
219
+
220
+ function httpError(status, message) {
221
+ return Object.assign(new Error(message), { status });
222
+ }
223
+
224
+ function html(body) {
225
+ return { status: 200, type: 'text/html; charset=utf-8', body };
226
+ }
227
+
228
+ function json(value, status = 200) {
229
+ return { status, type: 'application/json', body: JSON.stringify(value) };
230
+ }
231
+
232
+ function send(res, { status, type, body }) {
233
+ res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
234
+ res.end(body);
235
+ }
236
+
237
+ function readBody(req) {
238
+ return new Promise((resolve, reject) => {
239
+ let data = '';
240
+ req.on('data', (c) => {
241
+ data += c;
242
+ if (data.length > 2_000_000) reject(httpError(413, 'body too large'));
243
+ });
244
+ req.on('end', () => {
245
+ try {
246
+ resolve(data ? JSON.parse(data) : {});
247
+ } catch {
248
+ reject(httpError(400, 'invalid JSON'));
249
+ }
250
+ });
251
+ req.on('error', reject);
252
+ });
253
+ }
254
+
255
+ export function startServer({ port = 4777 } = {}) {
256
+ const server = http.createServer(createApp());
257
+ return new Promise((resolve, reject) => {
258
+ server.once('error', reject);
259
+ server.listen(port, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}` }));
260
+ });
261
+ }