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,530 @@
1
+ /**
2
+ * Gemini CLI data collector (pure JS).
3
+ *
4
+ * Scans ~/.gemini/tmp/ for session files in two layouts:
5
+ *
6
+ * Legacy JSON ~/.gemini/tmp/session-<id>.json
7
+ * Modern JSON ~/.gemini/tmp/<project_hash>/chats/<file>.json
8
+ * Modern JSONL ~/.gemini/tmp/<project_hash>/chats/<file>.jsonl
9
+ * (also: root-level session-*.jsonl)
10
+ *
11
+ * Token-count normalisation:
12
+ * Gemini's "input" in session files is cache-inclusive, i.e. it already
13
+ * contains the cached portion. We separate them:
14
+ * net_input = input − cached (clamped to ≥ 0)
15
+ * cache_read = cached
16
+ *
17
+ * Cache normalization uses Gemini's own total fields where available,
18
+ * including the "total" cross-check for session-format files.
19
+ */
20
+
21
+ import { readdir, readFile, stat } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { join, extname, basename } from 'node:path';
24
+ import { calculateCost } from '../pricing.mjs';
25
+ import { localDateFromTimestamp, normalizeModelForGrouping } from './utils.mjs';
26
+
27
+ export const CLIENT_KEY = 'gemini';
28
+ export const SOURCE_LABEL = 'Gemini CLI';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Path resolution
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function getTmpDir() {
35
+ return join(homedir(), '.gemini', 'tmp');
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ async function safeReaddir(dir) {
43
+ try {
44
+ return await readdir(dir, { withFileTypes: true });
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ async function safeReadFile(filePath) {
51
+ try {
52
+ return await readFile(filePath, 'utf8');
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function fileMtime(filePath) {
59
+ try {
60
+ const s = await stat(filePath);
61
+ return s.mtimeMs;
62
+ } catch {
63
+ return Date.now();
64
+ }
65
+ }
66
+
67
+ function pos(v) {
68
+ const n = Number(v ?? 0);
69
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
70
+ }
71
+
72
+ function zero() {
73
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
74
+ }
75
+
76
+ function addInto(agg, t) {
77
+ agg.input += t.input;
78
+ agg.output += t.output;
79
+ agg.cacheRead += t.cacheRead;
80
+ agg.cacheWrite += t.cacheWrite;
81
+ agg.reasoning += t.reasoning;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Cache normalisation
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * "Headless" path (stats object): Gemini's promptTokenCount is cache-inclusive,
90
+ * so subtract the cached portion from input.
91
+ */
92
+ function normaliseHeadlessInputAndCache(input, cached) {
93
+ const i = Math.max(0, input);
94
+ const c = Math.max(0, cached);
95
+ const cPortion = Math.min(c, i);
96
+ return { netInput: i - cPortion, cacheRead: c };
97
+ }
98
+
99
+ /**
100
+ * "Session" path (full session format):
101
+ * Use the total field (when available) to determine whether input is already
102
+ * net (exclusive) or still cache-inclusive.
103
+ *
104
+ * If total == input + output + reasoning + tool (inclusive formula matches),
105
+ * then input still contains cached, so subtract.
106
+ *
107
+ * If total == input + output + reasoning + tool + cached (exclusive formula
108
+ * matches), then input is already net — keep as-is.
109
+ */
110
+ function normaliseSessionInputAndCache(input, cached, output, reasoning, tool, total) {
111
+ const i = Math.max(0, input);
112
+ const c = Math.max(0, cached);
113
+ const o = Math.max(0, output);
114
+ const r = Math.max(0, reasoning);
115
+ const tk = Math.max(0, tool);
116
+
117
+ if (total == null) {
118
+ // No total hint — fall back to headless logic
119
+ const cPortion = Math.min(c, i);
120
+ return { netInput: i - cPortion, cacheRead: c };
121
+ }
122
+
123
+ const t = Math.max(0, total);
124
+ const inclusiveTotal = i + o + r + tk; // cached still inside input
125
+ const exclusiveTotal = inclusiveTotal + c; // cached separately added
126
+
127
+ // If total matches the inclusive formula (and not exclusive), input is still
128
+ // cache-inclusive → subtract.
129
+ if (c > 0 && t === inclusiveTotal && t !== exclusiveTotal) {
130
+ const cPortion = Math.min(c, i);
131
+ return { netInput: i - cPortion, cacheRead: c };
132
+ }
133
+
134
+ // Otherwise treat input as already net
135
+ return { netInput: i, cacheRead: c };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Session-format JSON parser (full GeminiSession structure)
140
+ // ---------------------------------------------------------------------------
141
+
142
+ function parseSessionJson(obj, fallbackDate) {
143
+ const events = [];
144
+ const sessionId = obj.sessionId || obj.session_id || 'unknown';
145
+ const messages = Array.isArray(obj.messages) ? obj.messages : [];
146
+
147
+ for (const msg of messages) {
148
+ if (msg.type !== 'gemini') continue;
149
+ const model = msg.model;
150
+ if (!model) continue;
151
+ const tokens = msg.tokens;
152
+ if (!tokens) continue;
153
+
154
+ let date = fallbackDate;
155
+ if (msg.timestamp) {
156
+ date = localDateFromTimestamp(msg.timestamp, fallbackDate);
157
+ }
158
+
159
+ const input = pos(tokens.input);
160
+ const output = pos(tokens.output);
161
+ const cached = pos(tokens.cached);
162
+ const reasoning = pos(tokens.thoughts);
163
+ const tool = pos(tokens.tool);
164
+ const total = tokens.total != null ? pos(tokens.total) : null;
165
+
166
+ const { netInput, cacheRead } = normaliseSessionInputAndCache(
167
+ input, cached, output, reasoning, tool, total
168
+ );
169
+
170
+ events.push({
171
+ sessionId,
172
+ date,
173
+ model: normalizeModelForGrouping(model),
174
+ tokens: {
175
+ input: netInput + tool, // tool tokens count as input (mirrors Rust)
176
+ output,
177
+ cacheRead,
178
+ cacheWrite: 0,
179
+ reasoning
180
+ }
181
+ });
182
+ }
183
+
184
+ return events;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Headless JSON parser (stats object)
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function parseHeadlessStats(stats, modelHint, date, sessionId) {
192
+ const events = [];
193
+
194
+ // Try per-model breakdown first
195
+ const models = stats.models;
196
+ if (models && typeof models === 'object') {
197
+ for (const [modelName, data] of Object.entries(models)) {
198
+ const t = data.tokens || {};
199
+ const input = pos(t.prompt ?? t.input ?? t.input_tokens);
200
+ const output = pos(t.candidates ?? t.output ?? t.output_tokens);
201
+ const cached = pos(t.cached ?? t.cached_tokens);
202
+ const reasoning = pos(t.thoughts ?? t.reasoning);
203
+
204
+ if (input === 0 && output === 0 && cached === 0 && reasoning === 0) continue;
205
+
206
+ const { netInput, cacheRead } = normaliseHeadlessInputAndCache(input, cached);
207
+ events.push({
208
+ sessionId,
209
+ date,
210
+ model: normalizeModelForGrouping(modelName),
211
+ tokens: { input: netInput, output, cacheRead, cacheWrite: 0, reasoning }
212
+ });
213
+ }
214
+ if (events.length > 0) return events;
215
+ }
216
+
217
+ // Flat stats fallback
218
+ const input = pos(stats.input_tokens ?? stats.prompt_tokens);
219
+ const output = pos(stats.output_tokens ?? stats.candidates_tokens);
220
+ const cached = pos(stats.cached_tokens);
221
+ const reasoning = pos(stats.thoughts_tokens ?? stats.reasoning_tokens);
222
+
223
+ if (input === 0 && output === 0 && cached === 0 && reasoning === 0) return [];
224
+
225
+ const { netInput, cacheRead } = normaliseHeadlessInputAndCache(input, cached);
226
+ events.push({
227
+ sessionId,
228
+ date,
229
+ model: normalizeModelForGrouping(modelHint || 'unknown'),
230
+ tokens: { input: netInput, output, cacheRead, cacheWrite: 0, reasoning }
231
+ });
232
+
233
+ return events;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // JSON file parser (handles both full-session and headless formats)
238
+ // ---------------------------------------------------------------------------
239
+
240
+ async function parseJsonFile(filePath, fallbackDate, sessionId) {
241
+ const text = await safeReadFile(filePath);
242
+ if (!text) return [];
243
+
244
+ let obj;
245
+ try { obj = JSON.parse(text); } catch { return []; }
246
+
247
+ // Full session format
248
+ if (obj.sessionId || obj.session_id) {
249
+ return parseSessionJson(obj, fallbackDate);
250
+ }
251
+
252
+ // Direct gemini event
253
+ if (obj.type === 'gemini') {
254
+ return parseSingleGeminiEvent(obj, sessionId, fallbackDate);
255
+ }
256
+
257
+ // Headless: stats object
258
+ const stats = obj.stats ?? obj.result?.stats;
259
+ if (stats) {
260
+ const date = extractDateFromValue(obj) ?? fallbackDate;
261
+ const modelHint = typeof obj.model === 'string' ? obj.model : null;
262
+ return parseHeadlessStats(stats, modelHint, date, sessionId);
263
+ }
264
+
265
+ return [];
266
+ }
267
+
268
+ function parseSingleGeminiEvent(obj, sessionId, fallbackDate) {
269
+ const model = typeof obj.model === 'string' ? obj.model : null;
270
+ const tokens = obj.tokens;
271
+ if (!model || !tokens) return [];
272
+
273
+ const date = extractDateFromValue(obj) ?? fallbackDate;
274
+
275
+ const input = pos(tokens.input);
276
+ const output = pos(tokens.output);
277
+ const cached = pos(tokens.cached);
278
+ const reasoning = pos(tokens.thoughts);
279
+ const tool = pos(tokens.tool);
280
+ const total = tokens.total != null ? pos(tokens.total) : null;
281
+
282
+ const { netInput, cacheRead } = normaliseSessionInputAndCache(
283
+ input, cached, output, reasoning, tool, total
284
+ );
285
+
286
+ return [{
287
+ sessionId,
288
+ date,
289
+ model: normalizeModelForGrouping(model),
290
+ tokens: { input: netInput + tool, output, cacheRead, cacheWrite: 0, reasoning }
291
+ }];
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // JSONL file parser (streaming format)
296
+ // ---------------------------------------------------------------------------
297
+
298
+ async function parseJsonlFile(filePath, fallbackDate) {
299
+ const text = await safeReadFile(filePath);
300
+ if (!text) return [];
301
+
302
+ let sessionId = basename(filePath, extname(filePath));
303
+ let currentModel = null;
304
+ const events = [];
305
+ // Track direct message IDs for dedup (Gemini may emit the same ID twice with updated data)
306
+ const directMsgIndex = new Map(); // id → index in events
307
+
308
+ for (const raw of text.split('\n')) {
309
+ const line = raw.trim();
310
+ if (!line) continue;
311
+
312
+ let obj;
313
+ try { obj = JSON.parse(line); } catch { continue; }
314
+
315
+ const type = obj.type;
316
+
317
+ // ── init ──
318
+ if (type === 'init') {
319
+ if (typeof obj.model === 'string') currentModel = obj.model;
320
+ const id = obj.session_id ?? obj.sessionId;
321
+ if (typeof id === 'string') sessionId = id;
322
+ continue;
323
+ }
324
+
325
+ // Update session ID from any line that carries it
326
+ const lineSessionId = obj.session_id ?? obj.sessionId;
327
+ if (typeof lineSessionId === 'string') sessionId = lineSessionId;
328
+
329
+ // ── gemini turn (direct token object) ──
330
+ if (type === 'gemini') {
331
+ if (typeof obj.model === 'string') currentModel = obj.model;
332
+
333
+ const parsed = parseSingleGeminiEvent(
334
+ obj,
335
+ sessionId,
336
+ extractDateFromValue(obj) ?? fallbackDate
337
+ );
338
+ if (parsed.length > 0) {
339
+ const msgId = typeof obj.id === 'string' ? obj.id : null;
340
+ if (msgId) {
341
+ if (directMsgIndex.has(msgId)) {
342
+ // Replace with updated data
343
+ events[directMsgIndex.get(msgId)] = parsed[0];
344
+ } else {
345
+ directMsgIndex.set(msgId, events.length);
346
+ events.push(parsed[0]);
347
+ }
348
+ } else {
349
+ events.push(parsed[0]);
350
+ }
351
+ }
352
+ continue;
353
+ }
354
+
355
+ // ── result / any line with stats ──
356
+ const stats = obj.stats ?? obj.result?.stats;
357
+ if (stats) {
358
+ const date = extractDateFromValue(obj) ?? fallbackDate;
359
+ const modelHint = currentModel;
360
+ const parsed = parseHeadlessStats(stats, modelHint, date, sessionId);
361
+ events.push(...parsed);
362
+ }
363
+ }
364
+
365
+ return events;
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Date extractor
370
+ // ---------------------------------------------------------------------------
371
+
372
+ function extractDateFromValue(obj) {
373
+ const raw = obj.timestamp ?? obj.created_at;
374
+ if (!raw) return null;
375
+ return localDateFromTimestamp(raw, null);
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Directory scanner
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /**
383
+ * Decide whether a given relative path under ~/.gemini/tmp/ is a valid
384
+ * Gemini session file we should parse.
385
+ *
386
+ * Accepted patterns:
387
+ * session-<anything>.json[l] (legacy, directly in tmp/)
388
+ * <hash>/chats/<filename>.json[l] (modern layout)
389
+ */
390
+ function isAcceptedFile(entry, parentName) {
391
+ const name = entry.name;
392
+ const ext = extname(name).toLowerCase();
393
+ if (ext !== '.json' && ext !== '.jsonl') return false;
394
+
395
+ // Legacy: filename starts with "session-"
396
+ if (name.startsWith('session-')) return true;
397
+
398
+ // Modern: parent directory is "chats"
399
+ if (parentName === 'chats') return true;
400
+
401
+ return false;
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Main collector
406
+ // ---------------------------------------------------------------------------
407
+
408
+ export async function collect(pricingData = null) {
409
+ const tmpDir = getTmpDir();
410
+ const topEntries = await safeReaddir(tmpDir);
411
+
412
+ const dailyMap = new Map();
413
+ const wmMap = new Map();
414
+
415
+ /**
416
+ * Accumulate parsed events into the two aggregate maps.
417
+ */
418
+ function accumulate(events) {
419
+ for (const { sessionId, date, model, tokens } of events) {
420
+ // Daily
421
+ const dk = `${date}::${model}`;
422
+ if (!dailyMap.has(dk)) dailyMap.set(dk, { date, model, ...zero(), cost: 0 });
423
+ addInto(dailyMap.get(dk), tokens);
424
+
425
+ // Workspace+model (use sessionId as workspace key for Gemini)
426
+ const wmk = `${sessionId}::${model}`;
427
+ if (!wmMap.has(wmk)) {
428
+ wmMap.set(wmk, {
429
+ workspace: sessionId,
430
+ workspaceLabel: sessionId,
431
+ model,
432
+ ...zero(),
433
+ cost: 0
434
+ });
435
+ }
436
+ addInto(wmMap.get(wmk), tokens);
437
+ }
438
+ }
439
+
440
+ for (const entry of topEntries) {
441
+ const entryPath = join(tmpDir, entry.name);
442
+
443
+ if (entry.isFile() && isAcceptedFile(entry, /* parentName */ '')) {
444
+ // Legacy root-level session file
445
+ const fallbackDate = localDateFromTimestamp(await fileMtime(entryPath));
446
+ const sessionId = basename(entry.name, extname(entry.name));
447
+ const ext = extname(entry.name).toLowerCase();
448
+ const events = ext === '.jsonl'
449
+ ? await parseJsonlFile(entryPath, fallbackDate)
450
+ : await parseJsonFile(entryPath, fallbackDate, sessionId);
451
+ accumulate(events);
452
+ continue;
453
+ }
454
+
455
+ if (entry.isDirectory()) {
456
+ // Modern layout: <hash>/chats/
457
+ const chatsDir = join(entryPath, 'chats');
458
+ const chatsEntries = await safeReaddir(chatsDir);
459
+
460
+ for (const chatEntry of chatsEntries) {
461
+ if (!chatEntry.isFile() || !isAcceptedFile(chatEntry, 'chats')) continue;
462
+
463
+ const filePath = join(chatsDir, chatEntry.name);
464
+ const fallbackDate = localDateFromTimestamp(await fileMtime(filePath));
465
+ const sessionId = basename(chatEntry.name, extname(chatEntry.name));
466
+ const ext = extname(chatEntry.name).toLowerCase();
467
+
468
+ const events = ext === '.jsonl'
469
+ ? await parseJsonlFile(filePath, fallbackDate)
470
+ : await parseJsonFile(filePath, fallbackDate, sessionId);
471
+ accumulate(events);
472
+ }
473
+ }
474
+ }
475
+
476
+ return buildOutput(dailyMap, wmMap, pricingData);
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Convert to common collector JSON
481
+ // ---------------------------------------------------------------------------
482
+
483
+ function buildOutput(dailyMap, wmMap, pricingData) {
484
+ const byDate = new Map();
485
+ for (const row of dailyMap.values()) {
486
+ if (!byDate.has(row.date)) byDate.set(row.date, []);
487
+ byDate.get(row.date).push(row);
488
+ }
489
+
490
+ const contributions = [...byDate.entries()]
491
+ .sort(([a], [b]) => a.localeCompare(b))
492
+ .map(([date, rows]) => ({
493
+ date,
494
+ clients: rows.map(row => {
495
+ const tokens = {
496
+ input: row.input,
497
+ output: row.output,
498
+ cacheRead: row.cacheRead,
499
+ cacheWrite: row.cacheWrite,
500
+ reasoning: row.reasoning,
501
+ };
502
+ return {
503
+ client: CLIENT_KEY,
504
+ modelId: row.model,
505
+ tokens,
506
+ cost: calculateCost(row.model, tokens, pricingData),
507
+ };
508
+ })
509
+ }));
510
+
511
+ const entries = [...wmMap.values()].map(wm => {
512
+ const tokens = {
513
+ input: wm.input,
514
+ output: wm.output,
515
+ cacheRead: wm.cacheRead,
516
+ cacheWrite: wm.cacheWrite,
517
+ reasoning: wm.reasoning,
518
+ };
519
+ return {
520
+ client: CLIENT_KEY,
521
+ workspaceKey: wm.workspace,
522
+ workspaceLabel: wm.workspaceLabel,
523
+ model: wm.model,
524
+ ...tokens,
525
+ cost: calculateCost(wm.model, tokens, pricingData),
526
+ };
527
+ });
528
+
529
+ return { graphJson: { contributions }, modelsJson: { entries } };
530
+ }
@@ -0,0 +1,15 @@
1
+ import { configuredPaths, expandPath } from '../collector-config.mjs';
2
+ import { collectStructuredUsage } from './structured-usage.mjs';
3
+
4
+ export const CLIENT_KEY = 'goose';
5
+ export const SOURCE_LABEL = 'Goose';
6
+
7
+ export function roots() {
8
+ return configuredPaths('goose', 'roots', ['~/.config/goose', '~/.goose'])
9
+ .map(expandPath)
10
+ .filter(Boolean);
11
+ }
12
+
13
+ export async function collect(pricingData = null) {
14
+ return collectStructuredUsage({ clientKey: CLIENT_KEY, roots: roots(), pricingData });
15
+ }