@stackmeter/cli 0.1.3 → 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";
@@ -21,7 +21,6 @@ const target = args[1];
21
21
  // ── Constants ────────────────────────────────────────────
22
22
 
23
23
  const DEFAULT_BASE_URL = "https://stackmeter.app";
24
- const DEFAULT_GATEWAY_PORT = 8787;
25
24
  const LAUNCH_AGENT_LABEL = "com.stackmeter.gateway";
26
25
  const PLIST_PATH = join(
27
26
  homedir(), "Library", "LaunchAgents", `${LAUNCH_AGENT_LABEL}.plist`
@@ -33,7 +32,6 @@ const SM_LAUNCHER_PATH = join(SM_CONFIG_DIR, "start-gateway.mjs");
33
32
  const SM_GATEWAY_LOG = join(SM_CONFIG_DIR, "gateway.log");
34
33
  const SM_GATEWAY_ERR_LOG = join(SM_CONFIG_DIR, "gateway.err.log");
35
34
  const OC_CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
36
- const OC_BACKUP_PATH = join(homedir(), ".openclaw", "openclaw.json.stackmeter.bak");
37
35
 
38
36
  // ── Command routing ──────────────────────────────────────
39
37
 
@@ -86,11 +84,6 @@ function openBrowser(url) {
86
84
  exec(`${cmd} "${url}"`, () => {});
87
85
  }
88
86
 
89
- function redactKey(key) {
90
- if (!key || key.length < 8) return "****";
91
- return `...${key.slice(-4)}`;
92
- }
93
-
94
87
  // ── Config readers / writers ─────────────────────────────
95
88
 
96
89
  function readOpenClawConfig() {
@@ -299,95 +292,46 @@ async function connectOpenClaw() {
299
292
  console.log(" \u2705 Token verified!");
300
293
  console.log("");
301
294
 
302
- // ── Step 4: Read OpenClaw config ───────────────────
295
+ // ── Step 4: Verify OpenClaw is installed ─────────────
303
296
  const { config: ocConfig } = readOpenClawConfig();
304
- const existingApiKey =
305
- ocConfig?.models?.providers?.openai?.apiKey ||
306
- process.env.OPENAI_API_KEY ||
307
- "";
308
-
309
- if (!existingApiKey) {
310
- console.log(" \u274C OpenAI API key not found.");
297
+ if (!ocConfig) {
311
298
  console.log(
312
- " Set models.providers.openai.apiKey in ~/.openclaw/openclaw.json"
299
+ " \u26A0\uFE0F OpenClaw config not found at ~/.openclaw/openclaw.json"
313
300
  );
314
- console.log(" or: export OPENAI_API_KEY=sk-...");
315
- rl.close();
316
- process.exit(1);
301
+ console.log(" The watcher will start anyway and wait for OpenClaw session files.");
302
+ console.log("");
317
303
  }
318
304
 
319
305
  if (autoFlag) {
320
- const gatewayPort = parseInt(
321
- process.env.STACKMETER_GATEWAY_PORT || String(DEFAULT_GATEWAY_PORT),
322
- 10
323
- );
324
- const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}/v1`;
325
- // Anthropic baseUrl doesn't include /v1 (SDK adds it)
326
- const gatewayBase = `http://127.0.0.1:${gatewayPort}`;
327
-
328
- // ── Backup openclaw.json (first run only) ────────
329
- if (ocConfig && !existsSync(OC_BACKUP_PATH)) {
330
- copyFileSync(OC_CONFIG_PATH, OC_BACKUP_PATH);
331
- console.log(` \u{1F4CB} Backed up ${OC_CONFIG_PATH}`);
332
- console.log(` \u2192 ${OC_BACKUP_PATH}`);
333
- console.log("");
334
- }
335
-
336
- // ── Patch openclaw.json (env vars for SDK routing) ─
337
- if (ocConfig) {
338
- // Patch the env section with SDK-recognized base URL env vars.
339
- // The OpenAI and Anthropic SDKs respect OPENAI_BASE_URL and
340
- // ANTHROPIC_BASE_URL, which OpenClaw injects at runtime.
341
- if (!ocConfig.env) ocConfig.env = {};
342
- ocConfig.env.OPENAI_BASE_URL = gatewayBaseUrl;
343
- ocConfig.env.ANTHROPIC_BASE_URL = gatewayBase;
344
-
345
- writeFileSync(OC_CONFIG_PATH, JSON.stringify(ocConfig, null, 2) + "\n");
346
- console.log(` \u2705 Patched ${OC_CONFIG_PATH}`);
347
- console.log(
348
- ` env.OPENAI_BASE_URL = "${gatewayBaseUrl}"`
349
- );
350
- console.log(
351
- ` env.ANTHROPIC_BASE_URL = "${gatewayBase}"`
352
- );
353
- } else {
354
- console.log(
355
- " \u274C OpenClaw config not found at ~/.openclaw/openclaw.json"
356
- );
357
- console.log(" Install OpenClaw first: https://openclaw.dev");
358
- rl.close();
359
- process.exit(1);
360
- }
361
- console.log("");
362
-
363
306
  // ── Write ~/.stackmeter/config.json ──────────────
364
307
  writeStackMeterConfig({
365
308
  token,
366
309
  url: apiUrl,
367
- gatewayPort,
368
310
  });
369
311
  console.log(` \u2705 Saved config to ${SM_CONFIG_PATH}`);
370
312
  console.log("");
371
313
 
372
- // ── Install gateway as persistent service ────────
314
+ // ── Install log watcher as persistent service ────
373
315
  if (isMac) {
374
- // Copy gateway to stable path so LaunchAgent survives npx cache eviction
375
- const gatewaySource = resolve(
376
- __dirname, "..", "src", "gateway", "server.mjs"
316
+ // Copy log watcher to stable path so LaunchAgent survives npx cache eviction
317
+ const watcherSource = resolve(
318
+ __dirname, "..", "src", "gateway", "log-watcher.mjs"
377
319
  );
378
- if (!existsSync(gatewaySource)) {
379
- console.log(` \u274C Gateway script not found at ${gatewaySource}`);
320
+ if (!existsSync(watcherSource)) {
321
+ console.log(` \u274C Log watcher script not found at ${watcherSource}`);
380
322
  rl.close();
381
323
  process.exit(1);
382
324
  }
383
325
 
384
- copyFileSync(gatewaySource, SM_GATEWAY_PATH);
326
+ copyFileSync(watcherSource, SM_GATEWAY_PATH);
385
327
  writeFileSync(
386
328
  SM_LAUNCHER_PATH,
387
329
  [
388
330
  "#!/usr/bin/env node",
389
- 'import { startGateway } from "./gateway.mjs";',
390
- "startGateway();",
331
+ 'import { startLogWatcher } from "./gateway.mjs";',
332
+ "startLogWatcher();",
333
+ // Keep process alive
334
+ "setInterval(() => {}, 60_000);",
391
335
  "",
392
336
  ].join("\n")
393
337
  );
@@ -395,20 +339,17 @@ async function connectOpenClaw() {
395
339
  installLaunchAgent(process.execPath, SM_LAUNCHER_PATH);
396
340
  loadLaunchAgent();
397
341
 
398
- console.log(" \u2705 Gateway installed as background service");
342
+ console.log(" \u2705 Log watcher installed as background service");
399
343
  console.log(` LaunchAgent: ${PLIST_PATH}`);
400
344
  console.log(` Logs: ${SM_GATEWAY_LOG}`);
401
345
  } else {
402
- // Non-macOS: start gateway in foreground
403
- console.log(" Starting gateway in foreground...");
404
- process.env.STACKMETER_URL = apiUrl;
405
- process.env.STACKMETER_TOKEN = token;
406
- if (!process.env.OPENAI_API_KEY)
407
- process.env.OPENAI_API_KEY = existingApiKey;
408
-
346
+ // Non-macOS: start log watcher in foreground
347
+ console.log(" Starting log watcher in foreground...");
409
348
  rl.close();
410
- const { startGateway } = await import("../src/gateway/server.mjs");
411
- await startGateway();
349
+ const { startLogWatcher } = await import("../src/gateway/log-watcher.mjs");
350
+ startLogWatcher({ smUrl: apiUrl, smToken: token });
351
+ // Keep process alive
352
+ setInterval(() => {}, 60_000);
412
353
  return;
413
354
  }
414
355
 
@@ -416,8 +357,9 @@ async function connectOpenClaw() {
416
357
  console.log(" \u2705 Done! OpenClaw is connected to StackMeter.");
417
358
  console.log("");
418
359
  console.log(
419
- " Just run OpenClaw normally \u2014 no extra flags needed."
360
+ " No changes to OpenClaw config needed \u2014 just run OpenClaw normally."
420
361
  );
362
+ console.log(" The watcher reads session files from ~/.openclaw/agents/");
421
363
  console.log(` View your usage at ${baseUrl}/app/ai-usage`);
422
364
  console.log("");
423
365
  rl.close();
@@ -425,30 +367,15 @@ async function connectOpenClaw() {
425
367
  // ── No --auto: Print manual steps ────────────────
426
368
  console.log(" \u2705 Token is valid. Follow these steps to complete setup:");
427
369
  console.log("");
428
- console.log(` 1. Edit ${OC_CONFIG_PATH}`);
429
- console.log(
430
- ' Add to the "env" section: "OPENAI_BASE_URL": "http://127.0.0.1:8787/v1"'
431
- );
432
- console.log(
433
- ' "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"'
434
- );
435
- console.log("");
436
- console.log(" 2. Set env vars:");
370
+ console.log(" 1. Set env vars:");
437
371
  console.log(` export STACKMETER_URL="${apiUrl}"`);
438
372
  console.log(` export STACKMETER_TOKEN="${token}"`);
439
- if (existingApiKey) {
440
- console.log(
441
- ` export OPENAI_API_KEY="${redactKey(existingApiKey)}"`
442
- );
443
- } else {
444
- console.log(' export OPENAI_API_KEY="sk-..." # your OpenAI key');
445
- }
446
373
  console.log("");
447
- console.log(" 3. Start the gateway:");
374
+ console.log(" 2. Start the log watcher:");
448
375
  console.log(" npx @stackmeter/cli gateway");
449
376
  console.log("");
450
377
  console.log(
451
- " 4. Start OpenClaw \u2014 traffic will flow through the gateway."
378
+ " 3. Run OpenClaw normally \u2014 the watcher reads its logs automatically."
452
379
  );
453
380
  console.log("");
454
381
  rl.close();
@@ -468,30 +395,7 @@ async function disconnectOpenClaw() {
468
395
  // ── Stop and remove LaunchAgent ────────────────────
469
396
  if (isMac) {
470
397
  unloadLaunchAgent();
471
- console.log(" \u2705 Gateway service stopped and removed");
472
- }
473
-
474
- // ── Restore openclaw.json from backup ──────────────
475
- if (existsSync(OC_BACKUP_PATH)) {
476
- copyFileSync(OC_BACKUP_PATH, OC_CONFIG_PATH);
477
- unlinkSync(OC_BACKUP_PATH);
478
- console.log(` \u2705 Restored ${OC_CONFIG_PATH} from backup`);
479
- } else {
480
- // No backup — just remove the env vars we set
481
- const { config: ocConfig } = readOpenClawConfig();
482
- let changed = false;
483
- if (ocConfig?.env?.OPENAI_BASE_URL?.includes("127.0.0.1")) {
484
- delete ocConfig.env.OPENAI_BASE_URL;
485
- changed = true;
486
- }
487
- if (ocConfig?.env?.ANTHROPIC_BASE_URL?.includes("127.0.0.1")) {
488
- delete ocConfig.env.ANTHROPIC_BASE_URL;
489
- changed = true;
490
- }
491
- if (changed) {
492
- writeFileSync(OC_CONFIG_PATH, JSON.stringify(ocConfig, null, 2) + "\n");
493
- console.log(` \u2705 Removed gateway env vars from ${OC_CONFIG_PATH}`);
494
- }
398
+ console.log(" \u2705 Log watcher service stopped and removed");
495
399
  }
496
400
 
497
401
  // ── Remove StackMeter config ───────────────────────
@@ -511,11 +415,13 @@ async function disconnectOpenClaw() {
511
415
  console.log("");
512
416
  }
513
417
 
514
- // ── gateway ──────────────────────────────────────────────
418
+ // ── gateway (now runs log watcher) ───────────────────────
515
419
 
516
420
  async function startGatewayCommand() {
517
- const { startGateway } = await import("../src/gateway/server.mjs");
518
- await startGateway();
421
+ const { startLogWatcher } = await import("../src/gateway/log-watcher.mjs");
422
+ startLogWatcher();
423
+ // Keep process alive
424
+ setInterval(() => {}, 60_000);
519
425
  }
520
426
 
521
427
  // ── gateway --self-test ──────────────────────────────────
@@ -525,17 +431,10 @@ async function selfTest() {
525
431
 
526
432
  // Read config from file or env
527
433
  const smConfig = readStackMeterConfig();
528
- const openaiKey =
529
- process.env.OPENAI_API_KEY ||
530
- (() => {
531
- const { config } = readOpenClawConfig();
532
- return config?.models?.providers?.openai?.apiKey;
533
- })();
534
434
  const smUrl = process.env.STACKMETER_URL || smConfig.url;
535
435
  const smToken = process.env.STACKMETER_TOKEN || smConfig.token;
536
436
 
537
437
  const missing = [];
538
- if (!openaiKey) missing.push("OPENAI_API_KEY");
539
438
  if (!smUrl) missing.push("STACKMETER_URL");
540
439
  if (!smToken) missing.push("STACKMETER_TOKEN");
541
440
  if (missing.length) {
@@ -549,89 +448,70 @@ async function selfTest() {
549
448
  process.exit(1);
550
449
  }
551
450
 
552
- // Set env vars so gateway picks them up
553
- if (!process.env.OPENAI_API_KEY) process.env.OPENAI_API_KEY = openaiKey;
554
- if (!process.env.STACKMETER_URL) process.env.STACKMETER_URL = smUrl;
555
- if (!process.env.STACKMETER_TOKEN) process.env.STACKMETER_TOKEN = smToken;
556
-
557
451
  console.log("");
558
- console.log(" \u{1F9EA} StackMeter Gateway \u2014 Self-test");
452
+ console.log(" \u{1F9EA} StackMeter \u2014 Self-test");
559
453
  console.log("");
560
454
 
561
- const { startGateway } = await import("../src/gateway/server.mjs");
562
-
563
- let emitStatus = 0;
564
- const server = await startGateway({
565
- port: 0, // OS-assigned port
566
- onEmit: (status) => {
567
- emitStatus = status;
568
- },
569
- });
570
-
571
- const addr = server.address();
572
- const gwUrl = `http://127.0.0.1:${addr.port}`;
573
-
574
- // 1/3 — Health check
575
- console.log(" 1/3 Health check...");
576
- const healthRes = await fetch(`${gwUrl}/health`);
577
- if (!healthRes.ok) {
578
- console.log(" \u274C Health check failed");
579
- server.close();
580
- process.exit(1);
581
- }
582
- console.log(" \u2705 Gateway healthy");
583
-
584
- // 2/3 — Send minimal completion through gateway
585
- console.log(
586
- " 2/3 Sending test completion (gpt-4o-mini, max_tokens=1)..."
587
- );
455
+ // 1/2 Test StackMeter API connectivity
456
+ console.log(" 1/2 Testing StackMeter API...");
588
457
  try {
589
- const compRes = await fetch(`${gwUrl}/v1/chat/completions`, {
458
+ const res = await fetch(smUrl, {
590
459
  method: "POST",
591
- headers: { "Content-Type": "application/json" },
460
+ headers: {
461
+ Authorization: `Bearer ${smToken}`,
462
+ "Content-Type": "application/json",
463
+ },
592
464
  body: JSON.stringify({
593
- model: "gpt-4o-mini",
594
- messages: [{ role: "user", content: "Hi" }],
595
- max_tokens: 1,
465
+ provider: "stackmeter",
466
+ model: "self-test",
467
+ inputTokens: 0,
468
+ outputTokens: 0,
469
+ costCents: 0,
470
+ sourceType: "openclaw",
471
+ sourceId: "cli-self-test",
472
+ dedupKey: `self-test-${Date.now()}`,
596
473
  }),
597
474
  });
598
475
 
599
- if (!compRes.ok) {
600
- const err = await compRes.text().catch(() => "");
601
- console.log(
602
- ` \u274C OpenAI returned ${compRes.status}: ${err.slice(0, 200)}`
603
- );
604
- server.close();
605
- process.exit(1);
476
+ if (res.ok) {
477
+ console.log(` \u2705 StackMeter API responded (${res.status})`);
478
+ } else {
479
+ const err = await res.text().catch(() => "");
480
+ console.log(` \u274C StackMeter API returned ${res.status}: ${err.slice(0, 200)}`);
606
481
  }
607
-
608
- const result = await compRes.json();
609
- console.log(
610
- ` \u2705 OpenAI response: ${result.usage?.total_tokens ?? "?"} tokens`
611
- );
612
482
  } catch (e) {
613
- console.log(` \u274C Request failed: ${e.message}`);
614
- server.close();
615
- process.exit(1);
483
+ console.log(` \u274C Could not reach StackMeter: ${e.message}`);
616
484
  }
617
485
 
618
- // 3/3Wait for async StackMeter emit
619
- console.log(" 3/3 Waiting for StackMeter ingestion...");
620
- await new Promise((r) => setTimeout(r, 2000));
486
+ // 2/2Check 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");
621
490
 
622
- if (emitStatus >= 200 && emitStatus < 300) {
623
- console.log(` \u2705 StackMeter ingestion returned ${emitStatus}`);
624
- } else if (emitStatus > 0) {
625
- console.log(
626
- ` \u26A0\uFE0F StackMeter ingestion returned ${emitStatus}`
491
+ if (existsSync(agentsDir)) {
492
+ const agents = readdirSync(agentsDir).filter(d =>
493
+ existsSync(join(agentsDir, d, "sessions"))
627
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
+ }
628
508
  } else {
629
- console.log(" \u26A0\uFE0F Could not confirm StackMeter ingestion");
509
+ console.log(` \u26A0\uFE0F No agents directory at ${agentsDir}`);
510
+ console.log(" Start OpenClaw first, then re-run this test.");
630
511
  }
631
512
 
632
513
  console.log("");
633
514
  console.log(" Self-test complete.");
634
515
  console.log("");
635
- server.close();
636
516
  process.exit(0);
637
517
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmeter/cli",
3
- "version": "0.1.3",
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": {
@@ -0,0 +1,285 @@
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.
3
+
4
+ import { readFileSync, existsSync, statSync, watchFile, unwatchFile, readdirSync } from "node:fs";
5
+ import { watch } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ /* ── Config ──────────────────────────────────────────────── */
10
+
11
+ function readStackMeterConfig() {
12
+ const p = join(homedir(), ".stackmeter", "config.json");
13
+ if (!existsSync(p)) return {};
14
+ try { return JSON.parse(readFileSync(p, "utf-8")); }
15
+ catch { return {}; }
16
+ }
17
+
18
+ function getOpenClawHome(smConfig = {}) {
19
+ return process.env.OPENCLAW_HOME || smConfig.openclawHome || join(homedir(), ".openclaw");
20
+ }
21
+
22
+ /* ── Emit usage event to StackMeter ──────────────────────── */
23
+
24
+ async function emitUsageEvent(event, smUrl, smToken) {
25
+ try {
26
+ const res = await fetch(smUrl, {
27
+ method: "POST",
28
+ headers: {
29
+ Authorization: `Bearer ${smToken}`,
30
+ "Content-Type": "application/json",
31
+ },
32
+ body: JSON.stringify(event),
33
+ });
34
+
35
+ if (res.ok) {
36
+ console.log(
37
+ ` [watcher] ${event.provider}/${event.model} ${event.inputTokens}+${event.outputTokens} tokens` +
38
+ ` \u2192 ${event.costCents.toFixed(4)}\u00A2 (${res.status})`
39
+ );
40
+ } else {
41
+ const err = await res.text().catch(() => "");
42
+ console.error(` [watcher] emit failed (${res.status}): ${err}`);
43
+ }
44
+ return res.status;
45
+ } catch (err) {
46
+ console.error(` [watcher] emit error: ${err.message}`);
47
+ return 0;
48
+ }
49
+ }
50
+
51
+ /* ── JSONL line parser ───────────────────────────────────── */
52
+
53
+ function parseSessionLine(line) {
54
+ try {
55
+ const obj = JSON.parse(line);
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
+ };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
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 ─────────────────────────────────────── */
109
+
110
+ export function startLogWatcher(opts = {}) {
111
+ const smConfig = readStackMeterConfig();
112
+ const smUrl = opts.smUrl || process.env.STACKMETER_URL || smConfig.url;
113
+ const smToken = opts.smToken || process.env.STACKMETER_TOKEN || smConfig.token;
114
+ const onEmit = opts.onEmit;
115
+
116
+ if (!smUrl) {
117
+ console.error(" Error: STACKMETER_URL is required.");
118
+ console.error(" Run: npx @stackmeter/cli connect openclaw --auto");
119
+ process.exit(1);
120
+ }
121
+ if (!smToken) {
122
+ console.error(" Error: STACKMETER_TOKEN is required.");
123
+ console.error(" Run: npx @stackmeter/cli connect openclaw --auto");
124
+ process.exit(1);
125
+ }
126
+
127
+ const ocHome = getOpenClawHome(smConfig);
128
+ const emittedKeys = new Set();
129
+
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 = [];
136
+
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;
141
+ }
142
+
143
+ function processNewBytes(filePath, agentName) {
144
+ if (!existsSync(filePath)) return;
145
+
146
+ const state = watchedFiles.get(filePath);
147
+ if (!state) return;
148
+
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");
158
+
159
+ // Last element might be a partial line (incomplete write)
160
+ state.partialLine = lines.pop() || "";
161
+
162
+ const sessionId = extractSessionId(filePath);
163
+
164
+ for (const line of lines) {
165
+ if (!line.trim()) continue;
166
+
167
+ const parsed = parseSessionLine(line);
168
+ if (!parsed) continue;
169
+
170
+ const dedupKey = `oc-${agentName}-${sessionId}-${parsed.messageId}`;
171
+ if (emittedKeys.has(dedupKey)) continue;
172
+ emittedKeys.add(dedupKey);
173
+
174
+ const event = {
175
+ provider: parsed.provider,
176
+ model: parsed.model,
177
+ inputTokens: parsed.inputTokens,
178
+ outputTokens: parsed.outputTokens,
179
+ costCents: parsed.costCents,
180
+ sourceType: "openclaw",
181
+ sourceId: `agent/${agentName}`,
182
+ ts: parsed.timestamp,
183
+ dedupKey,
184
+ };
185
+
186
+ const p = emitUsageEvent(event, smUrl, smToken);
187
+ if (onEmit) p.then(onEmit);
188
+ }
189
+
190
+ // Prune emittedKeys if it gets too large
191
+ if (emittedKeys.size > 100_000) {
192
+ emittedKeys.clear();
193
+ }
194
+ }
195
+
196
+ function startWatchingFile(filePath, agentName) {
197
+ if (watchedFiles.has(filePath)) return;
198
+
199
+ // Start from end of file (skip historical data)
200
+ let offset = 0;
201
+ if (existsSync(filePath)) {
202
+ offset = statSync(filePath).size;
203
+ }
204
+
205
+ watchedFiles.set(filePath, { offset, partialLine: "" });
206
+
207
+ watchFile(filePath, { interval: 2000 }, () => {
208
+ processNewBytes(filePath, agentName);
209
+ });
210
+ }
211
+
212
+ function startWatchingDir(agentName, sessionsDir) {
213
+ if (watchedDirs.has(sessionsDir)) return;
214
+ watchedDirs.add(sessionsDir);
215
+
216
+ // Watch existing session files
217
+ for (const filePath of listSessionFiles(sessionsDir)) {
218
+ startWatchingFile(filePath, agentName);
219
+ }
220
+
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);
228
+ }
229
+ });
230
+ dirWatchers.push(watcher);
231
+ } catch {
232
+ // fs.watch not available — rely on periodic scan below
233
+ }
234
+ }
235
+
236
+ function scanForAgents() {
237
+ const agents = discoverAgentDirs(ocHome);
238
+ for (const { agentName, sessionsDir } of agents) {
239
+ startWatchingDir(agentName, sessionsDir);
240
+ }
241
+ }
242
+
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
+ }
249
+ }
250
+ }
251
+
252
+ // Initial scan
253
+ scanForAgents();
254
+
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);
258
+
259
+ const agentDirs = discoverAgentDirs(ocHome);
260
+ const agentNames = agentDirs.map(a => a.agentName);
261
+
262
+ console.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
+ }
270
+ console.log(` Reporting: ${smUrl}`);
271
+ console.log("");
272
+
273
+ return {
274
+ stop() {
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
+ }
283
+ }
284
+ };
285
+ }
@@ -214,6 +214,8 @@ export function startGateway(opts = {}) {
214
214
  }
215
215
 
216
216
  const server = createServer(async (req, res) => {
217
+ console.log(` [gw] ${req.method} ${req.url}`);
218
+
217
219
  // Health check
218
220
  if (req.method === "GET" && req.url === "/health") {
219
221
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -222,12 +224,14 @@ export function startGateway(opts = {}) {
222
224
 
223
225
  // Only proxy /v1/* paths
224
226
  if (!req.url?.startsWith("/v1/")) {
227
+ console.log(` [gw] 404 — not a /v1/ path`);
225
228
  res.writeHead(404, { "Content-Type": "application/json" });
226
229
  return res.end('{"error":"Not found"}');
227
230
  }
228
231
 
229
232
  const provider = detectProvider(req.method, req.url);
230
233
  const isTracked = provider !== null;
234
+ console.log(` [gw] provider=${provider} tracked=${isTracked}`);
231
235
 
232
236
  // Read body for methods that have one
233
237
  let body = Buffer.alloc(0);
@@ -252,6 +256,7 @@ export function startGateway(opts = {}) {
252
256
  }
253
257
  } catch {}
254
258
  }
259
+ if (isTracked) console.log(` [gw] streaming=${isStreaming} bodyLen=${body.length}`);
255
260
 
256
261
  // Determine upstream host and build auth headers
257
262
  const upstreamHost = provider ? UPSTREAM[provider] : UPSTREAM.openai;