openclaw-observability 2026.3.21 → 2026.3.23

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.
Files changed (43) hide show
  1. package/dist/config.d.ts +11 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +6 -2
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +12 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +805 -98
  8. package/dist/index.js.map +1 -1
  9. package/dist/redaction.d.ts.map +1 -1
  10. package/dist/redaction.js +8 -1
  11. package/dist/redaction.js.map +1 -1
  12. package/dist/security/scanner.d.ts +15 -1
  13. package/dist/security/scanner.d.ts.map +1 -1
  14. package/dist/security/scanner.js +148 -11
  15. package/dist/security/scanner.js.map +1 -1
  16. package/dist/security/types.d.ts +1 -0
  17. package/dist/security/types.d.ts.map +1 -1
  18. package/dist/security/types.js +1 -0
  19. package/dist/security/types.js.map +1 -1
  20. package/dist/storage/duckdb-local-writer.d.ts +2 -1
  21. package/dist/storage/duckdb-local-writer.d.ts.map +1 -1
  22. package/dist/storage/duckdb-local-writer.js +20 -7
  23. package/dist/storage/duckdb-local-writer.js.map +1 -1
  24. package/dist/storage/mysql-writer.d.ts +3 -1
  25. package/dist/storage/mysql-writer.d.ts.map +1 -1
  26. package/dist/storage/mysql-writer.js +11 -6
  27. package/dist/storage/mysql-writer.js.map +1 -1
  28. package/dist/types.d.ts +3 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/types.js +3 -0
  31. package/dist/types.js.map +1 -1
  32. package/dist/web/api.d.ts +2 -2
  33. package/dist/web/api.d.ts.map +1 -1
  34. package/dist/web/api.js +121 -57
  35. package/dist/web/api.js.map +1 -1
  36. package/dist/web/routes.d.ts +7 -3
  37. package/dist/web/routes.d.ts.map +1 -1
  38. package/dist/web/routes.js +176 -25
  39. package/dist/web/routes.js.map +1 -1
  40. package/dist/web/ui.js +1190 -141
  41. package/dist/web/ui.js.map +1 -1
  42. package/openclaw.plugin.json +2 -2
  43. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -6,13 +6,46 @@
6
6
  * OpenClaw hooks:
7
7
  * Agent: before_model_resolve, before_prompt_build, before_agent_start, agent_end
8
8
  * LLM: llm_input, llm_output
9
- * Tool: before_tool_call, after_tool_call, tool_result_persist
9
+ * Tool: before_tool_call, after_tool_call
10
10
  * Msg: message_received, message_sending, message_sent, before_message_write
11
11
  * Ctx: before_compaction, after_compaction, before_reset
12
12
  * Session: session_start, session_end
13
13
  * Subagent: subagent_spawning, subagent_delivery_target, subagent_spawned, subagent_ended
14
14
  * Gateway: gateway_start, gateway_stop
15
15
  */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
16
49
  Object.defineProperty(exports, "__esModule", { value: true });
17
50
  exports.activate = activate;
18
51
  const config_1 = require("./config");
@@ -23,6 +56,10 @@ const duckdb_local_writer_1 = require("./storage/duckdb-local-writer");
23
56
  const buffer_1 = require("./storage/buffer");
24
57
  const routes_1 = require("./web/routes");
25
58
  const scanner_1 = require("./security/scanner");
59
+ const fs = __importStar(require("node:fs"));
60
+ const path = __importStar(require("node:path"));
61
+ const os = __importStar(require("node:os"));
62
+ const node_crypto_1 = require("node:crypto");
26
63
  /* ------------------------------------------------------------------ */
27
64
  /* Runtime state */
28
65
  /* ------------------------------------------------------------------ */
@@ -32,8 +69,16 @@ const MAX_MAP_ENTRIES = 1000;
32
69
  const MAP_ENTRY_TTL_MS = 300_000;
33
70
  /** Max alert buffer size */
34
71
  const MAX_ALERT_BUFFER = 500;
35
- /** Track start time of each LLM/tool call */
36
- const runStartTimes = new Map();
72
+ /** Track start time of each LLM call (runId -> timestamp) */
73
+ const llmRunStartTimes = new Map();
74
+ /** Resolve runtime event stream records back to observability sessions */
75
+ const runSessionIds = new Map();
76
+ /** Track start times of tool calls (supports same-key concurrency via FIFO queue) */
77
+ const toolCallStartTimes = new Map();
78
+ const pendingRuntimeEvents = new Map();
79
+ const runtimeTextSeen = new Set();
80
+ const replayedAssistantMessages = new Map();
81
+ const sessionTranscriptPathCache = new Map();
37
82
  /**
38
83
  * Map: requestUrl → usage from SSE final chunk
39
84
  * We key by a monotonically increasing request ID since we can't know
@@ -41,15 +86,242 @@ const runStartTimes = new Map();
41
86
  */
42
87
  const sseUsageCache = new Map();
43
88
  let sseRequestCounter = 0;
44
- /**
45
- * Most recent SSE usage entry (LIFO — the llm_output hook fires shortly
46
- * after the stream completes, so the latest entry is the one we want)
47
- */
48
- let latestSseUsage = null;
49
89
  /** Original fetch reference */
50
90
  let originalFetch = null;
51
91
  /** Whether the fetch interceptor is installed */
52
92
  let fetchInterceptorInstalled = false;
93
+ function getRuntimeTextSeenKey(runId, stream) {
94
+ return `${runId}:${stream}`;
95
+ }
96
+ function parseMessageTimestamp(value) {
97
+ if (typeof value === 'number' && Number.isFinite(value)) {
98
+ return value > 1e12 ? value : value * 1000;
99
+ }
100
+ if (typeof value === 'string') {
101
+ const parsed = Date.parse(value);
102
+ return Number.isNaN(parsed) ? null : parsed;
103
+ }
104
+ return null;
105
+ }
106
+ function extractTextParts(value) {
107
+ if (typeof value === 'string') {
108
+ const trimmed = value.trim();
109
+ return trimmed ? [trimmed] : [];
110
+ }
111
+ if (!Array.isArray(value))
112
+ return [];
113
+ const parts = [];
114
+ for (const item of value) {
115
+ if (!item || typeof item !== 'object')
116
+ continue;
117
+ const block = item;
118
+ const type = typeof block.type === 'string' ? block.type : '';
119
+ if (type === 'text') {
120
+ const text = typeof block.text === 'string' ? block.text.trim() : '';
121
+ if (text)
122
+ parts.push(text);
123
+ continue;
124
+ }
125
+ if (type === 'thinking') {
126
+ const thinking = typeof block.thinking === 'string' ? block.thinking.trim() : '';
127
+ if (thinking)
128
+ parts.push(thinking);
129
+ continue;
130
+ }
131
+ }
132
+ return parts;
133
+ }
134
+ function extractAssistantArtifacts(lastAssistant) {
135
+ if (!lastAssistant || typeof lastAssistant !== 'object') {
136
+ return { texts: [], thinking: [] };
137
+ }
138
+ const source = lastAssistant;
139
+ const messageRecord = source.message && typeof source.message === 'object'
140
+ ? source.message
141
+ : source;
142
+ const content = messageRecord.content;
143
+ if (!Array.isArray(content)) {
144
+ return {
145
+ texts: extractTextParts(content),
146
+ thinking: [],
147
+ };
148
+ }
149
+ const texts = [];
150
+ const thinking = [];
151
+ for (const item of content) {
152
+ if (!item || typeof item !== 'object')
153
+ continue;
154
+ const block = item;
155
+ const type = typeof block.type === 'string' ? block.type : '';
156
+ if (type === 'text') {
157
+ const text = typeof block.text === 'string' ? block.text.trim() : '';
158
+ if (text)
159
+ texts.push(text);
160
+ continue;
161
+ }
162
+ if (type === 'thinking') {
163
+ const value = typeof block.thinking === 'string' ? block.thinking.trim() : '';
164
+ if (value)
165
+ thinking.push(value);
166
+ }
167
+ }
168
+ return { texts, thinking };
169
+ }
170
+ function extractToolUseAssistantMessage(historyMessages) {
171
+ for (let i = historyMessages.length - 1; i >= 0; i--) {
172
+ const item = historyMessages[i];
173
+ if (!item || typeof item !== 'object')
174
+ continue;
175
+ const msg = item;
176
+ if (msg.role !== 'assistant')
177
+ continue;
178
+ const content = Array.isArray(msg.content) ? msg.content : [];
179
+ if (content.length === 0)
180
+ continue;
181
+ const texts = [];
182
+ const thinking = [];
183
+ const toolCallIds = [];
184
+ let hasToolCall = false;
185
+ for (const blockValue of content) {
186
+ if (!blockValue || typeof blockValue !== 'object')
187
+ continue;
188
+ const block = blockValue;
189
+ const type = typeof block.type === 'string' ? block.type : '';
190
+ if (type === 'text') {
191
+ const text = typeof block.text === 'string' ? block.text.trim() : '';
192
+ if (text)
193
+ texts.push(text);
194
+ continue;
195
+ }
196
+ if (type === 'thinking') {
197
+ const text = typeof block.thinking === 'string' ? block.thinking.trim() : '';
198
+ if (text)
199
+ thinking.push(text);
200
+ continue;
201
+ }
202
+ if (type === 'toolCall') {
203
+ hasToolCall = true;
204
+ if (typeof block.id === 'string' && block.id.trim()) {
205
+ toolCallIds.push(block.id);
206
+ }
207
+ }
208
+ }
209
+ const stopReason = typeof msg.stopReason === 'string' ? msg.stopReason : undefined;
210
+ if (!hasToolCall && stopReason !== 'toolUse')
211
+ continue;
212
+ if (texts.length === 0 && thinking.length === 0)
213
+ continue;
214
+ return {
215
+ messageId: typeof msg.id === 'string' ? msg.id : undefined,
216
+ timestampMs: parseMessageTimestamp(msg.timestamp),
217
+ stopReason,
218
+ texts,
219
+ thinking,
220
+ toolCallIds,
221
+ };
222
+ }
223
+ return null;
224
+ }
225
+ function resolveSessionTranscriptPath(sessionId) {
226
+ if (!sessionId || sessionId === 'unknown')
227
+ return null;
228
+ if (sessionTranscriptPathCache.has(sessionId)) {
229
+ return sessionTranscriptPathCache.get(sessionId) ?? null;
230
+ }
231
+ try {
232
+ const agentsRoot = path.join(os.homedir(), '.openclaw', 'agents');
233
+ const agentDirs = fs.readdirSync(agentsRoot, { withFileTypes: true });
234
+ for (const dir of agentDirs) {
235
+ if (!dir.isDirectory())
236
+ continue;
237
+ const candidate = path.join(agentsRoot, dir.name, 'sessions', `${sessionId}.jsonl`);
238
+ if (fs.existsSync(candidate)) {
239
+ sessionTranscriptPathCache.set(sessionId, candidate);
240
+ return candidate;
241
+ }
242
+ }
243
+ }
244
+ catch {
245
+ // ignore lookup failures
246
+ }
247
+ sessionTranscriptPathCache.set(sessionId, null);
248
+ return null;
249
+ }
250
+ function extractToolUseAssistantMessagesFromTranscript(params) {
251
+ const transcriptPath = resolveSessionTranscriptPath(params.sessionId);
252
+ if (!transcriptPath)
253
+ return [];
254
+ const lowerBound = (params.runStartedAt ?? params.runEndedAt - 300_000) - 2000;
255
+ const upperBound = params.runEndedAt + 2000;
256
+ let raw = '';
257
+ try {
258
+ raw = fs.readFileSync(transcriptPath, 'utf-8');
259
+ }
260
+ catch {
261
+ return [];
262
+ }
263
+ const results = [];
264
+ const lines = raw.split('\n');
265
+ for (const line of lines) {
266
+ if (!line.trim())
267
+ continue;
268
+ let record;
269
+ try {
270
+ record = JSON.parse(line);
271
+ }
272
+ catch {
273
+ continue;
274
+ }
275
+ if (record.type !== 'message')
276
+ continue;
277
+ const message = record.message;
278
+ if (!message || message.role !== 'assistant')
279
+ continue;
280
+ const timestampMs = parseMessageTimestamp(record.timestamp);
281
+ if (timestampMs === null || timestampMs < lowerBound || timestampMs > upperBound)
282
+ continue;
283
+ const content = Array.isArray(message.content) ? message.content : [];
284
+ if (content.length === 0)
285
+ continue;
286
+ const texts = [];
287
+ const thinking = [];
288
+ const toolCallIds = [];
289
+ let hasToolCall = false;
290
+ for (const blockValue of content) {
291
+ if (!blockValue || typeof blockValue !== 'object')
292
+ continue;
293
+ const block = blockValue;
294
+ const type = typeof block.type === 'string' ? block.type : '';
295
+ if (type === 'text') {
296
+ const text = typeof block.text === 'string' ? block.text.trim() : '';
297
+ if (text)
298
+ texts.push(text);
299
+ continue;
300
+ }
301
+ if (type === 'thinking') {
302
+ const text = typeof block.thinking === 'string' ? block.thinking.trim() : '';
303
+ if (text)
304
+ thinking.push(text);
305
+ continue;
306
+ }
307
+ if (type === 'toolCall') {
308
+ hasToolCall = true;
309
+ if (typeof block.id === 'string' && block.id.trim()) {
310
+ toolCallIds.push(block.id);
311
+ }
312
+ }
313
+ }
314
+ const stopReason = typeof message.stopReason === 'string' ? message.stopReason : undefined;
315
+ if (!hasToolCall && stopReason !== 'toolUse')
316
+ continue;
317
+ if (texts.length === 0 && thinking.length === 0)
318
+ continue;
319
+ const messageId = typeof record.id === 'string' ? record.id : `${timestampMs}:${toolCallIds.join(',')}`;
320
+ results.push({ messageId, timestampMs, stopReason, texts, thinking, toolCallIds });
321
+ }
322
+ results.sort((a, b) => a.timestampMs - b.timestampMs);
323
+ return results;
324
+ }
53
325
  function installFetchInterceptor() {
54
326
  if (fetchInterceptorInstalled)
55
327
  return;
@@ -142,7 +414,6 @@ function installFetchInterceptor() {
142
414
  processSseLines();
143
415
  if (extractedUsage) {
144
416
  sseUsageCache.set(requestId, extractedUsage);
145
- latestSseUsage = extractedUsage;
146
417
  }
147
418
  controller.close();
148
419
  break;
@@ -175,11 +446,28 @@ function uninstallFetchInterceptor() {
175
446
  }
176
447
  }
177
448
  /**
178
- * Pop the most recent SSE usage (consume once per llm_output call)
449
+ * Consume one SSE usage entry for a specific run.
450
+ * Picks the earliest entry whose timestamp is not older than run start
451
+ * (with small clock-tolerance). Falls back to FIFO oldest entry.
179
452
  */
180
- function popLatestSseUsage() {
181
- const usage = latestSseUsage;
182
- latestSseUsage = null;
453
+ function consumeSseUsageForRun(runStartedAt) {
454
+ if (sseUsageCache.size === 0)
455
+ return null;
456
+ const entries = Array.from(sseUsageCache.entries()).sort((a, b) => a[0] - b[0]);
457
+ let selectedKey = null;
458
+ if (typeof runStartedAt === 'number') {
459
+ const toleranceMs = 1000;
460
+ for (const [key, usage] of entries) {
461
+ if (usage.timestamp + toleranceMs >= runStartedAt) {
462
+ selectedKey = key;
463
+ break;
464
+ }
465
+ }
466
+ }
467
+ if (selectedKey === null)
468
+ selectedKey = entries[0][0];
469
+ const usage = sseUsageCache.get(selectedKey) ?? null;
470
+ sseUsageCache.delete(selectedKey);
183
471
  // Also trim old cache entries
184
472
  if (sseUsageCache.size > 100) {
185
473
  const keys = Array.from(sseUsageCache.keys()).sort((a, b) => a - b);
@@ -190,14 +478,61 @@ function popLatestSseUsage() {
190
478
  return usage;
191
479
  }
192
480
  const runInputs = new Map();
481
+ /** Track latest completed tool-call timestamp for each run (for thinking timeline alignment) */
482
+ const runLastToolCompletedAt = new Map();
483
+ /** Track latest assistant stream event timestamp for each run (for thinking end-time clamping) */
484
+ const runLastAssistantStreamAt = new Map();
485
+ /** Track first assistant stream event timestamp for each run (assistant start-time anchor) */
486
+ const runFirstAssistantStreamAt = new Map();
487
+ function summarizePromptMessages(messages) {
488
+ const roleCounts = {};
489
+ const firstRoles = [];
490
+ if (!Array.isArray(messages) || messages.length === 0) {
491
+ return { messageCount: 0, roleCounts, firstRoles };
492
+ }
493
+ for (let i = 0; i < messages.length; i++) {
494
+ const item = messages[i];
495
+ if (!item || typeof item !== 'object')
496
+ continue;
497
+ const role = String(item.role || 'unknown');
498
+ roleCounts[role] = (roleCounts[role] ?? 0) + 1;
499
+ if (firstRoles.length < 20) {
500
+ firstRoles.push(role);
501
+ }
502
+ }
503
+ return {
504
+ messageCount: messages.length,
505
+ roleCounts,
506
+ firstRoles,
507
+ };
508
+ }
509
+ function makeToolTimingKey(sessionId, runId, toolName, toolCallId) {
510
+ if (toolCallId)
511
+ return `tc:id:${toolCallId}`;
512
+ return `tc:fallback:${sessionId}:${runId ?? 'norun'}:${toolName}`;
513
+ }
193
514
  /**
194
515
  * Periodically clean up stale Map entries to prevent memory leaks when llm_input fires but llm_output does not
195
516
  */
196
517
  function cleanupStaleMaps() {
197
518
  const now = Date.now();
198
- for (const [key, startTime] of runStartTimes) {
519
+ for (const [key, startTime] of llmRunStartTimes) {
199
520
  if (now - startTime > MAP_ENTRY_TTL_MS) {
200
- runStartTimes.delete(key);
521
+ llmRunStartTimes.delete(key);
522
+ runSessionIds.delete(key);
523
+ runInputs.delete(key);
524
+ runLastToolCompletedAt.delete(key);
525
+ runLastAssistantStreamAt.delete(key);
526
+ runFirstAssistantStreamAt.delete(key);
527
+ }
528
+ }
529
+ for (const [key, queue] of toolCallStartTimes) {
530
+ const filtered = queue.filter((t) => now - t <= MAP_ENTRY_TTL_MS);
531
+ if (filtered.length === 0) {
532
+ toolCallStartTimes.delete(key);
533
+ }
534
+ else if (filtered.length !== queue.length) {
535
+ toolCallStartTimes.set(key, filtered);
201
536
  }
202
537
  }
203
538
  // runInputs has no timestamps; trim oldest entries when exceeding size limit
@@ -230,6 +565,18 @@ function cleanupStaleMaps() {
230
565
  sessionStatsMap.delete(entries[i][0]);
231
566
  }
232
567
  }
568
+ for (const [key, pending] of pendingRuntimeEvents) {
569
+ if (now - pending.lastEventAt > MAP_ENTRY_TTL_MS) {
570
+ if (pending.timer)
571
+ clearTimeout(pending.timer);
572
+ pendingRuntimeEvents.delete(key);
573
+ }
574
+ }
575
+ for (const [key, ts] of replayedAssistantMessages) {
576
+ if (now - ts > MAP_ENTRY_TTL_MS) {
577
+ replayedAssistantMessages.delete(key);
578
+ }
579
+ }
233
580
  }
234
581
  /**
235
582
  * Extract image/media metadata from historyMessages
@@ -446,6 +793,7 @@ let _alertBuffer = [];
446
793
  let _alertFlushTimer = null;
447
794
  let _mapCleanupTimer = null;
448
795
  let _checkpointTimer = null;
796
+ let _unsubscribeAgentEvents = null;
449
797
  function activate(api) {
450
798
  const rawConfig = (api.pluginConfig || api.config || {});
451
799
  const config = (0, config_1.resolveConfig)(rawConfig);
@@ -482,9 +830,38 @@ function activate(api) {
482
830
  console.log('[openclaw-observability] Reusing existing writer/buffer (skipping duplicate init)');
483
831
  }
484
832
  const redactor = new redaction_1.Redactor(config.redaction);
485
- // Security scanner
486
- const securityConfig = (0, scanner_1.resolveSecurityConfig)(config.security);
833
+ const stateDir = process.env.OPENCLAW_STATE_DIR ?? path.join(os.homedir(), '.openclaw');
834
+ // Security scanner (supports runtime rule toggles from UI)
835
+ const securityConfigPath = path.join(stateDir, 'data', 'observability-security-config.json');
836
+ let securityConfig = (0, scanner_1.resolveSecurityConfig)(config.security);
837
+ try {
838
+ if (fs.existsSync(securityConfigPath)) {
839
+ const raw = fs.readFileSync(securityConfigPath, 'utf8');
840
+ const parsed = JSON.parse(raw);
841
+ securityConfig = (0, scanner_1.resolveSecurityConfig)({
842
+ ...securityConfig,
843
+ ...parsed,
844
+ rules: {
845
+ ...securityConfig.rules,
846
+ ...(parsed.rules ?? {}),
847
+ },
848
+ });
849
+ console.log('[openclaw-observability-security] Loaded runtime security config from ' + securityConfigPath);
850
+ }
851
+ }
852
+ catch (e) {
853
+ console.warn('[openclaw-observability-security] Failed to load runtime security config:', e.message);
854
+ }
487
855
  const securityScanner = new scanner_1.SecurityScanner(securityConfig);
856
+ function persistSecurityConfig(nextConfig) {
857
+ try {
858
+ fs.mkdirSync(path.dirname(securityConfigPath), { recursive: true });
859
+ fs.writeFileSync(securityConfigPath, JSON.stringify(nextConfig, null, 2), 'utf8');
860
+ }
861
+ catch (e) {
862
+ console.error('[openclaw-observability-security] Failed to persist runtime security config:', e);
863
+ }
864
+ }
488
865
  function startServices() {
489
866
  if (_servicesStarted)
490
867
  return;
@@ -530,15 +907,49 @@ function activate(api) {
530
907
  'Please configure via Dashboard (Settings → Plugins → openclaw-observability Config) then restart.');
531
908
  }
532
909
  }
533
- // Observability UI auth: prefer plugin ui.accessToken, else gateway token from config, else built-in default
534
- const gatewayAuth = api.config?.gateway?.auth;
535
- const gatewayToken = (typeof gatewayAuth?.token === 'string' && gatewayAuth.token.length > 0)
536
- ? gatewayAuth.token
537
- : process.env.OPENCLAW_GATEWAY_TOKEN;
538
- const observabilityToken = config.ui?.accessToken ?? gatewayToken ?? 'openclaw-observability-default';
910
+ // Observability UI auth: read gateway.auth.token from same config file as gateway; if missing or empty, no auth (allow all)
911
+ const openclawConfigPath = process.env.OPENCLAW_CONFIG_PATH ?? path.join(stateDir, 'openclaw.json');
912
+ let observabilityToken;
913
+ try {
914
+ if (fs.existsSync(openclawConfigPath)) {
915
+ const raw = fs.readFileSync(openclawConfigPath, 'utf8');
916
+ let t;
917
+ try {
918
+ const parsed = JSON.parse(raw);
919
+ t = parsed?.gateway?.auth?.token;
920
+ }
921
+ catch {
922
+ // Config may be JSON5 (comments, unescaped newlines in strings); extract token with regex
923
+ const m = raw.match(/"gateway"\s*:\s*\{[\s\S]*?"auth"\s*:\s*\{[\s\S]*?"token"\s*:\s*"((?:[^"\\]|\\.)*)"/);
924
+ t = m?.[1]?.replace(/\\(.)/g, '$1');
925
+ }
926
+ if (typeof t === 'string' && t.length > 0)
927
+ observabilityToken = t;
928
+ }
929
+ }
930
+ catch (e) {
931
+ console.warn('[openclaw-observability] Failed to read gateway token from config:', e.message);
932
+ }
933
+ if (observabilityToken) {
934
+ console.log('[openclaw-observability] Observability UI auth: token required (from ' + openclawConfigPath + ')');
935
+ }
936
+ else {
937
+ console.log('[openclaw-observability] Observability UI auth: no token, allow all');
938
+ }
539
939
  // Always register HTTP routes on the current api (the latest api serves HTTP)
540
940
  if (typeof api.registerHttpRoute === 'function') {
541
- (0, routes_1.registerAuditRoutes)(api.registerHttpRoute.bind(api), writer, observabilityToken);
941
+ (0, routes_1.registerAuditRoutes)(api.registerHttpRoute.bind(api), writer, observabilityToken, {
942
+ getConfig: () => securityScanner.getConfig(),
943
+ updateConfig: (patch) => {
944
+ const next = securityScanner.updateConfig(patch);
945
+ persistSecurityConfig(next);
946
+ console.log(`[openclaw-observability-security] Runtime config updated: enabled=${next.enabled} ` +
947
+ `secretLeakage=${next.rules.secretLeakage} highRiskOps=${next.rules.highRiskOps} dataExfiltration=${next.rules.dataExfiltration} ` +
948
+ `promptInjection=${next.rules.promptInjection} customRegex=${next.rules.customRegex} chainDetection=${next.rules.chainDetection} ` +
949
+ `customRegexRules=${next.customRegexRules.length}`);
950
+ return next;
951
+ },
952
+ });
542
953
  }
543
954
  else {
544
955
  console.warn('[openclaw-observability] registerHttpRoute not available — observability UI disabled');
@@ -550,30 +961,28 @@ function activate(api) {
550
961
  const session = updateSessionStats(action.sessionId, action.modelName, action.userId, tokens);
551
962
  void buffer.addSession(session);
552
963
  // --- Security scan ---
553
- if (securityConfig.enabled) {
554
- try {
555
- const alerts = securityScanner.scan(action);
556
- if (alerts.length > 0) {
557
- // Prevent _alertBuffer from growing unbounded
558
- if (_alertBuffer.length + alerts.length > MAX_ALERT_BUFFER) {
559
- const overflow = _alertBuffer.length + alerts.length - MAX_ALERT_BUFFER;
560
- _alertBuffer.splice(0, overflow);
561
- console.warn(`[openclaw-observability-security] Alert buffer overflow, dropped ${overflow} oldest alerts`);
562
- }
563
- _alertBuffer.push(...alerts);
564
- for (const alert of alerts) {
565
- const icon = alert.severity === 'critical' ? '🔴' : alert.severity === 'warn' ? '🟡' : 'ℹ️';
566
- console.log(`[openclaw-observability-security] ${icon} ${alert.severity.toUpperCase()} ${alert.ruleId}: ${alert.ruleName} — ${alert.finding} (session=${alert.sessionId})`);
567
- }
568
- // Flush immediately on CRITICAL alerts
569
- if (alerts.some(a => a.severity === 'critical')) {
570
- void flushAlerts();
571
- }
964
+ try {
965
+ const alerts = securityScanner.scan(action);
966
+ if (alerts.length > 0) {
967
+ // Prevent _alertBuffer from growing unbounded
968
+ if (_alertBuffer.length + alerts.length > MAX_ALERT_BUFFER) {
969
+ const overflow = _alertBuffer.length + alerts.length - MAX_ALERT_BUFFER;
970
+ _alertBuffer.splice(0, overflow);
971
+ console.warn(`[openclaw-observability-security] Alert buffer overflow, dropped ${overflow} oldest alerts`);
972
+ }
973
+ _alertBuffer.push(...alerts);
974
+ for (const alert of alerts) {
975
+ const icon = alert.severity === 'critical' ? '🔴' : alert.severity === 'warn' ? '🟡' : 'ℹ️';
976
+ console.log(`[openclaw-observability-security] ${icon} ${alert.severity.toUpperCase()} ${alert.ruleId}: ${alert.ruleName} ${alert.finding} (session=${alert.sessionId})`);
977
+ }
978
+ // Flush immediately on CRITICAL alerts
979
+ if (alerts.some(a => a.severity === 'critical')) {
980
+ void flushAlerts();
572
981
  }
573
982
  }
574
- catch (err) {
575
- console.error('[openclaw-observability-security] Scan error:', err);
576
- }
983
+ }
984
+ catch (err) {
985
+ console.error('[openclaw-observability-security] Scan error:', err);
577
986
  }
578
987
  }
579
988
  /** Batch-write security alerts */
@@ -590,12 +999,6 @@ function activate(api) {
590
999
  _alertBuffer.unshift(...batch);
591
1000
  }
592
1001
  }
593
- /** Safely truncate a string */
594
- function truncate(s, max = 500) {
595
- if (typeof s !== 'string')
596
- return '';
597
- return s.length > max ? s.substring(0, max) + '…' : s;
598
- }
599
1002
  /** Convert bytes to human-readable format */
600
1003
  function formatBytes(bytes) {
601
1004
  if (bytes < 1024)
@@ -604,6 +1007,141 @@ function activate(api) {
604
1007
  return `${(bytes / 1024).toFixed(1)} KB`;
605
1008
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
606
1009
  }
1010
+ function flushPendingRuntimeEvent(key) {
1011
+ const pending = pendingRuntimeEvents.get(key);
1012
+ if (!pending)
1013
+ return;
1014
+ if (pending.timer) {
1015
+ clearTimeout(pending.timer);
1016
+ pending.timer = null;
1017
+ }
1018
+ pendingRuntimeEvents.delete(key);
1019
+ const text = pending.text.trim();
1020
+ if (!text)
1021
+ return;
1022
+ recordAction(makeAction(pending.sessionId, {
1023
+ actionType: pending.actionType,
1024
+ actionName: `${pending.stream}_stream`,
1025
+ inputParams: redactor.redact({
1026
+ runId: pending.runId,
1027
+ stream: pending.stream,
1028
+ }),
1029
+ outputResult: redactor.redact({
1030
+ text,
1031
+ length: text.length,
1032
+ }),
1033
+ durationMs: Math.max(0, pending.lastEventAt - pending.startedAt),
1034
+ createdAt: new Date(pending.lastEventAt),
1035
+ }));
1036
+ }
1037
+ function queueRuntimeTextEvent(params) {
1038
+ const key = `${params.runId}:${params.stream}`;
1039
+ const existing = pendingRuntimeEvents.get(key);
1040
+ if (existing) {
1041
+ existing.lastEventAt = params.ts;
1042
+ existing.text = params.text;
1043
+ if (existing.timer)
1044
+ clearTimeout(existing.timer);
1045
+ existing.timer = setTimeout(() => flushPendingRuntimeEvent(key), 900);
1046
+ if (existing.timer && typeof existing.timer === 'object' && 'unref' in existing.timer) {
1047
+ existing.timer.unref();
1048
+ }
1049
+ return;
1050
+ }
1051
+ const timer = setTimeout(() => flushPendingRuntimeEvent(key), 900);
1052
+ if (timer && typeof timer === 'object' && 'unref' in timer) {
1053
+ timer.unref();
1054
+ }
1055
+ pendingRuntimeEvents.set(key, {
1056
+ actionType: params.actionType,
1057
+ sessionId: params.sessionId,
1058
+ runId: params.runId,
1059
+ stream: params.stream,
1060
+ startedAt: params.ts,
1061
+ lastEventAt: params.ts,
1062
+ text: params.text,
1063
+ timer,
1064
+ });
1065
+ }
1066
+ function flushPendingRuntimeEventsForRun(runId) {
1067
+ const keys = Array.from(pendingRuntimeEvents.keys()).filter((key) => key.startsWith(`${runId}:`));
1068
+ keys.forEach((key) => flushPendingRuntimeEvent(key));
1069
+ }
1070
+ if (!_unsubscribeAgentEvents && typeof api.runtime?.events?.onAgentEvent === 'function') {
1071
+ const unsubscribe = api.runtime.events.onAgentEvent((evt) => {
1072
+ try {
1073
+ if (!evt?.runId || !evt?.stream)
1074
+ return;
1075
+ const sessionId = runSessionIds.get(evt.runId);
1076
+ if (!sessionId || sessionId === 'unknown')
1077
+ return;
1078
+ if (evt.stream === 'assistant' || evt.stream === 'thinking') {
1079
+ const fullText = typeof evt.data?.text === 'string'
1080
+ ? evt.data.text
1081
+ : typeof evt.data?.delta === 'string'
1082
+ ? evt.data.delta
1083
+ : '';
1084
+ if (!fullText.trim())
1085
+ return;
1086
+ runtimeTextSeen.add(getRuntimeTextSeenKey(evt.runId, evt.stream));
1087
+ if (evt.stream === 'assistant') {
1088
+ if (!runFirstAssistantStreamAt.has(evt.runId)) {
1089
+ runFirstAssistantStreamAt.set(evt.runId, typeof evt.ts === 'number' ? evt.ts : Date.now());
1090
+ }
1091
+ runLastAssistantStreamAt.set(evt.runId, typeof evt.ts === 'number' ? evt.ts : Date.now());
1092
+ }
1093
+ queueRuntimeTextEvent({
1094
+ sessionId,
1095
+ runId: evt.runId,
1096
+ stream: evt.stream,
1097
+ actionType: evt.stream === 'assistant' ? types_1.ActionType.AssistantStream : types_1.ActionType.Thinking,
1098
+ ts: typeof evt.ts === 'number' ? evt.ts : Date.now(),
1099
+ text: fullText,
1100
+ });
1101
+ return;
1102
+ }
1103
+ if (evt.stream === 'tool') {
1104
+ flushPendingRuntimeEventsForRun(evt.runId);
1105
+ const phase = typeof evt.data?.phase === 'string' ? evt.data.phase : '';
1106
+ const name = typeof evt.data?.name === 'string' ? evt.data.name : 'unknown';
1107
+ if (phase !== 'start' && phase !== 'update')
1108
+ return;
1109
+ const payload = { runId: evt.runId, phase };
1110
+ if (typeof evt.data?.toolCallId === 'string')
1111
+ payload.toolCallId = evt.data.toolCallId;
1112
+ if (phase === 'update' && evt.data?.partialResult !== undefined) {
1113
+ payload.partialResult = evt.data.partialResult;
1114
+ }
1115
+ recordAction(makeAction(sessionId, {
1116
+ actionType: types_1.ActionType.ToolUpdate,
1117
+ actionName: phase === 'start' ? `tool_start:${name}` : `tool_update:${name}`,
1118
+ inputParams: redactor.redact(payload),
1119
+ createdAt: new Date(typeof evt.ts === 'number' ? evt.ts : Date.now()),
1120
+ }));
1121
+ return;
1122
+ }
1123
+ if (evt.stream === 'lifecycle') {
1124
+ const phase = typeof evt.data?.phase === 'string' ? evt.data.phase : '';
1125
+ if (phase === 'end' || phase === 'error') {
1126
+ flushPendingRuntimeEventsForRun(evt.runId);
1127
+ }
1128
+ }
1129
+ }
1130
+ catch (err) {
1131
+ console.error('[openclaw-observability] Error handling runtime agent event:', err);
1132
+ }
1133
+ });
1134
+ if (typeof unsubscribe === 'function') {
1135
+ _unsubscribeAgentEvents = unsubscribe;
1136
+ }
1137
+ console.log('[openclaw-observability] Runtime agent event listener attached');
1138
+ }
1139
+ else if (_unsubscribeAgentEvents) {
1140
+ console.log('[openclaw-observability] Reusing existing runtime agent event listener');
1141
+ }
1142
+ else {
1143
+ console.log('[openclaw-observability] Runtime agent events unavailable on this OpenClaw build');
1144
+ }
607
1145
  // =====================================================================
608
1146
  // 1. Agent lifecycle
609
1147
  // =====================================================================
@@ -620,7 +1158,7 @@ function activate(api) {
620
1158
  actionType: types_1.ActionType.ModelResolve,
621
1159
  actionName: 'before_model_resolve',
622
1160
  userId: c?.agentId,
623
- inputParams: redactor.redact({ prompt: truncate(e.prompt) }),
1161
+ inputParams: redactor.redact({ prompt: e.prompt }),
624
1162
  }));
625
1163
  console.log(`[openclaw-observability] before_model_resolve: session=${sid}`);
626
1164
  }
@@ -634,13 +1172,16 @@ function activate(api) {
634
1172
  const e = event;
635
1173
  const c = ctx;
636
1174
  const sid = resolveSessionId(undefined, c?.sessionId);
1175
+ const summary = summarizePromptMessages(e.messages);
637
1176
  recordAction(makeAction(sid, {
638
1177
  actionType: types_1.ActionType.PromptBuild,
639
1178
  actionName: 'before_prompt_build',
640
1179
  userId: c?.agentId,
641
1180
  inputParams: redactor.redact({
642
- prompt: truncate(e.prompt),
643
- messageCount: Array.isArray(e.messages) ? e.messages.length : 0,
1181
+ prompt: e.prompt,
1182
+ messageCount: summary.messageCount,
1183
+ messageRoleCounts: summary.roleCounts,
1184
+ firstMessageRoles: summary.firstRoles,
644
1185
  }),
645
1186
  }));
646
1187
  console.log(`[openclaw-observability] before_prompt_build: session=${sid} msgs=${Array.isArray(e.messages) ? e.messages.length : '?'}`);
@@ -686,10 +1227,46 @@ function activate(api) {
686
1227
  const sctx = getSessionCtx(sid);
687
1228
  // Parse image/media metadata from historyMessages
688
1229
  const media = extractMediaMeta(e.historyMessages ?? []);
1230
+ const modelName = `${e.provider}/${e.model}`;
1231
+ const priorAssistant = extractToolUseAssistantMessage(e.historyMessages ?? []);
1232
+ if (priorAssistant && e.runId) {
1233
+ const replayKey = [
1234
+ sid,
1235
+ e.runId,
1236
+ priorAssistant.messageId ?? String(priorAssistant.timestampMs ?? 'notime'),
1237
+ priorAssistant.toolCallIds.join(','),
1238
+ ].join(':');
1239
+ if (!replayedAssistantMessages.has(replayKey)) {
1240
+ replayedAssistantMessages.set(replayKey, Date.now());
1241
+ const replayAt = new Date(priorAssistant.timestampMs ?? Math.max(Date.now() - 5, 0));
1242
+ const replayMeta = {
1243
+ runId: e.runId,
1244
+ source: 'history_message',
1245
+ synthetic: true,
1246
+ stopReason: priorAssistant.stopReason ?? 'toolUse',
1247
+ messageId: priorAssistant.messageId,
1248
+ toolCallIds: priorAssistant.toolCallIds,
1249
+ };
1250
+ if (priorAssistant.texts.length > 0) {
1251
+ const assistantText = priorAssistant.texts.join('\n\n');
1252
+ recordAction(makeAction(sid, {
1253
+ actionType: types_1.ActionType.AssistantStream,
1254
+ actionName: 'assistant_stream',
1255
+ modelName,
1256
+ inputParams: redactor.redact(replayMeta),
1257
+ outputResult: redactor.redact({ text: assistantText, length: assistantText.length }),
1258
+ createdAt: new Date(replayAt.getTime() + 1),
1259
+ userId: c?.agentId,
1260
+ }));
1261
+ }
1262
+ }
1263
+ }
689
1264
  if (e.runId) {
690
- runStartTimes.set(e.runId, Date.now());
1265
+ llmRunStartTimes.set(e.runId, Date.now());
1266
+ runSessionIds.set(e.runId, sid);
691
1267
  runInputs.set(e.runId, {
692
1268
  prompt: e.prompt,
1269
+ systemPrompt: typeof e.systemPrompt === 'string' ? e.systemPrompt : undefined,
693
1270
  imagesCount: e.imagesCount,
694
1271
  media: media.length > 0 ? media : undefined,
695
1272
  });
@@ -701,7 +1278,7 @@ function activate(api) {
701
1278
  saveChannelToContext(sid, identifiedChannel);
702
1279
  // Use identified channel or existing context channel
703
1280
  const channelId = identifiedChannel !== 'webchat' ? identifiedChannel : (sctx.channelId || 'webchat');
704
- sctx.modelName = `${e.provider}/${e.model}`;
1281
+ sctx.modelName = modelName;
705
1282
  console.log(`[openclaw-observability] llm_input: session=${sid} model=${e.provider}/${e.model} runId=${e.runId} channel=${channelId} images=${e.imagesCount ?? 0} mediaParts=${media.length}`);
706
1283
  }
707
1284
  catch (err) {
@@ -715,9 +1292,12 @@ function activate(api) {
715
1292
  const c = ctx;
716
1293
  // Calculate duration
717
1294
  let durationMs = null;
718
- if (e.runId && runStartTimes.has(e.runId)) {
719
- durationMs = Date.now() - runStartTimes.get(e.runId);
720
- runStartTimes.delete(e.runId);
1295
+ const runStartedAt = e.runId ? llmRunStartTimes.get(e.runId) : undefined;
1296
+ const runLastToolAt = e.runId ? runLastToolCompletedAt.get(e.runId) : undefined;
1297
+ const runAssistantAt = e.runId ? runLastAssistantStreamAt.get(e.runId) : undefined;
1298
+ const runAssistantFirstAt = e.runId ? runFirstAssistantStreamAt.get(e.runId) : undefined;
1299
+ if (typeof runStartedAt === 'number') {
1300
+ durationMs = Date.now() - runStartedAt;
721
1301
  }
722
1302
  // Retrieve cached user input
723
1303
  const cachedInput = e.runId ? runInputs.get(e.runId) : undefined;
@@ -748,7 +1328,7 @@ function activate(api) {
748
1328
  }
749
1329
  // Strategy 3: SSE usage captured by fetch interceptor (stream_options injection)
750
1330
  if (promptTokens === null && completionTokens === null) {
751
- const sseUsage = popLatestSseUsage();
1331
+ const sseUsage = consumeSseUsageForRun(runStartedAt);
752
1332
  if (sseUsage) {
753
1333
  promptTokens = sseUsage.input || null;
754
1334
  completionTokens = sseUsage.output || null;
@@ -758,11 +1338,22 @@ function activate(api) {
758
1338
  }
759
1339
  const totalTokens = (promptTokens ?? 0) + (completionTokens ?? 0);
760
1340
  const modelName = `${e.provider}/${e.model}`;
1341
+ const fallbackArtifacts = extractAssistantArtifacts(e.lastAssistant);
1342
+ const assistantTexts = Array.isArray(e.assistantTexts) && e.assistantTexts.some((text) => typeof text === 'string' && text.trim())
1343
+ ? e.assistantTexts.filter((text) => typeof text === 'string' && text.trim().length > 0)
1344
+ : fallbackArtifacts.texts;
761
1345
  // Build inputParams with media metadata
762
1346
  const inputData = {
1347
+ runId: e.runId,
763
1348
  userMessage: cachedInput?.prompt ?? '',
764
1349
  imagesCount: cachedInput?.imagesCount ?? 0,
765
1350
  };
1351
+ if (typeof cachedInput?.systemPrompt === 'string' && cachedInput.systemPrompt.length > 0) {
1352
+ inputData.systemPrompt = cachedInput.systemPrompt;
1353
+ inputData.systemPromptHash = (0, node_crypto_1.createHash)('sha256')
1354
+ .update(cachedInput.systemPrompt, 'utf8')
1355
+ .digest('hex');
1356
+ }
766
1357
  if (cachedInput?.media && cachedInput.media.length > 0) {
767
1358
  inputData.media = cachedInput.media.map((m) => ({
768
1359
  mimeType: m.mimeType,
@@ -782,14 +1373,127 @@ function activate(api) {
782
1373
  modelName,
783
1374
  inputParams: redactor.redact(inputData),
784
1375
  outputResult: redactor.redact({
785
- assistantTexts: e.assistantTexts ?? [],
1376
+ runId: e.runId,
1377
+ assistantTexts,
1378
+ thinking: fallbackArtifacts.thinking,
1379
+ usage: {
1380
+ promptTokens,
1381
+ completionTokens,
1382
+ totalTokens,
1383
+ cacheRead,
1384
+ cacheWrite,
1385
+ },
786
1386
  }),
787
1387
  promptTokens,
788
1388
  completionTokens,
789
1389
  durationMs,
790
1390
  userId: c?.agentId,
791
1391
  }), totalTokens);
1392
+ const endedAt = Date.now();
1393
+ const syntheticTs = endedAt;
1394
+ if (e.runId) {
1395
+ const transcriptToolUseMessages = extractToolUseAssistantMessagesFromTranscript({
1396
+ sessionId: sid,
1397
+ runStartedAt,
1398
+ runEndedAt: endedAt,
1399
+ });
1400
+ for (const msg of transcriptToolUseMessages) {
1401
+ const replayKey = `${sid}:${e.runId}:${msg.messageId}`;
1402
+ if (replayedAssistantMessages.has(replayKey))
1403
+ continue;
1404
+ replayedAssistantMessages.set(replayKey, Date.now());
1405
+ const replayMeta = {
1406
+ runId: e.runId,
1407
+ source: 'transcript_message',
1408
+ synthetic: true,
1409
+ stopReason: msg.stopReason ?? 'toolUse',
1410
+ messageId: msg.messageId,
1411
+ toolCallIds: msg.toolCallIds,
1412
+ };
1413
+ const replayAt = new Date(msg.timestampMs);
1414
+ if (msg.thinking.length > 0) {
1415
+ const thinkingText = msg.thinking.join('\n\n');
1416
+ recordAction(makeAction(sid, {
1417
+ actionType: types_1.ActionType.Thinking,
1418
+ actionName: 'thinking',
1419
+ modelName,
1420
+ inputParams: redactor.redact(replayMeta),
1421
+ outputResult: redactor.redact({ text: thinkingText, length: thinkingText.length }),
1422
+ createdAt: replayAt,
1423
+ userId: c?.agentId,
1424
+ }));
1425
+ }
1426
+ if (msg.texts.length > 0) {
1427
+ const assistantText = msg.texts.join('\n\n');
1428
+ recordAction(makeAction(sid, {
1429
+ actionType: types_1.ActionType.AssistantStream,
1430
+ actionName: 'assistant_stream',
1431
+ modelName,
1432
+ inputParams: redactor.redact(replayMeta),
1433
+ outputResult: redactor.redact({ text: assistantText, length: assistantText.length }),
1434
+ createdAt: new Date(msg.timestampMs + 1),
1435
+ userId: c?.agentId,
1436
+ }));
1437
+ }
1438
+ }
1439
+ if (assistantTexts.length > 0 &&
1440
+ !runtimeTextSeen.has(getRuntimeTextSeenKey(e.runId, 'assistant'))) {
1441
+ var assistantText = assistantTexts.join('\n\n');
1442
+ recordAction(makeAction(sid, {
1443
+ actionType: types_1.ActionType.AssistantStream,
1444
+ actionName: 'assistant_stream',
1445
+ modelName,
1446
+ inputParams: redactor.redact({ runId: e.runId, stream: 'assistant', synthetic: true }),
1447
+ outputResult: redactor.redact({ text: assistantText, length: assistantText.length }),
1448
+ createdAt: new Date(syntheticTs - 1),
1449
+ userId: c?.agentId,
1450
+ }));
1451
+ }
1452
+ if (fallbackArtifacts.thinking.length > 0 &&
1453
+ !runtimeTextSeen.has(getRuntimeTextSeenKey(e.runId, 'thinking'))) {
1454
+ const thinkingText = fallbackArtifacts.thinking.join('\n\n');
1455
+ let thinkingEndAt = Math.max(syntheticTs - 2, 0);
1456
+ // Anchor thinking end at assistant start time when available.
1457
+ // Priority: pending assistant stream start -> first observed assistant stream ts.
1458
+ const pendingAssistant = e.runId
1459
+ ? pendingRuntimeEvents.get(`${e.runId}:assistant`)
1460
+ : undefined;
1461
+ const assistantStartAt = (pendingAssistant && typeof pendingAssistant.startedAt === 'number')
1462
+ ? pendingAssistant.startedAt
1463
+ : (typeof runAssistantFirstAt === 'number' ? runAssistantFirstAt : undefined);
1464
+ if (typeof assistantStartAt === 'number') {
1465
+ thinkingEndAt = Math.min(thinkingEndAt, Math.max(assistantStartAt, 0));
1466
+ }
1467
+ else if (typeof runAssistantAt === 'number') {
1468
+ thinkingEndAt = Math.min(thinkingEndAt, Math.max(runAssistantAt - 1, 0));
1469
+ }
1470
+ let inferredThinkingDuration = null;
1471
+ if (typeof runLastToolAt === 'number' && runLastToolAt < thinkingEndAt) {
1472
+ inferredThinkingDuration = Math.max(1, thinkingEndAt - runLastToolAt);
1473
+ }
1474
+ recordAction(makeAction(sid, {
1475
+ actionType: types_1.ActionType.Thinking,
1476
+ actionName: 'thinking',
1477
+ modelName,
1478
+ inputParams: redactor.redact({ runId: e.runId, stream: 'thinking', synthetic: true }),
1479
+ outputResult: redactor.redact({ text: thinkingText, length: thinkingText.length }),
1480
+ createdAt: new Date(thinkingEndAt),
1481
+ durationMs: inferredThinkingDuration,
1482
+ userId: c?.agentId,
1483
+ }));
1484
+ }
1485
+ }
792
1486
  console.log(`[openclaw-observability] llm_output: session=${e.sessionId} model=${e.model} duration=${durationMs}ms tokens=${promptTokens ?? '?'}/${completionTokens ?? '?'} cache_r=${cacheRead ?? '-'} cache_w=${cacheWrite ?? '-'}`);
1487
+ if (e.runId) {
1488
+ flushPendingRuntimeEventsForRun(e.runId);
1489
+ llmRunStartTimes.delete(e.runId);
1490
+ runSessionIds.delete(e.runId);
1491
+ runLastToolCompletedAt.delete(e.runId);
1492
+ runLastAssistantStreamAt.delete(e.runId);
1493
+ runFirstAssistantStreamAt.delete(e.runId);
1494
+ runtimeTextSeen.delete(getRuntimeTextSeenKey(e.runId, 'assistant'));
1495
+ runtimeTextSeen.delete(getRuntimeTextSeenKey(e.runId, 'thinking'));
1496
+ }
793
1497
  }
794
1498
  catch (err) {
795
1499
  console.error('[openclaw-observability] Error in llm_output:', err);
@@ -913,8 +1617,9 @@ function activate(api) {
913
1617
  modelName: '',
914
1618
  inputParams: redactor.redact({
915
1619
  to: e.to,
916
- content: truncate(e.content, 1000),
1620
+ content: e.content,
917
1621
  channelId: c?.channelId,
1622
+ metadata: e.metadata,
918
1623
  }),
919
1624
  }));
920
1625
  console.log(`[openclaw-observability] message_sending: to=${e.to} channel=${c?.channelId}`);
@@ -958,13 +1663,14 @@ function activate(api) {
958
1663
  try {
959
1664
  const e = event;
960
1665
  const c = ctx;
961
- // Use toolCallId as timing key to avoid collision with llm_input runId
962
- // Note: cannot use Date.now() as fallback, otherwise before and after generate inconsistent keys
1666
+ // Use a stable key; when toolCallId is missing, use session/run/tool fallback key.
963
1667
  const callId = e.toolCallId || c?.toolCallId || '';
964
- const timingKey = callId ? `tc:${callId}` : `tc:${e.toolName}`;
965
- runStartTimes.set(timingKey, Date.now());
966
- // Update per-session context
967
1668
  const sid = resolveSessionId(undefined, c?.sessionId);
1669
+ const timingKey = makeToolTimingKey(sid, c?.runId || e.runId, e.toolName, callId || undefined);
1670
+ const queue = toolCallStartTimes.get(timingKey) ?? [];
1671
+ queue.push(Date.now());
1672
+ toolCallStartTimes.set(timingKey, queue);
1673
+ // Update per-session context
968
1674
  const sctx = getSessionCtx(sid);
969
1675
  if (c?.agentId)
970
1676
  sctx.userId = c.agentId;
@@ -984,14 +1690,29 @@ function activate(api) {
984
1690
  const sessionId = resolveSessionId(undefined, c?.sessionId);
985
1691
  // Calculate duration — use toolCallId as key to not interfere with LLM runId timing
986
1692
  const callId = e.toolCallId || c?.toolCallId || '';
987
- const timingKey = callId ? `tc:${callId}` : `tc:${e.toolName}`;
1693
+ const timingKey = makeToolTimingKey(sessionId, c?.runId || e.runId, e.toolName, callId || undefined);
988
1694
  let durationMs = e.durationMs ?? null;
989
- if (durationMs === null && runStartTimes.has(timingKey)) {
990
- durationMs = Date.now() - runStartTimes.get(timingKey);
1695
+ const queue = toolCallStartTimes.get(timingKey);
1696
+ const startAt = queue && queue.length > 0 ? queue.shift() : undefined;
1697
+ if (queue && queue.length > 0) {
1698
+ toolCallStartTimes.set(timingKey, queue);
1699
+ }
1700
+ else {
1701
+ toolCallStartTimes.delete(timingKey);
1702
+ }
1703
+ if (durationMs === null && typeof startAt === 'number') {
1704
+ durationMs = Date.now() - startAt;
1705
+ }
1706
+ if (typeof (e.runId || c?.runId) === 'string') {
1707
+ runLastToolCompletedAt.set(String(e.runId || c?.runId), Date.now());
991
1708
  }
992
- runStartTimes.delete(timingKey);
993
1709
  // BUG FIX: use correct field name params (not parameters/input/args)
994
- const inputParams = e.params ? redactor.redact(e.params) : null;
1710
+ const rawInputParams = e.params ? { ...e.params } : {};
1711
+ if (callId)
1712
+ rawInputParams.toolCallId = callId;
1713
+ if (e.runId || c?.runId)
1714
+ rawInputParams.runId = e.runId || c?.runId;
1715
+ const inputParams = redactor.redact(rawInputParams);
995
1716
  // Detect image content in tool results
996
1717
  let outputData = null;
997
1718
  if (e.error) {
@@ -1055,29 +1776,6 @@ function activate(api) {
1055
1776
  console.error('[openclaw-observability] Error in after_tool_call:', err);
1056
1777
  }
1057
1778
  });
1058
- // tool_result_persist: tool result persistence (sync hook)
1059
- api.on('tool_result_persist', (event, ctx) => {
1060
- try {
1061
- const e = event;
1062
- const c = ctx;
1063
- const name = e.toolName || c?.toolName || 'unknown';
1064
- const sid = lastFallbackSessionId;
1065
- if (sid === 'unknown')
1066
- return;
1067
- recordAction(makeAction(sid, {
1068
- actionType: types_1.ActionType.ToolPersist,
1069
- actionName: `tool_persist:${name}`,
1070
- inputParams: redactor.redact({
1071
- toolName: name,
1072
- toolCallId: e.toolCallId,
1073
- isSynthetic: e.isSynthetic,
1074
- }),
1075
- }));
1076
- }
1077
- catch (err) {
1078
- console.error('[openclaw-observability] Error in tool_result_persist:', err);
1079
- }
1080
- });
1081
1779
  // =====================================================================
1082
1780
  // 6. Session lifecycle
1083
1781
  // =====================================================================
@@ -1181,6 +1879,8 @@ function activate(api) {
1181
1879
  reason: e.reason,
1182
1880
  outcome: e.outcome,
1183
1881
  error: e.error,
1882
+ runId: e.runId,
1883
+ endedAt: e.endedAt,
1184
1884
  }),
1185
1885
  }));
1186
1886
  console.log(`[openclaw-observability] subagent_ended: target=${e.targetSessionKey} outcome=${e.outcome}`);
@@ -1240,6 +1940,11 @@ function activate(api) {
1240
1940
  return {
1241
1941
  deactivate: async () => {
1242
1942
  console.log('[openclaw-observability] Deactivating plugin...');
1943
+ if (_unsubscribeAgentEvents) {
1944
+ _unsubscribeAgentEvents();
1945
+ _unsubscribeAgentEvents = null;
1946
+ }
1947
+ Array.from(pendingRuntimeEvents.keys()).forEach((key) => flushPendingRuntimeEvent(key));
1243
1948
  // Stop module-level timers
1244
1949
  if (_alertFlushTimer) {
1245
1950
  clearInterval(_alertFlushTimer);
@@ -1268,10 +1973,12 @@ function activate(api) {
1268
1973
  _alertBuffer = [];
1269
1974
  _servicesStarted = false;
1270
1975
  // Reset function-level state
1271
- runStartTimes.clear();
1976
+ llmRunStartTimes.clear();
1977
+ runSessionIds.clear();
1978
+ toolCallStartTimes.clear();
1272
1979
  runInputs.clear();
1980
+ pendingRuntimeEvents.clear();
1273
1981
  sseUsageCache.clear();
1274
- latestSseUsage = null;
1275
1982
  sessionStatsMap.clear();
1276
1983
  sessionContextMap.clear();
1277
1984
  securityScanner.reset();