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,2747 @@
1
+ import { normalizeEvenAiRoutingMode } from "../even-ai/even-ai-settings-store.js";
2
+ import {
3
+ normalizeOcuClawDefaultModel,
4
+ normalizeOcuClawDefaultThinking,
5
+ normalizeOcuClawSystemPrompt,
6
+ } from "./ocuclaw-settings-store.js";
7
+
8
+ // --- Factory ---
9
+
10
+ function normalizeLogger(logger) {
11
+ if (!logger || typeof logger !== "object") {
12
+ return console;
13
+ }
14
+ return {
15
+ info: typeof logger.info === "function" ? logger.info.bind(logger) : console.log,
16
+ warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : console.warn,
17
+ error: typeof logger.error === "function" ? logger.error.bind(logger) : console.error,
18
+ debug:
19
+ typeof logger.debug === "function" ? logger.debug.bind(logger) : console.debug,
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Create a downstream message handler.
25
+ *
26
+ * Transport-agnostic protocol logic for processing messages from
27
+ * downstream clients (Even App, commander.html). Consumed by relay.js.
28
+ *
29
+ * @param {object} opts
30
+ * @param {(id: string, text: string, sessionKey: string|null, attachment: object|null) => Promise} opts.onSend
31
+ * Forward a user message to the upstream OpenClaw agent.
32
+ * @param {(sender: string, text: string) => Array} opts.onSimulate
33
+ * Inject a fake message into conversation state; returns pages array.
34
+ * @param {(request: object) => Promise<object>|object} [opts.onSimulateStream]
35
+ * Inject deterministic streaming payload (relay-local), then finalize pages.
36
+ * @param {() => Promise<Array>} opts.onNewChat
37
+ * Clear conversation and reset the OpenClaw session; returns empty pages array.
38
+ * @param {() => Promise<{models: Array, fetchedAtMs: number, stale: boolean}>} [opts.onGetModelsCatalog]
39
+ * Return cached model catalog snapshot.
40
+ * @param {() => Promise<{skills: Array, fetchedAtMs: number, stale: boolean}>} [opts.onGetSkillsCatalog]
41
+ * Return cached skills catalog snapshot.
42
+ * @param {() => Promise<{models: Array, fetchedAtMs: number, stale: boolean}>} [opts.onGetSonioxModels]
43
+ * Return cached Soniox model snapshot.
44
+ * @param {() => Promise<object>} [opts.onGetSessionModelConfig]
45
+ * Return current session model controls.
46
+ * @param {(patch: object) => Promise<{status: string, error?: string}>} [opts.onSetSessionModelConfig]
47
+ * Patch current session model controls.
48
+ * @param {() => Promise<object>} [opts.onGetEvenAiSettings]
49
+ * Return current relay-owned Even AI settings.
50
+ * @param {() => Promise<{sessions: Array, dedicatedKey: string}>} [opts.onGetEvenAiSessions]
51
+ * Return the Even AI session-browser payload.
52
+ * @param {(patch: object) => Promise<{status: string, error?: string, settings?: object}>} [opts.onSetEvenAiSettings]
53
+ * Patch relay-owned Even AI settings.
54
+ * @param {() => Promise<object>} [opts.onGetOcuClawSettings]
55
+ * Return current relay-owned OcuClaw settings.
56
+ * @param {(patch: object) => Promise<{status: string, error?: string, settings?: object}>} [opts.onSetOcuClawSettings]
57
+ * Patch relay-owned OcuClaw settings.
58
+ * @param {(clientId: string, payload: object) => Promise<object>|object} [opts.onRequestSonioxTemporaryKey]
59
+ * Mint a short-lived Soniox temporary key for the provided voiceSessionId.
60
+ * @param {() => object} [opts.onGetStatus]
61
+ * Return the current relay status snapshot.
62
+ * @param {() => boolean} opts.isUpstreamConnected
63
+ * Returns true if the OpenClaw gateway connection is active.
64
+ * @param {(clientId: string, payload: object) => void} [opts.onEventDebug]
65
+ * Optional structured client debug-event callback.
66
+ * @returns {object} Handler instance
67
+ */
68
+ function createDownstreamHandler(opts) {
69
+ const logger = normalizeLogger(opts.logger);
70
+ const externalDebugToolsEnabled = opts.externalDebugToolsEnabled !== false;
71
+ const onSend = opts.onSend;
72
+ const onSimulate = opts.onSimulate;
73
+ const onSimulateStream = opts.onSimulateStream || null;
74
+ const onNewChat = opts.onNewChat;
75
+ const onGetSessions = opts.onGetSessions;
76
+ const onSwitchSession = opts.onSwitchSession;
77
+ const onNewSession = opts.onNewSession;
78
+ const onSlashCommand = opts.onSlashCommand;
79
+ const onGetModelsCatalog = opts.onGetModelsCatalog;
80
+ const onGetSkillsCatalog = opts.onGetSkillsCatalog;
81
+ const onGetSonioxModels = opts.onGetSonioxModels || null;
82
+ const onGetSessionModelConfig = opts.onGetSessionModelConfig;
83
+ const onSetSessionModelConfig = opts.onSetSessionModelConfig;
84
+ const onGetEvenAiSettings = opts.onGetEvenAiSettings;
85
+ const onGetEvenAiSessions = opts.onGetEvenAiSessions;
86
+ const onSetEvenAiSettings = opts.onSetEvenAiSettings;
87
+ const onGetOcuClawSettings = opts.onGetOcuClawSettings;
88
+ const onSetOcuClawSettings = opts.onSetOcuClawSettings;
89
+ const onRequestSonioxTemporaryKey = opts.onRequestSonioxTemporaryKey || null;
90
+ const onGetStatus = opts.onGetStatus || null;
91
+ const isUpstreamConnected = opts.isUpstreamConnected;
92
+ const onConsoleLog = opts.onConsoleLog || null;
93
+ const onApprovalResolve = opts.onApprovalResolve || null;
94
+ const onDebugSet = opts.onDebugSet || null;
95
+ const onDebugDump = opts.onDebugDump || null;
96
+ const onEventDebug = opts.onEventDebug || null;
97
+ const onRemoteControl = opts.onRemoteControl || null;
98
+ const getSnapshotRevision = opts.getSnapshotRevision || null;
99
+
100
+ /** Client IDs subscribed to raw protocol frame forwarding. */
101
+ const protocolSubscribers = new Set();
102
+ const APPROVAL_DECISIONS = new Set(["allow-once", "allow-always", "deny"]);
103
+ const approvalResolveCacheTtlMs = Number.isFinite(opts.approvalResolveCacheTtlMs)
104
+ ? Math.max(1_000, Math.floor(opts.approvalResolveCacheTtlMs))
105
+ : 30_000;
106
+ const approvalResolveCacheMaxEntries = Number.isFinite(opts.approvalResolveCacheMaxEntries)
107
+ ? Math.max(10, Math.floor(opts.approvalResolveCacheMaxEntries))
108
+ : 500;
109
+ const EXTERNAL_DEBUG_TOOLS_DISABLED_ERROR =
110
+ "external debug tools are disabled by plugin config";
111
+ /** @type {Map<string, {expiresAtMs: number, promise: Promise<object>}>} */
112
+ const approvalResolveCache = new Map();
113
+ const APP_PROTOCOL = {
114
+ activity: "ocuclaw.activity.update",
115
+ approvalRequest: "ocuclaw.approval.request",
116
+ approvalResolve: "ocuclaw.approval.resolve",
117
+ approvalResolveAck: "ocuclaw.approval.resolve.ack",
118
+ approvalResolved: "ocuclaw.approval.resolved",
119
+ commandSlash: "ocuclaw.command.slash",
120
+ debugConfigSnapshot: "ocuclaw.debug.config.snapshot",
121
+ debugEvent: "ocuclaw.debug.event",
122
+ evenAiSettingsGet: "ocuclaw.evenai.settings.get",
123
+ evenAiSessionList: "ocuclaw.evenai.session.list",
124
+ evenAiSessionListResult: "ocuclaw.evenai.session.list.result",
125
+ evenAiSettingsSet: "ocuclaw.evenai.settings.set",
126
+ evenAiSettingsSetAck: "ocuclaw.evenai.settings.set.ack",
127
+ evenAiSettingsSnapshot: "ocuclaw.evenai.settings.snapshot",
128
+ ocuClawSettingsGet: "ocuclaw.settings.get",
129
+ ocuClawSettingsSet: "ocuclaw.settings.set",
130
+ ocuClawSettingsSetAck: "ocuclaw.settings.set.ack",
131
+ ocuClawSettingsSnapshot: "ocuclaw.settings.snapshot",
132
+ messageSend: "ocuclaw.message.send",
133
+ messageSendAck: "ocuclaw.message.send.ack",
134
+ messageStreamDelta: "ocuclaw.message.stream.delta",
135
+ modelCatalogGet: "ocuclaw.model.catalog.get",
136
+ modelCatalogSnapshot: "ocuclaw.model.catalog.snapshot",
137
+ skillsCatalogGet: "ocuclaw.skills.catalog.get",
138
+ skillsCatalogSnapshot: "ocuclaw.skills.catalog.snapshot",
139
+ pages: "ocuclaw.view.pages.snapshot",
140
+ protocolSubscribe: "ocuclaw.protocol.tap.subscribe",
141
+ protocolFrame: "ocuclaw.protocol.tap.frame",
142
+ remoteControl: "ocuclaw.remote.control",
143
+ requestSonioxTemporaryKey: "requestSonioxTemporaryKey",
144
+ sonioxModelsGet: "ocuclaw.voice.soniox.models.get",
145
+ sonioxModelsSnapshot: "ocuclaw.voice.soniox.models.snapshot",
146
+ sessionConfigGet: "ocuclaw.session.config.get",
147
+ sessionConfigSet: "ocuclaw.session.config.set",
148
+ sessionConfigSetAck: "ocuclaw.session.config.set.ack",
149
+ sessionConfigSnapshot: "ocuclaw.session.config.snapshot",
150
+ sessionCreate: "ocuclaw.session.create",
151
+ sessionList: "ocuclaw.session.list",
152
+ sessionListResult: "ocuclaw.session.list.result",
153
+ sessionReset: "ocuclaw.session.reset",
154
+ sessionSwitch: "ocuclaw.session.switch",
155
+ sessionSwitchApplied: "ocuclaw.session.switch.applied",
156
+ sonioxTemporaryKey: "sonioxTemporaryKey",
157
+ sonioxTemporaryKeyError: "sonioxTemporaryKeyError",
158
+ status: "ocuclaw.runtime.status",
159
+ statusGet: "ocuclaw.runtime.status.get",
160
+ };
161
+
162
+ // --- Format helpers ---
163
+
164
+ /**
165
+ * Format a pages message for broadcast.
166
+ *
167
+ * @param {Array<{content: string, subPage: [number,number]|null}>} pages
168
+ * @param {{revision?: number}} [meta]
169
+ * @returns {string} JSON string
170
+ */
171
+ function formatPages(pages, meta) {
172
+ const msg = { type: APP_PROTOCOL.pages, pages };
173
+ const fallbackRevision = getSnapshotRevision
174
+ ? getSnapshotRevision("pages")
175
+ : null;
176
+ const revision = Number.isFinite(meta && meta.revision)
177
+ ? Math.floor(meta.revision)
178
+ : Number.isFinite(fallbackRevision)
179
+ ? Math.floor(fallbackRevision)
180
+ : null;
181
+ if (revision !== null) {
182
+ msg.revision = revision;
183
+ }
184
+ return JSON.stringify(msg);
185
+ }
186
+
187
+ /**
188
+ * Format a status message for broadcast.
189
+ *
190
+ * @param {object} status - Status fields (openclaw, agent, session, etc.)
191
+ * @param {{revision?: number}} [meta]
192
+ * @returns {string} JSON string
193
+ */
194
+ function formatStatus(status, meta) {
195
+ const msg = { ...status, type: APP_PROTOCOL.status };
196
+ const fallbackRevision = getSnapshotRevision
197
+ ? getSnapshotRevision("status")
198
+ : null;
199
+ const revision = Number.isFinite(meta && meta.revision)
200
+ ? Math.floor(meta.revision)
201
+ : Number.isFinite(fallbackRevision)
202
+ ? Math.floor(fallbackRevision)
203
+ : null;
204
+ if (revision !== null) {
205
+ msg.revision = revision;
206
+ }
207
+ return JSON.stringify(msg);
208
+ }
209
+
210
+ /**
211
+ * Format an activity message for broadcast.
212
+ *
213
+ * @param {object} activity - Activity fields (state, tool, etc.)
214
+ * @returns {string} JSON string
215
+ */
216
+ function formatActivity(activity) {
217
+ return JSON.stringify({ ...activity, type: APP_PROTOCOL.activity });
218
+ }
219
+
220
+ /**
221
+ * Format an error message for unicast.
222
+ *
223
+ * @param {string} error
224
+ * @returns {string} JSON string
225
+ */
226
+ function formatError(error) {
227
+ return JSON.stringify({
228
+ type: "error",
229
+ error: error || "Unknown error",
230
+ });
231
+ }
232
+
233
+ function isExternalDebugToolMessageType(messageType) {
234
+ return (
235
+ messageType === "debug-set" ||
236
+ messageType === "debug-dump" ||
237
+ messageType === "remote-control"
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Format a send acknowledgement for unicast.
243
+ *
244
+ * @param {string} id - Message ID being acknowledged
245
+ * @param {string} status - "accepted" or "rejected"
246
+ * @param {string} [error] - Error message (for rejected)
247
+ * @param {string} [errorCode] - Structured error code (for deterministic client handling)
248
+ * @returns {string} JSON string
249
+ */
250
+ function formatSendAck(id, status, error, errorCode) {
251
+ const msg = { type: APP_PROTOCOL.messageSendAck, requestId: id, status };
252
+ if (error !== undefined) {
253
+ msg.error = error;
254
+ }
255
+ if (errorCode !== undefined) {
256
+ msg.errorCode = errorCode;
257
+ }
258
+ return JSON.stringify(msg);
259
+ }
260
+
261
+ /**
262
+ * Format a protocol frame for forwarding to subscribers.
263
+ *
264
+ * @param {string} direction - "in" or "out"
265
+ * @param {object} frame - Raw protocol frame
266
+ * @returns {string} JSON string
267
+ */
268
+ function formatProtocol(direction, frame) {
269
+ return JSON.stringify({
270
+ type: APP_PROTOCOL.protocolFrame,
271
+ direction,
272
+ frame,
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Format a streaming text message for broadcast during agent runs.
278
+ *
279
+ * @param {string} text - Streaming assistant text
280
+ * @returns {string} JSON string
281
+ */
282
+ function formatStreaming(text) {
283
+ return JSON.stringify({ type: APP_PROTOCOL.messageStreamDelta, text });
284
+ }
285
+
286
+ /**
287
+ * Format a sessions list message for unicast response.
288
+ *
289
+ * @param {Array<{key: string, updatedAt: number, preview: string, firstUserMessage?: string}>} sessions
290
+ * @returns {string} JSON string
291
+ */
292
+ function formatSessions(sessions) {
293
+ return JSON.stringify({ type: APP_PROTOCOL.sessionListResult, sessions });
294
+ }
295
+
296
+ /**
297
+ * Format a session-switched confirmation for broadcast.
298
+ *
299
+ * @param {string} sessionKey
300
+ * @returns {string} JSON string
301
+ */
302
+ function formatSessionSwitched(sessionKey) {
303
+ return JSON.stringify({
304
+ type: APP_PROTOCOL.sessionSwitchApplied,
305
+ sessionKey,
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Format a model catalog snapshot for unicast.
311
+ *
312
+ * @param {{models: Array, fetchedAtMs: number, stale: boolean}} payload
313
+ * @returns {string}
314
+ */
315
+ function formatModelsCatalog(payload) {
316
+ return JSON.stringify({
317
+ type: APP_PROTOCOL.modelCatalogSnapshot,
318
+ models: Array.isArray(payload && payload.models) ? payload.models : [],
319
+ fetchedAtMs:
320
+ Number.isFinite(payload && payload.fetchedAtMs)
321
+ ? Math.floor(payload.fetchedAtMs)
322
+ : 0,
323
+ stale: !!(payload && payload.stale),
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Format a cached skills catalog snapshot for unicast.
329
+ *
330
+ * @param {{skills?: Array, fetchedAtMs?: number, stale?: boolean}} payload
331
+ * @returns {string}
332
+ */
333
+ function formatSkillsCatalog(payload) {
334
+ return JSON.stringify({
335
+ type: APP_PROTOCOL.skillsCatalogSnapshot,
336
+ skills: Array.isArray(payload && payload.skills) ? payload.skills : [],
337
+ fetchedAtMs:
338
+ Number.isFinite(payload && payload.fetchedAtMs)
339
+ ? Math.floor(payload.fetchedAtMs)
340
+ : Date.now(),
341
+ stale: !!(payload && payload.stale),
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Format a Soniox model snapshot for unicast.
347
+ *
348
+ * @param {{models: Array, fetchedAtMs: number, stale: boolean}} payload
349
+ * @returns {string}
350
+ */
351
+ function formatSonioxModels(payload) {
352
+ return JSON.stringify({
353
+ type: APP_PROTOCOL.sonioxModelsSnapshot,
354
+ models: Array.isArray(payload && payload.models) ? payload.models : [],
355
+ fetchedAtMs:
356
+ Number.isFinite(payload && payload.fetchedAtMs)
357
+ ? Math.floor(payload.fetchedAtMs)
358
+ : 0,
359
+ stale: !!(payload && payload.stale),
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Format current session model controls.
365
+ *
366
+ * @param {object} payload
367
+ * @returns {string}
368
+ */
369
+ function formatSessionModelConfig(payload) {
370
+ return JSON.stringify({
371
+ type: APP_PROTOCOL.sessionConfigSnapshot,
372
+ sessionKey: (payload && payload.sessionKey) || "",
373
+ modelProvider:
374
+ payload && typeof payload.modelProvider === "string"
375
+ ? payload.modelProvider
376
+ : null,
377
+ model:
378
+ payload && typeof payload.model === "string" ? payload.model : null,
379
+ thinkingLevel:
380
+ payload && typeof payload.thinkingLevel === "string"
381
+ ? payload.thinkingLevel
382
+ : "",
383
+ reasoningLevel:
384
+ payload && typeof payload.reasoningLevel === "string"
385
+ ? payload.reasoningLevel
386
+ : "off",
387
+ verboseLevel:
388
+ payload && typeof payload.verboseLevel === "string"
389
+ ? payload.verboseLevel
390
+ : "off",
391
+ });
392
+ }
393
+
394
+ /**
395
+ * Format a session model config write acknowledgement.
396
+ *
397
+ * @param {{status: string, error?: string}} payload
398
+ * @returns {string}
399
+ */
400
+ function formatSessionModelConfigAck(payload) {
401
+ const out = {
402
+ type: APP_PROTOCOL.sessionConfigSetAck,
403
+ status:
404
+ payload && typeof payload.status === "string"
405
+ ? payload.status
406
+ : "rejected",
407
+ };
408
+ if (payload && payload.error !== undefined) {
409
+ out.error = payload.error;
410
+ }
411
+ return JSON.stringify(out);
412
+ }
413
+
414
+ /**
415
+ * Format the current relay-owned Even AI settings snapshot.
416
+ *
417
+ * @param {object} payload
418
+ * @returns {string}
419
+ */
420
+ function formatEvenAiSettings(payload) {
421
+ return JSON.stringify({
422
+ type: APP_PROTOCOL.evenAiSettingsSnapshot,
423
+ routingMode: normalizeEvenAiRoutingMode(
424
+ payload && typeof payload.routingMode === "string"
425
+ ? payload.routingMode
426
+ : undefined,
427
+ ),
428
+ systemPrompt:
429
+ payload && typeof payload.systemPrompt === "string"
430
+ ? payload.systemPrompt
431
+ : "",
432
+ defaultModel:
433
+ payload && typeof payload.defaultModel === "string"
434
+ ? payload.defaultModel
435
+ : "",
436
+ defaultThinking:
437
+ payload && typeof payload.defaultThinking === "string"
438
+ ? payload.defaultThinking
439
+ : "",
440
+ listenEnabled: payload && payload.listenEnabled === true,
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Format an Even AI settings write acknowledgement.
446
+ *
447
+ * @param {{status: string, error?: string}} payload
448
+ * @returns {string}
449
+ */
450
+ function formatEvenAiSettingsAck(payload) {
451
+ const out = {
452
+ type: APP_PROTOCOL.evenAiSettingsSetAck,
453
+ status:
454
+ payload && typeof payload.status === "string"
455
+ ? payload.status
456
+ : "rejected",
457
+ };
458
+ if (payload && payload.error !== undefined) {
459
+ out.error = payload.error;
460
+ }
461
+ return JSON.stringify(out);
462
+ }
463
+
464
+ /**
465
+ * Format the current relay-owned OcuClaw settings snapshot.
466
+ *
467
+ * @param {object} payload
468
+ * @returns {string}
469
+ */
470
+ function formatOcuClawSettings(payload) {
471
+ return JSON.stringify({
472
+ type: APP_PROTOCOL.ocuClawSettingsSnapshot,
473
+ systemPrompt: normalizeOcuClawSystemPrompt(
474
+ payload && typeof payload.systemPrompt === "string"
475
+ ? payload.systemPrompt
476
+ : undefined,
477
+ ),
478
+ defaultModel: normalizeOcuClawDefaultModel(
479
+ payload && typeof payload.defaultModel === "string"
480
+ ? payload.defaultModel
481
+ : undefined,
482
+ ),
483
+ defaultThinking: normalizeOcuClawDefaultThinking(
484
+ payload && typeof payload.defaultThinking === "string"
485
+ ? payload.defaultThinking
486
+ : undefined,
487
+ ),
488
+ });
489
+ }
490
+
491
+ /**
492
+ * Format an OcuClaw settings write acknowledgement.
493
+ *
494
+ * @param {{status: string, error?: string}} payload
495
+ * @returns {string}
496
+ */
497
+ function formatOcuClawSettingsAck(payload) {
498
+ const out = {
499
+ type: APP_PROTOCOL.ocuClawSettingsSetAck,
500
+ status:
501
+ payload && typeof payload.status === "string"
502
+ ? payload.status
503
+ : "rejected",
504
+ };
505
+ if (payload && payload.error !== undefined) {
506
+ out.error = payload.error;
507
+ }
508
+ return JSON.stringify(out);
509
+ }
510
+
511
+ /**
512
+ * Format the Even AI session-browser result payload.
513
+ *
514
+ * @param {{sessions?: Array, dedicatedKey?: string}} payload
515
+ * @returns {string}
516
+ */
517
+ function formatEvenAiSessions(payload) {
518
+ return JSON.stringify({
519
+ type: APP_PROTOCOL.evenAiSessionListResult,
520
+ sessions: Array.isArray(payload && payload.sessions) ? payload.sessions : [],
521
+ dedicatedKey:
522
+ payload && typeof payload.dedicatedKey === "string"
523
+ ? payload.dedicatedKey
524
+ : "ocuclaw:even-ai",
525
+ });
526
+ }
527
+
528
+ /**
529
+ * Format an exec approval request for broadcast.
530
+ *
531
+ * @param {object} data - Approval payload from gateway
532
+ * @returns {string} JSON string
533
+ */
534
+ function formatApproval(data) {
535
+ const request = data && data.request ? data.request : {};
536
+ return JSON.stringify({
537
+ type: APP_PROTOCOL.approvalRequest,
538
+ id: data.id,
539
+ requestId:
540
+ (data && typeof data.requestId === "string" && data.requestId) ||
541
+ (request && typeof request.requestId === "string" && request.requestId) ||
542
+ null,
543
+ command: request.command || "",
544
+ cwd: request.cwd || null,
545
+ agentId: request.agentId || null,
546
+ host: request.host || null,
547
+ security: request.security || null,
548
+ ask: request.ask || null,
549
+ resolvedPath: request.resolvedPath || null,
550
+ sessionKey: request.sessionKey || null,
551
+ createdAtMs: data.createdAtMs || 0,
552
+ expiresAtMs: data.expiresAtMs || 0,
553
+ });
554
+ }
555
+
556
+ /**
557
+ * Format an exec approval resolution for broadcast.
558
+ *
559
+ * @param {object} data - Resolution payload from gateway
560
+ * @returns {string} JSON string
561
+ */
562
+ function formatApprovalResolved(data) {
563
+ return JSON.stringify({
564
+ type: APP_PROTOCOL.approvalResolved,
565
+ id: data.id,
566
+ requestId:
567
+ data && typeof data.requestId === "string" && data.requestId
568
+ ? data.requestId
569
+ : null,
570
+ decision: data.decision || null,
571
+ });
572
+ }
573
+
574
+ /**
575
+ * Format an approval-response acknowledgement for unicast.
576
+ *
577
+ * @param {object} data
578
+ * @returns {string}
579
+ */
580
+ function formatApprovalResponseAck(data) {
581
+ return JSON.stringify({
582
+ type: APP_PROTOCOL.approvalResolveAck,
583
+ id: data && data.id ? data.id : null,
584
+ decision: data && data.decision ? data.decision : null,
585
+ requestId:
586
+ data && data.requestId !== undefined && data.requestId !== null
587
+ ? data.requestId
588
+ : null,
589
+ status:
590
+ data && typeof data.status === "string" && data.status
591
+ ? data.status
592
+ : "rejected",
593
+ code:
594
+ data && typeof data.code === "string" && data.code
595
+ ? data.code
596
+ : null,
597
+ message:
598
+ data && typeof data.message === "string" && data.message
599
+ ? data.message
600
+ : null,
601
+ idempotent:
602
+ data && data.idempotent !== undefined
603
+ ? !!data.idempotent
604
+ : false,
605
+ });
606
+ }
607
+
608
+ /**
609
+ * Format a transcription update for broadcast during voice mode.
610
+ *
611
+ * @param {string} text - Current transcription text
612
+ * @param {boolean} isFinal - Whether this is a final transcription
613
+ * @returns {string} JSON string
614
+ */
615
+ function formatTranscription(text, isFinal) {
616
+ return JSON.stringify({ type: "transcription", text, final: isFinal });
617
+ }
618
+
619
+ /**
620
+ * Format a committed listen handoff update for broadcast during voice mode.
621
+ *
622
+ * @param {string} text - Final committed text sent upstream
623
+ * @param {"manual"|"endpoint"} source - Commit source path
624
+ * @param {string|null} sessionKey - Active relay session key for diagnostics
625
+ * @returns {string} JSON string
626
+ */
627
+ function formatListenCommitted(text, source, sessionKey) {
628
+ return JSON.stringify({
629
+ type: "listen-committed",
630
+ text,
631
+ source,
632
+ sessionKey: sessionKey || null,
633
+ });
634
+ }
635
+
636
+ /**
637
+ * Format a listen-ended message for broadcast.
638
+ * @returns {string} JSON string
639
+ */
640
+ function formatListenEnded() {
641
+ return JSON.stringify({ type: "listen-ended" });
642
+ }
643
+
644
+ /**
645
+ * Format a listen-error message for broadcast.
646
+ * @param {string} error - Error description
647
+ * @param {string|null} [code] - Structured error code
648
+ * @returns {string} JSON string
649
+ */
650
+ function formatListenError(error, code = null) {
651
+ const msg = { type: "listen-error", error };
652
+ if (typeof code === "string" && code.trim()) {
653
+ msg.code = code.trim();
654
+ }
655
+ return JSON.stringify(msg);
656
+ }
657
+
658
+ /**
659
+ * Format a listen-ready message for broadcast (agent done, client can auto-restart).
660
+ * @returns {string} JSON string
661
+ */
662
+ function formatListenReady() {
663
+ return JSON.stringify({ type: "listen-ready" });
664
+ }
665
+
666
+ /**
667
+ * Format a temporary Soniox key lease for unicast back to the requesting client.
668
+ *
669
+ * @param {{voiceSessionId: string, temporaryKey: string, expiresAtMs: number}} payload
670
+ * @returns {string}
671
+ */
672
+ function formatSonioxTemporaryKey(payload) {
673
+ return JSON.stringify({
674
+ type: APP_PROTOCOL.sonioxTemporaryKey,
675
+ voiceSessionId:
676
+ payload && typeof payload.voiceSessionId === "string"
677
+ ? payload.voiceSessionId
678
+ : "",
679
+ temporaryKey:
680
+ payload && typeof payload.temporaryKey === "string"
681
+ ? payload.temporaryKey
682
+ : "",
683
+ expiresAtMs:
684
+ payload && Number.isFinite(payload.expiresAtMs)
685
+ ? Math.floor(payload.expiresAtMs)
686
+ : 0,
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Format a Soniox temporary-key failure for unicast back to the requesting client.
692
+ *
693
+ * @param {{voiceSessionId: string, error: string, code?: string|null}} payload
694
+ * @returns {string}
695
+ */
696
+ function formatSonioxTemporaryKeyError(payload) {
697
+ const msg = {
698
+ type: APP_PROTOCOL.sonioxTemporaryKeyError,
699
+ voiceSessionId:
700
+ payload && typeof payload.voiceSessionId === "string"
701
+ ? payload.voiceSessionId
702
+ : "",
703
+ error:
704
+ payload && typeof payload.error === "string" && payload.error.trim()
705
+ ? payload.error.trim()
706
+ : "Soniox temporary-key request failed",
707
+ };
708
+ const code =
709
+ payload && typeof payload.code === "string" && payload.code.trim()
710
+ ? payload.code.trim()
711
+ : "";
712
+ if (code) {
713
+ msg.code = code;
714
+ }
715
+ return JSON.stringify(msg);
716
+ }
717
+
718
+ function normalizeSonioxTemporaryKeyErrorCode(err) {
719
+ const message =
720
+ err && typeof err.message === "string" && err.message.trim()
721
+ ? err.message.trim()
722
+ : "";
723
+ const lowered = message.toLowerCase();
724
+ if (!message) return "soniox_temp_key_request_failed";
725
+ if (lowered.includes("is not available")) {
726
+ return "soniox_temp_key_unavailable";
727
+ }
728
+ if (lowered.includes("voicesessionid is required")) {
729
+ return "soniox_temp_key_invalid_request";
730
+ }
731
+ if (lowered.includes("api key is not configured")) {
732
+ return "soniox_temp_key_not_configured";
733
+ }
734
+ if (lowered.includes("fetch is not available")) {
735
+ return "soniox_temp_key_fetch_unavailable";
736
+ }
737
+ if (lowered.includes("missing temporarykey") || lowered.includes("missing expiresatms")) {
738
+ return "soniox_temp_key_invalid_response";
739
+ }
740
+ const statusMatch = lowered.match(/\((\d{3})\)/);
741
+ if (statusMatch) {
742
+ return `soniox_temp_key_http_${statusMatch[1]}`;
743
+ }
744
+ return "soniox_temp_key_request_failed";
745
+ }
746
+
747
+ /**
748
+ * Format a debug-set response for unicast.
749
+ *
750
+ * @param {object} data
751
+ * @returns {string}
752
+ */
753
+ function formatDebugSet(data) {
754
+ return JSON.stringify({
755
+ type: "debug-set",
756
+ ...data,
757
+ });
758
+ }
759
+
760
+ /**
761
+ * Format a debug-dump response for unicast.
762
+ *
763
+ * @param {object} data
764
+ * @returns {string}
765
+ */
766
+ function formatDebugDump(data) {
767
+ return JSON.stringify({
768
+ type: "debug-dump",
769
+ ...data,
770
+ });
771
+ }
772
+
773
+ /**
774
+ * Format the current relay debug-config snapshot for app/WebUI clients.
775
+ *
776
+ * @param {{serverNowMs: number, enabled: Array<{cat: string, expiresAtMs: number}>}} data
777
+ * @returns {string}
778
+ */
779
+ function formatDebugConfigSnapshot(data) {
780
+ const enabled = Array.isArray(data && data.enabled)
781
+ ? data.enabled
782
+ .filter(
783
+ (entry) =>
784
+ entry &&
785
+ typeof entry.cat === "string" &&
786
+ entry.cat.trim() &&
787
+ Number.isFinite(Number(entry.expiresAtMs)),
788
+ )
789
+ .map((entry) => ({
790
+ cat: entry.cat.trim(),
791
+ expiresAtMs: Math.floor(Number(entry.expiresAtMs)),
792
+ }))
793
+ : [];
794
+ return JSON.stringify({
795
+ type: APP_PROTOCOL.debugConfigSnapshot,
796
+ serverNowMs:
797
+ Number.isFinite(data && data.serverNowMs)
798
+ ? Math.floor(data.serverNowMs)
799
+ : 0,
800
+ enabled,
801
+ });
802
+ }
803
+
804
+ /**
805
+ * Format a remote-control command for broadcast to app clients.
806
+ *
807
+ * @param {object} data
808
+ * @returns {string}
809
+ */
810
+ function formatRemoteControl(data) {
811
+ return JSON.stringify({
812
+ type: APP_PROTOCOL.remoteControl,
813
+ ...data,
814
+ });
815
+ }
816
+
817
+ /**
818
+ * Format a remote-control acknowledgement for unicast.
819
+ *
820
+ * @param {object} data
821
+ * @returns {string}
822
+ */
823
+ function formatRemoteControlAck(data) {
824
+ return JSON.stringify({
825
+ type: "remote-control-ack",
826
+ ...data,
827
+ });
828
+ }
829
+
830
+ function normalizeCategories(raw, fieldName) {
831
+ if (raw === undefined || raw === null) return [];
832
+ if (!Array.isArray(raw)) {
833
+ throw new Error(`${fieldName} must be an array`);
834
+ }
835
+ const dedup = new Set();
836
+ for (const entry of raw) {
837
+ if (typeof entry !== "string") {
838
+ throw new Error(`${fieldName} entries must be strings`);
839
+ }
840
+ const cat = entry.trim();
841
+ if (!cat) {
842
+ throw new Error(`${fieldName} entries must be non-empty strings`);
843
+ }
844
+ dedup.add(cat);
845
+ }
846
+ return Array.from(dedup.values());
847
+ }
848
+
849
+ function parseDebugSet(msg) {
850
+ const hasEnableDisable = msg.enable !== undefined || msg.disable !== undefined;
851
+ const enable = hasEnableDisable
852
+ ? normalizeCategories(msg.enable, "enable")
853
+ : msg.enabled === false
854
+ ? []
855
+ : normalizeCategories(msg.categories, "categories");
856
+ const disable = hasEnableDisable
857
+ ? normalizeCategories(msg.disable, "disable")
858
+ : msg.enabled === false
859
+ ? normalizeCategories(msg.categories, "categories")
860
+ : [];
861
+ if (enable.length === 0 && disable.length === 0) {
862
+ throw new Error("debug-set requires categories to enable and/or disable");
863
+ }
864
+ if (
865
+ msg.ttlMs !== undefined &&
866
+ (!Number.isFinite(Number(msg.ttlMs)) || Number(msg.ttlMs) <= 0)
867
+ ) {
868
+ throw new Error("debug-set ttlMs must be a positive number");
869
+ }
870
+ return {
871
+ enable,
872
+ disable,
873
+ ttlMs: msg.ttlMs === undefined ? undefined : Number(msg.ttlMs),
874
+ };
875
+ }
876
+
877
+ function parseDebugDump(msg) {
878
+ const categories = normalizeCategories(msg.categories, "categories");
879
+ if (
880
+ msg.limit !== undefined &&
881
+ (!Number.isFinite(Number(msg.limit)) || Number(msg.limit) <= 0)
882
+ ) {
883
+ throw new Error("debug-dump limit must be a positive number");
884
+ }
885
+ if (
886
+ msg.sinceMs !== undefined &&
887
+ (!Number.isFinite(Number(msg.sinceMs)) || Number(msg.sinceMs) < 0)
888
+ ) {
889
+ throw new Error("debug-dump sinceMs must be a non-negative number");
890
+ }
891
+ if (
892
+ msg.sinceAgeMs !== undefined &&
893
+ (!Number.isFinite(Number(msg.sinceAgeMs)) || Number(msg.sinceAgeMs) < 0)
894
+ ) {
895
+ throw new Error("debug-dump sinceAgeMs must be a non-negative number");
896
+ }
897
+ if (
898
+ msg.untilMs !== undefined &&
899
+ (!Number.isFinite(Number(msg.untilMs)) || Number(msg.untilMs) < 0)
900
+ ) {
901
+ throw new Error("debug-dump untilMs must be a non-negative number");
902
+ }
903
+ if (msg.redaction !== undefined) {
904
+ if (typeof msg.redaction !== "string") {
905
+ throw new Error("debug-dump redaction must be one of: safe, full");
906
+ }
907
+ const normalized = msg.redaction.trim().toLowerCase();
908
+ if (normalized !== "safe" && normalized !== "full") {
909
+ throw new Error("debug-dump redaction must be one of: safe, full");
910
+ }
911
+ }
912
+ return {
913
+ categories,
914
+ limit: msg.limit === undefined ? undefined : Number(msg.limit),
915
+ sinceMs: msg.sinceMs === undefined ? undefined : Number(msg.sinceMs),
916
+ sinceAgeMs: msg.sinceAgeMs === undefined ? undefined : Number(msg.sinceAgeMs),
917
+ untilMs: msg.untilMs === undefined ? undefined : Number(msg.untilMs),
918
+ redaction:
919
+ msg.redaction === undefined
920
+ ? undefined
921
+ : msg.redaction.trim().toLowerCase(),
922
+ };
923
+ }
924
+
925
+ function parseEventDebug(msg) {
926
+ if (!msg || typeof msg !== "object") return null;
927
+ if (typeof msg.cat !== "string" || !msg.cat.trim()) return null;
928
+ if (typeof msg.event !== "string" || !msg.event.trim()) return null;
929
+ const severity =
930
+ msg.severity === "info" ||
931
+ msg.severity === "warn" ||
932
+ msg.severity === "error"
933
+ ? msg.severity
934
+ : "debug";
935
+ return {
936
+ cat: msg.cat.trim(),
937
+ event: msg.event.trim(),
938
+ severity,
939
+ screen:
940
+ typeof msg.screen === "string" && msg.screen.trim()
941
+ ? msg.screen.trim()
942
+ : null,
943
+ runId:
944
+ typeof msg.runId === "string" && msg.runId.trim()
945
+ ? msg.runId.trim()
946
+ : null,
947
+ sessionKey:
948
+ typeof msg.sessionKey === "string" && msg.sessionKey.trim()
949
+ ? msg.sessionKey.trim()
950
+ : null,
951
+ data:
952
+ msg.data && typeof msg.data === "object" && !Array.isArray(msg.data)
953
+ ? msg.data
954
+ : { value: msg.data ?? null },
955
+ };
956
+ }
957
+
958
+ function normalizeRemoteButton(raw) {
959
+ if (typeof raw !== "string" || !raw.trim()) {
960
+ throw new Error("remote-control button is required");
961
+ }
962
+ const normalized = raw.trim().toLowerCase();
963
+ if (
964
+ normalized === "click" ||
965
+ normalized === "tap" ||
966
+ normalized === "double-click" ||
967
+ normalized === "double_click" ||
968
+ normalized === "doubleclick" ||
969
+ normalized === "double-tap" ||
970
+ normalized === "double_tap" ||
971
+ normalized === "doubletap" ||
972
+ normalized === "scroll-up" ||
973
+ normalized === "scroll_up" ||
974
+ normalized === "scrollup" ||
975
+ normalized === "up" ||
976
+ normalized === "scroll-down" ||
977
+ normalized === "scroll_down" ||
978
+ normalized === "scrolldown" ||
979
+ normalized === "down"
980
+ ) {
981
+ if (normalized === "tap") return "click";
982
+ if (
983
+ normalized === "double_click" ||
984
+ normalized === "doubleclick" ||
985
+ normalized === "double-tap" ||
986
+ normalized === "double_tap" ||
987
+ normalized === "doubletap"
988
+ ) {
989
+ return "double-click";
990
+ }
991
+ if (normalized === "scroll_up" || normalized === "scrollup" || normalized === "up") {
992
+ return "scroll-up";
993
+ }
994
+ if (normalized === "scroll_down" || normalized === "scrolldown" || normalized === "down") {
995
+ return "scroll-down";
996
+ }
997
+ return normalized;
998
+ }
999
+ throw new Error(`unsupported remote-control button: ${raw}`);
1000
+ }
1001
+
1002
+ function normalizeRemoteRelayAction(raw) {
1003
+ if (typeof raw !== "string" || !raw.trim()) {
1004
+ throw new Error("remote-control relayAction is required");
1005
+ }
1006
+ const normalized = raw.trim().toLowerCase();
1007
+ if (
1008
+ normalized === "perf-conversation-upgrade-probe" ||
1009
+ normalized === "perf_conversation_upgrade_probe" ||
1010
+ normalized === "perfconversationupgradeprobe" ||
1011
+ normalized === "conversation-upgrade-probe" ||
1012
+ normalized === "conversation_upgrade_probe" ||
1013
+ normalized === "conversationupgradeprobe"
1014
+ ) {
1015
+ return "perf-conversation-upgrade-probe";
1016
+ }
1017
+ if (
1018
+ normalized === "new-session" ||
1019
+ normalized === "new_session" ||
1020
+ normalized === "newsession"
1021
+ ) {
1022
+ return "new-session";
1023
+ }
1024
+ if (
1025
+ normalized === "get-sessions" ||
1026
+ normalized === "get_sessions" ||
1027
+ normalized === "getsessions" ||
1028
+ normalized === "sessions"
1029
+ ) {
1030
+ return "get-sessions";
1031
+ }
1032
+ if (
1033
+ normalized === "switch-session" ||
1034
+ normalized === "switch_session" ||
1035
+ normalized === "switchsession"
1036
+ ) {
1037
+ return "switch-session";
1038
+ }
1039
+ if (normalized === "new-chat" || normalized === "new_chat" || normalized === "newchat") {
1040
+ return "new-chat";
1041
+ }
1042
+ if (
1043
+ normalized === "slash-command" ||
1044
+ normalized === "slash_command" ||
1045
+ normalized === "slash"
1046
+ ) {
1047
+ return "slash-command";
1048
+ }
1049
+ if (
1050
+ normalized === "listen-start" ||
1051
+ normalized === "listen_start" ||
1052
+ normalized === "listenstart"
1053
+ ) {
1054
+ return "listen-start";
1055
+ }
1056
+ if (
1057
+ normalized === "listen-stop" ||
1058
+ normalized === "listen_stop" ||
1059
+ normalized === "listenstop"
1060
+ ) {
1061
+ return "listen-stop";
1062
+ }
1063
+ if (
1064
+ normalized === "listen-send" ||
1065
+ normalized === "listen_send" ||
1066
+ normalized === "listensend"
1067
+ ) {
1068
+ return "listen-send";
1069
+ }
1070
+ if (
1071
+ normalized === "listen-retry" ||
1072
+ normalized === "listen_retry" ||
1073
+ normalized === "listenretry"
1074
+ ) {
1075
+ return "listen-retry";
1076
+ }
1077
+ if (
1078
+ normalized === "perf-reset-ladder" ||
1079
+ normalized === "perf_reset_ladder" ||
1080
+ normalized === "perfresetladder" ||
1081
+ normalized === "reset-ladder" ||
1082
+ normalized === "reset_ladder" ||
1083
+ normalized === "resetladder"
1084
+ ) {
1085
+ return "perf-reset-ladder";
1086
+ }
1087
+ if (
1088
+ normalized === "perf-relay-reconnect-only" ||
1089
+ normalized === "perf_relay_reconnect_only" ||
1090
+ normalized === "perfrelayreconnectonly" ||
1091
+ normalized === "relay-reconnect-only" ||
1092
+ normalized === "relay_reconnect_only" ||
1093
+ normalized === "relayreconnectonly"
1094
+ ) {
1095
+ return "perf-relay-reconnect-only";
1096
+ }
1097
+ if (
1098
+ normalized === "perf-sdk-page-recreate-only" ||
1099
+ normalized === "perf_sdk_page_recreate_only" ||
1100
+ normalized === "perfsdkpagerecreateonly" ||
1101
+ normalized === "sdk-page-recreate-only" ||
1102
+ normalized === "sdk_page_recreate_only" ||
1103
+ normalized === "sdkpagerecreateonly"
1104
+ ) {
1105
+ return "perf-sdk-page-recreate-only";
1106
+ }
1107
+ if (
1108
+ normalized === "perf-config" ||
1109
+ normalized === "perf_config" ||
1110
+ normalized === "perfconfig" ||
1111
+ normalized === "perf-drift-config" ||
1112
+ normalized === "perf_drift_config" ||
1113
+ normalized === "perfdriftconfig"
1114
+ ) {
1115
+ return "perf-config";
1116
+ }
1117
+ if (
1118
+ normalized === "debug-close-app-client" ||
1119
+ normalized === "debug_close_app_client" ||
1120
+ normalized === "debugcloseappclient" ||
1121
+ normalized === "close-app-client" ||
1122
+ normalized === "close_app_client" ||
1123
+ normalized === "closeappclient"
1124
+ ) {
1125
+ return "debug-close-app-client";
1126
+ }
1127
+ throw new Error(`unsupported remote relayAction: ${raw}`);
1128
+ }
1129
+
1130
+ function parseOptionalTrimmedString(raw) {
1131
+ if (typeof raw !== "string") return null;
1132
+ const trimmed = raw.trim();
1133
+ return trimmed || null;
1134
+ }
1135
+
1136
+ function parseOptionalBoolean(raw, fieldName) {
1137
+ if (raw === undefined || raw === null) return undefined;
1138
+ if (typeof raw === "boolean") return raw;
1139
+ if (typeof raw === "number") return raw !== 0;
1140
+ if (typeof raw !== "string") {
1141
+ throw new Error(`${fieldName} must be a boolean`);
1142
+ }
1143
+ const normalized = raw.trim().toLowerCase();
1144
+ if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
1145
+ if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
1146
+ throw new Error(`${fieldName} must be a boolean`);
1147
+ }
1148
+
1149
+ function parseOptionalPositiveNumber(raw, fieldName) {
1150
+ if (raw === undefined || raw === null) return undefined;
1151
+ const num = Number(raw);
1152
+ if (!Number.isFinite(num) || num <= 0) {
1153
+ throw new Error(`${fieldName} must be a positive number`);
1154
+ }
1155
+ return Math.floor(num);
1156
+ }
1157
+
1158
+ function parseOptionalNonNegativeNumber(raw, fieldName) {
1159
+ if (raw === undefined || raw === null) return undefined;
1160
+ const num = Number(raw);
1161
+ if (!Number.isFinite(num) || num < 0) {
1162
+ throw new Error(`${fieldName} must be a non-negative number`);
1163
+ }
1164
+ return Math.floor(num);
1165
+ }
1166
+
1167
+ function parseOptionalStringArray(raw, fieldName) {
1168
+ if (raw === undefined || raw === null) return undefined;
1169
+ if (!Array.isArray(raw)) {
1170
+ throw new Error(`${fieldName} must be an array of strings`);
1171
+ }
1172
+ const values = [];
1173
+ for (const entry of raw) {
1174
+ if (typeof entry !== "string") {
1175
+ throw new Error(`${fieldName} must be an array of strings`);
1176
+ }
1177
+ const trimmed = entry.trim();
1178
+ if (trimmed) values.push(trimmed);
1179
+ }
1180
+ return values;
1181
+ }
1182
+
1183
+ function parseSetSessionModelConfig(msg) {
1184
+ if (!msg || typeof msg !== "object") {
1185
+ throw new Error("setSessionModelConfig payload must be an object");
1186
+ }
1187
+
1188
+ const payload = {};
1189
+
1190
+ const modelRef = parseOptionalTrimmedString(msg.modelRef);
1191
+ if (modelRef) {
1192
+ if (!modelRef.includes("/")) {
1193
+ throw new Error("modelRef must be in provider/id format");
1194
+ }
1195
+ payload.modelRef = modelRef;
1196
+ }
1197
+
1198
+ if (Object.prototype.hasOwnProperty.call(msg, "thinkingLevel")) {
1199
+ if (typeof msg.thinkingLevel !== "string") {
1200
+ throw new Error(
1201
+ "thinkingLevel must be blank|off|minimal|low|medium|high|xhigh",
1202
+ );
1203
+ }
1204
+ const normalized = msg.thinkingLevel.trim().toLowerCase();
1205
+ if (
1206
+ normalized &&
1207
+ !["off", "minimal", "low", "medium", "high", "xhigh"].includes(normalized)
1208
+ ) {
1209
+ throw new Error(
1210
+ "thinkingLevel must be blank|off|minimal|low|medium|high|xhigh",
1211
+ );
1212
+ }
1213
+ payload.thinkingLevel = normalized;
1214
+ }
1215
+
1216
+ const reasoningEnabled = parseOptionalBoolean(
1217
+ msg.reasoningEnabled,
1218
+ "reasoningEnabled",
1219
+ );
1220
+ if (reasoningEnabled !== undefined) {
1221
+ payload.reasoningEnabled = reasoningEnabled;
1222
+ }
1223
+
1224
+ const verboseLevel = parseOptionalTrimmedString(msg.verboseLevel);
1225
+ if (verboseLevel) {
1226
+ const normalized = verboseLevel.toLowerCase();
1227
+ if (!["off", "on", "full"].includes(normalized)) {
1228
+ throw new Error("verboseLevel must be off|on|full");
1229
+ }
1230
+ payload.verboseLevel = normalized;
1231
+ }
1232
+
1233
+ if (Object.keys(payload).length === 0) {
1234
+ throw new Error("setSessionModelConfig requires at least one field");
1235
+ }
1236
+
1237
+ return payload;
1238
+ }
1239
+
1240
+ function parseSetEvenAiSettings(msg) {
1241
+ if (!msg || typeof msg !== "object") {
1242
+ throw new Error("setEvenAiSettings payload must be an object");
1243
+ }
1244
+
1245
+ const payload = {};
1246
+
1247
+ if (Object.prototype.hasOwnProperty.call(msg, "routingMode")) {
1248
+ const routingMode = parseOptionalTrimmedString(msg.routingMode);
1249
+ if (!routingMode) {
1250
+ throw new Error("routingMode must be active|background|background_new");
1251
+ }
1252
+ const normalizedInput = routingMode.toLowerCase();
1253
+ if (
1254
+ ![
1255
+ "active",
1256
+ "background",
1257
+ "background_new",
1258
+ "dedicated",
1259
+ "new",
1260
+ "dedicated_shadow",
1261
+ "new_shadow",
1262
+ ].includes(normalizedInput)
1263
+ ) {
1264
+ throw new Error("routingMode must be active|background|background_new");
1265
+ }
1266
+ payload.routingMode = normalizeEvenAiRoutingMode(routingMode);
1267
+ }
1268
+
1269
+ if (Object.prototype.hasOwnProperty.call(msg, "systemPrompt")) {
1270
+ if (typeof msg.systemPrompt !== "string") {
1271
+ throw new Error("systemPrompt must be a string");
1272
+ }
1273
+ payload.systemPrompt = msg.systemPrompt.trim();
1274
+ }
1275
+
1276
+ if (Object.prototype.hasOwnProperty.call(msg, "defaultModel")) {
1277
+ if (typeof msg.defaultModel !== "string") {
1278
+ throw new Error("defaultModel must be a string");
1279
+ }
1280
+ payload.defaultModel = msg.defaultModel.trim();
1281
+ }
1282
+
1283
+ if (Object.prototype.hasOwnProperty.call(msg, "defaultThinking")) {
1284
+ if (typeof msg.defaultThinking !== "string") {
1285
+ throw new Error("defaultThinking must be a string");
1286
+ }
1287
+ const normalizedThinking = msg.defaultThinking.trim().toLowerCase();
1288
+ if (
1289
+ normalizedThinking &&
1290
+ !["off", "minimal", "low", "medium", "high", "xhigh"].includes(normalizedThinking)
1291
+ ) {
1292
+ throw new Error("defaultThinking must be off|minimal|low|medium|high|xhigh");
1293
+ }
1294
+ payload.defaultThinking = normalizedThinking;
1295
+ }
1296
+
1297
+ if (Object.prototype.hasOwnProperty.call(msg, "listenEnabled")) {
1298
+ if (typeof msg.listenEnabled !== "boolean") {
1299
+ throw new Error("listenEnabled must be a boolean");
1300
+ }
1301
+ payload.listenEnabled = msg.listenEnabled;
1302
+ }
1303
+
1304
+ if (Object.keys(payload).length === 0) {
1305
+ throw new Error("setEvenAiSettings requires at least one field");
1306
+ }
1307
+
1308
+ return payload;
1309
+ }
1310
+
1311
+ function parseSetOcuClawSettings(msg) {
1312
+ if (!msg || typeof msg !== "object") {
1313
+ throw new Error("setOcuClawSettings payload must be an object");
1314
+ }
1315
+
1316
+ const payload = {};
1317
+
1318
+ if (Object.prototype.hasOwnProperty.call(msg, "systemPrompt")) {
1319
+ if (typeof msg.systemPrompt !== "string") {
1320
+ throw new Error("systemPrompt must be a string");
1321
+ }
1322
+ payload.systemPrompt = msg.systemPrompt.trim();
1323
+ }
1324
+
1325
+ if (Object.prototype.hasOwnProperty.call(msg, "defaultModel")) {
1326
+ if (typeof msg.defaultModel !== "string") {
1327
+ throw new Error("defaultModel must be a string");
1328
+ }
1329
+ payload.defaultModel = normalizeOcuClawDefaultModel(msg.defaultModel);
1330
+ }
1331
+
1332
+ if (Object.prototype.hasOwnProperty.call(msg, "defaultThinking")) {
1333
+ if (typeof msg.defaultThinking !== "string") {
1334
+ throw new Error("defaultThinking must be a string");
1335
+ }
1336
+ payload.defaultThinking = normalizeOcuClawDefaultThinking(msg.defaultThinking);
1337
+ }
1338
+
1339
+ if (Object.keys(payload).length === 0) {
1340
+ throw new Error("setOcuClawSettings requires at least one field");
1341
+ }
1342
+
1343
+ return payload;
1344
+ }
1345
+
1346
+ function parseRequestSonioxTemporaryKey(msg) {
1347
+ if (!msg || typeof msg !== "object") {
1348
+ throw new Error("requestSonioxTemporaryKey payload must be an object");
1349
+ }
1350
+
1351
+ const voiceSessionId = parseOptionalTrimmedString(msg.voiceSessionId);
1352
+ if (!voiceSessionId) {
1353
+ throw new Error("voiceSessionId is required");
1354
+ }
1355
+
1356
+ return {
1357
+ voiceSessionId,
1358
+ sessionKey: parseOptionalTrimmedString(msg.sessionKey),
1359
+ };
1360
+ }
1361
+
1362
+ function parseApprovalResponsePayload(msg) {
1363
+ if (!msg || typeof msg !== "object") {
1364
+ throw new Error("ocuclaw.approval.resolve payload must be an object");
1365
+ }
1366
+ const id = parseOptionalTrimmedString(msg.id);
1367
+ if (!id) {
1368
+ throw new Error("ocuclaw.approval.resolve id is required");
1369
+ }
1370
+ const decisionRaw = parseOptionalTrimmedString(msg.decision);
1371
+ if (!decisionRaw) {
1372
+ throw new Error("ocuclaw.approval.resolve decision is required");
1373
+ }
1374
+ const decision = decisionRaw.toLowerCase();
1375
+ if (!APPROVAL_DECISIONS.has(decision)) {
1376
+ throw new Error(
1377
+ "ocuclaw.approval.resolve decision must be allow-once|allow-always|deny",
1378
+ );
1379
+ }
1380
+ return {
1381
+ id,
1382
+ decision,
1383
+ requestId: parseOptionalTrimmedString(msg.requestId) || null,
1384
+ };
1385
+ }
1386
+
1387
+ function pruneApprovalResolveCache(nowMs) {
1388
+ for (const [key, entry] of approvalResolveCache) {
1389
+ if (!entry || entry.expiresAtMs <= nowMs) {
1390
+ approvalResolveCache.delete(key);
1391
+ }
1392
+ }
1393
+ while (approvalResolveCache.size > approvalResolveCacheMaxEntries) {
1394
+ const oldest = approvalResolveCache.keys().next();
1395
+ if (oldest.done) break;
1396
+ approvalResolveCache.delete(oldest.value);
1397
+ }
1398
+ }
1399
+
1400
+ function parseRemoteControl(msg) {
1401
+ if (!msg || typeof msg !== "object") {
1402
+ throw new Error("remote-control payload must be an object");
1403
+ }
1404
+ const actionRaw = parseOptionalTrimmedString(msg.action);
1405
+ if (!actionRaw) {
1406
+ throw new Error("remote-control action is required");
1407
+ }
1408
+
1409
+ const action = actionRaw.toLowerCase();
1410
+ const payload = {
1411
+ action,
1412
+ requestId: parseOptionalTrimmedString(msg.requestId) || null,
1413
+ };
1414
+
1415
+ if (action === "button") {
1416
+ payload.button = normalizeRemoteButton(msg.button);
1417
+ return payload;
1418
+ }
1419
+
1420
+ if (action === "send-message") {
1421
+ const text = typeof msg.text === "string" ? msg.text : "";
1422
+ if (!text.trim()) {
1423
+ throw new Error("remote-control send-message requires non-empty text");
1424
+ }
1425
+ payload.text = text;
1426
+ const sessionKey = parseOptionalTrimmedString(msg.sessionKey);
1427
+ if (sessionKey) payload.sessionKey = sessionKey;
1428
+ return payload;
1429
+ }
1430
+
1431
+ if (action === "relay-action") {
1432
+ payload.relayAction = normalizeRemoteRelayAction(msg.relayAction);
1433
+ if (
1434
+ payload.relayAction === "listen-start" ||
1435
+ payload.relayAction === "listen-stop" ||
1436
+ payload.relayAction === "listen-send" ||
1437
+ payload.relayAction === "listen-retry"
1438
+ ) {
1439
+ throw new Error(
1440
+ `remote-control relayAction ${payload.relayAction} was removed; voice stays local to the app`,
1441
+ );
1442
+ }
1443
+ const sessionKey = parseOptionalTrimmedString(msg.sessionKey);
1444
+ const command = parseOptionalTrimmedString(msg.command);
1445
+ if (payload.relayAction === "switch-session" && !sessionKey) {
1446
+ throw new Error("remote-control switch-session requires sessionKey");
1447
+ }
1448
+ if (payload.relayAction === "slash-command" && !command) {
1449
+ throw new Error("remote-control slash-command requires command");
1450
+ }
1451
+ if (sessionKey) payload.sessionKey = sessionKey;
1452
+ if (command) payload.command = command;
1453
+ const endpointDetection = parseOptionalBoolean(msg.endpointDetection, "endpointDetection");
1454
+ if (endpointDetection !== undefined) payload.endpointDetection = endpointDetection;
1455
+ const maxEndpointDelayMs = parseOptionalPositiveNumber(
1456
+ msg.maxEndpointDelayMs,
1457
+ "maxEndpointDelayMs",
1458
+ );
1459
+ if (maxEndpointDelayMs !== undefined) payload.maxEndpointDelayMs = maxEndpointDelayMs;
1460
+ const model = parseOptionalTrimmedString(msg.model);
1461
+ if (model) payload.model = model;
1462
+ const languageHints = parseOptionalStringArray(msg.languageHints, "languageHints");
1463
+ if (languageHints !== undefined) payload.languageHints = languageHints;
1464
+ return payload;
1465
+ }
1466
+
1467
+ if (action === "list-click") {
1468
+ const index = parseOptionalNonNegativeNumber(msg.index, "index");
1469
+ if (index === undefined) {
1470
+ throw new Error("remote-control list-click requires index");
1471
+ }
1472
+ payload.index = index;
1473
+ const containerId = parseOptionalNonNegativeNumber(msg.containerId, "containerId");
1474
+ const containerName = parseOptionalTrimmedString(msg.containerName);
1475
+ if (containerId !== undefined) payload.containerId = containerId;
1476
+ if (containerName) payload.containerName = containerName;
1477
+ return payload;
1478
+ }
1479
+
1480
+ if (action === "text-event") {
1481
+ payload.eventType = normalizeRemoteButton(msg.eventType || msg.button);
1482
+ const containerId = parseOptionalNonNegativeNumber(msg.containerId, "containerId");
1483
+ const containerName = parseOptionalTrimmedString(msg.containerName);
1484
+ if (containerId !== undefined) payload.containerId = containerId;
1485
+ if (containerName) payload.containerName = containerName;
1486
+ return payload;
1487
+ }
1488
+
1489
+ throw new Error(`unsupported remote-control action: ${actionRaw}`);
1490
+ }
1491
+
1492
+ const ATTACHMENT_MAX_DECODED_BYTES = 5_000_000;
1493
+ const ATTACHMENT_MAX_ENCODED_CHARS =
1494
+ Math.ceil((ATTACHMENT_MAX_DECODED_BYTES * 4) / 3) + 16;
1495
+
1496
+ function stripDataUrlPrefix(value) {
1497
+ if (typeof value !== "string") return "";
1498
+ if (!value.startsWith("data:")) return value;
1499
+ const comma = value.indexOf(",");
1500
+ return comma >= 0 ? value.slice(comma + 1) : value;
1501
+ }
1502
+
1503
+ function parseOptionalPositiveInt(value) {
1504
+ if (value === undefined || value === null) return null;
1505
+ const num = Number(value);
1506
+ if (!Number.isFinite(num) || num <= 0) return null;
1507
+ return Math.floor(num);
1508
+ }
1509
+
1510
+ function rejectAttachment(errorCode, error) {
1511
+ return {
1512
+ ok: false,
1513
+ errorCode,
1514
+ error,
1515
+ };
1516
+ }
1517
+
1518
+ function parseAttachment(rawAttachment) {
1519
+ if (rawAttachment === undefined || rawAttachment === null) {
1520
+ return { ok: true, attachment: null };
1521
+ }
1522
+ if (typeof rawAttachment !== "object" || Array.isArray(rawAttachment)) {
1523
+ return rejectAttachment(
1524
+ "attachment_invalid_type",
1525
+ "attachment must be an object",
1526
+ );
1527
+ }
1528
+
1529
+ const kind = parseOptionalTrimmedString(rawAttachment.kind) || "image";
1530
+ if (kind !== "image") {
1531
+ return rejectAttachment(
1532
+ "attachment_invalid_type",
1533
+ "unsupported attachment kind",
1534
+ );
1535
+ }
1536
+
1537
+ const mimeTypeRaw = parseOptionalTrimmedString(rawAttachment.mimeType);
1538
+ const mimeType = mimeTypeRaw ? mimeTypeRaw.toLowerCase() : null;
1539
+ if (!mimeType || !mimeType.startsWith("image/")) {
1540
+ return rejectAttachment(
1541
+ "attachment_invalid_type",
1542
+ "attachment mimeType must be image/*",
1543
+ );
1544
+ }
1545
+
1546
+ const base64Raw = parseOptionalTrimmedString(rawAttachment.base64Data);
1547
+ if (!base64Raw) {
1548
+ return rejectAttachment(
1549
+ "attachment_missing_data",
1550
+ "attachment base64Data is required",
1551
+ );
1552
+ }
1553
+
1554
+ const base64Data = stripDataUrlPrefix(base64Raw).replace(/\s+/g, "");
1555
+ if (!base64Data) {
1556
+ return rejectAttachment(
1557
+ "attachment_missing_data",
1558
+ "attachment base64Data is required",
1559
+ );
1560
+ }
1561
+
1562
+ if (base64Data.length > ATTACHMENT_MAX_ENCODED_CHARS) {
1563
+ return rejectAttachment(
1564
+ "attachment_too_large_encoded",
1565
+ `attachment payload exceeds encoded limit (${ATTACHMENT_MAX_ENCODED_CHARS} chars)`,
1566
+ );
1567
+ }
1568
+
1569
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64Data)) {
1570
+ return rejectAttachment(
1571
+ "attachment_decode_failed",
1572
+ "attachment base64Data is not valid base64",
1573
+ );
1574
+ }
1575
+
1576
+ let decoded;
1577
+ try {
1578
+ decoded = Buffer.from(base64Data, "base64");
1579
+ } catch {
1580
+ return rejectAttachment(
1581
+ "attachment_decode_failed",
1582
+ "attachment base64Data decode failed",
1583
+ );
1584
+ }
1585
+ if (!decoded || decoded.length <= 0) {
1586
+ return rejectAttachment(
1587
+ "attachment_missing_data",
1588
+ "attachment decoded payload is empty",
1589
+ );
1590
+ }
1591
+
1592
+ const canonical = decoded.toString("base64").replace(/=+$/g, "");
1593
+ const providedCanonical = base64Data.replace(/=+$/g, "");
1594
+ if (canonical !== providedCanonical) {
1595
+ return rejectAttachment(
1596
+ "attachment_decode_failed",
1597
+ "attachment base64Data decode failed",
1598
+ );
1599
+ }
1600
+
1601
+ if (decoded.length > ATTACHMENT_MAX_DECODED_BYTES) {
1602
+ return rejectAttachment(
1603
+ "attachment_too_large",
1604
+ `attachment exceeds ${ATTACHMENT_MAX_DECODED_BYTES} byte decoded limit`,
1605
+ );
1606
+ }
1607
+
1608
+ const sourceRaw = parseOptionalTrimmedString(rawAttachment.source);
1609
+ const sourceNormalized = sourceRaw ? sourceRaw.toLowerCase() : null;
1610
+ const source =
1611
+ sourceNormalized === "camera" || sourceNormalized === "gallery"
1612
+ ? sourceNormalized
1613
+ : null;
1614
+
1615
+ return {
1616
+ ok: true,
1617
+ attachment: {
1618
+ kind: "image",
1619
+ name: parseOptionalTrimmedString(rawAttachment.name) || "image.jpg",
1620
+ mimeType,
1621
+ base64Data,
1622
+ sizeBytes: decoded.length,
1623
+ widthPx: parseOptionalPositiveInt(rawAttachment.widthPx),
1624
+ heightPx: parseOptionalPositiveInt(rawAttachment.heightPx),
1625
+ source,
1626
+ },
1627
+ };
1628
+ }
1629
+
1630
+ // --- Message handlers ---
1631
+
1632
+ /**
1633
+ * Handle a "send" message: forward user text to upstream agent.
1634
+ *
1635
+ * @param {string} clientId
1636
+ * @param {object} msg - Parsed message with id, text, sessionKey, and optional attachment
1637
+ * @returns {{ unicast: string }|Promise<{ unicast: string }>}
1638
+ */
1639
+ function handleSend(clientId, msg) {
1640
+ const requestId = parseOptionalTrimmedString(msg.requestId);
1641
+ if (!requestId) {
1642
+ return {
1643
+ unicast: formatSendAck(
1644
+ requestId,
1645
+ "rejected",
1646
+ "Missing required field: requestId",
1647
+ ),
1648
+ };
1649
+ }
1650
+
1651
+ const parsedAttachment = parseAttachment(msg.attachment);
1652
+ if (!parsedAttachment.ok) {
1653
+ return {
1654
+ unicast: formatSendAck(
1655
+ requestId,
1656
+ "rejected",
1657
+ parsedAttachment.error,
1658
+ parsedAttachment.errorCode,
1659
+ ),
1660
+ };
1661
+ }
1662
+
1663
+ const text = typeof msg.text === "string" ? msg.text : "";
1664
+ if (!text.trim() && !parsedAttachment.attachment) {
1665
+ return {
1666
+ unicast: formatSendAck(
1667
+ requestId,
1668
+ "rejected",
1669
+ "Missing required field: text",
1670
+ ),
1671
+ };
1672
+ }
1673
+
1674
+ // Check upstream connectivity
1675
+ if (!isUpstreamConnected()) {
1676
+ return {
1677
+ unicast: formatSendAck(
1678
+ requestId,
1679
+ "rejected",
1680
+ "OpenClaw disconnected",
1681
+ ),
1682
+ };
1683
+ }
1684
+
1685
+ // Forward to upstream — resolves on initial ack (accepted/queued)
1686
+ return onSend(
1687
+ requestId,
1688
+ text,
1689
+ msg.sessionKey || null,
1690
+ parsedAttachment.attachment,
1691
+ ).then(
1692
+ (result) => {
1693
+ const status = (result && result.status) || "accepted";
1694
+ return { unicast: formatSendAck(requestId, status) };
1695
+ },
1696
+ (err) => ({
1697
+ unicast: formatSendAck(
1698
+ requestId,
1699
+ "rejected",
1700
+ err.message || "Send failed",
1701
+ err.errorCode || undefined,
1702
+ ),
1703
+ }),
1704
+ );
1705
+ }
1706
+
1707
+ /**
1708
+ * Handle a "simulate" message: inject fake message into conversation state.
1709
+ *
1710
+ * @param {string} clientId
1711
+ * @param {object} msg - Parsed message with sender, text
1712
+ * @returns {{ broadcast: string }}
1713
+ */
1714
+ function handleSimulate(clientId, msg) {
1715
+ const pages = onSimulate(
1716
+ msg.sender || "Simulator",
1717
+ msg.text || "",
1718
+ );
1719
+ return { broadcast: formatPages(pages) };
1720
+ }
1721
+
1722
+ /**
1723
+ * Handle a "simulateStream" message: relay-local deterministic stream replay.
1724
+ *
1725
+ * @param {string} clientId
1726
+ * @param {object} msg - Parsed message with id, text, sender, and timing controls.
1727
+ * @returns {{ unicast: string }|Promise<{ unicast: string }>}
1728
+ */
1729
+ function handleSimulateStream(clientId, msg) {
1730
+ const id = parseOptionalTrimmedString(msg.id);
1731
+ if (!id) {
1732
+ return {
1733
+ unicast: formatSendAck(
1734
+ msg.id || null,
1735
+ "rejected",
1736
+ "Missing required field: id",
1737
+ ),
1738
+ };
1739
+ }
1740
+
1741
+ if (!onSimulateStream) {
1742
+ return {
1743
+ unicast: formatSendAck(
1744
+ id,
1745
+ "rejected",
1746
+ "simulateStream not supported by relay",
1747
+ ),
1748
+ };
1749
+ }
1750
+
1751
+ const text = typeof msg.text === "string" ? msg.text : "";
1752
+ if (!text.trim()) {
1753
+ return {
1754
+ unicast: formatSendAck(
1755
+ id,
1756
+ "rejected",
1757
+ "simulateStream requires non-empty text",
1758
+ ),
1759
+ };
1760
+ }
1761
+
1762
+ let chunkChars;
1763
+ let chunkIntervalMs;
1764
+ let startDelayMs;
1765
+ let thinkingTailMs;
1766
+ try {
1767
+ chunkChars = parseOptionalPositiveNumber(msg.chunkChars, "chunkChars");
1768
+ chunkIntervalMs = parseOptionalPositiveNumber(msg.chunkIntervalMs, "chunkIntervalMs");
1769
+ startDelayMs = parseOptionalNonNegativeNumber(msg.startDelayMs, "startDelayMs");
1770
+ thinkingTailMs = parseOptionalNonNegativeNumber(msg.thinkingTailMs, "thinkingTailMs");
1771
+ } catch (err) {
1772
+ return {
1773
+ unicast: formatSendAck(
1774
+ id,
1775
+ "rejected",
1776
+ err && err.message ? err.message : "Invalid simulateStream parameters",
1777
+ ),
1778
+ };
1779
+ }
1780
+
1781
+ const request = {
1782
+ id,
1783
+ sender: parseOptionalTrimmedString(msg.sender) || "Simulator",
1784
+ text,
1785
+ sessionKey: parseOptionalTrimmedString(msg.sessionKey) || null,
1786
+ chunkChars,
1787
+ chunkIntervalMs,
1788
+ startDelayMs,
1789
+ thinkingTailMs,
1790
+ };
1791
+
1792
+ return Promise.resolve(onSimulateStream(request)).then(
1793
+ (result) => {
1794
+ const status = result && result.status ? result.status : "accepted";
1795
+ const error = result && result.error ? result.error : undefined;
1796
+ return { unicast: formatSendAck(id, status, error) };
1797
+ },
1798
+ (err) => ({
1799
+ unicast: formatSendAck(
1800
+ id,
1801
+ "rejected",
1802
+ err && err.message ? err.message : "simulateStream failed",
1803
+ ),
1804
+ }),
1805
+ );
1806
+ }
1807
+
1808
+ /**
1809
+ * Handle a "subscribeProtocol" message: mark client for protocol forwarding.
1810
+ *
1811
+ * @param {string} clientId
1812
+ * @returns {null}
1813
+ */
1814
+ function handleSubscribeProtocol(clientId) {
1815
+ protocolSubscribers.add(clientId);
1816
+ return null;
1817
+ }
1818
+
1819
+ /**
1820
+ * Handle an "approvalResponse" message: forward decision to upstream gateway.
1821
+ *
1822
+ * @param {string} clientId
1823
+ * @param {object} msg - Parsed message with id and decision
1824
+ * @returns {{ unicast: string }|Promise<{ unicast: string }>}
1825
+ */
1826
+ function handleApprovalResponse(clientId, msg) {
1827
+ let payload;
1828
+ try {
1829
+ payload = parseApprovalResponsePayload(msg);
1830
+ } catch (err) {
1831
+ return {
1832
+ unicast: formatApprovalResponseAck({
1833
+ id: parseOptionalTrimmedString(msg && msg.id) || null,
1834
+ decision: parseOptionalTrimmedString(msg && msg.decision) || null,
1835
+ requestId: parseOptionalTrimmedString(msg && msg.requestId) || null,
1836
+ status: "rejected",
1837
+ code: "invalid_approval_response",
1838
+ message:
1839
+ err && err.message
1840
+ ? err.message
1841
+ : "Invalid ocuclaw.approval.resolve payload",
1842
+ idempotent: false,
1843
+ }),
1844
+ };
1845
+ }
1846
+
1847
+ if (!onApprovalResolve) {
1848
+ return {
1849
+ unicast: formatApprovalResponseAck({
1850
+ id: payload.id,
1851
+ decision: payload.decision,
1852
+ requestId: payload.requestId,
1853
+ status: "rejected",
1854
+ code: "approval_unavailable",
1855
+ message: "approval resolution is not available",
1856
+ idempotent: false,
1857
+ }),
1858
+ };
1859
+ }
1860
+
1861
+ const nowMs = Date.now();
1862
+ pruneApprovalResolveCache(nowMs);
1863
+ const idempotencyScope = payload.requestId || `client:${clientId}`;
1864
+ const cacheKey = `${payload.id}|${payload.decision}|${idempotencyScope}`;
1865
+ const existing = approvalResolveCache.get(cacheKey);
1866
+ if (existing && existing.expiresAtMs > nowMs) {
1867
+ existing.expiresAtMs = nowMs + approvalResolveCacheTtlMs;
1868
+ return existing.promise.then((ack) => ({
1869
+ unicast: formatApprovalResponseAck({
1870
+ ...ack,
1871
+ idempotent: true,
1872
+ code:
1873
+ ack && ack.status === "accepted"
1874
+ ? "duplicate_request"
1875
+ : ack && ack.code
1876
+ ? ack.code
1877
+ : "duplicate_request",
1878
+ }),
1879
+ }));
1880
+ }
1881
+
1882
+ const promise = Promise.resolve(
1883
+ onApprovalResolve(
1884
+ payload.id,
1885
+ payload.decision,
1886
+ { requestId: payload.requestId, clientId },
1887
+ ),
1888
+ ).then(
1889
+ () => ({
1890
+ id: payload.id,
1891
+ decision: payload.decision,
1892
+ requestId: payload.requestId,
1893
+ status: "accepted",
1894
+ code: "ok",
1895
+ message: null,
1896
+ idempotent: false,
1897
+ }),
1898
+ (err) => {
1899
+ const message =
1900
+ err && err.message ? err.message : "approvalResolve failed";
1901
+ logger.error(`[downstream] approvalResolve failed: ${message}`);
1902
+ return {
1903
+ id: payload.id,
1904
+ decision: payload.decision,
1905
+ requestId: payload.requestId,
1906
+ status: "rejected",
1907
+ code: "approval_resolve_failed",
1908
+ message,
1909
+ idempotent: false,
1910
+ };
1911
+ },
1912
+ );
1913
+
1914
+ const cacheEntry = {
1915
+ expiresAtMs: nowMs + approvalResolveCacheTtlMs,
1916
+ promise,
1917
+ };
1918
+ approvalResolveCache.set(cacheKey, cacheEntry);
1919
+ pruneApprovalResolveCache(nowMs);
1920
+
1921
+ return promise.then((ack) => {
1922
+ if (ack && ack.status === "accepted") {
1923
+ cacheEntry.expiresAtMs = Date.now() + approvalResolveCacheTtlMs;
1924
+ } else {
1925
+ approvalResolveCache.delete(cacheKey);
1926
+ }
1927
+ return { unicast: formatApprovalResponseAck(ack) };
1928
+ });
1929
+ }
1930
+
1931
+ /**
1932
+ * Handle a "newChat" message: clear conversation and reset the session.
1933
+ *
1934
+ * @param {string} clientId
1935
+ * @returns {Promise<{ broadcast: string }>}
1936
+ */
1937
+ function handleNewChat(clientId) {
1938
+ return onNewChat().then(
1939
+ (pages) => ({ broadcast: formatPages(pages) }),
1940
+ (err) => {
1941
+ logger.error(`[downstream] newChat failed: ${err.message}`);
1942
+ return null;
1943
+ },
1944
+ );
1945
+ }
1946
+
1947
+ /**
1948
+ * Handle a "getSessions" message: fetch session list from upstream.
1949
+ *
1950
+ * @param {string} clientId
1951
+ * @returns {Promise<{ unicast: string }>}
1952
+ */
1953
+ function handleGetSessions(clientId) {
1954
+ return onGetSessions().then(
1955
+ (sessions) => ({ unicast: formatSessions(sessions) }),
1956
+ (err) => {
1957
+ logger.error(`[downstream] getSessions failed: ${err.message}`);
1958
+ return { unicast: formatSessions([]) };
1959
+ },
1960
+ );
1961
+ }
1962
+
1963
+ /**
1964
+ * Handle "getModelsCatalog": return cached model catalog snapshot.
1965
+ *
1966
+ * @param {string} clientId
1967
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
1968
+ */
1969
+ function handleGetModelsCatalog(clientId) {
1970
+ if (!onGetModelsCatalog) {
1971
+ return {
1972
+ unicast: formatModelsCatalog({
1973
+ models: [],
1974
+ fetchedAtMs: Date.now(),
1975
+ stale: true,
1976
+ }),
1977
+ };
1978
+ }
1979
+ return Promise.resolve(onGetModelsCatalog()).then(
1980
+ (payload) => ({
1981
+ unicast: formatModelsCatalog(payload || {}),
1982
+ }),
1983
+ (err) => {
1984
+ logger.error(`[downstream] getModelsCatalog failed: ${err.message}`);
1985
+ return {
1986
+ unicast: formatModelsCatalog({
1987
+ models: [],
1988
+ fetchedAtMs: Date.now(),
1989
+ stale: true,
1990
+ }),
1991
+ };
1992
+ },
1993
+ );
1994
+ }
1995
+
1996
+ /**
1997
+ * Handle "getSkills": return cached skills catalog snapshot.
1998
+ *
1999
+ * @param {string} clientId
2000
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2001
+ */
2002
+ function handleGetSkillsCatalog(clientId) {
2003
+ if (!onGetSkillsCatalog) {
2004
+ return {
2005
+ unicast: formatSkillsCatalog({
2006
+ skills: [],
2007
+ fetchedAtMs: Date.now(),
2008
+ stale: true,
2009
+ }),
2010
+ };
2011
+ }
2012
+ return Promise.resolve(onGetSkillsCatalog()).then(
2013
+ (payload) => ({
2014
+ unicast: formatSkillsCatalog(payload || {}),
2015
+ }),
2016
+ (err) => {
2017
+ logger.error(`[downstream] getSkills failed: ${err.message}`);
2018
+ return {
2019
+ unicast: formatSkillsCatalog({
2020
+ skills: [],
2021
+ fetchedAtMs: Date.now(),
2022
+ stale: true,
2023
+ }),
2024
+ };
2025
+ },
2026
+ );
2027
+ }
2028
+
2029
+ /**
2030
+ * Handle "getSonioxModels": return cached Soniox model snapshot.
2031
+ *
2032
+ * @param {string} clientId
2033
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2034
+ */
2035
+ function handleGetSonioxModels(clientId) {
2036
+ if (!onGetSonioxModels) {
2037
+ return {
2038
+ unicast: formatSonioxModels({
2039
+ models: [],
2040
+ fetchedAtMs: Date.now(),
2041
+ stale: true,
2042
+ }),
2043
+ };
2044
+ }
2045
+ return Promise.resolve(onGetSonioxModels()).then(
2046
+ (payload) => ({
2047
+ unicast: formatSonioxModels(payload || {}),
2048
+ }),
2049
+ (err) => {
2050
+ logger.error(`[downstream] getSonioxModels failed: ${err.message}`);
2051
+ return {
2052
+ unicast: formatSonioxModels({
2053
+ models: [],
2054
+ fetchedAtMs: Date.now(),
2055
+ stale: true,
2056
+ }),
2057
+ };
2058
+ },
2059
+ );
2060
+ }
2061
+
2062
+ /**
2063
+ * Handle "getStatus": return the current status snapshot.
2064
+ *
2065
+ * @param {string} clientId
2066
+ * @returns {{ unicast: string }}
2067
+ */
2068
+ function handleGetStatus(clientId) {
2069
+ if (!onGetStatus) {
2070
+ return { unicast: formatError("getStatus is not available") };
2071
+ }
2072
+ try {
2073
+ return {
2074
+ unicast: formatStatus(onGetStatus() || {}),
2075
+ };
2076
+ } catch (err) {
2077
+ return { unicast: formatError(err.message || "getStatus failed") };
2078
+ }
2079
+ }
2080
+
2081
+ /**
2082
+ * Handle "getSessionModelConfig": read controls for current session key.
2083
+ *
2084
+ * @param {string} clientId
2085
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2086
+ */
2087
+ function handleGetSessionModelConfig(clientId) {
2088
+ if (!onGetSessionModelConfig) {
2089
+ return { unicast: formatError("getSessionModelConfig is not available") };
2090
+ }
2091
+ return Promise.resolve(onGetSessionModelConfig()).then(
2092
+ (payload) => ({
2093
+ unicast: formatSessionModelConfig(payload || {}),
2094
+ }),
2095
+ (err) => {
2096
+ logger.error(`[downstream] getSessionModelConfig failed: ${err.message}`);
2097
+ return { unicast: formatError(err.message || "getSessionModelConfig failed") };
2098
+ },
2099
+ );
2100
+ }
2101
+
2102
+ /**
2103
+ * Handle "setSessionModelConfig": patch controls for current session key.
2104
+ *
2105
+ * @param {string} clientId
2106
+ * @param {object} msg
2107
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2108
+ */
2109
+ function handleSetSessionModelConfig(clientId, msg) {
2110
+ if (!onSetSessionModelConfig) {
2111
+ return {
2112
+ unicast: formatSessionModelConfigAck({
2113
+ status: "rejected",
2114
+ error: "setSessionModelConfig is not available",
2115
+ }),
2116
+ };
2117
+ }
2118
+
2119
+ let payload;
2120
+ try {
2121
+ payload = parseSetSessionModelConfig(msg);
2122
+ } catch (err) {
2123
+ return {
2124
+ unicast: formatSessionModelConfigAck({
2125
+ status: "rejected",
2126
+ error: err && err.message ? err.message : "invalid setSessionModelConfig payload",
2127
+ }),
2128
+ };
2129
+ }
2130
+
2131
+ return Promise.resolve(onSetSessionModelConfig(payload)).then(
2132
+ (result) =>
2133
+ ({
2134
+ unicast: formatSessionModelConfigAck(result || { status: "accepted" }),
2135
+ }),
2136
+ (err) =>
2137
+ ({
2138
+ unicast: formatSessionModelConfigAck({
2139
+ status: "rejected",
2140
+ error: err && err.message ? err.message : "setSessionModelConfig failed",
2141
+ }),
2142
+ }),
2143
+ );
2144
+ }
2145
+
2146
+ /**
2147
+ * Handle "getEvenAiSettings": read relay-owned Even AI settings.
2148
+ *
2149
+ * @param {string} clientId
2150
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2151
+ */
2152
+ function handleGetEvenAiSettings(clientId) {
2153
+ if (!onGetEvenAiSettings) {
2154
+ return { unicast: formatError("getEvenAiSettings is not available") };
2155
+ }
2156
+ return Promise.resolve(onGetEvenAiSettings()).then(
2157
+ (payload) => ({
2158
+ unicast: formatEvenAiSettings(payload || {}),
2159
+ }),
2160
+ (err) => {
2161
+ logger.error(`[downstream] getEvenAiSettings failed: ${err.message}`);
2162
+ return { unicast: formatError(err.message || "getEvenAiSettings failed") };
2163
+ },
2164
+ );
2165
+ }
2166
+
2167
+ /**
2168
+ * Handle "getEvenAiSessions": return tracked Even AI sessions.
2169
+ *
2170
+ * @param {string} clientId
2171
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2172
+ */
2173
+ function handleGetEvenAiSessions(clientId) {
2174
+ if (!onGetEvenAiSessions) {
2175
+ return { unicast: formatError("getEvenAiSessions is not available") };
2176
+ }
2177
+ return Promise.resolve(onGetEvenAiSessions()).then(
2178
+ (payload) => ({
2179
+ unicast: formatEvenAiSessions(payload || {}),
2180
+ }),
2181
+ (err) => {
2182
+ logger.error(`[downstream] getEvenAiSessions failed: ${err.message}`);
2183
+ return { unicast: formatEvenAiSessions({ sessions: [] }) };
2184
+ },
2185
+ );
2186
+ }
2187
+
2188
+ /**
2189
+ * Handle "setEvenAiSettings": patch relay-owned Even AI settings.
2190
+ *
2191
+ * @param {string} clientId
2192
+ * @param {object} msg
2193
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2194
+ */
2195
+ function handleSetEvenAiSettings(clientId, msg) {
2196
+ if (!onSetEvenAiSettings) {
2197
+ return {
2198
+ unicast: formatEvenAiSettingsAck({
2199
+ status: "rejected",
2200
+ error: "setEvenAiSettings is not available",
2201
+ }),
2202
+ };
2203
+ }
2204
+
2205
+ let payload;
2206
+ try {
2207
+ payload = parseSetEvenAiSettings(msg);
2208
+ } catch (err) {
2209
+ return {
2210
+ unicast: formatEvenAiSettingsAck({
2211
+ status: "rejected",
2212
+ error: err && err.message ? err.message : "invalid setEvenAiSettings payload",
2213
+ }),
2214
+ };
2215
+ }
2216
+
2217
+ return Promise.resolve(onSetEvenAiSettings(payload)).then(
2218
+ (result) => ({
2219
+ unicast: formatEvenAiSettingsAck(result || { status: "accepted" }),
2220
+ }),
2221
+ (err) => ({
2222
+ unicast: formatEvenAiSettingsAck({
2223
+ status: "rejected",
2224
+ error: err && err.message ? err.message : "setEvenAiSettings failed",
2225
+ }),
2226
+ }),
2227
+ );
2228
+ }
2229
+
2230
+ /**
2231
+ * Handle "getOcuClawSettings": read relay-owned OcuClaw settings.
2232
+ *
2233
+ * @param {string} clientId
2234
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2235
+ */
2236
+ function handleGetOcuClawSettings(clientId) {
2237
+ if (!onGetOcuClawSettings) {
2238
+ return { unicast: formatError("getOcuClawSettings is not available") };
2239
+ }
2240
+ return Promise.resolve(onGetOcuClawSettings()).then(
2241
+ (payload) => ({
2242
+ unicast: formatOcuClawSettings(payload || {}),
2243
+ }),
2244
+ (err) => {
2245
+ logger.error(`[downstream] getOcuClawSettings failed: ${err.message}`);
2246
+ return { unicast: formatError(err.message || "getOcuClawSettings failed") };
2247
+ },
2248
+ );
2249
+ }
2250
+
2251
+ /**
2252
+ * Handle "setOcuClawSettings": patch relay-owned OcuClaw settings.
2253
+ *
2254
+ * @param {string} clientId
2255
+ * @param {object} msg
2256
+ * @returns {Promise<{ unicast: string }>|{ unicast: string }}
2257
+ */
2258
+ function handleSetOcuClawSettings(clientId, msg) {
2259
+ if (!onSetOcuClawSettings) {
2260
+ return {
2261
+ unicast: formatOcuClawSettingsAck({
2262
+ status: "rejected",
2263
+ error: "setOcuClawSettings is not available",
2264
+ }),
2265
+ };
2266
+ }
2267
+
2268
+ let payload;
2269
+ try {
2270
+ payload = parseSetOcuClawSettings(msg);
2271
+ } catch (err) {
2272
+ return {
2273
+ unicast: formatOcuClawSettingsAck({
2274
+ status: "rejected",
2275
+ error: err && err.message ? err.message : "invalid setOcuClawSettings payload",
2276
+ }),
2277
+ };
2278
+ }
2279
+
2280
+ return Promise.resolve(onSetOcuClawSettings(payload)).then(
2281
+ (result) => ({
2282
+ unicast: formatOcuClawSettingsAck(result || { status: "accepted" }),
2283
+ }),
2284
+ (err) => ({
2285
+ unicast: formatOcuClawSettingsAck({
2286
+ status: "rejected",
2287
+ error: err && err.message ? err.message : "setOcuClawSettings failed",
2288
+ }),
2289
+ }),
2290
+ );
2291
+ }
2292
+
2293
+ /**
2294
+ * Handle a "switchSession" message: switch to a different session.
2295
+ *
2296
+ * @param {string} clientId
2297
+ * @param {object} msg - Parsed message with sessionKey
2298
+ * @returns {Promise<{ broadcast: string[] }|null>}
2299
+ */
2300
+ function handleSwitchSession(clientId, msg) {
2301
+ if (!msg.sessionKey) return null;
2302
+ return onSwitchSession(msg.sessionKey).then(
2303
+ (pages) => ({
2304
+ broadcast: [
2305
+ formatSessionSwitched(msg.sessionKey),
2306
+ formatPages(pages),
2307
+ ],
2308
+ }),
2309
+ (err) => {
2310
+ logger.error(`[downstream] switchSession failed: ${err.message}`);
2311
+ return null;
2312
+ },
2313
+ );
2314
+ }
2315
+
2316
+ /**
2317
+ * Handle a "newSession" message: create a new session.
2318
+ *
2319
+ * @param {string} clientId
2320
+ * @returns {Promise<{ broadcast: string[] }|null>}
2321
+ */
2322
+ function handleNewSession(clientId) {
2323
+ return onNewSession().then(
2324
+ (result) => {
2325
+ const broadcast = [
2326
+ formatSessionSwitched(result.sessionKey),
2327
+ formatPages(result.pages),
2328
+ ];
2329
+ if (result && result.sessionModelConfig) {
2330
+ broadcast.push(formatSessionModelConfig(result.sessionModelConfig));
2331
+ }
2332
+ return { broadcast };
2333
+ },
2334
+ (err) => {
2335
+ logger.error(`[downstream] newSession failed: ${err.message}`);
2336
+ return null;
2337
+ },
2338
+ );
2339
+ }
2340
+
2341
+ /**
2342
+ * Handle a "slashCommand" message: forward a slash command to OpenClaw.
2343
+ *
2344
+ * @param {string} clientId
2345
+ * @param {object} msg - Parsed message with command
2346
+ * @returns {Promise<null>}
2347
+ */
2348
+ function handleSlashCommand(clientId, msg) {
2349
+ if (!msg.command) return null;
2350
+ return onSlashCommand(msg.command).then(
2351
+ () => null,
2352
+ (err) => {
2353
+ logger.error(`[downstream] slashCommand failed: ${err.message}`);
2354
+ return null;
2355
+ },
2356
+ );
2357
+ }
2358
+
2359
+ /**
2360
+ * Handle a "console" message: forward browser console output to log.
2361
+ *
2362
+ * @param {string} clientId
2363
+ * @param {object} msg - Parsed message with level, message
2364
+ * @returns {null}
2365
+ */
2366
+ function handleConsole(clientId, msg) {
2367
+ if (onConsoleLog) {
2368
+ onConsoleLog(msg.level || "log", msg.message || "");
2369
+ }
2370
+ return null;
2371
+ }
2372
+
2373
+ /**
2374
+ * Handle a structured "eventDebug" message.
2375
+ *
2376
+ * Legacy payloads (without cat/event) fall back to onConsoleLog.
2377
+ */
2378
+ function handleEventDebug(clientId, msg) {
2379
+ const parsed = parseEventDebug(msg);
2380
+ if (parsed && onEventDebug) {
2381
+ onEventDebug(clientId, parsed);
2382
+ return null;
2383
+ }
2384
+ if (onConsoleLog) {
2385
+ const legacyMessage =
2386
+ typeof msg.data === "string" ? msg.data : JSON.stringify(msg);
2387
+ onConsoleLog("event", legacyMessage);
2388
+ }
2389
+ return null;
2390
+ }
2391
+
2392
+ function handleRemovedListenAction(messageType) {
2393
+ return {
2394
+ unicast: formatError(
2395
+ `${messageType} was removed; hybrid-local voice stays local to the app`,
2396
+ ),
2397
+ };
2398
+ }
2399
+
2400
+ /**
2401
+ * Handle a "requestSonioxTemporaryKey" message.
2402
+ */
2403
+ function handleRequestSonioxTemporaryKey(clientId, msg) {
2404
+ let payload;
2405
+ try {
2406
+ payload = parseRequestSonioxTemporaryKey(msg);
2407
+ } catch (err) {
2408
+ return {
2409
+ unicast: formatSonioxTemporaryKeyError({
2410
+ voiceSessionId:
2411
+ msg && typeof msg.voiceSessionId === "string" ? msg.voiceSessionId : "",
2412
+ error: err && err.message ? err.message : "requestSonioxTemporaryKey failed",
2413
+ code: normalizeSonioxTemporaryKeyErrorCode(err),
2414
+ }),
2415
+ };
2416
+ }
2417
+
2418
+ if (!onRequestSonioxTemporaryKey) {
2419
+ return {
2420
+ unicast: formatSonioxTemporaryKeyError({
2421
+ voiceSessionId: payload.voiceSessionId,
2422
+ error: "requestSonioxTemporaryKey is not available",
2423
+ code: "soniox_temp_key_unavailable",
2424
+ }),
2425
+ };
2426
+ }
2427
+
2428
+ try {
2429
+ const result = onRequestSonioxTemporaryKey(clientId, payload);
2430
+ if (result && typeof result.then === "function") {
2431
+ return result.then(
2432
+ (resolved) => ({ unicast: formatSonioxTemporaryKey(resolved || payload) }),
2433
+ (err) => {
2434
+ const error =
2435
+ err && err.message
2436
+ ? err.message
2437
+ : "requestSonioxTemporaryKey failed";
2438
+ return {
2439
+ unicast: formatSonioxTemporaryKeyError({
2440
+ voiceSessionId: payload.voiceSessionId,
2441
+ error,
2442
+ code: normalizeSonioxTemporaryKeyErrorCode(err),
2443
+ }),
2444
+ };
2445
+ },
2446
+ );
2447
+ }
2448
+ return { unicast: formatSonioxTemporaryKey(result || payload) };
2449
+ } catch (err) {
2450
+ return {
2451
+ unicast: formatSonioxTemporaryKeyError({
2452
+ voiceSessionId: payload.voiceSessionId,
2453
+ error: err && err.message ? err.message : "requestSonioxTemporaryKey failed",
2454
+ code: normalizeSonioxTemporaryKeyErrorCode(err),
2455
+ }),
2456
+ };
2457
+ }
2458
+ }
2459
+
2460
+ /**
2461
+ * Handle a "debug-set" message: enable/disable debug categories with TTL.
2462
+ */
2463
+ function handleDebugSet(clientId, msg) {
2464
+ if (!onDebugSet) {
2465
+ return { unicast: formatError("debug-set is not available") };
2466
+ }
2467
+
2468
+ let payload;
2469
+ try {
2470
+ payload = parseDebugSet(msg);
2471
+ } catch (err) {
2472
+ return { unicast: formatError(err.message) };
2473
+ }
2474
+
2475
+ try {
2476
+ const result = onDebugSet(clientId, payload);
2477
+ if (result && typeof result.then === "function") {
2478
+ return result.then(
2479
+ (resolved) => {
2480
+ const payloadResult = resolved || { ok: true };
2481
+ const out = { unicast: formatDebugSet(payloadResult) };
2482
+ if (
2483
+ payloadResult.ok !== false &&
2484
+ Number.isFinite(payloadResult.nowMs) &&
2485
+ Array.isArray(payloadResult.enabled)
2486
+ ) {
2487
+ out.broadcastApp = formatDebugConfigSnapshot({
2488
+ serverNowMs: payloadResult.nowMs,
2489
+ enabled: payloadResult.enabled,
2490
+ });
2491
+ }
2492
+ return out;
2493
+ },
2494
+ (err) => ({ unicast: formatError(err.message || "debug-set failed") }),
2495
+ );
2496
+ }
2497
+ const payloadResult = result || { ok: true };
2498
+ const out = { unicast: formatDebugSet(payloadResult) };
2499
+ if (
2500
+ payloadResult.ok !== false &&
2501
+ Number.isFinite(payloadResult.nowMs) &&
2502
+ Array.isArray(payloadResult.enabled)
2503
+ ) {
2504
+ out.broadcastApp = formatDebugConfigSnapshot({
2505
+ serverNowMs: payloadResult.nowMs,
2506
+ enabled: payloadResult.enabled,
2507
+ });
2508
+ }
2509
+ return out;
2510
+ } catch (err) {
2511
+ return { unicast: formatError(err.message || "debug-set failed") };
2512
+ }
2513
+ }
2514
+
2515
+ /**
2516
+ * Handle a "debug-dump" message: fetch structured debug events.
2517
+ */
2518
+ function handleDebugDump(clientId, msg) {
2519
+ if (!onDebugDump) {
2520
+ return { unicast: formatError("debug-dump is not available") };
2521
+ }
2522
+
2523
+ let payload;
2524
+ try {
2525
+ payload = parseDebugDump(msg);
2526
+ } catch (err) {
2527
+ return { unicast: formatError(err.message) };
2528
+ }
2529
+
2530
+ try {
2531
+ const result = onDebugDump(clientId, payload);
2532
+ if (result && typeof result.then === "function") {
2533
+ return result.then(
2534
+ (resolved) => ({ unicast: formatDebugDump(resolved || { ok: true, events: [] }) }),
2535
+ (err) => ({ unicast: formatError(err.message || "debug-dump failed") }),
2536
+ );
2537
+ }
2538
+ return { unicast: formatDebugDump(result || { ok: true, events: [] }) };
2539
+ } catch (err) {
2540
+ return { unicast: formatError(err.message || "debug-dump failed") };
2541
+ }
2542
+ }
2543
+
2544
+ /**
2545
+ * Handle a "remote-control" message: dispatch remote actions to app clients.
2546
+ */
2547
+ function handleRemoteControl(clientId, msg) {
2548
+ if (!onRemoteControl) {
2549
+ return { unicast: formatError("remote-control is not available") };
2550
+ }
2551
+
2552
+ let payload;
2553
+ try {
2554
+ payload = parseRemoteControl(msg);
2555
+ } catch (err) {
2556
+ return { unicast: formatError(err.message) };
2557
+ }
2558
+
2559
+ const finalize = (result) => {
2560
+ const resolved = result || {};
2561
+ const requestId = resolved.requestId || payload.requestId || null;
2562
+ const ack = {
2563
+ ok: resolved.ok !== false,
2564
+ requestId,
2565
+ action: payload.action,
2566
+ dispatched: !!resolved.control,
2567
+ };
2568
+ if (resolved.message) ack.message = resolved.message;
2569
+ if (resolved.detail) ack.detail = resolved.detail;
2570
+
2571
+ const out = {
2572
+ unicast: formatRemoteControlAck(ack),
2573
+ };
2574
+ if (resolved.control) {
2575
+ out.broadcast = formatRemoteControl(resolved.control);
2576
+ }
2577
+ return out;
2578
+ };
2579
+
2580
+ try {
2581
+ const result = onRemoteControl(clientId, payload);
2582
+ if (result && typeof result.then === "function") {
2583
+ return result.then(
2584
+ (resolved) => finalize(resolved),
2585
+ (err) => ({ unicast: formatError(err.message || "remote-control failed") }),
2586
+ );
2587
+ }
2588
+ return finalize(result);
2589
+ } catch (err) {
2590
+ return { unicast: formatError(err.message || "remote-control failed") };
2591
+ }
2592
+ }
2593
+
2594
+ // --- Public API ---
2595
+
2596
+ return {
2597
+ /**
2598
+ * Process an incoming downstream message.
2599
+ *
2600
+ * @param {string} clientId - Identifier of the sending client
2601
+ * @param {string} raw - Raw JSON string
2602
+ * @returns {{ unicast: string }|{ broadcast: string }|null|Promise}
2603
+ */
2604
+ handleMessage(clientId, raw) {
2605
+ let msg;
2606
+ try {
2607
+ msg = JSON.parse(raw);
2608
+ } catch {
2609
+ return { unicast: formatError("Invalid JSON") };
2610
+ }
2611
+
2612
+ if (
2613
+ !externalDebugToolsEnabled &&
2614
+ msg &&
2615
+ typeof msg.type === "string" &&
2616
+ isExternalDebugToolMessageType(msg.type)
2617
+ ) {
2618
+ return {
2619
+ unicast: formatError(EXTERNAL_DEBUG_TOOLS_DISABLED_ERROR),
2620
+ };
2621
+ }
2622
+
2623
+ switch (msg.type) {
2624
+ case APP_PROTOCOL.messageSend:
2625
+ return handleSend(clientId, msg);
2626
+ case "simulate":
2627
+ return handleSimulate(clientId, msg);
2628
+ case "simulateStream":
2629
+ return handleSimulateStream(clientId, msg);
2630
+ case APP_PROTOCOL.protocolSubscribe:
2631
+ return handleSubscribeProtocol(clientId);
2632
+ case APP_PROTOCOL.approvalResolve:
2633
+ return handleApprovalResponse(clientId, msg);
2634
+ case APP_PROTOCOL.sessionReset:
2635
+ return handleNewChat(clientId);
2636
+ case APP_PROTOCOL.sessionList:
2637
+ return handleGetSessions(clientId);
2638
+ case APP_PROTOCOL.sessionSwitch:
2639
+ return handleSwitchSession(clientId, msg);
2640
+ case APP_PROTOCOL.sessionCreate:
2641
+ return handleNewSession(clientId);
2642
+ case APP_PROTOCOL.modelCatalogGet:
2643
+ return handleGetModelsCatalog(clientId);
2644
+ case APP_PROTOCOL.skillsCatalogGet:
2645
+ case "getSkills":
2646
+ return handleGetSkillsCatalog(clientId);
2647
+ case APP_PROTOCOL.sonioxModelsGet:
2648
+ case "getSonioxModels":
2649
+ return handleGetSonioxModels(clientId);
2650
+ case APP_PROTOCOL.statusGet:
2651
+ case "getStatus":
2652
+ return handleGetStatus(clientId);
2653
+ case APP_PROTOCOL.sessionConfigGet:
2654
+ return handleGetSessionModelConfig(clientId);
2655
+ case APP_PROTOCOL.sessionConfigSet:
2656
+ return handleSetSessionModelConfig(clientId, msg);
2657
+ case APP_PROTOCOL.evenAiSettingsGet:
2658
+ return handleGetEvenAiSettings(clientId);
2659
+ case APP_PROTOCOL.evenAiSessionList:
2660
+ return handleGetEvenAiSessions(clientId);
2661
+ case APP_PROTOCOL.evenAiSettingsSet:
2662
+ return handleSetEvenAiSettings(clientId, msg);
2663
+ case APP_PROTOCOL.ocuClawSettingsGet:
2664
+ return handleGetOcuClawSettings(clientId);
2665
+ case APP_PROTOCOL.ocuClawSettingsSet:
2666
+ return handleSetOcuClawSettings(clientId, msg);
2667
+ case APP_PROTOCOL.commandSlash:
2668
+ return handleSlashCommand(clientId, msg);
2669
+ case "console":
2670
+ return handleConsole(clientId, msg);
2671
+ case "listen-start":
2672
+ case "listen-stop":
2673
+ case "listen-send":
2674
+ case "listen-retry":
2675
+ return handleRemovedListenAction(msg.type);
2676
+ case APP_PROTOCOL.requestSonioxTemporaryKey:
2677
+ return handleRequestSonioxTemporaryKey(clientId, msg);
2678
+ case "debug-set":
2679
+ return handleDebugSet(clientId, msg);
2680
+ case "debug-dump":
2681
+ return handleDebugDump(clientId, msg);
2682
+ case "remote-control":
2683
+ return handleRemoteControl(clientId, msg);
2684
+ case APP_PROTOCOL.debugEvent:
2685
+ return handleEventDebug(clientId, msg);
2686
+ default:
2687
+ return null;
2688
+ }
2689
+ },
2690
+
2691
+ formatPages,
2692
+ formatStatus,
2693
+ formatActivity,
2694
+ formatSendAck,
2695
+ formatProtocol,
2696
+ formatStreaming,
2697
+ formatSessions,
2698
+ formatSessionSwitched,
2699
+ formatModelsCatalog,
2700
+ formatSkillsCatalog,
2701
+ formatSonioxModels,
2702
+ formatSessionModelConfig,
2703
+ formatSessionModelConfigAck,
2704
+ formatEvenAiSettings,
2705
+ formatOcuClawSettings,
2706
+ formatEvenAiSessions,
2707
+ formatEvenAiSettingsAck,
2708
+ formatOcuClawSettingsAck,
2709
+ formatApproval,
2710
+ formatApprovalResolved,
2711
+ formatApprovalResponseAck,
2712
+ formatTranscription,
2713
+ formatListenCommitted,
2714
+ formatListenEnded,
2715
+ formatListenError,
2716
+ formatListenReady,
2717
+ formatSonioxTemporaryKey,
2718
+ formatSonioxTemporaryKeyError,
2719
+ formatDebugSet,
2720
+ formatDebugDump,
2721
+ formatDebugConfigSnapshot,
2722
+ formatRemoteControl,
2723
+ formatRemoteControlAck,
2724
+ formatError,
2725
+
2726
+ /**
2727
+ * Check whether a client is subscribed to protocol frame forwarding.
2728
+ *
2729
+ * @param {string} clientId
2730
+ * @returns {boolean}
2731
+ */
2732
+ isProtocolSubscriber(clientId) {
2733
+ return protocolSubscribers.has(clientId);
2734
+ },
2735
+
2736
+ /**
2737
+ * Clean up state for a disconnected client.
2738
+ *
2739
+ * @param {string} clientId
2740
+ */
2741
+ removeClient(clientId) {
2742
+ protocolSubscribers.delete(clientId);
2743
+ },
2744
+ };
2745
+ }
2746
+
2747
+ export { createDownstreamHandler };