Files
jarvis/api/endpoints/chat.php
T

1028 lines
50 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=? 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',
];
$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) {
// Build search terms from message (remove control words with word boundaries)
$searchMsg = preg_replace('/\b(turn|switch|put|set|the|my|all|please|jarvis|on|off|lights?|lamps?)\b/i', ' ', $msgLower);
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
foreach ($haEntityMap as $eid => $info) {
$nameLower = strtolower($info['name']);
// Score: exact substring match = 10, word overlap = words matched
if ($searchMsg && strpos($nameLower, $searchMsg) !== false) {
$score = 10;
} else {
$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'];
}
}
}
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';
}
}
// ── 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 — tasks & appointments ──────────────────────────────
if (!$reply) {
$lc = strtolower($message);
// ── Daily briefing / "what's my day" ─────────────────────────────────
if (preg_match('/\b(briefing|daily summary|my day|schedule today|what.*today|morning|good morning)\b/i', $message)
&& !preg_match('/\b(weather|news|temperature)\b/i', $message)) {
$today = date('Y-m-d');
$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 title 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]) ?? [];
$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: ' . implode(', ', $tl);
}
if ($tasks_overdue) {
$parts[] = count($tasks_overdue) . ' overdue item' . (count($tasks_overdue)>1?'s':'');
}
if ($parts) {
$reply = "Good morning, {$userAddr}. Today — " . implode('. ', $parts) . '.';
} else {
$reply = "Good morning, {$userAddr}. Your schedule is clear today — nothing due or scheduled.";
}
$source = 'planner:briefing';
}
// ── 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)\b/i', $message)) {
// Extract title — text after the trigger phrase
$title = preg_replace('/^.*(add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task)\s*/i', '', $message);
$title = trim($title, '. ');
// Check for due date: "by tomorrow", "on Monday", "due Friday"
$dueDate = null;
if (preg_match('/\b(?:by|on|due|before)\s+(.+)$/i', $title, $dm)) {
$ts = strtotime($dm[1]);
if ($ts !== false) {
$dueDate = date('Y-m-d', $ts);
$title = trim(preg_replace('/\s+(?:by|on|due|before)\s+.+$/i', '', $title));
}
}
// Detect category
$category = preg_match('/\b(work|meeting|project|client|office)\b/i', $title) ? 'work' : 'personal';
// Detect priority
$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';
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)\b/i', $message)) {
$today = date('Y-m-d');
$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(function($r) {
$due = $r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : '';
return $r['title'] . $due;
}, $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|completed)\b.*\btask\b|\btask\b.*\b(done|complete|finished)\b/i', $message)) {
// extract keyword to search by
$search = preg_replace('/\b(mark|complete|finished|done|completed|task|as|the)\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';
}
}
// ── Add appointment ───────────────────────────────────────────────────
if (!$reply && preg_match('/\b(schedule|appointment|meeting|add.*calendar|book|event)\b/i', $message)) {
// Extract title and time: "schedule dentist Tuesday at 2pm"
$raw = preg_replace('/^.*(schedule|appointment|meeting|add.*calendar|book|event)\s*/i', '', $message);
$raw = trim($raw);
// Try to find a datetime in the text
$dtParsed = null;
$title = $raw;
// Look for time/date keywords
if (preg_match('/\b(tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next\s+\w+|jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b.*?(\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?/i', $raw, $dm)) {
$dtStr = $dm[0];
$ts = strtotime($dtStr);
if ($ts !== false && $ts > time()) {
$dtParsed = date('Y-m-d H:i:s', $ts);
$title = trim(str_replace($dtStr, '', $raw));
}
}
if (!$dtParsed && preg_match('/\b(\d{1,2}(?::\d{2})?\s*(?:am|pm))\b/i', $raw, $tm)) {
$ts = strtotime('today ' . $tm[1]);
if ($ts !== false) { $dtParsed = date('Y-m-d H:i:s', $ts); }
}
$title = trim($title, ' .');
if ($title && $dtParsed) {
$category = preg_match('/\b(work|meeting|office|client|project)\b/i', $title) ? 'work' : 'personal';
JarvisDB::execute(
'INSERT INTO appointments (title,category,start_at) VALUES (?,?,?)',
[$title, $category, $dtParsed]
);
$reply = "Appointment scheduled: \"{$title}\" on " . date('l, M j \a\t g:i A', strtotime($dtParsed)) . ", {$userAddr}.";
$source = 'planner:appt_add';
} elseif ($title && !$dtParsed) {
$reply = "I'll add that appointment, {$userAddr} — what date and time?";
$source = 'planner:appt_need_time';
}
}
// ── View appointments / calendar ──────────────────────────────────────
if (!$reply && preg_match('/\b(appointments|calendar|my schedule|what.*scheduled|upcoming.*events?)\b/i', $message)) {
$from = date('Y-m-d');
$to = date('Y-m-d', strtotime('+7 days'));
$rows = JarvisDB::query(
"SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 6",
[$from, $to]
) ?? [];
if (!$rows) {
$reply = "No appointments in the next 7 days, {$userAddr}.";
} else {
$items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j \a\t g:i A', strtotime($r['start_at'])), $rows);
$reply = "Upcoming appointments, {$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 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;
}
}
}
// ── 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'),
]);