@stackmeter/cli 0.1.4 → 0.2.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.
@@ -4,7 +4,7 @@ import { createInterface } from "node:readline/promises";
4
4
  import { exec, execSync } from "node:child_process";
5
5
  import {
6
6
  readFileSync, writeFileSync, existsSync, mkdirSync,
7
- unlinkSync, chmodSync, copyFileSync,
7
+ unlinkSync, chmodSync, copyFileSync, readdirSync,
8
8
  } from "node:fs";
9
9
  import { homedir, platform } from "node:os";
10
10
  import { join, dirname, resolve } from "node:path";
@@ -298,7 +298,7 @@ async function connectOpenClaw() {
298
298
  console.log(
299
299
  " \u26A0\uFE0F OpenClaw config not found at ~/.openclaw/openclaw.json"
300
300
  );
301
- console.log(" The log watcher will start anyway and wait for OpenClaw logs.");
301
+ console.log(" The watcher will start anyway and wait for OpenClaw session files.");
302
302
  console.log("");
303
303
  }
304
304
 
@@ -359,7 +359,7 @@ async function connectOpenClaw() {
359
359
  console.log(
360
360
  " No changes to OpenClaw config needed \u2014 just run OpenClaw normally."
361
361
  );
362
- console.log(" The log watcher reads OpenClaw's logs in /tmp/openclaw/");
362
+ console.log(" The watcher reads session files from ~/.openclaw/agents/");
363
363
  console.log(` View your usage at ${baseUrl}/app/ai-usage`);
364
364
  console.log("");
365
365
  rl.close();
@@ -483,16 +483,30 @@ async function selfTest() {
483
483
  console.log(` \u274C Could not reach StackMeter: ${e.message}`);
484
484
  }
485
485
 
486
- // 2/2 — Check OpenClaw log file exists
487
- console.log(" 2/2 Checking OpenClaw logs...");
488
- const d = new Date();
489
- const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
490
- const logPath = `/tmp/openclaw/openclaw-${ymd}.log`;
486
+ // 2/2 — Check OpenClaw session files
487
+ console.log(" 2/2 Checking OpenClaw sessions...");
488
+ const ocHome = process.env.OPENCLAW_HOME || join(homedir(), ".openclaw");
489
+ const agentsDir = join(ocHome, "agents");
491
490
 
492
- if (existsSync(logPath)) {
493
- console.log(` \u2705 OpenClaw log found: ${logPath}`);
491
+ if (existsSync(agentsDir)) {
492
+ const agents = readdirSync(agentsDir).filter(d =>
493
+ existsSync(join(agentsDir, d, "sessions"))
494
+ );
495
+ if (agents.length > 0) {
496
+ console.log(` \u2705 Found ${agents.length} agent(s): ${agents.join(", ")}`);
497
+ let totalSessions = 0;
498
+ for (const agent of agents) {
499
+ const sessDir = join(agentsDir, agent, "sessions");
500
+ const files = readdirSync(sessDir).filter(f => f.endsWith(".jsonl"));
501
+ totalSessions += files.length;
502
+ }
503
+ console.log(` ${totalSessions} session file(s) total`);
504
+ } else {
505
+ console.log(` \u26A0\uFE0F ${agentsDir} exists but no agents with sessions found`);
506
+ console.log(" Start OpenClaw first, then re-run this test.");
507
+ }
494
508
  } else {
495
- console.log(` \u26A0\uFE0F No log found at ${logPath}`);
509
+ console.log(` \u26A0\uFE0F No agents directory at ${agentsDir}`);
496
510
  console.log(" Start OpenClaw first, then re-run this test.");
497
511
  }
498
512
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmeter/cli",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Track your SaaS AI costs from the terminal. One-command OpenClaw setup.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,81 +1,11 @@
1
- // StackMeter Log Watcher — Tails OpenClaw logs and emits usage events.
2
- // Replaces the proxy gateway approach since OpenClaw doesn't support
3
- // base URL overrides for its HTTP client.
1
+ // StackMeter Session Watcher — Reads OpenClaw session JSONL files for exact usage data.
2
+ // Watches ~/.openclaw/agents/*/sessions/*.jsonl for assistant messages with token usage.
4
3
 
5
- import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from "node:fs";
4
+ import { readFileSync, existsSync, statSync, watchFile, unwatchFile, readdirSync } from "node:fs";
5
+ import { watch } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
 
9
- const OC_LOG_DIR = "/tmp/openclaw";
10
-
11
- // Cost per 1M tokens in cents (best-effort estimates)
12
- const MODEL_PRICING = {
13
- // OpenAI
14
- "gpt-4o": { input: 250, output: 1000 },
15
- "gpt-4o-mini": { input: 15, output: 60 },
16
- "gpt-4-turbo": { input: 1000, output: 3000 },
17
- "gpt-4": { input: 3000, output: 6000 },
18
- "gpt-3.5-turbo": { input: 50, output: 150 },
19
- "o1": { input: 1500, output: 6000 },
20
- "o1-mini": { input: 300, output: 1200 },
21
- "o3-mini": { input: 110, output: 440 },
22
- // Anthropic
23
- "claude-opus-4-6": { input: 1500, output: 7500 },
24
- "claude-opus-4-5": { input: 1500, output: 7500 },
25
- "claude-sonnet-4-5": { input: 300, output: 1500 },
26
- "claude-haiku-4-5": { input: 80, output: 400 },
27
- "claude-3-opus": { input: 1500, output: 7500 },
28
- "claude-3-5-sonnet": { input: 300, output: 1500 },
29
- "claude-3-5-haiku": { input: 80, output: 400 },
30
- "claude-3-haiku": { input: 25, output: 125 },
31
- };
32
-
33
- // Rough tokens/second estimates by model class for duration-based estimation
34
- const TOKENS_PER_SEC = {
35
- "gpt-4o": 80,
36
- "gpt-4o-mini": 120,
37
- "gpt-4-turbo": 40,
38
- "gpt-4": 30,
39
- "gpt-3.5-turbo": 100,
40
- "o1": 20,
41
- "o1-mini": 40,
42
- "o3-mini": 60,
43
- "claude-opus-4-6": 30,
44
- "claude-opus-4-5": 30,
45
- "claude-sonnet-4-5": 60,
46
- "claude-haiku-4-5": 120,
47
- "claude-3-opus": 30,
48
- "claude-3-5-sonnet": 60,
49
- "claude-3-5-haiku": 120,
50
- "claude-3-haiku": 150,
51
- };
52
-
53
- function matchModel(model) {
54
- for (const key of Object.keys(MODEL_PRICING)) {
55
- if (model === key || model.startsWith(key + "-")) return key;
56
- }
57
- return null;
58
- }
59
-
60
- function estimateFromDuration(model, durationMs) {
61
- const matched = matchModel(model);
62
- const tps = matched ? (TOKENS_PER_SEC[matched] || 60) : 60;
63
- const secs = durationMs / 1000;
64
- // Rough split: ~30% input time, ~70% output time
65
- const outputTokens = Math.round(secs * 0.7 * tps);
66
- const inputTokens = Math.round(secs * 0.3 * tps * 0.5); // input is faster
67
- return { inputTokens, outputTokens };
68
- }
69
-
70
- function estimateCostCents(model, inputTokens, outputTokens) {
71
- const matched = matchModel(model);
72
- if (!matched) return 0;
73
- const pricing = MODEL_PRICING[matched];
74
- return Math.round(
75
- (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000
76
- );
77
- }
78
-
79
9
  /* ── Config ──────────────────────────────────────────────── */
80
10
 
81
11
  function readStackMeterConfig() {
@@ -85,6 +15,10 @@ function readStackMeterConfig() {
85
15
  catch { return {}; }
86
16
  }
87
17
 
18
+ function getOpenClawHome(smConfig = {}) {
19
+ return process.env.OPENCLAW_HOME || smConfig.openclawHome || join(homedir(), ".openclaw");
20
+ }
21
+
88
22
  /* ── Emit usage event to StackMeter ──────────────────────── */
89
23
 
90
24
  async function emitUsageEvent(event, smUrl, smToken) {
@@ -100,8 +34,8 @@ async function emitUsageEvent(event, smUrl, smToken) {
100
34
 
101
35
  if (res.ok) {
102
36
  console.log(
103
- ` [watcher] ${event.provider}/${event.model} ~${event.inputTokens}+${event.outputTokens} tokens` +
104
- ` \u2192 ${event.costCents}\u00A2 (${res.status})`
37
+ ` [watcher] ${event.provider}/${event.model} ${event.inputTokens}+${event.outputTokens} tokens` +
38
+ ` \u2192 ${event.costCents.toFixed(4)}\u00A2 (${res.status})`
105
39
  );
106
40
  } else {
107
41
  const err = await res.text().catch(() => "");
@@ -114,21 +48,64 @@ async function emitUsageEvent(event, smUrl, smToken) {
114
48
  }
115
49
  }
116
50
 
117
- /* ── Log parser ──────────────────────────────────────────── */
51
+ /* ── JSONL line parser ───────────────────────────────────── */
118
52
 
119
- function parseLogLine(line) {
53
+ function parseSessionLine(line) {
120
54
  try {
121
55
  const obj = JSON.parse(line);
122
- const subsystem = obj["0"] || "";
123
- const msg = typeof obj["1"] === "string" ? obj["1"] : "";
124
- const ts = obj.time || obj._meta?.date;
125
- return { subsystem, msg, data: obj["1"], ts };
56
+
57
+ // Only process assistant messages with usage data
58
+ if (obj.type !== "message") return null;
59
+ const msg = obj.message;
60
+ if (!msg || msg.role !== "assistant" || !msg.usage) return null;
61
+
62
+ const usage = msg.usage;
63
+ const cost = usage.cost || {};
64
+
65
+ return {
66
+ messageId: obj.id,
67
+ timestamp: obj.timestamp || new Date().toISOString(),
68
+ provider: msg.provider || "unknown",
69
+ model: msg.model || "unknown",
70
+ inputTokens: (usage.input || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0),
71
+ outputTokens: usage.output || 0,
72
+ costCents: (cost.total || 0) * 100, // dollars → cents
73
+ };
126
74
  } catch {
127
75
  return null;
128
76
  }
129
77
  }
130
78
 
131
- /* ── Log watcher ─────────────────────────────────────────── */
79
+ /* ── Directory discovery ─────────────────────────────────── */
80
+
81
+ function discoverAgentDirs(ocHome) {
82
+ const agentsDir = join(ocHome, "agents");
83
+ if (!existsSync(agentsDir)) return [];
84
+
85
+ try {
86
+ return readdirSync(agentsDir, { withFileTypes: true })
87
+ .filter(d => d.isDirectory())
88
+ .map(d => ({
89
+ agentName: d.name,
90
+ sessionsDir: join(agentsDir, d.name, "sessions"),
91
+ }))
92
+ .filter(a => existsSync(a.sessionsDir));
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ function listSessionFiles(sessionsDir) {
99
+ try {
100
+ return readdirSync(sessionsDir)
101
+ .filter(f => f.endsWith(".jsonl"))
102
+ .map(f => join(sessionsDir, f));
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+
108
+ /* ── Session watcher ─────────────────────────────────────── */
132
109
 
133
110
  export function startLogWatcher(opts = {}) {
134
111
  const smConfig = readStackMeterConfig();
@@ -147,157 +124,162 @@ export function startLogWatcher(opts = {}) {
147
124
  process.exit(1);
148
125
  }
149
126
 
150
- // Track active runs: runId -> { provider, model, startTime }
151
- const activeRuns = new Map();
152
- // Track emitted dedupKeys to avoid re-emitting on file re-read
153
- const emittedRuns = new Set();
127
+ const ocHome = getOpenClawHome(smConfig);
128
+ const emittedKeys = new Set();
154
129
 
155
- let currentLogPath = null;
156
- let fileOffset = 0;
130
+ // Track watched files: filePath -> { offset, partialLine }
131
+ const watchedFiles = new Map();
132
+ // Track watched directories
133
+ const watchedDirs = new Set();
134
+ // fs.watch handles for cleanup
135
+ const dirWatchers = [];
157
136
 
158
- function getLogPath() {
159
- const d = new Date();
160
- const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
161
- return join(OC_LOG_DIR, `openclaw-${ymd}.log`);
137
+ function extractSessionId(filePath) {
138
+ // /path/to/sessions/136ae359-fceb-4464-9d5a-9e7c2a15cdf7.jsonl 136ae359
139
+ const base = filePath.split("/").pop().replace(".jsonl", "");
140
+ return base.split("-")[0] || base;
162
141
  }
163
142
 
164
- function processLine(line) {
165
- if (!line.trim()) return;
166
- const parsed = parseLogLine(line);
167
- if (!parsed) return;
143
+ function processNewBytes(filePath, agentName) {
144
+ if (!existsSync(filePath)) return;
168
145
 
169
- const { msg, data, ts } = parsed;
146
+ const state = watchedFiles.get(filePath);
147
+ if (!state) return;
170
148
 
171
- // Match: "embedded run start: runId=xxx ... provider=yyy model=zzz"
172
- if (typeof msg === "string" && msg.includes("embedded run start:")) {
173
- const runId = msg.match(/runId=(\S+)/)?.[1];
174
- const provider = msg.match(/provider=(\S+)/)?.[1];
175
- const model = msg.match(/model=(\S+)/)?.[1];
176
- if (runId && provider && model) {
177
- activeRuns.set(runId, { provider, model, startTime: ts });
178
- }
179
- return;
180
- }
149
+ const stat = statSync(filePath);
150
+ if (stat.size <= state.offset) return;
151
+
152
+ // Read only new bytes
153
+ const content = readFileSync(filePath, "utf-8");
154
+ const newContent = (state.partialLine || "") + content.slice(state.offset);
155
+ state.offset = content.length;
156
+
157
+ const lines = newContent.split("\n");
181
158
 
182
- // Match: "embedded run done: runId=xxx ... durationMs=NNN"
183
- if (typeof msg === "string" && msg.includes("embedded run done:")) {
184
- const runId = msg.match(/runId=(\S+)/)?.[1];
185
- const durationMs = parseInt(msg.match(/durationMs=(\d+)/)?.[1] || "0", 10);
186
- const aborted = msg.includes("aborted=true");
159
+ // Last element might be a partial line (incomplete write)
160
+ state.partialLine = lines.pop() || "";
187
161
 
188
- if (!runId || aborted) return;
189
- if (emittedRuns.has(runId)) return;
162
+ const sessionId = extractSessionId(filePath);
190
163
 
191
- const run = activeRuns.get(runId);
192
- if (!run) return;
164
+ for (const line of lines) {
165
+ if (!line.trim()) continue;
193
166
 
194
- activeRuns.delete(runId);
195
- emittedRuns.add(runId);
167
+ const parsed = parseSessionLine(line);
168
+ if (!parsed) continue;
196
169
 
197
- // Estimate tokens from duration
198
- const { inputTokens, outputTokens } = estimateFromDuration(run.model, durationMs);
199
- const costCents = estimateCostCents(run.model, inputTokens, outputTokens);
170
+ const dedupKey = `oc-${agentName}-${sessionId}-${parsed.messageId}`;
171
+ if (emittedKeys.has(dedupKey)) continue;
172
+ emittedKeys.add(dedupKey);
200
173
 
201
174
  const event = {
202
- provider: run.provider,
203
- model: run.model,
204
- inputTokens,
205
- outputTokens,
206
- costCents,
175
+ provider: parsed.provider,
176
+ model: parsed.model,
177
+ inputTokens: parsed.inputTokens,
178
+ outputTokens: parsed.outputTokens,
179
+ costCents: parsed.costCents,
207
180
  sourceType: "openclaw",
208
- sourceId: "log-watcher",
209
- ts: run.startTime || new Date().toISOString(),
210
- dedupKey: `oc-${runId}`,
181
+ sourceId: `agent/${agentName}`,
182
+ ts: parsed.timestamp,
183
+ dedupKey,
211
184
  };
212
185
 
213
186
  const p = emitUsageEvent(event, smUrl, smToken);
214
187
  if (onEmit) p.then(onEmit);
215
- return;
216
188
  }
217
- }
218
-
219
- function readNewLines() {
220
- const logPath = getLogPath();
221
189
 
222
- // Date rolled over switch to new file
223
- if (logPath !== currentLogPath) {
224
- if (currentLogPath) {
225
- unwatchFile(currentLogPath);
226
- }
227
- currentLogPath = logPath;
228
- fileOffset = 0;
190
+ // Prune emittedKeys if it gets too large
191
+ if (emittedKeys.size > 100_000) {
192
+ emittedKeys.clear();
193
+ }
194
+ }
229
195
 
230
- // If file already exists, start from the end (don't replay old events)
231
- if (existsSync(logPath)) {
232
- const stat = statSync(logPath);
233
- fileOffset = stat.size;
234
- }
196
+ function startWatchingFile(filePath, agentName) {
197
+ if (watchedFiles.has(filePath)) return;
235
198
 
236
- watchLogFile(logPath);
237
- return;
199
+ // Start from end of file (skip historical data)
200
+ let offset = 0;
201
+ if (existsSync(filePath)) {
202
+ offset = statSync(filePath).size;
238
203
  }
239
204
 
240
- if (!existsSync(logPath)) return;
205
+ watchedFiles.set(filePath, { offset, partialLine: "" });
241
206
 
242
- const stat = statSync(logPath);
243
- if (stat.size <= fileOffset) return;
207
+ watchFile(filePath, { interval: 2000 }, () => {
208
+ processNewBytes(filePath, agentName);
209
+ });
210
+ }
244
211
 
245
- // Read only new bytes
246
- const fd = readFileSync(logPath, "utf-8");
247
- const newContent = fd.slice(fileOffset);
248
- fileOffset = fd.length;
212
+ function startWatchingDir(agentName, sessionsDir) {
213
+ if (watchedDirs.has(sessionsDir)) return;
214
+ watchedDirs.add(sessionsDir);
249
215
 
250
- const lines = newContent.split("\n");
251
- for (const line of lines) {
252
- processLine(line);
216
+ // Watch existing session files
217
+ for (const filePath of listSessionFiles(sessionsDir)) {
218
+ startWatchingFile(filePath, agentName);
253
219
  }
254
- }
255
220
 
256
- function watchLogFile(logPath) {
257
- if (!existsSync(logPath)) {
258
- // File doesn't exist yet poll until it does
259
- const check = setInterval(() => {
260
- if (existsSync(logPath)) {
261
- clearInterval(check);
262
- watchLogFile(logPath);
221
+ // Watch for new session files
222
+ try {
223
+ const watcher = watch(sessionsDir, (eventType, filename) => {
224
+ if (!filename || !filename.endsWith(".jsonl")) return;
225
+ const filePath = join(sessionsDir, filename);
226
+ if (existsSync(filePath)) {
227
+ startWatchingFile(filePath, agentName);
263
228
  }
264
- }, 5000);
265
- return;
229
+ });
230
+ dirWatchers.push(watcher);
231
+ } catch {
232
+ // fs.watch not available — rely on periodic scan below
266
233
  }
234
+ }
267
235
 
268
- watchFile(logPath, { interval: 1000 }, () => {
269
- readNewLines();
270
- });
236
+ function scanForAgents() {
237
+ const agents = discoverAgentDirs(ocHome);
238
+ for (const { agentName, sessionsDir } of agents) {
239
+ startWatchingDir(agentName, sessionsDir);
240
+ }
271
241
  }
272
242
 
273
- // Check for date rollover every 60s
274
- setInterval(() => {
275
- const newPath = getLogPath();
276
- if (newPath !== currentLogPath) {
277
- readNewLines(); // triggers switchover
243
+ function scanForNewFiles() {
244
+ for (const sessionsDir of watchedDirs) {
245
+ const agentName = sessionsDir.split("/").at(-2) || "unknown";
246
+ for (const filePath of listSessionFiles(sessionsDir)) {
247
+ startWatchingFile(filePath, agentName);
248
+ }
278
249
  }
279
- }, 60_000);
250
+ }
280
251
 
281
- // Start watching
282
- currentLogPath = getLogPath();
252
+ // Initial scan
253
+ scanForAgents();
283
254
 
284
- if (existsSync(currentLogPath)) {
285
- const stat = statSync(currentLogPath);
286
- fileOffset = stat.size; // start from end
287
- }
255
+ // Periodic scans: new agents every 30s, new session files every 10s
256
+ const agentScanInterval = setInterval(scanForAgents, 30_000);
257
+ const fileScanInterval = setInterval(scanForNewFiles, 10_000);
288
258
 
289
- watchLogFile(currentLogPath);
259
+ const agentDirs = discoverAgentDirs(ocHome);
260
+ const agentNames = agentDirs.map(a => a.agentName);
290
261
 
291
262
  console.log("");
292
- console.log(" StackMeter Log Watcher");
293
- console.log(` Watching: ${OC_LOG_DIR}/openclaw-*.log`);
263
+ console.log(" StackMeter Session Watcher");
264
+ console.log(` Watching: ${ocHome}/agents/*/sessions/*.jsonl`);
265
+ if (agentNames.length > 0) {
266
+ console.log(` Agents: ${agentNames.join(", ")}`);
267
+ } else {
268
+ console.log(" Agents: (none found yet — waiting for OpenClaw sessions)");
269
+ }
294
270
  console.log(` Reporting: ${smUrl}`);
295
271
  console.log("");
296
272
 
297
- // Return a cleanup function
298
273
  return {
299
274
  stop() {
300
- if (currentLogPath) unwatchFile(currentLogPath);
275
+ clearInterval(agentScanInterval);
276
+ clearInterval(fileScanInterval);
277
+ for (const [filePath] of watchedFiles) {
278
+ unwatchFile(filePath);
279
+ }
280
+ for (const watcher of dirWatchers) {
281
+ watcher.close();
282
+ }
301
283
  }
302
284
  };
303
285
  }