claude-rpc 1.0.2 → 1.1.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 +6 -4
- package/package.json +2 -2
- package/src/calendar.js +1 -1
- package/src/card.js +1 -1
- package/src/cli.js +38 -8
- package/src/default-config.js +1 -1
- package/src/hook.js +4 -87
- package/src/mcp.js +25 -4
- package/src/profile.js +1 -1
- package/src/recap.js +205 -0
- package/src/scanner.js +42 -2
- package/src/server/assets/wrapped.client.js +2 -2
- package/src/session-card.js +1 -1
- package/src/ships.js +91 -0
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
**Discord Rich Presence for [Claude Code](https://claude.com/claude-code)** — your live model, project, tokens, and lifetime stats, driven by the hooks Claude Code already fires.
|
|
17
17
|
|
|
18
|
-
**[claude-rpc.
|
|
18
|
+
**[claude-rpc.com →](https://claude-rpc.com)** — see it in one page.
|
|
19
19
|
|
|
20
20
|
[](#community-totals) [](#community-totals)
|
|
21
21
|
|
|
@@ -53,7 +53,7 @@ That installs `claude-rpc` globally, wires the hooks into Claude Code, and start
|
|
|
53
53
|
**Prefer a one-liner that figures it out for you?**
|
|
54
54
|
|
|
55
55
|
```sh
|
|
56
|
-
curl -fsSL https://claude-rpc.
|
|
56
|
+
curl -fsSL https://claude-rpc.com/install | sh
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
Detects Node (installs the npm package) or falls back to the prebuilt Apple-Silicon binary, then runs `setup` for you.
|
|
@@ -133,6 +133,7 @@ claude-rpc today (today's stats, focused)
|
|
|
133
133
|
claude-rpc week (weekday breakdown)
|
|
134
134
|
claude-rpc preview (every rotation frame rendered with real data)
|
|
135
135
|
claude-rpc insights (3–5 auto-generated lines: trend, peak, hotspot)
|
|
136
|
+
claude-rpc recap --md (standup-ready: yesterday's projects, ships, churn)
|
|
136
137
|
```
|
|
137
138
|
|
|
138
139
|
The web dashboard pushes updates via SSE; the TUI refreshes on a 3-second tick.
|
|
@@ -149,9 +150,9 @@ claude-rpc readme --raw | pbcopy # straight to your clipboard
|
|
|
149
150
|
**Live card + badges, paste once.** With a public profile (`claude-rpc profile set --handle <you> && claude-rpc profile on`), your stats are served as an always-current card (and badges) from the community worker — no `gh`, no gist, nothing to re-run:
|
|
150
151
|
|
|
151
152
|
```md
|
|
152
|
-
[](https://claude-rpc.
|
|
153
|
+
[](https://claude-rpc.com/u/<you>)
|
|
153
154
|
|
|
154
|
-
[](https://claude-rpc.
|
|
155
|
+
[](https://claude-rpc.com/?ref=badge)
|
|
155
156
|
```
|
|
156
157
|
|
|
157
158
|
The card shows tokens, sessions, active hours and streak; badge `metric=` is one of `tokens · sessions · hours · streak` (optional `&label=` to retitle). Both refresh themselves as the daemon flushes your profile (~every 30 min). Your profile page at `/u/<you>` has a one-click "copy" for the whole block.
|
|
@@ -309,6 +310,7 @@ It's a thin bootstrapper — on the first session it just runs `npx claude-rpc@l
|
|
|
309
310
|
| `scan` / `rescan`| Incremental / forced re-parse of `~/.claude/projects` |
|
|
310
311
|
| `backfill <dir>` | Import transcripts from any folder (backup, other machine) |
|
|
311
312
|
| `insights` | Print 3–5 auto-generated lines about your week |
|
|
313
|
+
| `recap` | Standup summary — yesterday's work, paste-ready (`today\|week\|date`, `--md`) |
|
|
312
314
|
| `badge` | Shields-style SVG (`--metric` `--range` `--out` `--gist`) |
|
|
313
315
|
| `card` | Poster-style SVG (`--range year\|month\|week\|all`) |
|
|
314
316
|
| `github-stat` | Embeddable profile stat card (`--handle` `--out` `--gist`) |
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-rpc",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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",
|
|
7
7
|
"author": "Archer Simmons",
|
|
8
|
-
"homepage": "https://
|
|
8
|
+
"homepage": "https://claude-rpc.com",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/rar-file/claude-rpc.git"
|
package/src/calendar.js
CHANGED
|
@@ -86,7 +86,7 @@ export function renderCalendar(aggregate, { weeks = 53, generatedAt = new Date()
|
|
|
86
86
|
<text x="${W - 170}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">less</text>
|
|
87
87
|
${legend}
|
|
88
88
|
<text x="${W - 28}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">more</text>
|
|
89
|
-
<text x="${padX}" y="${H - 8}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">claude-rpc.
|
|
89
|
+
<text x="${padX}" y="${H - 8}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">claude-rpc.com · v${VERSION}</text>
|
|
90
90
|
</svg>`;
|
|
91
91
|
}
|
|
92
92
|
|
package/src/card.js
CHANGED
|
@@ -322,7 +322,7 @@ export function renderCard(aggregate, { range = 'year', generatedAt = new Date()
|
|
|
322
322
|
<!-- ── credits ── -->
|
|
323
323
|
<g transform="translate(${W - 60} ${H - 24})" text-anchor="end">
|
|
324
324
|
<text font-family="JetBrains Mono, ui-monospace, monospace" font-size="10"
|
|
325
|
-
fill="${PALETTE.inkFaint}">${escapeXml(allTimeHours)}h all-time · claude-rpc.
|
|
325
|
+
fill="${PALETTE.inkFaint}">${escapeXml(allTimeHours)}h all-time · claude-rpc.com</text>
|
|
326
326
|
</g>
|
|
327
327
|
</svg>`;
|
|
328
328
|
}
|
package/src/cli.js
CHANGED
|
@@ -858,6 +858,34 @@ function showInsights() {
|
|
|
858
858
|
console.log('');
|
|
859
859
|
}
|
|
860
860
|
|
|
861
|
+
// `claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]` — the
|
|
862
|
+
// standup answer. Defaults to yesterday (with Monday-covers-Friday fallback,
|
|
863
|
+
// see recap.js). --md prints paste-ready markdown; --json the raw numbers.
|
|
864
|
+
async function doRecap(argv) {
|
|
865
|
+
const { buildRecap, renderRecapMarkdown, renderRecapLines, recapTitle } = await import('./recap.js');
|
|
866
|
+
let spec = 'yesterday', md = false, json = false;
|
|
867
|
+
for (const a of argv) {
|
|
868
|
+
if (a === '--md' || a === '--markdown') md = true;
|
|
869
|
+
else if (a === '--json') json = true;
|
|
870
|
+
else if (!a.startsWith('-')) spec = a;
|
|
871
|
+
else return fail(`unknown flag ${a}`, { hint: 'usage: claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]', code: EX_USER_ERROR });
|
|
872
|
+
}
|
|
873
|
+
const aggregate = readAggregate();
|
|
874
|
+
if (!aggregate) return fail('no stats yet', { hint: 'run `claude-rpc scan` to build your history first', code: EX_BAD_STATE });
|
|
875
|
+
const r = buildRecap(aggregate, spec);
|
|
876
|
+
if (!r) return fail(`unknown range: ${spec}`, { hint: 'usage: claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]', code: EX_USER_ERROR });
|
|
877
|
+
if (json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
878
|
+
if (md) { console.log(renderRecapMarkdown(r)); return; }
|
|
879
|
+
console.log('');
|
|
880
|
+
console.log(` ${c.bold}${c.magenta}◆ Recap${c.reset} ${c.dim}— ${recapTitle(r)}${c.reset}`);
|
|
881
|
+
console.log('');
|
|
882
|
+
box('recap', renderRecapLines(r, c));
|
|
883
|
+
console.log('');
|
|
884
|
+
if (process.stdout.isTTY && !r.empty) {
|
|
885
|
+
console.log(` ${c.dim}↗ standup-ready markdown: claude-rpc recap${spec === 'yesterday' ? '' : ` ${spec}`} --md${c.reset}\n`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
861
889
|
function parseBadgeArgs(argv) {
|
|
862
890
|
const out = { metric: 'hours', range: '7d', out: '', gist: false };
|
|
863
891
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1066,7 +1094,7 @@ function doReadme(argv) {
|
|
|
1066
1094
|
const raw = argv.includes('--raw') || argv.includes('--plain');
|
|
1067
1095
|
const cfg = loadConfig();
|
|
1068
1096
|
const endpoint = (cfg.community?.endpoint || 'https://claude-rpc-totals.claude-rpc.workers.dev').replace(/\/+$/, '');
|
|
1069
|
-
const site = 'https://claude-rpc.
|
|
1097
|
+
const site = 'https://claude-rpc.com';
|
|
1070
1098
|
const profile = cfg.profile || {};
|
|
1071
1099
|
const handle = profile.handle;
|
|
1072
1100
|
const live = !!profile.enabled && lb.isValidHandle(handle);
|
|
@@ -1287,7 +1315,7 @@ async function doExport(argv) {
|
|
|
1287
1315
|
// `claude-rpc squad join <code>`
|
|
1288
1316
|
// `claude-rpc squad` / `squad status` → standings for every squad you're in
|
|
1289
1317
|
// `claude-rpc squad leave [id]`
|
|
1290
|
-
// The web flow (claude-rpc.
|
|
1318
|
+
// The web flow (claude-rpc.com + GitHub login) drives the same worker
|
|
1291
1319
|
// endpoints; the CLI authenticates with the community instanceId it already has.
|
|
1292
1320
|
|
|
1293
1321
|
function squadAuth() {
|
|
@@ -1332,7 +1360,7 @@ function squadAuth() {
|
|
|
1332
1360
|
return { cfg, endpoint, instanceId, post, get };
|
|
1333
1361
|
}
|
|
1334
1362
|
|
|
1335
|
-
function squadPageUrl(id) { return `https://claude-rpc.
|
|
1363
|
+
function squadPageUrl(id) { return `https://claude-rpc.com/squad/${id}`; }
|
|
1336
1364
|
|
|
1337
1365
|
function printSquadInvite(squad) {
|
|
1338
1366
|
console.log('');
|
|
@@ -1431,11 +1459,11 @@ async function doSquadCmd(argv) {
|
|
|
1431
1459
|
// one-time code, no browser needed
|
|
1432
1460
|
// claude-rpc link <code> on the NEW machine → claims it, merging this
|
|
1433
1461
|
// install into the same leaderboard identity
|
|
1434
|
-
// The browser fallback lives at claude-rpc.
|
|
1462
|
+
// The browser fallback lives at claude-rpc.com/link (log in with
|
|
1435
1463
|
// GitHub → same code) for when the other machine isn't handy. Claiming
|
|
1436
1464
|
// verifies the profile (✓) and unlocks managing squads from the browser.
|
|
1437
1465
|
|
|
1438
|
-
const LINK_PAGE = 'https://claude-rpc.
|
|
1466
|
+
const LINK_PAGE = 'https://claude-rpc.com/link';
|
|
1439
1467
|
|
|
1440
1468
|
// Mint side: this machine asks the worker for a code. The worker only obliges
|
|
1441
1469
|
// when this install's canonical profile is verified — the ✓ a claim grants
|
|
@@ -1638,7 +1666,7 @@ function profileNextStep() {
|
|
|
1638
1666
|
const p = loadConfig().profile || {};
|
|
1639
1667
|
const next = profileSteps(p).find((s) => !s.done);
|
|
1640
1668
|
if (!next) {
|
|
1641
|
-
console.log(` ${c.dim}→ all set — you're live at${c.reset} ${c.cyan}https://claude-rpc.
|
|
1669
|
+
console.log(` ${c.dim}→ all set — you're live at${c.reset} ${c.cyan}https://claude-rpc.com/u/${encodeURIComponent(p.handle)}${c.reset}`);
|
|
1642
1670
|
} else if (next.key === 'verify') {
|
|
1643
1671
|
console.log(` ${c.dim}→ next: run${c.reset} ${c.cyan}claude-rpc link${c.reset} ${c.dim}on a machine that's already verified, then${c.reset} ${c.cyan}claude-rpc link <code>${c.reset} ${c.dim}here — first machine? ${LINK_PAGE}${c.reset}`);
|
|
1644
1672
|
} else {
|
|
@@ -1649,7 +1677,7 @@ function profileNextStep() {
|
|
|
1649
1677
|
function profileStatus() {
|
|
1650
1678
|
const p = (loadConfig().profile) || {};
|
|
1651
1679
|
const handleOk = lb.isValidHandle(p.handle);
|
|
1652
|
-
const boardUrl = handleOk ? `https://claude-rpc.
|
|
1680
|
+
const boardUrl = handleOk ? `https://claude-rpc.com/u/${encodeURIComponent(p.handle)}` : '';
|
|
1653
1681
|
|
|
1654
1682
|
console.log('');
|
|
1655
1683
|
console.log(` ${c.bold}${c.magenta}◆ profile${c.reset} ${c.dim}— public leaderboard identity${c.reset}`);
|
|
@@ -1776,7 +1804,7 @@ async function profilePublish() {
|
|
|
1776
1804
|
console.log(` ${c.dim}publishing @${cfg.profile.handle} to the board…${c.reset}`);
|
|
1777
1805
|
const r = await flushProfile(cfg);
|
|
1778
1806
|
if (r.ok) {
|
|
1779
|
-
console.log(` ${c.green}✓${c.reset} published — see it at ${c.cyan}https://claude-rpc.
|
|
1807
|
+
console.log(` ${c.green}✓${c.reset} published — see it at ${c.cyan}https://claude-rpc.com/u/${encodeURIComponent(cfg.profile.handle)}${c.reset}`);
|
|
1780
1808
|
} else if (r.reason === 'rate-limited') {
|
|
1781
1809
|
console.log(` ${c.yellow}!${c.reset} rate-limited — already published in the last minute; the board has you`);
|
|
1782
1810
|
} else {
|
|
@@ -2005,6 +2033,7 @@ function help() {
|
|
|
2005
2033
|
['rescan', 'Force re-parse every transcript (ignores cache)'],
|
|
2006
2034
|
['backfill', 'Import transcripts from any folder (e.g. a backup)'],
|
|
2007
2035
|
['insights', 'Auto-generated insights from your history'],
|
|
2036
|
+
['recap', 'Standup summary (yesterday by default; today|week|date, --md for markdown)'],
|
|
2008
2037
|
['badge', 'Render a Shields-style SVG (--metric --range --out --gist)'],
|
|
2009
2038
|
['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
|
|
2010
2039
|
['github-stat', 'Render an embeddable profile stat card (--handle --out --gist)'],
|
|
@@ -2161,6 +2190,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
2161
2190
|
case 'rescan': doScan(true); break;
|
|
2162
2191
|
case 'backfill': doBackfill(process.argv.slice(3)); break;
|
|
2163
2192
|
case 'insights': showInsights(); break;
|
|
2193
|
+
case 'recap': await doRecap(process.argv.slice(3)); break;
|
|
2164
2194
|
case 'badge': await doBadge(process.argv.slice(3)); break;
|
|
2165
2195
|
case 'card': await doCard(process.argv.slice(3)); break;
|
|
2166
2196
|
case 'github-stat': await doGithubStat(process.argv.slice(3)); break;
|
package/src/default-config.js
CHANGED
|
@@ -241,7 +241,7 @@ export const DEFAULT_CONFIG = {
|
|
|
241
241
|
// ?ref=discord lets the landing page attribute installs that originate
|
|
242
242
|
// from a presence card. (When the cwd is a github repo the daemon also
|
|
243
243
|
// prepends a "View on GitHub →" button, so both can show.)
|
|
244
|
-
{ label: "Get claude-rpc →", url: "https://claude-rpc.
|
|
244
|
+
{ label: "Get claude-rpc →", url: "https://claude-rpc.com/?ref=discord" },
|
|
245
245
|
],
|
|
246
246
|
},
|
|
247
247
|
statusIcons: {
|
package/src/hook.js
CHANGED
|
@@ -6,93 +6,10 @@ import { detectLastCommitSubject, detectGitBranch } from './git.js';
|
|
|
6
6
|
import { EVENTS_LOG_PATH } from './paths.js';
|
|
7
7
|
import { loadConfig } from './config.js';
|
|
8
8
|
import { ensureDaemonRunning } from './ensure-daemon.js';
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// Tokenize one command segment the way a shell roughly would for our purposes:
|
|
15
|
-
// strip leading env assignments (FOO=bar) and sudo/time wrappers, drop the path
|
|
16
|
-
// from the leading binary (/usr/bin/git → git), lowercase it.
|
|
17
|
-
function tokenizeSegment(seg) {
|
|
18
|
-
const stripped = seg.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim();
|
|
19
|
-
let toks = stripped.split(/\s+/).filter(Boolean);
|
|
20
|
-
while (toks.length && (toks[0] === 'sudo' || toks[0] === 'time')) toks = toks.slice(1);
|
|
21
|
-
if (toks.length) {
|
|
22
|
-
const slash = toks[0].lastIndexOf('/');
|
|
23
|
-
if (slash !== -1) toks[0] = toks[0].slice(slash + 1);
|
|
24
|
-
toks[0] = toks[0].toLowerCase();
|
|
25
|
-
}
|
|
26
|
-
return toks;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// First real git subcommand, skipping global flags and their values
|
|
30
|
-
// (`git -C /repo -c k=v push` → push).
|
|
31
|
-
function gitSubcommand(args) {
|
|
32
|
-
for (let i = 0; i < args.length; i++) {
|
|
33
|
-
const a = args[i];
|
|
34
|
-
if (a === '-C' || a === '-c') { i++; continue; } // flag that takes a value
|
|
35
|
-
if (a.startsWith('-')) continue;
|
|
36
|
-
return a.toLowerCase();
|
|
37
|
-
}
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// First two gh positionals (noun, verb), skipping global flags so the canonical
|
|
42
|
-
// targeted form `gh -R owner/repo pr create` still classifies. -R/--repo take a
|
|
43
|
-
// value; other globals here don't precede a create, so skipping the rest is safe.
|
|
44
|
-
function ghSubcommand(args) {
|
|
45
|
-
const pos = [];
|
|
46
|
-
for (let i = 0; i < args.length; i++) {
|
|
47
|
-
const a = args[i];
|
|
48
|
-
if (a === '-R' || a === '--repo') { i++; continue; } // flag that takes a value
|
|
49
|
-
if (a.startsWith('-')) continue;
|
|
50
|
-
pos.push(a.toLowerCase());
|
|
51
|
-
if (pos.length === 2) break;
|
|
52
|
-
}
|
|
53
|
-
return { noun: pos[0], verb: pos[1] };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function shipKindForSegment(seg) {
|
|
57
|
-
const toks = tokenizeSegment(seg);
|
|
58
|
-
if (!toks.length) return null;
|
|
59
|
-
if (toks[0] === 'git') {
|
|
60
|
-
const sub = gitSubcommand(toks.slice(1));
|
|
61
|
-
if (sub === 'push') return 'push';
|
|
62
|
-
if (sub === 'commit') return 'commit';
|
|
63
|
-
} else if (toks[0] === 'gh') {
|
|
64
|
-
const { noun, verb } = ghSubcommand(toks.slice(1));
|
|
65
|
-
if (noun === 'pr' && verb === 'create') return 'pr';
|
|
66
|
-
if (noun === 'issue' && verb === 'create') return 'issue';
|
|
67
|
-
if (noun === 'release' && verb === 'create') return 'tag';
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Return the "shipped" kind for a shell command, or null. Exported for tests.
|
|
73
|
-
// Splits on shell separators and only classifies a segment whose *actual*
|
|
74
|
-
// leading command is git/gh — so a quoted mention ("git push later" inside an
|
|
75
|
-
// echo or a commit message) no longer false-fires. Tolerates env prefixes,
|
|
76
|
-
// sudo/time, chained commands, and git global flags.
|
|
77
|
-
//
|
|
78
|
-
// Quoted spans are blanked BEFORE splitting: separators inside quotes
|
|
79
|
-
// (`echo "run git push && rejoice"`) used to create a fake segment whose
|
|
80
|
-
// leading command was git. The real command's own quoted args (`git commit
|
|
81
|
-
// -m "msg"`) classify the same with or without the message text, so blanking
|
|
82
|
-
// is lossless for detection. An unbalanced quote leaves the string untouched.
|
|
83
|
-
export function classifyShip(cmd) {
|
|
84
|
-
const blanked = String(cmd || '')
|
|
85
|
-
.replace(/'[^']*'/g, ' ')
|
|
86
|
-
.replace(/"(?:\\.|[^"\\])*"/g, ' ');
|
|
87
|
-
const segments = blanked.split(/[;&|\n]+/);
|
|
88
|
-
const found = new Set();
|
|
89
|
-
for (const seg of segments) {
|
|
90
|
-
const k = shipKindForSegment(seg);
|
|
91
|
-
if (k) found.add(k);
|
|
92
|
-
}
|
|
93
|
-
for (const kind of SHIP_PRECEDENCE) if (found.has(kind)) return kind;
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
9
|
+
// Ship classification lives in ships.js (shared with the scanner); re-exported
|
|
10
|
+
// here so existing importers/tests keep working.
|
|
11
|
+
import { classifyShip } from './ships.js';
|
|
12
|
+
export { classifyShip };
|
|
96
13
|
|
|
97
14
|
const EVENTS_LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
98
15
|
|
package/src/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// without standing up the transport.
|
|
9
9
|
|
|
10
10
|
import { readAggregate, dayKey } from './scanner.js';
|
|
11
|
+
import { buildRecap, renderRecapMarkdown } from './recap.js';
|
|
11
12
|
import { VERSION } from './version.js';
|
|
12
13
|
|
|
13
14
|
function fmtH(ms) { const h = (ms || 0) / 3_600_000; return h < 1 ? `${Math.round(h * 60)}m` : `${h.toFixed(1)}h`; }
|
|
@@ -75,6 +76,25 @@ export const TOOLS = {
|
|
|
75
76
|
return split.map((m) => `${m.model}: $${(m.cost || 0).toFixed(2)} (${Math.round((m.costPct || 0) * 100)}%) · ${fmtN(m.tokens)} tokens · ${m.turns} turns`).join('\n');
|
|
76
77
|
},
|
|
77
78
|
},
|
|
79
|
+
get_recap: {
|
|
80
|
+
description: 'Standup-ready recap of a day or week of Claude Code work: active time, sessions, projects touched, ships (commits/pushes/PRs), code churn, tokens/cost. Answers "what did I work on yesterday/this week?".',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
range: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: 'today | yesterday | week | YYYY-MM-DD (default yesterday; an inactive yesterday falls back to the most recent active day, so Monday covers Friday)',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
handler(agg, args) {
|
|
92
|
+
const spec = args?.range || 'yesterday';
|
|
93
|
+
const r = buildRecap(agg, spec);
|
|
94
|
+
if (!r) return `Unknown range "${spec}" — use today, yesterday, week, or YYYY-MM-DD.`;
|
|
95
|
+
return renderRecapMarkdown(r);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
78
98
|
};
|
|
79
99
|
|
|
80
100
|
// Build a tools/list payload from the registry.
|
|
@@ -82,7 +102,7 @@ export function toolList() {
|
|
|
82
102
|
return Object.entries(TOOLS).map(([name, t]) => ({
|
|
83
103
|
name,
|
|
84
104
|
description: t.description,
|
|
85
|
-
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
105
|
+
inputSchema: t.inputSchema || { type: 'object', properties: {}, additionalProperties: false },
|
|
86
106
|
}));
|
|
87
107
|
}
|
|
88
108
|
|
|
@@ -90,10 +110,11 @@ export function toolList() {
|
|
|
90
110
|
* Dispatch a tools/call by name. Reads a fresh aggregate per call (cheap).
|
|
91
111
|
* @param {string} name - Tool name (a key of TOOLS).
|
|
92
112
|
* @param {() => object} [getAgg] - Aggregate provider; defaults to readAggregate (injectable for tests).
|
|
113
|
+
* @param {object} [args] - Tool arguments (params.arguments from tools/call).
|
|
93
114
|
* @returns {string} The tool's text result.
|
|
94
115
|
* @throws {Error} If the tool name is unknown.
|
|
95
116
|
*/
|
|
96
|
-
export function callTool(name, getAgg = readAggregate) {
|
|
117
|
+
export function callTool(name, getAgg = readAggregate, args = {}) {
|
|
97
118
|
const t = TOOLS[name];
|
|
98
119
|
if (!t) throw new Error(`unknown tool: ${name}`);
|
|
99
120
|
const agg = getAgg();
|
|
@@ -101,7 +122,7 @@ export function callTool(name, getAgg = readAggregate) {
|
|
|
101
122
|
// a `|| {}` here would render all-zeros, indistinguishable from a genuinely
|
|
102
123
|
// idle day. Surface the same hint every other surface uses instead.
|
|
103
124
|
if (agg == null) return 'No stats yet — run `claude-rpc scan` to build your history.';
|
|
104
|
-
return t.handler(agg);
|
|
125
|
+
return t.handler(agg, args);
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
/**
|
|
@@ -144,7 +165,7 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
|
|
|
144
165
|
if (!name) return replyErr(id, -32602, 'missing tool name');
|
|
145
166
|
if (!TOOLS[name]) return replyErr(id, -32602, `unknown tool: ${name}`);
|
|
146
167
|
try {
|
|
147
|
-
const text = callTool(name);
|
|
168
|
+
const text = callTool(name, undefined, params?.arguments || {});
|
|
148
169
|
return reply(id, { content: [{ type: 'text', text }], isError: false });
|
|
149
170
|
} catch (e) {
|
|
150
171
|
return reply(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
|
package/src/profile.js
CHANGED
|
@@ -135,7 +135,7 @@ export function renderProfileCard(aggregate, { handle = '', generatedAt = new Da
|
|
|
135
135
|
<!-- footer -->
|
|
136
136
|
<text x="40" y="${H - 18}"
|
|
137
137
|
font-family="JetBrains Mono, ui-monospace, monospace"
|
|
138
|
-
font-size="11" fill="${PALETTE.inkFaint}">${escapeXml(`best streak ${t.longestStreak}d · ≈${fmtCost(t.cost)} · claude-rpc.
|
|
138
|
+
font-size="11" fill="${PALETTE.inkFaint}">${escapeXml(`best streak ${t.longestStreak}d · ≈${fmtCost(t.cost)} · claude-rpc.com`)}</text>
|
|
139
139
|
</svg>`;
|
|
140
140
|
}
|
|
141
141
|
|
package/src/recap.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Standup recap — "what did I do yesterday?" answered from aggregate.json.
|
|
2
|
+
// Pure functions of (aggregate, range spec, now): buildRecap assembles the
|
|
3
|
+
// numbers, renderRecapMarkdown/renderRecapLines format them for pasting into
|
|
4
|
+
// a standup message or printing in the terminal. Consumed by `claude-rpc
|
|
5
|
+
// recap` and the MCP `get_recap` tool.
|
|
6
|
+
//
|
|
7
|
+
// Range semantics:
|
|
8
|
+
// today — the current local day
|
|
9
|
+
// yesterday — the previous local day; if it had no activity (weekend,
|
|
10
|
+
// day off), falls back to the most recent active day before
|
|
11
|
+
// today and says so — Monday standups cover Friday.
|
|
12
|
+
// week — the last 7 local days, ending today
|
|
13
|
+
// YYYY-MM-DD — one explicit local day
|
|
14
|
+
import { dayKey } from './scanner.js';
|
|
15
|
+
|
|
16
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
17
|
+
const SHIP_LABELS = [
|
|
18
|
+
['commit', 'commit', 'commits'],
|
|
19
|
+
['push', 'push', 'pushes'],
|
|
20
|
+
['pr', 'PR', 'PRs'],
|
|
21
|
+
['issue', 'issue', 'issues'],
|
|
22
|
+
['tag', 'release', 'releases'],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function fmtDur(ms) {
|
|
26
|
+
const m = Math.round((ms || 0) / 60_000);
|
|
27
|
+
if (m < 1) return '0m';
|
|
28
|
+
if (m < 60) return `${m}m`;
|
|
29
|
+
return `${(m / 60).toFixed(1)}h`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fmtN(n) {
|
|
33
|
+
if (!n) return '0';
|
|
34
|
+
if (n < 1000) return String(Math.round(n));
|
|
35
|
+
if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
|
|
36
|
+
if (n < 1e9) return `${(n / 1e6).toFixed(2)}M`;
|
|
37
|
+
return `${(n / 1e9).toFixed(2)}B`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dayKeyAt(now, offsetDays) {
|
|
41
|
+
const d = new Date(now);
|
|
42
|
+
d.setHours(0, 0, 0, 0);
|
|
43
|
+
d.setDate(d.getDate() - offsetDays);
|
|
44
|
+
return dayKey(d.getTime());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// "Tue 2026-07-01" — manual weekday names so output is locale-stable.
|
|
48
|
+
function prettyDay(key) {
|
|
49
|
+
const [y, m, d] = String(key).split('-').map(Number);
|
|
50
|
+
if (!y || !m || !d) return String(key);
|
|
51
|
+
return `${WEEKDAYS[new Date(y, m - 1, d).getDay()]} ${key}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A day "has activity" when real work landed — active time, prompts, tool
|
|
55
|
+
// calls, sessions, or tokens. Notification-only days (the daemon logging a
|
|
56
|
+
// permission ping) don't count.
|
|
57
|
+
function hasActivity(bucket) {
|
|
58
|
+
if (!bucket) return false;
|
|
59
|
+
return (bucket.activeMs || 0) > 0 || (bucket.userMessages || 0) > 0
|
|
60
|
+
|| (bucket.toolCalls || 0) > 0 || (bucket.sessions || 0) > 0
|
|
61
|
+
|| (bucket.inputTokens || 0) + (bucket.outputTokens || 0)
|
|
62
|
+
+ (bucket.cacheReadTokens || 0) + (bucket.cacheWriteTokens || 0) > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolve a range spec to { label, days, note }. Exported for tests.
|
|
66
|
+
export function resolveRecapRange(spec, agg, now = Date.now()) {
|
|
67
|
+
const byDay = agg?.byDay || {};
|
|
68
|
+
const s = String(spec || 'yesterday').toLowerCase();
|
|
69
|
+
if (s === 'today') return { label: 'today', days: [dayKeyAt(now, 0)] };
|
|
70
|
+
if (s === 'week') {
|
|
71
|
+
const days = [];
|
|
72
|
+
for (let i = 6; i >= 0; i--) days.push(dayKeyAt(now, i));
|
|
73
|
+
return { label: 'last 7 days', days };
|
|
74
|
+
}
|
|
75
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return { label: prettyDay(s), days: [s] };
|
|
76
|
+
if (s !== 'yesterday') return null; // unknown spec — caller reports usage
|
|
77
|
+
const yesterday = dayKeyAt(now, 1);
|
|
78
|
+
if (hasActivity(byDay[yesterday])) return { label: 'yesterday', days: [yesterday] };
|
|
79
|
+
// Walk back for the most recent active day before today (bounded so a
|
|
80
|
+
// fresh install doesn't loop through a year of nothing).
|
|
81
|
+
for (let i = 2; i <= 366; i++) {
|
|
82
|
+
const k = dayKeyAt(now, i);
|
|
83
|
+
if (hasActivity(byDay[k])) {
|
|
84
|
+
return {
|
|
85
|
+
label: 'yesterday',
|
|
86
|
+
days: [k],
|
|
87
|
+
note: `no activity yesterday — showing ${prettyDay(k)}, your most recent active day`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { label: 'yesterday', days: [yesterday] }; // genuinely nothing — renders as empty
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Assemble a recap for a range of days.
|
|
96
|
+
* @param {object} agg - aggregate.json contents
|
|
97
|
+
* @param {string} [spec] - today|yesterday|week|YYYY-MM-DD (default yesterday)
|
|
98
|
+
* @param {number} [now] - injectable clock for tests
|
|
99
|
+
* @returns {object|null} recap data, or null for an unknown spec
|
|
100
|
+
*/
|
|
101
|
+
export function buildRecap(agg, spec = 'yesterday', now = Date.now()) {
|
|
102
|
+
const range = resolveRecapRange(spec, agg, now);
|
|
103
|
+
if (!range) return null;
|
|
104
|
+
const byDay = agg?.byDay || {};
|
|
105
|
+
const r = {
|
|
106
|
+
label: range.label,
|
|
107
|
+
days: range.days,
|
|
108
|
+
from: range.days[0],
|
|
109
|
+
to: range.days[range.days.length - 1],
|
|
110
|
+
note: range.note || null,
|
|
111
|
+
activeMs: 0, sessions: 0, prompts: 0, toolCalls: 0,
|
|
112
|
+
tokens: 0, cost: 0, linesAdded: 0, linesRemoved: 0,
|
|
113
|
+
ships: 0, shipKinds: {}, projects: [],
|
|
114
|
+
};
|
|
115
|
+
const projects = {};
|
|
116
|
+
for (const k of range.days) {
|
|
117
|
+
const d = byDay[k];
|
|
118
|
+
if (!d) continue;
|
|
119
|
+
r.activeMs += d.activeMs || 0;
|
|
120
|
+
r.sessions += d.sessions || 0;
|
|
121
|
+
r.prompts += d.userMessages || 0;
|
|
122
|
+
r.toolCalls += d.toolCalls || 0;
|
|
123
|
+
r.tokens += (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0);
|
|
124
|
+
r.cost += d.cost || 0;
|
|
125
|
+
r.linesAdded += d.linesAdded || 0;
|
|
126
|
+
r.linesRemoved += d.linesRemoved || 0;
|
|
127
|
+
r.ships += d.ships || 0;
|
|
128
|
+
for (const [kind, n] of Object.entries(d.shipKinds || {})) {
|
|
129
|
+
r.shipKinds[kind] = (r.shipKinds[kind] || 0) + n;
|
|
130
|
+
}
|
|
131
|
+
for (const [name, p] of Object.entries(d.projects || {})) {
|
|
132
|
+
const t = projects[name] ||= { name, activeMs: 0, tokens: 0 };
|
|
133
|
+
t.activeMs += p.activeMs || 0;
|
|
134
|
+
t.tokens += p.tokens || 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
r.projects = Object.values(projects)
|
|
138
|
+
.sort((a, b) => (b.activeMs - a.activeMs) || (b.tokens - a.tokens) || a.name.localeCompare(b.name));
|
|
139
|
+
r.empty = !(r.activeMs || r.prompts || r.toolCalls || r.sessions || r.tokens);
|
|
140
|
+
return r;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Human title for a recap: "Tue 2026-07-01 (yesterday)" / "last 7 days (…)". */
|
|
144
|
+
export function recapTitle(r) {
|
|
145
|
+
if (r.days.length > 1) return `${r.label} (${r.from} → ${r.to})`;
|
|
146
|
+
const day = prettyDay(r.from);
|
|
147
|
+
return r.label === 'today' || r.label === 'yesterday' ? `${day} (${r.label})` : day;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function shipPhrase(r) {
|
|
151
|
+
const parts = [];
|
|
152
|
+
for (const [kind, singular, plural] of SHIP_LABELS) {
|
|
153
|
+
const n = r.shipKinds[kind] || 0;
|
|
154
|
+
if (n) parts.push(`${n} ${n === 1 ? singular : plural}`);
|
|
155
|
+
}
|
|
156
|
+
// Counted ships whose kind map got lost (old partial data) still show up.
|
|
157
|
+
if (!parts.length && r.ships) parts.push(`${r.ships}×`);
|
|
158
|
+
return parts.join(' · ');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function projectPhrase(r, max = 6) {
|
|
162
|
+
const shown = r.projects.slice(0, max)
|
|
163
|
+
.map((p) => (p.activeMs >= 60_000 ? `${p.name} (${fmtDur(p.activeMs)})` : p.name));
|
|
164
|
+
const extra = r.projects.length - max;
|
|
165
|
+
if (extra > 0) shown.push(`+${extra} more`);
|
|
166
|
+
return shown.join(', ');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Markdown recap — standup-paste-ready. */
|
|
170
|
+
export function renderRecapMarkdown(r) {
|
|
171
|
+
const lines = [`**Claude Code recap — ${recapTitle(r)}**`, ''];
|
|
172
|
+
if (r.empty) {
|
|
173
|
+
lines.push(`_No Claude Code activity ${r.days.length > 1 ? 'in this range' : `on ${prettyDay(r.from)}`}._`);
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
lines.push(`- **Active:** ${fmtDur(r.activeMs)} across ${r.sessions} session${r.sessions === 1 ? '' : 's'} · ${r.prompts} prompt${r.prompts === 1 ? '' : 's'}`);
|
|
177
|
+
if (r.projects.length) lines.push(`- **Projects:** ${projectPhrase(r)}`);
|
|
178
|
+
if (r.ships) lines.push(`- **Shipped:** ${shipPhrase(r)}`);
|
|
179
|
+
if (r.linesAdded || r.linesRemoved) lines.push(`- **Code:** +${fmtN(r.linesAdded)} / −${fmtN(r.linesRemoved)} lines · ${fmtN(r.toolCalls)} tool calls`);
|
|
180
|
+
lines.push(`- **Tokens:** ${fmtN(r.tokens)} · est. $${r.cost.toFixed(2)}`);
|
|
181
|
+
if (r.note) lines.push('', `_(${r.note})_`);
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Terminal recap — array of lines for the CLI's box renderer.
|
|
187
|
+
* @param {object} r - buildRecap result
|
|
188
|
+
* @param {object} [c] - optional ANSI palette ({bold, dim, cyan, green, reset, …}); omit for plain text
|
|
189
|
+
*/
|
|
190
|
+
export function renderRecapLines(r, c = {}) {
|
|
191
|
+
const B = c.bold || '', D = c.dim || '', C = c.cyan || '', G = c.green || '', R = c.reset || '';
|
|
192
|
+
if (r.empty) {
|
|
193
|
+
const where = r.days.length > 1 ? 'in this range' : `on ${prettyDay(r.from)}`;
|
|
194
|
+
return [`${D}no Claude Code activity ${where}${R}`];
|
|
195
|
+
}
|
|
196
|
+
const lines = [
|
|
197
|
+
`${D}active${R} ${B}${fmtDur(r.activeMs)}${R} · ${r.sessions} session${r.sessions === 1 ? '' : 's'} · ${r.prompts} prompt${r.prompts === 1 ? '' : 's'}`,
|
|
198
|
+
];
|
|
199
|
+
if (r.projects.length) lines.push(`${D}projects${R} ${C}${projectPhrase(r)}${R}`);
|
|
200
|
+
if (r.ships) lines.push(`${D}shipped${R} ${G}${shipPhrase(r)}${R}`);
|
|
201
|
+
if (r.linesAdded || r.linesRemoved) lines.push(`${D}code${R} +${fmtN(r.linesAdded)} / −${fmtN(r.linesRemoved)} lines · ${fmtN(r.toolCalls)} tool calls`);
|
|
202
|
+
lines.push(`${D}tokens${R} ${fmtN(r.tokens)} · est. $${r.cost.toFixed(2)}`);
|
|
203
|
+
if (r.note) lines.push(`${D}(${r.note})${R}`);
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
package/src/scanner.js
CHANGED
|
@@ -4,10 +4,12 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { CLAUDE_PROJECTS, SCAN_CACHE_PATH, AGGREGATE_PATH, DATA_DIR, EVENTS_LOG_PATH } from './paths.js';
|
|
5
5
|
import { languageOf } from './languages.js';
|
|
6
6
|
import { costFor, pricingKeyFor } from './pricing.js';
|
|
7
|
+
import { classifyShip } from './ships.js';
|
|
7
8
|
|
|
8
9
|
// Bumping this forces a full re-parse on next scan. Increment whenever the
|
|
9
10
|
// per-transcript summary schema changes in a way old caches can't satisfy.
|
|
10
|
-
|
|
11
|
+
// v5: per-day ships/shipKinds + per-day project attribution (recap).
|
|
12
|
+
const CACHE_VERSION = 5;
|
|
11
13
|
|
|
12
14
|
// Cap counted gap between consecutive timestamps. Anything larger is treated
|
|
13
15
|
// as the user walking away — we count only what's plausibly active time.
|
|
@@ -129,8 +131,13 @@ function blankDay() {
|
|
|
129
131
|
linesRemoved: 0,
|
|
130
132
|
cost: 0,
|
|
131
133
|
notifications: 0,
|
|
134
|
+
ships: 0,
|
|
132
135
|
firstTs: null,
|
|
133
136
|
lastTs: null,
|
|
137
|
+
// Day buckets may also lazily carry:
|
|
138
|
+
// shipKinds — { push|commit|pr|issue|tag → count } (only when a ship lands)
|
|
139
|
+
// projects — { name → { activeMs, tokens } } (aggregate-level only,
|
|
140
|
+
// attributed at merge time where the file's project is known)
|
|
134
141
|
};
|
|
135
142
|
}
|
|
136
143
|
|
|
@@ -147,6 +154,10 @@ function mergeDay(target, src) {
|
|
|
147
154
|
target.linesRemoved += src.linesRemoved || 0;
|
|
148
155
|
target.cost += src.cost || 0;
|
|
149
156
|
target.notifications += src.notifications || 0;
|
|
157
|
+
target.ships += src.ships || 0;
|
|
158
|
+
for (const [k, n] of Object.entries(src.shipKinds || {})) {
|
|
159
|
+
(target.shipKinds ||= {})[k] = (target.shipKinds[k] || 0) + n;
|
|
160
|
+
}
|
|
150
161
|
if (src.firstTs && (!target.firstTs || src.firstTs < target.firstTs)) target.firstTs = src.firstTs;
|
|
151
162
|
if (src.lastTs && (!target.lastTs || src.lastTs > target.lastTs)) target.lastTs = src.lastTs;
|
|
152
163
|
}
|
|
@@ -236,6 +247,8 @@ function blankTranscriptSummary() {
|
|
|
236
247
|
bashCommands: {}, // first token → count
|
|
237
248
|
webDomains: {}, // hostname → count
|
|
238
249
|
subagents: {}, // subagent_type → count
|
|
250
|
+
ships: 0, // shipped-command count (git commit/push, gh pr/issue/release create)
|
|
251
|
+
shipKinds: {}, // ship kind → count
|
|
239
252
|
cost: 0, // estimated USD
|
|
240
253
|
costByModel: {}, // pricing key → USD
|
|
241
254
|
modelsUsed: {}, // raw model id → assistant turns
|
|
@@ -385,6 +398,13 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
385
398
|
} else if (b.name === 'Bash') {
|
|
386
399
|
const cmd = firstShellToken(input.command);
|
|
387
400
|
if (cmd) summary.bashCommands[cmd] = (summary.bashCommands[cmd] || 0) + 1;
|
|
401
|
+
const shipKind = classifyShip(input.command);
|
|
402
|
+
if (shipKind) {
|
|
403
|
+
summary.ships = (summary.ships || 0) + 1;
|
|
404
|
+
summary.shipKinds[shipKind] = (summary.shipKinds[shipKind] || 0) + 1;
|
|
405
|
+
for (const bucket of allBuckets) bucket.ships = (bucket.ships || 0) + 1;
|
|
406
|
+
if (dayBucket) (dayBucket.shipKinds ||= {})[shipKind] = (dayBucket.shipKinds[shipKind] || 0) + 1;
|
|
407
|
+
}
|
|
388
408
|
} else if (b.name === 'WebFetch' || b.name === 'WebSearch') {
|
|
389
409
|
const host = b.name === 'WebFetch' ? domainOf(input.url) : '';
|
|
390
410
|
if (host) summary.webDomains[host] = (summary.webDomains[host] || 0) + 1;
|
|
@@ -767,6 +787,8 @@ export function aggregateFrom(cache) {
|
|
|
767
787
|
linesAdded: 0,
|
|
768
788
|
linesRemoved: 0,
|
|
769
789
|
linesNet: 0,
|
|
790
|
+
ships: 0,
|
|
791
|
+
shipKinds: {},
|
|
770
792
|
bashCommands: {},
|
|
771
793
|
webDomains: {},
|
|
772
794
|
subagents: {},
|
|
@@ -800,6 +822,10 @@ export function aggregateFrom(cache) {
|
|
|
800
822
|
// — they represent real work done by Claude.
|
|
801
823
|
agg.linesAdded += summary.linesAdded || 0;
|
|
802
824
|
agg.linesRemoved += summary.linesRemoved || 0;
|
|
825
|
+
agg.ships += summary.ships || 0;
|
|
826
|
+
for (const [k, n] of Object.entries(summary.shipKinds || {})) {
|
|
827
|
+
agg.shipKinds[k] = (agg.shipKinds[k] || 0) + n;
|
|
828
|
+
}
|
|
803
829
|
agg.estimatedCost += summary.cost || 0;
|
|
804
830
|
for (const [m, v] of Object.entries(summary.costByModel || {})) {
|
|
805
831
|
agg.costByModel[m] = (agg.costByModel[m] || 0) + v;
|
|
@@ -848,6 +874,10 @@ export function aggregateFrom(cache) {
|
|
|
848
874
|
target.linesAdded += src.linesAdded || 0;
|
|
849
875
|
target.linesRemoved += src.linesRemoved || 0;
|
|
850
876
|
target.cost += src.cost || 0;
|
|
877
|
+
target.ships += src.ships || 0;
|
|
878
|
+
for (const [kind, n] of Object.entries(src.shipKinds || {})) {
|
|
879
|
+
(target.shipKinds ||= {})[kind] = (target.shipKinds[kind] || 0) + n;
|
|
880
|
+
}
|
|
851
881
|
}
|
|
852
882
|
};
|
|
853
883
|
mergeSubBuckets(summary.byDay, agg.byDay);
|
|
@@ -882,7 +912,17 @@ export function aggregateFrom(cache) {
|
|
|
882
912
|
if (summary.lastTs) agg.lastTs = agg.lastTs ? Math.max(agg.lastTs, summary.lastTs) : summary.lastTs;
|
|
883
913
|
// Full per-day/week/hour merge for top-level sessions.
|
|
884
914
|
for (const [k, day] of Object.entries(summary.byDay || {})) {
|
|
885
|
-
|
|
915
|
+
const target = agg.byDay[k] ||= blankDay();
|
|
916
|
+
mergeDay(target, day);
|
|
917
|
+
// Per-day project attribution — only possible here, where the whole
|
|
918
|
+
// file's project is known. Presence in the map means "touched that
|
|
919
|
+
// day", even if activeMs rounded to 0.
|
|
920
|
+
if (summary.project) {
|
|
921
|
+
const pm = (target.projects ||= {});
|
|
922
|
+
const p = pm[summary.project] ||= { activeMs: 0, tokens: 0 };
|
|
923
|
+
p.activeMs += day.activeMs || 0;
|
|
924
|
+
p.tokens += (day.inputTokens || 0) + (day.outputTokens || 0) + (day.cacheReadTokens || 0) + (day.cacheWriteTokens || 0);
|
|
925
|
+
}
|
|
886
926
|
}
|
|
887
927
|
for (const [k, w] of Object.entries(summary.byWeek || {})) {
|
|
888
928
|
mergeDay(agg.byWeek[k] ||= blankDay(), w);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// Landing-page CTA used by the finale share. The dashboard is served on
|
|
21
21
|
// localhost, so sharing `location.href` hands the recipient a dead link —
|
|
22
22
|
// the finale shares this public URL + a stats summary instead.
|
|
23
|
-
const LANDING = 'https://claude-rpc.
|
|
23
|
+
const LANDING = 'https://claude-rpc.com/?ref=wrapped';
|
|
24
24
|
let _shareText = ''; // built in the finale slide, consumed by wireFinale
|
|
25
25
|
|
|
26
26
|
// ── build the story slides from the wrapped payload ──────────
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
${cell('Lines', (d.linesNet >= 0 ? '+' : '−') + fmtNum(Math.abs(d.linesNet)), 'grass')}
|
|
132
132
|
${cell('Hotspot', d.hotspot ? esc(d.hotspot.name) : '—')}
|
|
133
133
|
</div>
|
|
134
|
-
<div class="foot">made with claude-rpc · claude-rpc.
|
|
134
|
+
<div class="foot">made with claude-rpc · claude-rpc.com</div>
|
|
135
135
|
</div>
|
|
136
136
|
<div class="actions">
|
|
137
137
|
<button class="btn primary" id="w-replay">↺ replay</button>
|
package/src/session-card.js
CHANGED
|
@@ -60,7 +60,7 @@ export function renderSessionCard(vars = {}, { generatedAt = new Date() } = {})
|
|
|
60
60
|
${statCell(C[1], 184, 'Est. cost', String(cost), PALETTE.blurple)}
|
|
61
61
|
${statCell(C[2], 184, 'Reads', String(v.filesRead ?? 0))}
|
|
62
62
|
|
|
63
|
-
<text x="40" y="${H - 16}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="10" fill="${PALETTE.inkFaint}">${escapeXml(`${v.currentFilePretty ? 'last: ' + v.currentFilePretty + ' · ' : ''}claude-rpc.
|
|
63
|
+
<text x="40" y="${H - 16}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="10" fill="${PALETTE.inkFaint}">${escapeXml(`${v.currentFilePretty ? 'last: ' + v.currentFilePretty + ' · ' : ''}claude-rpc.com · v${VERSION}`)}</text>
|
|
64
64
|
</svg>`;
|
|
65
65
|
}
|
|
66
66
|
|
package/src/ships.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// "Ship" classification — deciding whether a shell command published work to
|
|
2
|
+
// the world (commit, push, PR, issue, release). Shared by the hook (live
|
|
3
|
+
// celebration frame) and the scanner (per-day ship counts in aggregate.json),
|
|
4
|
+
// so both surfaces agree on what counts as shipping.
|
|
5
|
+
|
|
6
|
+
// Precedence when a command ships more than one way (`git commit && git push`
|
|
7
|
+
// → push). Highest first.
|
|
8
|
+
const SHIP_PRECEDENCE = ['push', 'commit', 'pr', 'issue', 'tag'];
|
|
9
|
+
|
|
10
|
+
// Tokenize one command segment the way a shell roughly would for our purposes:
|
|
11
|
+
// strip leading env assignments (FOO=bar) and sudo/time wrappers, drop the path
|
|
12
|
+
// from the leading binary (/usr/bin/git → git), lowercase it.
|
|
13
|
+
function tokenizeSegment(seg) {
|
|
14
|
+
const stripped = seg.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim();
|
|
15
|
+
let toks = stripped.split(/\s+/).filter(Boolean);
|
|
16
|
+
while (toks.length && (toks[0] === 'sudo' || toks[0] === 'time')) toks = toks.slice(1);
|
|
17
|
+
if (toks.length) {
|
|
18
|
+
const slash = toks[0].lastIndexOf('/');
|
|
19
|
+
if (slash !== -1) toks[0] = toks[0].slice(slash + 1);
|
|
20
|
+
toks[0] = toks[0].toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
return toks;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// First real git subcommand, skipping global flags and their values
|
|
26
|
+
// (`git -C /repo -c k=v push` → push).
|
|
27
|
+
function gitSubcommand(args) {
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
const a = args[i];
|
|
30
|
+
if (a === '-C' || a === '-c') { i++; continue; } // flag that takes a value
|
|
31
|
+
if (a.startsWith('-')) continue;
|
|
32
|
+
return a.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// First two gh positionals (noun, verb), skipping global flags so the canonical
|
|
38
|
+
// targeted form `gh -R owner/repo pr create` still classifies. -R/--repo take a
|
|
39
|
+
// value; other globals here don't precede a create, so skipping the rest is safe.
|
|
40
|
+
function ghSubcommand(args) {
|
|
41
|
+
const pos = [];
|
|
42
|
+
for (let i = 0; i < args.length; i++) {
|
|
43
|
+
const a = args[i];
|
|
44
|
+
if (a === '-R' || a === '--repo') { i++; continue; } // flag that takes a value
|
|
45
|
+
if (a.startsWith('-')) continue;
|
|
46
|
+
pos.push(a.toLowerCase());
|
|
47
|
+
if (pos.length === 2) break;
|
|
48
|
+
}
|
|
49
|
+
return { noun: pos[0], verb: pos[1] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shipKindForSegment(seg) {
|
|
53
|
+
const toks = tokenizeSegment(seg);
|
|
54
|
+
if (!toks.length) return null;
|
|
55
|
+
if (toks[0] === 'git') {
|
|
56
|
+
const sub = gitSubcommand(toks.slice(1));
|
|
57
|
+
if (sub === 'push') return 'push';
|
|
58
|
+
if (sub === 'commit') return 'commit';
|
|
59
|
+
} else if (toks[0] === 'gh') {
|
|
60
|
+
const { noun, verb } = ghSubcommand(toks.slice(1));
|
|
61
|
+
if (noun === 'pr' && verb === 'create') return 'pr';
|
|
62
|
+
if (noun === 'issue' && verb === 'create') return 'issue';
|
|
63
|
+
if (noun === 'release' && verb === 'create') return 'tag';
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Return the "shipped" kind for a shell command, or null. Exported for tests.
|
|
69
|
+
// Splits on shell separators and only classifies a segment whose *actual*
|
|
70
|
+
// leading command is git/gh — so a quoted mention ("git push later" inside an
|
|
71
|
+
// echo or a commit message) no longer false-fires. Tolerates env prefixes,
|
|
72
|
+
// sudo/time, chained commands, and git global flags.
|
|
73
|
+
//
|
|
74
|
+
// Quoted spans are blanked BEFORE splitting: separators inside quotes
|
|
75
|
+
// (`echo "run git push && rejoice"`) used to create a fake segment whose
|
|
76
|
+
// leading command was git. The real command's own quoted args (`git commit
|
|
77
|
+
// -m "msg"`) classify the same with or without the message text, so blanking
|
|
78
|
+
// is lossless for detection. An unbalanced quote leaves the string untouched.
|
|
79
|
+
export function classifyShip(cmd) {
|
|
80
|
+
const blanked = String(cmd || '')
|
|
81
|
+
.replace(/'[^']*'/g, ' ')
|
|
82
|
+
.replace(/"(?:\\.|[^"\\])*"/g, ' ');
|
|
83
|
+
const segments = blanked.split(/[;&|\n]+/);
|
|
84
|
+
const found = new Set();
|
|
85
|
+
for (const seg of segments) {
|
|
86
|
+
const k = shipKindForSegment(seg);
|
|
87
|
+
if (k) found.add(k);
|
|
88
|
+
}
|
|
89
|
+
for (const kind of SHIP_PRECEDENCE) if (found.has(kind)) return kind;
|
|
90
|
+
return null;
|
|
91
|
+
}
|