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
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI data collector (pure JS).
|
|
3
|
+
*
|
|
4
|
+
* Scans two roots:
|
|
5
|
+
* ~/.codex/sessions/ — active sessions (recursive JSONL)
|
|
6
|
+
* ~/.codex/archived_sessions/ — archived sessions (recursive JSONL)
|
|
7
|
+
* (CODEX_HOME env var overrides ~/.codex)
|
|
8
|
+
*
|
|
9
|
+
* The Codex JSONL format has three relevant event types:
|
|
10
|
+
* session_meta – workspace (cwd), session ID, provider, agent nickname
|
|
11
|
+
* turn_context – current model for the upcoming turn
|
|
12
|
+
* event_msg – when payload.type === "token_count", carries token usage
|
|
13
|
+
*
|
|
14
|
+
* Token counting strategy:
|
|
15
|
+
* • Primary source: last_token_usage (per-request increment)
|
|
16
|
+
* • Fallback: delta of total_token_usage between consecutive events
|
|
17
|
+
* • Dedup: skip events where total_token_usage unchanged
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
21
|
+
import { basename, join } from 'node:path';
|
|
22
|
+
import { configuredPaths, configuredStrings, envPathList } from '../collector-config.mjs';
|
|
23
|
+
import { calculateCost } from '../pricing.mjs';
|
|
24
|
+
import { localDateFromTimestamp, normalizeModelForGrouping } from './utils.mjs';
|
|
25
|
+
|
|
26
|
+
/** Recursively collect all .jsonl file paths under a directory. */
|
|
27
|
+
async function collectJsonlFiles(dir) {
|
|
28
|
+
const results = [];
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
32
|
+
} catch {
|
|
33
|
+
return results;
|
|
34
|
+
}
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const full = join(dir, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
results.push(...await collectJsonlFiles(full));
|
|
39
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
40
|
+
results.push(full);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const CLIENT_KEY = 'codex';
|
|
47
|
+
export const SOURCE_LABEL = 'Codex CLI';
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Path resolution
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function getCodexHomes() {
|
|
54
|
+
return envPathList(process.env.CODEX_HOME, configuredPaths('codex', 'homes'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getSessionRoots() {
|
|
58
|
+
const subdirs = configuredStrings('codex', 'sessionSubdirs', ['sessions', 'archived_sessions']);
|
|
59
|
+
return getCodexHomes().flatMap((home) => subdirs.map((subdir) => join(home, subdir)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getHeadlessRoots() {
|
|
63
|
+
const roots = envPathList(
|
|
64
|
+
process.env.TOKEN_STUDIO_HEADLESS_DIR || process.env.AI_TOKEN_DASHBOARD_HEADLESS_DIR,
|
|
65
|
+
configuredPaths('codex', 'headlessRoots')
|
|
66
|
+
);
|
|
67
|
+
return roots.map((root) => join(root, 'codex'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Helpers
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
async function safeReaddir(dir) {
|
|
75
|
+
try {
|
|
76
|
+
return await readdir(dir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pos(v) {
|
|
83
|
+
const n = Number(v ?? 0);
|
|
84
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function zero() {
|
|
88
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function addInto(agg, t) {
|
|
92
|
+
agg.input += t.input;
|
|
93
|
+
agg.output += t.output;
|
|
94
|
+
agg.cacheRead += t.cacheRead;
|
|
95
|
+
agg.cacheWrite += t.cacheWrite;
|
|
96
|
+
agg.reasoning += t.reasoning;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Extract a { input, output, cached, reasoning } summary from a token-usage object. */
|
|
100
|
+
function usageSummary(u) {
|
|
101
|
+
return {
|
|
102
|
+
input: pos(u.input_tokens),
|
|
103
|
+
output: pos(u.output_tokens),
|
|
104
|
+
// Codex uses cached_input_tokens OR cache_read_input_tokens interchangeably
|
|
105
|
+
cached: Math.max(pos(u.cached_input_tokens), pos(u.cache_read_input_tokens)),
|
|
106
|
+
reasoning: pos(u.reasoning_output_tokens)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Convert a Codex cumulative summary to our token breakdown.
|
|
112
|
+
* cached is clamped to <= input to avoid inflated totals.
|
|
113
|
+
*/
|
|
114
|
+
function summaryToTokens(s) {
|
|
115
|
+
const clamped = Math.min(s.cached, s.input);
|
|
116
|
+
return {
|
|
117
|
+
input: Math.max(0, s.input - clamped),
|
|
118
|
+
output: s.output,
|
|
119
|
+
cacheRead: clamped,
|
|
120
|
+
cacheWrite: 0,
|
|
121
|
+
reasoning: s.reasoning
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function summaryIsZero(s) {
|
|
126
|
+
return s.input === 0 && s.output === 0 && s.cached === 0 && s.reasoning === 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function summaryEqual(a, b) {
|
|
130
|
+
return a.input === b.input && a.output === b.output &&
|
|
131
|
+
a.cached === b.cached && a.reasoning === b.reasoning;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function summaryDelta(current, previous) {
|
|
135
|
+
if (
|
|
136
|
+
current.input < previous.input ||
|
|
137
|
+
current.output < previous.output ||
|
|
138
|
+
current.cached < previous.cached ||
|
|
139
|
+
current.reasoning < previous.reasoning
|
|
140
|
+
) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
input: current.input - previous.input,
|
|
146
|
+
output: current.output - previous.output,
|
|
147
|
+
cached: current.cached - previous.cached,
|
|
148
|
+
reasoning: current.reasoning - previous.reasoning
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function summaryTotal(s) {
|
|
153
|
+
return s.input + s.output + s.cached + s.reasoning;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function looksLikeStaleRegression(current, previous, last) {
|
|
157
|
+
const previousTotal = summaryTotal(previous);
|
|
158
|
+
const currentTotal = summaryTotal(current);
|
|
159
|
+
const lastTotal = summaryTotal(last);
|
|
160
|
+
|
|
161
|
+
if (previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0) return false;
|
|
162
|
+
|
|
163
|
+
return currentTotal * 100 >= previousTotal * 98 ||
|
|
164
|
+
currentTotal + lastTotal * 2 >= previousTotal;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// JSONL session parser
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse a single Codex JSONL session file.
|
|
173
|
+
* Returns an array of { timestamp, date, model, workspace, tokens }.
|
|
174
|
+
*/
|
|
175
|
+
async function parseSessionFile(filePath, sessionId) {
|
|
176
|
+
let text;
|
|
177
|
+
try {
|
|
178
|
+
text = await readFile(filePath, 'utf8');
|
|
179
|
+
} catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Per-file state
|
|
184
|
+
let currentModel = null;
|
|
185
|
+
let previousTotal = null; // last seen total_token_usage summary
|
|
186
|
+
let workspace = null;
|
|
187
|
+
|
|
188
|
+
const events = [];
|
|
189
|
+
|
|
190
|
+
for (const raw of text.split('\n')) {
|
|
191
|
+
const line = raw.trim();
|
|
192
|
+
if (!line) continue;
|
|
193
|
+
|
|
194
|
+
let entry;
|
|
195
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
196
|
+
|
|
197
|
+
const type = entry.type;
|
|
198
|
+
|
|
199
|
+
// ── session_meta ──────────────────────────────────────────────────
|
|
200
|
+
if (type === 'session_meta') {
|
|
201
|
+
const payload = entry.payload || {};
|
|
202
|
+
if (payload.cwd) {
|
|
203
|
+
workspace = payload.cwd;
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── turn_context ──────────────────────────────────────────────────
|
|
209
|
+
if (type === 'turn_context') {
|
|
210
|
+
const payload = entry.payload || {};
|
|
211
|
+
currentModel = extractModel(payload) || currentModel;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── event_msg / token_count ────────────────────────────────────────
|
|
216
|
+
if (type === 'event_msg') {
|
|
217
|
+
const payload = entry.payload || {};
|
|
218
|
+
if (payload.type !== 'token_count') continue;
|
|
219
|
+
|
|
220
|
+
const info = payload.info || {};
|
|
221
|
+
|
|
222
|
+
// Model resolution: payload.model → info.model → state.currentModel
|
|
223
|
+
const model = normalizeModelForGrouping(
|
|
224
|
+
extractModel(payload) ||
|
|
225
|
+
extractModel(info) ||
|
|
226
|
+
currentModel ||
|
|
227
|
+
'unknown'
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
currentModel = model;
|
|
231
|
+
|
|
232
|
+
const lastUsage = info.last_token_usage ? usageSummary(info.last_token_usage) : null;
|
|
233
|
+
const totalUsage = info.total_token_usage ? usageSummary(info.total_token_usage) : null;
|
|
234
|
+
|
|
235
|
+
// Dedup: skip if total hasn't changed since last event
|
|
236
|
+
if (totalUsage && previousTotal && summaryEqual(totalUsage, previousTotal)) continue;
|
|
237
|
+
|
|
238
|
+
// Choose token increment
|
|
239
|
+
let increment;
|
|
240
|
+
if (lastUsage && totalUsage && previousTotal) {
|
|
241
|
+
if (!summaryDelta(totalUsage, previousTotal) &&
|
|
242
|
+
looksLikeStaleRegression(totalUsage, previousTotal, lastUsage)) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Standard path: use last_token_usage as increment
|
|
246
|
+
increment = lastUsage;
|
|
247
|
+
} else if (lastUsage && totalUsage && !previousTotal) {
|
|
248
|
+
// First event in session: use last to avoid overcounting resumed session context
|
|
249
|
+
increment = lastUsage;
|
|
250
|
+
} else if (!lastUsage && totalUsage && previousTotal) {
|
|
251
|
+
// Fallback: delta of cumulative totals
|
|
252
|
+
increment = summaryDelta(totalUsage, previousTotal);
|
|
253
|
+
if (!increment) {
|
|
254
|
+
// Total went backwards (session context reset or stale event).
|
|
255
|
+
// Accept the new total as the new baseline to avoid future overcounting.
|
|
256
|
+
previousTotal = totalUsage;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
} else if (!lastUsage && totalUsage) {
|
|
260
|
+
// Very first event, no last — use full total (legacy/degraded)
|
|
261
|
+
increment = totalUsage;
|
|
262
|
+
} else if (lastUsage) {
|
|
263
|
+
increment = lastUsage;
|
|
264
|
+
} else {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (totalUsage) previousTotal = totalUsage;
|
|
269
|
+
|
|
270
|
+
if (summaryIsZero(increment)) continue;
|
|
271
|
+
|
|
272
|
+
const tokens = summaryToTokens(increment);
|
|
273
|
+
|
|
274
|
+
// Date from event timestamp
|
|
275
|
+
const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : '';
|
|
276
|
+
let date = 'unknown';
|
|
277
|
+
if (timestamp) {
|
|
278
|
+
date = localDateFromTimestamp(timestamp);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
events.push({ timestamp, date, model, workspace, tokens });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return events;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function extractModel(obj) {
|
|
289
|
+
if (!obj) return null;
|
|
290
|
+
const v =
|
|
291
|
+
obj.model ||
|
|
292
|
+
obj.model_name ||
|
|
293
|
+
obj.model_info?.slug ||
|
|
294
|
+
null;
|
|
295
|
+
return typeof v === 'string' && v.trim() ? v.trim() : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Main collector
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
export async function collect(pricingData = null) {
|
|
303
|
+
// Scan active, archived, and optional headless Codex outputs.
|
|
304
|
+
const roots = [...getSessionRoots(), ...getHeadlessRoots()];
|
|
305
|
+
const nestedPaths = await Promise.all(roots.map((root) => collectJsonlFiles(root)));
|
|
306
|
+
const filePaths = [...new Set(nestedPaths.flat())];
|
|
307
|
+
|
|
308
|
+
const dailyMap = new Map(); // "date::model" → aggregated
|
|
309
|
+
const wmMap = new Map(); // "workspace::model" → aggregated
|
|
310
|
+
const seenEventKeys = new Set();
|
|
311
|
+
|
|
312
|
+
for (const filePath of filePaths) {
|
|
313
|
+
const sessionId = basename(filePath).replace(/\.jsonl$/, '');
|
|
314
|
+
const events = await parseSessionFile(filePath, sessionId);
|
|
315
|
+
|
|
316
|
+
for (const { timestamp, date, model, workspace, tokens } of events) {
|
|
317
|
+
const eventKey = codexEventDedupKey({ timestamp, model, tokens });
|
|
318
|
+
if (eventKey && seenEventKeys.has(eventKey)) continue;
|
|
319
|
+
if (eventKey) seenEventKeys.add(eventKey);
|
|
320
|
+
|
|
321
|
+
const workspaceKey = workspace || sessionId;
|
|
322
|
+
|
|
323
|
+
// Daily
|
|
324
|
+
const dk = `${date}::${model}`;
|
|
325
|
+
if (!dailyMap.has(dk)) dailyMap.set(dk, { date, model, ...zero(), cost: 0 });
|
|
326
|
+
addInto(dailyMap.get(dk), tokens);
|
|
327
|
+
|
|
328
|
+
// Workspace+model
|
|
329
|
+
const wmk = `${workspaceKey}::${model}`;
|
|
330
|
+
if (!wmMap.has(wmk)) {
|
|
331
|
+
wmMap.set(wmk, {
|
|
332
|
+
workspace: workspaceKey,
|
|
333
|
+
workspaceLabel: decodeWorkspace(workspaceKey),
|
|
334
|
+
model,
|
|
335
|
+
...zero(),
|
|
336
|
+
cost: 0
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
addInto(wmMap.get(wmk), tokens);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return buildOutput(dailyMap, wmMap, pricingData);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function codexEventDedupKey({ timestamp, model, tokens }) {
|
|
347
|
+
if (!timestamp) return null;
|
|
348
|
+
return [
|
|
349
|
+
timestamp,
|
|
350
|
+
model,
|
|
351
|
+
tokens.input,
|
|
352
|
+
tokens.output,
|
|
353
|
+
tokens.cacheRead,
|
|
354
|
+
tokens.cacheWrite,
|
|
355
|
+
tokens.reasoning
|
|
356
|
+
].join('::');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Attempt to produce a human-readable label from a raw workspace path.
|
|
361
|
+
* Codex cwd values are already absolute paths, so just return as-is.
|
|
362
|
+
*/
|
|
363
|
+
function decodeWorkspace(raw) {
|
|
364
|
+
return raw;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Convert to common collector JSON
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
function buildOutput(dailyMap, wmMap, pricingData) {
|
|
372
|
+
const byDate = new Map();
|
|
373
|
+
for (const row of dailyMap.values()) {
|
|
374
|
+
if (!byDate.has(row.date)) byDate.set(row.date, []);
|
|
375
|
+
byDate.get(row.date).push(row);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const contributions = [...byDate.entries()]
|
|
379
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
380
|
+
.map(([date, rows]) => ({
|
|
381
|
+
date,
|
|
382
|
+
clients: rows.map(row => {
|
|
383
|
+
const tokens = {
|
|
384
|
+
input: row.input,
|
|
385
|
+
output: row.output,
|
|
386
|
+
cacheRead: row.cacheRead,
|
|
387
|
+
cacheWrite: row.cacheWrite,
|
|
388
|
+
reasoning: row.reasoning,
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
client: CLIENT_KEY,
|
|
392
|
+
modelId: row.model,
|
|
393
|
+
tokens,
|
|
394
|
+
cost: calculateCost(row.model, tokens, pricingData),
|
|
395
|
+
};
|
|
396
|
+
})
|
|
397
|
+
}));
|
|
398
|
+
|
|
399
|
+
const entries = [...wmMap.values()].map(wm => {
|
|
400
|
+
const tokens = {
|
|
401
|
+
input: wm.input,
|
|
402
|
+
output: wm.output,
|
|
403
|
+
cacheRead: wm.cacheRead,
|
|
404
|
+
cacheWrite: wm.cacheWrite,
|
|
405
|
+
reasoning: wm.reasoning,
|
|
406
|
+
};
|
|
407
|
+
return {
|
|
408
|
+
client: CLIENT_KEY,
|
|
409
|
+
workspaceKey: wm.workspace,
|
|
410
|
+
workspaceLabel: wm.workspaceLabel,
|
|
411
|
+
model: wm.model,
|
|
412
|
+
...tokens,
|
|
413
|
+
cost: calculateCost(wm.model, tokens, pricingData),
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
return { graphJson: { contributions }, modelsJson: { entries } };
|
|
418
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { configuredPaths, expandPath } from '../collector-config.mjs';
|
|
3
|
+
import { collectStructuredUsage } from './structured-usage.mjs';
|
|
4
|
+
|
|
5
|
+
export const CLIENT_KEY = 'copilot';
|
|
6
|
+
export const SOURCE_LABEL = 'GitHub Copilot CLI';
|
|
7
|
+
|
|
8
|
+
export function roots() {
|
|
9
|
+
return configuredPaths('copilot', 'roots', [
|
|
10
|
+
'~/.config/github-copilot',
|
|
11
|
+
'~/.copilot',
|
|
12
|
+
'~/Library/Application Support/github-copilot',
|
|
13
|
+
process.env.APPDATA ? join(process.env.APPDATA, 'GitHub Copilot') : null
|
|
14
|
+
]).map(expandPath).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function collect(pricingData = null) {
|
|
18
|
+
return collectStructuredUsage({ clientKey: CLIENT_KEY, roots: roots(), pricingData });
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { configuredPaths, expandPath } from '../collector-config.mjs';
|
|
4
|
+
import { collectStructuredUsage } from './structured-usage.mjs';
|
|
5
|
+
|
|
6
|
+
export const CLIENT_KEY = 'cursor';
|
|
7
|
+
export const SOURCE_LABEL = 'Cursor';
|
|
8
|
+
|
|
9
|
+
export function roots() {
|
|
10
|
+
const appData = process.env.APPDATA;
|
|
11
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
12
|
+
return configuredPaths('cursor', 'roots', [
|
|
13
|
+
appData ? join(appData, 'Cursor') : null,
|
|
14
|
+
localAppData ? join(localAppData, 'Cursor') : null,
|
|
15
|
+
join(homedir(), '.cursor'),
|
|
16
|
+
'~/.config/Cursor',
|
|
17
|
+
'~/Library/Application Support/Cursor'
|
|
18
|
+
]).map(expandPath).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function collect(pricingData = null) {
|
|
22
|
+
return collectStructuredUsage({ clientKey: CLIENT_KEY, roots: roots(), pricingData });
|
|
23
|
+
}
|