claude-rpc 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -146,13 +146,15 @@ claude-rpc readme # prints paste-ready README badge markdown
146
146
  claude-rpc readme --raw | pbcopy # straight to your clipboard
147
147
  ```
148
148
 
149
- **Live badges, paste once.** With a public profile (`claude-rpc profile set --handle <you> && claude-rpc profile on`), your stats are served as always-current badges from the community worker — no `gh`, no gist, nothing to re-run:
149
+ **Live card + badges, paste once.** With a public profile (`claude-rpc profile set --handle <you> && claude-rpc profile on`), your stats are served as an always-current card (and badges) from the community worker — no `gh`, no gist, nothing to re-run:
150
150
 
151
151
  ```md
152
+ [![Claude Code stats](https://claude-rpc-totals.claude-rpc.workers.dev/card/<you>.svg)](https://claude-rpc.vercel.app/u/<you>)
153
+
152
154
  [![Claude Code hours](https://claude-rpc-totals.claude-rpc.workers.dev/badge/<you>.svg?metric=hours)](https://claude-rpc.vercel.app/?ref=badge)
153
155
  ```
154
156
 
155
- `metric=` is one of `tokens · sessions · hours · streak` (optional `&label=` to retitle). Your profile page at `/u/<you>` has a one-click "copy" for the same block. The badge refreshes itself as the daemon flushes your profile (~every 30 min).
157
+ The card shows tokens, sessions, active hours and streak; badge `metric=` is one of `tokens · sessions · hours · streak` (optional `&label=` to retitle). Both refresh themselves as the daemon flushes your profile (~every 30 min). Your profile page at `/u/<you>` has a one-click "copy" for the whole block.
156
158
 
157
159
  Prefer to render locally? `badge`/`card`/`calendar`/`github-stat` all write SVG, and `--gist` self-hosts a live one:
158
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.22.1",
3
+ "version": "0.24.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
@@ -1075,35 +1075,43 @@ function doReadme(argv) {
1075
1075
  ['streak', 'Claude Code streak'],
1076
1076
  ['tokens', 'Claude Code tokens'],
1077
1077
  ];
1078
- const liveMd = (h) => metrics
1078
+ // The hero: a live stat card that auto-refreshes from your published profile.
1079
+ const cardMd = (h) => `[![Claude Code stats](${endpoint}/card/${h}.svg)](${site}/u/${h})`;
1080
+ const badgesMd = (h) => metrics
1079
1081
  .map(([m, alt]) => `[![${alt}](${endpoint}/badge/${h}.svg?metric=${m})](${site}/?ref=badge)`)
1080
1082
  .join('\n');
1083
+ const liveMd = (h) => `${cardMd(h)}\n\n${badgesMd(h)}`;
1081
1084
 
1082
1085
  // Raw mode: just the markdown, for `claude-rpc readme --raw | pbcopy`.
1083
1086
  if (raw) {
1084
1087
  process.stdout.write(live
1085
1088
  ? liveMd(handle) + '\n'
1086
- : '<!-- live badges need a public profile: claude-rpc profile set --handle <name> && claude-rpc profile on -->\n');
1089
+ : '<!-- live card/badges need a public profile: claude-rpc profile set --handle <name> && claude-rpc profile on -->\n');
1087
1090
  return;
1088
1091
  }
1089
1092
 
1090
1093
  console.log('');
1091
- console.log(` ${c.bold}${c.magenta}◆ README badges${c.reset}`);
1094
+ console.log(` ${c.bold}${c.magenta}◆ README card + badges${c.reset}`);
1092
1095
  console.log('');
1093
1096
 
1094
1097
  if (live) {
1095
- console.log(` ${c.dim}Live badges for ${c.reset}${c.cyan}@${handle}${c.reset}${c.dim} — paste anywhere; they refresh as you work:${c.reset}`);
1098
+ console.log(` ${c.dim}Live stat card for ${c.reset}${c.cyan}@${handle}${c.reset}${c.dim} — paste once; it auto-updates as you work:${c.reset}`);
1096
1099
  console.log('');
1097
- for (const line of liveMd(handle).split('\n')) console.log(` ${line}`);
1100
+ console.log(` ${cardMd(handle)}`);
1098
1101
  console.log('');
1099
- console.log(` ${c.dim}Pick any metric:${c.reset} ${c.cyan}tokens · sessions · hours · streak${c.reset}`);
1102
+ console.log(` ${c.dim}…or compact badges:${c.reset}`);
1103
+ console.log('');
1104
+ for (const line of badgesMd(handle).split('\n')) console.log(` ${line}`);
1105
+ console.log('');
1106
+ console.log(` ${c.dim}Badge metrics:${c.reset} ${c.cyan}tokens · sessions · hours · streak${c.reset}`);
1100
1107
  console.log(` ${c.dim}One-click copy + shareable page:${c.reset} ${c.cyan}${site}/u/${handle}${c.reset}`);
1101
1108
  console.log(` ${c.dim}Straight to clipboard:${c.reset} ${c.cyan}claude-rpc readme --raw${c.reset}${c.dim} | pbcopy${c.reset}`);
1102
1109
  } else {
1103
- console.log(` ${c.dim}A live, always-current badge needs a public profile. Turn one on:${c.reset}`);
1110
+ console.log(` ${c.dim}A live, always-current card + badges need a public profile. Turn one on:${c.reset}`);
1104
1111
  console.log(` ${c.cyan}claude-rpc profile set --handle <name> && claude-rpc profile on${c.reset}`);
1105
1112
  console.log('');
1106
- console.log(` ${c.dim}Then your badges live here (self-refreshing — paste once):${c.reset}`);
1113
+ console.log(` ${c.dim}Then your card + badges live here (self-refreshing — paste once):${c.reset}`);
1114
+ console.log(` ${c.cyan}${endpoint}/card/<handle>.svg${c.reset}`);
1107
1115
  console.log(` ${c.cyan}${endpoint}/badge/<handle>.svg?metric=hours${c.reset}`);
1108
1116
  }
1109
1117
 
@@ -2003,7 +2011,7 @@ function help() {
2003
2011
  ['statusline', 'One-line status for tmux/shell prompts (--template)'],
2004
2012
  ['calendar', 'Year activity heatmap SVG (--out --gist)'],
2005
2013
  ['session-card', 'Recap card for the current session (--out)'],
2006
- ['readme', 'Paste-ready README badges for your profile (--raw to pipe)'],
2014
+ ['readme', 'Paste-ready README card + badges for your profile (--raw to pipe)'],
2007
2015
  ['mcp install', 'Wire the stats MCP server into Claude Code (one command)'],
2008
2016
  ['mcp uninstall', 'Remove the stats MCP server from Claude Code'],
2009
2017
  ['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
package/src/insights.js CHANGED
@@ -1,18 +1,64 @@
1
- // Generate 3–5 short, contextual insight lines from an aggregate.json snapshot.
1
+ // Generate short, contextual insight lines from an aggregate.json snapshot.
2
2
  // Used by `claude-rpc insights`, the web `/api/insights` route, and the TUI.
3
3
  // Pure functions — no I/O, no globals beyond Date.
4
+ //
5
+ // Each generator contributes 0+ candidate lines with a priority weight. The
6
+ // final pick is weight + a seeded jitter, so the strongest signals lead but the
7
+ // mid-tier ROTATES — a fresh mix each run instead of the same five forever.
8
+ // Pass opts.seed for a deterministic order (tests); it defaults to the clock.
4
9
 
5
- import { dayKey, weekKey } from './scanner.js';
10
+ import { dayKey } from './scanner.js';
6
11
  import { fmtCost } from './pricing.js';
7
12
  import { fmtNum, fmtHours } from './fmt.js';
8
13
 
9
14
  const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
15
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
10
16
 
11
17
  function pct(n) {
12
18
  const sign = n >= 0 ? '+' : '−';
13
19
  return `${sign}${Math.round(Math.abs(n) * 100)}%`;
14
20
  }
15
21
 
22
+ // Plain thousands separator — fmtNum compacts to "1.7k", which reads oddly for
23
+ // page/file counts where the exact number is the point.
24
+ function commas(n) {
25
+ return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
26
+ }
27
+
28
+ function hourLabel(h) {
29
+ const ampm = h < 12 ? 'am' : 'pm';
30
+ const hr = h % 12 === 0 ? 12 : h % 12;
31
+ return `${hr}${ampm}`;
32
+ }
33
+
34
+ function monthDay(s) {
35
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ''));
36
+ return m ? `${MON[+m[2] - 1]} ${+m[3]}` : null;
37
+ }
38
+
39
+ function daysAgo(s) {
40
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ''));
41
+ if (!m) return null;
42
+ const d = new Date(+m[1], +m[2] - 1, +m[3]); d.setHours(0, 0, 0, 0);
43
+ const t = new Date(); t.setHours(0, 0, 0, 0);
44
+ return Math.round((t - d) / 86_400_000);
45
+ }
46
+
47
+ // "opus-4-8" → "Opus 4.8", "fable-5" → "Fable 5".
48
+ function prettyModel(m) {
49
+ if (!m) return '';
50
+ const p = String(m).split('-');
51
+ const fam = p[0].charAt(0).toUpperCase() + p[0].slice(1);
52
+ const ver = p.slice(1).filter((x) => /^\d+$/.test(x)).join('.');
53
+ return ver ? `${fam} ${ver}` : fam;
54
+ }
55
+
56
+ // Tiny seeded LCG so the rotation is deterministic given a seed.
57
+ function rng(seed) {
58
+ let s = (Math.floor(seed) >>> 0) || 1;
59
+ return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
60
+ }
61
+
16
62
  // Sum activeMs across the last `days` days (inclusive of today).
17
63
  function windowActive(byDay, days, offset = 0) {
18
64
  let total = 0;
@@ -26,13 +72,16 @@ function windowActive(byDay, days, offset = 0) {
26
72
 
27
73
  export function generateInsights(aggregate, opts = {}) {
28
74
  const a = aggregate || {};
29
- const out = [];
30
-
31
75
  if (!a.byDay || !Object.keys(a.byDay).length) {
32
76
  return ['Not enough data yet — keep coding and check back tomorrow.'];
33
77
  }
34
78
 
35
- // 1. Trend: last 7 days vs the 7 before it.
79
+ const C = [];
80
+ // topic (optional) groups near-duplicates so at most one survives selection
81
+ // (e.g. two streak lines, or peak-weekday + weekday-rhythm, never together).
82
+ const push = (w, text, topic) => { if (text) C.push({ w, text, topic }); };
83
+
84
+ // ── 1. Week-over-week trend (headline) ─────────────────────────────────
36
85
  const last7 = windowActive(a.byDay, 7, 0);
37
86
  const prev7 = windowActive(a.byDay, 7, 7);
38
87
  if (last7 || prev7) {
@@ -40,85 +89,207 @@ export function generateInsights(aggregate, opts = {}) {
40
89
  const delta = (last7 - prev7) / prev7;
41
90
  if (Math.abs(delta) >= 0.10) {
42
91
  const dir = delta > 0 ? 'above' : 'below';
43
- out.push(`You're ${pct(delta)} ${dir} last week ${fmtHours(last7)} vs ${fmtHours(prev7)}.`);
92
+ push(Math.abs(delta) >= 0.25 ? 95 : 78,
93
+ `You're ${pct(delta)} ${dir} last week — ${fmtHours(last7)} vs ${fmtHours(prev7)}.`);
44
94
  } else {
45
- out.push(`Steady week — ${fmtHours(last7)} active, on par with the previous 7 days.`);
95
+ push(64, `Steady week — ${fmtHours(last7)} active, on par with the previous 7 days.`);
46
96
  }
47
97
  } else if (last7 > 0) {
48
- out.push(`Fresh momentum — ${fmtHours(last7)} active this past week.`);
98
+ push(80, `Fresh momentum — ${fmtHours(last7)} active this past week.`);
99
+ }
100
+ }
101
+
102
+ // ── 2. Chronotype (peak hour / night owl / early bird) ─────────────────
103
+ if (a.byHour && Object.keys(a.byHour).length) {
104
+ let total = 0, night = 0;
105
+ for (const [h, d] of Object.entries(a.byHour)) {
106
+ const hr = +h; const ms = d.activeMs || 0;
107
+ total += ms;
108
+ if (hr >= 22 || hr <= 4) night += ms;
109
+ }
110
+ const ph = a.peakHour && a.peakHour.hour != null ? +a.peakHour.hour : null;
111
+ if (total > 0) {
112
+ const ns = night / total;
113
+ if (ns >= 0.33 || (ph != null && (ph >= 22 || ph <= 4))) {
114
+ push(64, `Night owl — ${Math.round(ns * 100)}% of your coding is between 10pm and 5am${ph != null ? ` (peak ${hourLabel(ph)})` : ''}.`);
115
+ } else if (ph != null && ph >= 5 && ph <= 9) {
116
+ push(62, `Early bird — your focus peaks around ${hourLabel(ph)}.`);
117
+ } else if (ph != null) {
118
+ push(54, `Your most productive hour is ${hourLabel(ph)}.`);
119
+ }
120
+ }
121
+ }
122
+
123
+ // ── 3. Best day on record ──────────────────────────────────────────────
124
+ if (a.bestDay && a.bestDay.day) {
125
+ const md = monthDay(a.bestDay.day);
126
+ const ago = daysAgo(a.bestDay.day);
127
+ if (md) {
128
+ const recent = ago != null && ago <= 1;
129
+ const ln = a.bestDay.linesAdded || 0;
130
+ const extra = ln > 0 ? ` and ${commas(ln)} lines` : '';
131
+ push(recent ? 93 : 60,
132
+ `${recent ? 'New personal best! ' : 'Biggest day so far: '}${md} — ${fmtHours(a.bestDay.activeMs || 0)}${extra}.`);
133
+ }
134
+ }
135
+
136
+ // ── 4. Weekend vs weekday rhythm ───────────────────────────────────────
137
+ if (a.byWeekday) {
138
+ let wd = 0, wdN = 0, we = 0, weN = 0;
139
+ for (const [k, d] of Object.entries(a.byWeekday)) {
140
+ const n = +k; const ms = d.activeMs || 0;
141
+ if (n === 0 || n === 6) { we += ms; weN += 1; } else if (n >= 1 && n <= 5) { wd += ms; wdN += 1; }
142
+ }
143
+ if (wdN && weN && wd > 0 && we > 0) {
144
+ const aWd = wd / wdN, aWe = we / weN;
145
+ if (aWd >= aWe * 1.6) push(55, `Weekday grinder — you average ${(aWd / aWe).toFixed(1)}× more on weekdays than weekends.`, 'weekday');
146
+ else if (aWe >= aWd * 1.4) push(57, `Weekend warrior — your Saturdays and Sundays out-pace the work week.`, 'weekday');
49
147
  }
50
148
  }
51
149
 
52
- // 2. Peak weekday.
150
+ // ── 5. Peak weekday ────────────────────────────────────────────────────
53
151
  if (a.byWeekday && Object.keys(a.byWeekday).length) {
54
152
  let best = null;
55
- for (const [wd, data] of Object.entries(a.byWeekday)) {
56
- if (!best || data.activeMs > best.ms) best = { wd: Number(wd), ms: data.activeMs };
57
- }
58
- if (best && best.ms > 0) {
59
- out.push(`Peak weekday is ${WEEKDAYS[best.wd]} — ${fmtHours(best.ms)} all-time.`);
153
+ for (const [w, d] of Object.entries(a.byWeekday)) {
154
+ if (!best || (d.activeMs || 0) > best.ms) best = { wd: +w, ms: d.activeMs || 0 };
60
155
  }
156
+ if (best && best.ms > 0) push(52, `Peak weekday is ${WEEKDAYS[best.wd]} — ${fmtHours(best.ms)} all-time.`, 'weekday');
157
+ }
158
+
159
+ // ── 6. Output as book pages ────────────────────────────────────────────
160
+ if ((a.linesAdded || 0) >= 500) {
161
+ const pages = Math.max(1, Math.round(a.linesAdded / 55));
162
+ push(48, `${commas(a.linesAdded)} lines written — about ${commas(pages)} pages of a paperback.`);
61
163
  }
62
164
 
63
- // 3. Cost pace (month-to-date forecast).
165
+ // ── 7. Cost pace (month-to-date forecast) ──────────────────────────────
64
166
  if (a.estimatedCost && a.byDay) {
65
167
  const now = new Date();
66
- const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
168
+ const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
67
169
  let mtd = 0;
68
- for (const [k, day] of Object.entries(a.byDay)) {
69
- if (k.startsWith(yearMonth)) mtd += day.cost || 0;
70
- }
170
+ for (const [k, day] of Object.entries(a.byDay)) if (k.startsWith(ym)) mtd += day.cost || 0;
71
171
  if (mtd > 0) {
72
172
  const daysIn = now.getDate();
73
- const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
74
- const forecast = (mtd / daysIn) * daysInMonth;
75
- out.push(`Month-to-date estimate: ${fmtCost(mtd)} — pace projects ${fmtCost(forecast)} for the month.`);
173
+ const dim = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
174
+ push(60, `Month-to-date estimate: ${fmtCost(mtd)} — pace projects ${fmtCost((mtd / daysIn) * dim)} for the month.`);
76
175
  }
77
176
  }
78
177
 
79
- // 4. Top hotspot.
178
+ // ── 8. Hotspot file ────────────────────────────────────────────────────
80
179
  if (a.topEditedFiles && a.topEditedFiles.length) {
81
180
  const top = a.topEditedFiles[0];
82
181
  const name = top.path.split(/[\\/]/).filter(Boolean).pop();
83
- out.push(`Hotspot: ${name} with ${fmtNum(top.count)} edits.`);
182
+ push(54, `Hotspot: ${name} with ${fmtNum(top.count)} edits.`);
84
183
  }
85
184
 
86
- // 5. Streak milestone tease.
185
+ // ── 9. Streak milestone tease + best-ever flag ───────────────────────
87
186
  if (a.streak >= 3) {
88
- const targets = [7, 14, 30, 60, 100, 365];
89
- const next = targets.find((t) => t > a.streak);
187
+ const next = [7, 14, 30, 60, 100, 365].find((t) => t > a.streak);
90
188
  if (next) {
91
- const remaining = next - a.streak;
92
- out.push(`${a.streak}-day streak — ${remaining} ${remaining === 1 ? 'day' : 'days'} to ${next}.`);
189
+ const rem = next - a.streak;
190
+ push(rem <= 2 ? 90 : 64, `${a.streak}-day streak — ${rem} ${rem === 1 ? 'day' : 'days'} to ${next}.`, 'streak');
93
191
  } else {
94
- out.push(`${a.streak}-day streak — beyond every milestone we track. Incredible.`);
192
+ push(82, `${a.streak}-day streak — beyond every milestone we track. Incredible.`, 'streak');
95
193
  }
96
194
  } else if (a.longestStreak >= 7) {
97
- out.push(`Longest streak so far: ${a.longestStreak} days. Today could start the next one.`);
195
+ push(58, `Longest streak so far: ${a.longestStreak} days. Today could start the next one.`, 'streak');
196
+ }
197
+ if (a.streak && a.streak === a.longestStreak && a.streak >= 5) {
198
+ push(72, `You're on your best-ever streak — ${a.streak} days and counting.`, 'streak');
98
199
  }
99
200
 
100
- // 6. Top language / framework.
201
+ // ── 10. Top language ───────────────────────────────────────────────────
101
202
  if (a.languages) {
102
203
  const top = Object.entries(a.languages).sort((x, y) => y[1].edits - x[1].edits)[0];
103
- if (top) {
104
- out.push(`Most edits land in ${top[0]} — ${fmtNum(top[1].edits)} across ${fmtNum(top[1].files)} files.`);
105
- }
204
+ if (top) push(53, `Most edits land in ${top[0]} — ${fmtNum(top[1].edits)} across ${fmtNum(top[1].files)} files.`);
106
205
  }
107
206
 
108
- // 7. Subagent usage (where it differs interestingly from defaults).
109
- if (a.subagents && Object.keys(a.subagents).length) {
110
- const top = Object.entries(a.subagents).sort((x, y) => y[1] - x[1])[0];
111
- if (top && top[1] >= 3) {
112
- out.push(`Favorite subagent: ${top[0]} invoked ${top[1]} times.`);
207
+ // ── 11. Top project + breadth ──────────────────────────────────────────
208
+ if (a.projects && Object.keys(a.projects).length) {
209
+ let best = null;
210
+ for (const [name, d] of Object.entries(a.projects)) {
211
+ if (!best || (d.activeMs || 0) > best.ms) best = { name, ms: d.activeMs || 0, s: d.sessions || 0 };
113
212
  }
213
+ if (best && best.ms > 0) push(56, `Most of your time goes to ${best.name} — ${fmtHours(best.ms)} across ${fmtNum(best.s)} sessions.`, 'project');
214
+ const n = Object.keys(a.projects).length;
215
+ if (n >= 5) push(40, `You've worked across ${n} projects with Claude Code.`, 'project');
114
216
  }
115
217
 
116
- // 8. Notification frequency (only when high).
218
+ // ── 12. Top tool + tool volume ─────────────────────────────────────────
219
+ if (a.toolBreakdown && Object.keys(a.toolBreakdown).length) {
220
+ const t = Object.entries(a.toolBreakdown).sort((x, y) => y[1] - x[1])[0];
221
+ if (t && t[1] >= 10) push(44, `Your most-used tool is ${t[0]} — ${fmtNum(t[1])} calls.`);
222
+ }
223
+ if ((a.uniqueFiles || 0) >= 50) push(42, `${commas(a.uniqueFiles)} unique files touched.`);
224
+
225
+ // ── 13. Top shell command ──────────────────────────────────────────────
226
+ if (a.bashCommands && Object.keys(a.bashCommands).length) {
227
+ const b = Object.entries(a.bashCommands).sort((x, y) => y[1] - x[1])[0];
228
+ if (b && b[1] >= 5) push(40, `Your go-to shell command: ${b[0]} (${fmtNum(b[1])}×).`);
229
+ }
230
+
231
+ // ── 14. Model mix ──────────────────────────────────────────────────────
232
+ if (Array.isArray(a.modelSplit) && a.modelSplit.length) {
233
+ const m = a.modelSplit[0];
234
+ if (m && m.tokenPct > 0) push(50, `${prettyModel(m.model)} does most of your work — ${Math.round(m.tokenPct * 100)}% of tokens.`, 'model');
235
+ if (a.modelSplit.length >= 3) push(38, `${a.modelSplit.length} models in your rotation.`, 'model');
236
+ }
237
+
238
+ // ── 15. Subagents ──────────────────────────────────────────────────────
239
+ if ((a.subagentRuns || 0) >= 5) {
240
+ let topName = null, topN = 0;
241
+ for (const [name, n] of Object.entries(a.subagents || {})) if (n > topN) { topN = n; topName = name; }
242
+ push(46, `${fmtNum(a.subagentRuns)} subagent runs${topName ? ` — favourite is ${topName}` : ''}.`);
243
+ }
244
+
245
+ // ── 16. Cache efficiency ───────────────────────────────────────────────
246
+ const totalTok = (a.inputTokens || 0) + (a.outputTokens || 0) + (a.cacheReadTokens || 0) + (a.cacheWriteTokens || 0);
247
+ if (totalTok > 0 && (a.cacheReadTokens || 0) / totalTok >= 0.5) {
248
+ push(46, `${Math.round((a.cacheReadTokens / totalTok) * 100)}% of your tokens come from cache — heavy context reuse.`);
249
+ }
250
+
251
+ // ── 17. Tenure / anniversary ───────────────────────────────────────────
252
+ if (a.daysSinceFirst >= 2) {
253
+ const near = [7, 30, 100, 182, 365, 730].find((t) => t >= a.daysSinceFirst && t - a.daysSinceFirst <= 3);
254
+ if (near && near === a.daysSinceFirst) push(78, `Day ${near} with Claude Code — milestone unlocked.`);
255
+ else if (near) push(70, `${near - a.daysSinceFirst} ${near - a.daysSinceFirst === 1 ? 'day' : 'days'} to your ${near}-day mark with Claude Code.`);
256
+ else push(44, `Day ${a.daysSinceFirst} with Claude Code.`);
257
+ }
258
+
259
+ // ── 18. Cadence ────────────────────────────────────────────────────────
260
+ if (a.daysSinceFirst >= 7 && a.sessions > 0) {
261
+ const per = a.sessions / a.daysSinceFirst;
262
+ if (per >= 1) push(40, `~${per.toFixed(1)} sessions a day since you started.`);
263
+ }
264
+
265
+ // ── 19. Web research breadth ───────────────────────────────────────────
266
+ if (a.webDomains && Object.keys(a.webDomains).length >= 10) {
267
+ push(36, `Researched across ${Object.keys(a.webDomains).length} web domains.`);
268
+ }
269
+
270
+ // ── 20. Notifications ──────────────────────────────────────────────────
117
271
  if (a.notifications && a.notifications > 20) {
118
- out.push(`${a.notifications} notifications — Claude has paused for you that many times.`);
272
+ push(38, `${fmtNum(a.notifications)} notifications — Claude has paused for you that many times.`);
119
273
  }
120
274
 
121
- // Trim to a tidy 5biggest signals first (sort is already roughly priority).
275
+ if (!C.length) return ['Not enough data yet keep coding and check back tomorrow.'];
276
+
277
+ // Weight + seeded jitter: headlines (≥90) reliably lead; the mid-tier rotates
278
+ // so repeat runs surface a fresh mix. Default seed = clock → fresh each run.
279
+ const rand = rng(opts.seed != null ? opts.seed : Date.now());
280
+ for (const c of C) c.score = c.w + rand() * 22;
281
+ C.sort((x, y) => y.score - x.score);
282
+
283
+ // Take the top `limit`, but at most one line per topic so near-duplicates
284
+ // (two streak lines, peak-weekday + weekday-rhythm) never appear together.
122
285
  const limit = opts.limit ?? 5;
123
- return out.slice(0, limit);
286
+ const seen = new Set();
287
+ const out = [];
288
+ for (const c of C) {
289
+ if (c.topic && seen.has(c.topic)) continue;
290
+ if (c.topic) seen.add(c.topic);
291
+ out.push(c.text);
292
+ if (out.length >= limit) break;
293
+ }
294
+ return out;
124
295
  }
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 = '0.22.1';
14
+ const BAKED = '0.24.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {