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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Session intent: who decided, in order of trust —
|
|
2
|
+
// 1. a tag (from /burner-tag, the report UI, or the CLI)
|
|
3
|
+
// 2. the project's default category (or its only allowed one)
|
|
4
|
+
// 3. the learned model, when it has been trained and is confident
|
|
5
|
+
// 4. otherwise "untagged" - no guessing from tool counts.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { FILES, readJson, writeJson, UNTAGGED, mapLegacyCategory } from './config.js';
|
|
11
|
+
|
|
12
|
+
const MODEL_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'model');
|
|
13
|
+
|
|
14
|
+
export function allowedCategories(project, cfg) {
|
|
15
|
+
const all = cfg.categories;
|
|
16
|
+
if (!project?.categories?.length) return all;
|
|
17
|
+
const allowed = all.filter((c) => project.categories.includes(c.id));
|
|
18
|
+
return allowed.length ? allowed : all;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveCategory({ tag, project, cfg, digest, learned }) {
|
|
22
|
+
const ids = new Set(cfg.categories.map((c) => c.id));
|
|
23
|
+
const tagCat = tag?.category ? mapLegacyCategory(tag.category) : null;
|
|
24
|
+
if (tagCat && ids.has(tagCat)) return { category: tagCat, source: tag.source ?? 'manual', confidence: tag.confidence ?? null };
|
|
25
|
+
if (project?.defaultCategory && ids.has(mapLegacyCategory(project.defaultCategory))) return { category: mapLegacyCategory(project.defaultCategory), source: 'project' };
|
|
26
|
+
if (project?.categories?.length === 1 && ids.has(project.categories[0])) return { category: project.categories[0], source: 'project' };
|
|
27
|
+
if (learned && cfg.tagging?.learned !== false && digest) {
|
|
28
|
+
const allowed = allowedCategories(project, cfg).map((c) => c.id);
|
|
29
|
+
const p = predictLearned(learned, digest, allowed);
|
|
30
|
+
if (p && p.confidence >= 0.6) return { category: p.category, source: 'learned', confidence: p.confidence };
|
|
31
|
+
}
|
|
32
|
+
return { category: UNTAGGED.id, source: 'none' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Digest: a compact summary of what a session was about ────────────────────
|
|
36
|
+
|
|
37
|
+
export function buildDigest(parsed, { title, projectName } = {}) {
|
|
38
|
+
const d = parsed.digest ?? parsed;
|
|
39
|
+
const counts = {};
|
|
40
|
+
for (const step of d.tools ?? []) counts[step] = (counts[step] ?? 0) + 1;
|
|
41
|
+
const topTools = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 15);
|
|
42
|
+
const prompts = (d.prompts ?? []).slice(0, 8).map((p) => p.text.replace(/\s+/g, ' ').slice(0, 400));
|
|
43
|
+
return {
|
|
44
|
+
project: projectName ?? null,
|
|
45
|
+
title: d.aiTitle ?? title ?? null,
|
|
46
|
+
branch: d.gitBranch ?? null,
|
|
47
|
+
prompts,
|
|
48
|
+
tools: topTools,
|
|
49
|
+
toolCount: (d.tools ?? []).length,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Learned model: multinomial naive Bayes over prompt words + tool steps ────
|
|
54
|
+
|
|
55
|
+
const STOP = new Set('the a an and or to of in on for is it this that with be as are was at by from can you i me my we our please just so do does not no yes if then there here what how why when which would could should will let lets ok okay'.split(' '));
|
|
56
|
+
|
|
57
|
+
export function features(digest) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const p of digest.prompts ?? []) {
|
|
60
|
+
const text = typeof p === 'string' ? p : String(p?.text ?? '');
|
|
61
|
+
if (!text) continue;
|
|
62
|
+
for (const w of text.toLowerCase().match(/[a-z][a-z0-9_+#.-]{1,24}/g) ?? []) {
|
|
63
|
+
const word = w.replace(/[.-]+$/, '');
|
|
64
|
+
if (!STOP.has(word)) out.push(word);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const [step, n] of digest.tools ?? []) {
|
|
68
|
+
const tok = 't:' + step.toLowerCase();
|
|
69
|
+
for (let i = 0; i < Math.min(n, 5); i++) out.push(tok);
|
|
70
|
+
out.push('t:' + step.toLowerCase().replace(/\(.*$/, ''));
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function trainLearned(examples, { prior } = {}) {
|
|
76
|
+
const model = prior ? JSON.parse(JSON.stringify(prior)) : { version: 1, classes: {}, vocab: {}, trainedAt: null, examples: 0, sources: {} };
|
|
77
|
+
for (const { digest, category, weight = 1, source = 'local' } of examples) {
|
|
78
|
+
if (!category) continue;
|
|
79
|
+
const cls = (model.classes[category] ??= { docs: 0, tokens: 0, counts: {} });
|
|
80
|
+
cls.docs += weight;
|
|
81
|
+
for (const f of features(digest)) {
|
|
82
|
+
cls.counts[f] = (cls.counts[f] ?? 0) + weight;
|
|
83
|
+
cls.tokens += weight;
|
|
84
|
+
model.vocab[f] = 1;
|
|
85
|
+
}
|
|
86
|
+
model.examples += 1;
|
|
87
|
+
model.sources[source] = (model.sources[source] ?? 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
model.trainedAt = new Date().toISOString();
|
|
90
|
+
return model;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function predictLearned(model, digest, allowed) {
|
|
94
|
+
const classes = Object.entries(model.classes).filter(([id, c]) => c.docs > 0 && (!allowed || allowed.includes(id)));
|
|
95
|
+
if (classes.length < 2) return null;
|
|
96
|
+
const feats = features(digest);
|
|
97
|
+
if (!feats.length) return null;
|
|
98
|
+
const V = Object.keys(model.vocab).length || 1;
|
|
99
|
+
const totalDocs = classes.reduce((s, [, c]) => s + c.docs, 0);
|
|
100
|
+
const scores = classes.map(([id, c]) => {
|
|
101
|
+
let s = Math.log(c.docs / totalDocs);
|
|
102
|
+
for (const f of feats) s += Math.log(((c.counts[f] ?? 0) + 1) / (c.tokens + V));
|
|
103
|
+
return [id, s];
|
|
104
|
+
});
|
|
105
|
+
const max = Math.max(...scores.map(([, s]) => s));
|
|
106
|
+
const exp = scores.map(([id, s]) => [id, Math.exp(s - max)]);
|
|
107
|
+
const z = exp.reduce((a, [, v]) => a + v, 0);
|
|
108
|
+
exp.sort((a, b) => b[1] - a[1]);
|
|
109
|
+
return { category: exp[0][0], confidence: Math.round((exp[0][1] / z) * 1000) / 1000 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readBundled(name) {
|
|
113
|
+
try { return JSON.parse(readFileSync(path.join(MODEL_DIR, name), 'utf-8')); } catch { return null; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The built-in model, trained on public datasets (see src/model/prior.meta.json).
|
|
117
|
+
// Not used when its own evaluation said it isn't better than guessing.
|
|
118
|
+
export function loadPrior() {
|
|
119
|
+
const meta = readBundled('prior.meta.json');
|
|
120
|
+
if (meta && meta.recommended === false) return null;
|
|
121
|
+
const model = readBundled('prior.json');
|
|
122
|
+
return model?.classes ? model : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function priorMeta() {
|
|
126
|
+
return readBundled('prior.meta.json');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The user's own trained model if there is one (it starts from the built-in
|
|
130
|
+
// one), otherwise the built-in model.
|
|
131
|
+
export function loadLearned() {
|
|
132
|
+
const own = readJson(FILES.model, null);
|
|
133
|
+
if (own) return own;
|
|
134
|
+
const prior = loadPrior();
|
|
135
|
+
return prior ? { ...prior, builtin: true } : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function saveLearned(model) {
|
|
139
|
+
writeJson(FILES.model, model);
|
|
140
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,118 +1,215 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
burner-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { analyzeSessions } from './analyze.js';
|
|
5
|
+
import { renderReport } from './report.js';
|
|
6
|
+
import { renderReceipt } from './receipt.js';
|
|
7
|
+
import { loadConfig, HOME, UNTAGGED } from './config.js';
|
|
8
|
+
import { loadPricingTable } from './pricing.js';
|
|
9
|
+
import { clearTag } from './tags.js';
|
|
10
|
+
import { applyTagging, installStatus, uninstall, SKILL_NAME } from './install.js';
|
|
11
|
+
import { tagFromCli, trainFromTags, fetchSweChat, readJsonl, resolveCurrentSession } from './tagging.js';
|
|
12
|
+
import { readHookPayload, tagSessionAtEnd } from './tag-end.js';
|
|
13
|
+
|
|
14
|
+
const HELP = `
|
|
15
|
+
burner - what your Claude Code sessions cost, and what they were for
|
|
16
|
+
|
|
17
|
+
Usage
|
|
18
|
+
burner open the web app (report + settings) on 127.0.0.1:4777
|
|
19
|
+
burner serve [--port N] [--no-open]
|
|
20
|
+
burner report [--no-open] write a static report to ~/.burner/report.html
|
|
21
|
+
burner list terminal table
|
|
22
|
+
burner receipt [id|title] one session's receipt (default: latest)
|
|
23
|
+
|
|
24
|
+
Tagging
|
|
25
|
+
burner tag <category> [--session ID] [--reason TEXT] [--task]
|
|
26
|
+
burner tag --clear [--session ID]
|
|
27
|
+
burner install install /${SKILL_NAME} globally + apply the tagging level
|
|
28
|
+
burner uninstall remove the skill and CLAUDE.md block
|
|
29
|
+
burner status
|
|
30
|
+
|
|
31
|
+
Pricing & model
|
|
32
|
+
burner pricing refresh fetch fresh public prices now
|
|
33
|
+
burner model train [--swe-chat] [--data FILE.jsonl] [--prior] learn from your tags (+ optional public labels)
|
|
34
|
+
|
|
35
|
+
Filters: --project <name> --from YYYY-MM-DD --to YYYY-MM-DD --json --offline
|
|
36
|
+
Files: ${HOME}
|
|
26
37
|
`;
|
|
27
38
|
|
|
28
|
-
function
|
|
29
|
-
const
|
|
30
|
-
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const opts = { _: [], open: true };
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (a === '--help' || a === '-h') opts.help = true;
|
|
44
|
+
else if (a === '--json') opts.json = true;
|
|
45
|
+
else if (a === '--no-open') opts.open = false;
|
|
46
|
+
else if (a === '--offline') opts.offline = true;
|
|
47
|
+
else if (a === '--all') opts.all = true;
|
|
48
|
+
else if (a === '--task') opts.task = true;
|
|
49
|
+
else if (a === '--clear') opts.clear = true;
|
|
50
|
+
else if (a === '--swe-chat') opts.sweChat = true;
|
|
51
|
+
else if (a === '--prior') opts.prior = true;
|
|
52
|
+
else if (['--project', '--from', '--to', '--session', '--reason', '--port'].includes(a)) opts[a.slice(2)] = argv[++i];
|
|
53
|
+
else if (a === '--data') opts.file = argv[++i];
|
|
54
|
+
else opts._.push(a);
|
|
55
|
+
}
|
|
56
|
+
return opts;
|
|
31
57
|
}
|
|
32
58
|
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
const fmtTok = (n) => (n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : String(n));
|
|
60
|
+
const fmtCost = (n) => '$' + n.toFixed(n > 0 && n < 0.01 ? 4 : 2);
|
|
61
|
+
const pad = (s, n) => String(s ?? '').slice(0, n).padEnd(n);
|
|
62
|
+
|
|
63
|
+
function openUrl(target) {
|
|
64
|
+
const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
65
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '""', target] : [target];
|
|
66
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
35
67
|
}
|
|
36
68
|
|
|
37
|
-
function printList(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
console.log(
|
|
45
|
-
`${s.sessionId.slice(0, 8)} ${title.padEnd(20)} ${s.project.slice(0, 25).padEnd(25)} ${date.padEnd(12)} ${models.padEnd(8)} ${fmtUsd(r.totals.cost.total)}`
|
|
46
|
-
);
|
|
69
|
+
function printList(data) {
|
|
70
|
+
const label = Object.fromEntries(data.categories.map((c) => [c.id, c.label]));
|
|
71
|
+
const header = [pad('DATE', 10), pad('TITLE', 30), pad('PROJECT', 16), pad('CATEGORY', 15), pad('MODEL', 20), pad('TOKENS', 8), pad('COST', 9)].join(' ');
|
|
72
|
+
console.log('\n ' + header + '\n ' + '─'.repeat(header.length));
|
|
73
|
+
for (const s of data.sessions) {
|
|
74
|
+
const model = s.models.map((m) => m.rawModel).join('+') || '-';
|
|
75
|
+
const cat = (label[s.category] ?? s.category) + (s.categorySource === 'learned' ? '?' : '');
|
|
76
|
+
console.log(' ' + [pad(s.date, 10), pad(s.title, 30), pad(s.project, 16), pad(cat, 15), pad(model, 20), pad(fmtTok(s.totalTokens.total), 8), pad(fmtCost(s.totalCost.total) + (s.hasUnpriced ? '*' : ''), 9)].join(' '));
|
|
47
77
|
}
|
|
78
|
+
const tok = data.sessions.reduce((a, s) => a + s.totalTokens.total, 0);
|
|
79
|
+
const cost = data.sessions.reduce((a, s) => a + s.totalCost.total, 0);
|
|
80
|
+
console.log(`\n ${data.sessions.length} sessions | ${fmtTok(tok)} tokens | ${fmtCost(cost)} | pricing ${data.pricing.source}`);
|
|
81
|
+
if (data.unpricedModels.length) console.log(` * $0 until priced: ${data.unpricedModels.join(', ')} - set rates on Settings -> Pricing`);
|
|
82
|
+
console.log('');
|
|
48
83
|
}
|
|
49
84
|
|
|
50
85
|
async function main() {
|
|
51
|
-
const
|
|
86
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
87
|
+
const [cmd = 'serve', sub] = opts._;
|
|
88
|
+
if (opts.help || cmd === 'help') return console.log(HELP);
|
|
89
|
+
|
|
90
|
+
if (cmd === 'tag') {
|
|
91
|
+
if (opts.clear) {
|
|
92
|
+
const s = resolveCurrentSession(opts.session);
|
|
93
|
+
if (!s) throw new Error('no session found');
|
|
94
|
+
clearTag(s.sessionId);
|
|
95
|
+
return console.log(`cleared tag on ${s.sessionId}`);
|
|
96
|
+
}
|
|
97
|
+
if (!sub) {
|
|
98
|
+
const cfg = loadConfig();
|
|
99
|
+
return console.log('categories: ' + cfg.categories.map((c) => c.id).join(', '));
|
|
100
|
+
}
|
|
101
|
+
const r = tagFromCli({ category: sub, session: opts.session, reason: opts.reason, task: opts.task });
|
|
102
|
+
return console.log(`tagged ${r.category}${opts.task ? ' (task)' : ''} | ${r.project} | ${r.sessionId.slice(0, 8)}`);
|
|
103
|
+
}
|
|
52
104
|
|
|
53
|
-
if (
|
|
54
|
-
|
|
105
|
+
if (cmd === 'hook' && sub === 'session-end') {
|
|
106
|
+
const payload = await readHookPayload();
|
|
107
|
+
const sid = payload.session_id ?? payload.sessionId ?? process.env.CLAUDE_SESSION_ID;
|
|
108
|
+
const r = await tagSessionAtEnd({ sessionId: sid });
|
|
109
|
+
if (r.skipped) return;
|
|
110
|
+
if (r.error) {
|
|
111
|
+
console.error(` session-end tag failed: ${r.error}`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (r.ok) console.log(` tagged ${r.category} (${r.model})`);
|
|
55
116
|
return;
|
|
56
117
|
}
|
|
57
|
-
|
|
58
|
-
|
|
118
|
+
|
|
119
|
+
if (cmd === 'install') {
|
|
120
|
+
const st = applyTagging(loadConfig());
|
|
121
|
+
console.log(` /${st.skillName} -> ${st.skillPath}${st.skillInstalled ? '' : ' (not installed: level is off)'}`);
|
|
122
|
+
console.log(` CLAUDE.md block: ${st.claudeMdLevel ?? 'none'}`);
|
|
123
|
+
if (st.sessionEndHook) console.log(` SessionEnd hook: on (model ${st.endModel || '?'})`);
|
|
59
124
|
return;
|
|
60
125
|
}
|
|
126
|
+
if (cmd === 'uninstall') {
|
|
127
|
+
uninstall();
|
|
128
|
+
return console.log(' removed skill and CLAUDE.md block');
|
|
129
|
+
}
|
|
130
|
+
if (cmd === 'status') {
|
|
131
|
+
const cfg = loadConfig();
|
|
132
|
+
return console.log(JSON.stringify({ home: HOME, tagging: cfg.tagging, install: installStatus(cfg) }, null, 2));
|
|
133
|
+
}
|
|
61
134
|
|
|
62
|
-
|
|
63
|
-
|
|
135
|
+
if (cmd === 'pricing') {
|
|
136
|
+
const t = await loadPricingTable({ force: sub === 'refresh' });
|
|
137
|
+
console.log(` pricing: ${t.source} | ${Object.keys(t.models).length} models${t.error ? ' | ' + t.error : ''}`);
|
|
138
|
+
if (sub === 'refresh' && t.source !== 'live') process.exitCode = 1;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
64
141
|
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
142
|
+
if (cmd === 'model' && sub === 'train') {
|
|
143
|
+
let prior = [];
|
|
144
|
+
if (opts.sweChat) {
|
|
145
|
+
process.stdout.write(' fetching SWE-chat prompt intents…');
|
|
146
|
+
prior = (await fetchSweChat({ token: process.env.HF_TOKEN, onProgress: (n) => process.stdout.write(`\r fetching SWE-chat prompt intents… ${n}`) })).map((r) => ({ ...r, source: 'swe-chat' }));
|
|
147
|
+
console.log('');
|
|
70
148
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
149
|
+
if (opts.file) prior.push(...readJsonl(opts.file).map((r) => ({ text: r.text, category: r.category ?? r.label, source: path.basename(opts.file) })));
|
|
150
|
+
const r = await trainFromTags({ prior, bootstrapPrior: !!opts.prior });
|
|
151
|
+
return console.log(r.ok ? ` trained on ${r.examples} examples: ${JSON.stringify(r.classes)}` : ' ' + r.message);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (cmd === 'serve') {
|
|
155
|
+
const { startServer } = await import('./server.js');
|
|
156
|
+
const want = Number(opts.port ?? 4777);
|
|
157
|
+
let url;
|
|
158
|
+
try {
|
|
159
|
+
({ url } = await startServer({ port: want }));
|
|
160
|
+
console.log(` burner -> ${url} (settings: ${url}/settings) | Ctrl+C to stop`);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err.code !== 'EADDRINUSE') throw err;
|
|
163
|
+
const base = `http://127.0.0.1:${want}`;
|
|
164
|
+
const running = await fetch(base + '/api/state', { signal: AbortSignal.timeout(3000) }).then((r) => r.ok).catch(() => false);
|
|
165
|
+
if (running) {
|
|
166
|
+
console.log(` burner is already running -> ${base} (settings: ${base}/settings)`);
|
|
167
|
+
if (opts.open) openUrl(base);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (opts.port) throw new Error(`port ${want} is in use by another program; pick one with --port`);
|
|
171
|
+
({ url } = await startServer({ port: 0 }));
|
|
172
|
+
console.log(` port ${want} is busy, using ${url} (settings: ${url}/settings) | Ctrl+C to stop`);
|
|
77
173
|
}
|
|
174
|
+
if (opts.open) openUrl(url);
|
|
78
175
|
return;
|
|
79
176
|
}
|
|
80
177
|
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!found) {
|
|
87
|
-
console.error(`No session found matching "${filtered[0]}". Try "burner-cc list".`);
|
|
88
|
-
process.exitCode = 1;
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
sessions = [found];
|
|
92
|
-
} else {
|
|
93
|
-
const latest = latestSession();
|
|
94
|
-
if (!latest) {
|
|
95
|
-
console.log('No Claude Code sessions found under ~/.claude/projects.');
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
sessions = [latest];
|
|
178
|
+
const data = await analyzeSessions(opts);
|
|
179
|
+
if (cmd === 'list') return opts.json ? console.log(JSON.stringify(data.sessions, null, 2)) : printList(data);
|
|
180
|
+
if (opts.json) {
|
|
181
|
+
const { digests, config, ...rest } = data;
|
|
182
|
+
return console.log(JSON.stringify(rest, null, 2));
|
|
99
183
|
}
|
|
100
184
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
185
|
+
mkdirSync(HOME, { recursive: true });
|
|
186
|
+
if (cmd === 'receipt') {
|
|
187
|
+
const needle = (sub ?? 'latest').toLowerCase();
|
|
188
|
+
const s = needle === 'latest' ? data.sessions[0] : data.sessions.find((x) => x.sessionId.startsWith(needle) || x.title.toLowerCase().includes(needle));
|
|
189
|
+
if (!s) throw new Error(`no session matching "${sub}" - try "burner list"`);
|
|
190
|
+
const file = path.join(HOME, `receipt-${s.sessionId.slice(0, 8)}.html`);
|
|
191
|
+
writeFileSync(file, renderReceipt(s, data.categories), 'utf-8');
|
|
192
|
+
console.log(' receipt -> ' + file);
|
|
193
|
+
if (opts.open) openUrl(file);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
104
196
|
|
|
105
|
-
if (
|
|
106
|
-
|
|
197
|
+
if (cmd === 'report') {
|
|
198
|
+
for (const s of data.sessions) writeFileSync(path.join(HOME, `receipt-${s.sessionId.slice(0, 8)}.html`), renderReceipt(s, data.categories), 'utf-8');
|
|
199
|
+
const file = path.join(HOME, 'report.html');
|
|
200
|
+
writeFileSync(file, renderReport(data, { served: false }), 'utf-8');
|
|
201
|
+
const untagged = data.sessions.filter((s) => s.category === UNTAGGED.id).length;
|
|
202
|
+
console.log(` report -> ${file} (${data.sessions.length} sessions, ${untagged} untagged)`);
|
|
203
|
+
if (opts.open) openUrl(file);
|
|
107
204
|
return;
|
|
108
205
|
}
|
|
109
206
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
openInBrowser(outFile);
|
|
207
|
+
console.log(HELP);
|
|
208
|
+
process.exitCode = 1;
|
|
113
209
|
}
|
|
114
210
|
|
|
115
211
|
main().catch((err) => {
|
|
116
|
-
console.error('
|
|
212
|
+
console.error(' x ' + err.message);
|
|
213
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
117
214
|
process.exitCode = 1;
|
|
118
215
|
});
|
package/src/config.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { parseYaml, stringifyYaml } from './yaml.js';
|
|
5
|
+
|
|
6
|
+
export const HOME = process.env.BURNER_HOME ?? path.join(os.homedir(), '.burner');
|
|
7
|
+
export const CLAUDE_HOME = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude');
|
|
8
|
+
export const FILES = {
|
|
9
|
+
config: path.join(HOME, 'config.yaml'),
|
|
10
|
+
pricing: path.join(HOME, 'pricing.yaml'),
|
|
11
|
+
tags: path.join(HOME, 'tags.json'),
|
|
12
|
+
assignments: path.join(HOME, 'assignments.json'),
|
|
13
|
+
pricingCache: path.join(HOME, 'cache', 'litellm-pricing.json'),
|
|
14
|
+
model: path.join(HOME, 'model', 'intent-nb.json'),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// What people use Claude Code for, by intent rather than by tool mechanics.
|
|
18
|
+
// Order matters only for display.
|
|
19
|
+
export const DEFAULT_CATEGORIES = [
|
|
20
|
+
{ id: 'coding', label: 'Coding', color: '#2563eb', description: 'Building or changing something: code, scripts, apps, automations, files' },
|
|
21
|
+
{ id: 'debugging', label: 'Debugging', color: '#dc2626', description: 'Figuring out why something is broken and fixing it' },
|
|
22
|
+
{ id: 'planning', label: 'Planning', color: '#059669', description: 'Deciding what to build or do: strategy, specs, designs, roadmaps, breakdowns' },
|
|
23
|
+
{ id: 'research', label: 'Research', color: '#475569', description: 'Finding out how something works: exploring files, docs, the web, options' },
|
|
24
|
+
{ id: 'writing', label: 'Writing', color: '#b45309', description: 'Docs, emails, reports, slides, copy, summaries' },
|
|
25
|
+
{ id: 'analysis', label: 'Analysis', color: '#0d9488', description: 'Working with data, numbers, metrics, spreadsheets, charts' },
|
|
26
|
+
{ id: 'review', label: 'Review', color: '#c2410c', description: 'Checking someone\'s work: code, documents, plans, feedback' },
|
|
27
|
+
{ id: 'learning', label: 'Learning', color: '#64748b', description: 'Questions and explanations, no real work product' },
|
|
28
|
+
{ id: 'other', label: 'Other', color: '#94a3b8', description: 'Anything that does not fit above' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Ids from earlier versions, mapped to the current set.
|
|
32
|
+
export const LEGACY_CATEGORY_IDS = { feature: 'coding', refactor: 'coding', testing: 'coding', devops: 'coding', bugfix: 'debugging', docs: 'writing', data: 'analysis' };
|
|
33
|
+
export const mapLegacyCategory = (id) => LEGACY_CATEGORY_IDS[id] ?? id;
|
|
34
|
+
|
|
35
|
+
export const THEMES = ['paper', 'thermal', 'matrix', 'dark'];
|
|
36
|
+
export const RANGES = ['all', '7', '30', '90', 'custom'];
|
|
37
|
+
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
38
|
+
export const UNTAGGED = { id: 'untagged', label: 'Untagged', color: '#a8a29e', description: 'No tag yet' };
|
|
39
|
+
|
|
40
|
+
// Tagging levels, lowest to highest — mirrors Claude Code's effort slider.
|
|
41
|
+
export const TAGGING_LEVELS = [
|
|
42
|
+
{ id: 'off', label: 'Off', description: 'Never tag. Sessions stay untagged unless the project has a default. Burner removes the global /burner-tag skill and hooks from ~/.claude.' },
|
|
43
|
+
{ id: 'manual', label: 'Manual', description: 'Only when you run /burner-tag. Burner keeps the skill installed globally; no CLAUDE.md reminder.' },
|
|
44
|
+
{ id: 'session', label: 'Session end', description: 'Tag when a session ends: SessionEnd hook + your Anthropic model id (below), or /burner-tag via CLAUDE.md if no model id. Burner updates ~/.claude when you move the slider.' },
|
|
45
|
+
{ id: 'task', label: 'Every task', description: 'Claude tags after each distinct task (/burner-tag with --task). Burner adds a CLAUDE.md block and keeps the skill installed.' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function defaultConfig() {
|
|
49
|
+
return {
|
|
50
|
+
version: 1,
|
|
51
|
+
categories: DEFAULT_CATEGORIES.map((c) => ({ ...c })),
|
|
52
|
+
projects: [],
|
|
53
|
+
tagging: {
|
|
54
|
+
level: 'manual',
|
|
55
|
+
learned: true,
|
|
56
|
+
endModel: '',
|
|
57
|
+
},
|
|
58
|
+
ui: { theme: 'paper', range: 'all', customFrom: '', customTo: '' },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readText(file) {
|
|
63
|
+
try {
|
|
64
|
+
return readFileSync(file, 'utf-8');
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeAtomic(file, text) {
|
|
71
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
72
|
+
const tmp = `${file}.tmp`;
|
|
73
|
+
writeFileSync(tmp, text, 'utf-8');
|
|
74
|
+
renameSync(tmp, file);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const CONFIG_HEADER = '# burner config — edited from the Settings page (burner serve). Safe to hand-edit.\n';
|
|
78
|
+
|
|
79
|
+
function normalizeConfig(raw) {
|
|
80
|
+
const base = defaultConfig();
|
|
81
|
+
const cfg = { ...base, ...(raw ?? {}) };
|
|
82
|
+
if (Array.isArray(cfg.categories) && cfg.categories.some((x) => LEGACY_CATEGORY_IDS[x?.id]) && !cfg.categories.some((x) => x?.id === 'coding')) cfg.categories = null;
|
|
83
|
+
cfg.categories = Array.isArray(cfg.categories) && cfg.categories.length
|
|
84
|
+
? cfg.categories.filter((c) => c?.id).map((c) => ({ id: String(c.id), label: c.label ?? c.id, color: c.color ?? '#888888', description: c.description ?? '' }))
|
|
85
|
+
: base.categories;
|
|
86
|
+
cfg.projects = Array.isArray(cfg.projects)
|
|
87
|
+
? cfg.projects.filter((p) => p?.name).map((p) => ({
|
|
88
|
+
name: String(p.name),
|
|
89
|
+
paths: (p.paths ?? []).map(String),
|
|
90
|
+
categories: [...new Set((p.categories ?? []).map(String).map(mapLegacyCategory))],
|
|
91
|
+
defaultCategory: p.defaultCategory ? mapLegacyCategory(p.defaultCategory) : null,
|
|
92
|
+
}))
|
|
93
|
+
: [];
|
|
94
|
+
cfg.tagging = { ...base.tagging, ...(cfg.tagging ?? {}) };
|
|
95
|
+
delete cfg.tagging.auto;
|
|
96
|
+
if (cfg.tagging.level === 'auto') cfg.tagging.level = 'manual';
|
|
97
|
+
cfg.tagging.endModel = String(cfg.tagging.endModel ?? '').trim();
|
|
98
|
+
const ui = cfg.ui ?? {};
|
|
99
|
+
cfg.ui = {
|
|
100
|
+
theme: THEMES.includes(ui.theme) ? ui.theme : 'paper',
|
|
101
|
+
range: RANGES.includes(String(ui.range)) ? String(ui.range) : 'all',
|
|
102
|
+
customFrom: DAY_RE.test(String(ui.customFrom ?? '')) ? String(ui.customFrom) : '',
|
|
103
|
+
customTo: DAY_RE.test(String(ui.customTo ?? '')) ? String(ui.customTo) : '',
|
|
104
|
+
};
|
|
105
|
+
if (!TAGGING_LEVELS.some((l) => l.id === cfg.tagging.level)) cfg.tagging.level = base.tagging.level;
|
|
106
|
+
return cfg;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function loadConfig() {
|
|
110
|
+
migrateLegacy();
|
|
111
|
+
const text = readText(FILES.config);
|
|
112
|
+
if (text == null) return defaultConfig();
|
|
113
|
+
return normalizeConfig(parseYaml(text));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function saveConfig(cfg) {
|
|
117
|
+
const clean = normalizeConfig(cfg);
|
|
118
|
+
writeAtomic(FILES.config, CONFIG_HEADER + stringifyYaml(clean) + '\n');
|
|
119
|
+
return clean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// pricing.yaml: { models: { "<raw model id>": { input, output, cacheWrite5m, cacheWrite1h, cacheRead } } }
|
|
123
|
+
// Rates are USD per million tokens. A model listed here always wins over the fetched table.
|
|
124
|
+
export function loadPricingOverrides() {
|
|
125
|
+
migrateLegacy();
|
|
126
|
+
const text = readText(FILES.pricing);
|
|
127
|
+
if (text == null) return {};
|
|
128
|
+
const parsed = parseYaml(text);
|
|
129
|
+
return parsed?.models ?? {};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function savePricingOverrides(models) {
|
|
133
|
+
const clean = {};
|
|
134
|
+
for (const [id, r] of Object.entries(models ?? {})) {
|
|
135
|
+
if (!r) continue;
|
|
136
|
+
clean[id] = {};
|
|
137
|
+
for (const k of ['input', 'output', 'cacheWrite5m', 'cacheWrite1h', 'cacheRead']) clean[id][k] = Number(r[k]) || 0;
|
|
138
|
+
}
|
|
139
|
+
writeAtomic(FILES.pricing, '# USD per million tokens. Edited from the Pricing page; models not listed use fetched public rates (Claude) or $0.\n' + stringifyYaml({ models: clean }) + '\n');
|
|
140
|
+
return clean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function readJson(file, fallback) {
|
|
144
|
+
const text = readText(file);
|
|
145
|
+
if (text == null) return fallback;
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(text);
|
|
148
|
+
} catch {
|
|
149
|
+
return fallback;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function writeJson(file, value) {
|
|
154
|
+
writeAtomic(file, JSON.stringify(value, null, 2));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let migrated = false;
|
|
158
|
+
// One-time import from the old ~/.ti-burner layout.
|
|
159
|
+
function migrateLegacy() {
|
|
160
|
+
if (migrated) return;
|
|
161
|
+
migrated = true;
|
|
162
|
+
const legacy = path.join(os.homedir(), '.ti-burner');
|
|
163
|
+
if (!existsSync(legacy) || HOME === legacy) return;
|
|
164
|
+
const overrides = readJson(path.join(legacy, 'overrides.json'), null);
|
|
165
|
+
if (overrides && !existsSync(FILES.tags)) {
|
|
166
|
+
const map = { 'Feature Work': 'coding', 'Bug Fix': 'debugging', Research: 'research', Planning: 'planning', Meetings: 'other', Documentation: 'writing', 'Data Analysis': 'analysis', 'Q&A': 'learning', Other: 'other' };
|
|
167
|
+
const tags = {};
|
|
168
|
+
for (const [id, cat] of Object.entries(overrides)) {
|
|
169
|
+
tags[id] = { category: map[cat] ?? 'other', source: 'manual', at: new Date().toISOString(), segments: [] };
|
|
170
|
+
}
|
|
171
|
+
writeJson(FILES.tags, tags);
|
|
172
|
+
}
|
|
173
|
+
const custom = readJson(path.join(legacy, 'custom-pricing.json'), null);
|
|
174
|
+
if (custom && !existsSync(FILES.pricing)) {
|
|
175
|
+
const models = {};
|
|
176
|
+
for (const [id, r] of Object.entries(custom)) {
|
|
177
|
+
const rid = id.replace(/ \(billed-unpriced\)$/, '');
|
|
178
|
+
const rates = ['input', 'output', 'cacheWrite5m', 'cacheWrite1h', 'cacheRead'].map((k) => Number(r?.[k]) || 0);
|
|
179
|
+
if (rates.some((v) => v > 0)) models[rid] = r;
|
|
180
|
+
}
|
|
181
|
+
if (Object.keys(models).length) savePricingOverrides(models);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normPath(p) {
|
|
186
|
+
let s = path.resolve(p).replace(/[\\/]+$/, '');
|
|
187
|
+
if (process.platform === 'win32' || /^[a-z]:/i.test(s)) s = s.toLowerCase().replace(/\//g, '\\');
|
|
188
|
+
return s;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isUnder(child, parent) {
|
|
192
|
+
if (child === parent) return true;
|
|
193
|
+
const sep = parent.includes('\\') ? '\\' : '/';
|
|
194
|
+
return child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The project a working directory belongs to: the configured project whose
|
|
198
|
+
// path is the longest prefix of cwd, else an implicit project named after
|
|
199
|
+
// the directory.
|
|
200
|
+
export function resolveProject(cwd, cfg) {
|
|
201
|
+
if (!cwd) return { name: 'unknown', implicit: true, categories: [], defaultCategory: null };
|
|
202
|
+
const target = normPath(cwd);
|
|
203
|
+
let best = null;
|
|
204
|
+
let bestLen = -1;
|
|
205
|
+
for (const p of cfg.projects) {
|
|
206
|
+
for (const dir of p.paths) {
|
|
207
|
+
const n = normPath(dir);
|
|
208
|
+
if (isUnder(target, n) && n.length > bestLen) {
|
|
209
|
+
best = p;
|
|
210
|
+
bestLen = n.length;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (best) return { ...best, implicit: false };
|
|
215
|
+
const home = normPath(os.homedir());
|
|
216
|
+
const name = target === home ? '~' : path.basename(cwd) || cwd;
|
|
217
|
+
return { name, implicit: true, categories: [], defaultCategory: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function categoryIds(cfg) {
|
|
221
|
+
return cfg.categories.map((c) => c.id);
|
|
222
|
+
}
|