@chrysb/alphaclaw 0.8.2 → 0.8.3-beta.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,835 @@
1
+ const { readOpenclawConfig } = require("./openclaw-config");
2
+
3
+ const kWsOpen = 1;
4
+ const kHistoryLimit = 200;
5
+ const kEnvRefPattern = /^\$\{([A-Z0-9_]+)\}$/i;
6
+ const kConnectTimeoutMs = 8000;
7
+ const kHistoryTimeoutMs = 12000;
8
+ const kGatewayReqTimeoutMs = 15000;
9
+ const kGatewayProtocolVersion = 3;
10
+ const kGatewayAdminScopes = ["operator.admin"];
11
+
12
+ const collectHistoryTextFragments = (value) => {
13
+ if (typeof value === "string") {
14
+ return value.length > 0 ? [value] : [];
15
+ }
16
+ if (Array.isArray(value)) {
17
+ return value.flatMap((entry) => collectHistoryTextFragments(entry));
18
+ }
19
+ if (!value || typeof value !== "object") return [];
20
+
21
+ if (typeof value.type === "string") {
22
+ const partType = String(value.type || "").toLowerCase();
23
+ if (partType === "text") {
24
+ return collectHistoryTextFragments(value.text);
25
+ }
26
+ if (
27
+ partType === "thinking" ||
28
+ partType === "toolcall" ||
29
+ partType === "tool_call" ||
30
+ partType === "toolresult" ||
31
+ partType === "tool_result"
32
+ ) {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ const textFields = [
38
+ value.text,
39
+ value.message,
40
+ value.content,
41
+ value.parts,
42
+ value.value,
43
+ value.output,
44
+ value.input,
45
+ ];
46
+
47
+ const fragments = textFields.flatMap((entry) => collectHistoryTextFragments(entry));
48
+
49
+ if (fragments.length > 0) return fragments;
50
+
51
+ // Fallback: scan object values to catch unknown transcript block shapes.
52
+ return Object.values(value).flatMap((entry) => collectHistoryTextFragments(entry));
53
+ };
54
+
55
+ const normalizeHistoryContent = (rawContent) => {
56
+ const parts = collectHistoryTextFragments(rawContent);
57
+ return parts
58
+ .join("")
59
+ .replace(/\r\n/g, "\n")
60
+ .replace(/\n{3,}/g, "\n\n")
61
+ .trim();
62
+ };
63
+
64
+ const normalizeHistoryRole = (rawRole = "") => {
65
+ const role = String(rawRole || "").toLowerCase();
66
+ if (
67
+ role === "user" ||
68
+ role === "human" ||
69
+ role === "client" ||
70
+ role === "input" ||
71
+ role.includes("user")
72
+ ) {
73
+ return "user";
74
+ }
75
+ return "assistant";
76
+ };
77
+
78
+ const normalizeHistoryTimestamp = (messageRow = {}) => {
79
+ const numericCandidate =
80
+ Number(messageRow?.timestamp) || Number(messageRow?.createdAt) || 0;
81
+ if (numericCandidate > 0) return numericCandidate;
82
+ const parsedDateMs = Date.parse(
83
+ String(messageRow?.timestamp || messageRow?.createdAt || ""),
84
+ );
85
+ return Number.isFinite(parsedDateMs) && parsedDateMs > 0
86
+ ? parsedDateMs
87
+ : Date.now();
88
+ };
89
+
90
+ const extractToolCalls = (messageRow = {}) => {
91
+ const contentParts = Array.isArray(messageRow?.content) ? messageRow.content : [];
92
+ return contentParts
93
+ .filter((part) => String(part?.type || "").toLowerCase() === "toolcall")
94
+ .map((part) => ({
95
+ id: String(part?.id || ""),
96
+ name: String(part?.name || ""),
97
+ arguments: part?.arguments || null,
98
+ partialJson: String(part?.partialJson || ""),
99
+ }))
100
+ .filter((toolCall) => toolCall.name || toolCall.id);
101
+ };
102
+
103
+ const extractHistoryMetadata = (messageRow = {}) => {
104
+ const metadata = {};
105
+ const assign = (key, value) => {
106
+ if (value === null || value === undefined) return;
107
+ if (typeof value === "string" && !value.trim()) return;
108
+ metadata[key] = value;
109
+ };
110
+ assign("api", messageRow?.api);
111
+ assign("provider", messageRow?.provider);
112
+ assign("model", messageRow?.model);
113
+ assign("stopReason", messageRow?.stopReason);
114
+ assign("thinkingLevel", messageRow?.thinkingLevel);
115
+ assign("senderLabel", messageRow?.senderLabel);
116
+ assign("runId", messageRow?.runId);
117
+ assign("inputTokens", Number(messageRow?.inputTokens) || undefined);
118
+ assign("outputTokens", Number(messageRow?.outputTokens) || undefined);
119
+ assign("totalTokens", Number(messageRow?.totalTokens) || undefined);
120
+ assign(
121
+ "cacheCreationInputTokens",
122
+ Number(messageRow?.cacheCreationInputTokens) || undefined,
123
+ );
124
+ assign(
125
+ "cacheReadInputTokens",
126
+ Number(messageRow?.cacheReadInputTokens) || undefined,
127
+ );
128
+ return Object.keys(metadata).length > 0 ? metadata : null;
129
+ };
130
+
131
+ const normalizePartType = (value = "") =>
132
+ String(value || "")
133
+ .toLowerCase()
134
+ .replaceAll("_", "")
135
+ .replaceAll("-", "");
136
+
137
+ const collectTextFromUnknownShape = (value) =>
138
+ normalizeHistoryContent(value?.content ?? value?.result ?? value?.text ?? value?.message);
139
+
140
+ const extractToolCallFromUnknownShape = (value) => {
141
+ if (!value || typeof value !== "object") return null;
142
+ if (Array.isArray(value)) {
143
+ for (const entry of value) {
144
+ const match = extractToolCallFromUnknownShape(entry);
145
+ if (match) return match;
146
+ }
147
+ return null;
148
+ }
149
+ const partType = normalizePartType(value?.type);
150
+ if (partType === "toolcall") {
151
+ const normalized = {
152
+ id: String(value?.id || value?.toolCallId || value?.callId || ""),
153
+ name: String(value?.name || value?.toolName || ""),
154
+ arguments: value?.arguments || value?.args || null,
155
+ partialJson: String(value?.partialJson || ""),
156
+ };
157
+ return normalized.name || normalized.id ? normalized : null;
158
+ }
159
+ const nestedCandidates = [
160
+ value?.part,
161
+ value?.delta,
162
+ value?.item,
163
+ value?.message,
164
+ value?.payload,
165
+ value?.data,
166
+ value?.value,
167
+ value?.content,
168
+ ];
169
+ for (const candidate of nestedCandidates) {
170
+ const match = extractToolCallFromUnknownShape(candidate);
171
+ if (match) return match;
172
+ }
173
+ return null;
174
+ };
175
+
176
+ const extractToolResultFromUnknownShape = (value) => {
177
+ if (!value || typeof value !== "object") return null;
178
+ if (Array.isArray(value)) {
179
+ for (const entry of value) {
180
+ const match = extractToolResultFromUnknownShape(entry);
181
+ if (match) return match;
182
+ }
183
+ return null;
184
+ }
185
+ const partType = normalizePartType(value?.type);
186
+ const rawRole = normalizePartType(value?.role);
187
+ const looksLikeToolResult =
188
+ partType === "toolresult" ||
189
+ rawRole === "toolresult" ||
190
+ (String(value?.toolCallId || value?.callId || "").trim().length > 0 &&
191
+ (value?.isError !== undefined ||
192
+ value?.status !== undefined ||
193
+ value?.content !== undefined ||
194
+ value?.result !== undefined ||
195
+ value?.text !== undefined));
196
+ if (looksLikeToolResult) {
197
+ const text = collectTextFromUnknownShape(value);
198
+ const content =
199
+ Array.isArray(value?.content) && value.content.length > 0
200
+ ? value.content
201
+ : text
202
+ ? [{ type: "text", text }]
203
+ : [];
204
+ return {
205
+ role: "toolResult",
206
+ toolCallId: String(value?.toolCallId || value?.callId || value?.id || ""),
207
+ toolName: String(value?.toolName || value?.name || ""),
208
+ content,
209
+ isError:
210
+ value?.isError === true ||
211
+ String(value?.status || "").toLowerCase() === "error",
212
+ timestamp: normalizeHistoryTimestamp(value),
213
+ };
214
+ }
215
+ const nestedCandidates = [
216
+ value?.part,
217
+ value?.delta,
218
+ value?.item,
219
+ value?.message,
220
+ value?.payload,
221
+ value?.data,
222
+ value?.value,
223
+ value?.content,
224
+ value?.result,
225
+ ];
226
+ for (const candidate of nestedCandidates) {
227
+ const match = extractToolResultFromUnknownShape(candidate);
228
+ if (match) return match;
229
+ }
230
+ return null;
231
+ };
232
+
233
+ const resolveRunIdFromPayload = (payload = {}) =>
234
+ String(
235
+ payload?.runId ||
236
+ payload?.run?.id ||
237
+ payload?.data?.runId ||
238
+ payload?.data?.run?.id ||
239
+ payload?.meta?.runId ||
240
+ "",
241
+ ).trim();
242
+
243
+ const resolveSessionKeyFromPayload = (payload = {}) =>
244
+ String(
245
+ payload?.sessionKey ||
246
+ payload?.session?.key ||
247
+ payload?.data?.sessionKey ||
248
+ payload?.data?.session?.key ||
249
+ payload?.meta?.sessionKey ||
250
+ "",
251
+ ).trim();
252
+
253
+ const sanitizeError = (error) => {
254
+ const message = error instanceof Error ? error.message : String(error || "");
255
+ if (message.toLowerCase().includes("not connected")) {
256
+ return "Agent runtime is not connected right now.";
257
+ }
258
+ return "Something went wrong. Please try again.";
259
+ };
260
+
261
+ const resolveTokenValue = (candidate = "") => {
262
+ const normalizedCandidate = String(candidate || "").trim();
263
+ if (!normalizedCandidate) return "";
264
+ const envMatch = normalizedCandidate.match(kEnvRefPattern);
265
+ if (!envMatch) return normalizedCandidate;
266
+ const envKey = String(envMatch[1] || "").trim();
267
+ if (!envKey) return "";
268
+ return String(process.env[envKey] || "").trim();
269
+ };
270
+
271
+ const withTimeout = async (promise, timeoutMs, label) => {
272
+ let timeoutId = null;
273
+ try {
274
+ return await Promise.race([
275
+ promise,
276
+ new Promise((_, reject) => {
277
+ timeoutId = setTimeout(() => {
278
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
279
+ }, timeoutMs);
280
+ }),
281
+ ]);
282
+ } finally {
283
+ if (timeoutId) clearTimeout(timeoutId);
284
+ }
285
+ };
286
+
287
+ const createChatWsService = ({
288
+ fs,
289
+ openclawDir = "",
290
+ getGatewayPort = () => 18789,
291
+ }) => {
292
+ let WebSocketServer = null;
293
+ let GatewayWebSocket = null;
294
+ try {
295
+ const wsModule = require("ws");
296
+ ({ WebSocketServer } = wsModule);
297
+ GatewayWebSocket = wsModule.WebSocket || wsModule;
298
+ } catch (err) {
299
+ console.warn(
300
+ `[alphaclaw] chat websocket disabled: missing ws dependency (${err.message})`,
301
+ );
302
+ return {
303
+ handleUpgrade: (request, socket) => {
304
+ socket.write(
305
+ "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nChat websocket unavailable",
306
+ );
307
+ socket.destroy();
308
+ },
309
+ fetchHistory: async () => {
310
+ throw new Error("Chat websocket unavailable");
311
+ },
312
+ };
313
+ }
314
+
315
+ const wss = new WebSocketServer({
316
+ noServer: true,
317
+ maxPayload: 1 * 1024 * 1024,
318
+ });
319
+ let gatewaySocket = null;
320
+ let gatewayConnectPromise = null;
321
+ const pendingGatewayRequests = new Map();
322
+ const runTargets = new Map();
323
+ const browserRuns = new WeakMap();
324
+
325
+ const sendJson = (ws, payload = {}) => {
326
+ if (!ws || ws.readyState !== kWsOpen) return;
327
+ ws.send(JSON.stringify(payload));
328
+ };
329
+
330
+ const getGatewayToken = () => {
331
+ const config = readOpenclawConfig({
332
+ fsModule: fs,
333
+ openclawDir,
334
+ fallback: {},
335
+ });
336
+ const envToken = String(process.env.OPENCLAW_GATEWAY_TOKEN || "").trim();
337
+ if (envToken) return envToken;
338
+ return resolveTokenValue(config?.gateway?.auth?.token);
339
+ };
340
+
341
+ const registerRunForBrowser = (ws, runId) => {
342
+ const existingRuns = browserRuns.get(ws);
343
+ if (existingRuns) {
344
+ existingRuns.add(runId);
345
+ return;
346
+ }
347
+ browserRuns.set(ws, new Set([runId]));
348
+ };
349
+
350
+ const clearRunTargetsForBrowser = (ws) => {
351
+ const runs = browserRuns.get(ws);
352
+ if (!runs) return;
353
+ for (const runId of runs) runTargets.delete(runId);
354
+ runs.clear();
355
+ browserRuns.delete(ws);
356
+ };
357
+
358
+ const settleGatewayRequest = (id, payload) => {
359
+ const pending = pendingGatewayRequests.get(id);
360
+ if (!pending) return;
361
+ pendingGatewayRequests.delete(id);
362
+ if (payload?.ok) {
363
+ pending.resolve(payload.payload || null);
364
+ return;
365
+ }
366
+ pending.reject(
367
+ new Error(
368
+ payload?.error?.message ||
369
+ payload?.error?.code ||
370
+ "Gateway request failed",
371
+ ),
372
+ );
373
+ };
374
+
375
+ const rejectAllGatewayRequests = (reason = "Gateway disconnected") => {
376
+ for (const [id, pending] of pendingGatewayRequests.entries()) {
377
+ pendingGatewayRequests.delete(id);
378
+ pending.reject(new Error(reason));
379
+ }
380
+ };
381
+
382
+ const markGatewayDisconnected = (reason = "Gateway disconnected") => {
383
+ gatewaySocket = null;
384
+ gatewayConnectPromise = null;
385
+ rejectAllGatewayRequests(reason);
386
+ };
387
+
388
+ const handleGatewayEvent = (eventPayload = {}) => {
389
+ const eventName = String(eventPayload.event || "");
390
+ const payload = eventPayload.payload || {};
391
+ const resolveTargetForPayload = () => {
392
+ const runId = resolveRunIdFromPayload(payload);
393
+ if (runId) {
394
+ const runTarget = runTargets.get(runId);
395
+ if (runTarget) return { runId, target: runTarget };
396
+ }
397
+ const sessionKey = resolveSessionKeyFromPayload(payload);
398
+ if (sessionKey) {
399
+ let sessionTarget = null;
400
+ for (const [, targetRow] of runTargets.entries()) {
401
+ if (String(targetRow?.sessionKey || "") !== sessionKey) continue;
402
+ sessionTarget = targetRow;
403
+ }
404
+ if (sessionTarget) return { runId: "", target: sessionTarget };
405
+ }
406
+ if (runTargets.size === 1) {
407
+ for (const [singleRunId, singleTarget] of runTargets.entries()) {
408
+ return { runId: String(singleRunId || ""), target: singleTarget };
409
+ }
410
+ }
411
+ return { runId: "", target: null };
412
+ };
413
+ if (eventName === "agent") {
414
+ const { runId, target } = resolveTargetForPayload();
415
+ if (!target) return;
416
+ const stream = String(payload?.stream || "");
417
+ const data = payload?.data || {};
418
+ const toolCall =
419
+ extractToolCallFromUnknownShape(payload) ||
420
+ extractToolCallFromUnknownShape(data);
421
+ if (toolCall) {
422
+ sendJson(target.ws, {
423
+ type: "tool",
424
+ phase: "call",
425
+ messageId: target.messageId,
426
+ sessionKey: target.sessionKey,
427
+ timestamp: Date.now(),
428
+ toolCall,
429
+ toolResult: null,
430
+ rawEvent: eventPayload || null,
431
+ });
432
+ }
433
+ const toolResult =
434
+ extractToolResultFromUnknownShape(payload) ||
435
+ extractToolResultFromUnknownShape(data);
436
+ if (toolResult) {
437
+ sendJson(target.ws, {
438
+ type: "tool",
439
+ phase: "result",
440
+ messageId: target.messageId,
441
+ sessionKey: target.sessionKey,
442
+ timestamp: Number(toolResult?.timestamp) || Date.now(),
443
+ toolCall: null,
444
+ toolResult,
445
+ rawEvent: eventPayload || null,
446
+ });
447
+ }
448
+ if (stream === "assistant") {
449
+ const rawDelta =
450
+ data?.delta == null || data?.delta === ""
451
+ ? data?.text
452
+ : data?.delta;
453
+ const delta = String(rawDelta || "");
454
+ if (!delta) return;
455
+ sendJson(target.ws, {
456
+ type: "chunk",
457
+ messageId: target.messageId,
458
+ content: delta,
459
+ sessionKey: target.sessionKey,
460
+ });
461
+ return;
462
+ }
463
+ if (stream === "lifecycle" && String(data?.phase || "") === "end") {
464
+ sendJson(target.ws, {
465
+ type: "done",
466
+ messageId: target.messageId,
467
+ sessionKey: target.sessionKey,
468
+ });
469
+ if (runId) {
470
+ runTargets.delete(runId);
471
+ } else {
472
+ for (const [candidateRunId, candidateTarget] of runTargets.entries()) {
473
+ if (candidateTarget !== target) continue;
474
+ runTargets.delete(candidateRunId);
475
+ break;
476
+ }
477
+ }
478
+ const runs = browserRuns.get(target.ws);
479
+ if (runs && runId) runs.delete(runId);
480
+ }
481
+ return;
482
+ }
483
+ if (eventName === "chat") {
484
+ const { runId, target } = resolveTargetForPayload();
485
+ if (!target) return;
486
+ if (String(payload?.state || "") === "error") {
487
+ sendJson(target.ws, {
488
+ type: "error",
489
+ message: "Something went wrong connecting to the agent.",
490
+ messageId: target.messageId,
491
+ sessionKey: target.sessionKey,
492
+ });
493
+ if (runId) {
494
+ runTargets.delete(runId);
495
+ } else {
496
+ for (const [candidateRunId, candidateTarget] of runTargets.entries()) {
497
+ if (candidateTarget !== target) continue;
498
+ runTargets.delete(candidateRunId);
499
+ break;
500
+ }
501
+ }
502
+ const runs = browserRuns.get(target.ws);
503
+ if (runs && runId) runs.delete(runId);
504
+ }
505
+ }
506
+ };
507
+
508
+ const ensureGatewayConnected = async () => {
509
+ if (gatewaySocket && gatewaySocket.readyState === kWsOpen) return gatewaySocket;
510
+ if (!gatewayConnectPromise) {
511
+ gatewayConnectPromise = withTimeout(
512
+ new Promise((resolve, reject) => {
513
+ const socket = new GatewayWebSocket(`ws://127.0.0.1:${getGatewayPort()}`);
514
+ const connectRequestId = crypto.randomUUID();
515
+ const connectParams = {
516
+ minProtocol: kGatewayProtocolVersion,
517
+ maxProtocol: kGatewayProtocolVersion,
518
+ client: {
519
+ id: "gateway-client",
520
+ version: "0.1.0",
521
+ platform: process.platform,
522
+ mode: "backend",
523
+ },
524
+ role: "operator",
525
+ scopes: kGatewayAdminScopes,
526
+ caps: [],
527
+ commands: [],
528
+ permissions: {},
529
+ auth: { token: getGatewayToken() },
530
+ locale: "en-US",
531
+ userAgent: "alphaclaw-chat-bridge/0.1.0",
532
+ };
533
+
534
+ socket.on("message", (rawData) => {
535
+ let payload = null;
536
+ try {
537
+ payload = JSON.parse(String(rawData || ""));
538
+ } catch {
539
+ return;
540
+ }
541
+ if (!payload || typeof payload !== "object") return;
542
+ if (
543
+ payload.type === "event" &&
544
+ String(payload.event || "") === "connect.challenge"
545
+ ) {
546
+ socket.send(
547
+ JSON.stringify({
548
+ type: "req",
549
+ id: connectRequestId,
550
+ method: "connect",
551
+ params: connectParams,
552
+ }),
553
+ );
554
+ return;
555
+ }
556
+ if (payload.type === "res") {
557
+ if (String(payload.id || "") === connectRequestId) {
558
+ if (payload.ok && payload?.payload?.type === "hello-ok") {
559
+ gatewaySocket = socket;
560
+ resolve(socket);
561
+ return;
562
+ }
563
+ reject(
564
+ new Error(
565
+ payload?.error?.message ||
566
+ payload?.error?.code ||
567
+ "OpenClaw gateway connect failed",
568
+ ),
569
+ );
570
+ try {
571
+ socket.close();
572
+ } catch {}
573
+ return;
574
+ }
575
+ settleGatewayRequest(String(payload.id || ""), payload);
576
+ return;
577
+ }
578
+ if (payload.type === "event") {
579
+ handleGatewayEvent(payload);
580
+ }
581
+ });
582
+
583
+ socket.on("error", (err) => {
584
+ const message = err instanceof Error ? err.message : String(err || "");
585
+ reject(new Error(message || "OpenClaw gateway websocket failed"));
586
+ markGatewayDisconnected("OpenClaw gateway websocket failed");
587
+ });
588
+
589
+ socket.on("close", (code) => {
590
+ markGatewayDisconnected(`Gateway disconnected (code ${code})`);
591
+ });
592
+ }),
593
+ kConnectTimeoutMs,
594
+ "OpenClaw client connect",
595
+ )
596
+ .finally(() => {
597
+ gatewayConnectPromise = null;
598
+ });
599
+ }
600
+ return gatewayConnectPromise;
601
+ };
602
+
603
+ const requestGateway = async (
604
+ method = "",
605
+ params = {},
606
+ timeoutMs = kGatewayReqTimeoutMs,
607
+ ) => {
608
+ const socket = await ensureGatewayConnected();
609
+ if (!socket || socket.readyState !== kWsOpen) {
610
+ throw new Error("OpenClaw gateway is not connected");
611
+ }
612
+ const requestId = crypto.randomUUID();
613
+ const responsePromise = new Promise((resolve, reject) => {
614
+ pendingGatewayRequests.set(requestId, { resolve, reject });
615
+ });
616
+ socket.send(
617
+ JSON.stringify({
618
+ type: "req",
619
+ id: requestId,
620
+ method,
621
+ params,
622
+ }),
623
+ );
624
+ return withTimeout(responsePromise, timeoutMs, `OpenClaw ${method} request`).finally(
625
+ () => {
626
+ pendingGatewayRequests.delete(requestId);
627
+ },
628
+ );
629
+ };
630
+
631
+ const handleHistory = async ({ ws, payload }) => {
632
+ const sessionKey = String(payload?.sessionKey || "").trim();
633
+ if (!sessionKey) {
634
+ sendJson(ws, { type: "history", messages: [] });
635
+ return;
636
+ }
637
+ const { messages, rawHistory } = await fetchHistory(sessionKey);
638
+ sendJson(ws, {
639
+ type: "history",
640
+ sessionKey,
641
+ messages,
642
+ rawHistory,
643
+ });
644
+ };
645
+
646
+ const handleMessage = async ({ ws, payload }) => {
647
+ const sessionKey = String(payload?.sessionKey || "").trim();
648
+ const content = String(payload?.content || "").trim();
649
+ const messageId = crypto.randomUUID();
650
+ if (!sessionKey || !content) {
651
+ sendJson(ws, {
652
+ type: "error",
653
+ message: "sessionKey and content are required",
654
+ messageId,
655
+ });
656
+ return;
657
+ }
658
+ const result = await requestGateway("chat.send", {
659
+ sessionKey,
660
+ message: content,
661
+ idempotencyKey: crypto.randomUUID(),
662
+ });
663
+ const runId = String(result?.runId || "").trim();
664
+ if (!runId) {
665
+ sendJson(ws, {
666
+ type: "error",
667
+ message: "Something went wrong connecting to the agent.",
668
+ messageId,
669
+ sessionKey,
670
+ });
671
+ return;
672
+ }
673
+ runTargets.set(runId, { ws, messageId, sessionKey });
674
+ registerRunForBrowser(ws, runId);
675
+ sendJson(ws, {
676
+ type: "started",
677
+ sessionKey,
678
+ runId,
679
+ messageId,
680
+ });
681
+ };
682
+
683
+ const handleStop = async ({ ws, payload }) => {
684
+ const sessionKey = String(payload?.sessionKey || "").trim();
685
+ if (!sessionKey) {
686
+ sendJson(ws, {
687
+ type: "error",
688
+ message: "sessionKey is required",
689
+ });
690
+ return;
691
+ }
692
+ const runs = browserRuns.get(ws);
693
+ if (runs) {
694
+ for (const runId of Array.from(runs)) {
695
+ const target = runTargets.get(runId);
696
+ if (!target || String(target.sessionKey || "") !== sessionKey) continue;
697
+ runTargets.delete(runId);
698
+ runs.delete(runId);
699
+ }
700
+ }
701
+ await requestGateway("chat.abort", { sessionKey });
702
+ sendJson(ws, {
703
+ type: "done",
704
+ sessionKey,
705
+ stopped: true,
706
+ });
707
+ };
708
+
709
+ wss.on("connection", (ws) => {
710
+ ws.on("close", () => {
711
+ clearRunTargetsForBrowser(ws);
712
+ });
713
+ ws.on("message", (rawData) => {
714
+ let payload = null;
715
+ try {
716
+ payload = JSON.parse(String(rawData || ""));
717
+ } catch {
718
+ return;
719
+ }
720
+ if (!payload || typeof payload !== "object") return;
721
+ const type = String(payload.type || "");
722
+ const run = async () => {
723
+ if (type === "history") {
724
+ await handleHistory({ ws, payload });
725
+ return;
726
+ }
727
+ if (type === "message") {
728
+ await handleMessage({ ws, payload });
729
+ return;
730
+ }
731
+ if (type === "stop") {
732
+ await handleStop({ ws, payload });
733
+ }
734
+ };
735
+ run().catch((err) => {
736
+ sendJson(ws, {
737
+ type: "error",
738
+ message: sanitizeError(err),
739
+ });
740
+ });
741
+ });
742
+ });
743
+
744
+ const fetchHistory = async (sessionKey = "") => {
745
+ const normalizedSessionKey = String(sessionKey || "").trim();
746
+ if (!normalizedSessionKey) {
747
+ return { messages: [], rawHistory: null };
748
+ }
749
+ const history = await requestGateway(
750
+ "chat.history",
751
+ {
752
+ sessionKey: normalizedSessionKey,
753
+ limit: kHistoryLimit,
754
+ },
755
+ kHistoryTimeoutMs,
756
+ );
757
+ const rawMessages = Array.isArray(history?.messages)
758
+ ? history.messages
759
+ : Array.isArray(history?.history)
760
+ ? history.history
761
+ : Array.isArray(history?.items)
762
+ ? history.items
763
+ : [];
764
+ const toolResultsByCallId = {};
765
+ for (const messageRow of rawMessages) {
766
+ if (String(messageRow?.role || "").toLowerCase() !== "toolresult") continue;
767
+ const toolCallId = String(messageRow?.toolCallId || "");
768
+ if (!toolCallId) continue;
769
+ toolResultsByCallId[toolCallId] = messageRow;
770
+ }
771
+
772
+ const messages = rawMessages
773
+ .flatMap((messageRow) => {
774
+ const rawRole = String(messageRow?.role || "").toLowerCase();
775
+ if (rawRole === "toolresult") return [];
776
+ let content = normalizeHistoryContent(
777
+ messageRow?.content ?? messageRow?.text ?? messageRow?.message,
778
+ );
779
+ const role = normalizeHistoryRole(messageRow?.role ?? messageRow?.author);
780
+ if (role === "user") {
781
+ content = content.replace(/^\[.*?\]\s*/, "");
782
+ }
783
+ const toolCalls = extractToolCalls(messageRow);
784
+ const normalizedContent = String(content || "").trim();
785
+ const timestamp = normalizeHistoryTimestamp(messageRow);
786
+ const metadata = extractHistoryMetadata(messageRow);
787
+ const basePayload = {
788
+ timestamp,
789
+ metadata,
790
+ rawMessage: messageRow || null,
791
+ };
792
+ const rows = [];
793
+ if (normalizedContent) {
794
+ rows.push({
795
+ role,
796
+ content: normalizedContent,
797
+ ...basePayload,
798
+ toolCalls: [],
799
+ toolResult: null,
800
+ });
801
+ }
802
+ for (const toolCall of toolCalls) {
803
+ const toolCallId = String(toolCall?.id || "");
804
+ rows.push({
805
+ role: "tool",
806
+ content: `Tool call: ${String(toolCall?.name || "unknown")}`,
807
+ ...basePayload,
808
+ toolCalls: [toolCall],
809
+ toolResult: toolCallId ? toolResultsByCallId[toolCallId] || null : null,
810
+ });
811
+ }
812
+ return rows;
813
+ })
814
+ .filter(
815
+ (messageRow) =>
816
+ String(messageRow.content || "").trim() ||
817
+ (Array.isArray(messageRow.toolCalls) && messageRow.toolCalls.length > 0),
818
+ );
819
+ return {
820
+ messages,
821
+ rawHistory: history || null,
822
+ };
823
+ };
824
+
825
+ return {
826
+ handleUpgrade: (request, socket, head) => {
827
+ wss.handleUpgrade(request, socket, head, (ws) => {
828
+ wss.emit("connection", ws, request);
829
+ });
830
+ },
831
+ fetchHistory,
832
+ };
833
+ };
834
+
835
+ module.exports = { createChatWsService };