Files
jarvis/api/endpoints/chat.php
T
myron 9ea43c852b feat: Phase 2 — Intel Protocol + Iron Protocol
Arc Reactor v2.0:
- research handler: DDG search → async page fetch → trafilatura extraction → Claude synthesis
- tool_loop handler: multi-step agent loop (up to 200 iter) with web_search, fetch_url, jarvis_agents, jarvis_alerts, current_time tools
- llm handler: multi-provider router (Claude/Groq/Ollama)
- /jobs/recent endpoint for HUD polling
- Phase 1 handlers preserved (ping/echo/shell)

chat.php — Tier 0.9 Intel Protocol (before KB intent engine):
- Detects: research/investigate/deep-dive/look up/find out about → research job
- Detects: step-by-step/figure out/analyze and report → tool_loop job
- Returns arc_job ID in response for UI polling
- Depth modifiers: quick/standard/deep

index.html:
- INTEL tab in right panel tab bar
- Research result cards with expand/collapse, synthesis, sources, status
- Live polling (4s) when INTEL tab is active + active jobs present
- Auto-switches to INTEL tab when research is triggered from chat
- intelPrompt() pre-fills chat input for new research

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 04:16:29 +00:00

1480 lines
78 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// JARVIS Chat — Intent Engine → Ollama → Claude fallback chain
if ($method !== 'POST') {
echo json_encode(['error' => '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: Intel Protocol — research & tool_loop detection ────────────
$arcJobId = null;
// Detect "research X", "look up X", "deep dive X", "investigate X", "find out about X"
$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'];
// Submit to Arc Reactor
$arcCh = curl_init('http://127.0.0.1:7474/job');
curl_setopt_array($arcCh, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'type' => $jobType,
'payload' => $jobPayload,
'priority' => 7,
'created_by' => 'chat:' . $sessionId,
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$arcRes = json_decode(curl_exec($arcCh), true);
curl_close($arcCh);
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,
]);