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/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
+ }