From 3e9b20a4847be7014e9068d0f4653724a1fce654 Mon Sep 17 00:00:00 2001 From: Johan Jongsma Date: Sun, 1 Feb 2026 08:04:06 +0000 Subject: [PATCH] Initial commit --- .gitignore | 5 + README.md | 132 +++++++++++++ package.json | 16 ++ server.js | 519 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 672 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d82971 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +*.log +.DS_Store + diff --git a/README.md b/README.md new file mode 100644 index 0000000..47b889e --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# MoltMobile 🎙️ + +**Voice-enabled mobile extension for Molt** + +MoltMobile is your phone's voice — answering calls, handling spam, and executing commands with sub-second latency via MiniMax AI. + +## Features + +- 🧠 **Fast LLM Triage** — MiniMax M2.1-lightning (~100 tps) for instant decisions +- 🔊 **Real-time TTS** — MiniMax speech-2.8-turbo (<250ms latency) +- 📞 **Smart Call Handling** — Answer, reject, or escalate based on context +- 🚨 **SUPER ATTENTION MODE** — Maximum urgency alerts +- 🌐 **Remote Browser** — Control Chrome on the phone +- 🔗 **Molt Integration** — Escalates to James when needed + +## Quick Start + +```bash +# Install +cd ~/dev/moltmobile-gateway +npm install + +# Set MiniMax API key +export MINIMAX_API_KEY=your_key_here + +# Run +npm start +``` + +## MiniMax Setup + +### 1. Create Account +Go to: https://platform.minimax.io + +### 2. Get Subscriptions + +**For LLM (text):** Pay-as-you-go +- MiniMax-M2.1-lightning: $0.27/M input, $1.10/M output +- No subscription needed, just add credits + +**For TTS (audio):** Monthly subscription +- **Recommended: Standard ($30/mo)** + - 300,000 credits/month + - 50 requests per minute + - All voice models + +### 3. Get API Key +- Go to: https://platform.minimax.io/user-center/basic-information/interface-key +- Create new API key +- Copy it (shown once!) + +### 4. Set Environment Variable +```bash +export MINIMAX_API_KEY=your_key_here +``` + +Or add to `~/.bashrc`: +```bash +echo 'export MINIMAX_API_KEY=your_key_here' >> ~/.bashrc +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Server status | +| `/speak` | POST | Generate TTS, play on phone | +| `/chat` | POST | Quick LLM response | +| `/attention/super` | POST | 🚨 SUPER ATTENTION MODE | +| `/attention/stop` | POST | Stop attention mode | +| `/browser/open` | POST | Open URL on phone | +| `/call/answer` | POST | Answer incoming call | +| `/call/reject` | POST | Reject incoming call | +| `/call/hangup` | POST | Hang up active call | + +## Examples + +### Speak on phone +```bash +curl -X POST http://localhost:9877/speak \ + -H 'Content-Type: application/json' \ + -d '{"text": "Hello Johan, you have a new message"}' +``` + +### Quick chat +```bash +curl -X POST http://localhost:9877/chat \ + -H 'Content-Type: application/json' \ + -d '{"message": "Is this caller likely spam?", "system": "Be brief."}' +``` + +### SUPER ATTENTION MODE 🚨 +```bash +curl -X POST http://localhost:9877/attention/super \ + -H 'Content-Type: application/json' \ + -d '{"message": "SOPHIA ALERT - CHECK NOW"}' +``` + +## Pricing Summary + +| Service | Plan | Cost | What you get | +|---------|------|------|--------------| +| LLM | Pay-as-you-go | ~$0.27/M tokens | Fast responses | +| TTS | Standard | $30/mo | 300K chars, 50 RPM | + +**Estimated monthly cost:** $30-50 depending on usage + +## Architecture + +``` +Phone (MoltMobile app) + ↓ WebSocket +MoltMobile Gateway (this) + ↓ +MiniMax API (LLM + TTS) + ↓ +Back to phone (commands, audio) + ↓ +Escalate to Molt/James if needed +``` + +## Next Steps + +1. [ ] Rename Android app from ClawdNode to MoltMobile +2. [ ] Add audio playback support in Android app +3. [ ] Add STT (speech-to-text) for listening to callers +4. [ ] Integrate with Clawdbot for escalation +5. [ ] Add call recording/transcription + +--- + +*MoltMobile — Molt in your pocket.* diff --git a/package.json b/package.json new file mode 100644 index 0000000..1338f3a --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "moltmobile-gateway", + "version": "0.2.0", + "description": "MoltMobile - Voice-enabled mobile extension for Molt", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "keywords": ["molt", "voice", "mobile", "gateway", "minimax"], + "author": "Johan Jongsma", + "license": "MIT", + "dependencies": { + "ws": "^8.14.2" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..47596be --- /dev/null +++ b/server.js @@ -0,0 +1,519 @@ +#!/usr/bin/env node +/** + * MoltMobile Gateway - Voice-enabled mobile extension for Molt + * Fast LLM (MiniMax) + TTS for real-time phone interactions + */ + +const http = require('http'); +const https = require('https'); +const { WebSocketServer, WebSocket } = require('ws'); +const fs = require('fs'); + +// ============================================ +// Configuration +// ============================================ +const CONFIG = { + // MiniMax API (set via environment) + MINIMAX_API_KEY: process.env.MINIMAX_API_KEY || '', + MINIMAX_API_BASE: 'https://api.minimax.io', + + // Models + LLM_MODEL: 'MiniMax-M2.1-lightning', // ~100 tps, very fast + TTS_MODEL: 'speech-2.8-turbo', // Low latency TTS + TTS_VOICE: 'English_expressive_narrator', + + // Clawdbot integration (for escalation to Molt/James) + CLAWDBOT_GATEWAY: 'http://localhost:18789', + + // Server ports + HTTP_PORT: 9877, + WS_PORT: 9878, + + // Phone owner + JOHAN_SIGNAL: '+17272252475', + + // Logging + LOG_FILE: '/tmp/moltmobile-gateway.log' +}; + +// ============================================ +// MiniMax API Client +// ============================================ +class MiniMaxClient { + constructor(apiKey) { + this.apiKey = apiKey; + this.baseUrl = CONFIG.MINIMAX_API_BASE; + } + + /** + * Fast LLM completion - for triage and quick responses + */ + async chat(messages, options = {}) { + const startTime = Date.now(); + + const response = await fetch(`${this.baseUrl}/anthropic/v1/messages`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: options.model || CONFIG.LLM_MODEL, + max_tokens: options.maxTokens || 500, + system: options.system || PROMPTS.TRIAGE, + messages: messages, + stream: false + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`MiniMax API error: ${response.status} - ${error}`); + } + + const data = await response.json(); + const latency = Date.now() - startTime; + + log(`🧠 [LLM] Response in ${latency}ms`); + + return { + content: data.content?.[0]?.text || '', + latency, + usage: data.usage + }; + } + + /** + * Text-to-Speech - for voice responses + */ + async speak(text, options = {}) { + const startTime = Date.now(); + + const response = await fetch(`${this.baseUrl}/v1/t2a_v2`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: options.model || CONFIG.TTS_MODEL, + text: text, + stream: false, + language_boost: 'auto', + output_format: 'hex', + voice_setting: { + voice_id: options.voice || CONFIG.TTS_VOICE, + speed: options.speed || 1.0, + vol: 1, + pitch: 0 + }, + audio_setting: { + sample_rate: 24000, + bitrate: 128000, + format: 'mp3', + channel: 1 + } + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`MiniMax TTS error: ${response.status} - ${error}`); + } + + const data = await response.json(); + const latency = Date.now() - startTime; + + log(`🔊 [TTS] Generated ${data.extra_info?.audio_length}ms audio in ${latency}ms`); + + return { + audio: data.data?.audio, // hex-encoded MP3 + audioLength: data.extra_info?.audio_length, + latency + }; + } +} + +// ============================================ +// Prompts for different scenarios +// ============================================ +const PROMPTS = { + TRIAGE: `You are MoltMobile, a fast voice assistant for phone calls. You triage incoming events and decide immediate actions. + +Your responses MUST be JSON with this structure: +{ + "action": "answer|reject|hold|escalate|speak|silent", + "speak_text": "What to say (if action involves speaking)", + "reason": "Brief reason for decision", + "escalate_to": "james" (only if action is escalate) +} + +Rules: +- SPAM/telemarketing/unknown: action=answer, then speak the legal disclaimer +- Known contacts: action=answer, greet them warmly +- Emergency keywords: action=escalate immediately +- Be FAST - lives may depend on it`, + + SPAM_DISCLAIMER: `This call is being recorded. You have reached a number registered on the National Do Not Call Registry. This is an unsolicited call. We request immediate removal from your calling list. Any recurring call from your organization may result in a formal complaint and penalties up to $10,000 per violation under the Florida Telemarketing Act.`, + + KNOWN_CALLER_GREETING: `Hello! This is MoltMobile, Johan's assistant. Johan isn't available right now. How can I help you?`, + + SUPER_ATTENTION: `URGENT: Triggering maximum attention mode on device.` +}; + +// ============================================ +// Call Handler - Uses MiniMax for decisions +// ============================================ +class CallHandler { + constructor(minimax) { + this.minimax = minimax; + this.activeCalls = new Map(); + } + + async handleIncoming(callInfo) { + const { number, contact, callId } = callInfo; + + log(`📞 [INCOMING] ${contact || number} (${callId})`); + + // Build context for LLM + const context = { + number, + contact: contact || 'Unknown', + isSpamLikely: this.isSpamLikely(number, contact), + isKnownContact: !!contact && contact !== 'Unknown', + timestamp: new Date().toISOString() + }; + + try { + // Get fast decision from MiniMax + const decision = await this.minimax.chat([ + { + role: 'user', + content: JSON.stringify({ + event: 'incoming_call', + ...context + }) + } + ]); + + const action = JSON.parse(decision.content); + log(`🧠 [DECISION] ${action.action}: ${action.reason}`); + + return action; + + } catch (e) { + log(`❌ [ERROR] Decision failed: ${e.message}`); + // Default: answer and greet + return { + action: 'answer', + speak_text: PROMPTS.KNOWN_CALLER_GREETING, + reason: 'Fallback - decision error' + }; + } + } + + isSpamLikely(number, contact) { + // Basic spam detection heuristics + if (!contact || contact === 'Unknown' || contact === 'Spam Likely') return true; + if (number?.startsWith('+1800') || number?.startsWith('+1888')) return true; + return false; + } +} + +// ============================================ +// Global instances +// ============================================ +let minimax = null; +let callHandler = null; +const clients = new Map(); +let commandQueue = []; + +// Logging +const logStream = fs.createWriteStream(CONFIG.LOG_FILE, { flags: 'a' }); +function log(msg) { + const ts = new Date().toISOString(); + const line = `[${ts}] ${msg}`; + console.log(line); + logStream.write(line + '\n'); +} + +// ============================================ +// WebSocket Server (for Android app) +// ============================================ +const wss = new WebSocketServer({ port: CONFIG.WS_PORT }); + +wss.on('connection', (ws, req) => { + const clientId = `mm_${Date.now()}`; + const ip = req.socket.remoteAddress; + + log(`📱 [CONNECT] ${clientId} from ${ip}`); + clients.set(clientId, { ws, ip, connectedAt: Date.now() }); + + ws.send(JSON.stringify({ + type: 'hello', + clientId, + serverTime: Date.now(), + name: 'MoltMobile Gateway' + })); + + // Send queued commands + while (commandQueue.length > 0) { + const cmd = commandQueue.shift(); + ws.send(JSON.stringify(cmd)); + } + + ws.on('message', async (data) => { + try { + const msg = JSON.parse(data.toString()); + await handleMessage(clientId, msg, ws); + } catch (e) { + log(`❌ [ERROR] ${e.message}`); + } + }); + + ws.on('close', () => { + log(`📱 [DISCONNECT] ${clientId}`); + clients.delete(clientId); + }); +}); + +async function handleMessage(clientId, msg, ws) { + const type = msg.type || msg.event || 'unknown'; + + switch (type) { + case 'call.incoming': + case 'call': + if (msg.state === 'incoming' || type === 'call.incoming') { + // Use MiniMax for fast decision + if (callHandler) { + const decision = await callHandler.handleIncoming(msg); + + // Execute decision + if (decision.action === 'answer') { + sendCommand('call.answer', { callId: msg.callId }); + + // Generate and send TTS if we need to speak + if (decision.speak_text && minimax) { + try { + const audio = await minimax.speak(decision.speak_text); + sendCommand('audio.play', { + audioHex: audio.audio, + callId: msg.callId + }); + } catch (e) { + log(`❌ [TTS ERROR] ${e.message}`); + } + } + } else if (decision.action === 'reject') { + sendCommand('call.reject', { callId: msg.callId }); + } else if (decision.action === 'escalate') { + escalateToJames(`📞 Call from ${msg.contact || msg.number} needs attention: ${decision.reason}`); + } + } + } + log(`📞 [CALL] ${msg.state}: ${msg.contact || msg.number}`); + break; + + case 'notification': + log(`🔔 [NOTIF] ${msg.app}: ${msg.title}`); + break; + + case 'response': + log(`✅ [RESPONSE] ${msg.commandId}: ${msg.success ? 'OK' : 'FAIL'}`); + break; + + default: + log(`⚡ [EVENT:${type}] ${JSON.stringify(msg).slice(0, 200)}`); + } +} + +function broadcast(msg) { + const data = JSON.stringify(msg); + let sent = 0; + clients.forEach((client) => { + if (client.ws.readyState === WebSocket.OPEN) { + client.ws.send(data); + sent++; + } + }); + return sent; +} + +function sendCommand(command, params = {}) { + const msg = { + type: 'command', + command, + params, + commandId: `cmd_${Date.now()}`, + timestamp: Date.now() + }; + + const sent = broadcast(msg); + if (sent === 0) { + commandQueue.push(msg); + log(`📤 [QUEUE] ${command}`); + } else { + log(`📤 [CMD] ${command} → ${sent} client(s)`); + } + return msg.commandId; +} + +async function escalateToJames(message) { + log(`🚨 [ESCALATE] ${message}`); + // TODO: Send via Clawdbot gateway to James +} + +// ============================================ +// HTTP API +// ============================================ +const httpServer = http.createServer(async (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + + if (req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Methods', 'GET, POST'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.end(); + return; + } + + const body = await parseBody(req); + const url = req.url.split('?')[0]; + + switch (url) { + case '/health': + res.end(JSON.stringify({ + status: 'ok', + name: 'MoltMobile Gateway', + clients: clients.size, + minimax: !!minimax, + time: new Date().toISOString() + })); + break; + + case '/speak': + // Generate TTS and send to phone + if (!minimax) { + res.statusCode = 503; + res.end(JSON.stringify({ error: 'MiniMax not configured' })); + return; + } + try { + const audio = await minimax.speak(body.text, body.options); + sendCommand('audio.play', { audioHex: audio.audio }); + res.end(JSON.stringify({ status: 'ok', latency: audio.latency })); + } catch (e) { + res.statusCode = 500; + res.end(JSON.stringify({ error: e.message })); + } + break; + + case '/chat': + // Quick LLM response + if (!minimax) { + res.statusCode = 503; + res.end(JSON.stringify({ error: 'MiniMax not configured' })); + return; + } + try { + const response = await minimax.chat([ + { role: 'user', content: body.message } + ], { system: body.system }); + res.end(JSON.stringify({ + response: response.content, + latency: response.latency + })); + } catch (e) { + res.statusCode = 500; + res.end(JSON.stringify({ error: e.message })); + } + break; + + case '/attention/super': + // SUPER ATTENTION MODE + log(`🚨 [SUPER ATTENTION] Triggered!`); + sendCommand('attention.super', { + message: body.message || 'URGENT ALERT', + duration: body.duration || 0 // 0 = until acknowledged + }); + res.end(JSON.stringify({ status: 'triggered' })); + break; + + case '/attention/stop': + sendCommand('attention.stop', {}); + res.end(JSON.stringify({ status: 'stopped' })); + break; + + case '/browser/open': + sendCommand('browser.open', { url: body.url }); + res.end(JSON.stringify({ status: 'sent' })); + break; + + case '/call/answer': + sendCommand('call.answer', { callId: body.callId }); + res.end(JSON.stringify({ status: 'sent' })); + break; + + case '/call/reject': + sendCommand('call.reject', { callId: body.callId }); + res.end(JSON.stringify({ status: 'sent' })); + break; + + case '/call/hangup': + sendCommand('call.hangup', { callId: body.callId }); + res.end(JSON.stringify({ status: 'sent' })); + break; + + default: + res.end(JSON.stringify({ + name: 'MoltMobile Gateway', + version: '0.2.0', + endpoints: [ + 'GET /health - Server status', + 'POST /speak - TTS via MiniMax', + 'POST /chat - Quick LLM response', + 'POST /attention/super - SUPER ATTENTION MODE 🚨', + 'POST /attention/stop - Stop attention mode', + 'POST /browser/open - Open URL on phone', + 'POST /call/answer - Answer call', + 'POST /call/reject - Reject call', + 'POST /call/hangup - Hang up call' + ] + })); + } +}); + +function parseBody(req) { + return new Promise((resolve) => { + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', () => { + try { resolve(JSON.parse(body || '{}')); } + catch { resolve({}); } + }); + }); +} + +// ============================================ +// Startup +// ============================================ +httpServer.listen(CONFIG.HTTP_PORT, '0.0.0.0', () => { + log('═══════════════════════════════════════════════'); + log(' MoltMobile Gateway - Voice-enabled mobile extension'); + log('═══════════════════════════════════════════════'); + log(` HTTP API: http://0.0.0.0:${CONFIG.HTTP_PORT}`); + log(` WebSocket: ws://0.0.0.0:${CONFIG.WS_PORT}`); + log(` Log file: ${CONFIG.LOG_FILE}`); + + if (CONFIG.MINIMAX_API_KEY) { + minimax = new MiniMaxClient(CONFIG.MINIMAX_API_KEY); + callHandler = new CallHandler(minimax); + log(` MiniMax: ✅ Configured`); + log(` LLM Model: ${CONFIG.LLM_MODEL}`); + log(` TTS Model: ${CONFIG.TTS_MODEL}`); + } else { + log(` MiniMax: ❌ Not configured (set MINIMAX_API_KEY)`); + } + + log('═══════════════════════════════════════════════'); +});