claude-rpc 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@ Your live model, project, current tool, tokens, and lifetime stats — in your D
21
21
  [![Claude Code](https://img.shields.io/badge/Claude%20Code-hooks-d97757.svg)](https://claude.com/claude-code)
22
22
  [![Discord RPC](https://img.shields.io/badge/Discord-RPC-5865F2.svg?logo=discord&logoColor=white)](https://discord.com/developers/docs/topics/rpc)
23
23
  [![Release](https://img.shields.io/github/v/release/rar-file/claude-rpc?color=4c1)](https://github.com/rar-file/claude-rpc/releases/latest)
24
+ [![npm downloads](https://img.shields.io/npm/dm/claude-rpc?color=cb3837&logo=npm&logoColor=white&label=downloads)](https://www.npmjs.com/package/claude-rpc)
24
25
 
25
26
  </div>
26
27
 
@@ -253,6 +254,7 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
253
254
  | `insights` | Print 3–5 auto-generated lines about your week |
254
255
  | `badge` | Shields-style SVG (`--metric` `--range` `--out` `--gist`) |
255
256
  | `card` | Poster-style SVG (`--range year\|month\|week\|all`) |
257
+ | `github-stat` | Embeddable profile stat card (`--handle` `--out` `--gist`) |
256
258
  | `private` / `public` / `privacy` | Per-cwd visibility toggles + status |
257
259
  | `community` | Opt-in community totals — `on` \| `off` \| `status` \| `report` |
258
260
  | `doctor` | Diagnostic checklist with one-line fix hints |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -750,6 +750,41 @@ async function doCard(argv) {
750
750
  }
751
751
  }
752
752
 
753
+ // GitHub profile stat card — a compact, embeddable lifetime summary meant
754
+ // for a profile README. `--gist` publishes it to a gist (raw URL stays stable
755
+ // across re-runs) so the README image auto-refreshes when you re-run it.
756
+ function parseGithubStatArgs(argv) {
757
+ const out = { out: '', gist: false, handle: '' };
758
+ for (let i = 0; i < argv.length; i++) {
759
+ const a = argv[i];
760
+ if (a === '--out' || a === '-o') out.out = argv[++i];
761
+ else if (a === '--handle' || a === '-u') out.handle = argv[++i];
762
+ else if (a === '--gist') out.gist = true;
763
+ }
764
+ return out;
765
+ }
766
+
767
+ async function doGithubStat(argv) {
768
+ const opts = parseGithubStatArgs(argv);
769
+ const aggregate = readAggregate();
770
+ if (!aggregate) {
771
+ fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
772
+ }
773
+ const { renderProfileCard } = await import('./profile.js');
774
+ const svg = renderProfileCard(aggregate, { handle: opts.handle });
775
+ if (opts.gist) {
776
+ await publishBadgeToGist(svg, { metric: 'profile', range: 'all-time' });
777
+ return;
778
+ }
779
+ if (opts.out) {
780
+ writeFileSync(opts.out, svg);
781
+ console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
782
+ console.log(`${c.dim}Embed in your README: <img src="${opts.out}" alt="Claude Code stats" width="500" />${c.reset}`);
783
+ } else {
784
+ process.stdout.write(svg);
785
+ }
786
+ }
787
+
753
788
  // ── Privacy commands ─────────────────────────────────────────────────────
754
789
  //
755
790
  // `claude-rpc private` → add current cwd to ~/.claude-rpc/private-list.json
@@ -1039,6 +1074,7 @@ function help() {
1039
1074
  ['insights', 'Auto-generated insights from your history'],
1040
1075
  ['badge', 'Render a Shields-style SVG (--metric --range --out --gist)'],
1041
1076
  ['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
1077
+ ['github-stat', 'Render an embeddable profile stat card (--handle --out --gist)'],
1042
1078
  ['private', 'Mark the current directory as private (hide from Discord)'],
1043
1079
  ['public', 'Un-mark the current directory'],
1044
1080
  ['privacy', 'Show resolved visibility for the current directory'],
@@ -1111,6 +1147,7 @@ const packagedDefault = IS_PACKAGED && !cmd;
1111
1147
  case 'insights': showInsights(); break;
1112
1148
  case 'badge': await doBadge(process.argv.slice(3)); break;
1113
1149
  case 'card': await doCard(process.argv.slice(3)); break;
1150
+ case 'github-stat': await doGithubStat(process.argv.slice(3)); break;
1114
1151
  case 'private': doPrivate(); break;
1115
1152
  case 'public': doPublic(); break;
1116
1153
  case 'privacy': doPrivacy(); break;
package/src/daemon.js CHANGED
@@ -7,6 +7,7 @@ import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scan
7
7
  import { detectGithubUrl } from './git.js';
8
8
  import { applyPrivacy } from './privacy.js';
9
9
  import { loadConfig } from './config.js';
10
+ import { migrateConfig } from './install.js';
10
11
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
11
12
 
12
13
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
@@ -51,6 +52,20 @@ function loadConfigWithLog() {
51
52
  return loadConfig({ onError: (msg) => log(msg) });
52
53
  }
53
54
 
55
+ // Bring an existing config.json up to date with the current defaults before
56
+ // we load it. This is how upgrades reach users who just `npm update` + restart
57
+ // the daemon without re-running `claude-rpc setup` — e.g. the v0.8.1 button-URL
58
+ // move. Idempotent: only writes when something actually changes, so steady-state
59
+ // restarts are a no-op and can't loop the config watcher. Best-effort — a
60
+ // migration failure must never stop the daemon from starting.
61
+ try {
62
+ if (migrateConfig({ silent: true })) {
63
+ log('config.json migrated to current defaults on startup');
64
+ }
65
+ } catch (e) {
66
+ log('startup config migration failed (continuing):', e?.message || String(e));
67
+ }
68
+
54
69
  let config = loadConfigWithLog();
55
70
  let aggregate = readAggregate() || null;
56
71
  let liveSessions = [];
@@ -87,6 +87,11 @@ export const DEFAULT_CONFIG = {
87
87
  details: "Working in {project}",
88
88
  state: "{currentToolPretty} · {currentFilePretty} · {toolElapsed} · {tokensLabel}",
89
89
  largeImageText: "Working on a {fileLang} file",
90
+ rotation: [
91
+ // Pops in for ~5min when the session crosses an hour milestone, then
92
+ // the `requires` gate drops it and we're back to the single frame.
93
+ { details: "{sessionMilestoneLabel} · {project}", state: "{tokensLabel} · {messagesLabel}", requires: ["sessionMilestoneHit"] },
94
+ ],
90
95
  },
91
96
  thinking: {
92
97
  details: "Thinking in {project}",
@@ -99,7 +104,9 @@ export const DEFAULT_CONFIG = {
99
104
  largeImageText: "Compacting · {compactTriggerLabel}",
100
105
  },
101
106
  shipped: {
102
- details: "Just shipped in {project}",
107
+ // {justShippedLabel} adapts to the action: "Pushed to main",
108
+ // "Committed on feat/x", "Opened a pull request", "Opened an issue".
109
+ details: "{justShippedLabel} · {project}",
103
110
  state: "{lastCommit}",
104
111
  largeImageText: "{justShippedLabel}",
105
112
  },
@@ -115,9 +122,11 @@ export const DEFAULT_CONFIG = {
115
122
  rotation: [
116
123
  { details: "This week · {weekHours}", state: "{weekPromptsLabel} · {weekTokensFmt} tokens", requires: ["weekActiveMs"] },
117
124
  { details: "{streakLabel}", state: "{daysSinceFirstLabel} · {allSessionsLabel}", requires: ["streakIsMilestone"] },
118
- { details: "Hotspot · {topEditedFile}", state: "{topEditedCountLabel} all-time", requires: ["topEditedCount"] },
125
+ { details: "Hotspot · {topEditedFile}", state: "{topEditedCountLabel} · {topEditedAgeLabel}", requires: ["topEditedCount"] },
126
+ { details: "Model split", state: "{modelSplitLabel}", requires: ["modelSplitLabel"] },
119
127
  { details: "{allHours} on Claude all-time", state: "{allSessionsLabel} · {allMessagesFmt} prompts", requires: ["allSessions"] },
120
128
  { details: "Lifetime · {allTokensFmt} tokens", state: "{allToolsFmt} tool calls · {allFilesFmt} files", requires: ["allTools"] },
129
+ { details: "{allFreshTokensFmt} fresh tokens", state: "{allCachePctLabel}", requires: ["allCachePctLabel"] },
121
130
  { details: "Code churn · {linesAddedFmt} added", state: "{linesNetFmt} net · {topLanguage}", requires: ["topLanguage"] },
122
131
  { details: "Cost · {todayCostFmt} today", state: "{allCostFmt} all-time", requires: ["allCost"] },
123
132
  ],
package/src/format.js CHANGED
@@ -317,6 +317,31 @@ export function buildVars(state, config, aggregate) {
317
317
  && (streak % 7 === 0 || streak === 30 || streak === 60 || streak === 100 || streak === 365)
318
318
  ? 1 : 0;
319
319
 
320
+ // Session-duration milestones — surface a celebratory frame for a few
321
+ // minutes after the live session crosses 1h/2h/3h/5h/8h/12h. Stateless:
322
+ // derived from elapsed duration, so no per-session bookkeeping is needed.
323
+ const SESSION_MILESTONES_H = [1, 2, 3, 5, 8, 12];
324
+ let sessionMilestoneHit = 0;
325
+ let sessionMilestoneLabel = '';
326
+ if (sessionActive && duration > 0) {
327
+ for (const h of SESSION_MILESTONES_H) {
328
+ const t = h * 3_600_000;
329
+ if (duration >= t && duration - t < 5 * 60_000) {
330
+ sessionMilestoneHit = 1;
331
+ sessionMilestoneLabel = `${h}-hour session`;
332
+ }
333
+ }
334
+ }
335
+
336
+ // Model split — per-model share of all-time spend, biggest first.
337
+ const modelSplit = Array.isArray(agg.modelSplit) ? agg.modelSplit : [];
338
+ const topModelEntry = modelSplit[0] || null;
339
+ const modelSplitLabel = modelSplit
340
+ .slice(0, 3)
341
+ .filter((m) => (m.costPct || 0) > 0)
342
+ .map((m) => `${humanModel(m.model)} ${Math.round(m.costPct * 100)}%`)
343
+ .join(' · ');
344
+
320
345
  // Idle duration for sleeker idle copy.
321
346
  const idleMs = state.status === 'idle' && state.lastActivity
322
347
  ? Math.max(0, Date.now() - state.lastActivity)
@@ -448,7 +473,13 @@ export function buildVars(state, config, aggregate) {
448
473
  ? (state.justShippedBranch ? `Pushed to ${state.justShippedBranch}` : 'Pushed')
449
474
  : state.justShippedKind === 'commit'
450
475
  ? (state.justShippedBranch ? `Committed on ${state.justShippedBranch}` : 'Committed')
451
- : '',
476
+ : state.justShippedKind === 'pr'
477
+ ? 'Opened a pull request'
478
+ : state.justShippedKind === 'issue'
479
+ ? 'Opened an issue'
480
+ : state.justShippedKind === 'tag'
481
+ ? (state.justShippedBranch ? `Tagged ${state.justShippedBranch}` : 'Tagged a release')
482
+ : '',
452
483
  // {lastCommit} reads more naturally than {justShippedSubject} in user templates.
453
484
  lastCommit: state.justShippedSubject || '',
454
485
 
@@ -477,6 +508,20 @@ export function buildVars(state, config, aggregate) {
477
508
  allCacheTokens: fmtNum(allCache),
478
509
  allCacheReadTokens: fmtNum(allCacheRead),
479
510
  allCacheWriteTokens: fmtNum(allCacheWrite),
511
+ // Fresh (input+output) vs cache breakdown — clarifies the lumped total so
512
+ // "X tokens" isn't mistaken for billable spend (most of it is cheap cache).
513
+ allFreshTokens: allReal,
514
+ allFreshTokensFmt: fmtNum(allReal),
515
+ allCachePct: allTotal > 0 ? Math.round((allCache / allTotal) * 100) : 0,
516
+ allCachePctLabel: allTotal > 0 ? `${Math.round((allCache / allTotal) * 100)}% from cache` : '',
517
+ // Model split (v0.9) — top model by spend + a compact share label.
518
+ topModel: topModelEntry ? topModelEntry.model : '',
519
+ topModelPretty: topModelEntry ? humanModel(topModelEntry.model) : '',
520
+ topModelCostPct: topModelEntry ? Math.round((topModelEntry.costPct || 0) * 100) : 0,
521
+ topModelShareLabel: topModelEntry && (topModelEntry.costPct || 0) > 0
522
+ ? `${humanModel(topModelEntry.model)} · ${Math.round(topModelEntry.costPct * 100)}% of spend`
523
+ : '',
524
+ modelSplitLabel,
480
525
  allHours: fmtHours(agg.activeMs || 0),
481
526
  allWallHours: fmtHours(agg.wallMs || 0),
482
527
  allMessages: agg.userMessages || 0,
@@ -543,6 +588,12 @@ export function buildVars(state, config, aggregate) {
543
588
  topEditedFile: hotspot ? basename(hotspot.path) : '',
544
589
  topEditedCount: hotspot?.count || 0,
545
590
  topEditedCountLabel: hotspot ? plural(hotspot.count, 'edit') : '0 edits',
591
+ // Hotspot aging — how long since the top file was last touched.
592
+ topEditedDaysAgo: hotspot && hotspot.daysSinceLastEdit != null ? hotspot.daysSinceLastEdit : null,
593
+ topEditedAgeLabel: !hotspot || hotspot.daysSinceLastEdit == null ? ''
594
+ : hotspot.daysSinceLastEdit === 0 ? 'edited today'
595
+ : hotspot.daysSinceLastEdit === 1 ? 'edited yesterday'
596
+ : `${hotspot.daysSinceLastEdit}d since last edit`,
546
597
 
547
598
  // Per-project (current cwd's project)
548
599
  projectHours: projectStats ? fmtHours(projectStats.activeMs || 0) : '0h',
@@ -555,6 +606,9 @@ export function buildVars(state, config, aggregate) {
555
606
 
556
607
  // Streak milestone gate (for special rotation frame)
557
608
  streakIsMilestone,
609
+ // Session-duration milestone gate + label (v0.9)
610
+ sessionMilestoneHit,
611
+ sessionMilestoneLabel,
558
612
 
559
613
  // ── Code churn ───────────────────────────────────────────────
560
614
  linesAdded: allLinesAdded,
package/src/hook.js CHANGED
@@ -5,10 +5,24 @@ import { updateState, resetState, pushUnique, shortFile } from './state.js';
5
5
  import { detectLastCommitSubject, detectGitBranch } from './git.js';
6
6
  import { EVENTS_LOG_PATH } from './paths.js';
7
7
 
8
- // Match the bash invocations we treat as "just shipped" captures the
9
- // verb (push / commit) for state.justShippedKind. Tolerates extra args,
10
- // leading env vars, and chained commands ("git add . && git commit -m ...").
11
- const GIT_SHIP_RE = /(?:^|[;&|]|\s)git\s+(push|commit)(?:\s|$)/;
8
+ // Bash invocations we treat as "just shipped", classified into a kind for
9
+ // state.justShippedKind. Tolerates extra args, leading env vars, and chained
10
+ // commands ("git add . && git commit -m "). Order matters: push outranks
11
+ // commit when a command does both (`git commit && git push`).
12
+ const SHIP_PATTERNS = [
13
+ { kind: 'push', re: /(?:^|[;&|]|\s)git\s+push(?:\s|$)/ },
14
+ { kind: 'commit', re: /(?:^|[;&|]|\s)git\s+commit(?:\s|$)/ },
15
+ { kind: 'pr', re: /(?:^|[;&|]|\s)gh\s+pr\s+create(?:\s|$)/ },
16
+ { kind: 'issue', re: /(?:^|[;&|]|\s)gh\s+issue\s+create(?:\s|$)/ },
17
+ { kind: 'tag', re: /(?:^|[;&|]|\s)gh\s+release\s+create(?:\s|$)/ },
18
+ ];
19
+
20
+ // Return the "shipped" kind for a shell command, or null. Exported for tests.
21
+ export function classifyShip(cmd) {
22
+ const s = String(cmd || '');
23
+ for (const p of SHIP_PATTERNS) if (p.re.test(s)) return p.kind;
24
+ return null;
25
+ }
12
26
 
13
27
  const EVENTS_LOG_ROTATE_BYTES = 5 * 1024 * 1024;
14
28
 
@@ -112,13 +126,15 @@ export function processHookEvent(event, input = {}) {
112
126
  let shipSubject = null;
113
127
  let shipBranch = null;
114
128
  if (toolName === 'Bash') {
115
- const cmd = String(toolInput.command || '');
116
- const m = cmd.match(GIT_SHIP_RE);
117
- if (m) {
118
- shipKind = m[1];
129
+ shipKind = classifyShip(toolInput.command);
130
+ if (shipKind) {
119
131
  const shipCwd = input.cwd || process.cwd();
120
- shipSubject = detectLastCommitSubject(shipCwd) || null;
121
132
  shipBranch = detectGitBranch(shipCwd) || null;
133
+ // Only commit/push carry a meaningful "what shipped" subject; a PR
134
+ // or issue creation doesn't map to a commit message.
135
+ if (shipKind === 'commit' || shipKind === 'push') {
136
+ shipSubject = detectLastCommitSubject(shipCwd) || null;
137
+ }
122
138
  }
123
139
  }
124
140
  updateState((s) => {
package/src/install.js CHANGED
@@ -235,12 +235,12 @@ export function seedConfig() {
235
235
  // without clobbering the user's customizations. Anything the user already
236
236
  // has — including a pre-existing byStatus, custom rotation array, custom
237
237
  // appName etc. — is left untouched.
238
- export function migrateConfig() {
238
+ export function migrateConfig({ silent = false } = {}) {
239
239
  if (!existsSync(CONFIG_PATH)) return false;
240
240
  let cfg;
241
241
  try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
242
242
  catch (e) {
243
- console.warn(` ! could not read config for migration: ${e.message}`);
243
+ if (!silent) console.warn(` ! could not read config for migration: ${e.message}`);
244
244
  return false;
245
245
  }
246
246
  if (!cfg || typeof cfg !== 'object') return false;
@@ -300,13 +300,31 @@ export function migrateConfig() {
300
300
  added.push('community (preserved-off)');
301
301
  }
302
302
 
303
+ // v0.8.1: the default presence button moved from the Claude Code website
304
+ // to the project repo. Existing configs carry their own `buttons` array,
305
+ // which fully REPLACES the default (arrays don't deep-merge) — so the new
306
+ // default never reaches upgraders just by bumping the package. Rewrite
307
+ // ONLY a button still pointing at the verbatim old default URL; anything a
308
+ // user has customized (label or url) is left untouched.
309
+ const OLD_BTN_URL = 'https://claude.com/claude-code';
310
+ const NEW_BTN_URL = DEFAULT_CONFIG.presence?.buttons?.[0]?.url;
311
+ if (NEW_BTN_URL && Array.isArray(cfg.presence?.buttons)) {
312
+ let changed = false;
313
+ for (const b of cfg.presence.buttons) {
314
+ if (b && b.url === OLD_BTN_URL) { b.url = NEW_BTN_URL; changed = true; }
315
+ }
316
+ if (changed) added.push('presence.buttons[].url → repo');
317
+ }
318
+
303
319
  if (added.length === 0) {
304
- console.log(` config up to date → ${CONFIG_PATH}`);
320
+ if (!silent) console.log(` config up to date → ${CONFIG_PATH}`);
305
321
  return false;
306
322
  }
307
323
  writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
308
- console.log(` config migrated → ${CONFIG_PATH}`);
309
- console.log(` added: ${added.join(', ')}`);
324
+ if (!silent) {
325
+ console.log(` config migrated → ${CONFIG_PATH}`);
326
+ console.log(` added: ${added.join(', ')}`);
327
+ }
310
328
  return true;
311
329
  }
312
330
 
package/src/profile.js ADDED
@@ -0,0 +1,160 @@
1
+ // GitHub-profile stat card — a compact, embeddable SVG summary of your
2
+ // all-time Claude Code stats, meant to drop into a profile README via a
3
+ // raw gist URL (see `claude-rpc github-stat --gist`). Same paper/terracotta
4
+ // brand as the poster card (card.js); smaller and lifetime-focused.
5
+ //
6
+ // Output is pure SVG — GitHub renders it inline in a README <img>.
7
+
8
+ import { fmtCost } from './pricing.js';
9
+ import { VERSION } from './version.js';
10
+
11
+ const W = 520;
12
+ const H = 240;
13
+
14
+ const PALETTE = {
15
+ paper: '#f4ede0',
16
+ paper2: '#ebe2d2',
17
+ paper3: '#e1d6c0',
18
+ ink: '#1a1611',
19
+ inkMute:'#5c5147',
20
+ inkFaint:'#8a7c6d',
21
+ rust: '#c2491e',
22
+ tape: '#f2d76e',
23
+ grass: '#4a9462',
24
+ blurple:'#5865f2',
25
+ };
26
+
27
+ function escapeXml(s) {
28
+ return String(s == null ? '' : s)
29
+ .replace(/&/g, '&amp;')
30
+ .replace(/</g, '&lt;')
31
+ .replace(/>/g, '&gt;')
32
+ .replace(/"/g, '&quot;');
33
+ }
34
+
35
+ function fmtNum(n) {
36
+ if (!n) return '0';
37
+ if (n < 1000) return String(Math.round(n));
38
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
39
+ if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
40
+ return `${(n / 1_000_000_000).toFixed(2)}B`;
41
+ }
42
+
43
+ function fmtHours(ms) {
44
+ if (!ms || ms < 0) return '0h';
45
+ const h = ms / 3_600_000;
46
+ if (h < 1) return `${Math.round(h * 60)}m`;
47
+ if (h < 10) return `${h.toFixed(1)}h`;
48
+ return `${Math.round(h)}h`;
49
+ }
50
+
51
+ function topLanguage(aggregate) {
52
+ const langs = aggregate?.languages || {};
53
+ let best = null;
54
+ for (const [name, v] of Object.entries(langs)) {
55
+ if (!best || (v.edits || 0) > (best.edits || 0)) best = { name, edits: v.edits || 0 };
56
+ }
57
+ return best;
58
+ }
59
+
60
+ // One "LABEL / value" stat cell. Label in mono caps above a display-font value.
61
+ function statCell(x, y, label, value, accent = PALETTE.ink) {
62
+ return `
63
+ <text x="${x}" y="${y}"
64
+ font-family="JetBrains Mono, ui-monospace, monospace"
65
+ font-size="11" font-weight="700" letter-spacing="2"
66
+ fill="${PALETTE.inkMute}">${escapeXml(label.toUpperCase())}</text>
67
+ <text x="${x}" y="${y + 28}"
68
+ font-family="Space Grotesk, Inter, system-ui, sans-serif"
69
+ font-size="26" font-weight="800" letter-spacing="-0.5"
70
+ fill="${accent}">${escapeXml(value)}</text>`;
71
+ }
72
+
73
+ function tapeSticker(x, y, text, { rotate = 3 } = {}) {
74
+ const pad = 11;
75
+ const fontSize = 12;
76
+ const w = text.length * 7.6 + pad * 2;
77
+ const h = fontSize + 11;
78
+ return `
79
+ <g transform="translate(${x} ${y}) rotate(${rotate})">
80
+ <rect x="0" y="0" width="${w}" height="${h}" fill="${PALETTE.tape}" stroke="${PALETTE.ink}" stroke-width="1.5"/>
81
+ <text x="${w / 2}" y="${h / 2 + 4.5}"
82
+ font-family="JetBrains Mono, ui-monospace, monospace"
83
+ font-size="${fontSize}" font-weight="700" letter-spacing="1.2"
84
+ text-anchor="middle" fill="${PALETTE.ink}">${escapeXml(text.toUpperCase())}</text>
85
+ </g>`;
86
+ }
87
+
88
+ // Lifetime totals straight off the aggregate (not windowed — a profile card
89
+ // is an all-time brag).
90
+ function lifetime(aggregate) {
91
+ const a = aggregate || {};
92
+ const tokens = (a.inputTokens || 0) + (a.outputTokens || 0)
93
+ + (a.cacheReadTokens || 0) + (a.cacheWriteTokens || 0);
94
+ const linesNet = a.linesNet ?? ((a.linesAdded || 0) - (a.linesRemoved || 0));
95
+ return {
96
+ hours: a.activeMs || 0,
97
+ sessions: a.sessions || 0,
98
+ prompts: a.userMessages || 0,
99
+ tokens,
100
+ streak: a.streak || 0,
101
+ longestStreak: a.longestStreak || 0,
102
+ linesNet,
103
+ cost: a.estimatedCost || 0,
104
+ daysSinceFirst: a.daysSinceFirst || 0,
105
+ };
106
+ }
107
+
108
+ export function renderProfileCard(aggregate, { handle = '', generatedAt = new Date() } = {}) {
109
+ const t = lifetime(aggregate);
110
+ const lang = topLanguage(aggregate);
111
+ const who = handle ? `@${String(handle).replace(/^@/, '')}` : 'on Claude Code';
112
+ const sub = `${who} · Day ${t.daysSinceFirst} · as of ${generatedAt.toISOString().slice(0, 10)}`;
113
+ const netStr = `${t.linesNet >= 0 ? '+' : '−'}${fmtNum(Math.abs(t.linesNet))}`;
114
+
115
+ // 3 columns × 2 rows of stats.
116
+ const C = [40, 210, 380];
117
+
118
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Claude Code stats">
119
+ <defs>
120
+ <pattern id="dg" width="22" height="22" patternUnits="userSpaceOnUse">
121
+ <circle cx="1" cy="1" r="1" fill="${PALETTE.ink}" opacity="0.06"/>
122
+ </pattern>
123
+ </defs>
124
+ <rect x="3" y="4" width="${W - 6}" height="${H - 7}" fill="${PALETTE.ink}"/>
125
+ <rect x="0.75" y="0.75" width="${W - 7}" height="${H - 9}" fill="${PALETTE.paper}" stroke="${PALETTE.ink}" stroke-width="1.5"/>
126
+ <rect x="0.75" y="0.75" width="${W - 7}" height="${H - 9}" fill="url(#dg)"/>
127
+
128
+ <!-- header -->
129
+ <text x="40" y="50"
130
+ font-family="Space Grotesk, Inter, system-ui, sans-serif"
131
+ font-size="30" font-weight="800" letter-spacing="-1"
132
+ fill="${PALETTE.ink}">Claude Code</text>
133
+ <text x="40" y="72"
134
+ font-family="JetBrains Mono, ui-monospace, monospace"
135
+ font-size="12" fill="${PALETTE.inkMute}">${escapeXml(sub)}</text>
136
+ ${lang ? tapeSticker(W - 150, 28, lang.name, { rotate: 3 }) : ''}
137
+
138
+ <line x1="40" y1="92" x2="${W - 40}" y2="92" stroke="${PALETTE.ink}" stroke-width="1" opacity="0.18"/>
139
+
140
+ <!-- stat grid: row 1 -->
141
+ ${statCell(C[0], 120, 'Time with Claude', fmtHours(t.hours), PALETTE.rust)}
142
+ ${statCell(C[1], 120, 'Sessions', fmtNum(t.sessions))}
143
+ ${statCell(C[2], 120, 'Streak', t.streak ? `${t.streak}d` : '—')}
144
+
145
+ <!-- stat grid: row 2 -->
146
+ ${statCell(C[0], 184, 'Prompts', fmtNum(t.prompts))}
147
+ ${statCell(C[1], 184, 'Tokens', fmtNum(t.tokens))}
148
+ ${statCell(C[2], 184, 'Lines', netStr, PALETTE.grass)}
149
+
150
+ <!-- footer -->
151
+ <text x="40" y="${H - 18}"
152
+ font-family="JetBrains Mono, ui-monospace, monospace"
153
+ font-size="11" fill="${PALETTE.inkFaint}">${escapeXml(`best streak ${t.longestStreak}d · ≈${fmtCost(t.cost)} · claude-rpc v${VERSION}`)}</text>
154
+ </svg>`;
155
+ }
156
+
157
+ // Top-level convenience matching badge.js / card.js shape.
158
+ export function profileCardSvg({ aggregate, handle = '' } = {}) {
159
+ return renderProfileCard(aggregate, { handle });
160
+ }
package/src/scanner.js CHANGED
@@ -7,7 +7,7 @@ import { costFor, pricingKeyFor } from './pricing.js';
7
7
 
8
8
  // Bumping this forces a full re-parse on next scan. Increment whenever the
9
9
  // per-transcript summary schema changes in a way old caches can't satisfy.
10
- const CACHE_VERSION = 2;
10
+ const CACHE_VERSION = 3;
11
11
 
12
12
  // Cap counted gap between consecutive timestamps. Anything larger is treated
13
13
  // as the user walking away — we count only what's plausibly active time.
@@ -175,6 +175,7 @@ export function parseTranscript(filePath) {
175
175
  byWeek: {}, // ISO week key → blankDay
176
176
  byHour: {}, // hour-of-day (0..23) → blankDay
177
177
  fileEdits: {}, // absolute path → edit count
178
+ fileEditTs: {}, // absolute path → most-recent edit timestamp (hotspot aging)
178
179
  // Phase 1 enrichments
179
180
  linesAdded: 0,
180
181
  linesRemoved: 0,
@@ -184,6 +185,7 @@ export function parseTranscript(filePath) {
184
185
  cost: 0, // estimated USD
185
186
  costByModel: {}, // pricing key → USD
186
187
  modelsUsed: {}, // raw model id → assistant turns
188
+ byModel: {}, // pricing key → { turns, tokens, cost } (model split)
187
189
  };
188
190
  const fileSet = new Set();
189
191
  // Records in their original order, retaining timestamps for per-day bucketing.
@@ -210,6 +212,9 @@ export function parseTranscript(filePath) {
210
212
  if (r.type === 'assistant') {
211
213
  const turnModel = r.message?.model || summary.model;
212
214
  const u = r.message?.usage;
215
+ // Per-model split bucket, keyed by pricing key so cost/tokens/turns align.
216
+ const mkey = turnModel ? pricingKeyFor(turnModel) : null;
217
+ const mb = mkey ? (summary.byModel[mkey] ||= { turns: 0, tokens: 0, cost: 0 }) : null;
213
218
  if (u) {
214
219
  summary.inputTokens += u.input_tokens || 0;
215
220
  summary.outputTokens += u.output_tokens || 0;
@@ -221,18 +226,23 @@ export function parseTranscript(filePath) {
221
226
  bucket.cacheReadTokens += u.cache_read_input_tokens || 0;
222
227
  bucket.cacheWriteTokens += u.cache_creation_input_tokens || 0;
223
228
  }
229
+ if (mb) {
230
+ mb.tokens += (u.input_tokens || 0) + (u.output_tokens || 0)
231
+ + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
232
+ }
224
233
  // Per-turn cost — uses this turn's model id, not the session's first-seen one.
225
234
  const turnCost = costFor({ model: turnModel, usage: u });
226
235
  if (turnCost > 0) {
227
236
  summary.cost += turnCost;
228
- const key = pricingKeyFor(turnModel);
229
- summary.costByModel[key] = (summary.costByModel[key] || 0) + turnCost;
237
+ summary.costByModel[mkey] = (summary.costByModel[mkey] || 0) + turnCost;
238
+ if (mb) mb.cost += turnCost;
230
239
  for (const bucket of allBuckets) bucket.cost += turnCost;
231
240
  }
232
241
  }
233
242
  if (turnModel) {
234
243
  if (!summary.model) summary.model = turnModel;
235
244
  summary.modelsUsed[turnModel] = (summary.modelsUsed[turnModel] || 0) + 1;
245
+ if (mb) mb.turns += 1;
236
246
  }
237
247
  const blocks = r.message?.content || [];
238
248
  for (const b of blocks) {
@@ -246,6 +256,7 @@ export function parseTranscript(filePath) {
246
256
  fileSet.add(f);
247
257
  if (EDITING_TOOLS.has(b.name)) {
248
258
  summary.fileEdits[f] = (summary.fileEdits[f] || 0) + 1;
259
+ if (ts && ts > (summary.fileEditTs[f] || 0)) summary.fileEditTs[f] = ts;
249
260
  }
250
261
  }
251
262
  // Code churn — lines added/removed. For Edit, we count
@@ -505,6 +516,7 @@ function aggregateFrom(cache) {
505
516
  byHour: {},
506
517
  byWeekday: {},
507
518
  fileEdits: {},
519
+ fileEditTs: {},
508
520
  streak: 0,
509
521
  longestStreak: 0,
510
522
  daysSinceFirst: 0,
@@ -524,6 +536,8 @@ function aggregateFrom(cache) {
524
536
  estimatedCost: 0,
525
537
  costByModel: {},
526
538
  modelsUsed: {},
539
+ byModel: {},
540
+ modelSplit: [],
527
541
  notifications: 0,
528
542
  generatedAt: Date.now(),
529
543
  _v: CACHE_VERSION,
@@ -553,6 +567,15 @@ function aggregateFrom(cache) {
553
567
  for (const [m, v] of Object.entries(summary.modelsUsed || {})) {
554
568
  agg.modelsUsed[m] = (agg.modelsUsed[m] || 0) + v;
555
569
  }
570
+ for (const [m, v] of Object.entries(summary.byModel || {})) {
571
+ const t = agg.byModel[m] ||= { turns: 0, tokens: 0, cost: 0 };
572
+ t.turns += v.turns || 0;
573
+ t.tokens += v.tokens || 0;
574
+ t.cost += v.cost || 0;
575
+ }
576
+ for (const [f, t] of Object.entries(summary.fileEditTs || {})) {
577
+ if (t > (agg.fileEditTs[f] || 0)) agg.fileEditTs[f] = t;
578
+ }
556
579
  for (const [c, n] of Object.entries(summary.bashCommands || {})) {
557
580
  agg.bashCommands[c] = (agg.bashCommands[c] || 0) + n;
558
581
  }
@@ -690,11 +713,36 @@ function aggregateFrom(cache) {
690
713
  agg.peakHour = bestHour;
691
714
  }
692
715
 
693
- // Top edited files (paths + counts), descending.
716
+ // Top edited files (paths + counts), descending. Carries last-edit time and
717
+ // age in days so the dashboard / insights can flag a hotspot cooling off.
718
+ const nowMs = Date.now();
694
719
  agg.topEditedFiles = Object.entries(agg.fileEdits)
695
720
  .sort((a, b) => b[1] - a[1])
696
721
  .slice(0, 25)
697
- .map(([path, count]) => ({ path, count }));
722
+ .map(([path, count]) => {
723
+ const lastTs = agg.fileEditTs[path] || 0;
724
+ return {
725
+ path,
726
+ count,
727
+ lastEditedTs: lastTs || null,
728
+ daysSinceLastEdit: lastTs ? Math.floor((nowMs - lastTs) / 86_400_000) : null,
729
+ };
730
+ });
731
+
732
+ // Model split — per pricing key, sorted by cost. Each entry carries its share
733
+ // of total cost so the dashboard / card can show "Sonnet · 61% of spend".
734
+ const splitCostTotal = Object.values(agg.byModel).reduce((s, v) => s + (v.cost || 0), 0);
735
+ const splitTokenTotal = Object.values(agg.byModel).reduce((s, v) => s + (v.tokens || 0), 0);
736
+ agg.modelSplit = Object.entries(agg.byModel)
737
+ .map(([model, v]) => ({
738
+ model,
739
+ turns: v.turns || 0,
740
+ tokens: v.tokens || 0,
741
+ cost: v.cost || 0,
742
+ costPct: splitCostTotal > 0 ? (v.cost || 0) / splitCostTotal : 0,
743
+ tokenPct: splitTokenTotal > 0 ? (v.tokens || 0) / splitTokenTotal : 0,
744
+ }))
745
+ .sort((a, b) => b.cost - a.cost);
698
746
 
699
747
  // Languages: bucket file edits by extension via languages.js.
700
748
  for (const [path, count] of Object.entries(agg.fileEdits)) {
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.8.0';
14
+ const BAKED = '0.9.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {