burner-cc 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,84 +2,93 @@
2
2
 
3
3
  # burner
4
4
 
5
- What your Claude Code sessions cost, and what they were for. Itemized receipts, projects that span several directories, and intent tagging that doesn't guess from tool counts.
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
- Zero dependencies. Reads `~/.claude/projects/` — nothing leaves your machine except the public price list fetch.
7
+ Zero runtime dependencies. Reads `~/.claude/projects/` on your machine. Only network use by default is refreshing public model prices (optional).
8
+
9
+ ## Install
8
10
 
9
11
  ```bash
10
- npx burner-cc # or: node bin/burner.js
12
+ npm install -g burner-cc
13
+ # commands: burner (alias entry: burner-cc)
11
14
  ```
12
15
 
13
- Opens a local app at `http://127.0.0.1:4777` — the report, per-session receipts, and **Settings** (projects, categories, pricing, tagging).
14
-
15
- ## Commands
16
+ Or without installing:
16
17
 
17
18
  ```bash
18
- burner # web app: report + settings
19
- burner report # static ~/.burner/report.html (+ receipts)
20
- burner list # terminal table
21
- burner receipt [id|title] # one receipt (default: latest)
19
+ npx burner-cc
20
+ ```
21
+
22
+ **Node 18+** required.
22
23
 
23
- burner install # install /burner-tag globally, apply tagging level
24
- burner tag <category> [--task] [--reason TEXT] [--session ID]
25
- burner pricing refresh # fetch fresh prices now
26
- burner model train [--swe-chat] [--data FILE.jsonl]
24
+ ## Quick start
27
25
 
28
- --project NAME --from YYYY-MM-DD --to YYYY-MM-DD --json --offline
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
29
30
  ```
30
31
 
31
- ## Categories
32
+ Settings covers **projects**, **categories**, **pricing** (pin/refresh per model), and **tagging** (slider, train your intent model).
32
33
 
33
- Intent, not mechanics — reading code to fix a bug is a bug fix:
34
+ ## Commands
34
35
 
35
- `coding` · `debugging` · `planning` · `research` · `writing` · `analysis` · `review` · `learning` · `other`
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
36
40
 
37
- Edit them on Settings → Categories. A session's category comes from, in order:
41
+ burner tag <category> [--session ID] [--reason TEXT] [--task]
42
+ burner tag --clear [--session ID]
43
+ burner install | uninstall | status
38
44
 
39
- 1. **A tag** — from `/burner-tag`, or the receipt's category picker
40
- 2. **Its project's default category** (or the project's only allowed category)
41
- 3. **The learned model**, once trained on your tags and only when confident
42
- 4. Otherwise **untagged**. There are no folder-name or tool-ratio rules.
45
+ burner pricing refresh
46
+ burner model train [--data FILE.jsonl] [--swe-chat] [--prior]
47
+ ```
43
48
 
44
- ## Projects
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.
45
50
 
46
- A project groups one or more directories; any session started under one of them belongs to it (longest match wins). Each project can restrict which categories apply and set a default. Configure on Settings → Projects — it's stored in `~/.burner/config.yaml`, which you never have to open.
51
+ ## Categories
47
52
 
48
- ## Tagging levels
53
+ Intent, not tool counts — e.g. reading files to fix a bug is **debugging**, not research.
49
54
 
50
- Like Claude Code's effort slider (Settings → Tagging):
55
+ Default ids: `coding` · `debugging` · `planning` · `research` · `writing` · `analysis` · `review` · `learning` · `other` (editable in Settings).
51
56
 
52
- | Level | What happens |
53
- |---|---|
54
- | Off | No skill, nothing injected |
55
- | Manual | `/burner-tag` is available in every project |
56
- | Session end | A block in `~/.claude/CLAUDE.md` asks Claude to tag once when the work is done (~100 output tokens) |
57
- | Every task | Claude tags each finished task, so one session can split across categories by cost |
57
+ Resolution order:
58
58
 
59
- ### Learned model
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
60
63
 
61
- `burner model train` fits a small naive-Bayes classifier on prompt words + tool steps from sessions *you* tagged. `--swe-chat` adds intent-labeled prompts from real Claude Code sessions from [SALT-NLP/SWE-chat](https://huggingface.co/datasets/SALT-NLP/SWE-chat) (gated: accept its terms and set `HF_TOKEN`). `--data` takes any `{"text", "category"}` JSONL.
64
+ ## Tagging levels (Settings)
62
65
 
63
- ## Tokens
66
+ | Level | Behavior |
67
+ |--------|----------|
68
+ | Off | Removes global skill / hooks |
69
+ | Manual | `/burner-tag` only; skill stays installed |
70
+ | Session end | SessionEnd hook tags with your **local** trained model (no API, no CLAUDE.md) |
64
71
 
65
- Claude Code writes one API response as several lines (one per streamed block) with growing `output_tokens`. Burner keeps the final line per request ID, drops requests a continued session re-logs from its parent, and bills subagent transcripts to their parent session. Totals include all four billed buckets: fresh input, cache write (5m/1h), cache read, output. On real sessions this lands within a few percent of Claude Code's own `/cost`; the gap is background calls (titles, compaction) Claude Code doesn't write to the transcript.
72
+ Moving the tagging slider 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.
66
73
 
67
- ## Pricing
74
+ ## Tokens & pricing
68
75
 
69
- - **Claude models** — public list prices from [LiteLLM's price table](https://github.com/BerriAI/litellm), refreshed daily (bundled snapshot when offline). Bedrock/Vertex/dated ids are normalized.
70
- - **Everything else** (gateway, on-prem, local models behind the Claude Code harness) — **$0 until you set a price**. Settings → Pricing shows each model, offers the public price when one exists, and saves overrides to `~/.burner/pricing.yaml`.
76
+ - One API request → several transcript lines; burner bills the **last** chunk per `requestId`, dedupes continued sessions, rolls subagents into the parent.
77
+ - Totals: fresh input, cache write (5m/1h), cache read, output.
78
+ - **Claude** models: public LiteLLM price list (daily refresh, offline snapshot fallback).
79
+ - **Other** models (gateway, on-prem, local): **$0** until you set a price in Settings.
71
80
 
72
81
  ## Files
73
82
 
74
- | Path | |
75
- |---|---|
76
- | `~/.burner/config.yaml` | projects, categories, tagging level, theme |
77
- | `~/.burner/pricing.yaml` | your price overrides (USD / 1M tokens) |
83
+ | Path | Purpose |
84
+ |------|---------|
85
+ | `~/.burner/config.yaml` | projects, categories, tagging, UI theme/range |
86
+ | `~/.burner/pricing.yaml` | your USD/1M overrides |
78
87
  | `~/.burner/tags.json` | session tags |
79
- | `~/.burner/model/intent-nb.json` | learned model |
80
- | `~/.burner/cache/litellm-pricing.json` | price cache |
81
- | `~/.claude/skills/burner-tag/` | the global skill (`burner install`) |
88
+ | `~/.burner/assignments.json` | per-session project overrides |
89
+ | `~/.burner/model/intent-nb.json` | your trained intent model |
90
+ | `~/.claude/skills/burner-tag/` | global skill (`burner install`) |
82
91
 
83
- Old `~/.ti-burner` overrides and custom prices are imported on first run. `BURNER_HOME` and `CLAUDE_CONFIG_DIR` relocate both sides.
92
+ `BURNER_HOME` and `CLAUDE_CONFIG_DIR` override `~/.burner` and `~/.claude`. Legacy `~/.ti-burner` data is migrated on first run.
84
93
 
85
- *not official billing*
94
+ *Not official Anthropic billing.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "burner-cc",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
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": {
package/src/cli.js CHANGED
@@ -25,7 +25,7 @@ const HELP = `
25
25
  burner tag <category> [--session ID] [--reason TEXT] [--task]
26
26
  burner tag --clear [--session ID]
27
27
  burner install install /${SKILL_NAME} globally + apply the tagging level
28
- burner uninstall remove the skill and CLAUDE.md block
28
+ burner uninstall remove the skill and SessionEnd hook
29
29
  burner status
30
30
 
31
31
  Pricing & model
@@ -119,13 +119,13 @@ async function main() {
119
119
  if (cmd === 'install') {
120
120
  const st = applyTagging(loadConfig());
121
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 || '?'})`);
122
+ if (st.legacyClaudeMd) console.log(' legacy CLAUDE.md burner block: still present (run install to strip)');
123
+ if (st.sessionEndHook) console.log(' SessionEnd hook: on (local auto-tag)');
124
124
  return;
125
125
  }
126
126
  if (cmd === 'uninstall') {
127
127
  uninstall();
128
- return console.log(' removed skill and CLAUDE.md block');
128
+ return console.log(' removed skill, hook, and legacy CLAUDE.md block');
129
129
  }
130
130
  if (cmd === 'status') {
131
131
  const cfg = loadConfig();
package/src/config.js CHANGED
@@ -40,9 +40,8 @@ export const UNTAGGED = { id: 'untagged', label: 'Untagged', color: '#a8a29e', d
40
40
  // Tagging levels, lowest to highest — mirrors Claude Code's effort slider.
41
41
  export const TAGGING_LEVELS = [
42
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.' },
43
+ { id: 'manual', label: 'Manual', description: 'Only when you run /burner-tag. Burner keeps the skill installed globally.' },
44
+ { id: 'session', label: 'Session end', description: 'SessionEnd hook tags using your trained local model (and project default). No API calls and no CLAUDE.md edits.' },
46
45
  ];
47
46
 
48
47
  export function defaultConfig() {
@@ -53,7 +52,6 @@ export function defaultConfig() {
53
52
  tagging: {
54
53
  level: 'manual',
55
54
  learned: true,
56
- endModel: '',
57
55
  },
58
56
  ui: { theme: 'paper', range: 'all', customFrom: '', customTo: '' },
59
57
  };
@@ -94,7 +92,8 @@ function normalizeConfig(raw) {
94
92
  cfg.tagging = { ...base.tagging, ...(cfg.tagging ?? {}) };
95
93
  delete cfg.tagging.auto;
96
94
  if (cfg.tagging.level === 'auto') cfg.tagging.level = 'manual';
97
- cfg.tagging.endModel = String(cfg.tagging.endModel ?? '').trim();
95
+ if (cfg.tagging.level === 'task') cfg.tagging.level = 'manual';
96
+ delete cfg.tagging.endModel;
98
97
  const ui = cfg.ui ?? {};
99
98
  cfg.ui = {
100
99
  theme: THEMES.includes(ui.theme) ? ui.theme : 'paper',
package/src/install.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // Wires burner into Claude Code globally (~/.claude), never per-repo:
2
2
  // skills/<SKILL_NAME>/SKILL.md — the tag skill, available in every project
3
- // CLAUDE.md block — "Session end" / "Every task" levels tell Claude to use it
3
+ // SessionEnd hook — session level: local auto-tag (no API, no CLAUDE.md)
4
4
 
5
5
  import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
6
6
  import path from 'node:path';
7
+ import process from 'node:process';
7
8
  import { fileURLToPath } from 'node:url';
8
9
  import { CLAUDE_HOME } from './config.js';
9
10
 
@@ -51,7 +52,7 @@ export function skillSetupPrompt() {
51
52
  return `Set up burner session tagging on this machine (global, not per-repo):
52
53
  1. Run in a terminal: ${cmd}${dev}
53
54
  (After npm i -g burner-cc, the same command is just: burner install)
54
- 2. That writes ${path.join(home, 'skills', SKILL_NAME, 'SKILL.md')} and applies the tagging level from ~/.burner/config.yaml (CLAUDE.md block and/or SessionEnd hook).
55
+ 2. That writes ${path.join(home, 'skills', SKILL_NAME, 'SKILL.md')} and applies the tagging level from ~/.burner/config.yaml (SessionEnd hook when level is session).
55
56
  3. Re-run install after you change categories or tagging level on the Settings page.
56
57
  Do not edit project repos for this — only files under ${home}.`;
57
58
  }
@@ -60,7 +61,7 @@ export function skillMarkdown(cfg) {
60
61
  const cats = cfg.categories.map((c) => `- \`${c.id}\` — ${c.description || c.label}`).join('\n');
61
62
  return `---
62
63
  name: ${SKILL_NAME}
63
- description: Tag the current Claude Code session with what it was used for (coding, debugging, planning, research, writing, …) for burner usage analytics. Use when the user runs /${SKILL_NAME} or asks to tag/categorize this session, and when the burner block in CLAUDE.md asks you to tag at the end of a session or task.
64
+ description: Tag the current Claude Code session with what it was used for (coding, debugging, planning, research, writing, …) for burner usage analytics. Use when the user runs /${SKILL_NAME} or asks to tag/categorize this session.
64
65
  ---
65
66
 
66
67
  # ${SKILL_NAME}
@@ -81,22 +82,6 @@ ${burnerShell('tag')} <category> --session "\${CLAUDE_SESSION_ID}" --reason "<un
81
82
  `;
82
83
  }
83
84
 
84
- function claudeMdBlock(level) {
85
- if (level === 'session') {
86
- return `${START}
87
- ## burner
88
- When you've finished the work the user asked for in this session — before your final summary — use the \`${SKILL_NAME}\` skill once to tag the session. Tag again only if the session's main purpose changed. Keep it silent.
89
- ${END}`;
90
- }
91
- if (level === 'task') {
92
- return `${START}
93
- ## burner
94
- Each time you finish a distinct task the user asked for, use the \`${SKILL_NAME}\` skill with \`--task\` to tag that task before replying. One tag per task, silent.
95
- ${END}`;
96
- }
97
- return null;
98
- }
99
-
100
85
  function readJsonFile(file) {
101
86
  if (!existsSync(file)) return {};
102
87
  try {
@@ -106,19 +91,36 @@ function readJsonFile(file) {
106
91
  }
107
92
  }
108
93
 
94
+ /** Claude Code command hook (exec form): node + burner.js, no shell/PATH/.cmd shims. */
95
+ export function sessionEndHookEntry() {
96
+ return {
97
+ type: 'command',
98
+ command: process.execPath,
99
+ args: [BIN, 'hook', 'session-end'],
100
+ timeout: 60,
101
+ };
102
+ }
103
+
109
104
  function isOurHook(h) {
110
- return typeof h?.command === 'string' && h.command.includes('burner') && h.command.includes('hook session-end');
105
+ if (h?.type !== 'command') return false;
106
+ const args = h.args;
107
+ if (Array.isArray(args) && args.includes('hook') && args[args.length - 1] === 'session-end') return true;
108
+ return typeof h.command === 'string' && h.command.includes('hook session-end');
111
109
  }
112
110
 
113
111
  function ensureSessionEndHook() {
114
112
  const { settings: file } = paths();
115
113
  let settings;
116
114
  try { settings = readJsonFile(file); } catch { settings = {}; }
117
- const cmd = burnerShell('hook session-end');
118
- const groups = [...(settings.hooks?.SessionEnd ?? [])];
119
- if (groups.some((g) => (g.hooks ?? []).some(isOurHook))) return;
120
- groups.push({ hooks: [{ type: 'command', command: cmd }] });
121
- settings.hooks = { ...(settings.hooks ?? {}), SessionEnd: groups };
115
+ const entry = sessionEndHookEntry();
116
+ const hooks = settings.hooks ?? {};
117
+ const before = JSON.stringify(settings);
118
+ let groups = (hooks.SessionEnd ?? [])
119
+ .map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isOurHook(h)) }))
120
+ .filter((g) => g.hooks.length);
121
+ groups.push({ hooks: [entry] });
122
+ settings.hooks = { ...hooks, SessionEnd: groups };
123
+ if (JSON.stringify(settings) === before) return;
122
124
  mkdirSync(path.dirname(file), { recursive: true });
123
125
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
124
126
  }
@@ -142,18 +144,15 @@ function removeSessionEndHook() {
142
144
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
143
145
  }
144
146
 
145
- function setClaudeMd(level) {
147
+ /** Remove legacy burner blocks from ~/.claude/CLAUDE.md (we no longer install them). */
148
+ export function stripBurnerClaudeMd() {
146
149
  const { claudeMd } = paths();
147
- const current = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf-8') : '';
150
+ if (!existsSync(claudeMd)) return;
151
+ const current = readFileSync(claudeMd, 'utf-8');
148
152
  const re = new RegExp(`\\n?${START}[\\s\\S]*?${END}\\n?`, 'g');
149
- let next = current.replace(re, '\n').replace(/\s+$/, '');
150
- const block = claudeMdBlock(level);
151
- if (block) next = (next ? next + '\n\n' : '') + block;
152
- next = next ? next + '\n' : '';
153
+ const next = current.replace(re, '\n').replace(/\s+$/, '');
153
154
  if (next.trim() === current.trim()) return;
154
- if (!next.trim() && !existsSync(claudeMd)) return;
155
- mkdirSync(path.dirname(claudeMd), { recursive: true });
156
- writeFileSync(claudeMd, next, 'utf-8');
155
+ writeFileSync(claudeMd, next ? next + '\n' : '', 'utf-8');
157
156
  }
158
157
 
159
158
  export function installSkill(cfg) {
@@ -166,40 +165,34 @@ export function installSkill(cfg) {
166
165
  // Applies the tagging level from config to ~/.claude. Idempotent.
167
166
  export function applyTagging(cfg) {
168
167
  const level = cfg.tagging?.level ?? 'manual';
169
- const endModel = String(cfg.tagging?.endModel ?? '').trim();
170
168
  if (level === 'off') {
171
169
  const p = paths();
172
170
  if (existsSync(p.skillDir)) rmSync(p.skillDir, { recursive: true, force: true });
173
171
  } else {
174
172
  installSkill(cfg);
175
173
  }
174
+ stripBurnerClaudeMd();
176
175
  removeSessionEndHook();
177
- if (level === 'session' && endModel) {
178
- ensureSessionEndHook();
179
- setClaudeMd('off');
180
- } else {
181
- setClaudeMd(level === 'off' ? 'off' : level);
182
- }
176
+ if (level === 'session') ensureSessionEndHook();
183
177
  return installStatus(cfg);
184
178
  }
185
179
 
186
180
  export function uninstall() {
187
181
  const p = paths();
188
182
  if (existsSync(p.skillDir)) rmSync(p.skillDir, { recursive: true, force: true });
189
- setClaudeMd('off');
183
+ stripBurnerClaudeMd();
190
184
  removeSessionEndHook();
191
185
  }
192
186
 
193
187
  export function installStatus(cfg = null) {
194
188
  const p = paths();
195
- const md = existsSync(p.claudeMd) ? readFileSync(p.claudeMd, 'utf-8') : '';
196
- const block = md.match(new RegExp(`${START}[\\s\\S]*?${END}`))?.[0] ?? '';
197
189
  let hookInstalled = false;
198
190
  try {
199
191
  const settings = readJsonFile(p.settings);
200
192
  hookInstalled = (settings.hooks?.SessionEnd ?? []).some((g) => (g.hooks ?? []).some(isOurHook));
201
193
  } catch { /* no settings */ }
202
- const endModel = String(cfg?.tagging?.endModel ?? '').trim();
194
+ const md = existsSync(p.claudeMd) ? readFileSync(p.claudeMd, 'utf-8') : '';
195
+ const legacyClaudeMd = !!md.match(new RegExp(`${START}[\\s\\S]*?${END}`));
203
196
  return {
204
197
  skillName: SKILL_NAME,
205
198
  skillInstalled: existsSync(p.skillFile),
@@ -207,9 +200,8 @@ export function installStatus(cfg = null) {
207
200
  skillInstallCmd: burnerShell('install'),
208
201
  skillInstallCmdNpm: `${NPM_CLI} install`,
209
202
  devCheckout: isDevCheckout(),
210
- claudeMdLevel: block.includes('--task') ? 'task' : block ? 'session' : null,
203
+ legacyClaudeMd,
211
204
  sessionEndHook: hookInstalled,
212
- endModel,
213
205
  claudeHome: CLAUDE_HOME,
214
206
  };
215
207
  }
package/src/receipt.js CHANGED
@@ -62,7 +62,7 @@ const TAGLINES = [
62
62
  'burned so you don\'t have to guess',
63
63
  'classified and costed',
64
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' };
65
+ const SOURCE_LABEL = { skill: 'TAGGED BY CLAUDE', manual: 'TAGGED BY YOU', 'end-model': 'SESSION-END (legacy)', 'session-end': 'SESSION-END AUTO', learned: 'LEARNED GUESS', project: 'PROJECT DEFAULT', none: 'NOT TAGGED' };
66
66
 
67
67
  function money(n, unpriced) {
68
68
  if (unpriced) return '—';
package/src/report.js CHANGED
@@ -596,7 +596,7 @@ ${logoTextUri ? `<img class="page-brand" src="${logoTextUri}" alt="Burner" />` :
596
596
  <h3>Tokens</h3>
597
597
  <p>Claude Code logs one API response as several transcript lines (one per streamed content block) with growing <code>output_tokens</code>. Burner keeps only the final line per request ID, and drops requests a continued session re-logs from its parent. Totals include all four billed buckets: fresh input, cache write, cache read, output. Subagent transcripts are billed to their parent session.</p>
598
598
  <h3>Categories</h3>
599
- <p>A session's category comes from, in order: a tag (<code>/burner-tag</code>, the report UI, session-end model hook, or CLI), its project's default category, then the <strong>learned model</strong> if trained and confident (≥60%). Otherwise it stays <em>untagged</em>.</p>
599
+ <p>A session's category comes from, in order: a tag (<code>/burner-tag</code>, the report UI, SessionEnd auto-tag, or CLI), its project's default category, then the <strong>learned model</strong> if trained and confident (≥60%). Otherwise it stays <em>untagged</em>.</p>
600
600
  <p><strong>Learned model</strong> — optional on-device classifier (multinomial naive Bayes) trained on your tagged sessions; guesses only when nothing else applied. <strong>/burner-tag skill</strong> — global skill under <code>~/.claude/skills/burner-tag</code>; run <code>burner install</code> (Settings → Tagging has copy blocks). <strong>Session end</strong> — with a model id on Settings, a SessionEnd hook asks that Anthropic model what the session was for; without a model id, the main agent tags via the skill before wrapping up.</p>
601
601
  <p>Token totals follow the same idea as <a href="https://ccusage.com/guide/cost-modes" target="_blank" rel="noopener">ccusage</a>: one row per <code>requestId</code> (last streamed chunk), 5m/1h cache write split when present, global dedup for continued sessions. Burner always calculates from tokens (like ccusage <code>--mode calculate</code>); Claude Code's <code>cost-state</code> line is shown on the receipt for comparison when available.</p>
602
602
  <h3>Projects</h3>
@@ -598,9 +598,8 @@ function taggingSetupPills(level, ins, tagging, learned){
598
598
  pills.appendChild(h('span', {class:'badge dimmed', text:'skill removed'}));
599
599
  } else {
600
600
  pills.appendChild(h('span', {class:'badge' + (ins.skillInstalled ? ' ok' : ''), text:'/' + (ins.skillName || 'burner-tag') + (ins.skillInstalled ? ' \u2713' : ' \u2717')}));
601
- var md = ins.claudeMdLevel ? ('CLAUDE.md ' + ins.claudeMdLevel) : 'CLAUDE.md off';
602
- if (level === 'session' && ins.sessionEndHook) md = 'SessionEnd hook \u2713';
603
- pills.appendChild(h('span', {class:'badge' + ((ins.claudeMdLevel || ins.sessionEndHook) ? ' ok' : ' dimmed'), text: md}));
601
+ var hookLbl = level === 'session' ? (ins.sessionEndHook ? 'SessionEnd hook \u2713' : 'SessionEnd hook \u2717') : 'SessionEnd off';
602
+ pills.appendChild(h('span', {class:'badge' + (level === 'session' && ins.sessionEndHook ? ' ok' : (level === 'session' ? '' : ' dimmed')), text: hookLbl}));
604
603
  }
605
604
  pills.appendChild(h('span', {class:'dim small', style:'margin-left:4px', text:'|'}));
606
605
  var L = learned;
@@ -633,7 +632,6 @@ function rTagging(){
633
632
  var lv = levels[i].id;
634
633
  desc.textContent = levels[i].description;
635
634
  Array.prototype.forEach.call(ticks.children, function(s, j){ s.className = j === i ? 'sel' : ''; });
636
- sessionCard.style.display = lv === 'session' ? '' : 'none';
637
635
  }
638
636
  slider.addEventListener('input', function(){ syncLevelUi(Number(slider.value)); });
639
637
  slider.addEventListener('change', function(){
@@ -647,28 +645,20 @@ function rTagging(){
647
645
  levelCard.appendChild(setupPills);
648
646
  wrap.appendChild(levelCard);
649
647
 
650
- var sessionCard = h('div', {class:'card', style: t.level === 'session' ? '' : 'display:none'});
651
- sessionCard.appendChild(h('h3', {text:'Session-end chat model (optional)'}));
652
- sessionCard.appendChild(h('p', {class:'note', text:'At Session end level: set an Anthropic model id to tag via a SessionEnd hook, or leave blank to have the main agent tag with /burner-tag before wrapping up.'}));
653
- var endIn = h('input', {type:'text', placeholder:'e.g. claude-haiku-4-5', value: t.endModel || ''});
654
- endIn.addEventListener('change', function(){
655
- save(api('PUT', '/api/config', {tagging:{endModel: endIn.value.trim()}}), 'Claude setup updated');
656
- });
657
- var endField = h('div', {class:'field'});
658
- endField.appendChild(h('label', {text:'Model id'}));
659
- endField.appendChild(endIn);
660
- sessionCard.appendChild(endField);
661
- sessionCard.appendChild(h('p', {class:'dim small', style:'margin-top:8px', text: ins.sessionEndHook ? 'Hook installed' + (ins.endModel ? ' for ' + ins.endModel : '') + ' · needs ANTHROPIC_API_KEY' : 'Saving a model id installs the SessionEnd hook automatically.'}));
662
- wrap.appendChild(sessionCard);
648
+ var sessionNote = h('div', {class:'card'});
649
+ sessionNote.appendChild(h('h3', {text:'Session end auto-tag'}));
650
+ sessionNote.appendChild(h('p', {class:'note', text:'Uses your trained local model (train below) and project defaults. Runs in the SessionEnd hook — no Anthropic API, no edits to CLAUDE.md.'}));
651
+ sessionNote.appendChild(h('p', {class:'dim small', text: ins.sessionEndHook ? 'Hook installed in ~/.claude/settings.json' : 'Move the slider to Session end to install the hook.'}));
652
+ wrap.appendChild(sessionNote);
663
653
 
664
654
  var skillCard = h('div', {class:'card'});
665
655
  skillCard.appendChild(h('h3', {text:'Troubleshooting / manual install'}));
666
- skillCard.appendChild(h('p', {class:'note', text:'Moving the tagging slider or saving session-end model updates ~/.claude for you. Use this only if /burner-tag is missing or hooks look wrong.'}));
656
+ skillCard.appendChild(h('p', {class:'note', text:'Moving the tagging slider updates ~/.claude for you. Use this only if /burner-tag is missing or hooks look wrong.'}));
667
657
  skillCard.appendChild(h('div', {class:'card-section', style:'margin-top:0;padding-top:0;border:none'}, [
668
658
  h('h4', {text:'Status'}),
669
659
  h('div', {text:'/' + ins.skillName + ' ' + (ins.skillInstalled ? '\u2713' : '\u2717')}),
670
660
  ins.skillPath ? h('div', {class:'dim small', text:ins.skillPath}) : null,
671
- h('div', {class:'dim small', text:'CLAUDE.md: ' + (ins.claudeMdLevel || 'none')})]));
661
+ ins.legacyClaudeMd ? h('div', {class:'dim small', text:'Legacy CLAUDE.md burner block still present — run Sync to remove'}) : null]));
672
662
  var installCmd = ins.skillInstallCmdNpm || 'burner install';
673
663
  if (ins.devCheckout && ins.skillInstallCmd && ins.skillInstallCmd !== installCmd) {
674
664
  installCmd = installCmd + '\\n' + ins.skillInstallCmd;
package/src/tag-end.js CHANGED
@@ -1,11 +1,18 @@
1
- // SessionEnd hook: ask a small/fast model what the session was for, then tag it.
1
+ // SessionEnd hook: tag from local signals only (learned model, project default). No API calls.
2
2
 
3
- import { readFileSync } from 'node:fs';
4
3
  import { discoverSessions } from './discover.js';
5
4
  import { parseTranscript } from './parse.js';
6
- import { loadConfig } from './config.js';
5
+ import { loadConfig, UNTAGGED } from './config.js';
7
6
  import { loadTags, setTag, resolveSessionProject } from './tags.js';
8
- import { allowedCategories, buildDigest } from './classifier.js';
7
+ import {
8
+ allowedCategories,
9
+ buildDigest,
10
+ loadLearned,
11
+ predictLearned,
12
+ resolveCategory,
13
+ } from './classifier.js';
14
+
15
+ const SESSION_END_CONFIDENCE = 0.45;
9
16
 
10
17
  export async function readHookPayload() {
11
18
  try {
@@ -19,53 +26,11 @@ export async function readHookPayload() {
19
26
  }
20
27
  }
21
28
 
22
- function pickCategory(text, allowed) {
23
- const t = String(text ?? '').toLowerCase();
24
- for (const id of allowed) {
25
- if (t === id || t.includes(id)) return id;
26
- }
27
- const word = t.match(/[a-z][a-z0-9_-]*/)?.[0];
28
- if (word && allowed.includes(word)) return word;
29
- return null;
30
- }
31
-
32
- async function classifyWithModel(digest, categories, model) {
33
- const key = process.env.ANTHROPIC_API_KEY;
34
- if (!key) throw new Error('ANTHROPIC_API_KEY is not set (needed for session-end tagging)');
35
- const lines = categories.map((c) => `${c.id}: ${c.description || c.label}`).join('\n');
36
- const prompts = (digest.prompts ?? []).slice(0, 6).map((p, i) => `${i + 1}. ${p}`).join('\n');
37
- const tools = (digest.tools ?? []).slice(0, 8).map(([s, n]) => `${s} x${n}`).join(', ');
38
- const user = `Pick exactly one category id for this Claude Code session.
39
-
40
- Categories (reply with only the id):
41
- ${lines}
42
-
43
- Session title: ${digest.title ?? '—'}
44
- Project: ${digest.project ?? '—'}
45
- User prompts:
46
- ${prompts || '(none)'}
47
- Tools used: ${tools || '(none)'}`;
48
-
49
- const res = await fetch('https://api.anthropic.com/v1/messages', {
50
- method: 'POST',
51
- headers: {
52
- 'x-api-key': key,
53
- 'anthropic-version': '2023-06-01',
54
- 'content-type': 'application/json',
55
- },
56
- body: JSON.stringify({ model, max_tokens: 40, messages: [{ role: 'user', content: user }] }),
57
- signal: AbortSignal.timeout(45_000),
58
- });
59
- if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${(await res.text()).slice(0, 300)}`);
60
- const data = await res.json();
61
- const text = data.content?.find((b) => b.type === 'text')?.text ?? '';
62
- return text.trim();
63
- }
64
-
65
- export async function tagSessionAtEnd({ sessionId, model } = {}) {
29
+ export async function tagSessionAtEnd({ sessionId } = {}) {
66
30
  const cfg = loadConfig();
67
- const endModel = (model ?? cfg.tagging?.endModel ?? '').trim();
68
- if (!endModel) return { ok: false, skipped: true, reason: 'no endModel configured' };
31
+ if ((cfg.tagging?.level ?? 'manual') !== 'session') {
32
+ return { ok: false, skipped: true, reason: 'tagging level is not session' };
33
+ }
69
34
 
70
35
  const id = sessionId ?? process.env.CLAUDE_SESSION_ID;
71
36
  if (!id || id.includes('${')) return { ok: false, skipped: true, reason: 'no session id' };
@@ -84,11 +49,25 @@ export async function tagSessionAtEnd({ sessionId, model } = {}) {
84
49
  const allowed = allowedCategories(project, cfg);
85
50
  const allowedIds = allowed.map((c) => c.id);
86
51
  const digest = buildDigest(parsed, { title: s.customTitle ?? parsed.digest.aiTitle, projectName: project.name });
52
+ const learned = loadLearned();
87
53
 
88
- const answer = await classifyWithModel(digest, allowed, endModel);
89
- const category = pickCategory(answer.split(/\r?\n/)[0], allowedIds);
90
- if (!category) return { ok: false, error: `model answer not a category: ${answer.slice(0, 80)}` };
54
+ let resolved = resolveCategory({ tag: existing, project, cfg, digest, learned });
55
+ if (resolved.category === UNTAGGED.id && learned) {
56
+ const p = predictLearned(learned, digest, allowedIds);
57
+ if (p && p.confidence >= SESSION_END_CONFIDENCE) {
58
+ resolved = { category: p.category, source: 'session-end', confidence: p.confidence };
59
+ }
60
+ }
91
61
 
92
- setTag(s.sessionId, { category, source: 'end-model', reason: answer.slice(0, 80) });
93
- return { ok: true, sessionId: s.sessionId, category, model: endModel };
62
+ if (resolved.category === UNTAGGED.id || resolved.source === 'none') {
63
+ return { ok: true, skipped: true, reason: 'no confident category' };
64
+ }
65
+
66
+ setTag(s.sessionId, {
67
+ category: resolved.category,
68
+ source: 'session-end',
69
+ reason: resolved.source === 'project' ? 'project default' : `auto (${resolved.source})`,
70
+ confidence: resolved.confidence ?? null,
71
+ });
72
+ return { ok: true, sessionId: s.sessionId, category: resolved.category, source: resolved.source };
94
73
  }