@jungjaehoon/mama-os 0.8.3 → 0.9.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 (106) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/agent/agent-loop.d.ts +1 -8
  3. package/dist/agent/agent-loop.d.ts.map +1 -1
  4. package/dist/agent/agent-loop.js +44 -159
  5. package/dist/agent/agent-loop.js.map +1 -1
  6. package/dist/agent/claude-cli-wrapper.d.ts +6 -0
  7. package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
  8. package/dist/agent/claude-cli-wrapper.js +6 -0
  9. package/dist/agent/claude-cli-wrapper.js.map +1 -1
  10. package/dist/agent/codex-mcp-process.d.ts +85 -0
  11. package/dist/agent/codex-mcp-process.d.ts.map +1 -0
  12. package/dist/agent/codex-mcp-process.js +357 -0
  13. package/dist/agent/codex-mcp-process.js.map +1 -0
  14. package/dist/agent/session-pool.d.ts +17 -2
  15. package/dist/agent/session-pool.d.ts.map +1 -1
  16. package/dist/agent/session-pool.js +51 -26
  17. package/dist/agent/session-pool.js.map +1 -1
  18. package/dist/agent/types.d.ts +9 -24
  19. package/dist/agent/types.d.ts.map +1 -1
  20. package/dist/agent/types.js.map +1 -1
  21. package/dist/api/graph-api.d.ts.map +1 -1
  22. package/dist/api/graph-api.js +133 -45
  23. package/dist/api/graph-api.js.map +1 -1
  24. package/dist/cli/commands/init.d.ts +1 -1
  25. package/dist/cli/commands/init.d.ts.map +1 -1
  26. package/dist/cli/commands/init.js +14 -25
  27. package/dist/cli/commands/init.js.map +1 -1
  28. package/dist/cli/commands/run.d.ts.map +1 -1
  29. package/dist/cli/commands/run.js +3 -10
  30. package/dist/cli/commands/run.js.map +1 -1
  31. package/dist/cli/commands/start.d.ts.map +1 -1
  32. package/dist/cli/commands/start.js +143 -54
  33. package/dist/cli/commands/start.js.map +1 -1
  34. package/dist/cli/commands/status.d.ts.map +1 -1
  35. package/dist/cli/commands/status.js +2 -7
  36. package/dist/cli/commands/status.js.map +1 -1
  37. package/dist/cli/config/config-manager.d.ts.map +1 -1
  38. package/dist/cli/config/config-manager.js +9 -17
  39. package/dist/cli/config/config-manager.js.map +1 -1
  40. package/dist/cli/config/types.d.ts +19 -25
  41. package/dist/cli/config/types.d.ts.map +1 -1
  42. package/dist/cli/config/types.js.map +1 -1
  43. package/dist/cli/index.js +2 -2
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/gateways/context-injector.d.ts.map +1 -1
  46. package/dist/gateways/context-injector.js +6 -3
  47. package/dist/gateways/context-injector.js.map +1 -1
  48. package/dist/gateways/discord.d.ts +4 -0
  49. package/dist/gateways/discord.d.ts.map +1 -1
  50. package/dist/gateways/discord.js +39 -16
  51. package/dist/gateways/discord.js.map +1 -1
  52. package/dist/gateways/message-router.d.ts +6 -1
  53. package/dist/gateways/message-router.d.ts.map +1 -1
  54. package/dist/gateways/message-router.js +92 -7
  55. package/dist/gateways/message-router.js.map +1 -1
  56. package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
  57. package/dist/multi-agent/agent-process-manager.js +36 -9
  58. package/dist/multi-agent/agent-process-manager.js.map +1 -1
  59. package/dist/multi-agent/runtime-process.d.ts +4 -4
  60. package/dist/multi-agent/runtime-process.d.ts.map +1 -1
  61. package/dist/multi-agent/runtime-process.js +9 -20
  62. package/dist/multi-agent/runtime-process.js.map +1 -1
  63. package/dist/multi-agent/types.d.ts +13 -8
  64. package/dist/multi-agent/types.d.ts.map +1 -1
  65. package/dist/multi-agent/types.js.map +1 -1
  66. package/dist/setup/setup-prompt.d.ts +1 -1
  67. package/dist/setup/setup-prompt.d.ts.map +1 -1
  68. package/dist/setup/setup-prompt.js +19 -0
  69. package/dist/setup/setup-prompt.js.map +1 -1
  70. package/dist/setup/setup-server.d.ts.map +1 -1
  71. package/dist/setup/setup-server.js +39 -16
  72. package/dist/setup/setup-server.js.map +1 -1
  73. package/dist/skills/skill-registry.d.ts.map +1 -1
  74. package/dist/skills/skill-registry.js +5 -2
  75. package/dist/skills/skill-registry.js.map +1 -1
  76. package/package.json +5 -3
  77. package/public/setup.html +12 -1
  78. package/public/viewer/js/modules/chat.js +1760 -1976
  79. package/public/viewer/js/modules/dashboard.js +613 -695
  80. package/public/viewer/js/modules/graph.js +857 -970
  81. package/public/viewer/js/modules/memory.js +357 -312
  82. package/public/viewer/js/modules/settings.js +1009 -1026
  83. package/public/viewer/js/modules/skills.js +336 -355
  84. package/public/viewer/js/utils/api.js +255 -255
  85. package/public/viewer/js/utils/debug-logger.js +20 -26
  86. package/public/viewer/js/utils/dom.js +73 -60
  87. package/public/viewer/js/utils/format.js +182 -228
  88. package/public/viewer/js/utils/markdown.js +40 -0
  89. package/public/viewer/src/modules/chat.ts +2258 -0
  90. package/public/viewer/src/modules/dashboard.ts +1052 -0
  91. package/public/viewer/src/modules/graph.ts +1080 -0
  92. package/public/viewer/src/modules/memory.ts +453 -0
  93. package/public/viewer/src/modules/settings.ts +1398 -0
  94. package/public/viewer/src/modules/skills.ts +457 -0
  95. package/public/viewer/src/types/global.d.ts +168 -0
  96. package/public/viewer/src/utils/api.ts +650 -0
  97. package/public/viewer/src/utils/debug-logger.ts +36 -0
  98. package/public/viewer/src/utils/dom.ts +138 -0
  99. package/public/viewer/src/utils/format.ts +331 -0
  100. package/public/viewer/src/utils/markdown.ts +46 -0
  101. package/public/viewer/tsconfig.viewer.json +18 -0
  102. package/public/viewer/viewer.html +214 -311
  103. package/dist/agent/codex-cli-wrapper.d.ts +0 -85
  104. package/dist/agent/codex-cli-wrapper.d.ts.map +0 -1
  105. package/dist/agent/codex-cli-wrapper.js +0 -295
  106. package/dist/agent/codex-cli-wrapper.js.map +0 -1
@@ -0,0 +1,2258 @@
1
+ /**
2
+ * Chat Module - Mobile Chat with Voice Input
3
+ * @module modules/chat
4
+ * @version 1.0.0
5
+ *
6
+ * Handles Chat tab functionality including:
7
+ * - WebSocket chat with Claude Code CLI
8
+ * - Voice input (Web Speech API)
9
+ * - Conversation history management
10
+ * - Real-time streaming responses
11
+ */
12
+
13
+ /* eslint-env browser */
14
+
15
+ import {
16
+ escapeHtml,
17
+ escapeAttr,
18
+ showToast,
19
+ scrollToBottom,
20
+ autoResizeTextarea,
21
+ getElementByIdOrNull,
22
+ getErrorMessage,
23
+ } from '../utils/dom.js';
24
+ import { formatMessageTime, formatAssistantMessage } from '../utils/format.js';
25
+ import { API, type JsonRecord } from '../utils/api.js';
26
+ import { DebugLogger } from '../utils/debug-logger.js';
27
+
28
+ const logger = new DebugLogger('Chat');
29
+
30
+ type ChatAttachment = {
31
+ isImage: boolean;
32
+ mediaUrl: string;
33
+ filename: string;
34
+ originalName: string;
35
+ };
36
+
37
+ type ChatHistoryMessage = {
38
+ role: 'user' | 'assistant' | 'system';
39
+ content: string;
40
+ timestamp: string;
41
+ attachment?: ChatAttachment;
42
+ };
43
+
44
+ type ChatToolInput = {
45
+ file_path?: string;
46
+ command?: string;
47
+ [key: string]: unknown;
48
+ };
49
+
50
+ type ChatIncomingMessage = {
51
+ type?: string;
52
+ sessionId?: string;
53
+ messages?: ChatHistoryMessage[];
54
+ content?: string;
55
+ error?: string;
56
+ tool?: string;
57
+ toolId?: string;
58
+ input?: ChatToolInput | Record<string, unknown> | null;
59
+ index?: number;
60
+ elapsed?: number;
61
+ [key: string]: unknown;
62
+ };
63
+
64
+ type CheckpointRecord = {
65
+ timestamp: string;
66
+ summary: string;
67
+ };
68
+
69
+ // Reserved for future use - attachment message handling
70
+ type _ChatMessageWithAttachment = {
71
+ id?: string;
72
+ [key: string]: unknown;
73
+ };
74
+
75
+ /**
76
+ * Chat Module Class
77
+ */
78
+ export class ChatModule {
79
+ memoryModule: {
80
+ showRelatedForMessage: (message: string) => void;
81
+ showSaveFormWithText: (text: string) => void;
82
+ searchWithQuery: (query: string) => Promise<void>;
83
+ } | null = null;
84
+ ws: WebSocket | null = null;
85
+ sessionId: string | null = null;
86
+ reconnectAttempts = 0;
87
+ maxReconnectDelay = 30000;
88
+ speechRecognition: SpeechRecognition | null = null;
89
+ isRecording = false;
90
+ silenceTimeout: ReturnType<typeof setTimeout> | null = null;
91
+ silenceDelay = 2500;
92
+ accumulatedTranscript = '';
93
+ speechSynthesis: SpeechSynthesis = window.speechSynthesis;
94
+ isSpeaking = false;
95
+ ttsEnabled = false;
96
+ handsFreeMode = false;
97
+ ttsVoice: SpeechSynthesisVoice | null = null;
98
+ ttsRate = 1.8;
99
+ ttsPitch = 1.0;
100
+ currentStreamEl: HTMLDivElement | null = null;
101
+ currentStreamText = '';
102
+ streamBuffer = '';
103
+ rafPending = false;
104
+ history: ChatHistoryMessage[] = [];
105
+ historyPrefix = 'mama_chat_history_';
106
+ maxHistoryMessages = 50;
107
+ historyExpiryMs = 24 * 60 * 60 * 1000;
108
+ checkpointCooldown = false;
109
+ COOLDOWN_MS = 60 * 1000;
110
+ idleTimer: ReturnType<typeof setTimeout> | null = null;
111
+ IDLE_TIMEOUT = 5 * 60 * 1000;
112
+ _onDragMouseMove: ((event: MouseEvent) => void) | null = null;
113
+ _onDragMouseUp: ((event: MouseEvent) => void) | null = null;
114
+ _onDragTouchMove: ((event: TouchEvent) => void) | null = null;
115
+ _onDragTouchEnd: (() => void) | null = null;
116
+ _onResizeMouseMove: ((event: MouseEvent) => void) | null = null;
117
+ _onResizeMouseUp: ((event: MouseEvent) => void) | null = null;
118
+ _onResizeTouchMove: ((event: TouchEvent) => void) | null = null;
119
+ _onResizeTouchEnd: (() => void) | null = null;
120
+ _onEscapeKey: ((event: KeyboardEvent) => void) | null = null;
121
+
122
+ constructor(
123
+ memoryModule: {
124
+ showRelatedForMessage: (message: string) => void;
125
+ showSaveFormWithText: (text: string) => void;
126
+ searchWithQuery: (query: string) => Promise<void>;
127
+ } | null = null
128
+ ) {
129
+ // External dependencies
130
+ this.memoryModule = memoryModule;
131
+
132
+ // Initialize
133
+ this.initChatInput();
134
+ this.initLongPressCopy();
135
+ this.initSpeechRecognition();
136
+ this.initSpeechSynthesis();
137
+ }
138
+
139
+ // =============================================
140
+ // Idle Auto-Checkpoint
141
+ // =============================================
142
+
143
+ resetIdleTimer(): void {
144
+ if (this.idleTimer) {
145
+ clearTimeout(this.idleTimer);
146
+ }
147
+
148
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
149
+ this.idleTimer = setTimeout(() => {
150
+ this.autoCheckpoint();
151
+ }, this.IDLE_TIMEOUT);
152
+ }
153
+ }
154
+
155
+ async autoCheckpoint(): Promise<void> {
156
+ // DISABLED: Auto-checkpoint was saving raw conversation history to MAMA memory.
157
+ // Checkpoints should only be saved manually via /checkpoint command with proper summaries.
158
+ // The viewer chat uses localStorage for session persistence instead.
159
+ logger.info('Auto-checkpoint disabled (use /checkpoint for manual saves)');
160
+ return;
161
+ }
162
+
163
+ // =============================================
164
+ // Session Management
165
+ // =============================================
166
+
167
+ /**
168
+ * Initialize chat session
169
+ */
170
+ async initSession(): Promise<void> {
171
+ // Check for resumable session first
172
+ await this.checkForResumableSession();
173
+
174
+ // Try to get last active server session first
175
+ const lastActiveSession = await API.getLastActiveSession().catch(() => null);
176
+ if (lastActiveSession && lastActiveSession.id && lastActiveSession.isAlive) {
177
+ logger.info('Resuming last active session:', lastActiveSession.id);
178
+ this.addSystemMessage('Resuming previous session...');
179
+ localStorage.setItem('mama_chat_session_id', lastActiveSession.id);
180
+ this.initWebSocket(lastActiveSession.id);
181
+ return;
182
+ }
183
+
184
+ const savedSessionId = localStorage.getItem('mama_chat_session_id');
185
+
186
+ if (savedSessionId) {
187
+ logger.info('Trying saved session:', savedSessionId);
188
+ this.addSystemMessage('Connecting to session...');
189
+ this.initWebSocket(savedSessionId);
190
+ } else {
191
+ try {
192
+ this.addSystemMessage('Creating new session...');
193
+ const data = await API.createSession('.');
194
+ const sessionId = data.sessionId;
195
+
196
+ logger.info('Created new session:', sessionId);
197
+ localStorage.setItem('mama_chat_session_id', sessionId);
198
+
199
+ this.initWebSocket(sessionId);
200
+ } catch (error) {
201
+ logger.error('Failed to create session:', error);
202
+ const message = getErrorMessage(error);
203
+ this.addSystemMessage(`Failed to create session: ${message}`, 'error');
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Connect to session (public method)
210
+ */
211
+ connectToSession(sessionId: string): void {
212
+ this.initWebSocket(sessionId);
213
+ }
214
+
215
+ /**
216
+ * Disconnect from session (public method)
217
+ */
218
+ disconnect(): void {
219
+ if (this.ws) {
220
+ this.sessionId = null; // Prevent auto-reconnect
221
+ this.ws.close();
222
+ this.ws = null;
223
+ }
224
+ this.updateStatus('disconnected');
225
+ this.enableInput(false);
226
+ }
227
+
228
+ // =============================================
229
+ // WebSocket Management
230
+ // =============================================
231
+
232
+ /**
233
+ * Initialize WebSocket connection
234
+ */
235
+ initWebSocket(sessionId: string): void {
236
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
237
+ logger.info('Already connected');
238
+ return;
239
+ }
240
+
241
+ this.sessionId = sessionId;
242
+ this.restoreHistory(sessionId);
243
+
244
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
245
+ const wsUrl = `${protocol}//${window.location.host}/ws?sessionId=${sessionId}`;
246
+
247
+ logger.info('Connecting to:', wsUrl);
248
+ this.ws = new WebSocket(wsUrl);
249
+
250
+ this.ws.onopen = () => {
251
+ logger.info('Connected');
252
+ this.reconnectAttempts = 0;
253
+ this.updateStatus('connected');
254
+ this.enableInput(true);
255
+
256
+ this.ws.send(
257
+ JSON.stringify({
258
+ type: 'attach',
259
+ sessionId: sessionId,
260
+ osAgentMode: true, // Enable OS Agent capabilities (Viewer-only)
261
+ language: navigator.language || 'en', // Browser language for greeting
262
+ })
263
+ );
264
+ };
265
+
266
+ this.ws.onmessage = (event) => {
267
+ try {
268
+ const data = JSON.parse(event.data);
269
+ this.handleMessage(data);
270
+ } catch (e) {
271
+ logger.error('Parse error:', e);
272
+ }
273
+ };
274
+
275
+ this.ws.onclose = (event) => {
276
+ logger.info('Disconnected:', event.code, event.reason);
277
+ this.updateStatus('disconnected');
278
+ this.enableInput(false);
279
+
280
+ if (this.sessionId) {
281
+ this.scheduleReconnect();
282
+ }
283
+ };
284
+
285
+ this.ws.onerror = (error) => {
286
+ logger.error('WebSocket error:', error);
287
+ this.updateStatus('disconnected');
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Handle incoming WebSocket message
293
+ */
294
+ handleMessage(data: ChatIncomingMessage): void {
295
+ switch (data.type) {
296
+ case 'attached':
297
+ logger.info('Attached to session:', data.sessionId);
298
+ this.addSystemMessage('Connected to session');
299
+ break;
300
+
301
+ case 'history':
302
+ // Display conversation history from server
303
+ if (data.messages && data.messages.length > 0) {
304
+ logger.info('Received history:', data.messages.length, 'messages');
305
+ this.displayHistory(data.messages);
306
+ }
307
+ break;
308
+
309
+ case 'output':
310
+ case 'stream':
311
+ if (data.content) {
312
+ this.hideTypingIndicator();
313
+ this.enableSend(true);
314
+ this.appendStreamChunk(data.content);
315
+ }
316
+ break;
317
+
318
+ case 'stream_end':
319
+ this.hideTypingIndicator();
320
+ this.finalizeStreamMessage();
321
+ break;
322
+
323
+ case 'error':
324
+ if (data.error === 'session_not_found') {
325
+ logger.info('Session not found, creating new one...');
326
+ localStorage.removeItem('mama_chat_session_id');
327
+ this.addSystemMessage('Session expired. Creating new session...');
328
+
329
+ if (this.ws) {
330
+ this.ws.close();
331
+ this.ws = null;
332
+ }
333
+
334
+ setTimeout(() => this.initSession(), 500);
335
+ } else {
336
+ this.addSystemMessage(`Error: ${data.message || data.error}`, 'error');
337
+ this.enableSend(true);
338
+ }
339
+ break;
340
+
341
+ case 'tool_use':
342
+ this.addToolCard(
343
+ data.tool || 'tool',
344
+ data.toolId || '',
345
+ data.input && typeof data.input === 'object' ? (data.input as ChatToolInput) : null
346
+ );
347
+ break;
348
+
349
+ case 'tool_complete':
350
+ if (typeof data.index === 'number') {
351
+ this.completeToolCard(data.index);
352
+ }
353
+ break;
354
+
355
+ case 'typing':
356
+ this.showTypingIndicator(data.elapsed);
357
+ break;
358
+
359
+ case 'pong':
360
+ break;
361
+
362
+ case 'connected':
363
+ logger.info('WebSocket connected:', data.clientId);
364
+ break;
365
+
366
+ default:
367
+ logger.warn('Unknown message type:', data.type);
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Schedule reconnection with exponential backoff
373
+ */
374
+ scheduleReconnect(): void {
375
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
376
+ this.reconnectAttempts++;
377
+
378
+ logger.info(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
379
+ this.addSystemMessage(
380
+ `Connection lost. Reconnecting in ${Math.round(delay / 1000)}s...`,
381
+ 'warning'
382
+ );
383
+
384
+ setTimeout(() => {
385
+ if (this.sessionId) {
386
+ this.initWebSocket(this.sessionId);
387
+ }
388
+ }, delay);
389
+ }
390
+
391
+ // =============================================
392
+ // Message Handling
393
+ // =============================================
394
+
395
+ /**
396
+ * Send chat message
397
+ */
398
+ send(): void {
399
+ const input = getElementByIdOrNull<HTMLTextAreaElement>('chat-input');
400
+ if (!input) {
401
+ return;
402
+ }
403
+ const message = input.value.trim();
404
+
405
+ if (!message) {
406
+ return;
407
+ }
408
+
409
+ // Handle slash commands
410
+ if (message.startsWith('/')) {
411
+ this.handleCommand(message);
412
+ input.value = '';
413
+ autoResizeTextarea(input);
414
+ return;
415
+ }
416
+
417
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
418
+ this.addSystemMessage('Not connected. Please connect to a session first.', 'error');
419
+ return;
420
+ }
421
+
422
+ this.addUserMessage(message);
423
+ this.enableSend(false);
424
+
425
+ this.ws.send(
426
+ JSON.stringify({
427
+ type: 'send',
428
+ sessionId: this.sessionId,
429
+ content: message,
430
+ })
431
+ );
432
+
433
+ // Search for related MAMA decisions
434
+ if (this.memoryModule) {
435
+ this.memoryModule.showRelatedForMessage(message);
436
+ }
437
+
438
+ input.value = '';
439
+ autoResizeTextarea(input);
440
+
441
+ logger.info('Sent:', message);
442
+ this.resetIdleTimer();
443
+ }
444
+
445
+ /**
446
+ * Send quiz choice (A, B, C, D)
447
+ */
448
+ sendQuizChoice(choice: string): void {
449
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
450
+ this.addSystemMessage('Not connected.', 'error');
451
+ return;
452
+ }
453
+
454
+ // Display choice as user message
455
+ this.addUserMessage(choice);
456
+ this.enableSend(false);
457
+
458
+ // Send to server
459
+ this.ws.send(
460
+ JSON.stringify({
461
+ type: 'send',
462
+ sessionId: this.sessionId,
463
+ content: choice,
464
+ })
465
+ );
466
+
467
+ logger.info('Quiz choice sent:', choice);
468
+ this.resetIdleTimer();
469
+ }
470
+
471
+ /**
472
+ * Handle slash commands
473
+ */
474
+ handleCommand(message: string): void {
475
+ const parts = message.slice(1).split(' ');
476
+ const command = parts[0].toLowerCase();
477
+ const args = parts.slice(1).join(' ');
478
+
479
+ logger.info('Command:', command, 'Args:', args);
480
+
481
+ switch (command) {
482
+ case 'save':
483
+ this.commandSave(args);
484
+ break;
485
+ case 'search':
486
+ this.commandSearch(args);
487
+ break;
488
+ case 'checkpoint':
489
+ this.commandCheckpoint();
490
+ break;
491
+ case 'resume':
492
+ this.commandResume();
493
+ break;
494
+ case 'help':
495
+ this.commandHelp();
496
+ break;
497
+ default:
498
+ // Forward unrecognized commands to agent as regular messages
499
+ this.sendRaw(message);
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Send a message directly to the agent (bypass command parsing)
505
+ * Rewrites /command to avoid Claude CLI slash command interception
506
+ */
507
+ sendRaw(message: string): void {
508
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
509
+ this.addSystemMessage('Not connected. Please connect to a session first.', 'error');
510
+ return;
511
+ }
512
+
513
+ this.addUserMessage(message);
514
+ this.enableSend(false);
515
+
516
+ // Rewrite /command → natural language to avoid Claude CLI interception
517
+ // Must be explicit enough to override built-in skills (BMAD, etc.)
518
+ let agentMessage = message;
519
+ if (message.startsWith('/')) {
520
+ const parts = message.slice(1).split(' ');
521
+ const cmd = parts[0];
522
+ const args = parts.slice(1).join(' ');
523
+ agentMessage = [
524
+ `[INSTALLED PLUGIN COMMAND — DO NOT USE SKILL TOOL]`,
525
+ `Look in your system prompt under "Installed Skills (PRIORITY)" for the "commands/${cmd}.md" section.`,
526
+ `Execute ONLY the instructions from that installed plugin command file.`,
527
+ `DO NOT invoke the Skill tool. DO NOT match to bmad or any other built-in skill.`,
528
+ `This command comes from a user-installed Cowork/OpenClaw plugin, not a system skill.`,
529
+ args ? `User arguments: <user_args>${args}</user_args>` : '',
530
+ ]
531
+ .filter(Boolean)
532
+ .join(' ');
533
+ }
534
+
535
+ this.ws.send(
536
+ JSON.stringify({
537
+ type: 'send',
538
+ sessionId: this.sessionId,
539
+ content: agentMessage,
540
+ })
541
+ );
542
+
543
+ if (this.memoryModule) {
544
+ this.memoryModule.showRelatedForMessage(message);
545
+ }
546
+
547
+ logger.info('Forwarded to agent:', agentMessage);
548
+ this.resetIdleTimer();
549
+ }
550
+
551
+ /**
552
+ * /save <text> - Open Memory form with text
553
+ */
554
+ commandSave(text: string): void {
555
+ if (!this.memoryModule) {
556
+ this.addSystemMessage('Memory module not available', 'error');
557
+ return;
558
+ }
559
+
560
+ if (!text) {
561
+ this.addSystemMessage('Usage: /save <decision text>', 'error');
562
+ return;
563
+ }
564
+
565
+ // Switch to Memory tab and open form with text
566
+ window.switchTab('memory');
567
+ this.memoryModule.showSaveFormWithText(text);
568
+ this.addSystemMessage(`💾 Opening save form with: "${text.substring(0, 50)}..."`);
569
+ }
570
+
571
+ /**
572
+ * /search <query> - Search in Memory tab
573
+ */
574
+ commandSearch(query: string): void {
575
+ if (!this.memoryModule) {
576
+ this.addSystemMessage('Memory module not available', 'error');
577
+ return;
578
+ }
579
+
580
+ if (!query) {
581
+ this.addSystemMessage('Usage: /search <query>', 'error');
582
+ return;
583
+ }
584
+
585
+ // Switch to Memory tab and execute search
586
+ window.switchTab('memory');
587
+ this.memoryModule.searchWithQuery(query);
588
+ this.addSystemMessage(`🔍 Searching for: "${query}"`);
589
+ }
590
+
591
+ /**
592
+ * /checkpoint - Save current session as checkpoint
593
+ */
594
+ async commandCheckpoint(): Promise<void> {
595
+ try {
596
+ const summary = this.generateCheckpointSummary();
597
+ await this.saveCheckpoint(summary);
598
+ this.addSystemMessage('✅ Checkpoint saved successfully');
599
+ } catch (error) {
600
+ logger.error('Checkpoint save failed:', error);
601
+ const message = getErrorMessage(error);
602
+ this.addSystemMessage(`Failed to save checkpoint: ${message}`, 'error');
603
+ }
604
+ }
605
+
606
+ /**
607
+ * /resume - Load last checkpoint
608
+ */
609
+ async commandResume(): Promise<void> {
610
+ try {
611
+ const checkpoint = await this.loadCheckpoint();
612
+ if (checkpoint) {
613
+ this.addSystemMessage(
614
+ `📖 Last checkpoint (${new Date(checkpoint.timestamp).toLocaleString()}):`
615
+ );
616
+ this.addSystemMessage(checkpoint.summary);
617
+ } else {
618
+ this.addSystemMessage('No checkpoint found', 'error');
619
+ }
620
+ } catch (error) {
621
+ logger.error('Checkpoint load failed:', error);
622
+ const message = getErrorMessage(error);
623
+ this.addSystemMessage(`Failed to load checkpoint: ${message}`, 'error');
624
+ }
625
+ }
626
+
627
+ /**
628
+ * /help - Show available commands
629
+ */
630
+ commandHelp(): void {
631
+ const helpText = `
632
+ **Available Commands:**
633
+
634
+ **/save <text>** - Save a decision to Memory
635
+ **/search <query>** - Search decisions in Memory
636
+ **/checkpoint** - Save current session
637
+ **/resume** - Load last checkpoint
638
+ **/help** - Show this help message
639
+
640
+ **Keyboard Shortcuts:**
641
+ - **Enter** - Send message
642
+ - **Shift+Enter** - New line
643
+ - **Long press message** - Copy to clipboard
644
+ `.trim();
645
+
646
+ this.addSystemMessage(helpText);
647
+ }
648
+
649
+ /**
650
+ * Add user message to chat
651
+ */
652
+ addUserMessage(text: string): void {
653
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
654
+ if (!container) {
655
+ return;
656
+ }
657
+ this.removePlaceholder();
658
+
659
+ const timestamp = new Date();
660
+ const msgEl = document.createElement('div');
661
+ msgEl.className = 'chat-message user';
662
+ msgEl.innerHTML = `
663
+ <div class="message-content">${escapeHtml(text)}</div>
664
+ <div class="message-time">${formatMessageTime(timestamp)}</div>
665
+ `;
666
+
667
+ container.appendChild(msgEl);
668
+ scrollToBottom(container);
669
+
670
+ this.saveToHistory('user', text, timestamp);
671
+ }
672
+
673
+ addUserMessageWithAttachment(text: string, attachment: ChatAttachment): void {
674
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
675
+ if (!container) {
676
+ return;
677
+ }
678
+ this.removePlaceholder();
679
+
680
+ const timestamp = new Date();
681
+ const msgEl = document.createElement('div');
682
+ msgEl.className = 'chat-message user';
683
+
684
+ let attachHtml = '';
685
+ if (attachment.isImage) {
686
+ const safeUrl = escapeAttr(attachment.mediaUrl);
687
+ const safeAlt = escapeAttr(attachment.originalName);
688
+ attachHtml = `<img src="${safeUrl}" class="max-w-[200px] rounded-lg mt-1 cursor-pointer" alt="${safeAlt}" data-lightbox="${safeUrl}" />`;
689
+ } else {
690
+ const safeName = encodeURIComponent(attachment.filename);
691
+ attachHtml = `<a href="/api/media/download/${safeName}" target="_blank" class="flex items-center gap-2 mt-1 px-3 py-2 bg-white/50 rounded-lg border border-gray-200 text-sm hover:bg-white/80 transition-colors"><span class="text-lg">\u{1F4CE}</span><span class="truncate max-w-[180px]">${escapeHtml(attachment.originalName)}</span></a>`;
692
+ }
693
+
694
+ msgEl.innerHTML = `
695
+ <div class="message-content">${escapeHtml(text)}${attachHtml}</div>
696
+ <div class="message-time">${formatMessageTime(timestamp)}</div>
697
+ `;
698
+
699
+ container.appendChild(msgEl);
700
+ scrollToBottom(container);
701
+
702
+ this.saveToHistory('user', text, timestamp, attachment);
703
+ }
704
+
705
+ /**
706
+ * Add assistant message to chat
707
+ */
708
+ addAssistantMessage(text: string): void {
709
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
710
+ if (!container) {
711
+ return;
712
+ }
713
+ this.removePlaceholder();
714
+
715
+ this.enableSend(true);
716
+
717
+ const timestamp = new Date();
718
+ const msgEl = document.createElement('div');
719
+ msgEl.className = 'chat-message assistant';
720
+ msgEl.innerHTML = `
721
+ <div class="message-content">${formatAssistantMessage(text)}</div>
722
+ <div class="message-time">${formatMessageTime(timestamp)}</div>
723
+ `;
724
+
725
+ container.appendChild(msgEl);
726
+ scrollToBottom(container);
727
+
728
+ this.saveToHistory('assistant', text, timestamp);
729
+
730
+ // Show unread badge if floating panel is closed
731
+ this.showUnreadBadge();
732
+
733
+ // Auto-play TTS if enabled
734
+ if (this.ttsEnabled && text) {
735
+ logger.info('Auto-play enabled, speaking assistant message');
736
+ this.speak(text);
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Add system message to chat
742
+ */
743
+ addSystemMessage(text: string, type = 'info'): void {
744
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
745
+ if (!container) {
746
+ return;
747
+ }
748
+ this.removePlaceholder();
749
+
750
+ const msgEl = document.createElement('div');
751
+ msgEl.className = `chat-message system ${type}`;
752
+ msgEl.innerHTML = `
753
+ <div class="message-content">${escapeHtml(text)}</div>
754
+ `;
755
+
756
+ container.appendChild(msgEl);
757
+ scrollToBottom(container);
758
+ }
759
+
760
+ /**
761
+ * Add tool usage card
762
+ */
763
+ addToolCard(toolName: string, toolId: string, input: ChatToolInput | null): void {
764
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
765
+ if (!container) {
766
+ return;
767
+ }
768
+ this.removePlaceholder();
769
+
770
+ // Tool icon mapping
771
+ const iconMap = {
772
+ Read: '📄',
773
+ Write: '✏️',
774
+ Bash: '💻',
775
+ Edit: '🔧',
776
+ Grep: '🔍',
777
+ Glob: '📂',
778
+ Task: '🤖',
779
+ WebFetch: '🌐',
780
+ WebSearch: '🔎',
781
+ };
782
+ const icon = iconMap[toolName] || '🔧';
783
+
784
+ // Extract file path for Read tool
785
+ let detail = '';
786
+ if (toolName === 'Read' && input?.file_path) {
787
+ const fileName = input.file_path.split('/').pop();
788
+ detail = `<div class="tool-detail">${escapeHtml(fileName)}</div>`;
789
+ } else if (toolName === 'Bash' && input?.command) {
790
+ const command = String(input.command);
791
+ detail = `<div class="tool-detail">${escapeHtml(command.substring(0, 50))}${
792
+ command.length > 50 ? '...' : ''
793
+ }</div>`;
794
+ }
795
+
796
+ const cardEl = document.createElement('div');
797
+ cardEl.className = 'tool-card loading';
798
+ cardEl.dataset.collapsed = 'true';
799
+ cardEl.dataset.toolId = toolId;
800
+ cardEl.innerHTML = `
801
+ <div class="tool-header" data-tool-toggle="true">
802
+ <span class="tool-icon">${icon}</span>
803
+ <span class="tool-name">${escapeHtml(toolName)}</span>
804
+ <span class="tool-spinner">⏳</span>
805
+ </div>
806
+ ${detail}
807
+ `;
808
+
809
+ // Bind click handler safely (avoid inline onclick with string interpolation)
810
+ const header = cardEl.querySelector('.tool-header');
811
+ if (header) {
812
+ header.addEventListener('click', () => this.toggleToolCard(toolId));
813
+ }
814
+
815
+ container.appendChild(cardEl);
816
+ scrollToBottom(container);
817
+ }
818
+
819
+ /**
820
+ * Complete tool card (mark as finished)
821
+ */
822
+ completeToolCard(_index: number): void {
823
+ // Find the most recent loading tool card
824
+ const loadingCards = document.querySelectorAll('.tool-card.loading');
825
+ if (loadingCards.length > 0) {
826
+ const lastCard = loadingCards[loadingCards.length - 1];
827
+ lastCard.classList.remove('loading');
828
+ lastCard.classList.add('completed');
829
+
830
+ // Replace spinner with checkmark
831
+ const spinner = lastCard.querySelector('.tool-spinner');
832
+ if (spinner) {
833
+ spinner.textContent = '✓';
834
+ spinner.classList.add('checkmark');
835
+ }
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Toggle tool card collapsed/expanded state
841
+ */
842
+ toggleToolCard(toolId: string): void {
843
+ const card = document.querySelector(`.tool-card[data-tool-id="${CSS.escape(toolId)}"]`);
844
+ if (!card) {
845
+ return;
846
+ }
847
+ const toolCard = card as HTMLElement;
848
+ const isCollapsed = toolCard.dataset.collapsed === 'true';
849
+ toolCard.dataset.collapsed = isCollapsed ? 'false' : 'true';
850
+ }
851
+
852
+ /**
853
+ * Remove placeholder
854
+ */
855
+ removePlaceholder(): void {
856
+ const placeholder = document.querySelector('.chat-placeholder');
857
+ if (placeholder) {
858
+ placeholder.remove();
859
+ }
860
+ }
861
+
862
+ // =============================================
863
+ // Streaming Message Handling
864
+ // =============================================
865
+
866
+ /**
867
+ * Append streaming chunk with RAF batching
868
+ */
869
+ appendStreamChunk(content: string): void {
870
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
871
+ if (!container) {
872
+ return;
873
+ }
874
+
875
+ if (!this.currentStreamEl) {
876
+ this.removePlaceholder();
877
+ this.currentStreamEl = document.createElement('div');
878
+ this.currentStreamEl.className = 'chat-message assistant streaming';
879
+ this.currentStreamEl.innerHTML = `
880
+ <div class="message-content"></div>
881
+ <div class="message-time">${formatMessageTime(new Date())}</div>
882
+ `;
883
+ container.appendChild(this.currentStreamEl);
884
+ this.currentStreamText = '';
885
+ this.streamBuffer = '';
886
+ }
887
+
888
+ this.streamBuffer += content;
889
+
890
+ if (!this.rafPending) {
891
+ this.rafPending = true;
892
+ requestAnimationFrame(() => {
893
+ if (this.streamBuffer) {
894
+ this.currentStreamText += this.streamBuffer;
895
+ this.streamBuffer = '';
896
+
897
+ const contentEl = this.currentStreamEl.querySelector('.message-content');
898
+ if (contentEl) {
899
+ contentEl.innerHTML = formatAssistantMessage(this.currentStreamText);
900
+ }
901
+
902
+ container.scrollTo({
903
+ top: container.scrollHeight,
904
+ behavior: 'auto',
905
+ });
906
+ }
907
+ this.rafPending = false;
908
+ });
909
+ }
910
+ }
911
+
912
+ /**
913
+ * Finalize streaming message
914
+ */
915
+ finalizeStreamMessage(): void {
916
+ if (this.streamBuffer && this.currentStreamEl) {
917
+ this.currentStreamText += this.streamBuffer;
918
+ const contentEl = this.currentStreamEl.querySelector('.message-content');
919
+ contentEl.innerHTML = formatAssistantMessage(this.currentStreamText);
920
+ }
921
+
922
+ if (this.currentStreamText) {
923
+ this.saveToHistory('assistant', this.currentStreamText);
924
+
925
+ // Auto-play TTS for streamed responses
926
+ if (this.ttsEnabled) {
927
+ this.speak(this.currentStreamText);
928
+ }
929
+ }
930
+
931
+ // Show unread badge if floating panel is closed
932
+ this.showUnreadBadge();
933
+
934
+ if (this.currentStreamEl) {
935
+ this.currentStreamEl.classList.remove('streaming');
936
+ this.currentStreamEl = null;
937
+ this.currentStreamText = '';
938
+ this.streamBuffer = '';
939
+ }
940
+ this.rafPending = false;
941
+ this.enableSend(true);
942
+ }
943
+
944
+ /**
945
+ * Show typing indicator while agent is processing
946
+ */
947
+ showTypingIndicator(elapsed: number): void {
948
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
949
+ if (!container) {
950
+ return;
951
+ }
952
+ let indicator = container.querySelector('.chat-typing-indicator');
953
+ if (!indicator) {
954
+ indicator = document.createElement('div');
955
+ indicator.className = 'chat-typing-indicator';
956
+ indicator.innerHTML = `
957
+ <div class="typing-dots">
958
+ <span></span><span></span><span></span>
959
+ </div>
960
+ <span class="typing-label">thinking...</span>`;
961
+ container.appendChild(indicator);
962
+ scrollToBottom(container);
963
+ }
964
+ if (elapsed) {
965
+ const label = indicator.querySelector('.typing-label');
966
+ if (label) {
967
+ label.textContent = `thinking... (${elapsed}s)`;
968
+ }
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Hide typing indicator
974
+ */
975
+ hideTypingIndicator(): void {
976
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
977
+ if (!container) {
978
+ return;
979
+ }
980
+ const indicator = container.querySelector('.chat-typing-indicator');
981
+ if (indicator) {
982
+ indicator.remove();
983
+ }
984
+ }
985
+
986
+ // =============================================
987
+ // UI Control
988
+ // =============================================
989
+
990
+ /**
991
+ * Update chat status
992
+ */
993
+ updateStatus(status: string): void {
994
+ const statusEl = getElementByIdOrNull<HTMLDivElement>('chat-status');
995
+ if (!statusEl) {
996
+ logger.warn('Status element not found');
997
+ return;
998
+ }
999
+
1000
+ const indicator = statusEl.querySelector('.status-indicator');
1001
+ const text = statusEl.querySelector('span:not(.status-indicator)');
1002
+
1003
+ if (!indicator || !text) {
1004
+ logger.warn('Status indicator or text not found');
1005
+ return;
1006
+ }
1007
+
1008
+ indicator.className = 'status-indicator ' + status;
1009
+
1010
+ switch (status) {
1011
+ case 'connected':
1012
+ text.textContent = 'Connected';
1013
+ break;
1014
+ case 'disconnected':
1015
+ text.textContent = 'Disconnected';
1016
+ break;
1017
+ case 'connecting':
1018
+ text.textContent = 'Connecting...';
1019
+ break;
1020
+ default:
1021
+ text.textContent = status;
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Enable/disable chat input
1027
+ */
1028
+ enableInput(enabled: boolean): void {
1029
+ const input = getElementByIdOrNull<HTMLTextAreaElement>('chat-input');
1030
+ const sendBtn = getElementByIdOrNull<HTMLButtonElement>('chat-send');
1031
+ if (!input || !sendBtn) {
1032
+ return;
1033
+ }
1034
+
1035
+ input.disabled = !enabled;
1036
+ sendBtn.disabled = !enabled;
1037
+
1038
+ if (enabled) {
1039
+ input.placeholder = 'Type your message...';
1040
+ } else {
1041
+ input.placeholder = 'Connect to a session to chat';
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * Enable/disable send button
1047
+ */
1048
+ enableSend(enabled: boolean): void {
1049
+ const sendBtn = getElementByIdOrNull<HTMLButtonElement>('chat-send');
1050
+ if (!sendBtn) {
1051
+ return;
1052
+ }
1053
+ sendBtn.disabled = !enabled;
1054
+
1055
+ if (enabled) {
1056
+ sendBtn.textContent = 'Send';
1057
+ sendBtn.classList.remove('loading');
1058
+ } else {
1059
+ sendBtn.textContent = 'Sending...';
1060
+ sendBtn.classList.add('loading');
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Enable/disable mic button
1066
+ */
1067
+ enableMic(enabled: boolean): void {
1068
+ const micBtn = getElementByIdOrNull<HTMLButtonElement>('chat-mic');
1069
+ if (micBtn) {
1070
+ micBtn.disabled = !enabled;
1071
+ }
1072
+ }
1073
+
1074
+ // =============================================
1075
+ // Input Handlers
1076
+ // =============================================
1077
+
1078
+ /**
1079
+ * Handle chat input keydown
1080
+ */
1081
+ handleInputKeydown(event: KeyboardEvent): void {
1082
+ if (event.key === 'Enter' && !event.shiftKey) {
1083
+ event.preventDefault();
1084
+ this.send();
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * Initialize chat input handlers
1090
+ */
1091
+ initChatInput(): void {
1092
+ const input = getElementByIdOrNull<HTMLTextAreaElement>('chat-input');
1093
+ if (!input) {
1094
+ return;
1095
+ }
1096
+
1097
+ const messagesContainer = getElementByIdOrNull<HTMLDivElement>('chat-messages');
1098
+
1099
+ input.addEventListener('input', () => {
1100
+ autoResizeTextarea(input);
1101
+ });
1102
+
1103
+ if (messagesContainer) {
1104
+ messagesContainer.addEventListener('click', (event: MouseEvent) => {
1105
+ const target = (event.target as HTMLElement | null)?.closest<HTMLButtonElement>(
1106
+ '.quiz-choice-btn'
1107
+ );
1108
+ if (!target) {
1109
+ return;
1110
+ }
1111
+ const choice = target.dataset.choice;
1112
+ if (!choice) {
1113
+ return;
1114
+ }
1115
+ event.preventDefault();
1116
+ this.sendQuizChoice(choice);
1117
+ });
1118
+ }
1119
+
1120
+ input.addEventListener('keydown', (event) => {
1121
+ this.handleInputKeydown(event);
1122
+ });
1123
+ }
1124
+
1125
+ /**
1126
+ * Initialize long press to copy message functionality
1127
+ * Supports both touch (mobile) and mouse (desktop) events
1128
+ */
1129
+ initLongPressCopy(): void {
1130
+ const messagesContainer = getElementByIdOrNull<HTMLDivElement>('chat-messages');
1131
+ if (!messagesContainer) {
1132
+ return;
1133
+ }
1134
+ let pressTimer: ReturnType<typeof setTimeout> | null = null;
1135
+ const PRESS_DURATION = 750; // milliseconds
1136
+
1137
+ // Touch events (mobile)
1138
+ messagesContainer.addEventListener('touchstart', (e: TouchEvent) => {
1139
+ const target = e.target as HTMLElement | null;
1140
+ const message = target?.closest('.chat-message') as HTMLElement | null;
1141
+ if (!message || message.classList.contains('system')) {
1142
+ return;
1143
+ }
1144
+
1145
+ pressTimer = setTimeout(() => {
1146
+ copyMessageText(message);
1147
+ }, PRESS_DURATION);
1148
+ });
1149
+
1150
+ messagesContainer.addEventListener('touchend', () => {
1151
+ if (pressTimer) {
1152
+ clearTimeout(pressTimer);
1153
+ pressTimer = null;
1154
+ }
1155
+ });
1156
+
1157
+ messagesContainer.addEventListener('touchmove', () => {
1158
+ if (pressTimer) {
1159
+ clearTimeout(pressTimer);
1160
+ pressTimer = null;
1161
+ }
1162
+ });
1163
+
1164
+ // Mouse events (desktop)
1165
+ messagesContainer.addEventListener('mousedown', (e: MouseEvent) => {
1166
+ const target = e.target as HTMLElement | null;
1167
+ const message = target?.closest('.chat-message') as HTMLElement | null;
1168
+ if (!message || message.classList.contains('system')) {
1169
+ return;
1170
+ }
1171
+
1172
+ pressTimer = setTimeout(() => {
1173
+ copyMessageText(message);
1174
+ }, PRESS_DURATION);
1175
+ });
1176
+
1177
+ messagesContainer.addEventListener('mouseup', () => {
1178
+ if (pressTimer) {
1179
+ clearTimeout(pressTimer);
1180
+ pressTimer = null;
1181
+ }
1182
+ });
1183
+
1184
+ messagesContainer.addEventListener('mouseleave', () => {
1185
+ if (pressTimer) {
1186
+ clearTimeout(pressTimer);
1187
+ pressTimer = null;
1188
+ }
1189
+ });
1190
+
1191
+ /**
1192
+ * Copy message text to clipboard
1193
+ */
1194
+ async function copyMessageText(messageEl: HTMLElement) {
1195
+ const textContent = messageEl.querySelector('.message-content') as HTMLElement | null;
1196
+ if (!textContent) {
1197
+ return;
1198
+ }
1199
+
1200
+ const text = textContent.textContent || '';
1201
+
1202
+ try {
1203
+ await navigator.clipboard.writeText(text);
1204
+ showToast('📋 Copied to clipboard');
1205
+
1206
+ // Visual feedback
1207
+ messageEl.style.opacity = '0.5';
1208
+ setTimeout(() => {
1209
+ messageEl.style.opacity = '1';
1210
+ }, 300);
1211
+ } catch (err) {
1212
+ logger.error('Copy failed:', err);
1213
+ showToast('Failed to copy');
1214
+ }
1215
+ }
1216
+ }
1217
+
1218
+ // =============================================
1219
+ // Voice Input (Web Speech API)
1220
+ // =============================================
1221
+
1222
+ /**
1223
+ * Initialize speech recognition
1224
+ */
1225
+ initSpeechRecognition(): void {
1226
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
1227
+
1228
+ if (!SpeechRecognition) {
1229
+ logger.warn('SpeechRecognition not supported');
1230
+ const micBtn = getElementByIdOrNull<HTMLButtonElement>('chat-mic');
1231
+ if (micBtn) {
1232
+ micBtn.style.display = 'none';
1233
+ }
1234
+ return;
1235
+ }
1236
+
1237
+ this.speechRecognition = new SpeechRecognition();
1238
+ this.speechRecognition.lang = navigator.language || 'ko-KR';
1239
+ this.speechRecognition.continuous = true; // Enable continuous recognition for longer phrases
1240
+ this.speechRecognition.interimResults = true;
1241
+ this.speechRecognition.maxAlternatives = 3; // Get multiple recognition candidates for better accuracy
1242
+
1243
+ this.speechRecognition.onresult = (event: SpeechRecognitionEvent) => {
1244
+ const input = getElementByIdOrNull<HTMLTextAreaElement>('chat-input');
1245
+ if (!input) {
1246
+ return;
1247
+ }
1248
+ let interimTranscript = '';
1249
+ let finalTranscript = '';
1250
+
1251
+ // Build transcript from NEW results only (use resultIndex)
1252
+ logger.debug(
1253
+ 'onresult fired, resultIndex:',
1254
+ event.resultIndex,
1255
+ 'total results:',
1256
+ event.results.length
1257
+ );
1258
+
1259
+ for (let i = event.resultIndex; i < event.results.length; i++) {
1260
+ const result = event.results[i];
1261
+ const transcript = result[0].transcript;
1262
+
1263
+ if (result.isFinal) {
1264
+ finalTranscript += transcript;
1265
+ logger.debug(
1266
+ 'Final result [' + i + ']:',
1267
+ transcript,
1268
+ 'Confidence:',
1269
+ result[0].confidence
1270
+ );
1271
+ } else {
1272
+ interimTranscript += transcript;
1273
+ logger.debug('Interim result [' + i + ']:', transcript);
1274
+ }
1275
+ }
1276
+
1277
+ // Handle final transcripts - accumulate them
1278
+ if (finalTranscript) {
1279
+ // Add space before appending if there's already text
1280
+ if (this.accumulatedTranscript) {
1281
+ this.accumulatedTranscript += ' ' + finalTranscript;
1282
+ } else {
1283
+ this.accumulatedTranscript = finalTranscript;
1284
+ }
1285
+ input.value = this.accumulatedTranscript;
1286
+ input.classList.remove('voice-active');
1287
+ logger.debug('Accumulated transcript:', this.accumulatedTranscript);
1288
+ }
1289
+
1290
+ // Handle interim transcripts - show temporarily with accumulated text
1291
+ if (interimTranscript) {
1292
+ const displayText = this.accumulatedTranscript
1293
+ ? this.accumulatedTranscript + ' ' + interimTranscript
1294
+ : interimTranscript;
1295
+ input.value = displayText;
1296
+ input.classList.add('voice-active');
1297
+ logger.debug('Showing interim (temp):', displayText);
1298
+ }
1299
+
1300
+ autoResizeTextarea(input);
1301
+
1302
+ // Reset silence timer on each result
1303
+ clearTimeout(this.silenceTimeout);
1304
+ this.silenceTimeout = setTimeout(() => {
1305
+ if (this.isRecording) {
1306
+ logger.info('Silence detected, stopping...');
1307
+ this.stopVoice();
1308
+ }
1309
+ }, this.silenceDelay);
1310
+ };
1311
+
1312
+ this.speechRecognition.onend = () => {
1313
+ logger.info('Recognition ended');
1314
+ this.stopVoice();
1315
+ };
1316
+
1317
+ this.speechRecognition.onerror = (event: SpeechRecognitionErrorEvent) => {
1318
+ logger.error('Error:', event.error);
1319
+ this.stopVoice();
1320
+
1321
+ let errorMessage = '';
1322
+ switch (event.error) {
1323
+ case 'not-allowed':
1324
+ errorMessage = '마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크를 허용해주세요.';
1325
+ break;
1326
+ case 'no-speech':
1327
+ errorMessage = '음성이 감지되지 않았습니다. 다시 시도해주세요.';
1328
+ break;
1329
+ case 'network':
1330
+ errorMessage = '네트워크 오류가 발생했습니다.';
1331
+ break;
1332
+ default:
1333
+ errorMessage = `음성 인식 오류: ${event.error}`;
1334
+ }
1335
+
1336
+ this.addSystemMessage(errorMessage, 'error');
1337
+ };
1338
+
1339
+ logger.info('SpeechRecognition initialized (lang:', this.speechRecognition.lang + ')');
1340
+ }
1341
+
1342
+ /**
1343
+ * Toggle voice input
1344
+ */
1345
+ toggleVoice(): void {
1346
+ if (this.isRecording) {
1347
+ this.stopVoice();
1348
+ } else {
1349
+ this.startVoice();
1350
+ }
1351
+ }
1352
+
1353
+ /**
1354
+ * Start voice recording
1355
+ */
1356
+ startVoice(): void {
1357
+ if (!this.speechRecognition) {
1358
+ this.addSystemMessage('이 브라우저에서는 음성 인식이 지원되지 않습니다.', 'error');
1359
+ return;
1360
+ }
1361
+
1362
+ try {
1363
+ const micBtn = getElementByIdOrNull<HTMLButtonElement>('chat-mic');
1364
+ const input = getElementByIdOrNull<HTMLTextAreaElement>('chat-input');
1365
+ if (!micBtn || !input) {
1366
+ return;
1367
+ }
1368
+
1369
+ // Clear input and accumulated transcript for new recording
1370
+ input.value = '';
1371
+ this.accumulatedTranscript = '';
1372
+
1373
+ this.speechRecognition.start();
1374
+ this.isRecording = true;
1375
+
1376
+ micBtn.classList.add('recording');
1377
+ input.classList.add('voice-active');
1378
+ input.placeholder = '말씀해주세요... (계속 말하면 이어서 인식됩니다)';
1379
+
1380
+ logger.info('Recording started (continuous mode)');
1381
+ logger.debug('Settings:', {
1382
+ lang: this.speechRecognition.lang,
1383
+ continuous: this.speechRecognition.continuous,
1384
+ interimResults: this.speechRecognition.interimResults,
1385
+ maxAlternatives: this.speechRecognition.maxAlternatives,
1386
+ });
1387
+
1388
+ this.silenceTimeout = setTimeout(() => {
1389
+ if (this.isRecording) {
1390
+ this.stopVoice();
1391
+ }
1392
+ }, this.silenceDelay);
1393
+ } catch (err) {
1394
+ logger.error('Failed to start:', err);
1395
+ this.addSystemMessage('음성 인식을 시작할 수 없습니다.', 'error');
1396
+ }
1397
+ }
1398
+
1399
+ /**
1400
+ * Stop voice recording
1401
+ */
1402
+ stopVoice(): void {
1403
+ if (!this.isRecording) {
1404
+ return;
1405
+ }
1406
+
1407
+ clearTimeout(this.silenceTimeout);
1408
+
1409
+ try {
1410
+ this.speechRecognition.stop();
1411
+ } catch {
1412
+ // Ignore errors
1413
+ }
1414
+
1415
+ this.isRecording = false;
1416
+
1417
+ const micBtn = getElementByIdOrNull<HTMLButtonElement>('chat-mic');
1418
+ const input = getElementByIdOrNull<HTMLTextAreaElement>('chat-input');
1419
+ if (!micBtn || !input || !this.speechRecognition) {
1420
+ return;
1421
+ }
1422
+
1423
+ micBtn.classList.remove('recording');
1424
+ input.classList.remove('voice-active');
1425
+ input.placeholder = 'Type your message...';
1426
+
1427
+ logger.info('Recording stopped');
1428
+ this.resetIdleTimer();
1429
+ }
1430
+
1431
+ // =============================================
1432
+ // Text-to-Speech (TTS)
1433
+ // =============================================
1434
+
1435
+ /**
1436
+ * Initialize Speech Synthesis
1437
+ */
1438
+ initSpeechSynthesis(): void {
1439
+ if (!this.speechSynthesis) {
1440
+ logger.warn('SpeechSynthesis not supported');
1441
+ return;
1442
+ }
1443
+
1444
+ // Wait for voices to load
1445
+ const loadVoices = () => {
1446
+ const voices = this.speechSynthesis.getVoices();
1447
+ // Find Korean voice
1448
+ this.ttsVoice =
1449
+ voices.find((v) => v.lang === 'ko-KR') ||
1450
+ voices.find((v) => v.lang.startsWith('ko')) ||
1451
+ voices[0];
1452
+
1453
+ if (this.ttsVoice) {
1454
+ logger.info('Korean voice selected:', this.ttsVoice.name, this.ttsVoice.lang);
1455
+ } else {
1456
+ logger.warn('No Korean voice found, using default');
1457
+ }
1458
+ };
1459
+
1460
+ // Voices might not be loaded immediately
1461
+ if (this.speechSynthesis.getVoices().length > 0) {
1462
+ loadVoices();
1463
+ } else {
1464
+ this.speechSynthesis.onvoiceschanged = loadVoices;
1465
+ }
1466
+
1467
+ logger.info('SpeechSynthesis initialized');
1468
+ }
1469
+
1470
+ /**
1471
+ * Toggle TTS auto-play
1472
+ */
1473
+ toggleTTS(): void {
1474
+ this.ttsEnabled = !this.ttsEnabled;
1475
+ if (!this.ttsEnabled) {
1476
+ this.stopSpeaking();
1477
+ }
1478
+ const btn = getElementByIdOrNull<HTMLButtonElement>('chat-tts-toggle');
1479
+
1480
+ if (btn) {
1481
+ btn.classList.toggle('active', this.ttsEnabled);
1482
+ btn.title = this.ttsEnabled
1483
+ ? 'TTS 활성화됨 (클릭하여 끄기)'
1484
+ : 'TTS 비활성화됨 (클릭하여 켜기)';
1485
+ }
1486
+
1487
+ logger.info('Auto-play:', this.ttsEnabled ? 'ON' : 'OFF');
1488
+ showToast(this.ttsEnabled ? '🔊 TTS 활성화' : '🔇 TTS 비활성화');
1489
+ }
1490
+
1491
+ /**
1492
+ * Toggle hands-free mode
1493
+ */
1494
+ toggleHandsFree(): void {
1495
+ this.handsFreeMode = !this.handsFreeMode;
1496
+ const btn = getElementByIdOrNull<HTMLButtonElement>('chat-handsfree-toggle');
1497
+
1498
+ if (btn) {
1499
+ btn.classList.toggle('active', this.handsFreeMode);
1500
+ btn.title = this.handsFreeMode ? '핸즈프리 활성화됨' : '핸즈프리 비활성화됨';
1501
+ }
1502
+
1503
+ logger.info('Hands-free mode:', this.handsFreeMode ? 'ON' : 'OFF');
1504
+ showToast(this.handsFreeMode ? '🎙️ 핸즈프리 모드 활성화' : '🎙️ 핸즈프리 모드 비활성화');
1505
+
1506
+ // Enable TTS automatically when hands-free is enabled
1507
+ if (this.handsFreeMode && !this.ttsEnabled) {
1508
+ this.toggleTTS();
1509
+ }
1510
+ }
1511
+
1512
+ /**
1513
+ * Speak text using TTS
1514
+ */
1515
+ stripMarkdownForTTS(text: string): string {
1516
+ return text
1517
+ .replace(/```[\s\S]*?```/g, '') // code blocks
1518
+ .replace(/`([^`]+)`/g, '$1') // inline code
1519
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // bold
1520
+ .replace(/\*([^*]+)\*/g, '$1') // italic
1521
+ .replace(/~~([^~]+)~~/g, '$1') // strikethrough
1522
+ .replace(/#{1,6}\s(.+)/g, '$1') // headers
1523
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
1524
+ .replace(/^[-*]\s/gm, '') // list markers
1525
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // images
1526
+ .replace(/~\/.mama\/workspace\/media\/[^\s]+/g, '') // media paths
1527
+ .replace(
1528
+ /[\u{1F600}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1FA00}-\u{1FAFF}]/gu,
1529
+ ''
1530
+ ) // emoji
1531
+ .replace(/\n{2,}/g, '. ')
1532
+ .trim();
1533
+ }
1534
+
1535
+ speak(text: string): void {
1536
+ if (!this.speechSynthesis || !text) {
1537
+ return;
1538
+ }
1539
+
1540
+ text = this.stripMarkdownForTTS(text);
1541
+ if (!text) {
1542
+ return;
1543
+ }
1544
+
1545
+ // Stop any ongoing speech
1546
+ this.stopSpeaking();
1547
+
1548
+ const utterance = new SpeechSynthesisUtterance(text);
1549
+ utterance.voice = this.ttsVoice;
1550
+ utterance.rate = this.ttsRate;
1551
+ utterance.pitch = this.ttsPitch;
1552
+ utterance.lang = this.ttsVoice?.lang || navigator.language || 'ko-KR';
1553
+
1554
+ utterance.onstart = () => {
1555
+ this.isSpeaking = true;
1556
+ logger.debug('Speaking started');
1557
+ };
1558
+
1559
+ utterance.onend = () => {
1560
+ this.isSpeaking = false;
1561
+ logger.debug('Speaking ended');
1562
+
1563
+ // If hands-free mode, start listening after TTS finishes
1564
+ if (this.handsFreeMode && !this.isRecording) {
1565
+ logger.info('Hands-free mode: auto-starting voice input');
1566
+ setTimeout(() => {
1567
+ this.startVoice();
1568
+ }, 500); // Small delay for smooth transition
1569
+ }
1570
+ };
1571
+
1572
+ utterance.onerror = (event) => {
1573
+ this.isSpeaking = false;
1574
+ logger.error('Error:', event.error);
1575
+ };
1576
+
1577
+ this.speechSynthesis.speak(utterance);
1578
+ logger.debug('Speaking:', text.substring(0, 50) + '...');
1579
+ }
1580
+
1581
+ /**
1582
+ * Stop speaking
1583
+ */
1584
+ stopSpeaking(): void {
1585
+ if (this.speechSynthesis && this.isSpeaking) {
1586
+ this.speechSynthesis.cancel();
1587
+ this.isSpeaking = false;
1588
+ logger.debug('Speaking stopped');
1589
+ }
1590
+ }
1591
+
1592
+ /**
1593
+ * Set TTS rate (0.5 - 2.0)
1594
+ */
1595
+ setTTSRate(rate: number): void {
1596
+ this.ttsRate = Math.max(0.5, Math.min(2.0, rate));
1597
+ logger.info('Rate set to:', this.ttsRate);
1598
+ }
1599
+
1600
+ // =============================================
1601
+ // History Management
1602
+ // =============================================
1603
+
1604
+ /**
1605
+ * Save message to history
1606
+ */
1607
+ saveToHistory(
1608
+ role: ChatHistoryMessage['role'],
1609
+ content: string,
1610
+ timestamp: Date = new Date(),
1611
+ attachment: ChatHistoryMessage['attachment'] | null = null
1612
+ ): void {
1613
+ if (!this.sessionId) {
1614
+ return;
1615
+ }
1616
+
1617
+ const entry: ChatHistoryMessage = {
1618
+ role,
1619
+ content,
1620
+ timestamp: timestamp.toISOString(),
1621
+ ...(attachment ? { attachment } : {}),
1622
+ };
1623
+
1624
+ this.history.push(entry);
1625
+
1626
+ if (this.history.length > this.maxHistoryMessages) {
1627
+ this.history = this.history.slice(-this.maxHistoryMessages);
1628
+ }
1629
+
1630
+ try {
1631
+ const storageKey = this.historyPrefix + this.sessionId;
1632
+ const storageData = {
1633
+ history: this.history,
1634
+ savedAt: Date.now(),
1635
+ };
1636
+ localStorage.setItem(storageKey, JSON.stringify(storageData));
1637
+ } catch (e) {
1638
+ logger.warn('Failed to save history:', e);
1639
+ }
1640
+ }
1641
+
1642
+ /**
1643
+ * Load history from localStorage
1644
+ */
1645
+ loadHistory(sessionId: string): ChatHistoryMessage[] | null {
1646
+ try {
1647
+ const storageKey = this.historyPrefix + sessionId;
1648
+ const stored = localStorage.getItem(storageKey);
1649
+
1650
+ if (!stored) {
1651
+ return null;
1652
+ }
1653
+
1654
+ const data = JSON.parse(stored);
1655
+
1656
+ if (Date.now() - data.savedAt > this.historyExpiryMs) {
1657
+ localStorage.removeItem(storageKey);
1658
+ return null;
1659
+ }
1660
+
1661
+ return data.history || [];
1662
+ } catch (e) {
1663
+ logger.warn('Failed to load history:', e);
1664
+ return null;
1665
+ }
1666
+ }
1667
+
1668
+ /**
1669
+ * Restore chat history
1670
+ */
1671
+ restoreHistory(sessionId: string): boolean {
1672
+ const history = this.loadHistory(sessionId);
1673
+
1674
+ if (!history || history.length === 0) {
1675
+ return false;
1676
+ }
1677
+
1678
+ this.history = history;
1679
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
1680
+ if (!container) {
1681
+ return false;
1682
+ }
1683
+
1684
+ this.removePlaceholder();
1685
+
1686
+ history.forEach((msg) => {
1687
+ const msgEl = document.createElement('div');
1688
+ msgEl.className = `chat-message ${msg.role}`;
1689
+
1690
+ if (msg.role === 'user') {
1691
+ let attachHtml = '';
1692
+ if (msg.attachment) {
1693
+ const att = msg.attachment;
1694
+ if (att.isImage) {
1695
+ const safeUrl = escapeAttr(att.mediaUrl);
1696
+ const safeAlt = escapeAttr(att.originalName || '');
1697
+ attachHtml = `<img src="${safeUrl}" class="max-w-[200px] rounded-lg mt-1 cursor-pointer" alt="${safeAlt}" data-lightbox="${safeUrl}" />`;
1698
+ } else {
1699
+ const safeName = encodeURIComponent(att.filename);
1700
+ attachHtml = `<a href="/api/media/download/${safeName}" target="_blank" class="flex items-center gap-2 mt-1 px-3 py-2 bg-white/50 rounded-lg border border-gray-200 text-sm hover:bg-white/80 transition-colors"><span class="text-lg">\u{1F4CE}</span><span class="truncate max-w-[180px]">${escapeHtml(att.originalName || att.filename)}</span></a>`;
1701
+ }
1702
+ }
1703
+ msgEl.innerHTML = `
1704
+ <div class="message-content">${escapeHtml(msg.content)}${attachHtml}</div>
1705
+ <div class="message-time">${formatMessageTime(new Date(msg.timestamp))}</div>
1706
+ `;
1707
+ } else if (msg.role === 'assistant') {
1708
+ msgEl.innerHTML = `
1709
+ <div class="message-content">${formatAssistantMessage(msg.content)}</div>
1710
+ <div class="message-time">${formatMessageTime(new Date(msg.timestamp))}</div>
1711
+ `;
1712
+ } else if (msg.role === 'system') {
1713
+ msgEl.innerHTML = `
1714
+ <div class="message-content">${escapeHtml(msg.content)}</div>
1715
+ `;
1716
+ }
1717
+
1718
+ container.appendChild(msgEl);
1719
+ });
1720
+
1721
+ scrollToBottom(container);
1722
+ showToast('Previous conversation restored');
1723
+
1724
+ return true;
1725
+ }
1726
+
1727
+ /**
1728
+ * Display history received from server
1729
+ */
1730
+ displayHistory(messages: ChatHistoryMessage[]): void {
1731
+ const container = getElementByIdOrNull<HTMLDivElement>('chat-messages');
1732
+ if (!container) {
1733
+ return;
1734
+ }
1735
+
1736
+ // Server history is authoritative — always use it when available
1737
+ if (messages.length === 0 && this.history.length > 0) {
1738
+ logger.info(`Server sent empty history, keeping local (${this.history.length})`);
1739
+ return;
1740
+ }
1741
+
1742
+ container.innerHTML = '';
1743
+ this.history = [];
1744
+
1745
+ messages.forEach((msg) => {
1746
+ const msgEl = document.createElement('div');
1747
+ msgEl.className = `chat-message ${msg.role}`;
1748
+
1749
+ const timestamp = msg.timestamp ? new Date(msg.timestamp) : new Date();
1750
+
1751
+ if (msg.role === 'user') {
1752
+ msgEl.innerHTML = `
1753
+ <div class="message-content">${escapeHtml(msg.content)}</div>
1754
+ <div class="message-time">${formatMessageTime(timestamp)}</div>
1755
+ `;
1756
+ } else if (msg.role === 'assistant') {
1757
+ msgEl.innerHTML = `
1758
+ <div class="message-content">${formatAssistantMessage(msg.content)}</div>
1759
+ <div class="message-time">${formatMessageTime(timestamp)}</div>
1760
+ `;
1761
+ } else if (msg.role === 'system') {
1762
+ msgEl.innerHTML = `
1763
+ <div class="message-content">${escapeHtml(msg.content)}</div>
1764
+ `;
1765
+ }
1766
+
1767
+ container.appendChild(msgEl);
1768
+ });
1769
+
1770
+ scrollToBottom(container);
1771
+ logger.info('Displayed', messages.length, 'history messages');
1772
+ }
1773
+
1774
+ /**
1775
+ * Clear chat history
1776
+ */
1777
+ clearHistory(sessionId: string | null = null): void {
1778
+ try {
1779
+ const storageKey = this.historyPrefix + (sessionId || this.sessionId);
1780
+ localStorage.removeItem(storageKey);
1781
+ this.history = [];
1782
+ } catch (e) {
1783
+ logger.warn('Failed to clear history:', e);
1784
+ }
1785
+ }
1786
+
1787
+ /**
1788
+ * Clean up expired histories
1789
+ */
1790
+ cleanupExpiredHistories(): void {
1791
+ try {
1792
+ const keys = Object.keys(localStorage);
1793
+ const now = Date.now();
1794
+
1795
+ keys.forEach((key) => {
1796
+ if (key.startsWith(this.historyPrefix)) {
1797
+ try {
1798
+ const data = JSON.parse(localStorage.getItem(key));
1799
+ if (data && data.savedAt && now - data.savedAt > this.historyExpiryMs) {
1800
+ localStorage.removeItem(key);
1801
+ logger.info('Cleaned up expired history:', key);
1802
+ }
1803
+ } catch {
1804
+ // Invalid data, remove it
1805
+ localStorage.removeItem(key);
1806
+ }
1807
+ }
1808
+ });
1809
+ } catch (e) {
1810
+ logger.warn('Failed to cleanup histories:', e);
1811
+ }
1812
+ }
1813
+
1814
+ // =============================================
1815
+ // Checkpoint Management
1816
+ // =============================================
1817
+
1818
+ /**
1819
+ * Generate checkpoint summary from current session (for manual /checkpoint command)
1820
+ */
1821
+ generateCheckpointSummary(): string {
1822
+ const summary = {
1823
+ sessionId: this.sessionId,
1824
+ messageCount: this.history.length,
1825
+ lastActivity: new Date().toISOString(),
1826
+ messages: this.history.slice(-10).map((msg) => ({
1827
+ role: msg.role,
1828
+ preview: msg.content.substring(0, 100),
1829
+ timestamp: msg.timestamp,
1830
+ })),
1831
+ };
1832
+
1833
+ return JSON.stringify(summary, null, 2);
1834
+ }
1835
+
1836
+ /**
1837
+ * Save checkpoint via API
1838
+ */
1839
+ async saveCheckpoint(summary: string): Promise<JsonRecord> {
1840
+ return await API.post<JsonRecord, { summary: string }>('/api/checkpoint/save', { summary });
1841
+ }
1842
+
1843
+ /**
1844
+ * Load last checkpoint via API
1845
+ */
1846
+ async loadCheckpoint(): Promise<CheckpointRecord | null> {
1847
+ try {
1848
+ return await API.get<CheckpointRecord>('/api/checkpoint/load');
1849
+ } catch (error) {
1850
+ if (error instanceof Error && error.message.includes('HTTP 404')) {
1851
+ return null;
1852
+ }
1853
+ throw error;
1854
+ }
1855
+ }
1856
+
1857
+ /**
1858
+ * Check for resumable session on init
1859
+ */
1860
+ async checkForResumableSession() {
1861
+ try {
1862
+ const checkpoint = await this.loadCheckpoint();
1863
+ if (checkpoint) {
1864
+ // Show resume banner
1865
+ const banner = getElementByIdOrNull<HTMLDivElement>('session-resume-banner');
1866
+ if (banner) {
1867
+ banner.style.display = 'flex';
1868
+ logger.info('Resume banner shown');
1869
+ }
1870
+ }
1871
+ } catch {
1872
+ // Silent fail - no checkpoint is okay
1873
+ logger.info('No resumable session');
1874
+ }
1875
+ }
1876
+
1877
+ // =============================================
1878
+ // Floating Chat
1879
+ // =============================================
1880
+
1881
+ /**
1882
+ * Initialize floating chat panel bindings
1883
+ */
1884
+ initFloating(): void {
1885
+ const bubble = getElementByIdOrNull<HTMLButtonElement>('chat-bubble');
1886
+ const closeBtn = getElementByIdOrNull<HTMLButtonElement>('chat-close');
1887
+ const resizeHandle = getElementByIdOrNull<HTMLDivElement>('chat-resize-handle');
1888
+ const panel = getElementByIdOrNull<HTMLDivElement>('chat-panel');
1889
+ const header = getElementByIdOrNull<HTMLDivElement>('chat-header');
1890
+
1891
+ if (bubble) {
1892
+ bubble.addEventListener('click', () => this.togglePanel());
1893
+ }
1894
+ if (closeBtn) {
1895
+ closeBtn.addEventListener('click', () => this.togglePanel(false));
1896
+ }
1897
+
1898
+ if (panel && header) {
1899
+ let dragging = false;
1900
+ let startX = 0;
1901
+ let startY = 0;
1902
+ let startLeft = 0;
1903
+ let startTop = 0;
1904
+
1905
+ const startDrag = (clientX: number, clientY: number) => {
1906
+ dragging = true;
1907
+ const rect = panel.getBoundingClientRect();
1908
+ startX = clientX;
1909
+ startY = clientY;
1910
+ startLeft = rect.left;
1911
+ startTop = rect.top;
1912
+ panel.classList.add('chat-panel-draggable');
1913
+ document.body.style.userSelect = 'none';
1914
+ };
1915
+
1916
+ const doDrag = (clientX: number, clientY: number) => {
1917
+ if (!dragging) {
1918
+ return;
1919
+ }
1920
+ const dx = clientX - startX;
1921
+ const dy = clientY - startY;
1922
+ const nextLeft = Math.max(8, Math.min(window.innerWidth - 80, startLeft + dx));
1923
+ const nextTop = Math.max(8, Math.min(window.innerHeight - 80, startTop + dy));
1924
+ panel.style.left = `${nextLeft}px`;
1925
+ panel.style.top = `${nextTop}px`;
1926
+ };
1927
+
1928
+ const endDrag = () => {
1929
+ if (!dragging) {
1930
+ return;
1931
+ }
1932
+ dragging = false;
1933
+ document.body.style.userSelect = '';
1934
+ document.body.classList.remove('no-scroll');
1935
+ this.savePanelState(panel);
1936
+ };
1937
+
1938
+ header.addEventListener('mousedown', (e: MouseEvent) => {
1939
+ const target = e.target as Element | null;
1940
+ if (target?.closest('button, a, input, select')) {
1941
+ return;
1942
+ }
1943
+ e.preventDefault();
1944
+ startDrag(e.clientX, e.clientY);
1945
+ });
1946
+
1947
+ this._onDragMouseMove = (e: MouseEvent) => doDrag(e.clientX, e.clientY);
1948
+ this._onDragMouseUp = endDrag;
1949
+ window.addEventListener('mousemove', this._onDragMouseMove);
1950
+ window.addEventListener('mouseup', this._onDragMouseUp);
1951
+
1952
+ this._onDragTouchMove = (e: TouchEvent) => {
1953
+ const touch = e.touches[0];
1954
+ if (!touch) {
1955
+ return;
1956
+ }
1957
+ if (!dragging) {
1958
+ return;
1959
+ }
1960
+ e.preventDefault();
1961
+ doDrag(touch.clientX, touch.clientY);
1962
+ };
1963
+ this._onDragTouchEnd = endDrag;
1964
+
1965
+ header.addEventListener(
1966
+ 'touchstart',
1967
+ (e: TouchEvent) => {
1968
+ const target = e.target as Element | null;
1969
+ if (target?.closest('button, a, input, select')) {
1970
+ return;
1971
+ }
1972
+ const touch = e.touches[0];
1973
+ if (!touch) {
1974
+ return;
1975
+ }
1976
+ e.preventDefault();
1977
+ startDrag(touch.clientX, touch.clientY);
1978
+ document.body.classList.add('no-scroll');
1979
+ },
1980
+ { passive: false }
1981
+ );
1982
+ window.addEventListener('touchmove', this._onDragTouchMove, { passive: false });
1983
+ window.addEventListener('touchend', this._onDragTouchEnd);
1984
+ }
1985
+
1986
+ if (resizeHandle && panel) {
1987
+ let resizing = false;
1988
+ let startX = 0;
1989
+ let startY = 0;
1990
+ let startW = 0;
1991
+ let startH = 0;
1992
+
1993
+ const startResize = (clientX: number, clientY: number) => {
1994
+ resizing = true;
1995
+ const rect = panel.getBoundingClientRect();
1996
+ startX = clientX;
1997
+ startY = clientY;
1998
+ startW = rect.width;
1999
+ startH = rect.height;
2000
+ document.body.style.userSelect = 'none';
2001
+ };
2002
+
2003
+ const doResize = (clientX: number, clientY: number) => {
2004
+ if (!resizing) {
2005
+ return;
2006
+ }
2007
+ const dx = clientX - startX;
2008
+ const dy = clientY - startY;
2009
+ const minW = 280;
2010
+ const minH = 320;
2011
+ const maxW = Math.min(window.innerWidth * 0.96, 800);
2012
+ const maxH = Math.min(window.innerHeight * 0.85, 900);
2013
+ const nextW = Math.max(minW, Math.min(maxW, startW + dx));
2014
+ const nextH = Math.max(minH, Math.min(maxH, startH + dy));
2015
+ panel.style.width = `${nextW}px`;
2016
+ panel.style.height = `${nextH}px`;
2017
+ };
2018
+
2019
+ const endResize = () => {
2020
+ if (!resizing) {
2021
+ return;
2022
+ }
2023
+ resizing = false;
2024
+ document.body.style.userSelect = '';
2025
+ document.body.classList.remove('no-scroll');
2026
+ this.savePanelState(panel);
2027
+ };
2028
+
2029
+ resizeHandle.addEventListener('mousedown', (e: MouseEvent) => {
2030
+ e.preventDefault();
2031
+ startResize(e.clientX, e.clientY);
2032
+ });
2033
+
2034
+ this._onResizeMouseMove = (e: MouseEvent) => doResize(e.clientX, e.clientY);
2035
+ this._onResizeMouseUp = endResize;
2036
+ window.addEventListener('mousemove', this._onResizeMouseMove);
2037
+ window.addEventListener('mouseup', this._onResizeMouseUp);
2038
+
2039
+ this._onResizeTouchMove = (e: TouchEvent) => {
2040
+ const touch = e.touches[0];
2041
+ if (!touch) {
2042
+ return;
2043
+ }
2044
+ if (!resizing) {
2045
+ return;
2046
+ }
2047
+ e.preventDefault();
2048
+ doResize(touch.clientX, touch.clientY);
2049
+ };
2050
+ this._onResizeTouchEnd = endResize;
2051
+
2052
+ resizeHandle.addEventListener(
2053
+ 'touchstart',
2054
+ (e: TouchEvent) => {
2055
+ const touch = e.touches[0];
2056
+ if (!touch) {
2057
+ return;
2058
+ }
2059
+ e.preventDefault();
2060
+ startResize(touch.clientX, touch.clientY);
2061
+ document.body.classList.add('no-scroll');
2062
+ },
2063
+ { passive: false }
2064
+ );
2065
+ window.addEventListener('touchmove', this._onResizeTouchMove, { passive: false });
2066
+ window.addEventListener('touchend', this._onResizeTouchEnd);
2067
+ }
2068
+
2069
+ this._onEscapeKey = (e: KeyboardEvent) => {
2070
+ if (e.key === 'Escape' && this.isFloatingOpen()) {
2071
+ this.togglePanel(false);
2072
+ }
2073
+ };
2074
+ document.addEventListener('keydown', this._onEscapeKey);
2075
+
2076
+ logger.info('Floating mode initialized');
2077
+ }
2078
+
2079
+ /**
2080
+ * Toggle floating chat panel open/close
2081
+ * @param {boolean} [forceState] - Force open (true) or close (false)
2082
+ */
2083
+ togglePanel(forceState?: boolean): void {
2084
+ const panel = getElementByIdOrNull<HTMLDivElement>('chat-panel');
2085
+ const bubble = getElementByIdOrNull<HTMLButtonElement>('chat-bubble');
2086
+ const badge = getElementByIdOrNull<HTMLSpanElement>('chat-badge');
2087
+ if (!panel) {
2088
+ return;
2089
+ }
2090
+
2091
+ const shouldOpen = forceState !== undefined ? forceState : panel.classList.contains('hidden');
2092
+
2093
+ if (shouldOpen) {
2094
+ panel.classList.remove('hidden');
2095
+ panel.classList.add('animate-slide-up');
2096
+ this.restorePanelState(panel);
2097
+ if (bubble) {
2098
+ bubble.classList.add('scale-0');
2099
+ }
2100
+ if (badge) {
2101
+ badge.classList.add('hidden');
2102
+ }
2103
+ const input = getElementByIdOrNull<HTMLTextAreaElement>('chat-input');
2104
+ if (input) {
2105
+ setTimeout(() => input.focus(), 100);
2106
+ }
2107
+ const messages = getElementByIdOrNull<HTMLDivElement>('chat-messages');
2108
+ if (messages) {
2109
+ messages.scrollTop = messages.scrollHeight;
2110
+ }
2111
+ } else {
2112
+ panel.classList.add('hidden');
2113
+ panel.classList.remove('animate-slide-up');
2114
+ if (bubble) {
2115
+ bubble.classList.remove('scale-0');
2116
+ }
2117
+ }
2118
+ }
2119
+
2120
+ /**
2121
+ * Persist panel size + position
2122
+ */
2123
+ savePanelState(panel: HTMLDivElement): void {
2124
+ try {
2125
+ const rect = panel.getBoundingClientRect();
2126
+ const state = {
2127
+ width: rect.width,
2128
+ height: rect.height,
2129
+ left: rect.left,
2130
+ top: rect.top,
2131
+ };
2132
+ localStorage.setItem('mama_chat_panel_state', JSON.stringify(state));
2133
+ } catch {
2134
+ // ignore storage errors
2135
+ }
2136
+ }
2137
+
2138
+ /**
2139
+ * Restore panel size + position
2140
+ */
2141
+ restorePanelState(panel: HTMLDivElement): void {
2142
+ try {
2143
+ const raw = localStorage.getItem('mama_chat_panel_state');
2144
+ if (!raw) {
2145
+ return;
2146
+ }
2147
+ const state = JSON.parse(raw);
2148
+ if (state.width) {
2149
+ panel.style.width = `${state.width}px`;
2150
+ }
2151
+ if (state.height) {
2152
+ panel.style.height = `${state.height}px`;
2153
+ }
2154
+ if (state.left !== undefined && state.top !== undefined) {
2155
+ panel.classList.add('chat-panel-draggable');
2156
+ panel.style.left = `${state.left}px`;
2157
+ panel.style.top = `${state.top}px`;
2158
+ }
2159
+ } catch {
2160
+ // ignore storage errors
2161
+ }
2162
+ }
2163
+
2164
+ /**
2165
+ * Check if floating panel is open
2166
+ */
2167
+ isFloatingOpen(): boolean {
2168
+ const panel = getElementByIdOrNull<HTMLDivElement>('chat-panel');
2169
+ return Boolean(panel && !panel.classList.contains('hidden'));
2170
+ }
2171
+
2172
+ /**
2173
+ * Show unread badge on bubble when panel is closed
2174
+ */
2175
+ showUnreadBadge(): void {
2176
+ if (this.isFloatingOpen()) {
2177
+ return;
2178
+ }
2179
+ const badge = getElementByIdOrNull<HTMLSpanElement>('chat-badge');
2180
+ if (badge) {
2181
+ badge.classList.remove('hidden');
2182
+ }
2183
+ }
2184
+
2185
+ /**
2186
+ * Cleanup resources when module is destroyed
2187
+ * Prevents memory leaks by cleaning up timers, connections, and APIs
2188
+ */
2189
+ cleanup(): void {
2190
+ // Clean up WebSocket
2191
+ if (this.ws) {
2192
+ this.ws.close();
2193
+ this.ws = null;
2194
+ }
2195
+
2196
+ // Clean up timers
2197
+ if (this.silenceTimeout) {
2198
+ clearTimeout(this.silenceTimeout);
2199
+ this.silenceTimeout = null;
2200
+ }
2201
+ if (this.idleTimer) {
2202
+ clearTimeout(this.idleTimer);
2203
+ this.idleTimer = null;
2204
+ }
2205
+
2206
+ // Clean up Speech Recognition
2207
+ if (this.speechRecognition) {
2208
+ this.speechRecognition.stop();
2209
+ this.speechRecognition = null;
2210
+ }
2211
+
2212
+ // Clean up Speech Synthesis
2213
+ if (this.isSpeaking) {
2214
+ this.speechSynthesis.cancel();
2215
+ this.isSpeaking = false;
2216
+ }
2217
+
2218
+ // Clean up window/document event listeners
2219
+ if (this._onDragMouseMove) {
2220
+ window.removeEventListener('mousemove', this._onDragMouseMove);
2221
+ this._onDragMouseMove = null;
2222
+ }
2223
+ if (this._onDragMouseUp) {
2224
+ window.removeEventListener('mouseup', this._onDragMouseUp);
2225
+ this._onDragMouseUp = null;
2226
+ }
2227
+ if (this._onDragTouchMove) {
2228
+ window.removeEventListener('touchmove', this._onDragTouchMove);
2229
+ this._onDragTouchMove = null;
2230
+ }
2231
+ if (this._onDragTouchEnd) {
2232
+ window.removeEventListener('touchend', this._onDragTouchEnd);
2233
+ this._onDragTouchEnd = null;
2234
+ }
2235
+ if (this._onResizeMouseMove) {
2236
+ window.removeEventListener('mousemove', this._onResizeMouseMove);
2237
+ this._onResizeMouseMove = null;
2238
+ }
2239
+ if (this._onResizeMouseUp) {
2240
+ window.removeEventListener('mouseup', this._onResizeMouseUp);
2241
+ this._onResizeMouseUp = null;
2242
+ }
2243
+ if (this._onResizeTouchMove) {
2244
+ window.removeEventListener('touchmove', this._onResizeTouchMove);
2245
+ this._onResizeTouchMove = null;
2246
+ }
2247
+ if (this._onResizeTouchEnd) {
2248
+ window.removeEventListener('touchend', this._onResizeTouchEnd);
2249
+ this._onResizeTouchEnd = null;
2250
+ }
2251
+ if (this._onEscapeKey) {
2252
+ document.removeEventListener('keydown', this._onEscapeKey);
2253
+ this._onEscapeKey = null;
2254
+ }
2255
+
2256
+ logger.info('Cleanup completed');
2257
+ }
2258
+ }