@stackmeter/cli 0.1.4 → 0.2.1
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 +40 -17
- package/package.json +1 -1
- package/src/gateway/log-watcher.mjs +177 -189
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";
|
|
@@ -18,6 +18,12 @@ const args = process.argv.slice(2);
|
|
|
18
18
|
const command = args[0];
|
|
19
19
|
const target = args[1];
|
|
20
20
|
|
|
21
|
+
function getFlag(name) {
|
|
22
|
+
const idx = args.indexOf(name);
|
|
23
|
+
if (idx === -1 || idx + 1 >= args.length) return null;
|
|
24
|
+
return args[idx + 1];
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
// ── Constants ────────────────────────────────────────────
|
|
22
28
|
|
|
23
29
|
const DEFAULT_BASE_URL = "https://stackmeter.app";
|
|
@@ -174,9 +180,12 @@ async function connectOpenClaw() {
|
|
|
174
180
|
console.log("");
|
|
175
181
|
|
|
176
182
|
// ── Step 1: Get StackMeter base URL ────────────────
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
const flagUrl = getFlag("--url");
|
|
184
|
+
let baseUrl = flagUrl
|
|
185
|
+
? flagUrl.replace(/\/api\/usage-events\/?$/, "")
|
|
186
|
+
: process.env.STACKMETER_URL
|
|
187
|
+
? process.env.STACKMETER_URL.replace(/\/api\/usage-events\/?$/, "")
|
|
188
|
+
: "";
|
|
180
189
|
|
|
181
190
|
if (!baseUrl) {
|
|
182
191
|
baseUrl = await rl.question(
|
|
@@ -184,17 +193,17 @@ async function connectOpenClaw() {
|
|
|
184
193
|
);
|
|
185
194
|
if (!baseUrl.trim()) baseUrl = DEFAULT_BASE_URL;
|
|
186
195
|
} else {
|
|
187
|
-
console.log(` Using
|
|
196
|
+
console.log(` Using URL: ${baseUrl}`);
|
|
188
197
|
}
|
|
189
198
|
baseUrl = baseUrl.replace(/\/+$/, "");
|
|
190
199
|
const apiUrl = `${baseUrl}/api/usage-events`;
|
|
191
200
|
|
|
192
201
|
// ── Step 2: Get or create tracking token ───────────
|
|
193
|
-
let token = process.env.STACKMETER_TOKEN || "";
|
|
202
|
+
let token = getFlag("--token") || process.env.STACKMETER_TOKEN || "";
|
|
194
203
|
|
|
195
204
|
if (token) {
|
|
196
205
|
console.log(
|
|
197
|
-
`
|
|
206
|
+
` Using token (sm_...${token.slice(-4)})`
|
|
198
207
|
);
|
|
199
208
|
} else {
|
|
200
209
|
console.log("");
|
|
@@ -298,7 +307,7 @@ async function connectOpenClaw() {
|
|
|
298
307
|
console.log(
|
|
299
308
|
" \u26A0\uFE0F OpenClaw config not found at ~/.openclaw/openclaw.json"
|
|
300
309
|
);
|
|
301
|
-
console.log(" The
|
|
310
|
+
console.log(" The watcher will start anyway and wait for OpenClaw session files.");
|
|
302
311
|
console.log("");
|
|
303
312
|
}
|
|
304
313
|
|
|
@@ -359,7 +368,7 @@ async function connectOpenClaw() {
|
|
|
359
368
|
console.log(
|
|
360
369
|
" No changes to OpenClaw config needed \u2014 just run OpenClaw normally."
|
|
361
370
|
);
|
|
362
|
-
console.log(" The
|
|
371
|
+
console.log(" The watcher reads session files from ~/.openclaw/agents/");
|
|
363
372
|
console.log(` View your usage at ${baseUrl}/app/ai-usage`);
|
|
364
373
|
console.log("");
|
|
365
374
|
rl.close();
|
|
@@ -483,16 +492,30 @@ async function selfTest() {
|
|
|
483
492
|
console.log(` \u274C Could not reach StackMeter: ${e.message}`);
|
|
484
493
|
}
|
|
485
494
|
|
|
486
|
-
// 2/2 — Check OpenClaw
|
|
487
|
-
console.log(" 2/2 Checking OpenClaw
|
|
488
|
-
const
|
|
489
|
-
const
|
|
490
|
-
const logPath = `/tmp/openclaw/openclaw-${ymd}.log`;
|
|
495
|
+
// 2/2 — Check OpenClaw session files
|
|
496
|
+
console.log(" 2/2 Checking OpenClaw sessions...");
|
|
497
|
+
const ocHome = process.env.OPENCLAW_STATE_DIR || process.env.CLAWDBOT_STATE_DIR || process.env.OPENCLAW_HOME || join(homedir(), ".openclaw");
|
|
498
|
+
const agentsDir = join(ocHome, "agents");
|
|
491
499
|
|
|
492
|
-
if (existsSync(
|
|
493
|
-
|
|
500
|
+
if (existsSync(agentsDir)) {
|
|
501
|
+
const agents = readdirSync(agentsDir).filter(d =>
|
|
502
|
+
existsSync(join(agentsDir, d, "sessions"))
|
|
503
|
+
);
|
|
504
|
+
if (agents.length > 0) {
|
|
505
|
+
console.log(` \u2705 Found ${agents.length} agent(s): ${agents.join(", ")}`);
|
|
506
|
+
let totalSessions = 0;
|
|
507
|
+
for (const agent of agents) {
|
|
508
|
+
const sessDir = join(agentsDir, agent, "sessions");
|
|
509
|
+
const files = readdirSync(sessDir).filter(f => f.endsWith(".jsonl"));
|
|
510
|
+
totalSessions += files.length;
|
|
511
|
+
}
|
|
512
|
+
console.log(` ${totalSessions} session file(s) total`);
|
|
513
|
+
} else {
|
|
514
|
+
console.log(` \u26A0\uFE0F ${agentsDir} exists but no agents with sessions found`);
|
|
515
|
+
console.log(" Start OpenClaw first, then re-run this test.");
|
|
516
|
+
}
|
|
494
517
|
} else {
|
|
495
|
-
console.log(` \u26A0\uFE0F No
|
|
518
|
+
console.log(` \u26A0\uFE0F No agents directory at ${agentsDir}`);
|
|
496
519
|
console.log(" Start OpenClaw first, then re-run this test.");
|
|
497
520
|
}
|
|
498
521
|
|
package/package.json
CHANGED
|
@@ -1,81 +1,11 @@
|
|
|
1
|
-
// StackMeter
|
|
2
|
-
//
|
|
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,16 @@ function readStackMeterConfig() {
|
|
|
85
15
|
catch { return {}; }
|
|
86
16
|
}
|
|
87
17
|
|
|
18
|
+
function getOpenClawHome(smConfig = {}) {
|
|
19
|
+
return (
|
|
20
|
+
process.env.OPENCLAW_STATE_DIR ||
|
|
21
|
+
process.env.CLAWDBOT_STATE_DIR ||
|
|
22
|
+
process.env.OPENCLAW_HOME ||
|
|
23
|
+
smConfig.openclawHome ||
|
|
24
|
+
join(homedir(), ".openclaw")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
88
28
|
/* ── Emit usage event to StackMeter ──────────────────────── */
|
|
89
29
|
|
|
90
30
|
async function emitUsageEvent(event, smUrl, smToken) {
|
|
@@ -100,8 +40,8 @@ async function emitUsageEvent(event, smUrl, smToken) {
|
|
|
100
40
|
|
|
101
41
|
if (res.ok) {
|
|
102
42
|
console.log(
|
|
103
|
-
` [watcher] ${event.provider}/${event.model}
|
|
104
|
-
` \u2192 ${event.costCents}\u00A2 (${res.status})`
|
|
43
|
+
` [watcher] ${event.provider}/${event.model} ${event.inputTokens}+${event.outputTokens} tokens` +
|
|
44
|
+
` \u2192 ${event.costCents.toFixed(4)}\u00A2 (${res.status})`
|
|
105
45
|
);
|
|
106
46
|
} else {
|
|
107
47
|
const err = await res.text().catch(() => "");
|
|
@@ -114,21 +54,64 @@ async function emitUsageEvent(event, smUrl, smToken) {
|
|
|
114
54
|
}
|
|
115
55
|
}
|
|
116
56
|
|
|
117
|
-
/* ──
|
|
57
|
+
/* ── JSONL line parser ───────────────────────────────────── */
|
|
118
58
|
|
|
119
|
-
function
|
|
59
|
+
function parseSessionLine(line) {
|
|
120
60
|
try {
|
|
121
61
|
const obj = JSON.parse(line);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
62
|
+
|
|
63
|
+
// Only process assistant messages with usage data
|
|
64
|
+
if (obj.type !== "message") return null;
|
|
65
|
+
const msg = obj.message;
|
|
66
|
+
if (!msg || msg.role !== "assistant" || !msg.usage) return null;
|
|
67
|
+
|
|
68
|
+
const usage = msg.usage;
|
|
69
|
+
const cost = usage.cost || {};
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
messageId: obj.id,
|
|
73
|
+
timestamp: obj.timestamp || new Date().toISOString(),
|
|
74
|
+
provider: msg.provider || "unknown",
|
|
75
|
+
model: msg.model || "unknown",
|
|
76
|
+
inputTokens: (usage.input || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0),
|
|
77
|
+
outputTokens: usage.output || 0,
|
|
78
|
+
costCents: (cost.total || 0) * 100, // dollars → cents
|
|
79
|
+
};
|
|
126
80
|
} catch {
|
|
127
81
|
return null;
|
|
128
82
|
}
|
|
129
83
|
}
|
|
130
84
|
|
|
131
|
-
/* ──
|
|
85
|
+
/* ── Directory discovery ─────────────────────────────────── */
|
|
86
|
+
|
|
87
|
+
function discoverAgentDirs(ocHome) {
|
|
88
|
+
const agentsDir = join(ocHome, "agents");
|
|
89
|
+
if (!existsSync(agentsDir)) return [];
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return readdirSync(agentsDir, { withFileTypes: true })
|
|
93
|
+
.filter(d => d.isDirectory())
|
|
94
|
+
.map(d => ({
|
|
95
|
+
agentName: d.name,
|
|
96
|
+
sessionsDir: join(agentsDir, d.name, "sessions"),
|
|
97
|
+
}))
|
|
98
|
+
.filter(a => existsSync(a.sessionsDir));
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function listSessionFiles(sessionsDir) {
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(sessionsDir)
|
|
107
|
+
.filter(f => f.endsWith(".jsonl"))
|
|
108
|
+
.map(f => join(sessionsDir, f));
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ── Session watcher ─────────────────────────────────────── */
|
|
132
115
|
|
|
133
116
|
export function startLogWatcher(opts = {}) {
|
|
134
117
|
const smConfig = readStackMeterConfig();
|
|
@@ -147,157 +130,162 @@ export function startLogWatcher(opts = {}) {
|
|
|
147
130
|
process.exit(1);
|
|
148
131
|
}
|
|
149
132
|
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
// Track emitted dedupKeys to avoid re-emitting on file re-read
|
|
153
|
-
const emittedRuns = new Set();
|
|
133
|
+
const ocHome = getOpenClawHome(smConfig);
|
|
134
|
+
const emittedKeys = new Set();
|
|
154
135
|
|
|
155
|
-
|
|
156
|
-
|
|
136
|
+
// Track watched files: filePath -> { offset, partialLine }
|
|
137
|
+
const watchedFiles = new Map();
|
|
138
|
+
// Track watched directories
|
|
139
|
+
const watchedDirs = new Set();
|
|
140
|
+
// fs.watch handles for cleanup
|
|
141
|
+
const dirWatchers = [];
|
|
157
142
|
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
return
|
|
143
|
+
function extractSessionId(filePath) {
|
|
144
|
+
// /path/to/sessions/136ae359-fceb-4464-9d5a-9e7c2a15cdf7.jsonl → 136ae359
|
|
145
|
+
const base = filePath.split("/").pop().replace(".jsonl", "");
|
|
146
|
+
return base.split("-")[0] || base;
|
|
162
147
|
}
|
|
163
148
|
|
|
164
|
-
function
|
|
165
|
-
if (!
|
|
166
|
-
const parsed = parseLogLine(line);
|
|
167
|
-
if (!parsed) return;
|
|
149
|
+
function processNewBytes(filePath, agentName) {
|
|
150
|
+
if (!existsSync(filePath)) return;
|
|
168
151
|
|
|
169
|
-
const
|
|
152
|
+
const state = watchedFiles.get(filePath);
|
|
153
|
+
if (!state) return;
|
|
170
154
|
|
|
171
|
-
|
|
172
|
-
if (
|
|
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
|
-
}
|
|
155
|
+
const stat = statSync(filePath);
|
|
156
|
+
if (stat.size <= state.offset) return;
|
|
181
157
|
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
158
|
+
// Read only new bytes
|
|
159
|
+
const content = readFileSync(filePath, "utf-8");
|
|
160
|
+
const newContent = (state.partialLine || "") + content.slice(state.offset);
|
|
161
|
+
state.offset = content.length;
|
|
162
|
+
|
|
163
|
+
const lines = newContent.split("\n");
|
|
187
164
|
|
|
188
|
-
|
|
189
|
-
|
|
165
|
+
// Last element might be a partial line (incomplete write)
|
|
166
|
+
state.partialLine = lines.pop() || "";
|
|
190
167
|
|
|
191
|
-
|
|
192
|
-
if (!run) return;
|
|
168
|
+
const sessionId = extractSessionId(filePath);
|
|
193
169
|
|
|
194
|
-
|
|
195
|
-
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
if (!line.trim()) continue;
|
|
196
172
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
173
|
+
const parsed = parseSessionLine(line);
|
|
174
|
+
if (!parsed) continue;
|
|
175
|
+
|
|
176
|
+
const dedupKey = `oc-${agentName}-${sessionId}-${parsed.messageId}`;
|
|
177
|
+
if (emittedKeys.has(dedupKey)) continue;
|
|
178
|
+
emittedKeys.add(dedupKey);
|
|
200
179
|
|
|
201
180
|
const event = {
|
|
202
|
-
provider:
|
|
203
|
-
model:
|
|
204
|
-
inputTokens,
|
|
205
|
-
outputTokens,
|
|
206
|
-
costCents,
|
|
181
|
+
provider: parsed.provider,
|
|
182
|
+
model: parsed.model,
|
|
183
|
+
inputTokens: parsed.inputTokens,
|
|
184
|
+
outputTokens: parsed.outputTokens,
|
|
185
|
+
costCents: parsed.costCents,
|
|
207
186
|
sourceType: "openclaw",
|
|
208
|
-
sourceId:
|
|
209
|
-
ts:
|
|
210
|
-
dedupKey
|
|
187
|
+
sourceId: `agent/${agentName}`,
|
|
188
|
+
ts: parsed.timestamp,
|
|
189
|
+
dedupKey,
|
|
211
190
|
};
|
|
212
191
|
|
|
213
192
|
const p = emitUsageEvent(event, smUrl, smToken);
|
|
214
193
|
if (onEmit) p.then(onEmit);
|
|
215
|
-
return;
|
|
216
194
|
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function readNewLines() {
|
|
220
|
-
const logPath = getLogPath();
|
|
221
195
|
|
|
222
|
-
//
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
currentLogPath = logPath;
|
|
228
|
-
fileOffset = 0;
|
|
196
|
+
// Prune emittedKeys if it gets too large
|
|
197
|
+
if (emittedKeys.size > 100_000) {
|
|
198
|
+
emittedKeys.clear();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
229
201
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const stat = statSync(logPath);
|
|
233
|
-
fileOffset = stat.size;
|
|
234
|
-
}
|
|
202
|
+
function startWatchingFile(filePath, agentName) {
|
|
203
|
+
if (watchedFiles.has(filePath)) return;
|
|
235
204
|
|
|
236
|
-
|
|
237
|
-
|
|
205
|
+
// Start from end of file (skip historical data)
|
|
206
|
+
let offset = 0;
|
|
207
|
+
if (existsSync(filePath)) {
|
|
208
|
+
offset = statSync(filePath).size;
|
|
238
209
|
}
|
|
239
210
|
|
|
240
|
-
|
|
211
|
+
watchedFiles.set(filePath, { offset, partialLine: "" });
|
|
241
212
|
|
|
242
|
-
|
|
243
|
-
|
|
213
|
+
watchFile(filePath, { interval: 2000 }, () => {
|
|
214
|
+
processNewBytes(filePath, agentName);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
244
217
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
fileOffset = fd.length;
|
|
218
|
+
function startWatchingDir(agentName, sessionsDir) {
|
|
219
|
+
if (watchedDirs.has(sessionsDir)) return;
|
|
220
|
+
watchedDirs.add(sessionsDir);
|
|
249
221
|
|
|
250
|
-
|
|
251
|
-
for (const
|
|
252
|
-
|
|
222
|
+
// Watch existing session files
|
|
223
|
+
for (const filePath of listSessionFiles(sessionsDir)) {
|
|
224
|
+
startWatchingFile(filePath, agentName);
|
|
253
225
|
}
|
|
254
|
-
}
|
|
255
226
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
227
|
+
// Watch for new session files
|
|
228
|
+
try {
|
|
229
|
+
const watcher = watch(sessionsDir, (eventType, filename) => {
|
|
230
|
+
if (!filename || !filename.endsWith(".jsonl")) return;
|
|
231
|
+
const filePath = join(sessionsDir, filename);
|
|
232
|
+
if (existsSync(filePath)) {
|
|
233
|
+
startWatchingFile(filePath, agentName);
|
|
263
234
|
}
|
|
264
|
-
}
|
|
265
|
-
|
|
235
|
+
});
|
|
236
|
+
dirWatchers.push(watcher);
|
|
237
|
+
} catch {
|
|
238
|
+
// fs.watch not available — rely on periodic scan below
|
|
266
239
|
}
|
|
240
|
+
}
|
|
267
241
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
})
|
|
242
|
+
function scanForAgents() {
|
|
243
|
+
const agents = discoverAgentDirs(ocHome);
|
|
244
|
+
for (const { agentName, sessionsDir } of agents) {
|
|
245
|
+
startWatchingDir(agentName, sessionsDir);
|
|
246
|
+
}
|
|
271
247
|
}
|
|
272
248
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
249
|
+
function scanForNewFiles() {
|
|
250
|
+
for (const sessionsDir of watchedDirs) {
|
|
251
|
+
const agentName = sessionsDir.split("/").at(-2) || "unknown";
|
|
252
|
+
for (const filePath of listSessionFiles(sessionsDir)) {
|
|
253
|
+
startWatchingFile(filePath, agentName);
|
|
254
|
+
}
|
|
278
255
|
}
|
|
279
|
-
}
|
|
256
|
+
}
|
|
280
257
|
|
|
281
|
-
//
|
|
282
|
-
|
|
258
|
+
// Initial scan
|
|
259
|
+
scanForAgents();
|
|
283
260
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
261
|
+
// Periodic scans: new agents every 30s, new session files every 10s
|
|
262
|
+
const agentScanInterval = setInterval(scanForAgents, 30_000);
|
|
263
|
+
const fileScanInterval = setInterval(scanForNewFiles, 10_000);
|
|
288
264
|
|
|
289
|
-
|
|
265
|
+
const agentDirs = discoverAgentDirs(ocHome);
|
|
266
|
+
const agentNames = agentDirs.map(a => a.agentName);
|
|
290
267
|
|
|
291
268
|
console.log("");
|
|
292
|
-
console.log(" StackMeter
|
|
293
|
-
console.log(` Watching: ${
|
|
269
|
+
console.log(" StackMeter Session Watcher");
|
|
270
|
+
console.log(` Watching: ${ocHome}/agents/*/sessions/*.jsonl`);
|
|
271
|
+
if (agentNames.length > 0) {
|
|
272
|
+
console.log(` Agents: ${agentNames.join(", ")}`);
|
|
273
|
+
} else {
|
|
274
|
+
console.log(" Agents: (none found yet — waiting for OpenClaw sessions)");
|
|
275
|
+
}
|
|
294
276
|
console.log(` Reporting: ${smUrl}`);
|
|
295
277
|
console.log("");
|
|
296
278
|
|
|
297
|
-
// Return a cleanup function
|
|
298
279
|
return {
|
|
299
280
|
stop() {
|
|
300
|
-
|
|
281
|
+
clearInterval(agentScanInterval);
|
|
282
|
+
clearInterval(fileScanInterval);
|
|
283
|
+
for (const [filePath] of watchedFiles) {
|
|
284
|
+
unwatchFile(filePath);
|
|
285
|
+
}
|
|
286
|
+
for (const watcher of dirWatchers) {
|
|
287
|
+
watcher.close();
|
|
288
|
+
}
|
|
301
289
|
}
|
|
302
290
|
};
|
|
303
291
|
}
|