From dc55e6c45b37567809b108ea1f45c0253c63cdd2 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 25 May 2026 13:22:57 +0000 Subject: [PATCH] Initial commit: JARVIS AI dashboard v2.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 10 + README.md | 29 + api/config.example.php | 54 + api/endpoints/agent.php | 244 +++ api/endpoints/alerts.php | 177 ++ api/endpoints/auth.php | 53 + api/endpoints/chat.php | 717 ++++++++ api/endpoints/do_server.php | 77 + api/endpoints/facts_collector.php | 306 ++++ api/endpoints/ha.php | 77 + api/endpoints/network.php | 169 ++ api/endpoints/news.php | 20 + api/endpoints/proxmox.php | 41 + api/endpoints/stats_cache.php | 298 ++++ api/endpoints/system.php | 136 ++ api/endpoints/weather.php | 20 + api/lib/db.php | 40 + api/lib/kb_engine.php | 139 ++ public_html/.htaccess | 11 + public_html/agent/install-mac.sh | 122 ++ public_html/agent/install-windows.ps1 | 152 ++ public_html/agent/install.sh | 117 ++ public_html/agent/jarvis-agent-windows.py | 346 ++++ public_html/agent/jarvis-agent.py | 454 +++++ public_html/agent/setup-task.ps1 | 36 + public_html/api.php | 90 + public_html/index.html | 1900 +++++++++++++++++++++ 27 files changed, 5835 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/config.example.php create mode 100644 api/endpoints/agent.php create mode 100644 api/endpoints/alerts.php create mode 100644 api/endpoints/auth.php create mode 100644 api/endpoints/chat.php create mode 100644 api/endpoints/do_server.php create mode 100644 api/endpoints/facts_collector.php create mode 100644 api/endpoints/ha.php create mode 100644 api/endpoints/network.php create mode 100644 api/endpoints/news.php create mode 100644 api/endpoints/proxmox.php create mode 100644 api/endpoints/stats_cache.php create mode 100644 api/endpoints/system.php create mode 100644 api/endpoints/weather.php create mode 100644 api/lib/db.php create mode 100644 api/lib/kb_engine.php create mode 100644 public_html/.htaccess create mode 100644 public_html/agent/install-mac.sh create mode 100644 public_html/agent/install-windows.ps1 create mode 100644 public_html/agent/install.sh create mode 100644 public_html/agent/jarvis-agent-windows.py create mode 100644 public_html/agent/jarvis-agent.py create mode 100644 public_html/agent/setup-task.ps1 create mode 100644 public_html/api.php create mode 100644 public_html/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c206185 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Credentials - never commit +api/config.php +backup/ + +# Logs +logs/ +*.log + +# OS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9b6a3b --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# JARVIS + +Iron Man-style AI assistant for home and network management. + +## Features +- Home Assistant control (lights, climate, scenes, switches) +- Proxmox VM management (start/stop/status) +- 4-tier chat: KB intents > Groq cloud > Ollama local > Claude API +- Real-time status bar (HA, Proxmox, DigitalOcean) +- Iron Man HUD at jarvis.orbishosting.com + +## Stack +- PHP 8.x / Apache / MySQL on Ubuntu 24.04 +- Ollama VM at 10.48.200.95 (llama3.2:1b) +- Groq API (llama-3.3-70b / compound-mini with web search) +- Claude API (Anthropic) final fallback + +## Setup +cp api/config.example.php api/config.php +Fill in all credentials in config.php before running. + +## Key Files +- public/index.html Iron Man HUD frontend +- public/api.php API router +- api/config.example.php Config template +- api/endpoints/chat.php 4-tier chat handler +- api/endpoints/facts_collector.php HA entity sync cron +- api/lib/kb_engine.php KB intent engine +- api/lib/db.php PDO database wrapper diff --git a/api/config.example.php b/api/config.example.php new file mode 100644 index 0000000..ca1968b --- /dev/null +++ b/api/config.example.php @@ -0,0 +1,54 @@ + $msg]); + exit; +} + +function agent_ok(array $payload = []): void { + echo json_encode(array_merge(['ok' => true], $payload)); + exit; +} + +function generate_api_key(): string { + return bin2hex(random_bytes(24)); +} + +function get_agent_by_key(string $key): ?array { + $rows = JarvisDB::query( + 'SELECT * FROM registered_agents WHERE api_key = ? LIMIT 1', + [$key] + ); + return $rows[0] ?? null; +} + +function update_agent_seen(string $agentId, string $status = 'online'): void { + JarvisDB::query( + 'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?', + [$status, $agentId] + ); +} + +// ── Auth (all actions except register) ─────────────────────────────────────── + +$agentKey = $_SERVER['HTTP_X_AGENT_KEY'] ?? ''; +$browserActions = ['list', 'status', 'myip']; + +if ($agentAction !== 'register') { + if (in_array($agentAction, $browserActions)) { + $agent = null; // browser-accessible via session auth already validated by api.php + } else { + if (empty($agentKey)) agent_error(401, 'X-Agent-Key header required'); + $agent = get_agent_by_key($agentKey); + if (!$agent) agent_error(401, 'Invalid agent key'); + } +} + +// ── Route ───────────────────────────────────────────────────────────────────── + +switch ($agentAction) { + + // ── REGISTER ───────────────────────────────────────────────────────────── + case 'register': + if ($method !== 'POST') agent_error(405, 'POST only'); + $regKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? ($data['registration_key'] ?? ''); + if ($regKey !== AGENT_REGISTRATION_KEY) agent_error(403, 'Invalid registration key'); + + $hostname = trim($data['hostname'] ?? ''); + $agentType = $data['agent_type'] ?? 'linux'; + $ipAddress = $data['ip_address'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''); + $capabilities = $data['capabilities'] ?? []; + $agentId = $data['agent_id'] ?? ($hostname . '_' . substr(md5($hostname . $ipAddress), 0, 8)); + + if (!$hostname) agent_error(400, 'hostname required'); + if (!in_array($agentType, ['linux', 'homeassistant', 'proxmox', 'windows'])) agent_error(400, 'Invalid agent_type'); + + // Upsert agent + $existing = JarvisDB::query('SELECT api_key FROM registered_agents WHERE agent_id = ?', [$agentId]); + if ($existing) { + $apiKey = $existing[0]['api_key']; + JarvisDB::query( + 'UPDATE registered_agents SET hostname=?, agent_type=?, ip_address=?, capabilities=?, last_seen=NOW(), status="online" WHERE agent_id=?', + [$hostname, $agentType, $ipAddress, json_encode($capabilities), $agentId] + ); + } else { + $apiKey = generate_api_key(); + JarvisDB::query( + 'INSERT INTO registered_agents (agent_id, hostname, agent_type, ip_address, api_key, capabilities, last_seen, status) VALUES (?,?,?,?,?,?,NOW(),"online")', + [$agentId, $hostname, $agentType, $ipAddress, $apiKey, json_encode($capabilities)] + ); + } + + agent_ok(['agent_id' => $agentId, 'api_key' => $apiKey]); + + // ── HEARTBEAT ──────────────────────────────────────────────────────────── + case 'heartbeat': + update_agent_seen($agent['agent_id']); + + // Return any pending commands for this agent + $commands = JarvisDB::query( + 'SELECT id, command_type, command_data FROM agent_commands WHERE agent_id = ? AND status = "pending" ORDER BY created_at ASC LIMIT 10', + [$agent['agent_id']] + ); + + // Mark as delivered + if ($commands) { + $ids = implode(',', array_column($commands, 'id')); + JarvisDB::query("UPDATE agent_commands SET status='delivered', delivered_at=NOW() WHERE id IN ($ids)"); + foreach ($commands as &$cmd) { + $cmd['command_data'] = json_decode($cmd['command_data'] ?? '{}', true); + } + } + + agent_ok(['commands' => $commands ?: []]); + + // ── METRICS ────────────────────────────────────────────────────────────── + case 'metrics': + if ($method !== 'POST') agent_error(405, 'POST only'); + update_agent_seen($agent['agent_id']); + + $metricType = $data['type'] ?? 'system'; + $metricData = $data['data'] ?? []; + + JarvisDB::query( + 'INSERT INTO agent_metrics (agent_id, metric_type, metric_data) VALUES (?,?,?)', + [$agent['agent_id'], $metricType, json_encode($metricData)] + ); + + // Prune old metrics (keep 24h) + JarvisDB::query( + 'DELETE FROM agent_metrics WHERE agent_id = ? AND recorded_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)', + [$agent['agent_id']] + ); + + agent_ok(); + + // ── HA STATE PUSH ──────────────────────────────────────────────────────── + case 'ha_state': + if ($method !== 'POST') agent_error(405, 'POST only'); + update_agent_seen($agent['agent_id']); + + $entities = $data['entities'] ?? []; + if (empty($entities)) agent_error(400, 'entities array required'); + + foreach ($entities as $e) { + $entityId = $e['entity_id'] ?? ''; + $entityName = $e['name'] ?? $e['friendly_name'] ?? $entityId; + $domain = explode('.', $entityId)[0] ?? 'unknown'; + $state = $e['state'] ?? ''; + $attrs = $e['attributes'] ?? []; + $lastChanged = $e['last_changed'] ?? date('Y-m-d H:i:s'); + + if (!$entityId) continue; + + JarvisDB::query( + 'INSERT INTO ha_entities (agent_id, entity_id, entity_name, domain, state, attributes, last_changed, updated_at) + VALUES (?,?,?,?,?,?,?,NOW()) + ON DUPLICATE KEY UPDATE entity_name=VALUES(entity_name), state=VALUES(state), attributes=VALUES(attributes), last_changed=VALUES(last_changed), updated_at=NOW()', + [$agent['agent_id'], $entityId, $entityName, $domain, $state, json_encode($attrs), $lastChanged] + ); + } + + // Also update kb_facts for compatibility with existing KB engine + $entityMap = []; + $all = JarvisDB::query('SELECT entity_id, entity_name, domain, state, attributes FROM ha_entities WHERE agent_id = ?', [$agent['agent_id']]); + foreach ($all as $row) { + $entityMap[$row['entity_id']] = [ + 'entity_id' => $row['entity_id'], + 'name' => $row['entity_name'], + 'domain' => $row['domain'], + 'state' => $row['state'], + 'attributes'=> json_decode($row['attributes'] ?? '{}', true), + ]; + } + JarvisDB::query( + 'INSERT INTO kb_facts (fact_key, fact_value, fact_type) VALUES ("ha/entity_map", ?, "json") + ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()', + [json_encode($entityMap)] + ); + + agent_ok(['accepted' => count($entities)]); + + // ── COMMAND RESULT ─────────────────────────────────────────────────────── + case 'command_result': + if ($method !== 'POST') agent_error(405, 'POST only'); + $cmdId = (int)($data['command_id'] ?? 0); + $result = $data['result'] ?? []; + $status = ($data['success'] ?? true) ? 'executed' : 'failed'; + + JarvisDB::query( + 'UPDATE agent_commands SET status=?, executed_at=NOW(), result=? WHERE id=? AND agent_id=?', + [$status, json_encode($result), $cmdId, $agent['agent_id']] + ); + agent_ok(); + + // ── LIST (admin: get all agents status) ────────────────────────────────── + case 'list': + // Mark agents offline if last_seen > 2 minutes ago + JarvisDB::query( + 'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"' + ); + $agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, capabilities FROM registered_agents ORDER BY agent_type, hostname'); + foreach ($agents as &$a) { + $a['capabilities'] = json_decode($a['capabilities'] ?? '[]', true); + } + agent_ok(['agents' => $agents, 'my_ip' => $_SERVER['REMOTE_ADDR'] ?? '']); + + // ── LATEST METRICS (for dashboard display) ─────────────────────────────── + case 'status': + $agentIdReq = $data['agent_id'] ?? ($parts[2] ?? ''); + $whereAgent = $agentIdReq ? 'AND agent_id = ?' : ''; + $params = $agentIdReq ? [$agentIdReq] : []; + + $latest = JarvisDB::query( + "SELECT agent_id, metric_type, metric_data, recorded_at + FROM agent_metrics + WHERE (agent_id, metric_type, recorded_at) IN ( + SELECT agent_id, metric_type, MAX(recorded_at) + FROM agent_metrics $whereAgent + GROUP BY agent_id, metric_type + ) + ORDER BY agent_id, metric_type", + $params + ); + + $grouped = []; + foreach ($latest as $row) { + $grouped[$row['agent_id']][$row['metric_type']] = json_decode($row['metric_data'], true); + $grouped[$row['agent_id']]['recorded_at'] = $row['recorded_at']; + } + + agent_ok(['metrics' => $grouped]); + + // ── MY IP (browser client IP detection) ────────────────────────────────── + case 'myip': + agent_ok(['ip' => $_SERVER['REMOTE_ADDR'] ?? '']); + + default: + agent_error(404, 'Unknown agent action: ' . $agentAction); +} diff --git a/api/endpoints/alerts.php b/api/endpoints/alerts.php new file mode 100644 index 0000000..a2881be --- /dev/null +++ b/api/endpoints/alerts.php @@ -0,0 +1,177 @@ + DATE_SUB(NOW(), INTERVAL 5 MINUTE)" + ); + + foreach ($latest as $row) { + $d = json_decode($row['metric_data'] ?? '{}', true); + $hn = $row['hostname']; + $id = $row['agent_id']; + + // CPU + $cpu = (float)($d['cpu_percent'] ?? 0); + if ($cpu >= $CPU_WARN) { + $key = 'agent:' . $id . ':cpu_high'; + $sev = $cpu >= 95 ? 'critical' : 'warning'; + upsert_alert($key, $sev, 'High CPU: ' . $hn, + round($cpu, 1) . '% CPU utilization on ' . $hn . '. Sustained high load detected.'); + $still_active[$key] = true; + } + + // Memory + $mem_pct = (float)($d['memory']['percent'] ?? 0); + if ($mem_pct >= $MEM_WARN) { + $key = 'agent:' . $id . ':mem_high'; + $sev = $mem_pct >= 95 ? 'critical' : 'warning'; + upsert_alert($key, $sev, 'High Memory: ' . $hn, + round($mem_pct, 1) . '% memory used on ' . $hn . + ' (' . round($d['memory']['used_mb'] ?? 0) . '/' . + round($d['memory']['total_mb'] ?? 0) . ' MB).'); + $still_active[$key] = true; + } + + // Disk + foreach (($d['disk'] ?? []) as $disk) { + $pct = (int)($disk['percent'] ?? 0); + if ($pct >= $DISK_WARN) { + $mount = $disk['mount'] ?? '/'; + $key = 'agent:' . $id . ':disk:' . str_replace('/', '_', $mount); + $sev = $pct >= $DISK_CRIT ? 'critical' : 'warning'; + upsert_alert($key, $sev, 'Disk Full: ' . $hn . ' ' . $mount, + $mount . ' is ' . $pct . '% full on ' . $hn . + ' (' . ($disk['used'] ?? '?') . ' of ' . ($disk['size'] ?? '?') . ' used).'); + $still_active[$key] = true; + } + } + + // Services down + foreach (($d['services'] ?? []) as $svc) { + if (($svc['status'] ?? '') === 'active') continue; + if (($svc['status'] ?? '') === 'unknown') continue; // not watched/installed + $svcName = $svc['service'] ?? ''; + $key = 'agent:' . $id . ':svc:' . $svcName; + upsert_alert($key, 'warning', 'Service Down: ' . $svcName . ' on ' . $hn, + $svcName . ' is ' . ($svc['status'] ?? 'inactive') . ' on ' . $hn . '.'); + $still_active[$key] = true; + } + } + + // ── Auto-resolve alerts whose condition has cleared ──────────────────────── + if (!empty($still_active)) { + $active_keys = array_keys($still_active); + // Get all auto-resolvable alerts that are unresolved + $open_auto = JarvisDB::query( + "SELECT id, source_key FROM alerts WHERE resolved=0 AND auto_resolve=1 AND source_key IS NOT NULL" + ); + foreach ($open_auto as $row) { + if (!isset($still_active[$row['source_key']])) { + JarvisDB::query( + 'UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?', + [$row['id']] + ); + } + } + } else { + // Nothing active — resolve all auto alerts + JarvisDB::query( + "UPDATE alerts SET resolved=1, resolved_at=NOW() + WHERE resolved=0 AND auto_resolve=1" + ); + } +} + +function upsert_alert(string $key, string $sev, string $title, string $msg): void { + $existing = JarvisDB::query( + 'SELECT id, severity FROM alerts WHERE source_key=? AND resolved=0 LIMIT 1', + [$key] + ); + if ($existing) { + // Update severity/message if changed (e.g., warning → critical) + if ($existing[0]['severity'] !== $sev) { + JarvisDB::query( + 'UPDATE alerts SET severity=?, title=?, message=?, created_at=NOW() WHERE id=?', + [$sev, $title, $msg, $existing[0]['id']] + ); + } + } else { + JarvisDB::query( + 'INSERT INTO alerts (alert_type, title, message, severity, source_key, auto_resolve) VALUES (?,?,?,?,?,1)', + ['agent', $title, $msg, $sev, $key] + ); + } +} + +// ── Route ───────────────────────────────────────────────────────────────────── + +if ($method === 'GET') { + // Rate-limit agent alert refresh to once per 60 seconds via kb_facts lock + $last_refresh = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='agent' AND fact_key='alert_refresh' LIMIT 1"); + $last_ts = !empty($last_refresh) ? (int)$last_refresh[0]['fact_value'] : 0; + if (time() - $last_ts >= 60) { + JarvisDB::query( + "INSERT INTO kb_facts (category, fact_key, fact_value, host) VALUES ('agent', 'alert_refresh', ?, 'local') + ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()", + [time()] + ); + refresh_agent_alerts(); + } + $alerts = JarvisDB::query( + 'SELECT * FROM alerts WHERE resolved=0 ORDER BY severity DESC, created_at DESC LIMIT 30' + ); + echo json_encode(['alerts' => $alerts ?: [], 'count' => count($alerts ?: [])]); + +} elseif ($method === 'POST' && ($action === 'resolve' || ($data['action'] ?? '') === 'resolve')) { + $id = (int)($data['id'] ?? 0); + JarvisDB::query('UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?', [$id]); + echo json_encode(['success' => true]); + +} elseif ($method === 'POST') { + JarvisDB::query( + 'INSERT INTO alerts (alert_type, title, message, severity) VALUES (?,?,?,?)', + [$data['type'] ?? 'system', $data['title'] ?? 'Alert', $data['message'] ?? '', $data['severity'] ?? 'info'] + ); + echo json_encode(['success' => true]); +} diff --git a/api/endpoints/auth.php b/api/endpoints/auth.php new file mode 100644 index 0000000..ac549cc --- /dev/null +++ b/api/endpoints/auth.php @@ -0,0 +1,53 @@ + 'Credentials required']); + exit; + } + + $user = JarvisDB::single( + 'SELECT * FROM users WHERE username = ?', [$username] + ); + + if ($user && password_verify($password, $user['password_hash'])) { + $token = bin2hex(random_bytes(32)); + $_SESSION['jarvis_token'] = $token; + $_SESSION['jarvis_user_id'] = $user['id']; + $_SESSION['jarvis_name'] = $user['display_name']; + + JarvisDB::execute( + 'UPDATE users SET last_seen = NOW() WHERE id = ?', [$user['id']] + ); + + // Use stored address preference for greeting + $addrRow = JarvisDB::query( + "SELECT pref_value FROM kb_preferences WHERE pref_key='user_title' LIMIT 1" + ); + $userAddr = $addrRow[0]['pref_value'] ?? $user['display_name']; + + echo json_encode([ + 'success' => true, + 'token' => $token, + 'display_name' => $userAddr, + 'greeting' => "Welcome back, {$userAddr}. All systems are online and awaiting your command.", + ]); + } else { + http_response_code(401); + echo json_encode(['error' => 'Invalid credentials']); + } +} elseif ($method === 'DELETE') { + session_destroy(); + echo json_encode(['success' => true, 'message' => 'Session terminated.']); +} else { + // Check session status + $loggedIn = !empty($_SESSION['jarvis_token']); + echo json_encode([ + 'authenticated' => $loggedIn, + 'name' => $_SESSION['jarvis_name'] ?? null, + ]); +} diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php new file mode 100644 index 0000000..29d404b --- /dev/null +++ b/api/endpoints/chat.php @@ -0,0 +1,717 @@ + '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'), +]); diff --git a/api/endpoints/do_server.php b/api/endpoints/do_server.php new file mode 100644 index 0000000..bc292b4 --- /dev/null +++ b/api/endpoints/do_server.php @@ -0,0 +1,77 @@ +/dev/null', + escapeshellarg(DO_SSH_PASS), + escapeshellarg(DO_SSH_USER), + escapeshellarg(DO_SERVER_IP), + escapeshellarg($cmd) + ); + return shell_exec($sshCmd) ?? ''; +} + +// Check if sshpass is available +$hasSshpass = trim(shell_exec('which sshpass 2>/dev/null')); +if (!$hasSshpass) { + echo json_encode([ + 'error' => 'sshpass not installed on Jarvis server. Run: sudo apt-get install sshpass', + 'ip' => DO_SERVER_IP, + ]); + exit; +} + +// Gather DO server stats in one SSH session +$statsRaw = sshCommand("echo CPU:$(grep 'cpu ' /proc/stat | awk '{u=\$2+\$4; t=\$2+\$3+\$4+\$5; print u/t*100}');echo MEM_TOTAL:\$(grep MemTotal /proc/meminfo | awk '{print \$2}');echo MEM_FREE:\$(grep MemAvailable /proc/meminfo | awk '{print \$2}');echo UPTIME:\$(cat /proc/uptime | awk '{print int(\$1)}');echo DISK_USED:\$(df / | tail -1 | awk '{print \$5}');echo LOAD:\$(cat /proc/loadavg | awk '{print \$1}')"); + +$stats = []; +foreach (explode("\n", trim($statsRaw)) as $line) { + [$key, $val] = explode(':', $line, 2) + [null, null]; + if ($key) $stats[$key] = trim($val ?? ''); +} + +// Get running services on DO +$services = sshCommand("systemctl is-active lsphp85 lshttpd nginx apache2 mysql mariadb php8.1-fpm 2>/dev/null | paste <(echo -e 'lsphp85\nlshttpd\nnginx\napache2\nmysql\nmariadb\nphp8.1-fpm') - | awk '{print \$1\":\"\$2}'"); +$svcMap = []; +foreach (explode("\n", trim($services)) as $line) { + if (!$line) continue; + [$name, $status] = explode(':', $line, 2) + [null, null]; + if ($name && $status && trim($status) === 'active') { + $svcMap[trim($name)] = true; + } +} + +// Get disk usage per site +$siteDisk = sshCommand("du -sh /home/*/public_html 2>/dev/null | sort -h"); +$sites = []; +foreach (explode("\n", trim($siteDisk)) as $line) { + if (preg_match('/^([\d.]+\w)\s+\/home\/([^\/]+)/', $line, $m)) { + $sites[$m[2]] = $m[1]; + } +} + +$cpuPct = isset($stats['CPU']) ? round((float)$stats['CPU'], 1) : null; +$memTotal = (int)($stats['MEM_TOTAL'] ?? 0); +$memFree = (int)($stats['MEM_FREE'] ?? 0); +$memUsed = $memTotal - $memFree; +$uptimeSec = (int)($stats['UPTIME'] ?? 0); +$uptimeDays = intdiv($uptimeSec, 86400); +$uptimeHrs = intdiv($uptimeSec % 86400, 3600); + +echo json_encode([ + 'ip' => DO_SERVER_IP, + 'reachable' => !empty($statsRaw), + 'cpu_pct' => $cpuPct, + 'memory' => [ + 'total_mb' => round($memTotal / 1024), + 'used_mb' => round($memUsed / 1024), + 'percent' => $memTotal > 0 ? round(($memUsed/$memTotal)*100,1) : 0, + ], + 'disk_used_pct' => $stats['DISK_USED'] ?? null, + 'load_1m' => (float)($stats['LOAD'] ?? 0), + 'uptime' => "{$uptimeDays}d {$uptimeHrs}h", + 'services' => $svcMap, + 'sites' => $sites, + 'timestamp' => date('c'), +]); diff --git a/api/endpoints/facts_collector.php b/api/endpoints/facts_collector.php new file mode 100644 index 0000000..e927193 --- /dev/null +++ b/api/endpoints/facts_collector.php @@ -0,0 +1,306 @@ + 0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0; + KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl); + + $memLines = file('/proc/meminfo'); + $mem = []; + foreach ($memLines as $l) { + if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2]; + } + $total = round($mem['MemTotal'] / 1048576, 1); + $avail = round($mem['MemAvailable'] / 1048576, 1); + $used = round($total - $avail, 1); + $free = round($mem['MemFree'] / 1048576, 1); + $memPct = $total > 0 ? round($used / $total * 100) : 0; + KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl); + KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl); + KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl); + KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl); + + $la = explode(' ', file_get_contents('/proc/loadavg')); + KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl); + KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl); + KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl); + + $sec = (int) file_get_contents('/proc/uptime'); + KBEngine::storeFact('system', 'uptime', + intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours', + 'local', $ttl); + + $df = disk_free_space('/'); + $dt = disk_total_space('/'); + KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl); + KBEngine::storeFact('system', 'disk_used', round(($dt - $df) / 1073741824, 1) . 'GB', 'local', $ttl); + KBEngine::storeFact('system', 'disk_free', round($df / 1073741824, 1) . 'GB', 'local', $ttl); + + $results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)"; + } catch (Exception $e) { + $results['system'] = 'error: ' . $e->getMessage(); + } + + // ── Network ─────────────────────────────────────────────────────────── + try { + $watchlist = [ + 'gateway' => '10.48.200.1', + 'proxmox' => '10.48.200.90', + 'ollama' => '10.48.200.95', + 'fusionpbx' => '10.48.200.96', + 'ha' => '10.48.200.97', + 'do_server' => '165.22.1.228', + ]; + $online = 0; + $total = count($watchlist); + foreach ($watchlist as $name => $ip) { + exec('ping -c1 -W1 ' . escapeshellarg($ip) . ' > /dev/null 2>&1', $o, $code); + $up = ($code === 0); + if ($up) $online++; + KBEngine::storeFact('network', "host_{$name}", $up ? 'online' : 'offline', $ip, $ttl); + } + KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl); + KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl); + KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl); + $results['network'] = "ok ({$online}/{$total} online)"; + } catch (Exception $e) { + $results['network'] = 'error: ' . $e->getMessage(); + } + + // ── Proxmox ─────────────────────────────────────────────────────────── + try { + if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) { + $base = 'https://' . PROXMOX_HOST . ':' . PROXMOX_PORT . '/api2/json'; + $auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL; + + $nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth); + $vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth); + $cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth); + + if (isset($nd['data'])) { + $cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1); + $memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1); + $memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1); + $memPct = $memT > 0 ? round($memU / $memT * 100) : 0; + KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl); + KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl); + KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl); + KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl); + } + $all = array_merge($vms['data'] ?? [], $cts['data'] ?? []); + $running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running')); + KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl); + KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl); + $results['proxmox'] = "ok ({$running}/" . count($all) . " running)"; + } else { + $results['proxmox'] = 'skipped (no token)'; + } + } catch (Exception $e) { + $results['proxmox'] = 'error: ' . $e->getMessage(); + } + + // ── Home Assistant ──────────────────────────────────────────────────── + try { + if (defined('HA_URL') && defined('HA_TOKEN') && HA_TOKEN !== 'YOUR_HA_TOKEN_HERE') { + $haUrl = HA_URL; + $haToken = HA_TOKEN; + $haHdr = ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json']; + + // Fetch all entity states + $ch = curl_init($haUrl . '/api/states'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $haHdr, + CURLOPT_TIMEOUT => 12, + CURLOPT_CONNECTTIMEOUT => 5, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200) { + $allStates = json_decode($resp, true) ?? []; + + // Domains to index for control + $controlDomains = ['light','switch','input_boolean','climate','cover','fan', + 'scene','script','lawn_mower','vacuum','media_player']; + // Switch keywords to skip (camera/HACS settings, not real devices) + $skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone', + '_siren_on','_email_on','_manual_record','_infrared_', + 'do_not_disturb','matter_server','zerotier','mariadb', + 'spotify','file_editor','ssh_web','uptime_kuma', + 'adguard_home_','adguard_protection','adguard_parental', + 'adguard_safe','adguard_filter','adguard_query', + 'assist_microphone','folding_home','music_assistant', + 'get_hacs','mealie','mosquitto','social_to', + 'motion_detection','front_yard_record','down_hill_record', + 'camera1_record','back_yard_record','nvr_']; + + $entityMap = []; + $statusCount = ['online' => 0, 'offline' => 0, 'unavailable' => 0]; + + foreach ($allStates as $s) { + $eid = $s['entity_id']; + $domain = explode('.', $eid)[0]; + $name = $s['attributes']['friendly_name'] ?? $eid; + $state = $s['state']; + + if (!in_array($domain, $controlDomains)) continue; + + // Skip camera/HACS internals for switches + if ($domain === 'switch') { + $skip = false; + foreach ($skipKeywords as $kw) { + if (strpos($eid, $kw) !== false) { $skip = true; break; } + } + if ($skip) continue; + } + + $entityMap[$eid] = ['name' => $name, 'state' => $state, 'domain' => $domain]; + + if ($state === 'unavailable' || $state === 'unknown') { + $statusCount['unavailable']++; + } elseif (in_array($state, ['on','open','playing','mowing','home','active','idle'])) { + $statusCount['online']++; + } elseif (in_array($state, ['off','closed','paused','docked','away'])) { + $statusCount['offline']++; + } + } + + // Store entity map as JSON for chat.php to use + KBEngine::storeFact('ha', 'entity_map', json_encode($entityMap), 'ha', 270); + KBEngine::storeFact('ha', 'entity_count', count($entityMap), 'ha', $ttl); + KBEngine::storeFact('ha', 'online_count', $statusCount['online'], 'ha', $ttl); + KBEngine::storeFact('ha', 'offline_count', $statusCount['offline'], 'ha', $ttl); + KBEngine::storeFact('ha', 'unavail_count', $statusCount['unavailable'], 'ha', $ttl); + KBEngine::storeFact('ha', 'ha_status', 'online', 'ha', $ttl); + + // Store individual sensor facts + $sensorDomains = ['sensor','binary_sensor','weather']; + $interestingPatterns = ['temperature','humidity','battery','power','energy', + 'voltage','current','illuminance','co2','pm25']; + foreach ($allStates as $s) { + $domain = explode('.', $s['entity_id'])[0]; + if (!in_array($domain, $sensorDomains)) continue; + $eid = $s['entity_id']; + foreach ($interestingPatterns as $pat) { + if (strpos($eid, $pat) !== false) { + $name = $s['attributes']['friendly_name'] ?? $eid; + $unit = $s['attributes']['unit_of_measurement'] ?? ''; + KBEngine::storeFact('ha_sensors', $eid, + $s['state'] . ($unit ? " {$unit}" : ''), 'ha', $ttl); + break; + } + } + } + + $results['ha'] = sprintf('ok (%d entities, %d on, %d off, %d unavail)', + count($entityMap), $statusCount['online'], + $statusCount['offline'], $statusCount['unavailable']); + } else { + KBEngine::storeFact('ha', 'ha_status', 'unreachable', 'ha', $ttl); + $results['ha'] = "unreachable (HTTP {$code})"; + } + } else { + KBEngine::storeFact('ha', 'ha_status', 'token not configured', 'ha', $ttl); + $results['ha'] = 'skipped (no token)'; + } + } catch (Exception $e) { + KBEngine::storeFact('ha', 'ha_status', 'error', 'ha', $ttl); + $results['ha'] = 'error: ' . $e->getMessage(); + } + + // ── Digital Ocean ───────────────────────────────────────────────────── + try { + exec('ping -c1 -W2 165.22.1.228 > /dev/null 2>&1', $o2, $doCode); + $doStatus = ($doCode === 0) ? 'online' : 'unreachable'; + KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl); + $results['do_server'] = "ok ({$doStatus})"; + } catch (Exception $e) { + $results['do_server'] = 'error: ' . $e->getMessage(); + } + + // ── Ollama ──────────────────────────────────────────────────────────── + try { + $ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434'; + $ch = curl_init($ollamaHost . '/api/tags'); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200) { + $models = json_decode($resp, true)['models'] ?? []; + $names = array_column($models, 'name'); + KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null); + KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl); + KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl); + foreach ($models as $m) { + JarvisDB::execute( + 'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?) + ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()', + [$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)] + ); + } + $results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')'; + } else { + KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl); + $results['ollama'] = 'unreachable (VM may be booting)'; + } + } catch (Exception $e) { + $results['ollama'] = 'error: ' . $e->getMessage(); + } + + return $results; +} + +function pve_api_get(string $url, string $authHeader): array { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [$authHeader], + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_TIMEOUT => 8, + ]); + $resp = curl_exec($ch); + curl_close($ch); + return $resp ? (json_decode($resp, true) ?? []) : []; +} + +// ── Entry point ─────────────────────────────────────────────────────────── +$results = collect_all(); +if ($isCLI) { + echo date('Y-m-d H:i:s') . " JARVIS facts collected:\n"; + foreach ($results as $k => $v) { + echo " {$k}: {$v}\n"; + } +} else { + echo json_encode(['status' => 'ok', 'results' => $results, 'timestamp' => date('c')]); +} diff --git a/api/endpoints/ha.php b/api/endpoints/ha.php new file mode 100644 index 0000000..697b7ad --- /dev/null +++ b/api/endpoints/ha.php @@ -0,0 +1,77 @@ + true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 8, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_SSL_VERIFYPEER => false, + ]); + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + } + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 200 && $code < 300 && $resp) { + return json_decode($resp, true); + } + return null; +} + +$configured = !(HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false); + +if (!$configured) { + echo json_encode(['configured' => false, 'message' => 'HA token not configured.', 'entities' => []]); + exit; +} + +// Live service call (toggle device) — always direct to HA, never cached +if ($method === 'POST' && $action === 'service') { + $domain = $data['domain'] ?? ''; + $service = $data['service'] ?? ''; + $entity_id = $data['entity_id'] ?? ''; + if ($domain && $service && $entity_id) { + $result = haRequest("/services/{$domain}/{$service}", 'POST', ['entity_id' => $entity_id]); + echo json_encode(['success' => true, 'result' => $result]); + } else { + echo json_encode(['error' => 'Missing domain/service/entity_id']); + } + exit; +} + +// Serve entities from cache (populated by stats_cache.php cron, every 5 min) +$cached = JarvisDB::query( + 'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1', + ['ha_entities'] +); + +if ($cached && !empty($cached[0]['data'])) { + $row = $cached[0]; + $data_out = json_decode($row['data'], true); + $data_out['cache_age_s'] = (int)(time() - (int)$row['updated_ts']); + echo json_encode($data_out); +} else { + echo json_encode([ + 'configured' => true, + 'ha_version' => 'unknown', + 'location' => 'Home', + 'entity_count' => 0, + 'entities' => [], + 'cached_at' => null, + 'cache_age_s' => -1, + 'message' => 'Cache warming up — first update in under 5 minutes.', + ]); +} diff --git a/api/endpoints/network.php b/api/endpoints/network.php new file mode 100644 index 0000000..d09eae1 --- /dev/null +++ b/api/endpoints/network.php @@ -0,0 +1,169 @@ +/dev/null'; + $out = shell_exec($cmd); + $alive = $out && strpos($out, '1 received') !== false; + $latency = null; + if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) { + $latency = (float)$m[1]; + } + return ['alive' => $alive, 'latency_ms' => $latency]; +} + +function scanSubnet(string $prefix, int $timeout = 10): array { + $cmd = 'nmap -sn --host-timeout 1s ' . escapeshellarg($prefix . '.0/24') . + ' -oG - 2>/dev/null | grep "Up$" | awk \'{print $2}\''; + $out = shell_exec($cmd) ?? ''; + $hosts = array_filter(explode("\n", trim($out))); + return array_values($hosts); +} + +function getArpTable(): array { + $out = shell_exec('arp -n 2>/dev/null') ?? ''; + $devices = []; + foreach (explode("\n", trim($out)) as $line) { + if (preg_match('/^([\d.]+)\s+\w+\s+([\w:]+)/', $line, $m)) { + $devices[$m[1]] = strtolower($m[2]); + } + } + return $devices; +} + +$action = $action ?? 'status'; +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + +if ($action === 'scan') { + $liveHosts = scanSubnet(LOCAL_SUBNET, 8); + $arp = getArpTable(); + $known = JarvisDB::query('SELECT * FROM network_devices'); + $knownMap = []; + foreach ($known as $d) $knownMap[$d['ip']] = $d; + + $devices = []; + foreach ($liveHosts as $ip) { + $mac = $arp[$ip] ?? null; + $known_dev = $knownMap[$ip] ?? null; + $nsOut = shell_exec('timeout 1 nslookup ' . escapeshellarg($ip) . ' 2>/dev/null | grep "name ="'); + $hostname = null; + if ($nsOut && preg_match('/name = (.+)\./', $nsOut, $nm)) { + $hostname = rtrim($nm[1], '.'); + } + $devices[] = [ + 'ip' => $ip, + 'mac' => $mac, + 'hostname' => $hostname, + 'alias' => $known_dev['alias'] ?? null, + 'type' => $known_dev['device_type'] ?? 'unknown', + 'status' => 'online', + ]; + JarvisDB::execute( + 'INSERT INTO network_devices (ip, mac, hostname, status, last_seen) VALUES (?,?,?,\'online\',NOW()) + ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=VALUES(hostname), status=\'online\', last_seen=NOW()', + [$ip, $mac, $hostname] + ); + } + foreach ($knownMap as $ip => $dev) { + if (!in_array($ip, $liveHosts)) { + JarvisDB::execute('UPDATE network_devices SET status=\'offline\' WHERE ip=?', [$ip]); + } + } + echo json_encode(['devices' => $devices, 'count' => count($devices), 'scanned_at' => date('c')]); + +} elseif ($action === 'add' && $method === 'POST') { + $ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP); + $alias = substr(trim($data['alias'] ?? ''), 0, 100); + $type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($data['type'] ?? 'device')); + if (!$ip) { echo json_encode(['error' => 'Invalid IP address']); exit; } + if (!$alias) { echo json_encode(['error' => 'Name is required']); exit; } + JarvisDB::execute( + 'INSERT INTO network_devices (ip, alias, device_type, status) VALUES (?,?,?,\'unknown\') + ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)', + [$ip, $alias, $type] + ); + echo json_encode(['success' => true]); + +} elseif ($action === 'delete' && $method === 'POST') { + $ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP); + if (!$ip) { echo json_encode(['error' => 'Invalid IP']); exit; } + // Don't allow deleting agent-managed entries + $isAgent = JarvisDB::query('SELECT id FROM registered_agents WHERE ip_address=? LIMIT 1', [$ip]); + if (!empty($isAgent)) { echo json_encode(['error' => 'Cannot delete agent-managed device']); exit; } + JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$ip]); + echo json_encode(['success' => true]); + +} else { + // Status: unified device list from agents + user-managed DB entries + external services + $devices = []; + + // Mark agents offline if not heard from in 2 minutes + JarvisDB::execute( + 'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"' + ); + + // 1. Agent-based devices — status from heartbeat, no ping from DO needed + $agents = JarvisDB::query( + 'SELECT agent_id, hostname, ip_address, status, last_seen, agent_type FROM registered_agents ORDER BY hostname' + ); + $agentIPs = []; + foreach ($agents as $ag) { + $agentIPs[] = $ag['ip_address']; + $devices[] = [ + 'ip' => $ag['ip_address'], + 'name' => $ag['hostname'], + 'type' => 'agent', + 'agent_id' => $ag['agent_id'], + 'agent_type' => $ag['agent_type'], + 'alive' => $ag['status'] === 'online', + 'status' => $ag['status'], + 'last_seen' => $ag['last_seen'], + 'source' => 'agent', + 'deletable' => false, + ]; + } + + // 2. User-managed devices from DB (named/aliased entries not covered by agents) + $pinned = JarvisDB::query( + 'SELECT ip, alias, device_type, status, last_seen FROM network_devices + WHERE alias IS NOT NULL AND alias != "" ORDER BY alias' + ); + foreach ($pinned as $dev) { + if (in_array($dev['ip'], $agentIPs)) continue; // agent already covers this IP + $ping = pingHost($dev['ip']); + $newStatus = $ping['alive'] ? 'online' : 'offline'; + JarvisDB::execute( + 'UPDATE network_devices SET status=?, last_seen=NOW() WHERE ip=?', + [$newStatus, $dev['ip']] + ); + $devices[] = [ + 'ip' => $dev['ip'], + 'name' => $dev['alias'], + 'type' => $dev['device_type'] ?: 'device', + 'alive' => $ping['alive'], + 'latency_ms' => $ping['latency_ms'], + 'status' => $newStatus, + 'last_seen' => $dev['last_seen'], + 'source' => 'db', + 'deletable' => true, + ]; + } + + // 3. External services we can actually ping from DO + $external = [ + ['ip' => '134.209.72.226', 'name' => 'FusionPBX DO', 'type' => 'server'], + ]; + foreach ($external as $host) { + if (in_array($host['ip'], $agentIPs)) continue; + $ping = pingHost($host['ip']); + $devices[] = array_merge($host, [ + 'alive' => $ping['alive'], + 'latency_ms' => $ping['latency_ms'], + 'status' => $ping['alive'] ? 'online' : 'offline', + 'source' => 'static', + 'deletable' => false, + ]); + } + + echo json_encode(['devices' => $devices, 'timestamp' => date('c')]); +} diff --git a/api/endpoints/news.php b/api/endpoints/news.php new file mode 100644 index 0000000..0982b71 --- /dev/null +++ b/api/endpoints/news.php @@ -0,0 +1,20 @@ + [], + 'total' => 0, + 'cache_age_s' => -1, + 'message' => 'News feed warming up — available within 5 minutes.', + ]); +} diff --git a/api/endpoints/proxmox.php b/api/endpoints/proxmox.php new file mode 100644 index 0000000..f95e289 --- /dev/null +++ b/api/endpoints/proxmox.php @@ -0,0 +1,41 @@ + false, + 'message' => 'Proxmox API token not yet configured.', + 'vms' => [], 'nodes' => [], + ]); + exit; +} + +// Serve from cache (refreshed by stats_cache.php cron every 5 min) +$cached = JarvisDB::query( + 'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1', + ['proxmox'] +); + +if ($cached && !empty($cached[0]['data'])) { + $row = $cached[0]; + $data = json_decode($row['data'], true); + // Add cache age to response + $data['cache_age_s'] = (int)(time() - (int)$row['updated_ts']); + echo json_encode($data); +} else { + // Cache empty — return placeholder so UI shows something useful + echo json_encode([ + 'configured' => true, + 'node' => PROXMOX_NODE, + 'node_status' => null, + 'vms' => [], + 'containers' => [], + 'vm_count' => 0, + 'ct_count' => 0, + 'cached_at' => null, + 'cache_age_s' => -1, + 'message' => 'Cache warming up — first update in under 5 minutes.', + ]); +} diff --git a/api/endpoints/stats_cache.php b/api/endpoints/stats_cache.php new file mode 100644 index 0000000..2146582 --- /dev/null +++ b/api/endpoints/stats_cache.php @@ -0,0 +1,298 @@ + true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + ]); + $out = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + if ($err) { echo "[cache] curl error: $err\n"; return null; } + return ($code >= 200 && $code < 300) ? $out : null; +} + +function cacheStore(string $key, $data): void { + $json = is_string($data) ? $data : json_encode($data); + JarvisDB::execute( + 'INSERT INTO api_cache (cache_key, data, updated_at) VALUES (?,?,NOW()) + ON DUPLICATE KEY UPDATE data=VALUES(data), updated_at=NOW()', + [$key, $json] + ); +} + +// ── Proxmox ────────────────────────────────────────────────────────────── +if (PROXMOX_HOST !== '10.48.200.X' && PROXMOX_TOKEN_VAL !== 'YOUR_TOKEN_VALUE_HERE') { + $pveBase = 'https://' . PROXMOX_HOST . ':' . PROXMOX_PORT . '/api2/json'; + $pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL]; + + $nodeStatusRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/status", $pveAuth); + $vmsRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/qemu", $pveAuth); + $lxcRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/lxc", $pveAuth); + + $nodeStatus = $nodeStatusRaw ? (json_decode($nodeStatusRaw, true)['data'] ?? null) : null; + $vms = $vmsRaw ? (json_decode($vmsRaw, true)['data'] ?? []) : []; + $lxcs = $lxcRaw ? (json_decode($lxcRaw, true)['data'] ?? []) : []; + + $vmDetails = []; + foreach ($vms as $vm) { + $vmDetails[] = [ + 'vmid' => $vm['vmid'], + 'name' => $vm['name'] ?? 'VM-' . $vm['vmid'], + 'status' => $vm['status'] ?? 'unknown', + 'cpu' => round(($vm['cpu'] ?? 0) * 100, 1), + 'mem_mb' => round(($vm['mem'] ?? 0) / 1048576), + 'maxmem_mb' => round(($vm['maxmem'] ?? 0) / 1048576), + 'disk_gb' => round(($vm['disk'] ?? 0) / 1073741824, 1), + 'uptime' => $vm['uptime'] ?? 0, + 'netin' => $vm['netin'] ?? 0, + 'netout' => $vm['netout'] ?? 0, + ]; + } + $lxcDetails = []; + foreach ($lxcs as $lxc) { + $lxcDetails[] = [ + 'vmid' => $lxc['vmid'], + 'name' => $lxc['name'] ?? 'CT-' . $lxc['vmid'], + 'status' => $lxc['status'] ?? 'unknown', + 'cpu' => round(($lxc['cpu'] ?? 0) * 100, 1), + 'mem_mb' => round(($lxc['mem'] ?? 0) / 1048576), + 'maxmem_mb' => round(($lxc['maxmem'] ?? 0) / 1048576), + 'type' => 'lxc', + ]; + } + + cacheStore('proxmox', [ + 'configured' => true, + 'node' => PROXMOX_NODE, + 'node_status' => $nodeStatus, + 'vms' => $vmDetails, + 'containers' => $lxcDetails, + 'vm_count' => count($vmDetails), + 'ct_count' => count($lxcDetails), + 'cached_at' => date('c'), + ]); + echo '[cache] Proxmox: ' . count($vmDetails) . ' VMs, ' . count($lxcDetails) . " CTs cached\n"; +} + +// ── Home Assistant ──────────────────────────────────────────────────────── +if (HA_TOKEN !== 'YOUR_HA_TOKEN_HERE' && strpos(HA_URL, '10.48.200.X') === false) { + $haHeaders = [ + 'Authorization: Bearer ' . HA_TOKEN, + 'Content-Type: application/json', + ]; + + $statesRaw = curlGet(HA_URL . '/api/states', $haHeaders, 15); + $configRaw = curlGet(HA_URL . '/api/config', $haHeaders, 8); + + $states = $statesRaw ? json_decode($statesRaw, true) : []; + $config = $configRaw ? json_decode($configRaw, true) : []; + + $interesting = ['light','switch','sensor','climate','binary_sensor','cover', + 'media_player','camera','alarm_control_panel','lock','fan','input_boolean']; + $grouped = []; + foreach (($states ?? []) as $entity) { + $domain = explode('.', $entity['entity_id'])[0]; + if (!in_array($domain, $interesting)) continue; + if (strpos($entity['entity_id'], 'adguard') !== false) continue; + if (!isset($grouped[$domain])) $grouped[$domain] = []; + $grouped[$domain][] = [ + 'entity_id' => $entity['entity_id'], + 'name' => $entity['attributes']['friendly_name'] ?? $entity['entity_id'], + 'state' => $entity['state'], + 'last_changed' => $entity['last_changed'] ?? null, + ]; + } + + cacheStore('ha_entities', [ + 'configured' => true, + 'ha_version' => $config['version'] ?? 'unknown', + 'location' => $config['location_name'] ?? 'Home', + 'entity_count' => count($states ?? []), + 'entities' => $grouped, + 'cached_at' => date('c'), + ]); + $total = array_sum(array_map('count', $grouped)); + echo "[cache] HA: $total entities across " . count($grouped) . " domains cached\n"; +} + +// ── Weather (wttr.in — refresh every 30 min) ────────────────────────────── +$weatherRow = JarvisDB::query( + "SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='weather' LIMIT 1" +); +$weatherAge = $weatherRow ? (time() - (int)$weatherRow[0]['ts']) : PHP_INT_MAX; + +if ($weatherAge > 1800) { + // wttr.in code → icon mapping + $wttrIcon = function(int $code): string { + if ($code === 113) return 'SUNNY'; + if ($code === 116) return 'PARTLY CLOUDY'; + if (in_array($code, [119,122])) return 'CLOUDY'; + if (in_array($code, [143,248,260])) return 'FOGGY'; + if (in_array($code, [176,263,266,293,296])) return 'LIGHT RAIN'; + if (in_array($code, [299,302,305,308,353,356,359])) return 'RAIN'; + if (in_array($code, [317,320,362,365])) return 'SLEET'; + if (in_array($code, [323,326,329,332,335,338,368,371])) return 'SNOW'; + if (in_array($code, [386,389,392,395,200])) return 'STORMS'; + if (in_array($code, [281,284,311,314])) return 'FREEZING RAIN'; + return 'MIXED'; + }; + $wttrEmoji = function(int $code): string { + if ($code === 113) return 'Sunny'; + if ($code === 116) return 'Partly Cloudy'; + if (in_array($code, [119,122])) return 'Cloudy'; + if (in_array($code, [143,248,260])) return 'Foggy'; + if (in_array($code, [176,263,266,293,296])) return 'Light Rain'; + if (in_array($code, [299,302,305,308,353,356,359])) return 'Rain'; + if (in_array($code, [317,320,362,365])) return 'Sleet'; + if (in_array($code, [323,326,329,332,335,338,368,371])) return 'Snow'; + if (in_array($code, [386,389,392,395,200])) return 'Thunderstorm'; + return 'Mixed'; + }; + + $weatherRaw = curlGet( + 'https://wttr.in/FortWorth,TX?format=j1', + ['User-Agent: curl/7.88 Jarvis/1.0'], + 15 + ); + + if ($weatherRaw) { + $w = json_decode($weatherRaw, true); + $cu = $w['current_condition'][0] ?? []; + $days = $w['weather'] ?? []; + + $curCode = (int)($cu['weatherCode'] ?? 113); + $forecast = []; + foreach (array_slice($days, 0, 4) as $day) { + // max rain chance across 8 hourly slots + $rainPct = 0; + foreach ($day['hourly'] ?? [] as $h) { + $rainPct = max($rainPct, (int)($h['chanceofrain'] ?? 0)); + } + $dayCode = (int)($day['hourly'][4]['weatherCode'] ?? 113); + $forecast[] = [ + 'date' => $day['date'] ?? '', + 'day' => date('D', strtotime($day['date'] ?? 'now')), + 'high' => (int)($day['maxtempF'] ?? 0), + 'low' => (int)($day['mintempF'] ?? 0), + 'rain_pct' => $rainPct, + 'desc' => $wttrEmoji($dayCode), + 'icon' => $wttrIcon($dayCode), + ]; + } + + cacheStore('weather', [ + 'source' => 'wttr.in', + 'location' => 'Fort Worth, TX', + 'current' => [ + 'temp' => (int)($cu['temp_F'] ?? 0), + 'feels' => (int)($cu['FeelsLikeF'] ?? 0), + 'humidity' => (int)($cu['humidity'] ?? 0), + 'wind' => (int)($cu['windspeedMiles'] ?? 0), + 'desc' => $wttrEmoji($curCode), + 'icon' => $wttrIcon($curCode), + 'cloud' => (int)($cu['cloudcover'] ?? 0), + 'vis' => (int)($cu['visibility'] ?? 0), + ], + 'forecast' => $forecast, + 'cached_at' => date('c'), + ]); + echo '[cache] Weather: ' . ($cu['temp_F'] ?? '?') . "°F, " . $wttrEmoji($curCode) . " (wttr.in) cached\n"; + } else { + echo "[cache] Weather: wttr.in fetch failed\n"; + } +} else { + echo '[cache] Weather: fresh (' . round($weatherAge/60) . " min old)\n"; +} + +// ── News (RSS feeds — refresh every 30 min) ─────────────────────────────── +$newsRow = JarvisDB::query( + "SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='news' LIMIT 1" +); +$newsAge = $newsRow ? (time() - (int)$newsRow[0]['ts']) : PHP_INT_MAX; + +if ($newsAge > 1800) { + $feeds = [ + 'headlines' => [ + 'https://feeds.bbci.co.uk/news/rss.xml', + 'https://feeds.npr.org/1001/rss.xml', + 'https://feeds.abcnews.com/abcnews/topstories', + ], + 'technology' => [ + 'http://feeds.arstechnica.com/arstechnica/index', + 'https://www.theverge.com/rss/index.xml', + ], + ]; + + function parseRss(string $xml, int $max = 5): array { + $items = []; + if (!$xml) return $items; + libxml_use_internal_errors(true); + $dom = new DOMDocument(); + $dom->loadXML($xml); + $nodes = $dom->getElementsByTagName('item'); + $count = 0; + foreach ($nodes as $node) { + if ($count >= $max) break; + $title = $node->getElementsByTagName('title')->item(0)?->textContent ?? ''; + $link = $node->getElementsByTagName('link')->item(0)?->textContent ?? ''; + $pub = $node->getElementsByTagName('pubDate')->item(0)?->textContent ?? ''; + $title = html_entity_decode(trim($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if ($title && strlen($title) > 5) { + $items[] = [ + 'title' => $title, + 'link' => trim($link), + 'pub' => $pub ? date('M j g:ia', strtotime($pub)) : '', + 'source' => '', + ]; + $count++; + } + } + return $items; + } + + $allNews = []; + foreach ($feeds as $category => $urls) { + $allNews[$category] = []; + foreach ($urls as $url) { + $xml = curlGet($url, ['User-Agent: Mozilla/5.0 Jarvis/1.0'], 12); + $items = parseRss($xml, 4); + // Tag source from URL + $src = preg_match('/bbc/i', $url) ? 'BBC' : + (preg_match('/npr/i', $url) ? 'NPR' : + (preg_match('/abcnews/i', $url) ? 'ABC' : + (preg_match('/arstechnica/i', $url) ? 'Ars Technica' : + (preg_match('/theverge/i', $url) ? 'The Verge' : 'News')))); + foreach ($items as &$it) { $it['source'] = $src; } + $allNews[$category] = array_merge($allNews[$category], $items); + if (count($allNews[$category]) >= 8) break; + } + // Trim to 8 per category + $allNews[$category] = array_slice($allNews[$category], 0, 8); + } + + $totalItems = array_sum(array_map('count', $allNews)); + cacheStore('news', [ + 'categories' => $allNews, + 'cached_at' => date('c'), + 'total' => $totalItems, + ]); + echo "[cache] News: $totalItems articles cached\n"; +} else { + echo '[cache] News: fresh (' . round($newsAge/60) . " min old)\n"; +} + +echo '[cache] Done at ' . date('Y-m-d H:i:s') . "\n"; diff --git a/api/endpoints/system.php b/api/endpoints/system.php new file mode 100644 index 0000000..5cd4321 --- /dev/null +++ b/api/endpoints/system.php @@ -0,0 +1,136 @@ + 0 ? round((($dTotal - $dIdle) / $dTotal) * 100, 1) : 0.0; +} + +function getMemory(): array { + $lines = file('/proc/meminfo'); + $mem = []; + foreach ($lines as $l) { + if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2]; + } + $total = $mem['MemTotal'] ?? 0; + $available = $mem['MemAvailable'] ?? 0; + $used = $total - $available; + return [ + 'total_mb' => round($total / 1024), + 'used_mb' => round($used / 1024), + 'free_mb' => round($available / 1024), + 'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0, + ]; +} + +function getDisk(): array { + $disks = []; + foreach (disk_total_space('/') as $dummy) break; // warm up + $total = disk_total_space('/'); + $free = disk_free_space('/'); + $used = $total - $free; + return [ + 'total_gb' => round($total / 1073741824, 1), + 'used_gb' => round($used / 1073741824, 1), + 'free_gb' => round($free / 1073741824, 1), + 'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0, + ]; +} + +function getDisk2(): array { + $total = disk_total_space('/'); + $free = disk_free_space('/'); + $used = $total - $free; + return [ + 'total_gb' => round($total / 1073741824, 1), + 'used_gb' => round($used / 1073741824, 1), + 'free_gb' => round($free / 1073741824, 1), + 'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0, + ]; +} + +function getUptime(): string { + $secs = (int)file_get_contents('/proc/uptime'); + $d = intdiv($secs, 86400); $h = intdiv($secs % 86400, 3600); + $m = intdiv($secs % 3600, 60); + return "{$d}d {$h}h {$m}m"; +} + +function getLoadAvg(): array { + $l = explode(' ', file_get_contents('/proc/loadavg')); + return ['1m' => (float)$l[0], '5m' => (float)$l[1], '15m' => (float)$l[2]]; +} + +function getNetworkIO(): array { + $lines = file('/proc/net/dev'); + $ifaces = []; + foreach ($lines as $line) { + if (strpos($line, ':') === false) continue; + [$name, $stats] = explode(':', $line, 2); + $name = trim($name); + if ($name === 'lo') continue; + $vals = preg_split('/\s+/', trim($stats)); + $ifaces[$name] = [ + 'rx_mb' => round($vals[0] / 1048576, 2), + 'tx_mb' => round($vals[8] / 1048576, 2), + ]; + } + return $ifaces; +} + +function getServices(): array { + $services = ['apache2', 'mysql']; + $result = []; + foreach ($services as $svc) { + $out = shell_exec('systemctl is-active ' . escapeshellarg($svc) . ' 2>/dev/null'); + $result[$svc] = trim($out ?? '') === 'active'; + } + return $result; +} + +function getTopProcesses(): array { + $out = shell_exec("ps aux --sort=-%cpu | awk 'NR>1 && NR<=6 {print $11\",\"$3\",\"$4}' 2>/dev/null"); + $procs = []; + foreach (explode("\n", trim($out ?? '')) as $line) { + if (!$line) continue; + [$cmd, $cpu, $mem] = explode(',', $line, 3); + $procs[] = ['cmd' => basename($cmd), 'cpu' => (float)$cpu, 'mem' => (float)$mem]; + } + return $procs; +} + +$cpu = getCpuUsage(); +$memory = getMemory(); +$disk = getDisk2(); + +$stats = [ + 'cpu' => $cpu, + 'memory' => $memory, + 'disk' => $disk, + 'uptime' => getUptime(), + 'load' => getLoadAvg(), + 'network_io' => getNetworkIO(), + 'services' => getServices(), + 'processes' => getTopProcesses(), + 'hostname' => gethostname(), + 'ip' => JARVIS_IP, + 'timestamp' => date('c'), +]; + +// Log to history +JarvisDB::execute( + 'INSERT INTO metrics_history (metric_name, metric_value, host) VALUES (?,?,?),(?,?,?),(?,?,?)', + ['cpu', $cpu, 'jarvis', 'memory', $memory['percent'], 'jarvis', 'disk', $disk['percent'], 'jarvis'] +); + +echo json_encode($stats); diff --git a/api/endpoints/weather.php b/api/endpoints/weather.php new file mode 100644 index 0000000..e7eb39d --- /dev/null +++ b/api/endpoints/weather.php @@ -0,0 +1,20 @@ + null, + 'forecast' => [], + 'cache_age_s' => -1, + 'message' => 'Weather data warming up — available within 5 minutes.', + ]); +} diff --git a/api/lib/db.php b/api/lib/db.php new file mode 100644 index 0000000..d4997d8 --- /dev/null +++ b/api/lib/db.php @@ -0,0 +1,40 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false] + ); + } + return self::$pdo; + } + + public static function query(string $sql, array $params = []): array { + $stmt = self::get()->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + public static function execute(string $sql, array $params = []): int { + $stmt = self::get()->prepare($sql); + $stmt->execute($params); + return $stmt->rowCount(); + } + + public static function single(string $sql, array $params = []): ?array { + $rows = self::query($sql, $params); + return $rows[0] ?? null; + } + + public static function insert(string $sql, array $params = []): int { + $stmt = self::get()->prepare($sql); + $stmt->execute($params); + return (int)self::get()->lastInsertId(); + } +} diff --git a/api/lib/kb_engine.php b/api/lib/kb_engine.php new file mode 100644 index 0000000..fbbdbfb --- /dev/null +++ b/api/lib/kb_engine.php @@ -0,0 +1,139 @@ + string, 'intent' => string] or null if no match. + */ + public static function match(string $input): ?array { + $intents = JarvisDB::query( + 'SELECT * FROM kb_intents WHERE active=1 ORDER BY priority DESC, id ASC' + ); + if (!$intents) return null; + + foreach ($intents as $intent) { + $pat = '~' . $intent['pattern'] . '~'; + if (@preg_match($pat, $input)) { + $reply = self::fillTemplate( + $intent['response_template'], + $intent['fact_category'] + ); + return [ + 'reply' => $reply, + 'intent' => $intent['intent_name'], + 'action' => $intent['action_type'], + ]; + } + } + return null; + } + + /** + * Replace {placeholder} tokens in a template with values from kb_facts, + * plus built-in dynamic tokens like {current_time}. + */ + private static function fillTemplate(string $template, ?string $category): string { + // Built-in tokens + $builtins = [ + 'current_time' => date('g:i A'), + 'current_date' => date('l, F j Y'), + ]; + + // Fetch all facts for this category (and null-category universal facts) + $facts = []; + if ($category) { + $rows = JarvisDB::query( + 'SELECT fact_key, fact_value FROM kb_facts + WHERE category = ?', + [$category] + ); + foreach ($rows as $r) { + $facts[$r['fact_key']] = $r['fact_value']; + } + } + // Also pull network facts for network tokens used in any template + if (strpos($template, '{online_count}') !== false || strpos($template, '{total_count}') !== false) { + $netRows = JarvisDB::query( + "SELECT fact_key, fact_value FROM kb_facts + WHERE category='network'" + ); + foreach ($netRows as $r) { + $facts[$r['fact_key']] = $r['fact_value']; + } + } + + $allTokens = array_merge($builtins, $facts); + + // Replace placeholders + return preg_replace_callback('/\{([a-z0-9_]+)\}/', function ($m) use ($allTokens) { + return $allTokens[$m[1]] ?? '[unknown]'; + }, $template); + } + + /** + * Store a fact in kb_facts (upsert). + */ + public static function storeFact( + string $category, + string $key, + string $value, + string $host = 'local', + ?int $ttlSeconds = null + ): void { + $expires = $ttlSeconds ? gmdate('Y-m-d H:i:s', time() + $ttlSeconds) : null; + JarvisDB::execute( + 'INSERT INTO kb_facts (category, fact_key, fact_value, host, expires_at) + VALUES (?,?,?,?,?) + ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), expires_at=VALUES(expires_at)', + [$category, $key, $value, $host, $expires] + ); + } + + /** + * Learn from conversation — store interesting facts the user mentions. + */ + public static function learnFromConversation(string $input, string $reply): void { + // Preference learning: user states a preference + if (preg_match('/(?i)i (prefer|like|want|always)\s+(.+?)(?:\.|$)/', $input, $m)) { + $pref = trim($m[2]); + if (strlen($pref) < 120) { + JarvisDB::execute( + 'INSERT INTO kb_preferences (pref_key, pref_value) + VALUES (?,?) + ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)', + ['learned_' . md5($pref), $pref] + ); + } + } + } + + /** + * Return a summary of what the KB knows (for system prompt injection). + */ + public static function getContextSummary(): string { + // Exclude entity_map — too large for Ollama 1B tokenizer + $facts = JarvisDB::query( + "SELECT category, fact_key, fact_value FROM kb_facts + WHERE fact_key != 'entity_map' + ORDER BY category, updated_at DESC" + ); + if (!$facts) return ''; + + $byCategory = []; + foreach ($facts as $f) { + $byCategory[$f['category']][] = "{$f['fact_key']}={$f['fact_value']}"; + } + + $lines = []; + foreach ($byCategory as $cat => $items) { + $lines[] = strtoupper($cat) . ': ' . implode(', ', array_slice($items, 0, 8)); + } + return implode("\n", $lines); + } +} diff --git a/public_html/.htaccess b/public_html/.htaccess new file mode 100644 index 0000000..02b8ec1 --- /dev/null +++ b/public_html/.htaccess @@ -0,0 +1,11 @@ +Options -Indexes +RewriteEngine On + +# Route all /api/* requests to api.php +RewriteCond %{REQUEST_URI} ^/api(/|$) +RewriteRule ^api(/.*)?$ api.php [QSA,L] + +# Everything else serves static files or index.html +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.html [L] diff --git a/public_html/agent/install-mac.sh b/public_html/agent/install-mac.sh new file mode 100644 index 0000000..0f8777b --- /dev/null +++ b/public_html/agent/install-mac.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# JARVIS Agent Installer — macOS +# Usage: bash install-mac.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_KEY + +set -e + +JARVIS_URL="" +REG_KEY="" +INSTALL_DIR="$HOME/.jarvis-agent" +CONFIG_DIR="$HOME/.jarvis-agent" +PLIST_PATH="$HOME/Library/LaunchAgents/com.jarvis.agent.plist" +SERVICE_LABEL="com.jarvis.agent" + +while [[ $# -gt 0 ]]; do + case "$1" in + --jarvis-url) JARVIS_URL="$2"; shift 2 ;; + --key) REG_KEY="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +if [[ -z "$JARVIS_URL" ]]; then + read -rp "JARVIS URL (e.g. https://jarvis.orbishosting.com): " JARVIS_URL +fi +if [[ -z "$REG_KEY" ]]; then + read -rp "Registration key: " REG_KEY +fi + +JARVIS_URL="${JARVIS_URL%/}" + +# Check for Python3 +PYTHON3=$(command -v python3 2>/dev/null || command -v /usr/bin/python3 2>/dev/null || "") +if [[ -z "$PYTHON3" ]]; then + echo "Python 3 is required. Install it with:" + echo " brew install python3" + echo " or download from https://www.python.org/downloads/" + exit 1 +fi +echo "Using Python: $PYTHON3 ($($PYTHON3 --version))" + +mkdir -p "$INSTALL_DIR" + +# Download or copy agent script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$SCRIPT_DIR/jarvis-agent.py" ]]; then + cp "$SCRIPT_DIR/jarvis-agent.py" "$INSTALL_DIR/jarvis-agent.py" +else + echo "Downloading agent..." + curl -sSL "https://raw.githubusercontent.com/myronblair/jarvis/master/agent/jarvis-agent.py" \ + -o "$INSTALL_DIR/jarvis-agent.py" +fi +chmod +x "$INSTALL_DIR/jarvis-agent.py" + +HOSTNAME=$(hostname -f 2>/dev/null || hostname) + +# Write config +if [[ ! -f "$CONFIG_DIR/config.json" ]]; then +cat > "$CONFIG_DIR/config.json" << JSONEOF +{ + "jarvis_url": "$JARVIS_URL", + "registration_key": "$REG_KEY", + "hostname": "$HOSTNAME", + "agent_type": "linux", + "poll_interval": 30, + "heartbeat_every": 10, + "watch_services": [] +} +JSONEOF + chmod 600 "$CONFIG_DIR/config.json" +fi + +# Override state path in agent for macOS +STATE_PATH="$INSTALL_DIR/state.json" + +# Write launchd plist +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$PLIST_PATH" << PLISTEOF + + + + + Label + $SERVICE_LABEL + ProgramArguments + + $PYTHON3 + $INSTALL_DIR/jarvis-agent.py + + EnvironmentVariables + + JARVIS_CONFIG + $CONFIG_DIR/config.json + JARVIS_STATE + $STATE_PATH + + RunAtLoad + + KeepAlive + + StandardOutPath + $INSTALL_DIR/jarvis-agent.log + StandardErrorPath + $INSTALL_DIR/jarvis-agent.log + + +PLISTEOF + +# Load the service +launchctl unload "$PLIST_PATH" 2>/dev/null || true +launchctl load "$PLIST_PATH" + +sleep 2 +if launchctl list | grep -q "$SERVICE_LABEL"; then + echo "" + echo "✓ JARVIS Agent installed and running." + echo " View logs: tail -f $INSTALL_DIR/jarvis-agent.log" + echo " Config: $CONFIG_DIR/config.json" + echo " Stop: launchctl unload $PLIST_PATH" +else + echo "⚠ Agent installed but not running. Check logs:" + echo " tail -f $INSTALL_DIR/jarvis-agent.log" +fi diff --git a/public_html/agent/install-windows.ps1 b/public_html/agent/install-windows.ps1 new file mode 100644 index 0000000..c83fba1 --- /dev/null +++ b/public_html/agent/install-windows.ps1 @@ -0,0 +1,152 @@ +# JARVIS Agent Installer — Windows (PowerShell) +# Run as Administrator: +# Set-ExecutionPolicy Bypass -Scope Process +# .\install-windows.ps1 -JarvisUrl https://jarvis.orbishosting.com -Key YOUR_KEY +# Or one-liner (from PowerShell as Admin): +# irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex + +param( + [string]$JarvisUrl = "https://jarvis.orbishosting.com", + [string]$Key = "", + [string]$AgentName = $env:COMPUTERNAME.ToLower() +) + +$ErrorActionPreference = "Stop" +$InstallDir = "C:\ProgramData\jarvis-agent" +$AgentScript = "$InstallDir\jarvis-agent.py" +$ConfigFile = "$InstallDir\config.json" +$TaskName = "JARVIS-Agent" + +Write-Host "" +Write-Host " ====================================" -ForegroundColor Cyan +Write-Host " JARVIS Agent Installer v2.2 " -ForegroundColor Cyan +Write-Host " ====================================" -ForegroundColor Cyan +Write-Host "" + +# ── Require admin ───────────────────────────────────────────────────────────── +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Error "Run PowerShell as Administrator and try again." +} + +# ── Prompt if not provided ──────────────────────────────────────────────────── +$JarvisUrl = $JarvisUrl.TrimEnd("/") +if (-not $Key) { $Key = Read-Host "Enter registration key" } + +# ── Find Python 3 ───────────────────────────────────────────────────────────── +Write-Host "Checking Python 3..." -NoNewline +$python = $null +$searchPaths = @( + "python", "python3", "py", + "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe", + "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe", + "$env:LOCALAPPDATA\Programs\Python\Python310\python.exe", + "C:\Python312\python.exe", "C:\Python311\python.exe" +) +foreach ($p in $searchPaths) { + try { + $ver = & $p --version 2>&1 + if ("$ver" -match "Python 3") { $python = $p; break } + } catch {} +} + +if (-not $python) { + Write-Host " not found." -ForegroundColor Yellow + Write-Host "Installing Python 3.12 via winget..." -ForegroundColor Yellow + try { + winget install Python.Python.3.12 --silent --accept-package-agreements --accept-source-agreements + $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + + [System.Environment]::GetEnvironmentVariable("PATH","User") + foreach ($p in @("python","python3")) { + try { $ver = & $p --version 2>&1; if ("$ver" -match "Python 3") { $python = $p; break } } catch {} + } + } catch {} +} +if (-not $python) { Write-Error "Python 3 not found. Install from https://python.org then re-run." } + +# Resolve full path for task scheduler (PS 5.1 compatible — no ?. operator) +$_pyCmd = Get-Command $python -ErrorAction SilentlyContinue +$pythonPath = if ($_pyCmd) { $_pyCmd.Source } else { $null } +if (-not $pythonPath -or -not (Test-Path $pythonPath)) { + foreach ($p in @("$env:LOCALAPPDATA\Programs\Python\Python312\python.exe", + "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe", + "C:\Python312\python.exe")) { + if (Test-Path $p) { $pythonPath = $p; break } + } +} +if (-not $pythonPath) { $pythonPath = $python } +Write-Host " $pythonPath" -ForegroundColor Green + +# ── Create install directory ────────────────────────────────────────────────── +New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +Write-Host "Install dir: $InstallDir" + +# ── Download Windows agent script ───────────────────────────────────────────── +Write-Host "Downloading jarvis-agent-windows.py..." -NoNewline +try { + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } + $wc = New-Object System.Net.WebClient + $wc.Headers.Add("User-Agent", "JARVIS-Installer/1.0") + $wc.DownloadFile("$JarvisUrl/agent/jarvis-agent-windows.py", $AgentScript) + Write-Host " done." -ForegroundColor Green +} catch { + Write-Error "Download failed from $JarvisUrl/agent/jarvis-agent-windows.py`nError: $_" +} + +# ── Write config ────────────────────────────────────────────────────────────── +$agentId = "${AgentName}_windows" +$config = [ordered]@{ + jarvis_url = $JarvisUrl + host_header = "" + ssl_verify = $true + registration_key = $Key + agent_type = "windows" + hostname = $AgentName + agent_id = $agentId + poll_interval = 30 + heartbeat_every = 10 + watch_services = @("WinDefend", "Spooler") +} | ConvertTo-Json -Depth 3 + +[System.IO.File]::WriteAllText($ConfigFile, $config, [System.Text.UTF8Encoding]::new($false)) +Write-Host "Config: $ConfigFile" + +# ── Register scheduled task ─────────────────────────────────────────────────── +try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue } catch {} + +$action = New-ScheduledTaskAction -Execute "`"$pythonPath`"" ` + -Argument "`"$AgentScript`"" -WorkingDirectory $InstallDir +$trigger = New-ScheduledTaskTrigger -AtStartup +$settings = New-ScheduledTaskSettingsSet ` + -ExecutionTimeLimit (New-TimeSpan -Seconds 0) ` + -RestartCount 10 -RestartInterval (New-TimeSpan -Minutes 1) ` + -StartWhenAvailable -MultipleInstances IgnoreNew + +# Run as current user (not SYSTEM) so per-user Python install is accessible +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Highest + +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` + -Settings $settings -Principal $principal ` + -Description "JARVIS AI System Monitoring Agent" -Force | Out-Null + +Write-Host "Scheduled task '$TaskName' registered." -ForegroundColor Green + +# ── Start now ───────────────────────────────────────────────────────────────── +Write-Host "Starting agent..." -NoNewline +Start-ScheduledTask -TaskName $TaskName +Start-Sleep -Seconds 3 +$state = (Get-ScheduledTask -TaskName $TaskName).State +Write-Host " $state" -ForegroundColor $(if ($state -eq "Running") {"Green"} else {"Yellow"}) + +Write-Host "" +Write-Host " Installation complete!" -ForegroundColor Green +Write-Host " Machine : $AgentName ($agentId)" -ForegroundColor White +Write-Host " JARVIS : $JarvisUrl" -ForegroundColor White +Write-Host " Logs : $InstallDir\jarvis-agent.log" -ForegroundColor White +Write-Host "" +Write-Host " Useful commands:" -ForegroundColor Gray +Write-Host " Get-Content '$InstallDir\jarvis-agent.log' -Tail 20 -Wait" -ForegroundColor Gray +Write-Host " Stop-ScheduledTask -TaskName '$TaskName'" -ForegroundColor Gray +Write-Host " Start-ScheduledTask -TaskName '$TaskName'" -ForegroundColor Gray +Write-Host " Unregister-ScheduledTask -TaskName '$TaskName' -Confirm:`$false" -ForegroundColor Gray +Write-Host "" diff --git a/public_html/agent/install.sh b/public_html/agent/install.sh new file mode 100644 index 0000000..f3878ab --- /dev/null +++ b/public_html/agent/install.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# JARVIS Agent Installer +# Usage: curl -sSL https://raw.githubusercontent.com/myronblair/jarvis/master/agent/install.sh | sudo bash +# Or: sudo bash install.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_REGISTRATION_KEY + +set -e + +JARVIS_URL="" +REG_KEY="" +AGENT_TYPE="linux" +INSTALL_DIR="/opt/jarvis-agent" +CONFIG_DIR="/etc/jarvis-agent" +STATE_DIR="/var/lib/jarvis-agent" +SERVICE_NAME="jarvis-agent" + +# ── Parse args ──────────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --jarvis-url) JARVIS_URL="$2"; shift 2 ;; + --key) REG_KEY="$2"; shift 2 ;; + --type) AGENT_TYPE="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +# ── Interactive prompts if not provided ────────────────────────────────────── +if [[ -z "$JARVIS_URL" ]]; then + read -rp "JARVIS URL (e.g. https://jarvis.orbishosting.com): " JARVIS_URL +fi +if [[ -z "$REG_KEY" ]]; then + read -rp "Registration key: " REG_KEY +fi + +JARVIS_URL="${JARVIS_URL%/}" + +echo "" +echo "Installing JARVIS Agent..." +echo " URL: $JARVIS_URL" +echo " Type: $AGENT_TYPE" +echo "" + +# ── Install Python3 if needed ───────────────────────────────────────────────── +if ! command -v python3 &>/dev/null; then + echo "Installing python3..." + apt-get update -qq && apt-get install -y python3 || yum install -y python3 || dnf install -y python3 +fi + +# ── Create directories ──────────────────────────────────────────────────────── +mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$STATE_DIR" + +# ── Copy agent script ───────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$SCRIPT_DIR/jarvis-agent.py" ]]; then + cp "$SCRIPT_DIR/jarvis-agent.py" "$INSTALL_DIR/jarvis-agent.py" +else + echo "Downloading agent script..." + curl -sSL "https://raw.githubusercontent.com/myronblair/jarvis/master/agent/jarvis-agent.py" \ + -o "$INSTALL_DIR/jarvis-agent.py" +fi +chmod +x "$INSTALL_DIR/jarvis-agent.py" + +# ── Write config ────────────────────────────────────────────────────────────── +HOSTNAME=$(hostname -f 2>/dev/null || hostname) + +if [[ -f "$CONFIG_DIR/config.json" ]]; then + echo "Config already exists at $CONFIG_DIR/config.json — skipping (keeping existing settings)." +else +cat > "$CONFIG_DIR/config.json" << JSONEOF +{ + "jarvis_url": "$JARVIS_URL", + "registration_key": "$REG_KEY", + "hostname": "$HOSTNAME", + "agent_type": "$AGENT_TYPE", + "poll_interval": 30, + "heartbeat_every": 10, + "watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"] +} +JSONEOF + chmod 600 "$CONFIG_DIR/config.json" + echo "Config written to $CONFIG_DIR/config.json" +fi + +# ── Write systemd service ───────────────────────────────────────────────────── +cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SVCEOF +[Unit] +Description=JARVIS Monitoring Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 $INSTALL_DIR/jarvis-agent.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +SVCEOF + +# ── Enable and start ────────────────────────────────────────────────────────── +systemctl daemon-reload +systemctl enable "$SERVICE_NAME" +systemctl restart "$SERVICE_NAME" + +sleep 2 +if systemctl is-active --quiet "$SERVICE_NAME"; then + echo "" + echo "✓ JARVIS Agent installed and running." + echo " View logs: journalctl -u $SERVICE_NAME -f" + echo " Config: $CONFIG_DIR/config.json" +else + echo "" + echo "⚠ Agent installed but not running. Check logs:" + echo " journalctl -u $SERVICE_NAME -n 30" +fi diff --git a/public_html/agent/jarvis-agent-windows.py b/public_html/agent/jarvis-agent-windows.py new file mode 100644 index 0000000..efa3688 --- /dev/null +++ b/public_html/agent/jarvis-agent-windows.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD. +Install via PowerShell: iwr https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex +Config: C:\\ProgramData\\jarvis-agent\\config.json +Logs: C:\\ProgramData\\jarvis-agent\\jarvis-agent.log +""" + +import json +import os +import platform +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import uuid +from datetime import datetime, timezone +from pathlib import Path + +INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent") +CONFIG_PATH = INSTALL_DIR / "config.json" +STATE_PATH = INSTALL_DIR / "state.json" +LOG_PATH = INSTALL_DIR / "jarvis-agent.log" +AGENT_VERSION = "2.2" + +# ── Logging ──────────────────────────────────────────────────────────────────── + +_log_file = None + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + try: + with open(LOG_PATH, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + +# ── Config ───────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") + sys.exit(1) + with open(CONFIG_PATH, encoding="utf-8-sig") as f: + return json.load(f) + +def load_state() -> dict: + if STATE_PATH.exists(): + with open(STATE_PATH, encoding="utf-8") as f: + return json.load(f) + return {} + +def save_state(state: dict): + INSTALL_DIR.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool): + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent-Windows/1.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent-Windows/1.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── Metrics ──────────────────────────────────────────────────────────────────── + +def _ps(script: str, timeout: int = 8) -> str: + """Run a PowerShell one-liner and return stdout.""" + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, timeout=timeout + ) + return r.stdout.strip() + except Exception: + return "" + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +_last_cpu_counters = None + +def get_cpu_percent() -> float: + global _last_cpu_counters + try: + out = _ps("(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average") + return round(float(out), 1) + except Exception: + return 0.0 + +def get_memory() -> dict: + try: + out = _ps("$o=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$o.TotalVisibleMemorySize;free=$o.FreePhysicalMemory}|ConvertTo-Json") + d = json.loads(out) + total_kb = int(d.get("total", 0)) + free_kb = int(d.get("free", 0)) + used_kb = total_kb - free_kb + if total_kb == 0: + return {} + return { + "total_mb": round(total_kb / 1024, 1), + "used_mb": round(used_kb / 1024, 1), + "free_mb": round(free_kb / 1024, 1), + "percent": round(used_kb / total_kb * 100, 1), + } + except Exception: + return {} + +def get_disk() -> list: + try: + out = _ps("Get-PSDrive -PSProvider FileSystem | Where-Object{$_.Used -ne $null} | Select-Object Name,@{n='used';e={[math]::Round($_.Used/1GB,2)}},@{n='free';e={[math]::Round($_.Free/1GB,2)}} | ConvertTo-Json") + if not out: + return [] + items = json.loads(out) + if isinstance(items, dict): + items = [items] + disks = [] + for d in items: + used = float(d.get("used", 0)) + free = float(d.get("free", 0)) + total = used + free + pct = round(used / total * 100, 1) if total else 0 + disks.append({ + "mount": d.get("Name", "?") + ":\\", + "size": f"{round(total, 1)}G", + "used": f"{used}G", + "avail": f"{free}G", + "percent": str(int(pct)), + }) + return disks + except Exception: + return [] + +def get_uptime() -> dict: + try: + out = _ps("(Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime | Select-Object -ExpandProperty TotalSeconds") + secs = float(out) + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + return {} + +def get_services(cfg: dict) -> list: + watch = cfg.get("watch_services", ["WinDefend", "wuauserv", "Spooler"]) + statuses = [] + for svc in watch: + try: + out = _ps(f"(Get-Service -Name '{svc}' -ErrorAction SilentlyContinue).Status") + status = "active" if out.lower() == "running" else (out.lower() or "unknown") + statuses.append({"service": svc, "status": status}) + except Exception: + statuses.append({"service": svc, "status": "unknown"}) + return statuses + +def detect_capabilities(cfg: dict) -> list: + caps = ["metrics", "commands"] + if Path(r"C:\Program Files\Docker\Docker\Docker Desktop.exe").exists(): + caps.append("docker") + return caps + +def collect_metrics(cfg: dict) -> dict: + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": [0, 0, 0], + "services": get_services(cfg), + "platform": "Windows", + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + +# ── Registration ─────────────────────────────────────────────────────────────── + +def register(cfg: dict, state: dict) -> str: + hostname = cfg.get("hostname", socket.gethostname().lower()) + agent_type = cfg.get("agent_type", "linux") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_{hostname[:8]}") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + log(f"[JARVIS] Registering as '{agent_id}' from {ip}...") + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + {"hostname": hostname, "agent_type": agent_type, "ip_address": ip, + "capabilities": capabilities, "agent_id": agent_id}, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + log(f"[ERROR] Registration failed: {result['error']}") + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + log(f"[JARVIS] Registered. agent_id={state['agent_id']}") + return api_key + +# ── Command execution ────────────────────────────────────────────────────────── + +def execute_command(cmd: dict, cfg: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + try: + if cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-n", "3", host], capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + log("[CMD] Self-update requested") + return {"success": True, "message": "Windows agent self-update not implemented"} + + elif cmd_type == "shell": + if not cmd_data.get("allowed", False): + return {"success": False, "error": "Shell commands not enabled"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(["powershell", "-NoProfile", "-Command", cmd_str], + capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Main loop ────────────────────────────────────────────────────────────────── + +def main(): + global _host_header + + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + + api_key = state.get("api_key", "") + if not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + time.sleep(60) + main() + return + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + log(f"[JARVIS] Agent v{AGENT_VERSION} (Windows) running. Connecting to {jarvis_url} every {heartbeat_every}s.") + + while True: + now = time.time() + try: + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + log(f"[WARN] Heartbeat failed: {hb['error']}") + else: + for cmd in hb.get("commands", []): + log(f"[CMD] Executing: {cmd['command_type']}") + result = execute_command(cmd, cfg) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + r = api_post(f"{jarvis_url}/api/agent/metrics", + {"system": metrics}, headers, ssl_verify=ssl_verify) + if "error" not in r: + last_metrics = now + + except Exception as e: + log(f"[ERROR] Loop error: {e}") + + time.sleep(heartbeat_every) + +if __name__ == "__main__": + main() diff --git a/public_html/agent/jarvis-agent.py b/public_html/agent/jarvis-agent.py new file mode 100644 index 0000000..8045fc2 --- /dev/null +++ b/public_html/agent/jarvis-agent.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent — lightweight system monitor for Linux machines. +Registers with JARVIS, reports metrics, and executes commands. + +Install: sudo bash /opt/jarvis-agent/install.sh +Config: /etc/jarvis-agent/config.json +Logs: journalctl -u jarvis-agent -f +""" + +import json +import os +import platform +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import uuid +from datetime import datetime +from pathlib import Path + +CONFIG_PATH = "/etc/jarvis-agent/config.json" +STATE_PATH = "/var/lib/jarvis-agent/state.json" +AGENT_VERSION = "2.3" # bumped on each release + +# ── Config helpers ──────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not os.path.exists(CONFIG_PATH): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True) + sys.exit(1) + with open(CONFIG_PATH) as f: + return json.load(f) + +def load_state() -> dict: + if os.path.exists(STATE_PATH): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +# ── HTTP helpers ────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool) -> _ssl.SSLContext | None: + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" # set from config at startup + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent/1.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── Registration ────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +def detect_capabilities(cfg: dict) -> list: + caps = ["metrics", "commands"] + # Check for Proxmox + if os.path.exists("/usr/bin/pvesh") or os.path.exists("/usr/sbin/pveversion"): + caps.append("proxmox") + # Check for Docker + if os.path.exists("/usr/bin/docker") or os.path.exists("/usr/local/bin/docker"): + caps.append("docker") + # Check for Ollama + if os.path.exists("/usr/local/bin/ollama") or os.path.exists("/usr/bin/ollama"): + caps.append("ollama") + # Check for Home Assistant + if os.path.exists("/etc/homeassistant") or os.path.exists("/config/configuration.yaml"): + caps.append("homeassistant") + return caps + +def register(cfg: dict, state: dict) -> str: + """Register with JARVIS. Returns api_key.""" + hostname = cfg.get("hostname", socket.gethostname()) + agent_type = cfg.get("agent_type", "linux") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_{socket.gethostname()[:8]}") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + print(f"[JARVIS] Registering as '{agent_id}' ({agent_type}) from {ip}...", flush=True) + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + { + "hostname": hostname, + "agent_type": agent_type, + "ip_address": ip, + "capabilities": capabilities, + "agent_id": agent_id, + }, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + print(f"[ERROR] Registration failed: {result['error']}", flush=True) + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + print(f"[JARVIS] Registered. agent_id={state['agent_id']}", flush=True) + return api_key + +# ── Metrics collection ──────────────────────────────────────────────────────── + +def read_cpu_percent() -> float: + try: + with open("/proc/stat") as f: + line = f.readline() + fields = list(map(int, line.split()[1:])) + idle = fields[3] + total = sum(fields) + return round((1 - idle / total) * 100, 1) if total else 0.0 + except Exception: + return 0.0 + +_last_cpu = None + +def get_cpu_percent() -> float: + global _last_cpu + try: + with open("/proc/stat") as f: + line = f.readline() + fields = list(map(int, line.split()[1:])) + idle = fields[3] + fields[4] # idle + iowait + total = sum(fields) + if _last_cpu: + d_idle = idle - _last_cpu[0] + d_total = total - _last_cpu[1] + result = round((1 - d_idle / d_total) * 100, 1) if d_total else 0.0 + else: + result = 0.0 + _last_cpu = (idle, total) + return result + except Exception: + return 0.0 + +def get_memory() -> dict: + mem = {} + try: + with open("/proc/meminfo") as f: + for line in f: + parts = line.split() + if parts[0] in ("MemTotal:", "MemAvailable:", "MemFree:", "Buffers:", "Cached:"): + mem[parts[0].rstrip(":")] = int(parts[1]) + total = mem.get("MemTotal", 0) + available = mem.get("MemAvailable", 0) + used = total - available + return { + "total_mb": round(total / 1024, 1), + "used_mb": round(used / 1024, 1), + "free_mb": round(available / 1024, 1), + "percent": round(used / total * 100, 1) if total else 0, + } + except Exception: + return {} + +def get_disk() -> list: + disks = [] + try: + result = subprocess.run(["df", "-h", "--output=source,fstype,size,used,avail,pcent,target"], + capture_output=True, text=True, timeout=5) + lines = result.stdout.strip().split("\n")[1:] + for line in lines: + parts = line.split() + if len(parts) >= 7: + mount = parts[6] + if not any(mount.startswith(x) for x in ["/sys", "/proc", "/dev/pts", "/run", "/snap"]): + disks.append({ + "mount": mount, + "size": parts[2], + "used": parts[3], + "avail": parts[4], + "percent": parts[5].rstrip("%"), + }) + except Exception: + pass + return disks + +def get_uptime() -> dict: + try: + with open("/proc/uptime") as f: + secs = float(f.read().split()[0]) + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + return {} + +def get_services(cfg: dict) -> list: + watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"]) + statuses = [] + for svc in watch: + try: + r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True, timeout=3) + statuses.append({"service": svc, "status": r.stdout.strip()}) + except Exception: + statuses.append({"service": svc, "status": "unknown"}) + return statuses + +def get_load() -> list: + try: + with open("/proc/loadavg") as f: + parts = f.read().split() + return [float(parts[0]), float(parts[1]), float(parts[2])] + except Exception: + return [0, 0, 0] + +def collect_metrics(cfg: dict) -> dict: + # First reading for CPU delta + get_cpu_percent() + time.sleep(1) + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": get_load(), + "services": get_services(cfg), + "platform": platform.system(), + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── Proxmox metrics ─────────────────────────────────────────────────────────── + +def collect_proxmox_metrics(cfg: dict) -> dict | None: + try: + result = subprocess.run( + ["pvesh", "get", "/nodes/pve/status", "--output-format", "json"], + capture_output=True, text=True, timeout=10 + ) + node_status = json.loads(result.stdout) + vms_result = subprocess.run( + ["pvesh", "get", "/nodes/pve/qemu", "--output-format", "json"], + capture_output=True, text=True, timeout=10 + ) + vms = json.loads(vms_result.stdout) + return {"node": node_status, "vms": vms} + except Exception as e: + return {"error": str(e)} + +# ── Command execution ───────────────────────────────────────────────────────── + +def execute_command(cmd: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + + try: + if cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["systemctl", "restart", svc], capture_output=True, text=True, timeout=30) + return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["journalctl", "-u", svc, "-n", str(lines), "--no-pager"], + capture_output=True, text=True, timeout=15) + return {"success": True, "output": r.stdout} + + elif cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-c", "3", "-W", "2", host], capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + updated = self_update(cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + # Only allow if explicitly enabled in config + if not cmd_data.get("allowed", False): + return {"success": False, "error": "Shell commands not enabled"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Main loop ───────────────────────────────────────────────────────────────── + +def main(): + global _host_header + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + + # Register if no API key yet + api_key = state.get("api_key", "") + if not api_key: + api_key = register(cfg, state) + if not api_key: + print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) + time.sleep(60) + main() + return + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + tick = 0 + + print(f"[JARVIS] Agent v{AGENT_VERSION} running. Polling {jarvis_url} every {heartbeat_every}s.", flush=True) + + while True: + tick += 1 + now = time.time() + + try: + # Heartbeat + get commands + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True) + else: + commands = hb.get("commands", []) + for cmd in commands: + print(f"[CMD] Executing: {cmd['command_type']}", flush=True) + result = execute_command(cmd) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check (every update_interval seconds, default 24h) + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) # restarts process if update found + + # Push metrics every poll_interval seconds + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + + # Proxmox metrics if available + if "proxmox" in detect_capabilities(cfg): + px = collect_proxmox_metrics(cfg) + if px: + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "proxmox", "data": px}, headers, ssl_verify=ssl_verify) + + last_metrics = now + + except Exception as e: + print(f"[ERROR] Loop error: {e}", flush=True) + + time.sleep(heartbeat_every) + + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + """Check JARVIS server for a newer version of this script. If different, replace and restart.""" + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + try: + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent/1.0") + with urllib.request.urlopen(req, timeout=30) as resp: + new_content = resp.read() + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True) + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + print(f"[JARVIS] Self-update check failed: {e}", flush=True) + return False + + +if __name__ == "__main__": + main() diff --git a/public_html/agent/setup-task.ps1 b/public_html/agent/setup-task.ps1 new file mode 100644 index 0000000..5518817 --- /dev/null +++ b/public_html/agent/setup-task.ps1 @@ -0,0 +1,36 @@ +# Kill any stale Task Scheduler approach +Unregister-ScheduledTask -TaskName 'JARVIS-Agent' -Confirm:$false -ErrorAction SilentlyContinue + +# Create a VBScript launcher (runs Python silently, no console window) +$vbs = 'Set WShell = CreateObject("WScript.Shell")' + "`r`n" + + 'WShell.Run """C:\Users\myron\AppData\Local\Programs\Python\Python312\pythonw.exe"" ""C:\ProgramData\jarvis-agent\jarvis-agent.py""", 0, False' + +[System.IO.File]::WriteAllText('C:\ProgramData\jarvis-agent\start-agent.vbs', $vbs, [System.Text.ASCIIEncoding]::new()) + +# Add to user startup folder so it runs at every login +$startupDir = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup" +Copy-Item 'C:\ProgramData\jarvis-agent\start-agent.vbs' "$startupDir\JARVIS-Agent.vbs" -Force +Write-Host "Startup entry created: $startupDir\JARVIS-Agent.vbs" -ForegroundColor Green + +# Kill any existing python process running the agent +Get-Process python*, pythonw* -ErrorAction SilentlyContinue | Where-Object {$_.CommandLine -like '*jarvis-agent*'} | Stop-Process -Force -ErrorAction SilentlyContinue + +# Launch now +Write-Host "Starting agent..." -ForegroundColor Cyan +Start-Process 'C:\Users\myron\AppData\Local\Programs\Python\Python312\pythonw.exe' -ArgumentList 'C:\ProgramData\jarvis-agent\jarvis-agent.py' -WorkingDirectory 'C:\ProgramData\jarvis-agent' +Start-Sleep -Seconds 4 + +# Confirm running +$proc = Get-Process pythonw -ErrorAction SilentlyContinue +if ($proc) { + Write-Host "Agent running — PID $($proc.Id)" -ForegroundColor Green +} else { + Write-Host "pythonw not found — check C:\ProgramData\jarvis-agent\jarvis-agent.log" -ForegroundColor Yellow +} + +# Show log tail +Start-Sleep -Seconds 2 +if (Test-Path 'C:\ProgramData\jarvis-agent\jarvis-agent.log') { + Write-Host "`nLog:" -ForegroundColor Cyan + Get-Content 'C:\ProgramData\jarvis-agent\jarvis-agent.log' -Tail 10 +} diff --git a/public_html/api.php b/public_html/api.php new file mode 100644 index 0000000..d095de5 --- /dev/null +++ b/public_html/api.php @@ -0,0 +1,90 @@ + 'Unauthorized', 'code' => 401]); + exit; + } + } +} + +if ($endpoint !== 'auth') session_write_close(); // Skip for auth so login can write session token + +$body = file_get_contents('php://input'); +$data = json_decode($body, true) ?? []; + +switch ($endpoint) { + case 'ping': + echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]); + break; + case 'auth': + require __DIR__ . '/../api/endpoints/auth.php'; + break; + case 'chat': + require __DIR__ . '/../api/endpoints/chat.php'; + break; + case 'system': + require __DIR__ . '/../api/endpoints/system.php'; + break; + case 'network': + require __DIR__ . '/../api/endpoints/network.php'; + break; + case 'proxmox': + require __DIR__ . '/../api/endpoints/proxmox.php'; + break; + case 'ha': + require __DIR__ . '/../api/endpoints/ha.php'; + break; + case 'do': + require __DIR__ . '/../api/endpoints/do_server.php'; + break; + case 'alerts': + require __DIR__ . '/../api/endpoints/alerts.php'; + break; + case 'facts': + require __DIR__ . '/../api/endpoints/facts_collector.php'; + break; + case 'weather': + require __DIR__ . '/../api/endpoints/weather.php'; + break; + case 'news': + require __DIR__ . '/../api/endpoints/news.php'; + break; + case "agent": + require __DIR__ . '/../api/endpoints/agent.php'; + break; + default: + http_response_code(404); + echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]); +} diff --git a/public_html/index.html b/public_html/index.html new file mode 100644 index 0000000..2ebd803 --- /dev/null +++ b/public_html/index.html @@ -0,0 +1,1900 @@ + + + + + +JARVIS — Integrated Defense and Logistics System + + + + + +
+ + +
+ +

JARVIS

+

Just A Rather Very Intelligent System

+ +
+ + +
+ +
+ +
+
LOCAL --% CPU
+
MEM --%
+
DO SERVER --
+
NO ALERTS
+
+
+
+
--:--:--
+
LOADING...
+
+
+ + + + + +
+
+ + +
+ +
+
+
LOCAL SYSTEM
+
+
CPU USAGE --%
+
+
+
+
MEMORY --%
+
+
+
+
DISK / --%
+
+
+
UPTIME
--
+
LOAD AVG
--
+
HOSTNAME
--
+
+ +
+
SERVICES
+
+
+
+
+
+ +
+
DO SERVER 165.22.1.228
+
+
+
+
+
+ +
+
TOP PROCESSES
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
+
◈ JARVIS ONLINE — AWAITING INSTRUCTIONS ◈
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ CONTEXT + + +
+
+ + + +
+
+
+ + +
+ +
+
WEATHER FORT WORTH, TX
+
+
+
+ -- + °F +
+
LOADING...
+
+
+
+
FEELS LIKE
+
--°F
+
HUMIDITY
+
--%
+
+
+
+
+ + +
+
NETWORK STATUS
+
+
+
+
+
+ +
+ + +
+
+
PROXMOX
+
HOME
+
ALERTS
+
NEWS
+
AGENTS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ JARVIS CORE ONLINE +
+
+
+ DO SERVER CHECKING +
+
+
+ PROXMOX CHECKING +
+
+
+ HOME ASSISTANT CHECKING +
+
+
+ AGENTS -- +
+ +
+ JARVIS v2.0 · -- · SECURITY LEVEL ALPHA +
+
+
+
+
+ +

● JARVIS AGENT

+
+
+
+ + + + + + + +