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,931 @@
1
+ function normalizeLogger(logger) {
2
+ if (!logger || typeof logger !== "object") {
3
+ return console;
4
+ }
5
+ return {
6
+ info: typeof logger.info === "function" ? logger.info.bind(logger) : console.log,
7
+ warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : console.warn,
8
+ error: typeof logger.error === "function" ? logger.error.bind(logger) : console.error,
9
+ debug:
10
+ typeof logger.debug === "function" ? logger.debug.bind(logger) : console.debug,
11
+ };
12
+ }
13
+
14
+ const DEFAULT_MODEL_PROVIDER = "anthropic";
15
+ const DEFAULT_MODEL_ID = "claude-opus-4-6";
16
+
17
+ function modelRefKey(provider, model) {
18
+ return `${provider}/${model}`;
19
+ }
20
+
21
+ function normalizeProviderId(rawProvider) {
22
+ const normalized = String(rawProvider || "").trim().toLowerCase();
23
+ if (normalized === "z.ai" || normalized === "z-ai") {
24
+ return "zai";
25
+ }
26
+ if (normalized === "opencode-zen") {
27
+ return "opencode";
28
+ }
29
+ if (normalized === "qwen") {
30
+ return "qwen-portal";
31
+ }
32
+ if (normalized === "kimi-code") {
33
+ return "kimi-coding";
34
+ }
35
+ return normalized;
36
+ }
37
+
38
+ function parseModelRef(raw, defaultProvider) {
39
+ if (typeof raw !== "string") return null;
40
+ const trimmed = raw.trim();
41
+ if (!trimmed) return null;
42
+ const slash = trimmed.indexOf("/");
43
+ if (slash === -1) {
44
+ const provider = normalizeProviderId(defaultProvider);
45
+ if (!provider) return null;
46
+ return { provider, model: trimmed };
47
+ }
48
+ const provider = normalizeProviderId(trimmed.slice(0, slash));
49
+ const model = trimmed.slice(slash + 1).trim();
50
+ if (!provider || !model) return null;
51
+ return { provider, model };
52
+ }
53
+
54
+ function buildModelAliasIndex(config) {
55
+ const aliasIndex = new Map();
56
+ const modelEntries =
57
+ config &&
58
+ config.agents &&
59
+ config.agents.defaults &&
60
+ config.agents.defaults.models &&
61
+ typeof config.agents.defaults.models === "object" &&
62
+ !Array.isArray(config.agents.defaults.models)
63
+ ? config.agents.defaults.models
64
+ : {};
65
+ for (const [rawKey, rawEntry] of Object.entries(modelEntries)) {
66
+ const parsed = parseModelRef(String(rawKey || ""), DEFAULT_MODEL_PROVIDER);
67
+ if (!parsed) continue;
68
+ const alias =
69
+ rawEntry &&
70
+ typeof rawEntry === "object" &&
71
+ typeof rawEntry.alias === "string"
72
+ ? rawEntry.alias.trim()
73
+ : "";
74
+ if (!alias) continue;
75
+ aliasIndex.set(alias.toLowerCase(), parsed);
76
+ }
77
+ return aliasIndex;
78
+ }
79
+
80
+ function resolveModelRefFromString(raw, aliasIndex) {
81
+ if (typeof raw !== "string") return null;
82
+ const trimmed = raw.trim();
83
+ if (!trimmed) return null;
84
+ if (!trimmed.includes("/")) {
85
+ const aliasMatch = aliasIndex.get(trimmed.toLowerCase());
86
+ if (aliasMatch) {
87
+ return aliasMatch;
88
+ }
89
+ }
90
+ return parseModelRef(trimmed, DEFAULT_MODEL_PROVIDER);
91
+ }
92
+
93
+ function resolveConfiguredDefaultModelRef(config, aliasIndex) {
94
+ const modelConfig =
95
+ config && config.agents && config.agents.defaults
96
+ ? config.agents.defaults.model
97
+ : undefined;
98
+ const rawModel = (() => {
99
+ if (typeof modelConfig === "string") return modelConfig.trim();
100
+ if (
101
+ modelConfig &&
102
+ typeof modelConfig === "object" &&
103
+ typeof modelConfig.primary === "string"
104
+ ) {
105
+ return modelConfig.primary.trim();
106
+ }
107
+ return "";
108
+ })();
109
+ if (rawModel) {
110
+ if (!rawModel.includes("/")) {
111
+ const aliasMatch = aliasIndex.get(rawModel.toLowerCase());
112
+ if (aliasMatch) {
113
+ return aliasMatch;
114
+ }
115
+ return { provider: DEFAULT_MODEL_PROVIDER, model: rawModel };
116
+ }
117
+ const parsed = resolveModelRefFromString(rawModel, aliasIndex);
118
+ if (parsed) {
119
+ return parsed;
120
+ }
121
+ }
122
+ return { provider: DEFAULT_MODEL_PROVIDER, model: DEFAULT_MODEL_ID };
123
+ }
124
+
125
+ function resolveConfiguredModelRefs(config) {
126
+ const aliasIndex = buildModelAliasIndex(config || {});
127
+ const refs = [];
128
+ const seen = new Set();
129
+
130
+ function addRef(ref) {
131
+ if (!ref || !ref.provider || !ref.model) return;
132
+ const key = modelRefKey(ref.provider, ref.model);
133
+ if (seen.has(key)) return;
134
+ seen.add(key);
135
+ refs.push(ref);
136
+ }
137
+
138
+ addRef(resolveConfiguredDefaultModelRef(config || {}, aliasIndex));
139
+
140
+ const modelConfig =
141
+ config && config.agents && config.agents.defaults
142
+ ? config.agents.defaults.model
143
+ : undefined;
144
+ const imageModelConfig =
145
+ config && config.agents && config.agents.defaults
146
+ ? config.agents.defaults.imageModel
147
+ : undefined;
148
+
149
+ const modelFallbacks =
150
+ modelConfig &&
151
+ typeof modelConfig === "object" &&
152
+ Array.isArray(modelConfig.fallbacks)
153
+ ? modelConfig.fallbacks
154
+ : [];
155
+ for (const raw of modelFallbacks) {
156
+ const parsed = resolveModelRefFromString(String(raw || ""), aliasIndex);
157
+ addRef(parsed);
158
+ }
159
+
160
+ const imagePrimary =
161
+ imageModelConfig &&
162
+ typeof imageModelConfig === "object" &&
163
+ typeof imageModelConfig.primary === "string"
164
+ ? imageModelConfig.primary.trim()
165
+ : "";
166
+ if (imagePrimary) {
167
+ const parsed = resolveModelRefFromString(imagePrimary, aliasIndex);
168
+ addRef(parsed);
169
+ }
170
+
171
+ const imageFallbacks =
172
+ imageModelConfig &&
173
+ typeof imageModelConfig === "object" &&
174
+ Array.isArray(imageModelConfig.fallbacks)
175
+ ? imageModelConfig.fallbacks
176
+ : [];
177
+ for (const raw of imageFallbacks) {
178
+ const parsed = resolveModelRefFromString(String(raw || ""), aliasIndex);
179
+ addRef(parsed);
180
+ }
181
+
182
+ const modelEntries =
183
+ config &&
184
+ config.agents &&
185
+ config.agents.defaults &&
186
+ config.agents.defaults.models &&
187
+ typeof config.agents.defaults.models === "object" &&
188
+ !Array.isArray(config.agents.defaults.models)
189
+ ? config.agents.defaults.models
190
+ : {};
191
+ for (const rawKey of Object.keys(modelEntries)) {
192
+ const parsed = parseModelRef(String(rawKey || ""), DEFAULT_MODEL_PROVIDER);
193
+ addRef(parsed);
194
+ }
195
+
196
+ return refs;
197
+ }
198
+
199
+ function extractConfigObject(configSnapshot) {
200
+ if (
201
+ configSnapshot &&
202
+ typeof configSnapshot === "object" &&
203
+ configSnapshot.config &&
204
+ typeof configSnapshot.config === "object" &&
205
+ !Array.isArray(configSnapshot.config)
206
+ ) {
207
+ return configSnapshot.config;
208
+ }
209
+ return {};
210
+ }
211
+
212
+ function mapConfiguredCatalogRows(modelsCatalogRows, configSnapshot) {
213
+ const byKey = new Map();
214
+ for (const row of modelsCatalogRows) {
215
+ byKey.set(modelRefKey(row.provider, row.id), row);
216
+ }
217
+ const config = extractConfigObject(configSnapshot);
218
+ const configuredRefs = resolveConfiguredModelRefs(config);
219
+ const out = [];
220
+ for (const ref of configuredRefs) {
221
+ const key = modelRefKey(ref.provider, ref.model);
222
+ const row = byKey.get(key);
223
+ if (row) {
224
+ out.push(row);
225
+ } else {
226
+ out.push({
227
+ provider: ref.provider,
228
+ id: ref.model,
229
+ name: ref.model,
230
+ });
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+
236
+ function normalizeModelCatalogRows(rows) {
237
+ if (!Array.isArray(rows)) return [];
238
+ const out = [];
239
+ for (const row of rows) {
240
+ if (!row || typeof row !== "object") continue;
241
+ const provider =
242
+ typeof row.provider === "string" ? row.provider.trim() : "";
243
+ const id = typeof row.id === "string" ? row.id.trim() : "";
244
+ if (!provider || !id) continue;
245
+ const name =
246
+ typeof row.name === "string" && row.name.trim() ? row.name.trim() : id;
247
+ const model = { provider, id, name };
248
+ if (Number.isFinite(row.contextWindow) && row.contextWindow > 0) {
249
+ model.contextWindow = Math.floor(row.contextWindow);
250
+ }
251
+ if (typeof row.reasoning === "boolean") {
252
+ model.reasoning = row.reasoning;
253
+ }
254
+ out.push(model);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ function normalizeSkillsCatalogRows(rows) {
260
+ if (!Array.isArray(rows)) return [];
261
+ const out = [];
262
+ const seen = new Set();
263
+ for (const row of rows) {
264
+ if (!row || typeof row !== "object" || row.eligible !== true) continue;
265
+ const name = typeof row.name === "string" ? row.name.trim() : "";
266
+ if (!name) continue;
267
+ const key = name.toLowerCase();
268
+ if (seen.has(key)) continue;
269
+ seen.add(key);
270
+ out.push({
271
+ name,
272
+ description:
273
+ typeof row.description === "string" && row.description.trim()
274
+ ? row.description.trim()
275
+ : "",
276
+ });
277
+ }
278
+ return out;
279
+ }
280
+
281
+ export function createUpstreamRuntime(opts = {}) {
282
+ const logger = normalizeLogger(opts.logger);
283
+ const gatewayBridge = opts.gatewayBridge;
284
+ const conversationState = opts.conversationState;
285
+ const sessionService = opts.sessionService;
286
+ const handler = opts.handler;
287
+ const emitDebug = typeof opts.emitDebug === "function" ? opts.emitDebug : () => {};
288
+ const broadcastPages =
289
+ typeof opts.broadcastPages === "function" ? opts.broadcastPages : () => {};
290
+ const broadcastStatus =
291
+ typeof opts.broadcastStatus === "function" ? opts.broadcastStatus : () => {};
292
+ const broadcastActivity =
293
+ typeof opts.broadcastActivity === "function"
294
+ ? opts.broadcastActivity
295
+ : (activity) => activity;
296
+ const resetActivityStatusAdapter =
297
+ typeof opts.resetActivityStatusAdapter === "function"
298
+ ? opts.resetActivityStatusAdapter
299
+ : () => {};
300
+ const getServer =
301
+ typeof opts.getServer === "function" ? opts.getServer : () => null;
302
+ const getVoiceRuntime =
303
+ typeof opts.getVoiceRuntime === "function" ? opts.getVoiceRuntime : () => null;
304
+
305
+ const modelsCacheTtlMs =
306
+ Number.isFinite(opts.modelsCacheTtlMs) && opts.modelsCacheTtlMs > 0
307
+ ? Math.floor(opts.modelsCacheTtlMs)
308
+ : 300000;
309
+
310
+ let openclawConnected = false;
311
+ let agentName = null;
312
+ /** @type {Array<{provider: string, id: string, name: string, contextWindow?: number, reasoning?: boolean}>|null} */
313
+ let cachedModelsCatalog = null;
314
+ let cachedModelsCatalogFetchedAt = 0;
315
+ let cachedModelsCatalogStale = true;
316
+ /** @type {Promise<{models: Array, fetchedAtMs: number, stale: boolean}>|null} */
317
+ let inFlightModelsCatalogFetch = null;
318
+ /** @type {Array<{name: string, description: string}>|null} */
319
+ let cachedSkillsCatalog = null;
320
+ let cachedSkillsCatalogFetchedAt = 0;
321
+ let cachedSkillsCatalogStale = true;
322
+ /** @type {Promise<{skills: Array, fetchedAtMs: number, stale: boolean}>|null} */
323
+ let inFlightSkillsCatalogFetch = null;
324
+ const upstreamRunPipeline = new Map();
325
+ let streamingThrottleTimer = null;
326
+ let pendingStreamingText = null;
327
+ let bootstrapRefreshTimer = null;
328
+ let bootstrapRefreshNonce = 0;
329
+
330
+ function getAgentName() {
331
+ return agentName;
332
+ }
333
+
334
+ function isConnected() {
335
+ return openclawConnected;
336
+ }
337
+
338
+ function clearStreamingThrottleTimer() {
339
+ if (!streamingThrottleTimer) return;
340
+ clearTimeout(streamingThrottleTimer);
341
+ streamingThrottleTimer = null;
342
+ }
343
+
344
+ function clearBootstrapRefreshTimer() {
345
+ if (!bootstrapRefreshTimer) return;
346
+ clearTimeout(bootstrapRefreshTimer);
347
+ bootstrapRefreshTimer = null;
348
+ }
349
+
350
+ function onConnectedStateEstablished(trigger) {
351
+ refreshModelCatalog(true).then((snapshot) => {
352
+ emitDebug(
353
+ "relay.session",
354
+ "models_catalog_prefetched",
355
+ "info",
356
+ { sessionKey: sessionService.ensureSessionKey() },
357
+ () => ({
358
+ count: Array.isArray(snapshot.models) ? snapshot.models.length : 0,
359
+ stale: !!snapshot.stale,
360
+ trigger,
361
+ }),
362
+ );
363
+ });
364
+ refreshSkillsCatalog(true).then((snapshot) => {
365
+ emitDebug(
366
+ "relay.session",
367
+ "skills_catalog_prefetched",
368
+ "info",
369
+ { sessionKey: sessionService.ensureSessionKey() },
370
+ () => ({
371
+ count: Array.isArray(snapshot.skills) ? snapshot.skills.length : 0,
372
+ stale: !!snapshot.stale,
373
+ trigger,
374
+ }),
375
+ );
376
+ });
377
+ }
378
+
379
+ function applyConnectedStatus(connected, trigger, emitTransportEvent = true) {
380
+ const wasConnected = openclawConnected;
381
+ openclawConnected = !!connected;
382
+ sessionService.handleUpstreamStatusChange(openclawConnected);
383
+ if (!openclawConnected) {
384
+ inFlightModelsCatalogFetch = null;
385
+ inFlightSkillsCatalogFetch = null;
386
+ cachedSkillsCatalogStale = true;
387
+ resetActivityStatusAdapter();
388
+ } else if (!wasConnected) {
389
+ onConnectedStateEstablished(trigger);
390
+ }
391
+ if (emitTransportEvent) {
392
+ emitDebug(
393
+ "relay.transport",
394
+ "upstream_status",
395
+ "info",
396
+ { sessionKey: sessionService.ensureSessionKey() },
397
+ () => ({
398
+ status: openclawConnected ? "connected" : "disconnected",
399
+ trigger,
400
+ }),
401
+ );
402
+ }
403
+ broadcastStatus();
404
+ }
405
+
406
+ async function refreshUpstreamBootstrap(trigger, attempt = 0) {
407
+ const refreshNonce = ++bootstrapRefreshNonce;
408
+ clearBootstrapRefreshTimer();
409
+ const sessionKey = sessionService.ensureSessionKey();
410
+ const [statusResult, identityResult] = await Promise.allSettled([
411
+ gatewayBridge.request("status", {}),
412
+ gatewayBridge.request("agent.identity.get", { sessionKey }),
413
+ ]);
414
+ if (refreshNonce !== bootstrapRefreshNonce) return;
415
+
416
+ const statusOk = statusResult.status === "fulfilled";
417
+ const identityOk = identityResult.status === "fulfilled";
418
+
419
+ if (statusOk || identityOk) {
420
+ applyConnectedStatus(true, `${trigger}_bootstrap`, !statusOk);
421
+ if (identityOk) {
422
+ const identity = identityResult.value;
423
+ agentName = identity && identity.name ? identity.name : null;
424
+ conversationState.setAgentName(agentName || "Agent");
425
+ emitDebug(
426
+ "relay.session",
427
+ "agent_identity_bootstrap",
428
+ "info",
429
+ { sessionKey: sessionService.ensureSessionKey() },
430
+ () => ({
431
+ hasName: !!agentName,
432
+ trigger,
433
+ attempt,
434
+ }),
435
+ );
436
+ broadcastStatus();
437
+ }
438
+ return;
439
+ }
440
+
441
+ emitDebug(
442
+ "relay.transport",
443
+ "upstream_state_bootstrap_failed",
444
+ attempt >= 4 ? "warn" : "debug",
445
+ { sessionKey: sessionService.ensureSessionKey() },
446
+ () => ({
447
+ trigger,
448
+ attempt,
449
+ statusError:
450
+ statusResult.status === "rejected" && statusResult.reason
451
+ ? statusResult.reason.message || String(statusResult.reason)
452
+ : null,
453
+ identityError:
454
+ identityResult.status === "rejected" && identityResult.reason
455
+ ? identityResult.reason.message || String(identityResult.reason)
456
+ : null,
457
+ }),
458
+ );
459
+ if (attempt >= 4) return;
460
+ const retryDelayMs = Math.min(250 * (attempt + 1), 1000);
461
+ bootstrapRefreshTimer = setTimeout(() => {
462
+ bootstrapRefreshTimer = null;
463
+ refreshUpstreamBootstrap(trigger, attempt + 1).catch((err) => {
464
+ logger.warn(`[relay] Upstream bootstrap retry failed: ${err.message}`);
465
+ });
466
+ }, retryDelayMs);
467
+ }
468
+
469
+ function flushPendingStreamingText() {
470
+ if (!pendingStreamingText) return;
471
+ const server = getServer();
472
+ if (server) {
473
+ server.broadcast(handler.formatStreaming(pendingStreamingText));
474
+ }
475
+ pendingStreamingText = null;
476
+ }
477
+
478
+ function modelCatalogSnapshot(nowMs) {
479
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
480
+ const hasCache = Array.isArray(cachedModelsCatalog);
481
+ const ageMs = hasCache ? now - cachedModelsCatalogFetchedAt : Infinity;
482
+ const ttlExpired = ageMs >= modelsCacheTtlMs;
483
+ return {
484
+ models: hasCache ? cachedModelsCatalog : [],
485
+ fetchedAtMs: hasCache ? cachedModelsCatalogFetchedAt : now,
486
+ stale: !hasCache || cachedModelsCatalogStale || ttlExpired,
487
+ };
488
+ }
489
+
490
+ function cacheModelCatalog(models, fetchedAtMs, stale) {
491
+ cachedModelsCatalog = Array.isArray(models) ? models : [];
492
+ cachedModelsCatalogFetchedAt = Number.isFinite(fetchedAtMs)
493
+ ? Math.floor(fetchedAtMs)
494
+ : Date.now();
495
+ cachedModelsCatalogStale = !!stale;
496
+ return modelCatalogSnapshot(cachedModelsCatalogFetchedAt);
497
+ }
498
+
499
+ function skillsCatalogSnapshot(nowMs) {
500
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
501
+ const hasCache = Array.isArray(cachedSkillsCatalog);
502
+ return {
503
+ skills: hasCache ? cachedSkillsCatalog : [],
504
+ fetchedAtMs: hasCache ? cachedSkillsCatalogFetchedAt : now,
505
+ stale: !hasCache || cachedSkillsCatalogStale,
506
+ };
507
+ }
508
+
509
+ function cacheSkillsCatalog(skills, fetchedAtMs, stale) {
510
+ cachedSkillsCatalog = Array.isArray(skills) ? skills : [];
511
+ cachedSkillsCatalogFetchedAt = Number.isFinite(fetchedAtMs)
512
+ ? Math.floor(fetchedAtMs)
513
+ : Date.now();
514
+ cachedSkillsCatalogStale = !!stale;
515
+ return skillsCatalogSnapshot(cachedSkillsCatalogFetchedAt);
516
+ }
517
+
518
+ async function refreshModelCatalog(force) {
519
+ const snapshot = modelCatalogSnapshot();
520
+ if (!force && !snapshot.stale) {
521
+ return snapshot;
522
+ }
523
+ if (inFlightModelsCatalogFetch) {
524
+ return inFlightModelsCatalogFetch;
525
+ }
526
+ if (!openclawConnected) {
527
+ return snapshot;
528
+ }
529
+
530
+ inFlightModelsCatalogFetch = gatewayBridge
531
+ .request("models.list", {})
532
+ .then(async (result) => {
533
+ const allModels = normalizeModelCatalogRows(result && result.models);
534
+ const configSnapshot = await gatewayBridge.request("config.get", {});
535
+ const models = mapConfiguredCatalogRows(allModels, configSnapshot);
536
+ return cacheModelCatalog(models, Date.now(), false);
537
+ })
538
+ .catch((err) => {
539
+ emitDebug(
540
+ "relay.session",
541
+ "models_catalog_refresh_failed",
542
+ "warn",
543
+ { sessionKey: sessionService.ensureSessionKey() },
544
+ () => ({
545
+ message: err && err.message ? err.message : String(err),
546
+ hadCache: Array.isArray(cachedModelsCatalog),
547
+ }),
548
+ );
549
+ if (Array.isArray(cachedModelsCatalog)) {
550
+ cachedModelsCatalogStale = true;
551
+ return modelCatalogSnapshot();
552
+ }
553
+ return cacheModelCatalog([], Date.now(), true);
554
+ });
555
+
556
+ return inFlightModelsCatalogFetch.finally(() => {
557
+ inFlightModelsCatalogFetch = null;
558
+ });
559
+ }
560
+
561
+ async function refreshSkillsCatalog(force) {
562
+ const snapshot = skillsCatalogSnapshot();
563
+ if (!force && !snapshot.stale) {
564
+ return snapshot;
565
+ }
566
+ if (inFlightSkillsCatalogFetch) {
567
+ return inFlightSkillsCatalogFetch;
568
+ }
569
+ if (!openclawConnected) {
570
+ return snapshot;
571
+ }
572
+
573
+ inFlightSkillsCatalogFetch = gatewayBridge
574
+ .request("skills.status", {})
575
+ .then((result) => {
576
+ const skills = normalizeSkillsCatalogRows(result && result.skills);
577
+ return cacheSkillsCatalog(skills, Date.now(), false);
578
+ })
579
+ .catch((err) => {
580
+ emitDebug(
581
+ "relay.session",
582
+ "skills_catalog_refresh_failed",
583
+ "warn",
584
+ { sessionKey: sessionService.ensureSessionKey() },
585
+ () => ({
586
+ message: err && err.message ? err.message : String(err),
587
+ hadCache: Array.isArray(cachedSkillsCatalog),
588
+ }),
589
+ );
590
+ if (Array.isArray(cachedSkillsCatalog)) {
591
+ cachedSkillsCatalogStale = true;
592
+ return skillsCatalogSnapshot();
593
+ }
594
+ return cacheSkillsCatalog([], Date.now(), true);
595
+ });
596
+
597
+ return inFlightSkillsCatalogFetch.finally(() => {
598
+ inFlightSkillsCatalogFetch = null;
599
+ });
600
+ }
601
+
602
+ function trackAcceptedRun(entry) {
603
+ if (!entry || !entry.runId) return;
604
+ upstreamRunPipeline.set(entry.runId, {
605
+ runId: entry.runId,
606
+ sessionKey: entry.sessionKey || null,
607
+ messageId: entry.messageId || null,
608
+ sendStartedAt: entry.sendStartedAt || Date.now(),
609
+ ackAt: entry.ackAt || Date.now(),
610
+ lifecycleStartAt: null,
611
+ firstStreamingAt: null,
612
+ });
613
+ }
614
+
615
+ async function getModelsCatalogSnapshot() {
616
+ const snapshot = modelCatalogSnapshot();
617
+ if (snapshot.stale && openclawConnected) {
618
+ return refreshModelCatalog(true);
619
+ }
620
+ return snapshot;
621
+ }
622
+
623
+ async function getSkillsCatalogSnapshot() {
624
+ const snapshot = skillsCatalogSnapshot();
625
+ if (snapshot.stale && openclawConnected) {
626
+ return refreshSkillsCatalog(true);
627
+ }
628
+ return snapshot;
629
+ }
630
+
631
+ function handleSessionChanged(trigger) {
632
+ if (!openclawConnected) {
633
+ cachedSkillsCatalogStale = true;
634
+ return;
635
+ }
636
+ refreshSkillsCatalog(true).catch((err) => {
637
+ logger.warn(`[relay] Skills catalog refresh failed after ${trigger}: ${err.message}`);
638
+ });
639
+ }
640
+
641
+ gatewayBridge.on("history", (data) => {
642
+ if (!sessionService.isCurrentSession(data.sessionKey)) return;
643
+ emitDebug(
644
+ "openclaw.history",
645
+ "history",
646
+ "debug",
647
+ { sessionKey: data.sessionKey || sessionService.ensureSessionKey() },
648
+ () => ({
649
+ messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
650
+ }),
651
+ );
652
+ conversationState.hydrate(data.messages, agentName);
653
+ broadcastPages();
654
+ });
655
+
656
+ gatewayBridge.on("thinkingDebug", (data) => {
657
+ if (!sessionService.isCurrentSession(data.sessionKey)) return;
658
+ emitDebug(
659
+ "openclaw.history",
660
+ "thinking_payload",
661
+ "debug",
662
+ {
663
+ sessionKey: data.sessionKey || sessionService.ensureSessionKey(),
664
+ runId: data.runId || null,
665
+ },
666
+ () => ({
667
+ source: data.source || null,
668
+ signatureId: data.signatureId || null,
669
+ rawKeys: Array.isArray(data.rawKeys) ? data.rawKeys : [],
670
+ rawPayload:
671
+ data.rawPayload && typeof data.rawPayload === "object" ? data.rawPayload : null,
672
+ summaryKey: data.summaryKey || null,
673
+ detailKey: data.detailKey || null,
674
+ labelKey: data.labelKey || null,
675
+ labelRaw: data.labelRaw || null,
676
+ labelSource: data.labelSource || null,
677
+ thinkingSummarySource: data.thinkingSummarySource || data.labelSource || null,
678
+ normalizedSummary: data.normalizedSummary || null,
679
+ normalizedDetail: data.normalizedDetail || null,
680
+ label: data.label || null,
681
+ detail: data.detail || null,
682
+ boldLabelCandidate: data.boldLabelCandidate || null,
683
+ boldLabelMatchesCurrentLabel:
684
+ typeof data.boldLabelMatchesCurrentLabel === "boolean"
685
+ ? data.boldLabelMatchesCurrentLabel
686
+ : null,
687
+ }),
688
+ );
689
+ });
690
+
691
+ gatewayBridge.on("message", (data) => {
692
+ if (!sessionService.isCurrentSession(data.sessionKey)) return;
693
+ const runId = data.runId || null;
694
+ const runPipeline = runId ? upstreamRunPipeline.get(runId) : null;
695
+ if (runPipeline) {
696
+ const completedAt = Date.now();
697
+ emitDebug(
698
+ "relay.protocol",
699
+ "run_complete",
700
+ "debug",
701
+ {
702
+ sessionKey: data.sessionKey || sessionService.ensureSessionKey(),
703
+ runId,
704
+ },
705
+ () => ({
706
+ messageId: runPipeline.messageId,
707
+ sendToCompleteMs: completedAt - runPipeline.sendStartedAt,
708
+ ackToCompleteMs: runPipeline.ackAt ? (completedAt - runPipeline.ackAt) : null,
709
+ runStartToCompleteMs: runPipeline.lifecycleStartAt
710
+ ? (completedAt - runPipeline.lifecycleStartAt)
711
+ : null,
712
+ firstStreamingToCompleteMs: runPipeline.firstStreamingAt
713
+ ? (completedAt - runPipeline.firstStreamingAt)
714
+ : null,
715
+ }),
716
+ );
717
+ upstreamRunPipeline.delete(runId);
718
+ }
719
+ emitDebug(
720
+ "openclaw.run",
721
+ "message",
722
+ "info",
723
+ {
724
+ sessionKey: data.sessionKey || sessionService.ensureSessionKey(),
725
+ runId,
726
+ },
727
+ () => ({
728
+ role: data.role || null,
729
+ contentBlocks: Array.isArray(data.content) ? data.content.length : 0,
730
+ }),
731
+ );
732
+
733
+ clearStreamingThrottleTimer();
734
+ flushPendingStreamingText();
735
+ conversationState.addMessage(data.role, data.content);
736
+ broadcastPages();
737
+
738
+ const voiceRuntime = getVoiceRuntime();
739
+ if (voiceRuntime && typeof voiceRuntime.onAgentMessage === "function") {
740
+ voiceRuntime.onAgentMessage();
741
+ }
742
+ });
743
+
744
+ gatewayBridge.on("activity", (data) => {
745
+ if (!sessionService.isCurrentSession(data.sessionKey)) return;
746
+ const runId = data.runId || null;
747
+ const origin = data.origin || null;
748
+ const phase = data.phase || null;
749
+ if (
750
+ runId &&
751
+ data.state === "thinking" &&
752
+ origin === "lifecycle" &&
753
+ phase === "start"
754
+ ) {
755
+ const now = Date.now();
756
+ const runPipeline = upstreamRunPipeline.get(runId);
757
+ if (runPipeline && !runPipeline.lifecycleStartAt) {
758
+ runPipeline.lifecycleStartAt = now;
759
+ }
760
+ emitDebug(
761
+ "relay.protocol",
762
+ "run_lifecycle_start",
763
+ "debug",
764
+ { sessionKey: data.sessionKey || sessionService.ensureSessionKey(), runId },
765
+ () => ({
766
+ messageId: runPipeline ? runPipeline.messageId : null,
767
+ sendToRunStartMs: runPipeline ? (now - runPipeline.sendStartedAt) : null,
768
+ ackToRunStartMs: runPipeline && runPipeline.ackAt ? (now - runPipeline.ackAt) : null,
769
+ }),
770
+ );
771
+ }
772
+ broadcastActivity(data);
773
+ });
774
+
775
+ gatewayBridge.on("streaming", (data) => {
776
+ if (!sessionService.isCurrentSession(data.sessionKey)) return;
777
+ const runId = data.runId || null;
778
+ if (runId) {
779
+ const now = Date.now();
780
+ const runPipeline = upstreamRunPipeline.get(runId);
781
+ if (runPipeline && !runPipeline.firstStreamingAt) {
782
+ runPipeline.firstStreamingAt = now;
783
+ emitDebug(
784
+ "relay.protocol",
785
+ "run_first_streaming",
786
+ "debug",
787
+ { sessionKey: data.sessionKey || sessionService.ensureSessionKey(), runId },
788
+ () => ({
789
+ messageId: runPipeline.messageId,
790
+ sendToFirstStreamingMs: now - runPipeline.sendStartedAt,
791
+ ackToFirstStreamingMs: runPipeline.ackAt ? (now - runPipeline.ackAt) : null,
792
+ runStartToFirstStreamingMs: runPipeline.lifecycleStartAt
793
+ ? (now - runPipeline.lifecycleStartAt)
794
+ : null,
795
+ }),
796
+ );
797
+ }
798
+ }
799
+ const { text } = conversationState._markdownToPlainText(data.text, {
800
+ stripReplyTags: true,
801
+ });
802
+ const prefix = agentName || "Agent";
803
+ pendingStreamingText = `${prefix}: ${text}`;
804
+ emitDebug(
805
+ "openclaw.run",
806
+ "streaming",
807
+ "debug",
808
+ {
809
+ sessionKey: data.sessionKey || sessionService.ensureSessionKey(),
810
+ runId,
811
+ },
812
+ () => ({
813
+ textChars: pendingStreamingText.length,
814
+ }),
815
+ );
816
+
817
+ if (!streamingThrottleTimer) {
818
+ const server = getServer();
819
+ if (server) {
820
+ server.broadcast(handler.formatStreaming(pendingStreamingText));
821
+ }
822
+ pendingStreamingText = null;
823
+ streamingThrottleTimer = setTimeout(() => {
824
+ streamingThrottleTimer = null;
825
+ flushPendingStreamingText();
826
+ }, 150);
827
+ }
828
+ });
829
+
830
+ gatewayBridge.on("status", (statusString) => {
831
+ applyConnectedStatus(statusString === "connected", "status_event");
832
+ });
833
+
834
+ gatewayBridge.on("agentIdentity", (data) => {
835
+ agentName = data && data.name ? data.name : null;
836
+ conversationState.setAgentName(agentName || "Agent");
837
+ broadcastStatus();
838
+ });
839
+
840
+ gatewayBridge.on("connected", () => {
841
+ refreshUpstreamBootstrap("connected_event").catch((err) => {
842
+ logger.warn(`[relay] Upstream connected bootstrap failed: ${err.message}`);
843
+ });
844
+ });
845
+
846
+ gatewayBridge.on("protocol", (data) => {
847
+ emitDebug(
848
+ "relay.protocol",
849
+ "protocol_frame",
850
+ "debug",
851
+ { sessionKey: sessionService.ensureSessionKey() },
852
+ () => ({
853
+ direction: data.direction || null,
854
+ frameType: data.frame && data.frame.type ? data.frame.type : null,
855
+ }),
856
+ );
857
+ const server = getServer();
858
+ if (!server) return;
859
+ const msg = handler.formatProtocol(data.direction, data.frame);
860
+ for (const clientId of server.getClientIds()) {
861
+ if (handler.isProtocolSubscriber(clientId)) {
862
+ server.unicast(clientId, msg);
863
+ }
864
+ }
865
+ });
866
+
867
+ gatewayBridge.on("approval", (data) => {
868
+ emitDebug(
869
+ "approvals.timeline",
870
+ "approval_requested",
871
+ "info",
872
+ { sessionKey: sessionService.ensureSessionKey() },
873
+ () => ({
874
+ approvalId: data && data.id ? data.id : null,
875
+ }),
876
+ );
877
+ const server = getServer();
878
+ if (server) {
879
+ server.broadcast(handler.formatApproval(data));
880
+ }
881
+ });
882
+
883
+ gatewayBridge.on("approvalResolved", (data) => {
884
+ emitDebug(
885
+ "approvals.timeline",
886
+ "approval_resolved",
887
+ "info",
888
+ { sessionKey: sessionService.ensureSessionKey() },
889
+ () => ({
890
+ approvalId: data && data.id ? data.id : null,
891
+ decision: data && data.decision ? data.decision : null,
892
+ }),
893
+ );
894
+ const server = getServer();
895
+ if (server) {
896
+ server.broadcast(handler.formatApprovalResolved(data));
897
+ }
898
+ });
899
+
900
+ gatewayBridge.on("error", (err) => {
901
+ logger.error(`[relay] Upstream error: ${err.message}`);
902
+ emitDebug(
903
+ "relay.transport",
904
+ "upstream_error",
905
+ "error",
906
+ { sessionKey: sessionService.ensureSessionKey() },
907
+ () => ({ message: err.message || null }),
908
+ );
909
+ });
910
+
911
+ return {
912
+ getAgentName,
913
+ getModelsCatalogSnapshot,
914
+ getSkillsCatalogSnapshot,
915
+ handleSessionChanged,
916
+ isConnected,
917
+ start() {
918
+ return refreshUpstreamBootstrap("runtime_start");
919
+ },
920
+ stop() {
921
+ clearStreamingThrottleTimer();
922
+ clearBootstrapRefreshTimer();
923
+ bootstrapRefreshNonce += 1;
924
+ pendingStreamingText = null;
925
+ inFlightModelsCatalogFetch = null;
926
+ inFlightSkillsCatalogFetch = null;
927
+ upstreamRunPipeline.clear();
928
+ },
929
+ trackAcceptedRun,
930
+ };
931
+ }