clawhouse 0.1.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/LICENSE +21 -0
- package/README.md +171 -0
- package/bin/clawhouse.js +29 -0
- package/lib/usage.js +568 -0
- package/package.json +30 -0
- package/public/furniture.json +128 -0
- package/public/furniture.png +0 -0
- package/public/icon-180.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/index.html +3335 -0
- package/public/manifest.webmanifest +16 -0
- package/public/monitors.json +20 -0
- package/public/monitors.png +0 -0
- package/public/props.json +20 -0
- package/public/props.png +0 -0
- package/public/sprites.json +27 -0
- package/public/sprites.png +0 -0
- package/scripts/build-furniture.js +762 -0
- package/scripts/build-monitors.js +496 -0
- package/scripts/build-props.js +675 -0
- package/scripts/build-sprites.js +1065 -0
- package/scripts/export-profile-pics.js +180 -0
- package/scripts/generate-icons.js +124 -0
- package/server.js +944 -0
package/lib/usage.js
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// Per-agent USD cost tracker. Walks each agent's session jsonl files,
|
|
2
|
+
// extracts per-call usage events, applies prices.json, and rolls up by
|
|
3
|
+
// window (current session / today / this month / this year), provider,
|
|
4
|
+
// and model.
|
|
5
|
+
//
|
|
6
|
+
// Three file formats are scanned per agent:
|
|
7
|
+
//
|
|
8
|
+
// A. OpenClaw-managed runs under ~/.openclaw/agents/<id>/sessions/
|
|
9
|
+
// — see formats 1 and 2 below.
|
|
10
|
+
//
|
|
11
|
+
// B. Claude Code direct sessions under
|
|
12
|
+
// ~/.claude/projects/-home-<user>--openclaw-agents-<id>-workspace/<uuid>.jsonl
|
|
13
|
+
// — Anthropic-native field names. Each line `{type:"assistant", timestamp,
|
|
14
|
+
// message:{model, usage:{input_tokens, output_tokens, cache_read_input_tokens,
|
|
15
|
+
// cache_creation_input_tokens}}}`. These are Claude turns invoked via the
|
|
16
|
+
// claude-cli runtime (or fast-mode subagents) within an agent's workspace
|
|
17
|
+
// and DO NOT appear in OpenClaw's sessions/ directory. Skipping these
|
|
18
|
+
// under-counts Claude usage by an order of magnitude — the very issue
|
|
19
|
+
// that made early "fleet cost" numbers look mostly OpenAI.
|
|
20
|
+
//
|
|
21
|
+
// Two file formats coexist in `agents/<id>/sessions/`:
|
|
22
|
+
// 1. <uuid>.jsonl — Claude Code "message" format. Each line
|
|
23
|
+
// `{type:"message", timestamp, message:{provider,
|
|
24
|
+
// model, usage:{input,output,cacheRead,cacheWrite,...}}}`.
|
|
25
|
+
// This is the canonical billing record for both
|
|
26
|
+
// Claude AND Codex sessions (one event per
|
|
27
|
+
// API call, including tool-use round-trips).
|
|
28
|
+
// 2. <uuid>.trajectory.jsonl — OpenAI Codex runtime metadata. Each line
|
|
29
|
+
// `{type:"model.completed", ts, provider, modelId,
|
|
30
|
+
// data:{usage:{...}}}`. One event per overall
|
|
31
|
+
// completion (under-counts tool-use loops vs the
|
|
32
|
+
// message format).
|
|
33
|
+
//
|
|
34
|
+
// Per session UUID we prefer the message jsonl when present, falling back to
|
|
35
|
+
// trajectory only when no message jsonl exists. This avoids double-counting
|
|
36
|
+
// and keeps Claude-only agents (which never write trajectory files) included.
|
|
37
|
+
//
|
|
38
|
+
// Caching strategy:
|
|
39
|
+
// - parsed events kept in-memory keyed by (filePath, mtimeMs)
|
|
40
|
+
// - on refresh(), we stat every candidate file; if mtime matches the cached
|
|
41
|
+
// entry we reuse it, otherwise we reparse just that file
|
|
42
|
+
// - prices.json is mtime-cached the same way
|
|
43
|
+
//
|
|
44
|
+
// First scan is the only slow one. Subsequent polls re-stat O(files) and
|
|
45
|
+
// reparse only the active session's growing log.
|
|
46
|
+
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const path = require('path');
|
|
49
|
+
const os = require('os');
|
|
50
|
+
const readline = require('readline');
|
|
51
|
+
|
|
52
|
+
const STATE_DIR = path.join(os.homedir(), '.openclaw');
|
|
53
|
+
const UUID_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
54
|
+
// Skip files older than a year — they can't contribute to any displayed window.
|
|
55
|
+
const STALE_FILE_MS = 366 * 24 * 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
// filePath -> { mtimeMs, events: Array<{ts, sessionId, modelKey, input, output, cacheRead, cacheWrite}> }
|
|
58
|
+
const fileCache = new Map();
|
|
59
|
+
|
|
60
|
+
let priceCache = { mtimeMs: 0, prices: {}, subscriptions: {}, billingModes: {} };
|
|
61
|
+
function loadPrices(pricesPath) {
|
|
62
|
+
const billingModes = detectBillingModes();
|
|
63
|
+
try {
|
|
64
|
+
const stat = fs.statSync(pricesPath);
|
|
65
|
+
if (stat.mtimeMs === priceCache.mtimeMs &&
|
|
66
|
+
sameModes(billingModes, priceCache.billingModes)) {
|
|
67
|
+
return priceCache;
|
|
68
|
+
}
|
|
69
|
+
const json = JSON.parse(fs.readFileSync(pricesPath, 'utf8'));
|
|
70
|
+
// Only treat a provider as a subscription if (a) the user declared a
|
|
71
|
+
// monthlyUSD AND (b) the detected auth mode is actually oauth/subscription.
|
|
72
|
+
// Switching to an API key (env var or profile) auto-flips it back to
|
|
73
|
+
// per-token billing without any prices.json edit.
|
|
74
|
+
const declared = json.subscriptions || {};
|
|
75
|
+
const effective = {};
|
|
76
|
+
for (const [provider, cfg] of Object.entries(declared)) {
|
|
77
|
+
if (billingModes[provider] === 'subscription') effective[provider] = cfg;
|
|
78
|
+
}
|
|
79
|
+
priceCache = {
|
|
80
|
+
mtimeMs: stat.mtimeMs,
|
|
81
|
+
prices: json.prices || {},
|
|
82
|
+
subscriptions: effective,
|
|
83
|
+
declaredSubscriptions: declared,
|
|
84
|
+
billingModes,
|
|
85
|
+
};
|
|
86
|
+
return priceCache;
|
|
87
|
+
} catch {
|
|
88
|
+
return priceCache;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sameModes(a, b) {
|
|
93
|
+
const ak = Object.keys(a), bk = Object.keys(b);
|
|
94
|
+
if (ak.length !== bk.length) return false;
|
|
95
|
+
for (const k of ak) if (a[k] !== b[k]) return false;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Detects which billing mode each provider is on:
|
|
100
|
+
// 'subscription' — OAuth login (Claude Max, ChatGPT Plus/Pro, etc.) → flat fee
|
|
101
|
+
// 'api' — API key (env var or auth profile) → per-token
|
|
102
|
+
//
|
|
103
|
+
// Sources, in priority order:
|
|
104
|
+
// 1. Env vars ANTHROPIC_API_KEY / OPENAI_API_KEY → forces 'api'
|
|
105
|
+
// 2. ~/.claude/.credentials.json with claudeAiOauth.subscriptionType → 'subscription'
|
|
106
|
+
// 3. ~/.openclaw/openclaw.json auth.profiles[*].mode (oauth → subscription, api_key → api)
|
|
107
|
+
//
|
|
108
|
+
// If nothing matches, the provider is omitted from the map and the caller
|
|
109
|
+
// treats it as per-token (safe default — we'd rather over-report cost than
|
|
110
|
+
// hide it).
|
|
111
|
+
let billingCache = { key: '', modes: {} };
|
|
112
|
+
function detectBillingModes() {
|
|
113
|
+
const claudeCredPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
114
|
+
const openclawCfgPath = path.join(STATE_DIR, 'openclaw.json');
|
|
115
|
+
let claudeMtime = 0, openclawMtime = 0;
|
|
116
|
+
try { claudeMtime = fs.statSync(claudeCredPath).mtimeMs; } catch {}
|
|
117
|
+
try { openclawMtime = fs.statSync(openclawCfgPath).mtimeMs; } catch {}
|
|
118
|
+
const envKey = `${process.env.ANTHROPIC_API_KEY ? 'A' : ''}|${process.env.OPENAI_API_KEY ? 'O' : ''}`;
|
|
119
|
+
const cacheKey = `${claudeMtime}|${openclawMtime}|${envKey}`;
|
|
120
|
+
if (cacheKey === billingCache.key) return billingCache.modes;
|
|
121
|
+
|
|
122
|
+
const modes = {};
|
|
123
|
+
|
|
124
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
125
|
+
modes['claude-cli'] = 'api';
|
|
126
|
+
} else {
|
|
127
|
+
try {
|
|
128
|
+
const cred = JSON.parse(fs.readFileSync(claudeCredPath, 'utf8'));
|
|
129
|
+
if (cred?.claudeAiOauth?.subscriptionType) modes['claude-cli'] = 'subscription';
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (process.env.OPENAI_API_KEY) {
|
|
134
|
+
modes['openai-codex'] = 'api';
|
|
135
|
+
modes['openai'] = 'api';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const cfg = JSON.parse(fs.readFileSync(openclawCfgPath, 'utf8'));
|
|
140
|
+
for (const profile of Object.values(cfg.auth?.profiles || {})) {
|
|
141
|
+
const provider = profile?.provider;
|
|
142
|
+
if (!provider || modes[provider]) continue; // env/credential signal wins
|
|
143
|
+
if (profile.mode === 'oauth') modes[provider] = 'subscription';
|
|
144
|
+
else if (profile.mode === 'api_key') modes[provider] = 'api';
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
|
|
148
|
+
billingCache = { key: cacheKey, modes };
|
|
149
|
+
return modes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Window length in days (must match the rolling windows below).
|
|
153
|
+
const WINDOW_DAYS = { session: 1, day: 1, month: 30, year: 365 };
|
|
154
|
+
|
|
155
|
+
// Sum subscription flat fees for a given window, prorated as monthlyUSD × (windowDays / 30).
|
|
156
|
+
function subscriptionFeeForWindow(subscriptions, windowKey) {
|
|
157
|
+
const days = WINDOW_DAYS[windowKey] || 0;
|
|
158
|
+
if (!days) return 0;
|
|
159
|
+
let total = 0;
|
|
160
|
+
for (const cfg of Object.values(subscriptions || {})) {
|
|
161
|
+
const m = Number(cfg && cfg.monthlyUSD) || 0;
|
|
162
|
+
total += m * (days / 30);
|
|
163
|
+
}
|
|
164
|
+
return total;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Sum subscription flat fees per provider (for the per-provider chip).
|
|
168
|
+
function subscriptionFeesByProvider(subscriptions, windowKey) {
|
|
169
|
+
const days = WINDOW_DAYS[windowKey] || 0;
|
|
170
|
+
const out = {};
|
|
171
|
+
if (!days) return out;
|
|
172
|
+
for (const [provider, cfg] of Object.entries(subscriptions || {})) {
|
|
173
|
+
const m = Number(cfg && cfg.monthlyUSD) || 0;
|
|
174
|
+
if (m > 0) out[provider] = m * (days / 30);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function priceTurn(prices, modelKey, ev) {
|
|
180
|
+
const p = prices[modelKey];
|
|
181
|
+
if (!p) return { cost: 0, unpriced: true };
|
|
182
|
+
const cost =
|
|
183
|
+
((ev.input || 0) * (p.input || 0) +
|
|
184
|
+
(ev.output || 0) * (p.output || 0) +
|
|
185
|
+
(ev.cacheRead || 0) * (p.cacheRead || 0) +
|
|
186
|
+
(ev.cacheWrite || 0) * (p.cacheWrite || 0)) / 1_000_000;
|
|
187
|
+
return { cost, unpriced: false };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Claude Code "message" jsonl: {type:"message", timestamp, message:{provider, model, usage:{...}}}.
|
|
191
|
+
// Used by Claude sessions AND by Codex sessions (with provider="openai-codex").
|
|
192
|
+
// sessionId is taken from the filename (the leading UUID).
|
|
193
|
+
async function parseMessageFile(filePath, sessionId) {
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
const events = [];
|
|
196
|
+
let stream;
|
|
197
|
+
try { stream = fs.createReadStream(filePath, { encoding: 'utf8' }); }
|
|
198
|
+
catch { return resolve(events); }
|
|
199
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
200
|
+
rl.on('line', (line) => {
|
|
201
|
+
if (!line) return;
|
|
202
|
+
let e;
|
|
203
|
+
try { e = JSON.parse(line); } catch { return; }
|
|
204
|
+
if (e.type !== 'message') return;
|
|
205
|
+
const m = e.message;
|
|
206
|
+
if (!m || !m.usage) return;
|
|
207
|
+
const u = m.usage;
|
|
208
|
+
const provider = m.provider || '';
|
|
209
|
+
const modelId = m.model || '';
|
|
210
|
+
const ts = Date.parse(e.timestamp || m.timestamp || '');
|
|
211
|
+
if (!Number.isFinite(ts)) return;
|
|
212
|
+
events.push({
|
|
213
|
+
ts,
|
|
214
|
+
sessionId,
|
|
215
|
+
modelKey: `${provider}/${modelId}`,
|
|
216
|
+
input: u.input || 0,
|
|
217
|
+
output: u.output || 0,
|
|
218
|
+
cacheRead: u.cacheRead || 0,
|
|
219
|
+
cacheWrite: u.cacheWrite || 0,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
rl.on('close', () => resolve(events));
|
|
223
|
+
rl.on('error', () => resolve(events));
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Claude Code transcript: {type:"assistant", timestamp, message:{model, usage:{...native...}}}.
|
|
228
|
+
// Lives under ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl. Field names follow
|
|
229
|
+
// Anthropic's API (input_tokens / cache_read_input_tokens / cache_creation_input_tokens)
|
|
230
|
+
// rather than OpenClaw's normalized form, so we map them on parse. Synthetic
|
|
231
|
+
// model entries (e.g. "<synthetic>" used for harness-generated turns) are skipped
|
|
232
|
+
// since they aren't real billed turns.
|
|
233
|
+
async function parseClaudeTranscript(filePath, sessionId) {
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
const events = [];
|
|
236
|
+
let stream;
|
|
237
|
+
try { stream = fs.createReadStream(filePath, { encoding: 'utf8' }); }
|
|
238
|
+
catch { return resolve(events); }
|
|
239
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
240
|
+
rl.on('line', (line) => {
|
|
241
|
+
if (!line) return;
|
|
242
|
+
let e;
|
|
243
|
+
try { e = JSON.parse(line); } catch { return; }
|
|
244
|
+
if (e.type !== 'assistant') return;
|
|
245
|
+
const m = e.message;
|
|
246
|
+
if (!m || !m.usage) return;
|
|
247
|
+
const u = m.usage;
|
|
248
|
+
const modelId = m.model || '';
|
|
249
|
+
if (!modelId || modelId === '<synthetic>') return;
|
|
250
|
+
const ts = Date.parse(e.timestamp || '');
|
|
251
|
+
if (!Number.isFinite(ts)) return;
|
|
252
|
+
events.push({
|
|
253
|
+
ts,
|
|
254
|
+
sessionId,
|
|
255
|
+
modelKey: `claude-cli/${modelId}`,
|
|
256
|
+
input: u.input_tokens || 0,
|
|
257
|
+
output: u.output_tokens || 0,
|
|
258
|
+
cacheRead: u.cache_read_input_tokens || 0,
|
|
259
|
+
cacheWrite: u.cache_creation_input_tokens || 0,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
rl.on('close', () => resolve(events));
|
|
263
|
+
rl.on('error', () => resolve(events));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// OpenClaw runtime trajectory: {type:"model.completed", ts, provider, modelId, data:{usage:{...}}}.
|
|
268
|
+
// Fallback for sessions where the message jsonl is absent (older Codex runs).
|
|
269
|
+
async function parseTrajectoryFile(filePath, sessionId) {
|
|
270
|
+
return new Promise((resolve) => {
|
|
271
|
+
const events = [];
|
|
272
|
+
let stream;
|
|
273
|
+
try { stream = fs.createReadStream(filePath, { encoding: 'utf8' }); }
|
|
274
|
+
catch { return resolve(events); }
|
|
275
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
276
|
+
rl.on('line', (line) => {
|
|
277
|
+
if (!line) return;
|
|
278
|
+
let e;
|
|
279
|
+
try { e = JSON.parse(line); } catch { return; }
|
|
280
|
+
if (e.type !== 'model.completed') return;
|
|
281
|
+
const u = e.data && e.data.usage;
|
|
282
|
+
if (!u) return;
|
|
283
|
+
const provider = e.provider || '';
|
|
284
|
+
const modelId = e.modelId || '';
|
|
285
|
+
const ts = Date.parse(e.ts);
|
|
286
|
+
if (!Number.isFinite(ts)) return;
|
|
287
|
+
events.push({
|
|
288
|
+
ts,
|
|
289
|
+
sessionId: e.sessionId || sessionId,
|
|
290
|
+
modelKey: `${provider}/${modelId}`,
|
|
291
|
+
input: u.input || 0,
|
|
292
|
+
output: u.output || 0,
|
|
293
|
+
cacheRead: u.cacheRead || 0,
|
|
294
|
+
cacheWrite: u.cacheWrite || 0,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
rl.on('close', () => resolve(events));
|
|
298
|
+
rl.on('error', () => resolve(events));
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Group session files by UUID; for each UUID prefer the message jsonl,
|
|
303
|
+
// fall back to trajectory if message is missing. Skip *.reset, *.deleted,
|
|
304
|
+
// *.checkpoint and similar derived files — only the canonical "<uuid>.jsonl"
|
|
305
|
+
// (no further suffix) and "<uuid>.trajectory.jsonl" are billing-relevant.
|
|
306
|
+
function groupSessionFiles(dir) {
|
|
307
|
+
let files;
|
|
308
|
+
try { files = fs.readdirSync(dir); } catch { return []; }
|
|
309
|
+
const groups = new Map(); // sessionId -> {message?, trajectory?}
|
|
310
|
+
for (const f of files) {
|
|
311
|
+
const m = f.match(UUID_RE);
|
|
312
|
+
if (!m) continue;
|
|
313
|
+
const sessionId = m[1];
|
|
314
|
+
const rest = f.slice(sessionId.length);
|
|
315
|
+
let kind = null;
|
|
316
|
+
if (rest === '.jsonl') kind = 'message';
|
|
317
|
+
else if (rest === '.trajectory.jsonl') kind = 'trajectory';
|
|
318
|
+
if (!kind) continue;
|
|
319
|
+
if (!groups.has(sessionId)) groups.set(sessionId, {});
|
|
320
|
+
groups.get(sessionId)[kind] = path.join(dir, f);
|
|
321
|
+
}
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const [sessionId, paths] of groups) {
|
|
324
|
+
if (paths.message) out.push({ sessionId, kind: 'message', filePath: paths.message });
|
|
325
|
+
else if (paths.trajectory) out.push({ sessionId, kind: 'trajectory', filePath: paths.trajectory });
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Map an agent ID to its Claude Code project transcript directory. Claude Code
|
|
331
|
+
// encodes the cwd as a directory name by replacing "/" and "." with "-". The
|
|
332
|
+
// agent's cwd is its workspace path, so we apply the same mangling.
|
|
333
|
+
function claudeProjectDir(agentId) {
|
|
334
|
+
const ws = path.join(STATE_DIR, 'agents', agentId, 'workspace');
|
|
335
|
+
const encoded = ws.replace(/[\/.]/g, '-');
|
|
336
|
+
return path.join(os.homedir(), '.claude', 'projects', encoded);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function refreshAgent(agentId) {
|
|
340
|
+
const cutoff = Date.now() - STALE_FILE_MS;
|
|
341
|
+
const all = [];
|
|
342
|
+
|
|
343
|
+
// Source A: OpenClaw-managed sessions (message + trajectory formats)
|
|
344
|
+
const dir = path.join(STATE_DIR, 'agents', agentId, 'sessions');
|
|
345
|
+
const sessions = groupSessionFiles(dir);
|
|
346
|
+
for (const { sessionId, kind, filePath } of sessions) {
|
|
347
|
+
let stat;
|
|
348
|
+
try { stat = fs.statSync(filePath); } catch { continue; }
|
|
349
|
+
if (stat.mtimeMs < cutoff) continue;
|
|
350
|
+
const cacheKey = `${kind}:${filePath}`;
|
|
351
|
+
const cached = fileCache.get(cacheKey);
|
|
352
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
353
|
+
all.push(...cached.events);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const events = kind === 'message'
|
|
357
|
+
? await parseMessageFile(filePath, sessionId)
|
|
358
|
+
: await parseTrajectoryFile(filePath, sessionId);
|
|
359
|
+
fileCache.set(cacheKey, { mtimeMs: stat.mtimeMs, events });
|
|
360
|
+
all.push(...events);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Source B: Claude Code direct sessions (e.g. /fast on main, claude-cli runs)
|
|
364
|
+
const claudeDir = claudeProjectDir(agentId);
|
|
365
|
+
let claudeFiles;
|
|
366
|
+
try { claudeFiles = fs.readdirSync(claudeDir); } catch { claudeFiles = []; }
|
|
367
|
+
for (const f of claudeFiles) {
|
|
368
|
+
const m = f.match(UUID_RE);
|
|
369
|
+
if (!m) continue;
|
|
370
|
+
if (f.slice(m[1].length) !== '.jsonl') continue; // skip .bloated, .reset, etc.
|
|
371
|
+
const filePath = path.join(claudeDir, f);
|
|
372
|
+
let stat;
|
|
373
|
+
try { stat = fs.statSync(filePath); } catch { continue; }
|
|
374
|
+
if (stat.mtimeMs < cutoff) continue;
|
|
375
|
+
const cacheKey = `claude-transcript:${filePath}`;
|
|
376
|
+
const cached = fileCache.get(cacheKey);
|
|
377
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
378
|
+
all.push(...cached.events);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const events = await parseClaudeTranscript(filePath, m[1]);
|
|
382
|
+
fileCache.set(cacheKey, { mtimeMs: stat.mtimeMs, events });
|
|
383
|
+
all.push(...events);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return all;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const lastEvents = new Map(); // agentId -> events[]
|
|
390
|
+
async function refresh(agentIds) {
|
|
391
|
+
for (const id of agentIds) {
|
|
392
|
+
lastEvents.set(id, await refreshAgent(id));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function rollup(events, prices, subscriptions, windowKey) {
|
|
397
|
+
// Per-provider per-token cost (estimate). For subscription providers this is
|
|
398
|
+
// an "if-billed-via-API" estimate, NOT what the user pays. Flat-fee accounting
|
|
399
|
+
// is added on top below.
|
|
400
|
+
let perTokenTotal = 0;
|
|
401
|
+
let unpriced = 0;
|
|
402
|
+
const byProvider = {};
|
|
403
|
+
const byModel = {};
|
|
404
|
+
for (const e of events) {
|
|
405
|
+
const provider = (e.modelKey.split('/')[0] || 'unknown');
|
|
406
|
+
const r = priceTurn(prices, e.modelKey, e);
|
|
407
|
+
if (r.unpriced) unpriced++;
|
|
408
|
+
perTokenTotal += r.cost;
|
|
409
|
+
byProvider[provider] = (byProvider[provider] || 0) + r.cost;
|
|
410
|
+
byModel[e.modelKey] = (byModel[e.modelKey] || 0) + r.cost;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Subscription flat fees prorated to this window. The user pays these whether
|
|
414
|
+
// or not they use the model, so they're added to billed.
|
|
415
|
+
const subSet = new Set(Object.keys(subscriptions || {}));
|
|
416
|
+
const subFees = subscriptionFeesByProvider(subscriptions, windowKey);
|
|
417
|
+
const subTotal = subscriptionFeeForWindow(subscriptions, windowKey);
|
|
418
|
+
|
|
419
|
+
// Pertoken cost from non-subscription providers = actual per-token spend.
|
|
420
|
+
let perTokenBilled = 0;
|
|
421
|
+
for (const [p, v] of Object.entries(byProvider)) {
|
|
422
|
+
if (!subSet.has(p)) perTokenBilled += v;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Replace the per-token Claude estimate in byProvider with the flat-fee share
|
|
426
|
+
// so the per-provider chips reflect what the user actually pays.
|
|
427
|
+
const provOutEstimate = {}; // unmodified per-token estimate (for "if API")
|
|
428
|
+
const provOut = {}; // billed view (per-token for billed providers, flat-fee for subscriptions)
|
|
429
|
+
for (const [k, v] of Object.entries(byProvider)) {
|
|
430
|
+
provOutEstimate[k] = v;
|
|
431
|
+
provOut[k] = subSet.has(k) ? (subFees[k] || 0) : v;
|
|
432
|
+
}
|
|
433
|
+
for (const [p, v] of Object.entries(subFees)) {
|
|
434
|
+
if (!(p in provOut)) provOut[p] = v; // subscription provider with no usage in window still owes the fee
|
|
435
|
+
if (!(p in provOutEstimate)) provOutEstimate[p] = 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const round = (v) => Math.round(v * 100) / 100;
|
|
439
|
+
const roundMap = (m) => { const o = {}; for (const [k, v] of Object.entries(m)) o[k] = round(v); return o; };
|
|
440
|
+
|
|
441
|
+
const billed = perTokenBilled + subTotal;
|
|
442
|
+
const estimate = perTokenTotal + subTotal; // "what would this cost on the API" + sub fees
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
billed: round(billed),
|
|
446
|
+
estimate: round(estimate),
|
|
447
|
+
perTokenTotal: round(perTokenTotal), // per-token cost across all providers (info)
|
|
448
|
+
subscription: round(subTotal), // sum of flat-fee subscription charges in window
|
|
449
|
+
byProvider: roundMap(provOut),
|
|
450
|
+
byProviderEstimate: roundMap(provOutEstimate),
|
|
451
|
+
byModel: roundMap(byModel),
|
|
452
|
+
unpriced,
|
|
453
|
+
count: events.length,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Rolling windows (relative to "now") rather than calendar-aligned. Calendar
|
|
458
|
+
// windows produce confusing equalities — on the 1st of a month, "Month" would
|
|
459
|
+
// equal "Day" exactly. Rolling 24h / 30d / 365d always nest meaningfully.
|
|
460
|
+
function startOfDay() { return Date.now() - 24 * 60 * 60 * 1000; }
|
|
461
|
+
function startOfMonth() { return Date.now() - 30 * 24 * 60 * 60 * 1000; }
|
|
462
|
+
function startOfYear() { return Date.now() - 365 * 24 * 60 * 60 * 1000; }
|
|
463
|
+
|
|
464
|
+
function latestSessionId(events) {
|
|
465
|
+
let latest = null;
|
|
466
|
+
for (const e of events) {
|
|
467
|
+
if (!e.sessionId) continue;
|
|
468
|
+
if (!latest || e.ts > latest.ts) latest = e;
|
|
469
|
+
}
|
|
470
|
+
return latest ? latest.sessionId : null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function usageForAgent(agentId, priceCfg) {
|
|
474
|
+
const events = lastEvents.get(agentId) || [];
|
|
475
|
+
const prices = priceCfg.prices || {};
|
|
476
|
+
// Per-agent: keep the SET of subscription providers (so Claude per-token is
|
|
477
|
+
// excluded from billed) but zero out monthlyUSD (the flat fee only applies
|
|
478
|
+
// once at the fleet level — adding it per-agent would multiply by N).
|
|
479
|
+
const perAgentSubs = {};
|
|
480
|
+
for (const p of Object.keys(priceCfg.subscriptions || {})) perAgentSubs[p] = { monthlyUSD: 0 };
|
|
481
|
+
const dayT = startOfDay();
|
|
482
|
+
const monthT = startOfMonth();
|
|
483
|
+
const yearT = startOfYear();
|
|
484
|
+
// "Session" = today's portion of the most recent session. Without the
|
|
485
|
+
// ts >= dayT clamp, a long-running session ID (Claude sessions can run for
|
|
486
|
+
// days; main's 706f15b9 has been live since 2026-04-27) would make Session
|
|
487
|
+
// larger than Today, breaking the obvious nesting Session ⊆ Day ⊆ Month ⊆ Year.
|
|
488
|
+
const sid = latestSessionId(events);
|
|
489
|
+
return {
|
|
490
|
+
session: rollup(events.filter(e => e.sessionId === sid && e.ts >= dayT), prices, perAgentSubs, 'session'),
|
|
491
|
+
day: rollup(events.filter(e => e.ts >= dayT), prices, perAgentSubs, 'day'),
|
|
492
|
+
month: rollup(events.filter(e => e.ts >= monthT), prices, perAgentSubs, 'month'),
|
|
493
|
+
year: rollup(events.filter(e => e.ts >= yearT), prices, perAgentSubs, 'year'),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function fleetUsage(agentIds, priceCfg) {
|
|
498
|
+
const result = {};
|
|
499
|
+
const empty = () => ({
|
|
500
|
+
billed: 0, estimate: 0, perTokenTotal: 0, subscription: 0,
|
|
501
|
+
byProvider: {}, byProviderEstimate: {}, byModel: {},
|
|
502
|
+
unpriced: 0, count: 0,
|
|
503
|
+
});
|
|
504
|
+
const fleet = { session: empty(), day: empty(), month: empty(), year: empty() };
|
|
505
|
+
for (const id of agentIds) {
|
|
506
|
+
const u = usageForAgent(id, priceCfg);
|
|
507
|
+
result[id] = u;
|
|
508
|
+
for (const w of ['session','day','month','year']) {
|
|
509
|
+
fleet[w].billed += u[w].billed;
|
|
510
|
+
fleet[w].estimate += u[w].estimate;
|
|
511
|
+
fleet[w].perTokenTotal += u[w].perTokenTotal;
|
|
512
|
+
fleet[w].unpriced += u[w].unpriced;
|
|
513
|
+
fleet[w].count += u[w].count;
|
|
514
|
+
for (const [p, v] of Object.entries(u[w].byProvider)) {
|
|
515
|
+
fleet[w].byProvider[p] = (fleet[w].byProvider[p] || 0) + v;
|
|
516
|
+
}
|
|
517
|
+
for (const [p, v] of Object.entries(u[w].byProviderEstimate || {})) {
|
|
518
|
+
fleet[w].byProviderEstimate[p] = (fleet[w].byProviderEstimate[p] || 0) + v;
|
|
519
|
+
}
|
|
520
|
+
for (const [m, v] of Object.entries(u[w].byModel || {})) {
|
|
521
|
+
fleet[w].byModel[m] = (fleet[w].byModel[m] || 0) + v;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// Apply subscription fees once at the fleet level.
|
|
526
|
+
const subs = priceCfg.subscriptions || {};
|
|
527
|
+
const subSet = new Set(Object.keys(subs));
|
|
528
|
+
for (const w of ['session','day','month','year']) {
|
|
529
|
+
const subTotal = subscriptionFeeForWindow(subs, w);
|
|
530
|
+
const subFees = subscriptionFeesByProvider(subs, w);
|
|
531
|
+
fleet[w].subscription = subTotal;
|
|
532
|
+
fleet[w].billed += subTotal;
|
|
533
|
+
fleet[w].estimate += subTotal;
|
|
534
|
+
// Replace per-token estimate of subscription providers with their flat fees in byProvider.
|
|
535
|
+
for (const [p, fee] of Object.entries(subFees)) {
|
|
536
|
+
fleet[w].byProvider[p] = fee; // flat fee replaces the per-token tally
|
|
537
|
+
if (!(p in fleet[w].byProviderEstimate)) fleet[w].byProviderEstimate[p] = 0;
|
|
538
|
+
}
|
|
539
|
+
// Subscription providers that had per-token tallies (estimate) need their
|
|
540
|
+
// billed-side entry overridden — already done above. For non-subscription
|
|
541
|
+
// providers, leave as-is.
|
|
542
|
+
for (const p of Object.keys(fleet[w].byProvider)) {
|
|
543
|
+
if (subSet.has(p)) continue;
|
|
544
|
+
// already correct (per-token billed total)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Round fleet totals.
|
|
549
|
+
const round = (v) => Math.round(v * 100) / 100;
|
|
550
|
+
for (const w of ['session','day','month','year']) {
|
|
551
|
+
fleet[w].billed = round(fleet[w].billed);
|
|
552
|
+
fleet[w].estimate = round(fleet[w].estimate);
|
|
553
|
+
fleet[w].perTokenTotal = round(fleet[w].perTokenTotal);
|
|
554
|
+
fleet[w].subscription = round(fleet[w].subscription);
|
|
555
|
+
for (const k of Object.keys(fleet[w].byProvider)) {
|
|
556
|
+
fleet[w].byProvider[k] = round(fleet[w].byProvider[k]);
|
|
557
|
+
}
|
|
558
|
+
for (const k of Object.keys(fleet[w].byProviderEstimate)) {
|
|
559
|
+
fleet[w].byProviderEstimate[k] = round(fleet[w].byProviderEstimate[k]);
|
|
560
|
+
}
|
|
561
|
+
for (const k of Object.keys(fleet[w].byModel)) {
|
|
562
|
+
fleet[w].byModel[k] = round(fleet[w].byModel[k]);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return { perAgent: result, fleet };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
module.exports = { loadPrices, refresh, fleetUsage };
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clawhouse",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pixel-art fleet status board for OpenClaw agents — part of the Clawvision family",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": { "node": ">=22" },
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js",
|
|
9
|
+
"dev": "PIXEL_OFFICE_PORT=18890 node server.js",
|
|
10
|
+
"demo": "PIXEL_OFFICE_DEMO=1 node server.js",
|
|
11
|
+
"build": "npm run build:sprites && npm run build:props && npm run build:monitors && npm run build:furniture && npm run build:icons",
|
|
12
|
+
"build:sprites": "node scripts/build-sprites.js",
|
|
13
|
+
"build:props": "node scripts/build-props.js",
|
|
14
|
+
"build:monitors": "node scripts/build-monitors.js",
|
|
15
|
+
"build:furniture": "node scripts/build-furniture.js",
|
|
16
|
+
"build:icons": "node scripts/generate-icons.js",
|
|
17
|
+
"build:profiles": "node scripts/export-profile-pics.js"
|
|
18
|
+
},
|
|
19
|
+
"main": "server.js",
|
|
20
|
+
"bin": {
|
|
21
|
+
"clawhouse": "bin/clawhouse.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"server.js",
|
|
25
|
+
"bin/",
|
|
26
|
+
"lib/",
|
|
27
|
+
"scripts/",
|
|
28
|
+
"public/"
|
|
29
|
+
]
|
|
30
|
+
}
|