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.
- package/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
package/src/collect.mjs
ADDED
|
@@ -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
|
+
}
|