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,415 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { hostname } from 'node:os';
3
+ import { calculateOfficialCost } from './pricing.mjs';
4
+ import { recordRun, upsertDaily, upsertSession, upsertTokenEvent } from './db.mjs';
5
+
6
+ const UNSAFE_KEYS = new Set([
7
+ 'prompt',
8
+ 'response',
9
+ 'messages',
10
+ 'message',
11
+ 'transcript',
12
+ 'conversation',
13
+ 'diff',
14
+ 'patch',
15
+ 'content',
16
+ 'text'
17
+ ]);
18
+
19
+ export function readCcusageImportInput(file) {
20
+ if (!file || file === '-') {
21
+ return readFileSync(0, 'utf8');
22
+ }
23
+ return readFileSync(file, 'utf8');
24
+ }
25
+
26
+ export function parseCcusageJsonText(text) {
27
+ let payload;
28
+ try {
29
+ payload = JSON.parse(String(text || ''));
30
+ } catch (error) {
31
+ throw new Error(`Invalid ccusage JSON: ${error.message}`);
32
+ }
33
+ const unsafePath = firstUnsafeKeyPath(payload);
34
+ if (unsafePath) {
35
+ throw new Error(`ccusage JSON contains conversation-like field: ${unsafePath}`);
36
+ }
37
+ return payload;
38
+ }
39
+
40
+ export function planCcusageImport(payload, options = {}) {
41
+ const device = cleanText(options.device, 120) || hostname();
42
+ const now = options.now || new Date();
43
+ const importSource = cleanText(options.importSource, 80) || 'import:ccusage-json';
44
+ const command = cleanText(options.command, 240) || 'import-usage --format=ccusage-json';
45
+ const toolCategory = cleanText(options.toolCategory, 80) || importSource;
46
+ const detectedShape = detectShape(payload);
47
+ const rows = extractUsageRows(payload, detectedShape);
48
+ if (!rows.length) {
49
+ throw new Error('No supported ccusage usage rows found');
50
+ }
51
+
52
+ const dailyByKey = new Map();
53
+ const sessionsByKey = new Map();
54
+ const eventsByKey = new Map();
55
+ const warnings = [];
56
+
57
+ for (const row of rows) {
58
+ const parts = expandModelBreakdowns(row);
59
+ for (const part of parts) {
60
+ const source = sourceFromRow(part);
61
+ const usageDate = usageDateFromRow(part);
62
+ const timestamp = timestampFromRow(part, usageDate, now);
63
+ const model = cleanText(part.model, 160) || '<unknown>';
64
+ const projectPath = cleanText(part.projectPath || part.project || part.projectName, 240) || null;
65
+ const sessionId = sessionIdFromRow(part, detectedShape, usageDate, model, projectPath);
66
+ const tokens = tokenFields(part);
67
+ const cost = calculateOfficialCost(model, {
68
+ input: tokens.inputTokens,
69
+ output: tokens.outputTokens,
70
+ cacheRead: tokens.cacheReadTokens,
71
+ cacheWrite: tokens.cacheCreationTokens,
72
+ reasoning: tokens.reasoningOutputTokens
73
+ }, { provider: providerFromSource(source) });
74
+
75
+ if (!cost.priced && number(part.costUSD ?? part.totalCost) > 0) {
76
+ warnings.push({
77
+ type: 'ignored-imported-cost',
78
+ model,
79
+ reason: 'ccusage cost was present but Token Studio keeps official-price conversion only.'
80
+ });
81
+ }
82
+
83
+ const usageRow = {
84
+ device,
85
+ source,
86
+ usageDate,
87
+ model,
88
+ ...tokens,
89
+ totalTokens: tokens.totalTokens,
90
+ costUSD: cost.totalUSD
91
+ };
92
+ const dailyKey = [usageRow.device, usageRow.source, usageRow.usageDate, usageRow.model].join('::');
93
+ mergeUsageRow(dailyByKey, dailyKey, usageRow);
94
+
95
+ const sessionRow = {
96
+ device,
97
+ source,
98
+ sessionId,
99
+ lastActivity: timestamp,
100
+ projectPath,
101
+ ...tokens,
102
+ totalTokens: tokens.totalTokens,
103
+ costUSD: cost.totalUSD
104
+ };
105
+ const sessionKey = [sessionRow.device, sessionRow.source, sessionRow.sessionId].join('::');
106
+ mergeSessionRow(sessionsByKey, sessionKey, sessionRow);
107
+
108
+ const eventRow = {
109
+ eventId: eventIdFor({ detectedShape, source, usageDate, sessionId, model, timestamp }),
110
+ device,
111
+ source,
112
+ sessionId,
113
+ timestamp,
114
+ model,
115
+ inputTokens: tokens.inputTokens,
116
+ outputTokens: tokens.outputTokens,
117
+ cacheReadTokens: tokens.cacheReadTokens,
118
+ cacheCreationTokens: tokens.cacheCreationTokens,
119
+ reasoningTokens: tokens.reasoningOutputTokens,
120
+ toolCategory,
121
+ privacyLevel: 'safe'
122
+ };
123
+ eventsByKey.set(eventRow.eventId, eventRow);
124
+ }
125
+ }
126
+
127
+ return {
128
+ detectedShape,
129
+ device,
130
+ daily: [...dailyByKey.values()],
131
+ sessions: [...sessionsByKey.values()],
132
+ tokenEvents: [...eventsByKey.values()],
133
+ warnings: dedupeWarnings(warnings),
134
+ run: {
135
+ device,
136
+ source: importSource,
137
+ status: 'ok',
138
+ message: `shape=${detectedShape}, daily=${dailyByKey.size}, sessions=${sessionsByKey.size}, token_events=${eventsByKey.size}`,
139
+ collectedAt: new Date(now).toISOString(),
140
+ command
141
+ }
142
+ };
143
+ }
144
+
145
+ export function applyCcusageImport(db, plan) {
146
+ db.exec('BEGIN');
147
+ try {
148
+ for (const row of plan.daily) upsertDaily(db, row);
149
+ for (const row of plan.sessions) upsertSession(db, row);
150
+ for (const row of plan.tokenEvents) upsertTokenEvent(db, row);
151
+ recordRun(db, plan.run);
152
+ db.exec('COMMIT');
153
+ } catch (error) {
154
+ db.exec('ROLLBACK');
155
+ throw error;
156
+ }
157
+ return {
158
+ daily: plan.daily.length,
159
+ sessions: plan.sessions.length,
160
+ tokenEvents: plan.tokenEvents.length,
161
+ warnings: plan.warnings.length
162
+ };
163
+ }
164
+
165
+ function detectShape(payload) {
166
+ if (Array.isArray(payload?.daily)) return 'daily';
167
+ if (payload?.projects && typeof payload.projects === 'object' && !Array.isArray(payload.projects)) return 'project-daily';
168
+ if (Array.isArray(payload?.data) && payload.type) {
169
+ const type = String(payload.type).toLowerCase();
170
+ if (['daily', 'weekly', 'session', 'blocks', 'monthly'].includes(type)) return type;
171
+ }
172
+ throw new Error('Unsupported ccusage JSON shape. Expected daily, project daily, weekly, session, blocks, or monthly output.');
173
+ }
174
+
175
+ function extractUsageRows(payload, shape) {
176
+ if (shape === 'daily') return payload.daily.map(row => ({ ...row }));
177
+ if (shape === 'project-daily') {
178
+ const rows = [];
179
+ for (const [project, entries] of Object.entries(payload.projects || {})) {
180
+ if (!Array.isArray(entries)) continue;
181
+ for (const row of entries) rows.push({ ...row, projectPath: project });
182
+ }
183
+ return rows;
184
+ }
185
+ return (payload.data || []).map(row => ({ ...row }));
186
+ }
187
+
188
+ function expandModelBreakdowns(row) {
189
+ const breakdown = row.modelBreakdowns || row.modelBreakdown || row.breakdowns;
190
+ if (!breakdown) {
191
+ return [{ ...row, model: primaryModel(row) }];
192
+ }
193
+
194
+ if (Array.isArray(breakdown)) {
195
+ const usable = breakdown.filter(item => item && typeof item === 'object' && hasTokenField(item));
196
+ if (!usable.length) return [{ ...row, model: primaryModel(row) }];
197
+ return usable.map((item, index) => ({
198
+ ...row,
199
+ ...item,
200
+ model: item.model || item.modelName || primaryModel(row, index)
201
+ }));
202
+ }
203
+
204
+ if (typeof breakdown === 'object') {
205
+ const usable = Object.entries(breakdown)
206
+ .filter(([, item]) => item && typeof item === 'object' && hasTokenField(item));
207
+ if (!usable.length) return [{ ...row, model: primaryModel(row) }];
208
+ return usable.map(([model, item]) => ({
209
+ ...row,
210
+ ...item,
211
+ model
212
+ }));
213
+ }
214
+
215
+ return [{ ...row, model: primaryModel(row) }];
216
+ }
217
+
218
+ function primaryModel(row, index = 0) {
219
+ const models = row.modelsUsed || row.models || row.model;
220
+ if (Array.isArray(models)) return models[index] || models[0] || '<unknown>';
221
+ return models || row.modelName || '<unknown>';
222
+ }
223
+
224
+ function tokenFields(row) {
225
+ const inputTokens = integer(row.inputTokens ?? row.input_tokens ?? row.input);
226
+ const outputTokens = integer(row.outputTokens ?? row.output_tokens ?? row.output);
227
+ const cacheCreationTokens = integer(
228
+ row.cacheCreationTokens
229
+ ?? row.cacheCreationInputTokens
230
+ ?? row.cache_creation_tokens
231
+ ?? row.cacheWriteTokens
232
+ );
233
+ const cacheReadTokens = integer(
234
+ row.cacheReadTokens
235
+ ?? row.cacheReadInputTokens
236
+ ?? row.cache_read_tokens
237
+ ?? row.cachedInputTokens
238
+ );
239
+ const reasoningOutputTokens = integer(row.reasoningTokens ?? row.reasoningOutputTokens ?? row.reasoning_output_tokens);
240
+ const explicitTotal = integer(row.totalTokens ?? row.total_tokens);
241
+ const computedTotal = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + reasoningOutputTokens;
242
+ return {
243
+ inputTokens,
244
+ outputTokens,
245
+ cacheCreationTokens,
246
+ cacheReadTokens,
247
+ cachedInputTokens: 0,
248
+ reasoningOutputTokens,
249
+ totalTokens: explicitTotal || computedTotal
250
+ };
251
+ }
252
+
253
+ function hasTokenField(row) {
254
+ return [
255
+ 'inputTokens',
256
+ 'input_tokens',
257
+ 'outputTokens',
258
+ 'output_tokens',
259
+ 'cacheCreationTokens',
260
+ 'cacheCreationInputTokens',
261
+ 'cacheReadTokens',
262
+ 'cacheReadInputTokens',
263
+ 'reasoningTokens',
264
+ 'totalTokens',
265
+ 'total_tokens'
266
+ ].some(key => row[key] != null);
267
+ }
268
+
269
+ function sourceFromRow(row) {
270
+ return cleanText(row.source || row.tool || row.instance || row.provider, 80) || 'ccusage';
271
+ }
272
+
273
+ function usageDateFromRow(row) {
274
+ const raw = row.date || row.usageDate || row.week || row.weekStart || row.startDate || row.month || row.blockStart || row.firstActivity || row.lastActivity;
275
+ const date = parseDate(raw);
276
+ if (!date) throw new Error('ccusage row is missing a usable date/month/activity field');
277
+ return formatDate(date);
278
+ }
279
+
280
+ function timestampFromRow(row, usageDate, now) {
281
+ const raw = row.lastActivity || row.blockEnd || row.firstActivity || row.blockStart || row.date || row.week || row.weekStart || row.startDate || row.month;
282
+ const date = parseDate(raw) || parseDate(usageDate) || new Date(now);
283
+ return date.toISOString();
284
+ }
285
+
286
+ function sessionIdFromRow(row, shape, usageDate, model, projectPath) {
287
+ const raw = row.session || row.sessionId || row.session_id || row.id || null;
288
+ if (raw) return cleanText(raw, 240);
289
+ const project = projectPath ? hashable(projectPath) : 'all';
290
+ return `ccusage:${shape}:${project}:${usageDate}:${hashable(model)}`;
291
+ }
292
+
293
+ function eventIdFor({ detectedShape, source, usageDate, sessionId, model, timestamp }) {
294
+ return [
295
+ 'ccusage',
296
+ detectedShape,
297
+ hashable(source),
298
+ usageDate,
299
+ hashable(sessionId),
300
+ hashable(model),
301
+ timestamp
302
+ ].join(':');
303
+ }
304
+
305
+ function mergeUsageRow(map, key, row) {
306
+ const existing = map.get(key);
307
+ if (!existing) {
308
+ map.set(key, { ...row });
309
+ return;
310
+ }
311
+ addTokenFields(existing, row);
312
+ existing.costUSD += row.costUSD || 0;
313
+ }
314
+
315
+ function mergeSessionRow(map, key, row) {
316
+ const existing = map.get(key);
317
+ if (!existing) {
318
+ map.set(key, { ...row });
319
+ return;
320
+ }
321
+ addTokenFields(existing, row);
322
+ existing.costUSD += row.costUSD || 0;
323
+ if (row.lastActivity && (!existing.lastActivity || row.lastActivity > existing.lastActivity)) {
324
+ existing.lastActivity = row.lastActivity;
325
+ }
326
+ if (!existing.projectPath && row.projectPath) existing.projectPath = row.projectPath;
327
+ }
328
+
329
+ function addTokenFields(target, row) {
330
+ target.inputTokens += row.inputTokens || 0;
331
+ target.outputTokens += row.outputTokens || 0;
332
+ target.cacheCreationTokens += row.cacheCreationTokens || 0;
333
+ target.cacheReadTokens += row.cacheReadTokens || 0;
334
+ target.cachedInputTokens += row.cachedInputTokens || 0;
335
+ target.reasoningOutputTokens += row.reasoningOutputTokens || 0;
336
+ target.totalTokens += row.totalTokens || 0;
337
+ }
338
+
339
+ function firstUnsafeKeyPath(value, path = '$') {
340
+ if (Array.isArray(value)) {
341
+ for (let index = 0; index < value.length; index += 1) {
342
+ const found = firstUnsafeKeyPath(value[index], `${path}[${index}]`);
343
+ if (found) return found;
344
+ }
345
+ return null;
346
+ }
347
+ if (!value || typeof value !== 'object') return null;
348
+ for (const [key, child] of Object.entries(value)) {
349
+ if (UNSAFE_KEYS.has(String(key).toLowerCase())) return `${path}.${key}`;
350
+ const found = firstUnsafeKeyPath(child, `${path}.${key}`);
351
+ if (found) return found;
352
+ }
353
+ return null;
354
+ }
355
+
356
+ function parseDate(value) {
357
+ const text = String(value || '').trim();
358
+ if (!text) return null;
359
+ const normalized = /^\d{4}-\d{2}$/.test(text) ? `${text}-01` : text;
360
+ const date = new Date(normalized);
361
+ return Number.isNaN(date.getTime()) ? null : date;
362
+ }
363
+
364
+ function formatDate(date) {
365
+ return [
366
+ date.getUTCFullYear(),
367
+ String(date.getUTCMonth() + 1).padStart(2, '0'),
368
+ String(date.getUTCDate()).padStart(2, '0')
369
+ ].join('-');
370
+ }
371
+
372
+ function cleanText(value, maxLength) {
373
+ const text = String(value ?? '').trim();
374
+ if (!text) return '';
375
+ return text.length > maxLength ? text.slice(0, maxLength) : text;
376
+ }
377
+
378
+ function integer(value) {
379
+ const number = Number(value || 0);
380
+ if (!Number.isFinite(number) || number < 0) return 0;
381
+ return Math.round(number);
382
+ }
383
+
384
+ function number(value) {
385
+ const n = Number(value || 0);
386
+ return Number.isFinite(n) && n > 0 ? n : 0;
387
+ }
388
+
389
+ function hashable(value) {
390
+ return String(value || '')
391
+ .trim()
392
+ .toLowerCase()
393
+ .replace(/[^a-z0-9_.-]+/g, '-')
394
+ .replace(/^-+|-+$/g, '')
395
+ .slice(0, 80) || 'unknown';
396
+ }
397
+
398
+ function providerFromSource(source) {
399
+ const value = String(source || '').toLowerCase();
400
+ if (value.includes('codex') || value.includes('openai')) return 'openai';
401
+ if (value.includes('claude') || value.includes('anthropic')) return 'anthropic';
402
+ if (value.includes('deepseek')) return 'deepseek';
403
+ if (value.includes('mimo') || value.includes('xiaomi')) return 'xiaomi';
404
+ return null;
405
+ }
406
+
407
+ function dedupeWarnings(warnings) {
408
+ const seen = new Set();
409
+ return warnings.filter(warning => {
410
+ const key = `${warning.type}:${warning.model}:${warning.reason}`;
411
+ if (seen.has(key)) return false;
412
+ seen.add(key);
413
+ return true;
414
+ });
415
+ }