@latitude-data/openclaw-telemetry 0.0.9 → 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,34 +1,8 @@
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);
10
- 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);
29
- }
30
- }
31
- //#endregion
4
+ import { readFileSync, statSync } from "node:fs";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
32
6
  //#region src/redaction.ts
33
7
  function parseRedactConfig(value) {
34
8
  if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
@@ -82,24 +56,14 @@ function redactedAttr(key, mask) {
82
56
  //#endregion
83
57
  //#region src/config.ts
84
58
  const DEFAULT_BASE_URL = "https://ingest.latitude.so";
59
+ const DEFAULT_SERVICE_NAME = "openclaw";
60
+ const DEFAULT_MAX_CONTENT_CHARS = 262144;
85
61
  /**
86
62
  * Build a `Config` from OpenClaw's per-plugin config bucket. The plugin SDK
87
63
  * passes `api.pluginConfig` (the user's `plugins.entries[id].config` block)
88
- * to the registration function that's the only source.
89
- *
90
- * Earlier 0.0.x versions also fell back to environment variables when keys
91
- * were missing from pluginConfig. That fallback is gone deliberately:
92
- * OpenClaw 2026.4.25's `openclaw plugins install` runs a static-analysis
93
- * security scan that flags any runtime source combining environment-variable
94
- * access with a network-send call (we have `fetch(` in postTraces). With
95
- * the fallback our bundled runtime tripped the scanner. The installer
96
- * writes credentials to `plugins.entries[id].config` anyway, so the
97
- * fallback was polish-not-feature — its removal also gives a cleaner
98
- * privacy story (the runtime can't pick up credentials the operator
99
- * didn't put in openclaw.json).
100
- *
101
- * For dev-time testing with debug logs, set `config.debug = true` in
102
- * 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.
103
67
  */
104
68
  function loadConfig(pluginConfig = void 0) {
105
69
  const fromOpts = pluginConfig ?? {};
@@ -117,7 +81,14 @@ function loadConfig(pluginConfig = void 0) {
117
81
  debug,
118
82
  allowConversationAccess,
119
83
  redact: parseRedactConfig(fromOpts.redact),
120
- enabled: hasCreds && !explicitlyDisabled
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
121
92
  };
122
93
  }
123
94
  function pickString(value) {
@@ -126,24 +97,44 @@ function pickString(value) {
126
97
  function pickBool(value) {
127
98
  return typeof value === "boolean" ? value : void 0;
128
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
+ }
129
115
  //#endregion
130
116
  //#region src/logger.ts
131
117
  const PREFIX = "[latitude-openclaw]";
132
- function createLogger(debugEnabled) {
118
+ function createLogger(debugEnabled, host) {
133
119
  return {
134
- debug: debugEnabled ? (msg) => process.stderr.write(`${PREFIX} ${msg}\n`) : () => {},
135
- 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)
136
122
  };
137
123
  }
124
+ function writeStderr(msg) {
125
+ process.stderr.write(`${PREFIX} ${msg}\n`);
126
+ }
138
127
  //#endregion
139
128
  //#region src/otlp.ts
140
129
  const SCOPE_NAME = "@latitude-data/openclaw-telemetry";
141
- const SCOPE_VERSION = "0.0.9";
142
- /** Build an OTLP export request for a single completed agent run. */
143
- function buildOtlpRequest(result, options) {
144
- 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)));
145
136
  return { resourceSpans: [{
146
- resource: { attributes: resourceAttrs() },
137
+ resource: { attributes: resourceAttrs(options.serviceName ?? "openclaw") },
147
138
  scopeSpans: [{
148
139
  scope: {
149
140
  name: SCOPE_NAME,
@@ -154,53 +145,128 @@ function buildOtlpRequest(result, options) {
154
145
  }] };
155
146
  }
156
147
  function toOtlpSpan(span, options) {
157
- const startNs = msToNs(span.startMs);
158
- const endNs = msToNs(span.endMs ?? span.startMs);
159
148
  const attrs = [];
160
149
  for (const [rawKey, value] of Object.entries(span.attrs)) {
161
150
  if (value === void 0 || value === null) continue;
162
- const isGated = rawKey.endsWith(":gated");
151
+ const isGated = rawKey.endsWith(GATED_SUFFIX);
163
152
  if (isGated && !options.allowConversationAccess) continue;
164
- const kv = encodeAttr(isGated ? rawKey.slice(0, -6) : rawKey, value);
153
+ const kv = encodeAttr(isGated ? rawKey.slice(0, -6) : rawKey, value, options.maxContentChars);
165
154
  if (kv !== void 0) attrs.push(kv);
166
155
  }
167
156
  attrs.push(bool("latitude.captured.content", options.allowConversationAccess));
168
- if (span.endMs !== void 0) attrs.push(int("openclaw.duration_ms.computed", Math.max(0, span.endMs - span.startMs)));
169
- const redactedAttrs = redactAttributes(attrs, options.redact);
170
- const statusCode = span.outcome === "error" ? 2 : 1;
171
157
  return {
172
158
  traceId: span.traceId,
173
159
  spanId: span.spanId,
174
160
  parentSpanId: span.parentSpanId,
175
161
  name: span.name,
176
- kind: 1,
177
- startTimeUnixNano: startNs,
178
- endTimeUnixNano: endNs,
179
- attributes: redactedAttrs,
180
- 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 }
181
167
  };
182
168
  }
183
- function encodeAttr(key, value) {
169
+ function encodeAttr(key, value, maxChars) {
184
170
  if (value === void 0 || value === null) return void 0;
185
- if (typeof value === "string") return str(key, value);
171
+ if (typeof value === "string") return str$1(key, budget(value, maxChars));
186
172
  if (typeof value === "boolean") return bool(key, value);
187
173
  if (typeof value === "number") return Number.isInteger(value) ? int(key, value) : {
188
174
  key,
189
175
  value: { doubleValue: value }
190
176
  };
191
- 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
+ };
192
257
  }
193
- function resourceAttrs() {
258
+ function resourceAttrs(serviceName) {
194
259
  return [
195
- str("service.name", "openclaw"),
196
- str("service.version", SCOPE_VERSION),
197
- str("host.name", hostname()),
198
- str("host.arch", arch()),
199
- str("os.type", platform()),
200
- 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())
201
267
  ];
202
268
  }
203
- function str(key, value) {
269
+ function str$1(key, value) {
204
270
  return {
205
271
  key,
206
272
  value: { stringValue: value }
@@ -230,6 +296,113 @@ function safeJson$1(value) {
230
296
  }
231
297
  }
232
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
233
406
  //#region src/messages.ts
234
407
  const ALLOWED_ROLES = new Set([
235
408
  "system",
@@ -237,26 +410,24 @@ const ALLOWED_ROLES = new Set([
237
410
  "assistant",
238
411
  "tool"
239
412
  ]);
240
- /**
241
- * Normalize a single message of any of the provider shapes we know about.
242
- * Returns `undefined` for non-objects so the caller can skip them.
243
- */
244
413
  function normalizeMessage(raw) {
245
414
  if (!raw || typeof raw !== "object") return void 0;
246
415
  const obj = raw;
247
- const role = coerceRole(obj.role);
248
416
  if (Array.isArray(obj.parts)) {
249
417
  const parts = [];
250
418
  for (const p of obj.parts) if (p && typeof p === "object") parts.push(p);
251
419
  return {
252
- role,
420
+ role: coerceRole(obj.role),
253
421
  parts: parts.length > 0 ? parts : [{
254
422
  type: "text",
255
423
  content: safeJson(raw)
256
424
  }]
257
425
  };
258
426
  }
427
+ if (obj.role === "toolResult") return normalizeToolResult(obj);
428
+ if (obj.role === "custom") return normalizeCustom(obj);
259
429
  const content = obj.content ?? obj.text ?? obj.message;
430
+ const role = isRuntimeContext(obj, content) ? "system" : coerceRole(obj.role);
260
431
  if (role === "tool" && obj.tool_call_id !== void 0) return {
261
432
  role,
262
433
  parts: [{
@@ -266,11 +437,16 @@ function normalizeMessage(raw) {
266
437
  }]
267
438
  };
268
439
  if (typeof content === "string") {
269
- const parts = [{
440
+ const parts = [];
441
+ if (content.length > 0) parts.push({
270
442
  type: "text",
271
443
  content
272
- }];
444
+ });
273
445
  appendToolCalls(parts, obj.tool_calls);
446
+ if (parts.length === 0) parts.push({
447
+ type: "text",
448
+ content: ""
449
+ });
274
450
  return {
275
451
  role,
276
452
  parts
@@ -300,7 +476,6 @@ function normalizeMessage(raw) {
300
476
  }]
301
477
  };
302
478
  }
303
- /** Normalize an array of provider messages. */
304
479
  function normalizeMessages(raw) {
305
480
  const out = [];
306
481
  for (const m of raw) {
@@ -309,7 +484,6 @@ function normalizeMessages(raw) {
309
484
  }
310
485
  return out;
311
486
  }
312
- /** Build a single user message from a string prompt. */
313
487
  function userMessageFromPrompt(prompt) {
314
488
  return {
315
489
  role: "user",
@@ -319,44 +493,73 @@ function userMessageFromPrompt(prompt) {
319
493
  }]
320
494
  };
321
495
  }
322
- /** Build a single assistant message from `assistantTexts` + `lastAssistant` fallback. */
323
- function assistantMessageFromOutput(assistantTexts, lastAssistant) {
324
- if (lastAssistant !== void 0) {
325
- const norm = normalizeMessage(lastAssistant);
326
- if (norm) return {
327
- ...norm,
328
- role: "assistant"
329
- };
330
- }
331
- const parts = [];
332
- for (const text of assistantTexts) if (text.length > 0) parts.push({
333
- type: "text",
334
- content: text
335
- });
336
- if (parts.length === 0) parts.push({
337
- type: "text",
338
- content: ""
339
- });
340
- return {
341
- role: "assistant",
342
- parts
343
- };
344
- }
345
- /**
346
- * Wrap a system prompt string into the parts-array shape expected for
347
- * `gen_ai.system_instructions`. Empty string in → single empty text part out
348
- * (still a valid array, never `undefined`).
349
- */
350
496
  function systemInstructionsParts(prompt) {
351
497
  return [{
352
498
  type: "text",
353
499
  content: prompt
354
500
  }];
355
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
+ }
356
514
  function coerceRole(raw) {
357
515
  if (typeof raw !== "string") return "user";
358
516
  return ALLOWED_ROLES.has(raw) ? raw : "user";
359
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
+ }
360
563
  function normalizeBlock(raw) {
361
564
  if (typeof raw === "string") return {
362
565
  type: "text",
@@ -364,24 +567,22 @@ function normalizeBlock(raw) {
364
567
  };
365
568
  if (!raw || typeof raw !== "object") return void 0;
366
569
  const obj = raw;
367
- if (typeof obj.type === "string" && (typeof obj.content === "string" || obj.content === void 0)) {
368
- 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 {
369
573
  type: "text",
370
574
  content: obj.content
371
575
  };
576
+ if (typeof obj.text === "string") return {
577
+ type: "text",
578
+ content: obj.text
579
+ };
372
580
  }
373
- const type = typeof obj.type === "string" ? obj.type : "text";
374
- if (type === "text" && typeof obj.text === "string") return {
581
+ if (type === "toolResult") return {
375
582
  type: "text",
376
- content: obj.text
377
- };
378
- if (type === "tool_use") return {
379
- type: "tool_call",
380
- id: typeof obj.id === "string" ? obj.id : "",
381
- name: typeof obj.name === "string" ? obj.name : "",
382
- arguments: obj.input ?? {}
583
+ content: blockText(obj) ?? safeJson(raw)
383
584
  };
384
- if (type === "tool_call") return {
585
+ if (type === "toolCall" || type === "tool_use" || type === "tool_call") return {
385
586
  type: "tool_call",
386
587
  id: typeof obj.id === "string" ? obj.id : "",
387
588
  name: typeof obj.name === "string" ? obj.name : "",
@@ -397,33 +598,46 @@ function normalizeBlock(raw) {
397
598
  id: typeof obj.id === "string" ? obj.id : "",
398
599
  response: obj.response ?? ""
399
600
  };
400
- if (type === "thinking" && typeof obj.thinking === "string") return {
401
- type: "reasoning",
402
- content: obj.thinking
403
- };
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
+ }
404
612
  if (type === "reasoning" && typeof obj.content === "string") return {
405
613
  type: "reasoning",
406
614
  content: obj.content
407
615
  };
408
- if (type === "image" && obj.source && typeof obj.source === "object") {
409
- const src = obj.source;
410
- const uri = src.url ?? (src.data ? `data:${src.media_type ?? "image/unknown"};base64,${src.data}` : "");
616
+ if (type === "image") {
617
+ const uri = imageUri(obj);
411
618
  if (uri) return {
412
619
  type: "uri",
413
620
  modality: "image",
414
621
  uri
415
622
  };
623
+ return {
624
+ type: "text",
625
+ content: "[image]"
626
+ };
416
627
  }
417
628
  return {
418
629
  type,
419
630
  content: safeJson(raw)
420
631
  };
421
632
  }
422
- /**
423
- * OpenAI assistant messages put tool calls in a separate `tool_calls` array
424
- * alongside string content. Append them as parts so the trace shows what the
425
- * model emitted in that turn.
426
- */
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
+ }
427
641
  function appendToolCalls(parts, raw) {
428
642
  if (!Array.isArray(raw)) return;
429
643
  for (const tc of raw) {
@@ -451,301 +665,1093 @@ function safeJson(value) {
451
665
  }
452
666
  }
453
667
  //#endregion
454
- //#region src/span-builder.ts
455
- const SUBAGENT_LINK_TTL_MS = 3600 * 1e3;
456
- const SUBAGENT_LINK_MAX = 1e3;
457
- var SpanBuilder = class {
458
- runs = /* @__PURE__ */ new Map();
459
- subagentLinks = /* @__PURE__ */ new Map();
460
- inflightCount() {
461
- 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;
462
689
  }
463
- /**
464
- * Open the root `agent` span. If the runId was previously registered as a
465
- * subagent's child, propagate the parent's traceId and parent the new span
466
- * under the parent's `subagent` span — so the entire subagent's work nests
467
- * inside the parent's trace as one waterfall.
468
- */
469
- onBeforeAgentStart(evt, ctx) {
470
- const runId = ctx.runId;
471
- if (!runId) return;
472
- if (this.runs.has(runId)) return;
473
- const link = this.subagentLinks.get(runId);
474
- const traceId = link?.traceId ?? hashHex(runId, 32);
475
- const parentSpanId = link?.subagentSpanId ?? "";
476
- const agent = {
477
- spanId: hashHex(`${traceId}:${runId}:agent`, 16),
478
- traceId,
479
- parentSpanId,
480
- name: "agent",
481
- startMs: Date.now(),
482
- endMs: void 0,
483
- attrs: {
484
- ...flattenCtx(ctx),
485
- ...latitudeAttrs(ctx),
486
- ...sessionAttrs(ctx),
487
- "openclaw.run.id": runId,
488
- "before_agent_start.prompt:gated": evt.prompt,
489
- "before_agent_start.messages:gated": evt.messages ? normalizeMessages(evt.messages) : void 0
490
- }
491
- };
492
- this.runs.set(runId, {
493
- agent,
494
- history: [],
495
- openModelCalls: /* @__PURE__ */ new Map(),
496
- openToolCalls: /* @__PURE__ */ new Map(),
497
- openCompaction: void 0,
498
- closed: [],
499
- childSubagentSpans: /* @__PURE__ */ new Map()
500
- });
690
+ };
691
+ var SessionHistoryStore = class {
692
+ sessions = /* @__PURE__ */ new Map();
693
+ now;
694
+ constructor(now = () => Date.now()) {
695
+ this.now = now;
501
696
  }
502
- /**
503
- * Enrich the open `agent` span with content + identity from the LLM input.
504
- * Also seeds the rolling history snapshot used by per-call `model_call`
505
- * input attributes.
506
- *
507
- * Provider-specific message shapes get normalized into the parts-based
508
- * GenAI format here — that's the contract Latitude's downstream parser
509
- * expects on `gen_ai.input.messages` and `gen_ai.system_instructions`.
510
- */
511
- onLlmInput(evt, ctx) {
512
- const run = this.runs.get(ctx.runId ?? evt.runId);
513
- if (!run) return;
514
- const inputMessages = [...normalizeMessages(evt.historyMessages)];
515
- if (evt.prompt) inputMessages.push(userMessageFromPrompt(evt.prompt));
516
- Object.assign(run.agent.attrs, {
517
- "gen_ai.system_instructions:gated": evt.systemPrompt ? systemInstructionsParts(evt.systemPrompt) : void 0,
518
- "user_prompt:gated": evt.prompt,
519
- "gen_ai.input.messages:gated": inputMessages,
520
- "openclaw.images.count": evt.imagesCount,
521
- "gen_ai.request.model": evt.model,
522
- "gen_ai.system": evt.provider,
523
- "openclaw.provider": evt.provider
524
- });
525
- 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;
526
702
  }
527
- /**
528
- * Enrich the agent span with attempt-aggregate output + token usage.
529
- * (Per-call usage isn't surfaced by OpenClaw today — see PR #2986.)
530
- */
531
- onLlmOutput(evt, ctx) {
532
- const run = this.runs.get(ctx.runId ?? evt.runId);
533
- if (!run) return;
534
- const assistantMessage = assistantMessageFromOutput(evt.assistantTexts, evt.lastAssistant);
535
- Object.assign(run.agent.attrs, {
536
- "gen_ai.output.messages:gated": [assistantMessage],
537
- "openclaw.resolved.ref": evt.resolvedRef,
538
- "openclaw.harness.id": evt.harnessId,
539
- "gen_ai.response.model": evt.model,
540
- ...usageAttrs(evt.usage)
541
- });
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));
542
706
  }
543
- onModelCallStarted(evt, ctx) {
544
- const run = this.runs.get(evt.runId);
545
- if (!run) return;
546
- const span = {
547
- spanId: hashHex(`${run.agent.traceId}:model_call:${evt.callId}`, 16),
548
- traceId: run.agent.traceId,
549
- parentSpanId: run.agent.spanId,
550
- name: "model_call",
551
- startMs: Date.now(),
552
- endMs: void 0,
553
- attrs: {
554
- ...latitudeAttrs(ctx),
555
- ...sessionAttrs(ctx),
556
- "openclaw.run.id": evt.runId,
557
- "openclaw.call.id": evt.callId,
558
- "gen_ai.system": evt.provider,
559
- "openclaw.provider": evt.provider,
560
- "gen_ai.request.model": evt.model,
561
- "openclaw.api": evt.api,
562
- "openclaw.transport": evt.transport,
563
- "gen_ai.input.messages:gated": [...run.history]
564
- }
565
- };
566
- 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));
567
710
  }
568
- onModelCallEnded(evt, _ctx) {
569
- const run = this.runs.get(evt.runId);
570
- if (!run) return;
571
- const span = run.openModelCalls.get(evt.callId);
572
- if (!span) return;
573
- span.endMs = Date.now();
574
- span.outcome = evt.outcome === "completed" ? "ok" : "error";
575
- span.errorMessage = evt.errorCategory;
576
- Object.assign(span.attrs, {
577
- "openclaw.duration_ms": evt.durationMs,
578
- "openclaw.outcome": evt.outcome,
579
- "openclaw.error.category": evt.errorCategory,
580
- "openclaw.failure.kind": evt.failureKind,
581
- "openclaw.request.payload_bytes": evt.requestPayloadBytes,
582
- "openclaw.response.stream_bytes": evt.responseStreamBytes,
583
- "openclaw.ttfb_ms": evt.timeToFirstByteMs,
584
- "openclaw.upstream.request_id_hash": evt.upstreamRequestIdHash
585
- });
586
- run.openModelCalls.delete(evt.callId);
587
- run.closed.push(span);
711
+ forget(sessionId) {
712
+ this.sessions.delete(sessionId);
588
713
  }
589
- /**
590
- * Open a `tool_call` span as a sibling of the agent span. Also append a
591
- * synthetic assistant `tool_call` part to the rolling history so the NEXT
592
- * model_call's input snapshot reflects what the model emitted.
593
- *
594
- * IMPORTANT: this runs as a `runModifyingHook` in OpenClaw — returning
595
- * anything other than `undefined`/falsy from this handler blocks the tool.
596
- * The plugin-side handler enforces a void return; this method's signature
597
- * already returns `void`.
598
- */
599
- onBeforeToolCall(evt, ctx) {
600
- if (!evt.runId) return;
601
- const run = this.runs.get(evt.runId);
602
- if (!run) return;
603
- const toolCallId = evt.toolCallId ?? `${evt.toolName}:${randomUUID()}`;
604
- const span = {
605
- spanId: hashHex(`${run.agent.traceId}:tool_call:${toolCallId}`, 16),
606
- traceId: run.agent.traceId,
607
- parentSpanId: run.agent.spanId,
608
- name: `tool_call:${evt.toolName}`,
609
- startMs: Date.now(),
610
- endMs: void 0,
611
- attrs: {
612
- ...latitudeAttrs(ctx),
613
- ...sessionAttrs(ctx),
614
- "openclaw.run.id": evt.runId,
615
- "gen_ai.tool.name": evt.toolName,
616
- "gen_ai.tool.call.id": toolCallId,
617
- "gen_ai.tool.call.arguments:gated": evt.params
618
- }
619
- };
620
- run.openToolCalls.set(toolCallId, span);
621
- run.history.push({
622
- role: "assistant",
623
- parts: [{
624
- type: "tool_call",
625
- id: toolCallId,
626
- name: evt.toolName,
627
- arguments: evt.params
628
- }]
714
+ set(sessionId, messages) {
715
+ this.evict();
716
+ this.sessions.set(sessionId, {
717
+ messages,
718
+ updatedAt: this.now()
629
719
  });
630
720
  }
631
- onAfterToolCall(evt, _ctx) {
632
- if (!evt.runId) return;
633
- const run = this.runs.get(evt.runId);
634
- if (!run) return;
635
- let resolvedId = evt.toolCallId && run.openToolCalls.has(evt.toolCallId) ? evt.toolCallId : void 0;
636
- if (!resolvedId) resolvedId = this.findOpenToolCallByName(run, evt.toolName);
637
- if (!resolvedId) return;
638
- const span = run.openToolCalls.get(resolvedId);
639
- if (!span) return;
640
- const toolCallId = resolvedId;
641
- span.endMs = Date.now();
642
- span.outcome = Boolean(evt.error) ? "error" : "ok";
643
- span.errorMessage = evt.error;
644
- Object.assign(span.attrs, {
645
- "gen_ai.tool.call.result:gated": evt.result,
646
- "openclaw.error.message:gated": evt.error,
647
- "openclaw.duration_ms": evt.durationMs
648
- });
649
- run.openToolCalls.delete(toolCallId);
650
- run.closed.push(span);
651
- run.history.push({
652
- role: "tool",
653
- parts: [{
654
- type: "tool_call_response",
655
- id: toolCallId,
656
- response: evt.result ?? evt.error ?? ""
657
- }]
658
- });
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);
659
727
  }
660
- onBeforeCompaction(evt, ctx) {
661
- const runId = ctx.runId;
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);
1422
+ run.history.push({
1423
+ role: "assistant",
1424
+ parts: [{
1425
+ type: "tool_call",
1426
+ id: toolCallId,
1427
+ name: evt.toolName,
1428
+ arguments: evt.params
1429
+ }]
1430
+ });
1431
+ if (SPAWN_TOOL_PATTERN.test(evt.toolName)) run.lastSpawnToolSpanId = span.spanId;
1432
+ }
1433
+ onAfterToolCall(evt, ctx) {
1434
+ const runId = evt.runId ?? ctx.runId;
662
1435
  if (!runId) return;
663
1436
  const run = this.runs.get(runId);
664
1437
  if (!run) return;
665
- run.openCompaction = {
666
- spanId: hashHex(`${run.agent.traceId}:compaction:${run.closed.length}`, 16),
667
- traceId: run.agent.traceId,
668
- 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 ?? "",
669
1491
  name: "compaction",
670
- startMs: Date.now(),
1492
+ kind: KIND_INTERNAL,
1493
+ startMs,
671
1494
  endMs: void 0,
672
1495
  attrs: {
673
- ...latitudeAttrs(ctx),
674
- ...sessionAttrs(ctx),
675
- "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",
676
1501
  "openclaw.compaction.message_count.before": evt.messageCount,
1502
+ "openclaw.compaction.compacting_count": evt.compactingCount,
1503
+ "openclaw.compaction.token_count.before": evt.tokenCount,
677
1504
  "openclaw.compaction.session_file": evt.sessionFile,
678
- "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
679
1507
  }
680
1508
  };
1509
+ if (run) {
1510
+ run.openCompaction = span;
1511
+ return;
1512
+ }
1513
+ remember(this.standaloneCompactions, ctx.sessionKey ?? "", span, STANDALONE_COMPACTION_MAX);
681
1514
  }
682
1515
  onAfterCompaction(evt, ctx) {
683
- const runId = ctx.runId;
684
- if (!runId) return;
685
- const run = this.runs.get(runId);
686
- if (!run?.openCompaction) return;
687
- const span = run.openCompaction;
688
- 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;
689
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);
690
1526
  Object.assign(span.attrs, {
691
1527
  "openclaw.compaction.message_count.after": evt.messageCount,
692
1528
  "openclaw.compaction.compacted_count": evt.compactedCount,
693
- "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
694
1540
  });
695
- run.openCompaction = void 0;
696
- 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);
697
1631
  }
698
1632
  /**
699
- * Open a `subagent` span on the parent run AND register the cross-run link
700
- * 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.
701
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
+ }
702
1688
  onSubagentSpawned(evt, ctx) {
703
- const parentRunId = ctx.runId;
704
- if (!parentRunId) return;
705
- const parent = this.runs.get(parentRunId);
706
- 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;
707
1696
  const span = {
708
- spanId: hashHex(`${parent.agent.traceId}:subagent:${evt.runId}`, 16),
709
- traceId: parent.agent.traceId,
710
- parentSpanId: parent.agent.spanId,
1697
+ spanId: hashHex(`${childRunId}:subagent`, 16),
1698
+ traceId,
1699
+ parentSpanId,
711
1700
  name: "subagent",
712
- startMs: Date.now(),
1701
+ kind: KIND_INTERNAL,
1702
+ startMs: this.now(),
713
1703
  endMs: void 0,
714
1704
  attrs: {
715
- ...latitudeAttrs(ctx),
716
- ...sessionAttrs(ctx),
717
- "openclaw.parent.run.id": parentRunId,
718
- "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,
719
1712
  "openclaw.subagent.child_session_key": evt.childSessionKey,
720
1713
  "openclaw.subagent.agent_id": evt.agentId,
721
1714
  "openclaw.subagent.label": evt.label,
722
1715
  "openclaw.subagent.mode": evt.mode,
723
1716
  "openclaw.subagent.thread_requested": evt.threadRequested,
1717
+ "openclaw.subagent.resolved_model": evt.resolvedModel,
1718
+ "openclaw.subagent.resolved_provider": evt.resolvedProvider,
724
1719
  "openclaw.subagent.requester.channel": evt.requester?.channel,
725
1720
  "openclaw.subagent.requester.account_id": evt.requester?.accountId,
726
1721
  "openclaw.subagent.requester.to": evt.requester?.to,
727
- "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)
728
1724
  }
729
1725
  };
730
- parent.childSubagentSpans.set(evt.runId, span);
731
- this.evictStaleSubagentLinks();
732
- this.subagentLinks.set(evt.runId, {
733
- traceId: parent.agent.traceId,
734
- subagentSpanId: span.spanId,
735
- 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()
736
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()
1742
+ });
1743
+ const child = this.runs.get(childRunId);
1744
+ if (child) this.applyLink(child);
737
1745
  }
738
1746
  onSubagentEnded(evt, ctx) {
739
- const parentRunId = ctx.runId;
740
- if (!parentRunId) return;
741
- const parent = this.runs.get(parentRunId);
742
- if (!parent) return;
743
- const childRunId = evt.runId;
1747
+ const childRunId = evt.runId ?? ctx.runId;
744
1748
  if (!childRunId) return;
745
- const span = parent.childSubagentSpans.get(childRunId);
746
- if (!span) return;
747
- span.endMs = Date.now();
748
- 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";
749
1755
  span.errorMessage = evt.error;
750
1756
  Object.assign(span.attrs, {
751
1757
  "openclaw.subagent.target_session_key": evt.targetSessionKey,
@@ -753,93 +1759,512 @@ var SpanBuilder = class {
753
1759
  "openclaw.subagent.reason": evt.reason,
754
1760
  "openclaw.subagent.outcome": evt.outcome,
755
1761
  "openclaw.subagent.send_farewell": evt.sendFarewell,
756
- "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
757
1765
  });
758
- parent.childSubagentSpans.delete(childRunId);
759
- 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
+ };
760
1881
  }
761
1882
  /**
762
- * Close out the run: finish the agent span, abandon any still-open
763
- * model_calls / tool_calls / compactions, and return everything ready to
764
- * 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.
765
1886
  */
766
- onAgentEnd(evt, ctx) {
767
- const runId = ctx.runId;
768
- if (!runId) return void 0;
769
- const run = this.runs.get(runId);
770
- if (!run) {
771
- 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";
772
1894
  return;
773
1895
  }
774
- const now = Date.now();
775
- run.agent.endMs = now;
776
- run.agent.outcome = evt.success ? "ok" : "error";
777
- run.agent.errorMessage = evt.error;
778
- Object.assign(run.agent.attrs, {
779
- "openclaw.duration_ms": evt.durationMs,
780
- "openclaw.run.success": evt.success,
781
- "openclaw.error.message:gated": evt.error,
782
- "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
783
2012
  });
784
- for (const span of run.openModelCalls.values()) {
785
- span.endMs = now;
786
- span.outcome = "error";
787
- span.attrs["openclaw.outcome"] = "abandoned";
788
- 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);
789
2033
  }
790
- for (const span of run.openToolCalls.values()) {
791
- span.endMs = now;
792
- span.outcome = "error";
793
- span.attrs["openclaw.outcome"] = "abandoned";
794
- 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;
795
2051
  }
796
- if (run.openCompaction) {
797
- run.openCompaction.endMs = now;
798
- run.openCompaction.outcome = "error";
799
- run.openCompaction.attrs["openclaw.outcome"] = "abandoned";
800
- 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";
801
2072
  }
802
- for (const span of run.childSubagentSpans.values()) {
803
- span.endMs = now;
804
- span.outcome = "error";
805
- span.attrs["openclaw.subagent.outcome"] = "abandoned";
806
- 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";
807
2084
  }
808
- const spans = [run.agent, ...run.closed];
809
- this.runs.delete(runId);
810
- this.subagentLinks.delete(runId);
811
- return {
812
- runId,
813
- spans
814
- };
815
2085
  }
816
- /** Drop a run without emitting — used on errors from the emit path. */
817
- abandon(runId) {
818
- this.runs.delete(runId);
819
- 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;
820
2131
  }
821
- /** Test-only: how many cross-run subagent links we're holding. */
822
- subagentLinkCount() {
823
- 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)];
824
2135
  }
825
2136
  /**
826
- * Drop any subagent links whose child run never reached `agent_end`. Called
827
- * before every `subagent_spawned` insert so the map stays bounded even when
828
- * children crash mid-spawn or the plugin reloads.
829
- *
830
- * Two passes: TTL eviction (anything older than `SUBAGENT_LINK_TTL_MS`),
831
- * then a hard size cap (when we're past `SUBAGENT_LINK_MAX`, drop the
832
- * 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.
833
2140
  */
834
- evictStaleSubagentLinks() {
835
- const now = Date.now();
836
- for (const [runId, link] of this.subagentLinks) if (now - link.createdAt > SUBAGENT_LINK_TTL_MS) this.subagentLinks.delete(runId);
837
- if (this.subagentLinks.size <= SUBAGENT_LINK_MAX) return;
838
- const sorted = Array.from(this.subagentLinks.entries()).sort((a, b) => a[1].createdAt - b[1].createdAt);
839
- const toRemove = this.subagentLinks.size - SUBAGENT_LINK_MAX;
840
- for (let i = 0; i < toRemove; i++) {
841
- const entry = sorted[i];
842
- 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);
843
2268
  }
844
2269
  }
845
2270
  findOpenToolCallByName(run, toolName) {
@@ -847,119 +2272,202 @@ var SpanBuilder = class {
847
2272
  const entries = Array.from(run.openToolCalls.entries());
848
2273
  for (let i = entries.length - 1; i >= 0; i--) {
849
2274
  const entry = entries[i];
850
- if (!entry) continue;
851
- const [id, span] = entry;
852
- if (span.name === target) return id;
2275
+ if (entry && entry[1].name === target) return entry[0];
853
2276
  }
854
2277
  }
855
2278
  };
856
- function flattenCtx(ctx) {
2279
+ function toolCtxToAgentCtx(ctx, runId) {
857
2280
  return {
858
- "openclaw.run.id": ctx.runId,
859
- "openclaw.session.id": ctx.sessionId,
860
- "openclaw.session.key": ctx.sessionKey,
861
- "openclaw.agent.id": ctx.agentId,
862
- "openclaw.agent.name": ctx.agentId,
863
- "openclaw.workspace.dir": ctx.workspaceDir,
864
- "openclaw.message.provider": ctx.messageProvider,
865
- "openclaw.trigger": ctx.trigger,
866
- "openclaw.channel.id": ctx.channelId,
867
- "openclaw.cron.job.id": ctx.jobId,
868
- "openclaw.model.provider.id": ctx.modelProviderId,
869
- "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
870
2289
  };
871
2290
  }
872
- /**
873
- * Mirror OpenClaw's session id onto the OTEL-standard keys Latitude's
874
- * resolver looks for. `gen_ai.session.id` and `session.id` are both in
875
- * `sessionIdCandidates` (domain/spans/src/otlp/resolvers/identity.ts), so
876
- * traces can be grouped by session in the Latitude UI without an
877
- * openclaw-specific code path. Emitted on every span, not just `agent`,
878
- * so child spans (model_call / tool_call / etc.) inherit the same grouping.
879
- */
880
- function sessionAttrs(ctx) {
881
- if (!ctx.sessionId) return {};
882
- return {
883
- "session.id": ctx.sessionId,
884
- "gen_ai.session.id": ctx.sessionId
885
- };
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";
886
2296
  }
887
- /**
888
- * Build `latitude.tags` and `latitude.metadata` attrs from the hook context.
889
- * The OTLP encoder JSON-stringifies arrays/objects, which is the encoding
890
- * Latitude's resolver expects:
891
- *
892
- * - `latitude.tags` is a JSON-encoded string array (`fromJsonStringArray`
893
- * in domain/spans/src/otlp/resolvers/enrichment.ts).
894
- * - `latitude.metadata` is a JSON-encoded string object (`fromJsonString`).
895
- *
896
- * Tags = the agent id, the channel id, and the trigger. When trigger is
897
- * `cron`, the tag becomes `cron:<jobId>` so dashboards can pivot on the
898
- * specific cron job. Each tag is conditionally included so absent ctx
899
- * fields don't produce empty entries.
900
- *
901
- * Metadata = every ctx field that's set, namespaced under `openclaw.*` so
902
- * it can't collide with metadata keys other plugins might emit.
903
- */
904
- function latitudeAttrs(ctx) {
905
- const tags = [];
906
- if (ctx.agentId) tags.push(ctx.agentId);
907
- if (ctx.channelId) tags.push(ctx.channelId);
908
- if (ctx.trigger) tags.push(ctx.trigger === "cron" && ctx.jobId ? `cron:${ctx.jobId}` : ctx.trigger);
909
- const metadata = {};
910
- if (ctx.runId) metadata["openclaw.run.id"] = ctx.runId;
911
- if (ctx.sessionId) metadata["openclaw.session.id"] = ctx.sessionId;
912
- if (ctx.sessionKey) metadata["openclaw.session.key"] = ctx.sessionKey;
913
- if (ctx.agentId) metadata["openclaw.agent.id"] = ctx.agentId;
914
- if (ctx.workspaceDir) metadata["openclaw.workspace.dir"] = ctx.workspaceDir;
915
- if (ctx.channelId) metadata["openclaw.channel.id"] = ctx.channelId;
916
- if (ctx.messageProvider) metadata["openclaw.message.provider"] = ctx.messageProvider;
917
- if (ctx.trigger) metadata["openclaw.trigger"] = ctx.trigger;
918
- if (ctx.jobId) metadata["openclaw.cron.job.id"] = ctx.jobId;
919
- if (ctx.modelProviderId) metadata["openclaw.model.provider.id"] = ctx.modelProviderId;
920
- if (ctx.modelId) metadata["openclaw.model.id"] = ctx.modelId;
921
- return {
922
- "latitude.tags": tags.length > 0 ? tags : void 0,
923
- "latitude.metadata": Object.keys(metadata).length > 0 ? metadata : void 0
924
- };
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;
925
2301
  }
926
- function usageAttrs(usage) {
927
- 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 {};
928
2308
  return {
929
- "gen_ai.usage.input_tokens": usage.input,
930
- "gen_ai.usage.output_tokens": usage.output,
931
- "gen_ai.usage.cache_read_input_tokens": usage.cacheRead,
932
- "gen_ai.usage.cache_creation_input_tokens": usage.cacheWrite,
933
- "gen_ai.usage.total_tokens": usage.total
2309
+ "session.id": sessionId,
2310
+ "gen_ai.session.id": sessionId
934
2311
  };
935
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
+ }
936
2328
  function hashHex(input, length) {
937
2329
  return createHash("sha256").update(input).digest("hex").slice(0, length);
938
2330
  }
939
2331
  //#endregion
940
- //#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;
941
2337
  /**
942
- * Register the Latitude plugin against an OpenClaw plugin API. OpenClaw calls
943
- * this once at plugin activation; we wire up the granular paired hooks
944
- * (model_call_started/_ended, before_/after_tool_call, before_/after_compaction,
945
- * subagent_spawned/_ended, before_agent_start/agent_end) plus the
946
- * data-only feeds (llm_input/llm_output) that enrich the agent span.
947
- *
948
- * Every typed hook on OpenClaw's side fires fire-and-forget for non-modifying
949
- * hooks; before_tool_call is a `runModifyingHook` where returning anything
950
- * other than undefined blocks the tool call. Our handler returns nothing —
951
- * 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.
952
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";
953
2423
  function registerLatitudePlugin(api, opts = {}) {
954
2424
  const config = opts.config ?? loadConfig(api.pluginConfig);
955
- const logger = opts.logger ?? createLogger(config.debug);
2425
+ const logger = opts.logger ?? createLogger(config.debug, api.logger);
956
2426
  if (!config.enabled) {
957
- if (config.apiKey === "") logger.debug("disabled: apiKey is empty (set plugins.entries[id].config.apiKey)");
958
- 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)");
959
2429
  return;
960
2430
  }
961
- logger.debug(`enabled: project=${config.project} base=${config.baseUrl} allowConversationAccess=${config.allowConversationAccess}`);
962
- 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
+ });
963
2471
  const wrap = (name, fn) => {
964
2472
  return (evt, ctx) => {
965
2473
  try {
@@ -969,64 +2477,33 @@ function registerLatitudePlugin(api, opts = {}) {
969
2477
  }
970
2478
  };
971
2479
  };
972
- api.on("before_agent_start", wrap("before_agent_start", (evt, ctx) => {
973
- builder.onBeforeAgentStart(evt, ctx);
974
- }));
975
- api.on("model_call_started", wrap("model_call_started", (evt, ctx) => {
976
- builder.onModelCallStarted(evt, ctx);
977
- }));
978
- api.on("model_call_ended", wrap("model_call_ended", (evt, ctx) => {
979
- builder.onModelCallEnded(evt, ctx);
980
- }));
981
- api.on("before_tool_call", wrap("before_tool_call", (evt, ctx) => {
982
- builder.onBeforeToolCall(evt, ctx);
983
- }));
984
- api.on("after_tool_call", wrap("after_tool_call", (evt, ctx) => {
985
- builder.onAfterToolCall(evt, ctx);
986
- }));
987
- api.on("before_compaction", wrap("before_compaction", (evt, ctx) => {
988
- builder.onBeforeCompaction(evt, ctx);
989
- }));
990
- api.on("after_compaction", wrap("after_compaction", (evt, ctx) => {
991
- builder.onAfterCompaction(evt, ctx);
992
- }));
993
- api.on("subagent_spawned", wrap("subagent_spawned", (evt, ctx) => {
994
- builder.onSubagentSpawned(evt, ctx);
995
- }));
996
- api.on("subagent_ended", wrap("subagent_ended", (evt, ctx) => {
997
- builder.onSubagentEnded(evt, ctx);
998
- }));
999
- api.on("llm_input", wrap("llm_input", (evt, ctx) => {
1000
- builder.onLlmInput(evt, ctx);
1001
- }));
1002
- api.on("llm_output", wrap("llm_output", (evt, ctx) => {
1003
- builder.onLlmOutput(evt, ctx);
1004
- }));
1005
- api.on("agent_end", wrap("agent_end", (evt, ctx) => {
1006
- 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) => {
1007
2498
  try {
1008
- const result = builder.onAgentEnd(evt, ctx);
1009
- if (!result) {
1010
- logger.debug("agent_end fired without a matching run in flight");
1011
- return;
1012
- }
1013
- opts.onEmit?.(result);
1014
- const payload = buildOtlpRequest(result, {
1015
- allowConversationAccess: config.allowConversationAccess,
1016
- redact: config.redact
1017
- });
1018
- postTraces({
1019
- baseUrl: config.baseUrl,
1020
- apiKey: config.apiKey,
1021
- project: config.project,
1022
- payload,
1023
- logger
1024
- });
2499
+ builder.onAgentEvent(evt);
1025
2500
  } catch (err) {
1026
- logger.warn(`agent_end finalize failed: ${String(err)}`);
2501
+ logger.warn(`agent event handler failed: ${String(err)}`);
1027
2502
  }
1028
2503
  });
1029
- }));
2504
+ } catch (err) {
2505
+ logger.debug(`agent event stream unavailable: ${String(err)}`);
2506
+ }
1030
2507
  }
1031
2508
  //#endregion
1032
2509
  export { registerLatitudePlugin as default };