@zhangferry-dev/tokendash 1.1.0 → 1.1.3

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.
@@ -0,0 +1,366 @@
1
+ import { readFileSync, readdirSync, statSync, accessSync, constants } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ // ---------------------------------------------------------------------------
5
+ // Directory helpers
6
+ // ---------------------------------------------------------------------------
7
+ /** All directories OpenClaw may have used (current + legacy names). */
8
+ function getOpenClawDirs() {
9
+ const home = homedir();
10
+ return [
11
+ join(home, '.openclaw'),
12
+ join(home, '.clawdbot'), // legacy name 1
13
+ join(home, '.moltbot'), // legacy name 2
14
+ join(home, '.moldbot'), // legacy name 3
15
+ ];
16
+ }
17
+ export function isOpenClawAccessible() {
18
+ for (const dir of getOpenClawDirs()) {
19
+ try {
20
+ accessSync(join(dir, 'agents'), constants.R_OK);
21
+ return true;
22
+ }
23
+ catch {
24
+ // try next
25
+ }
26
+ }
27
+ return false;
28
+ }
29
+ /** Scan all OpenClaw agent dirs and collect session file references. */
30
+ export function scanOpenClawSessions() {
31
+ const refs = [];
32
+ for (const baseDir of getOpenClawDirs()) {
33
+ const agentsDir = join(baseDir, 'agents');
34
+ let agentEntries;
35
+ try {
36
+ agentEntries = readdirSync(agentsDir);
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ for (const agentEntry of agentEntries) {
42
+ const sessionsDir = join(agentsDir, agentEntry, 'sessions');
43
+ // Try sessions.json index first
44
+ const indexPath = join(sessionsDir, 'sessions.json');
45
+ try {
46
+ const raw = readFileSync(indexPath, 'utf-8');
47
+ const index = JSON.parse(raw);
48
+ for (const entry of Object.values(index)) {
49
+ if (!entry.sessionId)
50
+ continue;
51
+ let sessionPath;
52
+ if (entry.sessionFile) {
53
+ sessionPath = entry.sessionFile.startsWith('/')
54
+ ? entry.sessionFile
55
+ : join(sessionsDir, entry.sessionFile);
56
+ }
57
+ else {
58
+ sessionPath = join(sessionsDir, `${entry.sessionId}.jsonl`);
59
+ }
60
+ refs.push({ sessionId: entry.sessionId, sessionFile: sessionPath, agentId: agentEntry });
61
+ }
62
+ }
63
+ catch {
64
+ // No sessions.json — fall back to scanning .jsonl files directly
65
+ let files;
66
+ try {
67
+ files = readdirSync(sessionsDir);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ for (const f of files) {
73
+ if (!f.endsWith('.jsonl'))
74
+ continue;
75
+ const sessionId = f.replace(/\.jsonl.*$/, ''); // strip .jsonl and any suffixes
76
+ refs.push({
77
+ sessionId,
78
+ sessionFile: join(sessionsDir, f),
79
+ agentId: agentEntry,
80
+ });
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return refs;
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // JSONL parser
89
+ // ---------------------------------------------------------------------------
90
+ export function parseOpenClawSession(ref) {
91
+ let content;
92
+ try {
93
+ content = readFileSync(ref.sessionFile, 'utf-8');
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ // Fallback timestamp: file mtime
99
+ let fileMtimeMs = 0;
100
+ try {
101
+ fileMtimeMs = statSync(ref.sessionFile).mtimeMs;
102
+ }
103
+ catch { /* ok */ }
104
+ const tokenEvents = [];
105
+ let currentModel = '';
106
+ let currentProvider = '';
107
+ for (const line of content.split('\n')) {
108
+ const trimmed = line.trim();
109
+ if (!trimmed)
110
+ continue;
111
+ let obj;
112
+ try {
113
+ obj = JSON.parse(trimmed);
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ const type = obj.type;
119
+ if (type === 'model_change') {
120
+ if (obj.modelId)
121
+ currentModel = obj.modelId;
122
+ if (obj.provider)
123
+ currentProvider = obj.provider;
124
+ continue;
125
+ }
126
+ if (type === 'custom' && obj.customType === 'model-snapshot') {
127
+ const data = obj.data || {};
128
+ if (data.modelId)
129
+ currentModel = data.modelId;
130
+ if (data.provider)
131
+ currentProvider = data.provider;
132
+ continue;
133
+ }
134
+ if (type === 'message') {
135
+ const msg = obj.message || {};
136
+ if (msg.role !== 'assistant')
137
+ continue;
138
+ const usage = msg.usage || {};
139
+ if (!usage)
140
+ continue;
141
+ // Model: prefer embedded, fall back to tracked state
142
+ const model = (msg.model || currentModel || '').trim();
143
+ const provider = (msg.provider || currentProvider || '').trim();
144
+ if (!model)
145
+ continue; // can't attribute cost without a model
146
+ // Update tracked state
147
+ if (model)
148
+ currentModel = model;
149
+ if (provider)
150
+ currentProvider = provider;
151
+ const input = Number(usage.input ?? 0);
152
+ const output = Number(usage.output ?? 0);
153
+ const cacheRead = Number(usage.cacheRead ?? 0);
154
+ const cacheWrite = Number(usage.cacheWrite ?? 0);
155
+ const costObj = usage.cost || {};
156
+ const cost = Number(costObj.total ?? 0);
157
+ const timestampMs = Number(msg.timestamp ?? fileMtimeMs);
158
+ tokenEvents.push({
159
+ timestampMs,
160
+ inputTokens: Math.max(0, input),
161
+ outputTokens: Math.max(0, output),
162
+ cacheReadTokens: Math.max(0, cacheRead),
163
+ cacheWriteTokens: Math.max(0, cacheWrite),
164
+ totalTokens: Math.max(0, input + output),
165
+ cost: Math.max(0, cost),
166
+ model: `${provider}/${model}`,
167
+ });
168
+ }
169
+ }
170
+ if (tokenEvents.length === 0)
171
+ return null;
172
+ return { id: ref.sessionId, agentId: ref.agentId, tokenEvents };
173
+ }
174
+ export function parseAllOpenClawSessions() {
175
+ return scanOpenClawSessions()
176
+ .map(parseOpenClawSession)
177
+ .filter((s) => s !== null);
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Date / timezone helpers (same logic as codexParser)
181
+ // ---------------------------------------------------------------------------
182
+ const TZ_OFFSETS = {
183
+ 'Asia/Shanghai': 8,
184
+ 'Asia/Tokyo': 9,
185
+ 'America/New_York': -5,
186
+ 'America/Los_Angeles': -8,
187
+ 'Europe/London': 0,
188
+ 'UTC': 0,
189
+ };
190
+ function getTzOffsetHours(tz) {
191
+ return TZ_OFFSETS[tz] ?? 8;
192
+ }
193
+ function msToLocalDate(ms, tz) {
194
+ return new Date(ms + getTzOffsetHours(tz) * 3_600_000);
195
+ }
196
+ function getDateKey(ms, tz) {
197
+ return msToLocalDate(ms, tz).toISOString().slice(0, 10);
198
+ }
199
+ function getHourKey(ms, tz) {
200
+ const d = msToLocalDate(ms, tz);
201
+ return d.toISOString().slice(0, 13).replace('T', ' ') + ':00';
202
+ }
203
+ function getMonthKey(ms, tz) {
204
+ return getDateKey(ms, tz).slice(0, 7);
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Aggregation helpers
208
+ // ---------------------------------------------------------------------------
209
+ function emptyAcc() {
210
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0, cost: 0 };
211
+ }
212
+ function addEvent(acc, ev) {
213
+ acc.inputTokens += ev.inputTokens;
214
+ acc.outputTokens += ev.outputTokens;
215
+ acc.cacheReadTokens += ev.cacheReadTokens;
216
+ acc.cacheWriteTokens += ev.cacheWriteTokens;
217
+ acc.totalTokens += ev.totalTokens;
218
+ acc.cost += ev.cost;
219
+ }
220
+ function mergeAcc(a, b) {
221
+ a.inputTokens += b.inputTokens;
222
+ a.outputTokens += b.outputTokens;
223
+ a.cacheReadTokens += b.cacheReadTokens;
224
+ a.cacheWriteTokens += b.cacheWriteTokens;
225
+ a.totalTokens += b.totalTokens;
226
+ a.cost += b.cost;
227
+ }
228
+ function accToEntry(date, acc, models) {
229
+ const modelList = [...models];
230
+ const costPerModel = modelList.length > 0 ? acc.cost / modelList.length : 0;
231
+ return {
232
+ date,
233
+ inputTokens: acc.inputTokens,
234
+ outputTokens: acc.outputTokens,
235
+ cacheCreationTokens: acc.cacheWriteTokens,
236
+ cacheReadTokens: acc.cacheReadTokens,
237
+ totalTokens: acc.totalTokens,
238
+ totalCost: acc.cost,
239
+ modelsUsed: modelList,
240
+ modelBreakdowns: modelList.map(name => ({
241
+ modelName: name,
242
+ inputTokens: acc.inputTokens,
243
+ outputTokens: acc.outputTokens,
244
+ cacheCreationTokens: acc.cacheWriteTokens,
245
+ cacheReadTokens: acc.cacheReadTokens,
246
+ cost: costPerModel,
247
+ })),
248
+ };
249
+ }
250
+ export function getDailyResponse(options) {
251
+ const sessions = parseAllOpenClawSessions();
252
+ const tz = options?.timezone || 'Asia/Shanghai';
253
+ const grouped = new Map();
254
+ const allModels = new Set();
255
+ const totalsAcc = emptyAcc();
256
+ for (const session of sessions) {
257
+ if (options?.project && session.agentId !== options.project)
258
+ continue;
259
+ for (const ev of session.tokenEvents) {
260
+ if (options?.since && ev.timestampMs < options.since.getTime())
261
+ continue;
262
+ if (options?.until && ev.timestampMs > options.until.getTime())
263
+ continue;
264
+ const key = getDateKey(ev.timestampMs, tz);
265
+ if (!grouped.has(key))
266
+ grouped.set(key, { acc: emptyAcc(), models: new Set() });
267
+ const entry = grouped.get(key);
268
+ addEvent(entry.acc, ev);
269
+ entry.models.add(ev.model);
270
+ allModels.add(ev.model);
271
+ }
272
+ }
273
+ const daily = [];
274
+ for (const [date, { acc, models }] of grouped) {
275
+ daily.push(accToEntry(date, acc, models));
276
+ mergeAcc(totalsAcc, acc);
277
+ }
278
+ daily.sort((a, b) => a.date.localeCompare(b.date));
279
+ return {
280
+ daily,
281
+ totals: {
282
+ inputTokens: totalsAcc.inputTokens,
283
+ outputTokens: totalsAcc.outputTokens,
284
+ cacheCreationTokens: totalsAcc.cacheWriteTokens,
285
+ cacheReadTokens: totalsAcc.cacheReadTokens,
286
+ totalTokens: totalsAcc.totalTokens,
287
+ totalCost: totalsAcc.cost,
288
+ },
289
+ };
290
+ }
291
+ export function getProjectsResponse(options) {
292
+ const sessions = parseAllOpenClawSessions();
293
+ const tz = options?.timezone || 'Asia/Shanghai';
294
+ const projects = {};
295
+ for (const session of sessions) {
296
+ const projectName = session.agentId;
297
+ const dailyMap = new Map();
298
+ for (const ev of session.tokenEvents) {
299
+ if (options?.since && ev.timestampMs < options.since.getTime())
300
+ continue;
301
+ if (options?.until && ev.timestampMs > options.until.getTime())
302
+ continue;
303
+ const dayKey = getDateKey(ev.timestampMs, tz);
304
+ if (!dailyMap.has(dayKey))
305
+ dailyMap.set(dayKey, { acc: emptyAcc(), models: new Set() });
306
+ addEvent(dailyMap.get(dayKey).acc, ev);
307
+ dailyMap.get(dayKey).models.add(ev.model);
308
+ }
309
+ if (!projects[projectName])
310
+ projects[projectName] = [];
311
+ for (const [date, { acc, models }] of dailyMap) {
312
+ projects[projectName].push(accToEntry(date, acc, models));
313
+ }
314
+ }
315
+ for (const key of Object.keys(projects)) {
316
+ projects[key].sort((a, b) => a.date.localeCompare(b.date));
317
+ }
318
+ return { projects };
319
+ }
320
+ export function getBlocksResponse(options) {
321
+ const sessions = parseAllOpenClawSessions();
322
+ const tz = options?.timezone || 'Asia/Shanghai';
323
+ const grouped = new Map();
324
+ for (const session of sessions) {
325
+ if (options?.project && session.agentId !== options.project)
326
+ continue;
327
+ for (const ev of session.tokenEvents) {
328
+ if (options?.since && ev.timestampMs < options.since.getTime())
329
+ continue;
330
+ if (options?.until && ev.timestampMs > options.until.getTime())
331
+ continue;
332
+ const key = getHourKey(ev.timestampMs, tz);
333
+ if (!grouped.has(key))
334
+ grouped.set(key, { acc: emptyAcc(), models: new Set() });
335
+ addEvent(grouped.get(key).acc, ev);
336
+ grouped.get(key).models.add(ev.model);
337
+ }
338
+ }
339
+ const blocks = [];
340
+ let idx = 0;
341
+ for (const [hourKey, { acc, models }] of grouped) {
342
+ const [datePart, timePart] = hourKey.split(' ');
343
+ const hour = timePart.split(':')[0];
344
+ blocks.push({
345
+ id: `openclaw-hour-${idx}`,
346
+ startTime: `${datePart}T${hour}:00:00`,
347
+ endTime: `${datePart}T${hour}:59:59`,
348
+ actualEndTime: null,
349
+ isActive: false,
350
+ isGap: false,
351
+ entries: acc.totalTokens > 0 ? 1 : 0,
352
+ tokenCounts: {
353
+ inputTokens: acc.inputTokens,
354
+ outputTokens: acc.outputTokens,
355
+ cacheCreationInputTokens: acc.cacheWriteTokens,
356
+ cacheReadInputTokens: acc.cacheReadTokens,
357
+ },
358
+ totalTokens: acc.totalTokens,
359
+ costUSD: acc.cost,
360
+ models: [...models],
361
+ });
362
+ idx++;
363
+ }
364
+ blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
365
+ return { blocks };
366
+ }
@@ -3,7 +3,24 @@ import { getMonthly } from './monthly.js';
3
3
  import { getSession } from './session.js';
4
4
  import { getProjects } from './projects.js';
5
5
  import { getBlocks } from './blocks.js';
6
+ import { detectAvailableAgents } from '../ccusage.js';
7
+ async function getAgents(_req, res) {
8
+ try {
9
+ const agents = await detectAvailableAgents();
10
+ const available = [];
11
+ if (agents.claude)
12
+ available.push('claude');
13
+ if (agents.codex)
14
+ available.push('codex');
15
+ res.json({ available, default: available[0] || null });
16
+ }
17
+ catch (error) {
18
+ const message = error instanceof Error ? error.message : 'Unknown error';
19
+ res.status(500).json({ error: 'Failed to detect agents', hint: message });
20
+ }
21
+ }
6
22
  export function registerApiRoutes(router) {
23
+ router.get('/agents', getAgents);
7
24
  router.get('/daily', getDaily);
8
25
  router.get('/monthly', getMonthly);
9
26
  router.get('/session', getSession);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangferry-dev/tokendash",
3
- "version": "1.1.0",
3
+ "version": "1.1.3",
4
4
  "type": "module",
5
5
  "description": "Token Usage Analytics Dashboard",
6
6
  "publishConfig": {
@@ -9,7 +9,7 @@
9
9
  "main": "./dist/server/index.js",
10
10
  "types": "./dist/server/index.d.ts",
11
11
  "bin": {
12
- "tokendash": "./bin/tokendash.js"
12
+ "tokendash": "bin/tokendash.js"
13
13
  },
14
14
  "files": [
15
15
  "dist",