claude-rpc 0.23.0 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
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/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,211 @@ 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');
216
+ }
217
+
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');
114
236
  }
115
237
 
116
- // 8. Notification frequency (only when high).
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
+ // Weighted random draw WITHOUT replacement. Weight sets how often (and how
278
+ // early) a line tends to surface, but every run is a genuinely fresh mix —
279
+ // including the top line — rather than the same weight-sorted five. Default
280
+ // seed = clock → fresh each run; pass opts.seed for a deterministic order.
281
+ // Topic keeps near-duplicates (two streak lines, etc.) out of the same draw.
282
+ const rand = rng(opts.seed != null ? opts.seed : Date.now());
122
283
  const limit = opts.limit ?? 5;
123
- return out.slice(0, limit);
284
+ const pool = C.slice();
285
+ const seen = new Set();
286
+ const out = [];
287
+ while (out.length < limit && pool.length) {
288
+ let total = 0;
289
+ for (const c of pool) total += c.w;
290
+ let r = rand() * total;
291
+ let idx = 0;
292
+ while (idx < pool.length - 1 && (r -= pool[idx].w) > 0) idx++;
293
+ const [c] = pool.splice(idx, 1);
294
+ if (c.topic && seen.has(c.topic)) continue;
295
+ if (c.topic) seen.add(c.topic);
296
+ out.push(c.text);
297
+ }
298
+ return out;
124
299
  }
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.23.0';
14
+ const BAKED = '0.24.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {