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 +1 -1
- package/src/insights.js +219 -44
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/insights.js
CHANGED
|
@@ -1,18 +1,64 @@
|
|
|
1
|
-
// Generate
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
push(64, `Steady week — ${fmtHours(last7)} active, on par with the previous 7 days.`);
|
|
46
96
|
}
|
|
47
97
|
} else if (last7 > 0) {
|
|
48
|
-
|
|
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
|
-
//
|
|
150
|
+
// ── 5. Peak weekday ────────────────────────────────────────────────────
|
|
53
151
|
if (a.byWeekday && Object.keys(a.byWeekday).length) {
|
|
54
152
|
let best = null;
|
|
55
|
-
for (const [
|
|
56
|
-
if (!best ||
|
|
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
|
-
//
|
|
165
|
+
// ── 7. Cost pace (month-to-date forecast) ──────────────────────────────
|
|
64
166
|
if (a.estimatedCost && a.byDay) {
|
|
65
167
|
const now = new Date();
|
|
66
|
-
const
|
|
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
|
|
74
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
182
|
+
push(54, `Hotspot: ${name} with ${fmtNum(top.count)} edits.`);
|
|
84
183
|
}
|
|
85
184
|
|
|
86
|
-
//
|
|
185
|
+
// ── 9. Streak — milestone tease + best-ever flag ───────────────────────
|
|
87
186
|
if (a.streak >= 3) {
|
|
88
|
-
const
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
192
|
+
push(82, `${a.streak}-day streak — beyond every milestone we track. Incredible.`, 'streak');
|
|
95
193
|
}
|
|
96
194
|
} else if (a.longestStreak >= 7) {
|
|
97
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
109
|
-
if (a.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
272
|
+
push(38, `${fmtNum(a.notifications)} notifications — Claude has paused for you that many times.`);
|
|
119
273
|
}
|
|
120
274
|
|
|
121
|
-
|
|
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
|
-
|
|
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
|
}
|