claude-rpc 1.1.4 → 1.2.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/SECURITY.md +16 -0
- package/package.json +1 -1
- package/src/cli.js +198 -3
- package/src/community.js +149 -0
- package/src/daemon.js +15 -2
- package/src/format.js +48 -1
- package/src/privacy.js +24 -0
- package/src/scanner.js +108 -13
- package/src/version.js +1 -1
package/SECURITY.md
CHANGED
|
@@ -208,6 +208,22 @@ anonymous 3a report, this one carries your chosen public identity. The
|
|
|
208
208
|
It sends absolute totals (not deltas) and is idempotent worker-side (a SET, not
|
|
209
209
|
an add). `profile off` stops it.
|
|
210
210
|
|
|
211
|
+
**Claude Wrapped (opt-in one-shot, never automatic).** `claude-rpc wrapped
|
|
212
|
+
--publish` publishes a year-in-review blob under your profile handle to
|
|
213
|
+
`<endpoint>/wrapped`, rendered at `claude-rpc.com/wrapped/<handle>`. It never
|
|
214
|
+
runs on a timer — it is a single explicit command, and the CLI **prints the
|
|
215
|
+
exact payload and asks for confirmation** before sending (non-TTY requires
|
|
216
|
+
`--yes`). The complete payload (`buildWrappedPayload`, enforced by the
|
|
217
|
+
worker's `validateWrapped`/`sanitizeWrapped` allowlist) is year-scoped
|
|
218
|
+
aggregate numbers — active time, sessions, prompts, tokens, cache %, lines,
|
|
219
|
+
ships, streak/peaks, estimated cost — plus up to 5 top project **names**, 5
|
|
220
|
+
language names, 4 model names with share %, and 3 tool names with share %. No
|
|
221
|
+
file paths, no prompts, no per-day breakdowns. Project names respect the
|
|
222
|
+
privacy lists and `privacy.patterns` before they ever appear in the preview;
|
|
223
|
+
anything you see in the confirmation box is everything the server sees. Each
|
|
224
|
+
publish overwrites the previous one; records expire worker-side after ~14
|
|
225
|
+
months unless re-published.
|
|
226
|
+
|
|
211
227
|
### 3d. Subscription usage — your own token, to its issuer, ON by default
|
|
212
228
|
|
|
213
229
|
**Source:** `src/usage.js`; consumed by the daemon poll, `claude-rpc usage`,
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -25,7 +25,7 @@ import { spawnDaemonDetached, daemonAlive } from './ensure-daemon.js';
|
|
|
25
25
|
// the stats/format/install code, so daemon-control and version stay near-instant.
|
|
26
26
|
// (The repo already lazy-imports cold deps like doctor.js/card.js/mcp.js inside
|
|
27
27
|
// their handlers — this just extends that to the graph the main switch needs.)
|
|
28
|
-
let buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum;
|
|
28
|
+
let buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum, fmtHours;
|
|
29
29
|
let scan, readAggregate, findLiveSessions, dayKey, weekKey;
|
|
30
30
|
let weekGrid;
|
|
31
31
|
let runInstall, runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro;
|
|
@@ -49,7 +49,7 @@ async function loadStats() {
|
|
|
49
49
|
import('./nudge.js'), import('./badge.js'), import('./pricing.js'),
|
|
50
50
|
import('./privacy.js'), import('./usage.js'), import('./leaderboard.js'),
|
|
51
51
|
]);
|
|
52
|
-
({ buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } = fmt);
|
|
52
|
+
({ buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum, fmtHours } = fmt);
|
|
53
53
|
({ scan, readAggregate, findLiveSessions, dayKey, weekKey } = scn);
|
|
54
54
|
({ weekGrid } = wk);
|
|
55
55
|
({ install: runInstall, uninstall: runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro } = inst);
|
|
@@ -687,6 +687,123 @@ function showWeek() {
|
|
|
687
687
|
}
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
// `projects` — every project the scanner has attributed, ranked by active
|
|
691
|
+
// time. The aggregate has carried per-project records since v0.6; this is the
|
|
692
|
+
// first terminal surface for them (the web dashboard's drilldown came first).
|
|
693
|
+
function showProjects(argv = []) {
|
|
694
|
+
const aggregate = readAggregate();
|
|
695
|
+
const entries = Object.entries(aggregate?.projects || {});
|
|
696
|
+
if (!entries.length) {
|
|
697
|
+
console.log(`\n No project data yet — run ${c.cyan}claude-rpc scan${c.reset} first.\n`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const limitFlag = argv.indexOf('--limit');
|
|
701
|
+
const limit = limitFlag !== -1 ? Math.max(1, Number(argv[limitFlag + 1]) || 12) : 12;
|
|
702
|
+
const ranked = entries.sort((a, b) => (b[1].activeMs || 0) - (a[1].activeMs || 0));
|
|
703
|
+
const name = (n) => n.length > 22 ? n.slice(0, 21) + '…' : n;
|
|
704
|
+
|
|
705
|
+
console.log('');
|
|
706
|
+
console.log(` ${c.bold}${c.magenta}◆ Projects${c.reset} ${c.dim}— ${entries.length} tracked, by active time${c.reset}`);
|
|
707
|
+
console.log('');
|
|
708
|
+
const maxMs = ranked[0][1].activeMs || 1;
|
|
709
|
+
const lines = ranked.slice(0, limit).map(([n, p]) => {
|
|
710
|
+
const toks = (p.inputTokens || 0) + (p.outputTokens || 0);
|
|
711
|
+
return `${name(n).padEnd(23)} ${bar(p.activeMs || 0, maxMs, 14)} ${c.cyan}${fmtHours(p.activeMs || 0).padStart(6)}${c.reset}` +
|
|
712
|
+
` ${c.dim}·${c.reset} ${String(p.sessions || 0).padStart(4)} sess` +
|
|
713
|
+
` ${c.dim}·${c.reset} ${fmtNum(toks).padStart(7)} tok` +
|
|
714
|
+
` ${c.dim}·${c.reset} ${c.yellow}${fmtCost(p.cost || 0).padStart(8)}${c.reset}`;
|
|
715
|
+
});
|
|
716
|
+
if (ranked.length > limit) lines.push(`${c.dim}… ${ranked.length - limit} more — --limit ${ranked.length} to see all${c.reset}`);
|
|
717
|
+
box('projects', lines, 76);
|
|
718
|
+
console.log(`\n ${c.dim}drill in: ${c.reset}${c.cyan}claude-rpc project <name>${c.reset}\n`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// `project <name>` — one project's full record + its recent daily rhythm
|
|
722
|
+
// (attributed per-day since scan cache v5).
|
|
723
|
+
function showProject(rawName) {
|
|
724
|
+
if (!rawName) {
|
|
725
|
+
console.log(`\n Usage: ${c.cyan}claude-rpc project <name>${c.reset} (see ${c.cyan}claude-rpc projects${c.reset})\n`);
|
|
726
|
+
process.exitCode = EX_USER_ERROR;
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const aggregate = readAggregate();
|
|
730
|
+
const projects = aggregate?.projects || {};
|
|
731
|
+
const key = Object.keys(projects).find((k) => k.toLowerCase() === rawName.toLowerCase())
|
|
732
|
+
|| Object.keys(projects).find((k) => k.toLowerCase().includes(rawName.toLowerCase()));
|
|
733
|
+
if (!key) {
|
|
734
|
+
console.log(`\n No project matching ${c.bold}${rawName}${c.reset} — ${c.cyan}claude-rpc projects${c.reset} lists what's tracked.\n`);
|
|
735
|
+
process.exitCode = EX_USER_ERROR;
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const p = projects[key];
|
|
739
|
+
const toks = (p.inputTokens || 0) + (p.outputTokens || 0);
|
|
740
|
+
|
|
741
|
+
console.log('');
|
|
742
|
+
console.log(` ${c.bold}${c.magenta}◆ ${key}${c.reset}`);
|
|
743
|
+
console.log('');
|
|
744
|
+
box(key, [
|
|
745
|
+
pair('active', `${c.bold}${c.green}${fmtHours(p.activeMs || 0)}${c.reset}`, ''),
|
|
746
|
+
pair('sessions', String(p.sessions || 0)),
|
|
747
|
+
pair('prompts', fmtNum(p.userMessages || 0), c.yellow),
|
|
748
|
+
pair('tool calls', fmtNum(p.toolCalls || 0), c.yellow),
|
|
749
|
+
pair('tokens', `${c.bold}${fmtNum(toks)}${c.reset} ${c.dim}in+out${c.reset}`, ''),
|
|
750
|
+
pair('lines', `${c.green}+${fmtNum(p.linesAdded || 0)}${c.reset} ${c.red}-${fmtNum(p.linesRemoved || 0)}${c.reset}`, ''),
|
|
751
|
+
pair('est. cost', fmtCost(p.cost || 0), c.yellow),
|
|
752
|
+
]);
|
|
753
|
+
console.log('');
|
|
754
|
+
|
|
755
|
+
// Last 14 days of per-day attribution, oldest first.
|
|
756
|
+
const days = Object.entries(aggregate?.byDay || {})
|
|
757
|
+
.filter(([, d]) => d.projects?.[key])
|
|
758
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
759
|
+
.slice(-14)
|
|
760
|
+
.map(([day, d]) => ({ day, ms: d.projects[key].activeMs || 0 }));
|
|
761
|
+
if (days.length) {
|
|
762
|
+
const maxMs = Math.max(...days.map((d) => d.ms), 1);
|
|
763
|
+
box('last 14 active days', days.map(({ day, ms }) =>
|
|
764
|
+
`${day} ${bar(ms, maxMs, 20)} ${c.cyan}${fmtHours(ms).padStart(6)}${c.reset}`
|
|
765
|
+
));
|
|
766
|
+
console.log('');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// `compare` — the full this-week-vs-last-week diff. The deltas existed in
|
|
771
|
+
// three partial forms (insights, showWeek, web api); this is the first
|
|
772
|
+
// complete side-by-side.
|
|
773
|
+
function showCompare() {
|
|
774
|
+
const aggregate = readAggregate();
|
|
775
|
+
if (!aggregate?.byWeek) {
|
|
776
|
+
console.log(`\n No history yet — run ${c.cyan}claude-rpc scan${c.reset} first.\n`);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const prevRef = new Date();
|
|
780
|
+
prevRef.setHours(12, 0, 0, 0);
|
|
781
|
+
prevRef.setDate(prevRef.getDate() - 7);
|
|
782
|
+
const nowKey = weekKey(Date.now());
|
|
783
|
+
const prevKey = weekKey(prevRef.getTime());
|
|
784
|
+
const wk = aggregate.byWeek[nowKey] || {};
|
|
785
|
+
const pw = aggregate.byWeek[prevKey] || {};
|
|
786
|
+
|
|
787
|
+
console.log('');
|
|
788
|
+
console.log(` ${c.bold}${c.magenta}◆ ${nowKey} vs ${prevKey}${c.reset} ${c.dim}— this week so far vs all of last week${c.reset}`);
|
|
789
|
+
console.log('');
|
|
790
|
+
const row = (label, cur, prev, fmt = fmtNum) => {
|
|
791
|
+
const d = fmtDelta(cur, prev);
|
|
792
|
+
return pair(label, `${c.bold}${fmt(cur)}${c.reset} ${c.dim}was ${fmt(prev)}${c.reset}${d ? ` ${d}` : ''}`, '');
|
|
793
|
+
};
|
|
794
|
+
box('week over week', [
|
|
795
|
+
row('active', wk.activeMs || 0, pw.activeMs || 0, fmtHours),
|
|
796
|
+
row('prompts', wk.userMessages || 0, pw.userMessages || 0),
|
|
797
|
+
row('tool calls', wk.toolCalls || 0, pw.toolCalls || 0),
|
|
798
|
+
row('sessions', wk.sessions || 0, pw.sessions || 0),
|
|
799
|
+
row('tokens', dayTokens(wk), dayTokens(pw)),
|
|
800
|
+
row('lines +', wk.linesAdded || 0, pw.linesAdded || 0),
|
|
801
|
+
row('ships', wk.ships || 0, pw.ships || 0),
|
|
802
|
+
row('est. cost', wk.cost || 0, pw.cost || 0, fmtCost),
|
|
803
|
+
], 72);
|
|
804
|
+
console.log('');
|
|
805
|
+
}
|
|
806
|
+
|
|
690
807
|
// Rows for the subscription-usage box (shared by `status` and `usage`).
|
|
691
808
|
// Bars ride the heat ramp — the % IS the intensity. Absent buckets (per-model
|
|
692
809
|
// outside Max plans) drop out.
|
|
@@ -1951,6 +2068,75 @@ async function profileVerify() {
|
|
|
1951
2068
|
}
|
|
1952
2069
|
}
|
|
1953
2070
|
|
|
2071
|
+
// `wrapped --publish [--year N] [--yes]` — put your year-in-review on the web
|
|
2072
|
+
// at claude-rpc.com/wrapped/<handle>. Opt-in and explicit: the exact payload
|
|
2073
|
+
// is printed and confirmed before anything leaves the machine (the name-level
|
|
2074
|
+
// privacy filter can't see everything the presence valve can, so the human
|
|
2075
|
+
// check is part of the design, not politeness).
|
|
2076
|
+
async function doWrappedPublish(argv) {
|
|
2077
|
+
await loadStats();
|
|
2078
|
+
const cfg = loadConfig();
|
|
2079
|
+
const aggregate = readAggregate();
|
|
2080
|
+
if (!aggregate) return fail('no aggregate yet', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
|
|
2081
|
+
if (!cfg.profile?.handle || cfg.profile?.enabled === false) {
|
|
2082
|
+
return fail('wrapped publishes under your leaderboard profile', {
|
|
2083
|
+
hint: 'set one up first: `claude-rpc profile set --handle <you>` then `claude-rpc profile publish`',
|
|
2084
|
+
code: EX_BAD_STATE,
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
const instanceId = cfg.community?.instanceId;
|
|
2088
|
+
if (!instanceId) return fail('no instanceId — run `claude-rpc setup`', { code: EX_BAD_STATE });
|
|
2089
|
+
|
|
2090
|
+
const yearFlag = argv.indexOf('--year');
|
|
2091
|
+
const year = yearFlag !== -1 ? Number(argv[yearFlag + 1]) : new Date().getFullYear();
|
|
2092
|
+
const { buildWrappedPayload, publishWrapped } = await import('./community.js');
|
|
2093
|
+
const payload = buildWrappedPayload(aggregate, cfg, { instanceId, year });
|
|
2094
|
+
const w = payload.wrapped;
|
|
2095
|
+
|
|
2096
|
+
console.log('');
|
|
2097
|
+
console.log(` ${c.bold}${c.magenta}◆ Claude Wrapped ${year}${c.reset} ${c.dim}— what will be published${c.reset}`);
|
|
2098
|
+
console.log('');
|
|
2099
|
+
box(`wrapped ${year}`, [
|
|
2100
|
+
pair('active', fmtHours(w.activeMs), c.green),
|
|
2101
|
+
pair('sessions', fmtNum(w.sessions)),
|
|
2102
|
+
pair('tokens', fmtNum(w.tokens)),
|
|
2103
|
+
pair('prompts', fmtNum(w.prompts), c.yellow),
|
|
2104
|
+
pair('best streak', `${w.streakBest}d`),
|
|
2105
|
+
pair('days active', String(w.daysActive)),
|
|
2106
|
+
pair('ships', fmtNum(w.ships)),
|
|
2107
|
+
pair('est. cost', fmtCost(w.costUsd), c.yellow),
|
|
2108
|
+
pair('projects', w.topProjects.map((p) => p.name).join(' · ') || c.dim + '(none public)' + c.reset, ''),
|
|
2109
|
+
pair('languages', w.topLanguages.map((l) => l.name).join(' · ') || '—', ''),
|
|
2110
|
+
pair('models', w.topModels.map((m) => `${m.name} ${m.pct}%`).join(' · ') || '—', ''),
|
|
2111
|
+
pair('hotspot', w.hotspot ? `${w.hotspot.name} (${fmtNum(w.hotspot.count)} edits)` : '—', ''),
|
|
2112
|
+
], 72);
|
|
2113
|
+
console.log(` ${c.dim}full payload: the fields above plus lines/cache%/peaks — nothing else.${c.reset}`);
|
|
2114
|
+
console.log(` ${c.dim}private-listed and pattern-matched project names are already excluded.${c.reset}\n`);
|
|
2115
|
+
|
|
2116
|
+
if (!argv.includes('--yes')) {
|
|
2117
|
+
if (!process.stdout.isTTY) {
|
|
2118
|
+
return fail('refusing to publish without confirmation on a non-TTY', {
|
|
2119
|
+
hint: 'pass --yes to publish non-interactively', code: EX_USER_ERROR,
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
const answer = (await prompt(` publish to claude-rpc.com/wrapped/${cfg.profile.handle}? [y/N] `)).trim().toLowerCase();
|
|
2123
|
+
if (answer !== 'y' && answer !== 'yes') { console.log(` ${c.dim}nothing sent.${c.reset}\n`); return; }
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
const res = await publishWrapped(cfg, payload);
|
|
2127
|
+
if (!res.ok) {
|
|
2128
|
+
if (res.reason === 'no-profile') {
|
|
2129
|
+
return fail('the worker has no published profile for this machine yet', {
|
|
2130
|
+
hint: 'run `claude-rpc profile publish` once, then retry', code: EX_BAD_STATE,
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
return fail(`publish failed: ${res.reason}${res.error ? ` (${res.error})` : ''}`, { code: EX_SYS_ERROR });
|
|
2134
|
+
}
|
|
2135
|
+
console.log(` ${c.green}✓ published${c.reset} ${c.bold}${res.url}${c.reset}`);
|
|
2136
|
+
console.log(` ${c.dim}share card:${c.reset} ${(cfg.community?.endpoint || '').replace(/\/+$/, '')}/wrapped/${res.handle}.svg`);
|
|
2137
|
+
console.log(` ${c.dim}re-publish any time — same command overwrites.${c.reset}\n`);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
1954
2140
|
async function doProfile(argv) {
|
|
1955
2141
|
const sub = (argv[0] || 'status').toLowerCase();
|
|
1956
2142
|
if (sub === 'status' || sub === '') return profileStatus();
|
|
@@ -2084,6 +2270,9 @@ function help() {
|
|
|
2084
2270
|
['status', 'Interactive stats TUI; --dump (or piped) prints static text'],
|
|
2085
2271
|
['today', 'Focus view: today\'s stats + 24h activity histogram'],
|
|
2086
2272
|
['week', 'Focus view: this week, daily breakdown'],
|
|
2273
|
+
['compare', 'This week vs last week — the full side-by-side diff'],
|
|
2274
|
+
['projects', 'Every project ranked by active time (--limit N)'],
|
|
2275
|
+
['project', 'One project\'s full record + recent daily rhythm'],
|
|
2087
2276
|
['usage', 'Subscription limits — session + weekly % (what /usage shows)'],
|
|
2088
2277
|
['serve', 'Open a live web dashboard in your browser'],
|
|
2089
2278
|
['preview', 'Show how each rotation frame renders right now'],
|
|
@@ -2103,6 +2292,7 @@ function help() {
|
|
|
2103
2292
|
['mcp uninstall', 'Remove the stats MCP server from Claude Code'],
|
|
2104
2293
|
['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
|
|
2105
2294
|
['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
|
|
2295
|
+
['wrapped --publish', 'Put your Wrapped on the web at claude-rpc.com/wrapped/<handle>'],
|
|
2106
2296
|
['pause', 'Snooze the Discord card globally (pause [30m|2h], default 1h)'],
|
|
2107
2297
|
['resume', 'Lift a pause early'],
|
|
2108
2298
|
['export', 'Dump the aggregate as JSON, or daily rows as CSV (--csv --out)'],
|
|
@@ -2240,6 +2430,9 @@ process.on('unhandledRejection', (e) => {
|
|
|
2240
2430
|
case 'dump': showStatus(); break;
|
|
2241
2431
|
case 'today': showToday(); break;
|
|
2242
2432
|
case 'week': showWeek(); break;
|
|
2433
|
+
case 'projects': showProjects(process.argv.slice(3)); break;
|
|
2434
|
+
case 'project': showProject(process.argv[3]); break;
|
|
2435
|
+
case 'compare': showCompare(); break;
|
|
2243
2436
|
case 'usage': await showUsage(); break;
|
|
2244
2437
|
case 'serve': await import('./server/index.js'); break;
|
|
2245
2438
|
case 'preview': showPreview(); break;
|
|
@@ -2263,7 +2456,9 @@ process.on('unhandledRejection', (e) => {
|
|
|
2263
2456
|
await doMcp();
|
|
2264
2457
|
break;
|
|
2265
2458
|
}
|
|
2266
|
-
case 'wrapped':
|
|
2459
|
+
case 'wrapped':
|
|
2460
|
+
if (process.argv.slice(3).includes('--publish')) { await doWrappedPublish(process.argv.slice(3)); break; }
|
|
2461
|
+
process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
|
|
2267
2462
|
case 'pause': doPause(process.argv.slice(3)); break;
|
|
2268
2463
|
case 'resume':
|
|
2269
2464
|
case 'unpause': doResume(); break;
|
package/src/community.js
CHANGED
|
@@ -24,6 +24,9 @@ import { platform } from 'node:os';
|
|
|
24
24
|
import { AGGREGATE_PATH, STATE_DIR } from './paths.js';
|
|
25
25
|
import { VERSION } from './version.js';
|
|
26
26
|
import { profileIsPublishable } from './leaderboard.js';
|
|
27
|
+
import { projectNameIsPrivate } from './privacy.js';
|
|
28
|
+
import { cleanProjectName } from './scanner.js';
|
|
29
|
+
import { humanModel } from './format.js';
|
|
27
30
|
|
|
28
31
|
const CURSOR_PATH = join(STATE_DIR, 'community-cursor.json');
|
|
29
32
|
|
|
@@ -116,6 +119,152 @@ export function buildProfilePayload(aggregate, profileCfg, { instanceId, now = D
|
|
|
116
119
|
};
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
// ── Claude Wrapped ───────────────────────────────────────────────────────
|
|
123
|
+
//
|
|
124
|
+
// Schema pair with the worker's validateWrapped/sanitizeWrapped — add fields
|
|
125
|
+
// in BOTH places. Everything here is derived from aggregate.json; per-day
|
|
126
|
+
// slices are year-scoped, and the few lifetime-only dimensions (languages,
|
|
127
|
+
// tool mix, session lengths) are labeled as such on the page. Project names
|
|
128
|
+
// pass the name-level privacy check AND the whole payload is shown to the
|
|
129
|
+
// user before anything is sent — publish is a one-shot, explicit action.
|
|
130
|
+
export function buildWrappedPayload(aggregate, config, { instanceId, year, now = Date.now() }) {
|
|
131
|
+
const agg = aggregate || {};
|
|
132
|
+
const y = year || new Date(now).getFullYear();
|
|
133
|
+
const days = Object.entries(agg.byDay || {}).filter(([k]) => k.startsWith(`${y}-`));
|
|
134
|
+
|
|
135
|
+
const sum = (f) => days.reduce((acc, [, d]) => acc + (d[f] || 0), 0);
|
|
136
|
+
const tokensOf = (d) => (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0);
|
|
137
|
+
const tokens = days.reduce((acc, [, d]) => acc + tokensOf(d), 0);
|
|
138
|
+
const cacheTokens = days.reduce((acc, [, d]) => acc + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0), 0);
|
|
139
|
+
|
|
140
|
+
// Peak day by active time.
|
|
141
|
+
let peakDay = null;
|
|
142
|
+
for (const [date, d] of days) {
|
|
143
|
+
if ((d.activeMs || 0) > 0 && (!peakDay || d.activeMs > peakDay.activeMs)) {
|
|
144
|
+
peakDay = { date, activeMs: d.activeMs };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Year-scoped project ranking from per-day attribution, privacy-filtered.
|
|
149
|
+
const projMs = {};
|
|
150
|
+
for (const [, d] of days) {
|
|
151
|
+
for (const [name, p] of Object.entries(d.projects || {})) {
|
|
152
|
+
projMs[name] = (projMs[name] || 0) + (p.activeMs || 0);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const topProjects = Object.entries(projMs)
|
|
156
|
+
.filter(([name]) => !projectNameIsPrivate(name, config, cleanProjectName))
|
|
157
|
+
.sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
158
|
+
.map(([name, activeMs]) => ({ name, activeMs }));
|
|
159
|
+
|
|
160
|
+
// Year-scoped model mix from the per-day byModel buckets (scan cache v7).
|
|
161
|
+
// Share is by SPEND, matching the local wrapped's "your models, by spend"
|
|
162
|
+
// slide; token share is the fallback when everything costed to zero. Names
|
|
163
|
+
// are humanized here so the web story renders "Opus 4.8", same as local.
|
|
164
|
+
const modelAgg = {};
|
|
165
|
+
for (const [, d] of days) {
|
|
166
|
+
for (const [m, v] of Object.entries(d.byModel || {})) {
|
|
167
|
+
const t = (modelAgg[m] ||= { tokens: 0, cost: 0 });
|
|
168
|
+
t.tokens += v.tokens || 0;
|
|
169
|
+
t.cost += v.cost || 0;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const costTotal = Object.values(modelAgg).reduce((a, b) => a + b.cost, 0);
|
|
173
|
+
const tokTotal = Object.values(modelAgg).reduce((a, b) => a + b.tokens, 0);
|
|
174
|
+
const share = (v) => costTotal > 0 ? v.cost / costTotal : (tokTotal > 0 ? v.tokens / tokTotal : 0);
|
|
175
|
+
const topModels = Object.entries(modelAgg).sort((a, b) => share(b[1]) - share(a[1])).slice(0, 4)
|
|
176
|
+
.map(([name, v]) => ({ name: humanModel(name) || name, pct: Math.round(share(v) * 100) }));
|
|
177
|
+
|
|
178
|
+
// Lifetime dimensions (no yearly slice exists for these).
|
|
179
|
+
const toolTotal = Object.values(agg.toolBreakdown || {}).reduce((a, b) => a + b, 0);
|
|
180
|
+
const toolMix = Object.entries(agg.toolBreakdown || {}).sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
181
|
+
.map(([name, n]) => ({ name, pct: toolTotal ? Math.round((n / toolTotal) * 100) : 0 }));
|
|
182
|
+
const topLanguages = Object.entries(agg.languages || {})
|
|
183
|
+
.sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0)).slice(0, 5)
|
|
184
|
+
.map(([name, v]) => ({ name, edits: v.edits || 0 }));
|
|
185
|
+
const sl = agg.sessionLengths || {};
|
|
186
|
+
const marathonPct = sl.count ? Math.round((((sl.buckets?.h2to4 || 0) + (sl.buckets?.gt4h || 0)) / sl.count) * 100) : 0;
|
|
187
|
+
|
|
188
|
+
// The hotspot file (basename only, never a path) and peak weekday — the two
|
|
189
|
+
// remaining slides of the local story. Both lifetime, like the local page.
|
|
190
|
+
const top = (agg.topEditedFiles || [])[0] || null;
|
|
191
|
+
const hotName = top ? String(top.path || '').split(/[\\/]/).pop() : null;
|
|
192
|
+
const hotspot = top && hotName && !projectNameIsPrivate(hotName, config, cleanProjectName)
|
|
193
|
+
? { name: hotName, count: top.count || 0, ...(top.daysSinceLastEdit != null ? { daysSinceLastEdit: top.daysSinceLastEdit } : {}) }
|
|
194
|
+
: null;
|
|
195
|
+
let peakWeekday = null;
|
|
196
|
+
{
|
|
197
|
+
const WD = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
198
|
+
for (const [k, v] of Object.entries(agg.byWeekday || {})) {
|
|
199
|
+
if ((v.activeMs || 0) > 0 && (!peakWeekday || v.activeMs > peakWeekday.activeMs)) {
|
|
200
|
+
peakWeekday = { name: WD[Number(k)] || '', activeMs: v.activeMs };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const compactions = Object.entries(agg.compactionsByDay || {})
|
|
206
|
+
.filter(([k]) => k.startsWith(`${y}-`)).reduce((acc, [, n]) => acc + n, 0);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
instanceId,
|
|
210
|
+
year: y,
|
|
211
|
+
wrapped: {
|
|
212
|
+
activeMs: sum('activeMs'),
|
|
213
|
+
sessions: sum('sessions'),
|
|
214
|
+
tokens,
|
|
215
|
+
prompts: sum('userMessages'),
|
|
216
|
+
streakBest: agg.longestStreak || 0,
|
|
217
|
+
streak: agg.streak || 0,
|
|
218
|
+
daysSinceFirst: agg.daysSinceFirst || 0,
|
|
219
|
+
daysActive: days.filter(([, d]) => (d.activeMs || 0) > 0 || (d.userMessages || 0) > 0).length,
|
|
220
|
+
linesAdded: sum('linesAdded'),
|
|
221
|
+
linesRemoved: sum('linesRemoved'),
|
|
222
|
+
ships: sum('ships'),
|
|
223
|
+
cachePct: tokens ? Math.round((cacheTokens / tokens) * 100) : 0,
|
|
224
|
+
peakHour: agg.peakHour?.hour ?? 0,
|
|
225
|
+
...(peakDay ? { peakDay } : {}),
|
|
226
|
+
longestSessionMs: sl.longestMs || 0,
|
|
227
|
+
marathonPct,
|
|
228
|
+
compactions,
|
|
229
|
+
subagentActiveMs: agg.subagentActiveMs || 0,
|
|
230
|
+
costUsd: Math.round(sum('cost') * 100) / 100,
|
|
231
|
+
topProjects,
|
|
232
|
+
topLanguages,
|
|
233
|
+
topModels,
|
|
234
|
+
toolMix,
|
|
235
|
+
...(hotspot ? { hotspot } : {}),
|
|
236
|
+
...(peakWeekday ? { peakWeekday } : {}),
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// One-shot POST of a built wrapped payload. Mirrors flushProfile's error
|
|
242
|
+
// contract: never throws, returns { ok, reason?, url? }.
|
|
243
|
+
export async function publishWrapped(cfg, payload, { fetchImpl = globalThis.fetch } = {}) {
|
|
244
|
+
const community = cfg?.community || {};
|
|
245
|
+
if (!community.endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
246
|
+
const url = community.endpoint.replace(/\/+$/, '') + '/wrapped';
|
|
247
|
+
let res;
|
|
248
|
+
try {
|
|
249
|
+
res = await fetchImpl(url, {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: { 'Content-Type': 'application/json' },
|
|
252
|
+
body: JSON.stringify(payload),
|
|
253
|
+
signal: AbortSignal.timeout(10_000),
|
|
254
|
+
});
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return { ok: false, reason: 'network', error: e.message };
|
|
257
|
+
}
|
|
258
|
+
let body = null;
|
|
259
|
+
try { body = await res.json(); } catch { /* non-JSON error body */ }
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
if (res.status === 403) return { ok: false, reason: 'no-profile' };
|
|
262
|
+
if (res.status === 429) return { ok: false, reason: 'rate-limited' };
|
|
263
|
+
return { ok: false, reason: `http-${res.status}`, error: body?.error };
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, url: body?.url, handle: body?.handle, year: body?.year };
|
|
266
|
+
}
|
|
267
|
+
|
|
119
268
|
export async function flushProfile(cfg, {
|
|
120
269
|
aggregatePath = AGGREGATE_PATH,
|
|
121
270
|
fetchImpl = globalThis.fetch,
|
package/src/daemon.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Client } from './discord-ipc.js';
|
|
|
6
6
|
import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
|
|
7
7
|
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession, throttleDecision } from './presence.js';
|
|
8
8
|
import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
|
|
9
|
-
import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
|
|
9
|
+
import { scan, readAggregate, findLiveSessions, readSessionTokens, readSessionModel } from './scanner.js';
|
|
10
10
|
import { detectGithubUrl } from './git.js';
|
|
11
11
|
import { applyPrivacy } from './privacy.js';
|
|
12
12
|
import { pauseUntil } from './pause.js';
|
|
@@ -167,9 +167,17 @@ if (ownerPid) {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
// Reclaim any per-pid state tmp files orphaned by a hard-killed writer, plus
|
|
170
|
-
// per-session state files from sessions that ended long ago.
|
|
170
|
+
// per-session state files from sessions that ended long ago. The session-state
|
|
171
|
+
// sweep repeats every 30 min — a daemon runs for weeks, and every file left
|
|
172
|
+
// behind is re-read by listSessionStates on every render tick, so boot-only
|
|
173
|
+
// sweeping leaks both disk and per-tick CPU.
|
|
171
174
|
sweepStaleStateTmp();
|
|
172
175
|
sweepStaleSessionStates();
|
|
176
|
+
const sweepTimer = setInterval(() => {
|
|
177
|
+
sweepStaleStateTmp();
|
|
178
|
+
sweepStaleSessionStates();
|
|
179
|
+
}, 30 * 60 * 1000);
|
|
180
|
+
if (sweepTimer.unref) sweepTimer.unref();
|
|
173
181
|
|
|
174
182
|
// pickFrames / selectFrame / resolveLargeImageKey now live in presence.js (pure
|
|
175
183
|
// + unit-tested). The rotation cursor (rotationCursor) is owned here and passed
|
|
@@ -228,6 +236,11 @@ function resolvePresence(opts = {}) {
|
|
|
228
236
|
if (match) {
|
|
229
237
|
const t = readSessionTokens(match.path);
|
|
230
238
|
if (t) state.tokens = t;
|
|
239
|
+
// The hook only learns the model at SessionStart; a mid-session /model
|
|
240
|
+
// switch is visible only in the transcript. Render-time override — the
|
|
241
|
+
// state file stays hook-owned.
|
|
242
|
+
const liveModel = readSessionModel(match.path);
|
|
243
|
+
if (liveModel) state.model = liveModel;
|
|
231
244
|
}
|
|
232
245
|
}
|
|
233
246
|
|
package/src/format.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename, dirname, extname } from 'node:path';
|
|
2
2
|
import { dayKey, weekKey, DATE_SUFFIX_RE, cleanProjectName } from './scanner.js';
|
|
3
|
-
import { fmtCost } from './pricing.js';
|
|
3
|
+
import { fmtCost, ratesFor } from './pricing.js';
|
|
4
4
|
import { languageOf } from './languages.js';
|
|
5
5
|
import { detectGitBranch, detectGitRepo } from './git.js';
|
|
6
6
|
import { fmtResetTime, fmtResetDay } from './usage.js';
|
|
@@ -296,6 +296,27 @@ export function buildVars(state, config, aggregate) {
|
|
|
296
296
|
const totalToolCalls = mcpCalls + builtinCalls;
|
|
297
297
|
const mcpPct = totalToolCalls > 0 ? Math.round((mcpCalls / totalToolCalls) * 100) : 0;
|
|
298
298
|
|
|
299
|
+
// Tool mix — the actual top tools (Read/Edit/Bash…), not just the MCP split.
|
|
300
|
+
const toolCallsTotal = Object.values(agg.toolBreakdown || {}).reduce((a, b) => a + b, 0);
|
|
301
|
+
const toolsTop = topN(agg.toolBreakdown, 3);
|
|
302
|
+
const topToolEntry = toolsTop[0] || null;
|
|
303
|
+
const toolMixLabel = toolCallsTotal > 0
|
|
304
|
+
? toolsTop.map(([name, n]) => `${name} ${Math.round((n / toolCallsTotal) * 100)}%`).join(' · ')
|
|
305
|
+
: '';
|
|
306
|
+
|
|
307
|
+
// Peak weekday by active time — byWeekday holds one blankDay bucket per 0–6.
|
|
308
|
+
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
309
|
+
let peakWd = null;
|
|
310
|
+
for (const [wd, b] of Object.entries(agg.byWeekday || {})) {
|
|
311
|
+
if ((b.activeMs || 0) > 0 && (!peakWd || b.activeMs > peakWd.activeMs)) {
|
|
312
|
+
peakWd = { name: WEEKDAY_NAMES[Number(wd)] || '', activeMs: b.activeMs };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Context compactions — appended by the PreCompact hook since v0.8, counted
|
|
317
|
+
// into the aggregate since v1.2.
|
|
318
|
+
const compactionsToday = (agg.compactionsByDay || {})[dayKey(Date.now())] || 0;
|
|
319
|
+
|
|
299
320
|
// Cost.
|
|
300
321
|
const todayCost = today.cost || 0;
|
|
301
322
|
const weekCost = thisWeek.cost || 0;
|
|
@@ -389,6 +410,12 @@ export function buildVars(state, config, aggregate) {
|
|
|
389
410
|
const TOOL_ELAPSED_THRESHOLD_MS = 5_000;
|
|
390
411
|
const toolElapsed = toolMs >= TOOL_ELAPSED_THRESHOLD_MS ? fmtToolElapsed(toolMs) : '';
|
|
391
412
|
|
|
413
|
+
// What cache reuse saved vs. paying fresh-input rates for the same tokens —
|
|
414
|
+
// an estimate priced at the top-by-spend model's rates (DEFAULT when unknown).
|
|
415
|
+
const savingsRates = ratesFor(topModelEntry?.model || '');
|
|
416
|
+
const cacheSavedUsd = ((agg.cacheReadTokens || 0) / 1e6)
|
|
417
|
+
* Math.max(0, (savingsRates.input || 0) - (savingsRates.cacheRead || 0));
|
|
418
|
+
|
|
392
419
|
// Compaction vars — populated only while a compaction is in flight so
|
|
393
420
|
// the {compactDuration} suffix in the compacting template collapses
|
|
394
421
|
// away naturally otherwise (via fillTemplate's `·` collapse).
|
|
@@ -724,6 +751,26 @@ export function buildVars(state, config, aggregate) {
|
|
|
724
751
|
builtinToolCallsFmt: fmtNum(builtinCalls),
|
|
725
752
|
mcpToolPercent: mcpPct,
|
|
726
753
|
mcpToolPercentLabel: totalToolCalls ? `${mcpPct}% MCP` : '',
|
|
754
|
+
// Tool mix (v1.2) — the actual top tools, lifetime.
|
|
755
|
+
topTool: topToolEntry ? topToolEntry[0] : '',
|
|
756
|
+
topToolCount: topToolEntry ? topToolEntry[1] : 0,
|
|
757
|
+
topToolLabel: topToolEntry ? `${topToolEntry[0]} × ${fmtNum(topToolEntry[1])}` : '',
|
|
758
|
+
toolMixLabel,
|
|
759
|
+
|
|
760
|
+
// ── Delegated work / rhythm / context (v1.2) ────────────────
|
|
761
|
+
subagentActiveMs: agg.subagentActiveMs || 0,
|
|
762
|
+
subagentHours: fmtHours(agg.subagentActiveMs || 0),
|
|
763
|
+
subagentHoursLabel: (agg.subagentActiveMs || 0) > 0
|
|
764
|
+
? `${fmtHours(agg.subagentActiveMs)} delegated` : '',
|
|
765
|
+
peakWeekday: peakWd ? peakWd.name : '',
|
|
766
|
+
peakWeekdayLabel: peakWd ? `busiest on ${peakWd.name}s` : '',
|
|
767
|
+
compactions: agg.compactions || 0,
|
|
768
|
+
compactionsToday,
|
|
769
|
+
compactionsTodayLabel: compactionsToday > 0
|
|
770
|
+
? `${compactionsToday} context squeeze${compactionsToday === 1 ? '' : 's'} today` : '',
|
|
771
|
+
cacheSavedUsd,
|
|
772
|
+
cacheSavedFmt: fmtCost(cacheSavedUsd),
|
|
773
|
+
cacheSavedLabel: cacheSavedUsd >= 1 ? `≈${fmtCost(cacheSavedUsd)} saved by cache` : '',
|
|
727
774
|
|
|
728
775
|
// ── Cost ────────────────────────────────────────────────────
|
|
729
776
|
todayCost,
|
package/src/privacy.js
CHANGED
|
@@ -255,6 +255,30 @@ export function resolveVisibility(cwd, config = {}) {
|
|
|
255
255
|
return { visibility: 'public', projectName: proj?.projectName ?? null, reason: 'default' };
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// Name-level privacy check for surfaces that only have a project NAME, not a
|
|
259
|
+
// cwd (the wrapped publish payload). Weaker than resolveVisibility — without a
|
|
260
|
+
// cwd it can't read .claude-rpc.json or auto-detect gh-private — which is why
|
|
261
|
+
// the wrapped publish ALSO shows the exact payload and asks before sending.
|
|
262
|
+
// `clean` lets the caller pass scanner.cleanProjectName so list entries match
|
|
263
|
+
// the cleaned names the aggregate uses (kept an argument to keep this module
|
|
264
|
+
// import-light for the hook path).
|
|
265
|
+
export function projectNameIsPrivate(name, config = {}, clean = (s) => s) {
|
|
266
|
+
if (!name) return true;
|
|
267
|
+
const n = String(name).toLowerCase();
|
|
268
|
+
const matches = (cwd) => {
|
|
269
|
+
const leaf = basename(cwd);
|
|
270
|
+
return leaf.toLowerCase() === n || String(clean(leaf)).toLowerCase() === n;
|
|
271
|
+
};
|
|
272
|
+
for (const cwd of listPrivateCwds()) if (matches(cwd)) return true;
|
|
273
|
+
for (const [cwd, level] of Object.entries(listVisibility())) {
|
|
274
|
+
if (level !== 'public' && matches(cwd)) return true;
|
|
275
|
+
}
|
|
276
|
+
for (const p of config?.privacy?.patterns || []) {
|
|
277
|
+
if (matchesPattern(n, p)) return true;
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
258
282
|
// Glob-lite. '*' matches any run; otherwise plain text. Case-insensitive.
|
|
259
283
|
function matchesPattern(name, pattern) {
|
|
260
284
|
if (pattern === name) return true;
|
package/src/scanner.js
CHANGED
|
@@ -11,7 +11,9 @@ import { classifyShip } from './ships.js';
|
|
|
11
11
|
// v5: per-day ships/shipKinds + per-day project attribution (recap).
|
|
12
12
|
// v6: day/week/hour buckets carry firstTs/lastTs (they were declared but
|
|
13
13
|
// never assigned, leaving {startTimeLabel} permanently blank).
|
|
14
|
-
|
|
14
|
+
// v7: per-day byModel buckets in transcript summaries — bump forces the one
|
|
15
|
+
// full rescan that backfills model-mix history for already-scanned files.
|
|
16
|
+
const CACHE_VERSION = 7;
|
|
15
17
|
|
|
16
18
|
// Cap counted gap between consecutive timestamps. Anything larger is treated
|
|
17
19
|
// as the user walking away — we count only what's plausibly active time.
|
|
@@ -140,6 +142,8 @@ function blankDay() {
|
|
|
140
142
|
// shipKinds — { push|commit|pr|issue|tag → count } (only when a ship lands)
|
|
141
143
|
// projects — { name → { activeMs, tokens } } (aggregate-level only,
|
|
142
144
|
// attributed at merge time where the file's project is known)
|
|
145
|
+
// byModel — { pricingKey → { turns, tokens, cost } } (day buckets only —
|
|
146
|
+
// week/hour stay lean; powers model-mix-over-time)
|
|
143
147
|
};
|
|
144
148
|
}
|
|
145
149
|
|
|
@@ -160,6 +164,12 @@ function mergeDay(target, src) {
|
|
|
160
164
|
for (const [k, n] of Object.entries(src.shipKinds || {})) {
|
|
161
165
|
(target.shipKinds ||= {})[k] = (target.shipKinds[k] || 0) + n;
|
|
162
166
|
}
|
|
167
|
+
for (const [m, v] of Object.entries(src.byModel || {})) {
|
|
168
|
+
const t = ((target.byModel ||= {})[m] ||= { turns: 0, tokens: 0, cost: 0 });
|
|
169
|
+
t.turns += v.turns || 0;
|
|
170
|
+
t.tokens += v.tokens || 0;
|
|
171
|
+
t.cost += v.cost || 0;
|
|
172
|
+
}
|
|
163
173
|
if (src.firstTs && (!target.firstTs || src.firstTs < target.firstTs)) target.firstTs = src.firstTs;
|
|
164
174
|
if (src.lastTs && (!target.lastTs || src.lastTs > target.lastTs)) target.lastTs = src.lastTs;
|
|
165
175
|
}
|
|
@@ -336,6 +346,13 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
336
346
|
mb.tokens += (u.input_tokens || 0) + (u.output_tokens || 0)
|
|
337
347
|
+ (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
338
348
|
}
|
|
349
|
+
// Per-day model split (day granularity only — week/hour stay lean).
|
|
350
|
+
// Powers model-mix-over-time; the all-time byModel can't trend.
|
|
351
|
+
if (mkey && dayBucket) {
|
|
352
|
+
const dm = ((dayBucket.byModel ||= {})[mkey] ||= { turns: 0, tokens: 0, cost: 0 });
|
|
353
|
+
dm.tokens += (u.input_tokens || 0) + (u.output_tokens || 0)
|
|
354
|
+
+ (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
355
|
+
}
|
|
339
356
|
// Per-turn cost — uses this turn's model id, not the session's first-seen one.
|
|
340
357
|
const turnCost = costFor({ model: turnModel, usage: u });
|
|
341
358
|
if (turnCost > 0) {
|
|
@@ -346,6 +363,10 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
346
363
|
const ck = mkey || 'sonnet';
|
|
347
364
|
summary.costByModel[ck] = (summary.costByModel[ck] || 0) + turnCost;
|
|
348
365
|
if (mb) mb.cost += turnCost;
|
|
366
|
+
if (dayBucket) {
|
|
367
|
+
const dm = ((dayBucket.byModel ||= {})[ck] ||= { turns: 0, tokens: 0, cost: 0 });
|
|
368
|
+
dm.cost += turnCost;
|
|
369
|
+
}
|
|
349
370
|
for (const bucket of allBuckets) bucket.cost += turnCost;
|
|
350
371
|
}
|
|
351
372
|
}
|
|
@@ -354,6 +375,10 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
354
375
|
if (firstSeen) {
|
|
355
376
|
summary.modelsUsed[turnModel] = (summary.modelsUsed[turnModel] || 0) + 1;
|
|
356
377
|
if (mb) mb.turns += 1;
|
|
378
|
+
if (mkey && dayBucket) {
|
|
379
|
+
const dm = ((dayBucket.byModel ||= {})[mkey] ||= { turns: 0, tokens: 0, cost: 0 });
|
|
380
|
+
dm.turns += 1;
|
|
381
|
+
}
|
|
357
382
|
}
|
|
358
383
|
}
|
|
359
384
|
const blocks = r.message?.content || [];
|
|
@@ -592,16 +617,29 @@ function readTranscriptCwd(path, mtimeMs) {
|
|
|
592
617
|
// Per-transcript token cache. Reading a multi-MB .jsonl on every push tick
|
|
593
618
|
// (4s) would be wasteful, so we only re-parse when the file's mtime has
|
|
594
619
|
// advanced since the last read.
|
|
595
|
-
const sessionTokenCache = new Map(); // path → { mtime, size, offset, tokens }
|
|
620
|
+
const sessionTokenCache = new Map(); // path → { mtime, size, offset, tokens, seenIds, model }
|
|
596
621
|
|
|
597
622
|
// Accumulate assistant-usage tokens from a chunk of complete JSONL lines.
|
|
598
|
-
|
|
623
|
+
// Claude Code repeats the same `usage` object on every content-block line of
|
|
624
|
+
// one assistant message, so count each message.id once (`seenIds`, the same
|
|
625
|
+
// bounded-ring dedup parseChunkInto uses) — otherwise the live count drifts
|
|
626
|
+
// above the lifetime aggregate on multi-block turns. Lines with no id
|
|
627
|
+
// (rare/legacy) are counted every time, matching the full scanner. Also
|
|
628
|
+
// captures the newest turn's model so a mid-session /model switch is visible.
|
|
629
|
+
function sumUsageLines(text, tokens, seenIds, meta) {
|
|
599
630
|
for (const line of iterLines(text)) {
|
|
600
631
|
if (!line) continue;
|
|
601
632
|
const r = safeJson(line);
|
|
602
633
|
if (!r || r.type !== 'assistant') continue;
|
|
634
|
+
if (meta && r.message?.model) meta.model = r.message.model;
|
|
603
635
|
const u = r.message?.usage;
|
|
604
636
|
if (!u) continue;
|
|
637
|
+
const msgId = r.message?.id;
|
|
638
|
+
if (msgId && seenIds) {
|
|
639
|
+
if (seenIds.includes(msgId)) continue;
|
|
640
|
+
seenIds.push(msgId);
|
|
641
|
+
if (seenIds.length > RECENT_IDS_MAX) seenIds.shift();
|
|
642
|
+
}
|
|
605
643
|
tokens.input += u.input_tokens || 0;
|
|
606
644
|
tokens.output += u.output_tokens || 0;
|
|
607
645
|
tokens.cacheRead += u.cache_read_input_tokens || 0;
|
|
@@ -636,6 +674,8 @@ export function readSessionTokens(path) {
|
|
|
636
674
|
const tokens = canAppend
|
|
637
675
|
? { ...cached.tokens }
|
|
638
676
|
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
677
|
+
const seenIds = canAppend ? (cached.seenIds || []) : [];
|
|
678
|
+
const meta = { model: canAppend ? cached.model : null };
|
|
639
679
|
const startOffset = canAppend ? cached.offset : 0;
|
|
640
680
|
let newOffset = startOffset;
|
|
641
681
|
|
|
@@ -654,7 +694,7 @@ export function readSessionTokens(path) {
|
|
|
654
694
|
if (lastNl !== -1) {
|
|
655
695
|
const complete = text.slice(0, lastNl + 1);
|
|
656
696
|
newOffset = startOffset + Buffer.byteLength(complete, 'utf8');
|
|
657
|
-
sumUsageLines(complete, tokens);
|
|
697
|
+
sumUsageLines(complete, tokens, seenIds, meta);
|
|
658
698
|
}
|
|
659
699
|
}
|
|
660
700
|
} catch {
|
|
@@ -665,10 +705,26 @@ export function readSessionTokens(path) {
|
|
|
665
705
|
}
|
|
666
706
|
}
|
|
667
707
|
|
|
668
|
-
lruTouch(sessionTokenCache, path, { mtime: st.mtimeMs, size: st.size, offset: newOffset, tokens });
|
|
708
|
+
lruTouch(sessionTokenCache, path, { mtime: st.mtimeMs, size: st.size, offset: newOffset, tokens, seenIds, model: meta.model });
|
|
669
709
|
return tokens;
|
|
670
710
|
}
|
|
671
711
|
|
|
712
|
+
// Latest assistant-turn model seen in a transcript — the live answer to "which
|
|
713
|
+
// model is this session on NOW". SessionStart is the only hook that carries a
|
|
714
|
+
// model, so a mid-session /model switch never reaches the state file; the
|
|
715
|
+
// transcript is the only source of truth. Piggybacks on readSessionTokens'
|
|
716
|
+
// cache entry (same parse, same mtime discipline) — call it AFTER
|
|
717
|
+
// readSessionTokens for O(1); standalone calls populate the cache themselves.
|
|
718
|
+
export function readSessionModel(path) {
|
|
719
|
+
const cached = sessionTokenCache.get(path);
|
|
720
|
+
let mtime;
|
|
721
|
+
try { mtime = statSync(path).mtimeMs; } catch { return null; }
|
|
722
|
+
if (!cached || cached.mtime !== mtime) {
|
|
723
|
+
if (readSessionTokens(path) === null) return null;
|
|
724
|
+
}
|
|
725
|
+
return sessionTokenCache.get(path)?.model || null;
|
|
726
|
+
}
|
|
727
|
+
|
|
672
728
|
// Detect live sessions by transcript mtime. Returns array of { path, project, cwd, mtime, ageSec }.
|
|
673
729
|
// A session is "live" if its .jsonl was modified within thresholdMs.
|
|
674
730
|
// Roots default to the same set scan() uses — the canonical ~/.claude/projects
|
|
@@ -732,10 +788,12 @@ function writeCache(cache) {
|
|
|
732
788
|
|
|
733
789
|
// Per-day notification counts come from a hook-side append log, since
|
|
734
790
|
// transcripts don't carry Notification events reliably.
|
|
735
|
-
function
|
|
736
|
-
const out = {};
|
|
791
|
+
function readEventsByDay() {
|
|
792
|
+
const out = { notifications: {}, compactions: {} };
|
|
737
793
|
// The hook rotates events.jsonl to `.1` at 5MB; read both (oldest first) so a
|
|
738
|
-
// rotation doesn't silently drop a day's
|
|
794
|
+
// rotation doesn't silently drop a day's counts. PreCompact events have been
|
|
795
|
+
// appended since the hook first shipped them — counting them here finally
|
|
796
|
+
// surfaces how hard sessions push context.
|
|
739
797
|
for (const path of [EVENTS_LOG_PATH + '.1', EVENTS_LOG_PATH]) {
|
|
740
798
|
if (!existsSync(path)) continue;
|
|
741
799
|
try {
|
|
@@ -743,9 +801,13 @@ function readNotificationsByDay() {
|
|
|
743
801
|
for (const line of raw.split('\n')) {
|
|
744
802
|
if (!line) continue;
|
|
745
803
|
const e = safeJson(line);
|
|
746
|
-
if (!e ||
|
|
804
|
+
if (!e || !e.ts) continue;
|
|
805
|
+
const bucket = e.type === 'notification' ? out.notifications
|
|
806
|
+
: e.type === 'precompact' ? out.compactions
|
|
807
|
+
: null;
|
|
808
|
+
if (!bucket) continue;
|
|
747
809
|
const k = dayKey(e.ts);
|
|
748
|
-
|
|
810
|
+
bucket[k] = (bucket[k] || 0) + 1;
|
|
749
811
|
}
|
|
750
812
|
} catch { /* a rotation file unreadable/truncated — count what we can */ }
|
|
751
813
|
}
|
|
@@ -772,6 +834,15 @@ export function aggregateFrom(cache) {
|
|
|
772
834
|
sessions: 0,
|
|
773
835
|
subagentRuns: 0,
|
|
774
836
|
subagentActiveMs: 0,
|
|
837
|
+
// Session-length distribution (ACTIVE time, top-level sessions only —
|
|
838
|
+
// subagents overlap their parent). Wall clock was tried first and lies:
|
|
839
|
+
// a transcript left open across a week reads as a 170h "session".
|
|
840
|
+
// activeMs already excludes ≥5-min gaps, so it measures the sitting, not
|
|
841
|
+
// the tab. Histogram, not raw durations, to keep aggregate.json lean.
|
|
842
|
+
sessionLengths: {
|
|
843
|
+
count: 0, totalMs: 0, longestMs: 0,
|
|
844
|
+
buckets: { lt15m: 0, m15to30: 0, m30to60: 0, h1to2: 0, h2to4: 0, gt4h: 0 },
|
|
845
|
+
},
|
|
775
846
|
inputTokens: 0,
|
|
776
847
|
outputTokens: 0,
|
|
777
848
|
cacheReadTokens: 0,
|
|
@@ -892,6 +963,12 @@ export function aggregateFrom(cache) {
|
|
|
892
963
|
for (const [kind, n] of Object.entries(src.shipKinds || {})) {
|
|
893
964
|
(target.shipKinds ||= {})[kind] = (target.shipKinds[kind] || 0) + n;
|
|
894
965
|
}
|
|
966
|
+
for (const [m, v] of Object.entries(src.byModel || {})) {
|
|
967
|
+
const t = ((target.byModel ||= {})[m] ||= { turns: 0, tokens: 0, cost: 0 });
|
|
968
|
+
t.turns += v.turns || 0;
|
|
969
|
+
t.tokens += v.tokens || 0;
|
|
970
|
+
t.cost += v.cost || 0;
|
|
971
|
+
}
|
|
895
972
|
}
|
|
896
973
|
};
|
|
897
974
|
mergeSubBuckets(summary.byDay, agg.byDay);
|
|
@@ -907,6 +984,17 @@ export function aggregateFrom(cache) {
|
|
|
907
984
|
agg.userMessages += summary.userMessages || 0;
|
|
908
985
|
agg.activeMs += summary.activeMs || 0;
|
|
909
986
|
if (summary.firstTs && summary.lastTs) agg.wallMs += summary.lastTs - summary.firstTs;
|
|
987
|
+
const durMs = summary.activeMs || 0;
|
|
988
|
+
if (durMs > 0) {
|
|
989
|
+
const sl = agg.sessionLengths;
|
|
990
|
+
sl.count += 1;
|
|
991
|
+
sl.totalMs += durMs;
|
|
992
|
+
if (durMs > sl.longestMs) sl.longestMs = durMs;
|
|
993
|
+
const mins = durMs / 60_000;
|
|
994
|
+
const bucket = mins < 15 ? 'lt15m' : mins < 30 ? 'm15to30' : mins < 60 ? 'm30to60'
|
|
995
|
+
: mins < 120 ? 'h1to2' : mins < 240 ? 'h2to4' : 'gt4h';
|
|
996
|
+
sl.buckets[bucket] += 1;
|
|
997
|
+
}
|
|
910
998
|
if (summary.project) {
|
|
911
999
|
const p = agg.projects[summary.project] = agg.projects[summary.project] || {
|
|
912
1000
|
sessions: 0, activeMs: 0, inputTokens: 0, outputTokens: 0, userMessages: 0, toolCalls: 0,
|
|
@@ -1072,15 +1160,22 @@ export function aggregateFrom(cache) {
|
|
|
1072
1160
|
// Folded line totals.
|
|
1073
1161
|
agg.linesNet = (agg.linesAdded || 0) - (agg.linesRemoved || 0);
|
|
1074
1162
|
|
|
1075
|
-
// Notifications from the hook-side append log.
|
|
1076
|
-
const
|
|
1163
|
+
// Notifications + compactions from the hook-side append log.
|
|
1164
|
+
const events = readEventsByDay();
|
|
1077
1165
|
let notifTotal = 0;
|
|
1078
|
-
for (const [k, n] of Object.entries(
|
|
1166
|
+
for (const [k, n] of Object.entries(events.notifications)) {
|
|
1079
1167
|
const d = agg.byDay[k] ||= blankDay();
|
|
1080
1168
|
d.notifications = (d.notifications || 0) + n;
|
|
1081
1169
|
notifTotal += n;
|
|
1082
1170
|
}
|
|
1083
1171
|
agg.notifications = notifTotal;
|
|
1172
|
+
let compactTotal = 0;
|
|
1173
|
+
agg.compactionsByDay = {};
|
|
1174
|
+
for (const [k, n] of Object.entries(events.compactions)) {
|
|
1175
|
+
agg.compactionsByDay[k] = n;
|
|
1176
|
+
compactTotal += n;
|
|
1177
|
+
}
|
|
1178
|
+
agg.compactions = compactTotal;
|
|
1084
1179
|
|
|
1085
1180
|
return agg;
|
|
1086
1181
|
}
|