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,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
|
+
});
|