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.
@@ -0,0 +1,320 @@
1
+ {
2
+ "trainedAt": "2026-10-25T10:33:58.921Z",
3
+ "recommended": false,
4
+ "datasets": [
5
+ {
6
+ "id": "dolly",
7
+ "name": "databricks/databricks-dolly-15k",
8
+ "license": "CC-BY-SA-3.0",
9
+ "url": "https://huggingface.co/datasets/databricks/databricks-dolly-15k",
10
+ "examplesUsed": 2809
11
+ },
12
+ {
13
+ "id": "no_robots",
14
+ "name": "HuggingFaceH4/no_robots",
15
+ "license": "CC-BY-NC-4.0",
16
+ "url": "https://huggingface.co/datasets/HuggingFaceH4/no_robots",
17
+ "examplesUsed": 2253
18
+ },
19
+ {
20
+ "id": "code_instructions",
21
+ "name": "iamtarun/python_code_instructions_18k_alpaca",
22
+ "license": "See dataset card (instruction-tuning; widely redistributed)",
23
+ "url": "https://huggingface.co/datasets/iamtarun/python_code_instructions_18k_alpaca",
24
+ "examplesUsed": 971
25
+ },
26
+ {
27
+ "id": "swe_bench",
28
+ "name": "princeton-nlp/SWE-bench",
29
+ "license": "MIT (per SWE-bench repository)",
30
+ "url": "https://huggingface.co/datasets/princeton-nlp/SWE-bench",
31
+ "examplesUsed": 1000
32
+ },
33
+ {
34
+ "id": "sql_context",
35
+ "name": "b-mc2/sql-create-context",
36
+ "license": "See dataset card (Spider-derived text-to-SQL context)",
37
+ "url": "https://huggingface.co/datasets/b-mc2/sql-create-context",
38
+ "examplesUsed": 762
39
+ },
40
+ {
41
+ "id": "github_codereview",
42
+ "name": "ronantakizawa/github-codereview",
43
+ "license": "Permissive (source repos MIT/Apache/BSD per dataset card)",
44
+ "url": "https://huggingface.co/datasets/ronantakizawa/github-codereview",
45
+ "examplesUsed": 1000
46
+ }
47
+ ],
48
+ "rejectedDatasets": [
49
+ {
50
+ "name": "lmsys/lmsys-chat-1m",
51
+ "reason": "Not accessible via public datasets-server (gated/private)."
52
+ },
53
+ {
54
+ "name": "irfanalee/github-code-review-full",
55
+ "reason": "No parquet config on datasets-server; raw JSONL paths not available without LFS pull."
56
+ },
57
+ {
58
+ "name": "allenai/WildChat",
59
+ "reason": "Ungated download works but has no intent/task labels suitable for honest mapping."
60
+ }
61
+ ],
62
+ "labelMappings": {
63
+ "dolly": {
64
+ "brainstorming": "planning",
65
+ "summarization": "writing",
66
+ "creative_writing": "writing",
67
+ "classification": "analysis",
68
+ "information_extraction": "research",
69
+ "open_qa": "learning",
70
+ "closed_qa": "learning",
71
+ "general_qa": "learning"
72
+ },
73
+ "no_robots": {
74
+ "Coding": "coding",
75
+ "Brainstorm": "planning",
76
+ "Summarize": "writing",
77
+ "Generation": "writing",
78
+ "Rewrite": "writing",
79
+ "Open QA": "learning",
80
+ "Closed QA": "learning",
81
+ "Extract": "research",
82
+ "Classify": "analysis",
83
+ "Chat": "other"
84
+ },
85
+ "code_instructions": {
86
+ "code_instruction": "coding"
87
+ },
88
+ "swe_bench": {
89
+ "bug_report": "debugging"
90
+ },
91
+ "sql_context": {
92
+ "text_to_sql": "analysis"
93
+ },
94
+ "github_codereview": {
95
+ "code_review": "review"
96
+ }
97
+ },
98
+ "training": {
99
+ "seed": 42,
100
+ "capPerClass": 1000,
101
+ "trainExamples": 7036,
102
+ "testExamples": 1759,
103
+ "minFeatureCount": 2,
104
+ "topNPerClass": 600
105
+ },
106
+ "examplesPerClass": {
107
+ "other": 795,
108
+ "coding": 1000,
109
+ "debugging": 1000,
110
+ "planning": 1000,
111
+ "writing": 1000,
112
+ "review": 1000,
113
+ "research": 1000,
114
+ "learning": 1000,
115
+ "analysis": 1000
116
+ },
117
+ "evaluation": {
118
+ "holdout": {
119
+ "accuracy": 0.6646,
120
+ "macroF1": 0.6315,
121
+ "perClass": {
122
+ "analysis": {
123
+ "precision": 0.9551,
124
+ "recall": 0.85,
125
+ "f1": 0.8995,
126
+ "support": 200
127
+ },
128
+ "coding": {
129
+ "precision": 0.8117,
130
+ "recall": 0.97,
131
+ "f1": 0.8838,
132
+ "support": 200
133
+ },
134
+ "debugging": {
135
+ "precision": 0.9879,
136
+ "recall": 0.815,
137
+ "f1": 0.8932,
138
+ "support": 200
139
+ },
140
+ "learning": {
141
+ "precision": 0.2984,
142
+ "recall": 0.37,
143
+ "f1": 0.3304,
144
+ "support": 200
145
+ },
146
+ "other": {
147
+ "precision": 0.5502,
148
+ "recall": 1,
149
+ "f1": 0.7098,
150
+ "support": 159
151
+ },
152
+ "planning": {
153
+ "precision": 0.4663,
154
+ "recall": 0.9,
155
+ "f1": 0.6143,
156
+ "support": 200
157
+ },
158
+ "research": {
159
+ "precision": 0.3333,
160
+ "recall": 0.01,
161
+ "f1": 0.0194,
162
+ "support": 200
163
+ },
164
+ "review": {
165
+ "precision": 0.9766,
166
+ "recall": 0.835,
167
+ "f1": 0.9003,
168
+ "support": 200
169
+ },
170
+ "writing": {
171
+ "precision": 0.7792,
172
+ "recall": 0.3,
173
+ "f1": 0.4332,
174
+ "support": 200
175
+ }
176
+ },
177
+ "majorityBaseline": 0.1137,
178
+ "majorityClass": "debugging",
179
+ "evaluated": 1759,
180
+ "confidentAccuracy": 0.708,
181
+ "confidentCoverage": 0.8977,
182
+ "confidentThreshold": 0.6
183
+ },
184
+ "crossDataset": {
185
+ "perDataset": {
186
+ "dolly": {
187
+ "skipped": false,
188
+ "testExamples": 2809,
189
+ "overlappingCategories": [
190
+ "writing",
191
+ "research",
192
+ "learning",
193
+ "planning",
194
+ "analysis"
195
+ ],
196
+ "accuracy": 0.3407,
197
+ "macroF1": 0.2643,
198
+ "majorityBaseline": 0.3158
199
+ },
200
+ "no_robots": {
201
+ "skipped": false,
202
+ "testExamples": 2253,
203
+ "overlappingCategories": [
204
+ "planning",
205
+ "writing",
206
+ "learning",
207
+ "research",
208
+ "coding",
209
+ "analysis"
210
+ ],
211
+ "accuracy": 0.2166,
212
+ "macroF1": 0.1463,
213
+ "majorityBaseline": 0.3529
214
+ },
215
+ "code_instructions": {
216
+ "skipped": true,
217
+ "reason": "fewer than 2 overlapping categories"
218
+ },
219
+ "swe_bench": {
220
+ "skipped": true,
221
+ "reason": "fewer than 2 overlapping categories"
222
+ },
223
+ "sql_context": {
224
+ "skipped": true,
225
+ "reason": "fewer than 2 overlapping categories"
226
+ },
227
+ "github_codereview": {
228
+ "skipped": true,
229
+ "reason": "fewer than 2 overlapping categories"
230
+ }
231
+ },
232
+ "avgMacroF1": 0.2053,
233
+ "avgAccuracy": 0.2787,
234
+ "datasetsScored": 2
235
+ }
236
+ },
237
+ "sanity": [
238
+ {
239
+ "prompt": "the login page crashes when I click submit",
240
+ "category": "coding",
241
+ "confidence": 0.738
242
+ },
243
+ {
244
+ "prompt": "write a PRD for onboarding new users",
245
+ "category": "coding",
246
+ "confidence": 0.924
247
+ },
248
+ {
249
+ "prompt": "summarize this Q3 sales spreadsheet into bullet points for leadership",
250
+ "category": "planning",
251
+ "confidence": 0.586
252
+ },
253
+ {
254
+ "prompt": "what does this function do? explain like I am new to the codebase",
255
+ "category": "planning",
256
+ "confidence": 0.902
257
+ },
258
+ {
259
+ "prompt": "review my PR for security issues before I merge",
260
+ "category": "review",
261
+ "confidence": 0.99
262
+ },
263
+ {
264
+ "prompt": "add a dark mode toggle to the settings page using our existing theme tokens",
265
+ "category": "review",
266
+ "confidence": 0.844
267
+ },
268
+ {
269
+ "prompt": "why is my Jest test failing with \"cannot read property of undefined\"",
270
+ "category": "review",
271
+ "confidence": 0.349
272
+ },
273
+ {
274
+ "prompt": "compare Postgres vs SQLite for our side project and recommend one",
275
+ "category": "learning",
276
+ "confidence": 0.472
277
+ },
278
+ {
279
+ "prompt": "draft an email to the customer apologizing for the outage",
280
+ "category": "coding",
281
+ "confidence": 0.605
282
+ },
283
+ {
284
+ "prompt": "plot month-over-month revenue from this CSV and call out anomalies",
285
+ "category": "coding",
286
+ "confidence": 0.704
287
+ },
288
+ {
289
+ "prompt": "break the epic into user stories with acceptance criteria",
290
+ "category": "other",
291
+ "confidence": 0.992
292
+ },
293
+ {
294
+ "prompt": "find where we authenticate API keys in this repo",
295
+ "category": "coding",
296
+ "confidence": 0.98
297
+ },
298
+ {
299
+ "prompt": "help me understand what a binary search tree is",
300
+ "category": "writing",
301
+ "confidence": 0.853
302
+ },
303
+ {
304
+ "prompt": "refactor this React component to use hooks instead of a class",
305
+ "category": "review",
306
+ "confidence": 0.9
307
+ },
308
+ {
309
+ "prompt": "check my migration script for data loss risks",
310
+ "category": "coding",
311
+ "confidence": 0.995
312
+ },
313
+ {
314
+ "prompt": "write release notes for version 2.4 from these commit messages",
315
+ "category": "debugging",
316
+ "confidence": 0.78
317
+ }
318
+ ],
319
+ "limitations": "Labels come from general instruction-tuning corpora (Q&A, summarization, coding exercises, bug reports, SQL questions, code-review diffs), not from Claude Code session logs. Categories such as planning and debugging are represented by narrower proxies (brainstorming prompts, SWE-bench issue text). Expect confusion between writing vs research, coding vs debugging, and analysis vs research on short prompts. The model should be treated as a weak default prior until a project trains on its own tagged sessions."
320
+ }
package/src/parse.js CHANGED
@@ -1,20 +1,108 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
 
3
- // Reads one transcript file and returns a flat list of billed turns:
4
- // { model, timestamp, input, output, cacheWrite5m, cacheWrite1h, cacheRead }
5
- // plus a tool-activity tally: { mcp: {server: count}, skills: {name: count}, web: {WebFetch/WebSearch: count} }
6
- export function parseTranscript(file) {
3
+ // Gateways and local servers are not always tidy: counts can arrive as
4
+ // strings or null, and some use OpenAI-style names. Anything that is not a
5
+ // finite non-negative number counts as 0 so one odd value can never turn a
6
+ // total into NaN or a concatenated string.
7
+ function tok(v) {
8
+ const n = Number(v);
9
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
10
+ }
11
+
12
+ function normalizeUsage(u) {
13
+ const cc = u.cache_creation ?? {};
14
+ const hasSplit = cc.ephemeral_5m_input_tokens != null || cc.ephemeral_1h_input_tokens != null;
15
+ const createdTotal = tok(u.cache_creation_input_tokens);
16
+ return {
17
+ input: tok(u.input_tokens ?? u.prompt_tokens),
18
+ output: tok(u.output_tokens ?? u.completion_tokens),
19
+ cacheWrite5m: hasSplit ? tok(cc.ephemeral_5m_input_tokens) : createdTotal,
20
+ cacheWrite1h: hasSplit ? tok(cc.ephemeral_1h_input_tokens) : 0,
21
+ cacheRead: tok(u.cache_read_input_tokens ?? u.prompt_tokens_details?.cached_tokens),
22
+ };
23
+ }
24
+
25
+ const EMPTY_ACTIVITY = () => ({ mcp: {}, skills: {}, web: {} });
26
+
27
+ function serverDisplayName(slug) {
28
+ return slug.replace(/_/g, ' ').trim();
29
+ }
30
+
31
+ function newBucket() {
32
+ return {
33
+ calls: 0,
34
+ outputTokens: 0,
35
+ contextTokens: 0,
36
+ contextMissing: 0,
37
+ outputByModel: {},
38
+ contextByModel: {},
39
+ via: {},
40
+ };
41
+ }
42
+
43
+ function userText(message) {
44
+ if (typeof message?.content === 'string') return message.content;
45
+ if (!Array.isArray(message?.content)) return '';
46
+ return message.content
47
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
48
+ .map((b) => b.text)
49
+ .join('\n');
50
+ }
51
+
52
+ // Prompts the human actually typed — not tool results, slash-command
53
+ // scaffolding, or the "session continued" summaries Claude Code injects.
54
+ function isHumanPrompt(entry, text) {
55
+ if (entry.isMeta || entry.isCompactSummary || entry.isSidechain) return false;
56
+ if (!text.trim()) return false;
57
+ if (/^<(command-|local-command|system-reminder|bash-|user-memory)/.test(text.trim())) return false;
58
+ if (text.startsWith('This session is being continued')) return false;
59
+ return true;
60
+ }
61
+
62
+ function toolStep(block) {
63
+ const name = block.name ?? 'unknown';
64
+ const input = block.input ?? {};
65
+ if (name === 'Bash' && typeof input.command === 'string') {
66
+ return `Bash(${input.command.trim().split(/\s+/).slice(0, 2).join(' ')})`;
67
+ }
68
+ const file = input.file_path ?? input.notebook_path ?? input.path;
69
+ if (typeof file === 'string') {
70
+ const ext = file.match(/\.([a-z0-9]+)$/i)?.[1];
71
+ return ext ? `${name}(.${ext.toLowerCase()})` : name;
72
+ }
73
+ if (name === 'Skill' && input.skill) return `Skill(${input.skill})`;
74
+ if (name === 'Task' || name === 'Agent') return `${name}(${input.subagent_type ?? 'agent'})`;
75
+ return name.startsWith('mcp__') ? `mcp:${name.split('__')[1]}` : name;
76
+ }
77
+
78
+ // Reads one transcript and returns:
79
+ // turns — one entry per billed API request:
80
+ // { requestId, model, timestamp, input, output, cacheWrite5m, cacheWrite1h, cacheRead }
81
+ // activity — call counts { mcp: {server: n}, skills: {name: n}, web: {tool: n} }
82
+ // mcp/skills/web — per-request cost attribution buckets (see build-receipt.js)
83
+ // digest — what the session was about: cwd, prompts, tool sequence
84
+ // reported — Claude Code's own "cost-state" totals when the transcript has one
85
+ //
86
+ // Claude Code writes one API response as several lines (one per content
87
+ // block, streamed). They share a requestId/message.id; output_tokens grows
88
+ // until the last one, so the final line is the billed usage.
89
+ export function parseTranscript(path) {
90
+ const file = String(path);
7
91
  let text;
8
92
  try {
9
- text = readFileSync(file, 'utf-8');
93
+ text = readFileSync(path, 'utf-8');
10
94
  } catch {
11
- return { turns: [], activity: { mcp: {}, skills: {}, web: {} } };
95
+ return { turns: [], activity: EMPTY_ACTIVITY(), mcp: {}, skills: {}, web: {}, digest: emptyDigest(), reported: null };
12
96
  }
13
97
 
14
- const turns = [];
15
- const mcp = {};
16
- const skills = {};
17
- const web = {};
98
+ const requests = new Map();
99
+ const latest = new Map(); // base id -> newest request carrying it
100
+ const order = [];
101
+ let userCount = 0;
102
+ let splitN = 0;
103
+ const digest = emptyDigest();
104
+ let reported = null;
105
+ let anon = 0;
18
106
 
19
107
  for (const line of text.split('\n')) {
20
108
  if (!line.trim()) continue;
@@ -25,43 +113,119 @@ export function parseTranscript(file) {
25
113
  continue;
26
114
  }
27
115
 
116
+ if (!digest.cwd && typeof entry.cwd === 'string') digest.cwd = entry.cwd;
117
+ if (!digest.gitBranch && entry.gitBranch) digest.gitBranch = entry.gitBranch;
118
+ if (entry.type === 'ai-title' && entry.aiTitle) digest.aiTitle = entry.aiTitle;
119
+ if (entry.type === 'cost-state' && entry.modelUsage) reported = entry;
120
+
121
+ if (entry.type === 'user') userCount += 1;
122
+ if (entry.type === 'user' && entry.message?.role === 'user') {
123
+ const t = userText(entry.message);
124
+ if (isHumanPrompt(entry, t)) {
125
+ digest.prompts.push({ text: t.slice(0, 2000), timestamp: entry.timestamp ?? null });
126
+ }
127
+ continue;
128
+ }
129
+
28
130
  const msg = entry.message;
29
- if (!msg || msg.type !== 'message' || msg.role !== 'assistant') continue;
131
+ if (!msg || msg.role !== 'assistant') continue;
132
+ if (msg.model === '<synthetic>') continue;
30
133
 
31
- // Tool-call activity: tallied regardless of whether this line also
32
- // carries usage, since a message can contain tool_use blocks either way.
134
+ // One API response is streamed as several lines sharing an id. Claude
135
+ // always supplies a unique requestId. A gateway may not: then message.id is
136
+ // all we have, and some servers reuse one constant id for every response,
137
+ // so a tool result in between means the id belongs to a new request.
138
+ const rid = entry.requestId || null;
139
+ const base = rid ?? (msg.id || null);
140
+ let req = base ? latest.get(base) : null;
141
+ if (req && !rid && req.lastUser !== userCount) req = null;
142
+ if (!req) {
143
+ const key = !base ? `${file}#anon${anon++}` : latest.has(base) ? `${file}#${base}#${++splitN}` : base;
144
+ req = { key, model: msg.model ?? 'unknown', timestamp: entry.timestamp ?? null, usage: null, tools: new Map(), skill: null, lastUser: userCount };
145
+ requests.set(key, req);
146
+ order.push(req);
147
+ if (base) latest.set(base, req);
148
+ }
149
+ req.lastUser = userCount;
150
+ if (entry.attributionSkill) req.skill = entry.attributionSkill;
151
+ if (msg.usage && typeof msg.usage === 'object' && (!req.usage || tok(msg.usage.output_tokens ?? msg.usage.completion_tokens) >= tok(req.usage.output_tokens ?? req.usage.completion_tokens))) {
152
+ req.usage = msg.usage;
153
+ req.model = msg.model ?? req.model;
154
+ if (entry.timestamp) req.timestamp = entry.timestamp;
155
+ }
33
156
  if (Array.isArray(msg.content)) {
34
157
  for (const block of msg.content) {
35
158
  if (block.type !== 'tool_use') continue;
36
- if (block.name === 'Skill' && block.input?.skill) {
37
- skills[block.input.skill] = (skills[block.input.skill] ?? 0) + 1;
38
- } else if (block.name.startsWith('mcp__')) {
39
- const server = block.name.split('__')[1] ?? 'unknown';
40
- mcp[server] = (mcp[server] ?? 0) + 1;
41
- } else if (block.name === 'WebFetch' || block.name === 'WebSearch') {
42
- web[block.name] = (web[block.name] ?? 0) + 1;
43
- }
159
+ const id = block.id ?? `${req.key}-${req.tools.size}`;
160
+ if (!req.tools.has(id)) req.tools.set(id, block);
44
161
  }
45
162
  }
163
+ }
46
164
 
47
- if (!msg.usage) continue;
48
- if (msg.model === '<synthetic>') continue; // internal zero-usage marker, not a billed turn
49
-
50
- const u = msg.usage;
51
- const isAllZero = !u.input_tokens && !u.output_tokens && !u.cache_creation_input_tokens && !u.cache_read_input_tokens;
52
- if (isAllZero) continue;
53
- const cacheCreation = u.cache_creation ?? {};
54
- turns.push({
55
- model: msg.model ?? 'unknown',
56
- timestamp: entry.timestamp ?? null,
57
- input: u.input_tokens ?? 0,
58
- output: u.output_tokens ?? 0,
59
- cacheWrite5m: cacheCreation.ephemeral_5m_input_tokens ?? u.cache_creation_input_tokens ?? 0,
60
- cacheWrite1h: cacheCreation.ephemeral_1h_input_tokens ?? 0,
61
- cacheRead: u.cache_read_input_tokens ?? 0,
62
- });
165
+ const turns = [];
166
+ const billed = [];
167
+ for (const req of order) {
168
+ // Some local servers report no usage at all. Keep the request (so the
169
+ // model shows up and can be priced) but mark that its tokens are unknown.
170
+ const n = req.usage ? normalizeUsage(req.usage) : { input: 0, output: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0 };
171
+ const unreported = !(n.input || n.output || n.cacheWrite5m || n.cacheWrite1h || n.cacheRead);
172
+ if (unreported && !req.model) continue;
173
+ const turn = { requestId: req.key, model: req.model, timestamp: req.timestamp, ...n, unreported };
174
+ turns.push(turn);
175
+ billed.push({ req, turn });
63
176
  }
64
- return { turns, activity: { mcp, skills, web } };
177
+
178
+ const activity = EMPTY_ACTIVITY();
179
+ const mcp = {};
180
+ const skills = {};
181
+ const web = {};
182
+
183
+ billed.forEach(({ req, turn }, i) => {
184
+ const next = billed[i + 1]?.turn ?? null;
185
+ const charge = (bucket) => {
186
+ bucket.calls += 1;
187
+ bucket.outputTokens += turn.output;
188
+ bucket.outputByModel[turn.model] = (bucket.outputByModel[turn.model] ?? 0) + turn.output;
189
+ if (!next) {
190
+ bucket.contextMissing += 1;
191
+ return;
192
+ }
193
+ bucket.contextTokens += next.input + next.cacheWrite5m + next.cacheWrite1h;
194
+ const ctx = (bucket.contextByModel[next.model] ??= { input: 0, cacheWrite5m: 0, cacheWrite1h: 0 });
195
+ ctx.input += next.input;
196
+ ctx.cacheWrite5m += next.cacheWrite5m;
197
+ ctx.cacheWrite1h += next.cacheWrite1h;
198
+ };
199
+
200
+ const blocks = [...req.tools.values()];
201
+ let mcpCharged = false;
202
+ for (const block of blocks) {
203
+ const name = block.name ?? '';
204
+ digest.tools.push(toolStep(block));
205
+ if (name.startsWith('mcp__')) {
206
+ const [, slug = 'unknown', ...rest] = name.split('__');
207
+ const tool = rest.join('__') || 'unknown';
208
+ activity.mcp[slug] = (activity.mcp[slug] ?? 0) + 1;
209
+ const server = (mcp[slug] ??= { displayName: serverDisplayName(slug), tools: {} });
210
+ const bucket = (server.tools[tool] ??= newBucket());
211
+ charge(bucket);
212
+ if (req.skill) bucket.via[req.skill] = (bucket.via[req.skill] ?? 0) + 1;
213
+ mcpCharged = true;
214
+ } else if (name === 'Skill' && block.input?.skill) {
215
+ activity.skills[block.input.skill] = (activity.skills[block.input.skill] ?? 0) + 1;
216
+ } else if (name === 'WebFetch' || name === 'WebSearch') {
217
+ activity.web[name] = (activity.web[name] ?? 0) + 1;
218
+ web[name] = (web[name] ?? 0) + 1;
219
+ }
220
+ }
221
+ if (req.skill && !mcpCharged) charge((skills[req.skill] ??= newBucket()));
222
+ });
223
+
224
+ return { turns, activity, mcp, skills, web, digest, reported };
225
+ }
226
+
227
+ function emptyDigest() {
228
+ return { cwd: null, gitBranch: null, aiTitle: null, prompts: [], tools: [] };
65
229
  }
66
230
 
67
231
  export function timeRange(turns) {
@@ -70,11 +234,39 @@ export function timeRange(turns) {
70
234
  }
71
235
 
72
236
  export function mergeActivity(activities) {
73
- const merged = { mcp: {}, skills: {}, web: {} };
237
+ const merged = EMPTY_ACTIVITY();
74
238
  for (const a of activities) {
75
- for (const [k, v] of Object.entries(a.mcp)) merged.mcp[k] = (merged.mcp[k] ?? 0) + v;
76
- for (const [k, v] of Object.entries(a.skills)) merged.skills[k] = (merged.skills[k] ?? 0) + v;
77
- for (const [k, v] of Object.entries(a.web)) merged.web[k] = (merged.web[k] ?? 0) + v;
239
+ for (const kind of ['mcp', 'skills', 'web']) {
240
+ for (const [k, v] of Object.entries(a[kind] ?? {})) merged[kind][k] = (merged[kind][k] ?? 0) + v;
241
+ }
78
242
  }
79
243
  return merged;
80
244
  }
245
+
246
+ function addBucket(into, from) {
247
+ into.calls += from.calls;
248
+ into.outputTokens += from.outputTokens;
249
+ into.contextTokens += from.contextTokens;
250
+ into.contextMissing += from.contextMissing;
251
+ for (const [m, n] of Object.entries(from.outputByModel)) into.outputByModel[m] = (into.outputByModel[m] ?? 0) + n;
252
+ for (const [m, u] of Object.entries(from.contextByModel)) {
253
+ const c = (into.contextByModel[m] ??= { input: 0, cacheWrite5m: 0, cacheWrite1h: 0 });
254
+ c.input += u.input;
255
+ c.cacheWrite5m += u.cacheWrite5m;
256
+ c.cacheWrite1h += u.cacheWrite1h;
257
+ }
258
+ for (const [s, n] of Object.entries(from.via)) into.via[s] = (into.via[s] ?? 0) + n;
259
+ }
260
+
261
+ export function mergeParsed(parsedList) {
262
+ const out = { mcp: {}, skills: {}, web: {} };
263
+ for (const p of parsedList) {
264
+ for (const [slug, server] of Object.entries(p.mcp ?? {})) {
265
+ const into = (out.mcp[slug] ??= { displayName: server.displayName, tools: {} });
266
+ for (const [tool, bucket] of Object.entries(server.tools)) addBucket((into.tools[tool] ??= newBucket()), bucket);
267
+ }
268
+ for (const [name, bucket] of Object.entries(p.skills ?? {})) addBucket((out.skills[name] ??= newBucket()), bucket);
269
+ for (const [k, v] of Object.entries(p.web ?? {})) out.web[k] = (out.web[k] ?? 0) + v;
270
+ }
271
+ return out;
272
+ }