nemo-relay-openclaw 0.3.0-rc.1 → 0.4.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/README.md CHANGED
@@ -12,10 +12,11 @@ observability component can export as ATIF JSON, OpenTelemetry spans, and
12
12
  OpenInference/Phoenix spans. The same generic plugin config path can initialize
13
13
  Adaptive components for hook-backed telemetry learning.
14
14
 
15
- This public OpenClaw plugin package uses OpenClaw public hooks. It does not
16
- rewrite OpenClaw tool execution, provider routing, policy decisions, or model
17
- requests. For middleware-backed behavior that changes execution, use the
18
- patch-based OpenClaw integration from the NeMo Relay repository.
15
+ This public OpenClaw plugin package uses OpenClaw public hooks. It can run
16
+ pre-tool conditional guardrails when OpenClaw invokes the before-tool hook, but
17
+ it does not rewrite provider routing or model requests. For middleware-backed
18
+ behavior that changes execution, use the patch-based OpenClaw integration from
19
+ the NeMo Relay repository.
19
20
 
20
21
  ## Why Use It?
21
22
 
@@ -204,11 +205,10 @@ openclaw gateway call nemoRelay.status --json
204
205
 
205
206
  ## Current Limits
206
207
 
207
- The plugin maps supported OpenClaw hook events into NeMo Relay telemetry without
208
- changing OpenClaw execution behavior.
208
+ The plugin maps supported OpenClaw hook events into NeMo Relay telemetry and can
209
+ run pre-tool conditional guardrail checks.
209
210
 
210
- It does not change OpenClaw tool execution, provider routing, policy decisions,
211
- or provider request payloads.
211
+ It does not rewrite provider routing or provider request payloads.
212
212
 
213
213
  Current OpenClaw public hooks expose request, response, message-write, and
214
214
  provider timing details through separate event streams. The plugin correlates
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { emitMark, toJsonRecord, toJsonValue } from './marks.js';
4
- import { evictExpiredCorrelationRecords, ensureSession, insertBoundedRecord, resolveSessionKey, } from './session.js';
4
+ import { evictExpiredCorrelationRecords, ensureSession, insertBoundedRecord, resolveSessionOwnerKey, } from './session.js';
5
5
  import { llmKey, modelTimingKey, modelTimingLlmKey, nowMicros, startMicrosFromDuration } from './correlation.js';
6
6
  /**
7
7
  * Store one OpenClaw llm_input snapshot and replay it immediately if the matching
@@ -9,12 +9,14 @@ import { llmKey, modelTimingKey, modelTimingLlmKey, nowMicros, startMicrosFromDu
9
9
  */
10
10
  export function recordLlmInput(manager, event, ctx) {
11
11
  evictExpiredReplayRecords(manager);
12
+ const observedAtMicros = nowMicros();
12
13
  const session = ensureSession(manager, {
13
14
  sessionId: event.sessionId,
14
15
  sessionKey: ctx.sessionKey,
15
16
  runId: event.runId,
16
17
  agentId: ctx.agentId,
17
18
  source: 'lazy_session',
19
+ timestamp: observedAtMicros,
18
20
  });
19
21
  if (!session) {
20
22
  return;
@@ -26,7 +28,7 @@ export function recordLlmInput(manager, event, ctx) {
26
28
  const key = llmKey(event);
27
29
  const input = createInputRecord(session, event);
28
30
  insertBoundedRecord(manager.state.llmInputs, key, input, manager.config.correlation.maxRecordsPerKey);
29
- const pending = shiftOldest(manager.state.llmOutputsPendingInput, key, (record) => record.sessionKey === session.sessionId);
31
+ const pending = shiftOldest(manager.state.llmOutputsPendingInput, key, (record) => record.sessionOwnerKey === session.ownerKey);
30
32
  if (!pending) {
31
33
  return;
32
34
  }
@@ -48,22 +50,24 @@ export function recordLlmInput(manager, event, ctx) {
48
50
  */
49
51
  export function recordLlmOutput(manager, event, ctx) {
50
52
  evictExpiredReplayRecords(manager);
53
+ const observedAtMicros = nowMicros();
51
54
  const session = ensureSession(manager, {
52
55
  sessionId: event.sessionId,
53
56
  sessionKey: ctx.sessionKey,
54
57
  runId: event.runId,
55
58
  agentId: ctx.agentId,
56
59
  source: 'lazy_session',
60
+ timestamp: observedAtMicros,
57
61
  });
58
62
  if (!session) {
59
63
  return;
60
64
  }
61
65
  const key = llmKey(event);
62
66
  if (hasTrajectoryReplay(session, event.runId)) {
63
- shiftOldest(manager.state.llmInputs, key, (record) => record.sessionKey === session.sessionId);
67
+ shiftOldest(manager.state.llmInputs, key, (record) => record.sessionOwnerKey === session.ownerKey);
64
68
  return;
65
69
  }
66
- const input = shiftOldest(manager.state.llmInputs, key, (record) => record.sessionKey === session.sessionId);
70
+ const input = shiftOldest(manager.state.llmInputs, key, (record) => record.sessionOwnerKey === session.ownerKey);
67
71
  if (input) {
68
72
  replayLlmOutput({
69
73
  manager,
@@ -75,7 +79,7 @@ export function recordLlmOutput(manager, event, ctx) {
75
79
  return;
76
80
  }
77
81
  const pending = {
78
- sessionKey: session.sessionId,
82
+ sessionOwnerKey: session.ownerKey,
79
83
  sessionId: event.sessionId,
80
84
  runId: event.runId,
81
85
  provider: event.provider,
@@ -120,7 +124,7 @@ export function recordBeforeMessageWrite(manager, event, ctx) {
120
124
  if (provider && model && (assistantTexts.length > 0 || assistantToolCalls.length > 0 || usage !== undefined)) {
121
125
  session.assistantMessageWrites ??= [];
122
126
  session.assistantMessageWrites.push({
123
- sessionKey: session.sessionId,
127
+ sessionOwnerKey: session.ownerKey,
124
128
  provider,
125
129
  model,
126
130
  assistantTexts,
@@ -158,7 +162,7 @@ export function recordModelCallStarted(manager, event, ctx) {
158
162
  return;
159
163
  }
160
164
  insertBoundedRecord(manager.state.modelCallsByCallId, modelTimingKey(event), {
161
- sessionKey: session.sessionId,
165
+ sessionOwnerKey: session.ownerKey,
162
166
  sessionId: session.sessionId,
163
167
  runId: event.runId,
164
168
  callId: event.callId,
@@ -191,7 +195,7 @@ export function recordModelCallEnded(manager, event, ctx) {
191
195
  const existing = latestUnendedRecord(manager.state.modelCallsByCallId.get(byCallKey), session);
192
196
  const record = existing ??
193
197
  {
194
- sessionKey: session.sessionId,
198
+ sessionOwnerKey: session.ownerKey,
195
199
  sessionId: session.sessionId,
196
200
  runId: event.runId,
197
201
  callId: event.callId,
@@ -205,7 +209,7 @@ export function recordModelCallEnded(manager, event, ctx) {
205
209
  insertBoundedRecord(manager.state.modelCallsByCallId, byCallKey, record, manager.config.correlation.maxRecordsPerKey);
206
210
  }
207
211
  insertBoundedRecord(manager.state.modelTimingsByLlmKey, modelTimingLlmKey({
208
- sessionId: session.sessionId,
212
+ sessionId: session.ownerKey,
209
213
  runId: event.runId,
210
214
  provider: event.provider,
211
215
  model: event.model,
@@ -219,7 +223,7 @@ export function replayPendingLlmOutputsForSession(manager, session, options) {
219
223
  for (const [key, records] of [...manager.state.llmOutputsPendingInput]) {
220
224
  const remaining = [];
221
225
  for (const record of records) {
222
- if (record.sessionKey !== session.sessionId) {
226
+ if (record.sessionOwnerKey !== session.ownerKey) {
223
227
  remaining.push(record);
224
228
  continue;
225
229
  }
@@ -274,7 +278,7 @@ export function replayAgentEndMessages(manager, event, ctx, session) {
274
278
  export function emitUnpairedModelCallTimingMarks(manager, session) {
275
279
  for (const records of manager.state.modelCallsByCallId.values()) {
276
280
  for (const record of records) {
277
- if (record.sessionKey !== session.sessionId || record.consumed || record.endedAtMs !== undefined) {
281
+ if (record.sessionOwnerKey !== session.ownerKey || record.consumed || record.endedAtMs !== undefined) {
278
282
  continue;
279
283
  }
280
284
  emitModelTimingMark(manager, session, 'openclaw.model_call_timing_unpaired', record);
@@ -284,7 +288,7 @@ export function emitUnpairedModelCallTimingMarks(manager, session) {
284
288
  const unpairedEnded = [];
285
289
  for (const records of manager.state.modelTimingsByLlmKey.values()) {
286
290
  for (const record of records) {
287
- if (record.sessionKey !== session.sessionId || record.consumed) {
291
+ if (record.sessionOwnerKey !== session.ownerKey || record.consumed) {
288
292
  continue;
289
293
  }
290
294
  unpairedEnded.push(record);
@@ -324,12 +328,14 @@ export function buildReplayLlmRequest(input, output, config, source = 'openclaw.
324
328
  /** Build the response payload passed to NeMo Relay for a replayed LLM span. */
325
329
  export function buildReplayLlmResponse(event, timing, config) {
326
330
  const usage = mapUsage(event.usage);
327
- const assistantToolCallNames = toolCallNames(event.assistantToolCalls);
331
+ const assistantToolCalls = assistantToolCallsForOutput(event, config);
332
+ const assistantToolCallNames = toolCallNames(assistantToolCalls);
328
333
  return toJsonValue({
329
334
  role: 'assistant',
330
335
  content: config.capture.includeResponses
331
336
  ? responseContent(event.assistantTexts, assistantToolCallNames)
332
337
  : undefined,
338
+ tool_calls: assistantToolCalls.length > 0 ? assistantToolCalls : undefined,
333
339
  assistant_texts_count: event.assistantTexts.length,
334
340
  resolved_ref: event.resolvedRef,
335
341
  harness_id: event.harnessId,
@@ -347,13 +353,20 @@ export function buildReplayLlmResponse(event, timing, config) {
347
353
  },
348
354
  });
349
355
  }
356
+ /** Return assistant tool calls from the current OpenClaw llm_output contract. */
357
+ function assistantToolCallsForOutput(event, config) {
358
+ const explicit = Array.isArray(event.assistantToolCalls) ? event.assistantToolCalls : [];
359
+ const inferred = explicit.length > 0 || !isRecord(event.lastAssistant) ? explicit : extractToolCalls(event.lastAssistant);
360
+ const snapshot = snapshotMessages(inferred);
361
+ return config.capture.stripToolArgs ? snapshot.map(stripToolCallArgs) : snapshot;
362
+ }
350
363
  /** Replay an output whose matching input never arrived before the grace timeout. */
351
364
  function replayExpiredPendingOutput(manager, key, record) {
352
365
  try {
353
366
  if (!removeRecord(manager.state.llmOutputsPendingInput, key, record)) {
354
367
  return;
355
368
  }
356
- const session = manager.state.sessions.get(record.sessionKey);
369
+ const session = manager.state.sessions.get(record.sessionOwnerKey);
357
370
  if (!session) {
358
371
  manager.state.counters.skippedEvents += 1;
359
372
  return;
@@ -399,7 +412,9 @@ function replayLlmOutput(params) {
399
412
  const metadata = toJsonRecord({
400
413
  source,
401
414
  runId: event.runId,
402
- sessionId: event.sessionId,
415
+ sessionId: session.sessionId,
416
+ sessionKey: session.sessionKey,
417
+ agentId: session.agentId,
403
418
  provider: event.provider,
404
419
  model: event.model,
405
420
  callId: timing?.callId,
@@ -443,7 +458,7 @@ function replayAssistantMessageWrites(manager, session, event, ctx) {
443
458
  },
444
459
  ctx,
445
460
  input: {
446
- sessionKey: session.sessionId,
461
+ sessionOwnerKey: session.ownerKey,
447
462
  sessionId: session.sessionId,
448
463
  runId,
449
464
  provider: record.provider,
@@ -466,13 +481,13 @@ function replayAssistantMessageWrites(manager, session, event, ctx) {
466
481
  /** Consume the next unpaired timing record for message-write trajectory replay. */
467
482
  function consumeNextTimingCandidate(manager, session, input) {
468
483
  const key = modelTimingLlmKey({
469
- sessionId: session.sessionId,
484
+ sessionId: session.ownerKey,
470
485
  runId: input.runId,
471
486
  provider: input.provider,
472
487
  model: input.model,
473
488
  });
474
489
  const records = manager.state.modelTimingsByLlmKey.get(key) ?? [];
475
- const candidate = records.find((record) => record.sessionKey === session.sessionId && !record.consumed);
490
+ const candidate = records.find((record) => record.sessionOwnerKey === session.ownerKey && !record.consumed);
476
491
  if (!candidate) {
477
492
  return undefined;
478
493
  }
@@ -482,12 +497,12 @@ function consumeNextTimingCandidate(manager, session, input) {
482
497
  /** Consume a timing candidate only when the public hook data makes it unambiguous. */
483
498
  function consumeTimingCandidate(manager, session, event) {
484
499
  const key = modelTimingLlmKey({
485
- sessionId: session.sessionId,
500
+ sessionId: session.ownerKey,
486
501
  runId: event.runId,
487
502
  provider: event.provider,
488
503
  model: event.model,
489
504
  });
490
- const candidates = (manager.state.modelTimingsByLlmKey.get(key) ?? []).filter((record) => record.sessionKey === session.sessionId && !record.consumed);
505
+ const candidates = (manager.state.modelTimingsByLlmKey.get(key) ?? []).filter((record) => record.sessionOwnerKey === session.ownerKey && !record.consumed);
491
506
  if (candidates.length === 1) {
492
507
  const candidate = candidates[0];
493
508
  if (!candidate) {
@@ -571,7 +586,7 @@ function emitModelTimingSummaryMark(manager, session, records) {
571
586
  /** Convert an OpenClaw llm_input event into the buffered request record. */
572
587
  function createInputRecord(session, event) {
573
588
  return {
574
- sessionKey: session.sessionId,
589
+ sessionOwnerKey: session.ownerKey,
575
590
  sessionId: event.sessionId,
576
591
  runId: event.runId,
577
592
  provider: event.provider,
@@ -585,15 +600,23 @@ function createInputRecord(session, event) {
585
600
  }
586
601
  /** Resolve an existing session for before_message_write without creating a fake one. */
587
602
  function existingSessionForMessageWrite(manager, event, ctx) {
588
- const key = resolveSessionKey(manager.state, {
603
+ const key = resolveSessionOwnerKey(manager.state, {
589
604
  sessionKey: event.sessionKey ?? ctx.sessionKey,
590
605
  });
591
- return key === undefined ? undefined : manager.state.sessions.get(key);
606
+ if (key === undefined) {
607
+ return undefined;
608
+ }
609
+ const session = manager.state.sessions.get(key);
610
+ const sessionKey = event.sessionKey ?? ctx.sessionKey;
611
+ if (session && session.sessionKey === undefined && sessionKey !== undefined) {
612
+ session.sessionKey = sessionKey;
613
+ }
614
+ return session;
592
615
  }
593
616
  /** Build a minimal request placeholder when only an llm_output hook is available. */
594
617
  function placeholderInputRecord(record) {
595
618
  return {
596
- sessionKey: record.sessionKey,
619
+ sessionOwnerKey: record.sessionOwnerKey,
597
620
  sessionId: record.sessionId,
598
621
  runId: record.runId,
599
622
  provider: record.provider,
@@ -658,6 +681,9 @@ function stripToolCallArgs(value) {
658
681
  stripped[key] = { stripped: true };
659
682
  }
660
683
  }
684
+ if (isRecord(stripped.function) && stripped.function.arguments !== undefined) {
685
+ stripped.function = { ...stripped.function, arguments: { stripped: true } };
686
+ }
661
687
  return stripped;
662
688
  }
663
689
  /** Strip provider thinking/signature payloads that are noisy in trace UIs. */
@@ -761,6 +787,8 @@ function extractToolCalls(message) {
761
787
  function isToolCallLike(value) {
762
788
  return (isRecord(value) &&
763
789
  (value.type === 'toolCall' ||
790
+ value.type === 'toolcall' ||
791
+ value.type === 'function_call' ||
764
792
  value.type === 'tool_use' ||
765
793
  value.type === 'tool-call' ||
766
794
  value.toolName !== undefined ||
@@ -776,13 +804,21 @@ function toolCallNames(toolCalls) {
776
804
  if (!isRecord(toolCall)) {
777
805
  continue;
778
806
  }
779
- const name = stringField(toolCall, 'name') ?? stringField(toolCall, 'toolName') ?? stringField(toolCall, 'functionName');
807
+ const name = stringField(toolCall, 'name') ??
808
+ stringField(toolCall, 'toolName') ??
809
+ stringField(toolCall, 'functionName') ??
810
+ functionToolCallName(toolCall);
780
811
  if (name) {
781
812
  names.push(name);
782
813
  }
783
814
  }
784
815
  return names;
785
816
  }
817
+ /** Read OpenAI-style nested function tool-call names. */
818
+ function functionToolCallName(toolCall) {
819
+ const fn = toolCall.function;
820
+ return isRecord(fn) ? stringField(fn, 'name') : undefined;
821
+ }
786
822
  /** Convert stored message-write usage back into the llm_output usage contract. */
787
823
  function mapHookUsage(usage) {
788
824
  const mapped = mapUsage(usage);
@@ -919,19 +955,33 @@ function incrementHookLlmOutputReplayCount(session, runId, maxRuns) {
919
955
  }
920
956
  /** Build the per-session run key used for trajectory de-duplication. */
921
957
  function trajectoryRunKey(session, runId) {
922
- return runId ?? session.sessionId;
958
+ return runId ?? session.ownerKey;
923
959
  }
924
- /** Normalize provider usage into OpenInference-friendly token/cost fields. */
960
+ /** Normalize provider usage into NeMo Relay token and cost fields. */
925
961
  function mapUsage(usage) {
926
962
  if (!isRecord(usage)) {
927
963
  return undefined;
928
964
  }
929
965
  const mapped = {};
930
- const input = numberField(usage, 'input') ?? numberField(usage, 'prompt_tokens');
931
- const output = numberField(usage, 'output') ?? numberField(usage, 'completion_tokens');
932
- const cacheRead = numberField(usage, 'cacheRead') ?? numberField(usage, 'cache_read_tokens');
933
- const cacheWrite = numberField(usage, 'cacheWrite') ?? numberField(usage, 'cache_write_tokens');
934
- const total = numberField(usage, 'total') ?? numberField(usage, 'totalTokens') ?? numberField(usage, 'total_tokens');
966
+ const input = numberField(usage, 'input') ??
967
+ numberField(usage, 'prompt_tokens') ??
968
+ numberField(usage, 'input_tokens') ??
969
+ numberField(usage, 'inputTokens');
970
+ const output = numberField(usage, 'output') ??
971
+ numberField(usage, 'completion_tokens') ??
972
+ numberField(usage, 'output_tokens') ??
973
+ numberField(usage, 'outputTokens');
974
+ const cacheRead = numberField(usage, 'cacheRead') ??
975
+ numberField(usage, 'cache_read_tokens') ??
976
+ numberField(usage, 'cache_read_input_tokens') ??
977
+ nestedNumberField(usage, 'input_tokens_details', 'cached_tokens') ??
978
+ nestedNumberField(usage, 'prompt_tokens_details', 'cached_tokens');
979
+ const cacheWrite = numberField(usage, 'cacheWrite') ??
980
+ numberField(usage, 'cache_write_tokens') ??
981
+ numberField(usage, 'cache_creation_input_tokens');
982
+ const total = numberField(usage, 'total') ??
983
+ numberField(usage, 'totalTokens') ??
984
+ numberField(usage, 'total_tokens');
935
985
  const totalCanIncludeCompletion = total === undefined || output === undefined || total >= output;
936
986
  const prompt = total !== undefined && output !== undefined && totalCanIncludeCompletion ? total - output : input;
937
987
  const totalCanIncludePrompt = total === undefined || prompt === undefined || total >= prompt;
@@ -971,6 +1021,11 @@ function numberField(record, key) {
971
1021
  const value = record[key];
972
1022
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
973
1023
  }
1024
+ /** Read a finite numeric field nested one object deep from a generic hook record. */
1025
+ function nestedNumberField(record, objectKey, fieldKey) {
1026
+ const value = record[objectKey];
1027
+ return isRecord(value) ? numberField(value, fieldKey) : undefined;
1028
+ }
974
1029
  /** Copy model_call_ended details into a retained timing record. */
975
1030
  function applyModelCallEnd(record, event, nowMs) {
976
1031
  record.observedAtMs = nowMs;
@@ -993,7 +1048,7 @@ function latestUnendedRecord(records, session) {
993
1048
  }
994
1049
  for (let index = records.length - 1; index >= 0; index -= 1) {
995
1050
  const record = records[index];
996
- if (record?.sessionKey === session.sessionId && record.endedAtMs === undefined) {
1051
+ if (record?.sessionOwnerKey === session.ownerKey && record.endedAtMs === undefined) {
997
1052
  return record;
998
1053
  }
999
1054
  }
@@ -16,6 +16,7 @@ export declare function emitMark(params: {
16
16
  session: SessionState;
17
17
  name: string;
18
18
  data: JsonRecord;
19
+ metadata?: JsonRecord | null;
19
20
  timestamp?: number;
20
21
  }): void;
21
22
  /** Return a blocked-tool event payload when OpenClaw denied the tool call. */
@@ -6,7 +6,7 @@ export function emitMark(params) {
6
6
  params.state.counters.skippedEvents += 1;
7
7
  return;
8
8
  }
9
- params.nf.event(params.name, params.session.rootHandle, params.data, null, params.timestamp ?? null);
9
+ params.nf.event(params.name, params.session.rootHandle, params.data, params.metadata ?? null, params.timestamp ?? null);
10
10
  params.state.counters.marksEmitted += 1;
11
11
  }
12
12
  /** Return a blocked-tool event payload when OpenClaw denied the tool call. */
@@ -19,11 +19,16 @@ export type SessionLookupInput = {
19
19
  };
20
20
  export type EnsureSessionInput = SessionLookupInput & {
21
21
  agentId?: string | undefined;
22
+ parentHandle?: ReturnType<NemoRelayRuntimeModule['pushScope']> | undefined;
23
+ scopeRole?: 'subagent' | undefined;
22
24
  source: 'session_start' | 'lazy_session';
23
25
  resumedFrom?: string | undefined;
24
26
  timestamp?: number | undefined;
27
+ deferRootOpen?: boolean | undefined;
25
28
  };
26
29
  export type SessionState = {
30
+ /** Immutable internal owner key used for replay buffers and alias lookups. */
31
+ ownerKey: string;
27
32
  sessionId: string;
28
33
  sessionKey?: string;
29
34
  agentId?: string;
@@ -42,9 +47,17 @@ export type SessionState = {
42
47
  assistantMessageWrites?: AssistantMessageRecord[];
43
48
  stack: ReturnType<NemoRelayRuntimeModule['createScopeStack']>;
44
49
  rootHandle?: ReturnType<NemoRelayRuntimeModule['pushScope']>;
50
+ scopeRole?: 'subagent';
51
+ pendingRootOpen?: boolean;
52
+ pendingRootTimestampMicros?: number;
53
+ pendingRootRunId?: string;
54
+ pendingCapturedEmits?: Array<{
55
+ label: string;
56
+ emit: () => void;
57
+ }>;
45
58
  };
46
59
  export type PendingLlmOutputRecord = {
47
- sessionKey: string;
60
+ sessionOwnerKey: string;
48
61
  sessionId: string;
49
62
  runId: string;
50
63
  provider: string;
@@ -55,7 +68,7 @@ export type PendingLlmOutputRecord = {
55
68
  timer?: ReturnType<typeof setTimeout> | undefined;
56
69
  };
57
70
  export type LlmInputRecord = {
58
- sessionKey: string;
71
+ sessionOwnerKey: string;
59
72
  sessionId: string;
60
73
  runId: string;
61
74
  provider: string;
@@ -68,7 +81,7 @@ export type LlmInputRecord = {
68
81
  placeholderRequest?: boolean | undefined;
69
82
  };
70
83
  export type AssistantMessageRecord = {
71
- sessionKey: string;
84
+ sessionOwnerKey: string;
72
85
  provider: string;
73
86
  model: string;
74
87
  assistantTexts: string[];
@@ -80,7 +93,7 @@ export type AssistantMessageRecord = {
80
93
  usage?: unknown;
81
94
  };
82
95
  export type ModelCallRecord = {
83
- sessionKey: string;
96
+ sessionOwnerKey: string;
84
97
  runId: string;
85
98
  callId: string;
86
99
  provider: string;
@@ -130,23 +143,26 @@ export type SessionManager = {
130
143
  }) => void;
131
144
  emitUnpairedModelCallTimingMarks: (session: SessionState) => void;
132
145
  logBoundedWarn: (key: string, message: string) => void;
146
+ resolveSessionRootContext?: (input: EnsureSessionInput) => Partial<EnsureSessionInput> | undefined;
133
147
  };
134
148
  /** Return all keys that may identify an existing OpenClaw session. */
135
149
  export declare function lookupSessionKeys(input: SessionLookupInput): string[];
136
- /** Return keys that should alias to a canonical session once it is known. */
150
+ /** Return keys that should alias to one stable internal session owner. */
137
151
  export declare function aliasSessionKeys(input: SessionLookupInput): string[];
138
- /** Resolve a hook's session identity to the canonical session id used in replay state. */
139
- export declare function resolveSessionKey(state: HookReplayBackendState, input: SessionLookupInput): string | undefined;
140
- /** Remember equivalent hook identifiers so later events attach to the same root span. */
152
+ /** Resolve a hook's session identity to the stable owner key used in replay state. */
153
+ export declare function resolveSessionOwnerKey(state: HookReplayBackendState, input: SessionLookupInput): string | undefined;
154
+ /** Remember equivalent hook identifiers so later events attach to the same owner and root span. */
141
155
  export declare function rememberSessionAliases(state: HookReplayBackendState, session: SessionState, input: SessionLookupInput): void;
142
156
  /** Create the mutable in-memory state used by the hook replay backend. */
143
157
  export declare function createHookReplayState(): HookReplayBackendState;
144
158
  /** Return an existing session or lazily create a root session scope for replay. */
145
159
  export declare function ensureSession(manager: SessionManager, input: EnsureSessionInput): SessionState | undefined;
160
+ /** Queue an emit callback until a deferred session root can open with honest lineage. */
161
+ export declare function queueCapturedEmit(session: SessionState, label: string, emit: () => void): boolean;
146
162
  /** Flush pending LLM output/timing state before the root session closes. */
147
163
  export declare function drainSession(manager: SessionManager, session: SessionState): void;
148
164
  /** Close the root session scope with separate lifecycle summary and user-visible output. */
149
- export declare function closeSessionRoot(manager: SessionManager, session: SessionState, summary: JsonRecord, rootOutput?: JsonRecord, timestamp?: number): void;
165
+ export declare function closeSessionRoot(manager: SessionManager, session: SessionState, summary: JsonRecord, rootOutput?: JsonRecord, metadata?: JsonRecord | null, timestamp?: number): void;
150
166
  /** Remove a closed session from active replay state. */
151
167
  export declare function deleteSession(state: HookReplayBackendState, session: SessionState): void;
152
168
  /** Insert a correlation record while bounding retained entries per key. */
@@ -155,3 +171,5 @@ export declare function insertBoundedRecord<T>(map: Map<string, T[]>, key: strin
155
171
  export declare function tupleKey(parts: Array<string | undefined>): string;
156
172
  /** Evict stale cross-hook correlation records across all replay maps. */
157
173
  export declare function evictExpiredCorrelationRecords(state: HookReplayBackendState, nowMs: number, ttlMs: number): void;
174
+ /** Open a deferred or new root session scope and flush queued child emissions. */
175
+ export declare function materializeSessionRoot(manager: SessionManager, session: SessionState, input: EnsureSessionInput): void;