'POST only']); exit; } $message = trim($data['message'] ?? ''); $sessionId = $data['session_id'] ?? session_id(); $panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.) if (!$message) { echo json_encode(['error' => 'Message required']); exit; } // Build context string from selected panel item $ctxSnippet = ''; if ($panelCtx && is_array($panelCtx)) { $type = $panelCtx['type'] ?? ''; switch ($type) { case 'vm': $ctxSnippet = sprintf( '[Selected VM: %s (VMID %s) — Status: %s, CPU: %s%%, RAM: %s/%sMB, Type: %s]', $panelCtx['name'] ?? '?', $panelCtx['vmid'] ?? '?', $panelCtx['status'] ?? '?', $panelCtx['cpu'] ?? '?', $panelCtx['mem_mb'] ?? '?', $panelCtx['maxmem_mb'] ?? '?', $panelCtx['type_label'] ?? 'qemu' ); break; case 'network': $ctxSnippet = sprintf( '[Selected Device: %s — IP: %s, Status: %s%s]', $panelCtx['name'] ?? '?', $panelCtx['ip'] ?? '?', $panelCtx['status'] ?? '?', $panelCtx['latency'] ? ', Latency: ' . $panelCtx['latency'] . 'ms' : '' ); break; case 'alert': $ctxSnippet = sprintf( '[Selected Alert: %s — Severity: %s, Message: %s]', $panelCtx['title'] ?? '?', $panelCtx['severity'] ?? '?', $panelCtx['message'] ?? '?' ); break; case 'news': $ctxSnippet = sprintf( '[Selected News Story: "%s" — Source: %s, Published: %s]', $panelCtx['title'] ?? '?', $panelCtx['source'] ?? '?', $panelCtx['pub'] ?? 'unknown' ); break; case 'ha': $ctxSnippet = sprintf( '[Selected Home Device: %s (%s) — Current State: %s]', $panelCtx['name'] ?? '?', $panelCtx['entity_id'] ?? '?', $panelCtx['state'] ?? '?' ); break; } } // Save user message JarvisDB::insert( 'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)', [$sessionId, 'user', $message] ); // Conversation history $history = JarvisDB::query( 'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 10', [$sessionId] ); $history = array_reverse($history); $reply = null; $source = 'unknown'; // ── Load user address preference ────────────────────────────────────────── $prefRows = JarvisDB::query( "SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')" ); $prefs = []; foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; } $userName = $prefs['user_name'] ?? 'Myron'; $userTitle = $prefs['user_title'] ?? 'Mr. Blair'; // Address to use in responses $userAddr = $userTitle; // ── Tier 0.1: Name preference change ───────────────────────────────────── if (!$reply) { $lc = strtolower($message); // Patterns: "call me X", "refer to me as X", "address me as X", "my name is X", // "don't call me X", "stop calling me X", "just call me X" if (preg_match( '/(?:(?:please\s+)?(?:just\s+)?(?:call|refer\s+to|address)\s+me\s+(?:as\s+)?|my\s+name\s+is\s+|i(?:\s+prefer|\s+go\s+by|\s+want\s+to\s+be\s+called)\s+)([A-Za-z][\w\s\-\'\.]{0,29})/i', $message, $m )) { $newName = trim(preg_replace('/\s+/', ' ', $m[1])); // Strip trailing punctuation $newName = rtrim($newName, '.!?,;'); if (strlen($newName) >= 2 && strlen($newName) <= 30) { JarvisDB::execute( "INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?) ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)", [$newName] ); $userTitle = $newName; $userAddr = $newName; $reply = "Understood. I'll address you as {$newName} from now on."; $source = 'intent:name_pref'; } } elseif (preg_match( '/(?:don\'?t|stop|no\s+(?:more|longer))\s+call(?:ing)?\s+me\s+([A-Za-z][\w\s\-\'\.]{0,29})/i', $message, $m )) { // "don't call me {$userAddr}" — switch to first name JarvisDB::execute( "INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?) ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)", [$userName] ); $userTitle = $userName; $userAddr = $userName; $reply = "Of course. I'll call you {$userName} going forward."; $source = 'intent:name_pref'; } } // ── Tier 0: Home Assistant Control ─────────────────────────────────────── // Uses entity_map stored by facts_collector to resolve natural language → entity $haEntityMapRow = JarvisDB::query( 'SELECT fact_value FROM kb_facts WHERE category=? AND fact_key=? AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY updated_at DESC LIMIT 1', ['ha', 'entity_map'] ); $haEntityMap = ($haEntityMapRow && !empty($haEntityMapRow[0]['fact_value'])) ? (json_decode($haEntityMapRow[0]['fact_value'], true) ?? []) : []; // Scene keywords for one-shot activations (no on/off needed) $sceneKeywords = [ 'good night' => 'scene.good_night', 'goodnight' => 'scene.good_night', 'good morning' => 'scene.good_morning', 'goodmorning' => 'scene.good_morning', 'goodbye' => 'scene.goodbye', 'bye' => 'scene.goodbye', 'kitchen lights on' => 'scene.kitchen_lights_on', 'kitchen on' => 'scene.kitchen_lights_on', 'kitchen lights off' => 'scene.kitchen_lights_off', 'kitchen off' => 'scene.kitchen_lights_off', 'front porch lights' => 'scene.outdoors_front_porch_lights', 'porch scene' => 'scene.outdoors_front_porch_lights', 'office dawn' => 'scene.office_ocean_dawn', 'good morning work' => 'scene.good_morning_work', 'morning work' => 'scene.good_morning_work', 'work morning' => 'scene.good_morning_work', 'master bedroom scene' => 'scene.master_bedroom_new_scene', 'bedroom scene' => 'scene.master_bedroom_new_scene', 'ceiling fan scene' => 'scene.master_bedroom_new_scene', 'porch lights on' => 'scene.outdoors_front_porch_lights', 'porch lights' => 'scene.outdoors_front_porch_lights', ]; $msgLower = strtolower(trim($message)); // Check for scene activation first $sceneId = null; foreach ($sceneKeywords as $kw => $sid) { if (strpos($msgLower, $kw) !== false) { $sceneId = $sid; break; } } if ($sceneId) { $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; $ch = curl_init($haUrl . '/api/services/scene/turn_on'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['entity_id' => $sceneId]), CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'], CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3, ]); $haResp = curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($haCode === 200) { $sceneName = ucwords(str_replace(['scene.', '_'], ['', ' '], $sceneId)); $reply = "Activating {$sceneName}, {$userAddr}."; $source = 'ha:scene'; } } // Check for device on/off control if (!$reply && preg_match('/(turn|switch|put|set)\s+(on|off)/i', $message, $actionMatch) || (!$reply && preg_match('/(lights?|lamps?|plugs?|strips?)\s+(on|off)/i', $message, $actionMatch))) { $turnOn = (bool) preg_match('/\bon\b/i', $message); $turnOff = (bool) preg_match('/\boff\b/i', $message); $haService = ($turnOn && !$turnOff) ? 'turn_on' : ($turnOff ? 'turn_off' : null); if ($haService && !empty($haEntityMap)) { // Find best matching entity $bestEid = null; $bestScore = 0; $bestName = ''; // Special: "all lights" / "everything" if (preg_match('/(all|everything|every)/i', $message)) { $bestEid = '__all_lights__'; $bestName = 'All lights'; $bestScore = 1; } if (!$bestEid) { // Detect preferred domain from message $preferLight = (bool) preg_match('/\blight(s)?\b/i', $message); $preferSwitch = (bool) preg_match('/\b(switch|plug|outlet|strip)\b/i', $message); // Strip control words — keep device/room name $searchMsg = preg_replace('/\b(turn|switch|put|set|the|my|all|please|jarvis|on|off|lights?|lamps?|plugs?|strips?)\b/i', ' ', $msgLower); $searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg)); // Empty search means generic light command (e.g. "turn on the lights") — all lights if ($searchMsg === '' && $preferLight) { $bestEid = '__all_lights__'; $bestName = 'All lights'; $bestScore = 1; } foreach ($haEntityMap as $eid => $info) { if (($info['state'] ?? '') === 'unavailable') continue; $nameLower = strtolower($info['name']); $domain = $info['domain'] ?? ''; $score = 0; // Exact name match = highest priority if ($nameLower === $searchMsg) { $score = 100; // Search is substring of name } elseif ($searchMsg && strpos($nameLower, $searchMsg) !== false) { $score = 40; // Name is substring of search (e.g. search="living room light", name="Living Room") } elseif ($searchMsg && strpos($searchMsg, $nameLower) !== false) { $score = 30; } else { // Word overlap scoring $words = array_filter(explode(' ', $searchMsg)); foreach ($words as $w) { if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score += 8; } } if ($score <= 0) continue; // Domain preference bonus if ($preferLight && $domain === 'light') $score += 20; if ($preferSwitch && $domain === 'switch') $score += 20; // Penalty for clearly wrong domain when user specified one if ($preferLight && in_array($domain, ['media_player','sensor','climate'])) $score -= 15; if ($score > $bestScore) { $bestScore = $score; $bestEid = $eid; $bestName = $info['name']; } } } if ($bestEid && $bestScore > 0) { $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; if ($bestEid === '__all_lights__') { // Turn all lights on/off via domain targeting $lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light')); $payload = ['entity_id' => $lightIds]; $svcDomain = 'light'; } else { $domain = $haEntityMap[$bestEid]['domain'] ?? 'switch'; $payload = ['entity_id' => $bestEid]; $svcDomain = $domain; } $ch = curl_init($haUrl . '/api/services/' . $svcDomain . '/' . $haService); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'], CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3, ]); $haResp = curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($haCode === 200) { $action = ($haService === 'turn_on') ? 'activated' : 'deactivated'; $label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName; $reply = "{$label} {$action}, {$userAddr}."; $source = 'ha:' . $haService; } } } } // Status query for HA entities if (!$reply && preg_match('/(is|are|what.s|status|state).*(on|off|light|switch|plug|strip|mower|garage|living|kitchen|office|bedroom|porch|carport|driveway)/i', $message) && !preg_match('/(turn|switch|put)/i', $message)) { // Status query - find the entity and report its state $searchMsg = preg_replace('/\b(is|are|the|my|status|what|state|of|jarvis)\b/i', ' ', $msgLower); $searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg)); $bestEid = null; $bestScore = 0; $bestName = ''; $bestState = ''; foreach ($haEntityMap as $eid => $info) { $nameLower = strtolower($info['name']); $words = array_filter(explode(' ', $searchMsg)); $score = 0; foreach ($words as $w) { if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++; } if ($score > $bestScore) { $bestScore = $score; $bestEid = $eid; $bestName = $info['name']; $bestState = $info['state']; } } if ($bestEid && $bestScore > 0) { $reply = "The {$bestName} is currently {$bestState}, {$userAddr}."; $source = 'ha:status'; } } // ── Tier 0a: Brightness control ────────────────────────────────────────── if (!$reply && preg_match('/\b(dim|brighten|brightness|set.*to\s+\d+\s*%|percent)\b/i', $message) && !empty($haEntityMap)) { $pctMatch = null; if (preg_match('/(\d+)\s*%/', $message, $pm)) { $pctMatch = max(1, min(100, (int)$pm[1])); } elseif (preg_match('/\b(full|max|maximum|hundred)\b/i', $message)) { $pctMatch = 100; } elseif (preg_match('/\b(half|fifty)\b/i', $message)) { $pctMatch = 50; } elseif (preg_match('/\b(low|dim|minimum|min|quarter)\b/i', $message)) { $pctMatch = 20; } if ($pctMatch !== null) { $searchMsg = preg_replace('/\b(dim|brighten|brightness|set|the|my|to|at|percent|%|\d+|jarvis|light|lights?)\b/i', ' ', $msgLower); $searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg)); $bestEid = null; $bestScore = 0; $bestName = ''; foreach ($haEntityMap as $eid => $info) { if ($info['domain'] !== 'light') continue; if (($info['state'] ?? '') === 'unavailable') continue; $nameLower = strtolower($info['name']); $score = 0; if ($searchMsg === '') { $bestEid = '__all_lights__'; $bestName = 'All lights'; $bestScore = 1; break; } $words = array_filter(explode(' ', $searchMsg)); foreach ($words as $w) { if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score += 10; } if ($score > $bestScore) { $bestScore = $score; $bestEid = $eid; $bestName = $info['name']; } } if ($bestEid && $bestScore >= 0) { $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; $brightness = (int)round($pctMatch * 2.55); if ($bestEid === '__all_lights__') { $lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light')); $payload = ['entity_id' => $lightIds, 'brightness' => $brightness]; } else { $payload = ['entity_id' => $bestEid, 'brightness' => $brightness]; } $ch = curl_init($haUrl . '/api/services/light/turn_on'); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>json_encode($payload), CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'], CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($haCode === 200) { $label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName; $reply = "{$label} set to {$pctMatch}%, {$userAddr}."; $source = 'ha:brightness'; } } } } // ── Tier 0b: Media player control ──────────────────────────────────────── if (!$reply && preg_match('/\b(apple tv|appletv|pioneer|receiver|tv|television)\b/i', $message) && preg_match('/\b(turn|switch|power|on|off|pause|play|resume|stop|mute)\b/i', $message) && !empty($haEntityMap)) { $mediaTurnOn = (bool) preg_match('/\b(on|play|resume|start)\b/i', $message); $mediaTurnOff = (bool) preg_match('/\b(off|stop|shutdown|power off)\b/i', $message); $mediaPause = (bool) preg_match('/\b(pause|mute)\b/i', $message); $isAppleTV = (bool) preg_match('/\b(apple.?tv|appletv)\b/i', $message); $isPioneer = (bool) preg_match('/\b(pioneer|receiver)\b/i', $message); $isTv = (bool) preg_match('/\b(tv|television)\b/i', $message); $targetEid = null; $targetName = ''; if ($isAppleTV) { $targetEid = 'media_player.apple_tv_4k'; $targetName = 'Apple TV 4K'; } elseif ($isPioneer) { $targetEid = 'media_player.vsx_822_2'; $targetName = 'Pioneer Receiver'; } elseif ($isTv) { $targetEid = 'media_player.apple_tv_4k'; $targetName = 'Apple TV'; } if ($targetEid) { $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; if ($mediaPause) { $svc = 'media_player/media_pause'; } elseif ($mediaTurnOff) { $svc = 'media_player/turn_off'; } else { $svc = 'media_player/turn_on'; } $ch = curl_init($haUrl . '/api/services/' . $svc); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$targetEid]), CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'], CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]); curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($haCode === 200) { if ($mediaPause) $verb = 'paused'; elseif ($mediaTurnOff) $verb = 'powered off'; else $verb = 'powered on'; $reply = "{$targetName} {$verb}, {$userAddr}."; $source = 'ha:media'; } } } // ── Tier 0c: Camera siren toggle ───────────────────────────────────────── if (!$reply && preg_match('/\b(siren|alarm|honk|sound (the )?alarm)\b/i', $message) && preg_match('/\b(camera|back yard|backyard|carport|driveway|front yard)\b/i', $message)) { $sirenOn = !preg_match('/\b(off|stop|disable|silence)\b/i', $message); $haService = $sirenOn ? 'turn_on' : 'turn_off'; $sirenMap = [ 'back yard' => ['switch.camera1_siren_on_event_2', 'Back Yard'], 'backyard' => ['switch.camera1_siren_on_event_2', 'Back Yard'], 'carport' => ['switch.camera1_siren_on_event_3', 'Carport'], 'front yard' => ['switch.front_yard_siren_on_event', 'Front Yard'], 'driveway' => ['switch.down_hill_siren_on_event', 'Driveway'], ]; $targetSiren = null; $targetLabel = 'Camera'; foreach ($sirenMap as $kw => [$eid, $lbl]) { if (strpos($msgLower, $kw) !== false) { $targetSiren = $eid; $targetLabel = $lbl; break; } } if (!$targetSiren) { $targetSiren = 'switch.camera1_siren_on_event_2'; $targetLabel = 'Back Yard'; } $haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123'; $haToken = defined('HA_TOKEN') ? HA_TOKEN : ''; $ch = curl_init($haUrl . '/api/services/switch/' . $haService); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$targetSiren]), CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$haToken,'Content-Type: application/json'], CURLOPT_TIMEOUT=>8, CURLOPT_CONNECTTIMEOUT=>3]); curl_exec($ch); $haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($haCode === 200) { $verb = $sirenOn ? 'activated' : 'silenced'; $reply = "{$targetLabel} camera siren {$verb}, {$userAddr}."; $source = 'ha:siren'; } } // ── Email + Planner voice intents (action items, create task/appt from email) ─ if (!$reply && preg_match('/\b(email|emails|inbox|gmail|outlook|mail|unread|messages)\b/i', $message)) { $lc = strtolower($message); // ── Action items from email ─────────────────────────────────────────────── if (preg_match('/\b(action item|action required|need.*attention|follow.?up|things to do.*email|email.*task)\b/i', $message)) { $rows = JarvisDB::query( "SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 5" ) ?? []; if (!$rows) { $reply = "No email action items pending, {$userAddr}."; } else { $items = array_map(fn($r) => ucfirst($r['action_type']) . ': "' . mb_substr($r['subject'], 0, 60) . '" from ' . ($r['from_name'] ?: $r['from_email']), $rows ); $reply = count($rows) . " email action item" . (count($rows)>1?'s':'') . " need attention, {$userAddr}: " . implode('. ', $items) . '.'; } $source = 'email:action_items'; // ── Create task from most recent action email ───────────────────────────── } elseif (preg_match('/\b(create task|add task|make.*task|task.*from.*email)\b/i', $message)) { $fromMatch = null; if (preg_match('/\bfrom\s+([a-z][\w\s\.]+)/i', $message, $fm)) $fromMatch = trim($fm[1]); $where = "WHERE dismissed=0 AND task_id IS NULL AND action_type IN ('task','appointment')"; $params = []; if ($fromMatch) { $where .= ' AND (from_name LIKE ? OR from_email LIKE ?)'; $params[] = "%{$fromMatch}%"; $params[] = "%{$fromMatch}%"; } $ea = JarvisDB::single("SELECT * FROM email_actions {$where} ORDER BY received_at DESC LIMIT 1", $params); if ($ea) { $title = mb_substr($ea['subject'], 0, 255); $notes = "From: {$ea['from_name']} <{$ea['from_email']}>"; $taskId = JarvisDB::insert('INSERT INTO tasks (title,notes,category,priority) VALUES (?,?,?,?)', [$title, $notes, 'work', 'normal']); JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?', [$taskId, $ea['id']]); $reply = "Task created from email: \"{$title}\", {$userAddr}."; $source = 'email:create_task'; } else { $reply = "No action-required emails found to create a task from, {$userAddr}."; $source = 'email:create_task_none'; } // ── Create appointment from meeting email ───────────────────────────────── } elseif (preg_match('/\b(schedule|appointment|meeting|calendar).*\bemail\b|\bemail\b.*(schedule|appointment|meeting|calendar)\b/i', $message)) { $ea = JarvisDB::single( "SELECT * FROM email_actions WHERE dismissed=0 AND appointment_id IS NULL AND action_type='appointment' ORDER BY received_at DESC LIMIT 1" ); if ($ea) { $start = $ea['suggested_date'] ? $ea['suggested_date'] . ' 09:00:00' : date('Y-m-d') . ' 09:00:00'; $title = mb_substr($ea['subject'], 0, 255); $apptId = JarvisDB::insert('INSERT INTO appointments (title,description,category,start_at) VALUES (?,?,?,?)', [$title, "From: {$ea['from_name']}", 'work', $start]); JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?', [$apptId, $ea['id']]); $reply = "Appointment scheduled from email: \"{$title}\" on " . date('l, M j', strtotime($start)) . ", {$userAddr}."; $source = 'email:create_appt'; } else { $reply = "No meeting emails found to schedule, {$userAddr}."; $source = 'email:create_appt_none'; } // ── Unread count ────────────────────────────────────────────────────────── } elseif (preg_match('/\b(how many|any|check|count|unread)\b/i', $message) && !preg_match('/\bread\b/i', $message)) { $emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email'; $account = 'all'; if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail'; if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook'; if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud'; $ch = curl_init($emailUrl . '?account=' . $account); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>['X-Session-Token: '.($_SESSION['jarvis_token']??'')], CURLOPT_TIMEOUT=>20, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>false]); $emailJson = curl_exec($ch); $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($emailCode === 200 && $emailJson) { $ed = json_decode($emailJson, true) ?? []; $unread = (int)($ed['summary']['total_unread'] ?? 0); $aiCount = (int)($ed['action_items_count'] ?? 0); $parts = []; foreach ($ed['accounts'] ?? [] as $a => $r) { if (!empty($r['unread'])) $parts[] = $r['unread'] . ' on ' . ucfirst($a); } if ($unread === 0) { $reply = "No unread emails, {$userAddr}."; } else { $bd = $parts ? ' (' . implode(', ', $parts) . ')' : ''; $reply = "You have {$unread} unread email" . ($unread>1?'s':'') . "{$bd}, {$userAddr}."; } if ($aiCount > 0) $reply .= " {$aiCount} require action."; $source = 'email:count'; } // ── Read recent emails ──────────────────────────────────────────────────── } else { $emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email'; $account = 'all'; if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail'; if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook'; if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud'; $ch = curl_init($emailUrl . '?account=' . $account); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>['X-Session-Token: '.($_SESSION['jarvis_token']??'')], CURLOPT_TIMEOUT=>20, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>false]); $emailJson = curl_exec($ch); $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($emailCode === 200 && $emailJson) { $ed = json_decode($emailJson, true) ?? []; $recent = $ed['summary']['recent'] ?? []; $unread = (int)($ed['summary']['total_unread'] ?? 0); $aiCount = (int)($ed['action_items_count'] ?? 0); // filter by sender if mentioned if (preg_match('/\bfrom\s+(.+)/i', $message, $fm)) { $sender = strtolower(trim($fm[1])); $recent = array_values(array_filter($recent, fn($m) => stripos($m['from_name']??'', $sender)!==false || stripos($m['from_email']??'', $sender)!==false)); } $toRead = array_filter($recent, fn($m) => $m['unread']) ?: $recent; $toRead = array_slice(array_values($toRead), 0, 3); if (empty($toRead)) { $reply = "No emails to report, {$userAddr}."; } else { $lines = []; foreach ($toRead as $m) { $acct = isset($m['account']) ? ' [' . strtoupper($m['account']) . ']' : ''; $lines[] = "From {$m['from_name']}: \"{$m['subject']}\", {$m['date']}{$acct}."; } $intro = $unread > 0 ? "You have {$unread} unread. " : ""; $reply = $intro . implode(' ', $lines); if ($aiCount > 0) $reply .= " {$aiCount} require action — say 'email action items' for details."; } $source = 'email:read'; } } } // ── Tier 0.5: Network Device Management ────────────────────────────────── if (!$reply) { // Flow state stored in kb_facts (session_write_close() is called before this runs) $flowKey = substr(session_id() ?: 'anon', 0, 32); // Expire stale chat flows older than 10 minutes JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)"); $flowRows = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='chat_flow' AND fact_key=? LIMIT 1", [$flowKey]); $devState = !empty($flowRows) ? json_decode($flowRows[0]['fact_value'], true) : null; // Continue an active multi-step add-device flow if ($devState && isset($devState['step'])) { switch ($devState['step']) { case 'waiting_ip': if (preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $m)) { $newState = array_merge($devState ?? [], ['ip' => $m[1], 'step' => 'waiting_name']); JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]); $reply = "Got it — {$m[1]}. What should I call this device?"; } else { $reply = "Please give me a valid IP address such as 192.168.1.100."; } $source = 'device:flow'; break; case 'waiting_name': $cleanName = preg_replace('/[^a-zA-Z0-9 \-_()\.]/u', '', trim($message)); $newState = array_merge($devState ?? [], ['name' => $cleanName, 'step' => 'waiting_type']); JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]); $reply = "Understood — '{$cleanName}'. What type of device is it? Options: server, camera, printer, router, phone, IoT, or say skip."; $source = 'device:flow'; break; case 'waiting_type': $rawType = strtolower(trim($message)); $allowedTypes = ['server','camera','printer','router','phone','iot','voip','switch','access point','unknown']; $devType = in_array($rawType, $allowedTypes) ? $rawType : ($rawType === 'skip' ? 'unknown' : 'unknown'); $devIp = $devState['ip'] ?? ''; $devName = $devState['name'] ?? ''; JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]); if ($devIp && $devName) { JarvisDB::execute( "INSERT INTO network_devices (ip, alias, device_type, status, last_seen) VALUES (?,?,?,'unknown',NOW()) ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)", [$devIp, $devName, $devType] ); $reply = "Device '{$devName}' at {$devIp} has been added to network monitoring as a {$devType}. It will appear in the Network Status panel and I'll begin tracking its status."; } else { $reply = "Something went wrong with the device registration. Please try again."; } $source = 'device:added'; break; default: JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]); } } // Start a new add-device flow or handle inline add if (!$reply && preg_match('/\b(add|create|register|monitor)\s+(a\s+)?(new\s+)?(device|network device|server|node|host)\b/i', $message)) { $hasIp = preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $ipM); $hasName = preg_match('/\b(?:called|named|as)\s+["\']?([^"\']+?)["\']?(?:\s+type\b|\s*$)/i', $message, $nameM); $hasType = preg_match('/\btype\s+(\w[\w ]*)/i', $message, $typeM); if ($hasIp && $hasName) { $devIp = $ipM[1]; $devName = trim($nameM[1]); $devType = $hasType ? trim($typeM[1]) : 'unknown'; JarvisDB::execute( "INSERT INTO network_devices (ip, alias, device_type, status, last_seen) VALUES (?,?,?,'unknown',NOW()) ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)", [$devIp, $devName, $devType] ); $reply = "Device '{$devName}' at {$devIp} has been added to network monitoring."; $source = 'device:added'; } elseif ($hasIp) { JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_name','ip'=>$ipM[1]])]); $reply = "I found the address {$ipM[1]}. What should I call this device?"; $source = 'device:flow'; } else { JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_ip'])]); $reply = "I'll add a new device to network monitoring. What's its IP address?"; $source = 'device:flow'; } } // Remove / delete device if (!$reply && preg_match('/\b(remove|delete|stop monitoring|unmonitor)\s+(?:device\s+)?(.+)/i', $message, $dm)) { $target = trim($dm[2]); $isIp = preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $target); $rows = $isIp ? JarvisDB::query('SELECT ip, alias FROM network_devices WHERE ip=?', [$target]) : JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']); if (count($rows)) { $dev = $rows[0]; JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$dev['ip']]); $label = $dev['alias'] ?? $dev['ip']; $reply = "Device '{$label}' at {$dev['ip']} has been removed from network monitoring."; $source = 'device:removed'; } } // Update device name or type if (!$reply && preg_match('/\b(rename|update|change)\s+(?:device\s+)?(.+?)\s+to\s+(.+)/i', $message, $um)) { $target = trim($um[2]); $newVal = trim($um[3]); $rows = JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']); if (count($rows)) { $dev = $rows[0]; JarvisDB::execute('UPDATE network_devices SET alias=? WHERE ip=?', [$newVal, $dev['ip']]); $reply = "Device at {$dev['ip']} has been renamed to '{$newVal}'."; $source = 'device:updated'; } } // List devices if (!$reply && preg_match('/\b(list|show|what are|tell me|display)\s+(?:the\s+|my\s+|all\s+)?(?:network\s+)?(device|devices|server|servers|node|nodes|host|hosts|monitored)\b/i', $message)) { $rows = JarvisDB::query( "SELECT alias, ip, device_type, status FROM network_devices WHERE alias IS NOT NULL ORDER BY alias" ); if (count($rows)) { $items = array_map(fn($r) => ($r['alias'] ?? $r['ip']) . ' at ' . $r['ip'] . ' — ' . ($r['status'] ?? 'unknown'), $rows ); $reply = "Monitored devices: " . implode('; ', $items) . '.'; } else { $reply = "No named devices in network monitoring yet. Say 'add a device' to get started."; } $source = 'device:list'; } } // ── Tier 0.7: Planner — full voice intent coverage ──────────────────────── if (!$reply) { $lc = strtolower($message); $today = date('Y-m-d'); // ── Daily briefing ──────────────────────────────────────────────────── if (!$reply && preg_match('/\b(briefing|daily summary|my day|schedule today|what.*today|morning|good morning|what do i have|what.?s on)\b/i', $message) && !preg_match('/\b(weather|news|temperature|forecast)\b/i', $message)) { $tasks_today = JarvisDB::query("SELECT title,priority FROM tasks WHERE due_date=? AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low')", [$today]) ?? []; $tasks_overdue = JarvisDB::query("SELECT COUNT(*) cnt FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled')", [$today]); $appts_today = JarvisDB::query("SELECT title,start_at FROM appointments WHERE DATE(start_at)=? ORDER BY start_at ASC", [$today]) ?? []; $email_actions = JarvisDB::single("SELECT COUNT(*) cnt FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL"); $parts = []; if ($appts_today) { $ap = array_map(fn($a) => $a['title'] . ' at ' . date('g:i A', strtotime($a['start_at'])), $appts_today); $parts[] = count($appts_today) . ' appointment' . (count($appts_today) > 1 ? 's' : '') . ': ' . implode(', ', $ap); } if ($tasks_today) { $tl = array_map(fn($t) => $t['title'], $tasks_today); $parts[] = count($tasks_today) . ' task' . (count($tasks_today) > 1 ? 's' : '') . ' due today: ' . implode(', ', $tl); } $ov = (int)($tasks_overdue['cnt'] ?? 0); if ($ov > 0) $parts[] = $ov . ' overdue task' . ($ov > 1 ? 's' : '') . ' need attention'; $ai = (int)($email_actions['cnt'] ?? 0); if ($ai > 0) $parts[] = $ai . ' email' . ($ai > 1 ? 's' : '') . ' require action'; $reply = $parts ? "Good morning, {$userAddr}. " . implode('. ', $parts) . '.' : "Good morning, {$userAddr}. Your schedule is clear — no tasks, appointments, or email actions pending today."; $source = 'planner:briefing'; } // ── Overdue tasks ───────────────────────────────────────────────────── if (!$reply && preg_match('/\b(overdue|past due|late|missed.*task|task.*overdue)\b/i', $message)) { $rows = JarvisDB::query("SELECT title, due_date FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC LIMIT 6", [$today]) ?? []; if (!$rows) { $reply = "No overdue tasks, {$userAddr}. You're all caught up."; } else { $items = array_map(fn($r) => $r['title'] . ' (was due ' . date('M j', strtotime($r['due_date'])) . ')', $rows); $reply = count($rows) . " overdue task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; } $source = 'planner:overdue'; } // ── Urgent / high priority tasks ────────────────────────────────────── if (!$reply && preg_match('/\b(urgent|high priority|important|critical|asap)\b.*\btask|\btask.*\b(urgent|high priority|important|critical)\b/i', $message)) { $rows = JarvisDB::query("SELECT title, priority, due_date FROM tasks WHERE priority IN ('urgent','high') AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high'), due_date ASC LIMIT 6") ?? []; if (!$rows) { $reply = "No urgent or high priority tasks at the moment, {$userAddr}."; } else { $items = array_map(fn($r) => '[' . strtoupper($r['priority']) . '] ' . $r['title'], $rows); $reply = count($rows) . " priority task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; } $source = 'planner:priority_tasks'; } // ── Tasks due this week ─────────────────────────────────────────────── if (!$reply && preg_match('/\b(this week|week.?s tasks|due this week|tasks.*week)\b/i', $message)) { $endOfWeek = date('Y-m-d', strtotime('sunday this week')); $rows = JarvisDB::query("SELECT title, due_date, priority FROM tasks WHERE due_date BETWEEN ? AND ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC, FIELD(priority,'urgent','high','normal','low') LIMIT 8", [$today, $endOfWeek]) ?? []; if (!$rows) { $reply = "Nothing due this week, {$userAddr}."; } else { $items = array_map(fn($r) => $r['title'] . ' (' . date('D', strtotime($r['due_date'])) . ')', $rows); $reply = count($rows) . " task" . (count($rows) > 1 ? 's' : '') . " due this week, {$userAddr}: " . implode('; ', $items) . '.'; } $source = 'planner:week_tasks'; } // ── Work tasks ──────────────────────────────────────────────────────── if (!$reply && preg_match('/\b(work tasks|work.*todo|office tasks|work related)\b/i', $message)) { $rows = JarvisDB::query("SELECT title, priority, due_date FROM tasks WHERE category='work' AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low'), due_date ASC LIMIT 6") ?? []; if (!$rows) { $reply = "No work tasks pending, {$userAddr}."; } else { $items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $rows); $reply = count($rows) . " work task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; } $source = 'planner:work_tasks'; } // ── Add task ────────────────────────────────────────────────────────── if (!$reply && preg_match('/\b(add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task|put.*task|add.*to.*list)\b/i', $message)) { // Strip trigger at START only (non-greedy anchor prevents stripping mid-phrase words) $title = preg_replace('/^\s*(?:jarvis\s+)?(?:add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task|put\s+\w+\s+on\s+(?:my\s+)?(?:task\s+)?list|add\s+(?:this\s+)?to\s+(?:my\s+)?list)\s*/i', '', $message); $title = trim($title, '. '); $dueDate = null; // Extract date — "by Friday", "on Monday", "due tomorrow", OR bare date at end of phrase $datePattern = '((?:next\s+\w+|this\s+(?:monday|tuesday|wednesday|thursday|friday)|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)(?:\s+at\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?)'; if (preg_match('/\s+(?:by|on|due|before|this|next)\s+' . $datePattern . '\s*$/i', $title, $dm)) { $ts = strtotime(trim($dm[1])); if ($ts !== false && $ts > time() - 86400) { $dueDate = date('Y-m-d', $ts); $title = trim(preg_replace('/\s+(?:by|on|due|before)\s+.*$/i', '', $title)); } } elseif (preg_match('/\s+' . $datePattern . '\s*$/i', $title, $dm)) { // Bare date at end: "call dentist tomorrow", "pay bills Monday" $ts = strtotime(trim($dm[1])); if ($ts !== false && $ts > time() - 86400 && $ts < time() + 365*86400) { $dueDate = date('Y-m-d', $ts); $title = trim(substr($title, 0, strrpos($title, $dm[0]))); } } $category = preg_match('/\b(work|meeting|project|client|office|report|email)\b/i', $title) ? 'work' : 'personal'; $priority = 'normal'; if (preg_match('/\b(urgent|asap|emergency|critical)\b/i', $title)) $priority = 'urgent'; elseif (preg_match('/\b(important|high priority)\b/i', $title)) $priority = 'high'; $title = trim($title); if ($title) { JarvisDB::execute('INSERT INTO tasks (title,category,priority,due_date) VALUES (?,?,?,?)', [$title, $category, $priority, $dueDate]); $duePart = $dueDate ? ', due ' . date('l, M j', strtotime($dueDate)) : ''; $reply = "Task added: \"{$title}\"{$duePart}, {$userAddr}."; $source = 'planner:task_add'; } } // ── List tasks ──────────────────────────────────────────────────────── if (!$reply && preg_match('/\b(my tasks|todo list|to.?do|pending tasks|what.*tasks|show.*tasks|task list|how many tasks|task count)\b/i', $message)) { $rows = JarvisDB::query("SELECT title,priority,due_date FROM tasks WHERE status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low'), due_date ASC LIMIT 8") ?? []; if (!$rows) { $reply = "Your task list is clear, {$userAddr}. Nothing pending."; } else { $items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $rows); $reply = "You have " . count($rows) . " pending task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; } $source = 'planner:task_list'; } // ── Mark task done ──────────────────────────────────────────────────── if (!$reply && preg_match('/\b(mark|complete|finished|done with|completed|check off|close)\b.{1,40}\btask\b|\btask\b.{1,20}\b(done|complete|finished)\b/i', $message)) { $search = preg_replace('/\b(mark|complete|finished|done|completed|task|as|the|check|off|close|with)\b/i', ' ', $message); $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]); if ($row) { JarvisDB::execute("UPDATE tasks SET status='done', completed_at=NOW() WHERE id=?", [$row['id']]); $reply = "Marked \"{$row['title']}\" as complete, {$userAddr}."; $source = 'planner:task_done'; } } // ── Delete / cancel task ────────────────────────────────────────────── if (!$reply && preg_match('/\b(delete task|remove task|cancel task|drop task)\b/i', $message)) { $search = preg_replace('/\b(delete|remove|cancel|drop|task)\b/i', ' ', $message); $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]); if ($row) { JarvisDB::execute("UPDATE tasks SET status='cancelled' WHERE id=?", [$row['id']]); $reply = "Task \"{$row['title']}\" cancelled, {$userAddr}."; $source = 'planner:task_cancel'; } } // ── Reschedule / move task ──────────────────────────────────────────── if (!$reply && preg_match('/\b(reschedule|move|push|change due|update due)\b.*\btask\b/i', $message)) { if (preg_match('/\bto\s+(.+)$/i', $message, $dm)) { $ts = strtotime($dm[1]); if ($ts !== false) { $newDate = date('Y-m-d', $ts); $search = preg_replace('/\b(reschedule|move|push|change|update|due|task|to\s+.+)$/i', ' ', $message); $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]); if ($row) { JarvisDB::execute("UPDATE tasks SET due_date=? WHERE id=?", [$newDate, $row['id']]); $reply = "Moved \"{$row['title']}\" to " . date('l, M j', $ts) . ", {$userAddr}."; $source = 'planner:task_reschedule'; } } } } // ── Next appointment ────────────────────────────────────────────────── if (!$reply && preg_match('/\b(next appointment|next meeting|next event|when.*next|upcoming appointment)\b/i', $message)) { $row = JarvisDB::single("SELECT title, start_at, location FROM appointments WHERE start_at > NOW() ORDER BY start_at ASC LIMIT 1"); if ($row) { $when = date('l, M j \a\t g:i A', strtotime($row['start_at'])); $locPart = $row['location'] ? ' at ' . $row['location'] : ''; $reply = "Your next appointment is \"{$row['title']}\"{$locPart} on {$when}, {$userAddr}."; } else { $reply = "No upcoming appointments on your calendar, {$userAddr}."; } $source = 'planner:next_appt'; } // ── Week / date range calendar view ────────────────────────────────── if (!$reply && preg_match('/\b(this week|week.*calendar|calendar.*week|appointments.*week|what.*week)\b/i', $message) && !preg_match('/\btasks\b/i', $message)) { $endOfWeek = date('Y-m-d', strtotime('sunday this week')); $rows = JarvisDB::query("SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 8", [$today, $endOfWeek]) ?? []; if (!$rows) { $reply = "Nothing on your calendar this week, {$userAddr}."; } else { $items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows); $reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . " this week, {$userAddr}: " . implode('; ', $items) . '.'; } $source = 'planner:week_calendar'; } // ── Add appointment ─────────────────────────────────────────────────── if (!$reply && preg_match('/\b(schedule|add\s+(?:an?\s+)?appointment|book\s+(?:an?\s+)?|set\s+up\s+(?:an?\s+)?meeting|add\s+(?:an?\s+)?meeting|add\s+to\s+(?:my\s+)?calendar)\b/i', $message) && !preg_match('/\b(my calendar|upcoming|list|show|what|from email)\b/i', $message)) { // Strip trigger at START only — prevents eating words like "dentist appointment" $raw = preg_replace('/^\s*(?:jarvis\s+)?(?:schedule|add\s+(?:an?\s+)?appointment|book\s+(?:a?n?\s+)?|set\s+up\s+(?:an?\s+)?meeting|add\s+(?:an?\s+)?meeting|add\s+to\s+(?:my\s+)?calendar)\s*/i', '', $message); $raw = trim($raw); // Normalize natural time words $raw = preg_replace('/\bnoon\b/i', '12:00 pm', $raw); $raw = preg_replace('/\bmidnight\b/i', '12:00 am', $raw); $raw = preg_replace('/\bmidday\b/i', '12:00 pm', $raw); // Step 1: extract time ("at 2pm", "at 2:30pm", "2pm", "14:00") $timeStr = null; if (preg_match('/\bat\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm))\b/i', $raw, $tm)) { $timeStr = $tm[1]; $raw = trim(str_ireplace($tm[0], '', $raw)); } elseif (preg_match('/\b(\d{1,2}:\d{2}\s*(?:am|pm)?)\b/i', $raw, $tm)) { $timeStr = $tm[1]; $raw = trim(str_ireplace($tm[0], '', $raw)); } // Step 2: extract date (day names, "tomorrow", "next X", month+day) $dateStr = null; if (preg_match('/\b(tomorrow|today|next\s+\w+|this\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)|monday|tuesday|wednesday|thursday|friday|saturday|sunday|(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2})\b/i', $raw, $dm)) { $dateStr = $dm[1]; $raw = trim(str_ireplace($dm[0], '', $raw)); } // Step 3: build datetime $dtParsed = null; if ($dateStr || $timeStr) { $dtInput = trim(($dateStr ?: 'today') . ' ' . ($timeStr ?: '09:00 am')); $ts = strtotime($dtInput); if ($ts !== false && $ts > time() - 3600) { $dtParsed = date('Y-m-d H:i:s', $ts); } } // Step 4: clean up title (remove leftover filler words) $title = trim(preg_replace('/\s+/', ' ', preg_replace('/\b(on|at|the|a|an)\b\s*/i', ' ', $raw))); $title = trim($title, ' .,'); if ($title && $dtParsed) { $category = preg_match('/\b(work|meeting|office|client|project|call|conference|interview)\b/i', $title) ? 'work' : 'personal'; JarvisDB::execute('INSERT INTO appointments (title,category,start_at) VALUES (?,?,?)', [$title, $category, $dtParsed]); $reply = "Scheduled: \"{$title}\" on " . date('l, M j \a\t g:i A', strtotime($dtParsed)) . ", {$userAddr}."; $source = 'planner:appt_add'; } elseif ($title) { $reply = "I can schedule that, {$userAddr} — what date and time?"; $source = 'planner:appt_need_time'; } } // ── Cancel / delete appointment ─────────────────────────────────────── if (!$reply && preg_match('/\b(cancel|delete|remove)\b.*\b(appointment|meeting|event)\b/i', $message)) { $search = preg_replace('/\b(cancel|delete|remove|appointment|meeting|event|the|my)\b/i', ' ', $message); $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; $row = JarvisDB::single("SELECT id, title FROM appointments WHERE title LIKE ? AND start_at > NOW() LIMIT 1", [$search]); if ($row) { JarvisDB::execute("DELETE FROM appointments WHERE id=?", [$row['id']]); $reply = "Appointment \"{$row['title']}\" removed from your calendar, {$userAddr}."; $source = 'planner:appt_cancel'; } } // ── View appointments / calendar ────────────────────────────────────── if (!$reply && preg_match('/\b(appointments|my calendar|my schedule|what.*scheduled|upcoming.*event|show.*calendar)\b/i', $message)) { $rows = JarvisDB::query("SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 6", [$today, date('Y-m-d', strtotime('+7 days'))]) ?? []; if (!$rows) { $reply = "No appointments in the next 7 days, {$userAddr}."; } else { $items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows); $reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; } $source = 'planner:appt_list'; } } // ── Tier 0.8: Weather & News intents ───────────────────────────────────── if (!$reply && preg_match('/\b(weather|forecast|temperature|temp|rain|snow|storm|outside|how.?s it (out|look)|what.?s it like outside)\b/i', $message)) { $wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1"); if ($wRow && !empty($wRow[0]['data'])) { $wd = json_decode($wRow[0]['data'], true); $c = $wd['current'] ?? []; $fc = $wd['forecast'] ?? []; $today = $fc[0] ?? []; $tomorrow = $fc[1] ?? []; $reply = sprintf( 'Current conditions: %s %s, %d°F (feels like %d°F), humidity %d%%, wind %d mph. ' . 'Today\'s range: %d–%d°F. ' . 'Tomorrow: %s, %d–%d°F, %d%% chance of rain.', $c['icon'] ?? '', $c['desc'] ?? '', $c['temp'] ?? 0, $c['feels'] ?? 0, $c['humidity'] ?? 0, $c['wind'] ?? 0, $today['low'] ?? 0, $today['high'] ?? 0, $tomorrow['desc'] ?? '', $tomorrow['low'] ?? 0, $tomorrow['high'] ?? 0, $tomorrow['rain_pct'] ?? 0 ); $source = 'intent:weather'; } } if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current events|whats new)\b/i', $message)) { $nRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='news' LIMIT 1"); if ($nRow && !empty($nRow[0]['data'])) { $nd = json_decode($nRow[0]['data'], true); $cats = $nd['categories'] ?? []; $lines = []; foreach ($cats as $cat => $articles) { if (!empty($articles)) { $top3 = array_slice($articles, 0, 3); foreach ($top3 as $a) { $lines[] = '[' . $a['source'] . '] ' . $a['title']; } } } if ($lines) { $reply = "Here are the latest headlines, {$userAddr}: " . implode(' — ', array_slice($lines, 0, 5)) . '.'; } else { $reply = 'News feed is still loading, Sir. Please try again in a moment.'; } $source = 'intent:news'; } } // ── Tier 0.9: Arc Protocols — research, triage, remote_exec, screenshot, sysinfo ─ $arcJobId = null; // Helper: submit job to Arc Reactor function arcSubmitJob(string $type, array $payload, string $sessionId): ?array { $ch = curl_init('http://127.0.0.1:7474/job'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode([ 'type' => $type, 'payload' => $payload, 'priority' => 7, 'created_by' => 'chat:' . $sessionId, ]), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, ]); $res = json_decode(curl_exec($ch), true); curl_close($ch); return $res; } // ── Tier 0.9a: Comms Protocol — gmail_triage detection ──────────────────── if (!$reply) { $triagePatterns = [ '/^(?:jarvis[,\s]+)?(?:check|triage|sort)\s+(?:my\s+)?(?:email|inbox|gmail|mail)/i', '/^(?:jarvis[,\s]+)?what(?:\'s|\s+is)\s+(?:urgent|important)\s+(?:in\s+my\s+)?(?:email|inbox|mail)/i', '/^(?:jarvis[,\s]+)?(?:any\s+)?(?:urgent|important)\s+(?:email|emails|messages)/i', '/^(?:jarvis[,\s]+)?email\s+(?:briefing|brief|report|summary)/i', ]; foreach ($triagePatterns as $pat) { if (preg_match($pat, $message)) { $account = preg_match('/icloud/i', $message) ? 'icloud' : 'gmail'; $maxEmails = 20; if (preg_match('/\b(\d+)\s+emails?\b/i', $message, $em)) $maxEmails = min((int)$em[1], 40); $arcRes = arcSubmitJob('gmail_triage', [ 'account' => $account, 'max_emails' => $maxEmails, 'provider' => 'claude', ], $sessionId); if (isset($arcRes['job_id'])) { $arcJobId = $arcRes['job_id']; $acctLabel = strtoupper($account); $reply = "◈ COMMS PROTOCOL ACTIVATED — Triaging your {$acctLabel} inbox (Job #{$arcJobId}). I'm fetching emails and running priority analysis now, {$userAddr}. Switch to the COMMS tab to see results."; $source = 'arc:gmail_triage'; } else { $reply = "Comms Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; $source = 'arc:offline'; } break; } } } // ── Tier 0.9b: Field Protocol — remote_exec detection ──────────────────── if (!$reply) { $remotePatterns = [ '/^(?:jarvis[,\s]+)?restart\s+(.+?)\s+on\s+(.+)/i' => ['restart_service', 1, 2], '/^(?:jarvis[,\s]+)?(?:run|execute|exec)\s+(.+?)\s+on\s+(.+)/i' => ['shell', 1, 2], '/^(?:jarvis[,\s]+)?get\s+logs?\s+(?:from\s+|for\s+)?(.+?)\s+on\s+(.+)/i' => ['get_logs', 1, 2], '/^(?:jarvis[,\s]+)?(?:ping|check)\s+agent\s+(.+)/i' => ['ping', 1, null], '/^(?:jarvis[,\s]+)?what(?:\'s|\s+is)\s+(?:running|the\s+status)\s+on\s+(.+)/i' => ['ping', 1, null], ]; foreach ($remotePatterns as $pat => $cfg) { if (preg_match($pat, $message, $m)) { [$cmdType, $cmdIdx, $agentIdx] = $cfg; $cmdArg = $agentIdx ? trim($m[$cmdIdx]) : ''; $agentArg = $agentIdx ? trim($m[$agentIdx]) : trim($m[$cmdIdx]); $cmdData = match($cmdType) { 'restart_service' => ['service' => $cmdArg], 'shell' => ['command' => $cmdArg], 'get_logs' => ['service' => $cmdArg, 'lines' => 50], default => [], }; $arcRes = arcSubmitJob('remote_exec', [ 'agent' => $agentArg, 'command_type' => $cmdType, 'command_data' => $cmdData, 'timeout' => 35, ], $sessionId); if (isset($arcRes['job_id'])) { $arcJobId = $arcRes['job_id']; $reply = "◈ FIELD PROTOCOL ACTIVATED — Dispatching **{$cmdType}** to agent **{$agentArg}** (Job #{$arcJobId}). I'll relay the result when the field station responds, {$userAddr}."; $source = 'arc:remote_exec'; } else { $reply = "Field Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; $source = 'arc:offline'; } break; } } } // ── Tier 0.9c: Vision Protocol — screenshot, sysinfo, vision detection ─────── if (!$reply) { // Screenshot patterns $screenshotMatch = null; if (preg_match('/^(?:jarvis[,\s]+)?(?:show\s+(?:me\s+)?(?:the\s+)?screen\s+(?:on|of|from)|screenshot\s+(?:of\s+)?|grab\s+(?:a\s+)?(?:screenshot|screen\s+cap)\s+(?:of\s+|from\s+)?|what(?:\'s|\s+is)\s+(?:on|showing\s+on)\s+(?:the\s+)?screen\s+(?:on|of))\s+(.+)/i', $message, $mm)) { $screenshotMatch = trim($mm[1]); $arcRes = arcSubmitJob('screenshot', ['agent' => $screenshotMatch, 'analyze' => true], $sessionId); if (isset($arcRes['job_id'])) { $arcJobId = $arcRes['job_id']; $reply = "◈ VISION PROTOCOL ACTIVATED — Capturing screen on **{$screenshotMatch}** (Job #{$arcJobId}). I'll analyze what I see and report back, {$userAddr}."; $source = 'arc:screenshot'; } else { $reply = "Vision Protocol is offline, {$userAddr}. Arc Reactor may be unavailable."; $source = 'arc:offline'; } } // Sysinfo patterns elseif (preg_match('/^(?:jarvis[,\s]+)?(?:(?:what(?:\'s|\s+is)\s+(?:the\s+)?status\s+of|check\s+(?:the\s+)?(?:health|status)\s+of|how\s+is)\s+(.+)|system\s+(?:info|status|snapshot)\s+(?:on|from|for)\s+(.+))/i', $message, $mm)) { $agentName = trim($mm[1] ?? $mm[2] ?? ''); // Only fire for explicit agent references (has a name), not generic questions if ($agentName && strlen($agentName) > 2 && !preg_match('/\b(?:weather|today|things|everything|jarvis|yourself)\b/i', $agentName)) { $arcRes = arcSubmitJob('sysinfo', ['agent' => $agentName, 'analyze' => true], $sessionId); if (isset($arcRes['job_id'])) { $arcJobId = $arcRes['job_id']; $reply = "◈ FIELD PROTOCOL — Running system health check on **{$agentName}** (Job #{$arcJobId}). Snapshot incoming, {$userAddr}."; $source = 'arc:sysinfo'; } } } } // ── Tier 0.9e: Intel Protocol — research & tool_loop detection ──────────── $intelPatterns = [ '/^(?:jarvis[,\s]+)?(?:research|investigate|deep[- ]dive|deep dive)\s+(.+)/i' => 'research', '/^(?:jarvis[,\s]+)?(?:look\s+(?:up|into)|find\s+out\s+(?:about)?)\s+(.+)/i' => 'research', '/^(?:jarvis[,\s]+)?(?:step[- ]by[- ]step|figure\s+out|analyze\s+and\s+report|work\s+through)\s+(.+)/i' => 'tool_loop', '/^(?:jarvis[,\s]+)?(?:run\s+a\s+research\s+(?:job|task)\s+on)\s+(.+)/i' => 'research', ]; if (!$reply) { foreach ($intelPatterns as $pattern => $jobType) { if (preg_match($pattern, $message, $m)) { $queryOrTask = trim($m[1]); if (strlen($queryOrTask) < 3) break; $depth = 'standard'; if (preg_match('/\b(?:quick|brief)\b/i', $message)) $depth = 'quick'; if (preg_match('/\b(?:deep|thorough|comprehensive|full)\b/i', $message)) $depth = 'deep'; $jobPayload = $jobType === 'research' ? ['query' => $queryOrTask, 'depth' => $depth, 'provider' => 'claude'] : ['task' => $queryOrTask, 'max_iterations' => 12, 'provider' => 'claude']; $arcRes = arcSubmitJob($jobType, $jobPayload, $sessionId); if (isset($arcRes['job_id'])) { $arcJobId = $arcRes['job_id']; if ($jobType === 'research') { $depthLabel = strtoupper($depth); $reply = "◈ INTEL PROTOCOL ACTIVATED — Running {$depthLabel} research on **{$queryOrTask}** (Job #{$arcJobId}). I'm searching sources, extracting content, and synthesizing a briefing now, {$userAddr}. Switch to the INTEL tab to watch live progress."; } else { $reply = "◈ IRON PROTOCOL ACTIVATED — Multi-step analysis initiated for **{$queryOrTask}** (Job #{$arcJobId}). I'll work through this systematically using available tools, {$userAddr}. Results will appear in the INTEL tab."; } $source = "arc:{$jobType}"; } else { $reply = "Intel Protocol is offline, {$userAddr}. Arc Reactor may be unavailable — I'll try to answer directly."; $source = 'arc:offline'; } break; } } } // ── Tier 1: Intent Engine (instant, no LLM) ─────────────────────────────── if (!$reply) { $matched = KBEngine::match($message); if ($matched && $matched['action'] === 'response') { $reply = $matched['reply']; $source = 'intent:' . $matched['intent']; } } // ── Tier 1b: Action Intents — handled directly, no LLM ────────────────── if (!$reply) { if (!isset($matched)) $matched = KBEngine::match($message); if ($matched && $matched['action'] === 'action') { switch ($matched['intent']) { case 'network_scan': $online = JarvisDB::single( "SELECT COUNT(*) cnt FROM network_devices WHERE status='online' AND last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)" ); $total = JarvisDB::single( "SELECT COUNT(*) cnt FROM network_devices WHERE last_seen > DATE_SUB(NOW(), INTERVAL 15 MINUTE)" ); // Queue netscan to PVE1 agent for immediate refresh $pve1 = JarvisDB::single( "SELECT agent_id FROM registered_agents WHERE ip_address='10.48.200.90' AND status='online' LIMIT 1" ); if ($pve1) { JarvisDB::execute( "INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)", [$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending'] ); } $o = (int)($online['cnt'] ?? 0); $t = (int)($total['cnt'] ?? 0); $reply = "Network status as of last scan: {$o} of {$t} devices online on 10.48.200.0/24, {$userAddr}. " . ($pve1 ? 'Fresh scan dispatched to PVE1 — the network panel will update in approximately 40 seconds.' : 'Automatic scan via PVE1 runs every 3 minutes.'); $source = 'intent:network_scan'; break; case 'alerts_show': $activeAlerts = JarvisDB::query( "SELECT title, severity, message FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 10" ); if (!$activeAlerts) { $reply = "No active alerts, {$userAddr}. All systems appear nominal."; } else { $lines = array_map(fn($a) => "[{$a['severity']}] {$a['title']}: {$a['message']}", $activeAlerts); $reply = count($activeAlerts) . " active alert" . (count($activeAlerts)>1?'s':'') . ", {$userAddr}: " . implode('; ', $lines) . '.'; } $source = 'intent:alerts_show'; break; case 'alerts_count': $alertCount = JarvisDB::single("SELECT COUNT(*) cnt FROM alerts WHERE resolved=0"); $cnt = (int)($alertCount['cnt'] ?? 0); $reply = $cnt > 0 ? "There are currently {$cnt} unresolved alert" . ($cnt>1?'s':'') . ", {$userAddr}. Say 'show alerts' for details." : "No active alerts at this time, {$userAddr}. All systems nominal."; $source = 'intent:alerts_count'; break; case 'alerts_clear': $cleared = JarvisDB::single("SELECT COUNT(*) cnt FROM alerts WHERE resolved=0"); JarvisDB::execute("UPDATE alerts SET resolved=1 WHERE resolved=0"); $cnt = (int)($cleared['cnt'] ?? 0); $reply = "Resolved {$cnt} alert" . ($cnt!==1?'s':'') . ", {$userAddr}. Alert panel cleared."; $source = 'intent:alerts_clear'; break; case 'agents_offline': $offline = JarvisDB::query( "SELECT hostname, ip_address, agent_type FROM registered_agents WHERE status='offline' ORDER BY last_seen DESC LIMIT 10" ); if (!$offline) { $reply = "All registered agents are currently online, {$userAddr}."; } else { $names = array_map(fn($a) => $a['hostname'] . ' (' . $a['ip_address'] . ')', $offline); $reply = count($offline) . " agent" . (count($offline)>1?'s are':' is') . " offline, {$userAddr}: " . implode(', ', $names) . '.'; } $source = 'intent:agents_offline'; break; case 'agents_all': $allAgents = JarvisDB::query( "SELECT hostname, ip_address, status, agent_type FROM registered_agents ORDER BY FIELD(status,'online','offline','unknown'), hostname ASC" ); if (!$allAgents) { $reply = "No registered agents found, {$userAddr}."; } else { $onlineList = array_filter($allAgents, fn($a) => $a['status'] === 'online'); $offlineList = array_filter($allAgents, fn($a) => $a['status'] !== 'online'); $reply = count($allAgents) . " registered agents — " . count($onlineList) . " online, " . count($offlineList) . " offline, {$userAddr}."; if ($onlineList) $reply .= ' Online: ' . implode(', ', array_map(fn($a) => $a['hostname'], $onlineList)) . '.'; if ($offlineList) $reply .= ' Offline: ' . implode(', ', array_map(fn($a) => $a['hostname'], $offlineList)) . '.'; } $source = 'intent:agents_all'; break; case 'agents_count': $agentStats = JarvisDB::single( "SELECT COUNT(*) total, SUM(status='online') online FROM registered_agents" ); $t = (int)($agentStats['total'] ?? 0); $o = (int)($agentStats['online'] ?? 0); $reply = "{$t} agents registered — {$o} online, " . ($t-$o) . " offline, {$userAddr}."; $source = 'intent:agents_count'; break; case 'deploy_status': $deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log'; if (file_exists($deployLog)) { $lines = array_filter(array_map('trim', array_slice(file($deployLog), -20))); $recent = array_slice(array_values($lines), -5); $last = end($recent); $reply = "Last deploy entry, {$userAddr}: " . htmlspecialchars_decode(strip_tags($last)) . '. Say "deploy log" to see the full recent history.'; } else { $reply = "Deploy log not found, {$userAddr}. Check /home/jarvis.orbishosting.com/logs/deploy.log on the server."; } $source = 'intent:deploy_status'; break; case 'deploy_force': $reply = "Manual deploy is triggered by pushing to the GitHub main branch, {$userAddr}. The webhook at jarvis.orbishosting.com/webhook.php handles it automatically within 60 seconds. To hot-fix without a push, SCP the file directly to the server."; $source = 'intent:deploy_force'; break; } } } // ── Tier 2: Ollama local LLM (fast local fallback) ─────────────────────── if (!$reply && defined('OLLAMA_HOST') && OLLAMA_HOST) { $ollamaHost = OLLAMA_HOST; $ollamaModel = defined('OLLAMA_MODEL_PRIMARY') ? OLLAMA_MODEL_PRIMARY : 'llama3.2:1b'; $timeout = defined('OLLAMA_TIMEOUT') ? OLLAMA_TIMEOUT : 45; $ollamaMessages = []; $ollamaMessages[] = ['role' => 'system', 'content' => "You are JARVIS, AI assistant for {$userName}. Address him as \"{$userAddr}\". " . 'British butler tone. Be concise — 1-3 sentences max. Today: ' . date('D M j Y g:i A') . '.']; $ollamaMessages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message]; $promptParts = []; foreach ($ollamaMessages as $msg) { $role = ucfirst($msg['role']); $promptParts[] = "{$role}: {$msg['content']}"; } $promptParts[] = 'Assistant:'; $promptStr = implode("\n\n", $promptParts); $payload = [ 'model' => $ollamaModel, 'prompt' => $promptStr, 'stream' => false, 'options' => ['temperature' => 0.7, 'num_predict' => 150], ]; $ch = curl_init($ollamaHost . '/api/generate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 200 && $resp) { $decoded = json_decode($resp, true); $text = $decoded['response'] ?? null; if ($text) { $reply = trim($text); $source = 'ollama:' . $ollamaModel; } } // Silently fall through to Groq if Ollama fails or times out } // ── Tier 3: Groq AI (cloud — fast 70B + built-in web search) ───────────── if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) { $needsSearch = (bool) preg_match( '/\b(latest|current|today|right now|live|breaking|score|who won|what happened|price|stock|market|exchange rate|news about|weather in|forecast for|recently|just now|this (week|month|year))\b/i', $message ); $groqModel = $needsSearch ? GROQ_MODEL_SEARCH : GROQ_MODEL_GENERAL; $groqMessages = [['role' => 'system', 'content' => "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} " . "(address him as \"{$userAddr}\"). Formal, efficient, British butler tone. " . 'Be concise — 2-4 sentences unless detail is explicitly requested. Today: ' . date('D M j Y g:i A T') . '.'], ]; foreach (array_slice($history, -6) as $h) { $groqMessages[] = ['role' => $h['role'], 'content' => $h['content']]; } $userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message; $groqMessages[] = ['role' => 'user', 'content' => $userMsg]; $ch = curl_init('https://api.groq.com/openai/v1/chat/completions'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode([ 'model' => $groqModel, 'messages' => $groqMessages, 'max_tokens' => 400, 'temperature' => 0.7, ]), CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . GROQ_API_KEY, 'Content-Type: application/json', ], CURLOPT_TIMEOUT => GROQ_TIMEOUT, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 200 && $resp) { $decoded = json_decode($resp, true); $text = $decoded['choices'][0]['message']['content'] ?? null; if ($text) { $reply = trim($text); $source = 'groq:' . $groqModel; } } // Silently fall through to Claude if Groq fails } // ── Tier 4: Claude API (final fallback) ────────────────────────────────── if (!$reply) { // Live context for Claude $systemContext = ''; try { $memLines = file('/proc/meminfo'); $mem = []; foreach ($memLines as $l) { if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2]; } $memPct = $mem['MemTotal'] > 0 ? round((($mem['MemTotal'] - $mem['MemAvailable']) / $mem['MemTotal']) * 100) : '?'; $sec = (int) file_get_contents('/proc/uptime'); $uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h'; $load = explode(' ', file_get_contents('/proc/loadavg')); $systemContext .= "Jarvis server (165.22.1.228 DO): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n"; } catch (Exception $e) {} $alerts = JarvisDB::query( 'SELECT title, severity FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 3' ); if ($alerts) { $systemContext .= 'Active alerts: ' . implode('; ', array_map(fn($a) => "[{$a['severity']}] {$a['title']}", $alerts)) . ".\n"; } $kbContext = KBEngine::getContextSummary(); $systemPrompt = "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} (address him as \"{$userAddr}\"). You manage his home network, servers, Proxmox VMs, websites, and Home Assistant smart home. Your personality: formal, efficient, British butler — like the AI in Iron Man. Be concise. Use technical precision. Infrastructure: - Jarvis Server: 165.22.1.228 (DigitalOcean, CyberPanel/OLS, Ubuntu 24.04) - Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b) - Proxmox Host: 10.48.200.90 (manages all VMs) - Home Assistant: 10.48.200.97:8123 - FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43 - Digital Ocean: 165.22.1.228 (tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com) - Network: 10.48.200.0/24, FortiGate firewall Live data: {$systemContext}" . ($kbContext ? "\nKnowledge base:\n{$kbContext}" : '') . " Today: " . date('l, F j Y, g:i A T') . " Respond as JARVIS. Voice readout: under 3 sentences unless detail is requested. For system status, interpret the data and give an assessment — don't just recite numbers."; $messages = []; foreach ($history as $h) { $messages[] = ['role' => $h['role'], 'content' => $h['content']]; } $messages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message]; if (!defined('CLAUDE_API_KEY') || CLAUDE_API_KEY === 'sk-ant-YOUR_KEY_HERE') { $reply = "My AI core requires a valid API key, {$userAddr}. I can still display all system dashboards and respond to local commands."; $source = 'fallback:no-key'; } else { $payload = [ 'model' => CLAUDE_MODEL, 'max_tokens' => CLAUDE_MAX_TOKENS, 'system' => $systemPrompt, 'messages' => $messages, ]; $ch = curl_init('https://api.anthropic.com/v1/messages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ 'x-api-key: ' . CLAUDE_API_KEY, 'anthropic-version: 2023-06-01', 'Content-Type: application/json', ], CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 200) { $decoded = json_decode($resp, true); $reply = $decoded['content'][0]['text'] ?? null; $source = 'claude'; } else { $err = json_decode($resp, true); $errMsg = $err['error']['message'] ?? ''; if (stripos($errMsg, 'credit') !== false || stripos($errMsg, 'balance') !== false) { $reply = "Claude is not currently connected, {$userAddr}. Local AI and intent systems remain operational."; } else { $reply = 'My AI core returned an error, Sir. Code: ' . $code . '. ' . $errMsg; } $source = 'claude:error'; } } } // ── Final fallback ───────────────────────────────────────────────────────── if (!$reply) { $reply = "My systems are processing your request, {$userAddr}. Please try again momentarily."; $source = 'fallback'; } // Save reply and learn JarvisDB::insert( 'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)', [$sessionId, 'assistant', $reply] ); KBEngine::learnFromConversation($message, $reply); echo json_encode([ 'reply' => $reply, 'source' => $source, 'session_id' => $sessionId, 'timestamp' => date('c'), 'arc_job' => $arcJobId, ]);