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,355 @@
1
+ /**
2
+ * Pure-JavaScript Claude Code data collector.
3
+ *
4
+ * Reads JSONL session files from the Claude Code projects directory and
5
+ * returns data in the common collector shape consumed by collect.mjs.
6
+ *
7
+ * Supported platforms: macOS, Linux, Windows — no native binaries required.
8
+ */
9
+
10
+ import { readdir, readFile } from 'node:fs/promises';
11
+ import { homedir } from 'node:os';
12
+ import { join, relative } from 'node:path';
13
+ import { configuredBool, configuredPath, configuredPaths, envPathList } from '../collector-config.mjs';
14
+ import { calculateCost } from '../pricing.mjs';
15
+ import { localDateFromTimestamp, normalizeModelForGrouping } from './utils.mjs';
16
+
17
+ export const CLIENT_KEY = 'claude';
18
+ export const SOURCE_LABEL = 'Claude Code';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Path resolution
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Return Claude Code data roots. Claude Code has used both ~/.claude and
26
+ * ~/.config/claude layouts; CLAUDE_CONFIG_DIR may contain comma-separated
27
+ * custom roots. Each root is expected to contain a projects/ directory.
28
+ */
29
+ export function getClaudeRoots() {
30
+ const envRoots = envPathList(process.env.CLAUDE_CONFIG_DIR);
31
+ if (envRoots.length) return envRoots;
32
+
33
+ return configuredPaths('claude', 'roots');
34
+ }
35
+
36
+ export async function getScanRoots() {
37
+ const envRoots = envPathList(process.env.CLAUDE_CONFIG_DIR);
38
+ const includeDesktopLocalAgent = configuredBool('claude', 'includeDesktopLocalAgent', true);
39
+ const roots = envRoots.length
40
+ ? envRoots
41
+ : [
42
+ ...getClaudeRoots(),
43
+ ...(includeDesktopLocalAgent ? await getClaudeDesktopLocalAgentRoots() : [])
44
+ ];
45
+
46
+ return unique(roots).flatMap((root) => [
47
+ { type: 'projects', path: join(root, 'projects') },
48
+ { type: 'transcripts', path: join(root, 'transcripts') }
49
+ ]);
50
+ }
51
+
52
+ async function collectJsonlFiles(dir) {
53
+ const results = [];
54
+ const entries = await safeReaddir(dir);
55
+ for (const entry of entries) {
56
+ const fullPath = join(dir, entry.name);
57
+ if (entry.isDirectory()) {
58
+ results.push(...await collectJsonlFiles(fullPath));
59
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
60
+ results.push(fullPath);
61
+ }
62
+ }
63
+ return results;
64
+ }
65
+
66
+ async function getClaudeDesktopLocalAgentRoots() {
67
+ if (process.platform !== 'darwin') return [];
68
+
69
+ const base = configuredPath(
70
+ 'claude',
71
+ 'desktopLocalAgentBase',
72
+ `${homedir()}/Library/Application Support/Claude/local-agent-mode-sessions`
73
+ );
74
+ if (!base) return [];
75
+ const sessionDirs = await collectClaudeDirs(base);
76
+ return sessionDirs.filter((dir) => /[/\\]local_[^/\\]+[/\\]\.claude$/.test(dir));
77
+ }
78
+
79
+ async function collectClaudeDirs(dir) {
80
+ const results = [];
81
+ const entries = await safeReaddir(dir);
82
+ for (const entry of entries) {
83
+ const fullPath = join(dir, entry.name);
84
+ if (!entry.isDirectory()) continue;
85
+
86
+ if (entry.name === '.claude') {
87
+ results.push(fullPath);
88
+ continue;
89
+ }
90
+
91
+ results.push(...await collectClaudeDirs(fullPath));
92
+ }
93
+ return results;
94
+ }
95
+
96
+ function unique(values) {
97
+ return [...new Set(values)];
98
+ }
99
+
100
+ /**
101
+ * Attempt to decode a project directory name into a human-readable path.
102
+ * Claude Code URL-encodes the absolute project path as the directory name,
103
+ * e.g. "%2FUsers%2Fjohn%2Fmy-project". Fall back to the raw name when
104
+ * decoding fails (older or unknown formats).
105
+ */
106
+ function decodeWorkspaceLabel(dirName) {
107
+ try {
108
+ const decoded = decodeURIComponent(dirName);
109
+ // Only use decoded form when it looks like an absolute path
110
+ if (decoded.startsWith('/') || /^[A-Za-z]:\\/.test(decoded)) {
111
+ return decoded;
112
+ }
113
+ } catch {
114
+ // ignore
115
+ }
116
+ return dirName;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // JSONL parsing
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Read one session JSONL file and return an array of assistant-turn records.
125
+ * Each record carries { timestamp, model, usage, costUSD }.
126
+ *
127
+ * Claude Code can write multiple assistant usage snapshots for the same
128
+ * streamed response. Collapse message.id+requestId duplicates, fall back to
129
+ * message.id when requestId is absent, and keep the largest token value seen
130
+ * for each field.
131
+ */
132
+ async function parseSessionFile(filePath) {
133
+ let text;
134
+ try {
135
+ text = await readFile(filePath, 'utf8');
136
+ } catch {
137
+ return [];
138
+ }
139
+
140
+ const records = [];
141
+ const dedupIndex = new Map();
142
+ for (const line of text.split('\n')) {
143
+ const trimmed = line.trim();
144
+ if (!trimmed) continue;
145
+
146
+ let obj;
147
+ try {
148
+ obj = JSON.parse(trimmed);
149
+ } catch {
150
+ continue;
151
+ }
152
+
153
+ // Only assistant turns carry usage information
154
+ if (obj.type !== 'assistant' || !obj.message?.usage) continue;
155
+
156
+ const record = {
157
+ timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : null,
158
+ model: obj.message.model || obj.model || 'unknown',
159
+ usage: obj.message.usage,
160
+ costUSD: typeof obj.costUSD === 'number' ? obj.costUSD : 0,
161
+ };
162
+
163
+ const dedupKey = dedupKeyForAssistant(obj);
164
+
165
+ if (dedupKey && dedupIndex.has(dedupKey)) {
166
+ const existing = records[dedupIndex.get(dedupKey)];
167
+ mergeUsageMax(existing.usage, record.usage);
168
+ existing.costUSD = Math.max(existing.costUSD || 0, record.costUSD || 0);
169
+ if (!existing.timestamp && record.timestamp) existing.timestamp = record.timestamp;
170
+ if (existing.model === 'unknown' && record.model !== 'unknown') existing.model = record.model;
171
+ continue;
172
+ }
173
+
174
+ if (dedupKey) dedupIndex.set(dedupKey, records.length);
175
+ records.push(record);
176
+ }
177
+
178
+ return records;
179
+ }
180
+
181
+ function dedupKeyForAssistant(obj) {
182
+ const messageId = obj.message?.id;
183
+ if (!messageId) return null;
184
+ return obj.requestId ? `${messageId}:${obj.requestId}` : `message:${messageId}`;
185
+ }
186
+
187
+ function mergeUsageMax(target, source) {
188
+ for (const key of [
189
+ 'input_tokens',
190
+ 'output_tokens',
191
+ 'cache_read_input_tokens',
192
+ 'cache_creation_input_tokens',
193
+ 'reasoning_tokens',
194
+ 'thinking_tokens'
195
+ ]) {
196
+ target[key] = Math.max(Number(target[key] || 0), Number(source[key] || 0));
197
+ }
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Safe directory helpers
202
+ // ---------------------------------------------------------------------------
203
+
204
+ async function safeReaddir(dirPath) {
205
+ try {
206
+ return await readdir(dirPath, { withFileTypes: true });
207
+ } catch {
208
+ return [];
209
+ }
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Aggregation helpers
214
+ // ---------------------------------------------------------------------------
215
+
216
+ function zeroTokens() {
217
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
218
+ }
219
+
220
+ function extractTokens(usage) {
221
+ return {
222
+ input: usage.input_tokens || 0,
223
+ output: usage.output_tokens || 0,
224
+ cacheRead: usage.cache_read_input_tokens || 0,
225
+ cacheWrite: usage.cache_creation_input_tokens || 0,
226
+ // Newer models expose reasoning/thinking tokens
227
+ reasoning: usage.reasoning_tokens || usage.thinking_tokens || 0
228
+ };
229
+ }
230
+
231
+ function addInto(target, tokens) {
232
+ target.input += tokens.input;
233
+ target.output += tokens.output;
234
+ target.cacheRead += tokens.cacheRead;
235
+ target.cacheWrite += tokens.cacheWrite;
236
+ target.reasoning += tokens.reasoning;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Main collector
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Scan the Claude Code projects directory and return the common daily and
245
+ * workspace/model objects consumed by collect.mjs.
246
+ *
247
+ * @returns {{ graphJson: object, modelsJson: object }}
248
+ */
249
+ export async function collect(pricingData = null) {
250
+ // dailyKey ("YYYY-MM-DD::model") -> aggregated token counts
251
+ const dailyMap = new Map();
252
+ // workspaceModelKey ("workspaceDir::model") -> aggregated token counts
253
+ const wmMap = new Map();
254
+
255
+ for (const root of await getScanRoots()) {
256
+ const filePaths = await collectJsonlFiles(root.path);
257
+ for (const filePath of filePaths) {
258
+ const workspaceKey = workspaceKeyFromPath(root, filePath);
259
+ const workspaceLabel = decodeWorkspaceLabel(workspaceKey);
260
+ const records = await parseSessionFile(filePath);
261
+
262
+ for (const record of records) {
263
+ const tokens = extractTokens(record.usage);
264
+ aggregateRecord({ ...record, tokens, workspaceKey, workspaceLabel }, dailyMap, wmMap, pricingData);
265
+ }
266
+ }
267
+ }
268
+
269
+ // -----------------------------------------------------------------------
270
+ // Convert to common daily JSON
271
+ // -----------------------------------------------------------------------
272
+ const byDate = new Map();
273
+ for (const row of dailyMap.values()) {
274
+ if (!byDate.has(row.date)) byDate.set(row.date, []);
275
+ byDate.get(row.date).push(row);
276
+ }
277
+
278
+ const contributions = [...byDate.entries()]
279
+ .sort(([a], [b]) => a.localeCompare(b))
280
+ .map(([date, rows]) => ({
281
+ date,
282
+ clients: rows.map((row) => ({
283
+ client: CLIENT_KEY,
284
+ modelId: row.model,
285
+ tokens: {
286
+ input: row.input,
287
+ output: row.output,
288
+ cacheRead: row.cacheRead,
289
+ cacheWrite: row.cacheWrite,
290
+ reasoning: row.reasoning
291
+ },
292
+ cost: row.cost
293
+ }))
294
+ }));
295
+
296
+ const graphJson = { contributions };
297
+
298
+ // -----------------------------------------------------------------------
299
+ // Convert to common workspace/model JSON
300
+ // -----------------------------------------------------------------------
301
+ const entries = [...wmMap.values()].map((wm) => ({
302
+ client: CLIENT_KEY,
303
+ workspaceKey: wm.workspace,
304
+ workspaceLabel: wm.workspaceLabel,
305
+ model: wm.model,
306
+ input: wm.input,
307
+ output: wm.output,
308
+ cacheRead: wm.cacheRead,
309
+ cacheWrite: wm.cacheWrite,
310
+ reasoning: wm.reasoning,
311
+ cost: wm.cost
312
+ }));
313
+
314
+ const modelsJson = { entries };
315
+
316
+ return { graphJson, modelsJson };
317
+ }
318
+
319
+ function workspaceKeyFromPath(root, filePath) {
320
+ const rel = relative(root.path, filePath);
321
+ const firstSegment = rel.split(/[\\/]/).find(Boolean);
322
+ if (root.type === 'projects' && firstSegment) return firstSegment;
323
+ return `transcripts:${firstSegment || filePath}`;
324
+ }
325
+
326
+ function aggregateRecord(record, dailyMap, wmMap, pricingData) {
327
+ const date = localDateFromTimestamp(record.timestamp);
328
+ const model = normalizeModelForGrouping(record.model);
329
+ const tokens = record.tokens || extractTokens(record.usage);
330
+ const costUSD = calculateCost(model, tokens, pricingData);
331
+
332
+ // --- daily ---
333
+ const dk = `${date}::${model}`;
334
+ if (!dailyMap.has(dk)) {
335
+ dailyMap.set(dk, { date, model, ...zeroTokens(), cost: 0 });
336
+ }
337
+ const dayAgg = dailyMap.get(dk);
338
+ addInto(dayAgg, tokens);
339
+ dayAgg.cost += costUSD;
340
+
341
+ // --- workspace+model ---
342
+ const wmk = `${record.workspaceKey}::${model}`;
343
+ if (!wmMap.has(wmk)) {
344
+ wmMap.set(wmk, {
345
+ workspace: record.workspaceKey,
346
+ workspaceLabel: record.workspaceLabel,
347
+ model,
348
+ ...zeroTokens(),
349
+ cost: 0
350
+ });
351
+ }
352
+ const wmAgg = wmMap.get(wmk);
353
+ addInto(wmAgg, tokens);
354
+ wmAgg.cost += costUSD;
355
+ }