claude-rpc 1.0.3 → 1.1.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/calendar.js +2 -1
- package/src/card.js +2 -2
- package/src/cli.js +102 -14
- package/src/daemon.js +46 -15
- package/src/fmt.js +8 -0
- package/src/format.js +4 -0
- package/src/hook.js +30 -89
- package/src/mcp.js +25 -4
- package/src/nudge.js +25 -14
- package/src/profile.js +2 -2
- package/src/recap.js +205 -0
- package/src/scanner.js +71 -5
- package/src/session-card.js +2 -1
- package/src/ships.js +91 -0
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -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.
|
|
@@ -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
package/src/calendar.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `claude-rpc calendar --out cal.svg [--gist]`.
|
|
4
4
|
|
|
5
5
|
import { dayKey } from './scanner.js';
|
|
6
|
+
import { localDateStamp } from './fmt.js';
|
|
6
7
|
import { VERSION } from './version.js';
|
|
7
8
|
|
|
8
9
|
const PALETTE = {
|
|
@@ -80,7 +81,7 @@ export function renderCalendar(aggregate, { weeks = 53, generatedAt = new Date()
|
|
|
80
81
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Claude Code activity calendar">
|
|
81
82
|
<rect width="${W}" height="${H}" fill="${PALETTE.paper}"/>
|
|
82
83
|
<text x="${padX}" y="28" font-family="Space Grotesk, Inter, system-ui, sans-serif" font-size="20" font-weight="800" fill="${PALETTE.ink}">a year on Claude Code</text>
|
|
83
|
-
<text x="${padX}" y="44" font-family="JetBrains Mono, ui-monospace, monospace" font-size="11" fill="${PALETTE.inkMute}">${escapeXml(activeDays)} active days · ${escapeXml(totalHours)}h · as of ${generatedAt
|
|
84
|
+
<text x="${padX}" y="44" font-family="JetBrains Mono, ui-monospace, monospace" font-size="11" fill="${PALETTE.inkMute}">${escapeXml(activeDays)} active days · ${escapeXml(totalHours)}h · as of ${localDateStamp(generatedAt)}</text>
|
|
84
85
|
${monthLabels}
|
|
85
86
|
${cells}
|
|
86
87
|
<text x="${W - 170}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">less</text>
|
package/src/card.js
CHANGED
|
@@ -15,7 +15,7 @@ import { dayKey } from './scanner.js';
|
|
|
15
15
|
import { fmtCost } from './pricing.js';
|
|
16
16
|
import { rangeToDays, rangeLabel, pickWindow } from './badge.js';
|
|
17
17
|
import { VERSION } from './version.js';
|
|
18
|
-
import { fmtNum, fmtHours } from './fmt.js';
|
|
18
|
+
import { fmtNum, fmtHours, localDateStamp } from './fmt.js';
|
|
19
19
|
|
|
20
20
|
const W = 880;
|
|
21
21
|
const H = 540;
|
|
@@ -237,7 +237,7 @@ export function renderCard(aggregate, { range = 'year', generatedAt = new Date()
|
|
|
237
237
|
const linesNet = r.linesAdded - r.linesRemoved;
|
|
238
238
|
const allTimeHours = ((aggregate?.activeMs || 0) / 3_600_000).toFixed(1);
|
|
239
239
|
|
|
240
|
-
const subtitle = `${escapeXml(r.daysActive)} active days / ${escapeXml(rangeLabel(range))} ending ${escapeXml(generatedAt
|
|
240
|
+
const subtitle = `${escapeXml(r.daysActive)} active days / ${escapeXml(rangeLabel(range))} ending ${escapeXml(localDateStamp(generatedAt))}`;
|
|
241
241
|
|
|
242
242
|
// Layout grid
|
|
243
243
|
// Title block 80 → W-80
|
package/src/cli.js
CHANGED
|
@@ -116,6 +116,19 @@ function takeValue(v, flag) {
|
|
|
116
116
|
return v;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Expand `--flag=value` into two tokens so every takeValue-based parser
|
|
120
|
+
// accepts the escape hatch takeValue's error message promises. Split on the
|
|
121
|
+
// FIRST `=` only — values may contain their own (`--template={a}={b}`).
|
|
122
|
+
function splitEqArgs(argv) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const a of argv) {
|
|
125
|
+
const eq = typeof a === 'string' && a.startsWith('--') ? a.indexOf('=') : -1;
|
|
126
|
+
if (eq > 2) out.push(a.slice(0, eq), a.slice(eq + 1));
|
|
127
|
+
else out.push(a);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
function startDaemon({ quiet = false } = {}) {
|
|
120
133
|
const pid = daemonAlive();
|
|
121
134
|
if (pid) {
|
|
@@ -241,8 +254,9 @@ function renderHourHistogram(byHour, opts = {}) {
|
|
|
241
254
|
const ch = heightChars[idx];
|
|
242
255
|
colored.push(h === opts.peakHour ? `${c.bold}${heat(1)}${ch}${c.reset}` : `${heat(ms / max)}${ch}${c.reset}`);
|
|
243
256
|
}
|
|
244
|
-
// Hour labels under every 3rd hour
|
|
245
|
-
|
|
257
|
+
// Hour labels under every 3rd hour — 3-char pitch to match the 1-char-per-
|
|
258
|
+
// hour bars above (a 4-char pitch drifted the labels a column per group).
|
|
259
|
+
const labels = '00 03 06 09 12 15 18 21';
|
|
246
260
|
return [
|
|
247
261
|
` ${colored.join('')}`,
|
|
248
262
|
` ${c.dim}${labels}${c.reset}`,
|
|
@@ -302,8 +316,26 @@ function renderHeatmap(byDay, days = 91) {
|
|
|
302
316
|
lastMonth = d.getMonth();
|
|
303
317
|
}
|
|
304
318
|
}
|
|
305
|
-
// Build header
|
|
306
|
-
|
|
319
|
+
// Build header on the SAME 2-chars-per-column pitch as the body rows
|
|
320
|
+
// (joining with a space drifted month labels a column per week, ~6 columns
|
|
321
|
+
// off by the right edge — and 2-char truncation made Mar/May both "Ma").
|
|
322
|
+
// Full 3-letter names are stamped into a char grid; months are always ≥4
|
|
323
|
+
// columns apart so an overhanging name can't collide with the next label.
|
|
324
|
+
const headerChars = new Array(cols * 2).fill(' ');
|
|
325
|
+
const stamps = [];
|
|
326
|
+
labelRow.forEach((s, col) => {
|
|
327
|
+
const name = String(s).trim(); // labelRow is prefilled with ' ' blanks
|
|
328
|
+
if (name) stamps.push({ name, col });
|
|
329
|
+
});
|
|
330
|
+
for (let i = 0; i < stamps.length; i++) {
|
|
331
|
+
const { name, col } = stamps[i];
|
|
332
|
+
const next = stamps[i + 1];
|
|
333
|
+
// Window-edge crowding: a month with a single week in view sits right
|
|
334
|
+
// next to the following month's label — yield to the newer month.
|
|
335
|
+
if (next && next.col * 2 < col * 2 + name.length + 1) continue;
|
|
336
|
+
for (let k = 0; k < name.length && col * 2 + k < headerChars.length; k++) headerChars[col * 2 + k] = name[k];
|
|
337
|
+
}
|
|
338
|
+
const header = headerChars.join('').replace(/\s+$/, '');
|
|
307
339
|
|
|
308
340
|
const dayLabels = [' ', 'M', ' ', 'W', ' ', 'F', ' '];
|
|
309
341
|
const lines = [` ${c.dim}${header}${c.reset}`];
|
|
@@ -858,7 +890,36 @@ function showInsights() {
|
|
|
858
890
|
console.log('');
|
|
859
891
|
}
|
|
860
892
|
|
|
893
|
+
// `claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]` — the
|
|
894
|
+
// standup answer. Defaults to yesterday (with Monday-covers-Friday fallback,
|
|
895
|
+
// see recap.js). --md prints paste-ready markdown; --json the raw numbers.
|
|
896
|
+
async function doRecap(argv) {
|
|
897
|
+
const { buildRecap, renderRecapMarkdown, renderRecapLines, recapTitle } = await import('./recap.js');
|
|
898
|
+
let spec = 'yesterday', md = false, json = false;
|
|
899
|
+
for (const a of argv) {
|
|
900
|
+
if (a === '--md' || a === '--markdown') md = true;
|
|
901
|
+
else if (a === '--json') json = true;
|
|
902
|
+
else if (!a.startsWith('-')) spec = a;
|
|
903
|
+
else return fail(`unknown flag ${a}`, { hint: 'usage: claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]', code: EX_USER_ERROR });
|
|
904
|
+
}
|
|
905
|
+
const aggregate = readAggregate();
|
|
906
|
+
if (!aggregate) return fail('no stats yet', { hint: 'run `claude-rpc scan` to build your history first', code: EX_BAD_STATE });
|
|
907
|
+
const r = buildRecap(aggregate, spec);
|
|
908
|
+
if (!r) return fail(`unknown range: ${spec}`, { hint: 'usage: claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]', code: EX_USER_ERROR });
|
|
909
|
+
if (json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
910
|
+
if (md) { console.log(renderRecapMarkdown(r)); return; }
|
|
911
|
+
console.log('');
|
|
912
|
+
console.log(` ${c.bold}${c.magenta}◆ Recap${c.reset} ${c.dim}— ${recapTitle(r)}${c.reset}`);
|
|
913
|
+
console.log('');
|
|
914
|
+
box('recap', renderRecapLines(r, c));
|
|
915
|
+
console.log('');
|
|
916
|
+
if (process.stdout.isTTY && !r.empty) {
|
|
917
|
+
console.log(` ${c.dim}↗ standup-ready markdown: claude-rpc recap${spec === 'yesterday' ? '' : ` ${spec}`} --md${c.reset}\n`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
861
921
|
function parseBadgeArgs(argv) {
|
|
922
|
+
argv = splitEqArgs(argv);
|
|
862
923
|
const out = { metric: 'hours', range: '7d', out: '', gist: false };
|
|
863
924
|
for (let i = 0; i < argv.length; i++) {
|
|
864
925
|
const a = argv[i];
|
|
@@ -893,11 +954,18 @@ async function doBadge(argv) {
|
|
|
893
954
|
// Publish the rendered badge to a GitHub gist and emit the README-ready
|
|
894
955
|
// markdown snippet. First successful publish records id+owner in
|
|
895
956
|
// config.json so subsequent runs UPDATE that gist (raw URL stays stable).
|
|
896
|
-
async function publishBadgeToGist(svg, opts) {
|
|
957
|
+
async function publishBadgeToGist(svg, opts, kind = 'badge') {
|
|
897
958
|
const { publishGistFile, gistMarkdown } = await import('./gist.js');
|
|
898
959
|
const cfg = loadConfig();
|
|
899
960
|
const stored = cfg.gist || {};
|
|
900
|
-
|
|
961
|
+
// One shared gist, but a filename PER ARTIFACT KIND — badge, calendar, and
|
|
962
|
+
// github-stat used to all write `claude.svg`, so publishing one silently
|
|
963
|
+
// replaced whichever the user had embedded. The legacy single `filename`
|
|
964
|
+
// stays with the badge (the original artifact); other kinds get their own
|
|
965
|
+
// stable file the first time they publish.
|
|
966
|
+
const files = stored.files || {};
|
|
967
|
+
const filename = files[kind]
|
|
968
|
+
|| (kind === 'badge' ? (stored.filename || 'claude.svg') : `claude-${kind}.svg`);
|
|
901
969
|
try {
|
|
902
970
|
const result = await publishGistFile({
|
|
903
971
|
svg,
|
|
@@ -915,8 +983,11 @@ async function publishBadgeToGist(svg, opts) {
|
|
|
915
983
|
...(userCfg.gist || {}),
|
|
916
984
|
id: result.id,
|
|
917
985
|
owner: result.owner,
|
|
918
|
-
filename,
|
|
986
|
+
files: { ...((userCfg.gist || {}).files || {}), [kind]: filename },
|
|
919
987
|
};
|
|
988
|
+
// Keep the legacy field pointing at the badge so an older CLI version
|
|
989
|
+
// reading `gist.filename` still updates the right file.
|
|
990
|
+
if (kind === 'badge') userCfg.gist.filename = filename;
|
|
920
991
|
if (stored.public !== undefined) userCfg.gist.public = stored.public;
|
|
921
992
|
writeUserConfig(userCfg);
|
|
922
993
|
const wasUpdate = !!stored.id;
|
|
@@ -940,6 +1011,7 @@ async function publishBadgeToGist(svg, opts) {
|
|
|
940
1011
|
// for a range (year / month / week / all-time). Output is SVG only;
|
|
941
1012
|
// screenshot or convert to PNG offline if needed.
|
|
942
1013
|
function parseCardArgs(argv) {
|
|
1014
|
+
argv = splitEqArgs(argv);
|
|
943
1015
|
const out = { range: 'year', out: '' };
|
|
944
1016
|
for (let i = 0; i < argv.length; i++) {
|
|
945
1017
|
const a = argv[i];
|
|
@@ -970,6 +1042,7 @@ async function doCard(argv) {
|
|
|
970
1042
|
// for a profile README. `--gist` publishes it to a gist (raw URL stays stable
|
|
971
1043
|
// across re-runs) so the README image auto-refreshes when you re-run it.
|
|
972
1044
|
function parseGithubStatArgs(argv) {
|
|
1045
|
+
argv = splitEqArgs(argv);
|
|
973
1046
|
const out = { out: '', gist: false, handle: '' };
|
|
974
1047
|
for (let i = 0; i < argv.length; i++) {
|
|
975
1048
|
const a = argv[i];
|
|
@@ -989,7 +1062,7 @@ async function doGithubStat(argv) {
|
|
|
989
1062
|
const { renderProfileCard } = await import('./profile.js');
|
|
990
1063
|
const svg = renderProfileCard(aggregate, { handle: opts.handle });
|
|
991
1064
|
if (opts.gist) {
|
|
992
|
-
await publishBadgeToGist(svg, { metric: 'profile', range: 'all-time' });
|
|
1065
|
+
await publishBadgeToGist(svg, { metric: 'profile', range: 'all-time' }, 'github-stat');
|
|
993
1066
|
return;
|
|
994
1067
|
}
|
|
995
1068
|
if (opts.out) {
|
|
@@ -1015,6 +1088,7 @@ function liveVars() {
|
|
|
1015
1088
|
// statusline — a compact one-line status for tmux / starship / shell prompts
|
|
1016
1089
|
// and Claude Code's own statusline. `--template "..."` overrides the format.
|
|
1017
1090
|
function doStatusline(argv) {
|
|
1091
|
+
argv = splitEqArgs(argv);
|
|
1018
1092
|
let tpl = '{statusVerbose} · {project} · {modelPretty}{tokensLabelPad}';
|
|
1019
1093
|
for (let i = 0; i < argv.length; i++) {
|
|
1020
1094
|
if (argv[i] === '--template' || argv[i] === '-t') tpl = takeValue(argv[++i], '--template');
|
|
@@ -1026,6 +1100,7 @@ function doStatusline(argv) {
|
|
|
1026
1100
|
|
|
1027
1101
|
// Activity calendar — GitHub-contributions-style year heatmap SVG.
|
|
1028
1102
|
async function doCalendar(argv) {
|
|
1103
|
+
argv = splitEqArgs(argv);
|
|
1029
1104
|
const opts = { out: '', gist: false };
|
|
1030
1105
|
for (let i = 0; i < argv.length; i++) {
|
|
1031
1106
|
if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
|
|
@@ -1035,7 +1110,7 @@ async function doCalendar(argv) {
|
|
|
1035
1110
|
if (!aggregate) fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
|
|
1036
1111
|
const { renderCalendar } = await import('./calendar.js');
|
|
1037
1112
|
const svg = renderCalendar(aggregate, {});
|
|
1038
|
-
if (opts.gist) return publishBadgeToGist(svg, { metric: 'calendar', range: 'year' });
|
|
1113
|
+
if (opts.gist) return publishBadgeToGist(svg, { metric: 'calendar', range: 'year' }, 'calendar');
|
|
1039
1114
|
if (opts.out) {
|
|
1040
1115
|
writeFileSync(opts.out, svg);
|
|
1041
1116
|
console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${opts.out}${c.reset} ${c.dim}(${svg.length} bytes)${c.reset}`);
|
|
@@ -1044,6 +1119,7 @@ async function doCalendar(argv) {
|
|
|
1044
1119
|
|
|
1045
1120
|
// Per-session recap card — current/most-recent session as a shareable SVG.
|
|
1046
1121
|
async function doSessionCard(argv) {
|
|
1122
|
+
argv = splitEqArgs(argv);
|
|
1047
1123
|
const opts = { out: '' };
|
|
1048
1124
|
for (let i = 0; i < argv.length; i++) {
|
|
1049
1125
|
if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
|
|
@@ -1258,10 +1334,12 @@ function doResume() {
|
|
|
1258
1334
|
// pipes cleanly into jq / a spreadsheet import.
|
|
1259
1335
|
|
|
1260
1336
|
async function doExport(argv) {
|
|
1337
|
+
argv = splitEqArgs(argv);
|
|
1261
1338
|
const csv = argv.includes('--csv');
|
|
1262
1339
|
let out = '';
|
|
1263
|
-
|
|
1264
|
-
|
|
1340
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1341
|
+
if (argv[i] === '--out' || argv[i] === '-o') out = takeValue(argv[++i], '--out');
|
|
1342
|
+
}
|
|
1265
1343
|
const aggregate = readAggregate();
|
|
1266
1344
|
if (!aggregate) {
|
|
1267
1345
|
fail('no aggregate yet — nothing to export', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
|
|
@@ -1601,7 +1679,13 @@ async function communityReport() {
|
|
|
1601
1679
|
} else if (result.ok) {
|
|
1602
1680
|
console.log(` ${c.cyan}·${c.reset} ${c.dim}${result.reason}${c.reset}`);
|
|
1603
1681
|
} else {
|
|
1604
|
-
|
|
1682
|
+
// Scripts rely on the exit code (`claude-rpc community report || alert`),
|
|
1683
|
+
// so a failed flush must exit non-zero, not just print a warning.
|
|
1684
|
+
console.log('');
|
|
1685
|
+
return fail('flush did not complete', {
|
|
1686
|
+
hint: `${result.reason}${result.error ? ': ' + result.error : ''}`,
|
|
1687
|
+
code: EX_SYS_ERROR,
|
|
1688
|
+
});
|
|
1605
1689
|
}
|
|
1606
1690
|
console.log('');
|
|
1607
1691
|
}
|
|
@@ -1717,8 +1801,10 @@ function profileSet(argv) {
|
|
|
1717
1801
|
next.githubUser = u;
|
|
1718
1802
|
}
|
|
1719
1803
|
// The ✓ belongs to the account that was verified — switching accounts
|
|
1720
|
-
// means re-verifying.
|
|
1721
|
-
|
|
1804
|
+
// means re-verifying. GitHub usernames are case-insensitive, so a
|
|
1805
|
+
// case-only respelling of the same account keeps the badge.
|
|
1806
|
+
const prevGh = String((userCfg.profile || {}).githubUser || '').toLowerCase();
|
|
1807
|
+
if (String(next.githubUser || '').toLowerCase() !== prevGh) delete next.verified;
|
|
1722
1808
|
}
|
|
1723
1809
|
|
|
1724
1810
|
userCfg.profile = next;
|
|
@@ -2005,6 +2091,7 @@ function help() {
|
|
|
2005
2091
|
['rescan', 'Force re-parse every transcript (ignores cache)'],
|
|
2006
2092
|
['backfill', 'Import transcripts from any folder (e.g. a backup)'],
|
|
2007
2093
|
['insights', 'Auto-generated insights from your history'],
|
|
2094
|
+
['recap', 'Standup summary (yesterday by default; today|week|date, --md for markdown)'],
|
|
2008
2095
|
['badge', 'Render a Shields-style SVG (--metric --range --out --gist)'],
|
|
2009
2096
|
['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
|
|
2010
2097
|
['github-stat', 'Render an embeddable profile stat card (--handle --out --gist)'],
|
|
@@ -2161,6 +2248,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
2161
2248
|
case 'rescan': doScan(true); break;
|
|
2162
2249
|
case 'backfill': doBackfill(process.argv.slice(3)); break;
|
|
2163
2250
|
case 'insights': showInsights(); break;
|
|
2251
|
+
case 'recap': await doRecap(process.argv.slice(3)); break;
|
|
2164
2252
|
case 'badge': await doBadge(process.argv.slice(3)); break;
|
|
2165
2253
|
case 'card': await doCard(process.argv.slice(3)); break;
|
|
2166
2254
|
case 'github-stat': await doGithubStat(process.argv.slice(3)); break;
|
package/src/daemon.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
|
-
import { basename, dirname } from 'node:path';
|
|
2
|
+
import { existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
import { Client } from './discord-ipc.js';
|
|
5
5
|
import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
|
|
6
6
|
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession, throttleDecision } from './presence.js';
|
|
@@ -291,10 +291,13 @@ function buildActivity(opts = {}) {
|
|
|
291
291
|
|
|
292
292
|
if (state.status === 'stale') {
|
|
293
293
|
effectiveSessionStart = null;
|
|
294
|
-
} else if (state.sessionStart) {
|
|
294
|
+
} else if (state.sessionStart && !state.borrowed) {
|
|
295
295
|
effectiveSessionStart = state.sessionStart;
|
|
296
296
|
} else if (!effectiveSessionStart) {
|
|
297
|
-
|
|
297
|
+
// Borrowed states carry a moving transcript mtime as sessionStart — seed
|
|
298
|
+
// the timer from the first observation and hold it, or the elapsed clock
|
|
299
|
+
// restarts every refreshLiveSessions tick.
|
|
300
|
+
effectiveSessionStart = state.sessionStart || state.lastActivity || Date.now();
|
|
298
301
|
}
|
|
299
302
|
if (config.showElapsed && effectiveSessionStart && state.status !== 'stale') {
|
|
300
303
|
// Discord IPC expects millisecond timestamps (not seconds).
|
|
@@ -574,7 +577,15 @@ function watchFiles() {
|
|
|
574
577
|
// every writer here (state.js, pause.js, the scanner, the settings GUI)
|
|
575
578
|
// commits via tmp+rename.
|
|
576
579
|
const targets = [
|
|
577
|
-
|
|
580
|
+
// Hooks write per-session state-<sid>.json (state.json only for legacy
|
|
581
|
+
// payloads without a session_id), so the state target matches the whole
|
|
582
|
+
// family — filtering on the literal 'state.json' made the instant watcher
|
|
583
|
+
// path dead for every real session, leaving only the poll tick.
|
|
584
|
+
{
|
|
585
|
+
path: STATE_PATH, label: 'state', onChange: pushPresence,
|
|
586
|
+
matches: (f) => /^state(-.+)?\.json$/.test(f),
|
|
587
|
+
scanDir: STATE_DIR,
|
|
588
|
+
},
|
|
578
589
|
{ path: PAUSE_PATH, label: 'pause', onChange: pushPresence },
|
|
579
590
|
{ path: CONFIG_PATH, label: 'config', onChange: () => {
|
|
580
591
|
log('Config changed — reloading');
|
|
@@ -595,17 +606,38 @@ function watchFiles() {
|
|
|
595
606
|
// poll only logs a "fallback caught it" line for events the watcher truly
|
|
596
607
|
// missed, not for everything it already handled.
|
|
597
608
|
const lastMtime = new Map();
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
609
|
+
// Current mtime for a target: single file by default, or the max across a
|
|
610
|
+
// directory's matching files for family targets (state-<sid>.json). Max is
|
|
611
|
+
// monotone under writes, so pollDecision's `cur > prev` still works; a
|
|
612
|
+
// deleted session file lowers it, which correctly doesn't fire.
|
|
613
|
+
const statTarget = (t) => {
|
|
614
|
+
if (t.scanDir) {
|
|
615
|
+
let max;
|
|
616
|
+
try {
|
|
617
|
+
for (const name of readdirSync(t.scanDir)) {
|
|
618
|
+
if (!t.matches(name)) continue;
|
|
619
|
+
try {
|
|
620
|
+
const m = statSync(join(t.scanDir, name)).mtimeMs;
|
|
621
|
+
if (max === undefined || m > max) max = m;
|
|
622
|
+
} catch { /* file vanished mid-scan */ }
|
|
623
|
+
}
|
|
624
|
+
} catch { /* dir missing (pre-first-hook) */ }
|
|
625
|
+
return max;
|
|
626
|
+
}
|
|
627
|
+
try { return existsSync(t.path) ? statSync(t.path).mtimeMs : undefined; }
|
|
628
|
+
catch { return undefined; /* mid-rename; a later observation records it */ }
|
|
629
|
+
};
|
|
630
|
+
const recordMtime = (t) => {
|
|
631
|
+
const m = statTarget(t);
|
|
632
|
+
if (m !== undefined) lastMtime.set(t.path, m);
|
|
601
633
|
};
|
|
602
634
|
// Seed baselines so the first poll tick doesn't fire for files that merely
|
|
603
635
|
// already existed when the daemon started.
|
|
604
|
-
targets.forEach((t) => recordMtime(t
|
|
636
|
+
targets.forEach((t) => recordMtime(t));
|
|
605
637
|
|
|
606
638
|
const fire = (t, viaPoll) => {
|
|
607
639
|
if (viaPoll) log(`${t.label} changed — poll fallback caught an event fs.watch missed`);
|
|
608
|
-
recordMtime(t
|
|
640
|
+
recordMtime(t); // record before onChange so a re-entrant tick can't double-fire
|
|
609
641
|
t.onChange();
|
|
610
642
|
};
|
|
611
643
|
|
|
@@ -625,11 +657,12 @@ function watchFiles() {
|
|
|
625
657
|
}
|
|
626
658
|
for (const [dir, group] of groups) {
|
|
627
659
|
if (!existsSync(dir)) continue;
|
|
628
|
-
const byName = new Map(group.map((t) => [basename(t.path), t]));
|
|
629
660
|
let timer = null;
|
|
630
661
|
try {
|
|
631
662
|
watch(dir, (event, filename) => {
|
|
632
|
-
const hits = filename
|
|
663
|
+
const hits = filename
|
|
664
|
+
? group.filter((t) => (t.matches ? t.matches(String(filename)) : basename(t.path) === filename))
|
|
665
|
+
: group;
|
|
633
666
|
if (!hits.length) return;
|
|
634
667
|
clearTimeout(timer);
|
|
635
668
|
timer = setTimeout(() => hits.forEach((t) => fire(t, false)), 250);
|
|
@@ -646,9 +679,7 @@ function watchFiles() {
|
|
|
646
679
|
// until the next unrelated state change.
|
|
647
680
|
setInterval(() => {
|
|
648
681
|
for (const t of targets) {
|
|
649
|
-
|
|
650
|
-
try { cur = existsSync(t.path) ? statSync(t.path).mtimeMs : undefined; }
|
|
651
|
-
catch { continue; /* mid-rename; next tick picks it up */ }
|
|
682
|
+
const cur = statTarget(t);
|
|
652
683
|
const decision = pollDecision(lastMtime.get(t.path), cur);
|
|
653
684
|
if (decision === 'seed') lastMtime.set(t.path, cur);
|
|
654
685
|
else if (decision === 'fire') fire(t, true);
|
package/src/fmt.js
CHANGED
|
@@ -31,3 +31,11 @@ export function fmtHours(ms) {
|
|
|
31
31
|
const hours = mins / 60;
|
|
32
32
|
return hours < 10 ? `${hours.toFixed(1)}h` : `${Math.round(hours)}h`;
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// Local calendar date "YYYY-MM-DD" — the same day the scanner's byDay buckets
|
|
36
|
+
// key on. Card/calendar/profile stamps used toISOString(), which is UTC and
|
|
37
|
+
// labels the data with the wrong day for any non-UTC user near midnight.
|
|
38
|
+
export function localDateStamp(d = new Date()) {
|
|
39
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
40
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
41
|
+
}
|
package/src/format.js
CHANGED
|
@@ -805,6 +805,10 @@ function borrowLiveSession(state, liveSessions, now) {
|
|
|
805
805
|
return {
|
|
806
806
|
...state,
|
|
807
807
|
status: 'working',
|
|
808
|
+
// sessionStart here is a moving transcript mtime, not a real anchor — the
|
|
809
|
+
// flag lets the daemon pin its elapsed timer to the first observation
|
|
810
|
+
// instead of restarting it every 30s refresh.
|
|
811
|
+
borrowed: true,
|
|
808
812
|
cwd: recent.cwd || state.cwd || '',
|
|
809
813
|
sessionStart: recent.mtime || now,
|
|
810
814
|
lastActivity: recent.mtime || now,
|
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
|
|
|
@@ -150,6 +67,23 @@ export function processHookEvent(event, input = {}) {
|
|
|
150
67
|
|
|
151
68
|
switch (event) {
|
|
152
69
|
case 'SessionStart': {
|
|
70
|
+
// Post-compaction continuation, NOT a new session: Claude Code re-fires
|
|
71
|
+
// SessionStart with source:'compact' after every compaction. Resetting
|
|
72
|
+
// here wiped the elapsed timer and message/tool/file counters mid-turn.
|
|
73
|
+
// Just clear the compacting marker and carry on; the turn that triggered
|
|
74
|
+
// the compaction resumes immediately, hence 'working'.
|
|
75
|
+
if (input.source === 'compact') {
|
|
76
|
+
update((s) => {
|
|
77
|
+
delete s.compactStartedAt;
|
|
78
|
+
delete s.compactTrigger;
|
|
79
|
+
s.status = 'working';
|
|
80
|
+
s.lastActivity = now;
|
|
81
|
+
s.claudeClosed = false;
|
|
82
|
+
if (!s.sessionStart) s.sessionStart = now;
|
|
83
|
+
return s;
|
|
84
|
+
});
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
153
87
|
reset({
|
|
154
88
|
cwd: input.cwd || process.cwd(),
|
|
155
89
|
model: input.model?.id || input.model || 'claude',
|
|
@@ -215,7 +149,7 @@ export function processHookEvent(event, input = {}) {
|
|
|
215
149
|
case 'PostToolUse': {
|
|
216
150
|
const toolName = input.tool_name || input.toolName || '';
|
|
217
151
|
const toolInput = input.tool_input || input.toolInput || {};
|
|
218
|
-
const file = toolInput.file_path || toolInput.path || null;
|
|
152
|
+
const file = toolInput.file_path || toolInput.path || toolInput.notebook_path || null;
|
|
219
153
|
// Just-shipped detection: any Bash command that contains `git push`
|
|
220
154
|
// or `git commit`. Capture cwd + branch + last commit subject NOW
|
|
221
155
|
// — by the time the daemon renders the next frame this info may be
|
|
@@ -310,8 +244,15 @@ export function processHookEvent(event, input = {}) {
|
|
|
310
244
|
});
|
|
311
245
|
break;
|
|
312
246
|
}
|
|
247
|
+
case 'SubagentStop': {
|
|
248
|
+
// A subagent finishing is not the SESSION going idle — the parent turn
|
|
249
|
+
// is still running (often with sibling subagents in flight). Flipping to
|
|
250
|
+
// idle here showed "Standing by" mid-generation. Just record liveness;
|
|
251
|
+
// the parent's own Stop/PreToolUse hooks drive status.
|
|
252
|
+
setActivity({});
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
313
255
|
case 'Stop':
|
|
314
|
-
case 'SubagentStop':
|
|
315
256
|
default: {
|
|
316
257
|
setActivity({ status: 'idle', currentTool: null, currentFile: null });
|
|
317
258
|
}
|
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/nudge.js
CHANGED
|
@@ -26,9 +26,11 @@ function reached(value, list) {
|
|
|
26
26
|
const fmt = (n) => n >= 1000 ? n.toLocaleString('en-US') : String(n);
|
|
27
27
|
|
|
28
28
|
// Returns { key, weight, message } for the biggest milestone the aggregate has
|
|
29
|
-
// crossed, or null. `weight` ranks across milestone
|
|
30
|
-
// the single most impressive one
|
|
31
|
-
|
|
29
|
+
// crossed that hasn't been shown yet, or null. `weight` ranks across milestone
|
|
30
|
+
// types so we show the single most impressive UNSEEN one — without the `shown`
|
|
31
|
+
// filter, a standing streak record (weight 1000+) re-won every pick, matched
|
|
32
|
+
// the dedup, and permanently silenced every other milestone behind it.
|
|
33
|
+
export function pickShareNudge(agg, shown = new Set()) {
|
|
32
34
|
if (!agg || typeof agg !== 'object') return null;
|
|
33
35
|
const out = [];
|
|
34
36
|
|
|
@@ -58,9 +60,10 @@ export function pickShareNudge(agg) {
|
|
|
58
60
|
message: `${fmt(h)}+ hours on Claude Code. Your year-in-review is ready — \`claude-rpc serve\` then open /wrapped and hit Share.`,
|
|
59
61
|
});
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
const fresh = out.filter((n) => !shown.has(n.key));
|
|
64
|
+
if (!fresh.length) return null;
|
|
65
|
+
fresh.sort((a, b) => b.weight - a.weight);
|
|
66
|
+
return fresh[0];
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
// A quiet, local celebration line for `claude-rpc today`. Complements the
|
|
@@ -88,15 +91,22 @@ export function pickTodayMilestone(agg, todayTokens = 0) {
|
|
|
88
91
|
return null;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
// Set of every nudge key already shown. Older state files stored a single
|
|
95
|
+
// `key`; fold it in so upgrading doesn't re-show the last nudge.
|
|
96
|
+
function readShownKeys(path = NUDGE_STATE) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
99
|
+
const keys = Array.isArray(raw.shown) ? raw.shown : [];
|
|
100
|
+
if (raw.key) keys.push(raw.key);
|
|
101
|
+
return new Set(keys.filter((k) => typeof k === 'string'));
|
|
102
|
+
} catch { return new Set(); }
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
function
|
|
105
|
+
function writeShownKeys(shown, path = NUDGE_STATE) {
|
|
97
106
|
try {
|
|
98
107
|
mkdirSync(dirname(path), { recursive: true });
|
|
99
|
-
|
|
108
|
+
// Cap the history — milestone lists are finite, 100 is plenty.
|
|
109
|
+
writeFileSync(path, JSON.stringify({ shown: [...shown].slice(-100), ts: Date.now() }));
|
|
100
110
|
} catch { /* best-effort */ }
|
|
101
111
|
}
|
|
102
112
|
|
|
@@ -104,9 +114,10 @@ function writeLastKey(key, path = NUDGE_STATE) {
|
|
|
104
114
|
// dedup. Returns a string to print, or null. Marks the nudge as shown.
|
|
105
115
|
export function maybeNudge(agg, config = {}, { path = NUDGE_STATE } = {}) {
|
|
106
116
|
if (config?.nudges?.enabled === false) return null;
|
|
107
|
-
const
|
|
117
|
+
const shown = readShownKeys(path);
|
|
118
|
+
const n = pickShareNudge(agg, shown);
|
|
108
119
|
if (!n) return null;
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
shown.add(n.key);
|
|
121
|
+
writeShownKeys(shown, path);
|
|
111
122
|
return n.message;
|
|
112
123
|
}
|
package/src/profile.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { fmtCost } from './pricing.js';
|
|
9
9
|
import { VERSION } from './version.js';
|
|
10
|
-
import { fmtNum, fmtHours } from './fmt.js';
|
|
10
|
+
import { fmtNum, fmtHours, localDateStamp } from './fmt.js';
|
|
11
11
|
|
|
12
12
|
const W = 520;
|
|
13
13
|
const H = 240;
|
|
@@ -94,7 +94,7 @@ export function renderProfileCard(aggregate, { handle = '', generatedAt = new Da
|
|
|
94
94
|
const t = lifetime(aggregate);
|
|
95
95
|
const lang = topLanguage(aggregate);
|
|
96
96
|
const who = handle ? `@${String(handle).replace(/^@/, '')}` : 'on Claude Code';
|
|
97
|
-
const sub = `${who} · Day ${t.daysSinceFirst} · as of ${generatedAt
|
|
97
|
+
const sub = `${who} · Day ${t.daysSinceFirst} · as of ${localDateStamp(generatedAt)}`;
|
|
98
98
|
const netStr = `${t.linesNet >= 0 ? '+' : '−'}${fmtNum(Math.abs(t.linesNet))}`;
|
|
99
99
|
|
|
100
100
|
// 3 columns × 2 rows of stats.
|
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
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, renameSync, openSync, readSync, closeSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, basename } from 'node:path';
|
|
3
3
|
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
|
+
// v6: day/week/hour buckets carry firstTs/lastTs (they were declared but
|
|
13
|
+
// never assigned, leaving {startTimeLabel} permanently blank).
|
|
14
|
+
const CACHE_VERSION = 6;
|
|
11
15
|
|
|
12
16
|
// Cap counted gap between consecutive timestamps. Anything larger is treated
|
|
13
17
|
// as the user walking away — we count only what's plausibly active time.
|
|
@@ -129,8 +133,13 @@ function blankDay() {
|
|
|
129
133
|
linesRemoved: 0,
|
|
130
134
|
cost: 0,
|
|
131
135
|
notifications: 0,
|
|
136
|
+
ships: 0,
|
|
132
137
|
firstTs: null,
|
|
133
138
|
lastTs: null,
|
|
139
|
+
// Day buckets may also lazily carry:
|
|
140
|
+
// shipKinds — { push|commit|pr|issue|tag → count } (only when a ship lands)
|
|
141
|
+
// projects — { name → { activeMs, tokens } } (aggregate-level only,
|
|
142
|
+
// attributed at merge time where the file's project is known)
|
|
134
143
|
};
|
|
135
144
|
}
|
|
136
145
|
|
|
@@ -147,6 +156,10 @@ function mergeDay(target, src) {
|
|
|
147
156
|
target.linesRemoved += src.linesRemoved || 0;
|
|
148
157
|
target.cost += src.cost || 0;
|
|
149
158
|
target.notifications += src.notifications || 0;
|
|
159
|
+
target.ships += src.ships || 0;
|
|
160
|
+
for (const [k, n] of Object.entries(src.shipKinds || {})) {
|
|
161
|
+
(target.shipKinds ||= {})[k] = (target.shipKinds[k] || 0) + n;
|
|
162
|
+
}
|
|
150
163
|
if (src.firstTs && (!target.firstTs || src.firstTs < target.firstTs)) target.firstTs = src.firstTs;
|
|
151
164
|
if (src.lastTs && (!target.lastTs || src.lastTs > target.lastTs)) target.lastTs = src.lastTs;
|
|
152
165
|
}
|
|
@@ -236,6 +249,8 @@ function blankTranscriptSummary() {
|
|
|
236
249
|
bashCommands: {}, // first token → count
|
|
237
250
|
webDomains: {}, // hostname → count
|
|
238
251
|
subagents: {}, // subagent_type → count
|
|
252
|
+
ships: 0, // shipped-command count (git commit/push, gh pr/issue/release create)
|
|
253
|
+
shipKinds: {}, // ship kind → count
|
|
239
254
|
cost: 0, // estimated USD
|
|
240
255
|
costByModel: {}, // pricing key → USD
|
|
241
256
|
modelsUsed: {}, // raw model id → assistant turns
|
|
@@ -282,6 +297,15 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
282
297
|
const weekBucket = week ? (summary.byWeek[week] ||= blankDay()) : null;
|
|
283
298
|
const hourBucket = hour !== null ? (summary.byHour[hour] ||= blankDay()) : null;
|
|
284
299
|
const allBuckets = [dayBucket, weekBucket, hourBucket].filter(Boolean);
|
|
300
|
+
// First/last activity within each bucket — blankDay declares these and
|
|
301
|
+
// mergeDay merges them, but nothing assigned them, so "started 09:14"-style
|
|
302
|
+
// labels never rendered.
|
|
303
|
+
if (ts) {
|
|
304
|
+
for (const bucket of allBuckets) {
|
|
305
|
+
if (!bucket.firstTs || ts < bucket.firstTs) bucket.firstTs = ts;
|
|
306
|
+
if (!bucket.lastTs || ts > bucket.lastTs) bucket.lastTs = ts;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
285
309
|
|
|
286
310
|
if (r.type === 'assistant') {
|
|
287
311
|
const turnModel = r.message?.model || summary.model;
|
|
@@ -385,6 +409,13 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
385
409
|
} else if (b.name === 'Bash') {
|
|
386
410
|
const cmd = firstShellToken(input.command);
|
|
387
411
|
if (cmd) summary.bashCommands[cmd] = (summary.bashCommands[cmd] || 0) + 1;
|
|
412
|
+
const shipKind = classifyShip(input.command);
|
|
413
|
+
if (shipKind) {
|
|
414
|
+
summary.ships = (summary.ships || 0) + 1;
|
|
415
|
+
summary.shipKinds[shipKind] = (summary.shipKinds[shipKind] || 0) + 1;
|
|
416
|
+
for (const bucket of allBuckets) bucket.ships = (bucket.ships || 0) + 1;
|
|
417
|
+
if (dayBucket) (dayBucket.shipKinds ||= {})[shipKind] = (dayBucket.shipKinds[shipKind] || 0) + 1;
|
|
418
|
+
}
|
|
388
419
|
} else if (b.name === 'WebFetch' || b.name === 'WebSearch') {
|
|
389
420
|
const host = b.name === 'WebFetch' ? domainOf(input.url) : '';
|
|
390
421
|
if (host) summary.webDomains[host] = (summary.webDomains[host] || 0) + 1;
|
|
@@ -691,7 +722,10 @@ function readCache() {
|
|
|
691
722
|
function writeCache(cache) {
|
|
692
723
|
ensureDataDir();
|
|
693
724
|
cache._v = CACHE_VERSION;
|
|
694
|
-
|
|
725
|
+
// Pid-suffixed like state.js: the daemon's background rescan and a user-run
|
|
726
|
+
// `claude-rpc scan` can overlap, and a shared tmp name lets one writer's
|
|
727
|
+
// rename land a half-written file (or ENOENT the other's rename).
|
|
728
|
+
const tmp = `${SCAN_CACHE_PATH}.${process.pid}.tmp`;
|
|
695
729
|
writeFileSync(tmp, JSON.stringify(cache));
|
|
696
730
|
renameSync(tmp, SCAN_CACHE_PATH);
|
|
697
731
|
}
|
|
@@ -720,7 +754,7 @@ function readNotificationsByDay() {
|
|
|
720
754
|
|
|
721
755
|
function writeAggregate(agg) {
|
|
722
756
|
ensureDataDir();
|
|
723
|
-
const tmp = AGGREGATE_PATH
|
|
757
|
+
const tmp = `${AGGREGATE_PATH}.${process.pid}.tmp`;
|
|
724
758
|
writeFileSync(tmp, JSON.stringify(agg, null, 2));
|
|
725
759
|
renameSync(tmp, AGGREGATE_PATH);
|
|
726
760
|
}
|
|
@@ -767,6 +801,8 @@ export function aggregateFrom(cache) {
|
|
|
767
801
|
linesAdded: 0,
|
|
768
802
|
linesRemoved: 0,
|
|
769
803
|
linesNet: 0,
|
|
804
|
+
ships: 0,
|
|
805
|
+
shipKinds: {},
|
|
770
806
|
bashCommands: {},
|
|
771
807
|
webDomains: {},
|
|
772
808
|
subagents: {},
|
|
@@ -800,6 +836,10 @@ export function aggregateFrom(cache) {
|
|
|
800
836
|
// — they represent real work done by Claude.
|
|
801
837
|
agg.linesAdded += summary.linesAdded || 0;
|
|
802
838
|
agg.linesRemoved += summary.linesRemoved || 0;
|
|
839
|
+
agg.ships += summary.ships || 0;
|
|
840
|
+
for (const [k, n] of Object.entries(summary.shipKinds || {})) {
|
|
841
|
+
agg.shipKinds[k] = (agg.shipKinds[k] || 0) + n;
|
|
842
|
+
}
|
|
803
843
|
agg.estimatedCost += summary.cost || 0;
|
|
804
844
|
for (const [m, v] of Object.entries(summary.costByModel || {})) {
|
|
805
845
|
agg.costByModel[m] = (agg.costByModel[m] || 0) + v;
|
|
@@ -848,6 +888,10 @@ export function aggregateFrom(cache) {
|
|
|
848
888
|
target.linesAdded += src.linesAdded || 0;
|
|
849
889
|
target.linesRemoved += src.linesRemoved || 0;
|
|
850
890
|
target.cost += src.cost || 0;
|
|
891
|
+
target.ships += src.ships || 0;
|
|
892
|
+
for (const [kind, n] of Object.entries(src.shipKinds || {})) {
|
|
893
|
+
(target.shipKinds ||= {})[kind] = (target.shipKinds[kind] || 0) + n;
|
|
894
|
+
}
|
|
851
895
|
}
|
|
852
896
|
};
|
|
853
897
|
mergeSubBuckets(summary.byDay, agg.byDay);
|
|
@@ -882,7 +926,17 @@ export function aggregateFrom(cache) {
|
|
|
882
926
|
if (summary.lastTs) agg.lastTs = agg.lastTs ? Math.max(agg.lastTs, summary.lastTs) : summary.lastTs;
|
|
883
927
|
// Full per-day/week/hour merge for top-level sessions.
|
|
884
928
|
for (const [k, day] of Object.entries(summary.byDay || {})) {
|
|
885
|
-
|
|
929
|
+
const target = agg.byDay[k] ||= blankDay();
|
|
930
|
+
mergeDay(target, day);
|
|
931
|
+
// Per-day project attribution — only possible here, where the whole
|
|
932
|
+
// file's project is known. Presence in the map means "touched that
|
|
933
|
+
// day", even if activeMs rounded to 0.
|
|
934
|
+
if (summary.project) {
|
|
935
|
+
const pm = (target.projects ||= {});
|
|
936
|
+
const p = pm[summary.project] ||= { activeMs: 0, tokens: 0 };
|
|
937
|
+
p.activeMs += day.activeMs || 0;
|
|
938
|
+
p.tokens += (day.inputTokens || 0) + (day.outputTokens || 0) + (day.cacheReadTokens || 0) + (day.cacheWriteTokens || 0);
|
|
939
|
+
}
|
|
886
940
|
}
|
|
887
941
|
for (const [k, w] of Object.entries(summary.byWeek || {})) {
|
|
888
942
|
mergeDay(agg.byWeek[k] ||= blankDay(), w);
|
|
@@ -1064,6 +1118,18 @@ export function scan({ projectsDir, projectsDirs, onProgress, force = false, ext
|
|
|
1064
1118
|
dirs.push(CLAUDE_PROJECTS);
|
|
1065
1119
|
dirs.push(...discoverAltProjectDirs());
|
|
1066
1120
|
}
|
|
1121
|
+
// Dedupe by REAL path, not path string: a symlinked alt location (e.g.
|
|
1122
|
+
// ~/.config/claude → ~/.claude) otherwise walks the same transcripts twice
|
|
1123
|
+
// under two absolute paths and exactly doubles every lifetime stat.
|
|
1124
|
+
{
|
|
1125
|
+
const seen = new Set();
|
|
1126
|
+
for (let i = 0; i < dirs.length; i++) {
|
|
1127
|
+
let key = dirs[i];
|
|
1128
|
+
try { key = realpathSync(key); } catch { /* missing dir — keep literal */ }
|
|
1129
|
+
if (seen.has(key)) { dirs.splice(i, 1); i--; continue; }
|
|
1130
|
+
seen.add(key);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1067
1133
|
for (const d of extraDirs) if (!dirs.includes(d)) dirs.push(d);
|
|
1068
1134
|
|
|
1069
1135
|
const cache = readCache();
|
package/src/session-card.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// `claude-rpc session-card --out s.svg`.
|
|
5
5
|
|
|
6
6
|
import { VERSION } from './version.js';
|
|
7
|
+
import { localDateStamp } from './fmt.js';
|
|
7
8
|
|
|
8
9
|
const W = 520;
|
|
9
10
|
const H = 230;
|
|
@@ -47,7 +48,7 @@ export function renderSessionCard(vars = {}, { generatedAt = new Date() } = {})
|
|
|
47
48
|
<rect x="0.75" y="0.75" width="${W - 7}" height="${H - 9}" fill="url(#dg)"/>
|
|
48
49
|
|
|
49
50
|
<text x="40" y="50" font-family="Space Grotesk, Inter, system-ui, sans-serif" font-size="28" font-weight="800" letter-spacing="-1" fill="${PALETTE.ink}">${escapeXml(v.project || 'this session')}</text>
|
|
50
|
-
<text x="40" y="72" font-family="JetBrains Mono, ui-monospace, monospace" font-size="12" fill="${PALETTE.inkMute}">${escapeXml(`${v.modelPretty || 'Claude'} · ${v.duration || '0s'} · ${generatedAt
|
|
51
|
+
<text x="40" y="72" font-family="JetBrains Mono, ui-monospace, monospace" font-size="12" fill="${PALETTE.inkMute}">${escapeXml(`${v.modelPretty || 'Claude'} · ${v.duration || '0s'} · ${localDateStamp(generatedAt)}`)}</text>
|
|
51
52
|
${v.modelPretty ? tape(W - 150, 28, String(v.modelPretty)) : ''}
|
|
52
53
|
|
|
53
54
|
<line x1="40" y1="92" x2="${W - 40}" y2="92" stroke="${PALETTE.ink}" stroke-width="1" opacity="0.18"/>
|
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
|
+
}
|