@jungjaehoon/mama-core 1.0.4 → 1.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.
@@ -1,443 +0,0 @@
1
- /**
2
- * @fileoverview WebSocket Handler - MessageRouter-based real-time communication
3
- * @module mobile/websocket-handler
4
- * @version 2.0.0
5
- *
6
- * Handles WebSocket connections for mobile clients, using MessageRouter
7
- * for unified message processing (same as Discord/Slack gateways).
8
- *
9
- * @example
10
- * const { createWebSocketHandler } = require('./websocket-handler');
11
- * const wsHandler = createWebSocketHandler({
12
- * httpServer,
13
- * messageRouter,
14
- * sessionStore,
15
- * authToken: process.env.MAMA_AUTH_TOKEN
16
- * });
17
- */
18
-
19
- const { WebSocketServer } = require('ws');
20
- const { authenticateWebSocket } = require('./auth.js');
21
- const fs = require('fs').promises;
22
- const path = require('path');
23
- const os = require('os');
24
-
25
- /**
26
- * Default heartbeat interval (30 seconds)
27
- * @type {number}
28
- */
29
- const HEARTBEAT_INTERVAL = 30000;
30
-
31
- /**
32
- * Connection timeout (35 seconds - slightly longer than heartbeat)
33
- * @type {number}
34
- */
35
- const _CONNECTION_TIMEOUT = 35000;
36
-
37
- /**
38
- * Generate unique client ID
39
- * @returns {string}
40
- */
41
- function generateClientId() {
42
- return `client_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
43
- }
44
-
45
- /**
46
- * Extract user ID from WebSocket URL or headers
47
- * @param {http.IncomingMessage} req - HTTP upgrade request
48
- * @returns {string}
49
- */
50
- function extractUserId(req) {
51
- try {
52
- const url = new URL(req.url, `http://${req.headers.host}`);
53
- const userId = url.searchParams.get('userId');
54
- if (userId) {
55
- return userId;
56
- }
57
-
58
- const ip = req.headers['x-forwarded-for'] || req.socket?.remoteAddress;
59
- if (!ip) {
60
- return `user_${Date.now()}`;
61
- }
62
- return `user_${ip.replace(/[:.]/g, '_')}`;
63
- } catch {
64
- return `user_${Date.now()}`;
65
- }
66
- }
67
-
68
- /**
69
- * Create WebSocket handler with MessageRouter integration
70
- * @param {Object} options - Configuration options
71
- * @param {http.Server} options.httpServer - HTTP server to attach WebSocket to
72
- * @param {MessageRouter} options.messageRouter - Message router instance
73
- * @param {SessionStore} options.sessionStore - Session store instance
74
- * @param {string} [options.authToken] - Optional authentication token
75
- * @returns {WebSocketServer}
76
- */
77
- function createWebSocketHandler({
78
- httpServer,
79
- messageRouter,
80
- sessionStore,
81
- authToken: _authToken,
82
- }) {
83
- const wss = new WebSocketServer({ noServer: true });
84
- const clients = new Map();
85
-
86
- httpServer.on('upgrade', (req, socket, head) => {
87
- const url = new URL(req.url, `http://${req.headers.host}`);
88
-
89
- if (url.pathname !== '/ws') {
90
- socket.destroy();
91
- return;
92
- }
93
-
94
- const mockWs = {
95
- close: (code, reason) => {
96
- socket.write(`HTTP/1.1 401 Unauthorized\r\n\r\n`);
97
- socket.destroy();
98
- console.error(`[WebSocket] Auth failed: ${reason}`);
99
- },
100
- };
101
-
102
- if (!authenticateWebSocket(req, mockWs)) {
103
- return;
104
- }
105
-
106
- wss.handleUpgrade(req, socket, head, (ws) => {
107
- wss.emit('connection', ws, req);
108
- });
109
- });
110
-
111
- wss.on('connection', (ws, req) => {
112
- const clientId = generateClientId();
113
- const userId = extractUserId(req);
114
-
115
- const clientInfo = {
116
- clientId,
117
- userId,
118
- ws,
119
- isAlive: true,
120
- connectedAt: new Date().toISOString(),
121
- };
122
- clients.set(clientId, clientInfo);
123
-
124
- console.error(`[WebSocket] Client ${clientId} connected (user: ${userId})`);
125
-
126
- ws.send(
127
- JSON.stringify({
128
- type: 'connected',
129
- clientId,
130
- userId,
131
- })
132
- );
133
-
134
- ws.on('message', (data) => {
135
- try {
136
- const message = JSON.parse(data.toString());
137
- handleClientMessage(clientId, message, clientInfo, messageRouter, sessionStore);
138
- } catch (err) {
139
- console.error(`[WebSocket] Invalid message from ${clientId}:`, err.message);
140
- ws.send(
141
- JSON.stringify({
142
- type: 'error',
143
- error: 'Invalid message format',
144
- })
145
- );
146
- }
147
- });
148
-
149
- ws.on('pong', () => {
150
- clientInfo.isAlive = true;
151
- });
152
-
153
- ws.on('close', (code, _reason) => {
154
- console.error(`[WebSocket] Client ${clientId} disconnected (code: ${code})`);
155
- clients.delete(clientId);
156
- });
157
-
158
- ws.on('error', (err) => {
159
- console.error(`[WebSocket] Error for client ${clientId}:`, err.message);
160
- });
161
- });
162
-
163
- const heartbeatInterval = setInterval(() => {
164
- clients.forEach((clientInfo, clientId) => {
165
- if (!clientInfo.isAlive) {
166
- console.error(`[WebSocket] Client ${clientId} timed out, terminating`);
167
- clientInfo.ws.terminate();
168
- clients.delete(clientId);
169
- return;
170
- }
171
-
172
- clientInfo.isAlive = false;
173
- clientInfo.ws.ping();
174
- });
175
- }, HEARTBEAT_INTERVAL);
176
-
177
- wss.on('close', () => {
178
- clearInterval(heartbeatInterval);
179
- });
180
-
181
- wss.getClients = () => clients;
182
- wss.getClientCount = () => clients.size;
183
-
184
- return wss;
185
- }
186
-
187
- /**
188
- * Handle incoming client message
189
- * @param {string} clientId - Client identifier
190
- * @param {Object} message - Parsed message object
191
- * @param {Object} clientInfo - Client info object
192
- * @param {MessageRouter} messageRouter - Message router instance
193
- * @param {SessionStore} sessionStore - Session store instance
194
- */
195
- async function handleClientMessage(clientId, message, clientInfo, messageRouter, sessionStore) {
196
- const { type, content } = message;
197
-
198
- switch (type) {
199
- case 'send':
200
- if (!content) {
201
- clientInfo.ws.send(
202
- JSON.stringify({
203
- type: 'error',
204
- error: 'Message content required',
205
- })
206
- );
207
- break;
208
- }
209
-
210
- try {
211
- clientInfo.ws.send(
212
- JSON.stringify({
213
- type: 'typing',
214
- status: 'processing',
215
- })
216
- );
217
-
218
- // Keep-alive: Send periodic status updates to prevent WebSocket timeout
219
- // Browser may disconnect idle WebSocket after ~30 seconds
220
- let processingSeconds = 0;
221
- const keepAliveInterval = setInterval(() => {
222
- processingSeconds += 10;
223
- if (clientInfo.ws.readyState === 1) {
224
- // WebSocket.OPEN
225
- clientInfo.ws.send(
226
- JSON.stringify({
227
- type: 'typing',
228
- status: 'processing',
229
- elapsed: processingSeconds,
230
- })
231
- );
232
- console.error(
233
- `[WebSocket] Keep-alive sent to ${clientId} (${processingSeconds}s elapsed)`
234
- );
235
- }
236
- }, 10000); // Every 10 seconds
237
-
238
- const normalizedMessage = {
239
- source: clientInfo.osAgentMode ? 'viewer' : 'mobile',
240
- channelId: 'mama_os_main', // Fixed channel for all MAMA OS viewers
241
- channelName: clientInfo.osAgentMode ? 'MAMA OS' : 'Mobile App', // Human-readable channel name
242
- userId: clientInfo.userId,
243
- text: content,
244
- metadata: {
245
- clientId,
246
- sessionId: clientInfo.sessionId,
247
- osAgentMode: clientInfo.osAgentMode,
248
- timestamp: Date.now(),
249
- },
250
- };
251
-
252
- let result;
253
- try {
254
- result = await messageRouter.process(normalizedMessage);
255
- } finally {
256
- clearInterval(keepAliveInterval);
257
- }
258
-
259
- // Send response as stream (for Chat tab compatibility)
260
- clientInfo.ws.send(
261
- JSON.stringify({
262
- type: 'stream',
263
- content: result.response,
264
- sessionId: result.sessionId,
265
- })
266
- );
267
-
268
- // Send stream end
269
- clientInfo.ws.send(
270
- JSON.stringify({
271
- type: 'stream_end',
272
- sessionId: result.sessionId,
273
- duration: result.duration,
274
- })
275
- );
276
-
277
- console.error(`[WebSocket] Message processed for ${clientId} (${result.duration}ms)`);
278
- } catch (error) {
279
- console.error(`[WebSocket] Message processing error:`, error);
280
- clientInfo.ws.send(
281
- JSON.stringify({
282
- type: 'error',
283
- error: 'Failed to process message',
284
- details: error.message,
285
- })
286
- );
287
- }
288
- break;
289
-
290
- case 'ping':
291
- clientInfo.ws.send(
292
- JSON.stringify({
293
- type: 'pong',
294
- timestamp: Date.now(),
295
- })
296
- );
297
- break;
298
-
299
- case 'attach': {
300
- // Attach to a session (for Chat tab compatibility)
301
- const { sessionId, osAgentMode, language } = message;
302
- clientInfo.sessionId = sessionId;
303
- clientInfo.osAgentMode = osAgentMode || false;
304
- clientInfo.language = language || 'en';
305
-
306
- console.error(
307
- `[WebSocket] Client ${clientId} attached to session ${sessionId}${osAgentMode ? ' (OS Agent mode)' : ''}`
308
- );
309
-
310
- // Send attached confirmation
311
- clientInfo.ws.send(
312
- JSON.stringify({
313
- type: 'attached',
314
- sessionId,
315
- osAgentMode: clientInfo.osAgentMode,
316
- })
317
- );
318
-
319
- // Send session history if available, or send onboarding greeting
320
- console.error(
321
- `[WebSocket] Checking sessionStore for history: ${!!sessionStore}, hasMethod: ${!!(sessionStore && sessionStore.getHistoryByChannel)}`
322
- );
323
- if (sessionStore) {
324
- try {
325
- // Use channel-based lookup for sessions
326
- // Use dynamic source based on osAgentMode (viewer vs mobile)
327
- const channelId = 'mama_os_main';
328
- const source = clientInfo.osAgentMode ? 'viewer' : 'mobile';
329
- console.error(`[WebSocket] Loading history for source=${source}, channelId=${channelId}`);
330
- const history = sessionStore.getHistoryByChannel
331
- ? sessionStore.getHistoryByChannel(source, channelId)
332
- : sessionStore.getHistory(sessionId);
333
- console.error(`[WebSocket] History loaded: ${history ? history.length : 0} turns`);
334
- if (history && history.length > 0) {
335
- // Convert {user, bot, timestamp} format to {role, content, timestamp} for display
336
- const formattedMessages = history.flatMap((turn) => {
337
- const messages = [];
338
- if (turn.user) {
339
- messages.push({
340
- role: 'user',
341
- content: turn.user,
342
- timestamp: turn.timestamp,
343
- });
344
- }
345
- if (turn.bot) {
346
- messages.push({
347
- role: 'assistant',
348
- content: turn.bot,
349
- timestamp: turn.timestamp,
350
- });
351
- }
352
- return messages;
353
- });
354
- clientInfo.ws.send(
355
- JSON.stringify({
356
- type: 'history',
357
- messages: formattedMessages,
358
- })
359
- );
360
- console.error(
361
- `[WebSocket] Sent ${formattedMessages.length} history messages to ${clientId}`
362
- );
363
- } else {
364
- // No history - check if onboarding mode (SOUL.md not found)
365
- const soulPath = path.join(os.homedir(), '.mama', 'SOUL.md');
366
- let isOnboarding = false;
367
- try {
368
- await fs.access(soulPath);
369
- } catch {
370
- isOnboarding = true;
371
- }
372
-
373
- if (isOnboarding) {
374
- // Send onboarding greeting based on browser language
375
- const isKorean = clientInfo.language && clientInfo.language.startsWith('ko');
376
- const greeting = isKorean
377
- ? '✨ I just woke up.\n\nNo name yet, no personality, no memories. Just... pure potential. 🌱\n\nWho are you? And more importantly—who do you want me to become? 💭'
378
- : '✨ I just woke up.\n\nNo name yet, no personality, no memories. Just... pure potential. 🌱\n\nWho are you? And more importantly—who do you want me to become? 💭';
379
-
380
- clientInfo.ws.send(
381
- JSON.stringify({
382
- type: 'history',
383
- messages: [
384
- {
385
- role: 'assistant',
386
- content: greeting,
387
- timestamp: Date.now(),
388
- },
389
- ],
390
- })
391
- );
392
- console.error(`[WebSocket] Sent onboarding greeting to ${clientId}`);
393
- }
394
- }
395
- } catch (error) {
396
- console.error(`[WebSocket] Failed to load history:`, error.message);
397
- }
398
- }
399
- break;
400
- }
401
-
402
- default:
403
- console.error(`[WebSocket] Unknown message type from ${clientId}: ${type}`);
404
- clientInfo.ws.send(
405
- JSON.stringify({
406
- type: 'error',
407
- error: `Unknown message type: ${type}`,
408
- })
409
- );
410
- }
411
- }
412
-
413
- /**
414
- * WebSocketHandler class (backwards compatibility)
415
- * @deprecated Use createWebSocketHandler() instead
416
- */
417
- class WebSocketHandler {
418
- constructor(httpServer, options) {
419
- this.httpServer = httpServer;
420
- this.options = options;
421
- this.wss = createWebSocketHandler({
422
- httpServer,
423
- ...options,
424
- });
425
- }
426
-
427
- getClients() {
428
- return this.wss.getClients();
429
- }
430
-
431
- getClientCount() {
432
- return this.wss.getClientCount();
433
- }
434
-
435
- close() {
436
- this.wss.close();
437
- }
438
- }
439
-
440
- module.exports = {
441
- createWebSocketHandler,
442
- WebSocketHandler,
443
- };