@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.
- package/bin/stackmeter.mjs +79 -199
- package/package.json +1 -1
- package/src/gateway/log-watcher.mjs +285 -0
- package/src/gateway/server.mjs +5 -0
package/bin/stackmeter.mjs
CHANGED
|
@@ -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:
|
|
295
|
+
// ── Step 4: Verify OpenClaw is installed ─────────────
|
|
303
296
|
const { config: ocConfig } = readOpenClawConfig();
|
|
304
|
-
|
|
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
|
-
"
|
|
299
|
+
" \u26A0\uFE0F OpenClaw config not found at ~/.openclaw/openclaw.json"
|
|
313
300
|
);
|
|
314
|
-
console.log("
|
|
315
|
-
|
|
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
|
|
314
|
+
// ── Install log watcher as persistent service ────
|
|
373
315
|
if (isMac) {
|
|
374
|
-
// Copy
|
|
375
|
-
const
|
|
376
|
-
__dirname, "..", "src", "gateway", "
|
|
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(
|
|
379
|
-
console.log(` \u274C
|
|
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(
|
|
326
|
+
copyFileSync(watcherSource, SM_GATEWAY_PATH);
|
|
385
327
|
writeFileSync(
|
|
386
328
|
SM_LAUNCHER_PATH,
|
|
387
329
|
[
|
|
388
330
|
"#!/usr/bin/env node",
|
|
389
|
-
'import {
|
|
390
|
-
"
|
|
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
|
|
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
|
|
403
|
-
console.log(" Starting
|
|
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 {
|
|
411
|
-
|
|
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
|
-
"
|
|
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(
|
|
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("
|
|
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
|
-
"
|
|
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
|
|
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 {
|
|
518
|
-
|
|
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
|
|
452
|
+
console.log(" \u{1F9EA} StackMeter \u2014 Self-test");
|
|
559
453
|
console.log("");
|
|
560
454
|
|
|
561
|
-
|
|
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
|
|
458
|
+
const res = await fetch(smUrl, {
|
|
590
459
|
method: "POST",
|
|
591
|
-
headers: {
|
|
460
|
+
headers: {
|
|
461
|
+
Authorization: `Bearer ${smToken}`,
|
|
462
|
+
"Content-Type": "application/json",
|
|
463
|
+
},
|
|
592
464
|
body: JSON.stringify({
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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 (
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
614
|
-
server.close();
|
|
615
|
-
process.exit(1);
|
|
483
|
+
console.log(` \u274C Could not reach StackMeter: ${e.message}`);
|
|
616
484
|
}
|
|
617
485
|
|
|
618
|
-
//
|
|
619
|
-
console.log("
|
|
620
|
-
|
|
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");
|
|
621
490
|
|
|
622
|
-
if (
|
|
623
|
-
|
|
624
|
-
|
|
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(
|
|
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
|
@@ -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
|
+
}
|
package/src/gateway/server.mjs
CHANGED
|
@@ -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;
|