ocuclaw 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.
@@ -0,0 +1,1977 @@
1
+ import * as fs from "node:fs";
2
+ import * as http from "node:http";
3
+ import * as path from "node:path";
4
+ import * as conversationStateModule from "../domain/conversation-state.js";
5
+ import { createDebugStore } from "../domain/debug-store.js";
6
+ import { composeReadabilitySystemPrompt } from "../domain/readability-system-prompt.js";
7
+ import { createActivityStatusAdapter } from "../domain/activity-status-adapter.js";
8
+ import { createEvenAiEndpoint } from "../even-ai/even-ai-endpoint.js";
9
+ import { createEvenAiRouter } from "../even-ai/even-ai-router.js";
10
+ import { createEvenAiRunWaiter } from "../even-ai/even-ai-run-waiter.js";
11
+ import { createEvenAiSettingsStore } from "../even-ai/even-ai-settings-store.js";
12
+ import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
13
+ import { createPluginRpcGatewayBridge } from "../gateway/gateway-bridge.js";
14
+ import { createDownstreamHandler } from "./downstream-handler.js";
15
+ import { createDownstreamServer } from "./downstream-server.js";
16
+ import { createOcuClawSettingsStore } from "./ocuclaw-settings-store.js";
17
+ import { createSessionService } from "./session-service.js";
18
+ import { createUpstreamRuntime } from "./upstream-runtime.js";
19
+
20
+ const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
21
+ const SONIOX_MODELS_URL = "https://api.soniox.com/v1/models";
22
+ const DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS = 3600;
23
+ const EVEN_AI_HANDLED = Symbol.for("ocuclaw.evenai.handled");
24
+ const EVEN_AI_NAMESPACE_PREFIX = "ocuclaw:even-ai";
25
+ const EVEN_AI_NAMESPACE_PREFIX_WITH_DELIMITER = "ocuclaw:even-ai:";
26
+ const LISTEN_INTERCEPT_RECOVERY_ERROR = "Voice interrupted; retry";
27
+ const LISTEN_INTERCEPT_RECOVERY_CODE = "transport_interrupted";
28
+
29
+ function normalizeLogger(logger) {
30
+ if (!logger || typeof logger !== "object") {
31
+ return console;
32
+ }
33
+ return {
34
+ info: typeof logger.info === "function" ? logger.info.bind(logger) : console.log,
35
+ warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : console.warn,
36
+ error: typeof logger.error === "function" ? logger.error.bind(logger) : console.error,
37
+ debug:
38
+ typeof logger.debug === "function" ? logger.debug.bind(logger) : console.debug,
39
+ };
40
+ }
41
+
42
+ function pickTrimmedString(...values) {
43
+ for (const value of values) {
44
+ if (typeof value !== "string") continue;
45
+ const trimmed = value.trim();
46
+ if (trimmed) return trimmed;
47
+ }
48
+ return "";
49
+ }
50
+
51
+ function tailForLog(value, maxChars = 160) {
52
+ const text =
53
+ typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
54
+ if (text.length <= maxChars) return text;
55
+ return text.slice(-maxChars);
56
+ }
57
+
58
+ function normalizeEvenAiSessionKeyForLookup(rawKey) {
59
+ if (typeof rawKey !== "string") return "";
60
+ const rawText = rawKey.trim();
61
+ if (!rawText) return "";
62
+ const rawTextLower = rawText.toLowerCase();
63
+ const prefixIndex = rawTextLower.indexOf("ocuclaw:");
64
+ const shortKey = prefixIndex >= 0 ? rawText.slice(prefixIndex) : rawText;
65
+ const shortKeyLower = shortKey.toLowerCase();
66
+ if (
67
+ shortKeyLower === EVEN_AI_NAMESPACE_PREFIX ||
68
+ shortKeyLower.startsWith(EVEN_AI_NAMESPACE_PREFIX_WITH_DELIMITER)
69
+ ) {
70
+ return shortKey;
71
+ }
72
+ return "";
73
+ }
74
+
75
+ function dedupeNormalizedSessionKeys(sessionKeys) {
76
+ if (!Array.isArray(sessionKeys) || sessionKeys.length === 0) {
77
+ return [];
78
+ }
79
+ const dedupe = new Set();
80
+ const normalized = [];
81
+ for (const rawKey of sessionKeys) {
82
+ const normalizedKey = normalizeEvenAiSessionKeyForLookup(rawKey);
83
+ if (!normalizedKey) continue;
84
+ const compareKey = normalizedKey.toLowerCase();
85
+ if (dedupe.has(compareKey)) continue;
86
+ dedupe.add(compareKey);
87
+ normalized.push(normalizedKey);
88
+ }
89
+ return normalized;
90
+ }
91
+
92
+ function parseExpiryMs(raw, nowMs) {
93
+ if (raw === undefined || raw === null || raw === "") return null;
94
+ if (typeof raw === "number") {
95
+ if (!Number.isFinite(raw) || raw <= 0) return null;
96
+ return raw > 10_000_000_000 ? Math.floor(raw) : nowMs + Math.floor(raw * 1000);
97
+ }
98
+ if (typeof raw === "string") {
99
+ const trimmed = raw.trim();
100
+ if (!trimmed) return null;
101
+ const numeric = Number(trimmed);
102
+ if (Number.isFinite(numeric)) {
103
+ return parseExpiryMs(numeric, nowMs);
104
+ }
105
+ const parsed = Date.parse(trimmed);
106
+ return Number.isFinite(parsed) ? Math.floor(parsed) : null;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function normalizeSonioxTemporaryKeyResult(result, voiceSessionId, nowMs = Date.now()) {
112
+ const temporaryKey = pickTrimmedString(
113
+ result && result.temporaryKey,
114
+ result && result.temporary_key,
115
+ result && result.key,
116
+ result && result.apiKey,
117
+ result && result.api_key,
118
+ );
119
+ if (!temporaryKey) {
120
+ throw new Error("Soniox temporary-key response missing temporaryKey");
121
+ }
122
+
123
+ const expiresAtMs =
124
+ parseExpiryMs(
125
+ result && (
126
+ result.expiresAtMs ??
127
+ result.expires_at_ms ??
128
+ result.expiresAt ??
129
+ result.expires_at
130
+ ),
131
+ nowMs,
132
+ ) ??
133
+ (
134
+ Number.isFinite(result && result.expiresInSeconds)
135
+ ? nowMs + Math.floor(result.expiresInSeconds * 1000)
136
+ : null
137
+ ) ??
138
+ (
139
+ Number.isFinite(result && result.expires_in_seconds)
140
+ ? nowMs + Math.floor(result.expires_in_seconds * 1000)
141
+ : null
142
+ );
143
+
144
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) {
145
+ throw new Error("Soniox temporary-key response missing expiresAtMs");
146
+ }
147
+
148
+ return {
149
+ voiceSessionId,
150
+ temporaryKey,
151
+ expiresAtMs: Math.floor(expiresAtMs),
152
+ };
153
+ }
154
+
155
+ function normalizeSonioxTemporaryKeyErrorCode(err) {
156
+ const message =
157
+ err && typeof err.message === "string" && err.message.trim()
158
+ ? err.message.trim()
159
+ : "";
160
+ const lowered = message.toLowerCase();
161
+ if (!message) return "soniox_temp_key_request_failed";
162
+ if (lowered.includes("api key is not configured")) {
163
+ return "soniox_temp_key_not_configured";
164
+ }
165
+ if (lowered.includes("fetch is not available")) {
166
+ return "soniox_temp_key_fetch_unavailable";
167
+ }
168
+ if (lowered.includes("temporary-key response missing")) {
169
+ return "soniox_temp_key_invalid_response";
170
+ }
171
+ if (lowered.includes("voicesessionid is required")) {
172
+ return "soniox_temp_key_invalid_request";
173
+ }
174
+ const statusMatch = lowered.match(/\((\d{3})\)/);
175
+ if (statusMatch) {
176
+ return `soniox_temp_key_http_${statusMatch[1]}`;
177
+ }
178
+ return "soniox_temp_key_request_failed";
179
+ }
180
+
181
+ function normalizeSonioxModelEntryRows(result) {
182
+ const rows =
183
+ result &&
184
+ typeof result === "object" &&
185
+ Array.isArray(result.models)
186
+ ? result.models
187
+ : [];
188
+ const models = [];
189
+ const seenIds = new Set();
190
+ for (const row of rows) {
191
+ if (!row || typeof row !== "object") continue;
192
+ const transcriptionMode =
193
+ typeof row.transcription_mode === "string"
194
+ ? row.transcription_mode.trim()
195
+ : "";
196
+ if (transcriptionMode !== "real_time") continue;
197
+ if (row.aliased_model_id !== undefined && row.aliased_model_id !== null) {
198
+ const aliasedModelId = pickTrimmedString(row.aliased_model_id);
199
+ if (aliasedModelId) continue;
200
+ }
201
+ const id = pickTrimmedString(row.id);
202
+ if (!id || seenIds.has(id)) continue;
203
+ seenIds.add(id);
204
+ models.push({
205
+ id,
206
+ name: pickTrimmedString(row.name) || id,
207
+ supportsMaxEndpointDelay: row.supports_max_endpoint_delay === true,
208
+ });
209
+ }
210
+ return models;
211
+ }
212
+
213
+ function createOwnedRelayHttpServer() {
214
+ return http.createServer((_req, res) => {
215
+ if (res.writableEnded || res[EVEN_AI_HANDLED]) {
216
+ return;
217
+ }
218
+ res.statusCode = 404;
219
+ res.setHeader("content-type", "text/plain; charset=utf-8");
220
+ res.end("not found");
221
+ });
222
+ }
223
+
224
+ function listenOwnedRelayHttpServer(httpServer, host, port) {
225
+ if (!httpServer || httpServer.listening) {
226
+ return;
227
+ }
228
+ httpServer.listen(port, host);
229
+ }
230
+
231
+ function closeOwnedRelayHttpServer(httpServer) {
232
+ if (!httpServer || !httpServer.listening) {
233
+ return Promise.resolve();
234
+ }
235
+ return new Promise((resolve) => {
236
+ httpServer.close(() => resolve());
237
+ });
238
+ }
239
+
240
+ // --- Factory ---
241
+
242
+ /**
243
+ * Create the relay orchestrator.
244
+ *
245
+ * Wires the upstream gateway bridge to downstream clients via the
246
+ * conversation-state module, downstream handler, and downstream server.
247
+ * This is the only module that knows about both sides.
248
+ *
249
+ * @param {object} opts
250
+ * @param {number} opts.port - WebSocket server port
251
+ * @param {string} opts.host - WebSocket server bind address
252
+ * @param {string} opts.token - Authentication token for downstream clients
253
+ * @param {object} [opts.gatewayBridge] - Override bridge for testing/integration
254
+ * @param {object} [opts.openclawClient] - Override for testing (default: plugin gateway client)
255
+ * @param {object} [opts.conversationState] - Override for testing (default: require singleton)
256
+ * @param {object} [opts.logger] - Structured logger for shared runtime logs
257
+ * @param {string|null} [opts.consoleLogPath] - Optional shim-only browser console sink path
258
+ * @returns {object} Relay instance with start(), stop(), server
259
+ */
260
+ function createRelay(opts) {
261
+ const logger = normalizeLogger(opts.logger);
262
+ const externalDebugToolsEnabled = opts.externalDebugToolsEnabled !== false;
263
+ const openclawClient =
264
+ opts.openclawClient ||
265
+ (opts.gatewayBridge
266
+ ? null
267
+ : createPluginOpenclawClient({
268
+ gatewayUrl: opts.gatewayUrl,
269
+ gatewayToken: opts.gatewayToken,
270
+ logger,
271
+ stateDir: opts.stateDir,
272
+ }));
273
+ if (openclawClient && typeof openclawClient.setLogger === "function") {
274
+ openclawClient.setLogger(logger);
275
+ }
276
+ const gatewayBridge =
277
+ opts.gatewayBridge ||
278
+ createPluginRpcGatewayBridge({
279
+ openclawClient,
280
+ });
281
+ const conversationState =
282
+ opts.conversationState || conversationStateModule;
283
+ const activityStatusAdapter = createActivityStatusAdapter(
284
+ opts.activityStatusAdapter,
285
+ );
286
+ const ownedHttpServer =
287
+ !opts.httpServer && opts.evenAiEnabled === true ? createOwnedRelayHttpServer() : null;
288
+ const sharedHttpServer = opts.httpServer || ownedHttpServer || null;
289
+
290
+ // --- Cached state ---
291
+
292
+ /** @type {string|null} Last formatted pages JSON string. */
293
+ let cachedPages = null;
294
+ /** Monotonic pages snapshot revision used for resume handshake. */
295
+ let pagesRevision = 0;
296
+
297
+ /** @type {string|null} Last formatted status JSON string. */
298
+ let cachedStatus = null;
299
+ /** Monotonic status snapshot revision used for resume handshake. */
300
+ let statusRevision = 0;
301
+
302
+ /** Relay-local deterministic simulate-stream run sequence counter. */
303
+ let simulateStreamRunSeq = 0;
304
+ /** Active timers for relay-local deterministic simulate-stream runs. */
305
+ const simulateStreamTimers = new Set();
306
+
307
+ // --- Structured debug state ---
308
+
309
+ const debugStore = createDebugStore({
310
+ categories: opts.debugCategories,
311
+ capacity: opts.debugCapacity,
312
+ payloadMaxBytes: opts.debugPayloadMaxBytes,
313
+ defaultTtlMs: opts.debugDefaultTtlMs,
314
+ maxTtlMs: opts.debugMaxTtlMs,
315
+ dumpDefaultLimit: opts.debugDumpDefaultLimit,
316
+ dumpMaxLimit: opts.debugDumpMaxLimit,
317
+ now: opts.debugNow,
318
+ noisyPolicies: opts.debugNoisyPolicies,
319
+ });
320
+
321
+ // --- Console log file ---
322
+
323
+ const consoleLogPath =
324
+ typeof opts.consoleLogPath === "string" && opts.consoleLogPath.trim()
325
+ ? opts.consoleLogPath
326
+ : null;
327
+ // Clear the shim-only browser console sink on startup.
328
+ if (consoleLogPath) {
329
+ try {
330
+ fs.writeFileSync(consoleLogPath, "");
331
+ } catch {}
332
+ }
333
+ const CONSOLE_LOG_MAX_LINES = 500;
334
+ const CONSOLE_LOG_TRIM_TO = 250;
335
+
336
+ /**
337
+ * Append a browser console message to the log file.
338
+ * Trims the file when it exceeds CONSOLE_LOG_MAX_LINES.
339
+ *
340
+ * @param {string} level - "log", "warn", or "error"
341
+ * @param {string} message - Console message text
342
+ */
343
+ function writeConsoleLog(level, message) {
344
+ if (!consoleLogPath) {
345
+ logger.debug(`[browser:${level}] ${message}`);
346
+ return;
347
+ }
348
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
349
+ const line = `[${timestamp}] [${level}] ${message}\n`;
350
+ try {
351
+ fs.appendFileSync(consoleLogPath, line);
352
+ // Trim if too large
353
+ const content = fs.readFileSync(consoleLogPath, "utf8");
354
+ const lines = content.split("\n");
355
+ if (lines.length > CONSOLE_LOG_MAX_LINES) {
356
+ const trimmed = lines.slice(-CONSOLE_LOG_TRIM_TO).join("\n");
357
+ fs.writeFileSync(consoleLogPath, trimmed);
358
+ }
359
+ } catch (err) {
360
+ logger.error(`[relay] Console log write failed: ${err.message}`);
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Emit a structured debug event when a category is enabled.
366
+ * This keeps the disabled path cheap by avoiding payload construction.
367
+ *
368
+ * @param {string} cat
369
+ * @param {string} event
370
+ * @param {"debug"|"info"|"warn"|"error"} severity
371
+ * @param {object} context
372
+ * @param {() => object} buildData
373
+ */
374
+ function emitDebug(cat, event, severity, context, buildData) {
375
+ if (!debugStore.isEnabled(cat)) return;
376
+
377
+ let data = {};
378
+ if (typeof buildData === "function") {
379
+ try {
380
+ data = buildData() || {};
381
+ } catch (err) {
382
+ data = { buildError: err.message || String(err) };
383
+ }
384
+ }
385
+
386
+ const payload = {
387
+ cat,
388
+ event,
389
+ severity,
390
+ data,
391
+ };
392
+
393
+ if (context && context.sessionKey) payload.sessionKey = context.sessionKey;
394
+ if (context && context.runId) payload.runId = context.runId;
395
+ if (context && context.screen) payload.screen = context.screen;
396
+
397
+ debugStore.emit(payload);
398
+ }
399
+
400
+ function scheduleSimulateStreamTimer(delayMs, callback) {
401
+ const timer = setTimeout(() => {
402
+ simulateStreamTimers.delete(timer);
403
+ try {
404
+ callback();
405
+ } catch (err) {
406
+ logger.error(`[relay] simulate-stream timer failed: ${err.message}`);
407
+ }
408
+ }, delayMs);
409
+ simulateStreamTimers.add(timer);
410
+ return timer;
411
+ }
412
+
413
+ function clearSimulateStreamTimers() {
414
+ for (const timer of simulateStreamTimers) {
415
+ clearTimeout(timer);
416
+ }
417
+ simulateStreamTimers.clear();
418
+ }
419
+
420
+ function resetActivityStatusAdapter() {
421
+ activityStatusAdapter.reset();
422
+ }
423
+
424
+ const configuredSonioxApiKey =
425
+ opts.sonioxApiKey !== undefined
426
+ ? opts.sonioxApiKey
427
+ : (opts.config && opts.config.sonioxApiKey) || "";
428
+ const sonioxTemporaryKeyExpiresInSeconds = Number.isFinite(
429
+ opts.sonioxTemporaryKeyExpiresInSeconds,
430
+ )
431
+ ? Math.max(30, Math.floor(opts.sonioxTemporaryKeyExpiresInSeconds))
432
+ : DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS;
433
+ /** @type {Array<{id: string, name: string, supportsMaxEndpointDelay: boolean}>|null} */
434
+ let cachedSonioxModels = null;
435
+ let cachedSonioxModelsFetchedAt = 0;
436
+ let cachedSonioxModelsStale = true;
437
+ let sonioxModelsFetchStarted = false;
438
+ /** @type {Promise<{models: Array, fetchedAtMs: number, stale: boolean}>|null} */
439
+ let inFlightSonioxModelsFetch = null;
440
+
441
+ function resolveFetchImpl() {
442
+ return typeof opts.fetch === "function"
443
+ ? opts.fetch
444
+ : typeof globalThis.fetch === "function"
445
+ ? globalThis.fetch.bind(globalThis)
446
+ : null;
447
+ }
448
+
449
+ function sonioxModelsSnapshot(nowMs) {
450
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
451
+ const hasCache = Array.isArray(cachedSonioxModels);
452
+ return {
453
+ models: hasCache ? cachedSonioxModels : [],
454
+ fetchedAtMs: hasCache ? cachedSonioxModelsFetchedAt : now,
455
+ stale: !hasCache || cachedSonioxModelsStale,
456
+ };
457
+ }
458
+
459
+ function cacheSonioxModels(models, fetchedAtMs, stale) {
460
+ cachedSonioxModels = Array.isArray(models) ? models : [];
461
+ cachedSonioxModelsFetchedAt = Number.isFinite(fetchedAtMs)
462
+ ? Math.floor(fetchedAtMs)
463
+ : Date.now();
464
+ cachedSonioxModelsStale = !!stale;
465
+ return sonioxModelsSnapshot(cachedSonioxModelsFetchedAt);
466
+ }
467
+
468
+ function getSonioxModelsSnapshot() {
469
+ if (inFlightSonioxModelsFetch) {
470
+ return inFlightSonioxModelsFetch;
471
+ }
472
+ return Promise.resolve(sonioxModelsSnapshot());
473
+ }
474
+
475
+ function prefetchSonioxModels(trigger = "relay_start") {
476
+ if (inFlightSonioxModelsFetch) {
477
+ return inFlightSonioxModelsFetch;
478
+ }
479
+ if (sonioxModelsFetchStarted) {
480
+ return Promise.resolve(sonioxModelsSnapshot());
481
+ }
482
+ sonioxModelsFetchStarted = true;
483
+
484
+ const fetchImpl = resolveFetchImpl();
485
+ if (!configuredSonioxApiKey) {
486
+ const snapshot = cacheSonioxModels([], Date.now(), true);
487
+ emitDebug(
488
+ "voice.timeline",
489
+ "soniox_models_prefetch_skipped",
490
+ "warn",
491
+ { sessionKey: sessionService.peekSessionKey() || undefined },
492
+ () => ({
493
+ trigger,
494
+ reason: "api_key_not_configured",
495
+ }),
496
+ );
497
+ return Promise.resolve(snapshot);
498
+ }
499
+ if (!fetchImpl) {
500
+ const snapshot = cacheSonioxModels([], Date.now(), true);
501
+ emitDebug(
502
+ "voice.timeline",
503
+ "soniox_models_prefetch_skipped",
504
+ "warn",
505
+ { sessionKey: sessionService.peekSessionKey() || undefined },
506
+ () => ({
507
+ trigger,
508
+ reason: "fetch_unavailable",
509
+ }),
510
+ );
511
+ return Promise.resolve(snapshot);
512
+ }
513
+
514
+ inFlightSonioxModelsFetch = Promise.resolve()
515
+ .then(async () => {
516
+ const response = await fetchImpl(SONIOX_MODELS_URL, {
517
+ method: "GET",
518
+ headers: {
519
+ Authorization: `Bearer ${configuredSonioxApiKey}`,
520
+ },
521
+ });
522
+ const rawText =
523
+ response && typeof response.text === "function"
524
+ ? await response.text()
525
+ : "";
526
+ let payload = {};
527
+ if (rawText) {
528
+ try {
529
+ payload = JSON.parse(rawText);
530
+ } catch {
531
+ if (!response.ok) {
532
+ throw new Error(
533
+ `Soniox models request failed (${response.status}): ${tailForLog(rawText)}`,
534
+ );
535
+ }
536
+ throw new Error(
537
+ `Soniox models response was not valid JSON (${response.status})`,
538
+ );
539
+ }
540
+ }
541
+ if (!response.ok) {
542
+ const errorDetail = pickTrimmedString(
543
+ payload && payload.error,
544
+ payload && payload.message,
545
+ payload && payload.detail,
546
+ rawText,
547
+ ) || `HTTP ${response.status}`;
548
+ throw new Error(
549
+ `Soniox models request failed (${response.status}): ${tailForLog(errorDetail)}`,
550
+ );
551
+ }
552
+ const snapshot = cacheSonioxModels(
553
+ normalizeSonioxModelEntryRows(payload || {}),
554
+ Date.now(),
555
+ false,
556
+ );
557
+ emitDebug(
558
+ "voice.timeline",
559
+ "soniox_models_prefetched",
560
+ "info",
561
+ { sessionKey: sessionService.peekSessionKey() || undefined },
562
+ () => ({
563
+ trigger,
564
+ count: snapshot.models.length,
565
+ stale: snapshot.stale,
566
+ }),
567
+ );
568
+ return snapshot;
569
+ })
570
+ .catch((err) => {
571
+ const snapshot = Array.isArray(cachedSonioxModels)
572
+ ? cacheSonioxModels(cachedSonioxModels, cachedSonioxModelsFetchedAt, true)
573
+ : cacheSonioxModels([], Date.now(), true);
574
+ emitDebug(
575
+ "voice.timeline",
576
+ "soniox_models_prefetch_failed",
577
+ "warn",
578
+ { sessionKey: sessionService.peekSessionKey() || undefined },
579
+ () => ({
580
+ trigger,
581
+ message: err && err.message ? err.message : String(err),
582
+ }),
583
+ );
584
+ return snapshot;
585
+ })
586
+ .finally(() => {
587
+ inFlightSonioxModelsFetch = null;
588
+ });
589
+
590
+ return inFlightSonioxModelsFetch;
591
+ }
592
+
593
+ async function mintSonioxTemporaryKey(clientId, request) {
594
+ const voiceSessionId = pickTrimmedString(request && request.voiceSessionId);
595
+ if (!voiceSessionId) {
596
+ throw new Error("voiceSessionId is required");
597
+ }
598
+
599
+ const sessionKey = pickTrimmedString(request && request.sessionKey) || null;
600
+ const nowMs = Date.now();
601
+ const resolvedSessionKey = sessionKey || sessionService.peekSessionKey() || undefined;
602
+ const emitIssued = (normalized, source) => {
603
+ logger.info(
604
+ `[relay] soniox temp key issued: clientId=${clientId} voiceSessionId=${voiceSessionId} source=${source} expiresAtMs=${normalized.expiresAtMs}`,
605
+ );
606
+ emitDebug(
607
+ "voice.timeline",
608
+ "soniox_temp_key_issued",
609
+ "info",
610
+ { sessionKey: resolvedSessionKey },
611
+ () => ({
612
+ clientId,
613
+ voiceSessionId,
614
+ expiresAtMs: normalized.expiresAtMs,
615
+ source,
616
+ }),
617
+ );
618
+ return normalized;
619
+ };
620
+
621
+ try {
622
+ emitDebug(
623
+ "voice.timeline",
624
+ "soniox_temp_key_requested",
625
+ "info",
626
+ { sessionKey: resolvedSessionKey },
627
+ () => ({
628
+ clientId,
629
+ voiceSessionId,
630
+ expiresInSeconds: sonioxTemporaryKeyExpiresInSeconds,
631
+ }),
632
+ );
633
+
634
+ if (typeof opts.createSonioxTemporaryKey === "function") {
635
+ const overrideResult = await Promise.resolve(
636
+ opts.createSonioxTemporaryKey({
637
+ voiceSessionId,
638
+ sessionKey,
639
+ expiresInSeconds: sonioxTemporaryKeyExpiresInSeconds,
640
+ }),
641
+ );
642
+ return emitIssued(
643
+ normalizeSonioxTemporaryKeyResult(
644
+ overrideResult || {},
645
+ voiceSessionId,
646
+ nowMs,
647
+ ),
648
+ "override",
649
+ );
650
+ }
651
+
652
+ if (!configuredSonioxApiKey) {
653
+ throw new Error("Soniox API key is not configured");
654
+ }
655
+
656
+ const fetchImpl = resolveFetchImpl();
657
+ if (!fetchImpl) {
658
+ throw new Error("fetch is not available for Soniox temporary-key minting");
659
+ }
660
+
661
+ const response = await fetchImpl(SONIOX_TEMP_KEY_URL, {
662
+ method: "POST",
663
+ headers: {
664
+ Authorization: `Bearer ${configuredSonioxApiKey}`,
665
+ "Content-Type": "application/json",
666
+ },
667
+ body: JSON.stringify({
668
+ usage_type: "transcribe_websocket",
669
+ expires_in_seconds: sonioxTemporaryKeyExpiresInSeconds,
670
+ client_reference_id: voiceSessionId,
671
+ }),
672
+ });
673
+
674
+ const rawText =
675
+ response && typeof response.text === "function"
676
+ ? await response.text()
677
+ : "";
678
+ let payload = {};
679
+ if (rawText) {
680
+ try {
681
+ payload = JSON.parse(rawText);
682
+ } catch (err) {
683
+ if (!response.ok) {
684
+ throw new Error(
685
+ `Soniox temporary-key request failed (${response.status}): ${tailForLog(rawText)}`,
686
+ );
687
+ }
688
+ throw new Error(
689
+ `Soniox temporary-key response was not valid JSON (${response.status})`,
690
+ );
691
+ }
692
+ }
693
+
694
+ if (!response.ok) {
695
+ const errorDetail = pickTrimmedString(
696
+ payload && payload.error,
697
+ payload && payload.message,
698
+ payload && payload.detail,
699
+ rawText,
700
+ ) || `HTTP ${response.status}`;
701
+ throw new Error(
702
+ `Soniox temporary-key request failed (${response.status}): ${tailForLog(errorDetail)}`,
703
+ );
704
+ }
705
+
706
+ return emitIssued(
707
+ normalizeSonioxTemporaryKeyResult(
708
+ payload || {},
709
+ voiceSessionId,
710
+ nowMs,
711
+ ),
712
+ "soniox_api",
713
+ );
714
+ } catch (err) {
715
+ const message =
716
+ err && err.message
717
+ ? err.message
718
+ : "Soniox temporary-key request failed";
719
+ const code = normalizeSonioxTemporaryKeyErrorCode(err);
720
+ logger.warn(
721
+ `[relay] soniox temp key failed: clientId=${clientId} voiceSessionId=${voiceSessionId} code=${code} message=${tailForLog(message)}`,
722
+ );
723
+ emitDebug(
724
+ "voice.timeline",
725
+ "soniox_temp_key_failed",
726
+ "warn",
727
+ { sessionKey: resolvedSessionKey },
728
+ () => ({
729
+ clientId,
730
+ voiceSessionId,
731
+ code,
732
+ message: tailForLog(message),
733
+ }),
734
+ );
735
+ throw err;
736
+ }
737
+ }
738
+
739
+ let upstreamRuntime = null;
740
+ const evenAiSettingsStore = createEvenAiSettingsStore({
741
+ logger,
742
+ emitDebug,
743
+ stateDir: opts.stateDir,
744
+ defaults: {
745
+ routingMode: opts.evenAiRoutingMode,
746
+ systemPrompt: opts.evenAiSystemPrompt,
747
+ },
748
+ });
749
+ const ocuClawSettingsStore = createOcuClawSettingsStore({
750
+ logger,
751
+ emitDebug,
752
+ stateDir: opts.stateDir,
753
+ defaults: {
754
+ systemPrompt: opts.ocuClawSystemPrompt,
755
+ },
756
+ });
757
+
758
+ function currentOcuClawSendOptions() {
759
+ return {
760
+ extraSystemPrompt: composeReadabilitySystemPrompt(
761
+ ocuClawSettingsStore.getSnapshot().systemPrompt,
762
+ ),
763
+ };
764
+ }
765
+
766
+ function buildLocalUserMessageContent(text, attachment) {
767
+ const userContent = [];
768
+ if (typeof text === "string" && text.trim()) {
769
+ userContent.push({ type: "text", text });
770
+ }
771
+ if (attachment) {
772
+ userContent.push({
773
+ type: "image",
774
+ mimeType: attachment.mimeType || null,
775
+ fileName: attachment.name || null,
776
+ source: attachment.source || null,
777
+ sizeBytes:
778
+ Number.isFinite(attachment.sizeBytes) && attachment.sizeBytes > 0
779
+ ? Math.floor(attachment.sizeBytes)
780
+ : null,
781
+ widthPx:
782
+ Number.isFinite(attachment.widthPx) && attachment.widthPx > 0
783
+ ? Math.floor(attachment.widthPx)
784
+ : null,
785
+ heightPx:
786
+ Number.isFinite(attachment.heightPx) && attachment.heightPx > 0
787
+ ? Math.floor(attachment.heightPx)
788
+ : null,
789
+ });
790
+ }
791
+ if (userContent.length === 0) {
792
+ userContent.push({ type: "text", text });
793
+ }
794
+ return userContent;
795
+ }
796
+
797
+ function buildOcuClawInitialSessionConfigPatch(settings) {
798
+ const patch = {};
799
+ if (settings && typeof settings.defaultModel === "string" && settings.defaultModel.trim()) {
800
+ patch.modelRef = settings.defaultModel.trim();
801
+ }
802
+ if (
803
+ settings &&
804
+ typeof settings.defaultThinking === "string" &&
805
+ settings.defaultThinking.trim()
806
+ ) {
807
+ patch.thinkingLevel = settings.defaultThinking.trim().toLowerCase();
808
+ }
809
+ return Object.keys(patch).length > 0 ? patch : null;
810
+ }
811
+
812
+ async function maybeSeedOcuClawSessionConfig(sessionKey) {
813
+ if (
814
+ !sessionKey ||
815
+ !sessionService ||
816
+ typeof sessionService.hasPendingInitialConfig !== "function" ||
817
+ !sessionService.hasPendingInitialConfig(sessionKey)
818
+ ) {
819
+ return;
820
+ }
821
+
822
+ const settings = ocuClawSettingsStore.getSnapshot();
823
+ const patch = buildOcuClawInitialSessionConfigPatch(settings);
824
+ if (!patch) {
825
+ sessionService.clearPendingInitialConfig(sessionKey);
826
+ return;
827
+ }
828
+
829
+ const result = await sessionService.setSessionModelConfig(sessionKey, patch);
830
+ if (!result || result.status !== "accepted") {
831
+ throw new Error(
832
+ (result && result.error) || "failed to seed OcuClaw new-session defaults",
833
+ );
834
+ }
835
+ if (
836
+ result.config &&
837
+ sessionKey === sessionService.ensureSessionKey() &&
838
+ server
839
+ ) {
840
+ server.broadcast(handler.formatSessionModelConfig(result.config));
841
+ }
842
+ }
843
+
844
+ async function seedOcuClawSessionConfigForNewSession(sessionKey) {
845
+ if (!sessionKey || !sessionService) {
846
+ return null;
847
+ }
848
+
849
+ const settings = ocuClawSettingsStore.getSnapshot();
850
+ const patch = buildOcuClawInitialSessionConfigPatch(settings);
851
+ if (!patch) {
852
+ return null;
853
+ }
854
+
855
+ const seededConfig =
856
+ typeof sessionService.primeSessionModelConfig === "function"
857
+ ? sessionService.primeSessionModelConfig(sessionKey, patch)
858
+ : null;
859
+ const result = await sessionService.setSessionModelConfig(sessionKey, patch);
860
+ if (result && result.status === "accepted" && result.config) {
861
+ return result.config;
862
+ }
863
+ return seededConfig;
864
+ }
865
+
866
+ const sessionService = createSessionService({
867
+ logger,
868
+ gatewayBridge,
869
+ conversationState,
870
+ emitDebug,
871
+ stateDir: opts.stateDir,
872
+ sessionLimit: opts.sessionLimit,
873
+ persistFirstUserMessages: opts.persistFirstUserMessages,
874
+ strictFirstUserMessage: opts.strictFirstUserMessage,
875
+ sessionCacheTtlMs: opts.sessionCacheTtlMs,
876
+ getOpenclawConnected() {
877
+ return upstreamRuntime ? upstreamRuntime.isConnected() : false;
878
+ },
879
+ getAgentName() {
880
+ return upstreamRuntime ? upstreamRuntime.getAgentName() : null;
881
+ },
882
+ onSessionStateReset: resetActivityStatusAdapter,
883
+ onPagesChanged: cachePages,
884
+ onStatusChanged: broadcastStatus,
885
+ });
886
+
887
+ function broadcastActivity(rawActivity) {
888
+ const activity = activityStatusAdapter.augmentActivity(rawActivity || {});
889
+ const runId = activity && activity.runId ? activity.runId : null;
890
+ const origin = activity && activity.origin ? activity.origin : null;
891
+ const phase = activity && activity.phase ? activity.phase : null;
892
+
893
+ emitDebug(
894
+ "app.timeline",
895
+ "activity",
896
+ "debug",
897
+ {
898
+ sessionKey: (activity && activity.sessionKey) || sessionService.ensureSessionKey(),
899
+ runId,
900
+ },
901
+ () => ({
902
+ state: (activity && activity.state) || null,
903
+ tool: (activity && activity.tool) || null,
904
+ label: (activity && activity.label) || null,
905
+ intent: (activity && activity.intent) || null,
906
+ thinkingSummarySource: (activity && activity.thinkingSummarySource) || null,
907
+ category: (activity && activity.category) || null,
908
+ activityId: (activity && activity.activityId) || null,
909
+ seq: Number.isFinite(activity && activity.seq) ? activity.seq : null,
910
+ origin,
911
+ phase,
912
+ }),
913
+ );
914
+
915
+ server.broadcast(handler.formatActivity(activity));
916
+ return activity;
917
+ }
918
+
919
+ function normalizeAttachmentErrorCode(err) {
920
+ if (!err) return "attachment_upstream_rejected";
921
+ const code = typeof err.code === "string" ? err.code.trim() : "";
922
+ if (
923
+ code === "attachment_invalid_type" ||
924
+ code === "attachment_decode_failed" ||
925
+ code === "attachment_too_large" ||
926
+ code === "attachment_too_large_encoded" ||
927
+ code === "attachment_missing_data" ||
928
+ code === "attachment_upstream_rejected"
929
+ ) {
930
+ return code;
931
+ }
932
+
933
+ const message = typeof err.message === "string" ? err.message.toLowerCase() : "";
934
+ if (message.includes("invalid type") || message.includes("mime")) {
935
+ return "attachment_invalid_type";
936
+ }
937
+ if (message.includes("base64") || message.includes("decode")) {
938
+ return "attachment_decode_failed";
939
+ }
940
+ if (message.includes("too large") || message.includes("exceeds")) {
941
+ return "attachment_too_large";
942
+ }
943
+ return "attachment_upstream_rejected";
944
+ }
945
+
946
+ function dispatchOcuClawUserSend(params = {}) {
947
+ const id = params.id;
948
+ const text = params.text;
949
+ const sessionKey = params.sessionKey;
950
+ const attachment = params.attachment || null;
951
+ const resolvedSessionKey = sessionKey || sessionService.ensureSessionKey();
952
+ sessionService.recordFirstSentUserMessage(resolvedSessionKey, text);
953
+ const hasAttachment = !!attachment;
954
+ const sendStartedAt = Date.now();
955
+ sessionService.invalidateSessionsCache();
956
+ emitDebug(
957
+ "relay.protocol",
958
+ "send",
959
+ "info",
960
+ { sessionKey: resolvedSessionKey },
961
+ () => ({
962
+ messageId: id,
963
+ textChars: typeof text === "string" ? text.length : 0,
964
+ hasAttachment,
965
+ attachmentBytes:
966
+ attachment && Number.isFinite(attachment.sizeBytes)
967
+ ? attachment.sizeBytes
968
+ : null,
969
+ }),
970
+ );
971
+
972
+ return maybeSeedOcuClawSessionConfig(resolvedSessionKey).then(() => {
973
+ // Dispatch upstream first so local transcript work cannot delay first
974
+ // model tokens on large histories.
975
+ const upstreamPromise = gatewayBridge.sendMessage(
976
+ text,
977
+ resolvedSessionKey,
978
+ attachment,
979
+ currentOcuClawSendOptions(),
980
+ );
981
+ const upstreamDispatchedAt = Date.now();
982
+
983
+ conversationState.addMessage(
984
+ "user",
985
+ buildLocalUserMessageContent(text, attachment),
986
+ );
987
+ broadcastPages();
988
+ const localPublishDoneAt = Date.now();
989
+
990
+ emitDebug(
991
+ "relay.protocol",
992
+ "send_local_publish",
993
+ "debug",
994
+ { sessionKey: resolvedSessionKey },
995
+ () => ({
996
+ messageId: id,
997
+ upstreamDispatchMs: upstreamDispatchedAt - sendStartedAt,
998
+ localPublishMs: localPublishDoneAt - upstreamDispatchedAt,
999
+ onSendSyncMs: localPublishDoneAt - sendStartedAt,
1000
+ hasAttachment,
1001
+ }),
1002
+ );
1003
+
1004
+ return upstreamPromise.then(
1005
+ (result) => {
1006
+ const ackAt = Date.now();
1007
+ const runId = result && result.runId ? result.runId : null;
1008
+ if (runId && upstreamRuntime) {
1009
+ upstreamRuntime.trackAcceptedRun({
1010
+ runId,
1011
+ sessionKey: resolvedSessionKey,
1012
+ messageId: id,
1013
+ sendStartedAt,
1014
+ ackAt,
1015
+ });
1016
+ }
1017
+ emitDebug(
1018
+ "relay.protocol",
1019
+ "send_upstream_ack",
1020
+ "debug",
1021
+ { sessionKey: resolvedSessionKey, runId },
1022
+ () => ({
1023
+ messageId: id,
1024
+ runId,
1025
+ status: result && result.status ? result.status : null,
1026
+ elapsedMs: ackAt - sendStartedAt,
1027
+ hasAttachment,
1028
+ }),
1029
+ );
1030
+ return result;
1031
+ },
1032
+ (err) => {
1033
+ if (attachment && !err.errorCode) {
1034
+ err.errorCode = normalizeAttachmentErrorCode(err);
1035
+ }
1036
+ emitDebug(
1037
+ "relay.protocol",
1038
+ "send_upstream_error",
1039
+ "warn",
1040
+ { sessionKey: resolvedSessionKey },
1041
+ () => ({
1042
+ messageId: id,
1043
+ elapsedMs: Date.now() - sendStartedAt,
1044
+ hasAttachment,
1045
+ errorCode: err.errorCode || null,
1046
+ message: err && err.message ? err.message : String(err),
1047
+ }),
1048
+ );
1049
+ throw err;
1050
+ },
1051
+ );
1052
+ });
1053
+ }
1054
+
1055
+ function emitListenInterceptRecovery(params = {}) {
1056
+ const connectedAppClients = server ? server.getConnectedAppCount() : 0;
1057
+ if (!server || !handler) {
1058
+ return {
1059
+ cleanupEmitted: false,
1060
+ connectedAppClients,
1061
+ };
1062
+ }
1063
+
1064
+ server.broadcast(
1065
+ handler.formatListenError(
1066
+ LISTEN_INTERCEPT_RECOVERY_ERROR,
1067
+ LISTEN_INTERCEPT_RECOVERY_CODE,
1068
+ ),
1069
+ );
1070
+ server.broadcast(handler.formatListenEnded());
1071
+ return {
1072
+ cleanupEmitted: true,
1073
+ connectedAppClients,
1074
+ };
1075
+ }
1076
+
1077
+ // --- Downstream handler ---
1078
+
1079
+ /** @type {ReturnType<typeof createDownstreamServer>|null} */
1080
+ let server = null;
1081
+ let evenAiEndpoint = null;
1082
+ let evenAiRouter = null;
1083
+ let evenAiRunWaiter = null;
1084
+
1085
+ const handler = createDownstreamHandler({
1086
+ logger,
1087
+ externalDebugToolsEnabled,
1088
+ getSnapshotRevision(kind) {
1089
+ if (kind === "pages") return pagesRevision;
1090
+ if (kind === "status") return statusRevision;
1091
+ return null;
1092
+ },
1093
+ /**
1094
+ * Forward a user message to the upstream OpenClaw agent.
1095
+ *
1096
+ * @param {string} id - Message ID
1097
+ * @param {string} text - User message text
1098
+ * @param {string|null} sessionKey - Session key
1099
+ * @param {object|null} attachment - Optional image attachment payload
1100
+ * @returns {Promise}
1101
+ */
1102
+ onSend(id, text, sessionKey, attachment) {
1103
+ return dispatchOcuClawUserSend({
1104
+ id,
1105
+ text,
1106
+ sessionKey,
1107
+ attachment,
1108
+ });
1109
+ },
1110
+
1111
+ /**
1112
+ * Inject a fake assistant message into conversation state.
1113
+ *
1114
+ * The sender name is used as a temporary agent name prefix for
1115
+ * this message. If no agent identity has been established yet,
1116
+ * the sender becomes the default agent name going forward.
1117
+ *
1118
+ * @param {string} sender - Display name for the simulated message
1119
+ * @param {string} text - Message text
1120
+ * @returns {Array<{content: string, subPage: [number, number]|null}>}
1121
+ */
1122
+ onSimulate(sender, text) {
1123
+ emitDebug(
1124
+ "relay.protocol",
1125
+ "simulate",
1126
+ "debug",
1127
+ { sessionKey: sessionService.ensureSessionKey() },
1128
+ () => ({
1129
+ sender: sender || "Simulator",
1130
+ textChars: typeof text === "string" ? text.length : 0,
1131
+ }),
1132
+ );
1133
+ // Add with per-message name override (doesn't affect other messages' prefix)
1134
+ conversationState.addMessage("assistant", [{ type: "text", text }], sender || "Simulator");
1135
+
1136
+ const pages = conversationState.getPages();
1137
+ cachePages(pages);
1138
+ return pages;
1139
+ },
1140
+
1141
+ onSimulateStream(request) {
1142
+ const sessionKey = request.sessionKey || sessionService.ensureSessionKey();
1143
+ const sender = (
1144
+ request.sender ||
1145
+ (upstreamRuntime ? upstreamRuntime.getAgentName() : null) ||
1146
+ "Simulator"
1147
+ ).trim();
1148
+ const text = typeof request.text === "string" ? request.text : "";
1149
+ const chunkChars = Math.min(
1150
+ 200,
1151
+ Math.max(1, request.chunkChars || 16),
1152
+ );
1153
+ const chunkIntervalMs = Math.min(
1154
+ 5000,
1155
+ Math.max(10, request.chunkIntervalMs || 45),
1156
+ );
1157
+ const startDelayMs = Math.min(
1158
+ 5000,
1159
+ Math.max(0, request.startDelayMs || 80),
1160
+ );
1161
+ const thinkingTailMs = Math.min(
1162
+ 10000,
1163
+ Math.max(0, request.thinkingTailMs || 900),
1164
+ );
1165
+ const runId = `sim-${Date.now()}-${++simulateStreamRunSeq}`;
1166
+ const chunkCount = Math.max(1, Math.ceil(text.length / chunkChars));
1167
+ const streamPrefix = `${sender}: `;
1168
+
1169
+ emitDebug(
1170
+ "relay.protocol",
1171
+ "simulate_stream_start",
1172
+ "info",
1173
+ { sessionKey, runId },
1174
+ () => ({
1175
+ messageId: request.id || null,
1176
+ sender,
1177
+ textChars: text.length,
1178
+ chunkChars,
1179
+ chunkIntervalMs,
1180
+ startDelayMs,
1181
+ thinkingTailMs,
1182
+ chunkCount,
1183
+ }),
1184
+ );
1185
+
1186
+ broadcastActivity({
1187
+ state: "thinking",
1188
+ sessionKey,
1189
+ runId,
1190
+ origin: "simulate",
1191
+ phase: "start",
1192
+ });
1193
+
1194
+ for (let index = 0; index < chunkCount; index += 1) {
1195
+ const visibleChars = Math.min(text.length, (index + 1) * chunkChars);
1196
+ const delayMs = startDelayMs + (index * chunkIntervalMs);
1197
+ scheduleSimulateStreamTimer(delayMs, () => {
1198
+ const streamedText = `${streamPrefix}${text.slice(0, visibleChars)}`;
1199
+ server.broadcast(handler.formatStreaming(streamedText));
1200
+ emitDebug(
1201
+ "relay.protocol",
1202
+ "simulate_stream_chunk",
1203
+ "debug",
1204
+ { sessionKey, runId },
1205
+ () => ({
1206
+ chunkIndex: index + 1,
1207
+ chunkCount,
1208
+ visibleChars,
1209
+ totalChars: text.length,
1210
+ }),
1211
+ );
1212
+ });
1213
+ }
1214
+
1215
+ const completeDelayMs = startDelayMs + (chunkCount * chunkIntervalMs) + thinkingTailMs;
1216
+ scheduleSimulateStreamTimer(completeDelayMs, () => {
1217
+ conversationState.addMessage(
1218
+ "assistant",
1219
+ [{ type: "text", text }],
1220
+ sender,
1221
+ );
1222
+ broadcastPages();
1223
+ broadcastActivity({
1224
+ state: "idle",
1225
+ sessionKey,
1226
+ runId,
1227
+ origin: "simulate",
1228
+ phase: "complete",
1229
+ });
1230
+ emitDebug(
1231
+ "relay.protocol",
1232
+ "simulate_stream_complete",
1233
+ "info",
1234
+ { sessionKey, runId },
1235
+ () => ({
1236
+ messageId: request.id || null,
1237
+ textChars: text.length,
1238
+ chunkCount,
1239
+ thinkingTailMs,
1240
+ completeDelayMs,
1241
+ }),
1242
+ );
1243
+ });
1244
+
1245
+ return Promise.resolve({
1246
+ status: "accepted",
1247
+ runId,
1248
+ });
1249
+ },
1250
+
1251
+ /**
1252
+ * Clear conversation state, reset cached pages, and send /new to OpenClaw.
1253
+ * Legacy support: delegates to newSession.
1254
+ *
1255
+ * @returns {Promise<Array>} Empty pages array
1256
+ */
1257
+ onNewChat() {
1258
+ emitDebug(
1259
+ "relay.session",
1260
+ "new_chat",
1261
+ "info",
1262
+ { sessionKey: sessionService.ensureSessionKey() },
1263
+ () => ({}),
1264
+ );
1265
+ sessionService.invalidateSessionsCache();
1266
+ resetActivityStatusAdapter();
1267
+ conversationState.clear();
1268
+ conversationState.setAgentName(
1269
+ (upstreamRuntime ? upstreamRuntime.getAgentName() : null) || "Agent",
1270
+ );
1271
+ const pages = conversationState.getPages();
1272
+ cachePages(pages);
1273
+ if (upstreamRuntime && upstreamRuntime.isConnected()) {
1274
+ gatewayBridge.sendMessage("/new", "main").catch((err) => {
1275
+ logger.error(`[relay] Failed to send /new: ${err.message}`);
1276
+ });
1277
+ }
1278
+ return Promise.resolve(pages);
1279
+ },
1280
+
1281
+ onGetSessions() {
1282
+ return sessionService.getSessions();
1283
+ },
1284
+
1285
+ onSwitchSession(sessionKey) {
1286
+ return sessionService.switchToSession(sessionKey).then((pages) => {
1287
+ if (upstreamRuntime && typeof upstreamRuntime.handleSessionChanged === "function") {
1288
+ upstreamRuntime.handleSessionChanged("switch_session");
1289
+ }
1290
+ return pages;
1291
+ });
1292
+ },
1293
+
1294
+ async onNewSession() {
1295
+ const result = await sessionService.newSession();
1296
+ if (upstreamRuntime && typeof upstreamRuntime.handleSessionChanged === "function") {
1297
+ upstreamRuntime.handleSessionChanged("new_session");
1298
+ }
1299
+ const sessionModelConfig = await seedOcuClawSessionConfigForNewSession(
1300
+ result && result.sessionKey,
1301
+ );
1302
+ return sessionModelConfig
1303
+ ? {
1304
+ ...result,
1305
+ sessionModelConfig,
1306
+ }
1307
+ : result;
1308
+ },
1309
+
1310
+ onGetModelsCatalog() {
1311
+ return upstreamRuntime
1312
+ ? upstreamRuntime.getModelsCatalogSnapshot()
1313
+ : Promise.resolve({ models: [], fetchedAtMs: Date.now(), stale: true });
1314
+ },
1315
+
1316
+ onGetSkillsCatalog() {
1317
+ return upstreamRuntime
1318
+ ? upstreamRuntime.getSkillsCatalogSnapshot()
1319
+ : Promise.resolve({ skills: [], fetchedAtMs: Date.now(), stale: true });
1320
+ },
1321
+
1322
+ onGetSonioxModels() {
1323
+ return getSonioxModelsSnapshot();
1324
+ },
1325
+
1326
+ onGetStatus() {
1327
+ return buildStatusObject();
1328
+ },
1329
+
1330
+ onGetSessionModelConfig() {
1331
+ return sessionService.getCurrentSessionModelConfig();
1332
+ },
1333
+
1334
+ async onSetSessionModelConfig(patch) {
1335
+ const result = await sessionService.setCurrentSessionModelConfig(patch || {});
1336
+ if (result && result.status === "accepted" && result.config) {
1337
+ server.broadcast(handler.formatSessionModelConfig(result.config));
1338
+ }
1339
+ return result;
1340
+ },
1341
+
1342
+ onGetEvenAiSettings() {
1343
+ return evenAiSettingsStore.getSnapshot();
1344
+ },
1345
+
1346
+ onGetOcuClawSettings() {
1347
+ return ocuClawSettingsStore.getSnapshot();
1348
+ },
1349
+
1350
+ async onGetEvenAiSessions() {
1351
+ const dedicatedKey =
1352
+ evenAiRouter && typeof evenAiRouter.getDedicatedSessionKey === "function"
1353
+ ? evenAiRouter.getDedicatedSessionKey()
1354
+ : opts.evenAiDedicatedSessionKey;
1355
+ const dedicatedEvenAiKey = normalizeEvenAiSessionKeyForLookup(dedicatedKey);
1356
+ const trackedThrowawayKeys =
1357
+ typeof evenAiSettingsStore.getTrackedThrowawayKeys === "function"
1358
+ ? evenAiSettingsStore.getTrackedThrowawayKeys()
1359
+ : [];
1360
+ const normalizedTrackedThrowawayKeys = dedupeNormalizedSessionKeys(
1361
+ trackedThrowawayKeys,
1362
+ );
1363
+ const resolvedSessions = await sessionService.getSessionsByExactKeys([
1364
+ ...normalizedTrackedThrowawayKeys,
1365
+ ...(dedicatedEvenAiKey ? [dedicatedEvenAiKey] : []),
1366
+ ]);
1367
+ const normalizedDedicatedKey = dedicatedEvenAiKey.toLowerCase();
1368
+ const sessions = [];
1369
+ let dedicatedIncluded = false;
1370
+ for (const session of resolvedSessions) {
1371
+ if (
1372
+ !dedicatedIncluded &&
1373
+ session &&
1374
+ typeof session.key === "string" &&
1375
+ session.key.trim().toLowerCase() === normalizedDedicatedKey
1376
+ ) {
1377
+ sessions.push(session);
1378
+ dedicatedIncluded = true;
1379
+ continue;
1380
+ }
1381
+ sessions.push(session);
1382
+ }
1383
+ if (!dedicatedIncluded && dedicatedEvenAiKey) {
1384
+ sessions.unshift({
1385
+ key: dedicatedEvenAiKey,
1386
+ updatedAt: 0,
1387
+ preview: "",
1388
+ firstUserMessage: "",
1389
+ });
1390
+ }
1391
+ return {
1392
+ sessions,
1393
+ dedicatedKey,
1394
+ };
1395
+ },
1396
+
1397
+ async onSetEvenAiSettings(patch) {
1398
+ const result = await evenAiSettingsStore.setSettings(patch || {});
1399
+ if (result && result.status === "accepted" && result.settings && server) {
1400
+ server.broadcast(handler.formatEvenAiSettings(result.settings));
1401
+ }
1402
+ return result;
1403
+ },
1404
+
1405
+ async onSetOcuClawSettings(patch) {
1406
+ const result = await ocuClawSettingsStore.setSettings(patch || {});
1407
+ if (result && result.status === "accepted" && result.settings && server) {
1408
+ server.broadcast(handler.formatOcuClawSettings(result.settings));
1409
+ }
1410
+ return result;
1411
+ },
1412
+
1413
+ onSlashCommand(command) {
1414
+ emitDebug(
1415
+ "relay.protocol",
1416
+ "slash_command",
1417
+ "debug",
1418
+ { sessionKey: sessionService.ensureSessionKey() },
1419
+ () => ({ command }),
1420
+ );
1421
+ if (command === "/reset") {
1422
+ sessionService.invalidateSessionsCache();
1423
+ resetActivityStatusAdapter();
1424
+ conversationState.clear();
1425
+ conversationState.setAgentName(
1426
+ (upstreamRuntime ? upstreamRuntime.getAgentName() : null) || "Agent",
1427
+ );
1428
+ broadcastPages();
1429
+ }
1430
+ if (upstreamRuntime && upstreamRuntime.isConnected()) {
1431
+ return gatewayBridge.sendMessage(command, sessionService.ensureSessionKey());
1432
+ }
1433
+ return Promise.resolve();
1434
+ },
1435
+
1436
+ /**
1437
+ * @returns {boolean} Whether upstream is connected.
1438
+ */
1439
+ isUpstreamConnected() {
1440
+ return upstreamRuntime ? upstreamRuntime.isConnected() : false;
1441
+ },
1442
+
1443
+ onConsoleLog(level, message) {
1444
+ writeConsoleLog(level, message);
1445
+ if (level === "event") {
1446
+ emitDebug(
1447
+ "sdk.events",
1448
+ "event_debug",
1449
+ "debug",
1450
+ { sessionKey: sessionService.ensureSessionKey() },
1451
+ () => ({
1452
+ level,
1453
+ message,
1454
+ }),
1455
+ );
1456
+ }
1457
+ },
1458
+
1459
+ onEventDebug(clientId, payload) {
1460
+ if (!payload || typeof payload !== "object") return;
1461
+ const cat = payload.cat;
1462
+ if (!debugStore.isEnabled(cat)) return;
1463
+ emitDebug(
1464
+ cat,
1465
+ payload.event,
1466
+ payload.severity || "debug",
1467
+ {
1468
+ sessionKey: payload.sessionKey || sessionService.ensureSessionKey(),
1469
+ runId: payload.runId || null,
1470
+ screen: payload.screen || null,
1471
+ },
1472
+ () => ({
1473
+ clientId,
1474
+ ...(payload.data || {}),
1475
+ }),
1476
+ );
1477
+ },
1478
+
1479
+ onApprovalResolve(id, decision) {
1480
+ return gatewayBridge.resolveApproval(id, decision);
1481
+ },
1482
+
1483
+ onRequestSonioxTemporaryKey(clientId, request) {
1484
+ return mintSonioxTemporaryKey(clientId, request);
1485
+ },
1486
+
1487
+ onDebugSet(clientId, request) {
1488
+ const result = debugStore.setCategories(request);
1489
+ if (!result.ok) {
1490
+ throw new Error(result.error || "debug-set failed");
1491
+ }
1492
+
1493
+ emitDebug(
1494
+ "relay.protocol",
1495
+ "debug_set",
1496
+ "info",
1497
+ { sessionKey: sessionService.ensureSessionKey() },
1498
+ () => ({
1499
+ clientId,
1500
+ enable: result.applied.enable,
1501
+ disable: result.applied.disable,
1502
+ ttlMs: result.ttlMs,
1503
+ enabledCount: result.enabled.length,
1504
+ }),
1505
+ );
1506
+
1507
+ return result;
1508
+ },
1509
+
1510
+ onDebugDump(clientId, request) {
1511
+ const result = debugStore.dump(request);
1512
+ if (!result.ok) {
1513
+ throw new Error(result.error || "debug-dump failed");
1514
+ }
1515
+
1516
+ emitDebug(
1517
+ "relay.protocol",
1518
+ "debug_dump",
1519
+ "debug",
1520
+ { sessionKey: sessionService.ensureSessionKey() },
1521
+ () => ({
1522
+ clientId,
1523
+ categories: result.categories,
1524
+ redaction: result.redaction,
1525
+ limit: result.limit,
1526
+ returned: result.returned,
1527
+ totalMatched: result.totalMatched,
1528
+ }),
1529
+ );
1530
+
1531
+ return result;
1532
+ },
1533
+
1534
+ onRemoteControl(clientId, request) {
1535
+ const now = Date.now();
1536
+ const requestId =
1537
+ (typeof request.requestId === "string" && request.requestId.trim()) ||
1538
+ `rc-${now}-${Math.random().toString(16).slice(2, 8)}`;
1539
+ const isDebugCloseAppClientAction =
1540
+ request &&
1541
+ request.action === "relay-action" &&
1542
+ request.relayAction === "debug-close-app-client";
1543
+ const recipientEstimate = server ? server.getConnectedAppCount(clientId) : 0;
1544
+
1545
+ emitDebug(
1546
+ "relay.protocol",
1547
+ "remote_control_requested",
1548
+ "info",
1549
+ { sessionKey: sessionService.ensureSessionKey() },
1550
+ () => ({
1551
+ clientId,
1552
+ requestId,
1553
+ action: request.action || "unknown",
1554
+ recipientEstimate,
1555
+ }),
1556
+ );
1557
+
1558
+ if (recipientEstimate <= 0) {
1559
+ return {
1560
+ ok: false,
1561
+ requestId,
1562
+ message: "No downstream app clients connected",
1563
+ detail: { recipientEstimate },
1564
+ };
1565
+ }
1566
+
1567
+ if (isDebugCloseAppClientAction) {
1568
+ if (!server || typeof server.closeConnectedAppClients !== "function") {
1569
+ return {
1570
+ ok: false,
1571
+ requestId,
1572
+ message: "Downstream close hook unavailable",
1573
+ detail: { recipientEstimate },
1574
+ };
1575
+ }
1576
+ const closeResult = server.closeConnectedAppClients({
1577
+ excludeClientId: clientId,
1578
+ reason: "debug_close_app_client",
1579
+ });
1580
+ emitDebug(
1581
+ "relay.protocol",
1582
+ "remote_control_server_action_applied",
1583
+ "warn",
1584
+ { sessionKey: sessionService.ensureSessionKey() },
1585
+ () => ({
1586
+ clientId,
1587
+ requestId,
1588
+ action: request.action || "unknown",
1589
+ relayAction: request.relayAction || null,
1590
+ recipientEstimate,
1591
+ closedCount: closeResult.closedCount,
1592
+ closedClientIds: closeResult.closedClientIds,
1593
+ reason: closeResult.reason,
1594
+ }),
1595
+ );
1596
+ return {
1597
+ ok: closeResult.closedCount > 0,
1598
+ requestId,
1599
+ message:
1600
+ closeResult.closedCount > 0
1601
+ ? `Closed ${closeResult.closedCount} app client(s)`
1602
+ : "No downstream app clients connected",
1603
+ detail: {
1604
+ recipientEstimate,
1605
+ closedCount: closeResult.closedCount,
1606
+ closedClientIds: closeResult.closedClientIds,
1607
+ reason: closeResult.reason,
1608
+ },
1609
+ };
1610
+ }
1611
+
1612
+ const control = {
1613
+ ...request,
1614
+ requestId,
1615
+ issuedAtMs: now,
1616
+ issuedByClientId: clientId,
1617
+ };
1618
+
1619
+ emitDebug(
1620
+ "relay.protocol",
1621
+ "remote_control_dispatched",
1622
+ "info",
1623
+ { sessionKey: sessionService.ensureSessionKey() },
1624
+ () => ({
1625
+ clientId,
1626
+ requestId,
1627
+ action: control.action || "unknown",
1628
+ recipientEstimate,
1629
+ }),
1630
+ );
1631
+
1632
+ return {
1633
+ ok: true,
1634
+ requestId,
1635
+ message: `Dispatched to ${recipientEstimate} client(s)`,
1636
+ detail: { recipientEstimate },
1637
+ control,
1638
+ };
1639
+ },
1640
+ });
1641
+
1642
+ // --- Downstream server ---
1643
+
1644
+ server = createDownstreamServer({
1645
+ logger,
1646
+ externalDebugToolsEnabled,
1647
+ handler,
1648
+ getCurrentPages() {
1649
+ return cachedPages;
1650
+ },
1651
+ getCurrentStatus() {
1652
+ return cachedStatus;
1653
+ },
1654
+ getCurrentDebugConfig() {
1655
+ return handler.formatDebugConfigSnapshot(debugStore.getSnapshot());
1656
+ },
1657
+ getCurrentResumeState() {
1658
+ return {
1659
+ pagesRevision: pagesRevision || 0,
1660
+ statusRevision: statusRevision || 0,
1661
+ };
1662
+ },
1663
+ onClientConnected(meta) {
1664
+ emitDebug(
1665
+ "relay.session",
1666
+ "downstream_client_connected",
1667
+ "info",
1668
+ { sessionKey: sessionService.peekSessionKey() || undefined },
1669
+ () => ({
1670
+ clientId: meta && meta.clientId ? meta.clientId : null,
1671
+ connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1672
+ connectedAtMs: meta && Number.isFinite(meta.connectedAtMs) ? meta.connectedAtMs : null,
1673
+ remoteAddress: meta && meta.remoteAddress ? meta.remoteAddress : null,
1674
+ userAgentTail: meta && meta.userAgent ? meta.userAgent : null,
1675
+ }),
1676
+ );
1677
+ },
1678
+ onClientDisconnected(meta) {
1679
+ emitDebug(
1680
+ "relay.session",
1681
+ "downstream_client_disconnected",
1682
+ "info",
1683
+ { sessionKey: sessionService.peekSessionKey() || undefined },
1684
+ () => ({
1685
+ clientId: meta && meta.clientId ? meta.clientId : null,
1686
+ connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1687
+ connectedAtMs: meta && Number.isFinite(meta.connectedAtMs) ? meta.connectedAtMs : null,
1688
+ lifetimeMs: meta && Number.isFinite(meta.lifetimeMs) ? meta.lifetimeMs : null,
1689
+ closeCode: meta && Number.isFinite(meta.closeCode) ? meta.closeCode : null,
1690
+ closeReasonTail: meta && meta.closeReason ? meta.closeReason : null,
1691
+ role: meta && meta.role ? meta.role : null,
1692
+ clientKind: meta && meta.clientKind ? meta.clientKind : null,
1693
+ protocolVersion: meta && meta.protocolVersion ? meta.protocolVersion : null,
1694
+ protocolReason: meta && meta.protocolReason ? meta.protocolReason : null,
1695
+ clientName: meta && meta.clientName ? meta.clientName : null,
1696
+ clientVersion: meta && meta.clientVersion ? meta.clientVersion : null,
1697
+ firstMessageType: meta && meta.firstMessageType ? meta.firstMessageType : null,
1698
+ textMessageCount: meta && Number.isFinite(meta.textMessageCount) ? meta.textMessageCount : null,
1699
+ binaryMessageCount: meta && Number.isFinite(meta.binaryMessageCount) ? meta.binaryMessageCount : null,
1700
+ remoteControlCount: meta && Number.isFinite(meta.remoteControlCount) ? meta.remoteControlCount : null,
1701
+ }),
1702
+ );
1703
+ },
1704
+ onTransportControl(meta) {
1705
+ if (!meta || meta.controlType !== "visibility") {
1706
+ return;
1707
+ }
1708
+ emitDebug(
1709
+ "relay.session",
1710
+ "downstream_transport_visibility",
1711
+ "info",
1712
+ { sessionKey: meta.sessionKey || sessionService.peekSessionKey() || undefined },
1713
+ () => ({
1714
+ clientId: meta && meta.clientId ? meta.clientId : null,
1715
+ state: meta && meta.state ? meta.state : null,
1716
+ connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1717
+ role: meta && meta.role ? meta.role : null,
1718
+ clientKind: meta && meta.clientKind ? meta.clientKind : null,
1719
+ clientName: meta && meta.clientName ? meta.clientName : null,
1720
+ clientVersion: meta && meta.clientVersion ? meta.clientVersion : null,
1721
+ protocolVersion: meta && meta.protocolVersion ? meta.protocolVersion : null,
1722
+ }),
1723
+ );
1724
+ },
1725
+ httpServer: sharedHttpServer,
1726
+ port: opts.port,
1727
+ host: opts.host,
1728
+ token: opts.token,
1729
+ });
1730
+ if (ownedHttpServer) {
1731
+ ownedHttpServer.on("listening", () => {
1732
+ server.wss.emit("listening");
1733
+ });
1734
+ ownedHttpServer.on("error", (err) => {
1735
+ server.wss.emit("error", err);
1736
+ });
1737
+ listenOwnedRelayHttpServer(ownedHttpServer, opts.host, opts.port);
1738
+ }
1739
+
1740
+ // --- Helpers ---
1741
+
1742
+ function buildStatusObject() {
1743
+ return {
1744
+ openclaw:
1745
+ upstreamRuntime && upstreamRuntime.isConnected()
1746
+ ? "connected"
1747
+ : "disconnected",
1748
+ agent: upstreamRuntime ? upstreamRuntime.getAgentName() : null,
1749
+ session: sessionService.ensureSessionKey(),
1750
+ evenAiEnabled: opts.evenAiEnabled === true,
1751
+ };
1752
+ }
1753
+
1754
+ function cachePages(pages) {
1755
+ const nextRevision = pagesRevision + 1;
1756
+ const next = handler.formatPages(pages, { revision: nextRevision });
1757
+ if (next !== cachedPages) {
1758
+ cachedPages = next;
1759
+ pagesRevision = nextRevision;
1760
+ }
1761
+ return cachedPages;
1762
+ }
1763
+
1764
+ function cacheStatus(statusObj) {
1765
+ const nextRevision = statusRevision + 1;
1766
+ const next = handler.formatStatus(statusObj, { revision: nextRevision });
1767
+ if (next !== cachedStatus) {
1768
+ cachedStatus = next;
1769
+ statusRevision = nextRevision;
1770
+ }
1771
+ return cachedStatus;
1772
+ }
1773
+
1774
+ /**
1775
+ * Recompute pages from conversation state, cache, and broadcast.
1776
+ */
1777
+ function broadcastPages() {
1778
+ const pages = conversationState.getPages();
1779
+ const next = cachePages(pages);
1780
+ if (next !== null) {
1781
+ server.broadcast(next);
1782
+ }
1783
+ }
1784
+
1785
+ /**
1786
+ * Build, cache, and broadcast the current status.
1787
+ */
1788
+ function broadcastStatus() {
1789
+ const next = cacheStatus(buildStatusObject());
1790
+ if (next !== null) {
1791
+ server.broadcast(next);
1792
+ }
1793
+ }
1794
+
1795
+ upstreamRuntime = createUpstreamRuntime({
1796
+ logger,
1797
+ gatewayBridge,
1798
+ conversationState,
1799
+ sessionService,
1800
+ handler,
1801
+ emitDebug,
1802
+ broadcastPages,
1803
+ broadcastStatus,
1804
+ broadcastActivity,
1805
+ resetActivityStatusAdapter,
1806
+ modelsCacheTtlMs: opts.modelsCacheTtlMs,
1807
+ getServer() {
1808
+ return server;
1809
+ },
1810
+ getVoiceRuntime() {
1811
+ return null;
1812
+ },
1813
+ });
1814
+
1815
+ if (opts.evenAiEnabled === true) {
1816
+ evenAiRouter = createEvenAiRouter({
1817
+ sessionService,
1818
+ getRoutingMode() {
1819
+ return evenAiSettingsStore.getSnapshot().routingMode;
1820
+ },
1821
+ dedicatedSessionKey: opts.evenAiDedicatedSessionKey,
1822
+ });
1823
+ evenAiRunWaiter = createEvenAiRunWaiter({
1824
+ gatewayBridge,
1825
+ logger,
1826
+ emitDebug,
1827
+ });
1828
+ evenAiEndpoint = createEvenAiEndpoint({
1829
+ logger,
1830
+ httpServer: sharedHttpServer,
1831
+ enabled: true,
1832
+ externallyRouted: opts.evenAiExternalHttpRouting === true,
1833
+ token: opts.evenAiToken,
1834
+ getSettingsSnapshot() {
1835
+ return evenAiSettingsStore.getSnapshot();
1836
+ },
1837
+ getSystemPrompt() {
1838
+ return evenAiSettingsStore.getSnapshot().systemPrompt;
1839
+ },
1840
+ requestTimeoutMs: opts.evenAiRequestTimeoutMs,
1841
+ maxBodyBytes: opts.evenAiMaxBodyBytes,
1842
+ dedupWindowMs: opts.evenAiDedupWindowMs,
1843
+ gatewayBridge,
1844
+ router: evenAiRouter,
1845
+ runWaiter: evenAiRunWaiter,
1846
+ emitDebug,
1847
+ dispatchOcuClawUserSend(params) {
1848
+ return dispatchOcuClawUserSend(params);
1849
+ },
1850
+ emitListenInterceptRecovery(params) {
1851
+ return emitListenInterceptRecovery(params);
1852
+ },
1853
+ hasConnectedAppClient() {
1854
+ return server ? server.getConnectedAppCount() > 0 : false;
1855
+ },
1856
+ recordFirstSentUserMessage(sessionKey, text) {
1857
+ sessionService.recordFirstSentUserMessage(sessionKey, text);
1858
+ },
1859
+ onSessionRouted(route) {
1860
+ if (!route || route.routingMode !== "background_new") {
1861
+ return;
1862
+ }
1863
+ if (
1864
+ typeof evenAiSettingsStore.recordTrackedThrowawayKey === "function" &&
1865
+ typeof route.sessionKey === "string"
1866
+ ) {
1867
+ evenAiSettingsStore.recordTrackedThrowawayKey(route.sessionKey);
1868
+ }
1869
+ },
1870
+ async shouldSeedThinkingForRoute(params) {
1871
+ const route = params && params.route ? params.route : params;
1872
+ const routingMode =
1873
+ route && typeof route.routingMode === "string"
1874
+ ? route.routingMode.trim().toLowerCase()
1875
+ : "active";
1876
+ const thinkingLevel =
1877
+ params && typeof params.thinkingLevel === "string"
1878
+ ? params.thinkingLevel.trim().toLowerCase()
1879
+ : "";
1880
+ const sessionKey =
1881
+ route && typeof route.sessionKey === "string" ? route.sessionKey.trim() : "";
1882
+ if (!thinkingLevel || !sessionKey || routingMode === "active") {
1883
+ return false;
1884
+ }
1885
+ if (routingMode === "background_new") {
1886
+ return true;
1887
+ }
1888
+ if (routingMode !== "background") {
1889
+ return false;
1890
+ }
1891
+ try {
1892
+ const existingSessions = await sessionService.getSessionsByExactKeys([sessionKey]);
1893
+ return existingSessions.length === 0;
1894
+ } catch {
1895
+ return false;
1896
+ }
1897
+ },
1898
+ onSessionActivated(route) {
1899
+ if (!route || !route.sessionChanged) {
1900
+ return;
1901
+ }
1902
+ server.broadcast(handler.formatSessionSwitched(route.sessionKey));
1903
+ if (cachedPages !== null) {
1904
+ server.broadcast(cachedPages);
1905
+ }
1906
+ },
1907
+ isUpstreamConnected() {
1908
+ return upstreamRuntime ? upstreamRuntime.isConnected() : false;
1909
+ },
1910
+ });
1911
+ }
1912
+
1913
+ // --- Public API ---
1914
+
1915
+ return {
1916
+ /**
1917
+ * Start the upstream OpenClaw connection.
1918
+ * The downstream server is already listening from construction.
1919
+ */
1920
+ start() {
1921
+ return Promise.resolve(gatewayBridge.start()).then(() => {
1922
+ prefetchSonioxModels("relay_start").catch((err) => {
1923
+ logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
1924
+ });
1925
+ if (upstreamRuntime && typeof upstreamRuntime.start === "function") {
1926
+ return upstreamRuntime.start();
1927
+ }
1928
+ });
1929
+ },
1930
+
1931
+ /**
1932
+ * Stop the upstream connection and shut down the downstream server.
1933
+ *
1934
+ * @returns {Promise<void>}
1935
+ */
1936
+ stop() {
1937
+ clearSimulateStreamTimers();
1938
+ if (evenAiEndpoint) {
1939
+ evenAiEndpoint.close();
1940
+ }
1941
+ if (evenAiRunWaiter) {
1942
+ evenAiRunWaiter.close();
1943
+ }
1944
+ if (upstreamRuntime) {
1945
+ upstreamRuntime.stop();
1946
+ }
1947
+ gatewayBridge.stop();
1948
+ return Promise.resolve(server.close()).then(() =>
1949
+ closeOwnedRelayHttpServer(ownedHttpServer),
1950
+ );
1951
+ },
1952
+
1953
+ handleEvenAiHttpRequest(req, res) {
1954
+ if (!evenAiEndpoint || typeof evenAiEndpoint.handleRequest !== "function") {
1955
+ return Promise.resolve(false);
1956
+ }
1957
+ return Promise.resolve(evenAiEndpoint.handleRequest(req, res));
1958
+ },
1959
+
1960
+ /** The downstream server instance. */
1961
+ get server() {
1962
+ return server;
1963
+ },
1964
+
1965
+ get httpServer() {
1966
+ return sharedHttpServer;
1967
+ },
1968
+
1969
+ getEvenAiSettingsSnapshot() {
1970
+ return evenAiSettingsStore.getSnapshot();
1971
+ },
1972
+ };
1973
+ }
1974
+
1975
+ const createRelayCore = createRelay;
1976
+
1977
+ export { createRelayCore, createRelay };