@latitude-data/openclaw-telemetry 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.js CHANGED
@@ -1,54 +1,69 @@
1
+ import { createRequire } from "node:module";
1
2
  import { arch, hostname, platform, release } from "node:os";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
- //#region src/client.ts
4
- async function postTraces({ baseUrl, apiKey, project, payload, logger, timeoutMs = 1e4 }) {
5
- const url = `${baseUrl.replace(/\/+$/, "")}/v1/traces`;
6
- const bodyText = JSON.stringify(payload);
7
- logger.debug(`POST ${url} (project=${project}, ${bodyText.length} bytes)`);
8
- const controller = new AbortController();
9
- const timer = setTimeout(() => controller.abort(), timeoutMs);
4
+ import { readFileSync, statSync } from "node:fs";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ //#region src/redaction.ts
7
+ function parseRedactConfig(value) {
8
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
9
+ const obj = value;
10
+ const attributes = parseAttributes(obj.attributes);
11
+ if (attributes.length === 0) return void 0;
12
+ return {
13
+ attributes,
14
+ mask: typeof obj.mask === "string" ? obj.mask : "******"
15
+ };
16
+ }
17
+ function redactAttributes(attributes, config) {
18
+ if (!config) return attributes;
19
+ const matchers = config.attributes.map(toMatcher).filter((matcher) => !!matcher);
20
+ if (matchers.length === 0) return attributes;
21
+ return attributes.map((attr) => matchers.some((matches) => matches(attr.key)) ? redactedAttr(attr.key, config.mask) : attr);
22
+ }
23
+ function parseAttributes(value) {
24
+ if (!Array.isArray(value)) return [];
25
+ return value.filter((item) => typeof item === "string" && item.trim() !== "");
26
+ }
27
+ function toMatcher(pattern) {
28
+ if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
29
+ const end = pattern.lastIndexOf("/");
30
+ try {
31
+ const regex = new RegExp(pattern.slice(1, end), pattern.slice(end + 1));
32
+ return (key) => {
33
+ regex.lastIndex = 0;
34
+ return regex.test(key);
35
+ };
36
+ } catch {
37
+ return;
38
+ }
39
+ }
10
40
  try {
11
- const res = await fetch(url, {
12
- method: "POST",
13
- headers: {
14
- "Content-Type": "application/json",
15
- Authorization: `Bearer ${apiKey}`,
16
- "X-Latitude-Project": project
17
- },
18
- body: bodyText,
19
- signal: controller.signal
20
- });
21
- if (!res.ok) {
22
- const text = await res.text().catch(() => "");
23
- logger.warn(`ingest HTTP ${res.status}: ${text.slice(0, 500)}`);
24
- } else logger.debug(`ingest HTTP ${res.status}`);
25
- } catch (err) {
26
- logger.warn(`ingest failed: ${String(err)}`);
27
- } finally {
28
- clearTimeout(timer);
41
+ const regex = new RegExp(pattern);
42
+ return (key) => {
43
+ regex.lastIndex = 0;
44
+ return key === pattern || regex.test(key);
45
+ };
46
+ } catch {
47
+ return (key) => key === pattern;
29
48
  }
30
49
  }
50
+ function redactedAttr(key, mask) {
51
+ return {
52
+ key,
53
+ value: { stringValue: mask }
54
+ };
55
+ }
31
56
  //#endregion
32
57
  //#region src/config.ts
33
58
  const DEFAULT_BASE_URL = "https://ingest.latitude.so";
59
+ const DEFAULT_SERVICE_NAME = "openclaw";
60
+ const DEFAULT_MAX_CONTENT_CHARS = 262144;
34
61
  /**
35
62
  * Build a `Config` from OpenClaw's per-plugin config bucket. The plugin SDK
36
63
  * passes `api.pluginConfig` (the user's `plugins.entries[id].config` block)
37
- * to the registration function that's the only source.
38
- *
39
- * Earlier 0.0.x versions also fell back to environment variables when keys
40
- * were missing from pluginConfig. That fallback is gone deliberately:
41
- * OpenClaw 2026.4.25's `openclaw plugins install` runs a static-analysis
42
- * security scan that flags any runtime source combining environment-variable
43
- * access with a network-send call (we have `fetch(` in postTraces). With
44
- * the fallback our bundled runtime tripped the scanner. The installer
45
- * writes credentials to `plugins.entries[id].config` anyway, so the
46
- * fallback was polish-not-feature — its removal also gives a cleaner
47
- * privacy story (the runtime can't pick up credentials the operator
48
- * didn't put in openclaw.json).
49
- *
50
- * For dev-time testing with debug logs, set `config.debug = true` in
51
- * openclaw.json directly.
64
+ * to the registration function; that is the only source. There is no
65
+ * environment fallback: the runtime must not combine env reads with the
66
+ * network send, and the installer always writes credentials here.
52
67
  */
53
68
  function loadConfig(pluginConfig = void 0) {
54
69
  const fromOpts = pluginConfig ?? {};
@@ -58,13 +73,22 @@ function loadConfig(pluginConfig = void 0) {
58
73
  const debug = pickBool(fromOpts.debug) ?? false;
59
74
  const allowConversationAccess = pickBool(fromOpts.allowConversationAccess) ?? false;
60
75
  const explicitlyDisabled = pickBool(fromOpts.enabled) === false;
76
+ const hasCreds = apiKey !== "" && project !== "";
61
77
  return {
62
78
  apiKey,
63
79
  baseUrl,
64
80
  project,
65
81
  debug,
66
82
  allowConversationAccess,
67
- enabled: apiKey !== "" && project !== "" && !explicitlyDisabled
83
+ redact: parseRedactConfig(fromOpts.redact),
84
+ enabled: hasCreds && !explicitlyDisabled,
85
+ serviceName: pickString(fromOpts.serviceName) ?? DEFAULT_SERVICE_NAME,
86
+ tags: pickStringList(fromOpts.tags),
87
+ metadata: pickStringMap(fromOpts.metadata),
88
+ memory: pickBool(fromOpts.memory) ?? true,
89
+ memoryContent: pickBool(fromOpts.memoryContent) ?? true,
90
+ toolDefinitions: pickBool(fromOpts.toolDefinitions) ?? true,
91
+ maxContentChars: pickPositiveInt(fromOpts.maxContentChars) ?? DEFAULT_MAX_CONTENT_CHARS
68
92
  };
69
93
  }
70
94
  function pickString(value) {
@@ -73,24 +97,44 @@ function pickString(value) {
73
97
  function pickBool(value) {
74
98
  return typeof value === "boolean" ? value : void 0;
75
99
  }
100
+ function pickPositiveInt(value) {
101
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
102
+ }
103
+ function pickStringList(value) {
104
+ if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
105
+ if (!Array.isArray(value)) return [];
106
+ return value.filter((v) => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
107
+ }
108
+ function pickStringMap(value) {
109
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
110
+ const out = {};
111
+ for (const [k, v] of Object.entries(value)) if (typeof v === "string") out[k] = v;
112
+ else if (typeof v === "number" || typeof v === "boolean") out[k] = String(v);
113
+ return out;
114
+ }
76
115
  //#endregion
77
116
  //#region src/logger.ts
78
117
  const PREFIX = "[latitude-openclaw]";
79
- function createLogger(debugEnabled) {
118
+ function createLogger(debugEnabled, host) {
80
119
  return {
81
- debug: debugEnabled ? (msg) => process.stderr.write(`${PREFIX} ${msg}\n`) : () => {},
82
- warn: (msg) => process.stderr.write(`${PREFIX} ${msg}\n`)
120
+ debug: debugEnabled ? host ? (msg) => host.info(`${PREFIX} ${msg}`) : (msg) => writeStderr(msg) : () => {},
121
+ warn: host ? (msg) => host.warn(`${PREFIX} ${msg}`) : (msg) => writeStderr(msg)
83
122
  };
84
123
  }
124
+ function writeStderr(msg) {
125
+ process.stderr.write(`${PREFIX} ${msg}\n`);
126
+ }
85
127
  //#endregion
86
128
  //#region src/otlp.ts
87
129
  const SCOPE_NAME = "@latitude-data/openclaw-telemetry";
88
- const SCOPE_VERSION = "0.0.8";
89
- /** Build an OTLP export request for a single completed agent run. */
90
- function buildOtlpRequest(result, options) {
91
- const spans = result.spans.map((span) => toOtlpSpan(span, options));
130
+ const SCOPE_VERSION = "0.1.0";
131
+ const GATED_SUFFIX = ":gated";
132
+ const ARRAY_VALUE_KEYS = new Set(["gen_ai.response.finish_reasons"]);
133
+ const TRUNCATION_MARKER = "\n…[truncated by latitude-openclaw]…\n";
134
+ function buildOtlpRequest(results, options) {
135
+ const spans = results.flatMap((result) => result.spans.map((span) => toOtlpSpan(span, options)));
92
136
  return { resourceSpans: [{
93
- resource: { attributes: resourceAttrs() },
137
+ resource: { attributes: resourceAttrs(options.serviceName ?? "openclaw") },
94
138
  scopeSpans: [{
95
139
  scope: {
96
140
  name: SCOPE_NAME,
@@ -101,52 +145,128 @@ function buildOtlpRequest(result, options) {
101
145
  }] };
102
146
  }
103
147
  function toOtlpSpan(span, options) {
104
- const startNs = msToNs(span.startMs);
105
- const endNs = msToNs(span.endMs ?? span.startMs);
106
148
  const attrs = [];
107
149
  for (const [rawKey, value] of Object.entries(span.attrs)) {
108
150
  if (value === void 0 || value === null) continue;
109
- const isGated = rawKey.endsWith(":gated");
151
+ const isGated = rawKey.endsWith(GATED_SUFFIX);
110
152
  if (isGated && !options.allowConversationAccess) continue;
111
- const kv = encodeAttr(isGated ? rawKey.slice(0, -6) : rawKey, value);
153
+ const kv = encodeAttr(isGated ? rawKey.slice(0, -6) : rawKey, value, options.maxContentChars);
112
154
  if (kv !== void 0) attrs.push(kv);
113
155
  }
114
156
  attrs.push(bool("latitude.captured.content", options.allowConversationAccess));
115
- if (span.endMs !== void 0) attrs.push(int("openclaw.duration_ms.computed", Math.max(0, span.endMs - span.startMs)));
116
- const statusCode = span.outcome === "error" ? 2 : 1;
117
157
  return {
118
158
  traceId: span.traceId,
119
159
  spanId: span.spanId,
120
160
  parentSpanId: span.parentSpanId,
121
161
  name: span.name,
122
- kind: 1,
123
- startTimeUnixNano: startNs,
124
- endTimeUnixNano: endNs,
125
- attributes: attrs,
126
- status: { code: statusCode }
162
+ kind: span.kind,
163
+ startTimeUnixNano: msToNs(span.startMs),
164
+ endTimeUnixNano: msToNs(span.endMs ?? span.startMs),
165
+ attributes: redactAttributes(attrs, options.redact),
166
+ status: { code: span.outcome === "error" ? 2 : 1 }
127
167
  };
128
168
  }
129
- function encodeAttr(key, value) {
169
+ function encodeAttr(key, value, maxChars) {
130
170
  if (value === void 0 || value === null) return void 0;
131
- if (typeof value === "string") return str(key, value);
171
+ if (typeof value === "string") return str$1(key, budget(value, maxChars));
132
172
  if (typeof value === "boolean") return bool(key, value);
133
173
  if (typeof value === "number") return Number.isInteger(value) ? int(key, value) : {
134
174
  key,
135
175
  value: { doubleValue: value }
136
176
  };
137
- return str(key, safeJson$1(value));
177
+ if (ARRAY_VALUE_KEYS.has(key) && Array.isArray(value)) return {
178
+ key,
179
+ value: { arrayValue: { values: value.map((v) => ({ stringValue: String(v) })) } }
180
+ };
181
+ return str$1(key, budgetJson(value, maxChars));
182
+ }
183
+ function budget(value, maxChars) {
184
+ if (!maxChars || value.length <= maxChars) return value;
185
+ const keep = Math.max(0, Math.floor((maxChars - 36) / 2));
186
+ let headEnd = keep;
187
+ if (headEnd > 0 && isHighSurrogate(value.charCodeAt(headEnd - 1))) headEnd--;
188
+ let tailStart = value.length - keep;
189
+ if (tailStart < value.length && isLowSurrogate(value.charCodeAt(tailStart))) tailStart++;
190
+ return `${value.slice(0, headEnd)}${TRUNCATION_MARKER}${value.slice(tailStart)}`;
191
+ }
192
+ function isHighSurrogate(code) {
193
+ return code >= 55296 && code <= 56319;
194
+ }
195
+ function isLowSurrogate(code) {
196
+ return code >= 56320 && code <= 57343;
197
+ }
198
+ /**
199
+ * A structured value must still parse after the budget, since Latitude reads
200
+ * messages, tool definitions and memory records as JSON: long strings inside
201
+ * it are truncated first, then whole items are shed from the middle of an
202
+ * array. Slicing the serialized text is the last resort, for a lone object.
203
+ */
204
+ function budgetJson(value, maxChars) {
205
+ const json = safeJson$1(value);
206
+ if (!maxChars || json.length <= maxChars) return json;
207
+ const trimmed = budgetStrings(value, Math.max(1, Math.floor(maxChars / 4)));
208
+ const trimmedJson = safeJson$1(trimmed);
209
+ if (trimmedJson.length <= maxChars) return trimmedJson;
210
+ if (Array.isArray(trimmed)) return safeJson$1(shedItems(trimmed, maxChars));
211
+ return budget(trimmedJson, maxChars);
212
+ }
213
+ function budgetStrings(value, maxChars) {
214
+ if (typeof value === "string") return budget(value, maxChars);
215
+ if (Array.isArray(value)) return value.map((item) => budgetStrings(item, maxChars));
216
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, budgetStrings(v, maxChars)]));
217
+ return value;
218
+ }
219
+ const OMISSION_RESERVE = 160;
220
+ /** Keeps the head and tail of an array within the budget; a message list gets a marker for what was dropped. */
221
+ function shedItems(items, maxChars) {
222
+ const sizes = items.map((item) => safeJson$1(item).length + 1);
223
+ const head = [];
224
+ const tail = [];
225
+ let budgetLeft = maxChars - OMISSION_RESERVE;
226
+ let low = 0;
227
+ let high = items.length - 1;
228
+ while (low <= high) {
229
+ const fromHead = head.length <= tail.length;
230
+ const size = fromHead ? sizes[low] : sizes[high];
231
+ if (budgetLeft - size < 0) break;
232
+ budgetLeft -= size;
233
+ if (fromHead) head.push(items[low++]);
234
+ else tail.unshift(items[high--]);
235
+ }
236
+ const omitted = items.length - head.length - tail.length;
237
+ if (omitted <= 0) return [...items];
238
+ const marker = isMessageList(items) ? [omissionMessage(omitted)] : [];
239
+ return [
240
+ ...head,
241
+ ...marker,
242
+ ...tail
243
+ ];
244
+ }
245
+ function isMessageList(items) {
246
+ const first = items[0];
247
+ return !!first && typeof first === "object" && typeof first.role === "string";
248
+ }
249
+ function omissionMessage(count) {
250
+ return {
251
+ role: "system",
252
+ parts: [{
253
+ type: "text",
254
+ content: `[… ${count} message(s) omitted by latitude-openclaw …]`
255
+ }]
256
+ };
138
257
  }
139
- function resourceAttrs() {
258
+ function resourceAttrs(serviceName) {
140
259
  return [
141
- str("service.name", "openclaw"),
142
- str("service.version", SCOPE_VERSION),
143
- str("host.name", hostname()),
144
- str("host.arch", arch()),
145
- str("os.type", platform()),
146
- str("os.version", release())
260
+ str$1("service.name", serviceName),
261
+ str$1("service.version", SCOPE_VERSION),
262
+ str$1("telemetry.sdk.name", SCOPE_NAME),
263
+ str$1("host.name", hostname()),
264
+ str$1("host.arch", arch()),
265
+ str$1("os.type", platform()),
266
+ str$1("os.version", release())
147
267
  ];
148
268
  }
149
- function str(key, value) {
269
+ function str$1(key, value) {
150
270
  return {
151
271
  key,
152
272
  value: { stringValue: value }
@@ -176,6 +296,113 @@ function safeJson$1(value) {
176
296
  }
177
297
  }
178
298
  //#endregion
299
+ //#region src/context.ts
300
+ const TAG_MAX_CHARS = 64;
301
+ const METADATA_VALUE_MAX_CHARS = 1024;
302
+ const METADATA_MAX_KEYS = 64;
303
+ /**
304
+ * Derived tags: `openclaw`, the channel, the agent id, `cron:<job>` (or the
305
+ * bare trigger for other non-user triggers) and `subagent:<agent>` on a run
306
+ * that spawned one. Operator tags are appended. Tags are what `queryAnalytics`
307
+ * can break down on, so they stay low-cardinality; ids go in metadata.
308
+ */
309
+ function deriveEnrichment(run, operator) {
310
+ const { ctx } = run;
311
+ const tags = ["openclaw"];
312
+ if (ctx.channel) tags.push(ctx.channel);
313
+ if (ctx.agentId) tags.push(ctx.agentId);
314
+ if (ctx.trigger === "cron") tags.push(run.cron ? `cron:${run.cron.id}` : "cron");
315
+ else if (ctx.trigger && ctx.trigger !== "user") tags.push(ctx.trigger);
316
+ for (const id of run.subagentIds) tags.push(`subagent:${id}`);
317
+ for (const tag of operator.tags) tags.push(tag);
318
+ const metadata = {};
319
+ for (const [k, v] of Object.entries(operator.metadata)) {
320
+ if (k.startsWith("openclaw.")) continue;
321
+ metadata[k] = v;
322
+ }
323
+ const derived = {
324
+ "openclaw.run.id": ctx.runId,
325
+ "openclaw.session.id": ctx.sessionId,
326
+ "openclaw.session.key": ctx.sessionKey,
327
+ "openclaw.agent.id": ctx.agentId,
328
+ "openclaw.workspace.dir": ctx.workspaceDir,
329
+ "openclaw.channel": ctx.channel,
330
+ "openclaw.channel.id": ctx.channelId,
331
+ "openclaw.account.id": ctx.accountId,
332
+ "openclaw.message.provider": ctx.messageProvider,
333
+ "openclaw.trigger": ctx.trigger,
334
+ "openclaw.model.provider.id": ctx.modelProviderId,
335
+ "openclaw.model.id": ctx.modelId,
336
+ "openclaw.cron.job.id": run.cron?.id,
337
+ "openclaw.cron.job.name": run.cron?.name,
338
+ "openclaw.sender.id": run.sender?.id,
339
+ "openclaw.sender.name": run.sender?.name,
340
+ "openclaw.sender.username": run.sender?.username,
341
+ "openclaw.trace.id": ctx.trace?.traceId,
342
+ "openclaw.plugin.version": run.pluginVersion
343
+ };
344
+ for (const [k, v] of Object.entries(derived)) if (v !== void 0 && v !== "") metadata[k] = v;
345
+ return {
346
+ tags: capTags(tags),
347
+ metadata: capMetadata(metadata)
348
+ };
349
+ }
350
+ function capTags(tags) {
351
+ const seen = /* @__PURE__ */ new Set();
352
+ const out = [];
353
+ for (const tag of tags) {
354
+ const trimmed = tag.trim();
355
+ if (trimmed.length === 0 || trimmed.length > TAG_MAX_CHARS || seen.has(trimmed)) continue;
356
+ seen.add(trimmed);
357
+ out.push(trimmed);
358
+ if (out.length >= 32) break;
359
+ }
360
+ return out;
361
+ }
362
+ function capMetadata(metadata) {
363
+ const out = {};
364
+ let count = 0;
365
+ for (const [k, v] of Object.entries(metadata)) {
366
+ if (v.length > METADATA_VALUE_MAX_CHARS) continue;
367
+ out[k] = v;
368
+ count++;
369
+ if (count >= METADATA_MAX_KEYS) break;
370
+ }
371
+ return out;
372
+ }
373
+ const CTX_MARKER = "⟦openclaw:ctx⟧";
374
+ /**
375
+ * OpenClaw prefixes channel prompts with a fenced JSON block after
376
+ * `⟦openclaw:ctx⟧` that carries the sender's id and display name; the only
377
+ * place the name reaches a run when `message_received` did not.
378
+ */
379
+ function senderFromPrompt(prompt) {
380
+ if (!prompt) return void 0;
381
+ const marker = prompt.indexOf(CTX_MARKER);
382
+ if (marker < 0) return void 0;
383
+ const fenceStart = prompt.indexOf("```json", marker);
384
+ if (fenceStart < 0) return void 0;
385
+ const bodyStart = prompt.indexOf("\n", fenceStart);
386
+ const fenceEnd = prompt.indexOf("```", bodyStart + 1);
387
+ if (bodyStart < 0 || fenceEnd < 0) return void 0;
388
+ try {
389
+ const sender = JSON.parse(prompt.slice(bodyStart + 1, fenceEnd)).sender;
390
+ if (!sender || typeof sender.id !== "string" || sender.id.length === 0) return void 0;
391
+ return {
392
+ id: sender.id,
393
+ name: typeof sender.name === "string" && sender.name.length > 0 ? sender.name : void 0,
394
+ username: typeof sender.username === "string" && sender.username.length > 0 ? sender.username : void 0
395
+ };
396
+ } catch {
397
+ return;
398
+ }
399
+ }
400
+ /** `agent:<agentId>:cron:<jobId>[:run:<runId>]` is the isolated cron session key shape. */
401
+ function cronJobFromSessionKey(sessionKey) {
402
+ if (!sessionKey) return void 0;
403
+ return /^agent:[^:]+:cron:([^:]+)/.exec(sessionKey)?.[1];
404
+ }
405
+ //#endregion
179
406
  //#region src/messages.ts
180
407
  const ALLOWED_ROLES = new Set([
181
408
  "system",
@@ -183,26 +410,24 @@ const ALLOWED_ROLES = new Set([
183
410
  "assistant",
184
411
  "tool"
185
412
  ]);
186
- /**
187
- * Normalize a single message of any of the provider shapes we know about.
188
- * Returns `undefined` for non-objects so the caller can skip them.
189
- */
190
413
  function normalizeMessage(raw) {
191
414
  if (!raw || typeof raw !== "object") return void 0;
192
415
  const obj = raw;
193
- const role = coerceRole(obj.role);
194
416
  if (Array.isArray(obj.parts)) {
195
417
  const parts = [];
196
418
  for (const p of obj.parts) if (p && typeof p === "object") parts.push(p);
197
419
  return {
198
- role,
420
+ role: coerceRole(obj.role),
199
421
  parts: parts.length > 0 ? parts : [{
200
422
  type: "text",
201
423
  content: safeJson(raw)
202
424
  }]
203
425
  };
204
426
  }
427
+ if (obj.role === "toolResult") return normalizeToolResult(obj);
428
+ if (obj.role === "custom") return normalizeCustom(obj);
205
429
  const content = obj.content ?? obj.text ?? obj.message;
430
+ const role = isRuntimeContext(obj, content) ? "system" : coerceRole(obj.role);
206
431
  if (role === "tool" && obj.tool_call_id !== void 0) return {
207
432
  role,
208
433
  parts: [{
@@ -212,11 +437,16 @@ function normalizeMessage(raw) {
212
437
  }]
213
438
  };
214
439
  if (typeof content === "string") {
215
- const parts = [{
440
+ const parts = [];
441
+ if (content.length > 0) parts.push({
216
442
  type: "text",
217
443
  content
218
- }];
444
+ });
219
445
  appendToolCalls(parts, obj.tool_calls);
446
+ if (parts.length === 0) parts.push({
447
+ type: "text",
448
+ content: ""
449
+ });
220
450
  return {
221
451
  role,
222
452
  parts
@@ -246,7 +476,6 @@ function normalizeMessage(raw) {
246
476
  }]
247
477
  };
248
478
  }
249
- /** Normalize an array of provider messages. */
250
479
  function normalizeMessages(raw) {
251
480
  const out = [];
252
481
  for (const m of raw) {
@@ -255,7 +484,6 @@ function normalizeMessages(raw) {
255
484
  }
256
485
  return out;
257
486
  }
258
- /** Build a single user message from a string prompt. */
259
487
  function userMessageFromPrompt(prompt) {
260
488
  return {
261
489
  role: "user",
@@ -265,44 +493,73 @@ function userMessageFromPrompt(prompt) {
265
493
  }]
266
494
  };
267
495
  }
268
- /** Build a single assistant message from `assistantTexts` + `lastAssistant` fallback. */
269
- function assistantMessageFromOutput(assistantTexts, lastAssistant) {
270
- if (lastAssistant !== void 0) {
271
- const norm = normalizeMessage(lastAssistant);
272
- if (norm) return {
273
- ...norm,
274
- role: "assistant"
275
- };
276
- }
277
- const parts = [];
278
- for (const text of assistantTexts) if (text.length > 0) parts.push({
279
- type: "text",
280
- content: text
281
- });
282
- if (parts.length === 0) parts.push({
283
- type: "text",
284
- content: ""
285
- });
286
- return {
287
- role: "assistant",
288
- parts
289
- };
290
- }
291
- /**
292
- * Wrap a system prompt string into the parts-array shape expected for
293
- * `gen_ai.system_instructions`. Empty string in → single empty text part out
294
- * (still a valid array, never `undefined`).
295
- */
296
496
  function systemInstructionsParts(prompt) {
297
497
  return [{
298
498
  type: "text",
299
499
  content: prompt
300
500
  }];
301
501
  }
502
+ const RUNTIME_CONTEXT_PREFIX = "[openclaw.runtime-context]";
503
+ /**
504
+ * OpenClaw injects its per-turn context as an extra user message right before
505
+ * the prompt. It is instructions rather than the user's words, so it renders
506
+ * as a system message instead of a second user bubble.
507
+ */
508
+ function isRuntimeContext(obj, content) {
509
+ if (obj.role !== "user") return false;
510
+ if (obj.runtimeContext) return true;
511
+ const text = typeof content === "string" ? content : Array.isArray(content) ? blockText(content[0]) : void 0;
512
+ return typeof text === "string" && text.trimStart().startsWith(RUNTIME_CONTEXT_PREFIX);
513
+ }
302
514
  function coerceRole(raw) {
303
515
  if (typeof raw !== "string") return "user";
304
516
  return ALLOWED_ROLES.has(raw) ? raw : "user";
305
517
  }
518
+ function normalizeToolResult(obj) {
519
+ const content = obj.content;
520
+ let response;
521
+ if (typeof content === "string") response = content;
522
+ else if (Array.isArray(content)) {
523
+ const texts = [];
524
+ let hasNonText = false;
525
+ for (const block of content) {
526
+ const text = blockText(block);
527
+ if (text !== void 0) texts.push(text);
528
+ else hasNonText = true;
529
+ }
530
+ response = hasNonText ? content.map(normalizeBlock).filter(Boolean) : texts.join("\n");
531
+ } else response = content ?? "";
532
+ return {
533
+ role: "tool",
534
+ parts: [{
535
+ type: "tool_call_response",
536
+ id: typeof obj.toolCallId === "string" ? obj.toolCallId : "",
537
+ name: typeof obj.toolName === "string" ? obj.toolName : void 0,
538
+ response,
539
+ ...obj.isError === true ? { is_error: true } : {}
540
+ }]
541
+ };
542
+ }
543
+ /** Text of a plain text block, or of the `toolResult` block the Codex harness wraps tool output in. */
544
+ function blockText(block) {
545
+ if (!block || typeof block !== "object") return void 0;
546
+ const b = block;
547
+ if (b.type === "text" && typeof b.text === "string") return b.text;
548
+ if (b.type === "toolResult") {
549
+ if (typeof b.text === "string") return b.text;
550
+ if (typeof b.content === "string") return b.content;
551
+ }
552
+ }
553
+ function normalizeCustom(obj) {
554
+ const text = typeof obj.content === "string" ? obj.content : safeJson(obj);
555
+ return {
556
+ role: "user",
557
+ parts: [{
558
+ type: "text",
559
+ content: `[${typeof obj.customType === "string" ? obj.customType : "custom"}] ${text}`
560
+ }]
561
+ };
562
+ }
306
563
  function normalizeBlock(raw) {
307
564
  if (typeof raw === "string") return {
308
565
  type: "text",
@@ -310,24 +567,22 @@ function normalizeBlock(raw) {
310
567
  };
311
568
  if (!raw || typeof raw !== "object") return void 0;
312
569
  const obj = raw;
313
- if (typeof obj.type === "string" && (typeof obj.content === "string" || obj.content === void 0)) {
314
- if (obj.type === "text" && typeof obj.content === "string") return {
570
+ const type = typeof obj.type === "string" ? obj.type : "text";
571
+ if (type === "text") {
572
+ if (typeof obj.content === "string") return {
315
573
  type: "text",
316
574
  content: obj.content
317
575
  };
576
+ if (typeof obj.text === "string") return {
577
+ type: "text",
578
+ content: obj.text
579
+ };
318
580
  }
319
- const type = typeof obj.type === "string" ? obj.type : "text";
320
- if (type === "text" && typeof obj.text === "string") return {
581
+ if (type === "toolResult") return {
321
582
  type: "text",
322
- content: obj.text
323
- };
324
- if (type === "tool_use") return {
325
- type: "tool_call",
326
- id: typeof obj.id === "string" ? obj.id : "",
327
- name: typeof obj.name === "string" ? obj.name : "",
328
- arguments: obj.input ?? {}
583
+ content: blockText(obj) ?? safeJson(raw)
329
584
  };
330
- if (type === "tool_call") return {
585
+ if (type === "toolCall" || type === "tool_use" || type === "tool_call") return {
331
586
  type: "tool_call",
332
587
  id: typeof obj.id === "string" ? obj.id : "",
333
588
  name: typeof obj.name === "string" ? obj.name : "",
@@ -343,33 +598,46 @@ function normalizeBlock(raw) {
343
598
  id: typeof obj.id === "string" ? obj.id : "",
344
599
  response: obj.response ?? ""
345
600
  };
346
- if (type === "thinking" && typeof obj.thinking === "string") return {
347
- type: "reasoning",
348
- content: obj.thinking
349
- };
601
+ if (type === "thinking") {
602
+ if (typeof obj.thinking === "string" && obj.thinking.length > 0) return {
603
+ type: "reasoning",
604
+ content: obj.thinking
605
+ };
606
+ if (obj.redacted === true) return {
607
+ type: "reasoning",
608
+ content: "[redacted]"
609
+ };
610
+ return;
611
+ }
350
612
  if (type === "reasoning" && typeof obj.content === "string") return {
351
613
  type: "reasoning",
352
614
  content: obj.content
353
615
  };
354
- if (type === "image" && obj.source && typeof obj.source === "object") {
355
- const src = obj.source;
356
- const uri = src.url ?? (src.data ? `data:${src.media_type ?? "image/unknown"};base64,${src.data}` : "");
616
+ if (type === "image") {
617
+ const uri = imageUri(obj);
357
618
  if (uri) return {
358
619
  type: "uri",
359
620
  modality: "image",
360
621
  uri
361
622
  };
623
+ return {
624
+ type: "text",
625
+ content: "[image]"
626
+ };
362
627
  }
363
628
  return {
364
629
  type,
365
630
  content: safeJson(raw)
366
631
  };
367
632
  }
368
- /**
369
- * OpenAI assistant messages put tool calls in a separate `tool_calls` array
370
- * alongside string content. Append them as parts so the trace shows what the
371
- * model emitted in that turn.
372
- */
633
+ function imageUri(obj) {
634
+ if (typeof obj.data === "string" && obj.data.length > 0) return `data:${typeof obj.mimeType === "string" ? obj.mimeType : "image/unknown"};base64,${obj.data}`;
635
+ if (obj.source && typeof obj.source === "object") {
636
+ const src = obj.source;
637
+ if (src.url) return src.url;
638
+ if (src.data) return `data:${src.media_type ?? "image/unknown"};base64,${src.data}`;
639
+ }
640
+ }
373
641
  function appendToolCalls(parts, raw) {
374
642
  if (!Array.isArray(raw)) return;
375
643
  for (const tc of raw) {
@@ -397,301 +665,1093 @@ function safeJson(value) {
397
665
  }
398
666
  }
399
667
  //#endregion
400
- //#region src/span-builder.ts
401
- const SUBAGENT_LINK_TTL_MS = 3600 * 1e3;
402
- const SUBAGENT_LINK_MAX = 1e3;
403
- var SpanBuilder = class {
404
- runs = /* @__PURE__ */ new Map();
405
- subagentLinks = /* @__PURE__ */ new Map();
406
- inflightCount() {
407
- return this.runs.size;
668
+ //#region src/history.ts
669
+ /**
670
+ * Conversation history for harnesses that do not hand it to plugins. The
671
+ * embedded runner passes the whole session on `llm_input.historyMessages` and
672
+ * on `agent_end.messages`; the Codex harness owns the thread itself and passes
673
+ * an empty history and a per-turn transcript. The plugin then rebuilds the
674
+ * session from the turns it has seen, and on a cold start from OpenClaw's own
675
+ * transcript store: the per-agent SQLite database (2026.9+) or the older
676
+ * JSONL file under `<stateDir>/agents/<agent>/sessions/`.
677
+ */
678
+ const MAX_MESSAGES = 400;
679
+ const MAX_SESSIONS = 200;
680
+ const SESSION_TTL_MS = 1440 * 60 * 1e3;
681
+ const MAX_FILE_BYTES$1 = 8 * 1024 * 1024;
682
+ const CURRENT_PROMPT_WINDOW_MS = 6e4;
683
+ const defaultTranscriptReader = (path) => {
684
+ try {
685
+ if (statSync(path).size > MAX_FILE_BYTES$1) return void 0;
686
+ return readFileSync(path, "utf8");
687
+ } catch {
688
+ return;
408
689
  }
409
- /**
410
- * Open the root `agent` span. If the runId was previously registered as a
411
- * subagent's child, propagate the parent's traceId and parent the new span
412
- * under the parent's `subagent` span — so the entire subagent's work nests
413
- * inside the parent's trace as one waterfall.
414
- */
415
- onBeforeAgentStart(evt, ctx) {
416
- const runId = ctx.runId;
417
- if (!runId) return;
418
- if (this.runs.has(runId)) return;
419
- const link = this.subagentLinks.get(runId);
420
- const traceId = link?.traceId ?? hashHex(runId, 32);
421
- const parentSpanId = link?.subagentSpanId ?? "";
422
- const agent = {
423
- spanId: hashHex(`${traceId}:${runId}:agent`, 16),
424
- traceId,
425
- parentSpanId,
426
- name: "agent",
427
- startMs: Date.now(),
428
- endMs: void 0,
429
- attrs: {
430
- ...flattenCtx(ctx),
431
- ...latitudeAttrs(ctx),
432
- ...sessionAttrs(ctx),
433
- "openclaw.run.id": runId,
434
- "before_agent_start.prompt:gated": evt.prompt,
435
- "before_agent_start.messages:gated": evt.messages ? normalizeMessages(evt.messages) : void 0
436
- }
437
- };
438
- this.runs.set(runId, {
439
- agent,
440
- history: [],
441
- openModelCalls: /* @__PURE__ */ new Map(),
442
- openToolCalls: /* @__PURE__ */ new Map(),
443
- openCompaction: void 0,
444
- closed: [],
445
- childSubagentSpans: /* @__PURE__ */ new Map()
446
- });
690
+ };
691
+ var SessionHistoryStore = class {
692
+ sessions = /* @__PURE__ */ new Map();
693
+ now;
694
+ constructor(now = () => Date.now()) {
695
+ this.now = now;
447
696
  }
448
- /**
449
- * Enrich the open `agent` span with content + identity from the LLM input.
450
- * Also seeds the rolling history snapshot used by per-call `model_call`
451
- * input attributes.
452
- *
453
- * Provider-specific message shapes get normalized into the parts-based
454
- * GenAI format here — that's the contract Latitude's downstream parser
455
- * expects on `gen_ai.input.messages` and `gen_ai.system_instructions`.
456
- */
457
- onLlmInput(evt, ctx) {
458
- const run = this.runs.get(ctx.runId ?? evt.runId);
459
- if (!run) return;
460
- const inputMessages = [...normalizeMessages(evt.historyMessages)];
461
- if (evt.prompt) inputMessages.push(userMessageFromPrompt(evt.prompt));
462
- Object.assign(run.agent.attrs, {
463
- "gen_ai.system_instructions:gated": evt.systemPrompt ? systemInstructionsParts(evt.systemPrompt) : void 0,
464
- "user_prompt:gated": evt.prompt,
465
- "gen_ai.input.messages:gated": inputMessages,
466
- "openclaw.images.count": evt.imagesCount,
467
- "gen_ai.request.model": evt.model,
468
- "gen_ai.system": evt.provider,
469
- "openclaw.provider": evt.provider
470
- });
471
- run.history = inputMessages;
697
+ get(sessionId) {
698
+ const entry = this.sessions.get(sessionId);
699
+ if (!entry) return void 0;
700
+ entry.updatedAt = this.now();
701
+ return entry.messages;
472
702
  }
473
- /**
474
- * Enrich the agent span with attempt-aggregate output + token usage.
475
- * (Per-call usage isn't surfaced by OpenClaw today — see PR #2986.)
476
- */
477
- onLlmOutput(evt, ctx) {
478
- const run = this.runs.get(ctx.runId ?? evt.runId);
479
- if (!run) return;
480
- const assistantMessage = assistantMessageFromOutput(evt.assistantTexts, evt.lastAssistant);
481
- Object.assign(run.agent.attrs, {
482
- "gen_ai.output.messages:gated": [assistantMessage],
483
- "openclaw.resolved.ref": evt.resolvedRef,
484
- "openclaw.harness.id": evt.harnessId,
485
- "gen_ai.response.model": evt.model,
486
- ...usageAttrs(evt.usage)
487
- });
703
+ /** Replace the session's history with a full transcript the harness supplied. */
704
+ replace(sessionId, messages) {
705
+ this.set(sessionId, messages.slice(-MAX_MESSAGES));
488
706
  }
489
- onModelCallStarted(evt, ctx) {
490
- const run = this.runs.get(evt.runId);
491
- if (!run) return;
492
- const span = {
493
- spanId: hashHex(`${run.agent.traceId}:model_call:${evt.callId}`, 16),
494
- traceId: run.agent.traceId,
495
- parentSpanId: run.agent.spanId,
496
- name: "model_call",
497
- startMs: Date.now(),
498
- endMs: void 0,
499
- attrs: {
500
- ...latitudeAttrs(ctx),
501
- ...sessionAttrs(ctx),
502
- "openclaw.run.id": evt.runId,
503
- "openclaw.call.id": evt.callId,
504
- "gen_ai.system": evt.provider,
505
- "openclaw.provider": evt.provider,
506
- "gen_ai.request.model": evt.model,
507
- "openclaw.api": evt.api,
508
- "openclaw.transport": evt.transport,
509
- "gen_ai.input.messages:gated": [...run.history]
510
- }
511
- };
512
- run.openModelCalls.set(evt.callId, span);
707
+ /** Extend the session's history with one turn's new messages. */
708
+ append(sessionId, base, turn) {
709
+ this.set(sessionId, [...base, ...turn].slice(-MAX_MESSAGES));
513
710
  }
514
- onModelCallEnded(evt, _ctx) {
515
- const run = this.runs.get(evt.runId);
516
- if (!run) return;
517
- const span = run.openModelCalls.get(evt.callId);
518
- if (!span) return;
519
- span.endMs = Date.now();
520
- span.outcome = evt.outcome === "completed" ? "ok" : "error";
521
- span.errorMessage = evt.errorCategory;
522
- Object.assign(span.attrs, {
523
- "openclaw.duration_ms": evt.durationMs,
524
- "openclaw.outcome": evt.outcome,
525
- "openclaw.error.category": evt.errorCategory,
526
- "openclaw.failure.kind": evt.failureKind,
527
- "openclaw.request.payload_bytes": evt.requestPayloadBytes,
528
- "openclaw.response.stream_bytes": evt.responseStreamBytes,
529
- "openclaw.ttfb_ms": evt.timeToFirstByteMs,
530
- "openclaw.upstream.request_id_hash": evt.upstreamRequestIdHash
531
- });
532
- run.openModelCalls.delete(evt.callId);
533
- run.closed.push(span);
711
+ forget(sessionId) {
712
+ this.sessions.delete(sessionId);
534
713
  }
535
- /**
536
- * Open a `tool_call` span as a sibling of the agent span. Also append a
537
- * synthetic assistant `tool_call` part to the rolling history so the NEXT
538
- * model_call's input snapshot reflects what the model emitted.
539
- *
540
- * IMPORTANT: this runs as a `runModifyingHook` in OpenClaw — returning
541
- * anything other than `undefined`/falsy from this handler blocks the tool.
542
- * The plugin-side handler enforces a void return; this method's signature
543
- * already returns `void`.
544
- */
545
- onBeforeToolCall(evt, ctx) {
546
- if (!evt.runId) return;
547
- const run = this.runs.get(evt.runId);
548
- if (!run) return;
549
- const toolCallId = evt.toolCallId ?? `${evt.toolName}:${randomUUID()}`;
550
- const span = {
551
- spanId: hashHex(`${run.agent.traceId}:tool_call:${toolCallId}`, 16),
552
- traceId: run.agent.traceId,
553
- parentSpanId: run.agent.spanId,
554
- name: `tool_call:${evt.toolName}`,
555
- startMs: Date.now(),
556
- endMs: void 0,
557
- attrs: {
558
- ...latitudeAttrs(ctx),
559
- ...sessionAttrs(ctx),
560
- "openclaw.run.id": evt.runId,
561
- "gen_ai.tool.name": evt.toolName,
562
- "gen_ai.tool.call.id": toolCallId,
563
- "gen_ai.tool.call.arguments:gated": evt.params
564
- }
565
- };
566
- run.openToolCalls.set(toolCallId, span);
567
- run.history.push({
568
- role: "assistant",
569
- parts: [{
570
- type: "tool_call",
571
- id: toolCallId,
572
- name: evt.toolName,
573
- arguments: evt.params
574
- }]
714
+ set(sessionId, messages) {
715
+ this.evict();
716
+ this.sessions.set(sessionId, {
717
+ messages,
718
+ updatedAt: this.now()
575
719
  });
576
720
  }
577
- onAfterToolCall(evt, _ctx) {
578
- if (!evt.runId) return;
579
- const run = this.runs.get(evt.runId);
580
- if (!run) return;
581
- let resolvedId = evt.toolCallId && run.openToolCalls.has(evt.toolCallId) ? evt.toolCallId : void 0;
582
- if (!resolvedId) resolvedId = this.findOpenToolCallByName(run, evt.toolName);
583
- if (!resolvedId) return;
584
- const span = run.openToolCalls.get(resolvedId);
585
- if (!span) return;
586
- const toolCallId = resolvedId;
587
- span.endMs = Date.now();
588
- span.outcome = Boolean(evt.error) ? "error" : "ok";
589
- span.errorMessage = evt.error;
590
- Object.assign(span.attrs, {
591
- "gen_ai.tool.call.result:gated": evt.result,
592
- "openclaw.error.message:gated": evt.error,
593
- "openclaw.duration_ms": evt.durationMs
594
- });
595
- run.openToolCalls.delete(toolCallId);
596
- run.closed.push(span);
721
+ evict() {
722
+ const now = this.now();
723
+ for (const [id, entry] of this.sessions) if (now - entry.updatedAt > SESSION_TTL_MS) this.sessions.delete(id);
724
+ if (this.sessions.size < MAX_SESSIONS) return;
725
+ const oldest = Array.from(this.sessions.entries()).sort((a, b) => a[1].updatedAt - b[1].updatedAt);
726
+ for (const [id] of oldest.slice(0, this.sessions.size - MAX_SESSIONS + 1)) this.sessions.delete(id);
727
+ }
728
+ };
729
+ /** The default workspace is `<stateDir>/workspace`, which is the only structural clue a run carries. */
730
+ function stateDirFromWorkspace(workspaceDir) {
731
+ if (!workspaceDir) return void 0;
732
+ return basename(workspaceDir) === "workspace" ? dirname(workspaceDir) : void 0;
733
+ }
734
+ function sessionTranscriptPath(stateDir, agentId, sessionId) {
735
+ return join(stateDir, "agents", agentId, "sessions", `${sessionId}.jsonl`);
736
+ }
737
+ function agentDatabasePath(stateDir, agentId) {
738
+ return join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite");
739
+ }
740
+ /**
741
+ * Reads through `node:sqlite` (Node 22.13+), loaded lazily so a host without
742
+ * it degrades to "no history" instead of failing to load the plugin. The
743
+ * database is opened read-only; the gateway keeps its own writer open.
744
+ */
745
+ const defaultTranscriptRowsReader = (dbPath, sessionId) => {
746
+ let db;
747
+ try {
748
+ db = new (createRequire(import.meta.url)("node:sqlite")).DatabaseSync(dbPath, { readOnly: true });
749
+ } catch {
750
+ return;
751
+ }
752
+ try {
753
+ const active = db.prepare(`SELECT e.event_json AS event_json
754
+ FROM session_transcript_active_events a
755
+ JOIN transcript_events e ON e.session_id = a.session_id AND e.seq = a.event_seq
756
+ WHERE a.session_id = ?
757
+ ORDER BY a.active_position`).all(sessionId);
758
+ return (active.length > 0 ? active : db.prepare("SELECT event_json FROM transcript_events WHERE session_id = ? ORDER BY seq").all(sessionId)).map((row) => row && typeof row === "object" ? row.event_json : void 0).filter((v) => typeof v === "string");
759
+ } catch {
760
+ return;
761
+ } finally {
762
+ try {
763
+ db.close();
764
+ } catch {}
765
+ }
766
+ };
767
+ /**
768
+ * The latest compaction entry of a session. `after_compaction` reports only
769
+ * counts; the summary that replaced the compacted messages lives in the
770
+ * transcript store, written before the hook fires.
771
+ */
772
+ function readLatestCompaction(dbPath, sessionId, readRows = defaultTranscriptRowsReader) {
773
+ const rows = readRows(dbPath, sessionId);
774
+ return rows ? latestCompactionFromEntries(rows) : void 0;
775
+ }
776
+ function readLatestCompactionFromFile(path, read = defaultTranscriptReader) {
777
+ const raw = read(path);
778
+ return raw === void 0 ? void 0 : latestCompactionFromEntries(raw.split("\n"));
779
+ }
780
+ function latestCompactionFromEntries(lines) {
781
+ let latest;
782
+ for (const line of lines) {
783
+ if (!line.includes("\"compaction\"")) continue;
784
+ let entry;
785
+ try {
786
+ entry = JSON.parse(line);
787
+ } catch {
788
+ continue;
789
+ }
790
+ if (!entry || entry.type !== "compaction" || typeof entry.summary !== "string") continue;
791
+ latest = {
792
+ summary: entry.summary,
793
+ tokensBefore: typeof entry.tokensBefore === "number" ? entry.tokensBefore : void 0,
794
+ tokensAfter: typeof entry.tokensAfter === "number" ? entry.tokensAfter : void 0
795
+ };
796
+ }
797
+ return latest;
798
+ }
799
+ /** The session's visible conversation from the agent database, oldest first. */
800
+ function readSessionTranscriptFromDatabase(dbPath, sessionId, readRows = defaultTranscriptRowsReader) {
801
+ const rows = readRows(dbPath, sessionId);
802
+ if (!rows) return void 0;
803
+ return messagesFromEntries(rows);
804
+ }
805
+ /**
806
+ * The current prompt is usually persisted before `llm_input` fires, so the
807
+ * stored transcript ends with it. Channels wrap the prompt in an envelope
808
+ * (conversation info, system lines) before the model sees it, so the stored
809
+ * text is matched by containment; a very recent trailing user message with no
810
+ * text match is dropped as well.
811
+ */
812
+ function withoutCurrentPrompt(stored, prompt, runStartMs) {
813
+ const kept = [...stored];
814
+ for (let i = 0; i < 3 && kept.length > 0; i++) {
815
+ const last = kept[kept.length - 1];
816
+ if (last.message.role !== "user") break;
817
+ const text = last.message.parts.map((p) => typeof p.content === "string" ? p.content : "").join("\n").trim();
818
+ const textMatches = text.length > 0 && prompt !== void 0 && (prompt === text || prompt.includes(text));
819
+ const recent = last.timestamp !== void 0 && Math.abs(runStartMs - last.timestamp) <= CURRENT_PROMPT_WINDOW_MS;
820
+ if (!textMatches && !recent) break;
821
+ kept.pop();
822
+ }
823
+ return kept.map((m) => m.message);
824
+ }
825
+ /**
826
+ * OpenClaw's transcript is a JSONL tree of entries; the visible conversation is
827
+ * the parent chain of the last message entry (compaction and reset entries
828
+ * branch it). Returns the normalized messages, oldest first.
829
+ */
830
+ function readSessionTranscript(path, read = defaultTranscriptReader) {
831
+ const raw = read(path);
832
+ if (raw === void 0) return void 0;
833
+ return messagesFromEntries(raw.split("\n"));
834
+ }
835
+ function messagesFromEntries(lines) {
836
+ const byId = /* @__PURE__ */ new Map();
837
+ let last;
838
+ for (const line of lines) {
839
+ if (line.trim().length === 0) continue;
840
+ let entry;
841
+ try {
842
+ entry = JSON.parse(line);
843
+ } catch {
844
+ continue;
845
+ }
846
+ if (!entry || typeof entry !== "object") continue;
847
+ if (typeof entry.id === "string") byId.set(entry.id, entry);
848
+ if (entry.type === "message" && entry.message) last = entry;
849
+ }
850
+ if (!last) return [];
851
+ const chain = [];
852
+ const seen = /* @__PURE__ */ new Set();
853
+ let cursor = last;
854
+ while (cursor && chain.length < MAX_MESSAGES) {
855
+ if (cursor.type === "message" && cursor.message) chain.push(cursor);
856
+ if (cursor.type === "compaction") {
857
+ if (typeof cursor.summary === "string" && cursor.summary.length > 0) chain.push({
858
+ type: "message",
859
+ message: {
860
+ role: "user",
861
+ content: `[compaction summary] ${cursor.summary}`
862
+ }
863
+ });
864
+ break;
865
+ }
866
+ const parentId = cursor.parentId;
867
+ if (!parentId || seen.has(parentId)) break;
868
+ seen.add(parentId);
869
+ cursor = byId.get(parentId);
870
+ }
871
+ chain.reverse();
872
+ const out = [];
873
+ for (const entry of chain) {
874
+ const [message] = normalizeMessages([entry.message]);
875
+ if (message) out.push({
876
+ message,
877
+ timestamp: entryTimestamp(entry)
878
+ });
879
+ }
880
+ return out;
881
+ }
882
+ function entryTimestamp(entry) {
883
+ const inner = entry.message?.timestamp;
884
+ if (typeof inner === "number") return inner;
885
+ if (typeof entry.timestamp === "string") {
886
+ const parsed = Date.parse(entry.timestamp);
887
+ if (!Number.isNaN(parsed)) return parsed;
888
+ }
889
+ }
890
+ //#endregion
891
+ //#region src/memory.ts
892
+ const MAX_FILE_BYTES = 1024 * 1024;
893
+ const MEMORY_ROOT_FILES = new Set([
894
+ "MEMORY.md",
895
+ "memory.md",
896
+ "USER.md",
897
+ "user.md"
898
+ ]);
899
+ const MEMORY_DIR = "memory";
900
+ function memoryStoreId(agentId) {
901
+ return `openclaw/${agentId ?? "main"}`;
902
+ }
903
+ const defaultFileReader = (path) => {
904
+ try {
905
+ if (statSync(path).size > MAX_FILE_BYTES) return void 0;
906
+ return readFileSync(path, "utf8");
907
+ } catch {
908
+ return;
909
+ }
910
+ };
911
+ /** Workspace-relative record id for a path inside the memory scope, else undefined. */
912
+ function memoryRecordId(path, workspaceDir) {
913
+ if (!path) return void 0;
914
+ const abs = workspaceDir ? resolve(workspaceDir, path) : isAbsolute(path) ? path : void 0;
915
+ if (!abs) return void 0;
916
+ const rel = workspaceDir ? relative(workspaceDir, abs) : abs;
917
+ if (rel.startsWith("..")) return void 0;
918
+ const normalized = rel.split(sep).join("/");
919
+ if (MEMORY_ROOT_FILES.has(normalized)) return normalized;
920
+ if (normalized.startsWith(`${MEMORY_DIR}/`) && normalized.endsWith(".md")) return normalized;
921
+ }
922
+ /** Classify a finished tool call as memory operations; empty when it touched none. */
923
+ function memoryEventsFromToolCall(call, readFile = defaultFileReader) {
924
+ if (call.error) return [];
925
+ if (call.toolName === "apply_patch") return patchEvents(call, memoryStoreId(call.agentId), readFile);
926
+ const single = singleMemoryEvent(call, readFile);
927
+ return single ? [single] : [];
928
+ }
929
+ function singleMemoryEvent(call, readFile) {
930
+ const storeId = memoryStoreId(call.agentId);
931
+ switch (call.toolName) {
932
+ case "memory_search": return {
933
+ operation: "search_memory",
934
+ storeId,
935
+ queryText: str(call.params.query),
936
+ records: searchRecords(call.result)
937
+ };
938
+ case "memory_get": {
939
+ const path = str(call.params.path);
940
+ const text = resultText(call.result);
941
+ return {
942
+ operation: "search_memory",
943
+ storeId,
944
+ recordId: path,
945
+ records: text !== void 0 ? [{
946
+ id: path,
947
+ content: text
948
+ }] : []
949
+ };
950
+ }
951
+ case "memory_recall": return {
952
+ operation: "search_memory",
953
+ storeId,
954
+ queryText: str(call.params.query),
955
+ records: searchRecords(call.result)
956
+ };
957
+ case "memory_store": {
958
+ const text = str(call.params.text) ?? str(call.params.content);
959
+ return {
960
+ operation: "upsert_memory",
961
+ storeId,
962
+ records: text !== void 0 ? [{ content: text }] : []
963
+ };
964
+ }
965
+ case "memory_forget": return {
966
+ operation: "delete_memory",
967
+ storeId,
968
+ recordId: str(call.params.id) ?? str(call.params.query),
969
+ records: []
970
+ };
971
+ case "write":
972
+ case "edit": return fileWriteEvent(call, storeId, readFile);
973
+ default: return;
974
+ }
975
+ }
976
+ function fileWriteEvent(call, storeId, readFile) {
977
+ const path = str(call.params.path);
978
+ const recordId = memoryRecordId(path, call.workspaceDir);
979
+ if (!recordId || !path) return void 0;
980
+ const abs = call.workspaceDir ? resolve(call.workspaceDir, path) : path;
981
+ return writeEvent(storeId, recordId, call.toolName === "write" ? str(call.params.content) : readFile(abs));
982
+ }
983
+ function writeEvent(storeId, recordId, body) {
984
+ if (body === void 0) return {
985
+ operation: "upsert_memory",
986
+ storeId,
987
+ recordId,
988
+ records: [],
989
+ bodyUnavailable: true
990
+ };
991
+ if (body.trim().length === 0) return {
992
+ operation: "delete_memory",
993
+ storeId,
994
+ recordId,
995
+ records: []
996
+ };
997
+ return {
998
+ operation: "upsert_memory",
999
+ storeId,
1000
+ recordId,
1001
+ records: [{
1002
+ id: recordId,
1003
+ content: body
1004
+ }]
1005
+ };
1006
+ }
1007
+ const PATCH_FILE_LINE = /^\*\*\* (Update|Add|Delete) File: (.+)$/gm;
1008
+ /**
1009
+ * Codex's `apply_patch` takes one patch text touching any number of files
1010
+ * (`*** Update File: <path>`). Each memory-scoped file becomes its own event,
1011
+ * with the body read back from disk after the patch landed.
1012
+ */
1013
+ function patchEvents(call, storeId, readFile) {
1014
+ const patch = str(call.params.command) ?? str(call.params.input) ?? str(call.params.patch);
1015
+ if (!patch) return [];
1016
+ const events = [];
1017
+ for (const match of patch.matchAll(PATCH_FILE_LINE)) {
1018
+ const action = match[1];
1019
+ const path = match[2]?.trim();
1020
+ const recordId = memoryRecordId(path, call.workspaceDir);
1021
+ if (!recordId || !path) continue;
1022
+ if (action === "Delete") {
1023
+ events.push({
1024
+ operation: "delete_memory",
1025
+ storeId,
1026
+ recordId,
1027
+ records: []
1028
+ });
1029
+ continue;
1030
+ }
1031
+ const abs = call.workspaceDir ? resolve(call.workspaceDir, path) : path;
1032
+ events.push(writeEvent(storeId, recordId, readFile(abs)));
1033
+ }
1034
+ return events;
1035
+ }
1036
+ /**
1037
+ * The frozen snapshot injected at session start: every non-empty built-in
1038
+ * store file, read once per session. Returns undefined when nothing exists.
1039
+ */
1040
+ function memorySnapshot(workspaceDir, agentId, readFile = defaultFileReader, today = /* @__PURE__ */ new Date()) {
1041
+ if (!workspaceDir) return void 0;
1042
+ const candidates = [
1043
+ "MEMORY.md",
1044
+ "USER.md",
1045
+ `${MEMORY_DIR}/${today.toISOString().slice(0, 10)}.md`
1046
+ ];
1047
+ const records = [];
1048
+ for (const rel of candidates) {
1049
+ const body = readFile(resolve(workspaceDir, rel));
1050
+ if (body !== void 0 && body.trim().length > 0) records.push({
1051
+ id: rel,
1052
+ content: body
1053
+ });
1054
+ }
1055
+ if (records.length === 0) return void 0;
1056
+ return {
1057
+ operation: "search_memory",
1058
+ storeId: memoryStoreId(agentId),
1059
+ records
1060
+ };
1061
+ }
1062
+ function searchRecords(result) {
1063
+ const details = detailsOf(result);
1064
+ const list = Array.isArray(details?.results) ? details.results : void 0;
1065
+ if (list) {
1066
+ const out = [];
1067
+ for (const item of list) {
1068
+ if (!item || typeof item !== "object") continue;
1069
+ const r = item;
1070
+ const path = str(r.path);
1071
+ const line = typeof r.startLine === "number" ? `#${r.startLine}` : "";
1072
+ out.push({
1073
+ ...path ? { id: `${path}${line}` } : {},
1074
+ content: str(r.snippet) ?? str(r.text) ?? str(r.content) ?? "",
1075
+ ...typeof r.score === "number" ? { score: r.score } : {}
1076
+ });
1077
+ }
1078
+ return out;
1079
+ }
1080
+ const text = resultText(result);
1081
+ return text !== void 0 && text.length > 0 ? [{ content: text }] : [];
1082
+ }
1083
+ function detailsOf(result) {
1084
+ if (!result || typeof result !== "object") return void 0;
1085
+ const details = result.details;
1086
+ return details && typeof details === "object" ? details : void 0;
1087
+ }
1088
+ function resultText(result) {
1089
+ if (typeof result === "string") return result;
1090
+ if (!result || typeof result !== "object") return void 0;
1091
+ const details = detailsOf(result);
1092
+ if (details && typeof details.text === "string") return details.text;
1093
+ const content = result.content;
1094
+ if (typeof content === "string") return content;
1095
+ if (Array.isArray(content)) {
1096
+ const texts = content.map((b) => b && typeof b === "object" && typeof b.text === "string" ? b.text : "").filter((t) => t.length > 0);
1097
+ if (texts.length > 0) return texts.join("\n");
1098
+ }
1099
+ }
1100
+ function str(value) {
1101
+ return typeof value === "string" ? value : void 0;
1102
+ }
1103
+ //#endregion
1104
+ //#region src/tools.ts
1105
+ /**
1106
+ * `llm_input.tools` is the post-policy tool list offered to the model, as
1107
+ * pi-ai `Tool` objects (`name`, `description`, `parameters` JSON schema) plus
1108
+ * runtime fields such as `execute` that never leave the process.
1109
+ */
1110
+ function toolDefinitionsFrom(raw) {
1111
+ if (!raw || raw.length === 0) return void 0;
1112
+ const out = [];
1113
+ for (const item of raw) {
1114
+ if (!item || typeof item !== "object") continue;
1115
+ const obj = item;
1116
+ const fn = obj.function && typeof obj.function === "object" ? obj.function : obj;
1117
+ if (typeof fn.name !== "string" || fn.name.length === 0) continue;
1118
+ const parameters = fn.parameters ?? fn.inputSchema ?? fn.input_schema;
1119
+ out.push({
1120
+ type: "function",
1121
+ name: fn.name,
1122
+ description: typeof fn.description === "string" ? fn.description : "",
1123
+ ...parameters !== void 0 ? { parameters: plainSchema(parameters) } : {}
1124
+ });
1125
+ }
1126
+ return out.length > 0 ? out : void 0;
1127
+ }
1128
+ function plainSchema(schema) {
1129
+ try {
1130
+ return JSON.parse(JSON.stringify(schema));
1131
+ } catch {
1132
+ return;
1133
+ }
1134
+ }
1135
+ //#endregion
1136
+ //#region src/usage.ts
1137
+ function isTranscriptAssistant(raw) {
1138
+ return !!raw && typeof raw === "object" && raw.role === "assistant";
1139
+ }
1140
+ const FINISH_REASONS = {
1141
+ stop: "stop",
1142
+ length: "length",
1143
+ toolUse: "tool_calls",
1144
+ error: "error",
1145
+ aborted: "cancelled"
1146
+ };
1147
+ function finishReason(stopReason) {
1148
+ if (!stopReason) return void 0;
1149
+ return FINISH_REASONS[stopReason] ?? stopReason;
1150
+ }
1151
+ /**
1152
+ * `gen_ai.usage.*` from a transcript message. `output_tokens` stays inclusive
1153
+ * of reasoning: Latitude's resolver subtracts `reasoning_tokens` itself.
1154
+ */
1155
+ function usageAttrsFromTranscript(usage) {
1156
+ if (!usage) return {};
1157
+ const input = num(usage.input);
1158
+ const output = num(usage.output);
1159
+ const cacheRead = num(usage.cacheRead);
1160
+ const cacheWrite = num(usage.cacheWrite);
1161
+ const total = num(usage.totalTokens) ?? sumDefined(input, output, cacheRead, cacheWrite);
1162
+ return {
1163
+ "gen_ai.usage.input_tokens": input,
1164
+ "gen_ai.usage.output_tokens": output,
1165
+ "gen_ai.usage.cache_read.input_tokens": cacheRead,
1166
+ "gen_ai.usage.cache_creation.input_tokens": cacheWrite,
1167
+ "gen_ai.usage.reasoning_tokens": num(usage.reasoningTokens),
1168
+ "gen_ai.usage.total_tokens": total,
1169
+ ...costAttrs(usage.cost)
1170
+ };
1171
+ }
1172
+ /** Attempt-aggregate usage from `llm_output`, same keys. */
1173
+ function usageAttrsFromAggregate(usage) {
1174
+ if (!usage) return {};
1175
+ const input = num(usage.input);
1176
+ const output = num(usage.output);
1177
+ const cacheRead = num(usage.cacheRead);
1178
+ const cacheWrite = num(usage.cacheWrite);
1179
+ return {
1180
+ "gen_ai.usage.input_tokens": input,
1181
+ "gen_ai.usage.output_tokens": output,
1182
+ "gen_ai.usage.cache_read.input_tokens": cacheRead,
1183
+ "gen_ai.usage.cache_creation.input_tokens": cacheWrite,
1184
+ "gen_ai.usage.total_tokens": num(usage.total) ?? sumDefined(input, output, cacheRead, cacheWrite)
1185
+ };
1186
+ }
1187
+ /**
1188
+ * OpenClaw prices every call from its own model catalog (or the provider's
1189
+ * bill when `totalOrigin` says so). A positive total is reported as the span's
1190
+ * cost so a model missing from Latitude's catalog still carries a price.
1191
+ */
1192
+ function costAttrs(cost) {
1193
+ if (!cost) return {};
1194
+ const total = num(cost.total);
1195
+ if (total === void 0 || total <= 0) return {};
1196
+ return {
1197
+ "gen_ai.usage.cost": total,
1198
+ "gen_ai.usage.input_cost": sumDefined(num(cost.input), num(cost.cacheRead), num(cost.cacheWrite)),
1199
+ "gen_ai.usage.output_cost": num(cost.output),
1200
+ "openclaw.cost.origin": cost.totalOrigin === "provider-billed" ? "provider-billed" : "catalog"
1201
+ };
1202
+ }
1203
+ function num(value) {
1204
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1205
+ }
1206
+ function sumDefined(...values) {
1207
+ const defined = values.filter((v) => v !== void 0);
1208
+ if (defined.length === 0) return void 0;
1209
+ return defined.reduce((a, b) => a + b, 0);
1210
+ }
1211
+ //#endregion
1212
+ //#region src/span-builder.ts
1213
+ const GRACE_MS = 1500;
1214
+ const RUN_TTL_MS = 7200 * 1e3;
1215
+ const LINK_TTL_MS = 3600 * 1e3;
1216
+ const LINK_MAX = 1e3;
1217
+ const SESSION_INDEX_MAX = 2e3;
1218
+ const STANDALONE_COMPACTION_MAX = 100;
1219
+ const SPAWN_TOOL_PATTERN = /spawn/i;
1220
+ const MAX_DELTAS = 256;
1221
+ const MAX_THINKING_CHARS = 64 * 1024;
1222
+ const KIND_INTERNAL = 1;
1223
+ const KIND_CLIENT = 3;
1224
+ var SpanBuilder = class {
1225
+ runs = /* @__PURE__ */ new Map();
1226
+ runsBySession = /* @__PURE__ */ new Map();
1227
+ subagentLinks = /* @__PURE__ */ new Map();
1228
+ pendingSubagents = /* @__PURE__ */ new Map();
1229
+ sessionIds = /* @__PURE__ */ new Map();
1230
+ modelBySession = /* @__PURE__ */ new Map();
1231
+ workspaceBySession = /* @__PURE__ */ new Map();
1232
+ memorySnapshotSessions = /* @__PURE__ */ new Set();
1233
+ senders = /* @__PURE__ */ new Map();
1234
+ sendersById = /* @__PURE__ */ new Map();
1235
+ cronStarted = /* @__PURE__ */ new Map();
1236
+ sessionHistory;
1237
+ standaloneCompactions = /* @__PURE__ */ new Map();
1238
+ emit;
1239
+ now;
1240
+ schedule;
1241
+ readFile;
1242
+ readTranscript;
1243
+ readTranscriptRows;
1244
+ stateDir;
1245
+ log;
1246
+ pluginVersion;
1247
+ operatorTags;
1248
+ operatorMetadata;
1249
+ memoryEnabled;
1250
+ memoryContent;
1251
+ toolDefinitionsEnabled;
1252
+ constructor(options) {
1253
+ this.emit = options.emit;
1254
+ this.now = options.now ?? (() => Date.now());
1255
+ this.schedule = options.schedule ?? ((fn, ms) => {
1256
+ const timer = setTimeout(fn, ms);
1257
+ timer.unref?.();
1258
+ return () => clearTimeout(timer);
1259
+ });
1260
+ this.readFile = options.readFile;
1261
+ this.readTranscript = options.readTranscript;
1262
+ this.readTranscriptRows = options.readTranscriptRows;
1263
+ this.stateDir = options.stateDir;
1264
+ this.sessionHistory = new SessionHistoryStore(this.now);
1265
+ this.log = options.log ?? (() => {});
1266
+ this.pluginVersion = options.pluginVersion;
1267
+ this.operatorTags = options.tags ?? [];
1268
+ this.operatorMetadata = options.metadata ?? {};
1269
+ this.memoryEnabled = options.memory ?? true;
1270
+ this.memoryContent = options.memoryContent ?? true;
1271
+ this.toolDefinitionsEnabled = options.toolDefinitions ?? true;
1272
+ }
1273
+ inflightCount() {
1274
+ return this.runs.size;
1275
+ }
1276
+ subagentLinkCount() {
1277
+ return this.subagentLinks.size;
1278
+ }
1279
+ onLlmInput(evt, ctx) {
1280
+ const run = this.ensureRun(evt.runId, ctx);
1281
+ if (!run) return;
1282
+ const harnessHistory = normalizeMessages(evt.historyMessages);
1283
+ if (harnessHistory.length > 0) {
1284
+ run.inheritedHistory = [];
1285
+ run.historySource = "harness";
1286
+ } else this.inheritHistory(run, evt.prompt);
1287
+ run.history = [...run.inheritedHistory, ...harnessHistory];
1288
+ if (evt.prompt) run.history.push(userMessageFromPrompt(evt.prompt));
1289
+ run.inputMessages = [...run.history];
1290
+ run.prompt = evt.prompt;
1291
+ run.systemPrompt = evt.systemPrompt;
1292
+ if (run.ctx.sessionKey) {
1293
+ remember(this.modelBySession, run.ctx.sessionKey, {
1294
+ provider: evt.provider,
1295
+ model: evt.model
1296
+ }, SESSION_INDEX_MAX);
1297
+ if (run.ctx.workspaceDir) remember(this.workspaceBySession, run.ctx.sessionKey, run.ctx.workspaceDir, SESSION_INDEX_MAX);
1298
+ }
1299
+ if (this.toolDefinitionsEnabled) run.toolDefinitions = toolDefinitionsFrom(evt.tools) ?? run.toolDefinitions;
1300
+ Object.assign(run.root.attrs, {
1301
+ "gen_ai.request.model": evt.model,
1302
+ "openclaw.provider": evt.provider,
1303
+ "openclaw.images.count": evt.imagesCount,
1304
+ "openclaw.tool_count": evt.tools?.length
1305
+ });
1306
+ this.recordMemorySnapshot(run);
1307
+ }
1308
+ onLlmOutput(evt, ctx) {
1309
+ const run = this.runs.get(ctx.runId ?? evt.runId);
1310
+ if (!run) return;
1311
+ this.mergeCtx(run, ctx);
1312
+ run.outputSeen = true;
1313
+ run.aggregateUsage = usageAttrsFromAggregate(evt.usage);
1314
+ run.output = {
1315
+ resolvedRef: evt.resolvedRef,
1316
+ harnessId: evt.harnessId,
1317
+ reasoningEffort: evt.reasoningEffort,
1318
+ lastAssistant: evt.lastAssistant
1319
+ };
1320
+ Object.assign(run.root.attrs, {
1321
+ "gen_ai.response.model": evt.model,
1322
+ "openclaw.resolved.ref": evt.resolvedRef,
1323
+ "openclaw.harness.id": evt.harnessId,
1324
+ "openclaw.reasoning.effort": evt.reasoningEffort
1325
+ });
1326
+ if (run.endEvent) this.finalize(run);
1327
+ }
1328
+ onAgentEnd(evt, ctx) {
1329
+ const runId = ctx.runId ?? evt.runId;
1330
+ if (!runId) return;
1331
+ const run = this.runs.get(runId);
1332
+ if (!run) {
1333
+ this.subagentLinks.delete(runId);
1334
+ return;
1335
+ }
1336
+ this.mergeCtx(run, ctx);
1337
+ run.endEvent = evt;
1338
+ if (run.outputSeen) {
1339
+ this.finalize(run);
1340
+ return;
1341
+ }
1342
+ run.cancelGrace = this.schedule(() => this.finalize(run), GRACE_MS);
1343
+ }
1344
+ onModelCallStarted(evt, ctx) {
1345
+ const run = this.ensureRun(evt.runId, {
1346
+ ...ctx,
1347
+ sessionId: ctx.sessionId ?? evt.sessionId,
1348
+ sessionKey: ctx.sessionKey ?? evt.sessionKey
1349
+ });
1350
+ if (!run) return;
1351
+ const span = {
1352
+ spanId: hashHex(`${run.runId}:llm_request:${evt.callId}`, 16),
1353
+ traceId: run.traceId,
1354
+ parentSpanId: run.root.spanId,
1355
+ name: "llm_request",
1356
+ kind: KIND_INTERNAL,
1357
+ startMs: this.now(),
1358
+ endMs: void 0,
1359
+ attrs: {
1360
+ "gen_ai.operation.name": "chat",
1361
+ "gen_ai.provider.name": evt.provider,
1362
+ "gen_ai.system": evt.provider,
1363
+ "gen_ai.request.model": evt.model,
1364
+ "gen_ai.request.stream": true,
1365
+ "openclaw.call.id": evt.callId,
1366
+ "openclaw.api": evt.api,
1367
+ "openclaw.transport": evt.transport,
1368
+ "openclaw.context.token_budget": evt.contextTokenBudget,
1369
+ "llm_request.call_index": run.llmCalls.length + run.openModelCalls.size,
1370
+ "gen_ai.input.messages:gated": [...run.history],
1371
+ "gen_ai.system_instructions:gated": run.systemPrompt ? systemInstructionsParts(run.systemPrompt) : void 0,
1372
+ "gen_ai.tool.definitions:gated": run.toolDefinitions
1373
+ }
1374
+ };
1375
+ run.openModelCalls.set(evt.callId, span);
1376
+ }
1377
+ onModelCallEnded(evt, _ctx) {
1378
+ const run = this.runs.get(evt.runId);
1379
+ if (!run) return;
1380
+ const span = run.openModelCalls.get(evt.callId);
1381
+ if (!span) return;
1382
+ span.endMs = this.now();
1383
+ span.outcome = evt.outcome === "completed" ? "ok" : "error";
1384
+ span.errorMessage = evt.errorCategory;
1385
+ Object.assign(span.attrs, {
1386
+ "openclaw.duration_ms": evt.durationMs,
1387
+ "openclaw.outcome": evt.outcome,
1388
+ "openclaw.error.category": evt.errorCategory,
1389
+ "openclaw.failure.kind": evt.failureKind,
1390
+ "error.type": evt.outcome === "error" ? evt.errorCategory ?? evt.failureKind ?? "error" : void 0,
1391
+ "openclaw.request.payload_bytes": evt.requestPayloadBytes,
1392
+ "openclaw.response.stream_bytes": evt.responseStreamBytes,
1393
+ "openclaw.ttfb_ms": evt.timeToFirstByteMs,
1394
+ "gen_ai.server.time_to_first_token": evt.timeToFirstByteMs !== void 0 && evt.timeToFirstByteMs > 0 ? Math.round(evt.timeToFirstByteMs * 1e6) : void 0,
1395
+ "openclaw.upstream.request_id_hash": evt.upstreamRequestIdHash
1396
+ });
1397
+ run.openModelCalls.delete(evt.callId);
1398
+ run.llmCalls.push(span);
1399
+ }
1400
+ onBeforeToolCall(evt, ctx) {
1401
+ const runId = evt.runId ?? ctx.runId;
1402
+ if (!runId) return;
1403
+ const run = this.ensureRun(runId, toolCtxToAgentCtx(ctx, runId));
1404
+ if (!run) return;
1405
+ const toolCallId = evt.toolCallId ?? ctx.toolCallId ?? `${evt.toolName}:${randomUUID()}`;
1406
+ const span = {
1407
+ spanId: hashHex(`${run.runId}:tool_call:${toolCallId}`, 16),
1408
+ traceId: run.traceId,
1409
+ parentSpanId: run.root.spanId,
1410
+ name: `tool_call:${evt.toolName}`,
1411
+ kind: KIND_CLIENT,
1412
+ startMs: this.now(),
1413
+ endMs: void 0,
1414
+ attrs: {
1415
+ "gen_ai.operation.name": "execute_tool",
1416
+ "gen_ai.tool.name": evt.toolName,
1417
+ "gen_ai.tool.call.id": toolCallId,
1418
+ "gen_ai.tool.call.arguments:gated": evt.params
1419
+ }
1420
+ };
1421
+ run.openToolCalls.set(toolCallId, span);
597
1422
  run.history.push({
598
- role: "tool",
1423
+ role: "assistant",
599
1424
  parts: [{
600
- type: "tool_call_response",
1425
+ type: "tool_call",
601
1426
  id: toolCallId,
602
- response: evt.result ?? evt.error ?? ""
1427
+ name: evt.toolName,
1428
+ arguments: evt.params
603
1429
  }]
604
1430
  });
1431
+ if (SPAWN_TOOL_PATTERN.test(evt.toolName)) run.lastSpawnToolSpanId = span.spanId;
605
1432
  }
606
- onBeforeCompaction(evt, ctx) {
607
- const runId = ctx.runId;
1433
+ onAfterToolCall(evt, ctx) {
1434
+ const runId = evt.runId ?? ctx.runId;
608
1435
  if (!runId) return;
609
1436
  const run = this.runs.get(runId);
610
1437
  if (!run) return;
611
- run.openCompaction = {
612
- spanId: hashHex(`${run.agent.traceId}:compaction:${run.closed.length}`, 16),
613
- traceId: run.agent.traceId,
614
- parentSpanId: run.agent.spanId,
1438
+ let resolvedId = evt.toolCallId && run.openToolCalls.has(evt.toolCallId) ? evt.toolCallId : void 0;
1439
+ if (!resolvedId) resolvedId = this.findOpenToolCallByName(run, evt.toolName);
1440
+ if (!resolvedId) return;
1441
+ const span = run.openToolCalls.get(resolvedId);
1442
+ if (!span) return;
1443
+ span.endMs = this.now();
1444
+ const isError = Boolean(evt.error);
1445
+ span.outcome = isError ? "error" : "ok";
1446
+ span.errorMessage = evt.error;
1447
+ Object.assign(span.attrs, {
1448
+ "gen_ai.tool.call.result:gated": evt.result,
1449
+ "tool.is_error": isError,
1450
+ "error.type": isError ? "tool_error" : void 0,
1451
+ "error.message:gated": evt.error,
1452
+ "openclaw.duration_ms": evt.durationMs
1453
+ });
1454
+ run.openToolCalls.delete(resolvedId);
1455
+ run.closed.push(span);
1456
+ run.history.push({
1457
+ role: "tool",
1458
+ parts: [{
1459
+ type: "tool_call_response",
1460
+ id: resolvedId,
1461
+ name: evt.toolName,
1462
+ response: evt.result ?? evt.error ?? ""
1463
+ }]
1464
+ });
1465
+ if (this.memoryEnabled) {
1466
+ const events = memoryEventsFromToolCall({
1467
+ toolName: evt.toolName,
1468
+ params: evt.params,
1469
+ result: evt.result,
1470
+ error: evt.error,
1471
+ workspaceDir: run.ctx.workspaceDir,
1472
+ agentId: run.ctx.agentId
1473
+ }, this.readFile);
1474
+ for (const memory of events) run.closed.push(this.memorySpan(run, memory, span));
1475
+ }
1476
+ }
1477
+ /**
1478
+ * A compaction is a model call of its own: the compacted messages go in and
1479
+ * a summary comes out, so it is exported as a `chat` span. OpenClaw fires no
1480
+ * per-call hooks for the summarizer, so its usage is unreported.
1481
+ */
1482
+ onBeforeCompaction(evt, ctx) {
1483
+ const run = this.openRunForSession(ctx.sessionKey);
1484
+ const startMs = this.now();
1485
+ const traceId = run?.traceId ?? hashHex(`${ctx.sessionKey ?? "unknown"}:compaction:${startMs}`, 32);
1486
+ const model = ctx.sessionKey ? this.modelBySession.get(ctx.sessionKey) : void 0;
1487
+ const span = {
1488
+ spanId: hashHex(`${traceId}:compaction:${startMs}`, 16),
1489
+ traceId,
1490
+ parentSpanId: run?.root.spanId ?? "",
615
1491
  name: "compaction",
616
- startMs: Date.now(),
1492
+ kind: KIND_INTERNAL,
1493
+ startMs,
617
1494
  endMs: void 0,
618
1495
  attrs: {
619
- ...latitudeAttrs(ctx),
620
- ...sessionAttrs(ctx),
621
- "openclaw.run.id": runId,
1496
+ "gen_ai.operation.name": "chat",
1497
+ "gen_ai.provider.name": model?.provider,
1498
+ "gen_ai.system": model?.provider,
1499
+ "gen_ai.request.model": model?.model,
1500
+ "openclaw.usage.state": "unreported",
622
1501
  "openclaw.compaction.message_count.before": evt.messageCount,
1502
+ "openclaw.compaction.compacting_count": evt.compactingCount,
1503
+ "openclaw.compaction.token_count.before": evt.tokenCount,
623
1504
  "openclaw.compaction.session_file": evt.sessionFile,
624
- "before_compaction.messages:gated": evt.messages ? normalizeMessages(evt.messages) : void 0
1505
+ "openclaw.session.key": ctx.sessionKey,
1506
+ "gen_ai.input.messages:gated": evt.messages ? normalizeMessages(evt.messages) : void 0
625
1507
  }
626
1508
  };
1509
+ if (run) {
1510
+ run.openCompaction = span;
1511
+ return;
1512
+ }
1513
+ remember(this.standaloneCompactions, ctx.sessionKey ?? "", span, STANDALONE_COMPACTION_MAX);
627
1514
  }
628
1515
  onAfterCompaction(evt, ctx) {
629
- const runId = ctx.runId;
630
- if (!runId) return;
631
- const run = this.runs.get(runId);
632
- if (!run?.openCompaction) return;
633
- const span = run.openCompaction;
634
- span.endMs = Date.now();
1516
+ const run = this.openRunForSession(ctx.sessionKey);
1517
+ const span = run?.openCompaction ?? this.standaloneCompactions.get(ctx.sessionKey ?? "");
1518
+ if (!span) return;
1519
+ const endMs = this.now();
1520
+ span.endMs = endMs;
635
1521
  span.outcome = "ok";
1522
+ const sessionId = ctx.sessionId ?? run?.ctx.sessionId ?? (ctx.sessionKey ? this.sessionIds.get(ctx.sessionKey) : void 0);
1523
+ const agentId = ctx.agentId ?? run?.ctx.agentId ?? agentIdFromSessionKey(ctx.sessionKey);
1524
+ const workspaceDir = run?.ctx.workspaceDir ?? (ctx.sessionKey ? this.workspaceBySession.get(ctx.sessionKey) : void 0);
1525
+ const record = this.latestCompaction(sessionId, agentId, workspaceDir);
636
1526
  Object.assign(span.attrs, {
637
1527
  "openclaw.compaction.message_count.after": evt.messageCount,
638
1528
  "openclaw.compaction.compacted_count": evt.compactedCount,
639
- "openclaw.compaction.token_count": evt.tokenCount
1529
+ "openclaw.compaction.token_count.after": evt.tokenCount ?? record?.tokensAfter,
1530
+ "openclaw.compaction.token_count.before": span.attrs["openclaw.compaction.token_count.before"] ?? record?.tokensBefore,
1531
+ "openclaw.compaction.previous_session_id": evt.previousSessionId,
1532
+ "openclaw.compaction.summary_chars": record?.summary.length,
1533
+ "gen_ai.output.messages:gated": record ? [{
1534
+ role: "assistant",
1535
+ parts: [{
1536
+ type: "text",
1537
+ content: record.summary
1538
+ }]
1539
+ }] : void 0
640
1540
  });
641
- run.openCompaction = void 0;
642
- run.closed.push(span);
1541
+ if (run?.openCompaction === span) {
1542
+ run.openCompaction = void 0;
1543
+ run.closed.push(span);
1544
+ return;
1545
+ }
1546
+ this.standaloneCompactions.delete(ctx.sessionKey ?? "");
1547
+ const enrichment = deriveEnrichment({
1548
+ ctx: {
1549
+ agentId,
1550
+ sessionKey: ctx.sessionKey,
1551
+ sessionId,
1552
+ workspaceDir
1553
+ },
1554
+ subagentIds: [],
1555
+ pluginVersion: this.pluginVersion
1556
+ }, {
1557
+ tags: this.operatorTags,
1558
+ metadata: this.operatorMetadata
1559
+ });
1560
+ const compacted = evt.compactedCount ?? 0;
1561
+ const root = {
1562
+ spanId: hashHex(`${span.traceId}:compaction-root`, 16),
1563
+ traceId: span.traceId,
1564
+ parentSpanId: "",
1565
+ name: "compaction",
1566
+ kind: KIND_INTERNAL,
1567
+ startMs: span.startMs,
1568
+ endMs,
1569
+ outcome: "ok",
1570
+ attrs: {
1571
+ "gen_ai.operation.name": "invoke_agent",
1572
+ "interaction.kind": "compaction",
1573
+ "interaction.duration_ms": endMs - span.startMs,
1574
+ "user_prompt:gated": `[compaction] ${compacted} messages summarized, ${evt.messageCount} kept`,
1575
+ "gen_ai.output.messages:gated": span.attrs["gen_ai.output.messages:gated"],
1576
+ "openclaw.outcome": "completed",
1577
+ "openclaw.llm_calls": 1,
1578
+ "openclaw.tool_calls": 0
1579
+ }
1580
+ };
1581
+ span.parentSpanId = root.spanId;
1582
+ const common = {
1583
+ ...sessionAttrs(sessionId),
1584
+ "openclaw.session.id": sessionId,
1585
+ "openclaw.session.key": ctx.sessionKey,
1586
+ "openclaw.agent.id": agentId,
1587
+ "gen_ai.agent.name": agentId,
1588
+ "openclaw.workspace.dir": workspaceDir,
1589
+ "latitude.tags": enrichment.tags,
1590
+ "latitude.metadata": enrichment.metadata
1591
+ };
1592
+ for (const target of [root, span]) for (const [k, v] of Object.entries(common)) if (target.attrs[k] === void 0) target.attrs[k] = v;
1593
+ this.emit({
1594
+ runId: `compaction:${span.spanId}`,
1595
+ spans: [root, span]
1596
+ });
1597
+ }
1598
+ latestCompaction(sessionId, agentId, workspaceDir) {
1599
+ const stateDir = this.stateDir ?? stateDirFromWorkspace(workspaceDir);
1600
+ if (!sessionId || !agentId || !stateDir) return void 0;
1601
+ return readLatestCompaction(agentDatabasePath(stateDir, agentId), sessionId, this.readTranscriptRows) ?? readLatestCompactionFromFile(sessionTranscriptPath(stateDir, agentId, sessionId), this.readTranscript);
1602
+ }
1603
+ onSessionStart(evt, _ctx) {
1604
+ if (evt.sessionKey) {
1605
+ remember(this.sessionIds, evt.sessionKey, evt.sessionId, SESSION_INDEX_MAX);
1606
+ this.memorySnapshotSessions.delete(evt.sessionKey);
1607
+ }
1608
+ }
1609
+ onSessionEnd(sessionKey, sessionId) {
1610
+ if (sessionId) this.sessionHistory.forget(sessionId);
1611
+ if (!sessionKey) return;
1612
+ this.memorySnapshotSessions.delete(sessionKey);
1613
+ this.senders.delete(sessionKey);
1614
+ }
1615
+ onMessageReceived(evt, ctx) {
1616
+ const sessionKey = evt.sessionKey ?? ctx.sessionKey;
1617
+ const id = evt.senderId ?? evt.metadata?.senderId ?? ctx.senderId;
1618
+ if (!sessionKey || !id) return;
1619
+ let bySender = this.senders.get(sessionKey);
1620
+ if (!bySender) {
1621
+ bySender = /* @__PURE__ */ new Map();
1622
+ this.senders.set(sessionKey, bySender);
1623
+ }
1624
+ const sender = {
1625
+ id,
1626
+ name: evt.metadata?.senderName,
1627
+ username: evt.metadata?.senderUsername
1628
+ };
1629
+ bySender.set(id, sender);
1630
+ if (sender.name || sender.username) this.sendersById.set(id, sender);
643
1631
  }
644
1632
  /**
645
- * Open a `subagent` span on the parent run AND register the cross-run link
646
- * so the child's `before_agent_start` can find us.
1633
+ * Streamed agent events: `lifecycle` start gives the run's true start before
1634
+ * any hook fires, and the first `assistant` / `thinking` delta after a model
1635
+ * call started is its time to first token, which the Codex harness reports
1636
+ * nowhere else.
647
1637
  */
1638
+ onAgentEvent(evt) {
1639
+ if (!evt.runId || !evt.stream) return;
1640
+ if (evt.stream === "lifecycle") {
1641
+ if (evt.data?.phase !== "start") return;
1642
+ const startedAt = typeof evt.data.startedAt === "number" ? evt.data.startedAt : evt.ts;
1643
+ const run = this.ensureRun(evt.runId, {
1644
+ runId: evt.runId,
1645
+ sessionKey: evt.sessionKey,
1646
+ sessionId: evt.sessionId,
1647
+ agentId: evt.agentId
1648
+ });
1649
+ if (run && startedAt !== void 0 && startedAt < run.startedAt) {
1650
+ run.startedAt = startedAt;
1651
+ run.root.startMs = startedAt;
1652
+ }
1653
+ return;
1654
+ }
1655
+ if (evt.stream !== "assistant" && evt.stream !== "thinking") return;
1656
+ const delta = evt.data?.delta;
1657
+ if (typeof delta !== "string" || delta.length === 0) return;
1658
+ const run = this.runs.get(evt.runId);
1659
+ if (!run) return;
1660
+ const ts = evt.ts ?? this.now();
1661
+ if (run.deltas.length < MAX_DELTAS) run.deltas.push(ts);
1662
+ if (evt.stream === "thinking" && run.thinkingChars < MAX_THINKING_CHARS) {
1663
+ run.thinking.push({
1664
+ ts,
1665
+ text: delta
1666
+ });
1667
+ run.thinkingChars += delta.length;
1668
+ }
1669
+ }
1670
+ onCronChanged(evt) {
1671
+ const agentId = evt.agentId ?? evt.job?.agentId ?? "main";
1672
+ const job = {
1673
+ id: evt.jobId,
1674
+ name: evt.job?.name
1675
+ };
1676
+ if (evt.action === "started" || evt.action === "added" || evt.action === "updated") this.cronStarted.set(`job:${evt.jobId}`, job);
1677
+ if (evt.action === "started") {
1678
+ this.cronStarted.set(agentId, job);
1679
+ if (evt.sessionKey) this.cronStarted.set(`session:${evt.sessionKey}`, job);
1680
+ return;
1681
+ }
1682
+ if (evt.action === "finished") {
1683
+ if (this.cronStarted.get(agentId)?.id === evt.jobId) this.cronStarted.delete(agentId);
1684
+ if (evt.sessionKey) this.cronStarted.delete(`session:${evt.sessionKey}`);
1685
+ }
1686
+ if (evt.action === "removed") this.cronStarted.delete(`job:${evt.jobId}`);
1687
+ }
648
1688
  onSubagentSpawned(evt, ctx) {
649
- const parentRunId = ctx.runId;
650
- if (!parentRunId) return;
651
- const parent = this.runs.get(parentRunId);
652
- if (!parent) return;
1689
+ const parent = this.openRunForSession(ctx.requesterSessionKey);
1690
+ const childRunId = evt.runId ?? ctx.runId;
1691
+ if (!childRunId) return;
1692
+ this.evictStale();
1693
+ const traceId = parent?.traceId ?? hashHex(childRunId, 32);
1694
+ const parentSpanId = parent ? parent.lastSpawnToolSpanId ?? parent.root.spanId : "";
1695
+ const agentName = evt.label ?? evt.agentId;
653
1696
  const span = {
654
- spanId: hashHex(`${parent.agent.traceId}:subagent:${evt.runId}`, 16),
655
- traceId: parent.agent.traceId,
656
- parentSpanId: parent.agent.spanId,
1697
+ spanId: hashHex(`${childRunId}:subagent`, 16),
1698
+ traceId,
1699
+ parentSpanId,
657
1700
  name: "subagent",
658
- startMs: Date.now(),
1701
+ kind: KIND_INTERNAL,
1702
+ startMs: this.now(),
659
1703
  endMs: void 0,
660
1704
  attrs: {
661
- ...latitudeAttrs(ctx),
662
- ...sessionAttrs(ctx),
663
- "openclaw.parent.run.id": parentRunId,
664
- "openclaw.run.id": evt.runId,
1705
+ ...parent ? this.commonAttrs(parent) : {},
1706
+ "gen_ai.agent.name": agentName,
1707
+ "subagent.name": agentName,
1708
+ "subagent.type": evt.agentId,
1709
+ "subagent.id": `${evt.agentId}:${childRunId}`,
1710
+ "openclaw.run.id": childRunId,
1711
+ "openclaw.parent.run.id": parent?.runId,
665
1712
  "openclaw.subagent.child_session_key": evt.childSessionKey,
666
1713
  "openclaw.subagent.agent_id": evt.agentId,
667
1714
  "openclaw.subagent.label": evt.label,
668
1715
  "openclaw.subagent.mode": evt.mode,
669
1716
  "openclaw.subagent.thread_requested": evt.threadRequested,
1717
+ "openclaw.subagent.resolved_model": evt.resolvedModel,
1718
+ "openclaw.subagent.resolved_provider": evt.resolvedProvider,
670
1719
  "openclaw.subagent.requester.channel": evt.requester?.channel,
671
1720
  "openclaw.subagent.requester.account_id": evt.requester?.accountId,
672
1721
  "openclaw.subagent.requester.to": evt.requester?.to,
673
- "openclaw.subagent.requester.thread_id": typeof evt.requester?.threadId === "string" || typeof evt.requester?.threadId === "number" ? String(evt.requester.threadId) : void 0
1722
+ "openclaw.subagent.requester.thread_id": evt.requester?.threadId !== void 0 ? String(evt.requester.threadId) : void 0,
1723
+ ...sessionAttrs(parent?.ctx.sessionId)
674
1724
  }
675
1725
  };
676
- parent.childSubagentSpans.set(evt.runId, span);
677
- this.evictStaleSubagentLinks();
678
- this.subagentLinks.set(evt.runId, {
679
- traceId: parent.agent.traceId,
680
- subagentSpanId: span.spanId,
681
- createdAt: Date.now()
1726
+ if (parent) {
1727
+ parent.subagentIds.push(evt.agentId);
1728
+ parent.lastSpawnToolSpanId = void 0;
1729
+ }
1730
+ this.pendingSubagents.set(childRunId, {
1731
+ span,
1732
+ parentRunId: parent?.runId ?? "",
1733
+ createdAt: this.now()
1734
+ });
1735
+ this.subagentLinks.set(childRunId, {
1736
+ traceId,
1737
+ parentSpanId: span.spanId,
1738
+ parentRunId: parent?.runId ?? "",
1739
+ sessionId: parent?.ctx.sessionId,
1740
+ agentName,
1741
+ createdAt: this.now()
682
1742
  });
1743
+ const child = this.runs.get(childRunId);
1744
+ if (child) this.applyLink(child);
683
1745
  }
684
1746
  onSubagentEnded(evt, ctx) {
685
- const parentRunId = ctx.runId;
686
- if (!parentRunId) return;
687
- const parent = this.runs.get(parentRunId);
688
- if (!parent) return;
689
- const childRunId = evt.runId;
1747
+ const childRunId = evt.runId ?? ctx.runId;
690
1748
  if (!childRunId) return;
691
- const span = parent.childSubagentSpans.get(childRunId);
692
- if (!span) return;
693
- span.endMs = Date.now();
694
- span.outcome = evt.outcome === "error" || Boolean(evt.error) ? "error" : "ok";
1749
+ const pending = this.pendingSubagents.get(childRunId);
1750
+ if (!pending) return;
1751
+ const span = pending.span;
1752
+ span.endMs = evt.endedAt ?? this.now();
1753
+ const isError = evt.outcome === "error" || evt.outcome === "timeout" || Boolean(evt.error);
1754
+ span.outcome = isError ? "error" : "ok";
695
1755
  span.errorMessage = evt.error;
696
1756
  Object.assign(span.attrs, {
697
1757
  "openclaw.subagent.target_session_key": evt.targetSessionKey,
@@ -699,93 +1759,512 @@ var SpanBuilder = class {
699
1759
  "openclaw.subagent.reason": evt.reason,
700
1760
  "openclaw.subagent.outcome": evt.outcome,
701
1761
  "openclaw.subagent.send_farewell": evt.sendFarewell,
702
- "openclaw.subagent.account_id": evt.accountId
1762
+ "openclaw.subagent.account_id": evt.accountId,
1763
+ "error.type": isError ? evt.outcome ?? "error" : void 0,
1764
+ "error.message:gated": evt.error
703
1765
  });
704
- parent.childSubagentSpans.delete(childRunId);
705
- parent.closed.push(span);
1766
+ this.pendingSubagents.delete(childRunId);
1767
+ this.emitSubagentSpan(span, pending.parentRunId);
1768
+ }
1769
+ ensureRun(runId, ctx) {
1770
+ const existing = this.runs.get(runId);
1771
+ if (existing) {
1772
+ this.mergeCtx(existing, ctx);
1773
+ return existing;
1774
+ }
1775
+ this.evictStale();
1776
+ const startedAt = this.now();
1777
+ const link = this.subagentLinks.get(runId);
1778
+ const traceId = link?.traceId ?? hashHex(runId, 32);
1779
+ const root = {
1780
+ spanId: hashHex(`${runId}:interaction`, 16),
1781
+ traceId,
1782
+ parentSpanId: link?.parentSpanId ?? "",
1783
+ name: "interaction",
1784
+ kind: KIND_INTERNAL,
1785
+ startMs: startedAt,
1786
+ endMs: void 0,
1787
+ attrs: {
1788
+ "gen_ai.operation.name": "invoke_agent",
1789
+ "openclaw.run.id": runId
1790
+ }
1791
+ };
1792
+ const run = {
1793
+ runId,
1794
+ ctx: {
1795
+ ...ctx,
1796
+ runId
1797
+ },
1798
+ traceId,
1799
+ root,
1800
+ startedAt,
1801
+ history: [],
1802
+ inputMessages: [],
1803
+ inheritedHistory: [],
1804
+ historySource: "none",
1805
+ deltas: [],
1806
+ thinking: [],
1807
+ thinkingChars: 0,
1808
+ prompt: void 0,
1809
+ systemPrompt: void 0,
1810
+ toolDefinitions: void 0,
1811
+ llmCalls: [],
1812
+ openModelCalls: /* @__PURE__ */ new Map(),
1813
+ openToolCalls: /* @__PURE__ */ new Map(),
1814
+ openCompaction: void 0,
1815
+ closed: [],
1816
+ lastSpawnToolSpanId: void 0,
1817
+ subagentIds: [],
1818
+ sender: void 0,
1819
+ cron: void 0,
1820
+ link,
1821
+ aggregateUsage: {},
1822
+ output: void 0,
1823
+ endEvent: void 0,
1824
+ outputSeen: false,
1825
+ finalized: false,
1826
+ cancelGrace: void 0
1827
+ };
1828
+ this.runs.set(runId, run);
1829
+ this.indexSession(run);
1830
+ this.resolveSender(run);
1831
+ this.resolveCron(run);
1832
+ return run;
1833
+ }
1834
+ mergeCtx(run, ctx) {
1835
+ let changed = false;
1836
+ for (const [key, value] of Object.entries(ctx)) {
1837
+ if (value === void 0 || value === null || value === "") continue;
1838
+ if (run.ctx[key] === void 0) {
1839
+ run.ctx[key] = value;
1840
+ changed = true;
1841
+ }
1842
+ }
1843
+ if (!changed) return;
1844
+ this.indexSession(run);
1845
+ this.resolveSender(run);
1846
+ this.resolveCron(run);
1847
+ }
1848
+ indexSession(run) {
1849
+ const key = run.ctx.sessionKey;
1850
+ if (!key) return;
1851
+ if (run.ctx.sessionId) remember(this.sessionIds, key, run.ctx.sessionId, SESSION_INDEX_MAX);
1852
+ const list = this.runsBySession.get(key) ?? [];
1853
+ if (!list.includes(run.runId)) {
1854
+ list.push(run.runId);
1855
+ this.runsBySession.set(key, list);
1856
+ }
1857
+ }
1858
+ resolveSender(run) {
1859
+ if (run.sender?.name) return;
1860
+ const id = run.ctx.senderId;
1861
+ if (!id) return;
1862
+ const cached = (run.ctx.sessionKey ? this.senders.get(run.ctx.sessionKey)?.get(id) : void 0) ?? this.sendersById.get(id);
1863
+ if (cached?.name) {
1864
+ run.sender = cached;
1865
+ return;
1866
+ }
1867
+ const fromPrompt = senderFromPrompt(run.prompt);
1868
+ run.sender = fromPrompt && fromPrompt.id === id ? fromPrompt : cached ?? { id };
1869
+ if (run.sender.name) this.sendersById.set(id, run.sender);
1870
+ }
1871
+ resolveCron(run) {
1872
+ if (run.cron?.name || run.ctx.trigger !== "cron") return;
1873
+ const fromKey = cronJobFromSessionKey(run.ctx.sessionKey);
1874
+ const bySession = run.ctx.sessionKey ? this.cronStarted.get(`session:${run.ctx.sessionKey}`) : void 0;
1875
+ const job = fromKey ? this.cronStarted.get(`job:${fromKey}`) ?? { id: fromKey } : bySession ?? this.cronStarted.get(run.ctx.agentId ?? "main");
1876
+ if (!job) return;
1877
+ run.cron = job.name ? job : {
1878
+ id: job.id,
1879
+ name: cronNameFromPrompt(run.prompt, job.id)
1880
+ };
706
1881
  }
707
1882
  /**
708
- * Close out the run: finish the agent span, abandon any still-open
709
- * model_calls / tool_calls / compactions, and return everything ready to
710
- * emit. Removes the subagent link if this was a child run.
1883
+ * History the harness withheld. The Codex harness passes an empty history
1884
+ * and only this turn's transcript, so the session is rebuilt from the turns
1885
+ * this process has seen, or from OpenClaw's transcript mirror on a cold start.
711
1886
  */
712
- onAgentEnd(evt, ctx) {
713
- const runId = ctx.runId;
714
- if (!runId) return void 0;
715
- const run = this.runs.get(runId);
716
- if (!run) {
717
- this.subagentLinks.delete(runId);
1887
+ inheritHistory(run, prompt) {
1888
+ const sessionId = run.ctx.sessionId;
1889
+ if (!sessionId) return;
1890
+ const remembered = this.sessionHistory.get(sessionId);
1891
+ if (remembered) {
1892
+ run.inheritedHistory = [...remembered];
1893
+ run.historySource = "memory";
718
1894
  return;
719
1895
  }
720
- const now = Date.now();
721
- run.agent.endMs = now;
722
- run.agent.outcome = evt.success ? "ok" : "error";
723
- run.agent.errorMessage = evt.error;
724
- Object.assign(run.agent.attrs, {
725
- "openclaw.duration_ms": evt.durationMs,
726
- "openclaw.run.success": evt.success,
727
- "openclaw.error.message:gated": evt.error,
728
- "agent_end.messages:gated": normalizeMessages(evt.messages)
1896
+ const stateDir = this.stateDir ?? stateDirFromWorkspace(run.ctx.workspaceDir);
1897
+ const agentId = run.ctx.agentId;
1898
+ if (!stateDir || !agentId) return;
1899
+ const fromDatabase = readSessionTranscriptFromDatabase(agentDatabasePath(stateDir, agentId), sessionId, this.readTranscriptRows);
1900
+ const stored = fromDatabase && fromDatabase.length > 0 ? {
1901
+ messages: fromDatabase,
1902
+ source: "sqlite"
1903
+ } : {
1904
+ messages: readSessionTranscript(sessionTranscriptPath(stateDir, agentId, sessionId), this.readTranscript),
1905
+ source: "file"
1906
+ };
1907
+ if (!stored.messages) return;
1908
+ run.inheritedHistory = withoutCurrentPrompt(stored.messages, prompt, run.startedAt);
1909
+ run.historySource = stored.source;
1910
+ }
1911
+ openRunForSession(sessionKey) {
1912
+ if (!sessionKey) return void 0;
1913
+ const ids = this.runsBySession.get(sessionKey);
1914
+ if (!ids) return void 0;
1915
+ for (let i = ids.length - 1; i >= 0; i--) {
1916
+ const run = this.runs.get(ids[i]);
1917
+ if (run && !run.finalized) return run;
1918
+ }
1919
+ }
1920
+ recordMemorySnapshot(run) {
1921
+ if (!this.memoryEnabled) return;
1922
+ const key = run.ctx.sessionKey ?? run.ctx.sessionId;
1923
+ if (!key || this.memorySnapshotSessions.has(key)) return;
1924
+ this.memorySnapshotSessions.add(key);
1925
+ const snapshot = memorySnapshot(run.ctx.workspaceDir, run.ctx.agentId, this.readFile);
1926
+ if (!snapshot) return;
1927
+ const span = this.memorySpan(run, snapshot, void 0);
1928
+ span.attrs["openclaw.memory.source"] = "session_snapshot";
1929
+ run.closed.push(span);
1930
+ }
1931
+ memorySpan(run, memory, parent) {
1932
+ const at = this.now();
1933
+ return {
1934
+ spanId: hashHex(`${run.runId}:memory:${memory.operation}:${memory.recordId ?? ""}:${at}:${run.closed.length}`, 16),
1935
+ traceId: run.traceId,
1936
+ parentSpanId: parent?.spanId ?? run.root.spanId,
1937
+ name: memory.operation,
1938
+ kind: KIND_CLIENT,
1939
+ startMs: parent?.startMs ?? at,
1940
+ endMs: parent?.endMs ?? at,
1941
+ outcome: "ok",
1942
+ attrs: {
1943
+ "gen_ai.operation.name": memory.operation,
1944
+ "gen_ai.provider.name": "openclaw",
1945
+ "gen_ai.memory.store.id": memory.storeId,
1946
+ "gen_ai.memory.record.id": memory.recordId,
1947
+ "gen_ai.memory.record.count": memory.records.length,
1948
+ "gen_ai.memory.query.text:gated": this.memoryContent ? memory.queryText : void 0,
1949
+ "gen_ai.memory.records:gated": this.memoryContent && memory.records.length > 0 ? memory.records : void 0,
1950
+ "openclaw.memory.body_unavailable": memory.bodyUnavailable ? true : void 0
1951
+ }
1952
+ };
1953
+ }
1954
+ applyLink(run) {
1955
+ const link = this.subagentLinks.get(run.runId);
1956
+ if (!link) return;
1957
+ run.link = link;
1958
+ run.traceId = link.traceId;
1959
+ run.root.parentSpanId = link.parentSpanId;
1960
+ for (const span of [
1961
+ run.root,
1962
+ ...run.llmCalls,
1963
+ ...run.closed,
1964
+ ...run.openModelCalls.values(),
1965
+ ...run.openToolCalls.values()
1966
+ ]) span.traceId = link.traceId;
1967
+ if (run.openCompaction) run.openCompaction.traceId = link.traceId;
1968
+ }
1969
+ finalize(run) {
1970
+ if (run.finalized) return;
1971
+ run.finalized = true;
1972
+ run.cancelGrace?.();
1973
+ run.cancelGrace = void 0;
1974
+ this.applyLink(run);
1975
+ const evt = run.endEvent;
1976
+ const now = this.now();
1977
+ const endMs = now;
1978
+ const startMs = evt?.durationMs !== void 0 ? Math.min(run.startedAt, endMs - evt.durationMs) : run.startedAt;
1979
+ run.root.startMs = startMs;
1980
+ run.root.endMs = endMs;
1981
+ const transcript = evt?.messages ?? [];
1982
+ this.resolveSender(run);
1983
+ this.resolveCron(run);
1984
+ const assistants = this.attributeCalls(run, transcript, startMs);
1985
+ this.applyTimeToFirstToken(run);
1986
+ this.applyStreamedReasoning(run);
1987
+ this.rememberHistory(run, transcript, startMs);
1988
+ const lastAssistant = assistants[assistants.length - 1]?.message ?? run.output?.lastAssistant;
1989
+ const outputMessage = lastAssistant !== void 0 ? normalizeMessage(lastAssistant) : void 0;
1990
+ const success = evt ? evt.success : false;
1991
+ run.root.outcome = success ? "ok" : "error";
1992
+ run.root.errorMessage = evt?.error ?? (evt ? void 0 : "abandoned");
1993
+ Object.assign(run.root.attrs, {
1994
+ "user_prompt:gated": run.prompt,
1995
+ "gen_ai.input.messages:gated": run.inputMessages.length > 0 ? run.inputMessages : void 0,
1996
+ "gen_ai.output.messages:gated": outputMessage ? [{
1997
+ ...outputMessage,
1998
+ role: "assistant"
1999
+ }] : void 0,
2000
+ "gen_ai.system_instructions:gated": run.systemPrompt ? systemInstructionsParts(run.systemPrompt) : void 0,
2001
+ "openclaw.run.success": success,
2002
+ "openclaw.duration_ms": evt?.durationMs,
2003
+ "openclaw.outcome": evt ? success ? "completed" : "error" : "abandoned",
2004
+ "error.type": success ? void 0 : evt ? "run_error" : "abandoned",
2005
+ "error.message:gated": evt?.error,
2006
+ "openclaw.llm_calls": run.llmCalls.length,
2007
+ "openclaw.tool_calls": run.closed.filter((s) => s.name.startsWith("tool_call:")).length,
2008
+ "interaction.kind": interactionKind(run),
2009
+ "interaction.duration_ms": endMs - startMs,
2010
+ "openclaw.history.source": run.historySource,
2011
+ "openclaw.history.messages": run.inheritedHistory.length
729
2012
  });
730
- for (const span of run.openModelCalls.values()) {
731
- span.endMs = now;
732
- span.outcome = "error";
733
- span.attrs["openclaw.outcome"] = "abandoned";
734
- run.closed.push(span);
2013
+ for (const span of run.openModelCalls.values()) abandon(span, now);
2014
+ for (const span of run.openToolCalls.values()) abandon(span, now);
2015
+ if (run.openCompaction) abandon(run.openCompaction, now);
2016
+ const spans = [
2017
+ run.root,
2018
+ ...run.llmCalls,
2019
+ ...run.closed,
2020
+ ...run.openModelCalls.values(),
2021
+ ...run.openToolCalls.values()
2022
+ ];
2023
+ if (run.openCompaction) spans.push(run.openCompaction);
2024
+ const common = this.commonAttrs(run);
2025
+ for (const span of spans) for (const [k, v] of Object.entries(common)) if (span.attrs[k] === void 0) span.attrs[k] = v;
2026
+ this.runs.delete(run.runId);
2027
+ this.subagentLinks.delete(run.runId);
2028
+ const key = run.ctx.sessionKey;
2029
+ if (key) {
2030
+ const ids = (this.runsBySession.get(key) ?? []).filter((id) => id !== run.runId);
2031
+ if (ids.length === 0) this.runsBySession.delete(key);
2032
+ else this.runsBySession.set(key, ids);
735
2033
  }
736
- for (const span of run.openToolCalls.values()) {
737
- span.endMs = now;
738
- span.outcome = "error";
739
- span.attrs["openclaw.outcome"] = "abandoned";
740
- run.closed.push(span);
2034
+ this.log(`run ${run.runId}: ${spans.length} spans, ${run.llmCalls.length} calls, history=${run.historySource}:${run.inheritedHistory.length}, transcript=${transcript.length}, deltas=${run.deltas.length}, thinking=${run.thinkingChars}`);
2035
+ this.emit({
2036
+ runId: run.runId,
2037
+ spans
2038
+ });
2039
+ }
2040
+ /**
2041
+ * Keep the session's conversation for the next turn of a harness that does
2042
+ * not pass history. A harness that passes the whole session on `agent_end`
2043
+ * replaces the remembered copy outright.
2044
+ */
2045
+ rememberHistory(run, transcript, runStartMs) {
2046
+ const sessionId = run.ctx.sessionId;
2047
+ if (!sessionId || transcript.length === 0) return;
2048
+ if (run.historySource === "harness") {
2049
+ this.sessionHistory.replace(sessionId, normalizeMessages(transcript));
2050
+ return;
741
2051
  }
742
- if (run.openCompaction) {
743
- run.openCompaction.endMs = now;
744
- run.openCompaction.outcome = "error";
745
- run.openCompaction.attrs["openclaw.outcome"] = "abandoned";
746
- run.closed.push(run.openCompaction);
2052
+ const turn = transcript.filter((m) => {
2053
+ const ts = m.timestamp;
2054
+ return typeof ts !== "number" || ts >= runStartMs - 5e3;
2055
+ });
2056
+ this.sessionHistory.append(sessionId, run.inheritedHistory, normalizeMessages(turn));
2057
+ }
2058
+ /** Reasoning streamed during a call's window, when its transcript message carries none. */
2059
+ applyStreamedReasoning(run) {
2060
+ if (run.thinking.length === 0) return;
2061
+ for (const call of run.llmCalls) {
2062
+ const endMs = call.endMs ?? call.startMs;
2063
+ const text = run.thinking.filter((t) => t.ts > call.startMs - 1e3 && t.ts <= endMs + 1e3).map((t) => t.text).join("").trim();
2064
+ if (text.length === 0) continue;
2065
+ const message = call.attrs["gen_ai.output.messages:gated"]?.[0];
2066
+ if (!message || message.parts.some((p) => p.type === "reasoning")) continue;
2067
+ message.parts.unshift({
2068
+ type: "reasoning",
2069
+ content: text
2070
+ });
2071
+ call.attrs["openclaw.reasoning.source"] = "stream";
747
2072
  }
748
- for (const span of run.childSubagentSpans.values()) {
749
- span.endMs = now;
750
- span.outcome = "error";
751
- span.attrs["openclaw.subagent.outcome"] = "abandoned";
752
- run.closed.push(span);
2073
+ }
2074
+ /** The first streamed delta inside a call's window, when the harness reported no TTFB. */
2075
+ applyTimeToFirstToken(run) {
2076
+ if (run.deltas.length === 0) return;
2077
+ for (const call of run.llmCalls) {
2078
+ if (call.attrs["gen_ai.server.time_to_first_token"] !== void 0) continue;
2079
+ const endMs = call.endMs ?? call.startMs;
2080
+ const first = run.deltas.find((ts) => ts > call.startMs && ts <= endMs + 1e3);
2081
+ if (first === void 0) continue;
2082
+ call.attrs["gen_ai.server.time_to_first_token"] = (first - call.startMs) * 1e6;
2083
+ call.attrs["openclaw.ttft.source"] = "stream";
753
2084
  }
754
- const spans = [run.agent, ...run.closed];
755
- this.runs.delete(runId);
756
- this.subagentLinks.delete(runId);
757
- return {
758
- runId,
759
- spans
760
- };
761
2085
  }
762
- /** Drop a run without emitting — used on errors from the emit path. */
763
- abandon(runId) {
764
- this.runs.delete(runId);
765
- this.subagentLinks.delete(runId);
2086
+ /**
2087
+ * Per-call usage, cost and output come from the transcript's assistant
2088
+ * messages, each of which is one provider response. Calls and messages are
2089
+ * both chronological, so a message is matched to the call whose window
2090
+ * contains its timestamp, falling back to order when the counts line up.
2091
+ * A harness that never fires the per-call hooks still leaves its responses
2092
+ * in the transcript, so those become `llm_request` spans of their own.
2093
+ */
2094
+ attributeCalls(run, transcript, runStartMs) {
2095
+ const assistants = [];
2096
+ transcript.forEach((m, index) => {
2097
+ if (!isTranscriptAssistant(m)) return;
2098
+ const ts = typeof m.timestamp === "number" ? m.timestamp : void 0;
2099
+ if (ts !== void 0 && ts < runStartMs - 5e3) return;
2100
+ assistants.push({
2101
+ message: m,
2102
+ index
2103
+ });
2104
+ });
2105
+ if (run.llmCalls.length === 0) {
2106
+ for (const [i, entry] of assistants.entries()) run.llmCalls.push(this.synthesizeCall(run, entry, i, transcript, runStartMs));
2107
+ this.applyAggregateIfUnreported(run);
2108
+ return assistants;
2109
+ }
2110
+ const unmatched = [...assistants];
2111
+ let matchedAny = false;
2112
+ for (const [callIndex, call] of run.llmCalls.entries()) {
2113
+ const remainingCalls = run.llmCalls.length - callIndex;
2114
+ let picked = unmatched.findIndex(({ message }) => {
2115
+ const ts = message.timestamp;
2116
+ return ts !== void 0 && ts >= call.startMs - 2e3 && ts <= (call.endMs ?? call.startMs) + 2e3;
2117
+ });
2118
+ if (picked < 0 && unmatched.length === remainingCalls) picked = 0;
2119
+ if (picked < 0) continue;
2120
+ const [entry] = unmatched.splice(picked, 1);
2121
+ if (!entry) continue;
2122
+ matchedAny = true;
2123
+ this.enrichCall(call, entry.message, this.inputBefore(run, transcript, entry.index));
2124
+ }
2125
+ if (!matchedAny) {
2126
+ const last = run.llmCalls[run.llmCalls.length - 1];
2127
+ if (last) Object.assign(last.attrs, run.aggregateUsage, { "openclaw.usage.scope": "attempt" });
2128
+ }
2129
+ this.applyAggregateIfUnreported(run);
2130
+ return assistants;
766
2131
  }
767
- /** Test-only: how many cross-run subagent links we're holding. */
768
- subagentLinkCount() {
769
- return this.subagentLinks.size;
2132
+ /** Everything the model saw before a given transcript index, including history the harness withheld. */
2133
+ inputBefore(run, transcript, index) {
2134
+ return [...run.inheritedHistory, ...transcript.slice(0, index)];
770
2135
  }
771
2136
  /**
772
- * Drop any subagent links whose child run never reached `agent_end`. Called
773
- * before every `subagent_spawned` insert so the map stays bounded even when
774
- * children crash mid-spawn or the plugin reloads.
775
- *
776
- * Two passes: TTL eviction (anything older than `SUBAGENT_LINK_TTL_MS`),
777
- * then a hard size cap (when we're past `SUBAGENT_LINK_MAX`, drop the
778
- * oldest until we're under).
2137
+ * A harness that reports usage only on the turn's final message leaves a
2138
+ * turn ending in a tool call with no usage at all; the attempt aggregate
2139
+ * from `llm_output` then goes on the last call.
779
2140
  */
780
- evictStaleSubagentLinks() {
781
- const now = Date.now();
782
- for (const [runId, link] of this.subagentLinks) if (now - link.createdAt > SUBAGENT_LINK_TTL_MS) this.subagentLinks.delete(runId);
783
- if (this.subagentLinks.size <= SUBAGENT_LINK_MAX) return;
784
- const sorted = Array.from(this.subagentLinks.entries()).sort((a, b) => a[1].createdAt - b[1].createdAt);
785
- const toRemove = this.subagentLinks.size - SUBAGENT_LINK_MAX;
786
- for (let i = 0; i < toRemove; i++) {
787
- const entry = sorted[i];
788
- if (entry) this.subagentLinks.delete(entry[0]);
2141
+ applyAggregateIfUnreported(run) {
2142
+ const last = run.llmCalls[run.llmCalls.length - 1];
2143
+ if (!last || run.aggregateUsage["gen_ai.usage.total_tokens"] === void 0) return;
2144
+ if (run.llmCalls.some((call) => Number(call.attrs["gen_ai.usage.total_tokens"] ?? 0) > 0)) return;
2145
+ Object.assign(last.attrs, run.aggregateUsage, { "openclaw.usage.scope": "attempt" });
2146
+ }
2147
+ synthesizeCall(run, entry, index, transcript, runStartMs) {
2148
+ const endMs = entry.message.timestamp ?? run.startedAt;
2149
+ const previous = transcript[entry.index - 1];
2150
+ const previousTs = typeof previous?.timestamp === "number" ? previous.timestamp : void 0;
2151
+ const startMs = previousTs !== void 0 && previousTs <= endMs ? Math.max(previousTs, runStartMs) : Math.min(runStartMs, endMs);
2152
+ const span = {
2153
+ spanId: hashHex(`${run.runId}:llm_request:transcript:${index}`, 16),
2154
+ traceId: run.traceId,
2155
+ parentSpanId: run.root.spanId,
2156
+ name: "llm_request",
2157
+ kind: KIND_INTERNAL,
2158
+ startMs,
2159
+ endMs,
2160
+ outcome: "ok",
2161
+ attrs: {
2162
+ "gen_ai.operation.name": "chat",
2163
+ "gen_ai.provider.name": entry.message.provider ?? run.root.attrs["openclaw.provider"],
2164
+ "gen_ai.system": entry.message.provider ?? run.root.attrs["openclaw.provider"],
2165
+ "gen_ai.request.model": entry.message.model ?? run.root.attrs["gen_ai.request.model"],
2166
+ "llm_request.call_index": index,
2167
+ "openclaw.call.source": "transcript",
2168
+ "gen_ai.system_instructions:gated": run.systemPrompt ? systemInstructionsParts(run.systemPrompt) : void 0,
2169
+ "gen_ai.tool.definitions:gated": run.toolDefinitions
2170
+ }
2171
+ };
2172
+ this.enrichCall(span, entry.message, this.inputBefore(run, transcript, entry.index));
2173
+ return span;
2174
+ }
2175
+ enrichCall(call, message, before) {
2176
+ const output = normalizeMessage(message);
2177
+ const usage = message.usage;
2178
+ const reason = finishReason(message.stopReason);
2179
+ Object.assign(call.attrs, {
2180
+ "gen_ai.input.messages:gated": before.length > 0 ? normalizeMessages(before) : call.attrs["gen_ai.input.messages:gated"],
2181
+ "gen_ai.output.messages:gated": output ? [{
2182
+ ...output,
2183
+ role: "assistant"
2184
+ }] : void 0,
2185
+ "gen_ai.response.model": message.responseModel ?? message.model,
2186
+ "gen_ai.response.id": message.responseId,
2187
+ "gen_ai.response.finish_reasons": reason ? [reason] : void 0,
2188
+ "openclaw.stop_reason": message.stopReason,
2189
+ ...usageAttrsFromTranscript(usage)
2190
+ });
2191
+ if (message.stopReason === "error" && message.errorMessage) {
2192
+ call.outcome = "error";
2193
+ call.errorMessage = message.errorMessage;
2194
+ call.attrs["error.type"] = call.attrs["error.type"] ?? "provider_error";
2195
+ call.attrs["error.message:gated"] = message.errorMessage;
2196
+ }
2197
+ }
2198
+ commonAttrs(run) {
2199
+ const sessionId = run.link?.sessionId ?? run.ctx.sessionId;
2200
+ const enrichment = deriveEnrichment({
2201
+ ctx: run.ctx,
2202
+ sender: run.sender,
2203
+ cron: run.cron,
2204
+ subagentIds: run.subagentIds,
2205
+ pluginVersion: this.pluginVersion
2206
+ }, {
2207
+ tags: this.operatorTags,
2208
+ metadata: this.operatorMetadata
2209
+ });
2210
+ return {
2211
+ ...sessionAttrs(sessionId),
2212
+ "openclaw.session.id": run.ctx.sessionId,
2213
+ "openclaw.session.key": run.ctx.sessionKey,
2214
+ "openclaw.run.id": run.runId,
2215
+ "openclaw.agent.id": run.ctx.agentId,
2216
+ "gen_ai.agent.name": run.link?.agentName ?? run.ctx.agentId,
2217
+ "openclaw.workspace.dir": run.ctx.workspaceDir,
2218
+ "openclaw.channel": run.ctx.channel,
2219
+ "openclaw.channel.id": run.ctx.channelId,
2220
+ "openclaw.message.provider": run.ctx.messageProvider,
2221
+ "openclaw.trigger": run.ctx.trigger,
2222
+ "openclaw.cron.job.id": run.cron?.id,
2223
+ "openclaw.parent.run.id": run.link?.parentRunId || void 0,
2224
+ "user.id": run.sender?.id,
2225
+ "latitude.tags": enrichment.tags,
2226
+ "latitude.metadata": enrichment.metadata
2227
+ };
2228
+ }
2229
+ emitSubagentSpan(span, parentRunId) {
2230
+ const parent = this.runs.get(parentRunId);
2231
+ if (parent && !parent.finalized) {
2232
+ parent.closed.push(span);
2233
+ return;
2234
+ }
2235
+ if (span.attrs["latitude.tags"] === void 0) {
2236
+ const enrichment = deriveEnrichment({
2237
+ ctx: {},
2238
+ subagentIds: [],
2239
+ pluginVersion: this.pluginVersion
2240
+ }, {
2241
+ tags: this.operatorTags,
2242
+ metadata: this.operatorMetadata
2243
+ });
2244
+ span.attrs["latitude.tags"] = enrichment.tags;
2245
+ span.attrs["latitude.metadata"] = enrichment.metadata;
2246
+ }
2247
+ this.emit({
2248
+ runId: `subagent:${span.spanId}`,
2249
+ spans: [span]
2250
+ });
2251
+ }
2252
+ evictStale() {
2253
+ const now = this.now();
2254
+ for (const [runId, link] of this.subagentLinks) if (now - link.createdAt > LINK_TTL_MS) this.subagentLinks.delete(runId);
2255
+ if (this.subagentLinks.size > LINK_MAX) {
2256
+ const sorted = Array.from(this.subagentLinks.entries()).sort((a, b) => a[1].createdAt - b[1].createdAt);
2257
+ for (const [runId] of sorted.slice(0, this.subagentLinks.size - LINK_MAX)) this.subagentLinks.delete(runId);
2258
+ }
2259
+ for (const [runId, pending] of this.pendingSubagents) if (now - pending.createdAt > LINK_TTL_MS) {
2260
+ abandon(pending.span, now);
2261
+ pending.span.attrs["openclaw.subagent.outcome"] = "abandoned";
2262
+ this.pendingSubagents.delete(runId);
2263
+ this.emitSubagentSpan(pending.span, pending.parentRunId);
2264
+ }
2265
+ for (const run of Array.from(this.runs.values())) if (now - run.startedAt > RUN_TTL_MS) {
2266
+ this.log(`run ${run.runId} never ended; exporting as abandoned`);
2267
+ this.finalize(run);
789
2268
  }
790
2269
  }
791
2270
  findOpenToolCallByName(run, toolName) {
@@ -793,119 +2272,202 @@ var SpanBuilder = class {
793
2272
  const entries = Array.from(run.openToolCalls.entries());
794
2273
  for (let i = entries.length - 1; i >= 0; i--) {
795
2274
  const entry = entries[i];
796
- if (!entry) continue;
797
- const [id, span] = entry;
798
- if (span.name === target) return id;
2275
+ if (entry && entry[1].name === target) return entry[0];
799
2276
  }
800
2277
  }
801
2278
  };
802
- function flattenCtx(ctx) {
2279
+ function toolCtxToAgentCtx(ctx, runId) {
803
2280
  return {
804
- "openclaw.run.id": ctx.runId,
805
- "openclaw.session.id": ctx.sessionId,
806
- "openclaw.session.key": ctx.sessionKey,
807
- "openclaw.agent.id": ctx.agentId,
808
- "openclaw.agent.name": ctx.agentId,
809
- "openclaw.workspace.dir": ctx.workspaceDir,
810
- "openclaw.message.provider": ctx.messageProvider,
811
- "openclaw.trigger": ctx.trigger,
812
- "openclaw.channel.id": ctx.channelId,
813
- "openclaw.cron.job.id": ctx.jobId,
814
- "openclaw.model.provider.id": ctx.modelProviderId,
815
- "openclaw.model.id": ctx.modelId
2281
+ runId,
2282
+ agentId: ctx.agentId,
2283
+ sessionKey: ctx.sessionKey,
2284
+ sessionId: ctx.sessionId,
2285
+ channelId: ctx.channelId,
2286
+ channel: ctx.requester?.channel,
2287
+ accountId: ctx.requester?.accountId,
2288
+ senderId: ctx.requester?.senderId
816
2289
  };
817
2290
  }
818
- /**
819
- * Mirror OpenClaw's session id onto the OTEL-standard keys Latitude's
820
- * resolver looks for. `gen_ai.session.id` and `session.id` are both in
821
- * `sessionIdCandidates` (domain/spans/src/otlp/resolvers/identity.ts), so
822
- * traces can be grouped by session in the Latitude UI without an
823
- * openclaw-specific code path. Emitted on every span, not just `agent`,
824
- * so child spans (model_call / tool_call / etc.) inherit the same grouping.
825
- */
826
- function sessionAttrs(ctx) {
827
- if (!ctx.sessionId) return {};
828
- return {
829
- "session.id": ctx.sessionId,
830
- "gen_ai.session.id": ctx.sessionId
831
- };
2291
+ function interactionKind(run) {
2292
+ if (run.link) return "subagent";
2293
+ if (run.runId.startsWith("announce:")) return "announce";
2294
+ if (run.ctx.trigger === "cron") return "cron";
2295
+ return run.ctx.trigger ?? "user";
832
2296
  }
833
- /**
834
- * Build `latitude.tags` and `latitude.metadata` attrs from the hook context.
835
- * The OTLP encoder JSON-stringifies arrays/objects, which is the encoding
836
- * Latitude's resolver expects:
837
- *
838
- * - `latitude.tags` is a JSON-encoded string array (`fromJsonStringArray`
839
- * in domain/spans/src/otlp/resolvers/enrichment.ts).
840
- * - `latitude.metadata` is a JSON-encoded string object (`fromJsonString`).
841
- *
842
- * Tags = the agent id, the channel id, and the trigger. When trigger is
843
- * `cron`, the tag becomes `cron:<jobId>` so dashboards can pivot on the
844
- * specific cron job. Each tag is conditionally included so absent ctx
845
- * fields don't produce empty entries.
846
- *
847
- * Metadata = every ctx field that's set, namespaced under `openclaw.*` so
848
- * it can't collide with metadata keys other plugins might emit.
849
- */
850
- function latitudeAttrs(ctx) {
851
- const tags = [];
852
- if (ctx.agentId) tags.push(ctx.agentId);
853
- if (ctx.channelId) tags.push(ctx.channelId);
854
- if (ctx.trigger) tags.push(ctx.trigger === "cron" && ctx.jobId ? `cron:${ctx.jobId}` : ctx.trigger);
855
- const metadata = {};
856
- if (ctx.runId) metadata["openclaw.run.id"] = ctx.runId;
857
- if (ctx.sessionId) metadata["openclaw.session.id"] = ctx.sessionId;
858
- if (ctx.sessionKey) metadata["openclaw.session.key"] = ctx.sessionKey;
859
- if (ctx.agentId) metadata["openclaw.agent.id"] = ctx.agentId;
860
- if (ctx.workspaceDir) metadata["openclaw.workspace.dir"] = ctx.workspaceDir;
861
- if (ctx.channelId) metadata["openclaw.channel.id"] = ctx.channelId;
862
- if (ctx.messageProvider) metadata["openclaw.message.provider"] = ctx.messageProvider;
863
- if (ctx.trigger) metadata["openclaw.trigger"] = ctx.trigger;
864
- if (ctx.jobId) metadata["openclaw.cron.job.id"] = ctx.jobId;
865
- if (ctx.modelProviderId) metadata["openclaw.model.provider.id"] = ctx.modelProviderId;
866
- if (ctx.modelId) metadata["openclaw.model.id"] = ctx.modelId;
867
- return {
868
- "latitude.tags": tags.length > 0 ? tags : void 0,
869
- "latitude.metadata": Object.keys(metadata).length > 0 ? metadata : void 0
870
- };
2297
+ /** Cron prompts open with `[cron:<jobId> <job name>]`, the only place the name reaches a run. */
2298
+ function cronNameFromPrompt(prompt, jobId) {
2299
+ if (!prompt) return void 0;
2300
+ return new RegExp(`^\\[cron:${jobId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} ([^\\]]+)\\]`).exec(prompt)?.[1]?.trim() || void 0;
871
2301
  }
872
- function usageAttrs(usage) {
873
- if (!usage) return {};
2302
+ /** Session keys are `agent:<agentId>:<rest>`. */
2303
+ function agentIdFromSessionKey(sessionKey) {
2304
+ return (sessionKey ? /^agent:([^:]+):/.exec(sessionKey) : null)?.[1];
2305
+ }
2306
+ function sessionAttrs(sessionId) {
2307
+ if (!sessionId) return {};
874
2308
  return {
875
- "gen_ai.usage.input_tokens": usage.input,
876
- "gen_ai.usage.output_tokens": usage.output,
877
- "gen_ai.usage.cache_read_input_tokens": usage.cacheRead,
878
- "gen_ai.usage.cache_creation_input_tokens": usage.cacheWrite,
879
- "gen_ai.usage.total_tokens": usage.total
2309
+ "session.id": sessionId,
2310
+ "gen_ai.session.id": sessionId
880
2311
  };
881
2312
  }
2313
+ function abandon(span, now) {
2314
+ span.endMs = now;
2315
+ span.outcome = "error";
2316
+ span.attrs["openclaw.outcome"] = "abandoned";
2317
+ span.attrs["error.type"] = "abandoned";
2318
+ }
2319
+ /** Insertion-ordered cache: re-setting a key moves it to the back, and the oldest keys go once `max` is exceeded. */
2320
+ function remember(map, key, value, max) {
2321
+ map.delete(key);
2322
+ map.set(key, value);
2323
+ for (const oldest of map.keys()) {
2324
+ if (map.size <= max) break;
2325
+ map.delete(oldest);
2326
+ }
2327
+ }
882
2328
  function hashHex(input, length) {
883
2329
  return createHash("sha256").update(input).digest("hex").slice(0, length);
884
2330
  }
885
2331
  //#endregion
886
- //#region src/plugin.ts
2332
+ //#region src/transport.ts
2333
+ const DEFAULT_TIMEOUT_MS = 1e4;
2334
+ const DEFAULT_MAX_ATTEMPTS = 3;
2335
+ const RETRY_BASE_MS = 500;
2336
+ const RETRY_AFTER_MAX_MS = 3e4;
887
2337
  /**
888
- * Register the Latitude plugin against an OpenClaw plugin API. OpenClaw calls
889
- * this once at plugin activation; we wire up the granular paired hooks
890
- * (model_call_started/_ended, before_/after_tool_call, before_/after_compaction,
891
- * subagent_spawned/_ended, before_agent_start/agent_end) plus the
892
- * data-only feeds (llm_input/llm_output) that enrich the agent span.
893
- *
894
- * Every typed hook on OpenClaw's side fires fire-and-forget for non-modifying
895
- * hooks; before_tool_call is a `runModifyingHook` where returning anything
896
- * other than undefined blocks the tool call. Our handler returns nothing —
897
- * keep it that way.
2338
+ * Ships OTLP requests sequentially with bounded retries. Every span is sent
2339
+ * exactly once on success: Latitude's trace and session rollups add per
2340
+ * insert, so a duplicate would inflate counts; a `4xx` other than `429` is
2341
+ * therefore final, while `429`, `5xx` and network errors retry with backoff.
898
2342
  */
2343
+ var Transport = class {
2344
+ url;
2345
+ opts;
2346
+ chain = Promise.resolve();
2347
+ pending = 0;
2348
+ constructor(opts) {
2349
+ this.opts = opts;
2350
+ this.url = `${withoutTrailingSlashes(opts.baseUrl)}/v1/traces`;
2351
+ }
2352
+ enqueue(payload) {
2353
+ this.pending++;
2354
+ this.chain = this.chain.then(() => this.send(payload)).catch((err) => this.opts.logger.warn(`export failed: ${String(err)}`)).finally(() => {
2355
+ this.pending--;
2356
+ });
2357
+ }
2358
+ /** Resolves when everything queued so far has been sent or given up on, or the budget elapses. */
2359
+ async flush(budgetMs) {
2360
+ if (this.pending === 0) return;
2361
+ await Promise.race([this.chain, sleep(budgetMs)]);
2362
+ }
2363
+ async send(payload) {
2364
+ const body = JSON.stringify(payload);
2365
+ const spanCount = payload.resourceSpans.reduce((n, rs) => n + rs.scopeSpans.reduce((m, ss) => m + ss.spans.length, 0), 0);
2366
+ const maxAttempts = this.opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
2367
+ const doFetch = this.opts.fetchImpl ?? fetch;
2368
+ const wait = this.opts.sleep ?? sleep;
2369
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2370
+ const controller = new AbortController();
2371
+ const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
2372
+ try {
2373
+ const res = await doFetch(this.url, {
2374
+ method: "POST",
2375
+ headers: {
2376
+ "Content-Type": "application/json",
2377
+ Authorization: `Bearer ${this.opts.apiKey}`,
2378
+ "X-Latitude-Project": this.opts.project
2379
+ },
2380
+ body,
2381
+ signal: controller.signal
2382
+ });
2383
+ if (res.ok) {
2384
+ this.opts.logger.debug(`exported ${spanCount} spans (${body.length} bytes) HTTP ${res.status}`);
2385
+ return;
2386
+ }
2387
+ const text = await res.text().catch(() => "");
2388
+ if (!(res.status === 429 || res.status >= 500) || attempt === maxAttempts) {
2389
+ this.opts.logger.warn(`ingest HTTP ${res.status} (final): ${text.slice(0, 300)}`);
2390
+ return;
2391
+ }
2392
+ const retryAfter = Number(res.headers.get("retry-after"));
2393
+ await wait((Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1e3, RETRY_AFTER_MAX_MS) : 0) || backoff(attempt));
2394
+ } catch (err) {
2395
+ if (attempt === maxAttempts) {
2396
+ this.opts.logger.warn(`ingest unreachable after ${attempt} attempts: ${String(err)}`);
2397
+ return;
2398
+ }
2399
+ await wait(backoff(attempt));
2400
+ } finally {
2401
+ clearTimeout(timer);
2402
+ }
2403
+ }
2404
+ }
2405
+ };
2406
+ function backoff(attempt) {
2407
+ return RETRY_BASE_MS * 2 ** (attempt - 1) + Math.random() * RETRY_BASE_MS;
2408
+ }
2409
+ function withoutTrailingSlashes(url) {
2410
+ let end = url.length;
2411
+ while (end > 0 && url[end - 1] === "/") end--;
2412
+ return url.slice(0, end);
2413
+ }
2414
+ function sleep(ms) {
2415
+ return new Promise((resolve) => {
2416
+ setTimeout(resolve, ms).unref?.();
2417
+ });
2418
+ }
2419
+ //#endregion
2420
+ //#region src/plugin.ts
2421
+ const STOP_FLUSH_BUDGET_MS = 4e3;
2422
+ const PLUGIN_ID = "@latitude-data/openclaw-telemetry";
899
2423
  function registerLatitudePlugin(api, opts = {}) {
900
2424
  const config = opts.config ?? loadConfig(api.pluginConfig);
901
- const logger = opts.logger ?? createLogger(config.debug);
2425
+ const logger = opts.logger ?? createLogger(config.debug, api.logger);
902
2426
  if (!config.enabled) {
903
- if (config.apiKey === "") logger.debug("disabled: apiKey is empty (set plugins.entries[id].config.apiKey)");
904
- if (config.project === "") logger.debug("disabled: project is empty (set plugins.entries[id].config.project)");
2427
+ if (config.apiKey === "") logger.warn("disabled: apiKey is empty (set plugins.entries[id].config.apiKey)");
2428
+ if (config.project === "") logger.warn("disabled: project is empty (set plugins.entries[id].config.project)");
905
2429
  return;
906
2430
  }
907
- logger.debug(`enabled: project=${config.project} base=${config.baseUrl} allowConversationAccess=${config.allowConversationAccess}`);
908
- const builder = new SpanBuilder();
2431
+ logger.debug(`enabled v${SCOPE_VERSION}: project=${config.project} base=${config.baseUrl} content=${config.allowConversationAccess}`);
2432
+ if (api.config?.plugins?.entries && api.config.plugins.entries[PLUGIN_ID]?.hooks?.allowConversationAccess !== true) logger.warn(`plugins.entries["${PLUGIN_ID}"].hooks.allowConversationAccess is not true; OpenClaw will not deliver the conversation hooks and no traces will be exported. Run: openclaw config set 'plugins.entries["${PLUGIN_ID}"].hooks.allowConversationAccess' true && openclaw gateway restart`);
2433
+ const transport = opts.transport ?? new Transport({
2434
+ baseUrl: config.baseUrl,
2435
+ apiKey: config.apiKey,
2436
+ project: config.project,
2437
+ logger
2438
+ });
2439
+ let stateDir;
2440
+ try {
2441
+ stateDir = api.runtime?.state?.resolveStateDir?.();
2442
+ } catch {
2443
+ stateDir = void 0;
2444
+ }
2445
+ const builder = new SpanBuilder({
2446
+ pluginVersion: SCOPE_VERSION,
2447
+ stateDir,
2448
+ tags: config.tags,
2449
+ metadata: config.metadata,
2450
+ memory: config.memory,
2451
+ memoryContent: config.memoryContent,
2452
+ toolDefinitions: config.toolDefinitions,
2453
+ now: opts.now,
2454
+ schedule: opts.schedule,
2455
+ log: (msg) => logger.debug(msg),
2456
+ emit: (result) => {
2457
+ try {
2458
+ opts.onEmit?.(result);
2459
+ logger.debug(`run ${result.runId}: ${result.spans.length} spans ready`);
2460
+ transport.enqueue(buildOtlpRequest([result], {
2461
+ allowConversationAccess: config.allowConversationAccess,
2462
+ redact: config.redact,
2463
+ serviceName: config.serviceName,
2464
+ maxContentChars: config.maxContentChars
2465
+ }));
2466
+ } catch (err) {
2467
+ logger.warn(`export of run ${result.runId} failed: ${String(err)}`);
2468
+ }
2469
+ }
2470
+ });
909
2471
  const wrap = (name, fn) => {
910
2472
  return (evt, ctx) => {
911
2473
  try {
@@ -915,61 +2477,33 @@ function registerLatitudePlugin(api, opts = {}) {
915
2477
  }
916
2478
  };
917
2479
  };
918
- api.on("before_agent_start", wrap("before_agent_start", (evt, ctx) => {
919
- builder.onBeforeAgentStart(evt, ctx);
920
- }));
921
- api.on("model_call_started", wrap("model_call_started", (evt, ctx) => {
922
- builder.onModelCallStarted(evt, ctx);
923
- }));
924
- api.on("model_call_ended", wrap("model_call_ended", (evt, ctx) => {
925
- builder.onModelCallEnded(evt, ctx);
926
- }));
927
- api.on("before_tool_call", wrap("before_tool_call", (evt, ctx) => {
928
- builder.onBeforeToolCall(evt, ctx);
929
- }));
930
- api.on("after_tool_call", wrap("after_tool_call", (evt, ctx) => {
931
- builder.onAfterToolCall(evt, ctx);
932
- }));
933
- api.on("before_compaction", wrap("before_compaction", (evt, ctx) => {
934
- builder.onBeforeCompaction(evt, ctx);
935
- }));
936
- api.on("after_compaction", wrap("after_compaction", (evt, ctx) => {
937
- builder.onAfterCompaction(evt, ctx);
938
- }));
939
- api.on("subagent_spawned", wrap("subagent_spawned", (evt, ctx) => {
940
- builder.onSubagentSpawned(evt, ctx);
941
- }));
942
- api.on("subagent_ended", wrap("subagent_ended", (evt, ctx) => {
943
- builder.onSubagentEnded(evt, ctx);
944
- }));
945
- api.on("llm_input", wrap("llm_input", (evt, ctx) => {
946
- builder.onLlmInput(evt, ctx);
947
- }));
948
- api.on("llm_output", wrap("llm_output", (evt, ctx) => {
949
- builder.onLlmOutput(evt, ctx);
950
- }));
951
- api.on("agent_end", wrap("agent_end", (evt, ctx) => {
952
- queueMicrotask(() => {
2480
+ api.on("llm_input", wrap("llm_input", (e, c) => builder.onLlmInput(e, c)));
2481
+ api.on("llm_output", wrap("llm_output", (e, c) => builder.onLlmOutput(e, c)));
2482
+ api.on("agent_end", wrap("agent_end", (e, c) => builder.onAgentEnd(e, c)));
2483
+ api.on("model_call_started", wrap("model_call_started", (e, c) => builder.onModelCallStarted(e, c)));
2484
+ api.on("model_call_ended", wrap("model_call_ended", (e, c) => builder.onModelCallEnded(e, c)));
2485
+ api.on("before_tool_call", wrap("before_tool_call", (e, c) => builder.onBeforeToolCall(e, c)));
2486
+ api.on("after_tool_call", wrap("after_tool_call", (e, c) => builder.onAfterToolCall(e, c)));
2487
+ api.on("before_compaction", wrap("before_compaction", (e, c) => builder.onBeforeCompaction(e, c)));
2488
+ api.on("after_compaction", wrap("after_compaction", (e, c) => builder.onAfterCompaction(e, c)));
2489
+ api.on("subagent_spawned", wrap("subagent_spawned", (e, c) => builder.onSubagentSpawned(e, c)));
2490
+ api.on("subagent_ended", wrap("subagent_ended", (e, c) => builder.onSubagentEnded(e, c)));
2491
+ api.on("session_start", wrap("session_start", (e, c) => builder.onSessionStart(e, c)));
2492
+ api.on("session_end", wrap("session_end", (e, c) => builder.onSessionEnd(e.sessionKey ?? c.sessionKey, e.sessionId ?? c.sessionId)));
2493
+ api.on("message_received", wrap("message_received", (e, c) => builder.onMessageReceived(e, c)));
2494
+ api.on("cron_changed", wrap("cron_changed", (e) => builder.onCronChanged(e)));
2495
+ api.on("gateway_stop", (_evt, _ctx) => transport.flush(STOP_FLUSH_BUDGET_MS));
2496
+ try {
2497
+ api.runtime?.events?.onAgentEvent?.((evt) => {
953
2498
  try {
954
- const result = builder.onAgentEnd(evt, ctx);
955
- if (!result) {
956
- logger.debug("agent_end fired without a matching run in flight");
957
- return;
958
- }
959
- opts.onEmit?.(result);
960
- const payload = buildOtlpRequest(result, { allowConversationAccess: config.allowConversationAccess });
961
- postTraces({
962
- baseUrl: config.baseUrl,
963
- apiKey: config.apiKey,
964
- project: config.project,
965
- payload,
966
- logger
967
- });
2499
+ builder.onAgentEvent(evt);
968
2500
  } catch (err) {
969
- logger.warn(`agent_end finalize failed: ${String(err)}`);
2501
+ logger.warn(`agent event handler failed: ${String(err)}`);
970
2502
  }
971
2503
  });
972
- }));
2504
+ } catch (err) {
2505
+ logger.debug(`agent event stream unavailable: ${String(err)}`);
2506
+ }
973
2507
  }
974
2508
  //#endregion
975
2509
  export { registerLatitudePlugin as default };