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,20 @@
1
+ {
2
+ "name": "claude-api-server",
3
+ "version": "1.0.0",
4
+ "description": "HTTP API wrapper for Claude Code CLI - Voice Interface",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js",
8
+ "dev": "node --watch server.js",
9
+ "lint": "cd .. && npm run lint:api-server",
10
+ "lint:fix": "cd .. && eslint claude-api-server/ --fix"
11
+ },
12
+ "dependencies": {
13
+ "express": "^4.18.2"
14
+ },
15
+ "engines": {
16
+ "node": ">=20.0.0"
17
+ },
18
+ "author": "Deepak Chougale",
19
+ "license": "MIT"
20
+ }
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Claude HTTP API Server
3
+ *
4
+ * HTTP server that wraps Claude Code CLI with session management
5
+ * Runs on the API server to handle voice interface queries
6
+ *
7
+ * Usage:
8
+ * node server.js
9
+ *
10
+ * Endpoints:
11
+ * POST /ask - Send a prompt to Claude (with optional callId for session)
12
+ * POST /end-session - Clean up session for a call
13
+ * GET /health - Health check
14
+ */
15
+
16
+ const express = require('express');
17
+ const { spawn } = require('child_process');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const {
21
+ buildQueryContext,
22
+ buildStructuredPrompt,
23
+ tryParseJsonFromText,
24
+ validateRequiredFields,
25
+ buildRepairPrompt,
26
+ } = require('./structured');
27
+
28
+ const app = express();
29
+ const PORT = process.env.PORT || 3333;
30
+
31
+ /**
32
+ * Build the full environment that Claude Code expects
33
+ * This mimics what happens when you run `claude` in a terminal
34
+ * with your zsh profile fully loaded.
35
+ */
36
+ function buildClaudeEnvironment() {
37
+ const HOME = process.env.HOME || process.env.USERPROFILE || '/root';
38
+ const PAI_DIR = path.join(HOME, '.claude');
39
+
40
+ // Load ~/.claude/.env (all API keys)
41
+ const envPath = path.join(PAI_DIR, '.env');
42
+ const paiEnv = {};
43
+ if (fs.existsSync(envPath)) {
44
+ const envContent = fs.readFileSync(envPath, 'utf8');
45
+ for (const line of envContent.split('\n')) {
46
+ const trimmed = line.trim();
47
+ if (trimmed && !trimmed.startsWith('#')) {
48
+ const [key, ...valueParts] = trimmed.split('=');
49
+ if (key && valueParts.length > 0) {
50
+ paiEnv[key] = valueParts.join('=');
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ // Build PATH like zsh profile does (mac/Linux). On Windows, the existing
57
+ // process PATH already resolves `claude`, `node`, etc. - don't stomp it.
58
+ const isWindows = process.platform === 'win32';
59
+ const fullPath = isWindows
60
+ ? process.env.PATH
61
+ : [
62
+ '/opt/homebrew/bin',
63
+ '/opt/homebrew/opt/python@3.12/bin',
64
+ '/opt/homebrew/opt/libpq/bin',
65
+ path.join(HOME, '.bun/bin'),
66
+ path.join(HOME, '.local/bin'),
67
+ path.join(HOME, '.pyenv/bin'),
68
+ path.join(HOME, '.pyenv/shims'),
69
+ path.join(HOME, 'go/bin'),
70
+ '/usr/local/go/bin',
71
+ path.join(HOME, 'bin'),
72
+ path.join(HOME, '.lmstudio/bin'),
73
+ path.join(HOME, '.opencode/bin'),
74
+ '/usr/local/bin',
75
+ '/usr/bin',
76
+ '/bin',
77
+ '/usr/sbin',
78
+ '/sbin'
79
+ ].join(':');
80
+
81
+ const env = {
82
+ ...process.env,
83
+ ...paiEnv,
84
+ PATH: fullPath,
85
+ HOME,
86
+ PAI_DIR,
87
+ PAI_HOME: HOME,
88
+ DA: 'Morpheus',
89
+ DA_COLOR: 'purple',
90
+ GOROOT: '/usr/local/go',
91
+ GOPATH: path.join(HOME, 'go'),
92
+ PYENV_ROOT: path.join(HOME, '.pyenv'),
93
+ BUN_INSTALL: path.join(HOME, '.bun'),
94
+ // CRITICAL: These tell Claude Code it's running in the proper environment
95
+ CLAUDECODE: '1',
96
+ CLAUDE_CODE_ENTRYPOINT: 'cli',
97
+ };
98
+
99
+ // CRITICAL: Remove ANTHROPIC_API_KEY so Claude CLI uses subscription auth
100
+ // If ANTHROPIC_API_KEY is set (even to placeholder), CLI tries API auth instead
101
+ delete env.ANTHROPIC_API_KEY;
102
+
103
+ return env;
104
+ }
105
+
106
+ // Pre-build the environment once at startup
107
+ const claudeEnv = buildClaudeEnvironment();
108
+ console.log('[STARTUP] Loaded environment with', Object.keys(claudeEnv).length, 'variables');
109
+ console.log('[STARTUP] PATH includes:', claudeEnv.PATH.split(':').slice(0, 5).join(', '), '...');
110
+
111
+ // Log which API keys are available (without showing values)
112
+ const apiKeys = Object.keys(claudeEnv).filter(k =>
113
+ k.includes('API_KEY') || k.includes('TOKEN') || k.includes('SECRET') || k === 'PAI_DIR'
114
+ );
115
+ console.log('[STARTUP] API keys loaded:', apiKeys.join(', '));
116
+
117
+ // Session storage: callId -> claudeSessionId
118
+ const sessions = new Map();
119
+
120
+ // Model selection - Sonnet for balanced speed/quality
121
+ const CLAUDE_MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
122
+
123
+ function parseClaudeStdout(stdout) {
124
+ // Claude Code CLI may output JSONL; when it does, extract the `result` message.
125
+ // Otherwise, fall back to raw stdout.
126
+ let response = '';
127
+ let sessionId = null;
128
+
129
+ try {
130
+ const lines = String(stdout || '').trim().split('\n');
131
+ for (const line of lines) {
132
+ try {
133
+ const parsed = JSON.parse(line);
134
+ if (parsed.type === 'result' && parsed.result) {
135
+ response = parsed.result;
136
+ sessionId = parsed.session_id;
137
+ }
138
+ } catch {
139
+ // Not JSONL; ignore.
140
+ }
141
+ }
142
+
143
+ if (!response) response = String(stdout || '').trim();
144
+ } catch {
145
+ response = String(stdout || '').trim();
146
+ }
147
+
148
+ return { response, sessionId };
149
+ }
150
+
151
+ function runClaudeOnce({ fullPrompt, callId, timestamp }) {
152
+ const startTime = Date.now();
153
+
154
+ const args = [
155
+ '--dangerously-skip-permissions',
156
+ // Every phone turn spawns a fresh CLI process. Without this it tries to
157
+ // connect to every configured MCP server first - including remote HTTP ones
158
+ // that need auth and simply time out - which added ~30-60s of dead air to
159
+ // each answer. The phone agent does not need them. Set PHONE_ENABLE_MCP=1
160
+ // if you deliberately want MCP tools available over the phone.
161
+ ...(process.env.PHONE_ENABLE_MCP === '1' ? [] : ['--strict-mcp-config']),
162
+ '-p', fullPrompt,
163
+ '--model', CLAUDE_MODEL
164
+ ];
165
+
166
+ if (callId) {
167
+ if (sessions.has(callId)) {
168
+ args.push('--resume', callId);
169
+ console.log(`[${timestamp}] Resuming session: ${callId}`);
170
+ } else {
171
+ args.push('--session-id', callId);
172
+ sessions.set(callId, true);
173
+ console.log(`[${timestamp}] Starting new session: ${callId}`);
174
+ }
175
+ }
176
+
177
+ return new Promise((resolve, reject) => {
178
+ const claude = spawn('claude', args, {
179
+ stdio: ['pipe', 'pipe', 'pipe'],
180
+ shell: false,
181
+ env: claudeEnv
182
+ });
183
+
184
+ let stdout = '';
185
+ let stderr = '';
186
+
187
+ claude.stdin.end();
188
+ claude.stdout.on('data', (data) => { stdout += data.toString(); });
189
+ claude.stderr.on('data', (data) => { stderr += data.toString(); });
190
+
191
+ claude.on('error', (error) => {
192
+ reject(error);
193
+ });
194
+
195
+ claude.on('close', (code) => {
196
+ const duration_ms = Date.now() - startTime;
197
+ resolve({ code, stdout, stderr, duration_ms });
198
+ });
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Voice Context - Prepended to all voice queries
204
+ *
205
+ * This tells Claude how to handle voice-specific patterns:
206
+ * - Output VOICE_RESPONSE for TTS (conversational, 40 words max)
207
+ * - Output COMPLETED for status logging (12 words max)
208
+ * - For Slack delivery requests: do the work, send to Slack, then acknowledge
209
+ */
210
+ const VOICE_CONTEXT = `[VOICE CALL CONTEXT]
211
+ This query comes via voice call. You MUST include BOTH of these lines in your response:
212
+
213
+ 🗣️ VOICE_RESPONSE: [Your conversational answer in 40 words or less. This is what gets spoken aloud via TTS. Be natural and helpful, like talking to a friend.]
214
+
215
+ 🎯 COMPLETED: [Status summary in 12 words or less. This is for logging only.]
216
+
217
+ IMPORTANT: The VOICE_RESPONSE line is what the caller HEARS. Make it conversational and complete - don't just say "Done" or "Task completed". Actually answer their question or confirm what you did in a natural way.
218
+
219
+ SLACK DELIVERY: When the caller requests delivery to Slack (phrases like "send to Slack", "post to #channel", "message me when done"):
220
+ 1. Do the requested work (research, generate content, analyze, etc.)
221
+ 2. Send results to the specified Slack channel using the Slack skill
222
+ 3. Include a VOICE_RESPONSE like: "Done! I sent the weather info to the 508 channel."
223
+
224
+ The caller may hang up while you're working (they'll hear hold music). That's fine - complete the work and send to Slack. They'll see it there.
225
+
226
+ Example query: "What's the weather in Royce City?"
227
+ Example response:
228
+ 🗣️ VOICE_RESPONSE: It's 65 degrees and partly cloudy in Royce City right now. Great weather for being outside!
229
+ 🎯 COMPLETED: Weather lookup for Royce City done.
230
+ [END VOICE CONTEXT]
231
+
232
+ `;
233
+
234
+ // Middleware
235
+ app.use(express.json());
236
+
237
+ // Request logging
238
+ app.use((req, res, next) => {
239
+ const timestamp = new Date().toISOString();
240
+ console.log(`[${timestamp}] ${req.method} ${req.path}`);
241
+ next();
242
+ });
243
+
244
+ /**
245
+ * POST /ask
246
+ *
247
+ * Request body:
248
+ * {
249
+ * "prompt": "What Docker containers are running?",
250
+ * "callId": "optional-call-uuid",
251
+ * "devicePrompt": "optional device-specific prompt"
252
+ * }
253
+ *
254
+ * Response:
255
+ * { "success": true, "response": "...", "duration_ms": 1234, "sessionId": "..." }
256
+ *
257
+ * Session Management:
258
+ * - If callId is provided and we have a stored session, uses --resume
259
+ * - First query for a callId captures the session_id for future turns
260
+ * - This maintains conversation context across multiple turns in a phone call
261
+ *
262
+ * Device Prompts:
263
+ * - If devicePrompt is provided, it's prepended before VOICE_CONTEXT
264
+ * - This allows each device (NAS, Proxmox, etc.) to have its own identity and skills
265
+ */
266
+ app.post('/ask', async (req, res) => {
267
+ const { prompt, callId, devicePrompt } = req.body;
268
+ const startTime = Date.now();
269
+ const timestamp = new Date().toISOString();
270
+
271
+ if (!prompt) {
272
+ return res.status(400).json({
273
+ success: false,
274
+ error: 'Missing prompt in request body'
275
+ });
276
+ }
277
+
278
+ // Check if we have an existing session for this call
279
+ const existingSession = callId ? sessions.get(callId) : null;
280
+
281
+ console.log(`[${timestamp}] QUERY: "${prompt.substring(0, 100)}..."`);
282
+ console.log(`[${timestamp}] MODEL: ${CLAUDE_MODEL}`);
283
+ console.log(`[${timestamp}] SESSION: callId=${callId || 'none'}, existing=${existingSession || 'none'}`);
284
+ console.log(`[${timestamp}] DEVICE PROMPT: ${devicePrompt ? 'Yes (' + devicePrompt.substring(0, 30) + '...)' : 'No'}`);
285
+
286
+ try {
287
+ /**
288
+ * Prompt layering order:
289
+ * 1. Device prompt (if provided) - identity and available skills
290
+ * 2. VOICE_CONTEXT - general voice call instructions
291
+ * 3. User's prompt - what they actually said
292
+ */
293
+ let fullPrompt = '';
294
+
295
+ if (devicePrompt) {
296
+ fullPrompt += `[DEVICE IDENTITY]\n${devicePrompt}\n[END DEVICE IDENTITY]\n\n`;
297
+ }
298
+
299
+ fullPrompt += VOICE_CONTEXT;
300
+ fullPrompt += prompt;
301
+
302
+ const { code, stdout, stderr, duration_ms } = await runClaudeOnce({ fullPrompt, callId, timestamp });
303
+
304
+ if (code !== 0) {
305
+ console.error(`[${new Date().toISOString()}] ERROR: Claude CLI exited with code ${code}`);
306
+ console.error(`STDERR: ${stderr}`);
307
+ console.error(`STDOUT: ${stdout.substring(0, 500)}`);
308
+ const errorMsg = stderr || stdout || `Exit code ${code}`;
309
+ return res.json({ success: false, error: `Claude CLI failed: ${errorMsg}`, duration_ms });
310
+ }
311
+
312
+ const { response, sessionId } = parseClaudeStdout(stdout);
313
+
314
+ if (sessionId && callId) {
315
+ sessions.set(callId, sessionId);
316
+ console.log(`[${new Date().toISOString()}] SESSION STORED: ${callId} -> ${sessionId}`);
317
+ }
318
+
319
+ console.log(`[${new Date().toISOString()}] RESPONSE (${duration_ms}ms): "${response.substring(0, 100)}..."`);
320
+
321
+ res.json({ success: true, response, sessionId, duration_ms });
322
+
323
+ } catch (error) {
324
+ const duration_ms = Date.now() - startTime;
325
+ console.error(`[${timestamp}] ERROR:`, error.message);
326
+
327
+ res.json({
328
+ success: false,
329
+ error: error.message,
330
+ duration_ms
331
+ });
332
+ }
333
+ });
334
+
335
+ /**
336
+ * POST /ask-structured
337
+ *
338
+ * Like /ask, but returns machine-validated JSON for n8n automations.
339
+ *
340
+ * Request body:
341
+ * {
342
+ * "prompt": "Check Ceph health",
343
+ * "callId": "optional-call-uuid",
344
+ * "devicePrompt": "optional device-specific prompt",
345
+ * "schema": {
346
+ * "queryType": "ceph_health",
347
+ * "requiredFields": ["cluster_status","ssd_usage_percent","recommendation"],
348
+ * "fieldGuidance": { "cluster_status": "Ceph overall health, e.g. HEALTH_OK/HEALTH_WARN/HEALTH_ERR" },
349
+ * "allowExtraFields": true,
350
+ * "example": { "cluster_status": "HEALTH_WARN", "ssd_usage_percent": 88, "recommendation": "alert" }
351
+ * },
352
+ * "includeVoiceContext": false,
353
+ * "maxRetries": 1
354
+ * }
355
+ *
356
+ * Response (success):
357
+ * { "success": true, "data": {...}, "raw_response": "...", "duration_ms": 1234 }
358
+ */
359
+ app.post('/ask-structured', async (req, res) => {
360
+ const {
361
+ prompt,
362
+ callId,
363
+ devicePrompt,
364
+ schema = {},
365
+ includeVoiceContext = false,
366
+ maxRetries = 1,
367
+ } = req.body || {};
368
+
369
+ const timestamp = new Date().toISOString();
370
+
371
+ if (!prompt) {
372
+ return res.status(400).json({ success: false, error: 'Missing prompt in request body' });
373
+ }
374
+
375
+ const queryContext = buildQueryContext({
376
+ queryType: schema.queryType,
377
+ requiredFields: schema.requiredFields,
378
+ fieldGuidance: schema.fieldGuidance,
379
+ allowExtraFields: schema.allowExtraFields !== false,
380
+ example: schema.example,
381
+ });
382
+
383
+ let fullPrompt = buildStructuredPrompt({
384
+ devicePrompt,
385
+ queryContext: (includeVoiceContext ? VOICE_CONTEXT : '') + queryContext,
386
+ userPrompt: prompt,
387
+ });
388
+
389
+ console.log(`[${timestamp}] STRUCTURED QUERY: "${String(prompt).substring(0, 100)}..."`);
390
+ console.log(`[${timestamp}] MODEL: ${CLAUDE_MODEL}`);
391
+ console.log(`[${timestamp}] SESSION: callId=${callId || 'none'}, existing=${callId ? (sessions.has(callId) ? 'yes' : 'no') : 'none'}`);
392
+
393
+ try {
394
+ let lastRaw = '';
395
+ let lastError = 'Unknown error';
396
+ let totalDuration = 0;
397
+ const retries = Number.isFinite(Number(maxRetries)) ? Number(maxRetries) : 0;
398
+ let attemptsMade = 0;
399
+
400
+ for (let attempt = 0; attempt <= retries; attempt++) {
401
+ attemptsMade = attempt + 1;
402
+ const { code, stdout, stderr, duration_ms } = await runClaudeOnce({ fullPrompt, callId, timestamp });
403
+ totalDuration += duration_ms;
404
+
405
+ if (code !== 0) {
406
+ lastError = `Claude CLI failed: ${stderr}`;
407
+ lastRaw = String(stdout || '').trim();
408
+ return res.status(502).json({
409
+ success: false,
410
+ error: lastError,
411
+ raw_response: lastRaw,
412
+ duration_ms: totalDuration,
413
+ attempts: attemptsMade,
414
+ });
415
+ }
416
+
417
+ const { response, sessionId } = parseClaudeStdout(stdout);
418
+ lastRaw = response;
419
+
420
+ if (sessionId && callId) sessions.set(callId, sessionId);
421
+
422
+ const parsed = tryParseJsonFromText(response);
423
+ if (!parsed.ok) {
424
+ lastError = parsed.error || 'Failed to parse JSON';
425
+ } else {
426
+ const validation = validateRequiredFields(parsed.data, schema.requiredFields);
427
+ if (validation.ok) {
428
+ return res.json({
429
+ success: true,
430
+ data: parsed.data,
431
+ json_text: parsed.jsonText,
432
+ raw_response: response,
433
+ duration_ms: totalDuration,
434
+ attempts: attemptsMade,
435
+ });
436
+ }
437
+ lastError = validation.error || 'Validation failed';
438
+ }
439
+
440
+ if (attempt >= retries) break;
441
+
442
+ // Retry once with a repair prompt that forces "JSON only" formatting.
443
+ const repairPrompt = buildRepairPrompt({
444
+ queryType: schema.queryType,
445
+ requiredFields: schema.requiredFields,
446
+ fieldGuidance: schema.fieldGuidance,
447
+ allowExtraFields: schema.allowExtraFields !== false,
448
+ originalUserPrompt: prompt,
449
+ invalidAssistantOutput: lastRaw,
450
+ example: schema.example,
451
+ });
452
+
453
+ fullPrompt = buildStructuredPrompt({
454
+ devicePrompt,
455
+ queryContext: includeVoiceContext ? VOICE_CONTEXT : '',
456
+ userPrompt: repairPrompt,
457
+ });
458
+ }
459
+
460
+ return res.status(422).json({
461
+ success: false,
462
+ error: lastError,
463
+ raw_response: lastRaw,
464
+ duration_ms: totalDuration,
465
+ attempts: attemptsMade,
466
+ });
467
+ } catch (error) {
468
+ console.error(`[${timestamp}] ERROR:`, error.message);
469
+ return res.status(500).json({ success: false, error: error.message });
470
+ }
471
+ });
472
+
473
+ /**
474
+ * POST /end-session
475
+ *
476
+ * Clean up session when a call ends
477
+ *
478
+ * Request body:
479
+ * { "callId": "call-uuid" }
480
+ */
481
+ app.post('/end-session', (req, res) => {
482
+ const { callId } = req.body;
483
+ const timestamp = new Date().toISOString();
484
+
485
+ if (callId && sessions.has(callId)) {
486
+ sessions.delete(callId);
487
+ console.log(`[${timestamp}] SESSION ENDED: ${callId}`);
488
+ }
489
+
490
+ res.json({ success: true });
491
+ });
492
+
493
+ /**
494
+ * GET /health
495
+ * Health check endpoint
496
+ */
497
+ app.get('/health', (req, res) => {
498
+ res.json({
499
+ status: 'ok',
500
+ service: 'claude-api-server',
501
+ timestamp: new Date().toISOString()
502
+ });
503
+ });
504
+
505
+ /**
506
+ * GET /
507
+ * Info endpoint
508
+ */
509
+ app.get('/', (req, res) => {
510
+ res.json({
511
+ service: 'Claude HTTP API Server',
512
+ version: '1.0.0',
513
+ endpoints: {
514
+ 'POST /ask': 'Send a prompt to Claude',
515
+ 'POST /ask-structured': 'Send a prompt and return validated JSON (n8n)',
516
+ 'GET /health': 'Health check'
517
+ }
518
+ });
519
+ });
520
+
521
+ // Start server
522
+ app.listen(PORT, '0.0.0.0', () => {
523
+ console.log('='.repeat(64));
524
+ console.log('Claude HTTP API Server');
525
+ console.log('='.repeat(64));
526
+ console.log(`\nListening on: http://0.0.0.0:${PORT}`);
527
+ console.log(`Health check: http://localhost:${PORT}/health`);
528
+ console.log('\nReady to receive Claude queries from voice interface.\n');
529
+ });
530
+
531
+ // Graceful shutdown
532
+ process.on('SIGTERM', () => {
533
+ console.log('\nReceived SIGTERM, shutting down gracefully...');
534
+ process.exit(0);
535
+ });
536
+
537
+ process.on('SIGINT', () => {
538
+ console.log('\nReceived SIGINT, shutting down gracefully...');
539
+ process.exit(0);
540
+ });