rol-websocket-channel 1.0.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 (58) hide show
  1. package/MQTT-API.md +967 -0
  2. package/dist/index.js +430 -0
  3. package/dist/message-handler.js +327 -0
  4. package/dist/src/admin/cli.js +43 -0
  5. package/dist/src/admin/jsonrpc.js +60 -0
  6. package/dist/src/admin/lib/fs.js +30 -0
  7. package/dist/src/admin/lib/paths.js +46 -0
  8. package/dist/src/admin/methods/admin.js +60 -0
  9. package/dist/src/admin/methods/agents-extended.js +235 -0
  10. package/dist/src/admin/methods/index.js +69 -0
  11. package/dist/src/admin/methods/memory.js +360 -0
  12. package/dist/src/admin/methods/models-extended.js +107 -0
  13. package/dist/src/admin/methods/models.js +39 -0
  14. package/dist/src/admin/methods/sessions-extended.js +207 -0
  15. package/dist/src/admin/methods/sessions.js +64 -0
  16. package/dist/src/admin/methods/skills-extended.js +157 -0
  17. package/dist/src/admin/methods/skills-toggle.js +182 -0
  18. package/dist/src/admin/methods/skills.js +384 -0
  19. package/dist/src/admin/methods/system.js +178 -0
  20. package/dist/src/admin/methods/usage.js +1170 -0
  21. package/dist/src/admin/types.js +1 -0
  22. package/dist/src/mqtt/connection-manager.js +155 -0
  23. package/dist/src/mqtt/index.js +5 -0
  24. package/dist/src/mqtt/mqtt-client.js +86 -0
  25. package/dist/src/mqtt/types.js +2 -0
  26. package/dist/src/shared/context.js +24 -0
  27. package/dist/src/shared/wrapper.js +23 -0
  28. package/index.ts +514 -0
  29. package/message-handler.ts +415 -0
  30. package/openclaw.plugin.json +84 -0
  31. package/package.json +35 -0
  32. package/readme.md +32 -0
  33. package/src/admin/cli.ts +60 -0
  34. package/src/admin/jsonrpc.ts +88 -0
  35. package/src/admin/lib/fs.ts +35 -0
  36. package/src/admin/lib/paths.ts +61 -0
  37. package/src/admin/methods/admin.ts +95 -0
  38. package/src/admin/methods/agents-extended.ts +310 -0
  39. package/src/admin/methods/index.ts +103 -0
  40. package/src/admin/methods/memory.ts +546 -0
  41. package/src/admin/methods/models-extended.ts +191 -0
  42. package/src/admin/methods/models.ts +103 -0
  43. package/src/admin/methods/sessions-extended.ts +313 -0
  44. package/src/admin/methods/sessions.ts +122 -0
  45. package/src/admin/methods/skills-extended.ts +249 -0
  46. package/src/admin/methods/skills-toggle.ts +235 -0
  47. package/src/admin/methods/skills.ts +651 -0
  48. package/src/admin/methods/system.ts +203 -0
  49. package/src/admin/methods/usage.ts +1491 -0
  50. package/src/admin/types.ts +46 -0
  51. package/src/mqtt/connection-manager.ts +188 -0
  52. package/src/mqtt/index.ts +6 -0
  53. package/src/mqtt/mqtt-client.ts +119 -0
  54. package/src/mqtt/types.ts +36 -0
  55. package/src/shared/context.ts +33 -0
  56. package/src/shared/wrapper.ts +35 -0
  57. package/tsconfig.json +16 -0
  58. package/types/openclaw.d.ts +74 -0
@@ -0,0 +1,1491 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+
5
+ import { getPluginRuntime } from '../../../index.js';
6
+ import { pathExists, readJsonFile } from '../lib/fs.ts';
7
+ import { resolveSessionFile } from './sessions.ts';
8
+ import type { JsonValue, MethodHandler, MethodContext } from '../types.ts';
9
+
10
+ interface SessionsIndexEntry {
11
+ sessionId?: string;
12
+ inputTokens?: number;
13
+ outputTokens?: number;
14
+ totalTokens?: number;
15
+ estimatedCostUsd?: number;
16
+ runtimeMs?: number;
17
+ cacheRead?: number;
18
+ cacheWrite?: number;
19
+ origin?: {
20
+ label?: string;
21
+ provider?: string;
22
+ chatType?: string;
23
+ };
24
+ }
25
+
26
+ type SessionsIndex = Record<string, SessionsIndexEntry>;
27
+
28
+ interface OpenClawGatewayConfig {
29
+ gateway?: {
30
+ port?: number;
31
+ bind?: string;
32
+ auth?: {
33
+ token?: string;
34
+ mode?: string;
35
+ };
36
+ };
37
+ }
38
+
39
+ interface UsageAccumulator {
40
+ input: number;
41
+ output: number;
42
+ cacheRead: number;
43
+ cacheWrite: number;
44
+ totalTokens: number;
45
+ totalCostUsd: number;
46
+ toolCalls: number;
47
+ errors: number;
48
+ messages: number;
49
+ sessions: number;
50
+ }
51
+
52
+ interface UsageQuery {
53
+ from?: string;
54
+ to?: string;
55
+ timezone?: string;
56
+ basis?: string;
57
+ agent?: string[];
58
+ channel?: string[];
59
+ provider?: string[];
60
+ model?: string[];
61
+ tool?: string[];
62
+ sessionIds?: string[];
63
+ }
64
+
65
+ interface SessionUsageEvent {
66
+ ts: number;
67
+ sessionId: string;
68
+ agent: string;
69
+ channel: string;
70
+ provider: string;
71
+ model: string;
72
+ toolName: string | null;
73
+ messageRole: string | null;
74
+ input: number;
75
+ output: number;
76
+ cacheRead: number;
77
+ cacheWrite: number;
78
+ totalTokens: number;
79
+ totalCostUsd: number;
80
+ isError: boolean;
81
+ isToolCall: boolean;
82
+ toolCallsCount: number;
83
+ messageCount: number;
84
+ usageMessageCount: number;
85
+ }
86
+
87
+ interface UsageBucketState {
88
+ usage: UsageSummaryBucket;
89
+ sessionIds: Set<string>;
90
+ }
91
+
92
+ interface SessionSummaryRecord {
93
+ sessionId: string;
94
+ totals: UsageSummaryBucket;
95
+ timestampMs: number | null;
96
+ runtimeMs: number | null;
97
+ provider: string;
98
+ model: string;
99
+ agent: string;
100
+ channel: string;
101
+ }
102
+
103
+ interface UsageSummaryBucket extends UsageAccumulator {
104
+ cachedTokens: number;
105
+ promptTokens: number;
106
+ completionTokens: number;
107
+ }
108
+
109
+ interface UsageSourceMeta {
110
+ source: 'gateway' | 'local_fallback';
111
+ usedLiveSnapshot: boolean;
112
+ usedTranscriptFallback: boolean;
113
+ usedDiagnostics: boolean;
114
+ }
115
+
116
+ interface UsageSourceResult {
117
+ events: SessionUsageEvent[];
118
+ summaryRecords: SessionSummaryRecord[];
119
+ meta: UsageSourceMeta;
120
+ }
121
+
122
+ interface GatewayUsageClient {
123
+ call(method: string, params: Record<string, JsonValue>): Promise<unknown>;
124
+ }
125
+
126
+ interface GatewayUsageMethodSet {
127
+ summary: string;
128
+ timeseries: string;
129
+ logs: string;
130
+ }
131
+
132
+ const GATEWAY_USAGE_METHODS: GatewayUsageMethodSet = {
133
+ summary: 'sessions.usage',
134
+ timeseries: 'sessions.usage.timeseries',
135
+ logs: 'sessions.usage.logs'
136
+ };
137
+
138
+ function blankUsage(): UsageSummaryBucket {
139
+ return {
140
+ input: 0,
141
+ output: 0,
142
+ cacheRead: 0,
143
+ cacheWrite: 0,
144
+ totalTokens: 0,
145
+ totalCostUsd: 0,
146
+ toolCalls: 0,
147
+ errors: 0,
148
+ messages: 0,
149
+ sessions: 0,
150
+ cachedTokens: 0,
151
+ promptTokens: 0,
152
+ completionTokens: 0
153
+ };
154
+ }
155
+
156
+ export const getUsageSummary: MethodHandler = async (_params, context): Promise<JsonValue> => {
157
+ const repo = await buildUsageRepo(context);
158
+ return repo.getPageSummary({});
159
+ };
160
+
161
+ export const getUsagePageSummary: MethodHandler = async (params, context): Promise<JsonValue> => {
162
+ const repo = await buildUsageRepo(context);
163
+ return repo.getPageSummary(readUsageQuery(params));
164
+ };
165
+
166
+ export const getUsageTimeseries: MethodHandler = async (params, context): Promise<JsonValue> => {
167
+ const repo = await buildUsageRepo(context);
168
+ const query = readUsageQuery(params);
169
+ const granularity = isObject(params) && typeof params.granularity === 'string' ? params.granularity : 'day';
170
+ return repo.getTimeseries(query, granularity);
171
+ };
172
+
173
+ export const getUsageBreakdown: MethodHandler = async (params, context): Promise<JsonValue> => {
174
+ const repo = await buildUsageRepo(context);
175
+ const query = readUsageQuery(params);
176
+ const objectParams = isObject(params) ? params : {};
177
+ const dimension = typeof objectParams.dimension === 'string' ? objectParams.dimension : 'model';
178
+ const limit = typeof objectParams.limit === 'number' ? objectParams.limit : 20;
179
+ const sortBy = typeof objectParams.sortBy === 'string' ? objectParams.sortBy : 'totalTokens';
180
+ const order = objectParams.order === 'asc' ? 'asc' : 'desc';
181
+ return repo.getBreakdown(query, dimension, limit, sortBy, order);
182
+ };
183
+
184
+ async function buildUsageRepo(context: MethodContext) {
185
+ const selectedSource = await getUsageSource(context);
186
+ const events = dedupeEvents(selectedSource.events);
187
+ const summaryRecords = selectedSource.summaryRecords;
188
+
189
+ return {
190
+ getPageSummary(query: UsageQuery) {
191
+ const filtered = filterEvents(events, query);
192
+ const filteredSummaries = filterSessionSummaries(summaryRecords, query);
193
+ const totals = aggregateSummary(filteredSummaries);
194
+ return {
195
+ window: buildWindow(query),
196
+ totals: buildSummaryMetrics(totals, filteredSummaries),
197
+ topModels: buildSummaryRankedList(filteredSummaries, 'model', 10),
198
+ topProviders: buildSummaryRankedList(filteredSummaries, 'provider', 10),
199
+ topTools: buildToolRankedList(filtered, 10),
200
+ topAgents: buildSummaryRankedList(filteredSummaries, 'agent', 10),
201
+ topChannels: buildSummaryRankedList(filteredSummaries, 'channel', 10),
202
+ sessions: buildSessionRows(filteredSummaries),
203
+ meta: {
204
+ sessionsScanned: new Set([
205
+ ...filtered.map((event) => event.sessionId),
206
+ ...filteredSummaries.map((record) => record.sessionId)
207
+ ]).size,
208
+ source: selectedSource.meta.source,
209
+ usedLiveSnapshot: selectedSource.meta.usedLiveSnapshot,
210
+ usedTranscriptFallback: selectedSource.meta.usedTranscriptFallback,
211
+ usedDiagnostics: selectedSource.meta.usedDiagnostics,
212
+ generatedAt: new Date().toISOString()
213
+ }
214
+ };
215
+ },
216
+
217
+ getTimeseries(query: UsageQuery, granularity: string) {
218
+ const filtered = filterEvents(events, query);
219
+ const filteredSummaries = filterSessionSummaries(summaryRecords, query);
220
+ const series = aggregateTimeseries(filtered, filteredSummaries, granularity);
221
+ return {
222
+ window: {
223
+ ...buildWindow(query),
224
+ granularity
225
+ },
226
+ series,
227
+ meta: {
228
+ sessionsScanned: new Set([
229
+ ...filtered.map((event) => event.sessionId),
230
+ ...filteredSummaries.map((record) => record.sessionId)
231
+ ]).size,
232
+ source: selectedSource.meta.source,
233
+ bucketCount: series.length,
234
+ usedTranscriptFallback: selectedSource.meta.usedTranscriptFallback,
235
+ generatedAt: new Date().toISOString()
236
+ }
237
+ };
238
+ },
239
+
240
+ getBreakdown(query: UsageQuery, dimension: string, limit: number, sortBy: string, order: 'asc' | 'desc') {
241
+ const filtered = filterEvents(events, query);
242
+ const filteredSummaries = filterSessionSummaries(summaryRecords, query);
243
+ const total = aggregateSummary(filteredSummaries);
244
+ const rows = aggregateBreakdown(filtered, filteredSummaries, dimension, total.totalTokens, limit, sortBy, order);
245
+
246
+ return {
247
+ window: {
248
+ ...buildWindow(query),
249
+ dimension
250
+ },
251
+ rows,
252
+ meta: {
253
+ limit,
254
+ sortBy,
255
+ order,
256
+ sessionsScanned: new Set([
257
+ ...filtered.map((event) => event.sessionId),
258
+ ...filteredSummaries.map((record) => record.sessionId)
259
+ ]).size,
260
+ source: selectedSource.meta.source,
261
+ generatedAt: new Date().toISOString()
262
+ }
263
+ };
264
+ }
265
+ };
266
+ }
267
+
268
+ async function getUsageSource(context: MethodContext): Promise<UsageSourceResult> {
269
+ return await readLocalUsageSource(context);
270
+ }
271
+
272
+ async function readGatewayUsageSource(context: MethodContext): Promise<UsageSourceResult | null> {
273
+ const client = await createGatewayUsageClient(context);
274
+ if (!client) {
275
+ return null;
276
+ }
277
+
278
+ try {
279
+ const [summaryPayload, logsPayload] = await Promise.all([
280
+ safeGatewayCall(client, GATEWAY_USAGE_METHODS.summary, {}),
281
+ safeGatewayCall(client, GATEWAY_USAGE_METHODS.logs, {})
282
+ ]);
283
+
284
+ const summaryRecords = normalizeGatewaySummaryPayload(summaryPayload);
285
+ const events = dedupeEvents(normalizeGatewayUsageLogs(logsPayload));
286
+
287
+ if (summaryRecords.length === 0 && events.length === 0) {
288
+ return null;
289
+ }
290
+
291
+ return {
292
+ events,
293
+ summaryRecords,
294
+ meta: {
295
+ source: 'gateway',
296
+ usedLiveSnapshot: true,
297
+ usedTranscriptFallback: false,
298
+ usedDiagnostics: false
299
+ }
300
+ };
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ async function createGatewayUsageClient(_context: MethodContext): Promise<GatewayUsageClient | null> {
307
+ const runtime = getPluginRuntime();
308
+ console.log('[usage][gateway] runtime keys:', Object.keys(runtime ?? {}));
309
+ console.log('[usage][gateway] runtime adapter probing unavailable, using ws client');
310
+
311
+ const configPath = path.join(_context.openclawRoot, 'openclaw.json');
312
+ if (!(await pathExists(configPath))) {
313
+ console.log('[usage][gateway] openclaw.json not found');
314
+ return null;
315
+ }
316
+
317
+ const config = await readJsonFile<OpenClawGatewayConfig>(configPath);
318
+ const port = typeof config.gateway?.port === 'number' ? config.gateway.port : 18789;
319
+ const token = typeof config.gateway?.auth?.token === 'string' ? config.gateway.auth.token : null;
320
+ if (!token) {
321
+ console.log('[usage][gateway] gateway auth token missing');
322
+ return null;
323
+ }
324
+
325
+ if (typeof globalThis.WebSocket !== 'function') {
326
+ console.log('[usage][gateway] global WebSocket unavailable');
327
+ return null;
328
+ }
329
+
330
+ const url = `ws://127.0.0.1:${port}`;
331
+ try {
332
+ return await createWebSocketGatewayUsageClient(url, token);
333
+ } catch (error) {
334
+ console.log('[usage][gateway] ws client init failed:', error);
335
+ return null;
336
+ }
337
+ }
338
+
339
+ async function safeGatewayCall(
340
+ client: GatewayUsageClient,
341
+ method: string,
342
+ params: Record<string, JsonValue>
343
+ ): Promise<unknown | null> {
344
+ try {
345
+ return await client.call(method, params);
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+
351
+ function normalizeGatewaySummaryPayload(payload: unknown): SessionSummaryRecord[] {
352
+ if (!isObjectRecord(payload)) {
353
+ return [];
354
+ }
355
+
356
+ const rows = Array.isArray(payload.rows)
357
+ ? payload.rows
358
+ : Array.isArray(payload.sessions)
359
+ ? payload.sessions
360
+ : [];
361
+
362
+ const records: SessionSummaryRecord[] = [];
363
+ for (const row of rows) {
364
+ if (!isObjectRecord(row)) {
365
+ continue;
366
+ }
367
+
368
+ const sessionId = asString(row.sessionId) ?? asString(row.id);
369
+ if (!sessionId) {
370
+ continue;
371
+ }
372
+
373
+ const totals = blankUsage();
374
+ totals.input = asNumber(row.inputTokens) ?? asNumber(row.input) ?? 0;
375
+ totals.output = asNumber(row.outputTokens) ?? asNumber(row.output) ?? 0;
376
+ totals.cacheRead = asNumber(row.cacheReadTokens) ?? asNumber(row.cacheRead) ?? 0;
377
+ totals.cacheWrite = asNumber(row.cacheWriteTokens) ?? asNumber(row.cacheWrite) ?? 0;
378
+ totals.cachedTokens = totals.cacheRead;
379
+ totals.promptTokens = totals.input;
380
+ totals.completionTokens = totals.output;
381
+ totals.totalTokens =
382
+ asNumber(row.totalTokens)
383
+ ?? asNumber(row.tokens)
384
+ ?? totals.input + totals.output + totals.cacheRead + totals.cacheWrite;
385
+ totals.totalCostUsd = asNumber(row.totalCostUsd) ?? asNumber(row.costUsd) ?? 0;
386
+ totals.toolCalls = asNumber(row.toolCalls) ?? 0;
387
+ totals.errors = asNumber(row.errors) ?? 0;
388
+ totals.messages = asNumber(row.messages) ?? 0;
389
+ totals.sessions = 1;
390
+
391
+ records.push({
392
+ sessionId,
393
+ totals,
394
+ timestampMs: parseTimestamp(row.timestamp ?? row.updatedAt),
395
+ runtimeMs: asNumber(row.runtimeMs) ?? null,
396
+ provider: asString(row.provider) ?? 'unknown',
397
+ model: asString(row.model) ?? 'unknown',
398
+ agent: asString(row.agent) ?? 'main',
399
+ channel: asString(row.channel) ?? 'unknown'
400
+ });
401
+ }
402
+
403
+ return records;
404
+ }
405
+
406
+ function unwrapGatewayResult(result: unknown): unknown {
407
+ if (!isObjectRecord(result)) {
408
+ return result;
409
+ }
410
+
411
+ if ('payload' in result) {
412
+ return result.payload;
413
+ }
414
+
415
+ if ('result' in result) {
416
+ return result.result;
417
+ }
418
+
419
+ if ('ok' in result && result.ok === true && 'data' in result) {
420
+ return result.data;
421
+ }
422
+
423
+ return result;
424
+ }
425
+
426
+ async function createWebSocketGatewayUsageClient(
427
+ url: string,
428
+ token: string
429
+ ): Promise<GatewayUsageClient> {
430
+ const ws = new WebSocket(url);
431
+ const pending = new Map<string, { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }>();
432
+ let requestCounter = 0;
433
+ let connectNonce: string | null = null;
434
+ let handshakeResolved = false;
435
+ let handshakeRejected = false;
436
+
437
+ const connectPromise = new Promise<void>((resolve, reject) => {
438
+ const rejectOnce = (reason: unknown) => {
439
+ if (handshakeResolved || handshakeRejected) {
440
+ return;
441
+ }
442
+ handshakeRejected = true;
443
+ reject(reason);
444
+ };
445
+
446
+ const resolveOnce = () => {
447
+ if (handshakeResolved || handshakeRejected) {
448
+ return;
449
+ }
450
+ handshakeResolved = true;
451
+ resolve();
452
+ };
453
+
454
+ ws.onopen = () => {
455
+ console.log('[usage][gateway] ws connected:', url);
456
+ };
457
+
458
+ ws.onerror = (event) => {
459
+ rejectOnce(new Error(`Gateway websocket error: ${String(event.type ?? 'unknown')}`));
460
+ };
461
+
462
+ ws.onclose = (event) => {
463
+ const reason = event.reason || 'gateway websocket closed';
464
+ const error = new Error(`Gateway websocket closed (${event.code}): ${reason}`);
465
+ rejectOnce(error);
466
+ for (const entry of pending.values()) {
467
+ entry.reject(error);
468
+ }
469
+ pending.clear();
470
+ };
471
+
472
+ ws.onmessage = (event) => {
473
+ try {
474
+ const message = JSON.parse(String(event.data ?? '')) as Record<string, any>;
475
+ if (message.type === 'event' && message.event === 'connect.challenge') {
476
+ const payload = isObjectRecord(message.payload) ? message.payload : {};
477
+ connectNonce = typeof payload.nonce === 'string' ? payload.nonce : null;
478
+ if (!connectNonce) {
479
+ rejectOnce(new Error('gateway connect challenge missing nonce'));
480
+ return;
481
+ }
482
+
483
+ ws.send(JSON.stringify(buildGatewayConnectFrame(token)));
484
+ return;
485
+ }
486
+
487
+ if (message.type === 'res') {
488
+ const id = typeof message.id === 'string' ? message.id : null;
489
+ if (id === '__connect__') {
490
+ if (message.ok === true) {
491
+ resolveOnce();
492
+ } else {
493
+ rejectOnce(new Error(`Gateway connect failed: ${message.error?.message ?? 'unknown error'}`));
494
+ }
495
+ return;
496
+ }
497
+
498
+ if (!id) {
499
+ return;
500
+ }
501
+
502
+ const entry = pending.get(id);
503
+ if (!entry) {
504
+ return;
505
+ }
506
+ pending.delete(id);
507
+
508
+ if (message.ok === true) {
509
+ entry.resolve(message.payload);
510
+ } else {
511
+ entry.reject(new Error(message.error?.message ?? 'Gateway request failed'));
512
+ }
513
+ }
514
+ } catch (error) {
515
+ rejectOnce(error);
516
+ }
517
+ };
518
+ });
519
+
520
+ await withTimeout(connectPromise, 5000, 'Gateway connect timeout');
521
+
522
+ return {
523
+ async call(method: string, params: Record<string, JsonValue>): Promise<unknown> {
524
+ const id = `usage-${++requestCounter}`;
525
+ const promise = new Promise<unknown>((resolve, reject) => {
526
+ pending.set(id, { resolve, reject });
527
+ });
528
+
529
+ ws.send(JSON.stringify({
530
+ type: 'req',
531
+ id,
532
+ method,
533
+ params
534
+ }));
535
+
536
+ const result = await withTimeout(promise, 10000, `Gateway request timeout for ${method}`);
537
+ console.log('[usage][gateway] ws call succeeded for method:', method);
538
+ return unwrapGatewayResult(result);
539
+ }
540
+ };
541
+ }
542
+
543
+ function buildGatewayConnectFrame(token: string) {
544
+ return {
545
+ type: 'req',
546
+ id: '__connect__',
547
+ method: 'connect',
548
+ params: {
549
+ minProtocol: 3,
550
+ maxProtocol: 3,
551
+ client: {
552
+ id: 'openclaw-control-ui',
553
+ version: 'rol-websocket-channel',
554
+ platform: 'node',
555
+ mode: 'webchat',
556
+ instanceId: 'rol-websocket-channel-usage'
557
+ },
558
+ role: 'operator',
559
+ scopes: [
560
+ 'operator.admin',
561
+ 'operator.read',
562
+ 'operator.write',
563
+ 'operator.approvals',
564
+ 'operator.pairing'
565
+ ],
566
+ caps: ['tool-events'],
567
+ auth: {
568
+ token
569
+ },
570
+ userAgent: 'rol-websocket-channel',
571
+ locale: 'en-US'
572
+ }
573
+ };
574
+ }
575
+
576
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
577
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
578
+ try {
579
+ return await Promise.race([
580
+ promise,
581
+ new Promise<T>((_, reject) => {
582
+ timeoutHandle = setTimeout(() => reject(new Error(message)), timeoutMs);
583
+ })
584
+ ]);
585
+ } finally {
586
+ if (timeoutHandle) {
587
+ clearTimeout(timeoutHandle);
588
+ }
589
+ }
590
+ }
591
+
592
+ function normalizeGatewayUsageLogs(payload: unknown): SessionUsageEvent[] {
593
+ if (!isObjectRecord(payload)) {
594
+ return [];
595
+ }
596
+
597
+ const rows = Array.isArray(payload.rows)
598
+ ? payload.rows
599
+ : Array.isArray(payload.logs)
600
+ ? payload.logs
601
+ : Array.isArray(payload.events)
602
+ ? payload.events
603
+ : [];
604
+
605
+ const events: SessionUsageEvent[] = [];
606
+ for (const row of rows) {
607
+ if (!isObjectRecord(row)) {
608
+ continue;
609
+ }
610
+
611
+ const ts = parseTimestamp(row.ts ?? row.timestamp ?? row.createdAt);
612
+ const sessionId = asString(row.sessionId) ?? asString(row.id);
613
+ if (!sessionId || ts === null) {
614
+ continue;
615
+ }
616
+
617
+ const input = asNumber(row.inputTokens) ?? asNumber(row.input) ?? 0;
618
+ const output = asNumber(row.outputTokens) ?? asNumber(row.output) ?? 0;
619
+ const cacheRead = asNumber(row.cacheReadTokens) ?? asNumber(row.cacheRead) ?? 0;
620
+ const cacheWrite = asNumber(row.cacheWriteTokens) ?? asNumber(row.cacheWrite) ?? 0;
621
+
622
+ events.push({
623
+ ts,
624
+ sessionId,
625
+ agent: asString(row.agent) ?? 'main',
626
+ channel: asString(row.channel) ?? 'unknown',
627
+ provider: asString(row.provider) ?? 'unknown',
628
+ model: asString(row.model) ?? 'unknown',
629
+ toolName: asString(row.toolName) ?? null,
630
+ messageRole: asString(row.role) ?? null,
631
+ input,
632
+ output,
633
+ cacheRead,
634
+ cacheWrite,
635
+ totalTokens:
636
+ asNumber(row.totalTokens)
637
+ ?? asNumber(row.tokens)
638
+ ?? input + output + cacheRead + cacheWrite,
639
+ totalCostUsd: asNumber(row.totalCostUsd) ?? asNumber(row.costUsd) ?? 0,
640
+ isError: Boolean(row.isError ?? row.errorCount ?? row.error),
641
+ isToolCall: Boolean(row.isToolCall ?? row.toolCalls),
642
+ toolCallsCount: asNumber(row.toolCalls) ?? 0,
643
+ messageCount: asNumber(row.messages) ?? 1,
644
+ usageMessageCount: asNumber(row.usageMessages) ?? 1
645
+ });
646
+ }
647
+
648
+ return events;
649
+ }
650
+
651
+ async function readLocalUsageSource(context: MethodContext): Promise<UsageSourceResult> {
652
+ const sessionMetadataById = new Map<string, { sessionKey: string; entry: SessionsIndexEntry }>();
653
+ const summaryRecordById = new Map<string, SessionSummaryRecord>();
654
+ await loadSessionMetadata(context.openclawRoot, sessionMetadataById);
655
+
656
+ const events: SessionUsageEvent[] = [];
657
+ const sessionFiles = await discoverSessionFiles(context.openclawRoot);
658
+
659
+ for (const sessionFile of sessionFiles) {
660
+ const sessionId = path.basename(sessionFile, '.jsonl');
661
+ const metadata = sessionMetadataById.get(sessionId);
662
+ const agentName = extractAgentNameFromPath(sessionFile);
663
+ const sessionKey = metadata?.sessionKey ?? `agent:${agentName}:${agentName}`;
664
+ const entry = metadata?.entry ?? {};
665
+ const sessionEvents = await readSessionEvents(sessionFile, sessionKey, {
666
+ ...entry,
667
+ sessionId
668
+ });
669
+ events.push(...sessionEvents);
670
+ summaryRecordById.set(sessionId, buildSessionSummaryFromEvents(sessionEvents, sessionKey, {
671
+ ...entry,
672
+ sessionId
673
+ }));
674
+ }
675
+
676
+ for (const [sessionId, metadata] of sessionMetadataById.entries()) {
677
+ if (summaryRecordById.has(sessionId)) {
678
+ continue;
679
+ }
680
+ summaryRecordById.set(sessionId, buildSessionSummaryRecord(metadata.sessionKey, metadata.entry));
681
+ }
682
+
683
+ return {
684
+ events,
685
+ summaryRecords: [...summaryRecordById.values()],
686
+ meta: {
687
+ source: 'local_fallback',
688
+ usedLiveSnapshot: false,
689
+ usedTranscriptFallback: true,
690
+ usedDiagnostics: false
691
+ }
692
+ };
693
+ }
694
+
695
+ async function loadSessionMetadata(
696
+ openclawRoot: string,
697
+ target: Map<string, { sessionKey: string; entry: SessionsIndexEntry }>
698
+ ): Promise<void> {
699
+ const agentsRoot = path.join(openclawRoot, 'agents');
700
+ if (!(await pathExists(agentsRoot))) {
701
+ return;
702
+ }
703
+
704
+ const agentEntries = await fs.readdir(agentsRoot, { withFileTypes: true });
705
+ for (const agentEntry of agentEntries) {
706
+ if (!agentEntry.isDirectory()) {
707
+ continue;
708
+ }
709
+
710
+ const sessionsPath = path.join(agentsRoot, agentEntry.name, 'sessions', 'sessions.json');
711
+ if (!(await pathExists(sessionsPath))) {
712
+ continue;
713
+ }
714
+
715
+ const sessions = await readJsonFile<SessionsIndex>(sessionsPath);
716
+ for (const [sessionKey, entry] of Object.entries(sessions)) {
717
+ if (entry.sessionId) {
718
+ target.set(entry.sessionId, { sessionKey, entry });
719
+ }
720
+ }
721
+ }
722
+ }
723
+
724
+ function dedupeEvents(events: SessionUsageEvent[]): SessionUsageEvent[] {
725
+ const seen = new Set<string>();
726
+ const deduped: SessionUsageEvent[] = [];
727
+
728
+ for (const event of events) {
729
+ const key = [
730
+ event.sessionId,
731
+ event.ts,
732
+ event.provider,
733
+ event.model,
734
+ event.messageRole ?? 'none',
735
+ event.toolName ?? 'none',
736
+ event.input,
737
+ event.output,
738
+ event.cacheRead,
739
+ event.cacheWrite,
740
+ event.totalTokens,
741
+ event.totalCostUsd
742
+ ].join(':');
743
+
744
+ if (seen.has(key)) {
745
+ continue;
746
+ }
747
+
748
+ seen.add(key);
749
+ deduped.push(event);
750
+ }
751
+
752
+ return deduped;
753
+ }
754
+
755
+ function aggregateSummary(records: SessionSummaryRecord[]): UsageSummaryBucket {
756
+ return summarizeSessionRecords(records);
757
+ }
758
+
759
+ function aggregateTimeseries(
760
+ events: SessionUsageEvent[],
761
+ records: SessionSummaryRecord[],
762
+ granularity: string
763
+ ) {
764
+ const buckets = new Map<string, UsageBucketState>();
765
+ const sessionsWithEvents = new Set<string>();
766
+
767
+ for (const event of events) {
768
+ const bucketTs = bucketTimestamp(event.ts, granularity);
769
+ const key = new Date(bucketTs).toISOString();
770
+ const bucket = buckets.get(key) ?? { usage: blankUsage(), sessionIds: new Set<string>() };
771
+ applyEvent(bucket.usage, event);
772
+ bucket.sessionIds.add(event.sessionId);
773
+ sessionsWithEvents.add(event.sessionId);
774
+ buckets.set(key, bucket);
775
+ }
776
+
777
+ for (const record of records) {
778
+ if (sessionsWithEvents.has(record.sessionId)) {
779
+ continue;
780
+ }
781
+ if (record.timestampMs === null) {
782
+ continue;
783
+ }
784
+ const bucketTs = bucketTimestamp(record.timestampMs, granularity);
785
+ const key = new Date(bucketTs).toISOString();
786
+ const bucket = buckets.get(key) ?? { usage: blankUsage(), sessionIds: new Set<string>() };
787
+ mergeUsageWindows(bucket.usage, record.totals);
788
+ bucket.sessionIds.add(record.sessionId);
789
+ buckets.set(key, bucket);
790
+ }
791
+
792
+ return [...buckets.entries()]
793
+ .sort((a, b) => a[0].localeCompare(b[0]))
794
+ .map(([ts, bucket]) => ({
795
+ ts,
796
+ messages: bucket.usage.messages,
797
+ sessions: bucket.sessionIds.size,
798
+ inputTokens: bucket.usage.input,
799
+ outputTokens: bucket.usage.output,
800
+ cacheReadTokens: bucket.usage.cacheRead,
801
+ cacheWriteTokens: bucket.usage.cacheWrite,
802
+ totalTokens: bucket.usage.totalTokens,
803
+ totalCostUsd: bucket.usage.totalCostUsd,
804
+ toolCalls: bucket.usage.toolCalls,
805
+ errors: bucket.usage.errors,
806
+ cachedTokens: bucket.usage.cachedTokens,
807
+ avgTokensPerMessage: bucket.usage.messages > 0 ? bucket.usage.totalTokens / bucket.usage.messages : 0,
808
+ throughputTokPerMin: computeThroughput(bucket.usage.totalTokens, granularity)
809
+ }));
810
+ }
811
+
812
+ function aggregateBreakdown(
813
+ events: SessionUsageEvent[],
814
+ records: SessionSummaryRecord[],
815
+ dimension: string,
816
+ totalTokens: number,
817
+ limit: number,
818
+ sortBy: string,
819
+ order: 'asc' | 'desc'
820
+ ) {
821
+ return dimension === 'tool'
822
+ ? buildToolBreakdown(events, totalTokens, limit, sortBy, order)
823
+ : buildSummaryBreakdown(records, dimension, totalTokens, limit, sortBy, order);
824
+ }
825
+
826
+ async function readSessionEvents(
827
+ filePath: string,
828
+ sessionKey: string,
829
+ entry: SessionsIndexEntry
830
+ ): Promise<SessionUsageEvent[]> {
831
+ const events: SessionUsageEvent[] = [];
832
+ const stream = await fs.open(filePath, 'r');
833
+ let currentProvider = '';
834
+ let currentModel = '';
835
+ const agent = extractAgentName(sessionKey);
836
+ const channel = entry.origin?.label ?? entry.origin?.provider ?? 'unknown';
837
+
838
+ try {
839
+ const rl = readline.createInterface({
840
+ input: stream.createReadStream(),
841
+ crlfDelay: Infinity
842
+ });
843
+
844
+ for await (const line of rl) {
845
+ if (!line.trim()) {
846
+ continue;
847
+ }
848
+
849
+ try {
850
+ const parsed = JSON.parse(line) as Record<string, any>;
851
+ const normalized = normalizeUsageRecord(parsed, {
852
+ sessionId: entry.sessionId ?? 'unknown',
853
+ sessionKey,
854
+ agent,
855
+ channel,
856
+ currentProvider,
857
+ currentModel
858
+ });
859
+
860
+ currentProvider = normalized.currentProvider;
861
+ currentModel = normalized.currentModel;
862
+
863
+ if (normalized.event) {
864
+ events.push(normalized.event);
865
+ }
866
+ } catch {
867
+ continue;
868
+ }
869
+ }
870
+ } finally {
871
+ await stream.close();
872
+ }
873
+
874
+ return events;
875
+ }
876
+
877
+ function normalizeUsageRecord(
878
+ parsed: Record<string, any>,
879
+ context: {
880
+ sessionId: string;
881
+ sessionKey: string;
882
+ agent: string;
883
+ channel: string;
884
+ currentProvider: string;
885
+ currentModel: string;
886
+ }
887
+ ): {
888
+ event: SessionUsageEvent | null;
889
+ currentProvider: string;
890
+ currentModel: string;
891
+ } {
892
+ let currentProvider = context.currentProvider;
893
+ let currentModel = context.currentModel;
894
+
895
+ if (parsed.type === 'model_change') {
896
+ currentProvider = typeof parsed.provider === 'string' ? parsed.provider : currentProvider;
897
+ currentModel = typeof parsed.modelId === 'string' ? parsed.modelId : currentModel;
898
+ }
899
+
900
+ const message = parsed.message as Record<string, any> | undefined;
901
+ const usage = message?.usage as Record<string, any> | undefined;
902
+
903
+ if (typeof message?.provider === 'string') {
904
+ currentProvider = message.provider;
905
+ }
906
+
907
+ if (typeof message?.model === 'string') {
908
+ currentModel = message.model;
909
+ }
910
+
911
+ const timestamp = typeof parsed.timestamp === 'string' ? Date.parse(parsed.timestamp) : Number.NaN;
912
+ if (!Number.isFinite(timestamp)) {
913
+ return { event: null, currentProvider, currentModel };
914
+ }
915
+
916
+ const content = Array.isArray(message?.content) ? message.content : [];
917
+ const toolCalls = content.filter((item: any) => item?.type === 'toolCall');
918
+ const cost = isObject(usage?.cost) ? usage.cost : {};
919
+ const role = typeof message?.role === 'string' ? message.role : null;
920
+ const messageCount = role === 'user' || role === 'assistant' ? 1 : 0;
921
+ const usageMessageCount = role === 'assistant' && usage ? 1 : 0;
922
+ const toolCallsCount = toolCalls.length;
923
+
924
+ if (!usage && messageCount === 0 && toolCallsCount === 0) {
925
+ return { event: null, currentProvider, currentModel };
926
+ }
927
+
928
+ return {
929
+ currentProvider,
930
+ currentModel,
931
+ event: {
932
+ ts: timestamp,
933
+ sessionId: context.sessionId,
934
+ agent: context.agent,
935
+ channel: context.channel,
936
+ provider: currentProvider || 'unknown',
937
+ model: currentModel || 'unknown',
938
+ toolName: toolCallsCount > 0 ? String(toolCalls[0].name ?? 'unknown') : null,
939
+ messageRole: role,
940
+ input: numeric(usage?.input),
941
+ output: numeric(usage?.output),
942
+ cacheRead: numeric(usage?.cacheRead),
943
+ cacheWrite: numeric(usage?.cacheWrite),
944
+ totalTokens: numeric(usage?.totalTokens),
945
+ totalCostUsd: numeric(cost.total),
946
+ isError: typeof message?.errorMessage === 'string' || parsed.stopReason === 'error',
947
+ isToolCall: toolCallsCount > 0,
948
+ toolCallsCount,
949
+ messageCount,
950
+ usageMessageCount
951
+ }
952
+ };
953
+ }
954
+
955
+ async function discoverSessionFiles(openclawRoot: string): Promise<string[]> {
956
+ const agentsRoot = path.join(openclawRoot, 'agents');
957
+ if (!(await pathExists(agentsRoot))) {
958
+ return [];
959
+ }
960
+
961
+ const sessionFiles: string[] = [];
962
+ const agentEntries = await fs.readdir(agentsRoot, { withFileTypes: true });
963
+
964
+ for (const agentEntry of agentEntries) {
965
+ if (!agentEntry.isDirectory()) {
966
+ continue;
967
+ }
968
+
969
+ const sessionsDir = path.join(agentsRoot, agentEntry.name, 'sessions');
970
+ if (!(await pathExists(sessionsDir))) {
971
+ continue;
972
+ }
973
+
974
+ const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
975
+ for (const entry of entries) {
976
+ if (!entry.isFile()) {
977
+ continue;
978
+ }
979
+ if (!(entry.name.endsWith('.jsonl') || entry.name.includes('.jsonl.reset.'))) {
980
+ continue;
981
+ }
982
+ sessionFiles.push(path.join(sessionsDir, entry.name));
983
+ }
984
+ }
985
+
986
+ return sessionFiles;
987
+ }
988
+
989
+ function filterSessionSummaries(records: SessionSummaryRecord[], query: UsageQuery): SessionSummaryRecord[] {
990
+ const from = query.from ? Date.parse(query.from) : Number.NEGATIVE_INFINITY;
991
+ const to = query.to ? Date.parse(query.to) : Number.POSITIVE_INFINITY;
992
+
993
+ return records.filter((record) => {
994
+ if (record.timestampMs !== null && (record.timestampMs < from || record.timestampMs > to)) return false;
995
+ if (query.sessionIds?.length && !query.sessionIds.includes(record.sessionId)) return false;
996
+ if (query.agent?.length && !query.agent.includes(record.agent)) return false;
997
+ if (query.channel?.length && !query.channel.includes(record.channel)) return false;
998
+ if (query.provider?.length && !query.provider.includes(record.provider)) return false;
999
+ if (query.model?.length && !query.model.includes(record.model)) return false;
1000
+ return true;
1001
+ });
1002
+ }
1003
+
1004
+ function filterEvents(events: SessionUsageEvent[], query: UsageQuery): SessionUsageEvent[] {
1005
+ const from = query.from ? Date.parse(query.from) : Number.NEGATIVE_INFINITY;
1006
+ const to = query.to ? Date.parse(query.to) : Number.POSITIVE_INFINITY;
1007
+
1008
+ return events.filter((event) => {
1009
+ if (event.ts < from || event.ts > to) return false;
1010
+ if (query.sessionIds?.length && !query.sessionIds.includes(event.sessionId)) return false;
1011
+ if (query.agent?.length && !query.agent.includes(event.agent)) return false;
1012
+ if (query.channel?.length && !query.channel.includes(event.channel)) return false;
1013
+ if (query.provider?.length && !query.provider.includes(event.provider)) return false;
1014
+ if (query.model?.length && !query.model.includes(event.model)) return false;
1015
+ if (query.tool?.length) {
1016
+ if (!event.toolName || !query.tool.includes(event.toolName)) return false;
1017
+ }
1018
+ return true;
1019
+ });
1020
+ }
1021
+
1022
+ function summarizeEvents(events: SessionUsageEvent[]): UsageSummaryBucket {
1023
+ const usage = blankUsage();
1024
+ const seenSessions = new Set<string>();
1025
+
1026
+ for (const event of events) {
1027
+ applyEvent(usage, event);
1028
+ seenSessions.add(event.sessionId);
1029
+ }
1030
+
1031
+ usage.sessions = seenSessions.size;
1032
+ return usage;
1033
+ }
1034
+
1035
+ function summarizeSessionRecords(records: SessionSummaryRecord[]): UsageSummaryBucket {
1036
+ const usage = blankUsage();
1037
+ const seenSessions = new Set<string>();
1038
+
1039
+ for (const record of records) {
1040
+ mergeUsageWindows(usage, record.totals);
1041
+ seenSessions.add(record.sessionId);
1042
+ }
1043
+
1044
+ usage.sessions = seenSessions.size;
1045
+ return usage;
1046
+ }
1047
+
1048
+ function buildSessionSummaryRecord(sessionKey: string, entry: SessionsIndexEntry): SessionSummaryRecord {
1049
+ const agent = extractAgentName(sessionKey);
1050
+ const channel = entry.origin?.label ?? entry.origin?.provider ?? 'unknown';
1051
+ const totals = blankUsage();
1052
+ totals.promptTokens = numeric(entry.inputTokens);
1053
+ totals.completionTokens = numeric(entry.outputTokens);
1054
+ totals.totalTokens = numeric(entry.totalTokens);
1055
+ totals.totalCostUsd = numeric(entry.estimatedCostUsd);
1056
+ totals.input = totals.promptTokens;
1057
+ totals.output = totals.completionTokens;
1058
+ totals.cacheRead = numeric(entry.cacheRead);
1059
+ totals.cacheWrite = numeric(entry.cacheWrite);
1060
+ totals.cachedTokens = totals.cacheRead;
1061
+
1062
+ return {
1063
+ sessionId: entry.sessionId ?? 'unknown',
1064
+ totals,
1065
+ timestampMs: typeof entry.updatedAt === 'number' ? entry.updatedAt : null,
1066
+ runtimeMs: typeof entry.runtimeMs === 'number' ? entry.runtimeMs : null,
1067
+ provider: 'unknown',
1068
+ model: 'unknown',
1069
+ agent,
1070
+ channel
1071
+ };
1072
+ }
1073
+
1074
+ function buildSessionSummaryFromEvents(
1075
+ events: SessionUsageEvent[],
1076
+ sessionKey: string,
1077
+ entry: SessionsIndexEntry
1078
+ ): SessionSummaryRecord {
1079
+ if (events.length === 0) {
1080
+ return buildSessionSummaryRecord(sessionKey, entry);
1081
+ }
1082
+
1083
+ const totals = summarizeEvents(events);
1084
+ const timestamps = events.map((event) => event.ts).sort((a, b) => a - b);
1085
+ const lastEvent = [...events].reverse().find((event) => event.model !== 'unknown' || event.provider !== 'unknown') ?? events[events.length - 1];
1086
+ const spanMs = timestamps.length > 1 ? timestamps[timestamps.length - 1] - timestamps[0] : 0;
1087
+
1088
+ return {
1089
+ sessionId: entry.sessionId ?? 'unknown',
1090
+ totals,
1091
+ timestampMs: timestamps[timestamps.length - 1] ?? null,
1092
+ runtimeMs: Math.max(typeof entry.runtimeMs === 'number' ? entry.runtimeMs : 0, spanMs) || null,
1093
+ provider: lastEvent.provider,
1094
+ model: lastEvent.model,
1095
+ agent: extractAgentName(sessionKey),
1096
+ channel: entry.origin?.label ?? entry.origin?.provider ?? 'unknown'
1097
+ };
1098
+ }
1099
+
1100
+ function applyEvent(target: UsageSummaryBucket, event: SessionUsageEvent) {
1101
+ target.input += event.input;
1102
+ target.output += event.output;
1103
+ target.cacheRead += event.cacheRead;
1104
+ target.cacheWrite += event.cacheWrite;
1105
+ target.totalTokens += event.totalTokens;
1106
+ target.totalCostUsd += event.totalCostUsd;
1107
+ target.cachedTokens += event.cacheRead;
1108
+ target.promptTokens += event.input;
1109
+ target.completionTokens += event.output;
1110
+ target.messages += event.messageCount;
1111
+ if (event.isError) {
1112
+ target.errors += 1;
1113
+ }
1114
+ target.toolCalls += event.toolCallsCount;
1115
+ }
1116
+
1117
+ function buildSummaryMetrics(usage: UsageSummaryBucket, summaries: SessionSummaryRecord[]) {
1118
+ const minutes = computeRuntimeMinutes(summaries);
1119
+
1120
+ return {
1121
+ messages: usage.messages,
1122
+ sessions: usage.sessions,
1123
+ totalTokens: usage.totalTokens,
1124
+ totalCostUsd: usage.totalCostUsd,
1125
+ toolCalls: usage.toolCalls,
1126
+ errors: usage.errors,
1127
+ cachedTokens: usage.cachedTokens,
1128
+ promptTokens: usage.promptTokens,
1129
+ completionTokens: usage.completionTokens,
1130
+ avgTokensPerMessage: usage.messages > 0 ? usage.totalTokens / usage.messages : 0,
1131
+ avgCostPerMessageUsd: usage.messages > 0 ? usage.totalCostUsd / usage.messages : 0,
1132
+ cacheHitRate: usage.promptTokens > 0 ? usage.cachedTokens / usage.promptTokens : 0,
1133
+ errorRate: usage.messages > 0 ? usage.errors / usage.messages : 0,
1134
+ throughputTokPerMin: minutes > 0 ? usage.totalTokens / minutes : 0
1135
+ };
1136
+ }
1137
+
1138
+ function buildSessionRows(records: SessionSummaryRecord[]) {
1139
+ return [...records]
1140
+ .sort((a, b) => (b.timestampMs ?? 0) - (a.timestampMs ?? 0))
1141
+ .map((record) => ({
1142
+ sessionId: record.sessionId,
1143
+ agent: record.agent,
1144
+ provider: record.provider,
1145
+ model: record.model,
1146
+ channel: record.channel,
1147
+ updatedAt: record.timestampMs ? new Date(record.timestampMs).toISOString() : null,
1148
+ runtimeMs: record.runtimeMs,
1149
+ messages: record.totals.messages,
1150
+ totalTokens: record.totals.totalTokens,
1151
+ totalCostUsd: record.totals.totalCostUsd,
1152
+ toolCalls: record.totals.toolCalls,
1153
+ errors: record.totals.errors,
1154
+ cachedTokens: record.totals.cachedTokens,
1155
+ promptTokens: record.totals.promptTokens,
1156
+ completionTokens: record.totals.completionTokens
1157
+ }));
1158
+ }
1159
+
1160
+ function buildSummaryRankedList(records: SessionSummaryRecord[], dimension: string, limit: number) {
1161
+ const breakdown = new Map<string, UsageBucketState>();
1162
+
1163
+ for (const record of records) {
1164
+ const key = dimensionValueFromSummary(record, dimension);
1165
+ if (shouldHideRankingKey(dimension, key, record.totals.totalTokens)) {
1166
+ continue;
1167
+ }
1168
+ const bucket = breakdown.get(key) ?? { usage: blankUsage(), sessionIds: new Set<string>() };
1169
+ mergeUsageWindows(bucket.usage, record.totals);
1170
+ bucket.sessionIds.add(record.sessionId);
1171
+ breakdown.set(key, bucket);
1172
+ }
1173
+
1174
+ return [...breakdown.entries()]
1175
+ .map(([key, bucket]) => ({
1176
+ key,
1177
+ label: key,
1178
+ messages: bucket.usage.messages,
1179
+ sessions: bucket.sessionIds.size,
1180
+ totalTokens: bucket.usage.totalTokens,
1181
+ totalCostUsd: bucket.usage.totalCostUsd,
1182
+ toolCalls: bucket.usage.toolCalls,
1183
+ errors: bucket.usage.errors,
1184
+ cachedTokens: bucket.usage.cachedTokens,
1185
+ cacheHitRate: bucket.usage.promptTokens > 0 ? bucket.usage.cachedTokens / bucket.usage.promptTokens : 0,
1186
+ avgTokensPerMessage: bucket.usage.messages > 0 ? bucket.usage.totalTokens / bucket.usage.messages : 0
1187
+ }))
1188
+ .sort((a, b) => b.totalTokens - a.totalTokens)
1189
+ .slice(0, limit);
1190
+ }
1191
+
1192
+ function buildToolRankedList(events: SessionUsageEvent[], limit: number) {
1193
+ const breakdown = new Map<string, UsageBucketState>();
1194
+
1195
+ for (const event of events) {
1196
+ if (event.toolCallsCount === 0) {
1197
+ continue;
1198
+ }
1199
+ const key = dimensionValue(event, 'tool');
1200
+ if (shouldHideRankingKey('tool', key, event.totalTokens)) {
1201
+ continue;
1202
+ }
1203
+ const bucket = breakdown.get(key) ?? { usage: blankUsage(), sessionIds: new Set<string>() };
1204
+ applyEvent(bucket.usage, event);
1205
+ bucket.sessionIds.add(event.sessionId);
1206
+ breakdown.set(key, bucket);
1207
+ }
1208
+
1209
+ return [...breakdown.entries()]
1210
+ .map(([key, bucket]) => ({
1211
+ key,
1212
+ label: key,
1213
+ messages: bucket.usage.messages,
1214
+ sessions: bucket.sessionIds.size,
1215
+ totalTokens: bucket.usage.totalTokens,
1216
+ totalCostUsd: bucket.usage.totalCostUsd,
1217
+ toolCalls: bucket.usage.toolCalls,
1218
+ errors: bucket.usage.errors,
1219
+ cachedTokens: bucket.usage.cachedTokens,
1220
+ cacheHitRate: bucket.usage.promptTokens > 0 ? bucket.usage.cachedTokens / bucket.usage.promptTokens : 0,
1221
+ avgTokensPerMessage: bucket.usage.messages > 0 ? bucket.usage.totalTokens / bucket.usage.messages : 0
1222
+ }))
1223
+ .sort((a, b) => b.toolCalls - a.toolCalls)
1224
+ .slice(0, limit);
1225
+ }
1226
+
1227
+ function buildSummaryBreakdown(
1228
+ records: SessionSummaryRecord[],
1229
+ dimension: string,
1230
+ totalTokens: number,
1231
+ limit: number,
1232
+ sortBy: string,
1233
+ order: 'asc' | 'desc'
1234
+ ) {
1235
+ const buckets = new Map<string, UsageBucketState>();
1236
+
1237
+ for (const record of records) {
1238
+ const key = dimensionValueFromSummary(record, dimension);
1239
+ if (shouldHideRankingKey(dimension, key, record.totals.totalTokens)) {
1240
+ continue;
1241
+ }
1242
+ const bucket = buckets.get(key) ?? { usage: blankUsage(), sessionIds: new Set<string>() };
1243
+ mergeUsageWindows(bucket.usage, record.totals);
1244
+ bucket.sessionIds.add(record.sessionId);
1245
+ buckets.set(key, bucket);
1246
+ }
1247
+
1248
+ return [...buckets.entries()]
1249
+ .map(([key, bucket]) => ({
1250
+ key,
1251
+ label: key,
1252
+ messages: bucket.usage.messages,
1253
+ sessions: bucket.sessionIds.size,
1254
+ totalTokens: bucket.usage.totalTokens,
1255
+ totalCostUsd: bucket.usage.totalCostUsd,
1256
+ toolCalls: bucket.usage.toolCalls,
1257
+ errors: bucket.usage.errors,
1258
+ cachedTokens: bucket.usage.cachedTokens,
1259
+ cacheHitRate: bucket.usage.promptTokens > 0 ? bucket.usage.cachedTokens / bucket.usage.promptTokens : 0,
1260
+ avgTokensPerMessage: bucket.usage.messages > 0 ? bucket.usage.totalTokens / bucket.usage.messages : 0,
1261
+ avgCostPerMessageUsd: bucket.usage.messages > 0 ? bucket.usage.totalCostUsd / bucket.usage.messages : 0,
1262
+ throughputTokPerMin: computeThroughput(bucket.usage.totalTokens, 'day'),
1263
+ share: totalTokens > 0 ? bucket.usage.totalTokens / totalTokens : 0
1264
+ }))
1265
+ .sort((a, b) => compareRows(a, b, sortBy, order))
1266
+ .slice(0, limit);
1267
+ }
1268
+
1269
+ function buildToolBreakdown(
1270
+ events: SessionUsageEvent[],
1271
+ totalTokens: number,
1272
+ limit: number,
1273
+ sortBy: string,
1274
+ order: 'asc' | 'desc'
1275
+ ) {
1276
+ const buckets = new Map<string, UsageBucketState>();
1277
+
1278
+ for (const event of events) {
1279
+ if (event.toolCallsCount === 0) {
1280
+ continue;
1281
+ }
1282
+ const key = dimensionValue(event, 'tool');
1283
+ if (shouldHideRankingKey('tool', key, event.totalTokens)) {
1284
+ continue;
1285
+ }
1286
+ const bucket = buckets.get(key) ?? { usage: blankUsage(), sessionIds: new Set<string>() };
1287
+ applyEvent(bucket.usage, event);
1288
+ bucket.sessionIds.add(event.sessionId);
1289
+ buckets.set(key, bucket);
1290
+ }
1291
+
1292
+ return [...buckets.entries()]
1293
+ .map(([key, bucket]) => ({
1294
+ key,
1295
+ label: key,
1296
+ messages: bucket.usage.messages,
1297
+ sessions: bucket.sessionIds.size,
1298
+ totalTokens: bucket.usage.totalTokens,
1299
+ totalCostUsd: bucket.usage.totalCostUsd,
1300
+ toolCalls: bucket.usage.toolCalls,
1301
+ errors: bucket.usage.errors,
1302
+ cachedTokens: bucket.usage.cachedTokens,
1303
+ cacheHitRate: bucket.usage.promptTokens > 0 ? bucket.usage.cachedTokens / bucket.usage.promptTokens : 0,
1304
+ avgTokensPerMessage: bucket.usage.messages > 0 ? bucket.usage.totalTokens / bucket.usage.messages : 0,
1305
+ avgCostPerMessageUsd: bucket.usage.messages > 0 ? bucket.usage.totalCostUsd / bucket.usage.messages : 0,
1306
+ throughputTokPerMin: computeThroughput(bucket.usage.totalTokens, 'day'),
1307
+ share: totalTokens > 0 ? bucket.usage.totalTokens / totalTokens : 0
1308
+ }))
1309
+ .sort((a, b) => compareRows(a, b, sortBy, order))
1310
+ .slice(0, limit);
1311
+ }
1312
+
1313
+ function dimensionValueFromSummary(record: SessionSummaryRecord, dimension: string): string {
1314
+ switch (dimension) {
1315
+ case 'provider':
1316
+ return record.provider;
1317
+ case 'agent':
1318
+ return record.agent;
1319
+ case 'channel':
1320
+ return record.channel;
1321
+ case 'model':
1322
+ default:
1323
+ return record.model;
1324
+ }
1325
+ }
1326
+
1327
+ function dimensionValue(event: SessionUsageEvent, dimension: string): string {
1328
+ switch (dimension) {
1329
+ case 'provider':
1330
+ return event.provider;
1331
+ case 'tool':
1332
+ return event.toolName ?? 'none';
1333
+ case 'agent':
1334
+ return event.agent;
1335
+ case 'channel':
1336
+ return event.channel;
1337
+ case 'model':
1338
+ default:
1339
+ return event.model;
1340
+ }
1341
+ }
1342
+
1343
+ function buildWindow(query: UsageQuery) {
1344
+ const now = new Date();
1345
+ const from = query.from ?? new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
1346
+ const to = query.to ?? now.toISOString();
1347
+
1348
+ return {
1349
+ from,
1350
+ to,
1351
+ timezone: query.timezone ?? 'UTC',
1352
+ basis: query.basis === 'cost' ? 'cost' : 'token'
1353
+ };
1354
+ }
1355
+
1356
+ function readUsageQuery(params: JsonValue | undefined): UsageQuery {
1357
+ if (!isObject(params)) {
1358
+ return {};
1359
+ }
1360
+
1361
+ return {
1362
+ from: typeof params.from === 'string' ? params.from : undefined,
1363
+ to: typeof params.to === 'string' ? params.to : undefined,
1364
+ timezone: typeof params.timezone === 'string' ? params.timezone : undefined,
1365
+ basis: typeof params.basis === 'string' ? params.basis : undefined,
1366
+ agent: stringArray(params.agent),
1367
+ channel: stringArray(params.channel),
1368
+ provider: stringArray(params.provider),
1369
+ model: stringArray(params.model),
1370
+ tool: stringArray(params.tool),
1371
+ sessionIds: stringArray(params.sessionIds)
1372
+ };
1373
+ }
1374
+
1375
+ function stringArray(value: JsonValue | undefined): string[] | undefined {
1376
+ if (!Array.isArray(value)) return undefined;
1377
+ return value.filter((item): item is string => typeof item === 'string');
1378
+ }
1379
+
1380
+ function isObject(value: JsonValue | undefined): value is Record<string, JsonValue> {
1381
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
1382
+ }
1383
+
1384
+ function isObjectRecord(value: unknown): value is Record<string, any> {
1385
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
1386
+ }
1387
+
1388
+ function numeric(value: unknown): number {
1389
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
1390
+ }
1391
+
1392
+ function asNumber(value: unknown): number | null {
1393
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
1394
+ }
1395
+
1396
+ function asString(value: unknown): string | null {
1397
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
1398
+ }
1399
+
1400
+ function parseTimestamp(value: unknown): number | null {
1401
+ if (typeof value === 'number' && Number.isFinite(value)) {
1402
+ return value;
1403
+ }
1404
+ if (typeof value === 'string') {
1405
+ const parsed = Date.parse(value);
1406
+ return Number.isFinite(parsed) ? parsed : null;
1407
+ }
1408
+ return null;
1409
+ }
1410
+
1411
+ function extractAgentName(sessionKey: string): string {
1412
+ const parts = sessionKey.split(':');
1413
+ return parts.length >= 2 ? parts[1] : 'main';
1414
+ }
1415
+
1416
+ function extractAgentNameFromPath(filePath: string): string {
1417
+ const normalized = filePath.replace(/\\/g, '/');
1418
+ const match = normalized.match(/\/agents\/([^/]+)\/sessions\//);
1419
+ return match?.[1] ?? 'main';
1420
+ }
1421
+
1422
+ function bucketTimestamp(timestamp: number, granularity: string): number {
1423
+ const date = new Date(timestamp);
1424
+ if (granularity === 'minute') {
1425
+ date.setSeconds(0, 0);
1426
+ return date.getTime();
1427
+ }
1428
+ if (granularity === 'hour') {
1429
+ date.setMinutes(0, 0, 0);
1430
+ return date.getTime();
1431
+ }
1432
+ date.setHours(0, 0, 0, 0);
1433
+ return date.getTime();
1434
+ }
1435
+
1436
+ function computeThroughput(totalTokens: number, granularity: string): number {
1437
+ if (granularity === 'minute') return totalTokens;
1438
+ if (granularity === 'hour') return totalTokens / 60;
1439
+ return totalTokens / (24 * 60);
1440
+ }
1441
+
1442
+ function compareRows(
1443
+ left: Record<string, number | string>,
1444
+ right: Record<string, number | string>,
1445
+ sortBy: string,
1446
+ order: 'asc' | 'desc'
1447
+ ) {
1448
+ const leftValue = typeof left[sortBy] === 'number' ? (left[sortBy] as number) : 0;
1449
+ const rightValue = typeof right[sortBy] === 'number' ? (right[sortBy] as number) : 0;
1450
+ return order === 'asc' ? leftValue - rightValue : rightValue - leftValue;
1451
+ }
1452
+
1453
+ function computeRuntimeMinutes(records: SessionSummaryRecord[]): number {
1454
+ const runtimeMs = records.reduce((sum, record) => sum + (record.runtimeMs ?? 0), 0);
1455
+ return runtimeMs > 0 ? runtimeMs / 60000 : 0;
1456
+ }
1457
+
1458
+ function shouldHideRankingKey(dimension: string, key: string, totalTokens: number): boolean {
1459
+ if (dimension === 'tool') {
1460
+ return key === 'none' || totalTokens <= 0;
1461
+ }
1462
+
1463
+ if (dimension === 'model') {
1464
+ return key === 'unknown' || key === 'gateway-injected' || totalTokens <= 0;
1465
+ }
1466
+
1467
+ if (dimension === 'provider') {
1468
+ return key === 'unknown' || key === 'openclaw' || totalTokens <= 0;
1469
+ }
1470
+
1471
+ if (dimension === 'channel') {
1472
+ return key === 'unknown' || totalTokens <= 0;
1473
+ }
1474
+
1475
+ return totalTokens <= 0;
1476
+ }
1477
+
1478
+ function mergeUsageWindows(target: UsageSummaryBucket, source: UsageSummaryBucket) {
1479
+ target.input += source.input;
1480
+ target.output += source.output;
1481
+ target.cacheRead += source.cacheRead;
1482
+ target.cacheWrite += source.cacheWrite;
1483
+ target.totalTokens += source.totalTokens;
1484
+ target.totalCostUsd += source.totalCostUsd;
1485
+ target.toolCalls += source.toolCalls;
1486
+ target.errors += source.errors;
1487
+ target.messages += source.messages;
1488
+ target.cachedTokens += source.cachedTokens;
1489
+ target.promptTokens += source.promptTokens;
1490
+ target.completionTokens += source.completionTokens;
1491
+ }