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.
- package/.env.example +108 -0
- package/Dockerfile +84 -0
- package/LICENSE +22 -0
- package/README.md +231 -0
- package/claude-api-server/package-lock.json +832 -0
- package/claude-api-server/package.json +20 -0
- package/claude-api-server/server.js +540 -0
- package/claude-api-server/structured.js +208 -0
- package/cli/README.md +231 -0
- package/cli/bin/check-publish-safety.js +53 -0
- package/cli/bin/claude-phone.js +23 -0
- package/cli/bin/cli-main.js +284 -0
- package/cli/bin/postinstall.js +20 -0
- package/cli/lib/commands/api-server.js +77 -0
- package/cli/lib/commands/backup.js +69 -0
- package/cli/lib/commands/config/path.js +30 -0
- package/cli/lib/commands/config/reset.js +73 -0
- package/cli/lib/commands/config/show.js +86 -0
- package/cli/lib/commands/device/add.js +143 -0
- package/cli/lib/commands/device/list.js +55 -0
- package/cli/lib/commands/device/remove.js +83 -0
- package/cli/lib/commands/doctor.js +422 -0
- package/cli/lib/commands/logs.js +186 -0
- package/cli/lib/commands/restore.js +172 -0
- package/cli/lib/commands/setup.js +1246 -0
- package/cli/lib/commands/start.js +346 -0
- package/cli/lib/commands/status.js +139 -0
- package/cli/lib/commands/stop.js +101 -0
- package/cli/lib/commands/uninstall.js +183 -0
- package/cli/lib/commands/update.js +205 -0
- package/cli/lib/config.js +113 -0
- package/cli/lib/docker.js +384 -0
- package/cli/lib/mcp-register.js +108 -0
- package/cli/lib/network.js +109 -0
- package/cli/lib/platform.js +85 -0
- package/cli/lib/port-check.js +135 -0
- package/cli/lib/prereqs/checks/compose.js +147 -0
- package/cli/lib/prereqs/checks/disk.js +89 -0
- package/cli/lib/prereqs/checks/docker.js +155 -0
- package/cli/lib/prereqs/checks/network.js +78 -0
- package/cli/lib/prereqs/checks/node.js +95 -0
- package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
- package/cli/lib/prereqs/installers/docker.js +229 -0
- package/cli/lib/prereqs/installers/node.js +254 -0
- package/cli/lib/prereqs/platform.js +175 -0
- package/cli/lib/prereqs/utils/execute.js +208 -0
- package/cli/lib/prereqs/utils/rollback.js +223 -0
- package/cli/lib/prereqs/utils/sudo.js +177 -0
- package/cli/lib/prereqs.js +228 -0
- package/cli/lib/prerequisites.js +115 -0
- package/cli/lib/process-manager.js +176 -0
- package/cli/lib/utils.js +78 -0
- package/cli/lib/validators.js +219 -0
- package/cli/lib/voice-downloader.js +79 -0
- package/docker/entrypoint.sh +110 -0
- package/docker/supervisord.conf +78 -0
- package/docker-compose.yml +40 -0
- package/docs/CLAUDE-CODE-SKILL.md +45 -0
- package/docs/LANGUAGES.md +113 -0
- package/docs/MCP-SERVER.md +111 -0
- package/docs/TROUBLESHOOTING.md +288 -0
- package/mcp-server/index.js +159 -0
- package/mcp-server/package-lock.json +1201 -0
- package/mcp-server/package.json +9 -0
- package/package.json +73 -0
- package/stt-local/Dockerfile +12 -0
- package/stt-local/server.py +45 -0
- package/tts-local/Dockerfile +9 -0
- package/tts-local/server.py +83 -0
- package/voice-app/API-QUERY-CONTRACT.md +238 -0
- package/voice-app/DEPLOYMENT.md +176 -0
- package/voice-app/Dockerfile +20 -0
- package/voice-app/README-OUTBOUND.md +316 -0
- package/voice-app/config/devices.json.example +18 -0
- package/voice-app/index.js +273 -0
- package/voice-app/lib/audio-fork.js +465 -0
- package/voice-app/lib/claude-bridge.js +113 -0
- package/voice-app/lib/connection-retry.js +58 -0
- package/voice-app/lib/conversation-loop.js +490 -0
- package/voice-app/lib/device-registry.js +171 -0
- package/voice-app/lib/http-server.js +242 -0
- package/voice-app/lib/logger.js +35 -0
- package/voice-app/lib/multi-registrar.js +133 -0
- package/voice-app/lib/outbound-handler.js +299 -0
- package/voice-app/lib/outbound-routes.js +417 -0
- package/voice-app/lib/outbound-session.js +324 -0
- package/voice-app/lib/query-routes.js +484 -0
- package/voice-app/lib/registrar.js +144 -0
- package/voice-app/lib/sip-handler.js +490 -0
- package/voice-app/lib/tts-service.js +205 -0
- package/voice-app/lib/whisper-client.js +140 -0
- package/voice-app/package.json +38 -0
- package/voice-app/static/gotit-beep.wav +0 -0
- package/voice-app/static/hold-music.wav +0 -0
- package/voice-app/static/ready-beep.wav +0 -0
- 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;
|