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