claude-rpc 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -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,74 @@ 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.join(' · ') || '—', ''),
2110
+ pair('models', w.topModels.map((m) => `${m.name} ${m.pct}%`).join(' · ') || '—', ''),
2111
+ ], 72);
2112
+ console.log(` ${c.dim}full payload: the fields above plus lines/cache%/peaks — nothing else.${c.reset}`);
2113
+ console.log(` ${c.dim}private-listed and pattern-matched project names are already excluded.${c.reset}\n`);
2114
+
2115
+ if (!argv.includes('--yes')) {
2116
+ if (!process.stdout.isTTY) {
2117
+ return fail('refusing to publish without confirmation on a non-TTY', {
2118
+ hint: 'pass --yes to publish non-interactively', code: EX_USER_ERROR,
2119
+ });
2120
+ }
2121
+ const answer = (await prompt(` publish to claude-rpc.com/wrapped/${cfg.profile.handle}? [y/N] `)).trim().toLowerCase();
2122
+ if (answer !== 'y' && answer !== 'yes') { console.log(` ${c.dim}nothing sent.${c.reset}\n`); return; }
2123
+ }
2124
+
2125
+ const res = await publishWrapped(cfg, payload);
2126
+ if (!res.ok) {
2127
+ if (res.reason === 'no-profile') {
2128
+ return fail('the worker has no published profile for this machine yet', {
2129
+ hint: 'run `claude-rpc profile publish` once, then retry', code: EX_BAD_STATE,
2130
+ });
2131
+ }
2132
+ return fail(`publish failed: ${res.reason}${res.error ? ` (${res.error})` : ''}`, { code: EX_SYS_ERROR });
2133
+ }
2134
+ console.log(` ${c.green}✓ published${c.reset} ${c.bold}${res.url}${c.reset}`);
2135
+ console.log(` ${c.dim}share card:${c.reset} ${(cfg.community?.endpoint || '').replace(/\/+$/, '')}/wrapped/${res.handle}.svg`);
2136
+ console.log(` ${c.dim}re-publish any time — same command overwrites.${c.reset}\n`);
2137
+ }
2138
+
1954
2139
  async function doProfile(argv) {
1955
2140
  const sub = (argv[0] || 'status').toLowerCase();
1956
2141
  if (sub === 'status' || sub === '') return profileStatus();
@@ -2084,6 +2269,9 @@ function help() {
2084
2269
  ['status', 'Interactive stats TUI; --dump (or piped) prints static text'],
2085
2270
  ['today', 'Focus view: today\'s stats + 24h activity histogram'],
2086
2271
  ['week', 'Focus view: this week, daily breakdown'],
2272
+ ['compare', 'This week vs last week — the full side-by-side diff'],
2273
+ ['projects', 'Every project ranked by active time (--limit N)'],
2274
+ ['project', 'One project\'s full record + recent daily rhythm'],
2087
2275
  ['usage', 'Subscription limits — session + weekly % (what /usage shows)'],
2088
2276
  ['serve', 'Open a live web dashboard in your browser'],
2089
2277
  ['preview', 'Show how each rotation frame renders right now'],
@@ -2103,6 +2291,7 @@ function help() {
2103
2291
  ['mcp uninstall', 'Remove the stats MCP server from Claude Code'],
2104
2292
  ['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
2105
2293
  ['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
2294
+ ['wrapped --publish', 'Put your Wrapped on the web at claude-rpc.com/wrapped/<handle>'],
2106
2295
  ['pause', 'Snooze the Discord card globally (pause [30m|2h], default 1h)'],
2107
2296
  ['resume', 'Lift a pause early'],
2108
2297
  ['export', 'Dump the aggregate as JSON, or daily rows as CSV (--csv --out)'],
@@ -2240,6 +2429,9 @@ process.on('unhandledRejection', (e) => {
2240
2429
  case 'dump': showStatus(); break;
2241
2430
  case 'today': showToday(); break;
2242
2431
  case 'week': showWeek(); break;
2432
+ case 'projects': showProjects(process.argv.slice(3)); break;
2433
+ case 'project': showProject(process.argv[3]); break;
2434
+ case 'compare': showCompare(); break;
2243
2435
  case 'usage': await showUsage(); break;
2244
2436
  case 'serve': await import('./server/index.js'); break;
2245
2437
  case 'preview': showPreview(); break;
@@ -2263,7 +2455,9 @@ process.on('unhandledRejection', (e) => {
2263
2455
  await doMcp();
2264
2456
  break;
2265
2457
  }
2266
- case 'wrapped': process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
2458
+ case 'wrapped':
2459
+ if (process.argv.slice(3).includes('--publish')) { await doWrappedPublish(process.argv.slice(3)); break; }
2460
+ process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
2267
2461
  case 'pause': doPause(process.argv.slice(3)); break;
2268
2462
  case 'resume':
2269
2463
  case 'unpause': doResume(); break;
package/src/community.js CHANGED
@@ -24,6 +24,8 @@ 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';
27
29
 
28
30
  const CURSOR_PATH = join(STATE_DIR, 'community-cursor.json');
29
31
 
@@ -116,6 +118,123 @@ export function buildProfilePayload(aggregate, profileCfg, { instanceId, now = D
116
118
  };
117
119
  }
118
120
 
121
+ // ── Claude Wrapped ───────────────────────────────────────────────────────
122
+ //
123
+ // Schema pair with the worker's validateWrapped/sanitizeWrapped — add fields
124
+ // in BOTH places. Everything here is derived from aggregate.json; per-day
125
+ // slices are year-scoped, and the few lifetime-only dimensions (languages,
126
+ // tool mix, session lengths) are labeled as such on the page. Project names
127
+ // pass the name-level privacy check AND the whole payload is shown to the
128
+ // user before anything is sent — publish is a one-shot, explicit action.
129
+ export function buildWrappedPayload(aggregate, config, { instanceId, year, now = Date.now() }) {
130
+ const agg = aggregate || {};
131
+ const y = year || new Date(now).getFullYear();
132
+ const days = Object.entries(agg.byDay || {}).filter(([k]) => k.startsWith(`${y}-`));
133
+
134
+ const sum = (f) => days.reduce((acc, [, d]) => acc + (d[f] || 0), 0);
135
+ const tokensOf = (d) => (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0);
136
+ const tokens = days.reduce((acc, [, d]) => acc + tokensOf(d), 0);
137
+ const cacheTokens = days.reduce((acc, [, d]) => acc + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0), 0);
138
+
139
+ // Peak day by active time.
140
+ let peakDay = null;
141
+ for (const [date, d] of days) {
142
+ if ((d.activeMs || 0) > 0 && (!peakDay || d.activeMs > peakDay.activeMs)) {
143
+ peakDay = { date, activeMs: d.activeMs };
144
+ }
145
+ }
146
+
147
+ // Year-scoped project ranking from per-day attribution, privacy-filtered.
148
+ const projMs = {};
149
+ for (const [, d] of days) {
150
+ for (const [name, p] of Object.entries(d.projects || {})) {
151
+ projMs[name] = (projMs[name] || 0) + (p.activeMs || 0);
152
+ }
153
+ }
154
+ const topProjects = Object.entries(projMs)
155
+ .filter(([name]) => !projectNameIsPrivate(name, config, cleanProjectName))
156
+ .sort((a, b) => b[1] - a[1]).slice(0, 5)
157
+ .map(([name, activeMs]) => ({ name, activeMs }));
158
+
159
+ // Year-scoped model mix from the per-day byModel buckets (scan cache v7).
160
+ const modelTok = {};
161
+ for (const [, d] of days) {
162
+ for (const [m, v] of Object.entries(d.byModel || {})) {
163
+ modelTok[m] = (modelTok[m] || 0) + (v.tokens || 0);
164
+ }
165
+ }
166
+ const modelTotal = Object.values(modelTok).reduce((a, b) => a + b, 0);
167
+ const topModels = Object.entries(modelTok).sort((a, b) => b[1] - a[1]).slice(0, 4)
168
+ .map(([name, t]) => ({ name, pct: modelTotal ? Math.round((t / modelTotal) * 100) : 0 }));
169
+
170
+ // Lifetime dimensions (no yearly slice exists for these).
171
+ const toolTotal = Object.values(agg.toolBreakdown || {}).reduce((a, b) => a + b, 0);
172
+ const toolMix = Object.entries(agg.toolBreakdown || {}).sort((a, b) => b[1] - a[1]).slice(0, 3)
173
+ .map(([name, n]) => ({ name, pct: toolTotal ? Math.round((n / toolTotal) * 100) : 0 }));
174
+ const topLanguages = Object.entries(agg.languages || {})
175
+ .sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0)).slice(0, 5).map(([n]) => n);
176
+ const sl = agg.sessionLengths || {};
177
+ const marathonPct = sl.count ? Math.round((((sl.buckets?.h2to4 || 0) + (sl.buckets?.gt4h || 0)) / sl.count) * 100) : 0;
178
+
179
+ const compactions = Object.entries(agg.compactionsByDay || {})
180
+ .filter(([k]) => k.startsWith(`${y}-`)).reduce((acc, [, n]) => acc + n, 0);
181
+
182
+ return {
183
+ instanceId,
184
+ year: y,
185
+ wrapped: {
186
+ activeMs: sum('activeMs'),
187
+ sessions: sum('sessions'),
188
+ tokens,
189
+ prompts: sum('userMessages'),
190
+ streakBest: agg.longestStreak || 0,
191
+ daysActive: days.filter(([, d]) => (d.activeMs || 0) > 0 || (d.userMessages || 0) > 0).length,
192
+ linesAdded: sum('linesAdded'),
193
+ linesRemoved: sum('linesRemoved'),
194
+ ships: sum('ships'),
195
+ cachePct: tokens ? Math.round((cacheTokens / tokens) * 100) : 0,
196
+ peakHour: agg.peakHour?.hour ?? 0,
197
+ ...(peakDay ? { peakDay } : {}),
198
+ longestSessionMs: sl.longestMs || 0,
199
+ marathonPct,
200
+ compactions,
201
+ subagentActiveMs: agg.subagentActiveMs || 0,
202
+ costUsd: Math.round(sum('cost') * 100) / 100,
203
+ topProjects,
204
+ topLanguages,
205
+ topModels,
206
+ toolMix,
207
+ },
208
+ };
209
+ }
210
+
211
+ // One-shot POST of a built wrapped payload. Mirrors flushProfile's error
212
+ // contract: never throws, returns { ok, reason?, url? }.
213
+ export async function publishWrapped(cfg, payload, { fetchImpl = globalThis.fetch } = {}) {
214
+ const community = cfg?.community || {};
215
+ if (!community.endpoint) return { ok: false, reason: 'no-endpoint' };
216
+ const url = community.endpoint.replace(/\/+$/, '') + '/wrapped';
217
+ let res;
218
+ try {
219
+ res = await fetchImpl(url, {
220
+ method: 'POST',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ body: JSON.stringify(payload),
223
+ signal: AbortSignal.timeout(10_000),
224
+ });
225
+ } catch (e) {
226
+ return { ok: false, reason: 'network', error: e.message };
227
+ }
228
+ let body = null;
229
+ try { body = await res.json(); } catch { /* non-JSON error body */ }
230
+ if (!res.ok) {
231
+ if (res.status === 403) return { ok: false, reason: 'no-profile' };
232
+ if (res.status === 429) return { ok: false, reason: 'rate-limited' };
233
+ return { ok: false, reason: `http-${res.status}`, error: body?.error };
234
+ }
235
+ return { ok: true, url: body?.url, handle: body?.handle, year: body?.year };
236
+ }
237
+
119
238
  export async function flushProfile(cfg, {
120
239
  aggregatePath = AGGREGATE_PATH,
121
240
  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
- const CACHE_VERSION = 6;
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
- function sumUsageLines(text, tokens) {
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 readNotificationsByDay() {
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 notification counts.
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 || e.type !== 'notification' || !e.ts) continue;
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
- out[k] = (out[k] || 0) + 1;
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 notifByDay = readNotificationsByDay();
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(notifByDay)) {
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
  }
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '1.1.4';
14
+ const BAKED = '1.2.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {