standout 0.1.0 → 0.5.15

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.
Files changed (45) hide show
  1. package/README.md +2 -1
  2. package/dist/ai-usage.d.ts +164 -0
  3. package/dist/ai-usage.js +1303 -0
  4. package/dist/api.d.ts +10 -0
  5. package/dist/api.js +86 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +351 -0
  8. package/dist/cursor.d.ts +54 -0
  9. package/dist/cursor.js +487 -0
  10. package/dist/dev-env.d.ts +28 -0
  11. package/dist/dev-env.js +264 -0
  12. package/dist/gather.d.ts +81 -0
  13. package/dist/gather.js +885 -0
  14. package/dist/mcp.d.ts +1 -0
  15. package/dist/mcp.js +25 -0
  16. package/dist/prompt.d.ts +1 -0
  17. package/dist/prompt.js +247 -0
  18. package/dist/redact.d.ts +1 -0
  19. package/dist/redact.js +37 -0
  20. package/dist/tools.d.ts +4 -0
  21. package/dist/tools.js +182 -0
  22. package/dist/twitter-scrape.d.ts +1 -0
  23. package/dist/twitter-scrape.js +267 -0
  24. package/dist/wrapped/aggregate.d.ts +6 -0
  25. package/dist/wrapped/aggregate.js +792 -0
  26. package/dist/wrapped/chrono-critters.d.ts +8 -0
  27. package/dist/wrapped/chrono-critters.js +32 -0
  28. package/dist/wrapped/index.d.ts +3 -0
  29. package/dist/wrapped/index.js +2 -0
  30. package/dist/wrapped/mascots.d.ts +2 -0
  31. package/dist/wrapped/mascots.js +35 -0
  32. package/dist/wrapped/preview.d.ts +1 -0
  33. package/dist/wrapped/preview.js +93 -0
  34. package/dist/wrapped/render.d.ts +14 -0
  35. package/dist/wrapped/render.js +996 -0
  36. package/dist/wrapped/tiers.d.ts +9 -0
  37. package/dist/wrapped/tiers.js +73 -0
  38. package/dist/wrapped/types.d.ts +184 -0
  39. package/dist/wrapped/types.js +4 -0
  40. package/dist/wrapped-client.d.ts +10 -0
  41. package/dist/wrapped-client.js +36 -0
  42. package/dist/wrapped-share.d.ts +3 -0
  43. package/dist/wrapped-share.js +27 -0
  44. package/package.json +35 -8
  45. package/bin/cli.mjs +0 -30
@@ -0,0 +1,792 @@
1
+ const SOURCES = ["claude_code", "codex", "cursor"];
2
+ const TOOL_LABELS = {
3
+ claude_code: "Claude Code",
4
+ codex: "Codex",
5
+ cursor: "Cursor",
6
+ };
7
+ const RETAIL_RATES = [
8
+ {
9
+ match: /claude.*opus/i,
10
+ input: 15,
11
+ output: 75,
12
+ cacheRead: 1.5,
13
+ cacheWrite: 18.75,
14
+ },
15
+ {
16
+ match: /claude.*sonnet/i,
17
+ input: 3,
18
+ output: 15,
19
+ cacheRead: 0.3,
20
+ cacheWrite: 3.75,
21
+ },
22
+ {
23
+ match: /claude.*haiku/i,
24
+ input: 1,
25
+ output: 5,
26
+ cacheRead: 0.1,
27
+ cacheWrite: 1.25,
28
+ },
29
+ {
30
+ match: /gpt-?5(-mini|-nano)?/i,
31
+ input: 1.25,
32
+ output: 10,
33
+ cacheRead: 0.125,
34
+ cacheWrite: 1.25,
35
+ },
36
+ {
37
+ match: /gpt-?4/i,
38
+ input: 2.5,
39
+ output: 10,
40
+ cacheRead: 0.25,
41
+ cacheWrite: 2.5,
42
+ },
43
+ ];
44
+ function rateForModel(model) {
45
+ if (model) {
46
+ for (const r of RETAIL_RATES) {
47
+ if (r.match.test(model))
48
+ return {
49
+ input: r.input,
50
+ output: r.output,
51
+ cacheRead: r.cacheRead,
52
+ cacheWrite: r.cacheWrite,
53
+ };
54
+ }
55
+ }
56
+ return { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }; // generic fallback
57
+ }
58
+ function classifyProvider(model) {
59
+ const m = model.toLowerCase();
60
+ if (m.startsWith("claude") || m.includes("anthropic"))
61
+ return "anthropic";
62
+ if (m.startsWith("gpt") ||
63
+ m.includes("openai") ||
64
+ m.startsWith("o1") ||
65
+ m.startsWith("o3"))
66
+ return "openai";
67
+ return "other";
68
+ }
69
+ function asObject(v) {
70
+ return v && typeof v === "object" ? v : {};
71
+ }
72
+ function str(v) {
73
+ return typeof v === "string" ? v : "";
74
+ }
75
+ function strOrNull(v) {
76
+ return typeof v === "string" && v.length > 0 ? v : null;
77
+ }
78
+ // Defensive parse of computed.collaboration (background-generated; absent on the
79
+ // first run). Returns null unless the core fields are present.
80
+ function parseCollaboration(v) {
81
+ const o = asObject(v);
82
+ if (!o.style_label || !o.summary)
83
+ return null;
84
+ const signals = asObject(o.signals);
85
+ const funFacts = asObject(o.fun_facts);
86
+ return {
87
+ style_label: str(o.style_label),
88
+ summary: str(o.summary),
89
+ signals: {
90
+ fact_checking: str(signals.fact_checking),
91
+ depth: str(signals.depth),
92
+ autonomy: str(signals.autonomy),
93
+ complexity: str(signals.complexity),
94
+ },
95
+ fun_facts: {
96
+ most_common_ask: strOrNull(funFacts.most_common_ask),
97
+ weirdest_prompt: strOrNull(funFacts.weirdest_prompt),
98
+ },
99
+ generated_by: o.generated_by === "llm" ? "llm" : "heuristic",
100
+ };
101
+ }
102
+ function parseProficiency(v) {
103
+ const o = asObject(v);
104
+ if (typeof o.score !== "number")
105
+ return null;
106
+ const num = (x) => typeof x === "number" && Number.isFinite(x) ? x : null;
107
+ const dim = o.top_dimension === "intensity" ||
108
+ o.top_dimension === "consistency" ||
109
+ o.top_dimension === "craft"
110
+ ? o.top_dimension
111
+ : "intensity";
112
+ return {
113
+ score: o.score,
114
+ intensity: num(o.intensity),
115
+ consistency: num(o.consistency) ?? 0,
116
+ craft: num(o.craft),
117
+ base: num(o.base) ?? o.score,
118
+ craft_bonus: num(o.craft_bonus) ?? 0,
119
+ top_dimension: dim,
120
+ score_percentile: num(o.score_percentile),
121
+ comment: strOrNull(o.comment) ?? "",
122
+ };
123
+ }
124
+ function parseContributions(v) {
125
+ const o = asObject(v);
126
+ if (typeof o.one_liner !== "string" || o.one_liner.length === 0)
127
+ return null;
128
+ const themes = Array.isArray(o.themes)
129
+ ? o.themes.filter((t) => typeof t === "string")
130
+ : [];
131
+ return { one_liner: o.one_liner, themes };
132
+ }
133
+ function parseProjectBlurbs(v) {
134
+ const out = new Map();
135
+ if (!Array.isArray(v))
136
+ return out;
137
+ for (const item of v) {
138
+ const o = asObject(item);
139
+ if (typeof o.name === "string" && typeof o.blurb === "string" && o.blurb) {
140
+ out.set(o.name, o.blurb);
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+ function asReadinessStatus(v, fallback) {
146
+ return v === "ready" ||
147
+ v === "scanning" ||
148
+ v === "enriching" ||
149
+ v === "unavailable" ||
150
+ v === "error"
151
+ ? v
152
+ : fallback;
153
+ }
154
+ function compactCwd(cwd) {
155
+ // Collapse parallel worktrees of one repo to a single project label. The CLI
156
+ // already canonicalizes these, but older/stored profiles may carry raw paths.
157
+ // ~/work/standout/.worktrees/feat-x → work/standout
158
+ // ~/work/standout/.claude/worktrees/foo → work/standout
159
+ const base = cwd
160
+ .replace(/\/\.claude\/worktrees\/[^/]+.*$/, "")
161
+ .replace(/\/\.worktrees\/[^/]+.*$/, "");
162
+ const segments = base.split("/").filter(Boolean);
163
+ // Conductor parallel workspaces ~/conductor/workspaces/<repo>/<leaf> → <repo>.
164
+ const ci = segments.indexOf("conductor");
165
+ if (ci >= 0 && segments[ci + 1] === "workspaces" && segments[ci + 2]) {
166
+ return segments[ci + 2];
167
+ }
168
+ return segments.slice(-2).join("/") || base;
169
+ }
170
+ export function aggregateView(args) {
171
+ const { profile, computed, share_url } = args;
172
+ const identity = asObject(profile.identity);
173
+ const aiUsage = asObject(profile.ai_usage);
174
+ const aiCoding = asObject(profile.ai_coding);
175
+ const explicitReadiness = asObject(profile.readiness);
176
+ let totalHours = 0;
177
+ let totalSessions = 0;
178
+ let activeDays = 0;
179
+ let streakDays = 0;
180
+ let weekendWeightedSum = 0;
181
+ let weekendWeightTotal = 0;
182
+ const toolCounts = {};
183
+ let hourBuckets = [];
184
+ let peakHour = null;
185
+ let conductorWorkspaces = 0;
186
+ const frameworkCounts = new Map();
187
+ const promptSamples = [];
188
+ const promptsSeen = new Set();
189
+ const askMap = new Map();
190
+ const projectMap = new Map();
191
+ let usageStatsSeen = false;
192
+ let allTimeHours = 0;
193
+ let allTimeSessions = 0;
194
+ let allTimeActiveDays = 0;
195
+ let allTimeFirstSession = null;
196
+ let allTimeLastSession = null;
197
+ const allTimeMonthly = new Map();
198
+ // Peak hour + longest streak are computed over the FULL history (all-time),
199
+ // not the rolling 30-day window — otherwise they wobble day to day.
200
+ const allTimeHourBuckets = new Array(24).fill(0);
201
+ let allTimeStreak = 0;
202
+ for (const src of SOURCES) {
203
+ const stats = aiUsage[src];
204
+ if (!stats)
205
+ continue;
206
+ usageStatsSeen = true;
207
+ const allTime = stats.all_time;
208
+ if (allTime) {
209
+ allTimeHours += allTime.total_duration_hours ?? 0;
210
+ allTimeSessions += allTime.total_sessions ?? 0;
211
+ allTimeActiveDays = Math.max(allTimeActiveDays, allTime.active_days ?? 0);
212
+ if (allTime.first_session &&
213
+ (!allTimeFirstSession || allTime.first_session < allTimeFirstSession)) {
214
+ allTimeFirstSession = allTime.first_session;
215
+ }
216
+ if (allTime.last_session &&
217
+ (!allTimeLastSession || allTime.last_session > allTimeLastSession)) {
218
+ allTimeLastSession = allTime.last_session;
219
+ }
220
+ for (const b of allTime.monthly_buckets ?? []) {
221
+ const month = allTimeMonthly.get(b.month) ?? {
222
+ sessions: 0,
223
+ duration_hours: 0,
224
+ };
225
+ month.sessions += b.sessions ?? 0;
226
+ month.duration_hours += b.duration_hours ?? 0;
227
+ allTimeMonthly.set(b.month, month);
228
+ }
229
+ if (allTime.hour_buckets && allTime.hour_buckets.length === 24) {
230
+ for (let i = 0; i < 24; i++)
231
+ allTimeHourBuckets[i] += allTime.hour_buckets[i] ?? 0;
232
+ }
233
+ allTimeStreak = Math.max(allTimeStreak, allTime.longest_streak_days ?? 0);
234
+ }
235
+ totalHours += stats.total_duration_hours ?? 0;
236
+ totalSessions += stats.total_sessions ?? 0;
237
+ activeDays = Math.max(activeDays, stats.active_days ?? 0);
238
+ streakDays = Math.max(streakDays, stats.longest_streak_days ?? 0);
239
+ if (typeof stats.weekend_ratio === "number") {
240
+ const weight = stats.total_sessions ?? 0;
241
+ weekendWeightedSum += stats.weekend_ratio * weight;
242
+ weekendWeightTotal += weight;
243
+ }
244
+ for (const [name, count] of Object.entries(stats.tool_call_counts ?? {})) {
245
+ toolCounts[name] = (toolCounts[name] ?? 0) + count;
246
+ }
247
+ if (stats.hour_buckets && stats.hour_buckets.length === 24) {
248
+ if (hourBuckets.length === 0)
249
+ hourBuckets = stats.hour_buckets.slice();
250
+ else
251
+ hourBuckets = hourBuckets.map((v, i) => v + (stats.hour_buckets?.[i] ?? 0));
252
+ }
253
+ // Merge per-framework usage counts so the stack orders by what's used most;
254
+ // fall back to presence (count 1) for older profiles without counts.
255
+ if (stats.framework_counts) {
256
+ for (const [fw, n] of Object.entries(stats.framework_counts))
257
+ frameworkCounts.set(fw, (frameworkCounts.get(fw) ?? 0) + n);
258
+ }
259
+ else {
260
+ for (const fw of stats.frameworks ?? [])
261
+ frameworkCounts.set(fw, (frameworkCounts.get(fw) ?? 0) + 1);
262
+ }
263
+ for (const p of stats.prompt_samples ?? []) {
264
+ if (!p || p.length < 16)
265
+ continue;
266
+ const key = p
267
+ .toLowerCase()
268
+ .replace(/[\s\p{P}]+$/u, "")
269
+ .slice(0, 160);
270
+ if (promptsSeen.has(key))
271
+ continue;
272
+ promptsSeen.add(key);
273
+ if (promptSamples.length < 6)
274
+ promptSamples.push(p);
275
+ }
276
+ for (const ask of stats.prompt_frequency ?? []) {
277
+ if (!ask?.text)
278
+ continue;
279
+ const key = ask.text.toLowerCase().slice(0, 120);
280
+ const existing = askMap.get(key);
281
+ if (existing)
282
+ existing.count += ask.count;
283
+ else
284
+ askMap.set(key, { text: ask.text, count: ask.count });
285
+ }
286
+ for (const proj of stats.projects ?? []) {
287
+ if (proj.cwd?.includes("/conductor/")) {
288
+ conductorWorkspaces += proj.session_count;
289
+ }
290
+ const label = compactCwd(proj.cwd);
291
+ projectMap.set(label, (projectMap.get(label) ?? 0) + proj.session_count);
292
+ }
293
+ }
294
+ // "WHEN YOU CODE" + peak are computed over the full history when available
295
+ // (stable), falling back to the 30-day window only if all-time is missing.
296
+ const allTimeHasHours = allTimeHourBuckets.some((v) => v > 0);
297
+ let histogram = allTimeHasHours ? allTimeHourBuckets : hourBuckets;
298
+ if (histogram.length === 0)
299
+ histogram = new Array(24).fill(0);
300
+ // Peak hour = argmax of that merged histogram (consistent with the bars drawn).
301
+ let peakVal = 0;
302
+ histogram.forEach((count, hour) => {
303
+ if (count > peakVal) {
304
+ peakVal = count;
305
+ peakHour = hour;
306
+ }
307
+ });
308
+ // Longest streak over the full history (only grows over time), falling back to
309
+ // the 30-day streak if all-time isn't available.
310
+ if (allTimeStreak > 0)
311
+ streakDays = allTimeStreak;
312
+ const topTools = Object.entries(toolCounts)
313
+ .sort((a, b) => b[1] - a[1])
314
+ .slice(0, 5)
315
+ .map(([name, count]) => ({ name, count }));
316
+ // Merge git repos you committed to (local.repos, all-time commits) with the
317
+ // AI-session cwds, so projects worked on outside Claude Code/Codex appear too.
318
+ const localRepos = Array.isArray(asObject(profile.local).repos)
319
+ ? asObject(profile.local).repos
320
+ : [];
321
+ const projByName = new Map();
322
+ for (const r of localRepos) {
323
+ const name = typeof r.name === "string" ? r.name : null;
324
+ if (!name)
325
+ continue;
326
+ const commits = typeof r.commit_count === "number" ? r.commit_count : 0;
327
+ const existing = projByName.get(name);
328
+ // Two local clones can share a repo name (e.g. a stray 1-commit checkout and
329
+ // the real one with hundreds). Keep the fuller clone's count so the stray
330
+ // can't overwrite and bury the real project.
331
+ if (existing) {
332
+ if (commits > existing.commits)
333
+ existing.commits = commits;
334
+ }
335
+ else {
336
+ projByName.set(name, { label: name, name, commits, sessions: 0 });
337
+ }
338
+ }
339
+ for (const [label, sessions] of projectMap) {
340
+ const name = label.split("/").filter(Boolean).pop() ?? label;
341
+ const existing = projByName.get(name);
342
+ if (existing) {
343
+ existing.sessions += sessions;
344
+ existing.label = label;
345
+ }
346
+ else {
347
+ projByName.set(name, { label, name, commits: 0, sessions });
348
+ }
349
+ }
350
+ const topProjects = [
351
+ ...projByName.values(),
352
+ ]
353
+ .sort((a, b) => b.commits - a.commits || b.sessions - a.sessions)
354
+ .slice(0, 12);
355
+ const totalCommits = aiCoding.total_commits_since_2024 ?? 0;
356
+ const aiAssistedCommits = aiCoding.ai_assisted_commits ?? 0;
357
+ const aiAssistedPct = totalCommits > 0 ? aiAssistedCommits / totalCommits : 0;
358
+ // ───────────────────── tokens, tool relationship, models ─────────────────────
359
+ const tokens = computeTokens(aiUsage);
360
+ const toolRelationship = computeToolRelationship(aiUsage);
361
+ const models = computeModels(aiUsage);
362
+ const computedObj = asObject(computed);
363
+ const archetypeLabel = typeof computedObj.archetype_label === "string"
364
+ ? computedObj.archetype_label
365
+ : "Builder";
366
+ const zinger = typeof computedObj.zinger === "string" && computedObj.zinger.length > 0
367
+ ? computedObj.zinger
368
+ : "just keeps building";
369
+ const mascotPose = (typeof computedObj.mascot_pose === "string"
370
+ ? computedObj.mascot_pose
371
+ : "default");
372
+ const earlyAdopter = computedObj.early_adopter !== false;
373
+ const cohortSize = computedObj.cohort_size ?? 0;
374
+ const percentileBands = asObject(computedObj.percentile_bands);
375
+ const rankSummary = asObject(computedObj.rank_summary);
376
+ const collaboration = parseCollaboration(computedObj.collaboration);
377
+ const contributions = parseContributions(computedObj.contributions);
378
+ const cardComments = asObject(computedObj.card_comments);
379
+ const talkThemes = Array.isArray(computedObj.talk_themes)
380
+ ? computedObj.talk_themes.filter((t) => typeof t === "string")
381
+ : [];
382
+ const projectBlurbs = parseProjectBlurbs(computedObj.project_blurbs);
383
+ if (projectBlurbs.size > 0) {
384
+ for (const p of topProjects) {
385
+ const repoName = p.label.split("/").filter(Boolean).pop() ?? p.label;
386
+ const blurb = projectBlurbs.get(repoName) ?? projectBlurbs.get(p.label);
387
+ if (blurb)
388
+ p.blurb = blurb;
389
+ }
390
+ }
391
+ const topAsks = [...askMap.values()]
392
+ .sort((a, b) => b.count - a.count)
393
+ .slice(0, 5);
394
+ const name = identity.name || identity.email || "you";
395
+ const readiness = {
396
+ usage_stats_status: asReadinessStatus(explicitReadiness.usage_stats_status, usageStatsSeen ? "ready" : "unavailable"),
397
+ profile_basics_status: asReadinessStatus(explicitReadiness.profile_basics_status, identity.name || identity.email || identity.github_username
398
+ ? "ready"
399
+ : "unavailable"),
400
+ enrichment_status: asReadinessStatus(explicitReadiness.enrichment_status, "unavailable"),
401
+ };
402
+ return {
403
+ readiness,
404
+ name,
405
+ archetype_label: archetypeLabel,
406
+ zinger,
407
+ mascot_pose: mascotPose,
408
+ total_hours: totalHours,
409
+ total_sessions: totalSessions,
410
+ active_days: activeDays,
411
+ streak_days: streakDays,
412
+ weekend_ratio: weekendWeightTotal > 0 ? weekendWeightedSum / weekendWeightTotal : 0,
413
+ top_tools: topTools,
414
+ hour_histogram: histogram,
415
+ peak_hour: peakHour,
416
+ conductor_workspaces: conductorWorkspaces,
417
+ top_projects: topProjects,
418
+ contributions,
419
+ all_time: {
420
+ total_hours: +allTimeHours.toFixed(1),
421
+ total_sessions: allTimeSessions,
422
+ active_days: allTimeActiveDays,
423
+ first_session: allTimeFirstSession,
424
+ last_session: allTimeLastSession,
425
+ monthly_buckets: [...allTimeMonthly.entries()]
426
+ .sort(([a], [b]) => a.localeCompare(b))
427
+ .map(([month, b]) => ({
428
+ month,
429
+ sessions: b.sessions,
430
+ duration_hours: +b.duration_hours.toFixed(1),
431
+ })),
432
+ },
433
+ frameworks: [...frameworkCounts.entries()]
434
+ .sort((a, b) => b[1] - a[1])
435
+ .map(([fw]) => fw),
436
+ prompt_samples: promptSamples,
437
+ top_asks: topAsks,
438
+ collaboration,
439
+ total_commits: totalCommits,
440
+ ai_assisted_commits: aiAssistedCommits,
441
+ ai_assisted_pct: aiAssistedPct,
442
+ code_footprint: computeCodeFootprint(aiCoding, totalCommits, strOrNull(cardComments.footprint)),
443
+ concurrency: computeConcurrency(aiUsage, strOrNull(cardComments.concurrency)),
444
+ conversation: computeConversation(aiUsage, strOrNull(cardComments.talk), talkThemes),
445
+ rhythm_comment: strOrNull(cardComments.rhythm),
446
+ stack_comment: strOrNull(cardComments.stack),
447
+ early_adopter: earlyAdopter,
448
+ cohort_size: cohortSize,
449
+ percentile_bands: {
450
+ typescript_shippers: percentileBands.typescript_shippers ?? null,
451
+ ai_native: percentileBands.ai_native ?? null,
452
+ open_source: percentileBands.open_source ?? null,
453
+ },
454
+ rank_summary: {
455
+ sample_size: rankSummary.sample_size ?? 0,
456
+ hours_percentile: rankSummary.hours_percentile ?? null,
457
+ sessions_percentile: rankSummary.sessions_percentile ?? null,
458
+ active_days_percentile: rankSummary.active_days_percentile ?? null,
459
+ tokens_percentile: rankSummary.tokens_percentile ?? null,
460
+ commit_days_percentile: rankSummary.commit_days_percentile ?? null,
461
+ },
462
+ proficiency: parseProficiency(computedObj.proficiency),
463
+ tokens,
464
+ tool_relationship: toolRelationship,
465
+ models,
466
+ share_url,
467
+ };
468
+ }
469
+ // ───────────────────── helpers ─────────────────────
470
+ function computeTokens(aiUsage) {
471
+ // The scanner feeds this view a rolling 30-day window. Sum all retained
472
+ // monthly buckets because a rolling window often spans two calendar months.
473
+ const displayMonths = new Map();
474
+ const byTool = {};
475
+ let totalCost = 0;
476
+ let workTokens = 0;
477
+ let cacheTokens = 0;
478
+ let workPrior30d = 0;
479
+ // Headline = "work" tokens the model actually generated/read fresh
480
+ // (input + output). Cache is reported separately because it's mostly the same
481
+ // context re-read every turn and would otherwise dwarf the number 100x.
482
+ const workTotal = (b) => (b.input_tokens ?? 0) + (b.output_tokens ?? 0);
483
+ const cacheTotal = (b) => b.cache_read_tokens !== undefined || b.cache_write_tokens !== undefined
484
+ ? (b.cache_read_tokens ?? 0) + (b.cache_write_tokens ?? 0)
485
+ : (b.cache_tokens ?? 0);
486
+ // Cursor exposes no reliable token counts — flag it so the card can say so,
487
+ // but only when it was actually used in THIS window (the card is "last 30
488
+ // days"). All-time Cursor sessions don't count: claiming "Cursor used too" when
489
+ // they haven't opened it in a month is just wrong.
490
+ const CURSOR_MIN_SESSIONS = 3;
491
+ const cursorStats = aiUsage["cursor"];
492
+ const cursorUntracked = !!(cursorStats && (cursorStats.total_sessions ?? 0) >= CURSOR_MIN_SESSIONS);
493
+ for (const src of SOURCES) {
494
+ const stats = aiUsage[src];
495
+ if (!stats)
496
+ continue;
497
+ const buckets = stats.monthly_buckets ?? [];
498
+ for (const b of buckets) {
499
+ const work = workTotal(b);
500
+ const cache = cacheTotal(b);
501
+ workTokens += work;
502
+ cacheTokens += cache;
503
+ // The per-tool bars + sparkline show the cache-inclusive total (matches the
504
+ // rainbow headline); the "generated" callout is the work-only number.
505
+ byTool[TOOL_LABELS[src] ?? src] =
506
+ (byTool[TOOL_LABELS[src] ?? src] ?? 0) + work + cache;
507
+ const rate = rateForModel(b.primary_model);
508
+ const cacheRead = b.cache_read_tokens ??
509
+ (b.cache_write_tokens === undefined ? (b.cache_tokens ?? 0) : 0);
510
+ const cacheWrite = b.cache_write_tokens ?? 0;
511
+ totalCost +=
512
+ ((b.input_tokens ?? 0) * rate.input) / 1_000_000 +
513
+ ((b.output_tokens ?? 0) * rate.output) / 1_000_000 +
514
+ (cacheRead * rate.cacheRead) / 1_000_000 +
515
+ (cacheWrite * rate.cacheWrite) / 1_000_000;
516
+ }
517
+ const priorBuckets = stats.previous_30d?.monthly_buckets ?? [];
518
+ workPrior30d +=
519
+ priorBuckets.reduce((sum, b) => sum + workTotal(b), 0) ||
520
+ (stats.previous_30d?.total_input_tokens ?? 0) +
521
+ (stats.previous_30d?.total_output_tokens ?? 0);
522
+ const monthSource = stats.all_time?.monthly_buckets && stats.all_time.monthly_buckets.length
523
+ ? stats.all_time.monthly_buckets
524
+ : buckets;
525
+ for (const b of monthSource) {
526
+ const m = displayMonths.get(b.month) ?? { tokens: 0 };
527
+ m.tokens += workTotal(b) + cacheTotal(b);
528
+ displayMonths.set(b.month, m);
529
+ }
530
+ }
531
+ const sortedMonths = [...displayMonths.keys()].sort();
532
+ const thisMonth = sortedMonths[sortedMonths.length - 1] ?? null;
533
+ // Omit tools with no token data (e.g. Cursor, which we can't read tokens for)
534
+ // so the card never renders a dead "Cursor ░░░░ 0" row.
535
+ const sortedByTool = Object.entries(byTool)
536
+ .filter(([, tokens]) => tokens > 0)
537
+ .sort(([, a], [, b]) => b - a)
538
+ .map(([tool, tokens]) => ({ tool, tokens }));
539
+ // 6-month sparkline
540
+ const sixMonth = [];
541
+ if (thisMonth) {
542
+ const [year, month] = thisMonth.split("-").map(Number);
543
+ for (let i = 5; i >= 0; i--) {
544
+ const d = new Date(Date.UTC(year, month - 1 - i, 1));
545
+ const key = d.toISOString().slice(0, 7);
546
+ sixMonth.push({
547
+ month: key,
548
+ tokens: displayMonths.get(key)?.tokens ?? 0,
549
+ });
550
+ }
551
+ }
552
+ return {
553
+ work_tokens: workTokens,
554
+ cache_tokens: cacheTokens,
555
+ total_30d: workTokens + cacheTokens,
556
+ total_prior_30d: workPrior30d,
557
+ multiplier_vs_prior: workTokens > 0 && workPrior30d > 0 ? workTokens / workPrior30d : null,
558
+ by_tool: sortedByTool,
559
+ six_month_buckets: sixMonth,
560
+ estimated_retail_cost_usd: Math.round(totalCost),
561
+ cursor_untracked: cursorUntracked,
562
+ };
563
+ }
564
+ function computeToolRelationship(aiUsage) {
565
+ // Build a per-month, per-tool session map across the trailing 6 months
566
+ const monthMap = new Map();
567
+ for (const src of SOURCES) {
568
+ const stats = aiUsage[src];
569
+ if (!stats)
570
+ continue;
571
+ for (const b of stats.monthly_buckets ?? []) {
572
+ let row = monthMap.get(b.month);
573
+ if (!row) {
574
+ row = new Map();
575
+ monthMap.set(b.month, row);
576
+ }
577
+ row.set(src, (row.get(src) ?? 0) + b.sessions);
578
+ }
579
+ }
580
+ const months = [...monthMap.keys()].sort();
581
+ if (months.length < 2) {
582
+ // Not enough data to detect a relationship — fall back to "loyalist" if there's
583
+ // any single-tool usage, otherwise insufficient.
584
+ if (months.length === 1) {
585
+ const row = monthMap.get(months[0]);
586
+ const best = [...row.entries()].sort(([, a], [, b]) => b - a)[0];
587
+ if (best) {
588
+ return {
589
+ kind: "loyalist",
590
+ tool: TOOL_LABELS[best[0]] ?? best[0],
591
+ months_count: 1,
592
+ sessions_count: best[1],
593
+ };
594
+ }
595
+ }
596
+ return { kind: "insufficient" };
597
+ }
598
+ const timeline = [];
599
+ for (const month of months) {
600
+ const row = monthMap.get(month);
601
+ const total = [...row.values()].reduce((a, b) => a + b, 0);
602
+ if (total === 0)
603
+ continue;
604
+ const sorted = [...row.entries()].sort(([, a], [, b]) => b - a);
605
+ const [topTool, topCount] = sorted[0];
606
+ timeline.push({
607
+ month,
608
+ dominant: topTool,
609
+ share: topCount / total,
610
+ });
611
+ }
612
+ // Detect a switch — find the longest run of consistent dominance, then look for
613
+ // a transition at the end. If the last 2+ months differ from the first 2+ months,
614
+ // that's a switch.
615
+ const dominants = timeline.map((t) => t.dominant);
616
+ const lastDominant = dominants[dominants.length - 1];
617
+ const firstDominant = dominants[0];
618
+ if (firstDominant && lastDominant && firstDominant !== lastDominant) {
619
+ // Find the month where the switch became permanent
620
+ let switchIdx = -1;
621
+ for (let i = dominants.length - 1; i >= 1; i--) {
622
+ if (dominants[i] === lastDominant && dominants[i - 1] !== lastDominant) {
623
+ switchIdx = i;
624
+ break;
625
+ }
626
+ }
627
+ if (switchIdx > 0) {
628
+ return {
629
+ kind: "switch",
630
+ from_tool: TOOL_LABELS[firstDominant] ?? firstDominant,
631
+ to_tool: TOOL_LABELS[lastDominant] ?? lastDominant,
632
+ switch_month: timeline[switchIdx].month,
633
+ timeline: timeline.map((t) => ({
634
+ month: t.month,
635
+ dominant: TOOL_LABELS[t.dominant] ?? t.dominant,
636
+ share: t.share,
637
+ })),
638
+ };
639
+ }
640
+ }
641
+ // No switch detected — check if all months had the same dominant tool (loyalist)
642
+ // or if it's been a mix (polyglot)
643
+ const uniqueDominants = new Set(dominants);
644
+ if (uniqueDominants.size === 1) {
645
+ let totalSessions = 0;
646
+ for (const row of monthMap.values()) {
647
+ totalSessions += row.get(firstDominant) ?? 0;
648
+ }
649
+ return {
650
+ kind: "loyalist",
651
+ tool: TOOL_LABELS[firstDominant] ?? firstDominant,
652
+ months_count: months.length,
653
+ sessions_count: totalSessions,
654
+ };
655
+ }
656
+ // Polyglot — bouncing between tools across months
657
+ const totalsByTool = new Map();
658
+ for (const row of monthMap.values()) {
659
+ for (const [tool, n] of row) {
660
+ totalsByTool.set(tool, (totalsByTool.get(tool) ?? 0) + n);
661
+ }
662
+ }
663
+ const grandTotal = [...totalsByTool.values()].reduce((a, b) => a + b, 0);
664
+ const tools = [...totalsByTool.entries()]
665
+ .sort(([, a], [, b]) => b - a)
666
+ .map(([tool, n]) => ({
667
+ tool: TOOL_LABELS[tool] ?? tool,
668
+ share: grandTotal > 0 ? n / grandTotal : 0,
669
+ }));
670
+ return { kind: "polyglot", tools };
671
+ }
672
+ function computeModels(aiUsage) {
673
+ const allModelCounts = new Map();
674
+ for (const src of SOURCES) {
675
+ const stats = aiUsage[src];
676
+ if (!stats?.model_session_counts)
677
+ continue;
678
+ for (const [model, n] of Object.entries(stats.model_session_counts)) {
679
+ allModelCounts.set(model, (allModelCounts.get(model) ?? 0) + n);
680
+ }
681
+ }
682
+ const totalClassified = [...allModelCounts.values()].reduce((a, b) => a + b, 0);
683
+ if (totalClassified === 0) {
684
+ return { by_provider: [], top_model: null };
685
+ }
686
+ const byProvider = new Map();
687
+ for (const [model, sessions] of allModelCounts) {
688
+ const provider = classifyProvider(model);
689
+ const entry = byProvider.get(provider) ?? { sessions: 0, models: [] };
690
+ entry.sessions += sessions;
691
+ entry.models.push({ name: model, sessions });
692
+ byProvider.set(provider, entry);
693
+ }
694
+ const providers = [
695
+ ...byProvider.entries(),
696
+ ]
697
+ .sort(([, a], [, b]) => b.sessions - a.sessions)
698
+ .map(([provider, entry]) => ({
699
+ provider,
700
+ sessions: entry.sessions,
701
+ share: entry.sessions / totalClassified,
702
+ models: entry.models.sort((a, b) => b.sessions - a.sessions).slice(0, 4),
703
+ }));
704
+ let topModel = null;
705
+ let topCount = 0;
706
+ for (const [name, sessions] of allModelCounts) {
707
+ if (sessions > topCount) {
708
+ topCount = sessions;
709
+ topModel = {
710
+ name,
711
+ sessions,
712
+ share: sessions / totalClassified,
713
+ };
714
+ }
715
+ }
716
+ return { by_provider: providers, top_model: topModel };
717
+ }
718
+ function footprintLabel(net, additiveRatio) {
719
+ if (net <= 0)
720
+ return "Cleaner";
721
+ if (additiveRatio >= 0.7)
722
+ return "Shipper";
723
+ return "Refactorer";
724
+ }
725
+ function computeCodeFootprint(aiCoding, totalCommits, line) {
726
+ const added = aiCoding.lines_added_since_2024 ?? 0;
727
+ const deleted = aiCoding.lines_deleted_since_2024 ?? 0;
728
+ if (added === 0 && deleted === 0)
729
+ return null;
730
+ const net = added - deleted;
731
+ const additiveRatio = added + deleted > 0 ? added / (added + deleted) : 0;
732
+ return {
733
+ lines_added: added,
734
+ lines_deleted: deleted,
735
+ net,
736
+ additive_ratio: +additiveRatio.toFixed(2),
737
+ median_commit_size: aiCoding.median_commit_size ?? 0,
738
+ biggest_commit_size: aiCoding.biggest_commit_size ?? 0,
739
+ lines_per_commit: totalCommits > 0 ? Math.round((added + deleted) / totalCommits) : 0,
740
+ label: footprintLabel(net, additiveRatio),
741
+ line,
742
+ };
743
+ }
744
+ // Concurrency comes from the tool with the most sessions (the user's main
745
+ // driver) — open-tab overlap isn't additive across tools.
746
+ function computeConcurrency(aiUsage, line) {
747
+ let best = null;
748
+ let bestSessions = -1;
749
+ for (const src of SOURCES) {
750
+ const stats = aiUsage[src];
751
+ if (!stats?.concurrency)
752
+ continue;
753
+ const sessions = stats.total_sessions ?? 0;
754
+ if (sessions > bestSessions) {
755
+ bestSessions = sessions;
756
+ best = stats.concurrency;
757
+ }
758
+ }
759
+ if (!best)
760
+ return null;
761
+ return {
762
+ open_tab_avg: best.open_tab_avg ?? 0,
763
+ open_tab_peak: best.open_tab_peak ?? 0,
764
+ longest_session_hours: best.longest_session_hours ?? 0,
765
+ active_juggle_pct: best.active_juggle_pct ?? 0,
766
+ line,
767
+ };
768
+ }
769
+ function computeConversation(aiUsage, line, themes) {
770
+ let totalPrompts = 0;
771
+ let questionWeighted = 0;
772
+ let charsWeighted = 0;
773
+ for (const src of SOURCES) {
774
+ const stats = aiUsage[src];
775
+ const c = stats?.conversation;
776
+ if (!c)
777
+ continue;
778
+ const n = c.total_prompts ?? 0;
779
+ totalPrompts += n;
780
+ questionWeighted += (c.question_ratio ?? 0) * n;
781
+ charsWeighted += (c.avg_prompt_chars ?? 0) * n;
782
+ }
783
+ if (totalPrompts === 0)
784
+ return null;
785
+ return {
786
+ total_prompts: totalPrompts,
787
+ question_ratio: +(questionWeighted / totalPrompts).toFixed(2),
788
+ avg_prompt_chars: Math.round(charsWeighted / totalPrompts),
789
+ line,
790
+ themes,
791
+ };
792
+ }