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,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
|
+
}
|