clawculator 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/clawculator.js +25 -2
- package/package.json +1 -1
- package/skills/clawculator/liveDashboard.js +401 -0
- package/src/liveDashboard.js +401 -0
package/bin/clawculator.js
CHANGED
|
@@ -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,30 @@ async function main() {
|
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
console.log(BANNER);
|
|
65
|
+
if (flags.live) {
|
|
66
|
+
console.log(BANNER);
|
|
67
|
+
const { startLiveDashboard } = require('../src/liveDashboard');
|
|
68
|
+
startLiveDashboard({ openclawHome });
|
|
69
|
+
return; // dashboard runs until user quits
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
console.log('\x1b[90mScanning your setup...\x1b[0m\n');
|
|
63
73
|
|
|
64
|
-
const
|
|
65
|
-
const
|
|
74
|
+
const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
|
|
75
|
+
const configPath = flags.config || path.join(openclawHome, 'openclaw.json');
|
|
76
|
+
|
|
77
|
+
// Auto-discover sessions path: find first agent with a sessions.json
|
|
78
|
+
let sessionsPath = path.join(openclawHome, 'agents', 'main', 'sessions', 'sessions.json');
|
|
79
|
+
if (!fs.existsSync(sessionsPath)) {
|
|
80
|
+
const agentsDir = path.join(openclawHome, 'agents');
|
|
81
|
+
try {
|
|
82
|
+
for (const agent of fs.readdirSync(agentsDir)) {
|
|
83
|
+
const candidate = path.join(agentsDir, agent, 'sessions', 'sessions.json');
|
|
84
|
+
if (fs.existsSync(candidate)) { sessionsPath = candidate; break; }
|
|
85
|
+
}
|
|
86
|
+
} catch { /* agents dir missing */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
const logsDir = '/tmp/openclaw';
|
|
67
90
|
|
|
68
91
|
let analysis;
|
package/package.json
CHANGED
|
@@ -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 };
|