clawculator 2.4.0 โ†’ 2.5.1

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.
@@ -13,6 +13,7 @@ const flags = {
13
13
  report: args.includes('--report'),
14
14
  json: args.includes('--json'),
15
15
  md: args.includes('--md'),
16
+ live: args.includes('--live'),
16
17
  help: args.includes('--help') || args.includes('-h'),
17
18
  config: args.find(a => a.startsWith('--config='))?.split('=')[1],
18
19
  out: args.find(a => a.startsWith('--out='))?.split('=')[1],
@@ -36,6 +37,7 @@ Usage: clawculator [options]
36
37
 
37
38
  Options:
38
39
  (no flags) Full terminal analysis
40
+ --live Real-time cost dashboard (watches transcripts)
39
41
  --md Save markdown report to ./clawculator-report.md
40
42
  --report Generate HTML report and open in browser
41
43
  --json Output raw JSON
@@ -45,6 +47,7 @@ Options:
45
47
 
46
48
  Examples:
47
49
  npx clawculator # Terminal analysis
50
+ npx clawculator --live # Real-time cost dashboard
48
51
  npx clawculator --md # Markdown report (readable by your AI agent)
49
52
  npx clawculator --report # Visual HTML dashboard
50
53
  npx clawculator --json # JSON for piping
@@ -59,10 +62,32 @@ async function main() {
59
62
  }
60
63
 
61
64
  console.log(BANNER);
65
+ if (flags.live) {
66
+ console.log(BANNER);
67
+ console.log(` ${D}๐Ÿ’ก Tip: Run this in a tmux pane alongside your main session${R}`);
68
+ console.log(` ${D} tmux split-window -h "npx clawculator --live"${R}\n`);
69
+ const { startLiveDashboard } = require('../src/liveDashboard');
70
+ startLiveDashboard({ openclawHome });
71
+ return; // dashboard runs until user quits
72
+ }
73
+
62
74
  console.log('\x1b[90mScanning your setup...\x1b[0m\n');
63
75
 
64
- const configPath = flags.config || path.join(os.homedir(), '.openclaw', 'openclaw.json');
65
- const sessionsPath = path.join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions', 'sessions.json');
76
+ const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
77
+ const configPath = flags.config || path.join(openclawHome, 'openclaw.json');
78
+
79
+ // Auto-discover sessions path: find first agent with a sessions.json
80
+ let sessionsPath = path.join(openclawHome, 'agents', 'main', 'sessions', 'sessions.json');
81
+ if (!fs.existsSync(sessionsPath)) {
82
+ const agentsDir = path.join(openclawHome, 'agents');
83
+ try {
84
+ for (const agent of fs.readdirSync(agentsDir)) {
85
+ const candidate = path.join(agentsDir, agent, 'sessions', 'sessions.json');
86
+ if (fs.existsSync(candidate)) { sessionsPath = candidate; break; }
87
+ }
88
+ } catch { /* agents dir missing */ }
89
+ }
90
+
66
91
  const logsDir = '/tmp/openclaw';
67
92
 
68
93
  let analysis;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawculator",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "AI cost forensics for OpenClaw and multi-model setups. Your friendly penny pincher. 100% offline. Zero AI. Pure deterministic logic.",
5
5
  "main": "src/analyzer.js",
6
6
  "bin": {
@@ -0,0 +1,401 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { resolveModel, MODEL_PRICING } = require('./analyzer');
7
+
8
+ // โ”€โ”€ ANSI codes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
9
+ const R = '\x1b[0m';
10
+ const B = '\x1b[1m';
11
+ const D = '\x1b[90m';
12
+ const RED = '\x1b[31m';
13
+ const GRN = '\x1b[32m';
14
+ const YEL = '\x1b[33m';
15
+ const CYN = '\x1b[36m';
16
+ const WHT = '\x1b[37m';
17
+ const BG_DARK = '\x1b[48;5;233m';
18
+ const CLEAR = '\x1b[2J\x1b[H';
19
+ const HIDE_CURSOR = '\x1b[?25l';
20
+ const SHOW_CURSOR = '\x1b[?25h';
21
+
22
+ // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23
+ function modelLabel(modelStr) {
24
+ const key = resolveModel(modelStr);
25
+ return key ? (MODEL_PRICING[key]?.label || key) : (modelStr || 'unknown');
26
+ }
27
+
28
+ function fmtCost(n) {
29
+ if (n >= 1) return `$${n.toFixed(2)}`;
30
+ if (n >= 0.01) return `$${n.toFixed(4)}`;
31
+ return `$${n.toFixed(6)}`;
32
+ }
33
+
34
+ function fmtTokens(n) {
35
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
36
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
37
+ return String(n);
38
+ }
39
+
40
+ function timestamp() {
41
+ return new Date().toLocaleTimeString();
42
+ }
43
+
44
+ function relTime(ms) {
45
+ const s = Math.floor(ms / 1000);
46
+ if (s < 60) return `${s}s ago`;
47
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
48
+ return `${Math.floor(s / 3600)}h ago`;
49
+ }
50
+
51
+ // โ”€โ”€ Parse a single .jsonl line for usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
52
+ function parseUsageLine(line) {
53
+ try {
54
+ const entry = JSON.parse(line);
55
+ if (entry.type !== 'message') return null;
56
+ const u = entry.usage || entry.message?.usage;
57
+ if (!u) return null;
58
+ const model = entry.model || entry.message?.model;
59
+ const ts = entry.timestamp || entry.message?.timestamp;
60
+ const cost = u.cost
61
+ ? (typeof u.cost === 'object' ? u.cost.total || 0 : u.cost)
62
+ : 0;
63
+ return {
64
+ model,
65
+ input: u.input || 0,
66
+ output: u.output || 0,
67
+ cacheRead: u.cacheRead || 0,
68
+ cacheWrite: u.cacheWrite || 0,
69
+ totalTokens: u.totalTokens || 0,
70
+ cost,
71
+ timestamp: ts ? new Date(ts).getTime() : Date.now(),
72
+ };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // โ”€โ”€ Live Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
79
+ function startLiveDashboard(opts = {}) {
80
+ const openclawHome = opts.openclawHome || process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
81
+ const refreshMs = opts.refreshMs || 2000;
82
+
83
+ // State
84
+ const sessions = new Map(); // sessionId -> { name, model, cost, tokens, messages, lastSeen, cacheRead, cacheWrite }
85
+ const feed = []; // last N events for the activity feed
86
+ const MAX_FEED = 12;
87
+ let todayCost = 0;
88
+ let todayMessages = 0;
89
+ let todayTokens = 0;
90
+ let todayCacheRead = 0;
91
+ let todayCacheWrite = 0;
92
+ let peakCostPerMsg = 0;
93
+ let startTime = Date.now();
94
+ let lastEventTime = null;
95
+
96
+ // File watchers & byte offsets
97
+ const watchers = new Map(); // filePath -> fs.FSWatcher
98
+ const offsets = new Map(); // filePath -> byte offset (for tailing)
99
+ const fileToSession = new Map(); // filePath -> { id, name }
100
+
101
+ // โ”€โ”€ Discover sessions.json for friendly names โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
102
+ function getSessionNames() {
103
+ const names = new Map(); // sessionId -> friendly key name
104
+ const agentsDir = path.join(openclawHome, 'agents');
105
+ try {
106
+ for (const agent of fs.readdirSync(agentsDir)) {
107
+ const sjPath = path.join(agentsDir, agent, 'sessions', 'sessions.json');
108
+ if (!fs.existsSync(sjPath)) continue;
109
+ const sj = JSON.parse(fs.readFileSync(sjPath, 'utf8'));
110
+ for (const [key, val] of Object.entries(sj)) {
111
+ if (val.sessionId) {
112
+ // Use shortest meaningful name
113
+ const short = key
114
+ .replace('agent:main:', '')
115
+ .replace(/:[a-f0-9-]{36}/g, '')
116
+ .replace(/:run$/, '');
117
+ if (!names.has(val.sessionId) || short.length < names.get(val.sessionId).length) {
118
+ names.set(val.sessionId, short);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ } catch { /* ok */ }
124
+ return names;
125
+ }
126
+
127
+ // โ”€โ”€ Discover & watch .jsonl files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
128
+ function discoverFiles() {
129
+ const sessionNames = getSessionNames();
130
+ const agentsDir = path.join(openclawHome, 'agents');
131
+ try {
132
+ for (const agent of fs.readdirSync(agentsDir)) {
133
+ const sessDir = path.join(agentsDir, agent, 'sessions');
134
+ if (!fs.existsSync(sessDir)) continue;
135
+ for (const file of fs.readdirSync(sessDir)) {
136
+ if (!file.endsWith('.jsonl')) continue;
137
+ const filePath = path.join(sessDir, file);
138
+ if (watchers.has(filePath)) continue; // already watching
139
+
140
+ const sessionId = file.replace('.jsonl', '');
141
+ const friendlyName = sessionNames.get(sessionId) || sessionId.slice(0, 8);
142
+ fileToSession.set(filePath, { id: sessionId, name: friendlyName });
143
+
144
+ // Initial parse โ€” only count today's messages
145
+ initialParse(filePath, sessionId, friendlyName);
146
+
147
+ // Watch for changes
148
+ try {
149
+ const watcher = fs.watch(filePath, () => {
150
+ tailFile(filePath);
151
+ });
152
+ watchers.set(filePath, watcher);
153
+ } catch { /* can't watch */ }
154
+ }
155
+ }
156
+ } catch { /* agents dir missing */ }
157
+ }
158
+
159
+ // โ”€โ”€ Initial parse: read existing today's data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
160
+ function initialParse(filePath, sessionId, friendlyName) {
161
+ try {
162
+ const content = fs.readFileSync(filePath, 'utf8');
163
+ const stat = fs.statSync(filePath);
164
+ offsets.set(filePath, stat.size); // start tailing from end
165
+
166
+ const todayStart = new Date();
167
+ todayStart.setHours(0, 0, 0, 0);
168
+ const todayMs = todayStart.getTime();
169
+
170
+ let sessionCost = 0, sessionTokens = 0, sessionMessages = 0;
171
+ let sessionCacheRead = 0, sessionCacheWrite = 0;
172
+ let sessionModel = null, sessionLastSeen = null;
173
+
174
+ for (const line of content.split('\n')) {
175
+ if (!line.trim()) continue;
176
+ const usage = parseUsageLine(line);
177
+ if (!usage) continue;
178
+
179
+ if (!sessionModel) sessionModel = usage.model;
180
+ sessionLastSeen = usage.timestamp;
181
+
182
+ // Only count today for the live totals
183
+ if (usage.timestamp >= todayMs) {
184
+ sessionCost += usage.cost;
185
+ sessionTokens += usage.totalTokens;
186
+ sessionMessages++;
187
+ sessionCacheRead += usage.cacheRead;
188
+ sessionCacheWrite += usage.cacheWrite;
189
+ todayCost += usage.cost;
190
+ todayMessages++;
191
+ todayTokens += usage.totalTokens;
192
+ todayCacheRead += usage.cacheRead;
193
+ todayCacheWrite += usage.cacheWrite;
194
+ if (usage.cost > peakCostPerMsg) peakCostPerMsg = usage.cost;
195
+ }
196
+ }
197
+
198
+ if (sessionMessages > 0 || sessionModel) {
199
+ sessions.set(sessionId, {
200
+ name: friendlyName,
201
+ model: sessionModel,
202
+ cost: sessionCost,
203
+ tokens: sessionTokens,
204
+ messages: sessionMessages,
205
+ lastSeen: sessionLastSeen,
206
+ cacheRead: sessionCacheRead,
207
+ cacheWrite: sessionCacheWrite,
208
+ });
209
+ }
210
+ } catch { /* can't read */ }
211
+ }
212
+
213
+ // โ”€โ”€ Tail new data from file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
214
+ function tailFile(filePath) {
215
+ try {
216
+ const stat = fs.statSync(filePath);
217
+ const prevOffset = offsets.get(filePath) || 0;
218
+ if (stat.size <= prevOffset) return;
219
+
220
+ const fd = fs.openSync(filePath, 'r');
221
+ const buf = Buffer.alloc(stat.size - prevOffset);
222
+ fs.readSync(fd, buf, 0, buf.length, prevOffset);
223
+ fs.closeSync(fd);
224
+ offsets.set(filePath, stat.size);
225
+
226
+ const newContent = buf.toString('utf8');
227
+ const session = fileToSession.get(filePath);
228
+ if (!session) return;
229
+
230
+ for (const line of newContent.split('\n')) {
231
+ if (!line.trim()) continue;
232
+ const usage = parseUsageLine(line);
233
+ if (!usage) continue;
234
+
235
+ // Update session totals
236
+ const existing = sessions.get(session.id) || {
237
+ name: session.name, model: null, cost: 0, tokens: 0,
238
+ messages: 0, lastSeen: null, cacheRead: 0, cacheWrite: 0,
239
+ };
240
+ existing.model = usage.model || existing.model;
241
+ existing.cost += usage.cost;
242
+ existing.tokens += usage.totalTokens;
243
+ existing.messages++;
244
+ existing.lastSeen = usage.timestamp;
245
+ existing.cacheRead += usage.cacheRead;
246
+ existing.cacheWrite += usage.cacheWrite;
247
+ sessions.set(session.id, existing);
248
+
249
+ // Update today totals
250
+ todayCost += usage.cost;
251
+ todayMessages++;
252
+ todayTokens += usage.totalTokens;
253
+ todayCacheRead += usage.cacheRead;
254
+ todayCacheWrite += usage.cacheWrite;
255
+ if (usage.cost > peakCostPerMsg) peakCostPerMsg = usage.cost;
256
+ lastEventTime = Date.now();
257
+
258
+ // Add to activity feed
259
+ feed.unshift({
260
+ time: new Date(usage.timestamp).toLocaleTimeString(),
261
+ session: session.name,
262
+ model: modelLabel(usage.model),
263
+ cost: usage.cost,
264
+ tokens: usage.totalTokens,
265
+ cacheWrite: usage.cacheWrite,
266
+ });
267
+ if (feed.length > MAX_FEED) feed.length = MAX_FEED;
268
+ }
269
+
270
+ render();
271
+ } catch { /* file read error */ }
272
+ }
273
+
274
+ // โ”€โ”€ Render the dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
275
+ function render() {
276
+ const cols = process.stdout.columns || 80;
277
+ const rows = process.stdout.rows || 24;
278
+ const line = D + 'โ”€'.repeat(Math.min(cols - 2, 80)) + R;
279
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
280
+ const uptimeStr = uptime < 60 ? `${uptime}s` : `${Math.floor(uptime / 60)}m ${uptime % 60}s`;
281
+
282
+ let out = CLEAR;
283
+
284
+ // Header
285
+ out += `${CYN}${B} CLAWCULATOR LIVE${R} ${D}ยท${R} ${D}watching ${watchers.size} transcript${watchers.size !== 1 ? 's' : ''}${R} ${D}ยท${R} ${D}uptime ${uptimeStr}${R} ${D}ยท${R} ${D}${timestamp()}${R}\n`;
286
+ out += `${line}\n`;
287
+
288
+ // Big numbers row
289
+ const costColor = todayCost > 10 ? RED : todayCost > 1 ? YEL : GRN;
290
+ out += `\n`;
291
+ out += ` ${D}TODAY'S SPEND${R} ${D}MESSAGES${R} ${D}AVG $/MSG${R} ${D}PEAK $/MSG${R}\n`;
292
+ out += ` ${B}${costColor}${fmtCost(todayCost)}${R}`;
293
+ out += `${' '.repeat(Math.max(1, 18 - fmtCost(todayCost).length))}`;
294
+ out += `${B}${WHT}${todayMessages}${R}`;
295
+ out += `${' '.repeat(Math.max(1, 16 - String(todayMessages).length))}`;
296
+ const avgCost = todayMessages > 0 ? todayCost / todayMessages : 0;
297
+ out += `${B}${WHT}${fmtCost(avgCost)}${R}`;
298
+ out += `${' '.repeat(Math.max(1, 16 - fmtCost(avgCost).length))}`;
299
+ out += `${B}${RED}${fmtCost(peakCostPerMsg)}${R}\n`;
300
+ out += `\n`;
301
+
302
+ // Token breakdown bar
303
+ const totalTok = todayTokens + todayCacheRead + todayCacheWrite;
304
+ if (totalTok > 0) {
305
+ out += ` ${D}TOKENS${R} ${WHT}${fmtTokens(todayTokens)} i/o${R} ${D}ยท${R} ${GRN}${fmtTokens(todayCacheRead)} cache read${R} ${D}ยท${R} ${YEL}${fmtTokens(todayCacheWrite)} cache write${R}\n`;
306
+ out += `\n`;
307
+ }
308
+
309
+ out += `${line}\n`;
310
+
311
+ // Active sessions
312
+ out += ` ${CYN}${B}ACTIVE SESSIONS${R}\n`;
313
+ const sortedSessions = [...sessions.entries()]
314
+ .filter(([, s]) => s.messages > 0)
315
+ .sort((a, b) => b[1].cost - a[1].cost);
316
+
317
+ if (sortedSessions.length === 0) {
318
+ out += ` ${D}No API calls yet today. Waiting...${R}\n`;
319
+ } else {
320
+ out += ` ${D}${'Name'.padEnd(20)} ${'Model'.padEnd(22)} ${'Msgs'.padEnd(6)} ${'Cost'.padEnd(12)} Last Active${R}\n`;
321
+ for (const [, s] of sortedSessions.slice(0, 8)) {
322
+ const name = s.name.length > 18 ? s.name.slice(0, 16) + 'โ€ฆ' : s.name;
323
+ const model = modelLabel(s.model);
324
+ const modelDisp = model.length > 20 ? model.slice(0, 18) + 'โ€ฆ' : model;
325
+ const age = s.lastSeen ? relTime(Date.now() - s.lastSeen) : 'โ€”';
326
+ const costStr = fmtCost(s.cost);
327
+ const costClr = s.cost > 5 ? RED : s.cost > 0.5 ? YEL : GRN;
328
+ out += ` ${WHT}${name.padEnd(20)}${R} ${D}${modelDisp.padEnd(22)}${R} ${WHT}${String(s.messages).padEnd(6)}${R} ${costClr}${costStr.padEnd(12)}${R} ${D}${age}${R}\n`;
329
+ }
330
+ }
331
+ out += `\n${line}\n`;
332
+
333
+ // Live activity feed
334
+ out += ` ${CYN}${B}LIVE FEED${R}${lastEventTime ? ` ${D}last event: ${relTime(Date.now() - lastEventTime)}${R}` : ''}\n`;
335
+ if (feed.length === 0) {
336
+ out += ` ${D}Waiting for API calls...${R}\n`;
337
+ } else {
338
+ for (const ev of feed.slice(0, 8)) {
339
+ const costClr = ev.cost > 0.5 ? RED : ev.cost > 0.05 ? YEL : GRN;
340
+ const cacheTag = ev.cacheWrite > 10000 ? ` ${D}(${fmtTokens(ev.cacheWrite)} cache write)${R}` : '';
341
+ out += ` ${D}${ev.time}${R} ${WHT}${ev.session.padEnd(14)}${R} ${D}${ev.model.slice(0, 18).padEnd(18)}${R} ${costClr}${fmtCost(ev.cost).padEnd(10)}${R} ${D}${fmtTokens(ev.tokens)} tok${R}${cacheTag}\n`;
342
+ }
343
+ }
344
+ out += `\n${line}\n`;
345
+
346
+ // Footer
347
+ out += ` ${D}Press ${WHT}q${D} to quit ยท ${WHT}r${D} to refresh ยท Ctrl+C to exit${R}\n`;
348
+
349
+ process.stdout.write(out);
350
+ }
351
+
352
+ // โ”€โ”€ Keyboard input โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
353
+ if (process.stdin.isTTY) {
354
+ process.stdin.setRawMode(true);
355
+ process.stdin.resume();
356
+ process.stdin.setEncoding('utf8');
357
+ process.stdin.on('data', (key) => {
358
+ if (key === 'q' || key === '\u0003') { // q or Ctrl+C
359
+ cleanup();
360
+ process.exit(0);
361
+ }
362
+ if (key === 'r') {
363
+ // Force re-discover and re-render
364
+ discoverFiles();
365
+ render();
366
+ }
367
+ });
368
+ }
369
+
370
+ // โ”€โ”€ Cleanup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
371
+ function cleanup() {
372
+ process.stdout.write(SHOW_CURSOR);
373
+ for (const [, watcher] of watchers) {
374
+ try { watcher.close(); } catch {}
375
+ }
376
+ watchers.clear();
377
+ console.log(`\n${CYN}Clawculator Live${R} stopped. Today's total: ${B}${fmtCost(todayCost)}${R} across ${todayMessages} messages.\n`);
378
+ }
379
+
380
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
381
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
382
+
383
+ // โ”€โ”€ Start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
384
+ process.stdout.write(HIDE_CURSOR);
385
+ discoverFiles();
386
+ render();
387
+
388
+ // Periodic refresh + file discovery (catch new sessions)
389
+ const refreshInterval = setInterval(() => {
390
+ render();
391
+ }, refreshMs);
392
+
393
+ // Re-discover new files every 30s
394
+ const discoverInterval = setInterval(() => {
395
+ discoverFiles();
396
+ }, 30000);
397
+
398
+ return { cleanup, render };
399
+ }
400
+
401
+ module.exports = { startLiveDashboard };
@@ -0,0 +1,401 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { resolveModel, MODEL_PRICING } = require('./analyzer');
7
+
8
+ // โ”€โ”€ ANSI codes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
9
+ const R = '\x1b[0m';
10
+ const B = '\x1b[1m';
11
+ const D = '\x1b[90m';
12
+ const RED = '\x1b[31m';
13
+ const GRN = '\x1b[32m';
14
+ const YEL = '\x1b[33m';
15
+ const CYN = '\x1b[36m';
16
+ const WHT = '\x1b[37m';
17
+ const BG_DARK = '\x1b[48;5;233m';
18
+ const CLEAR = '\x1b[2J\x1b[H';
19
+ const HIDE_CURSOR = '\x1b[?25l';
20
+ const SHOW_CURSOR = '\x1b[?25h';
21
+
22
+ // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23
+ function modelLabel(modelStr) {
24
+ const key = resolveModel(modelStr);
25
+ return key ? (MODEL_PRICING[key]?.label || key) : (modelStr || 'unknown');
26
+ }
27
+
28
+ function fmtCost(n) {
29
+ if (n >= 1) return `$${n.toFixed(2)}`;
30
+ if (n >= 0.01) return `$${n.toFixed(4)}`;
31
+ return `$${n.toFixed(6)}`;
32
+ }
33
+
34
+ function fmtTokens(n) {
35
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
36
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
37
+ return String(n);
38
+ }
39
+
40
+ function timestamp() {
41
+ return new Date().toLocaleTimeString();
42
+ }
43
+
44
+ function relTime(ms) {
45
+ const s = Math.floor(ms / 1000);
46
+ if (s < 60) return `${s}s ago`;
47
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
48
+ return `${Math.floor(s / 3600)}h ago`;
49
+ }
50
+
51
+ // โ”€โ”€ Parse a single .jsonl line for usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
52
+ function parseUsageLine(line) {
53
+ try {
54
+ const entry = JSON.parse(line);
55
+ if (entry.type !== 'message') return null;
56
+ const u = entry.usage || entry.message?.usage;
57
+ if (!u) return null;
58
+ const model = entry.model || entry.message?.model;
59
+ const ts = entry.timestamp || entry.message?.timestamp;
60
+ const cost = u.cost
61
+ ? (typeof u.cost === 'object' ? u.cost.total || 0 : u.cost)
62
+ : 0;
63
+ return {
64
+ model,
65
+ input: u.input || 0,
66
+ output: u.output || 0,
67
+ cacheRead: u.cacheRead || 0,
68
+ cacheWrite: u.cacheWrite || 0,
69
+ totalTokens: u.totalTokens || 0,
70
+ cost,
71
+ timestamp: ts ? new Date(ts).getTime() : Date.now(),
72
+ };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // โ”€โ”€ Live Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
79
+ function startLiveDashboard(opts = {}) {
80
+ const openclawHome = opts.openclawHome || process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
81
+ const refreshMs = opts.refreshMs || 2000;
82
+
83
+ // State
84
+ const sessions = new Map(); // sessionId -> { name, model, cost, tokens, messages, lastSeen, cacheRead, cacheWrite }
85
+ const feed = []; // last N events for the activity feed
86
+ const MAX_FEED = 12;
87
+ let todayCost = 0;
88
+ let todayMessages = 0;
89
+ let todayTokens = 0;
90
+ let todayCacheRead = 0;
91
+ let todayCacheWrite = 0;
92
+ let peakCostPerMsg = 0;
93
+ let startTime = Date.now();
94
+ let lastEventTime = null;
95
+
96
+ // File watchers & byte offsets
97
+ const watchers = new Map(); // filePath -> fs.FSWatcher
98
+ const offsets = new Map(); // filePath -> byte offset (for tailing)
99
+ const fileToSession = new Map(); // filePath -> { id, name }
100
+
101
+ // โ”€โ”€ Discover sessions.json for friendly names โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
102
+ function getSessionNames() {
103
+ const names = new Map(); // sessionId -> friendly key name
104
+ const agentsDir = path.join(openclawHome, 'agents');
105
+ try {
106
+ for (const agent of fs.readdirSync(agentsDir)) {
107
+ const sjPath = path.join(agentsDir, agent, 'sessions', 'sessions.json');
108
+ if (!fs.existsSync(sjPath)) continue;
109
+ const sj = JSON.parse(fs.readFileSync(sjPath, 'utf8'));
110
+ for (const [key, val] of Object.entries(sj)) {
111
+ if (val.sessionId) {
112
+ // Use shortest meaningful name
113
+ const short = key
114
+ .replace('agent:main:', '')
115
+ .replace(/:[a-f0-9-]{36}/g, '')
116
+ .replace(/:run$/, '');
117
+ if (!names.has(val.sessionId) || short.length < names.get(val.sessionId).length) {
118
+ names.set(val.sessionId, short);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ } catch { /* ok */ }
124
+ return names;
125
+ }
126
+
127
+ // โ”€โ”€ Discover & watch .jsonl files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
128
+ function discoverFiles() {
129
+ const sessionNames = getSessionNames();
130
+ const agentsDir = path.join(openclawHome, 'agents');
131
+ try {
132
+ for (const agent of fs.readdirSync(agentsDir)) {
133
+ const sessDir = path.join(agentsDir, agent, 'sessions');
134
+ if (!fs.existsSync(sessDir)) continue;
135
+ for (const file of fs.readdirSync(sessDir)) {
136
+ if (!file.endsWith('.jsonl')) continue;
137
+ const filePath = path.join(sessDir, file);
138
+ if (watchers.has(filePath)) continue; // already watching
139
+
140
+ const sessionId = file.replace('.jsonl', '');
141
+ const friendlyName = sessionNames.get(sessionId) || sessionId.slice(0, 8);
142
+ fileToSession.set(filePath, { id: sessionId, name: friendlyName });
143
+
144
+ // Initial parse โ€” only count today's messages
145
+ initialParse(filePath, sessionId, friendlyName);
146
+
147
+ // Watch for changes
148
+ try {
149
+ const watcher = fs.watch(filePath, () => {
150
+ tailFile(filePath);
151
+ });
152
+ watchers.set(filePath, watcher);
153
+ } catch { /* can't watch */ }
154
+ }
155
+ }
156
+ } catch { /* agents dir missing */ }
157
+ }
158
+
159
+ // โ”€โ”€ Initial parse: read existing today's data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
160
+ function initialParse(filePath, sessionId, friendlyName) {
161
+ try {
162
+ const content = fs.readFileSync(filePath, 'utf8');
163
+ const stat = fs.statSync(filePath);
164
+ offsets.set(filePath, stat.size); // start tailing from end
165
+
166
+ const todayStart = new Date();
167
+ todayStart.setHours(0, 0, 0, 0);
168
+ const todayMs = todayStart.getTime();
169
+
170
+ let sessionCost = 0, sessionTokens = 0, sessionMessages = 0;
171
+ let sessionCacheRead = 0, sessionCacheWrite = 0;
172
+ let sessionModel = null, sessionLastSeen = null;
173
+
174
+ for (const line of content.split('\n')) {
175
+ if (!line.trim()) continue;
176
+ const usage = parseUsageLine(line);
177
+ if (!usage) continue;
178
+
179
+ if (!sessionModel) sessionModel = usage.model;
180
+ sessionLastSeen = usage.timestamp;
181
+
182
+ // Only count today for the live totals
183
+ if (usage.timestamp >= todayMs) {
184
+ sessionCost += usage.cost;
185
+ sessionTokens += usage.totalTokens;
186
+ sessionMessages++;
187
+ sessionCacheRead += usage.cacheRead;
188
+ sessionCacheWrite += usage.cacheWrite;
189
+ todayCost += usage.cost;
190
+ todayMessages++;
191
+ todayTokens += usage.totalTokens;
192
+ todayCacheRead += usage.cacheRead;
193
+ todayCacheWrite += usage.cacheWrite;
194
+ if (usage.cost > peakCostPerMsg) peakCostPerMsg = usage.cost;
195
+ }
196
+ }
197
+
198
+ if (sessionMessages > 0 || sessionModel) {
199
+ sessions.set(sessionId, {
200
+ name: friendlyName,
201
+ model: sessionModel,
202
+ cost: sessionCost,
203
+ tokens: sessionTokens,
204
+ messages: sessionMessages,
205
+ lastSeen: sessionLastSeen,
206
+ cacheRead: sessionCacheRead,
207
+ cacheWrite: sessionCacheWrite,
208
+ });
209
+ }
210
+ } catch { /* can't read */ }
211
+ }
212
+
213
+ // โ”€โ”€ Tail new data from file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
214
+ function tailFile(filePath) {
215
+ try {
216
+ const stat = fs.statSync(filePath);
217
+ const prevOffset = offsets.get(filePath) || 0;
218
+ if (stat.size <= prevOffset) return;
219
+
220
+ const fd = fs.openSync(filePath, 'r');
221
+ const buf = Buffer.alloc(stat.size - prevOffset);
222
+ fs.readSync(fd, buf, 0, buf.length, prevOffset);
223
+ fs.closeSync(fd);
224
+ offsets.set(filePath, stat.size);
225
+
226
+ const newContent = buf.toString('utf8');
227
+ const session = fileToSession.get(filePath);
228
+ if (!session) return;
229
+
230
+ for (const line of newContent.split('\n')) {
231
+ if (!line.trim()) continue;
232
+ const usage = parseUsageLine(line);
233
+ if (!usage) continue;
234
+
235
+ // Update session totals
236
+ const existing = sessions.get(session.id) || {
237
+ name: session.name, model: null, cost: 0, tokens: 0,
238
+ messages: 0, lastSeen: null, cacheRead: 0, cacheWrite: 0,
239
+ };
240
+ existing.model = usage.model || existing.model;
241
+ existing.cost += usage.cost;
242
+ existing.tokens += usage.totalTokens;
243
+ existing.messages++;
244
+ existing.lastSeen = usage.timestamp;
245
+ existing.cacheRead += usage.cacheRead;
246
+ existing.cacheWrite += usage.cacheWrite;
247
+ sessions.set(session.id, existing);
248
+
249
+ // Update today totals
250
+ todayCost += usage.cost;
251
+ todayMessages++;
252
+ todayTokens += usage.totalTokens;
253
+ todayCacheRead += usage.cacheRead;
254
+ todayCacheWrite += usage.cacheWrite;
255
+ if (usage.cost > peakCostPerMsg) peakCostPerMsg = usage.cost;
256
+ lastEventTime = Date.now();
257
+
258
+ // Add to activity feed
259
+ feed.unshift({
260
+ time: new Date(usage.timestamp).toLocaleTimeString(),
261
+ session: session.name,
262
+ model: modelLabel(usage.model),
263
+ cost: usage.cost,
264
+ tokens: usage.totalTokens,
265
+ cacheWrite: usage.cacheWrite,
266
+ });
267
+ if (feed.length > MAX_FEED) feed.length = MAX_FEED;
268
+ }
269
+
270
+ render();
271
+ } catch { /* file read error */ }
272
+ }
273
+
274
+ // โ”€โ”€ Render the dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
275
+ function render() {
276
+ const cols = process.stdout.columns || 80;
277
+ const rows = process.stdout.rows || 24;
278
+ const line = D + 'โ”€'.repeat(Math.min(cols - 2, 80)) + R;
279
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
280
+ const uptimeStr = uptime < 60 ? `${uptime}s` : `${Math.floor(uptime / 60)}m ${uptime % 60}s`;
281
+
282
+ let out = CLEAR;
283
+
284
+ // Header
285
+ out += `${CYN}${B} CLAWCULATOR LIVE${R} ${D}ยท${R} ${D}watching ${watchers.size} transcript${watchers.size !== 1 ? 's' : ''}${R} ${D}ยท${R} ${D}uptime ${uptimeStr}${R} ${D}ยท${R} ${D}${timestamp()}${R}\n`;
286
+ out += `${line}\n`;
287
+
288
+ // Big numbers row
289
+ const costColor = todayCost > 10 ? RED : todayCost > 1 ? YEL : GRN;
290
+ out += `\n`;
291
+ out += ` ${D}TODAY'S SPEND${R} ${D}MESSAGES${R} ${D}AVG $/MSG${R} ${D}PEAK $/MSG${R}\n`;
292
+ out += ` ${B}${costColor}${fmtCost(todayCost)}${R}`;
293
+ out += `${' '.repeat(Math.max(1, 18 - fmtCost(todayCost).length))}`;
294
+ out += `${B}${WHT}${todayMessages}${R}`;
295
+ out += `${' '.repeat(Math.max(1, 16 - String(todayMessages).length))}`;
296
+ const avgCost = todayMessages > 0 ? todayCost / todayMessages : 0;
297
+ out += `${B}${WHT}${fmtCost(avgCost)}${R}`;
298
+ out += `${' '.repeat(Math.max(1, 16 - fmtCost(avgCost).length))}`;
299
+ out += `${B}${RED}${fmtCost(peakCostPerMsg)}${R}\n`;
300
+ out += `\n`;
301
+
302
+ // Token breakdown bar
303
+ const totalTok = todayTokens + todayCacheRead + todayCacheWrite;
304
+ if (totalTok > 0) {
305
+ out += ` ${D}TOKENS${R} ${WHT}${fmtTokens(todayTokens)} i/o${R} ${D}ยท${R} ${GRN}${fmtTokens(todayCacheRead)} cache read${R} ${D}ยท${R} ${YEL}${fmtTokens(todayCacheWrite)} cache write${R}\n`;
306
+ out += `\n`;
307
+ }
308
+
309
+ out += `${line}\n`;
310
+
311
+ // Active sessions
312
+ out += ` ${CYN}${B}ACTIVE SESSIONS${R}\n`;
313
+ const sortedSessions = [...sessions.entries()]
314
+ .filter(([, s]) => s.messages > 0)
315
+ .sort((a, b) => b[1].cost - a[1].cost);
316
+
317
+ if (sortedSessions.length === 0) {
318
+ out += ` ${D}No API calls yet today. Waiting...${R}\n`;
319
+ } else {
320
+ out += ` ${D}${'Name'.padEnd(20)} ${'Model'.padEnd(22)} ${'Msgs'.padEnd(6)} ${'Cost'.padEnd(12)} Last Active${R}\n`;
321
+ for (const [, s] of sortedSessions.slice(0, 8)) {
322
+ const name = s.name.length > 18 ? s.name.slice(0, 16) + 'โ€ฆ' : s.name;
323
+ const model = modelLabel(s.model);
324
+ const modelDisp = model.length > 20 ? model.slice(0, 18) + 'โ€ฆ' : model;
325
+ const age = s.lastSeen ? relTime(Date.now() - s.lastSeen) : 'โ€”';
326
+ const costStr = fmtCost(s.cost);
327
+ const costClr = s.cost > 5 ? RED : s.cost > 0.5 ? YEL : GRN;
328
+ out += ` ${WHT}${name.padEnd(20)}${R} ${D}${modelDisp.padEnd(22)}${R} ${WHT}${String(s.messages).padEnd(6)}${R} ${costClr}${costStr.padEnd(12)}${R} ${D}${age}${R}\n`;
329
+ }
330
+ }
331
+ out += `\n${line}\n`;
332
+
333
+ // Live activity feed
334
+ out += ` ${CYN}${B}LIVE FEED${R}${lastEventTime ? ` ${D}last event: ${relTime(Date.now() - lastEventTime)}${R}` : ''}\n`;
335
+ if (feed.length === 0) {
336
+ out += ` ${D}Waiting for API calls...${R}\n`;
337
+ } else {
338
+ for (const ev of feed.slice(0, 8)) {
339
+ const costClr = ev.cost > 0.5 ? RED : ev.cost > 0.05 ? YEL : GRN;
340
+ const cacheTag = ev.cacheWrite > 10000 ? ` ${D}(${fmtTokens(ev.cacheWrite)} cache write)${R}` : '';
341
+ out += ` ${D}${ev.time}${R} ${WHT}${ev.session.padEnd(14)}${R} ${D}${ev.model.slice(0, 18).padEnd(18)}${R} ${costClr}${fmtCost(ev.cost).padEnd(10)}${R} ${D}${fmtTokens(ev.tokens)} tok${R}${cacheTag}\n`;
342
+ }
343
+ }
344
+ out += `\n${line}\n`;
345
+
346
+ // Footer
347
+ out += ` ${D}Press ${WHT}q${D} to quit ยท ${WHT}r${D} to refresh ยท Ctrl+C to exit${R}\n`;
348
+
349
+ process.stdout.write(out);
350
+ }
351
+
352
+ // โ”€โ”€ Keyboard input โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
353
+ if (process.stdin.isTTY) {
354
+ process.stdin.setRawMode(true);
355
+ process.stdin.resume();
356
+ process.stdin.setEncoding('utf8');
357
+ process.stdin.on('data', (key) => {
358
+ if (key === 'q' || key === '\u0003') { // q or Ctrl+C
359
+ cleanup();
360
+ process.exit(0);
361
+ }
362
+ if (key === 'r') {
363
+ // Force re-discover and re-render
364
+ discoverFiles();
365
+ render();
366
+ }
367
+ });
368
+ }
369
+
370
+ // โ”€โ”€ Cleanup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
371
+ function cleanup() {
372
+ process.stdout.write(SHOW_CURSOR);
373
+ for (const [, watcher] of watchers) {
374
+ try { watcher.close(); } catch {}
375
+ }
376
+ watchers.clear();
377
+ console.log(`\n${CYN}Clawculator Live${R} stopped. Today's total: ${B}${fmtCost(todayCost)}${R} across ${todayMessages} messages.\n`);
378
+ }
379
+
380
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
381
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
382
+
383
+ // โ”€โ”€ Start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
384
+ process.stdout.write(HIDE_CURSOR);
385
+ discoverFiles();
386
+ render();
387
+
388
+ // Periodic refresh + file discovery (catch new sessions)
389
+ const refreshInterval = setInterval(() => {
390
+ render();
391
+ }, refreshMs);
392
+
393
+ // Re-discover new files every 30s
394
+ const discoverInterval = setInterval(() => {
395
+ discoverFiles();
396
+ }, 30000);
397
+
398
+ return { cleanup, render };
399
+ }
400
+
401
+ module.exports = { startLiveDashboard };