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