claude-rpc 0.8.1 → 0.9.1
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 +2 -0
- package/package.json +1 -1
- package/src/cli.js +37 -0
- package/src/default-config.js +19 -8
- package/src/format.js +62 -7
- package/src/hook.js +25 -9
- package/src/profile.js +160 -0
- package/src/scanner.js +53 -5
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Your live model, project, current tool, tokens, and lifetime stats — in your D
|
|
|
21
21
|
[](https://claude.com/claude-code)
|
|
22
22
|
[](https://discord.com/developers/docs/topics/rpc)
|
|
23
23
|
[](https://github.com/rar-file/claude-rpc/releases/latest)
|
|
24
|
+
[](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
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/default-config.js
CHANGED
|
@@ -21,12 +21,14 @@ export const DEFAULT_CONFIG = {
|
|
|
21
21
|
// when Claude isn't open, the Discord presence should disappear quickly.
|
|
22
22
|
// The SessionEnd hook short-circuits this — see hook.js + format.applyIdle.
|
|
23
23
|
staleSessionMin: 5,
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
24
|
+
// Closing the terminal kills Claude Code without firing its SessionEnd hook,
|
|
25
|
+
// so the daemon can't tell "closed" from "paused" — it only sees the
|
|
26
|
+
// transcript stop. Default false: clear the card ~90-120s after the
|
|
27
|
+
// transcript goes quiet, so a closed terminal doesn't leave a stale card up.
|
|
28
|
+
// Set true to instead linger as 'idle' until staleSessionMin (keeps the card
|
|
29
|
+
// up through short pauses, at the cost of a closed terminal showing idle for
|
|
30
|
+
// up to staleSessionMin minutes before clearing).
|
|
31
|
+
idleWhenOpen: false,
|
|
30
32
|
// When true, the daemon CLEARS Discord activity entirely once the state
|
|
31
33
|
// goes stale — your profile shows nothing instead of an "Away" frame.
|
|
32
34
|
hideWhenStale: true,
|
|
@@ -87,6 +89,11 @@ export const DEFAULT_CONFIG = {
|
|
|
87
89
|
details: "Working in {project}",
|
|
88
90
|
state: "{currentToolPretty} · {currentFilePretty} · {toolElapsed} · {tokensLabel}",
|
|
89
91
|
largeImageText: "Working on a {fileLang} file",
|
|
92
|
+
rotation: [
|
|
93
|
+
// Pops in for ~5min when the session crosses an hour milestone, then
|
|
94
|
+
// the `requires` gate drops it and we're back to the single frame.
|
|
95
|
+
{ details: "{sessionMilestoneLabel} · {project}", state: "{tokensLabel} · {messagesLabel}", requires: ["sessionMilestoneHit"] },
|
|
96
|
+
],
|
|
90
97
|
},
|
|
91
98
|
thinking: {
|
|
92
99
|
details: "Thinking in {project}",
|
|
@@ -99,7 +106,9 @@ export const DEFAULT_CONFIG = {
|
|
|
99
106
|
largeImageText: "Compacting · {compactTriggerLabel}",
|
|
100
107
|
},
|
|
101
108
|
shipped: {
|
|
102
|
-
|
|
109
|
+
// {justShippedLabel} adapts to the action: "Pushed to main",
|
|
110
|
+
// "Committed on feat/x", "Opened a pull request", "Opened an issue".
|
|
111
|
+
details: "{justShippedLabel} · {project}",
|
|
103
112
|
state: "{lastCommit}",
|
|
104
113
|
largeImageText: "{justShippedLabel}",
|
|
105
114
|
},
|
|
@@ -115,9 +124,11 @@ export const DEFAULT_CONFIG = {
|
|
|
115
124
|
rotation: [
|
|
116
125
|
{ details: "This week · {weekHours}", state: "{weekPromptsLabel} · {weekTokensFmt} tokens", requires: ["weekActiveMs"] },
|
|
117
126
|
{ details: "{streakLabel}", state: "{daysSinceFirstLabel} · {allSessionsLabel}", requires: ["streakIsMilestone"] },
|
|
118
|
-
{ details: "Hotspot · {topEditedFile}", state: "{topEditedCountLabel}
|
|
127
|
+
{ details: "Hotspot · {topEditedFile}", state: "{topEditedCountLabel} · {topEditedAgeLabel}", requires: ["topEditedCount"] },
|
|
128
|
+
{ details: "Model split", state: "{modelSplitLabel}", requires: ["modelSplitLabel"] },
|
|
119
129
|
{ details: "{allHours} on Claude all-time", state: "{allSessionsLabel} · {allMessagesFmt} prompts", requires: ["allSessions"] },
|
|
120
130
|
{ details: "Lifetime · {allTokensFmt} tokens", state: "{allToolsFmt} tool calls · {allFilesFmt} files", requires: ["allTools"] },
|
|
131
|
+
{ details: "{allFreshTokensFmt} fresh tokens", state: "{allCachePctLabel}", requires: ["allCachePctLabel"] },
|
|
121
132
|
{ details: "Code churn · {linesAddedFmt} added", state: "{linesNetFmt} net · {topLanguage}", requires: ["topLanguage"] },
|
|
122
133
|
{ details: "Cost · {todayCostFmt} today", state: "{allCostFmt} all-time", requires: ["allCost"] },
|
|
123
134
|
],
|
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,
|
|
@@ -682,12 +736,13 @@ export function applyIdle(state, cfg = {}) {
|
|
|
682
736
|
const idleMs = (cfg.idleThresholdSec || 60) * 1000;
|
|
683
737
|
const staleMs = Math.max(60_000, (cfg.staleSessionMin || 5) * 60 * 1000);
|
|
684
738
|
const notificationMs = (cfg.notificationWindowSec || 8) * 1000;
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
739
|
+
// Closing the terminal kills Claude Code without firing SessionEnd, so the
|
|
740
|
+
// only passive "is it gone?" signal is "no transcript is being written".
|
|
741
|
+
// DEFAULT (false): clear the card within ~90-120s of the transcript going
|
|
742
|
+
// quiet — a closed terminal shouldn't leave a card up for 5 minutes. Opt in
|
|
743
|
+
// with idleWhenOpen:true to instead linger as 'idle' until the staleMs
|
|
744
|
+
// backstop (keeps the card up through short pauses with the terminal open).
|
|
745
|
+
const idleWhenOpen = cfg.idleWhenOpen === true;
|
|
691
746
|
|
|
692
747
|
// Authoritative close signal from the SessionEnd hook — trust it instead
|
|
693
748
|
// of waiting on staleSessionMin. Any other hook clears the flag, so a
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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/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, '&')
|
|
30
|
+
.replace(/</g, '<')
|
|
31
|
+
.replace(/>/g, '>')
|
|
32
|
+
.replace(/"/g, '"');
|
|
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 =
|
|
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
|
-
|
|
229
|
-
|
|
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]) =>
|
|
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)) {
|