mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
dc55e6c45b
- 4-tier chat: HA control → Ollama → Groq → Claude - Push-based agent system with heartbeat/metrics - Network monitoring, alerts, Proxmox, Home Assistant - Windows + Linux agent installers - Stats cache cron, facts collector, KB engine
718 lines
32 KiB
PHP
718 lines
32 KiB
PHP
<?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';
|
||
}
|
||
}
|
||
|
||
|
||
// ── 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.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 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 => false,
|
||
]);
|
||
|
||
$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 => false,
|
||
]);
|
||
|
||
$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'),
|
||
]);
|