token-studio 4.8.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.
Files changed (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
@@ -0,0 +1,345 @@
1
+ /* =============================================================
2
+ Review-page specific analysis utilities
3
+ ============================================================= */
4
+
5
+ import { U } from '../shared/utils.js';
6
+
7
+ // Local YYYY-MM-DD (avoids toISOString's UTC drift)
8
+ function localDateStr(d) {
9
+ const y = d.getFullYear();
10
+ const m = String(d.getMonth() + 1).padStart(2, '0');
11
+ const day = String(d.getDate()).padStart(2, '0');
12
+ return `${y}-${m}-${day}`;
13
+ }
14
+ function parseDateStr(s) {
15
+ const [y, m, d] = s.split('-').map(Number);
16
+ return { y, m: m - 1, d };
17
+ }
18
+
19
+ const PERIOD_LABELS = {
20
+ week: '本周',
21
+ month: '本月',
22
+ prev: '上月',
23
+ '90d': '近 90 天',
24
+ all: '全部'
25
+ };
26
+
27
+ function getPeriod(id, today = new Date(), rows = []) {
28
+ const t = new Date(today); t.setHours(0,0,0,0);
29
+ if (id === 'week') {
30
+ const start = new Date(t); start.setDate(t.getDate() - 6);
31
+ return {
32
+ id,
33
+ label: '本周',
34
+ start: localDateStr(start),
35
+ end: localDateStr(t),
36
+ pretty: `${localDateStr(start).slice(5)} – ${localDateStr(t).slice(5)}`,
37
+ prev: (function () {
38
+ const ps = new Date(start); ps.setDate(ps.getDate() - 7);
39
+ const pe = new Date(start); pe.setDate(pe.getDate() - 1);
40
+ return { start: localDateStr(ps), end: localDateStr(pe) };
41
+ })()
42
+ };
43
+ }
44
+ if (id === 'month') {
45
+ const start = new Date(t.getFullYear(), t.getMonth(), 1);
46
+ return {
47
+ id,
48
+ label: '本月',
49
+ start: localDateStr(start),
50
+ end: localDateStr(t),
51
+ pretty: `${t.getFullYear()} 年 ${t.getMonth() + 1} 月`,
52
+ prev: (function () {
53
+ const ps = new Date(t.getFullYear(), t.getMonth() - 1, 1);
54
+ const pe = new Date(t.getFullYear(), t.getMonth(), 0);
55
+ return { start: localDateStr(ps), end: localDateStr(pe) };
56
+ })()
57
+ };
58
+ }
59
+ if (id === 'prev') {
60
+ const ps = new Date(t.getFullYear(), t.getMonth() - 1, 1);
61
+ const pe = new Date(t.getFullYear(), t.getMonth(), 0);
62
+ return {
63
+ id,
64
+ label: '上月',
65
+ start: localDateStr(ps),
66
+ end: localDateStr(pe),
67
+ pretty: `${ps.getFullYear()} 年 ${ps.getMonth() + 1} 月`,
68
+ prev: (function () {
69
+ const pps = new Date(ps.getFullYear(), ps.getMonth() - 1, 1);
70
+ const ppe = new Date(ps.getFullYear(), ps.getMonth(), 0);
71
+ return { start: localDateStr(pps), end: localDateStr(ppe) };
72
+ })()
73
+ };
74
+ }
75
+ if (id === '90d') {
76
+ const start = new Date(t); start.setDate(t.getDate() - 89);
77
+ return {
78
+ id,
79
+ label: '近 90 天',
80
+ start: localDateStr(start),
81
+ end: localDateStr(t),
82
+ pretty: `近 90 天`,
83
+ prev: null
84
+ };
85
+ }
86
+ if (id === 'all') {
87
+ const dates = rows.map(r => r.usageDate).filter(Boolean).sort();
88
+ const start = dates[0] || localDateStr(t);
89
+ const end = dates[dates.length - 1] || localDateStr(t);
90
+ return {
91
+ id,
92
+ label: '全部',
93
+ start,
94
+ end,
95
+ pretty: `${start} – ${end}`,
96
+ prev: null
97
+ };
98
+ }
99
+ }
100
+
101
+ function inRange(d, p) { return d >= p.start && d <= p.end; }
102
+
103
+ function filterByPeriod(rows, period) {
104
+ return rows.filter(r => inRange(r.usageDate, period));
105
+ }
106
+
107
+ function sumField(rows, f) {
108
+ let s = 0; for (const r of rows) s += r[f] || 0; return s;
109
+ }
110
+
111
+ // Aggregate by a key
112
+ function aggregateBy(rows, key) {
113
+ const m = new Map();
114
+ for (const r of rows) {
115
+ const k = r[key];
116
+ if (!k) continue;
117
+ if (!m.has(k)) m.set(k, {
118
+ key: k, totalTokens: 0, inputTokens: 0, outputTokens: 0,
119
+ cacheReadTokens: 0, cacheCreationTokens: 0, reasoningOutputTokens: 0,
120
+ costUSD: 0, days: new Set(), sources: new Set(), models: new Set()
121
+ });
122
+ const a = m.get(k);
123
+ a.totalTokens += r.totalTokens;
124
+ a.inputTokens += r.inputTokens;
125
+ a.outputTokens += r.outputTokens;
126
+ a.cacheReadTokens += r.cacheReadTokens;
127
+ a.cacheCreationTokens += r.cacheCreationTokens;
128
+ a.reasoningOutputTokens += r.reasoningOutputTokens;
129
+ a.costUSD += r.costUSD;
130
+ a.days.add(r.usageDate);
131
+ a.sources.add(r.source);
132
+ a.models.add(r.model);
133
+ }
134
+ return Array.from(m.values()).map(v => ({
135
+ ...v,
136
+ dayCount: v.days.size,
137
+ cacheHitRate: v.totalTokens ? (v.cacheReadTokens / v.totalTokens) * 100 : 0
138
+ }));
139
+ }
140
+
141
+ // Top model used per project / source — pick model with biggest contribution
142
+ function topModelFor(rows, filterFn) {
143
+ const m = new Map();
144
+ for (const r of rows) {
145
+ if (!filterFn(r)) continue;
146
+ m.set(r.model, (m.get(r.model) || 0) + r.totalTokens);
147
+ }
148
+ let topName = '—', topVal = -1;
149
+ for (const [k, v] of m) if (v > topVal) { topName = k; topVal = v; }
150
+ return topName;
151
+ }
152
+
153
+ function dailyTotals(rows, period) {
154
+ // build sorted array of {date, total, cost, byTool}
155
+ const ps = parseDateStr(period.start);
156
+ const pe = parseDateStr(period.end);
157
+ const start = new Date(ps.y, ps.m, ps.d);
158
+ const end = new Date(pe.y, pe.m, pe.d);
159
+ const days = [];
160
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
161
+ const ds = localDateStr(d);
162
+ days.push({ date: ds, total: 0, cost: 0, byTool: {} });
163
+ }
164
+ const idx = new Map(days.map((d, i) => [d.date, i]));
165
+ for (const r of rows) {
166
+ const i = idx.get(r.usageDate);
167
+ if (i == null) continue;
168
+ days[i].total += r.totalTokens;
169
+ days[i].cost += r.costUSD;
170
+ days[i].byTool[r.source] = (days[i].byTool[r.source] || 0) + r.totalTokens;
171
+ }
172
+ return days;
173
+ }
174
+
175
+ // Auto-generate narrative for the project section
176
+ function narrativeForProjects(projAgg, totalTokens, daily) {
177
+ if (!projAgg.length) return null;
178
+ const top = projAgg[0];
179
+ const share = ((top.totalTokens / (totalTokens || 1)) * 100);
180
+ const topModel = topModelFor(daily, r => r.projectPath === top.key);
181
+ const cacheRate = top.cacheHitRate.toFixed(0);
182
+ return { top, share, topModel, cacheRate };
183
+ }
184
+
185
+ // Find peaks (top N days)
186
+ function findPeaks(days, n = 3) {
187
+ return [...days].filter(d => d.total > 0).sort((a, b) => b.total - a.total).slice(0, n);
188
+ }
189
+
190
+ // Generate insights
191
+ function buildInsights(daily, period, prevDaily) {
192
+ const insights = [];
193
+ const totals = {
194
+ total: sumField(daily, 'totalTokens'),
195
+ cost: sumField(daily, 'costUSD'),
196
+ cache: sumField(daily, 'cacheReadTokens'),
197
+ avgDaily: 0
198
+ };
199
+ const days = dailyTotals(daily, period);
200
+ const nonZero = days.filter(d => d.total > 0);
201
+ const avg = nonZero.length ? totals.total / nonZero.length : 0;
202
+ totals.avgDaily = avg;
203
+
204
+ // 1. Spike day
205
+ const peak = nonZero.sort((a, b) => b.total - a.total)[0];
206
+ if (peak && avg > 0 && peak.total > avg * 2.2) {
207
+ const ratio = (peak.total / avg).toFixed(1);
208
+ const topTool = Object.entries(peak.byTool).sort((a, b) => b[1] - a[1])[0];
209
+ insights.push({
210
+ kind: 'red',
211
+ emoji: '⚡',
212
+ headline: `${peak.date.slice(5)} 单日消耗是平均的 ${ratio} 倍`,
213
+ detail: [
214
+ { k: '当日总量', v: U.compactCN(peak.total) },
215
+ { k: '日均', v: U.compactCN(avg) },
216
+ { k: '主因', v: topTool ? topTool[0] : '—' }
217
+ ],
218
+ narrative: `当天 ${topTool ? topTool[0] : '主要工具'} 贡献了 ${topTool ? ((topTool[1] / peak.total) * 100).toFixed(0) : 0}% 的用量,可能与大规模重构或新任务启动相关。`
219
+ });
220
+ }
221
+
222
+ // 2. Tool cost efficiency
223
+ const byTool = aggregateBy(daily, 'source').sort((a, b) => b.costUSD - a.costUSD);
224
+ if (byTool.length >= 2 && byTool[0].costUSD > 0 && byTool[1].costUSD > 0) {
225
+ const a = byTool[0], b = byTool[1];
226
+ const aCostPerM = a.totalTokens ? (a.costUSD / a.totalTokens) * 1e6 : 0;
227
+ const bCostPerM = b.totalTokens ? (b.costUSD / b.totalTokens) * 1e6 : 0;
228
+ if (aCostPerM > bCostPerM * 1.6) {
229
+ const ratio = (aCostPerM / bCostPerM).toFixed(1);
230
+ insights.push({
231
+ kind: 'yellow',
232
+ emoji: '💰',
233
+ headline: `${a.key} 单位 token 官方价是 ${b.key} 的 ${ratio} 倍`,
234
+ detail: [
235
+ { k: `${a.key} $/百万 tk`, v: U.fmtUS.format(aCostPerM) },
236
+ { k: `${b.key} $/百万 tk`, v: U.fmtUS.format(bCostPerM) },
237
+ { k: '官方价差距', v: U.fmtUS.format(a.costUSD - b.costUSD) }
238
+ ],
239
+ narrative: `如果将 ${a.key} 中的部分工作迁移到 ${b.key},理论上能省下约 ${U.fmtUS.format((a.costUSD - a.totalTokens / 1e6 * bCostPerM))}。但要权衡场景适配。`
240
+ });
241
+ }
242
+ }
243
+
244
+ // 3. Cache hit improvement
245
+ if (prevDaily && prevDaily.length) {
246
+ const prevCache = sumField(prevDaily, 'cacheReadTokens');
247
+ const prevTotal = sumField(prevDaily, 'totalTokens');
248
+ const prevRate = prevTotal ? (prevCache / prevTotal) * 100 : 0;
249
+ const currRate = totals.total ? (totals.cache / totals.total) * 100 : 0;
250
+ const diff = currRate - prevRate;
251
+ if (Math.abs(diff) > 2) {
252
+ insights.push({
253
+ kind: diff > 0 ? 'green' : 'yellow',
254
+ emoji: diff > 0 ? '🌱' : '⚠️',
255
+ headline: `Cache 命中率${diff > 0 ? '提升' : '下降'} ${Math.abs(diff).toFixed(1)} 个百分点`,
256
+ detail: [
257
+ { k: '本期', v: `${currRate.toFixed(1)}%` },
258
+ { k: '上期', v: `${prevRate.toFixed(1)}%` },
259
+ { k: '节省 tk', v: U.compactCN(totals.cache - prevCache) }
260
+ ],
261
+ narrative: diff > 0
262
+ ? `更高的命中率意味着你在同一上下文里反复迭代,工作连续性更好。继续保持。`
263
+ : `命中率下降可能是因为切换项目或重启上下文更频繁,看看能否合并任务批次。`
264
+ });
265
+ }
266
+ }
267
+
268
+ // 4. Highest-growth project
269
+ if (prevDaily && prevDaily.length) {
270
+ const currByProj = aggregateBy(daily, 'projectPath');
271
+ const prevByProj = aggregateBy(prevDaily, 'projectPath');
272
+ const prevMap = new Map(prevByProj.map(p => [p.key, p.totalTokens]));
273
+ const ranked = currByProj
274
+ .map(p => {
275
+ const prev = prevMap.get(p.key) || 0;
276
+ const delta = prev ? ((p.totalTokens - prev) / prev) * 100 : null;
277
+ return { ...p, prevTokens: prev, delta };
278
+ })
279
+ .filter(p => p.delta != null && p.delta > 50 && p.totalTokens > 100000)
280
+ .sort((a, b) => b.delta - a.delta);
281
+
282
+ if (ranked.length) {
283
+ const top = ranked[0];
284
+ insights.push({
285
+ kind: 'blue',
286
+ emoji: '🚀',
287
+ headline: `${top.key} 用量上升 ${top.delta.toFixed(0)}%,是你最近的主战场`,
288
+ detail: [
289
+ { k: '本期', v: U.compactCN(top.totalTokens) },
290
+ { k: '上期', v: U.compactCN(top.prevTokens) },
291
+ { k: '增幅', v: `+${top.delta.toFixed(0)}%` }
292
+ ],
293
+ narrative: `该项目本期消耗集中在 ${top.dayCount} 个活跃天,平均每天 ${U.compactCN(Math.round(top.totalTokens / Math.max(1, top.dayCount)))} tokens。`
294
+ });
295
+ }
296
+ }
297
+
298
+ return insights.slice(0, 4);
299
+ }
300
+
301
+ // Heat color scale (warm)
302
+ function heatColor(t) {
303
+ if (t < 0.02) return 'oklch(0.94 0.005 80)';
304
+ const lightness = 0.90 - t * 0.50;
305
+ const chroma = 0.02 + t * 0.18;
306
+ return `oklch(${lightness} ${chroma} 265)`;
307
+ }
308
+
309
+ // build month grid (return array of weeks with day cells)
310
+ function buildMonthGrid(year, month, dayMap) {
311
+ // month is 0-indexed
312
+ const firstDay = new Date(year, month, 1);
313
+ const lastDay = new Date(year, month + 1, 0);
314
+ const startCol = firstDay.getDay(); // 0=Sun
315
+ const totalDays = lastDay.getDate();
316
+ const cells = [];
317
+ for (let i = 0; i < startCol; i++) cells.push(null);
318
+ for (let d = 1; d <= totalDays; d++) {
319
+ const ds = localDateStr(new Date(year, month, d));
320
+ cells.push({ date: ds, day: d, ...(dayMap.get(ds) || { total: 0, cost: 0, byTool: {} }) });
321
+ }
322
+ // pad to multiple of 7
323
+ while (cells.length % 7 !== 0) cells.push(null);
324
+ return cells;
325
+ }
326
+
327
+ // list months that intersect a period (parse via local-date helper, not Date constructor)
328
+ function monthsInPeriod(period) {
329
+ const a = parseDateStr(period.start);
330
+ const b = parseDateStr(period.end);
331
+ const months = [];
332
+ let y = a.y, m = a.m;
333
+ while (y < b.y || (y === b.y && m <= b.m)) {
334
+ months.push({ year: y, month: m });
335
+ m++; if (m > 11) { m = 0; y++; }
336
+ }
337
+ return months;
338
+ }
339
+
340
+ export const RU = {
341
+ PERIOD_LABELS, getPeriod, filterByPeriod, sumField,
342
+ aggregateBy, topModelFor, dailyTotals, narrativeForProjects, findPeaks,
343
+ buildInsights, heatColor, buildMonthGrid, monthsInPeriod,
344
+ localDateStr, parseDateStr
345
+ };
@@ -0,0 +1,236 @@
1
+ /* =============================================================
2
+ Shared helpers, formatters, and aggregations
3
+ ============================================================= */
4
+
5
+ const PALETTE = {
6
+ // Claude family → indigo
7
+ 'Claude Code': 'oklch(0.55 0.16 265)',
8
+ 'Claude Code (JS)': 'oklch(0.55 0.16 265)',
9
+ // Codex / OpenAI → violet
10
+ 'Codex CLI': 'oklch(0.60 0.15 295)',
11
+ 'Codex CLI (JS)': 'oklch(0.60 0.15 295)',
12
+ // Hermes → blue
13
+ 'Hermes Agent': 'oklch(0.58 0.14 240)',
14
+ 'Hermes Agent (JS)': 'oklch(0.58 0.14 240)',
15
+ // OpenClaw → teal
16
+ 'OpenClaw': 'oklch(0.65 0.11 200)',
17
+ 'OpenClaw (JS)': 'oklch(0.65 0.11 200)',
18
+ 'openclaw, hermes': 'oklch(0.65 0.11 200)',
19
+ // OpenCode → cyan
20
+ 'OpenCode': 'oklch(0.62 0.12 195)',
21
+ // Gemini → amber
22
+ 'Gemini CLI': 'oklch(0.72 0.14 75)',
23
+ 'Gemini CLI (JS)': 'oklch(0.72 0.14 75)',
24
+ // Cursor → sky
25
+ 'Cursor': 'oklch(0.68 0.12 220)',
26
+ // Aider → green
27
+ 'Aider': 'oklch(0.65 0.13 155)',
28
+ // Amp → rose
29
+ 'Amp': 'oklch(0.62 0.16 20)',
30
+ // pi-agent → pink
31
+ 'pi-agent': 'oklch(0.63 0.14 330)',
32
+ };
33
+
34
+ const PALETTE_FALLBACK = [
35
+ 'oklch(0.55 0.16 265)', 'oklch(0.60 0.15 295)', 'oklch(0.65 0.11 200)',
36
+ 'oklch(0.72 0.14 75)', 'oklch(0.65 0.12 150)', 'oklch(0.62 0.16 20)',
37
+ 'oklch(0.58 0.14 240)', 'oklch(0.63 0.14 330)', 'oklch(0.68 0.12 220)',
38
+ ];
39
+
40
+ // Deterministic color for any source name (even future ones not in PALETTE)
41
+ function getSourceColor(name) {
42
+ if (!name) return 'var(--muted)';
43
+ if (PALETTE[name]) return PALETTE[name];
44
+ // Hash the name to pick a consistent fallback color
45
+ let h = 0;
46
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
47
+ return PALETTE_FALLBACK[h % PALETTE_FALLBACK.length];
48
+ }
49
+
50
+ const fmt = new Intl.NumberFormat('zh-CN');
51
+ const fmtUS = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
52
+ const fmtUS4 = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4 });
53
+
54
+ function compact(v) {
55
+ if (v == null) return '—';
56
+ const a = Math.abs(v);
57
+ if (a >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
58
+ if (a >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
59
+ if (a >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
60
+ return fmt.format(v);
61
+ }
62
+
63
+ function compactCN(v) {
64
+ if (v == null) return '—';
65
+ const a = Math.abs(v);
66
+ if (a >= 1e8) return (v / 1e8).toFixed(2).replace(/\.?0+$/, '') + ' 亿';
67
+ if (a >= 1e4) return (v / 1e4).toFixed(1).replace(/\.0$/, '') + ' 万';
68
+ return fmt.format(v);
69
+ }
70
+
71
+ function pct(num, den) {
72
+ if (!num || !den) return 0;
73
+ return (num / den) * 100;
74
+ }
75
+
76
+ function deltaPct(curr, prev) {
77
+ if (prev == null || prev === 0) return null;
78
+ return ((curr - prev) / prev) * 100;
79
+ }
80
+
81
+ function formatTs(v) {
82
+ if (!v) return '—';
83
+ const text = String(v).trim();
84
+ const normalized = text.includes('T') ? text : text.replace(' ', 'T');
85
+ const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized);
86
+ const value = new Date(hasZone ? normalized : `${normalized}Z`);
87
+ if (Number.isNaN(value.getTime())) return text.replace('T', ' ').slice(0, 16);
88
+
89
+ const parts = new Intl.DateTimeFormat('zh-CN', {
90
+ year: 'numeric',
91
+ month: '2-digit',
92
+ day: '2-digit',
93
+ hour: '2-digit',
94
+ minute: '2-digit',
95
+ hour12: false
96
+ }).formatToParts(value);
97
+ const get = type => parts.find(part => part.type === type)?.value || '';
98
+ return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`;
99
+ }
100
+
101
+ function localDateStr(date) {
102
+ return [
103
+ date.getFullYear(),
104
+ String(date.getMonth() + 1).padStart(2, '0'),
105
+ String(date.getDate()).padStart(2, '0')
106
+ ].join('-');
107
+ }
108
+
109
+ function parseLocalDate(value) {
110
+ const [year, month, day] = String(value || '').split('-').map(Number);
111
+ return new Date(year, (month || 1) - 1, day || 1);
112
+ }
113
+
114
+ function daysAgo(n) {
115
+ const d = new Date();
116
+ d.setHours(0, 0, 0, 0);
117
+ d.setDate(d.getDate() - n);
118
+ return localDateStr(d);
119
+ }
120
+
121
+ function addDays(dateStr, days) {
122
+ const d = parseLocalDate(dateStr);
123
+ d.setDate(d.getDate() + days);
124
+ return localDateStr(d);
125
+ }
126
+
127
+ function rangeDates(startStr, endStr) {
128
+ const out = [];
129
+ const s = parseLocalDate(startStr), e = parseLocalDate(endStr);
130
+ for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
131
+ out.push(localDateStr(d));
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // Apply filters to daily rows
137
+ function filterDaily(rows, f) {
138
+ return rows.filter(r =>
139
+ r.usageDate >= f.startDate && r.usageDate <= f.endDate &&
140
+ (f.sources.size === 0 || f.sources.has(r.source)) &&
141
+ (f.devices.size === 0 || f.devices.has(r.device)) &&
142
+ (f.models.size === 0 || f.models.has(r.model))
143
+ );
144
+ }
145
+
146
+ // Aggregate totals across rows
147
+ function aggregateTotals(rows) {
148
+ let total = 0, inp = 0, out = 0, cacheRd = 0, cacheCr = 0, reason = 0, cost = 0;
149
+ for (const r of rows) {
150
+ total += r.totalTokens;
151
+ inp += r.inputTokens;
152
+ out += r.outputTokens;
153
+ cacheRd += r.cacheReadTokens;
154
+ cacheCr += r.cacheCreationTokens;
155
+ reason += r.reasoningOutputTokens;
156
+ cost += r.costUSD;
157
+ }
158
+ return {
159
+ totalTokens: total,
160
+ inputTokens: inp,
161
+ outputTokens: out,
162
+ cacheReadTokens: cacheRd,
163
+ cacheCreationTokens: cacheCr,
164
+ cacheTokens: cacheRd + cacheCr,
165
+ reasoningTokens: reason,
166
+ costUSD: cost,
167
+ cacheHitRate: total ? (cacheRd / total) * 100 : 0
168
+ };
169
+ }
170
+
171
+ // Group by date + dimension
172
+ function groupByDate(rows, dim = 'source') {
173
+ const map = new Map(); // date -> {dim -> total}
174
+ for (const r of rows) {
175
+ const d = r.usageDate;
176
+ if (!map.has(d)) map.set(d, {});
177
+ const k = r[dim];
178
+ map.get(d)[k] = (map.get(d)[k] || 0) + r.totalTokens;
179
+ }
180
+ return map;
181
+ }
182
+
183
+ function uniqueValues(rows, field) {
184
+ const s = new Set();
185
+ for (const r of rows) if (r[field]) s.add(r[field]);
186
+ return Array.from(s).sort();
187
+ }
188
+
189
+ // CSV download
190
+ function csvCell(value) {
191
+ const text = value == null ? '' : String(value);
192
+ const safe = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
193
+ return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
194
+ }
195
+
196
+ function downloadCSV(filename, rows, columns) {
197
+ const header = columns.map(c => csvCell(c.title)).join(',');
198
+ const body = rows.map(r =>
199
+ columns.map(c => {
200
+ const v = typeof c.value === 'function' ? c.value(r) : r[c.field];
201
+ return csvCell(v);
202
+ }).join(',')
203
+ ).join('\n');
204
+ const blob = new Blob([header + '\n' + body], { type: 'text/csv;charset=utf-8' });
205
+ const url = URL.createObjectURL(blob);
206
+ const a = document.createElement('a');
207
+ a.href = url; a.download = filename; a.click();
208
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
209
+ }
210
+
211
+ function downloadText(filename, text, type = 'text/plain;charset=utf-8') {
212
+ const blob = new Blob([text], { type });
213
+ const url = URL.createObjectURL(blob);
214
+ const a = document.createElement('a');
215
+ a.href = url; a.download = filename; a.click();
216
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
217
+ }
218
+
219
+ // Project path label from sessionId
220
+ function projectLabel(s) {
221
+ return s.projectPath || s.sessionId;
222
+ }
223
+
224
+ // Mix two oklch colors by t via color-mix string
225
+ function alpha(color, a) {
226
+ return `color-mix(in oklab, ${color}, transparent ${100 - a * 100}%)`;
227
+ }
228
+
229
+ export const U = {
230
+ PALETTE, PALETTE_FALLBACK, getSourceColor,
231
+ fmt, fmtUS, fmtUS4,
232
+ compact, compactCN, pct, deltaPct, formatTs,
233
+ localDateStr, daysAgo, addDays, rangeDates,
234
+ filterDaily, aggregateTotals, groupByDate, uniqueValues,
235
+ csvCell, downloadCSV, downloadText, projectLabel, alpha
236
+ };