claude-phone-local 2.0.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 (96) hide show
  1. package/.env.example +108 -0
  2. package/Dockerfile +84 -0
  3. package/LICENSE +22 -0
  4. package/README.md +231 -0
  5. package/claude-api-server/package-lock.json +832 -0
  6. package/claude-api-server/package.json +20 -0
  7. package/claude-api-server/server.js +540 -0
  8. package/claude-api-server/structured.js +208 -0
  9. package/cli/README.md +231 -0
  10. package/cli/bin/check-publish-safety.js +53 -0
  11. package/cli/bin/claude-phone.js +23 -0
  12. package/cli/bin/cli-main.js +284 -0
  13. package/cli/bin/postinstall.js +20 -0
  14. package/cli/lib/commands/api-server.js +77 -0
  15. package/cli/lib/commands/backup.js +69 -0
  16. package/cli/lib/commands/config/path.js +30 -0
  17. package/cli/lib/commands/config/reset.js +73 -0
  18. package/cli/lib/commands/config/show.js +86 -0
  19. package/cli/lib/commands/device/add.js +143 -0
  20. package/cli/lib/commands/device/list.js +55 -0
  21. package/cli/lib/commands/device/remove.js +83 -0
  22. package/cli/lib/commands/doctor.js +422 -0
  23. package/cli/lib/commands/logs.js +186 -0
  24. package/cli/lib/commands/restore.js +172 -0
  25. package/cli/lib/commands/setup.js +1246 -0
  26. package/cli/lib/commands/start.js +346 -0
  27. package/cli/lib/commands/status.js +139 -0
  28. package/cli/lib/commands/stop.js +101 -0
  29. package/cli/lib/commands/uninstall.js +183 -0
  30. package/cli/lib/commands/update.js +205 -0
  31. package/cli/lib/config.js +113 -0
  32. package/cli/lib/docker.js +384 -0
  33. package/cli/lib/mcp-register.js +108 -0
  34. package/cli/lib/network.js +109 -0
  35. package/cli/lib/platform.js +85 -0
  36. package/cli/lib/port-check.js +135 -0
  37. package/cli/lib/prereqs/checks/compose.js +147 -0
  38. package/cli/lib/prereqs/checks/disk.js +89 -0
  39. package/cli/lib/prereqs/checks/docker.js +155 -0
  40. package/cli/lib/prereqs/checks/network.js +78 -0
  41. package/cli/lib/prereqs/checks/node.js +95 -0
  42. package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
  43. package/cli/lib/prereqs/installers/docker.js +229 -0
  44. package/cli/lib/prereqs/installers/node.js +254 -0
  45. package/cli/lib/prereqs/platform.js +175 -0
  46. package/cli/lib/prereqs/utils/execute.js +208 -0
  47. package/cli/lib/prereqs/utils/rollback.js +223 -0
  48. package/cli/lib/prereqs/utils/sudo.js +177 -0
  49. package/cli/lib/prereqs.js +228 -0
  50. package/cli/lib/prerequisites.js +115 -0
  51. package/cli/lib/process-manager.js +176 -0
  52. package/cli/lib/utils.js +78 -0
  53. package/cli/lib/validators.js +219 -0
  54. package/cli/lib/voice-downloader.js +79 -0
  55. package/docker/entrypoint.sh +110 -0
  56. package/docker/supervisord.conf +78 -0
  57. package/docker-compose.yml +40 -0
  58. package/docs/CLAUDE-CODE-SKILL.md +45 -0
  59. package/docs/LANGUAGES.md +113 -0
  60. package/docs/MCP-SERVER.md +111 -0
  61. package/docs/TROUBLESHOOTING.md +288 -0
  62. package/mcp-server/index.js +159 -0
  63. package/mcp-server/package-lock.json +1201 -0
  64. package/mcp-server/package.json +9 -0
  65. package/package.json +73 -0
  66. package/stt-local/Dockerfile +12 -0
  67. package/stt-local/server.py +45 -0
  68. package/tts-local/Dockerfile +9 -0
  69. package/tts-local/server.py +83 -0
  70. package/voice-app/API-QUERY-CONTRACT.md +238 -0
  71. package/voice-app/DEPLOYMENT.md +176 -0
  72. package/voice-app/Dockerfile +20 -0
  73. package/voice-app/README-OUTBOUND.md +316 -0
  74. package/voice-app/config/devices.json.example +18 -0
  75. package/voice-app/index.js +273 -0
  76. package/voice-app/lib/audio-fork.js +465 -0
  77. package/voice-app/lib/claude-bridge.js +113 -0
  78. package/voice-app/lib/connection-retry.js +58 -0
  79. package/voice-app/lib/conversation-loop.js +490 -0
  80. package/voice-app/lib/device-registry.js +171 -0
  81. package/voice-app/lib/http-server.js +242 -0
  82. package/voice-app/lib/logger.js +35 -0
  83. package/voice-app/lib/multi-registrar.js +133 -0
  84. package/voice-app/lib/outbound-handler.js +299 -0
  85. package/voice-app/lib/outbound-routes.js +417 -0
  86. package/voice-app/lib/outbound-session.js +324 -0
  87. package/voice-app/lib/query-routes.js +484 -0
  88. package/voice-app/lib/registrar.js +144 -0
  89. package/voice-app/lib/sip-handler.js +490 -0
  90. package/voice-app/lib/tts-service.js +205 -0
  91. package/voice-app/lib/whisper-client.js +140 -0
  92. package/voice-app/package.json +38 -0
  93. package/voice-app/static/gotit-beep.wav +0 -0
  94. package/voice-app/static/hold-music.wav +0 -0
  95. package/voice-app/static/ready-beep.wav +0 -0
  96. package/voice-app/test/freeswitch-retry.test.js +100 -0
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Shared Conversation Loop
3
+ * Extracted from sip-handler.js for use by both inbound and outbound calls
4
+ *
5
+ * Features:
6
+ * - VAD-based speech detection
7
+ * - DTMF # key to end speech early
8
+ * - Whisper transcription
9
+ * - Claude API integration
10
+ * - TTS response generation
11
+ * - Turn-taking audio cues (beeps)
12
+ * - Hold music during processing
13
+ */
14
+
15
+ const logger = require('./logger');
16
+
17
+ // FreeSWITCH (a separate container) fetches/connects to these - must be the
18
+ // voice-app container's address, not FreeSWITCH's own loopback.
19
+ const AUDIO_BASE_URL = process.env.AUDIO_BASE_URL || 'http://voice-app:3000';
20
+ const AUDIO_WS_HOST = new URL(AUDIO_BASE_URL).hostname;
21
+
22
+ // Audio cue URLs
23
+ const READY_BEEP_URL = `${AUDIO_BASE_URL}/static/ready-beep.wav`;
24
+ const GOTIT_BEEP_URL = `${AUDIO_BASE_URL}/static/gotit-beep.wav`;
25
+ const HOLD_MUSIC_URL = `${AUDIO_BASE_URL}/static/hold-music.mp3`;
26
+
27
+ // Claude Code-style thinking phrases
28
+ const THINKING_PHRASES = [
29
+ "Pondering...",
30
+ "Elucidating...",
31
+ "Cogitating...",
32
+ "Ruminating...",
33
+ "Contemplating...",
34
+ "Consulting the oracle...",
35
+ "Summoning knowledge...",
36
+ "Engaging neural pathways...",
37
+ "Accessing the mainframe...",
38
+ "Querying the void...",
39
+ "Let me think about that...",
40
+ "Processing...",
41
+ "Hmm, interesting question...",
42
+ "One moment...",
43
+ "Searching my brain...",
44
+ ];
45
+
46
+ function getRandomThinkingPhrase() {
47
+ return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)];
48
+ }
49
+
50
+ function isGoodbye(transcript) {
51
+ const lower = transcript.toLowerCase().trim();
52
+ const goodbyePhrases = ['goodbye', 'good bye', 'bye', 'hang up', 'end call', "that's all", 'thats all'];
53
+ return goodbyePhrases.some(phrase => {
54
+ return lower === phrase || lower.includes(` ${phrase}`) ||
55
+ lower.startsWith(`${phrase} `) || lower.endsWith(` ${phrase}`);
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Extract voice-friendly line from Claude's response
61
+ * Priority: VOICE_RESPONSE > CUSTOM COMPLETED > COMPLETED > first sentence
62
+ */
63
+ function extractVoiceLine(response) {
64
+ /**
65
+ * Clean markdown and formatting from text for speech
66
+ */
67
+ function cleanForSpeech(text) {
68
+ return text
69
+ .replace(/\*+/g, '') // Remove bold/italic markers
70
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Convert [text](url) to just text
71
+ .replace(/\[([^\]]+)\]/g, '$1') // Remove remaining brackets
72
+ .trim();
73
+ }
74
+
75
+ // Priority 1: Check for new VOICE_RESPONSE line (voice-optimized content)
76
+ const voiceMatch = response.match(/🗣️\s*VOICE_RESPONSE:\s*([^\n]+)/im);
77
+ if (voiceMatch) {
78
+ const text = cleanForSpeech(voiceMatch[1]);
79
+ const wordCount = text.split(/\s+/).filter(w => w.length > 0).length;
80
+
81
+ // Accept if under 60 words
82
+ if (text && wordCount <= 60) {
83
+ return text;
84
+ }
85
+
86
+ // If too long, log warning but continue to next fallback
87
+ logger.warn('VOICE_RESPONSE too long, falling back', { wordCount, maxWords: 60 });
88
+ }
89
+
90
+ // Priority 2: Check for legacy CUSTOM COMPLETED line
91
+ const customMatch = response.match(/🗣️\s*CUSTOM\s+COMPLETED:\s*(.+?)(?:\n|$)/im);
92
+ if (customMatch) {
93
+ const text = cleanForSpeech(customMatch[1]);
94
+ if (text && text.split(/\s+/).length <= 50) {
95
+ return text;
96
+ }
97
+ }
98
+
99
+ // Priority 3: Check for standard COMPLETED line
100
+ const completedMatch = response.match(/🎯\s*COMPLETED:\s*(.+?)(?:\n|$)/im);
101
+ if (completedMatch) {
102
+ return cleanForSpeech(completedMatch[1]);
103
+ }
104
+
105
+ // Priority 4: Fallback to first sentence
106
+ const firstSentence = response.split(/[.!?]/)[0];
107
+ if (firstSentence && firstSentence.length < 500) {
108
+ return firstSentence.trim();
109
+ }
110
+
111
+ // Last resort: truncate
112
+ return response.substring(0, 500).trim();
113
+ }
114
+
115
+ /**
116
+ * Run the conversation loop
117
+ *
118
+ * @param {Object} endpoint - FreeSWITCH endpoint
119
+ * @param {Object} dialog - SIP dialog
120
+ * @param {string} callUuid - Unique call identifier
121
+ * @param {Object} options - Configuration options
122
+ * @param {Object} options.audioForkServer - WebSocket audio fork server
123
+ * @param {Object} options.whisperClient - Whisper transcription client
124
+ * @param {Object} options.claudeBridge - Claude API bridge
125
+ * @param {Object} options.ttsService - TTS service
126
+ * @param {number} options.wsPort - WebSocket port
127
+ * @param {string} [options.initialContext] - Context for outbound calls (why we're calling)
128
+ * @param {boolean} [options.skipGreeting=false] - Skip greeting (for outbound, greeting already played)
129
+ * @param {number} [options.maxTurns=20] - Maximum conversation turns
130
+ * @returns {Promise<void>}
131
+ */
132
+ async function runConversationLoop(endpoint, dialog, callUuid, options) {
133
+ const {
134
+ audioForkServer,
135
+ whisperClient,
136
+ claudeBridge,
137
+ ttsService,
138
+ wsPort,
139
+ initialContext = null,
140
+ skipGreeting = false,
141
+ deviceConfig = null,
142
+ maxTurns = 20,
143
+ // Outbound calls need to give up rather than sit there asking "are you still
144
+ // there?" twenty times into an empty room.
145
+ maxSilentTurns = parseInt(process.env.MAX_SILENT_TURNS || '2', 10),
146
+ onTurn = null
147
+ } = options;
148
+
149
+ let silentTurns = 0;
150
+
151
+ // Extract devicePrompt and voiceId from deviceConfig (for Cephanie etc)
152
+ const devicePrompt = deviceConfig?.prompt || null;
153
+ const voiceId = deviceConfig?.voiceId || null; // null = use default Morpheus voice
154
+ let session = null;
155
+ let forkRunning = false;
156
+ let callActive = true;
157
+ let dtmfHandler = null;
158
+
159
+ // Track when call ends to prevent operations on dead endpoints
160
+ const onDialogDestroy = () => {
161
+ callActive = false;
162
+ logger.info('Call ended (dialog destroyed)', { callUuid });
163
+ };
164
+
165
+ try {
166
+ logger.info('Conversation loop starting', {
167
+ callUuid,
168
+ skipGreeting,
169
+ hasInitialContext: !!initialContext
170
+ });
171
+
172
+ // Listen for call end
173
+ dialog.on('destroy', onDialogDestroy);
174
+
175
+ // Play greeting (skip for outbound where initial message already played)
176
+ if (!skipGreeting && callActive) {
177
+ const greetingUrl = await ttsService.generateSpeech(
178
+ "Hello! I'm your server. How can I help you today?",
179
+ voiceId
180
+ );
181
+ await endpoint.play(greetingUrl);
182
+ }
183
+
184
+ // Prime Claude with context if this is an outbound call (NON-BLOCKING)
185
+ // Fire-and-forget: we don't use the response, just establishing session context
186
+ if (initialContext && callActive) {
187
+ logger.info('Priming Claude with outbound context (non-blocking)', { callUuid });
188
+ claudeBridge.query(
189
+ `[SYSTEM CONTEXT - DO NOT REPEAT]: You just called the user to tell them: "${initialContext}". They have answered. Now listen to their response and help them.`,
190
+ { callId: callUuid, devicePrompt: devicePrompt, isSystemPrime: true }
191
+ ).catch(err => logger.warn('Prime query failed', { callUuid, error: err.message }));
192
+ }
193
+
194
+ // Check if call is still active before starting audio fork
195
+ if (!callActive) {
196
+ logger.info('Call ended before audio fork could start', { callUuid });
197
+ return;
198
+ }
199
+
200
+ // Start audio fork for entire call
201
+ const wsUrl = `ws://${AUDIO_WS_HOST}:${wsPort}/${encodeURIComponent(callUuid)}`;
202
+
203
+ // Use try-catch for expectSession to handle race conditions
204
+ let sessionPromise;
205
+ try {
206
+ sessionPromise = audioForkServer.expectSession(callUuid, { timeoutMs: 10000 });
207
+ } catch (err) {
208
+ logger.warn('Failed to set up session expectation', { callUuid, error: err.message });
209
+ return;
210
+ }
211
+
212
+ await endpoint.forkAudioStart({
213
+ wsUrl,
214
+ mixType: 'mono',
215
+ sampling: '16k'
216
+ });
217
+ forkRunning = true;
218
+
219
+ try {
220
+ session = await sessionPromise;
221
+ logger.info('Audio fork connected', { callUuid });
222
+ } catch (err) {
223
+ logger.warn('Audio fork session failed', { callUuid, error: err.message });
224
+ // Cancel the pending expectation if still there
225
+ audioForkServer.cancelExpectation && audioForkServer.cancelExpectation(callUuid);
226
+ return;
227
+ }
228
+
229
+ // Set up DTMF handler for # key
230
+ dtmfHandler = (evt) => {
231
+ const digit = evt.dtmf || evt.digit;
232
+ logger.info('DTMF received', { callUuid, digit });
233
+
234
+ if (digit === '#' && session) {
235
+ logger.info('DTMF # pressed - forcing utterance finalization', { callUuid });
236
+ session.forceFinalize();
237
+ }
238
+ };
239
+
240
+ // Enable DTMF detection on endpoint
241
+ try {
242
+ // Tell FreeSWITCH to detect DTMF
243
+ await endpoint.api('uuid_recv_dtmf', `${endpoint.uuid} true`);
244
+ endpoint.on('dtmf', dtmfHandler);
245
+ logger.info('DTMF detection enabled', { callUuid });
246
+ } catch (err) {
247
+ logger.warn('Failed to enable DTMF detection', { callUuid, error: err.message });
248
+ // Continue without DTMF - not critical
249
+ }
250
+
251
+ // Emit session event for external monitoring
252
+ if (audioForkServer.emit) {
253
+ audioForkServer.emit('session', session);
254
+ }
255
+ console.log('[AUDIO] New session for call ' + callUuid);
256
+
257
+ // Main conversation loop
258
+ let turnCount = 0;
259
+
260
+ while (turnCount < maxTurns && callActive) {
261
+ turnCount++;
262
+ logger.info('Conversation turn', { callUuid, turn: turnCount, maxTurns });
263
+
264
+ // Check if call is still active
265
+ if (!callActive) {
266
+ logger.info('Call ended during turn', { callUuid, turn: turnCount });
267
+ break;
268
+ }
269
+
270
+ // ============================================
271
+ // READY BEEP: Signal "your turn to speak"
272
+ // ============================================
273
+ try {
274
+ if (callActive) await endpoint.play(READY_BEEP_URL);
275
+ } catch (e) {
276
+ if (!callActive) break;
277
+ logger.warn('Ready beep failed', { callUuid, error: e.message });
278
+ }
279
+
280
+ // Enable capture and wait for speech
281
+ session.setCaptureEnabled(true);
282
+ logger.info('Waiting for speech (press # to send immediately)', { callUuid });
283
+
284
+ let utterance = null;
285
+ try {
286
+ utterance = await session.waitForUtterance({ timeoutMs: 30000 });
287
+ logger.info('Got utterance', { callUuid, bytes: utterance.audio.length, reason: utterance.reason });
288
+ } catch (err) {
289
+ if (!callActive) break;
290
+ logger.info('Utterance timeout', { callUuid, error: err.message });
291
+ }
292
+
293
+ session.setCaptureEnabled(false);
294
+
295
+ // Check if call ended during speech detection
296
+ if (!callActive) {
297
+ logger.info('Call ended during speech detection', { callUuid });
298
+ break;
299
+ }
300
+
301
+ // Handle no speech
302
+ if (!utterance) {
303
+ silentTurns++;
304
+ if (silentTurns >= maxSilentTurns) {
305
+ logger.info('No response - ending call', { callUuid, silentTurns });
306
+ if (callActive) {
307
+ const byeUrl = await ttsService.generateSpeech(
308
+ "I'll let you go. Talk to you later.",
309
+ voiceId
310
+ );
311
+ await endpoint.play(byeUrl);
312
+ }
313
+ break;
314
+ }
315
+ const promptUrl = await ttsService.generateSpeech(
316
+ "I didn't hear anything. Are you still there?",
317
+ voiceId
318
+ );
319
+ if (callActive) await endpoint.play(promptUrl);
320
+ continue;
321
+ }
322
+ silentTurns = 0;
323
+
324
+ // ============================================
325
+ // GOT-IT BEEP: Signal "I heard you, processing"
326
+ // ============================================
327
+ try {
328
+ if (callActive) await endpoint.play(GOTIT_BEEP_URL);
329
+ } catch (e) {
330
+ if (!callActive) break;
331
+ logger.warn('Got-it beep failed', { callUuid, error: e.message });
332
+ }
333
+
334
+ // Transcribe
335
+ const transcript = await whisperClient.transcribe(utterance.audio, {
336
+ format: 'pcm',
337
+ sampleRate: 16000
338
+ });
339
+
340
+ logger.info('Transcribed', { callUuid, transcript });
341
+
342
+ // Handle empty transcription
343
+ if (!transcript || transcript.trim().length < 2) {
344
+ const clarifyUrl = await ttsService.generateSpeech(
345
+ "Sorry, I didn't catch that. Could you repeat?",
346
+ voiceId
347
+ );
348
+ if (callActive) await endpoint.play(clarifyUrl);
349
+ continue;
350
+ }
351
+
352
+ // Handle goodbye
353
+ if (isGoodbye(transcript)) {
354
+ const byeUrl = await ttsService.generateSpeech("Goodbye! Call again anytime.", voiceId);
355
+ if (callActive) await endpoint.play(byeUrl);
356
+ break;
357
+ }
358
+
359
+ // ============================================
360
+ // THINKING FEEDBACK
361
+ // ============================================
362
+
363
+ // Check if call still active before thinking feedback
364
+ if (!callActive) break;
365
+
366
+ // 1. Play random thinking phrase
367
+ const thinkingPhrase = getRandomThinkingPhrase();
368
+ logger.info('Playing thinking phrase', { callUuid, phrase: thinkingPhrase });
369
+ const thinkingUrl = await ttsService.generateSpeech(thinkingPhrase, voiceId);
370
+ if (callActive) await endpoint.play(thinkingUrl);
371
+
372
+ // 2. Start hold music in background
373
+ let musicPlaying = false;
374
+ if (callActive) {
375
+ endpoint.play(HOLD_MUSIC_URL).catch(e => {
376
+ logger.warn('Hold music failed', { callUuid, error: e.message });
377
+ });
378
+ musicPlaying = true;
379
+ }
380
+
381
+ // 3. Query Claude
382
+ logger.info('Querying Claude', { callUuid });
383
+ const claudeResponse = await claudeBridge.query(
384
+ transcript,
385
+ { callId: callUuid, devicePrompt: devicePrompt }
386
+ );
387
+
388
+ // 4. Stop hold music
389
+ if (musicPlaying && callActive) {
390
+ try {
391
+ await endpoint.api('uuid_break', endpoint.uuid);
392
+ } catch (e) {
393
+ // Ignore - music may have already stopped
394
+ }
395
+ }
396
+
397
+ // Check if call ended during Claude processing
398
+ if (!callActive) {
399
+ logger.info('Call ended during Claude processing', { callUuid });
400
+ break;
401
+ }
402
+
403
+ logger.info('Claude responded', { callUuid });
404
+
405
+ // 5. Extract and play voice line
406
+ const voiceLine = extractVoiceLine(claudeResponse);
407
+ logger.info('Voice line', { callUuid, voiceLine });
408
+
409
+ const responseUrl = await ttsService.generateSpeech(voiceLine, voiceId);
410
+ if (callActive) await endpoint.play(responseUrl);
411
+
412
+ // Let the caller record what was said, so an outbound call's answer can be
413
+ // read back later via the status endpoint.
414
+ if (onTurn) {
415
+ try { onTurn(transcript, voiceLine); } catch (e) { /* never break the call */ }
416
+ }
417
+
418
+ logger.info('Turn complete', { callUuid, turn: turnCount });
419
+ }
420
+
421
+ // Max turns reached
422
+ if (turnCount >= maxTurns && callActive) {
423
+ const maxUrl = await ttsService.generateSpeech(
424
+ "We've been talking for a while. Goodbye!",
425
+ voiceId
426
+ );
427
+ await endpoint.play(maxUrl);
428
+ }
429
+
430
+ logger.info('Conversation loop ended normally', { callUuid, turns: turnCount });
431
+
432
+ } catch (error) {
433
+ logger.error('Conversation loop error', {
434
+ callUuid,
435
+ error: error.message,
436
+ stack: error.stack
437
+ });
438
+
439
+ try {
440
+ if (session) session.setCaptureEnabled(false);
441
+ if (callActive) {
442
+ const errUrl = await ttsService.generateSpeech("Sorry, something went wrong.", voiceId);
443
+ await endpoint.play(errUrl);
444
+ }
445
+ } catch (e) {
446
+ // Ignore cleanup errors
447
+ }
448
+ } finally {
449
+ logger.info('Conversation loop cleanup', { callUuid });
450
+
451
+ // Remove dialog listener
452
+ dialog.off('destroy', onDialogDestroy);
453
+
454
+ // Remove DTMF handler
455
+ if (dtmfHandler) {
456
+ endpoint.off('dtmf', dtmfHandler);
457
+ }
458
+
459
+ // Cancel any pending session expectations
460
+ if (audioForkServer.cancelExpectation) {
461
+ audioForkServer.cancelExpectation(callUuid);
462
+ }
463
+
464
+ // End Claude session
465
+ try {
466
+ await claudeBridge.endSession(callUuid);
467
+ } catch (e) {
468
+ // Ignore
469
+ }
470
+
471
+ // Stop audio fork
472
+ if (forkRunning) {
473
+ try {
474
+ await endpoint.forkAudioStop();
475
+ } catch (e) {
476
+ // Ignore
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ module.exports = {
483
+ runConversationLoop,
484
+ extractVoiceLine,
485
+ isGoodbye,
486
+ getRandomThinkingPhrase,
487
+ READY_BEEP_URL,
488
+ GOTIT_BEEP_URL,
489
+ HOLD_MUSIC_URL
490
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Device Registry Module
3
+ *
4
+ * Manages device configurations for multi-device voice interface support.
5
+ * Loads device configs from JSON and provides lookup by extension or name.
6
+ *
7
+ * Each device has:
8
+ * - name: Human-readable identifier (e.g., "Cephanie", "Morpheus")
9
+ * - extension: SIP extension number (e.g., "9002")
10
+ * - authId: 3CX authentication ID for SIP REGISTER
11
+ * - password: 3CX authentication password
12
+ * - voiceId: ElevenLabs voice ID for TTS
13
+ * - prompt: System prompt that defines device personality
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const logger = require('./logger');
19
+
20
+ const CONFIG_PATH = process.env.DEVICES_CONFIG || path.join(__dirname, '../config/devices.json');
21
+
22
+ // Default device (Morpheus) - used when config file missing or no match found
23
+ const MORPHEUS_DEFAULT = {
24
+ name: 'Morpheus',
25
+ extension: '9000',
26
+ authId: 'Au0XZPTpJY',
27
+ password: 'DGHwMW6v25',
28
+ voiceId: 'JAgnJveGGUh4qy4kh6dF',
29
+ prompt: 'You are Morpheus, Chuck\'s principal AI assistant. You are meticulous, systematic, and excellence-driven. Keep voice responses under 40 words.'
30
+ };
31
+
32
+ class DeviceRegistry {
33
+ constructor() {
34
+ this.devices = {};
35
+ this.devicesByName = {};
36
+ this.loaded = false;
37
+ this.load();
38
+ }
39
+
40
+ /**
41
+ * Load devices from config file
42
+ */
43
+ load() {
44
+ try {
45
+ if (!fs.existsSync(CONFIG_PATH)) {
46
+ logger.warn('Device config not found, using Morpheus default only', {
47
+ path: CONFIG_PATH
48
+ });
49
+ this.devices = {
50
+ [MORPHEUS_DEFAULT.extension]: MORPHEUS_DEFAULT
51
+ };
52
+ this.devicesByName = {
53
+ [MORPHEUS_DEFAULT.name.toLowerCase()]: MORPHEUS_DEFAULT
54
+ };
55
+ this.loaded = true;
56
+ return;
57
+ }
58
+
59
+ const configData = fs.readFileSync(CONFIG_PATH, 'utf8');
60
+ const devicesJson = JSON.parse(configData);
61
+
62
+ if (typeof devicesJson !== 'object') {
63
+ throw new Error('Device config must be an object');
64
+ }
65
+
66
+ this.devices = {};
67
+ this.devicesByName = {};
68
+
69
+ for (const [extension, device] of Object.entries(devicesJson)) {
70
+ if (!device.name || !device.extension) {
71
+ logger.warn('Skipping invalid device config', { extension, device });
72
+ continue;
73
+ }
74
+
75
+ this.devices[extension] = device;
76
+ this.devicesByName[device.name.toLowerCase()] = device;
77
+ }
78
+
79
+ // Only use Morpheus default if NO devices are configured
80
+ if (Object.keys(this.devices).length === 0) {
81
+ logger.warn('No devices configured, using Morpheus default');
82
+ this.devices[MORPHEUS_DEFAULT.extension] = MORPHEUS_DEFAULT;
83
+ this.devicesByName[MORPHEUS_DEFAULT.name.toLowerCase()] = MORPHEUS_DEFAULT;
84
+ }
85
+
86
+ this.loaded = true;
87
+ logger.info('Device registry loaded', {
88
+ deviceCount: Object.keys(this.devices).length,
89
+ devices: Object.keys(this.devices)
90
+ });
91
+
92
+ } catch (error) {
93
+ logger.error('Failed to load device config', { error: error.message });
94
+ this.devices = { [MORPHEUS_DEFAULT.extension]: MORPHEUS_DEFAULT };
95
+ this.devicesByName = { [MORPHEUS_DEFAULT.name.toLowerCase()]: MORPHEUS_DEFAULT };
96
+ this.loaded = true;
97
+ }
98
+ }
99
+
100
+ reload() {
101
+ logger.info('Reloading device registry...');
102
+ this.load();
103
+ }
104
+
105
+ getByExtension(extension) {
106
+ return this.devices[extension] || null;
107
+ }
108
+
109
+ getByName(name) {
110
+ if (!name) return null;
111
+ return this.devicesByName[name.toLowerCase()] || null;
112
+ }
113
+
114
+ get(identifier) {
115
+ if (!identifier) return null;
116
+ let device = this.getByExtension(identifier);
117
+ if (!device) {
118
+ device = this.getByName(identifier);
119
+ }
120
+ return device;
121
+ }
122
+
123
+ getAll() {
124
+ return { ...this.devices };
125
+ }
126
+
127
+ getAllDevices() {
128
+ return { ...this.devices };
129
+ }
130
+
131
+ getDefault() {
132
+ return { ...MORPHEUS_DEFAULT };
133
+ }
134
+
135
+ isLoaded() {
136
+ return this.loaded;
137
+ }
138
+
139
+ /**
140
+ * Get devices that have auth credentials for SIP registration
141
+ * Returns array of devices with authId and password
142
+ */
143
+ getRegistrableDevices() {
144
+ const registrable = [];
145
+ for (const device of Object.values(this.devices)) {
146
+ if (device.authId && device.password) {
147
+ registrable.push(device);
148
+ }
149
+ }
150
+ return registrable;
151
+ }
152
+
153
+ /**
154
+ * Get registration configs for all registrable devices
155
+ * Returns object keyed by extension
156
+ */
157
+ getRegistrationConfigs() {
158
+ const configs = {};
159
+ for (const [ext, device] of Object.entries(this.devices)) {
160
+ if (device.authId && device.password) {
161
+ configs[ext] = device;
162
+ }
163
+ }
164
+ return configs;
165
+ }
166
+ }
167
+
168
+ // Singleton instance
169
+ const registry = new DeviceRegistry();
170
+
171
+ module.exports = registry;