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,206 @@
1
+ /**
2
+ * Hermes Agent data collector (pure JS).
3
+ *
4
+ * Reads aggregated session rows from Hermes Agent's SQLite state database:
5
+ * ~/.hermes/state.db (default)
6
+ * $HERMES_HOME/state.db (if env var is set)
7
+ *
8
+ * Requires Node.js >= 22.5.0 (built-in node:sqlite).
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { DatabaseSync } from 'node:sqlite';
14
+ import { configuredPath, expandPath } from '../collector-config.mjs';
15
+ import { calculateCost } from '../pricing.mjs';
16
+ import { canonicalProvider, inferProviderFromModel, localDateFromTimestamp, normalizeModelForGrouping } from './utils.mjs';
17
+
18
+ export const CLIENT_KEY = 'hermes';
19
+ export const SOURCE_LABEL = 'Hermes Agent';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Path resolution
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function getDbPath() {
26
+ const hermesHome = process.env.HERMES_HOME;
27
+ if (hermesHome) return join(expandPath(hermesHome), 'state.db');
28
+ return configuredPath('hermes', 'dbPath');
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function pos(value) {
36
+ const n = Number(value ?? 0);
37
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
38
+ }
39
+
40
+ function posFloat(value) {
41
+ const n = Number(value ?? 0);
42
+ return Number.isFinite(n) && n > 0 ? n : 0;
43
+ }
44
+
45
+ /** Convert a Hermes started_at float (seconds or ms) to a YYYY-MM-DD string. */
46
+ function tsToDate(started_at) {
47
+ if (!started_at) return 'unknown';
48
+ const n = Number(started_at);
49
+ if (!Number.isFinite(n)) return 'unknown';
50
+ return localDateFromTimestamp(n);
51
+ }
52
+
53
+ function zero() {
54
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
55
+ }
56
+
57
+ function add(agg, t) {
58
+ agg.input += t.input;
59
+ agg.output += t.output;
60
+ agg.cacheRead += t.cacheRead;
61
+ agg.cacheWrite += t.cacheWrite;
62
+ agg.reasoning += t.reasoning;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Main collector
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * @returns {{ graphJson: object, modelsJson: object }}
71
+ */
72
+ export async function collect(pricingData = null) {
73
+ const empty = {
74
+ graphJson: { contributions: [] },
75
+ modelsJson: { entries: [] }
76
+ };
77
+
78
+ const dbPath = getDbPath();
79
+ if (!existsSync(dbPath)) return empty;
80
+
81
+ let db;
82
+ try {
83
+ db = new DatabaseSync(dbPath);
84
+ } catch {
85
+ return empty;
86
+ }
87
+
88
+ let rows;
89
+ try {
90
+ rows = db.prepare(`
91
+ SELECT
92
+ id,
93
+ model,
94
+ billing_provider,
95
+ started_at,
96
+ COALESCE(input_tokens, 0) AS input_tokens,
97
+ COALESCE(output_tokens, 0) AS output_tokens,
98
+ COALESCE(cache_read_tokens, 0) AS cache_read_tokens,
99
+ COALESCE(cache_write_tokens, 0) AS cache_write_tokens,
100
+ COALESCE(reasoning_tokens, 0) AS reasoning_tokens,
101
+ COALESCE(actual_cost_usd, estimated_cost_usd, 0) AS cost_usd
102
+ FROM sessions
103
+ WHERE model IS NOT NULL
104
+ AND TRIM(model) != ''
105
+ AND (
106
+ COALESCE(input_tokens, 0) > 0 OR
107
+ COALESCE(output_tokens, 0) > 0 OR
108
+ COALESCE(cache_read_tokens, 0) > 0 OR
109
+ COALESCE(cache_write_tokens, 0) > 0 OR
110
+ COALESCE(reasoning_tokens, 0) > 0 OR
111
+ COALESCE(actual_cost_usd, estimated_cost_usd, 0) > 0
112
+ )
113
+ `).all();
114
+ } catch {
115
+ try { db.close(); } catch { /* ignore */ }
116
+ return empty;
117
+ }
118
+
119
+ try { db.close(); } catch { /* ignore */ }
120
+
121
+ const dailyMap = new Map(); // "date::model" -> aggregated
122
+ const wmMap = new Map(); // sessionId -> session-level record
123
+
124
+ for (const row of rows) {
125
+ const date = tsToDate(row.started_at);
126
+ const model = normalizeModelForGrouping(row.model || 'unknown');
127
+ const provider = canonicalProvider(row.billing_provider) || inferProviderFromModel(model) || 'hermes';
128
+ const tokens = {
129
+ input: pos(row.input_tokens),
130
+ output: pos(row.output_tokens),
131
+ cacheRead: pos(row.cache_read_tokens),
132
+ cacheWrite: pos(row.cache_write_tokens),
133
+ reasoning: pos(row.reasoning_tokens)
134
+ };
135
+ const calculatedCost = calculateCost(model, tokens, pricingData, provider);
136
+ const cost = calculatedCost;
137
+ const sessId = String(row.id || 'unknown');
138
+
139
+ // Daily aggregation
140
+ const dk = `${date}::${model}`;
141
+ if (!dailyMap.has(dk)) dailyMap.set(dk, { date, model, provider, ...zero(), cost: 0 });
142
+ const d = dailyMap.get(dk);
143
+ add(d, tokens);
144
+ d.cost += cost;
145
+
146
+ // Per-session record (each Hermes row IS a fully-aggregated session)
147
+ wmMap.set(sessId, {
148
+ workspace: sessId,
149
+ workspaceLabel: sessId,
150
+ model,
151
+ provider,
152
+ ...tokens,
153
+ cost
154
+ });
155
+ }
156
+
157
+ return buildOutput(dailyMap, wmMap);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Convert to common collector JSON
162
+ // ---------------------------------------------------------------------------
163
+
164
+ function buildOutput(dailyMap, wmMap) {
165
+ // Graph JSON
166
+ const byDate = new Map();
167
+ for (const row of dailyMap.values()) {
168
+ if (!byDate.has(row.date)) byDate.set(row.date, []);
169
+ byDate.get(row.date).push(row);
170
+ }
171
+
172
+ const contributions = [...byDate.entries()]
173
+ .sort(([a], [b]) => a.localeCompare(b))
174
+ .map(([date, rows]) => ({
175
+ date,
176
+ clients: rows.map(row => ({
177
+ client: CLIENT_KEY,
178
+ modelId: row.model,
179
+ tokens: {
180
+ input: row.input,
181
+ output: row.output,
182
+ cacheRead: row.cacheRead,
183
+ cacheWrite: row.cacheWrite,
184
+ reasoning: row.reasoning
185
+ },
186
+ cost: row.cost
187
+ }))
188
+ }));
189
+
190
+ // Models JSON
191
+ const entries = [...wmMap.values()].map(wm => ({
192
+ client: CLIENT_KEY,
193
+ workspaceKey: wm.workspace,
194
+ workspaceLabel: wm.workspaceLabel,
195
+ model: wm.model,
196
+ provider: wm.provider,
197
+ input: wm.input,
198
+ output: wm.output,
199
+ cacheRead: wm.cacheRead,
200
+ cacheWrite: wm.cacheWrite,
201
+ reasoning: wm.reasoning,
202
+ cost: wm.cost
203
+ }));
204
+
205
+ return { graphJson: { contributions }, modelsJson: { entries } };
206
+ }
@@ -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 = 'kimi';
5
+ export const SOURCE_LABEL = 'Kimi / Moonshot Coding CLI';
6
+
7
+ export function roots() {
8
+ return configuredPaths('kimi', 'roots', ['~/.kimi', '~/.moonshot'])
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
+ }
@@ -0,0 +1,400 @@
1
+ /**
2
+ * OpenClaw data collector (pure JS).
3
+ *
4
+ * Scans agent directories in multiple roots for JSONL transcript files:
5
+ *
6
+ * Primary: ~/.openclaw/agents/<agentId>/sessions/*.jsonl[*]
7
+ * Legacy: ~/.clawdbot/agents/...
8
+ * ~/.moltbot/agents/...
9
+ * ~/.moldbot/agents/...
10
+ *
11
+ * Supported file variants:
12
+ * <sessionId>.jsonl live transcript
13
+ * <sessionId>.jsonl.deleted.<timestamp> archived
14
+ * <sessionId>.jsonl.reset.<timestamp> reset
15
+ * sessions.json index file (legacy)
16
+ *
17
+ * JSONL event types:
18
+ * model_change – { type, modelId, provider }
19
+ * custom – { type, customType:"model-snapshot", data:{ modelId, provider } }
20
+ * message – { type, message:{ role:"assistant", model, provider,
21
+ * timestamp, usage:{ input, output, cacheRead, cacheWrite,
22
+ * totalTokens, cost:{ total } } } }
23
+ *
24
+ * Only assistant messages with a resolved model are counted.
25
+ */
26
+
27
+ import { readdir, readFile, stat } from 'node:fs/promises';
28
+ import { existsSync } from 'node:fs';
29
+ import { join, basename, extname } from 'node:path';
30
+ import { configuredPaths } from '../collector-config.mjs';
31
+ import { calculateCost } from '../pricing.mjs';
32
+ import { canonicalProvider, localDateFromTimestamp, normalizeModelForGrouping } from './utils.mjs';
33
+
34
+ export const CLIENT_KEY = 'openclaw';
35
+ export const SOURCE_LABEL = 'OpenClaw';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Path helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** All roots that may contain OpenClaw agent data. */
42
+ function getAgentRoots() {
43
+ return configuredPaths('openclaw', 'agentRoots');
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Low-level file helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ async function safeReaddir(dir) {
51
+ try { return await readdir(dir, { withFileTypes: true }); } catch { return []; }
52
+ }
53
+
54
+ async function safeReadFile(filePath) {
55
+ try { return await readFile(filePath, 'utf8'); } catch { return null; }
56
+ }
57
+
58
+ async function fileMtimeMs(filePath) {
59
+ try { return (await stat(filePath)).mtimeMs; } catch { return Date.now(); }
60
+ }
61
+
62
+ function pos(v) {
63
+ const n = Number(v ?? 0);
64
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
65
+ }
66
+
67
+ function zero() {
68
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
69
+ }
70
+
71
+ function addInto(agg, t) {
72
+ agg.input += t.input;
73
+ agg.output += t.output;
74
+ agg.cacheRead += t.cacheRead;
75
+ agg.cacheWrite += t.cacheWrite;
76
+ agg.reasoning += t.reasoning;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Session ID extraction
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Derive a session ID from a filename that may be:
85
+ * abc-123.jsonl
86
+ * abc-123.jsonl.deleted.1700000000000
87
+ * abc-123.jsonl.reset.2026-03-20T06-34-44.520Z
88
+ *
89
+ * Strategy: split on the first occurrence of ".jsonl" and take the prefix.
90
+ */
91
+ function sessionIdFromFilename(name) {
92
+ const idx = name.indexOf('.jsonl');
93
+ return idx > 0 ? name.slice(0, idx) : basename(name, extname(name));
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Determine whether a file should be parsed
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function isTranscriptFile(name) {
101
+ if (name === 'sessions.json') return false; // handled separately
102
+ if (name.endsWith('.json')) return false; // other json, not JSONL
103
+ return name.endsWith('.jsonl')
104
+ || name.includes('.jsonl.deleted.')
105
+ || name.includes('.jsonl.reset.');
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Index file parser (sessions.json)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Parse a sessions.json index:
114
+ * { "agent:main:main": { sessionId: "...", sessionFile?: "..." }, ... }
115
+ *
116
+ * Returns an array of { sessionId, filePath } objects whose files exist.
117
+ */
118
+ async function parseIndexFile(indexPath) {
119
+ const text = await safeReadFile(indexPath);
120
+ if (!text) return [];
121
+
122
+ let obj;
123
+ try { obj = JSON.parse(text); } catch { return []; }
124
+
125
+ const indexDir = indexPath.slice(0, indexPath.lastIndexOf('/'));
126
+ const results = [];
127
+
128
+ for (const entry of Object.values(obj)) {
129
+ if (!entry || typeof entry.sessionId !== 'string') continue;
130
+ const sessionId = entry.sessionId;
131
+
132
+ // Resolve session file path
133
+ let filePath;
134
+ const sf = typeof entry.sessionFile === 'string' ? entry.sessionFile.trim() : '';
135
+ if (sf) {
136
+ filePath = sf.startsWith('/') ? sf : join(indexDir, sf);
137
+ } else {
138
+ filePath = join(indexDir, `${sessionId}.jsonl`);
139
+ }
140
+
141
+ if (existsSync(filePath)) {
142
+ results.push({ sessionId, filePath });
143
+ }
144
+ }
145
+
146
+ return results;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // JSONL session parser
151
+ // ---------------------------------------------------------------------------
152
+
153
+ async function parseSessionFile(filePath, sessionId, agentPath) {
154
+ const text = await safeReadFile(filePath);
155
+ if (!text) return [];
156
+
157
+ const fallbackTimestamp = await fileMtimeMs(filePath);
158
+ const fallbackDate = localDateFromTimestamp(fallbackTimestamp);
159
+
160
+ let currentModel = null;
161
+ let currentProvider = null;
162
+ const events = [];
163
+
164
+ for (const raw of text.split('\n')) {
165
+ const line = raw.trim();
166
+ if (!line) continue;
167
+
168
+ let entry;
169
+ try { entry = JSON.parse(line); } catch { continue; }
170
+
171
+ const type = entry.type;
172
+
173
+ // ── model_change ──────────────────────────────────────────────────────
174
+ if (type === 'model_change') {
175
+ if (typeof entry.modelId === 'string' && entry.modelId)
176
+ currentModel = entry.modelId;
177
+ if (typeof entry.provider === 'string' && entry.provider)
178
+ currentProvider = entry.provider;
179
+ continue;
180
+ }
181
+
182
+ // ── custom / model-snapshot ───────────────────────────────────────────
183
+ if (type === 'custom' && entry.customType === 'model-snapshot') {
184
+ const d = entry.data;
185
+ if (d) {
186
+ if (typeof d.modelId === 'string' && d.modelId)
187
+ currentModel = d.modelId;
188
+ if (typeof d.provider === 'string' && d.provider)
189
+ currentProvider = d.provider;
190
+ }
191
+ continue;
192
+ }
193
+
194
+ // ── message ───────────────────────────────────────────────────────────
195
+ if (type === 'message') {
196
+ const msg = entry.message;
197
+ if (!msg || msg.role !== 'assistant') continue;
198
+
199
+ const usage = msg.usage;
200
+ if (!usage) continue;
201
+
202
+ // Model resolution: message-embedded → current state
203
+ const model =
204
+ (typeof msg.model === 'string' && msg.model ? msg.model : null) ||
205
+ (typeof currentModel === 'string' && currentModel ? currentModel : null);
206
+
207
+ if (!model) continue; // no model resolved — skip (mirrors Rust)
208
+
209
+ const provider =
210
+ (typeof msg.provider === 'string' && msg.provider ? msg.provider : null) ||
211
+ (typeof currentProvider === 'string' && currentProvider ? currentProvider : null) ||
212
+ 'unknown';
213
+
214
+ currentModel = model;
215
+ currentProvider = provider;
216
+
217
+ // Date from message timestamp (milliseconds since epoch)
218
+ let date = fallbackDate;
219
+ if (msg.timestamp != null) {
220
+ date = localDateFromTimestamp(msg.timestamp, fallbackDate);
221
+ }
222
+
223
+ const cost = (usage.cost && usage.cost.total != null)
224
+ ? Math.max(0, Number(usage.cost.total) || 0)
225
+ : 0;
226
+
227
+ events.push({
228
+ sessionId,
229
+ agentPath,
230
+ date,
231
+ model: normalizeModelForGrouping(model),
232
+ provider: canonicalProvider(provider) || provider,
233
+ tokens: {
234
+ input: pos(usage.input),
235
+ output: pos(usage.output),
236
+ cacheRead: pos(usage.cacheRead),
237
+ cacheWrite: pos(usage.cacheWrite),
238
+ reasoning: 0
239
+ },
240
+ cost
241
+ });
242
+ }
243
+ }
244
+
245
+ return events;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Directory scanner — walks one agents root
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * Scan one agents root (e.g. ~/.openclaw/agents).
254
+ * Layout: <root>/<agentId>/sessions/<files>
255
+ *
256
+ * We use a two-level walk: agentId dirs → sessions subdir → files.
257
+ * This mirrors the real layout observed in tests and scanner.rs.
258
+ *
259
+ * Also tolerates a flatter layout where transcripts sit directly under
260
+ * <agentId>/ without a "sessions" subdir (for forward-compat).
261
+ */
262
+ async function scanAgentsRoot(root) {
263
+ const events = [];
264
+
265
+ const agentEntries = await safeReaddir(root);
266
+ for (const agentEntry of agentEntries) {
267
+ if (!agentEntry.isDirectory()) continue;
268
+
269
+ const agentDir = join(root, agentEntry.name);
270
+ const agentPath = agentDir; // use as workspace key
271
+
272
+ // Prefer <agentId>/sessions/ if it exists, else fall back to <agentId>/
273
+ const sessionsDir = join(agentDir, 'sessions');
274
+ const targetDir = existsSync(sessionsDir) ? sessionsDir : agentDir;
275
+ const fileEntries = await safeReaddir(targetDir);
276
+
277
+ // --- index file first (to avoid double-counting files referenced by index)
278
+ const indexRefs = new Set();
279
+ const indexEntry = fileEntries.find(e => e.isFile() && e.name === 'sessions.json');
280
+ if (indexEntry) {
281
+ const indexPath = join(targetDir, 'sessions.json');
282
+ const indexed = await parseIndexFile(indexPath);
283
+ for (const { sessionId, filePath } of indexed) {
284
+ indexRefs.add(filePath);
285
+ const ev = await parseSessionFile(filePath, sessionId, agentPath);
286
+ events.push(...ev);
287
+ }
288
+ }
289
+
290
+ // --- individual transcript files
291
+ for (const fileEntry of fileEntries) {
292
+ if (!fileEntry.isFile()) continue;
293
+ if (!isTranscriptFile(fileEntry.name)) continue;
294
+
295
+ const filePath = join(targetDir, fileEntry.name);
296
+ if (indexRefs.has(filePath)) continue; // already handled via index
297
+
298
+ const sessionId = sessionIdFromFilename(fileEntry.name);
299
+ const ev = await parseSessionFile(filePath, sessionId, agentPath);
300
+ events.push(...ev);
301
+ }
302
+ }
303
+
304
+ return events;
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Main collector
309
+ // ---------------------------------------------------------------------------
310
+
311
+ export async function collect(pricingData = null) {
312
+ const roots = getAgentRoots();
313
+ const dailyMap = new Map(); // "date::model" → aggregated
314
+ const wmMap = new Map(); // "agentPath::model" → aggregated
315
+
316
+ function accumulate(events) {
317
+ for (const { sessionId, agentPath, date, model, provider, tokens, cost } of events) {
318
+ const calculatedCost = calculateCost(model, tokens, pricingData, provider);
319
+ const effectiveCost = calculatedCost;
320
+
321
+ // Daily
322
+ const dk = `${date}::${model}`;
323
+ if (!dailyMap.has(dk)) dailyMap.set(dk, { date, model, provider, ...zero(), cost: 0 });
324
+ const d = dailyMap.get(dk);
325
+ addInto(d, tokens);
326
+ d.cost += effectiveCost;
327
+
328
+ // Workspace+model (agentPath is the natural workspace grouping for OpenClaw)
329
+ const wmk = `${agentPath}::${model}`;
330
+ if (!wmMap.has(wmk)) {
331
+ wmMap.set(wmk, {
332
+ workspace: agentPath,
333
+ workspaceLabel: agentPath,
334
+ sessionId,
335
+ model,
336
+ provider,
337
+ ...zero(),
338
+ cost: 0
339
+ });
340
+ }
341
+ const wm = wmMap.get(wmk);
342
+ addInto(wm, tokens);
343
+ wm.cost += effectiveCost;
344
+ }
345
+ }
346
+
347
+ for (const root of roots) {
348
+ if (!existsSync(root)) continue;
349
+ const events = await scanAgentsRoot(root);
350
+ accumulate(events);
351
+ }
352
+
353
+ return buildOutput(dailyMap, wmMap);
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Convert to common collector JSON
358
+ // ---------------------------------------------------------------------------
359
+
360
+ function buildOutput(dailyMap, wmMap) {
361
+ const byDate = new Map();
362
+ for (const row of dailyMap.values()) {
363
+ if (!byDate.has(row.date)) byDate.set(row.date, []);
364
+ byDate.get(row.date).push(row);
365
+ }
366
+
367
+ const contributions = [...byDate.entries()]
368
+ .sort(([a], [b]) => a.localeCompare(b))
369
+ .map(([date, rows]) => ({
370
+ date,
371
+ clients: rows.map(row => ({
372
+ client: CLIENT_KEY,
373
+ modelId: row.model,
374
+ tokens: {
375
+ input: row.input,
376
+ output: row.output,
377
+ cacheRead: row.cacheRead,
378
+ cacheWrite: row.cacheWrite,
379
+ reasoning: row.reasoning
380
+ },
381
+ cost: row.cost
382
+ }))
383
+ }));
384
+
385
+ const entries = [...wmMap.values()].map(wm => ({
386
+ client: CLIENT_KEY,
387
+ workspaceKey: wm.workspace,
388
+ workspaceLabel: wm.workspaceLabel,
389
+ model: wm.model,
390
+ provider: wm.provider,
391
+ input: wm.input,
392
+ output: wm.output,
393
+ cacheRead: wm.cacheRead,
394
+ cacheWrite: wm.cacheWrite,
395
+ reasoning: wm.reasoning,
396
+ cost: wm.cost
397
+ }));
398
+
399
+ return { graphJson: { contributions }, modelsJson: { entries } };
400
+ }