const express = require('express'); const { spawn } = require('child_process'); const https = require('https'); const http = require('http'); const fs = require('fs'); const path = require('path'); const app = express(); app.use(express.json()); // Try multiple sources for Brave API Key (in order of priority): // 1. Environment variable // 2. .env file in proxy directory // 3. ~/.claude/settings.json (Claude Code config) function getBraveApiKey() { // 1. Check environment variable first if (process.env.BRAVE_SEARCH_API_KEY) { return process.env.BRAVE_SEARCH_API_KEY; } // 2. Check .env file in proxy directory const envPath = path.join(__dirname, '.env'); if (fs.existsSync(envPath)) { const envContent = fs.readFileSync(envPath, 'utf8'); const match = envContent.match(/BRAVE_SEARCH_API_KEY\s*=\s*(.+)/m); if (match) { return match[1].trim(); } } // 3. Check Claude Code settings.json const claudeSettingsPath = path.join(process.env.HOME || '/root', '.claude', 'settings.json'); if (fs.existsSync(claudeSettingsPath)) { try { const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8')); if (settings.env?.BRAVE_SEARCH_API_KEY) { return settings.env.BRAVE_SEARCH_API_KEY; } } catch (e) { // Ignore JSON parse errors } } return ''; } // Health check endpoint app.get('/ping', (req, res) => { const status = { status: 'pong', braveSearchConfigured: !!BRAVE_API_KEY, timestamp: new Date().toISOString() }; res.json(status); }); // Main inference endpoint (OpenAI-compatible format) app.post('/v1/messages', async (req, res) => { const { messages, stream } = req.body; if (!messages || !Array.isArray(messages) || messages.length === 0) { return res.status(400).json({ error: { message: 'Invalid request: messages array required' } }); } // Check if web search is requested (via X-Search-Enabled header) const webSearchEnabled = req.headers['x-search-enabled'] === 'true'; const searchQuery = req.headers['x-search-query'] || ''; console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('Request from:', req.ip); console.log('Web Search:', webSearchEnabled ? 'ENABLED' : 'disabled'); if (searchQuery) { console.log('Search Query:', searchQuery.substring(0, 100) + '...'); } const braveApiKey = getBraveApiKey(); console.log('Brave API Key:', braveApiKey ? 'CONFIGURED' : 'NOT SET'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); // If web search is enabled and we have a query, fetch search results first let searchContext = ''; if (webSearchEnabled && searchQuery && braveApiKey) { console.log('Fetching web search results...'); try { searchContext = await fetchBraveSearchResults(searchQuery, braveApiKey); console.log('Search results fetched:', searchContext.length, 'chars'); } catch (err) { console.error('Search error:', err.message); } } // Build conversation context from messages array // Include previous messages for context continuity let conversationPrompt = ''; for (const msg of messages) { const role = msg.role === 'assistant' ? 'Assistant' : 'User'; conversationPrompt += `${role}: ${msg.content}\n\n`; } let prompt = conversationPrompt.trim(); // Prepend search context if available if (searchContext) { prompt = `WEB SEARCH RESULTS:\n${searchContext}\n\n---\n\nUSER QUERY:\n${prompt}\n\nPlease answer based on the search results above when relevant.`; } console.log('Prompt length:', prompt.length, 'chars'); console.log('Prompt preview:', prompt.substring(0, 150) + '...'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); // Spawn Claude CLI process const claude = spawn('claude', [], { stdio: ['pipe', 'pipe', 'pipe'] }); let output = ''; let errorOutput = ''; claude.stdout.on('data', (data) => { output += data.toString(); process.stdout.write('.'); }); claude.stderr.on('data', (data) => { errorOutput += data.toString(); console.error('Claude stderr:', data.toString()); }); claude.on('close', (code) => { console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('Claude exit code:', code); console.log('Response length:', output.length, 'chars'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); if (code !== 0 || !output.trim()) { return res.status(500).json({ error: { message: 'Claude CLI error', details: errorOutput || 'No response from Claude' } }); } // Return OpenAI-compatible response format res.json({ id: 'local-' + Date.now(), object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: 'claude-local', choices: [{ index: 0, message: { role: 'assistant', content: output.trim() }, finish_reason: 'stop' }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } }); }); claude.on('error', (err) => { console.error('Failed to spawn Claude CLI:', err); res.status(500).json({ error: { message: 'Failed to spawn Claude CLI', details: err.message } }); }); // Send prompt to Claude after brief pause setTimeout(() => { claude.stdin.write(prompt + '\n'); claude.stdin.end(); }, 100); }); /** * Fetch search results from Brave Search API */ async function fetchBraveSearchResults(query, apiKey, count = 5) { return new Promise((resolve, reject) => { const encodedQuery = encodeURIComponent(query); const url = `https://api.search.brave.com/res/v1/web/search?q=${encodedQuery}&count=${count}`; const options = { headers: { 'Accept': 'application/json', 'X-Subscription-Token': apiKey } }; const protocol = url.startsWith('https') ? https : http; const request = protocol.get(url, options, (response) => { let data = ''; response.on('data', (chunk) => { data += chunk; }); response.on('end', () => { if (response.statusCode !== 200) { return reject(new Error(`Brave API error: ${response.statusCode}`)); } try { const json = JSON.parse(data); const results = json.web?.results || []; if (results.length === 0) { return resolve('No search results found.'); } // Format results for LLM consumption let formatted = 'Search Results:\n\n'; results.forEach((result, i) => { formatted += `${i + 1}. **${result.title}**\n`; formatted += ` URL: ${result.url}\n`; if (result.description) { formatted += ` Summary: ${result.description}\n`; } formatted += '\n'; }); resolve(formatted); } catch (err) { reject(new Error('Failed to parse Brave response')); } }); }); request.on('error', (err) => { reject(err); }); request.setTimeout(10000, () => { request.destroy(); reject(new Error('Brave search timeout')); }); }); } const PORT = process.env.PORT || 8080; app.listen(PORT, '0.0.0.0', () => { const braveApiKey = getBraveApiKey(); console.log('═══════════════════════════════════════════════════'); console.log('🚀 Agentic Writer Local Backend v1.1.0'); console.log('═══════════════════════════════════════════════════'); console.log(`Local: http://localhost:${PORT}`); console.log(`Network: http://YOUR-IP:${PORT}`); console.log(''); console.log('Plugin Configuration:'); console.log(` Base URL: http://YOUR-IP:${PORT}`); console.log(` API Key: dummy`); console.log(` Model: claude-local`); console.log(''); console.log('Brave Search:'); console.log(` API Key: ${braveApiKey ? 'CONFIGURED' : 'NOT SET'}`); console.log(''); console.log('Web search works when Brave API key is found from:'); console.log(' 1. Environment: export BRAVE_SEARCH_API_KEY="key"'); console.log(' 2. .env file: BRAVE_SEARCH_API_KEY=key'); console.log(' 3. ~/.claude/settings.json env.BRAVE_SEARCH_API_KEY'); console.log(''); console.log('Restart proxy after adding key: ./stop-proxy.sh && ./start-proxy.sh'); console.log(''); console.log('Health check: GET /ping'); console.log('Inference: POST /v1/messages'); console.log('═══════════════════════════════════════════════════'); });