@turingpulse/sdk 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.github/dependabot.yml +38 -0
  2. package/.github/workflows/ci.yml +246 -0
  3. package/.github/workflows/framework-compat.yml +169 -0
  4. package/.github/workflows/security.yml +336 -0
  5. package/CHANGELOG.md +29 -0
  6. package/LICENSE +13 -0
  7. package/MIGRATION.md +30 -0
  8. package/README.md +221 -0
  9. package/dist/attachments.d.ts +28 -0
  10. package/dist/attachments.d.ts.map +1 -0
  11. package/dist/attachments.js +59 -0
  12. package/dist/attachments.js.map +1 -0
  13. package/dist/config.d.ts +72 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +78 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/context.d.ts +126 -0
  18. package/dist/context.d.ts.map +1 -0
  19. package/dist/context.js +163 -0
  20. package/dist/context.js.map +1 -0
  21. package/dist/decorators.d.ts +6 -0
  22. package/dist/decorators.d.ts.map +1 -0
  23. package/dist/decorators.js +52 -0
  24. package/dist/decorators.js.map +1 -0
  25. package/dist/deploy.d.ts +89 -0
  26. package/dist/deploy.d.ts.map +1 -0
  27. package/dist/deploy.js +203 -0
  28. package/dist/deploy.js.map +1 -0
  29. package/dist/errors.d.ts +18 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +34 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/eventBuilder.d.ts +21 -0
  34. package/dist/eventBuilder.d.ts.map +1 -0
  35. package/dist/eventBuilder.js +127 -0
  36. package/dist/eventBuilder.js.map +1 -0
  37. package/dist/fingerprint.d.ts +158 -0
  38. package/dist/fingerprint.d.ts.map +1 -0
  39. package/dist/fingerprint.js +339 -0
  40. package/dist/fingerprint.js.map +1 -0
  41. package/dist/governance.d.ts +47 -0
  42. package/dist/governance.d.ts.map +1 -0
  43. package/dist/governance.js +104 -0
  44. package/dist/governance.js.map +1 -0
  45. package/dist/http.d.ts +62 -0
  46. package/dist/http.d.ts.map +1 -0
  47. package/dist/http.js +181 -0
  48. package/dist/http.js.map +1 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +23 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/instrumentation.d.ts +40 -0
  54. package/dist/instrumentation.d.ts.map +1 -0
  55. package/dist/instrumentation.js +31 -0
  56. package/dist/instrumentation.js.map +1 -0
  57. package/dist/integrations/mastra.d.ts +64 -0
  58. package/dist/integrations/mastra.d.ts.map +1 -0
  59. package/dist/integrations/mastra.js +256 -0
  60. package/dist/integrations/mastra.js.map +1 -0
  61. package/dist/kpi.d.ts +21 -0
  62. package/dist/kpi.d.ts.map +1 -0
  63. package/dist/kpi.js +83 -0
  64. package/dist/kpi.js.map +1 -0
  65. package/dist/llmDetector.d.ts +22 -0
  66. package/dist/llmDetector.d.ts.map +1 -0
  67. package/dist/llmDetector.js +269 -0
  68. package/dist/llmDetector.js.map +1 -0
  69. package/dist/plugin.d.ts +33 -0
  70. package/dist/plugin.d.ts.map +1 -0
  71. package/dist/plugin.js +312 -0
  72. package/dist/plugin.js.map +1 -0
  73. package/dist/registry.d.ts +13 -0
  74. package/dist/registry.d.ts.map +1 -0
  75. package/dist/registry.js +18 -0
  76. package/dist/registry.js.map +1 -0
  77. package/dist/tracing.d.ts +10 -0
  78. package/dist/tracing.d.ts.map +1 -0
  79. package/dist/tracing.js +30 -0
  80. package/dist/tracing.js.map +1 -0
  81. package/dist/triggerState.d.ts +5 -0
  82. package/dist/triggerState.d.ts.map +1 -0
  83. package/dist/triggerState.js +19 -0
  84. package/dist/triggerState.js.map +1 -0
  85. package/dist/utils.d.ts +27 -0
  86. package/dist/utils.d.ts.map +1 -0
  87. package/dist/utils.js +72 -0
  88. package/dist/utils.js.map +1 -0
  89. package/package.json +37 -0
  90. package/packages/anthropic/package.json +16 -0
  91. package/packages/anthropic/src/index.ts +5 -0
  92. package/packages/anthropic/src/wrapper.ts +102 -0
  93. package/packages/anthropic/tsconfig.build.json +20 -0
  94. package/packages/langchain/package.json +16 -0
  95. package/packages/langchain/src/index.ts +7 -0
  96. package/packages/langchain/src/wrapper.ts +51 -0
  97. package/packages/mastra/package.json +17 -0
  98. package/packages/mastra/src/index.ts +8 -0
  99. package/packages/mastra/src/wrapper.ts +301 -0
  100. package/packages/openai/package.json +16 -0
  101. package/packages/openai/src/index.ts +8 -0
  102. package/packages/openai/src/wrapper.ts +103 -0
  103. package/packages/openai/tsconfig.build.json +20 -0
  104. package/packages/openclaw/openclaw.plugin.json +100 -0
  105. package/packages/openclaw/package.json +41 -0
  106. package/packages/openclaw/src/buffer.ts +99 -0
  107. package/packages/openclaw/src/config.ts +139 -0
  108. package/packages/openclaw/src/hooks/governance.ts +267 -0
  109. package/packages/openclaw/src/hooks/lifecycle.ts +75 -0
  110. package/packages/openclaw/src/hooks/telemetry.ts +207 -0
  111. package/packages/openclaw/src/index.ts +91 -0
  112. package/packages/openclaw/src/mapper.ts +233 -0
  113. package/packages/openclaw/src/session-tracker.ts +181 -0
  114. package/packages/openclaw/src/types.ts +220 -0
  115. package/packages/openclaw/tests/buffer.test.ts +148 -0
  116. package/packages/openclaw/tests/config.test.ts +122 -0
  117. package/packages/openclaw/tests/governance.test.ts +232 -0
  118. package/packages/openclaw/tests/mapper.test.ts +242 -0
  119. package/packages/openclaw/tests/session-tracker.test.ts +124 -0
  120. package/packages/openclaw/tsconfig.json +18 -0
  121. package/packages/openclaw/vitest.config.ts +8 -0
  122. package/packages/vercel-ai/package.json +16 -0
  123. package/packages/vercel-ai/src/index.ts +5 -0
  124. package/packages/vercel-ai/src/wrapper.ts +49 -0
  125. package/scripts/bump-version.sh +58 -0
  126. package/scripts/update-readme-compat.mjs +151 -0
  127. package/src/__tests__/fingerprint.test.ts +328 -0
  128. package/src/attachments.ts +88 -0
  129. package/src/config.ts +164 -0
  130. package/src/context.ts +258 -0
  131. package/src/decorators.ts +61 -0
  132. package/src/deploy.ts +260 -0
  133. package/src/errors.ts +44 -0
  134. package/src/eventBuilder.ts +153 -0
  135. package/src/fingerprint.ts +421 -0
  136. package/src/governance.ts +156 -0
  137. package/src/http.ts +241 -0
  138. package/src/index.ts +57 -0
  139. package/src/instrumentation.ts +68 -0
  140. package/src/integrations/mastra.ts +335 -0
  141. package/src/kpi.ts +112 -0
  142. package/src/llmDetector.ts +330 -0
  143. package/src/plugin.ts +384 -0
  144. package/src/registry.ts +27 -0
  145. package/src/tracing.ts +39 -0
  146. package/src/triggerState.ts +27 -0
  147. package/src/utils.ts +78 -0
  148. package/tests/compat/anthropic.test.ts +61 -0
  149. package/tests/compat/cohere.test.ts +57 -0
  150. package/tests/compat/google-genai.test.ts +61 -0
  151. package/tests/compat/langchain-openai.test.ts +41 -0
  152. package/tests/compat/langchain.test.ts +64 -0
  153. package/tests/compat/mistral.test.ts +58 -0
  154. package/tests/compat/openai.test.ts +71 -0
  155. package/tests/compat/vercel-ai.test.ts +56 -0
  156. package/tests/plugins/anthropic-wrapper.test.ts +120 -0
  157. package/tests/plugins/langchain-wrapper.test.ts +128 -0
  158. package/tests/plugins/openai-wrapper.test.ts +165 -0
  159. package/tsconfig.json +21 -0
  160. package/vitest.config.ts +9 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * TuringPulse plugin for OpenClaw.
3
+ *
4
+ * Provides deterministic governance, observability, evaluations, and alerting
5
+ * for OpenClaw agents. Governance rules are enforced OUTSIDE the LLM's
6
+ * decision loop — if a policy says block, the tool call is cancelled
7
+ * regardless of the LLM's intent.
8
+ *
9
+ * Installation:
10
+ * openclaw plugins install @turingpulse/sdk-openclaw
11
+ *
12
+ * Configuration (in openclaw.json):
13
+ * {
14
+ * "plugins": {
15
+ * "entries": {
16
+ * "turingpulse": {
17
+ * "enabled": true,
18
+ * "config": {
19
+ * "apiKey": "tp-...",
20
+ * "governance": { "enabled": true },
21
+ * "telemetry": { "enabled": true }
22
+ * }
23
+ * }
24
+ * }
25
+ * }
26
+ * }
27
+ *
28
+ * Status command:
29
+ * /turingpulse — shows plugin health, active sessions, pending events
30
+ */
31
+
32
+ import { TuringPulseHttpClient, TuringPulseConfig } from '@turingpulse/sdk';
33
+ import { resolveConfig } from './config.js';
34
+ import { EventBuffer } from './buffer.js';
35
+ import { SessionTracker } from './session-tracker.js';
36
+ import { registerGovernanceHooks } from './hooks/governance.js';
37
+ import { registerTelemetryHooks } from './hooks/telemetry.js';
38
+ import { registerLifecycleHooks } from './hooks/lifecycle.js';
39
+ import type { OpenClawPluginAPI } from './types.js';
40
+
41
+ /**
42
+ * OpenClaw plugin entry point. Called by the OpenClaw Gateway when
43
+ * the plugin is loaded. Receives the plugin API for hook registration.
44
+ */
45
+ export default function register(api: OpenClawPluginAPI): void {
46
+ let config;
47
+ try {
48
+ config = resolveConfig(api.config);
49
+ } catch (err) {
50
+ // eslint-disable-next-line no-console
51
+ console.error(
52
+ '[turingpulse] Configuration error:',
53
+ err instanceof Error ? err.message : String(err),
54
+ );
55
+ return;
56
+ }
57
+
58
+ const sdkConfig = new TuringPulseConfig({
59
+ apiKey: config.apiKey,
60
+ endpoint: config.endpoint,
61
+ workflowName: 'openclaw',
62
+ timeoutMs: config.governance.timeoutMs,
63
+ maxRetries: 2,
64
+ });
65
+
66
+ const client = new TuringPulseHttpClient(sdkConfig);
67
+ const buffer = new EventBuffer(
68
+ client,
69
+ config.telemetry.batchSize,
70
+ config.telemetry.flushIntervalMs,
71
+ );
72
+ const sessionTracker = new SessionTracker();
73
+
74
+ registerGovernanceHooks(api, client, config, sessionTracker);
75
+ registerTelemetryHooks(api, config, buffer, sessionTracker);
76
+ registerLifecycleHooks(api, config, buffer, sessionTracker);
77
+ }
78
+
79
+ export { resolveConfig } from './config.js';
80
+ export type { TuringPulseOpenClawConfig, GovernanceConfig, TelemetryConfig } from './config.js';
81
+ export type {
82
+ OpenClawPluginAPI,
83
+ ToolPreContext,
84
+ ToolPostContext,
85
+ MessageReceivedContext,
86
+ MessageSentContext,
87
+ AgentPreContext,
88
+ AgentPostContext,
89
+ } from './types.js';
90
+ export { EventBuffer } from './buffer.js';
91
+ export { SessionTracker } from './session-tracker.js';
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Maps OpenClaw hook context objects to TuringPulse AgentEvent format.
3
+ *
4
+ * All events emitted by the plugin flow through this module to ensure
5
+ * consistent structure, metadata enrichment, and content redaction.
6
+ */
7
+
8
+ import type { AgentEvent, AgentEventPayload, ToolCallInfo } from '@turingpulse/interfaces';
9
+ import type { TuringPulseOpenClawConfig } from './config.js';
10
+ import type { SessionState } from './session-tracker.js';
11
+ import type { ToolPostContext, AgentPostContext } from './types.js';
12
+
13
+ const FRAMEWORK_NAME = 'openclaw';
14
+ const MAX_FIELD_LENGTH = 50_000;
15
+
16
+ /**
17
+ * Build the root workflow span for a complete interaction
18
+ * (message_received → message_sent).
19
+ */
20
+ export function buildRootSpan(
21
+ session: SessionState,
22
+ config: TuringPulseOpenClawConfig,
23
+ opts: {
24
+ inputText?: string;
25
+ outputText?: string;
26
+ status: string;
27
+ error?: string;
28
+ },
29
+ ): AgentEvent {
30
+ const durationMs = Date.now() - session.startedAt;
31
+
32
+ const metadata: Record<string, unknown> = {
33
+ span_id: session.rootSpanId,
34
+ trace_id: session.traceId,
35
+ node_type: 'workflow',
36
+ framework: FRAMEWORK_NAME,
37
+ workflow_name: session.agentId ?? 'openclaw-agent',
38
+ channel: session.channelId,
39
+ tool_call_count: String(session.toolCallCount),
40
+ ...config.metadata,
41
+ };
42
+
43
+ if (opts.inputText && config.telemetry.captureMessageContent) {
44
+ metadata.input = redact(truncate(opts.inputText), config);
45
+ }
46
+ if (opts.outputText && config.telemetry.captureMessageContent) {
47
+ metadata.output = redact(truncate(opts.outputText), config);
48
+ }
49
+ if (opts.error) {
50
+ metadata.error_message = truncate(opts.error, 500);
51
+ }
52
+
53
+ return {
54
+ runId: session.traceId,
55
+ agentId: session.agentId ?? 'openclaw-agent',
56
+ timestamp: new Date(session.startedAt).toISOString(),
57
+ type: 'span',
58
+ payload: {
59
+ name: `${session.agentId ?? 'openclaw-agent'}.execute`,
60
+ durationMs,
61
+ status: opts.status,
62
+ tokens: {
63
+ prompt: session.tokens.prompt,
64
+ completion: session.tokens.completion,
65
+ },
66
+ costUsd: session.costUsd,
67
+ metadata,
68
+ },
69
+ workflowName: session.agentId ?? 'openclaw-agent',
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Build a child span for a tool execution (from tool.post context).
75
+ */
76
+ export function buildToolSpan(
77
+ ctx: ToolPostContext,
78
+ session: SessionState,
79
+ spanId: string,
80
+ config: TuringPulseOpenClawConfig,
81
+ ): AgentEvent {
82
+ const status = ctx.error ? 'error' : 'success';
83
+ const durationMs = ctx.durationMs ?? 0;
84
+
85
+ const metadata: Record<string, unknown> = {
86
+ span_id: spanId,
87
+ parent_span_id: session.rootSpanId,
88
+ trace_id: session.traceId,
89
+ node_type: 'tool',
90
+ framework: FRAMEWORK_NAME,
91
+ workflow_name: session.agentId ?? 'openclaw-agent',
92
+ depth: '1',
93
+ tool_name: ctx.toolName,
94
+ channel: session.channelId,
95
+ ...config.metadata,
96
+ };
97
+
98
+ if (config.telemetry.captureToolParams) {
99
+ metadata.input = redact(
100
+ truncate(JSON.stringify(ctx.toolParams)),
101
+ config,
102
+ );
103
+ }
104
+
105
+ const resultStr = ctx.result != null ? String(ctx.result) : '';
106
+ if (resultStr.length > 0) {
107
+ metadata.output = redact(truncate(resultStr), config);
108
+ }
109
+
110
+ if (ctx.error) {
111
+ metadata.error_message = truncate(ctx.error.message, 500);
112
+ }
113
+
114
+ const toolCalls: ToolCallInfo[] = [
115
+ {
116
+ toolName: ctx.toolName,
117
+ toolArgs: config.telemetry.captureToolParams ? ctx.toolParams : {},
118
+ toolResult: truncate(resultStr),
119
+ success: !ctx.error,
120
+ errorMessage: ctx.error?.message,
121
+ },
122
+ ];
123
+
124
+ return {
125
+ runId: session.traceId,
126
+ agentId: `tool_${ctx.toolName}`,
127
+ timestamp: new Date().toISOString(),
128
+ type: 'span',
129
+ payload: {
130
+ name: `${session.agentId ?? 'openclaw-agent'}.tool_${ctx.toolName}`,
131
+ durationMs,
132
+ status,
133
+ metadata,
134
+ toolCalls,
135
+ },
136
+ workflowName: session.agentId ?? 'openclaw-agent',
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Build a child span for an agent reasoning turn (from agent.post context).
142
+ */
143
+ export function buildAgentReasoningSpan(
144
+ ctx: AgentPostContext,
145
+ session: SessionState,
146
+ spanId: string,
147
+ config: TuringPulseOpenClawConfig,
148
+ ): AgentEvent {
149
+ const status = ctx.error ? 'error' : 'success';
150
+ const durationMs = ctx.durationMs ?? 0;
151
+
152
+ const promptTokens = ctx.usage?.promptTokens ?? 0;
153
+ const completionTokens = ctx.usage?.completionTokens ?? 0;
154
+
155
+ const metadata: Record<string, unknown> = {
156
+ span_id: spanId,
157
+ parent_span_id: session.rootSpanId,
158
+ trace_id: session.traceId,
159
+ node_type: 'llm',
160
+ framework: FRAMEWORK_NAME,
161
+ workflow_name: session.agentId ?? 'openclaw-agent',
162
+ depth: '1',
163
+ model: ctx.model ?? 'unknown',
164
+ provider: inferProvider(ctx.model),
165
+ channel: session.channelId,
166
+ ...config.metadata,
167
+ };
168
+
169
+ if (ctx.error) {
170
+ metadata.error_message = truncate(ctx.error.message, 500);
171
+ }
172
+
173
+ const payload: AgentEventPayload = {
174
+ name: `${session.agentId ?? 'openclaw-agent'}.agent_reasoning`,
175
+ durationMs,
176
+ status,
177
+ metadata,
178
+ };
179
+
180
+ if (promptTokens > 0 || completionTokens > 0) {
181
+ payload.tokens = { prompt: promptTokens, completion: completionTokens };
182
+ }
183
+
184
+ return {
185
+ runId: session.traceId,
186
+ agentId: 'agent_reasoning',
187
+ timestamp: new Date().toISOString(),
188
+ type: 'span',
189
+ payload,
190
+ workflowName: session.agentId ?? 'openclaw-agent',
191
+ };
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Helpers
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function truncate(value: string, maxLen: number = MAX_FIELD_LENGTH): string {
199
+ if (value.length <= maxLen) return value;
200
+ return value.slice(0, maxLen) + '...[truncated]';
201
+ }
202
+
203
+ /**
204
+ * Apply redaction patterns to a string. Replaces matches with [REDACTED].
205
+ */
206
+ function redact(value: string, config: TuringPulseOpenClawConfig): string {
207
+ if (config.telemetry.redactPatterns.length === 0) return value;
208
+
209
+ let result = value;
210
+ for (const pattern of config.telemetry.redactPatterns) {
211
+ try {
212
+ result = result.replace(new RegExp(pattern, 'g'), '[REDACTED]');
213
+ } catch {
214
+ // invalid regex — skip
215
+ }
216
+ }
217
+ return result;
218
+ }
219
+
220
+ /**
221
+ * Best-effort provider inference from model string.
222
+ * OpenClaw uses "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").
223
+ */
224
+ function inferProvider(model?: string): string {
225
+ if (!model) return 'unknown';
226
+ const slash = model.indexOf('/');
227
+ if (slash > 0) return model.slice(0, slash);
228
+ if (model.startsWith('claude')) return 'anthropic';
229
+ if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) return 'openai';
230
+ if (model.startsWith('gemini')) return 'google';
231
+ if (model.startsWith('mistral')) return 'mistral';
232
+ return 'unknown';
233
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Tracks per-session state across OpenClaw lifecycle hooks.
3
+ *
4
+ * Each user interaction (message_received → tool calls → message_sent)
5
+ * creates a session entry that accumulates trace data, child spans,
6
+ * and cost metrics. Sessions are identified by OpenClaw's sessionKey.
7
+ *
8
+ * Uses LRU eviction to bound memory for long-running gateways.
9
+ */
10
+
11
+ import type { AgentEvent } from '@turingpulse/interfaces';
12
+
13
+ export interface SessionState {
14
+ /** Trace ID for this interaction (generated on message_received). */
15
+ traceId: string;
16
+ /** Root span ID for the top-level workflow span. */
17
+ rootSpanId: string;
18
+ /** Timestamp when the interaction started. */
19
+ startedAt: number;
20
+ /** Accumulated child span events during this interaction. */
21
+ childEvents: AgentEvent[];
22
+ /** Chat channel this session originates from. */
23
+ channelId?: string;
24
+ /** OpenClaw agent handling this session. */
25
+ agentId?: string;
26
+ /** Cumulative token counts for this interaction. */
27
+ tokens: { prompt: number; completion: number };
28
+ /** Cumulative estimated cost in USD. */
29
+ costUsd: number;
30
+ /** Number of tool calls in this interaction. */
31
+ toolCallCount: number;
32
+ /** Depth counter for nested spans. */
33
+ currentDepth: number;
34
+ }
35
+
36
+ const DEFAULT_MAX_SESSIONS = 500;
37
+ const DEFAULT_STALE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
38
+
39
+ export class SessionTracker {
40
+ private readonly sessions = new Map<string, SessionState>();
41
+ private readonly accessOrder: string[] = [];
42
+ private readonly maxSessions: number;
43
+ private readonly staleTimeoutMs: number;
44
+
45
+ constructor(maxSessions?: number, staleTimeoutMs?: number) {
46
+ this.maxSessions = maxSessions ?? DEFAULT_MAX_SESSIONS;
47
+ this.staleTimeoutMs = staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
48
+ }
49
+
50
+ /**
51
+ * Create a new session entry for an incoming interaction.
52
+ * Returns the new session state with generated trace/span IDs.
53
+ */
54
+ start(
55
+ sessionKey: string,
56
+ traceId: string,
57
+ rootSpanId: string,
58
+ channelId?: string,
59
+ agentId?: string,
60
+ ): SessionState {
61
+ this.evictStale();
62
+
63
+ const state: SessionState = {
64
+ traceId,
65
+ rootSpanId,
66
+ startedAt: Date.now(),
67
+ childEvents: [],
68
+ channelId,
69
+ agentId,
70
+ tokens: { prompt: 0, completion: 0 },
71
+ costUsd: 0,
72
+ toolCallCount: 0,
73
+ currentDepth: 0,
74
+ };
75
+
76
+ this.sessions.set(sessionKey, state);
77
+ this.touchAccessOrder(sessionKey);
78
+ this.enforceCapacity();
79
+
80
+ return state;
81
+ }
82
+
83
+ /**
84
+ * Retrieve the active session state. Returns undefined if no session
85
+ * is active for this key (e.g., message arrived without message_received hook).
86
+ */
87
+ get(sessionKey: string): SessionState | undefined {
88
+ const state = this.sessions.get(sessionKey);
89
+ if (state) {
90
+ this.touchAccessOrder(sessionKey);
91
+ }
92
+ return state;
93
+ }
94
+
95
+ /**
96
+ * Add a child span event to the session. Used by telemetry hooks
97
+ * to accumulate tool call and agent reasoning spans.
98
+ */
99
+ addChildEvent(sessionKey: string, event: AgentEvent): void {
100
+ const state = this.sessions.get(sessionKey);
101
+ if (!state) return;
102
+ state.childEvents.push(event);
103
+ }
104
+
105
+ /**
106
+ * Update cumulative metrics for the session.
107
+ */
108
+ addMetrics(
109
+ sessionKey: string,
110
+ metrics: {
111
+ promptTokens?: number;
112
+ completionTokens?: number;
113
+ costUsd?: number;
114
+ toolCalls?: number;
115
+ },
116
+ ): void {
117
+ const state = this.sessions.get(sessionKey);
118
+ if (!state) return;
119
+
120
+ state.tokens.prompt += metrics.promptTokens ?? 0;
121
+ state.tokens.completion += metrics.completionTokens ?? 0;
122
+ state.costUsd += metrics.costUsd ?? 0;
123
+ state.toolCallCount += metrics.toolCalls ?? 0;
124
+ }
125
+
126
+ /**
127
+ * Finalize and remove a session, returning all accumulated state.
128
+ * Called on message_sent to flush the complete interaction trace.
129
+ */
130
+ finish(sessionKey: string): SessionState | undefined {
131
+ const state = this.sessions.get(sessionKey);
132
+ if (!state) return undefined;
133
+
134
+ this.sessions.delete(sessionKey);
135
+ const idx = this.accessOrder.indexOf(sessionKey);
136
+ if (idx !== -1) this.accessOrder.splice(idx, 1);
137
+
138
+ return state;
139
+ }
140
+
141
+ /** Number of active sessions (for health/status reporting). */
142
+ get size(): number {
143
+ return this.sessions.size;
144
+ }
145
+
146
+ /**
147
+ * Remove sessions that have been idle beyond the stale timeout.
148
+ * Prevents unbounded memory growth from abandoned sessions.
149
+ */
150
+ private evictStale(): void {
151
+ const now = Date.now();
152
+ const toRemove: string[] = [];
153
+
154
+ for (const [key, state] of this.sessions) {
155
+ if (now - state.startedAt > this.staleTimeoutMs) {
156
+ toRemove.push(key);
157
+ }
158
+ }
159
+
160
+ for (const key of toRemove) {
161
+ this.sessions.delete(key);
162
+ const idx = this.accessOrder.indexOf(key);
163
+ if (idx !== -1) this.accessOrder.splice(idx, 1);
164
+ }
165
+ }
166
+
167
+ /** Evict least-recently-used sessions if capacity is exceeded. */
168
+ private enforceCapacity(): void {
169
+ while (this.sessions.size > this.maxSessions && this.accessOrder.length > 0) {
170
+ const oldest = this.accessOrder.shift();
171
+ if (oldest) this.sessions.delete(oldest);
172
+ }
173
+ }
174
+
175
+ /** Move a session key to the end of the access order (most recent). */
176
+ private touchAccessOrder(key: string): void {
177
+ const idx = this.accessOrder.indexOf(key);
178
+ if (idx !== -1) this.accessOrder.splice(idx, 1);
179
+ this.accessOrder.push(key);
180
+ }
181
+ }