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,349 @@
1
+ /**
2
+ * OpenCode data collector (pure JS).
3
+ *
4
+ * Reads the local OpenCode usage stores:
5
+ * ~/.local/share/opencode/opencode*.db — OpenCode 1.2+ SQLite
6
+ * ~/.local/share/opencode/storage/message/.../*.json — legacy JSON messages
7
+ */
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { readdir, readFile } from 'node:fs/promises';
11
+ import { homedir } from 'node:os';
12
+ import { basename, extname, join } from 'node:path';
13
+ import { DatabaseSync } from 'node:sqlite';
14
+ import { configuredPath, configuredPaths, expandPath } from '../collector-config.mjs';
15
+ import { calculateCost } from '../pricing.mjs';
16
+ import { canonicalProvider, inferProviderFromModel, localDateFromTimestamp, normalizeModelForGrouping } from './utils.mjs';
17
+
18
+ export const CLIENT_KEY = 'opencode';
19
+ export const SOURCE_LABEL = 'OpenCode';
20
+
21
+ function opencodeDataDir() {
22
+ return configuredPath(
23
+ 'opencode',
24
+ 'dataDir',
25
+ `${homedir()}/.local/share/opencode`
26
+ );
27
+ }
28
+
29
+ function legacyMessageDir() {
30
+ const dataDir = opencodeDataDir();
31
+ if (!dataDir) return null;
32
+ return join(dataDir, 'storage', 'message');
33
+ }
34
+
35
+ function isOpenCodeDbFilename(name) {
36
+ if (extname(name) !== '.db') return false;
37
+ const stem = basename(name, '.db');
38
+ if (stem === 'opencode') return true;
39
+ const channel = stem.startsWith('opencode-') ? stem.slice('opencode-'.length) : '';
40
+ return channel.length > 0 && /^[A-Za-z0-9._-]+$/.test(channel);
41
+ }
42
+
43
+ async function discoverDbPaths() {
44
+ const dataDir = opencodeDataDir();
45
+ const paths = [];
46
+ for (const entry of await safeReaddir(dataDir)) {
47
+ if (entry.isFile() && isOpenCodeDbFilename(entry.name)) {
48
+ paths.push(join(dataDir, entry.name));
49
+ }
50
+ }
51
+
52
+ const explicit = String(process.env.OPENCODE_DB || '').trim();
53
+ const explicitPath = expandPath(explicit);
54
+ if (explicitPath && existsSync(explicitPath) && isOpenCodeDbFilename(basename(explicitPath))) {
55
+ paths.push(explicitPath);
56
+ }
57
+
58
+ for (const extraPath of configuredPaths('opencode', 'extraDbPaths')) {
59
+ if (existsSync(extraPath) && isOpenCodeDbFilename(basename(extraPath))) {
60
+ paths.push(extraPath);
61
+ }
62
+ }
63
+
64
+ return [...new Set(paths)].sort();
65
+ }
66
+
67
+ async function collectJsonFiles(dir) {
68
+ const results = [];
69
+ for (const entry of await safeReaddir(dir)) {
70
+ const full = join(dir, entry.name);
71
+ if (entry.isDirectory()) {
72
+ results.push(...await collectJsonFiles(full));
73
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
74
+ results.push(full);
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+
80
+ async function safeReaddir(dir) {
81
+ try {
82
+ return await readdir(dir, { withFileTypes: true });
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ async function safeReadJson(filePath) {
89
+ try {
90
+ return JSON.parse(await readFile(filePath, 'utf8'));
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function pos(v) {
97
+ const n = Number(v ?? 0);
98
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
99
+ }
100
+
101
+ function posFloat(v) {
102
+ const n = Number(v ?? 0);
103
+ return Number.isFinite(n) && n > 0 ? n : 0;
104
+ }
105
+
106
+ function zero() {
107
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
108
+ }
109
+
110
+ function addInto(agg, t) {
111
+ agg.input += t.input;
112
+ agg.output += t.output;
113
+ agg.cacheRead += t.cacheRead;
114
+ agg.cacheWrite += t.cacheWrite;
115
+ agg.reasoning += t.reasoning;
116
+ }
117
+
118
+ function workspaceLabel(raw) {
119
+ if (!raw) return null;
120
+ const normalized = String(raw).replace(/\\/g, '/').replace(/\/+$/, '');
121
+ return normalized.split('/').filter(Boolean).pop() || raw;
122
+ }
123
+
124
+ function tokensFromMessage(msg) {
125
+ const tokens = msg?.tokens;
126
+ if (!tokens) return null;
127
+ return {
128
+ input: pos(tokens.input),
129
+ output: pos(tokens.output),
130
+ cacheRead: pos(tokens.cache?.read),
131
+ cacheWrite: pos(tokens.cache?.write),
132
+ reasoning: pos(tokens.reasoning)
133
+ };
134
+ }
135
+
136
+ function parseMessageObject(msg, fallbackId, fallbackSessionId, fallbackWorkspace) {
137
+ if (!msg || msg.role !== 'assistant') return null;
138
+
139
+ const tokens = tokensFromMessage(msg);
140
+ if (!tokens) return null;
141
+
142
+ const model = typeof msg.modelID === 'string' && msg.modelID.trim()
143
+ ? normalizeModelForGrouping(msg.modelID)
144
+ : null;
145
+ if (!model) return null;
146
+
147
+ const provider = canonicalProvider(msg.providerID) || inferProviderFromModel(model) || 'unknown';
148
+ const workspace = msg.path?.root || fallbackWorkspace || null;
149
+ const timestamp = Number(msg.time?.created || 0);
150
+
151
+ return {
152
+ client: CLIENT_KEY,
153
+ sessionId: msg.sessionID || fallbackSessionId || 'unknown',
154
+ dedupKey: msg.id || fallbackId || null,
155
+ fingerprint: fingerprintFor(msg, tokens, model, provider),
156
+ date: localDateFromTimestamp(timestamp, 'unknown'),
157
+ model,
158
+ provider,
159
+ workspace,
160
+ workspaceLabel: workspaceLabel(workspace),
161
+ tokens,
162
+ cost: posFloat(msg.cost),
163
+ agent: msg.mode || msg.agent || null
164
+ };
165
+ }
166
+
167
+ function fingerprintFor(msg, tokens, model, provider) {
168
+ return JSON.stringify({
169
+ created: msg.time?.created ?? null,
170
+ completed: msg.time?.completed ?? null,
171
+ model,
172
+ provider,
173
+ input: tokens.input,
174
+ output: tokens.output,
175
+ reasoning: tokens.reasoning,
176
+ cacheRead: tokens.cacheRead,
177
+ cacheWrite: tokens.cacheWrite,
178
+ cost: posFloat(msg.cost),
179
+ agent: msg.mode || msg.agent || null
180
+ });
181
+ }
182
+
183
+ function parseDbRows(dbPath) {
184
+ let db;
185
+ try {
186
+ db = new DatabaseSync(dbPath);
187
+ } catch {
188
+ return [];
189
+ }
190
+
191
+ let rows;
192
+ try {
193
+ rows = db.prepare(`
194
+ SELECT m.id, m.session_id, m.data, NULLIF(s.directory, '') AS workspace_root
195
+ FROM message m
196
+ LEFT JOIN session s ON s.id = m.session_id
197
+ WHERE json_extract(m.data, '$.role') = 'assistant'
198
+ AND json_extract(m.data, '$.tokens') IS NOT NULL
199
+ ORDER BY m.id, m.session_id
200
+ `).all();
201
+ } catch {
202
+ try {
203
+ rows = db.prepare(`
204
+ SELECT m.id, m.session_id, m.data, NULL AS workspace_root
205
+ FROM message m
206
+ WHERE json_extract(m.data, '$.role') = 'assistant'
207
+ AND json_extract(m.data, '$.tokens') IS NOT NULL
208
+ ORDER BY m.id, m.session_id
209
+ `).all();
210
+ } catch {
211
+ try { db.close(); } catch { /* ignore */ }
212
+ return [];
213
+ }
214
+ }
215
+
216
+ try { db.close(); } catch { /* ignore */ }
217
+
218
+ const messages = [];
219
+ const fingerprintIndices = new Map();
220
+
221
+ for (const row of rows) {
222
+ let msg;
223
+ try {
224
+ msg = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
225
+ } catch {
226
+ continue;
227
+ }
228
+
229
+ const parsed = parseMessageObject(msg, row.id, row.session_id, row.workspace_root);
230
+ if (!parsed) continue;
231
+
232
+ const existingIndex = fingerprintIndices.get(parsed.fingerprint);
233
+ if (existingIndex != null) {
234
+ const existing = messages[existingIndex];
235
+ if (!existing.dedupKey && parsed.dedupKey) existing.dedupKey = parsed.dedupKey;
236
+ if (!existing.workspace && parsed.workspace) {
237
+ existing.workspace = parsed.workspace;
238
+ existing.workspaceLabel = parsed.workspaceLabel;
239
+ } else if (existing.workspace && parsed.workspace && existing.workspace !== parsed.workspace) {
240
+ existing.workspace = null;
241
+ existing.workspaceLabel = null;
242
+ }
243
+ continue;
244
+ }
245
+
246
+ fingerprintIndices.set(parsed.fingerprint, messages.length);
247
+ messages.push(parsed);
248
+ }
249
+
250
+ return messages;
251
+ }
252
+
253
+ async function parseLegacyJsonFile(filePath) {
254
+ const msg = await safeReadJson(filePath);
255
+ const fallbackId = basename(filePath, '.json');
256
+ return parseMessageObject(msg, fallbackId, msg?.sessionID, msg?.path?.root);
257
+ }
258
+
259
+ export async function collect(pricingData = null) {
260
+ const dailyMap = new Map();
261
+ const wmMap = new Map();
262
+ const seen = new Set();
263
+
264
+ const addMessage = (message) => {
265
+ if (!message) return;
266
+ const dedupKey = message.dedupKey || message.fingerprint;
267
+ if (dedupKey && seen.has(dedupKey)) return;
268
+ if (dedupKey) seen.add(dedupKey);
269
+
270
+ const calculatedCost = calculateCost(message.model, message.tokens, pricingData, message.provider);
271
+ const cost = calculatedCost;
272
+
273
+ const dk = `${message.date}::${message.model}`;
274
+ if (!dailyMap.has(dk)) {
275
+ dailyMap.set(dk, { date: message.date, model: message.model, provider: message.provider, ...zero(), cost: 0 });
276
+ }
277
+ const day = dailyMap.get(dk);
278
+ addInto(day, message.tokens);
279
+ day.cost += cost;
280
+
281
+ const workspaceKey = message.workspace || message.sessionId || 'unknown';
282
+ const wmk = `${workspaceKey}::${message.model}`;
283
+ if (!wmMap.has(wmk)) {
284
+ wmMap.set(wmk, {
285
+ workspace: workspaceKey,
286
+ workspaceLabel: message.workspaceLabel || workspaceKey,
287
+ model: message.model,
288
+ provider: message.provider,
289
+ ...zero(),
290
+ cost: 0
291
+ });
292
+ }
293
+ const wm = wmMap.get(wmk);
294
+ addInto(wm, message.tokens);
295
+ wm.cost += cost;
296
+ };
297
+
298
+ for (const dbPath of await discoverDbPaths()) {
299
+ for (const message of parseDbRows(dbPath)) addMessage(message);
300
+ }
301
+
302
+ for (const jsonPath of await collectJsonFiles(legacyMessageDir())) {
303
+ addMessage(await parseLegacyJsonFile(jsonPath));
304
+ }
305
+
306
+ return buildOutput(dailyMap, wmMap);
307
+ }
308
+
309
+ function buildOutput(dailyMap, wmMap) {
310
+ const byDate = new Map();
311
+ for (const row of dailyMap.values()) {
312
+ if (!byDate.has(row.date)) byDate.set(row.date, []);
313
+ byDate.get(row.date).push(row);
314
+ }
315
+
316
+ const contributions = [...byDate.entries()]
317
+ .sort(([a], [b]) => a.localeCompare(b))
318
+ .map(([date, rows]) => ({
319
+ date,
320
+ clients: rows.map(row => ({
321
+ client: CLIENT_KEY,
322
+ modelId: row.model,
323
+ tokens: {
324
+ input: row.input,
325
+ output: row.output,
326
+ cacheRead: row.cacheRead,
327
+ cacheWrite: row.cacheWrite,
328
+ reasoning: row.reasoning
329
+ },
330
+ cost: row.cost
331
+ }))
332
+ }));
333
+
334
+ const entries = [...wmMap.values()].map(wm => ({
335
+ client: CLIENT_KEY,
336
+ workspaceKey: wm.workspace,
337
+ workspaceLabel: wm.workspaceLabel,
338
+ model: wm.model,
339
+ provider: wm.provider,
340
+ input: wm.input,
341
+ output: wm.output,
342
+ cacheRead: wm.cacheRead,
343
+ cacheWrite: wm.cacheWrite,
344
+ reasoning: wm.reasoning,
345
+ cost: wm.cost
346
+ }));
347
+
348
+ return { graphJson: { contributions }, modelsJson: { entries } };
349
+ }
@@ -0,0 +1,15 @@
1
+ import { configuredPaths, expandPath } from '../collector-config.mjs';
2
+ import { collectStructuredUsage } from './structured-usage.mjs';
3
+
4
+ export const CLIENT_KEY = 'qwen';
5
+ export const SOURCE_LABEL = 'Qwen Code';
6
+
7
+ export function roots() {
8
+ return configuredPaths('qwen', 'roots', ['~/.qwen', '~/.qwen-code'])
9
+ .map(expandPath)
10
+ .filter(Boolean);
11
+ }
12
+
13
+ export async function collect(pricingData = null) {
14
+ return collectStructuredUsage({ clientKey: CLIENT_KEY, roots: roots(), pricingData });
15
+ }