burner-cc 0.3.1 → 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
@@ -29,7 +29,7 @@ burner serve # same; --port N, --no-open
29
29
  burner install # global /burner-tag skill + tagging level from ~/.burner/config.yaml
30
30
  ```
31
31
 
32
- Settings covers **projects**, **categories**, **pricing** (pin/refresh per model), and **tagging** (slider, session-end model, train your intent model).
32
+ Settings covers **projects**, **categories**, **pricing** (pin/refresh per model), and **tagging** (slider, train your intent model).
33
33
 
34
34
  ## Commands
35
35
 
@@ -67,10 +67,9 @@ Resolution order:
67
67
  |--------|----------|
68
68
  | Off | Removes global skill / hooks |
69
69
  | Manual | `/burner-tag` only; skill stays installed |
70
- | Session end | SessionEnd hook + optional Anthropic model id, or CLAUDE.md asks Claude to tag via `/burner-tag` |
71
- | Every task | CLAUDE.md asks Claude to tag each task (`--task`) |
70
+ | Session end | SessionEnd hook tags with your **local** trained model (no API, no CLAUDE.md) |
72
71
 
73
- Moving the slider (or saving session-end model) updates `~/.claude` automatically. Use **Train from my tags** in Settings for a local naive-Bayes model (`~/.burner/model/intent-nb.json`). Training is pure Node — no GPU or Python. CLI `burner model train` does the same; `--prior` / `--swe-chat` are optional extras.
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.
74
73
 
75
74
  ## Tokens & pricing
76
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "burner-cc",
3
- "version": "0.3.1",
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
  }