@xalia/agent 0.5.8 → 0.6.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.
Files changed (185) hide show
  1. package/README.md +23 -8
  2. package/dist/agent/src/agent/agent.js +173 -96
  3. package/dist/agent/src/agent/agentUtils.js +82 -53
  4. package/dist/agent/src/agent/compressingContextManager.js +102 -0
  5. package/dist/agent/src/agent/context.js +189 -0
  6. package/dist/agent/src/agent/dummyLLM.js +46 -5
  7. package/dist/agent/src/agent/iAgentEventHandler.js +2 -0
  8. package/dist/agent/src/agent/mcpServerManager.js +22 -23
  9. package/dist/agent/src/agent/nullAgentEventHandler.js +21 -0
  10. package/dist/agent/src/agent/nullPlatform.js +14 -0
  11. package/dist/agent/src/agent/openAILLMStreaming.js +12 -7
  12. package/dist/agent/src/agent/promptProvider.js +63 -0
  13. package/dist/agent/src/agent/repeatLLM.js +5 -5
  14. package/dist/agent/src/agent/sudoMcpServerManager.js +11 -9
  15. package/dist/agent/src/agent/tokenAuth.js +7 -7
  16. package/dist/agent/src/agent/tools.js +1 -1
  17. package/dist/agent/src/chat/client/chatClient.js +733 -0
  18. package/dist/agent/src/chat/client/connection.js +209 -0
  19. package/dist/agent/src/chat/client/connection.test.js +188 -0
  20. package/dist/agent/src/chat/client/constants.js +5 -0
  21. package/dist/agent/src/chat/client/index.js +15 -0
  22. package/dist/agent/src/chat/client/interfaces.js +2 -0
  23. package/dist/agent/src/chat/client/responseHandler.js +105 -0
  24. package/dist/agent/src/chat/client/sessionClient.js +331 -0
  25. package/dist/agent/src/chat/client/teamManager.js +2 -0
  26. package/dist/agent/src/chat/{apiKeyManager.js → data/apiKeyManager.js} +4 -0
  27. package/dist/agent/src/chat/data/dataModels.js +2 -0
  28. package/dist/agent/src/chat/data/database.js +749 -0
  29. package/dist/agent/src/chat/data/dbMcpServerConfigs.js +47 -0
  30. package/dist/agent/src/chat/protocol/connectionMessages.js +5 -0
  31. package/dist/agent/src/chat/protocol/constants.js +50 -0
  32. package/dist/agent/src/chat/protocol/errors.js +22 -0
  33. package/dist/agent/src/chat/protocol/messages.js +110 -0
  34. package/dist/agent/src/chat/server/chatContextManager.js +405 -0
  35. package/dist/agent/src/chat/server/connectionManager.js +352 -0
  36. package/dist/agent/src/chat/server/connectionManager.test.js +159 -0
  37. package/dist/agent/src/chat/server/conversation.js +198 -0
  38. package/dist/agent/src/chat/server/errorUtils.js +23 -0
  39. package/dist/agent/src/chat/server/openSession.js +869 -0
  40. package/dist/agent/src/chat/server/server.js +177 -0
  41. package/dist/agent/src/chat/server/sessionFileManager.js +161 -0
  42. package/dist/agent/src/chat/server/sessionRegistry.js +700 -0
  43. package/dist/agent/src/chat/server/sessionRegistry.test.js +97 -0
  44. package/dist/agent/src/chat/server/test-utils/mockFactories.js +307 -0
  45. package/dist/agent/src/chat/server/tools.js +243 -0
  46. package/dist/agent/src/chat/utils/agentSessionMap.js +66 -0
  47. package/dist/agent/src/chat/utils/approvalManager.js +85 -0
  48. package/dist/agent/src/{utils → chat/utils}/asyncLock.js +3 -3
  49. package/dist/agent/src/chat/{asyncQueue.js → utils/asyncQueue.js} +12 -2
  50. package/dist/agent/src/chat/utils/htmlToText.js +84 -0
  51. package/dist/agent/src/chat/utils/multiAsyncQueue.js +42 -0
  52. package/dist/agent/src/chat/utils/search.js +145 -0
  53. package/dist/agent/src/chat/utils/userResolver.js +46 -0
  54. package/dist/agent/src/chat/{websocket.js → utils/websocket.js} +2 -0
  55. package/dist/agent/src/test/agent.test.js +332 -0
  56. package/dist/agent/src/test/approvalManager.test.js +58 -0
  57. package/dist/agent/src/test/chatContextManager.test.js +392 -0
  58. package/dist/agent/src/test/clientServerConnection.test.js +158 -0
  59. package/dist/agent/src/test/compressingContextManager.test.js +65 -0
  60. package/dist/agent/src/test/context.test.js +83 -0
  61. package/dist/agent/src/test/conversation.test.js +89 -0
  62. package/dist/agent/src/test/db.test.js +262 -90
  63. package/dist/agent/src/test/dbMcpServerConfigs.test.js +72 -0
  64. package/dist/agent/src/test/dbTestTools.js +99 -0
  65. package/dist/agent/src/test/imageLoad.test.js +8 -7
  66. package/dist/agent/src/test/mcpServerManager.test.js +21 -18
  67. package/dist/agent/src/test/multiAsyncQueue.test.js +101 -0
  68. package/dist/agent/src/test/openaiStreaming.test.js +12 -11
  69. package/dist/agent/src/test/prompt.test.js +5 -4
  70. package/dist/agent/src/test/promptProvider.test.js +28 -0
  71. package/dist/agent/src/test/responseHandler.test.js +61 -0
  72. package/dist/agent/src/test/sudoMcpServerManager.test.js +14 -12
  73. package/dist/agent/src/test/testTools.js +109 -0
  74. package/dist/agent/src/test/tools.test.js +31 -0
  75. package/dist/agent/src/tool/agentChat.js +21 -10
  76. package/dist/agent/src/tool/agentMain.js +1 -1
  77. package/dist/agent/src/tool/chatMain.js +235 -58
  78. package/dist/agent/src/tool/commandPrompt.js +15 -9
  79. package/dist/agent/src/tool/files.js +20 -16
  80. package/dist/agent/src/tool/nodePlatform.js +47 -3
  81. package/dist/agent/src/tool/options.js +4 -4
  82. package/dist/agent/src/tool/prompt.js +19 -13
  83. package/eslint.config.mjs +14 -1
  84. package/package.json +14 -6
  85. package/scripts/chat_server +8 -0
  86. package/scripts/setup_chat +7 -2
  87. package/scripts/shutdown_chat_server +3 -0
  88. package/scripts/test_chat +135 -17
  89. package/src/agent/agent.ts +270 -135
  90. package/src/agent/agentUtils.ts +136 -95
  91. package/src/agent/compressingContextManager.ts +164 -0
  92. package/src/agent/context.ts +268 -0
  93. package/src/agent/dummyLLM.ts +76 -8
  94. package/src/agent/iAgentEventHandler.ts +54 -0
  95. package/src/agent/iplatform.ts +1 -0
  96. package/src/agent/mcpServerManager.ts +32 -30
  97. package/src/agent/nullAgentEventHandler.ts +20 -0
  98. package/src/agent/nullPlatform.ts +13 -0
  99. package/src/agent/openAILLMStreaming.ts +12 -6
  100. package/src/agent/promptProvider.ts +87 -0
  101. package/src/agent/repeatLLM.ts +5 -5
  102. package/src/agent/sudoMcpServerManager.ts +13 -11
  103. package/src/agent/tokenAuth.ts +7 -7
  104. package/src/agent/tools.ts +3 -1
  105. package/src/chat/client/chatClient.ts +900 -0
  106. package/src/chat/client/connection.test.ts +241 -0
  107. package/src/chat/client/connection.ts +276 -0
  108. package/src/chat/client/constants.ts +3 -0
  109. package/src/chat/client/index.ts +18 -0
  110. package/src/chat/client/interfaces.ts +34 -0
  111. package/src/chat/client/responseHandler.ts +131 -0
  112. package/src/chat/client/sessionClient.ts +443 -0
  113. package/src/chat/client/teamManager.ts +29 -0
  114. package/src/chat/{apiKeyManager.ts → data/apiKeyManager.ts} +6 -2
  115. package/src/chat/data/dataModels.ts +85 -0
  116. package/src/chat/data/database.ts +982 -0
  117. package/src/chat/data/dbMcpServerConfigs.ts +59 -0
  118. package/src/chat/protocol/connectionMessages.ts +49 -0
  119. package/src/chat/protocol/constants.ts +55 -0
  120. package/src/chat/protocol/errors.ts +16 -0
  121. package/src/chat/protocol/messages.ts +682 -0
  122. package/src/chat/server/README.md +127 -0
  123. package/src/chat/server/chatContextManager.ts +612 -0
  124. package/src/chat/server/connectionManager.test.ts +266 -0
  125. package/src/chat/server/connectionManager.ts +541 -0
  126. package/src/chat/server/conversation.ts +269 -0
  127. package/src/chat/server/errorUtils.ts +28 -0
  128. package/src/chat/server/openSession.ts +1332 -0
  129. package/src/chat/server/server.ts +177 -0
  130. package/src/chat/server/sessionFileManager.ts +239 -0
  131. package/src/chat/server/sessionRegistry.test.ts +138 -0
  132. package/src/chat/server/sessionRegistry.ts +1064 -0
  133. package/src/chat/server/test-utils/mockFactories.ts +422 -0
  134. package/src/chat/server/tools.ts +265 -0
  135. package/src/chat/utils/agentSessionMap.ts +76 -0
  136. package/src/chat/utils/approvalManager.ts +111 -0
  137. package/src/{utils → chat/utils}/asyncLock.ts +3 -3
  138. package/src/chat/{asyncQueue.ts → utils/asyncQueue.ts} +14 -3
  139. package/src/chat/utils/htmlToText.ts +61 -0
  140. package/src/chat/utils/multiAsyncQueue.ts +52 -0
  141. package/src/chat/utils/search.ts +139 -0
  142. package/src/chat/utils/userResolver.ts +48 -0
  143. package/src/chat/{websocket.ts → utils/websocket.ts} +2 -0
  144. package/src/test/agent.test.ts +487 -0
  145. package/src/test/approvalManager.test.ts +73 -0
  146. package/src/test/chatContextManager.test.ts +521 -0
  147. package/src/test/clientServerConnection.test.ts +207 -0
  148. package/src/test/compressingContextManager.test.ts +82 -0
  149. package/src/test/context.test.ts +105 -0
  150. package/src/test/conversation.test.ts +109 -0
  151. package/src/test/db.test.ts +351 -103
  152. package/src/test/dbMcpServerConfigs.test.ts +112 -0
  153. package/src/test/dbTestTools.ts +153 -0
  154. package/src/test/imageLoad.test.ts +7 -6
  155. package/src/test/mcpServerManager.test.ts +19 -14
  156. package/src/test/multiAsyncQueue.test.ts +125 -0
  157. package/src/test/openaiStreaming.test.ts +11 -10
  158. package/src/test/prompt.test.ts +4 -3
  159. package/src/test/promptProvider.test.ts +33 -0
  160. package/src/test/responseHandler.test.ts +78 -0
  161. package/src/test/sudoMcpServerManager.test.ts +22 -15
  162. package/src/test/testTools.ts +146 -0
  163. package/src/test/tools.test.ts +39 -0
  164. package/src/tool/agentChat.ts +26 -12
  165. package/src/tool/agentMain.ts +1 -1
  166. package/src/tool/chatMain.ts +283 -100
  167. package/src/tool/commandPrompt.ts +25 -9
  168. package/src/tool/files.ts +25 -19
  169. package/src/tool/nodePlatform.ts +52 -3
  170. package/src/tool/options.ts +4 -2
  171. package/src/tool/prompt.ts +22 -15
  172. package/test_data/dummyllm_script_crash.json +32 -0
  173. package/test_data/frog.png.b64 +1 -0
  174. package/vitest.config.ts +39 -0
  175. package/dist/agent/src/chat/client.js +0 -310
  176. package/dist/agent/src/chat/conversationManager.js +0 -502
  177. package/dist/agent/src/chat/db.js +0 -218
  178. package/dist/agent/src/chat/messages.js +0 -29
  179. package/dist/agent/src/chat/server.js +0 -158
  180. package/src/chat/client.ts +0 -445
  181. package/src/chat/conversationManager.ts +0 -730
  182. package/src/chat/db.ts +0 -304
  183. package/src/chat/messages.ts +0 -266
  184. package/src/chat/server.ts +0 -177
  185. /package/{frog.png → test_data/frog.png} +0 -0
@@ -0,0 +1,541 @@
1
+ import * as ws from "ws";
2
+ import { v4 as uuidv4 } from "uuid";
3
+ import { getLogger } from "@xalia/xmcp/sdk";
4
+
5
+ import {
6
+ ServerConnectionReady,
7
+ ServerConnectionError,
8
+ ServerConnectionPing,
9
+ ClientConnectionMessage,
10
+ ServerConnectionMessage,
11
+ } from "../protocol/connectionMessages";
12
+ import { ChatErrorMessage, ChatFatalError } from "../protocol/errors";
13
+ import { ApiKeyManager } from "../data/apiKeyManager";
14
+
15
+ const logger = getLogger();
16
+
17
+ const HEARTBEAT_INTERVAL_MS: number = parseInt(
18
+ process.env["HEARTBEAT_INTERVAL_MS"] || String(30 * 1000)
19
+ );
20
+
21
+ /**
22
+ * Interface for sending messages to clients.
23
+ */
24
+ export interface IUserConnectionManager<ServerMsgT> {
25
+ /**
26
+ * Send message to all active connections of specific users.
27
+ * Handles user-to-connection routing internally.
28
+ */
29
+ sendToUsers(userIds: Set<string>, message: ServerMsgT): void;
30
+
31
+ /**
32
+ * Send message to a specific connection.
33
+ */
34
+ sendToConnection(connectionId: string, message: ServerMsgT): void;
35
+
36
+ /**
37
+ * Get cached api key for a user. This key will not be available
38
+ * after the user disconnects.
39
+ */
40
+ getLiveUserApiKey(userId: string): string | undefined;
41
+ }
42
+
43
+ /**
44
+ * Interface for processing messages from the client.
45
+ */
46
+ export interface IMessageProcessor<ClientMsgT> {
47
+ processMessage(
48
+ connectionId: string,
49
+ userId: string,
50
+ message: ClientMsgT
51
+ ): Promise<void>;
52
+
53
+ /**
54
+ * Handle user disconnect - clean up from all sessions.
55
+ * Called when a connection is closed to ensure proper cleanup.
56
+ */
57
+ handleUserDisconnect(userId: string): void;
58
+ }
59
+
60
+ class Connection {
61
+ public ws: ws.WebSocket;
62
+ public userId: string;
63
+ public apiKey: string;
64
+ public heartbeat: NodeJS.Timeout;
65
+ public lastMessageTime: number;
66
+
67
+ constructor(
68
+ ws: ws.WebSocket,
69
+ userId: string,
70
+ apiKey: string,
71
+ heartbeat: NodeJS.Timeout
72
+ ) {
73
+ this.ws = ws;
74
+ this.userId = userId;
75
+ this.apiKey = apiKey;
76
+ this.heartbeat = heartbeat;
77
+ this.lastMessageTime = Date.now();
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Manages WebSocket connections and handles protocol-level message routing.
83
+ *
84
+ * - Connection handshake
85
+ * - Connection lifecycle management
86
+ * - Message routing between connections and sessions
87
+ * - Connection-to-user
88
+ */
89
+ export class ConnectionManager<ClientMsgT, ServerMsgT>
90
+ implements IUserConnectionManager<ServerMsgT>
91
+ {
92
+ // Connections by id
93
+ private connections: Map<string, Connection> = new Map();
94
+ // userId -> connectionIds
95
+ private userToConnections: Map<string, Set<string>> = new Map();
96
+
97
+ // this is a singleton object and can only be initialized by .init method
98
+ private constructor(
99
+ private apiKeyManager: ApiKeyManager,
100
+ private sessionMessageProcessor: IMessageProcessor<ClientMsgT>
101
+ ) {}
102
+
103
+ static init<ClientMsgT, ServerMsgT>(
104
+ apiKeyManager: ApiKeyManager,
105
+ createMsgProcessor: (
106
+ cm: IUserConnectionManager<ServerMsgT>
107
+ ) => IMessageProcessor<ClientMsgT>
108
+ ): ConnectionManager<ClientMsgT, ServerMsgT> {
109
+ const connManager = new ConnectionManager(
110
+ apiKeyManager,
111
+ {} as IMessageProcessor<ClientMsgT>
112
+ );
113
+ connManager.sessionMessageProcessor = createMsgProcessor(connManager);
114
+ return connManager;
115
+ }
116
+
117
+ /**
118
+ * Handle new WebSocket connection with handshake protocol
119
+ * TODO: proper error code returning
120
+ */
121
+ async handleConnection(
122
+ webSocket: ws.WebSocket,
123
+ apiKey: string,
124
+ req?: { headers: { [key: string]: string | string[] | undefined } }
125
+ ): Promise<void> {
126
+ const connectionId = uuidv4();
127
+ logger.info(`[ConnectionManager] New connection: ${connectionId}`);
128
+
129
+ try {
130
+ // Verify API key
131
+ const userData = await this.apiKeyManager.verifyApiKey(apiKey);
132
+ if (!userData) {
133
+ logger.error(`[ConnectionManager] Invalid API key: ${apiKey}`);
134
+ throw new ChatFatalError("invalid api key");
135
+ }
136
+
137
+ // Heartbeat interval
138
+ const heartbeat = setInterval(
139
+ this.onHeartbeat.bind(this, connectionId),
140
+ HEARTBEAT_INTERVAL_MS
141
+ );
142
+ // Register connection
143
+ this.connections.set(
144
+ connectionId,
145
+ new Connection(webSocket, userData.uuid, apiKey, heartbeat)
146
+ );
147
+
148
+ // Add to user connections
149
+ if (!this.userToConnections.has(userData.uuid)) {
150
+ this.userToConnections.set(userData.uuid, new Set());
151
+ }
152
+ const userConnections = this.userToConnections.get(userData.uuid);
153
+ if (userConnections) {
154
+ userConnections.add(connectionId);
155
+ }
156
+
157
+ // Setup connection handlers
158
+ this.setupConnectionHandlers(connectionId, webSocket);
159
+
160
+ // Send connection_ready after WebSocket is fully open
161
+ const clientVersion =
162
+ (req?.headers["x-client-version"] as string) || "unknown";
163
+
164
+ const response: ServerConnectionReady = {
165
+ t: "ready",
166
+ c_id: connectionId,
167
+ user_uuid: userData.uuid,
168
+ };
169
+
170
+ // Check if connection still exists before sending
171
+ if (this.connections.has(connectionId)) {
172
+ this.sendConnectionReadyMessage(connectionId, response);
173
+ } else {
174
+ logger.warn(
175
+ `[ConnectionManager] Connection ${connectionId} was closed` +
176
+ ` before ready message could be sent`
177
+ );
178
+ }
179
+
180
+ logger.info(
181
+ `[ConnectionManager] Connection ${connectionId} registered ` +
182
+ `for user ${userData.uuid}, version: ${clientVersion}`
183
+ );
184
+ } catch (error) {
185
+ const errorMessage =
186
+ error instanceof Error ? error.message : String(error);
187
+ logger.error(
188
+ `[ConnectionManager] Failed to setup connection ${connectionId}:`,
189
+ errorMessage
190
+ );
191
+
192
+ // Send error to client if possible
193
+ this.sendErrorDirect(webSocket, connectionId, errorMessage);
194
+
195
+ // Force cleanup of any partial state
196
+ this.forceCleanupConnection(connectionId);
197
+
198
+ // Close WebSocket - this might trigger handlers if they were attached,
199
+ // but we've already cleaned up state above as a safeguard
200
+ try {
201
+ webSocket.close(4000, "Connection setup failed");
202
+ } catch (closeError) {
203
+ logger.error(
204
+ `[ConnectionManager] Error closing WebSocket ${connectionId}:`,
205
+ closeError
206
+ );
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Send connection_ready message with unified error handling
213
+ */
214
+ private sendConnectionReadyMessage(
215
+ connectionId: string,
216
+ response: ServerConnectionReady
217
+ ): void {
218
+ try {
219
+ this.sendConnectionMessage(connectionId, response);
220
+ } catch (error) {
221
+ logger.error(
222
+ `[ConnectionManager] Failed connection_ready to ${connectionId}:`,
223
+ error
224
+ );
225
+ this.forceCleanupConnection(connectionId);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Send error message to connection directly.
231
+ * This is used when the connection is possibly not yet registered.
232
+ */
233
+ private sendErrorDirect(
234
+ ws: ws.WebSocket,
235
+ connectionId: string,
236
+ errorMessage: string
237
+ ): void {
238
+ const errorMsg: ServerConnectionError = {
239
+ t: "error",
240
+ e: errorMessage,
241
+ };
242
+ try {
243
+ if (ws.readyState === ws.OPEN) {
244
+ ws.send(JSON.stringify(errorMsg));
245
+ } else {
246
+ logger.error(
247
+ `[ConnectionManager] Conn not open sending error ${connectionId}`
248
+ );
249
+ }
250
+ } catch (error) {
251
+ logger.error(
252
+ `[ConnectionManager] Failed to send error to connection` +
253
+ ` ${connectionId}:`,
254
+ error
255
+ );
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Setup message and lifecycle handlers for a connection
261
+ */
262
+ private setupConnectionHandlers(
263
+ connectionId: string,
264
+ webSocket: ws.WebSocket
265
+ ): void {
266
+ // TODO: keep the Connection object in the closure here instead of always
267
+ // doing many (expensive) lookups.
268
+
269
+ // set message handler for incoming messages
270
+ webSocket.on("message", (data: ws.RawData) => {
271
+ void (async () => {
272
+ try {
273
+ const msgStr = (data as Buffer).toString("utf8");
274
+ logger.info(
275
+ `[ConnectionManager] Message from ${connectionId}: ${msgStr}`
276
+ );
277
+ const message = JSON.parse(
278
+ msgStr
279
+ ) as ClientConnectionMessage<ClientMsgT>;
280
+ await this.routeMessage(connectionId, message);
281
+ } catch (error) {
282
+ logger.error(`[ConnectionManager] Error processing message:`, error);
283
+ this.handleError(connectionId, error);
284
+ }
285
+ })();
286
+ });
287
+
288
+ webSocket.on("close", () => {
289
+ logger.debug(`[ConnectionManager] Connection ${connectionId} closed`);
290
+ this.handleConnectionClose(connectionId);
291
+ });
292
+
293
+ webSocket.on("error", (error) => {
294
+ logger.error(
295
+ `[ConnectionManager] WebSocket error on ${connectionId}:`,
296
+ error
297
+ );
298
+ this.handleConnectionClose(connectionId);
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Route incoming message to appropriate handler based on message type
304
+ */
305
+ private async routeMessage(
306
+ connectionId: string,
307
+ msg: ClientConnectionMessage<ClientMsgT>
308
+ ): Promise<void> {
309
+ const conn = this.connections.get(connectionId);
310
+ if (!conn) {
311
+ throw new ChatErrorMessage(
312
+ `Connection ${connectionId} not found for message routing`
313
+ );
314
+ }
315
+
316
+ conn.lastMessageTime = Date.now();
317
+
318
+ // Handle Connection-level messages
319
+ {
320
+ switch (msg.t) {
321
+ case "data":
322
+ await this.sessionMessageProcessor.processMessage(
323
+ connectionId,
324
+ conn.userId,
325
+ msg.d
326
+ );
327
+ break;
328
+ case "pong":
329
+ // The only operation is to update the `lastMessageTime`, done above
330
+ logger.debug(
331
+ `[ConnectionManager.routeMessage] pong from ${connectionId}`
332
+ );
333
+ break;
334
+ default: {
335
+ const _: never = msg;
336
+ break;
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ private onHeartbeat(connectionId: string): void {
343
+ // TODO: move this and other handling to the Connection class so we can
344
+ // avoid all the per-event lookups.
345
+
346
+ const conn = this.connections.get(connectionId);
347
+ if (!conn) {
348
+ // TODO: We intentionally don't try to clean up here, to force
349
+ // correctness fo the real cleanup code.
350
+ logger.error(
351
+ `[onHeartbeat] unexpected callback for connId ${connectionId}`
352
+ );
353
+ return;
354
+ }
355
+
356
+ const now = Date.now();
357
+ const timeSinceLast = now - conn.lastMessageTime;
358
+ logger.debug(
359
+ `[onHeartbeat] conn ${connectionId} since ${String(timeSinceLast)}`
360
+ );
361
+ if (timeSinceLast > 2 * HEARTBEAT_INTERVAL_MS) {
362
+ this.forceCleanupConnection(connectionId);
363
+ return;
364
+ }
365
+
366
+ const msg: ServerConnectionPing = { t: "ping" };
367
+ this.sendConnectionMessage(connectionId, msg);
368
+ }
369
+
370
+ /**
371
+ * Force cleanup of connection state - used for error recovery.
372
+ * This method is safe to call multiple times and handles partial state.
373
+ */
374
+ private forceCleanupConnection(connectionId: string): void {
375
+ const conn = this.connections.get(connectionId);
376
+ if (conn) {
377
+ // Cancel the heartbeat interval
378
+ clearInterval(conn.heartbeat);
379
+
380
+ // Remove from user connections
381
+ const userConnections = this.userToConnections.get(conn.userId);
382
+ if (userConnections) {
383
+ userConnections.delete(connectionId);
384
+ if (userConnections.size === 0) {
385
+ this.userToConnections.delete(conn.userId);
386
+ this.sessionMessageProcessor.handleUserDisconnect(conn.userId);
387
+ }
388
+ }
389
+
390
+ this.connections.delete(connectionId);
391
+ }
392
+
393
+ logger.debug(
394
+ `[ConnectionManager] Force cleaned up connection ${connectionId}`
395
+ );
396
+ }
397
+
398
+ /**
399
+ * Handle connection close - cleanup all state.
400
+ * This is the normal cleanup path triggered by WebSocket events.
401
+ */
402
+ private handleConnectionClose(connectionId: string): void {
403
+ this.forceCleanupConnection(connectionId);
404
+ logger.info(`[ConnectionManager] Connection ${connectionId} closed`);
405
+ }
406
+
407
+ private sendConnectionMessage(
408
+ connectionId: string,
409
+ message: ServerConnectionMessage<ServerMsgT>
410
+ ): void {
411
+ const conn = this.connections.get(connectionId);
412
+ if (!conn) {
413
+ logger.warn(
414
+ `[ConnectionManager] Cannot send to connection ${connectionId} - ` +
415
+ `not found`
416
+ );
417
+ return;
418
+ }
419
+
420
+ const webSocket = conn.ws;
421
+ if (webSocket.readyState !== 1) {
422
+ // WebSocket.OPEN = 1
423
+ logger.warn(
424
+ `[ConnectionManager] Cannot send to connection ${connectionId} - ` +
425
+ `not open`
426
+ );
427
+ return;
428
+ }
429
+
430
+ const msgString = JSON.stringify(message);
431
+ logger.debug(
432
+ `[ConnectionManager] Sending to ${connectionId}: ${msgString}`
433
+ );
434
+ webSocket.send(msgString);
435
+ }
436
+
437
+ /**
438
+ * Send message to specific connection.
439
+ */
440
+ public sendToConnection(connectionId: string, message: ServerMsgT): void {
441
+ this.sendConnectionMessage(connectionId, { t: "data", d: message });
442
+ }
443
+
444
+ /**
445
+ * Send error message to connection
446
+ */
447
+ private sendConnectionError(
448
+ connectionId: string,
449
+ errorMessage: string
450
+ ): void {
451
+ const errorMsg: ServerConnectionError = {
452
+ t: "error",
453
+ e: errorMessage,
454
+ };
455
+ this.sendConnectionMessage(connectionId, errorMsg);
456
+ }
457
+
458
+ /**
459
+ * Handle errors during message processing
460
+ */
461
+ private handleError(connectionId: string, error: unknown): void {
462
+ let message: string;
463
+ let shouldClose = false;
464
+
465
+ if (typeof error === "string") {
466
+ message = error;
467
+ } else if (error instanceof ChatFatalError) {
468
+ message = error.message;
469
+ shouldClose = true;
470
+ } else if (error instanceof ChatErrorMessage) {
471
+ message = error.message;
472
+ } else if (error instanceof Error) {
473
+ message = "Internal server error: " + error.message;
474
+ } else {
475
+ message = "Unknown error occurred";
476
+ }
477
+
478
+ logger.warn(
479
+ `[ConnectionManager] Error on connection ${connectionId}: ${message}`
480
+ );
481
+ this.sendConnectionError(connectionId, message);
482
+
483
+ if (shouldClose) {
484
+ const conn = this.connections.get(connectionId);
485
+ logger.info(
486
+ `[ConnectionManager] Closing connection ${connectionId} ` +
487
+ `due to error: ${message}`
488
+ );
489
+ if (conn) {
490
+ conn.ws.close(4000, message);
491
+ }
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Implementation of IUserConnectionManager interface.
497
+ * Send message to all active connections of specific users.
498
+ */
499
+ public sendToUsers(userIds: Set<string>, message: ServerMsgT): void {
500
+ for (const userId of userIds.values()) {
501
+ const connectionIds = this.getUserConnections(userId);
502
+ for (const connectionId of connectionIds) {
503
+ logger.debug(
504
+ `[ConnectionManager] sending to user ${userId} ` +
505
+ `connection ${connectionId}`
506
+ );
507
+ this.sendToConnection(connectionId, message);
508
+ }
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Get all active connection IDs for a user.
514
+ * Helper method for sendToUsers implementation.
515
+ */
516
+ private getUserConnections(userId: string): string[] {
517
+ const connections = this.userToConnections.get(userId);
518
+ return connections ? Array.from(connections) : [];
519
+ }
520
+
521
+ /**
522
+ * Implementation of IUserConnectionManager interface.
523
+ * Get cached api key for a user. This key will not be available
524
+ * after the user disconnects.
525
+ */
526
+ public getLiveUserApiKey(userId: string): string | undefined {
527
+ const connectionIds = this.getUserConnections(userId);
528
+
529
+ // Return API key from the first active connection
530
+ // (all connections for a user should have the same API key)
531
+ for (const connectionId of connectionIds) {
532
+ const conn = this.connections.get(connectionId);
533
+ if (conn) {
534
+ return conn.apiKey;
535
+ }
536
+ }
537
+
538
+ // No active connections for this user
539
+ return undefined;
540
+ }
541
+ }