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,247 @@
1
+ import { hostname } from 'node:os';
2
+ import { resolve } from 'node:path';
3
+ import { openDb, recordRun, upsertDaily, upsertSession, upsertTokenEvent } from './db.mjs';
4
+ import { loadPricing } from './pricing.mjs';
5
+ import { collectableCollectors, collectorLabel, enabledCollectorIds } from './collector-registry.mjs';
6
+
7
+ const args = parseArgs(process.argv.slice(2));
8
+ const device = args.device || hostname();
9
+ const db = openDb(args.db);
10
+ const exportPayload = {
11
+ device,
12
+ collectedAt: new Date().toISOString(),
13
+ daily: [],
14
+ sessions: [],
15
+ tokenEvents: [],
16
+ runs: []
17
+ };
18
+
19
+ // Load official provider pricing once. Unknown models are intentionally unpriced.
20
+ const pricingCachePath = resolve(process.cwd(), 'data', 'official-pricing.json');
21
+ const pricingData = await loadPricing(pricingCachePath);
22
+
23
+ await collectLocal();
24
+
25
+ if (args.push) {
26
+ await pushPayload(args.push, exportPayload, args.token);
27
+ }
28
+
29
+ db.close();
30
+
31
+ async function collectLocal() {
32
+ let anyError = false;
33
+ const enabled = enabledCollectors();
34
+ const includeExperimental = Boolean(args.sources || args.collectors || args.experimental);
35
+ const collectors = collectableCollectors({ includeExperimental }).filter(({ id }) => enabled.has(id));
36
+
37
+ console.log(`[collect] enabled=${Array.from(enabled).join(',') || 'none'}`);
38
+
39
+ for (const { module, label } of collectors) {
40
+ let graphJson;
41
+ let modelsJson;
42
+ let tokenEvents;
43
+
44
+ try {
45
+ const { collect } = await import(module);
46
+ ({ graphJson, modelsJson, tokenEvents } = await collect(pricingData));
47
+ } catch (error) {
48
+ const run = {
49
+ device,
50
+ source: label,
51
+ status: 'error',
52
+ message: error.message,
53
+ collectedAt: exportPayload.collectedAt,
54
+ command: `js-collector:${module}`
55
+ };
56
+ recordRun(db, run);
57
+ exportPayload.runs.push(run);
58
+ console.warn(`[${label}] ${error.message}`);
59
+ anyError = true;
60
+ continue;
61
+ }
62
+
63
+ const dailyRows = normalizeDailyRows(graphJson, device);
64
+ runInTransaction(db, () => dailyRows.forEach((row) => upsertDaily(db, row)));
65
+ exportPayload.daily.push(...dailyRows);
66
+
67
+ const sessionRows = normalizeSessionRows(modelsJson, device);
68
+ runInTransaction(db, () => sessionRows.forEach((row) => upsertSession(db, row)));
69
+ exportPayload.sessions.push(...sessionRows);
70
+
71
+ const eventRows = normalizeTokenEventRows(tokenEvents, device);
72
+ runInTransaction(db, () => eventRows.forEach((row) => upsertTokenEvent(db, row)));
73
+ exportPayload.tokenEvents.push(...eventRows);
74
+
75
+ const run = {
76
+ device,
77
+ source: label,
78
+ status: dailyRows.length || sessionRows.length ? 'ok' : 'empty',
79
+ message: `daily=${dailyRows.length}, workspace_model=${sessionRows.length}, token_events=${eventRows.length}`,
80
+ collectedAt: exportPayload.collectedAt,
81
+ command: `js-collector:${module}`
82
+ };
83
+ recordRun(db, run);
84
+ exportPayload.runs.push(run);
85
+ console.log(`[${label}] daily=${dailyRows.length}, workspace_model=${sessionRows.length}, token_events=${eventRows.length}`);
86
+ }
87
+
88
+ if (anyError) process.exitCode = 1;
89
+ }
90
+
91
+ function enabledCollectors() {
92
+ const sourceArg = args.sources || args.collectors;
93
+ if (sourceArg) {
94
+ return enabledCollectorIds({ includeExperimental: true, values: sourceArg });
95
+ }
96
+ return enabledCollectorIds({ includeExperimental: Boolean(args.experimental) });
97
+ }
98
+
99
+ function runInTransaction(database, work) {
100
+ database.exec('BEGIN');
101
+ try {
102
+ work();
103
+ database.exec('COMMIT');
104
+ } catch (error) {
105
+ database.exec('ROLLBACK');
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ function normalizeDailyRows(json, deviceName) {
111
+ const days = Array.isArray(json.contributions) ? json.contributions : [];
112
+ return days.flatMap((day) => {
113
+ const clients = Array.isArray(day.clients) ? day.clients : [];
114
+ return clients.map((entry) => {
115
+ const tokens = normalizeTokens(entry.tokens);
116
+ return {
117
+ device: deviceName,
118
+ source: sourceLabel(entry.client),
119
+ usageDate: day.date,
120
+ model: entry.modelId || entry.model_id || 'unknown',
121
+ inputTokens: tokens.input,
122
+ outputTokens: tokens.output,
123
+ cacheCreationTokens: tokens.cacheWrite,
124
+ cacheReadTokens: tokens.cacheRead,
125
+ reasoningOutputTokens: tokens.reasoning,
126
+ totalTokens: tokenTotal(tokens),
127
+ costUSD: entry.cost || 0
128
+ };
129
+ });
130
+ });
131
+ }
132
+
133
+ function normalizeSessionRows(json, deviceName) {
134
+ const entries = Array.isArray(json.entries) ? json.entries : [];
135
+ return entries.map((entry) => {
136
+ const tokens = {
137
+ input: positiveNumber(entry.input),
138
+ output: positiveNumber(entry.output),
139
+ cacheRead: positiveNumber(entry.cacheRead),
140
+ cacheWrite: positiveNumber(entry.cacheWrite),
141
+ reasoning: positiveNumber(entry.reasoning)
142
+ };
143
+ const source = sourceLabel(entry.client);
144
+ const workspace = entry.workspaceLabel || entry.workspaceKey || '';
145
+ const model = entry.model || 'unknown';
146
+ return {
147
+ device: deviceName,
148
+ source,
149
+ sessionId: ['local', entry.client || 'unknown', workspace || 'no-workspace', model].join(':'),
150
+ lastActivity: exportPayload.collectedAt,
151
+ projectPath: workspace || null,
152
+ inputTokens: tokens.input,
153
+ outputTokens: tokens.output,
154
+ cacheCreationTokens: tokens.cacheWrite,
155
+ cacheReadTokens: tokens.cacheRead,
156
+ reasoningOutputTokens: tokens.reasoning,
157
+ totalTokens: tokenTotal(tokens),
158
+ costUSD: entry.cost || 0
159
+ };
160
+ });
161
+ }
162
+
163
+ function normalizeTokenEventRows(events, deviceName) {
164
+ if (!Array.isArray(events)) return [];
165
+ return events.map((event) => ({
166
+ device: deviceName,
167
+ source: sourceLabel(event.source || event.client),
168
+ sessionId: event.sessionId || event.session_id || 'unknown-session',
169
+ timestamp: event.timestamp || exportPayload.collectedAt,
170
+ model: event.model || 'unknown',
171
+ inputTokens: positiveNumber(event.inputTokens ?? event.input_tokens),
172
+ outputTokens: positiveNumber(event.outputTokens ?? event.output_tokens),
173
+ cacheReadTokens: positiveNumber(event.cacheReadTokens ?? event.cache_read_tokens),
174
+ cacheCreationTokens: positiveNumber(event.cacheCreationTokens ?? event.cache_creation_tokens),
175
+ reasoningTokens: positiveNumber(event.reasoningTokens ?? event.reasoning_tokens),
176
+ toolCategory: event.toolCategory ?? event.tool_category ?? null,
177
+ fileExtension: event.fileExtension ?? event.file_extension ?? null,
178
+ repoPathHash: event.repoPathHash ?? event.repo_path_hash ?? null,
179
+ privacyLevel: event.privacyLevel ?? event.privacy_level ?? 'safe',
180
+ eventId: event.eventId ?? event.event_id ?? null
181
+ }));
182
+ }
183
+
184
+ function normalizeTokens(tokens = {}) {
185
+ return {
186
+ input: positiveNumber(tokens.input),
187
+ output: positiveNumber(tokens.output),
188
+ cacheRead: positiveNumber(tokens.cacheRead ?? tokens.cache_read),
189
+ cacheWrite: positiveNumber(tokens.cacheWrite ?? tokens.cache_write),
190
+ reasoning: positiveNumber(tokens.reasoning)
191
+ };
192
+ }
193
+
194
+ function tokenTotal(tokens) {
195
+ return tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite + tokens.reasoning;
196
+ }
197
+
198
+ function positiveNumber(value) {
199
+ const number = Number(value || 0);
200
+ return Number.isFinite(number) && number > 0 ? number : 0;
201
+ }
202
+
203
+ function sourceLabel(client) {
204
+ return collectorLabel(client) || client || 'unknown';
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // CLI argument parser
209
+ // ---------------------------------------------------------------------------
210
+
211
+ function parseArgs(argv) {
212
+ const parsed = {};
213
+ for (let i = 0; i < argv.length; i += 1) {
214
+ const arg = argv[i];
215
+ if (arg === '--device') {
216
+ parsed.device = argv[++i];
217
+ } else if (arg === '--db') {
218
+ parsed.db = argv[++i];
219
+ } else if (arg === '--push') {
220
+ parsed.push = argv[++i];
221
+ } else if (arg === '--token') {
222
+ parsed.token = argv[++i];
223
+ } else if (arg === '--sources' || arg === '--collectors') {
224
+ parsed.sources = argv[++i];
225
+ }
226
+ }
227
+ return parsed;
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Remote push helper
232
+ // ---------------------------------------------------------------------------
233
+
234
+ async function pushPayload(url, payload, token) {
235
+ const response = await fetch(url, {
236
+ method: 'POST',
237
+ headers: {
238
+ 'content-type': 'application/json',
239
+ ...(token ? { authorization: `Bearer ${token}` } : {})
240
+ },
241
+ body: JSON.stringify(payload)
242
+ });
243
+ if (!response.ok) {
244
+ throw new Error(`上报失败:HTTP ${response.status} ${await response.text()}`);
245
+ }
246
+ console.log(`[push] ${url}`);
247
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { resolve } from 'node:path';
4
+
5
+ let cachedConfig;
6
+
7
+ export function loadCollectorConfig() {
8
+ if (cachedConfig) return cachedConfig;
9
+
10
+ const configPath = process.env.TOKEN_STUDIO_CONFIG ||
11
+ process.env.AI_TOKEN_DASHBOARD_CONFIG ||
12
+ resolve(process.cwd(), 'config', 'collectors.json');
13
+
14
+ try {
15
+ cachedConfig = JSON.parse(readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
16
+ } catch {
17
+ cachedConfig = { collectors: {} };
18
+ }
19
+
20
+ return cachedConfig;
21
+ }
22
+
23
+ export function collectorConfig(name) {
24
+ return loadCollectorConfig().collectors?.[name] || {};
25
+ }
26
+
27
+ export function configuredPaths(name, key, fallback = []) {
28
+ const value = collectorConfig(name)[key];
29
+ const paths = Array.isArray(value) ? value : fallback;
30
+ return paths
31
+ .map((item) => expandPath(item))
32
+ .filter(Boolean);
33
+ }
34
+
35
+ export function configuredPath(name, key, fallback = null) {
36
+ const value = collectorConfig(name)[key] ?? fallback;
37
+ return expandPath(value);
38
+ }
39
+
40
+ export function configuredBool(name, key, fallback = false) {
41
+ const value = collectorConfig(name)[key];
42
+ return typeof value === 'boolean' ? value : fallback;
43
+ }
44
+
45
+ export function configuredStrings(name, key, fallback = []) {
46
+ const value = collectorConfig(name)[key];
47
+ return Array.isArray(value)
48
+ ? value.map((item) => String(item)).filter(Boolean)
49
+ : fallback;
50
+ }
51
+
52
+ export function envPathList(value, fallback = []) {
53
+ const paths = String(value || '')
54
+ .split(',')
55
+ .map((item) => expandPath(item.trim()))
56
+ .filter(Boolean);
57
+ return paths.length ? paths : fallback;
58
+ }
59
+
60
+ export function existingPaths(paths) {
61
+ return paths.filter((path) => existsSync(path));
62
+ }
63
+
64
+ export function expandPath(value) {
65
+ if (typeof value !== 'string' || !value.trim()) return null;
66
+
67
+ let expanded = value.trim();
68
+ if (expanded === '~') {
69
+ expanded = homedir();
70
+ } else if (expanded.startsWith('~/')) {
71
+ expanded = `${homedir()}${expanded.slice(1)}`;
72
+ }
73
+
74
+ expanded = expanded.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
75
+ return process.env[name] || '';
76
+ });
77
+ expanded = expanded.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => {
78
+ return process.env[name] || '';
79
+ });
80
+
81
+ return expanded;
82
+ }
@@ -0,0 +1,333 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { configuredPath, configuredPaths } from './collector-config.mjs';
5
+ import { auditStructuredUsage } from './collectors/structured-usage.mjs';
6
+
7
+ const STABLE_FIELDS = [
8
+ 'date',
9
+ 'source',
10
+ 'session_id',
11
+ 'project',
12
+ 'model',
13
+ 'input_tokens',
14
+ 'output_tokens',
15
+ 'cache_tokens',
16
+ 'reasoning_tokens'
17
+ ];
18
+
19
+ const EXPERIMENTAL_FIELDS = [
20
+ 'timestamp',
21
+ 'source',
22
+ 'session_id',
23
+ 'project_label',
24
+ 'model',
25
+ 'input_tokens',
26
+ 'output_tokens',
27
+ 'cache_tokens',
28
+ 'tool_category',
29
+ 'file_extension'
30
+ ];
31
+
32
+ const IMPORT_FIELDS = [
33
+ 'date',
34
+ 'session',
35
+ 'model',
36
+ 'input_tokens',
37
+ 'output_tokens',
38
+ 'cache_tokens',
39
+ 'cost_usd'
40
+ ];
41
+
42
+ export const COLLECTOR_REGISTRY = [
43
+ stableCollector('claude', 'Claude Code', './collectors/claude-code.mjs', {
44
+ privacyLevel: 'metadata-only',
45
+ roots: () => configuredPaths('claude', 'roots', ['~/.config/claude', '~/.claude'])
46
+ }),
47
+ stableCollector('codex', 'Codex CLI', './collectors/codex.mjs', {
48
+ privacyLevel: 'metadata-only',
49
+ roots: () => configuredPaths('codex', 'homes', ['~/.codex'])
50
+ }),
51
+ stableCollector('gemini', 'Gemini CLI', './collectors/gemini.mjs', {
52
+ privacyLevel: 'metadata-only',
53
+ roots: () => [join(homedir(), '.gemini', 'tmp')]
54
+ }),
55
+ stableCollector('opencode', 'OpenCode', './collectors/opencode.mjs', {
56
+ privacyLevel: 'metadata-only',
57
+ roots: () => [configuredPath('opencode', 'dataDir', '~/.local/share/opencode')]
58
+ }),
59
+ stableCollector('openclaw', 'OpenClaw', './collectors/openclaw.mjs', {
60
+ privacyLevel: 'metadata-only',
61
+ roots: () => configuredPaths('openclaw', 'agentRoots', [
62
+ '~/.openclaw/agents',
63
+ '~/.clawdbot/agents',
64
+ '~/.moltbot/agents',
65
+ '~/.moldbot/agents'
66
+ ])
67
+ }),
68
+ stableCollector('hermes', 'Hermes Agent', './collectors/hermes.mjs', {
69
+ privacyLevel: 'metadata-only',
70
+ roots: () => [configuredPath('hermes', 'dbPath', '~/.hermes/state.db')]
71
+ }),
72
+ experimentalCollector('cursor', 'Cursor', {
73
+ module: './collectors/cursor.mjs',
74
+ privacyLevel: 'metadata-only',
75
+ roots: () => cursorRoots(),
76
+ note: 'Experimental: only explicit local usage records with token fields are imported; chat content is ignored.'
77
+ }),
78
+ experimentalCollector('copilot', 'GitHub Copilot CLI', {
79
+ module: './collectors/copilot.mjs',
80
+ privacyLevel: 'metadata-only',
81
+ roots: () => copilotRoots(),
82
+ note: 'Experimental: local token rows are imported only when token fields are present.'
83
+ }),
84
+ experimentalCollector('qwen', 'Qwen Code', {
85
+ module: './collectors/qwen.mjs',
86
+ privacyLevel: 'metadata-only',
87
+ roots: () => configuredPaths('qwen', 'roots', ['~/.qwen', '~/.qwen-code']),
88
+ note: 'Experimental: supports fixture-backed structured usage logs without message-body ingestion.'
89
+ }),
90
+ experimentalCollector('kimi', 'Kimi / Moonshot Coding CLI', {
91
+ module: './collectors/kimi.mjs',
92
+ privacyLevel: 'metadata-only',
93
+ roots: () => configuredPaths('kimi', 'roots', ['~/.kimi', '~/.moonshot']),
94
+ note: 'Experimental: supports fixture-backed structured usage logs without message-body ingestion.'
95
+ }),
96
+ experimentalCollector('goose', 'Goose', {
97
+ module: './collectors/goose.mjs',
98
+ privacyLevel: 'metadata-only',
99
+ roots: () => configuredPaths('goose', 'roots', ['~/.config/goose', '~/.goose']),
100
+ note: 'Experimental: supports explicit token metadata only; message bodies are not imported.'
101
+ }),
102
+ importOnlyCollector('ccusage', 'ccusage Import Bridge', {
103
+ roots: () => configuredPaths('ccusage', 'roots', []),
104
+ note: 'Import-only: use token-studio import-usage --format=ccusage-json for saved JSON or --format=ccusage-cli for an explicit ccusage CLI bridge.'
105
+ }),
106
+ detectedOnlyCollector('amp', 'Amp', ['~/.config/amp', '~/.amp']),
107
+ detectedOnlyCollector('droid', 'Droid', ['~/.droid', '~/.config/droid']),
108
+ detectedOnlyCollector('codebuff', 'Codebuff', ['~/.codebuff', '~/.config/codebuff']),
109
+ detectedOnlyCollector('pi-agent', 'pi-agent', ['~/.pi-agent', '~/.config/pi-agent']),
110
+ detectedOnlyCollector('roo-code', 'Roo Code', ['~/.roo-code', '~/.config/roo-code']),
111
+ detectedOnlyCollector('zed-agent', 'Zed Agent', ['~/.config/zed', '~/Library/Application Support/Zed']),
112
+ detectedOnlyCollector('antigravity', 'Antigravity', ['~/.antigravity', '~/.config/antigravity']),
113
+ detectedOnlyCollector('cline', 'Cline', ['~/.cline', '~/.config/cline']),
114
+ detectedOnlyCollector('kiro', 'Kiro', ['~/.kiro', '~/.config/kiro']),
115
+ detectedOnlyCollector('grok-build', 'Grok Build', ['~/.grok', '~/.config/grok']),
116
+ detectedOnlyCollector('kilo', 'Kilo', ['~/.kilo', '~/.config/kilo'])
117
+ ];
118
+
119
+ export function listCollectors() {
120
+ return COLLECTOR_REGISTRY.map(({ detect, roots, ...entry }) => ({
121
+ ...entry,
122
+ configuredRoots: roots().filter(Boolean)
123
+ }));
124
+ }
125
+
126
+ export function stableCollectors() {
127
+ return COLLECTOR_REGISTRY.filter(item => item.supportStatus === 'stable');
128
+ }
129
+
130
+ export function collectableCollectors({ includeExperimental = false } = {}) {
131
+ return COLLECTOR_REGISTRY.filter(item =>
132
+ item.module && (item.supportStatus === 'stable' || (includeExperimental && item.supportStatus === 'experimental'))
133
+ );
134
+ }
135
+
136
+ export function collectorById(id) {
137
+ return COLLECTOR_REGISTRY.find(item => item.id === id);
138
+ }
139
+
140
+ export function collectorLabel(id) {
141
+ return collectorById(id)?.label || id || 'unknown';
142
+ }
143
+
144
+ export function detectCollectors() {
145
+ return COLLECTOR_REGISTRY.map(item => {
146
+ const roots = item.roots().filter(Boolean);
147
+ const existingRoots = roots.filter(path => existsSync(path));
148
+ return {
149
+ id: item.id,
150
+ label: item.label,
151
+ supportStatus: item.supportStatus,
152
+ privacyLevel: item.privacyLevel,
153
+ defaultEnabled: item.defaultEnabled,
154
+ detected: existingRoots.length > 0,
155
+ configuredRoots: roots,
156
+ existingRoots,
157
+ module: item.module || null,
158
+ fixtures: item.fixtures || null,
159
+ dataFields: item.dataFields || [],
160
+ readsConversationContent: Boolean(item.readsConversationContent),
161
+ tokenReliability: item.tokenReliability || 'unknown',
162
+ fixtureBacked: Boolean(item.fixtures),
163
+ auditRecommended: item.supportStatus === 'experimental',
164
+ lastAudit: null,
165
+ note: item.note || null
166
+ };
167
+ });
168
+ }
169
+
170
+ export async function auditExperimentalCollectors() {
171
+ const auditedAt = new Date().toISOString();
172
+ const collectors = [];
173
+
174
+ for (const item of COLLECTOR_REGISTRY.filter(row => row.supportStatus === 'experimental')) {
175
+ const roots = item.roots().filter(Boolean);
176
+ const existingRoots = roots.filter(path => existsSync(path));
177
+ const summary = existingRoots.length
178
+ ? await auditStructuredUsage({ roots: existingRoots })
179
+ : emptyAuditSummary();
180
+ collectors.push({
181
+ id: item.id,
182
+ label: item.label,
183
+ supportStatus: item.supportStatus,
184
+ auditRecommended: true,
185
+ detected: existingRoots.length > 0,
186
+ privacyLevel: item.privacyLevel,
187
+ tokenReliability: item.tokenReliability || 'unknown',
188
+ readsConversationContent: Boolean(item.readsConversationContent),
189
+ auditedAt,
190
+ summary
191
+ });
192
+ }
193
+
194
+ return {
195
+ auditedAt,
196
+ collectors,
197
+ totals: collectors.reduce((acc, item) => addAuditSummary(acc, item.summary), emptyAuditSummary())
198
+ };
199
+ }
200
+
201
+ export function enabledCollectorIds({ includeExperimental = false, values = null } = {}) {
202
+ const envValue = process.env.TOKEN_STUDIO_COLLECTORS || process.env.AI_TOKEN_DASHBOARD_COLLECTORS;
203
+ const configRoot = globalCollectorConfig();
204
+ const rawValues = values != null
205
+ ? String(values).split(',')
206
+ : envValue ? envValue.split(',')
207
+ : Array.isArray(configRoot.enabledCollectors) ? configRoot.enabledCollectors
208
+ : stableCollectors().filter(item => item.defaultEnabled).map(item => item.id);
209
+
210
+ const ids = rawValues.map(item => String(item).trim().toLowerCase()).filter(Boolean);
211
+ const allowed = new Set(COLLECTOR_REGISTRY
212
+ .filter(item => includeExperimental || item.supportStatus === 'stable')
213
+ .map(item => item.id));
214
+ return new Set(ids.filter(id => allowed.has(id)));
215
+ }
216
+
217
+ function stableCollector(id, label, module, options) {
218
+ return {
219
+ id,
220
+ label,
221
+ module,
222
+ privacyLevel: options.privacyLevel,
223
+ defaultEnabled: true,
224
+ supportStatus: 'stable',
225
+ fixtures: `test/fixtures/collectors/${id}`,
226
+ dataFields: STABLE_FIELDS,
227
+ readsConversationContent: false,
228
+ tokenReliability: 'native-token-fields',
229
+ roots: options.roots
230
+ };
231
+ }
232
+
233
+ function experimentalCollector(id, label, options) {
234
+ return {
235
+ id,
236
+ label,
237
+ module: options.module || null,
238
+ privacyLevel: options.privacyLevel,
239
+ defaultEnabled: false,
240
+ supportStatus: options.module ? 'experimental' : 'detected-only',
241
+ fixtures: `test/fixtures/collectors/${id}`,
242
+ dataFields: EXPERIMENTAL_FIELDS,
243
+ readsConversationContent: false,
244
+ tokenReliability: 'explicit-token-fields-only',
245
+ roots: options.roots,
246
+ note: options.note
247
+ };
248
+ }
249
+
250
+ function importOnlyCollector(id, label, options) {
251
+ return {
252
+ id,
253
+ label,
254
+ module: null,
255
+ privacyLevel: 'metadata-only',
256
+ defaultEnabled: false,
257
+ supportStatus: 'import-only',
258
+ fixtures: null,
259
+ dataFields: IMPORT_FIELDS,
260
+ readsConversationContent: false,
261
+ tokenReliability: 'external-json-token-fields',
262
+ roots: options.roots,
263
+ note: options.note
264
+ };
265
+ }
266
+
267
+ function detectedOnlyCollector(id, label, roots) {
268
+ return {
269
+ id,
270
+ label,
271
+ module: null,
272
+ privacyLevel: 'detected-only',
273
+ defaultEnabled: false,
274
+ supportStatus: 'detected-only',
275
+ fixtures: null,
276
+ dataFields: [],
277
+ readsConversationContent: false,
278
+ tokenReliability: 'unknown-no-usage-import',
279
+ roots: () => configuredPaths(id, 'roots', roots),
280
+ note: 'Detected-only: Token Studio can show local presence, but it will not write token usage until a reliable token field is audited.'
281
+ };
282
+ }
283
+
284
+ function globalCollectorConfig() {
285
+ try {
286
+ const path = join(process.cwd(), 'config', 'collectors.json');
287
+ if (!existsSync(path)) return {};
288
+ return JSON.parse(readFileSync(path, 'utf8'));
289
+ } catch {
290
+ return {};
291
+ }
292
+ }
293
+
294
+ function cursorRoots() {
295
+ const appData = process.env.APPDATA;
296
+ const localAppData = process.env.LOCALAPPDATA;
297
+ return configuredPaths('cursor', 'roots', [
298
+ appData ? join(appData, 'Cursor') : null,
299
+ localAppData ? join(localAppData, 'Programs', 'Cursor') : null,
300
+ '~/.config/Cursor',
301
+ '~/Library/Application Support/Cursor'
302
+ ]);
303
+ }
304
+
305
+ function copilotRoots() {
306
+ return configuredPaths('copilot', 'roots', [
307
+ '~/.config/github-copilot',
308
+ '~/.copilot',
309
+ '~/Library/Application Support/github-copilot',
310
+ process.env.APPDATA ? join(process.env.APPDATA, 'GitHub Copilot') : null
311
+ ]);
312
+ }
313
+
314
+ function emptyAuditSummary() {
315
+ return {
316
+ candidateFiles: 0,
317
+ usableTokenRecords: 0,
318
+ skippedNoTokenRecords: 0,
319
+ skippedConversationLikeRecords: 0,
320
+ skippedOversizedFiles: 0,
321
+ parseErrors: 0
322
+ };
323
+ }
324
+
325
+ function addAuditSummary(target, source) {
326
+ target.candidateFiles += source.candidateFiles || 0;
327
+ target.usableTokenRecords += source.usableTokenRecords || 0;
328
+ target.skippedNoTokenRecords += source.skippedNoTokenRecords || 0;
329
+ target.skippedConversationLikeRecords += source.skippedConversationLikeRecords || 0;
330
+ target.skippedOversizedFiles += source.skippedOversizedFiles || 0;
331
+ target.parseErrors += source.parseErrors || 0;
332
+ return target;
333
+ }