whoburnedmore 0.4.0 → 0.6.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.
Files changed (2) hide show
  1. package/dist/index.js +379 -143
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
11
11
  import { createRequire as createRequire4 } from "node:module";
12
12
  import { platform as platform3 } from "node:os";
13
- import { join as join6 } from "node:path";
13
+ import { join as join7 } from "node:path";
14
14
  import { createInterface } from "node:readline/promises";
15
15
  import pc2 from "picocolors";
16
16
 
@@ -29,6 +29,9 @@ function parseBoard(args) {
29
29
  function apiBase() {
30
30
  return process.env.WHOBURNEDMORE_API ?? "https://api.whoburnedmore.com";
31
31
  }
32
+ function webBase() {
33
+ return process.env.WHOBURNEDMORE_WEB ?? "https://whoburnedmore.com";
34
+ }
32
35
  async function readJson(res) {
33
36
  const text = await res.text();
34
37
  if (!text) return {};
@@ -40,15 +43,12 @@ async function readJson(res) {
40
43
  };
41
44
  }
42
45
  }
43
- async function post(path, body, token) {
46
+ async function post(path, body) {
44
47
  let res;
45
48
  try {
46
49
  res = await fetch(`${apiBase()}${path}`, {
47
50
  method: "POST",
48
- headers: {
49
- "Content-Type": "application/json",
50
- ...token ? { Authorization: `Bearer ${token}` } : {}
51
- },
51
+ headers: { "Content-Type": "application/json" },
52
52
  body: JSON.stringify(body)
53
53
  });
54
54
  } catch {
@@ -58,34 +58,6 @@ async function post(path, body, token) {
58
58
  }
59
59
  return { status: res.status, body: await readJson(res) };
60
60
  }
61
- async function deviceStart() {
62
- const { status, body } = await post("/v1/auth/device", {});
63
- if (status !== 200) throw new Error(`device auth failed (HTTP ${status})`);
64
- return body;
65
- }
66
- async function devicePoll(deviceCode) {
67
- const { body } = await post("/v1/auth/device/token", {
68
- deviceCode
69
- });
70
- return body;
71
- }
72
- async function submitUsage(token, payload) {
73
- const { status, body } = await post(
74
- "/v1/submit",
75
- payload,
76
- token
77
- );
78
- if (status === 401) {
79
- throw new Error("session expired \u2014 run `npx whoburnedmore login` again");
80
- }
81
- if (status !== 200) {
82
- const err = body;
83
- const details = err.details?.length ? `
84
- - ${err.details.join("\n - ")}` : "";
85
- throw new Error(`${err.error ?? `submit failed (HTTP ${status})`}${details}`);
86
- }
87
- return body;
88
- }
89
61
  async function anonSubmit(anonKey, payload) {
90
62
  const { status, body } = await post("/v1/anon/submit", { ...payload, anonKey });
91
63
  if (status !== 200) {
@@ -122,14 +94,14 @@ async function anonRemove(anonKey) {
122
94
 
123
95
  // src/autosync.ts
124
96
  import { spawnSync } from "node:child_process";
125
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
97
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
126
98
  import { homedir as homedir2, platform } from "node:os";
127
99
  import { join as join2 } from "node:path";
128
100
  import { fileURLToPath } from "node:url";
129
101
 
130
102
  // src/config.ts
131
103
  import { randomBytes } from "node:crypto";
132
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
104
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
133
105
  import { homedir } from "node:os";
134
106
  import { join } from "node:path";
135
107
  function defaultConfigDir() {
@@ -141,8 +113,6 @@ function loadConfig(dir = defaultConfigDir()) {
141
113
  try {
142
114
  const parsed = JSON.parse(readFileSync(file, "utf8"));
143
115
  const config = {};
144
- if (typeof parsed.token === "string") config.token = parsed.token;
145
- if (typeof parsed.handle === "string") config.handle = parsed.handle;
146
116
  if (typeof parsed.anonKey === "string") config.anonKey = parsed.anonKey;
147
117
  return Object.keys(config).length > 0 ? config : null;
148
118
  } catch {
@@ -154,9 +124,6 @@ function saveConfig(dir = defaultConfigDir(), config = {}) {
154
124
  const file = join(dir, "config.json");
155
125
  writeFileSync(file, JSON.stringify(config, null, 2), { mode: 384 });
156
126
  }
157
- function clearConfig(dir = defaultConfigDir()) {
158
- rmSync(join(dir, "config.json"), { force: true });
159
- }
160
127
  function ensureAnonKey(dir = defaultConfigDir()) {
161
128
  const config = loadConfig(dir) ?? {};
162
129
  if (config.anonKey) return config.anonKey;
@@ -250,7 +217,7 @@ function uninstallAutoSync() {
250
217
  const plistPath = launchAgentPath();
251
218
  if (existsSync2(plistPath)) {
252
219
  spawnSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
253
- rmSync2(plistPath, { force: true });
220
+ rmSync(plistPath, { force: true });
254
221
  }
255
222
  return "launchd agent removed";
256
223
  }
@@ -286,19 +253,246 @@ function autoSyncInstalled() {
286
253
  // src/collect.ts
287
254
  import { spawnSync as spawnSync4 } from "node:child_process";
288
255
  import { createRequire as createRequire3 } from "node:module";
289
- import { dirname as dirname2, join as join5 } from "node:path";
256
+ import { dirname as dirname2, join as join6 } from "node:path";
257
+
258
+ // src/attribution.ts
259
+ import { readFileSync as readFileSync2, readdirSync, statSync } from "node:fs";
260
+ import { homedir as homedir3 } from "node:os";
261
+ import { basename, join as join3 } from "node:path";
262
+
263
+ // src/pricing.ts
264
+ var TABLE = [
265
+ { match: /opus/i, price: { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
266
+ { match: /sonnet/i, price: { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
267
+ { match: /haiku/i, price: { in: 0.8, out: 4, cacheWrite: 1, cacheRead: 0.08 } },
268
+ { match: /fable/i, price: { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
269
+ { match: /gpt-4o|gpt-4\.1/i, price: { in: 2.5, out: 10, cacheWrite: 2.5, cacheRead: 1.25 } },
270
+ { match: /gpt-5|o3|o4|codex/i, price: { in: 1.25, out: 10, cacheWrite: 1.25, cacheRead: 0.125 } },
271
+ { match: /gemini.*flash/i, price: { in: 0.15, out: 0.6, cacheWrite: 0.15, cacheRead: 0.0375 } },
272
+ { match: /gemini/i, price: { in: 1.25, out: 5, cacheWrite: 1.25, cacheRead: 0.31 } }
273
+ ];
274
+ function estimateCostUSD(model, t) {
275
+ const row = TABLE.find((r) => r.match.test(model));
276
+ if (!row) return 0;
277
+ const p = row.price;
278
+ const usd = (t.inputTokens * p.in + t.outputTokens * p.out + t.cacheCreationTokens * p.cacheWrite + t.cacheReadTokens * p.cacheRead) / 1e6;
279
+ return usd > 0 ? usd : 0;
280
+ }
281
+
282
+ // src/attribution.ts
283
+ var CLAUDE_PROJECTS = join3(homedir3(), ".claude", "projects");
284
+ var MAX_FILES = 5e3;
285
+ var MAX_FILE_BYTES = 64 * 1024 * 1024;
286
+ var TIME_BUDGET_MS = 3e4;
287
+ var MAX_STATS = 300;
288
+ var MAX_PROJECTS = 500;
289
+ function createAccumulator() {
290
+ return {
291
+ tools: /* @__PURE__ */ new Map(),
292
+ skills: /* @__PURE__ */ new Map(),
293
+ projects: /* @__PURE__ */ new Map(),
294
+ agent: {
295
+ messageCount: 0,
296
+ subagentMessages: 0,
297
+ subagentTokens: 0,
298
+ totalTokens: 0
299
+ },
300
+ titles: /* @__PURE__ */ new Map(),
301
+ sessionMessages: /* @__PURE__ */ new Map()
302
+ };
303
+ }
304
+ function createFileContext() {
305
+ return { toolNames: /* @__PURE__ */ new Map() };
306
+ }
307
+ function recordTokens(usage) {
308
+ if (!usage || typeof usage !== "object") return 0;
309
+ const u = usage;
310
+ const n = (v) => {
311
+ const x = Math.round(Number(v));
312
+ return Number.isFinite(x) && x > 0 ? x : 0;
313
+ };
314
+ return n(u.input_tokens) + n(u.output_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens);
315
+ }
316
+ function processRecord(rec, acc, ctx) {
317
+ if (!rec || typeof rec !== "object") return;
318
+ const r = rec;
319
+ const recTokens = recordTokens(r.message?.usage);
320
+ if (typeof r.attributionSkill === "string" && r.attributionSkill) {
321
+ const s = r.attributionSkill.slice(0, 128);
322
+ const sk = acc.skills.get(s) ?? { count: 0, tokens: 0 };
323
+ sk.count += 1;
324
+ sk.tokens += recTokens;
325
+ acc.skills.set(s, sk);
326
+ }
327
+ if (r.type === "ai-title" && typeof r.aiTitle === "string" && r.aiTitle && typeof r.sessionId === "string" && r.sessionId) {
328
+ acc.titles.set(r.sessionId, r.aiTitle.slice(0, 200));
329
+ }
330
+ const content = r.message?.content;
331
+ if (!Array.isArray(content)) return;
332
+ const isAssistant = r.type === "assistant" || r.message?.role === "assistant";
333
+ const isUser = r.type === "user" || r.message?.role === "user";
334
+ if (isAssistant) {
335
+ const toolUses = [];
336
+ for (const block of content) {
337
+ if (block && typeof block === "object" && block.type === "tool_use" && typeof block.name === "string") {
338
+ const name = block.name.slice(0, 128);
339
+ if (!name) continue;
340
+ const id = block.id;
341
+ toolUses.push({ name, id: typeof id === "string" ? id : void 0 });
342
+ }
343
+ }
344
+ const perToolTokens = toolUses.length > 0 ? Math.floor(recTokens / toolUses.length) : 0;
345
+ for (const tu of toolUses) {
346
+ const t = acc.tools.get(tu.name) ?? { count: 0, errors: 0, tokens: 0 };
347
+ t.count += 1;
348
+ t.tokens += perToolTokens;
349
+ acc.tools.set(tu.name, t);
350
+ if (tu.id) ctx.toolNames.set(tu.id, tu.name);
351
+ }
352
+ const tokens = recTokens;
353
+ acc.agent.messageCount += 1;
354
+ acc.agent.totalTokens += tokens;
355
+ const sidechain = r.isSidechain === true;
356
+ if (sidechain) {
357
+ acc.agent.subagentMessages += 1;
358
+ acc.agent.subagentTokens += tokens;
359
+ }
360
+ if (typeof r.sessionId === "string" && r.sessionId) {
361
+ acc.sessionMessages.set(
362
+ r.sessionId,
363
+ (acc.sessionMessages.get(r.sessionId) ?? 0) + 1
364
+ );
365
+ }
366
+ if (typeof r.cwd === "string" && r.cwd && tokens > 0) {
367
+ const name = basename(r.cwd).slice(0, 128) || "unknown";
368
+ const model = typeof r.message?.model === "string" ? r.message.model : "unknown";
369
+ const u = r.message?.usage;
370
+ const num3 = (v) => {
371
+ const x = Math.round(Number(v));
372
+ return Number.isFinite(x) && x > 0 ? x : 0;
373
+ };
374
+ const cost = estimateCostUSD(model, {
375
+ inputTokens: num3(u?.input_tokens),
376
+ outputTokens: num3(u?.output_tokens),
377
+ cacheCreationTokens: num3(u?.cache_creation_input_tokens),
378
+ cacheReadTokens: num3(u?.cache_read_input_tokens)
379
+ });
380
+ const p = acc.projects.get(name) ?? { tokens: 0, costUSD: 0 };
381
+ p.tokens += tokens;
382
+ p.costUSD += cost;
383
+ acc.projects.set(name, p);
384
+ }
385
+ } else if (isUser) {
386
+ for (const block of content) {
387
+ if (block && typeof block === "object" && block.type === "tool_result" && block.is_error === true) {
388
+ const id = block.tool_use_id;
389
+ const name = typeof id === "string" ? ctx.toolNames.get(id) : void 0;
390
+ if (name) {
391
+ const t = acc.tools.get(name) ?? { count: 0, errors: 0, tokens: 0 };
392
+ t.errors += 1;
393
+ acc.tools.set(name, t);
394
+ }
395
+ }
396
+ }
397
+ }
398
+ }
399
+ function toSkillStats(map) {
400
+ return [...map.entries()].map(([name, v]) => ({ name, count: v.count, tokens: v.tokens })).filter((s) => s.count > 0).sort((a, b) => b.tokens - a.tokens || b.count - a.count).slice(0, MAX_STATS).map((s) => s.tokens > 0 ? s : { name: s.name, count: s.count });
401
+ }
402
+ function toToolStats(map) {
403
+ return [...map.entries()].map(([name, v]) => ({ name, count: v.count, errors: v.errors, tokens: v.tokens })).filter((s) => s.count > 0).sort((a, b) => b.tokens - a.tokens || b.count - a.count).slice(0, MAX_STATS).map((s) => {
404
+ const base = { name: s.name, count: s.count };
405
+ if (s.errors > 0) base.errors = s.errors;
406
+ if (s.tokens > 0) base.tokens = s.tokens;
407
+ return base;
408
+ });
409
+ }
410
+ function toProjectStats(map) {
411
+ return [...map.entries()].map(([name, v]) => ({
412
+ name,
413
+ tokens: v.tokens,
414
+ costUSD: Number(v.costUSD.toFixed(6))
415
+ })).filter((p) => p.tokens > 0).sort((a, b) => b.tokens - a.tokens).slice(0, MAX_PROJECTS);
416
+ }
417
+ function accumulatorToResult(acc) {
418
+ return {
419
+ tools: toToolStats(acc.tools),
420
+ skills: toSkillStats(acc.skills),
421
+ projects: toProjectStats(acc.projects),
422
+ agent: { ...acc.agent },
423
+ titles: acc.titles,
424
+ sessionMessages: acc.sessionMessages
425
+ };
426
+ }
427
+ function listTranscripts(dir) {
428
+ const out = [];
429
+ const walk = (d) => {
430
+ if (out.length >= MAX_FILES) return;
431
+ let entries;
432
+ try {
433
+ entries = readdirSync(d, { withFileTypes: true });
434
+ } catch {
435
+ return;
436
+ }
437
+ for (const e of entries) {
438
+ if (out.length >= MAX_FILES) return;
439
+ const p = join3(d, e.name);
440
+ if (e.isDirectory()) walk(p);
441
+ else if (e.isFile() && e.name.endsWith(".jsonl")) {
442
+ try {
443
+ out.push({ path: p, mtime: statSync(p).mtimeMs });
444
+ } catch {
445
+ }
446
+ }
447
+ }
448
+ };
449
+ walk(dir);
450
+ return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES).map((f) => f.path);
451
+ }
452
+ function collectAttribution() {
453
+ const acc = createAccumulator();
454
+ const deadline = Date.now() + TIME_BUDGET_MS;
455
+ try {
456
+ for (const file of listTranscripts(CLAUDE_PROJECTS)) {
457
+ if (Date.now() > deadline) break;
458
+ let size = 0;
459
+ try {
460
+ size = statSync(file).size;
461
+ } catch {
462
+ continue;
463
+ }
464
+ if (size > MAX_FILE_BYTES) continue;
465
+ let text;
466
+ try {
467
+ text = readFileSync2(file, "utf8");
468
+ } catch {
469
+ continue;
470
+ }
471
+ const ctx = createFileContext();
472
+ for (const line of text.split("\n")) {
473
+ if (!line) continue;
474
+ try {
475
+ processRecord(JSON.parse(line), acc, ctx);
476
+ } catch {
477
+ }
478
+ }
479
+ }
480
+ } catch {
481
+ }
482
+ return accumulatorToResult(acc);
483
+ }
290
484
 
291
485
  // src/cursor.ts
292
486
  import { spawnSync as spawnSync3 } from "node:child_process";
293
487
  import { existsSync as existsSync3 } from "node:fs";
294
488
  import { createRequire as createRequire2 } from "node:module";
295
- import { homedir as homedir3, platform as platform2 } from "node:os";
296
- import { join as join4 } from "node:path";
489
+ import { homedir as homedir4, platform as platform2 } from "node:os";
490
+ import { join as join5 } from "node:path";
297
491
 
298
492
  // src/tokscale.ts
299
493
  import { spawnSync as spawnSync2 } from "node:child_process";
300
494
  import { createRequire } from "node:module";
301
- import { dirname, join as join3 } from "node:path";
495
+ import { dirname, join as join4 } from "node:path";
302
496
  var LOOKBACK_DAYS = 30;
303
497
  function num(n) {
304
498
  const v = Math.round(Number(n));
@@ -342,7 +536,7 @@ function resolveTokscaleBin() {
342
536
  const pkg = require3("tokscale/package.json");
343
537
  const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.tokscale ?? "";
344
538
  if (!rel) return null;
345
- const binPath = join3(dirname(pkgPath), rel);
539
+ const binPath = join4(dirname(pkgPath), rel);
346
540
  if (/\.(c|m)?js$/.test(binPath)) {
347
541
  return { cmd: process.execPath, prefixArgs: [binPath] };
348
542
  }
@@ -397,9 +591,9 @@ function collectCursorViaTokscale(lookbackDays = LOOKBACK_DAYS) {
397
591
  // src/cursor.ts
398
592
  var EVENTS_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events";
399
593
  function cursorDbPath() {
400
- const home = homedir3();
594
+ const home = homedir4();
401
595
  const os = platform2();
402
- const p = os === "darwin" ? join4(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb") : os === "win32" ? join4(process.env.APPDATA ?? join4(home, "AppData", "Roaming"), "Cursor", "User", "globalStorage", "state.vscdb") : join4(process.env.XDG_CONFIG_HOME ?? join4(home, ".config"), "Cursor", "User", "globalStorage", "state.vscdb");
596
+ const p = os === "darwin" ? join5(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb") : os === "win32" ? join5(process.env.APPDATA ?? join5(home, "AppData", "Roaming"), "Cursor", "User", "globalStorage", "state.vscdb") : join5(process.env.XDG_CONFIG_HOME ?? join5(home, ".config"), "Cursor", "User", "globalStorage", "state.vscdb");
403
597
  return existsSync3(p) ? p : null;
404
598
  }
405
599
  function readCursorToken(db) {
@@ -672,7 +866,7 @@ function resolveCcusageBin() {
672
866
  const pkgPath = require3.resolve("ccusage/package.json");
673
867
  const pkg = require3("ccusage/package.json");
674
868
  const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.ccusage ?? "ccusage";
675
- const binPath = join5(dirname2(pkgPath), rel);
869
+ const binPath = join6(dirname2(pkgPath), rel);
676
870
  if (/\.(c|m)?js$/.test(binPath)) {
677
871
  return { cmd: process.execPath, prefixArgs: [binPath] };
678
872
  }
@@ -760,11 +954,25 @@ async function collectAll() {
760
954
  blocks.push(...cursor.blocks);
761
955
  toolsFound.push("cursor");
762
956
  }
957
+ const { tools, skills, projects, agent, titles, sessionMessages } = collectAttribution();
958
+ const dedupedSessions = dedupeSessions(sessions).map((s) => {
959
+ const title = titles.get(s.sessionId);
960
+ const messageCount = sessionMessages.get(s.sessionId);
961
+ return {
962
+ ...s,
963
+ ...title ? { title } : {},
964
+ ...messageCount ? { messageCount } : {}
965
+ };
966
+ });
763
967
  return {
764
968
  entries: dedupeDaily(entries),
765
- sessions: dedupeSessions(sessions),
969
+ sessions: dedupedSessions,
766
970
  blocks: dedupeBlocks(blocks),
767
- toolsFound
971
+ toolsFound,
972
+ tools,
973
+ skills,
974
+ projects,
975
+ agent
768
976
  };
769
977
  }
770
978
 
@@ -4855,13 +5063,46 @@ var SessionEntry = external_exports.object({
4855
5063
  cacheCreationTokens: tokenCount,
4856
5064
  cacheReadTokens: tokenCount,
4857
5065
  costUSD: external_exports.number().nonnegative(),
4858
- lastActivity: Timestamp
5066
+ lastActivity: Timestamp,
5067
+ /** Human-readable AI-generated session title (from transcripts). Optional. */
5068
+ title: external_exports.string().max(200).optional(),
5069
+ /** Number of assistant messages in this session (from transcripts). Optional. */
5070
+ messageCount: external_exports.number().int().nonnegative().optional()
4859
5071
  });
4860
5072
  var BlockEntry = external_exports.object({
4861
5073
  startTime: Timestamp,
4862
5074
  totalTokens: tokenCount,
4863
5075
  costUSD: external_exports.number().nonnegative()
4864
5076
  });
5077
+ var ToolStat = external_exports.object({
5078
+ name: external_exports.string().min(1).max(128),
5079
+ count: external_exports.number().int().nonnegative(),
5080
+ /** How many of those calls returned an error/interrupt (tool reliability). Optional. */
5081
+ errors: external_exports.number().int().nonnegative().optional(),
5082
+ /** Tokens burned on turns that used this tool (turn tokens split across its tool calls). Optional. */
5083
+ tokens: external_exports.number().int().nonnegative().optional()
5084
+ });
5085
+ var ProjectStat = external_exports.object({
5086
+ name: external_exports.string().min(1).max(128),
5087
+ tokens: external_exports.number().int().nonnegative(),
5088
+ costUSD: external_exports.number().nonnegative()
5089
+ });
5090
+ var AgentStat = external_exports.object({
5091
+ /** Total assistant messages across transcripts. */
5092
+ messageCount: external_exports.number().int().nonnegative(),
5093
+ /** Assistant messages that ran inside a subagent sidechain. */
5094
+ subagentMessages: external_exports.number().int().nonnegative(),
5095
+ /** Tokens spent inside subagent sidechains. */
5096
+ subagentTokens: external_exports.number().int().nonnegative(),
5097
+ /** Total tokens observed across transcripts (denominator for the share). */
5098
+ totalTokens: external_exports.number().int().nonnegative()
5099
+ });
5100
+ var SkillStat = external_exports.object({
5101
+ name: external_exports.string().min(1).max(128),
5102
+ count: external_exports.number().int().nonnegative(),
5103
+ /** Tokens burned in records produced while this skill was active. Optional. */
5104
+ tokens: external_exports.number().int().nonnegative().optional()
5105
+ });
4865
5106
  var SubmitPayload = external_exports.object({
4866
5107
  cliVersion: external_exports.string().min(1).max(32),
4867
5108
  entries: external_exports.array(DailyUsageEntry).min(1).max(2e4),
@@ -4869,6 +5110,14 @@ var SubmitPayload = external_exports.object({
4869
5110
  sessions: external_exports.array(SessionEntry).max(1e4).optional(),
4870
5111
  /** Optional time-window rollups (ccusage blocks) for peak-hours analysis. */
4871
5112
  blocks: external_exports.array(BlockEntry).max(1e4).optional(),
5113
+ /** Optional tool-call frequencies parsed from local transcripts (names + counts). */
5114
+ tools: external_exports.array(ToolStat).max(300).optional(),
5115
+ /** Optional skill-usage frequencies parsed from local transcripts. */
5116
+ skills: external_exports.array(SkillStat).max(300).optional(),
5117
+ /** Optional per-project usage totals parsed from local transcripts. */
5118
+ projects: external_exports.array(ProjectStat).max(500).optional(),
5119
+ /** Optional subagent-vs-main rollup parsed from local transcripts. */
5120
+ agent: AgentStat.optional(),
4872
5121
  /** Optional friends-board code (from `--board=<code>`): auto-join this board on submit. */
4873
5122
  board: external_exports.string().min(1).max(32).optional()
4874
5123
  });
@@ -4933,7 +5182,7 @@ function printSummary(entries) {
4933
5182
  function esc(s) {
4934
5183
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4935
5184
  }
4936
- function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date()) {
5185
+ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(), connect) {
4937
5186
  const today = generatedAt.toISOString().slice(0, 10);
4938
5187
  const totals = {
4939
5188
  tokens: 0,
@@ -4985,6 +5234,18 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date())
4985
5234
  ([model, a]) => `<tr><td class="mono">${esc(model)}</td><td class="num">${esc(formatTokens(a.tokens))}</td><td class="num">${esc(formatUSD(a.cost))}</td></tr>`
4986
5235
  ).join("");
4987
5236
  const stat = (label, value, accent = false) => `<div class="card"><div class="label">${label}</div><div class="value${accent ? " accent" : ""}">${esc(value)}</div></div>`;
5237
+ const connectCta = connect ? `
5238
+ <form class="connect" method="POST" action="${esc(connect.webBaseUrl)}/connect">
5239
+ <input type="hidden" name="payload" value="${Buffer.from(JSON.stringify(connect.payload)).toString("base64")}">
5240
+ <div class="connect-row">
5241
+ <div>
5242
+ <div class="connect-title">Connect your account</div>
5243
+ <div class="connect-sub">Save this dashboard to your account and claim your spot on the public leaderboard. The local numbers above become your starting point \u2014 nothing has left your machine yet.</div>
5244
+ </div>
5245
+ <button type="submit">Connect your account \u2192</button>
5246
+ </div>
5247
+ <div class="connect-note">After connecting, run <code>npx whoburnedmore</code> (no flag) once so it keeps syncing automatically in the background.</div>
5248
+ </form>` : "";
4988
5249
  return `<!doctype html>
4989
5250
  <html lang="en">
4990
5251
  <head>
@@ -5022,13 +5283,22 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date())
5022
5283
  td.mono { font-family: ui-monospace, monospace; font-size: 12px; }
5023
5284
  .foot { color: #78716c; font-size: 12px; margin-top: 32px; }
5024
5285
  .foot code { color: #d6d3d1; }
5286
+ .connect { display: block; margin: 24px 0 4px; background: linear-gradient(135deg, rgba(234,88,12,.16), rgba(249,115,22,.06)); border: 1px solid rgba(234,88,12,.5); border-radius: 14px; padding: 18px 20px; }
5287
+ .connect-row { display: flex; flex-direction: column; gap: 14px; align-items: flex-start; }
5288
+ @media (min-width: 640px) { .connect-row { flex-direction: row; align-items: center; justify-content: space-between; } }
5289
+ .connect-title { font-size: 16px; font-weight: 700; }
5290
+ .connect-sub { color: #d6d3d1; font-size: 13px; margin-top: 4px; max-width: 60ch; }
5291
+ .connect button { flex-shrink: 0; cursor: pointer; border: 0; border-radius: 10px; background: #ea580c; color: #fff; font-size: 14px; font-weight: 600; padding: 11px 18px; font-family: inherit; transition: background .15s; }
5292
+ .connect button:hover { background: #f97316; }
5293
+ .connect-note { color: #a8a29e; font-size: 12px; margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(234,88,12,.25); }
5294
+ .connect-note code { color: #fed7aa; background: rgba(0,0,0,.25); padding: 1px 6px; border-radius: 5px; }
5025
5295
  </style>
5026
5296
  </head>
5027
5297
  <body>
5028
5298
  <div class="wrap">
5029
5299
  <h1>who burned more<span class="q">?</span></h1>
5030
5300
  <div class="sub">your local burn report \xB7 generated ${esc(generatedAt.toISOString().slice(0, 16).replace("T", " "))} \xB7 nothing left your machine</div>
5031
-
5301
+ ${connectCta}
5032
5302
  <div class="grid">
5033
5303
  ${stat("total tokens", formatTokens(totals.tokens), true)}
5034
5304
  ${stat("est. cost", formatUSD(totals.cost))}
@@ -5072,7 +5342,7 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date())
5072
5342
 
5073
5343
  <div class="foot">
5074
5344
  Re-run <code>npx whoburnedmore --local</code> to refresh this page.<br>
5075
- Run <code>npx whoburnedmore</code> (no flag) to get a shareable dashboard at whoburnedmore.com \u2014 no sign-in.
5345
+ ${connect ? "Use \u201CConnect your account\u201D above to save it to whoburnedmore.com and join the leaderboard." : "Run <code>npx whoburnedmore</code> (no flag) to get a shareable dashboard at whoburnedmore.com \u2014 no sign-in."}
5076
5346
  </div>
5077
5347
  </div>
5078
5348
  </body>
@@ -5123,27 +5393,6 @@ function openBrowser(url) {
5123
5393
  const [cmd, args] = os === "darwin" ? ["open", [url]] : os === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5124
5394
  spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
5125
5395
  }
5126
- async function login() {
5127
- const device = await deviceStart();
5128
- console.log();
5129
- console.log(` Opening ${pc2.cyan(device.verifyUrl)}`);
5130
- console.log(` Your code: ${pc2.bold(pc2.yellow(device.userCode))}`);
5131
- console.log(pc2.dim(" Sign in with Google or GitHub and approve this device."));
5132
- openBrowser(device.verifyUrl);
5133
- const deadline = Date.now() + device.expiresInSeconds * 1e3;
5134
- while (Date.now() < deadline) {
5135
- await new Promise((r) => setTimeout(r, device.pollIntervalSeconds * 1e3));
5136
- const poll = await devicePoll(device.deviceCode);
5137
- if (poll.status === "ok") {
5138
- const config = { token: poll.token, handle: poll.handle };
5139
- saveConfig(void 0, config);
5140
- console.log(` Signed in as ${pc2.bold(poll.handle)} \u2713`);
5141
- return config;
5142
- }
5143
- if (poll.status === "expired") break;
5144
- }
5145
- throw new Error("login timed out \u2014 run `npx whoburnedmore` to try again");
5146
- }
5147
5396
  async function confirm(question) {
5148
5397
  if (!process.stdin.isTTY) return false;
5149
5398
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -5151,11 +5400,17 @@ async function confirm(question) {
5151
5400
  rl.close();
5152
5401
  return answer === "" || /^y(es)?$/i.test(answer);
5153
5402
  }
5154
- function showLocalDashboard(entries) {
5403
+ function showLocalDashboard(payload) {
5155
5404
  const dir = defaultConfigDir();
5156
5405
  mkdirSync3(dir, { recursive: true });
5157
- const file = join6(dir, "dashboard.html");
5158
- writeFileSync3(file, renderDashboardHtml(entries));
5406
+ const file = join7(dir, "dashboard.html");
5407
+ writeFileSync3(
5408
+ file,
5409
+ renderDashboardHtml(payload.entries, /* @__PURE__ */ new Date(), {
5410
+ payload,
5411
+ webBaseUrl: webBase()
5412
+ })
5413
+ );
5159
5414
  console.log();
5160
5415
  console.log(` Local dashboard: ${pc2.cyan(`file://${file}`)}`);
5161
5416
  console.log(pc2.dim(" Re-run `npx whoburnedmore --local` to refresh it. Nothing left your machine."));
@@ -5173,7 +5428,7 @@ async function run(flags) {
5173
5428
  } finally {
5174
5429
  stop();
5175
5430
  }
5176
- const { entries, sessions, blocks, toolsFound } = collected;
5431
+ const { entries, sessions, blocks, toolsFound, tools, skills, projects, agent } = collected;
5177
5432
  if (entries.length === 0) {
5178
5433
  console.log();
5179
5434
  console.log(" Nothing to burn yet \u2014 no local usage found from any coding agent.");
@@ -5183,6 +5438,10 @@ async function run(flags) {
5183
5438
  const payload = { cliVersion: VERSION, entries };
5184
5439
  if (sessions.length > 0) payload.sessions = sessions;
5185
5440
  if (blocks.length > 0) payload.blocks = blocks;
5441
+ if (tools.length > 0) payload.tools = tools;
5442
+ if (skills.length > 0) payload.skills = skills;
5443
+ if (projects.length > 0) payload.projects = projects;
5444
+ if (agent.messageCount > 0) payload.agent = agent;
5186
5445
  if (flags.board) payload.board = flags.board;
5187
5446
  if (flags.dryRun) {
5188
5447
  console.log(pc2.dim("\n --dry-run: this exact payload would be sent, nothing else:\n"));
@@ -5191,7 +5450,7 @@ async function run(flags) {
5191
5450
  }
5192
5451
  if (!flags.quiet) printSummary(entries);
5193
5452
  if (flags.local) {
5194
- showLocalDashboard(entries);
5453
+ showLocalDashboard(payload);
5195
5454
  if (!flags.quiet && process.stdin.isTTY) {
5196
5455
  await publishLocal(payload, {
5197
5456
  confirm,
@@ -5207,61 +5466,46 @@ async function run(flags) {
5207
5466
  console.log(pc2.dim(" --no-submit: skipped the dashboard."));
5208
5467
  return;
5209
5468
  }
5210
- const config = loadConfig();
5211
- if (config?.token) {
5212
- const result = await submitUsage(config.token, payload);
5213
- if (!flags.quiet) {
5214
- console.log(pc2.dim(" Opening your dashboard in your browser\u2026"));
5215
- openBrowser(result.boardUrl ?? result.profileUrl);
5216
- }
5469
+ const anonKey = ensureAnonKey();
5470
+ const result = await anonSubmit(anonKey, payload);
5471
+ const target = result.boardUrl ?? claimUrl(result.dashboardUrl, anonKey);
5472
+ if (!flags.quiet) {
5473
+ console.log(pc2.dim(" Opening your dashboard in your browser\u2026"));
5474
+ openBrowser(target);
5475
+ }
5476
+ console.log(
5477
+ ` Submitted ${pc2.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5478
+ );
5479
+ if (result.boardUrl) {
5217
5480
  console.log(
5218
- ` Submitted ${pc2.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5481
+ ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 \u{1F91D} you're on the friends board:`
5219
5482
  );
5220
- if (result.rank) {
5221
- console.log(
5222
- ` You are ${pc2.bold(pc2.yellow(`#${result.rank}`))} with ${pc2.bold(formatTokens(result.totalTokens))} tokens burned.`
5223
- );
5224
- }
5225
- console.log(` ${pc2.cyan(result.profileUrl)}`);
5226
- if (result.boardUrl) {
5227
- console.log(` \u{1F91D} You're on the friends board: ${pc2.cyan(result.boardUrl)}`);
5228
- }
5483
+ console.log(` ${pc2.cyan(result.boardUrl)}`);
5484
+ console.log(pc2.dim(` Your dashboard: ${result.dashboardUrl}`));
5229
5485
  } else {
5230
- const anonKey = ensureAnonKey();
5231
- const result = await anonSubmit(anonKey, payload);
5232
- const target = result.boardUrl ?? claimUrl(result.dashboardUrl, anonKey);
5233
- if (!flags.quiet) {
5234
- console.log(pc2.dim(" Opening your dashboard in your browser\u2026"));
5235
- openBrowser(target);
5236
- }
5237
5486
  console.log(
5238
- ` Submitted ${pc2.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5487
+ ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 you're on the public leaderboard:`
5239
5488
  );
5240
- if (result.boardUrl) {
5489
+ console.log(` ${pc2.cyan(result.dashboardUrl)}`);
5490
+ if (!flags.quiet) {
5241
5491
  console.log(
5242
- ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 \u{1F91D} you're on the friends board:`
5492
+ pc2.dim(" Claim it (name + X) on the web to own your rank, or make it private / remove it.")
5243
5493
  );
5244
- console.log(` ${pc2.cyan(result.boardUrl)}`);
5245
- console.log(pc2.dim(` Your dashboard: ${result.dashboardUrl}`));
5246
- } else {
5247
5494
  console.log(
5248
- ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 you're on the public leaderboard:`
5495
+ pc2.dim(" Manage anytime: `npx whoburnedmore private` \xB7 `npx whoburnedmore public` \xB7 `npx whoburnedmore remove`.")
5249
5496
  );
5250
- console.log(` ${pc2.cyan(result.dashboardUrl)}`);
5251
- if (!flags.quiet) {
5252
- console.log(
5253
- pc2.dim(" Claim it (name + X) to own your rank, or make it private / remove it.")
5254
- );
5255
- console.log(
5256
- pc2.dim(" Manage anytime: `npx whoburnedmore private` \xB7 `npx whoburnedmore public` \xB7 `npx whoburnedmore remove`.")
5257
- );
5258
- }
5259
5497
  }
5260
5498
  }
5261
- console.log();
5499
+ if (!flags.quiet && !autoSyncInstalled()) {
5500
+ try {
5501
+ installAutoSync();
5502
+ } catch {
5503
+ }
5504
+ }
5262
5505
  if (!flags.quiet) {
5506
+ console.log();
5263
5507
  console.log(
5264
- autoSyncInstalled() ? pc2.dim(" Background sync is on \u2014 your page keeps updating automatically (`npx whoburnedmore uninstall-sync` to stop).") : pc2.dim(" Re-run anytime to update \xB7 `npx whoburnedmore install-sync` to keep it live in the background.")
5508
+ autoSyncInstalled() ? pc2.dim(" Background sync is on \u2014 your page updates automatically every 3h (`npx whoburnedmore uninstall-sync` to stop).") : pc2.dim(" Re-run anytime to update your page.")
5265
5509
  );
5266
5510
  }
5267
5511
  }
@@ -5290,14 +5534,6 @@ async function main() {
5290
5534
  await run({ ...flags, noSubmit: false, dryRun: false, local: false });
5291
5535
  break;
5292
5536
  }
5293
- case "login":
5294
- await login();
5295
- break;
5296
- case "logout":
5297
- clearConfig();
5298
- console.log(" Logged out. Your leaderboard data is untouched.");
5299
- console.log(pc2.dim(" Delete your data anytime from your profile page."));
5300
- break;
5301
5537
  case "private":
5302
5538
  case "public": {
5303
5539
  const cfg = loadConfig();
@@ -5352,19 +5588,19 @@ function printHelp() {
5352
5588
  npx whoburnedmore --local build the dashboard on your machine and open it (offline)
5353
5589
  npx whoburnedmore --dry-run print exactly what would be sent, send nothing
5354
5590
  npx whoburnedmore --no-submit print local stats only, send nothing
5355
- npx whoburnedmore private hide your anonymous dashboard from the leaderboard
5591
+ npx whoburnedmore private hide your dashboard from the leaderboard
5356
5592
  npx whoburnedmore public put it back on the leaderboard
5357
- npx whoburnedmore remove delete your anonymous dashboard and its data
5358
- npx whoburnedmore login sign in to claim a public handle + join the leaderboard
5359
- npx whoburnedmore logout forget the local token
5360
- npx whoburnedmore install-sync keep your dashboard live (background sync, 3h)
5361
- npx whoburnedmore uninstall-sync remove background sync
5593
+ npx whoburnedmore remove delete your dashboard and its data
5594
+ npx whoburnedmore uninstall-sync turn off the background sync
5595
+ npx whoburnedmore install-sync turn it back on after uninstalling
5362
5596
 
5363
- By default your dashboard is public on the leaderboard as an anonymous burner \u2014
5364
- claim it (sign in for a handle + X) to own your rank, or run \`private\`/\`remove\`
5365
- to pull it. Only daily aggregate numbers (date, tool, model, token counts, est.
5366
- cost) ever leave your machine \u2014 never prompts, code, or file names. With --local,
5367
- nothing leaves your machine at all.
5597
+ Background sync is on by default: after your first run, your page refreshes
5598
+ automatically every 3h (\`uninstall-sync\` to stop). Your dashboard is public on
5599
+ the leaderboard as an anonymous burner \u2014 sign in on whoburnedmore.com to claim
5600
+ it (handle + X) and own your rank, or run \`private\`/\`remove\` to pull it. Only
5601
+ daily aggregate numbers (date, tool, model, token counts, est. cost) ever leave
5602
+ your machine \u2014 never prompts, code, or file names. With --local, nothing leaves
5603
+ your machine at all.
5368
5604
  `);
5369
5605
  }
5370
5606
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whoburnedmore",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Find out who burned more — submit your AI coding-agent token usage to the public leaderboard at whoburnedmore.com",
5
5
  "type": "module",
6
6
  "bin": {