standout 0.5.33 → 0.5.35

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