agentmetrics-openclaw 0.2.1 → 0.2.4
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/README.md +88 -46
- package/index.ts +703 -92
- package/openclaw.plugin.json +50 -2
- package/package.json +40 -27
package/README.md
CHANGED
|
@@ -1,76 +1,118 @@
|
|
|
1
1
|
# agentmetrics-openclaw
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/agentmetrics-openclaw)
|
|
4
|
+
[](../LICENSE)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
AgentMetrics integration for [OpenClaw](https://openclaw.dev). Install the plugin, point it at your server, and every agent session reports back to your dashboard automatically showing tokens, cost, tools, subagents, context health, and reliability, all without changing your agent code.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
---
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|---|---|
|
|
11
|
-
| Status (success / failed) | `agent_end.success` |
|
|
12
|
-
| Duration | `agent_end.durationMs` |
|
|
13
|
-
| Model & provider | `llm_output` |
|
|
14
|
-
| Input tokens | `llm_output.usage.input` |
|
|
15
|
-
| Output tokens | `llm_output.usage.output` |
|
|
16
|
-
| Cache read tokens | `llm_output.usage.cacheRead` |
|
|
17
|
-
| Cache write tokens | `llm_output.usage.cacheWrite` |
|
|
18
|
-
| Tool call count | `after_tool_call` (counted) |
|
|
19
|
-
| Tool error count | `after_tool_call.error` (counted) |
|
|
20
|
-
| Step count | `agent_end.messages.length` |
|
|
21
|
-
| Error message | `agent_end.error` |
|
|
22
|
-
|
|
23
|
-
All token buckets are accumulated across the full run — multi-turn, multi-LLM-call runs are handled correctly.
|
|
10
|
+
## Requirements
|
|
24
11
|
|
|
25
|
-
|
|
12
|
+
- OpenClaw 2026.3.2 or later
|
|
13
|
+
- Node.js 22 or later
|
|
14
|
+
- A running AgentMetrics server (see the [main README](../../../README.md) for setup)
|
|
15
|
+
|
|
16
|
+
---
|
|
26
17
|
|
|
27
|
-
|
|
18
|
+
## Install
|
|
28
19
|
|
|
29
|
-
```
|
|
20
|
+
```bash
|
|
30
21
|
openclaw plugins install agentmetrics-openclaw
|
|
31
22
|
```
|
|
32
23
|
|
|
33
|
-
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Setup
|
|
27
|
+
|
|
28
|
+
**1. Start AgentMetrics** (if not already running)
|
|
34
29
|
|
|
35
|
-
```
|
|
36
|
-
|
|
30
|
+
```bash
|
|
31
|
+
# Docker
|
|
32
|
+
docker compose up
|
|
33
|
+
|
|
34
|
+
# Or Python CLI
|
|
35
|
+
pip install agentmetrics
|
|
36
|
+
agentmetrics dashboard
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
**2. Set the server URL** (if not running on localhost)
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
```bash
|
|
42
|
+
# macOS / Linux, permanent
|
|
43
|
+
echo 'export AGENTMETRICS_BASE_URL=http://your-server:8099' >> ~/.bashrc && source ~/.bashrc
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
# Windows (PowerShell)
|
|
46
|
+
$Env:AGENTMETRICS_BASE_URL = "http://your-server:8099"
|
|
45
47
|
```
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
Omit this step if your server runs on `http://localhost:8099` (the default).
|
|
50
|
+
|
|
51
|
+
**3. Trust the plugin** (silences the security scan advisory)
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
```bash
|
|
54
|
+
openclaw config set plugins.allow '["agentmetrics"]'
|
|
55
|
+
```
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
**4. Restart the gateway**
|
|
52
58
|
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
agentmetrics:
|
|
56
|
-
apiKey: am_your_key_here
|
|
57
|
-
endpoint: https://api.agentmetrics.dev # optional
|
|
59
|
+
```bash
|
|
60
|
+
openclaw gateway restart
|
|
58
61
|
```
|
|
59
62
|
|
|
60
|
-
|
|
63
|
+
**5. Verify**
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
openclaw plugins list
|
|
67
|
+
# agentmetrics loaded
|
|
68
|
+
```
|
|
61
69
|
|
|
62
|
-
|
|
70
|
+
**6. Set your agent name** (recommended)
|
|
63
71
|
|
|
64
|
-
|
|
72
|
+
In your agent's `openclaw.json`:
|
|
65
73
|
|
|
66
|
-
```
|
|
67
|
-
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"name": "my-agent"
|
|
77
|
+
}
|
|
68
78
|
```
|
|
69
79
|
|
|
70
|
-
|
|
80
|
+
The `name` field becomes the agent ID in your dashboard. Give each agent a distinct name.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## What gets tracked
|
|
85
|
+
|
|
86
|
+
Every agent session reports automatically:
|
|
87
|
+
|
|
88
|
+
| Signal | Detail |
|
|
89
|
+
|---|---|
|
|
90
|
+
| **Cost** | Computed from token counts and model pricing |
|
|
91
|
+
| **Latency** | Wall-clock duration per run |
|
|
92
|
+
| **Tokens** | Input, output, cache read, cache write |
|
|
93
|
+
| **Tools** | Call count, errors, per-tool duration |
|
|
94
|
+
| **Subagents** | Spawned count, error count |
|
|
95
|
+
| **Context health** | Compaction count, reset count |
|
|
96
|
+
| **Reliability** | Success/failure, full error message |
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Troubleshooting
|
|
101
|
+
|
|
102
|
+
**"dangerous code patterns" warning on install**
|
|
103
|
+
Safe to ignore. The plugin reads `AGENTMETRICS_BASE_URL` and makes network calls to the AgentMetrics API. Add `agentmetrics` to `plugins.allow` to suppress it permanently.
|
|
104
|
+
|
|
105
|
+
**"manifest id does not match package name" warning**
|
|
106
|
+
Not an error. The plugin's internal manifest id is `agentmetrics`; the npm package name is `agentmetrics-openclaw`. Use `agentmetrics` (not the npm name) in `plugins.allow`.
|
|
107
|
+
|
|
108
|
+
**Runs not appearing in the dashboard**
|
|
109
|
+
1. Verify the plugin loads: `openclaw plugins list`
|
|
110
|
+
2. Check the server is reachable: `openclaw agentmetrics test`
|
|
111
|
+
3. Restart the gateway after any env var change
|
|
112
|
+
4. Confirm your `openclaw.json` has a `name` field
|
|
113
|
+
|
|
114
|
+
---
|
|
71
115
|
|
|
72
|
-
##
|
|
116
|
+
## License
|
|
73
117
|
|
|
74
|
-
|
|
75
|
-
- Docs: [agentmetrics.dev/docs](https://agentmetrics.dev/docs)
|
|
76
|
-
- GitHub: [github.com/andausman/agentmetrics](https://github.com/andausman/agentmetrics)
|
|
118
|
+
[MIT](../LICENSE)
|
package/index.ts
CHANGED
|
@@ -1,10 +1,111 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { gzipSync } from "zlib";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
function _hashName(name: string): string {
|
|
6
|
+
let h = 0x811c9dc5;
|
|
7
|
+
for (let i = 0; i < name.length; i++) {
|
|
8
|
+
h = (Math.imul(h ^ name.charCodeAt(i), 0x01000193)) >>> 0;
|
|
9
|
+
}
|
|
10
|
+
return `t_${h.toString(16).padStart(8, "0")}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const _SECRET_PATTERNS: RegExp[] = [
|
|
14
|
+
/sk-[A-Za-z0-9\-_]{20,}/g,
|
|
15
|
+
/am_[A-Za-z0-9\-_]{16,}/g,
|
|
16
|
+
/\bey[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}/g,
|
|
17
|
+
/(?:api[_\-]?key|apikey|api[_\-]?token|access[_\-]?token|secret|password|passwd|auth)[=:\s"']+([^\s"'&,\]}\n]{8,})/gi,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function _scrubSecrets(str: string): string {
|
|
21
|
+
let out = str;
|
|
22
|
+
for (const re of _SECRET_PATTERNS) {
|
|
23
|
+
out = out.replace(re, "[REDACTED]");
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const _PRICING: Record<string, [number, number, number?, number?]> = {
|
|
29
|
+
"claude-opus-4-7": [15.0, 75.0, 1.50, 18.75],
|
|
30
|
+
"claude-opus-4-5": [15.0, 75.0, 1.50, 18.75],
|
|
31
|
+
"claude-opus-4": [15.0, 75.0, 1.50, 18.75],
|
|
32
|
+
"claude-sonnet-4-6": [ 3.0, 15.0, 0.30, 3.75],
|
|
33
|
+
"claude-sonnet-4-5": [ 3.0, 15.0, 0.30, 3.75],
|
|
34
|
+
"claude-haiku-4-5": [ 0.8, 4.0, 0.08, 1.00],
|
|
35
|
+
"claude-sonnet-3-7": [ 3.0, 15.0, 0.30, 3.75],
|
|
36
|
+
"claude-3-5-sonnet-20241022": [ 3.0, 15.0, 0.30, 3.75],
|
|
37
|
+
"claude-3-5-sonnet-20240620": [ 3.0, 15.0, 0.30, 3.75],
|
|
38
|
+
"claude-3-5-haiku-20241022": [ 0.8, 4.0, 0.08, 1.00],
|
|
39
|
+
"claude-3-opus-20240229": [15.0, 75.0, 1.50, 18.75],
|
|
40
|
+
"claude-3-sonnet-20240229": [ 3.0, 15.0],
|
|
41
|
+
"claude-3-haiku-20240307": [ 0.25, 1.25, 0.03, 0.30],
|
|
42
|
+
"gpt-4o": [ 2.5, 10.0],
|
|
43
|
+
"gpt-4o-mini": [ 0.15, 0.60],
|
|
44
|
+
"gpt-4-turbo": [10.0, 30.0],
|
|
45
|
+
"gpt-4": [30.0, 60.0],
|
|
46
|
+
"gpt-3.5-turbo": [ 0.50, 1.50],
|
|
47
|
+
"gemini-2.0-flash": [ 0.075, 0.30],
|
|
48
|
+
"gemini-2.5-pro": [ 1.25, 10.0],
|
|
49
|
+
"gemini-1.5-pro": [ 1.25, 5.0],
|
|
50
|
+
"gemini-1.5-flash": [ 0.075, 0.30],
|
|
51
|
+
};
|
|
52
|
+
|
|
2
53
|
|
|
3
|
-
// Resolved once in register() — pluginConfig overrides env vars
|
|
4
54
|
let API_KEY: string | undefined;
|
|
5
55
|
let BASE_URL: string;
|
|
6
56
|
|
|
7
|
-
|
|
57
|
+
let ENABLED = true;
|
|
58
|
+
let REDACTION_MODE: "strict" | "moderate" | "debug" = "strict";
|
|
59
|
+
let EXPORTED_TOOL_NAMES: "allowlist" | "blocklist" | "hash" | "off" = "blocklist";
|
|
60
|
+
let REDACT_TOOL_NAMES: string[] = [];
|
|
61
|
+
let DEBUG_EXPIRES_AT: number | null = null;
|
|
62
|
+
|
|
63
|
+
let FLUSH_INTERVAL_MS = 10_000;
|
|
64
|
+
let MAX_BATCH_SIZE = 100;
|
|
65
|
+
let MAX_QUEUE_SIZE = 10_000;
|
|
66
|
+
let RETRY_MAX_ATTEMPTS = 5;
|
|
67
|
+
let COMPRESS_PAYLOADS = false;
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
const _metrics = { sent: 0, failed: 0, dropped: 0 };
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
interface QueuedEvent {
|
|
74
|
+
payload: Record<string, unknown>;
|
|
75
|
+
attempt: number;
|
|
76
|
+
enqueuedAt: number;
|
|
77
|
+
}
|
|
78
|
+
const _queue: QueuedEvent[] = [];
|
|
79
|
+
const _dlq: QueuedEvent[] = [];
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
const CB_THRESHOLD = 10;
|
|
83
|
+
const CB_PROBE_MS = 5 * 60_000;
|
|
84
|
+
type CbState = "closed" | "open" | "half-open";
|
|
85
|
+
let _cbState: CbState = "closed";
|
|
86
|
+
let _cbConsecFails = 0;
|
|
87
|
+
let _cbOpenAt: number | null = null;
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
let WAL_PATH: string | null = null;
|
|
91
|
+
let _flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
92
|
+
|
|
93
|
+
// Duplicate-registration guard
|
|
94
|
+
let _registered = false;
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
interface PluginApi {
|
|
98
|
+
config: Record<string, unknown>;
|
|
99
|
+
pluginConfig?: Record<string, unknown>;
|
|
100
|
+
registerAutoEnableProbe?: (probe: () => boolean) => void;
|
|
101
|
+
registerCli?: (registrar: {
|
|
102
|
+
name: string;
|
|
103
|
+
description: string;
|
|
104
|
+
commands: Array<{ name: string; description: string; handler: () => void | Promise<void> }>;
|
|
105
|
+
}) => void;
|
|
106
|
+
on: (hookName: string, handler: (...args: unknown[]) => void) => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
8
109
|
|
|
9
110
|
type AgentContext = {
|
|
10
111
|
runId?: string;
|
|
@@ -139,7 +240,6 @@ type GatewayStartEvent = { port: number };
|
|
|
139
240
|
type GatewayStopEvent = { reason?: string };
|
|
140
241
|
type GatewayContext = { port?: number };
|
|
141
242
|
|
|
142
|
-
// ─── Per-session and per-run state ────────────────────────────────────────────
|
|
143
243
|
|
|
144
244
|
interface SessionMeta {
|
|
145
245
|
traceId: string;
|
|
@@ -147,6 +247,15 @@ interface SessionMeta {
|
|
|
147
247
|
startedAt: number;
|
|
148
248
|
compactions: number;
|
|
149
249
|
resets: number;
|
|
250
|
+
// Session-level aggregates accumulated across all runs in this session
|
|
251
|
+
runCount: number;
|
|
252
|
+
totalInputTokens: number;
|
|
253
|
+
totalOutputTokens: number;
|
|
254
|
+
totalCacheReadTokens: number;
|
|
255
|
+
totalCacheWriteTokens: number;
|
|
256
|
+
totalToolCalls: number;
|
|
257
|
+
totalEstimatedCostUsd: number;
|
|
258
|
+
totalDurationMs: number;
|
|
150
259
|
}
|
|
151
260
|
|
|
152
261
|
interface RunMeta {
|
|
@@ -164,42 +273,201 @@ interface RunMeta {
|
|
|
164
273
|
model?: string;
|
|
165
274
|
provider?: string;
|
|
166
275
|
sessionKey?: string;
|
|
276
|
+
startedAt: number;
|
|
167
277
|
}
|
|
168
278
|
|
|
169
279
|
const sessions = new Map<string, SessionMeta>();
|
|
170
280
|
const runs = new Map<string, RunMeta>();
|
|
171
281
|
|
|
172
|
-
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
173
282
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (!API_KEY) return;
|
|
283
|
+
function _walAppend(payload: Record<string, unknown>): void {
|
|
284
|
+
if (!WAL_PATH) return;
|
|
177
285
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
286
|
+
appendFileSync(WAL_PATH, JSON.stringify(payload) + "\n", "utf8");
|
|
287
|
+
} catch { /* non-fatal */ }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function _walCompact(sentIds: Set<string>): void {
|
|
291
|
+
if (!WAL_PATH || !existsSync(WAL_PATH)) return;
|
|
292
|
+
try {
|
|
293
|
+
const lines = readFileSync(WAL_PATH, "utf8").split("\n").filter(Boolean);
|
|
294
|
+
const kept = lines.filter((line) => {
|
|
295
|
+
try {
|
|
296
|
+
const ev = JSON.parse(line) as Record<string, unknown>;
|
|
297
|
+
return !sentIds.has(String(ev.event_id ?? ""));
|
|
298
|
+
} catch { return false; }
|
|
185
299
|
});
|
|
300
|
+
writeFileSync(WAL_PATH, kept.length ? kept.join("\n") + "\n" : "", "utf8");
|
|
301
|
+
} catch { /* non-fatal */ }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _walRecover(): void {
|
|
305
|
+
if (!WAL_PATH || !existsSync(WAL_PATH)) return;
|
|
306
|
+
try {
|
|
307
|
+
const lines = readFileSync(WAL_PATH, "utf8").split("\n").filter(Boolean);
|
|
308
|
+
for (const line of lines) {
|
|
309
|
+
try {
|
|
310
|
+
const payload = JSON.parse(line) as Record<string, unknown>;
|
|
311
|
+
_enqueue(payload, true);
|
|
312
|
+
} catch { /* skip corrupt lines */ }
|
|
313
|
+
}
|
|
314
|
+
if (lines.length > 0) {
|
|
315
|
+
console.log(`AgentMetrics: recovered ${lines.length} event(s) from WAL`);
|
|
316
|
+
}
|
|
317
|
+
} catch { /* non-fatal */ }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
function _enqueue(payload: Record<string, unknown>, fromWal = false): void {
|
|
322
|
+
if (_queue.length >= MAX_QUEUE_SIZE) {
|
|
323
|
+
_queue.shift(); // FIFO: drop oldest on overflow
|
|
324
|
+
_metrics.dropped += 1;
|
|
325
|
+
}
|
|
326
|
+
_queue.push({ payload, attempt: 0, enqueuedAt: Date.now() });
|
|
327
|
+
if (!fromWal) _walAppend(payload);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
function _cbIsOpen(): boolean {
|
|
332
|
+
if (_cbState === "closed") return false;
|
|
333
|
+
if (_cbState === "open") {
|
|
334
|
+
if (_cbOpenAt !== null && Date.now() - _cbOpenAt >= CB_PROBE_MS) {
|
|
335
|
+
_cbState = "half-open";
|
|
336
|
+
return false; // let one probe through
|
|
337
|
+
}
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
return false; // half-open: probe is allowed
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function _cbOnSuccess(): void {
|
|
344
|
+
if (_cbState !== "closed") {
|
|
345
|
+
console.log("AgentMetrics: circuit breaker closed - delivery resumed");
|
|
346
|
+
}
|
|
347
|
+
_cbState = "closed";
|
|
348
|
+
_cbConsecFails = 0;
|
|
349
|
+
_cbOpenAt = null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function _cbOnFailure(): void {
|
|
353
|
+
_cbConsecFails += 1;
|
|
354
|
+
if (_cbState === "half-open" || _cbConsecFails >= CB_THRESHOLD) {
|
|
355
|
+
_cbState = "open";
|
|
356
|
+
_cbOpenAt = Date.now();
|
|
357
|
+
console.log(
|
|
358
|
+
`AgentMetrics: circuit breaker opened after ${_cbConsecFails} consecutive failures - ` +
|
|
359
|
+
`probing again in ${CB_PROBE_MS / 60_000}min`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
function _buildHeaders(): Record<string, string> {
|
|
366
|
+
return {
|
|
367
|
+
"Content-Type": "application/json",
|
|
368
|
+
"Authorization": `Bearer ${API_KEY}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function _maybeGzip(body: string): { body: string | Uint8Array; extra: Record<string, string> } {
|
|
373
|
+
if (COMPRESS_PAYLOADS && body.length > 1024) {
|
|
374
|
+
try {
|
|
375
|
+
return {
|
|
376
|
+
body: gzipSync(Buffer.from(body, "utf8")),
|
|
377
|
+
extra: { "Content-Encoding": "gzip" },
|
|
378
|
+
};
|
|
379
|
+
} catch { /* fall through */ }
|
|
380
|
+
}
|
|
381
|
+
return { body, extra: {} };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function _requeue(batch: QueuedEvent[]): void {
|
|
385
|
+
for (const item of batch) {
|
|
386
|
+
item.attempt += 1;
|
|
387
|
+
_metrics.failed += 1;
|
|
388
|
+
if (item.attempt >= RETRY_MAX_ATTEMPTS) {
|
|
389
|
+
_dlq.push(item);
|
|
390
|
+
} else {
|
|
391
|
+
_queue.push(item); // back of queue, not front - prevent starvation
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function _flushBatch(batch: QueuedEvent[]): Promise<void> {
|
|
397
|
+
const rawBody = JSON.stringify({ events: batch.map((e) => e.payload) });
|
|
398
|
+
const { body, extra } = _maybeGzip(rawBody);
|
|
399
|
+
try {
|
|
400
|
+
const resp = await fetch(`${BASE_URL}/v1/events/batch`, {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: { ..._buildHeaders(), ...extra },
|
|
403
|
+
body,
|
|
404
|
+
});
|
|
405
|
+
if (resp.ok) {
|
|
406
|
+
_cbOnSuccess();
|
|
407
|
+
_metrics.sent += batch.length;
|
|
408
|
+
_walCompact(new Set(batch.map((e) => String(e.payload.event_id ?? ""))));
|
|
409
|
+
} else if (resp.status === 404) {
|
|
410
|
+
await _flushIndividual(batch); // batch endpoint not yet available
|
|
411
|
+
} else {
|
|
412
|
+
_cbOnFailure();
|
|
413
|
+
_requeue(batch);
|
|
414
|
+
}
|
|
186
415
|
} catch {
|
|
187
|
-
|
|
416
|
+
_cbOnFailure();
|
|
417
|
+
_requeue(batch);
|
|
188
418
|
}
|
|
189
419
|
}
|
|
190
420
|
|
|
421
|
+
async function _flushIndividual(batch: QueuedEvent[]): Promise<void> {
|
|
422
|
+
for (const item of batch) {
|
|
423
|
+
const rawBody = JSON.stringify(item.payload);
|
|
424
|
+
const { body, extra } = _maybeGzip(rawBody);
|
|
425
|
+
try {
|
|
426
|
+
const resp = await fetch(`${BASE_URL}/v1/events`, {
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: { ..._buildHeaders(), ...extra },
|
|
429
|
+
body,
|
|
430
|
+
});
|
|
431
|
+
if (resp.ok) {
|
|
432
|
+
_cbOnSuccess();
|
|
433
|
+
_metrics.sent += 1;
|
|
434
|
+
_walCompact(new Set([String(item.payload.event_id ?? "")]));
|
|
435
|
+
} else {
|
|
436
|
+
_cbOnFailure();
|
|
437
|
+
_requeue([item]);
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
_cbOnFailure();
|
|
441
|
+
_requeue([item]);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function _flush(): Promise<void> {
|
|
447
|
+
if (!API_KEY || _queue.length === 0 || _cbIsOpen()) return;
|
|
448
|
+
const batch = _queue.splice(0, MAX_BATCH_SIZE);
|
|
449
|
+
await _flushBatch(batch);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
/** Enqueue a completed run summary; delivered by the flush loop. */
|
|
454
|
+
function send(payload: Record<string, unknown>): void {
|
|
455
|
+
if (!API_KEY || !ENABLED) return;
|
|
456
|
+
_enqueue(payload);
|
|
457
|
+
}
|
|
458
|
+
|
|
191
459
|
/**
|
|
192
460
|
* Send a real-time intermediate event to /v1/activity (in-memory, streamed
|
|
193
|
-
* to dashboard via SSE). Fire-and-forget
|
|
461
|
+
* to dashboard via SSE). Fire-and-forget - no retry, no crash on failure.
|
|
194
462
|
*/
|
|
195
463
|
function sendActivity(
|
|
196
|
-
type:
|
|
197
|
-
agentId:
|
|
198
|
-
sessionKey:
|
|
199
|
-
runId:
|
|
200
|
-
data?:
|
|
464
|
+
type: string,
|
|
465
|
+
agentId: string,
|
|
466
|
+
sessionKey: string | undefined,
|
|
467
|
+
runId: string | undefined,
|
|
468
|
+
data?: Record<string, unknown>,
|
|
201
469
|
): void {
|
|
202
|
-
if (!API_KEY) return;
|
|
470
|
+
if (!API_KEY || !ENABLED) return;
|
|
203
471
|
const payload = JSON.stringify({
|
|
204
472
|
type,
|
|
205
473
|
agent_id: agentId,
|
|
@@ -226,15 +494,77 @@ function emptyRun(sessionKey?: string): RunMeta {
|
|
|
226
494
|
toolCalls: 0, toolErrors: 0, toolNames: new Set(),
|
|
227
495
|
subagentsSpawned: 0, subagentErrors: 0,
|
|
228
496
|
sessionKey,
|
|
497
|
+
startedAt: Date.now(),
|
|
229
498
|
};
|
|
230
499
|
}
|
|
231
500
|
|
|
232
|
-
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
function _activeMode(): "strict" | "moderate" | "debug" {
|
|
504
|
+
if (DEBUG_EXPIRES_AT !== null) {
|
|
505
|
+
if (Date.now() < DEBUG_EXPIRES_AT) return "debug";
|
|
506
|
+
DEBUG_EXPIRES_AT = null;
|
|
507
|
+
console.log("AgentMetrics: debug mode expired - reverting to strict redaction");
|
|
508
|
+
}
|
|
509
|
+
return REDACTION_MODE;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function _redactError(err: string | undefined, activityStream = false): string | undefined {
|
|
513
|
+
if (!err) return err;
|
|
514
|
+
const mode = _activeMode();
|
|
515
|
+
if (mode === "debug") return err;
|
|
516
|
+
const maxLen = (mode === "strict" || activityStream) ? 200 : 500;
|
|
517
|
+
return _scrubSecrets(err).slice(0, maxLen);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function _redactToolName(name: string): string {
|
|
521
|
+
const mode = _activeMode();
|
|
522
|
+
if (mode === "debug") return name;
|
|
523
|
+
// allowlist mode: redactToolNames is the permitted list; blocklist mode: it's the deny list
|
|
524
|
+
switch (EXPORTED_TOOL_NAMES) {
|
|
525
|
+
case "off":
|
|
526
|
+
return "[REDACTED]";
|
|
527
|
+
case "hash":
|
|
528
|
+
return _hashName(name);
|
|
529
|
+
case "allowlist":
|
|
530
|
+
return REDACT_TOOL_NAMES.includes(name) ? name : `[REDACTED:${_hashName(name)}]`;
|
|
531
|
+
case "blocklist":
|
|
532
|
+
default:
|
|
533
|
+
return REDACT_TOOL_NAMES.includes(name) ? `[REDACTED:${_hashName(name)}]` : name;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function _redactToolNames(names: string[]): string[] {
|
|
538
|
+
return names.map(_redactToolName);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
function _estimateCost(
|
|
544
|
+
model: string | undefined,
|
|
545
|
+
input: number,
|
|
546
|
+
output: number,
|
|
547
|
+
cacheRead: number,
|
|
548
|
+
cacheWrite: number,
|
|
549
|
+
): number | undefined {
|
|
550
|
+
if (!model) return undefined;
|
|
551
|
+
// Try exact match first, then strip date suffix (e.g. -20241022)
|
|
552
|
+
const rates = _PRICING[model.toLowerCase()] ?? _PRICING[model.toLowerCase().replace(/-\d{8}$/, "")];
|
|
553
|
+
if (!rates) return undefined;
|
|
554
|
+
const M = 1_000_000;
|
|
555
|
+
return (
|
|
556
|
+
input * rates[0] / M +
|
|
557
|
+
output * rates[1] / M +
|
|
558
|
+
cacheRead * (rates[2] ?? 0) / M +
|
|
559
|
+
cacheWrite * (rates[3] ?? 0) / M
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
233
563
|
|
|
234
564
|
const plugin = {
|
|
235
565
|
id: "agentmetrics",
|
|
236
566
|
name: "AgentMetrics",
|
|
237
|
-
description: "360-degree observability for every OpenClaw agent
|
|
567
|
+
description: "360-degree observability for every OpenClaw agent - real-time streaming, tokens, tools, latency, cost, subagents, and reliability.",
|
|
238
568
|
configSchema: {
|
|
239
569
|
type: "object",
|
|
240
570
|
properties: {
|
|
@@ -244,29 +574,125 @@ const plugin = {
|
|
|
244
574
|
},
|
|
245
575
|
endpoint: {
|
|
246
576
|
type: "string",
|
|
247
|
-
description: "Custom API endpoint (default:
|
|
577
|
+
description: "Custom API endpoint (default: http://localhost:8099)",
|
|
578
|
+
},
|
|
579
|
+
enabled: {
|
|
580
|
+
type: "boolean",
|
|
581
|
+
description: "Disable the plugin without removing it (default: true)",
|
|
582
|
+
},
|
|
583
|
+
flushIntervalSeconds: {
|
|
584
|
+
type: "number",
|
|
585
|
+
description: "How often to flush the event queue to the API (default: 10)",
|
|
586
|
+
},
|
|
587
|
+
maxBatchSize: {
|
|
588
|
+
type: "number",
|
|
589
|
+
description: "Maximum events per batch request (default: 100)",
|
|
590
|
+
},
|
|
591
|
+
maxQueueSize: {
|
|
592
|
+
type: "number",
|
|
593
|
+
description: "Maximum in-memory queue depth before FIFO drop (default: 10000)",
|
|
594
|
+
},
|
|
595
|
+
retryMaxAttempts: {
|
|
596
|
+
type: "number",
|
|
597
|
+
description: "Max retry attempts before moving event to DLQ (default: 5)",
|
|
598
|
+
},
|
|
599
|
+
redactionMode: {
|
|
600
|
+
type: "string",
|
|
601
|
+
enum: ["strict", "moderate", "debug"],
|
|
602
|
+
description: "PII redaction level applied to prompts/completions (default: strict)",
|
|
603
|
+
},
|
|
604
|
+
exportedToolNames: {
|
|
605
|
+
type: "string",
|
|
606
|
+
enum: ["allowlist", "blocklist", "hash", "off"],
|
|
607
|
+
description: "Which tool names to include in exports (default: blocklist)",
|
|
608
|
+
},
|
|
609
|
+
redactToolNames: {
|
|
610
|
+
type: "array",
|
|
611
|
+
items: { type: "string" },
|
|
612
|
+
description: "Tool names to redact when exportedToolNames is 'blocklist'",
|
|
613
|
+
},
|
|
614
|
+
compressPayloads: {
|
|
615
|
+
type: "boolean",
|
|
616
|
+
description: "Gzip-compress batch payloads larger than 1 KB (default: false)",
|
|
248
617
|
},
|
|
249
618
|
},
|
|
250
619
|
additionalProperties: false,
|
|
251
620
|
} as const,
|
|
252
621
|
|
|
253
|
-
register(api:
|
|
254
|
-
|
|
255
|
-
|
|
622
|
+
register(api: PluginApi) {
|
|
623
|
+
if (_registered) {
|
|
624
|
+
console.warn(
|
|
625
|
+
"\n AgentMetrics: ⚠ register() called twice - possible duplicate instrumentation.\n" +
|
|
626
|
+
" If you have both the plugin and an SDK hook active, remove one to avoid\n" +
|
|
627
|
+
" double-counting runs and inflated token/cost totals.\n",
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
_registered = true;
|
|
631
|
+
|
|
632
|
+
API_KEY = (api.pluginConfig?.apiKey as string | undefined) ?? process.env.AGENTMETRICS_API_KEY;
|
|
256
633
|
BASE_URL = (
|
|
257
|
-
api.
|
|
634
|
+
(api.pluginConfig?.endpoint as string | undefined) ??
|
|
258
635
|
process.env.AGENTMETRICS_URL ??
|
|
259
|
-
"
|
|
636
|
+
"http://localhost:8099"
|
|
260
637
|
).replace(/\/$/, "");
|
|
261
638
|
|
|
262
|
-
|
|
639
|
+
ENABLED = (api.pluginConfig?.enabled as boolean | undefined) ?? true;
|
|
640
|
+
REDACTION_MODE = (api.pluginConfig?.redactionMode as typeof REDACTION_MODE | undefined) ?? "strict";
|
|
641
|
+
EXPORTED_TOOL_NAMES = (api.pluginConfig?.exportedToolNames as typeof EXPORTED_TOOL_NAMES | undefined) ?? "blocklist";
|
|
642
|
+
REDACT_TOOL_NAMES = (api.pluginConfig?.redactToolNames as string[] | undefined) ?? [];
|
|
643
|
+
FLUSH_INTERVAL_MS = ((api.pluginConfig?.flushIntervalSeconds as number | undefined) ?? 10) * 1000;
|
|
644
|
+
MAX_BATCH_SIZE = (api.pluginConfig?.maxBatchSize as number | undefined) ?? 100;
|
|
645
|
+
MAX_QUEUE_SIZE = (api.pluginConfig?.maxQueueSize as number | undefined) ?? 10_000;
|
|
646
|
+
RETRY_MAX_ATTEMPTS = (api.pluginConfig?.retryMaxAttempts as number | undefined) ?? 5;
|
|
647
|
+
COMPRESS_PAYLOADS = (api.pluginConfig?.compressPayloads as boolean | undefined) ?? false;
|
|
648
|
+
|
|
649
|
+
if (REDACTION_MODE === "debug") {
|
|
650
|
+
DEBUG_EXPIRES_AT = Date.now() + 60 * 60 * 1000;
|
|
651
|
+
console.log("AgentMetrics: ⚠ debug redaction mode active - expires in 1 hour");
|
|
652
|
+
}
|
|
653
|
+
|
|
263
654
|
if (typeof api.registerAutoEnableProbe === "function") {
|
|
264
|
-
api.registerAutoEnableProbe(() => !!API_KEY);
|
|
655
|
+
api.registerAutoEnableProbe(() => !!API_KEY && ENABLED);
|
|
265
656
|
}
|
|
266
657
|
|
|
267
|
-
if (!
|
|
658
|
+
if (!ENABLED) {
|
|
659
|
+
console.log("\n AgentMetrics: disabled via config (metrics.enabled: false)\n");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (!API_KEY) {
|
|
664
|
+
console.log(
|
|
665
|
+
"\n AgentMetrics: no API key found.\n" +
|
|
666
|
+
" Your agent runs are not being tracked.\n" +
|
|
667
|
+
" Start AgentMetrics (see README) and set AGENTMETRICS_API_KEY.\n" +
|
|
668
|
+
" AGENTMETRICS_URL defaults to http://localhost:8099.\n",
|
|
669
|
+
);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? process.cwd();
|
|
675
|
+
WAL_PATH = join(home, ".config", "openclaw", "agentmetrics-wal.jsonl");
|
|
676
|
+
mkdirSync(dirname(WAL_PATH), { recursive: true });
|
|
677
|
+
try { chmodSync(dirname(WAL_PATH), 0o700); } catch {}
|
|
678
|
+
_walRecover();
|
|
679
|
+
} catch {
|
|
680
|
+
WAL_PATH = null; // WAL unavailable - queue still works in-memory
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (_flushTimer) clearInterval(_flushTimer);
|
|
684
|
+
_flushTimer = setInterval(() => { _flush().catch(() => {}); }, FLUSH_INTERVAL_MS);
|
|
685
|
+
// Allow process to exit even with an active timer
|
|
686
|
+
if (typeof (_flushTimer as unknown as { unref?: () => void }).unref === "function") {
|
|
687
|
+
(_flushTimer as unknown as { unref: () => void }).unref();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
console.log(
|
|
691
|
+
`\n AgentMetrics active - sending data to ${BASE_URL}\n` +
|
|
692
|
+
` Queue: max ${MAX_QUEUE_SIZE} events, batch ${MAX_BATCH_SIZE}, flush every ${FLUSH_INTERVAL_MS / 1000}s\n` +
|
|
693
|
+
` View your dashboard → http://localhost:3099\n`,
|
|
694
|
+
);
|
|
268
695
|
|
|
269
|
-
// CLI: openclaw agentmetrics status
|
|
270
696
|
if (typeof api.registerCli === "function") {
|
|
271
697
|
api.registerCli({
|
|
272
698
|
name: "agentmetrics",
|
|
@@ -274,49 +700,182 @@ const plugin = {
|
|
|
274
700
|
commands: [
|
|
275
701
|
{
|
|
276
702
|
name: "status",
|
|
277
|
-
description: "Show current
|
|
703
|
+
description: "Show current plugin status, config, delivery counters, and circuit breaker state",
|
|
278
704
|
handler() {
|
|
279
705
|
const keyPreview = API_KEY
|
|
280
706
|
? `${API_KEY.slice(0, 8)}...${API_KEY.slice(-4)}`
|
|
281
707
|
: "(not set)";
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
console.log(
|
|
708
|
+
const mode = _activeMode();
|
|
709
|
+
const cbInfo = _cbState === "open" && _cbOpenAt
|
|
710
|
+
? ` (opens probe at ${new Date(_cbOpenAt + CB_PROBE_MS).toLocaleTimeString()})`
|
|
711
|
+
: "";
|
|
712
|
+
console.log("AgentMetrics - status");
|
|
713
|
+
console.log(` API key : ${keyPreview}`);
|
|
714
|
+
console.log(` Endpoint : ${BASE_URL}`);
|
|
715
|
+
console.log(` Redaction : ${mode}${mode === "debug" && DEBUG_EXPIRES_AT ? ` (expires ${new Date(DEBUG_EXPIRES_AT).toLocaleTimeString()})` : ""}`);
|
|
716
|
+
console.log(` Tool names : ${EXPORTED_TOOL_NAMES}`);
|
|
717
|
+
console.log(` Compress payloads: ${COMPRESS_PAYLOADS}`);
|
|
718
|
+
console.log(` Flush interval : ${FLUSH_INTERVAL_MS / 1000}s`);
|
|
719
|
+
console.log(` WAL path : ${WAL_PATH ?? "(unavailable)"}`);
|
|
720
|
+
console.log("");
|
|
721
|
+
console.log(` Circuit breaker : ${_cbState}${cbInfo}`);
|
|
722
|
+
console.log(` Queue depth : ${_queue.length} / ${MAX_QUEUE_SIZE}`);
|
|
723
|
+
console.log(` DLQ depth : ${_dlq.length}`);
|
|
724
|
+
console.log(` Sessions tracked : ${sessions.size}`);
|
|
725
|
+
console.log(` Runs in flight : ${runs.size}`);
|
|
726
|
+
console.log("");
|
|
727
|
+
console.log(` Sent : ${_metrics.sent}`);
|
|
728
|
+
console.log(` Failed : ${_metrics.failed}`);
|
|
729
|
+
console.log(` Dropped (overflow): ${_metrics.dropped}`);
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: "flush",
|
|
734
|
+
description: "Force-flush all queued events immediately",
|
|
735
|
+
async handler() {
|
|
736
|
+
const before = _queue.length;
|
|
737
|
+
if (before === 0) {
|
|
738
|
+
console.log("AgentMetrics flush - queue empty, nothing to flush");
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (_cbIsOpen()) {
|
|
742
|
+
console.log(`AgentMetrics flush - circuit breaker is ${_cbState}, skipping`);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
console.log(`AgentMetrics flush - flushing ${before} event(s)…`);
|
|
746
|
+
// Drain the entire queue in batches
|
|
747
|
+
while (_queue.length > 0 && !_cbIsOpen()) {
|
|
748
|
+
await _flush();
|
|
749
|
+
}
|
|
750
|
+
console.log(` Done - sent: ${_metrics.sent}, failed: ${_metrics.failed}, queued: ${_queue.length}`);
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: "tail",
|
|
755
|
+
description: "Show recent in-flight run state",
|
|
756
|
+
handler() {
|
|
757
|
+
if (runs.size === 0 && sessions.size === 0) {
|
|
758
|
+
console.log("AgentMetrics tail - no active sessions or runs");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
console.log("AgentMetrics tail - active state");
|
|
762
|
+
if (sessions.size > 0) {
|
|
763
|
+
console.log(` Sessions (${sessions.size}):`);
|
|
764
|
+
for (const [key, s] of sessions) {
|
|
765
|
+
console.log(` ${key.slice(0, 12)}… agent=${s.agentId} compactions=${s.compactions} resets=${s.resets}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (runs.size > 0) {
|
|
769
|
+
console.log(` Runs in flight (${runs.size}):`);
|
|
770
|
+
for (const [id, r] of runs) {
|
|
771
|
+
const age = Math.round((Date.now() - r.startedAt) / 1000);
|
|
772
|
+
console.log(` ${id.slice(0, 12)}… llm=${r.llmCalls} tools=${r.toolCalls} ${age}s elapsed`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
name: "test",
|
|
779
|
+
description: "Send a test event and verify end-to-end delivery",
|
|
780
|
+
async handler() {
|
|
781
|
+
if (!API_KEY) {
|
|
782
|
+
console.log("AgentMetrics test - no API key set, cannot send");
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
console.log(`AgentMetrics test - sending to ${BASE_URL}…`);
|
|
786
|
+
try {
|
|
787
|
+
const resp = await fetch(`${BASE_URL}/v1/events`, {
|
|
788
|
+
method: "POST",
|
|
789
|
+
headers: {
|
|
790
|
+
"Content-Type": "application/json",
|
|
791
|
+
"Authorization": `Bearer ${API_KEY}`,
|
|
792
|
+
},
|
|
793
|
+
body: JSON.stringify({
|
|
794
|
+
event_id: randomUUID(),
|
|
795
|
+
trace_id: randomUUID(),
|
|
796
|
+
agent_id: "agentmetrics-test",
|
|
797
|
+
platform: "openclaw",
|
|
798
|
+
event_name: "agent_end",
|
|
799
|
+
ts: Date.now(),
|
|
800
|
+
status: "success",
|
|
801
|
+
duration_ms: 1,
|
|
802
|
+
redaction_policy_version: `v1-${_activeMode()}`,
|
|
803
|
+
}),
|
|
804
|
+
});
|
|
805
|
+
if (resp.ok) {
|
|
806
|
+
console.log(` ✓ Delivered - HTTP ${resp.status}`);
|
|
807
|
+
} else {
|
|
808
|
+
const body = await resp.text().catch(() => "");
|
|
809
|
+
console.log(` ✗ Failed - HTTP ${resp.status} ${body.slice(0, 200)}`);
|
|
810
|
+
}
|
|
811
|
+
} catch (err) {
|
|
812
|
+
console.log(` ✗ Failed - ${err}`);
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
name: "redaction-check",
|
|
818
|
+
description: "Show what the current redaction policy does to a sample payload",
|
|
819
|
+
handler() {
|
|
820
|
+
const mode = _activeMode();
|
|
821
|
+
const sampleError = "Connection failed: Bearer sk-ant-abc123exampletoken and api_key=supersecret";
|
|
822
|
+
const sampleTools = ["bash", "read_file", "write_file", "send_email"];
|
|
823
|
+
const redactedError = _redactError(sampleError);
|
|
824
|
+
const redactedTools = _redactToolNames(sampleTools);
|
|
825
|
+
console.log("AgentMetrics redaction-check");
|
|
826
|
+
console.log(` Mode : ${mode}`);
|
|
827
|
+
console.log(` Tool export : ${EXPORTED_TOOL_NAMES}`);
|
|
828
|
+
console.log(` Blocked names : ${REDACT_TOOL_NAMES.length ? REDACT_TOOL_NAMES.join(", ") : "(none)"}`);
|
|
829
|
+
console.log("");
|
|
830
|
+
console.log(" Error sample:");
|
|
831
|
+
console.log(` Input : ${sampleError}`);
|
|
832
|
+
console.log(` Output : ${redactedError}`);
|
|
833
|
+
console.log("");
|
|
834
|
+
console.log(" Tool name sample:");
|
|
835
|
+
sampleTools.forEach((t, i) =>
|
|
836
|
+
console.log(` ${t.padEnd(16)} → ${redactedTools[i]}`),
|
|
837
|
+
);
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
name: "drain",
|
|
842
|
+
description: "Retry all events in the dead-letter queue",
|
|
843
|
+
async handler() {
|
|
844
|
+
if (_dlq.length === 0) {
|
|
845
|
+
console.log("AgentMetrics drain - DLQ is empty");
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const count = _dlq.length;
|
|
849
|
+
console.log(`AgentMetrics drain - retrying ${count} DLQ event(s)…`);
|
|
850
|
+
// Reset attempts and move DLQ back to main queue
|
|
851
|
+
const items = _dlq.splice(0, _dlq.length);
|
|
852
|
+
for (const item of items) {
|
|
853
|
+
item.attempt = 0;
|
|
854
|
+
_queue.push(item);
|
|
855
|
+
}
|
|
856
|
+
// Flush immediately
|
|
857
|
+
while (_queue.length > 0 && !_cbIsOpen()) {
|
|
858
|
+
await _flush();
|
|
859
|
+
}
|
|
860
|
+
console.log(` Done - sent: ${_metrics.sent}, failed: ${_metrics.failed}, remaining DLQ: ${_dlq.length}`);
|
|
287
861
|
},
|
|
288
862
|
},
|
|
289
863
|
],
|
|
290
864
|
});
|
|
291
865
|
}
|
|
292
866
|
|
|
293
|
-
// ── gateway_start ─────────────────────────────────────────────────────
|
|
294
867
|
api.on("gateway_start", (event: GatewayStartEvent, _ctx: GatewayContext) => {
|
|
295
868
|
sendActivity("gateway_start", "openclaw-gateway", undefined, undefined, {
|
|
296
869
|
port: event.port,
|
|
297
870
|
});
|
|
298
|
-
send({
|
|
299
|
-
trace_id: randomUUID(),
|
|
300
|
-
agent_id: "openclaw-gateway",
|
|
301
|
-
status: "success",
|
|
302
|
-
metadata: { event_type: "gateway_start", port: event.port },
|
|
303
|
-
});
|
|
304
871
|
});
|
|
305
872
|
|
|
306
|
-
// ── gateway_stop ──────────────────────────────────────────────────────
|
|
307
873
|
api.on("gateway_stop", (event: GatewayStopEvent, _ctx: GatewayContext) => {
|
|
308
874
|
sendActivity("gateway_stop", "openclaw-gateway", undefined, undefined, {
|
|
309
875
|
reason: event.reason,
|
|
310
876
|
});
|
|
311
|
-
send({
|
|
312
|
-
trace_id: randomUUID(),
|
|
313
|
-
agent_id: "openclaw-gateway",
|
|
314
|
-
status: "success",
|
|
315
|
-
metadata: { event_type: "gateway_stop", reason: event.reason },
|
|
316
|
-
});
|
|
317
877
|
});
|
|
318
878
|
|
|
319
|
-
// ── session_start ─────────────────────────────────────────────────────
|
|
320
879
|
api.on("session_start", (event: SessionStartEvent, ctx: SessionContext) => {
|
|
321
880
|
const key = event.sessionKey ?? event.sessionId;
|
|
322
881
|
if (!key || sessions.has(key)) return;
|
|
@@ -327,11 +886,17 @@ const plugin = {
|
|
|
327
886
|
startedAt: Date.now(),
|
|
328
887
|
compactions: 0,
|
|
329
888
|
resets: 0,
|
|
889
|
+
runCount: 0,
|
|
890
|
+
totalInputTokens: 0,
|
|
891
|
+
totalOutputTokens: 0,
|
|
892
|
+
totalCacheReadTokens: 0,
|
|
893
|
+
totalCacheWriteTokens: 0,
|
|
894
|
+
totalToolCalls: 0,
|
|
895
|
+
totalEstimatedCostUsd: 0,
|
|
896
|
+
totalDurationMs: 0,
|
|
330
897
|
});
|
|
331
898
|
});
|
|
332
899
|
|
|
333
|
-
// ── before_agent_start ────────────────────────────────────────────────
|
|
334
|
-
// Fires at the start of each run. Sends run_start for real-time UI.
|
|
335
900
|
api.on("before_agent_start", (event: BeforeAgentStartEvent, ctx: AgentContext) => {
|
|
336
901
|
const sessionKey = ctx.sessionKey ?? ctx.sessionId;
|
|
337
902
|
const agentId = ctx.agentId ?? "openclaw-agent";
|
|
@@ -345,9 +910,6 @@ const plugin = {
|
|
|
345
910
|
});
|
|
346
911
|
});
|
|
347
912
|
|
|
348
|
-
// ── llm_input ─────────────────────────────────────────────────────────
|
|
349
|
-
// Fires before each LLM call. Sends llm_start for real-time "thinking"
|
|
350
|
-
// indicator. Also accumulates LLM call count and image count.
|
|
351
913
|
api.on("llm_input", (event: LlmInputEvent, ctx: AgentContext) => {
|
|
352
914
|
const { runId } = event;
|
|
353
915
|
if (!runId) return;
|
|
@@ -371,9 +933,6 @@ const plugin = {
|
|
|
371
933
|
});
|
|
372
934
|
});
|
|
373
935
|
|
|
374
|
-
// ── llm_output ────────────────────────────────────────────────────────
|
|
375
|
-
// Fires after each LLM response. Accumulates tokens and sends llm_end
|
|
376
|
-
// so the dashboard can show token counts updating in real time.
|
|
377
936
|
api.on("llm_output", (event: LlmOutputEvent, ctx: AgentContext) => {
|
|
378
937
|
const { runId } = event;
|
|
379
938
|
if (!runId) return;
|
|
@@ -400,15 +959,11 @@ const plugin = {
|
|
|
400
959
|
output_tokens: event.usage?.output,
|
|
401
960
|
cache_read: event.usage?.cacheRead,
|
|
402
961
|
cache_write: event.usage?.cacheWrite,
|
|
403
|
-
// Running totals for the dashboard
|
|
404
962
|
total_input: run.inputTokens,
|
|
405
963
|
total_output: run.outputTokens,
|
|
406
964
|
});
|
|
407
965
|
});
|
|
408
966
|
|
|
409
|
-
// ── before_tool_call ──────────────────────────────────────────────────
|
|
410
|
-
// Fires before each tool execution. Sends tool_start immediately so
|
|
411
|
-
// the live visualizer shows tool activity in real time.
|
|
412
967
|
api.on("before_tool_call", (event: BeforeToolCallEvent, ctx: ToolContext) => {
|
|
413
968
|
const runId = event.runId ?? ctx.runId;
|
|
414
969
|
const sessionKey = ctx.sessionKey ?? ctx.sessionId;
|
|
@@ -419,9 +974,6 @@ const plugin = {
|
|
|
419
974
|
});
|
|
420
975
|
});
|
|
421
976
|
|
|
422
|
-
// ── after_tool_call ───────────────────────────────────────────────────
|
|
423
|
-
// Fires after every tool execution. Counts calls/errors and sends
|
|
424
|
-
// tool_end with outcome and duration.
|
|
425
977
|
api.on("after_tool_call", (event: AfterToolCallEvent, ctx: ToolContext) => {
|
|
426
978
|
const runId = event.runId ?? ctx.runId;
|
|
427
979
|
const sessionKey = ctx.sessionKey ?? ctx.sessionId;
|
|
@@ -437,13 +989,12 @@ const plugin = {
|
|
|
437
989
|
}
|
|
438
990
|
|
|
439
991
|
sendActivity("tool_end", agentId, sessionKey, runId, {
|
|
440
|
-
tool_name: event.toolName,
|
|
992
|
+
tool_name: _redactToolName(event.toolName),
|
|
441
993
|
duration_ms: event.durationMs,
|
|
442
|
-
error: event.error
|
|
994
|
+
error: _redactError(event.error, true),
|
|
443
995
|
});
|
|
444
996
|
});
|
|
445
997
|
|
|
446
|
-
// ── subagent_spawning ─────────────────────────────────────────────────
|
|
447
998
|
api.on("subagent_spawning", (event: SubagentSpawningEvent, ctx: SubagentContext) => {
|
|
448
999
|
const runId = ctx.runId;
|
|
449
1000
|
const sessionKey = ctx.requesterSessionKey;
|
|
@@ -465,7 +1016,6 @@ const plugin = {
|
|
|
465
1016
|
});
|
|
466
1017
|
});
|
|
467
1018
|
|
|
468
|
-
// ── subagent_ended ────────────────────────────────────────────────────
|
|
469
1019
|
api.on("subagent_ended", (event: SubagentEndedEvent, ctx: SubagentContext) => {
|
|
470
1020
|
const runId = event.runId ?? ctx.runId;
|
|
471
1021
|
const sessionKey = ctx.requesterSessionKey;
|
|
@@ -482,11 +1032,10 @@ const plugin = {
|
|
|
482
1032
|
sendActivity("subagent_end", agentId, sessionKey, runId, {
|
|
483
1033
|
child_session_key: event.targetSessionKey,
|
|
484
1034
|
outcome: event.outcome,
|
|
485
|
-
error: event.error
|
|
1035
|
+
error: _redactError(event.error, true),
|
|
486
1036
|
});
|
|
487
1037
|
});
|
|
488
1038
|
|
|
489
|
-
// ── before_compaction ─────────────────────────────────────────────────
|
|
490
1039
|
api.on("before_compaction", (_event: CompactionEvent, ctx: AgentContext) => {
|
|
491
1040
|
const key = ctx.sessionKey ?? ctx.sessionId;
|
|
492
1041
|
const agentId = ctx.agentId ?? sessions.get(key ?? "")?.agentId ?? "openclaw-agent";
|
|
@@ -501,7 +1050,6 @@ const plugin = {
|
|
|
501
1050
|
sendActivity("compaction", agentId, key, ctx.runId);
|
|
502
1051
|
});
|
|
503
1052
|
|
|
504
|
-
// ── before_reset ──────────────────────────────────────────────────────
|
|
505
1053
|
api.on("before_reset", (event: ResetEvent, ctx: AgentContext) => {
|
|
506
1054
|
const key = ctx.sessionKey ?? ctx.sessionId;
|
|
507
1055
|
const agentId = ctx.agentId ?? sessions.get(key ?? "")?.agentId ?? "openclaw-agent";
|
|
@@ -516,9 +1064,6 @@ const plugin = {
|
|
|
516
1064
|
sendActivity("reset", agentId, key, ctx.runId, { reason: event.reason });
|
|
517
1065
|
});
|
|
518
1066
|
|
|
519
|
-
// ── agent_end ─────────────────────────────────────────────────────────
|
|
520
|
-
// Fires when the agent finishes a run. Sends run_end for real-time UI,
|
|
521
|
-
// then the full persisted event to /v1/events with all accumulated data.
|
|
522
1067
|
api.on("agent_end", (event: AgentEndEvent, ctx: AgentContext) => {
|
|
523
1068
|
const sessionKey = ctx.sessionKey ?? ctx.sessionId;
|
|
524
1069
|
if (!sessionKey) return;
|
|
@@ -535,21 +1080,49 @@ const plugin = {
|
|
|
535
1080
|
(run?.cacheReadTokens ?? 0) +
|
|
536
1081
|
(run?.cacheWriteTokens ?? 0);
|
|
537
1082
|
|
|
538
|
-
|
|
1083
|
+
const durationMs = event.durationMs ?? (run ? Date.now() - run.startedAt : undefined);
|
|
1084
|
+
const redactedError = _redactError(event.error);
|
|
1085
|
+
|
|
1086
|
+
const estimatedCostUsd = _estimateCost(
|
|
1087
|
+
run?.model,
|
|
1088
|
+
run?.inputTokens ?? 0,
|
|
1089
|
+
run?.outputTokens ?? 0,
|
|
1090
|
+
run?.cacheReadTokens ?? 0,
|
|
1091
|
+
run?.cacheWriteTokens ?? 0,
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
// Accumulate into session-level totals
|
|
1095
|
+
if (session) {
|
|
1096
|
+
session.runCount += 1;
|
|
1097
|
+
session.totalInputTokens += run?.inputTokens ?? 0;
|
|
1098
|
+
session.totalOutputTokens += run?.outputTokens ?? 0;
|
|
1099
|
+
session.totalCacheReadTokens += run?.cacheReadTokens ?? 0;
|
|
1100
|
+
session.totalCacheWriteTokens += run?.cacheWriteTokens ?? 0;
|
|
1101
|
+
session.totalToolCalls += run?.toolCalls ?? 0;
|
|
1102
|
+
session.totalEstimatedCostUsd += estimatedCostUsd ?? 0;
|
|
1103
|
+
session.totalDurationMs += durationMs ?? 0;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
539
1106
|
sendActivity("run_end", agentId, sessionKey, ctx.runId, {
|
|
540
1107
|
status: event.success ? "success" : "failed",
|
|
541
|
-
duration_ms:
|
|
1108
|
+
duration_ms: durationMs,
|
|
542
1109
|
total_tokens: totalTokens || undefined,
|
|
543
1110
|
tool_calls: run?.toolCalls,
|
|
544
|
-
error: event.error
|
|
1111
|
+
error: _redactError(event.error, true),
|
|
545
1112
|
});
|
|
546
1113
|
|
|
547
|
-
// Persistent: full summary stored in DB
|
|
548
1114
|
send({
|
|
549
|
-
|
|
550
|
-
|
|
1115
|
+
event_id: randomUUID(),
|
|
1116
|
+
trace_id: session?.traceId ?? randomUUID(),
|
|
1117
|
+
session_id: sessionKey,
|
|
1118
|
+
run_id: ctx.runId,
|
|
1119
|
+
agent_id: agentId,
|
|
1120
|
+
platform: "openclaw",
|
|
1121
|
+
event_name: "agent_end",
|
|
1122
|
+
ts: Date.now(),
|
|
1123
|
+
redaction_policy_version: `v1-${_activeMode()}`,
|
|
551
1124
|
status: event.success ? "success" : "failed",
|
|
552
|
-
duration_ms:
|
|
1125
|
+
duration_ms: durationMs,
|
|
553
1126
|
model: run?.model,
|
|
554
1127
|
model_provider: run?.provider,
|
|
555
1128
|
input_tokens: run?.inputTokens ?? 0,
|
|
@@ -559,8 +1132,10 @@ const plugin = {
|
|
|
559
1132
|
total_tokens: totalTokens || undefined,
|
|
560
1133
|
tool_calls: run?.toolCalls ?? 0,
|
|
561
1134
|
tool_errors: run?.toolErrors ?? 0,
|
|
562
|
-
|
|
563
|
-
|
|
1135
|
+
tool_names: run ? _redactToolNames([...run.toolNames]) : [],
|
|
1136
|
+
step_count: event.messages?.length,
|
|
1137
|
+
...(estimatedCostUsd != null ? { estimated_cost_usd: estimatedCostUsd } : {}),
|
|
1138
|
+
...(redactedError ? { error: redactedError } : {}),
|
|
564
1139
|
metadata: {
|
|
565
1140
|
llm_calls: run?.llmCalls ?? 0,
|
|
566
1141
|
images_count: run?.imagesCount ?? 0,
|
|
@@ -568,16 +1143,52 @@ const plugin = {
|
|
|
568
1143
|
subagent_errors: run?.subagentErrors ?? 0,
|
|
569
1144
|
compactions: session?.compactions ?? 0,
|
|
570
1145
|
resets: session?.resets ?? 0,
|
|
571
|
-
tool_names: run ? [...run.toolNames] : [],
|
|
572
1146
|
},
|
|
573
1147
|
});
|
|
574
1148
|
|
|
575
1149
|
if (ctx.runId) runs.delete(ctx.runId);
|
|
576
1150
|
});
|
|
577
1151
|
|
|
578
|
-
// ── session_end ───────────────────────────────────────────────────────
|
|
579
1152
|
api.on("session_end", (event: SessionEndEvent, _ctx: SessionContext) => {
|
|
580
|
-
const key
|
|
1153
|
+
const key = event.sessionKey ?? event.sessionId;
|
|
1154
|
+
const session = sessions.get(key);
|
|
1155
|
+
|
|
1156
|
+
if (session && session.runCount > 0) {
|
|
1157
|
+
const sessionDurationMs = event.durationMs ?? (Date.now() - session.startedAt);
|
|
1158
|
+
const totalTokens =
|
|
1159
|
+
session.totalInputTokens + session.totalOutputTokens +
|
|
1160
|
+
session.totalCacheReadTokens + session.totalCacheWriteTokens;
|
|
1161
|
+
|
|
1162
|
+
send({
|
|
1163
|
+
event_id: randomUUID(),
|
|
1164
|
+
trace_id: session.traceId,
|
|
1165
|
+
session_id: key,
|
|
1166
|
+
agent_id: session.agentId,
|
|
1167
|
+
platform: "openclaw",
|
|
1168
|
+
event_name: "session_metrics",
|
|
1169
|
+
ts: Date.now(),
|
|
1170
|
+
redaction_policy_version: `v1-${_activeMode()}`,
|
|
1171
|
+
status: "success",
|
|
1172
|
+
duration_ms: sessionDurationMs,
|
|
1173
|
+
input_tokens: session.totalInputTokens,
|
|
1174
|
+
output_tokens: session.totalOutputTokens,
|
|
1175
|
+
cache_read_tokens: session.totalCacheReadTokens,
|
|
1176
|
+
cache_write_tokens: session.totalCacheWriteTokens,
|
|
1177
|
+
total_tokens: totalTokens || undefined,
|
|
1178
|
+
tool_calls: session.totalToolCalls,
|
|
1179
|
+
...(session.totalEstimatedCostUsd > 0
|
|
1180
|
+
? { estimated_cost_usd: session.totalEstimatedCostUsd }
|
|
1181
|
+
: {}),
|
|
1182
|
+
metadata: {
|
|
1183
|
+
run_count: session.runCount,
|
|
1184
|
+
compactions: session.compactions,
|
|
1185
|
+
resets: session.resets,
|
|
1186
|
+
message_count: event.messageCount,
|
|
1187
|
+
reason: event.reason,
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
581
1192
|
sessions.delete(key);
|
|
582
1193
|
});
|
|
583
1194
|
},
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "agentmetrics",
|
|
3
3
|
"name": "AgentMetrics",
|
|
4
|
-
"description": "360-degree observability for every OpenClaw agent
|
|
4
|
+
"description": "360-degree observability for every OpenClaw agent - tokens, tools, latency, cost, subagents, context health, and reliability.",
|
|
5
5
|
"configSchema": {
|
|
6
6
|
"type": "object",
|
|
7
7
|
"properties": {
|
|
@@ -11,7 +11,55 @@
|
|
|
11
11
|
},
|
|
12
12
|
"endpoint": {
|
|
13
13
|
"type": "string",
|
|
14
|
-
"description": "Custom API endpoint (default:
|
|
14
|
+
"description": "Custom API endpoint (default: http://localhost:8099)"
|
|
15
|
+
},
|
|
16
|
+
"enabled": {
|
|
17
|
+
"type": "boolean",
|
|
18
|
+
"description": "Master toggle - set to false to disable all telemetry without uninstalling (default: true)",
|
|
19
|
+
"default": true
|
|
20
|
+
},
|
|
21
|
+
"flushIntervalSeconds": {
|
|
22
|
+
"type": "integer",
|
|
23
|
+
"description": "How often to flush the event batch to the API in seconds (default: 10)",
|
|
24
|
+
"default": 10
|
|
25
|
+
},
|
|
26
|
+
"maxBatchSize": {
|
|
27
|
+
"type": "integer",
|
|
28
|
+
"description": "Maximum number of events per batch request (default: 100)",
|
|
29
|
+
"default": 100
|
|
30
|
+
},
|
|
31
|
+
"maxQueueSize": {
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"description": "Maximum number of events to hold in the local queue before dropping (FIFO, default: 10000)",
|
|
34
|
+
"default": 10000
|
|
35
|
+
},
|
|
36
|
+
"retryMaxAttempts": {
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"description": "Maximum retry attempts per event batch before moving to dead-letter queue (default: 5)",
|
|
39
|
+
"default": 5
|
|
40
|
+
},
|
|
41
|
+
"redactionMode": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"enum": ["strict", "moderate", "debug"],
|
|
44
|
+
"description": "Redaction policy: strict = all sensitive fields scrubbed (default); moderate = errors and secrets only; debug = opt-in full detail, auto-expires after 1h",
|
|
45
|
+
"default": "strict"
|
|
46
|
+
},
|
|
47
|
+
"toolNameExport": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"enum": ["allowlist", "blocklist", "hash", "off"],
|
|
50
|
+
"description": "How tool names are exported: blocklist = export all except redactToolNames (default); allowlist = export only listed; hash = pseudonymise; off = never export",
|
|
51
|
+
"default": "blocklist"
|
|
52
|
+
},
|
|
53
|
+
"redactToolNames": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"items": { "type": "string" },
|
|
56
|
+
"description": "Tool names to always redact regardless of toolNameExport mode (default: [])",
|
|
57
|
+
"default": []
|
|
58
|
+
},
|
|
59
|
+
"compressPayloads": {
|
|
60
|
+
"type": "boolean",
|
|
61
|
+
"description": "Gzip-compress batch payloads before sending (requires server-side Content-Encoding: gzip support, default: false)",
|
|
62
|
+
"default": false
|
|
15
63
|
}
|
|
16
64
|
},
|
|
17
65
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -1,27 +1,40 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "agentmetrics-openclaw",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "AgentMetrics observability plugin for OpenClaw agents",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"homepage": "https://agentmetrics
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/
|
|
11
|
-
},
|
|
12
|
-
"keywords": [
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "agentmetrics-openclaw",
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "AgentMetrics observability plugin for OpenClaw agents",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/andalabx/agentmetrics",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/andalabx/agentmetrics.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"agentmetrics",
|
|
15
|
+
"observability",
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"monitoring"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"openclaw": ">=2026.3.2"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"openclaw": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"openclaw": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.ts",
|
|
34
|
+
"openclaw.plugin.json",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22"
|
|
39
|
+
}
|
|
40
|
+
}
|